Compare commits

...

24 Commits

Author SHA1 Message Date
9qeklajc
87efed0021 Merge branch 'v0.3.0' into claude-code
# Conflicts:
#	routstr/upstream/base.py
2026-01-12 20:53:27 +01:00
Shroominic
3dfbd3815c fix typing 2026-01-12 17:47:14 +08:00
Shroominic
6199f6467b mock-upstream-with-testnut-mint 2026-01-12 17:47:14 +08:00
Shroominic
9960e5596e bump v0.2.2 2026-01-12 17:45:40 +08:00
shroominic
a516a10737 Merge pull request #292 from Routstr/fix-not-enough-inputs-to-melt
Fix not enough inputs to melt
2026-01-12 17:45:40 +08:00
shroominic
be9b1da832 Merge pull request #295 from Routstr/update-ui-deps
update vuln deps
2026-01-12 17:45:40 +08:00
Shroominic
e4dd0aceae fix not enough inputs to melt bug 2026-01-12 17:45:40 +08:00
9qeklajc
30170c2ec6 Merge pull request #291 from Routstr/fix-reserved-balance
Fix reserved balance
2026-01-12 17:45:40 +08:00
9qeklajc
825bd38d8e fix test 2026-01-12 17:45:40 +08:00
9qeklajc
d3b3152520 Merge pull request #289 from Routstr/282-better-filltering
#282 more filter options
2026-01-12 17:45:40 +08:00
Shroominic
653b51452a bump delay to make sure its not taking more time to reset 2026-01-12 17:45:40 +08:00
9qeklajc
aa00664cd6 fmt 2026-01-12 17:45:40 +08:00
9qeklajc
ea677cf66b Merge pull request #284 from Routstr/274-do-not-charge-when-empty-content
#274 do not charge user for empty response by upstream
2026-01-12 17:45:40 +08:00
9qeklajc
11868f9180 #282 more filter options 2026-01-12 17:45:40 +08:00
Shroominic
daae4cd2cb lint pls 2026-01-12 17:45:40 +08:00
9qeklajc
2cb8d2d744 update build script 2026-01-12 17:45:40 +08:00
9qeklajc
22f7d198a6 #274 do not change user for empty response by upstream 2026-01-12 17:45:40 +08:00
Shroominic
73af5e23c7 fix typing 2026-01-12 17:45:40 +08:00
9qeklajc
eb5af32fac update vuln deps 2026-01-12 17:45:40 +08:00
Shroominic
0f9df3ca77 rm not needed generic 2026-01-12 17:45:40 +08:00
Shroominic
3b1b3da847 fix linting 2026-01-12 17:45:40 +08:00
Shroominic
a34583d2ca fix other potential reserved balance problems 2026-01-12 17:45:40 +08:00
Shroominic
4eda9eaf1b finalize_without_usage when client disconnects 2026-01-12 17:45:40 +08:00
9qeklajc
b9879cbea7 claude code integration 2026-01-10 23:32:45 +01:00

View File

@@ -1064,6 +1064,170 @@ class BaseUpstreamProvider:
},
)
async def handle_streaming_messages_completion(
self, response: httpx.Response, key: ApiKey, max_cost_for_model: int
) -> StreamingResponse:
async def stream_with_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
stored_chunks: list[bytes] = []
usage_finalized: bool = False
last_model_seen: str | None = None
input_tokens: int = 0
output_tokens: int = 0
async def finalize_without_usage() -> bytes | None:
nonlocal usage_finalized
if usage_finalized:
return None
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
usage_finalized = True
return None
try:
fallback: dict = {
"model": last_model_seen or "unknown",
"usage": None,
}
cost_data = await adjust_payment_for_tokens(
fresh_key, fallback, new_session, max_cost_for_model
)
usage_finalized = True
return f"event: cost\ndata: {json.dumps({'cost': cost_data})}\n\n".encode()
except Exception:
usage_finalized = True
return None
try:
async for chunk in response.aiter_bytes():
stored_chunks.append(chunk)
try:
decoded_chunk = chunk.decode("utf-8", errors="ignore")
for line in decoded_chunk.split("\n"):
if line.startswith("data: "):
try:
data = json.loads(line[6:])
if isinstance(data, dict):
msg = data.get("message", {})
if msg and msg.get("model"):
last_model_seen = str(msg.get("model"))
if usage := msg.get("usage"):
input_tokens += usage.get("input_tokens", 0)
output_tokens += usage.get(
"output_tokens", 0
)
if usage := data.get("usage"):
input_tokens += usage.get("input_tokens", 0)
output_tokens += usage.get(
"output_tokens", 0
)
except json.JSONDecodeError:
pass
except Exception:
pass
yield chunk
usage_data = {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}
if input_tokens > 0 or output_tokens > 0:
async with create_session() as new_session:
fresh_key = await new_session.get(key.__class__, key.hashed_key)
if fresh_key:
try:
combined_data = {
"model": last_model_seen or "unknown",
"usage": usage_data,
}
cost_data = await adjust_payment_for_tokens(
fresh_key,
combined_data,
new_session,
max_cost_for_model,
)
usage_finalized = True
yield f"event: cost\ndata: {json.dumps({'cost': cost_data})}\n\n".encode()
except Exception:
pass
if not usage_finalized:
maybe_cost_event = await finalize_without_usage()
if maybe_cost_event is not None:
yield maybe_cost_event
except Exception:
if not usage_finalized:
await finalize_without_usage()
raise
finally:
if not usage_finalized:
await finalize_without_usage()
response_headers = dict(response.headers)
response_headers.pop("content-encoding", None)
response_headers.pop("content-length", None)
return StreamingResponse(
stream_with_cost(max_cost_for_model),
status_code=response.status_code,
headers=response_headers,
)
async def handle_non_streaming_messages_completion(
self,
response: httpx.Response,
key: ApiKey,
session: AsyncSession,
deducted_max_cost: int,
path: str,
) -> Response:
try:
content = await response.aread()
response_json = json.loads(content)
if path.endswith("count_tokens") and "usage" not in response_json:
input_tokens = response_json.get("input_tokens", 0)
response_json["usage"] = {"input_tokens": input_tokens}
cost_data = await adjust_payment_for_tokens(
key, response_json, session, deducted_max_cost
)
response_json["cost"] = cost_data
allowed_headers = {
"content-type",
"cache-control",
"date",
"vary",
"access-control-allow-origin",
"access-control-allow-methods",
"access-control-allow-headers",
"access-control-allow-credentials",
"access-control-expose-headers",
"access-control-max-age",
}
response_headers = {
k: v
for k, v in response.headers.items()
if k.lower() in allowed_headers
}
return Response(
content=json.dumps(response_json).encode(),
status_code=response.status_code,
headers=response_headers,
media_type="application/json",
)
except Exception:
raise
async def forward_request(
self,
request: Request,
@@ -1157,7 +1321,54 @@ class BaseUpstreamProvider:
await client.aclose()
return mapped_error
if path.endswith("chat/completions") or path.endswith("embeddings"):
if (
path.endswith("chat/completions")
or path.endswith("embeddings")
or path.endswith("messages")
or path.endswith("messages/count_tokens")
):
if path.endswith("messages"):
client_wants_streaming = False
if request_body:
try:
request_data = json.loads(request_body)
client_wants_streaming = request_data.get("stream", False)
except json.JSONDecodeError:
pass
content_type = response.headers.get("content-type", "")
upstream_is_streaming = "text/event-stream" in content_type
is_streaming = client_wants_streaming and upstream_is_streaming
if is_streaming and response.status_code == 200:
result = await self.handle_streaming_messages_completion(
response, key, max_cost_for_model
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
result.background = background_tasks
return result
if response.status_code == 200:
try:
return await self.handle_non_streaming_messages_completion(
response, key, session, max_cost_for_model, path
)
finally:
await response.aclose()
await client.aclose()
if path.endswith("messages/count_tokens"):
if response.status_code == 200:
try:
return await self.handle_non_streaming_messages_completion(
response, key, session, max_cost_for_model, path
)
finally:
await response.aclose()
await client.aclose()
if path.endswith("chat/completions"):
client_wants_streaming = False
if request_body: