Compare commits

...

12 Commits

Author SHA1 Message Date
9qeklajc
9e9c2bde57 fix: buffer-based SSE parser for all supported providers
Replace the fragile `re.split(b"data: ")` streaming parser with a
buffered, event-delimited parser in both the chat-completion and
Responses-API streamers. Events are accumulated until the SSE blank-line
delimiter, so parsing is independent of network chunk boundaries.

Fixes:
- OpenRouter `: OPENROUTER PROCESSING` keepalive comments no longer leak
  to clients as `data: : ...` (the `Unexpected token ':'` client crash).
- JSON payloads split across TCP reads are reassembled before parsing.
- CRLF framing (Gemini native alt=sse) handled.
- Combined content+usage chunks (Gemini thinking models over the
  OpenAI-compat endpoint) forward content once and still report usage in
  the cost trailer, instead of dropping the assistant message.
- Multi-line non-JSON `data` blocks are re-prefixed per line so they stay
  valid SSE framing for the client.

Adds tests/unit/test_streaming_sse_providers.py driving the real
generator against per-provider on-the-wire framing, and switches the
integration token-mint fallback to secrets.token_hex to kill a
PRNG/clock collision flake.
2026-06-07 12:58:54 +02:00
9qeklajc
222fd6ed45 Merge pull request #542 from Routstr/fix-model-provider-field-response
make sure model provider field naming is correct
2026-06-07 11:32:01 +02:00
9qeklajc
4abd751f5f make sure model provider field naming is correct 2026-06-07 11:01:53 +02:00
9qeklajc
8f89171db1 Merge pull request #541 from jeroenubbink/fix/lightning-invoice-key-constraints
fix: persist and propagate key constraints from Lightning invoices
2026-06-07 10:51:29 +02:00
9qeklajc
5dc9d60bec Merge pull request #540 from jeroenubbink/fix/child-key-atomic-balance-deduction
fix: make child key balance deduction atomic
2026-06-04 10:09:26 +02:00
Jeroen Ubbink
feb76bc89d fix: persist and propagate key constraints from Lightning invoices
LightningInvoice had no columns for balance_limit, balance_limit_reset,
or validity_date. SQLModel silently dropped these constructor kwargs, so
create_api_key_from_invoice always produced an unconstrained key.

Add the three columns to LightningInvoice with a migration, and wire them
through to the ApiKey in create_api_key_from_invoice, matching the pattern
already used in the child key creation path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:58:40 +02:00
Jeroen Ubbink
3b7f96560b fix: make child key balance deduction atomic
The previous in-memory deduction (key.balance -= cost) was a read-modify-
write on stale state, allowing two concurrent create_child_key() calls to
both pass the balance check and both succeed, effectively charging the
parent only once for two child keys.

Replace with an atomic UPDATE ... WHERE balance - reserved_balance >= cost
and check rowcount, matching the pattern already used in pay_for_request.
Also adds a concurrent integration test that reproduces the race and
confirms the fix holds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:02:12 +02:00
9qeklajc
23e206c93a Merge pull request #538 from Routstr/revert-sse-refactoring
revert sse refactoring
2026-06-02 14:00:48 +02:00
9qeklajc
3e0ca0daf9 revert sse refactoring 2026-06-02 13:58:43 +02:00
9qeklajc
d7bf1d6582 Merge pull request #536 from Routstr/add-created-date-to-api-key
add api key creation date
2026-06-02 00:20:57 +02:00
9qeklajc
955090bd91 Merge pull request #535 from Routstr/prevent-zero-balance-token
prevent zero balance token
2026-06-01 23:34:51 +02:00
9qeklajc
aaa80d47bc add api key creation date 2026-06-01 23:27:26 +02:00
16 changed files with 1489 additions and 400 deletions

View File

@@ -0,0 +1,26 @@
"""Add balance_limit, balance_limit_reset, validity_date to lightning_invoices
Revision ID: a2b3c4d5e6f7
Revises: f1a2b3c4d5e6
Create Date: 2026-06-03 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "a2b3c4d5e6f7"
down_revision = "f1a2b3c4d5e6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("lightning_invoices", sa.Column("balance_limit", sa.Integer(), nullable=True))
op.add_column("lightning_invoices", sa.Column("balance_limit_reset", sa.String(), nullable=True))
op.add_column("lightning_invoices", sa.Column("validity_date", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("lightning_invoices", "validity_date")
op.drop_column("lightning_invoices", "balance_limit_reset")
op.drop_column("lightning_invoices", "balance_limit")

View File

@@ -0,0 +1,25 @@
"""add created_at to api_keys
Revision ID: f1a2b3c4d5e6
Revises: cli_tokens_001
Create Date: 2026-06-01 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f1a2b3c4d5e6"
down_revision = "cli_tokens_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Nullable on purpose: existing keys keep NULL (unknown creation time) and
# sort last; new keys get populated by the model's default_factory.
op.add_column("api_keys", sa.Column("created_at", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("api_keys", "created_at")

View File

@@ -535,10 +535,24 @@ async def create_child_key(
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Deduct cost from parent
key.balance -= total_cost
key.total_spent += total_cost
session.add(key)
# Deduct cost from parent atomically — guards against concurrent requests
# that both pass the balance check above on stale in-memory state.
deduct_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= total_cost)
.values(
balance=col(ApiKey.balance) - total_cost,
total_spent=col(ApiKey.total_spent) + total_cost,
)
)
result = await session.exec(deduct_stmt) # type: ignore[call-overload]
if result.rowcount == 0:
raise HTTPException(
status_code=402,
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
)
# Generate new keys
import secrets
@@ -563,6 +577,7 @@ async def create_child_key(
new_keys.append("sk-" + new_key_hash)
await session.commit()
await session.refresh(key)
response_data = {
"api_keys": new_keys,

View File

@@ -68,26 +68,89 @@ async def require_admin_api(request: Request) -> None:
@admin_router.get("/api/temporary-balances", dependencies=[Depends(require_admin_api)])
async def get_temporary_balances_api(request: Request) -> list[dict[str, object]]:
async def get_temporary_balances_api(
request: Request,
search: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, object]:
from sqlalchemy import case
from sqlmodel import col, func
filters = []
if search:
pattern = f"%{search}%"
filters.append(
col(ApiKey.hashed_key).like(pattern)
| col(ApiKey.refund_address).like(pattern)
)
async with create_session() as session:
result = await session.exec(select(ApiKey))
base = select(ApiKey).where(*filters)
count_result = await session.exec(
select(func.count()).select_from(base.subquery())
)
total = count_result.one()
# Aggregate totals across the whole (search-filtered) set, not just the
# current page. Balance counts only parent (non-child) keys to avoid
# double-counting, since child keys draw from their parent's balance.
totals_result = await session.exec(
select(
func.coalesce(
func.sum(
case(
(col(ApiKey.parent_key_hash).is_(None), ApiKey.balance),
else_=0,
)
),
0,
),
func.coalesce(func.sum(ApiKey.total_spent), 0),
func.coalesce(func.sum(ApiKey.total_requests), 0),
).where(*filters)
)
total_balance, total_spent, total_requests = totals_result.one()
# Latest created first; keys with no created_at (legacy rows) sort last.
# Use an explicit CASE rather than relying on dialect NULL-ordering so
# the behaviour is identical on SQLite and Postgres.
stmt = (
base.order_by(
case((col(ApiKey.created_at).is_(None), 1), else_=0),
col(ApiKey.created_at).desc(),
)
.offset(offset)
.limit(limit)
)
result = await session.exec(stmt)
api_keys = result.all()
return [
{
"hashed_key": key.hashed_key,
"balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"refund_address": key.refund_address,
"key_expiry_time": key.key_expiry_time,
"parent_key_hash": key.parent_key_hash,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
for key in api_keys
]
return {
"balances": [
{
"hashed_key": key.hashed_key,
"balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"refund_address": key.refund_address,
"key_expiry_time": key.key_expiry_time,
"parent_key_hash": key.parent_key_hash,
"balance_limit": key.balance_limit,
"balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
"created_at": key.created_at,
}
for key in api_keys
],
"total": total,
"totals": {
"total_balance": total_balance,
"total_spent": total_spent,
"total_requests": total_requests,
},
}
class ApiKeyUpdate(BaseModel):

View File

@@ -45,6 +45,14 @@ class ApiKey(SQLModel, table=True): # type: ignore
default=0, description="Total spent in millisatoshis (msats)"
)
total_requests: int = Field(default=0)
created_at: int | None = Field(
default_factory=lambda: int(time.time()),
nullable=True,
description=(
"Unix timestamp when the key was created. Nullable: keys created "
"before this column existed have no value and sort last."
),
)
refund_mint_url: str | None = Field(
default=None,
description="URL of the mint used to create the cashu-token",
@@ -132,6 +140,18 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
)
expires_at: int = Field(description="Unix timestamp when invoice expires")
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
balance_limit: int | None = Field(
default=None,
description="Max spendable msats for the created key",
)
balance_limit_reset: str | None = Field(
default=None,
description="Reset policy for balance limit (daily, weekly, monthly)",
)
validity_date: int | None = Field(
default=None,
description="Unix timestamp after which the created key expires",
)
class CashuTransaction(SQLModel, table=True): # type: ignore

View File

@@ -269,6 +269,9 @@ async def create_api_key_from_invoice(
balance=invoice.amount_sats * 1000, # Convert to msats
refund_currency="sat",
refund_mint_url=settings.primary_mint,
balance_limit=invoice.balance_limit,
balance_limit_reset=invoice.balance_limit_reset,
validity_date=invoice.validity_date,
)
session.add(api_key)

View File

@@ -4,7 +4,7 @@ import asyncio
import json
import traceback
import uuid
from collections.abc import AsyncGenerator, AsyncIterator
from collections.abc import AsyncGenerator, AsyncIterator, Iterator
from typing import Any, Mapping, cast
import httpx
@@ -202,14 +202,26 @@ class BaseUpstreamProvider:
already reported its own provider (e.g. OpenRouter returns
``"provider": "Fireworks"``), otherwise just ``"<provider_type>"``
for direct upstreams.
Idempotent: re-stamping an already-stamped payload must not nest the
prefix repeatedly (e.g. never ``"anthropic:anthropic"``). This matters
because streaming paths can apply the field more than once per chunk.
"""
if not isinstance(response_json, dict):
return
provider_type = (self.provider_type or "").strip()
existing = response_json.get("provider")
if isinstance(existing, str) and existing.strip():
response_json["provider"] = f"{self.provider_type}:{existing.strip()}"
else:
response_json["provider"] = self.provider_type
existing_str = existing.strip() if isinstance(existing, str) else ""
if not existing_str:
response_json["provider"] = provider_type
return
# Already stamped by a previous pass — leave it untouched.
if existing_str == provider_type or existing_str.startswith(
f"{provider_type}:"
):
response_json["provider"] = existing_str
return
response_json["provider"] = f"{provider_type}:{existing_str}"
def inject_cost_metadata(
self,
@@ -715,102 +727,140 @@ class BaseUpstreamProvider:
except Exception:
pass
try:
sse_buffer = b""
async for chunk in response.aiter_bytes():
sse_buffer += chunk
logger.debug(
"[chat] SSE chunk from upstream",
extra={"chunk_size": len(chunk), "buffer_size": len(sse_buffer)},
)
# SSE events are separated by \n\n (double newline).
# Process complete events and keep incomplete ones in the buffer.
while b"\n\n" in sse_buffer:
event_raw, sse_buffer = sse_buffer.split(b"\n\n", 1)
event_raw = event_raw.strip()
if not event_raw:
continue
def _process_event(raw_event: bytes) -> Iterator[bytes]:
"""Process one complete SSE event block (lines up to a blank line).
logger.debug(
"[chat] SSE event from upstream",
extra={"event": event_raw[:500].decode("utf-8", errors="replace")},
)
Handles arbitrary upstream framing across every supported
provider:
if event_raw == b"data: [DONE]":
logger.debug("[chat] SSE [DONE] seen")
done_seen = True
continue
* ``data:`` lines are gathered and concatenated per the SSE
spec, so a payload split across network chunks is reassembled
before parsing.
* Comment/keepalive lines (those beginning with ``:`` such as
OpenRouter's ``: OPENROUTER PROCESSING``) are dropped. They
carry no JSON and forwarding them downstream breaks naive SSE
clients; the keepalive only matters for the upstream hop.
* Other SSE fields (``event:``/``id:``/``retry:``) are preserved
and kept attached to the event's ``data:`` line, which the
OpenAI Responses API and Anthropic-style streams rely on.
* ``[DONE]`` is swallowed so it can be re-emitted exactly once at
end of stream.
"""
nonlocal last_model_seen, usage_chunk_data, done_seen
# Extract the JSON payload from "data: {...}"
data_prefix = b"data: "
if event_raw.startswith(data_prefix):
payload_bytes = event_raw[len(data_prefix):]
else:
payload_bytes = event_raw
event = raw_event.strip(b"\r\n")
if not event:
return
try:
obj = json.loads(payload_bytes)
if isinstance(obj, dict):
self._apply_provider_field(obj)
if obj.get("model"):
last_model_seen = str(obj.get("model"))
if requested_model:
obj["model"] = requested_model
if (
"id" not in obj
or not isinstance(obj["id"], str)
or obj["id"] == "existing-id"
):
if not hasattr(self, "_current_stream_id"):
self._current_stream_id = (
f"chatcmpl-{uuid.uuid4()}"
)
obj["id"] = self._current_stream_id
if isinstance(obj.get("usage"), dict):
# Check if this chunk has actual content (vs. billing-only chunks)
choices = obj.get("choices") or []
has_content = any(
c.get("delta", {}).get("content")
or c.get("delta", {}).get("reasoning")
for c in choices
)
if not has_content:
# Billing-only chunk — hold back for cost metadata injection
logger.debug(
"[chat] Holding back usage-only chunk",
extra={"model": obj.get("model"), "completion_tokens": obj.get("usage", {}).get("completion_tokens", 0)},
)
usage_chunk_data = obj
continue
# Content-bearing chunk — yield it, but also save usage for later injection
logger.debug(
"[chat] Content chunk with usage — yielding",
extra={"model": obj.get("model"), "completion_tokens": obj.get("usage", {}).get("completion_tokens", 0)},
)
if not usage_chunk_data:
usage_chunk_data = obj
logger.debug(
"[chat] Yielding chunk to client",
extra={"model": obj.get("model")},
)
yield b"data: " + json.dumps(obj).encode() + b"\n\n"
continue
except Exception as e:
logger.debug(
"[chat] JSON parse failed for event",
extra={"error": str(e), "event_preview": event_raw[:200].decode("utf-8", errors="replace")},
field_lines: list[bytes] = []
data_lines: list[bytes] = []
for line in event.split(b"\n"):
line = line.rstrip(b"\r")
if line.startswith(b"data:"):
# Strip the field name and a single optional leading space.
data_lines.append(line[len(b"data:") :].lstrip(b" "))
elif line.startswith(b":"):
# SSE comment / keepalive - drop.
continue
elif line:
# Other SSE field (event:/id:/retry:) - preserve in order.
field_lines.append(line)
if not data_lines:
return
data = b"\n".join(data_lines)
if not data.strip():
return
# Re-emit preserved SSE fields immediately before the data line so
# event/data framing stays intact (single trailing newline each;
# the blank-line terminator is appended to the data line below).
prefix = b"".join(fl + b"\n" for fl in field_lines)
if data.strip() == b"[DONE]":
done_seen = True
return
try:
obj = json.loads(data)
except Exception:
obj = None
if isinstance(obj, dict):
self._apply_provider_field(obj)
if obj.get("model"):
last_model_seen = str(obj.get("model"))
if requested_model:
obj["model"] = requested_model
if (
"id" not in obj
or not isinstance(obj["id"], str)
or obj["id"] == "existing-id"
):
if not hasattr(self, "_current_stream_id"):
self._current_stream_id = f"chatcmpl-{uuid.uuid4()}"
obj["id"] = self._current_stream_id
if isinstance(obj.get("usage"), dict):
# Capture usage for end-of-stream cost reconciliation.
# Some models (e.g. Gemini thinking models over the
# OpenAI-compat endpoint) attach ``usage`` to the SAME
# chunk that carries the final content/finish_reason
# rather than sending a separate ``choices: []`` usage
# chunk. Only swallow the chunk when it is a pure usage
# chunk (no choices); otherwise the content would be
# silently dropped and the client would receive no
# assistant message at all.
if obj.get("choices"):
# Capture usage (with model) for the cost trailer,
# but with choices stripped so the trailer never
# re-emits this chunk's content.
usage_chunk_data = {
k: v for k, v in obj.items() if k != "choices"
}
usage_chunk_data["choices"] = []
# Forward the content now, without usage, so token
# usage is reported exactly once (in the trailer).
forward = {k: v for k, v in obj.items() if k != "usage"}
yield (
prefix
+ b"data: "
+ json.dumps(forward).encode()
+ b"\n\n"
)
return
usage_chunk_data = obj
return
yield prefix + b"data: " + json.dumps(obj).encode() + b"\n\n"
else:
# Non-JSON data payload (partial fragment already reassembled
# by buffering, or a provider control string). Re-prefix each
# line so multi-line ``data`` stays valid SSE framing - a bare
# second line would otherwise reach the client without its
# ``data:`` field and break naive parsers.
body = b"".join(
b"data: " + ln + b"\n" for ln in data.split(b"\n")
)
yield prefix + body + b"\n"
# If JSON parsing failed but it looks like valid SSE, pass through
if event_raw.startswith(b"data: "):
yield event_raw + b"\n\n"
else:
yield b"data: " + event_raw + b"\n\n"
try:
# Buffer bytes across network chunks and dispatch only on the SSE
# event delimiter (a blank line). ``aiter_bytes`` yields arbitrary
# byte boundaries, so a single event's JSON can span chunks and
# multiple events can arrive together; buffering makes parsing
# boundary-independent for every provider.
buffer = b""
async for chunk in response.aiter_bytes():
buffer += chunk.replace(b"\r\n", b"\n")
while b"\n\n" in buffer:
raw_event, buffer = buffer.split(b"\n\n", 1)
for out in _process_event(raw_event):
yield out
logger.debug(
"[chat] Upstream stream ended",
extra={"usage_chunk_data": bool(usage_chunk_data), "last_model": last_model_seen},
)
# Flush any trailing event that lacked a final blank line.
if buffer.strip():
for out in _process_event(buffer):
yield out
async with create_session() as session:
fresh_key = await session.get(key.__class__, key.hashed_key)
@@ -1112,67 +1162,97 @@ class BaseUpstreamProvider:
except Exception:
pass
def _process_event(raw_event: bytes) -> Iterator[bytes]:
"""Process one complete SSE event block for the Responses API.
Buffers full events (delimited by a blank line) so parsing is
boundary-independent, gathers ``data:`` lines, drops comment/
keepalive lines (e.g. OpenRouter's ``: OPENROUTER PROCESSING``),
and preserves ``event:``/``id:`` fields attached to their data
line so Responses API event framing stays intact.
"""
nonlocal last_model_seen, usage_chunk_data, done_seen
nonlocal reasoning_tokens
event = raw_event.strip(b"\r\n")
if not event:
return
field_lines: list[bytes] = []
data_lines: list[bytes] = []
for line in event.split(b"\n"):
line = line.rstrip(b"\r")
if line.startswith(b"data:"):
data_lines.append(line[len(b"data:") :].lstrip(b" "))
elif line.startswith(b":"):
# SSE comment / keepalive - drop.
continue
elif line:
# Preserve event:/id:/retry: (Responses API event names).
field_lines.append(line)
if not data_lines:
return
data = b"\n".join(data_lines)
if not data.strip():
return
prefix = b"".join(fl + b"\n" for fl in field_lines)
if data.strip() == b"[DONE]":
done_seen = True
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
obj = None
if isinstance(obj, dict):
self._apply_provider_field(obj)
if obj.get("model"):
last_model_seen = str(obj.get("model"))
if requested_model:
obj["model"] = requested_model
# Track reasoning tokens for Responses API
if usage := obj.get("usage", {}):
if isinstance(usage, dict) and "reasoning_tokens" in usage:
reasoning_tokens += usage.get("reasoning_tokens", 0)
# Responses API usage is in response.completed/incomplete events
chunk_type = obj.get("type", "")
if chunk_type in (
"response.completed",
"response.incomplete",
):
usage_chunk_data = obj
return
yield prefix + b"data: " + json.dumps(obj).encode() + b"\n\n"
else:
# Re-prefix each line so multi-line ``data`` stays valid SSE
# framing for the client.
body = b"".join(
b"data: " + ln + b"\n" for ln in data.split(b"\n")
)
yield prefix + body + b"\n"
try:
sse_buffer = b""
# Buffer across network chunks; dispatch only on the SSE event
# delimiter so parsing is independent of byte boundaries.
buffer = b""
async for chunk in response.aiter_bytes():
sse_buffer += chunk
# SSE events are separated by \n\n (double newline).
# Process complete events and keep incomplete ones in the buffer.
while b"\n\n" in sse_buffer:
event_raw, sse_buffer = sse_buffer.split(b"\n\n", 1)
event_raw = event_raw.strip()
if not event_raw:
continue
buffer += chunk.replace(b"\r\n", b"\n")
while b"\n\n" in buffer:
raw_event, buffer = buffer.split(b"\n\n", 1)
for out in _process_event(raw_event):
yield out
if event_raw == b"data: [DONE]":
done_seen = True
continue
# Extract the JSON payload from "data: {...}"
data_prefix = b"data: "
if event_raw.startswith(data_prefix):
payload_bytes = event_raw[len(data_prefix):]
else:
payload_bytes = event_raw
try:
obj = json.loads(payload_bytes)
if isinstance(obj, dict):
self._apply_provider_field(obj)
if obj.get("model"):
last_model_seen = str(obj.get("model"))
if requested_model:
obj["model"] = requested_model
# Track reasoning tokens for Responses API
if usage := obj.get("usage", {}):
if (
isinstance(usage, dict)
and "reasoning_tokens" in usage
):
reasoning_tokens += usage.get(
"reasoning_tokens", 0
)
# Responses API usage is in response.completed/incomplete events
chunk_type = obj.get("type", "")
if chunk_type in (
"response.completed",
"response.incomplete",
):
usage_chunk_data = obj
continue
yield b"data: " + json.dumps(obj).encode() + b"\n\n"
continue
except json.JSONDecodeError:
pass
# Pass through non-JSON SSE events
if event_raw.startswith(b"data: "):
yield event_raw + b"\n\n"
else:
yield b"data: " + event_raw + b"\n\n"
if buffer.strip():
for out in _process_event(buffer):
yield out
# Always emit a cost-bearing data chunk
async with create_session() as session:

View File

@@ -18,6 +18,33 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
supports_anthropic_messages = True
litellm_provider_prefix = "openrouter/"
def _apply_provider_field(self, response_json: object) -> None:
"""Stamp the ``provider`` field for OpenRouter responses.
OpenRouter is a router, not the real serving provider, so a bare
``"openrouter"`` value carries no useful information. Rules:
- Real upstream sub-provider (e.g. ``"GMICloud"``) -> ``"openrouter:GMICloud"``.
- Missing sub-provider, or one that merely echoes ``"openrouter"`` ->
``"unknown"``.
- Idempotent: re-stamping never produces ``"openrouter:openrouter:..."``;
the ``openrouter:`` prefix appears at most once.
"""
if not isinstance(response_json, dict):
return
provider_type = (self.provider_type or "").strip()
existing = response_json.get("provider")
sub = existing.strip() if isinstance(existing, str) else ""
# Strip any already-applied "openrouter:" prefixes (idempotency).
prefix = f"{provider_type}:"
while sub.lower().startswith(prefix.lower()):
sub = sub[len(prefix) :].strip()
# No real sub-provider, or it just echoes our own router name.
if not sub or sub.lower() == provider_type.lower():
response_json["provider"] = "unknown"
return
response_json["provider"] = f"{provider_type}:{sub}"
def __init__(self, api_key: str, provider_fee: float = 1.06):
"""Initialize OpenRouter provider with API key.

View File

@@ -174,17 +174,20 @@ class TestmintWallet:
"""Fallback method to create a basic test token"""
import base64
import json
import random
import time
import secrets
unique_id = int(time.time() * 1000000) + random.randint(1000, 9999)
# Use a cryptographically random id/secret so every minted token is
# guaranteed unique. Time/PRNG-based ids can collide on hosts with
# coarse clock resolution, producing byte-identical tokens that hash to
# the same api_key and silently dedupe (flaky concurrency tests).
unique_id = secrets.token_hex(16)
token_data = {
"token": [
{
"mint": self.mint_url,
"proofs": [
{
"id": f"009a1f293253e41e{unique_id % 100000000:08d}",
"id": f"009a1f293253e41e{unique_id[:8]}",
"amount": amount,
"secret": f"test-secret-{amount}-{unique_id}",
"C": "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104",

View File

@@ -1,3 +1,4 @@
import asyncio
import secrets
from typing import Any
@@ -7,7 +8,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import adjust_payment_for_tokens, pay_for_request
from routstr.balance import ChildKeyRequest, create_child_key
from routstr.core.db import ApiKey
from routstr.core.db import ApiKey, create_session
from routstr.core.settings import settings
@@ -119,6 +120,54 @@ async def test_child_key_insufficient_balance(
assert exc.value.status_code == 402
@pytest.mark.asyncio
async def test_concurrent_child_key_creation_is_atomic(
patched_db_engine: None,
) -> None:
"""Two concurrent create_child_key() calls with balance for exactly one must
result in exactly one success and one 402, with the parent balance deducted
only once."""
child_key_cost = 1000
settings.child_key_cost = child_key_cost
parent_hash = f"parent_concurrent_{secrets.token_hex(8)}"
async with create_session() as session:
parent = ApiKey(hashed_key=parent_hash, balance=child_key_cost)
session.add(parent)
await session.commit()
results: list[str] = []
async def attempt() -> None:
async with create_session() as session:
fresh_parent = await session.get(ApiKey, parent_hash)
assert fresh_parent is not None
try:
await create_child_key(ChildKeyRequest(count=1), fresh_parent, session)
results.append("success")
except HTTPException as exc:
assert exc.status_code == 402
results.append("blocked")
await asyncio.gather(attempt(), attempt())
assert sorted(results) == ["blocked", "success"], (
f"Expected exactly one success and one 402, got: {results}"
)
async with create_session() as session:
final = await session.get(ApiKey, parent_hash)
assert final is not None
assert final.balance == 0, (
f"Balance should be fully deducted once: expected 0, got {final.balance}"
)
assert final.total_spent == child_key_cost, (
f"total_spent should equal one deduction: expected {child_key_cost}, "
f"got {final.total_spent}"
)
@pytest.mark.asyncio
async def test_child_key_cannot_create_child(integration_session: AsyncSession) -> None:
parent_key = ApiKey(

View File

@@ -0,0 +1,159 @@
"""Integration tests for Lightning invoice key constraint fields.
Covers two things:
- The three constraint fields (balance_limit, balance_limit_reset, validity_date)
are persisted on LightningInvoice and survive a DB round-trip.
- create_api_key_from_invoice propagates those fields to the created ApiKey,
so the constraints are actually enforced when the key is used.
"""
from __future__ import annotations
import time
from unittest.mock import AsyncMock, patch
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ApiKey, LightningInvoice
from routstr.lightning import create_api_key_from_invoice
def _make_invoice(**kwargs: object) -> LightningInvoice:
base = dict(
id="inv_test_001",
bolt11="lnbc1000n1test",
amount_sats=1000,
description="test invoice",
payment_hash="deadbeef" * 8,
status="paid",
purpose="create",
expires_at=int(time.time()) + 3600,
paid_at=int(time.time()),
)
base.update(kwargs)
return LightningInvoice(**base) # type: ignore[arg-type]
@pytest.fixture(autouse=True)
def mock_wallet_mint() -> object:
with patch("routstr.lightning.get_wallet") as mock_get_wallet:
wallet = AsyncMock()
wallet.mint = AsyncMock(return_value=[])
mock_get_wallet.return_value = wallet
yield mock_get_wallet
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_invoice_persists_balance_limit(
integration_session: AsyncSession,
) -> None:
invoice = _make_invoice(balance_limit=5000)
integration_session.add(invoice)
await integration_session.commit()
stored = await integration_session.get(LightningInvoice, invoice.id)
assert stored is not None
assert stored.balance_limit == 5000
@pytest.mark.asyncio
async def test_invoice_persists_balance_limit_reset(
integration_session: AsyncSession,
) -> None:
invoice = _make_invoice(balance_limit=5000, balance_limit_reset="daily")
integration_session.add(invoice)
await integration_session.commit()
stored = await integration_session.get(LightningInvoice, invoice.id)
assert stored is not None
assert stored.balance_limit_reset == "daily"
@pytest.mark.asyncio
async def test_invoice_persists_validity_date(
integration_session: AsyncSession,
) -> None:
expiry = int(time.time()) + 86400
invoice = _make_invoice(validity_date=expiry)
integration_session.add(invoice)
await integration_session.commit()
stored = await integration_session.get(LightningInvoice, invoice.id)
assert stored is not None
assert stored.validity_date == expiry
# ---------------------------------------------------------------------------
# Propagation to ApiKey
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_created_key_receives_balance_limit(
integration_session: AsyncSession,
) -> None:
invoice = _make_invoice(balance_limit=8000)
integration_session.add(invoice)
await integration_session.flush()
api_key = await create_api_key_from_invoice(invoice, integration_session)
await integration_session.commit()
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
assert stored_key is not None
assert stored_key.balance_limit == 8000
@pytest.mark.asyncio
async def test_created_key_receives_balance_limit_reset(
integration_session: AsyncSession,
) -> None:
invoice = _make_invoice(balance_limit=8000, balance_limit_reset="monthly")
integration_session.add(invoice)
await integration_session.flush()
api_key = await create_api_key_from_invoice(invoice, integration_session)
await integration_session.commit()
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
assert stored_key is not None
assert stored_key.balance_limit_reset == "monthly"
@pytest.mark.asyncio
async def test_created_key_receives_validity_date(
integration_session: AsyncSession,
) -> None:
expiry = int(time.time()) + 86400
invoice = _make_invoice(validity_date=expiry)
integration_session.add(invoice)
await integration_session.flush()
api_key = await create_api_key_from_invoice(invoice, integration_session)
await integration_session.commit()
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
assert stored_key is not None
assert stored_key.validity_date == expiry
@pytest.mark.asyncio
async def test_created_key_without_constraints_has_none_fields(
integration_session: AsyncSession,
) -> None:
invoice = _make_invoice()
integration_session.add(invoice)
await integration_session.flush()
api_key = await create_api_key_from_invoice(invoice, integration_session)
await integration_session.commit()
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
assert stored_key is not None
assert stored_key.balance_limit is None
assert stored_key.balance_limit_reset is None
assert stored_key.validity_date is None

View File

@@ -0,0 +1,210 @@
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlmodel import col, update
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.admin import admin_sessions
from routstr.core.db import ApiKey
def _admin_headers() -> dict[str, str]:
token = "test-admin-token"
admin_sessions[token] = int(
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
)
return {"Authorization": f"Bearer {token}"}
async def _add_key(
session: AsyncSession,
hashed_key: str,
*,
balance: int = 0,
total_spent: int = 0,
total_requests: int = 0,
created_at: int | None = None,
parent_key_hash: str | None = None,
refund_address: str | None = None,
) -> ApiKey:
key = ApiKey(
hashed_key=hashed_key,
balance=balance,
total_spent=total_spent,
total_requests=total_requests,
parent_key_hash=parent_key_hash,
refund_address=refund_address,
)
key.created_at = created_at
session.add(key)
await session.commit()
# The model's default_factory is translated into a SQLAlchemy column
# default that fires on INSERT whenever the value is None, so a true NULL
# (a legacy row created before the column existed) can only be produced by
# an explicit UPDATE after insert.
if created_at is None:
await session.exec(
update(ApiKey) # type: ignore[call-overload]
.where(col(ApiKey.hashed_key) == hashed_key)
.values(created_at=None)
)
await session.commit()
return key
@pytest.mark.integration
@pytest.mark.asyncio
async def test_temporary_balances_envelope_and_created_at(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
await _add_key(integration_session, "key_a", balance=1000, created_at=1000)
response = await integration_client.get(
"/admin/api/temporary-balances", headers=_admin_headers()
)
assert response.status_code == 200
body = response.json()
assert set(body.keys()) == {"balances", "total", "totals"}
assert body["total"] == 1
assert body["balances"][0]["hashed_key"] == "key_a"
assert body["balances"][0]["created_at"] == 1000
@pytest.mark.integration
@pytest.mark.asyncio
async def test_temporary_balances_sorted_latest_first_nulls_last(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
await _add_key(integration_session, "older", created_at=1000)
await _add_key(integration_session, "newer", created_at=2000)
await _add_key(integration_session, "legacy", created_at=None)
response = await integration_client.get(
"/admin/api/temporary-balances", headers=_admin_headers()
)
assert response.status_code == 200
order = [b["hashed_key"] for b in response.json()["balances"]]
# Newest created first, NULL created_at (legacy) sorts last.
assert order == ["newer", "older", "legacy"]
@pytest.mark.integration
@pytest.mark.asyncio
async def test_temporary_balances_pagination(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
for i in range(5):
await _add_key(integration_session, f"key_{i}", created_at=1000 + i)
headers = _admin_headers()
page1 = (
await integration_client.get(
"/admin/api/temporary-balances?limit=2&offset=0", headers=headers
)
).json()
page2 = (
await integration_client.get(
"/admin/api/temporary-balances?limit=2&offset=2", headers=headers
)
).json()
assert page1["total"] == 5
assert page2["total"] == 5
assert [b["hashed_key"] for b in page1["balances"]] == ["key_4", "key_3"]
assert [b["hashed_key"] for b in page2["balances"]] == ["key_2", "key_1"]
@pytest.mark.integration
@pytest.mark.asyncio
async def test_temporary_balances_totals_exclude_child_balance(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
await _add_key(
integration_session,
"parent",
balance=5000,
total_spent=100,
total_requests=3,
created_at=1000,
)
# Child draws from parent's balance, so its balance must NOT be summed,
# but its spent/requests still count.
await _add_key(
integration_session,
"child",
balance=0,
total_spent=200,
total_requests=7,
created_at=1001,
parent_key_hash="parent",
)
response = await integration_client.get(
"/admin/api/temporary-balances", headers=_admin_headers()
)
totals = response.json()["totals"]
assert totals["total_balance"] == 5000
assert totals["total_spent"] == 300
assert totals["total_requests"] == 10
@pytest.mark.integration
@pytest.mark.asyncio
async def test_temporary_balances_search_filters_total_and_totals(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
await _add_key(
integration_session,
"alpha",
balance=1000,
created_at=1000,
refund_address="alice@ln.tld",
)
await _add_key(
integration_session,
"beta",
balance=2000,
created_at=1001,
refund_address="bob@ln.tld",
)
headers = _admin_headers()
# Match by hashed_key.
by_key = (
await integration_client.get(
"/admin/api/temporary-balances?search=alpha", headers=headers
)
).json()
assert by_key["total"] == 1
assert by_key["balances"][0]["hashed_key"] == "alpha"
# totals reflect only the filtered set.
assert by_key["totals"]["total_balance"] == 1000
# Match by refund_address.
by_addr = (
await integration_client.get(
"/admin/api/temporary-balances?search=bob@ln.tld", headers=headers
)
).json()
assert by_addr["total"] == 1
assert by_addr["balances"][0]["hashed_key"] == "beta"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_temporary_balances_requires_admin(
integration_client: httpx.AsyncClient,
) -> None:
response = await integration_client.get("/admin/api/temporary-balances")
assert response.status_code in (401, 403)

View File

@@ -32,11 +32,39 @@ def test_apply_provider_field_openrouter_passthrough() -> None:
def test_apply_provider_field_openrouter_no_upstream_provider() -> None:
"""If OpenRouter omits the provider field, fall back to provider_type."""
"""If OpenRouter omits the provider field, the real serving provider is
unknown — a bare ``openrouter`` value carries no information."""
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"id": "gen-abc"}
p._apply_provider_field(data)
assert data["provider"] == "openrouter"
assert data["provider"] == "unknown"
def test_apply_provider_field_openrouter_echoes_router_name() -> None:
"""If OpenRouter reports its own name as the provider, treat as unknown."""
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"provider": "openrouter"}
p._apply_provider_field(data)
assert data["provider"] == "unknown"
def test_apply_provider_field_openrouter_idempotent_no_double_prefix() -> None:
"""Re-stamping must never nest the prefix: openrouter only once."""
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"provider": "GMICloud"}
p._apply_provider_field(data)
assert data["provider"] == "openrouter:GMICloud"
# Second pass (e.g. streaming) keeps a single prefix.
p._apply_provider_field(data)
assert data["provider"] == "openrouter:GMICloud"
def test_apply_provider_field_openrouter_collapses_existing_double_prefix() -> None:
"""A pre-existing double prefix is collapsed to a single one."""
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"provider": "openrouter:openrouter:GMICloud"}
p._apply_provider_field(data)
assert data["provider"] == "openrouter:GMICloud"
def test_apply_provider_field_strips_whitespace() -> None:
@@ -50,28 +78,24 @@ def test_apply_provider_field_blank_upstream_treated_as_missing() -> None:
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"provider": " "}
p._apply_provider_field(data)
assert data["provider"] == "openrouter"
assert data["provider"] == "unknown"
def test_apply_provider_field_non_string_upstream_treated_as_missing() -> None:
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"provider": 42}
p._apply_provider_field(data)
assert data["provider"] == "openrouter"
assert data["provider"] == "unknown"
def test_apply_provider_field_idempotent_for_direct_upstream() -> None:
"""Calling twice on a direct upstream payload should keep the same
value, not nest the prefix repeatedly."""
"""Calling twice on a direct upstream payload keeps the same value and
never nests the prefix (no ``anthropic:anthropic``)."""
p = _make_provider(AnthropicUpstreamProvider, "anthropic")
data: dict = {}
p._apply_provider_field(data)
p._apply_provider_field(data)
assert data["provider"] == "anthropic:anthropic"
# Document current (deliberate) behavior: second pass treats the
# first-pass value as an upstream-reported provider. Callers should
# only invoke this once per chunk — guarded via the
# ``"provider" not in data`` checks in streaming paths.
assert data["provider"] == "anthropic"
def test_apply_provider_field_ignores_non_dict() -> None:

View File

@@ -0,0 +1,339 @@
"""Battle-test the streaming SSE parser against real per-provider framing.
Each test drives the *actual* ``handle_streaming_chat_completion`` generator
with a mock upstream response whose ``aiter_bytes`` emits byte sequences that
mirror what each supported provider sends on the wire (captured from the
providers' own streaming docs):
* OpenAI / Groq / Fireworks / xAI / Perplexity / Azure - plain
``data: {json}\\n\\n`` + ``data: [DONE]``.
* OpenRouter - same, but with ``: OPENROUTER PROCESSING`` keepalive comments
interleaved (the framing that produced the original
``Unexpected token ':'`` client crash).
* Gemini (native ``alt=sse``) - ``data:`` payloads framed with CRLF.
The invariant every provider must satisfy: every line the proxy emits that
starts with ``data: `` either equals ``[DONE]`` or is valid JSON, and no SSE
comment ever reaches the client. That invariant is exactly what the buggy
``re.split(b"data: ")`` parser violated for OpenRouter.
"""
import json
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock
import pytest
from routstr.core.db import ApiKey
from routstr.upstream import base
from routstr.upstream.base import BaseUpstreamProvider
def _make_response(chunks: list[bytes]) -> MagicMock:
async def aiter_bytes() -> AsyncGenerator[bytes, None]:
for chunk in chunks:
yield chunk
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "text/event-stream"}
mock_response.aiter_bytes = aiter_bytes
return mock_response
async def _drive(chunks: list[bytes], requested_model: str | None = None) -> list[bytes]:
"""Run the real streaming generator over ``chunks`` and collect output bytes."""
provider = BaseUpstreamProvider(
base_url="https://api.example.com", api_key="test_key"
)
key = MagicMock(spec=ApiKey)
key.hashed_key = "test_hash"
key.balance = 1000
base.adjust_payment_for_tokens = AsyncMock(
return_value={"total_usd": 0.1, "total_msats": 100}
)
mock_session = MagicMock()
mock_session.get = AsyncMock(return_value=key)
mock_ctx = MagicMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
base.create_session = MagicMock(return_value=mock_ctx)
streaming_response = await provider.handle_streaming_chat_completion(
response=_make_response(chunks),
key=key,
max_cost_for_model=100,
background_tasks=MagicMock(),
requested_model=requested_model,
)
out: list[bytes] = []
async for chunk in streaming_response.body_iterator:
if isinstance(chunk, str):
out.append(chunk.encode())
else:
out.append(bytes(chunk))
return out
def _data_payloads(out: list[bytes]) -> list[bytes]:
"""Return the raw payload of every ``data: `` line across all emitted bytes."""
payloads: list[bytes] = []
for chunk in out:
for line in chunk.split(b"\n"):
if line.startswith(b"data: "):
payloads.append(line[len(b"data: ") :])
return payloads
def _assert_clean(out: list[bytes]) -> list[dict]:
"""Core invariant: every data line is [DONE] or valid JSON; no comments leak."""
blob = b"".join(out)
# No SSE comment line must ever reach the client.
for line in blob.split(b"\n"):
assert not line.startswith(b":"), f"comment leaked to client: {line!r}"
# The original bug signature: a data line whose value is itself a comment.
assert not line.startswith(b"data: :"), f"mangled comment frame: {line!r}"
objs: list[dict] = []
for payload in _data_payloads(out):
stripped = payload.strip()
if stripped == b"[DONE]":
continue
obj = json.loads(stripped) # raises if the proxy emitted non-JSON data
objs.append(obj)
return objs
@pytest.mark.asyncio
async def test_openai_style_plain_stream() -> None:
"""OpenAI / Groq / Fireworks / xAI / Perplexity: plain data + [DONE]."""
chunks = [
b'data: {"id":"x","choices":[{"delta":{"content":"Hello"}}]}\n\n',
b'data: {"id":"x","choices":[{"delta":{"content":" world"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
assert any(o.get("choices") for o in objs)
assert b"data: [DONE]\n\n" in b"".join(out)
@pytest.mark.asyncio
async def test_openrouter_keepalive_comments() -> None:
"""OpenRouter ``: OPENROUTER PROCESSING`` keepalives must never crash clients.
This is the exact regression: the old parser emitted
``data: : OPENROUTER PROCESSING`` which made downstream
``JSON.parse`` throw ``Unexpected token ':'``.
"""
chunks = [
b": OPENROUTER PROCESSING\n\n",
b": OPENROUTER PROCESSING\n\n",
b'data: {"id":"x","choices":[{"delta":{"content":"Hi"}}]}\n\n',
b": OPENROUTER PROCESSING\n\n",
b'data: {"id":"x","choices":[{"delta":{"content":"!"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
# The keepalive must be gone entirely.
assert b"OPENROUTER PROCESSING" not in b"".join(out)
# Real content survived.
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert "Hi" in contents and "!" in contents
@pytest.mark.asyncio
async def test_openrouter_comment_glued_to_data_chunk() -> None:
"""Keepalive packed into the same TCP chunk as data (the harder case)."""
chunks = [
b'data: {"id":"x","choices":[{"delta":{"content":"a"}}]}\n\n'
b": OPENROUTER PROCESSING\n\n"
b'data: {"id":"x","choices":[{"delta":{"content":"b"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert contents == ["a", "b"]
@pytest.mark.asyncio
async def test_json_split_across_chunk_boundary() -> None:
"""A single event's JSON arriving in two TCP reads must reassemble."""
chunks = [
b'data: {"id":"x","choices":[{"delta":{"con',
b'tent":"split"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert contents == ["split"]
@pytest.mark.asyncio
async def test_byte_by_byte_fragmentation() -> None:
"""Pathological framing: one byte per chunk. Must still parse cleanly."""
raw = (
b'data: {"id":"x","choices":[{"delta":{"content":"drip"}}]}\n\n'
b": OPENROUTER PROCESSING\n\n"
b"data: [DONE]\n\n"
)
chunks = [raw[i : i + 1] for i in range(len(raw))]
out = await _drive(chunks)
objs = _assert_clean(out)
assert objs and objs[0]["choices"][0]["delta"]["content"] == "drip"
@pytest.mark.asyncio
async def test_gemini_crlf_framing() -> None:
"""Gemini native (alt=sse) frames events with CRLF."""
chunks = [
b'data: {"id":"g","choices":[{"delta":{"content":"hej"}}]}\r\n\r\n',
b'data: {"id":"g","choices":[{"delta":{"content":"!"}}]}\r\n\r\n',
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert contents == ["hej", "!"]
@pytest.mark.asyncio
async def test_azure_leading_role_chunk() -> None:
"""Azure OpenAI opens with a content-filter / role-only chunk."""
chunks = [
b'data: {"id":"az","choices":[],"prompt_filter_results":[]}\n\n',
b'data: {"id":"az","choices":[{"delta":{"role":"assistant"}}]}\n\n',
b'data: {"id":"az","choices":[{"delta":{"content":"ok"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
_assert_clean(out)
@pytest.mark.asyncio
async def test_openrouter_mid_stream_error_event() -> None:
"""OpenRouter mid-stream errors arrive as a normal data JSON event."""
err = {
"id": "x",
"object": "chat.completion.chunk",
"model": "openai/gpt-4o",
"error": {"code": "server_error", "message": "Provider disconnected"},
"choices": [{"index": 0, "delta": {"content": ""}, "finish_reason": "error"}],
}
chunks = [
b'data: {"id":"x","choices":[{"delta":{"content":"partial"}}]}\n\n',
b"data: " + json.dumps(err).encode() + b"\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
assert any("error" in o for o in objs), "error event must be forwarded intact"
@pytest.mark.asyncio
async def test_gemini_combined_content_and_usage_chunk() -> None:
"""Gemini thinking models pack usage into the final *content* chunk.
Regression: the parser swallowed any chunk carrying a ``usage`` dict, so
when content + usage arrived together the assistant text was dropped and
the client saw "no assistant messages" despite a 200 + token accounting.
"""
chunks = [
b'data: {"id":"g","choices":[{"delta":{"content":"the answer"},'
b'"finish_reason":"stop"}],"usage":{"prompt_tokens":3,'
b'"completion_tokens":2,"total_tokens":5}}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
# Content delivered exactly once (not dropped, not duplicated by the trailer).
assert contents == ["the answer"]
@pytest.mark.asyncio
async def test_separate_usage_chunk_not_forwarded_as_content() -> None:
"""A pure usage chunk (choices: []) is still swallowed, content intact."""
chunks = [
b'data: {"id":"x","choices":[{"delta":{"content":"hello"}}]}\n\n',
b'data: {"id":"x","choices":[],"usage":{"total_tokens":4}}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
objs = _assert_clean(out)
contents = [
c["delta"]["content"]
for o in objs
for c in o.get("choices", [])
if "delta" in c
]
assert contents == ["hello"]
@pytest.mark.asyncio
async def test_requested_model_override_applied() -> None:
"""Model rewriting still works through the buffered parser."""
chunks = [
b'data: {"id":"x","model":"upstream-model","choices":[{"delta":{"content":"hi"}}]}\n\n',
b"data: [DONE]\n\n",
]
out = await _drive(chunks, requested_model="routstr-model")
objs = _assert_clean(out)
# The upstream content chunk carried model "upstream-model"; the parser must
# rewrite it to the requested model. (The trailing routstr-generated usage
# chunk is excluded - it is not an upstream-forwarded chunk.)
content_chunks = [o for o in objs if o.get("choices")]
assert content_chunks, "expected at least one forwarded content chunk"
assert all(o.get("model") == "routstr-model" for o in content_chunks)
@pytest.mark.asyncio
async def test_multiline_non_json_data_each_line_prefixed() -> None:
"""A multi-line non-JSON ``data`` block must keep a ``data:`` prefix per line.
Two ``data:`` lines in one event reassemble to ``line one\\nline two``, which
is not JSON, so it takes the raw-forward path. The parser must re-prefix each
line; a bare second line would reach the client without its ``data:`` field
and break naive SSE parsers.
"""
chunks = [
b"data: line one\ndata: line two\n\n",
b"data: [DONE]\n\n",
]
out = await _drive(chunks)
blob = b"".join(out)
for line in blob.split(b"\n"):
stripped = line.strip()
if not stripped or stripped == b"[DONE]":
continue
assert line.startswith(b"data: "), f"bare line leaked to client: {line!r}"
assert b"data: line one" in blob and b"data: line two" in blob

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import {
RefreshCw,
AlertCircle,
@@ -9,8 +9,10 @@ import {
Clock,
DollarSign,
Activity,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { AdminService, TemporaryBalance } from '@/lib/api/services/admin';
import { AdminService } from '@/lib/api/services/admin';
import {
Card,
CardContent,
@@ -41,52 +43,12 @@ import {
import { cn } from '@/lib/utils';
import type { DisplayUnit } from '@/lib/types/units';
import { formatFromMsat } from '@/lib/currency';
import { format } from 'date-fns';
function getTotals(balances: TemporaryBalance[]) {
let totalBalance = 0;
let totalSpent = 0;
let totalRequests = 0;
const PAGE_SIZE = 50;
balances.forEach((balance) => {
if (!balance.parent_key_hash) {
totalBalance += balance.balance || 0;
}
totalSpent += balance.total_spent || 0;
totalRequests += balance.total_requests || 0;
});
return { totalBalance, totalSpent, totalRequests };
}
function buildHierarchicalData(
allBalances: TemporaryBalance[],
filteredBalances: TemporaryBalance[]
) {
const parents = filteredBalances.filter((item) => !item.parent_key_hash);
const result: Array<TemporaryBalance & { isChild?: boolean }> = [];
parents.forEach((parent) => {
result.push(parent);
const children = allBalances.filter(
(item) => item.parent_key_hash === parent.hashed_key
);
children.forEach((child) => {
result.push({ ...child, isChild: true });
});
});
const orphans = filteredBalances.filter(
(item) =>
item.parent_key_hash &&
!result.some((r) => r.hashed_key === item.hashed_key)
);
result.push(...orphans.map((item) => ({ ...item, isChild: true })));
return result;
}
const formatCreatedAt = (createdAt: number | null | undefined) =>
createdAt ? format(createdAt * 1000, 'yyyy-MM-dd HH:mm:ss') : '—';
export function TemporaryBalances({
refreshInterval = 10000,
@@ -98,38 +60,55 @@ export function TemporaryBalances({
usdPerSat: number | null;
}) {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(0);
// Debounce the search input so we don't refetch on every keystroke.
useEffect(() => {
const handle = setTimeout(() => setDebouncedSearch(searchTerm), 300);
return () => clearTimeout(handle);
}, [searchTerm]);
// Reset to the first page whenever the active search changes.
useEffect(() => {
setPage(0);
}, [debouncedSearch]);
const searchParam = debouncedSearch || undefined;
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
queryKey: ['temporary-balances'],
queryFn: async () => AdminService.getTemporaryBalances(),
queryKey: ['temporary-balances', searchParam, page],
queryFn: async () =>
AdminService.getTemporaryBalances(
searchParam,
PAGE_SIZE,
page * PAGE_SIZE
),
refetchInterval: refreshInterval,
placeholderData: keepPreviousData,
});
const formatBalance = (msat: number) =>
formatFromMsat(msat, displayUnit, usdPerSat);
const filteredData = data
? data.filter(
(item) =>
item.hashed_key.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.refund_address?.toLowerCase().includes(searchTerm.toLowerCase())
)
: [];
const totals = data
? getTotals(data)
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
const rows = data ? buildHierarchicalData(data, filteredData) : [];
const rows = data?.balances ?? [];
const total = data?.total ?? 0;
const totals = data?.totals ?? {
total_balance: 0,
total_spent: 0,
total_requests: 0,
};
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<Card>
<CardHeader className='pb-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1.5'>
<CardTitle>Temporary Balances</CardTitle>
<CardTitle>API Keys</CardTitle>
<CardDescription className='max-w-2xl'>
API keys with their current balances and usage statistics
API keys with their current balances and usage statistics, newest
first
</CardDescription>
</div>
@@ -156,7 +135,7 @@ export function TemporaryBalances({
(isFetching || isLoading) && 'animate-spin'
)}
/>
<span className='sr-only'>Refresh temporary balances</span>
<span className='sr-only'>Refresh API keys</span>
</Button>
</div>
</div>
@@ -190,7 +169,7 @@ export function TemporaryBalances({
<Alert variant='destructive'>
<AlertCircle className='h-5 w-5' />
<AlertDescription>
Error loading temporary balances: {(error as Error).message}
Error loading API keys: {(error as Error).message}
</AlertDescription>
</Alert>
) : (
@@ -207,7 +186,7 @@ export function TemporaryBalances({
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatBalance(totals.totalBalance)}
{formatBalance(totals.total_balance)}
</p>
</CardContent>
</Card>
@@ -222,7 +201,7 @@ export function TemporaryBalances({
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{formatBalance(totals.totalSpent)}
{formatBalance(totals.total_spent)}
</p>
</CardContent>
</Card>
@@ -237,12 +216,44 @@ export function TemporaryBalances({
</CardHeader>
<CardContent className='pt-0'>
<p className='text-2xl font-semibold tracking-tight tabular-nums'>
{totals.totalRequests.toLocaleString()}
{totals.total_requests.toLocaleString()}
</p>
</CardContent>
</Card>
</div>
{totalPages > 1 && (
<div className='flex flex-col gap-2 border-b pb-3 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-muted-foreground text-xs sm:text-sm'>
{page * PAGE_SIZE + 1}
{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
</span>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
<ChevronLeft className='h-4 w-4' />
<span className='hidden sm:inline'>Previous</span>
</Button>
<span className='text-xs sm:text-sm'>
{page + 1} / {totalPages}
</span>
<Button
variant='outline'
size='sm'
disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)}
>
<span className='hidden sm:inline'>Next</span>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
</div>
)}
{rows.length > 0 ? (
<>
<div className='hidden md:block'>
@@ -257,6 +268,7 @@ export function TemporaryBalances({
<TableHead className='text-right'>
Total Requests
</TableHead>
<TableHead>Created</TableHead>
<TableHead>Refund Address</TableHead>
<TableHead className='text-right'>
Expiry Time
@@ -264,178 +276,192 @@ export function TemporaryBalances({
</TableRow>
</TableHeader>
<TableBody>
{rows.map((balance, index) => (
<TableRow
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
className={cn(
balance.balance === 0 &&
!balance.isChild &&
'opacity-60',
balance.isChild && 'bg-muted/30'
)}
>
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
<div className='flex items-center gap-2'>
{balance.isChild && (
<Badge
variant='outline'
className='h-4 px-1 text-[10px] uppercase'
>
Child
</Badge>
)}
<span>{balance.hashed_key}</span>
</div>
</TableCell>
<TableCell className='text-right font-mono'>
{balance.isChild ? (
<span className='text-muted-foreground italic'>
(Parent)
</span>
) : (
formatBalance(balance.balance)
{rows.map((balance, index) => {
const isChild = Boolean(balance.parent_key_hash);
return (
<TableRow
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-${index}`}
className={cn(
balance.balance === 0 && !isChild && 'opacity-60',
isChild && 'bg-muted/30'
)}
</TableCell>
<TableCell className='text-right font-mono'>
{formatBalance(balance.total_spent)}
</TableCell>
<TableCell className='text-right font-mono'>
{balance.total_requests.toLocaleString()}
</TableCell>
<TableCell className='max-w-[14rem] font-mono text-xs break-all whitespace-normal'>
{balance.refund_address || '-'}
</TableCell>
<TableCell className='text-right font-mono text-xs'>
{balance.key_expiry_time ? (
<div className='inline-flex items-center justify-end gap-1'>
<Clock className='h-3 w-3' />
<span>
{new Date(
balance.key_expiry_time * 1000
).toLocaleDateString()}
</span>
>
<TableCell className='max-w-[16rem] font-mono text-xs break-all whitespace-normal'>
<div className='flex items-center gap-2'>
{isChild && (
<Badge
variant='outline'
className='h-4 px-1 text-[10px] uppercase'
>
Child
</Badge>
)}
<span>{balance.hashed_key}</span>
</div>
) : (
'-'
)}
</TableCell>
</TableRow>
))}
</TableCell>
<TableCell className='text-right font-mono'>
{isChild ? (
<span className='text-muted-foreground italic'>
(Parent)
</span>
) : (
formatBalance(balance.balance)
)}
</TableCell>
<TableCell className='text-right font-mono'>
{formatBalance(balance.total_spent)}
</TableCell>
<TableCell className='text-right font-mono'>
{balance.total_requests.toLocaleString()}
</TableCell>
<TableCell className='font-mono text-xs whitespace-nowrap'>
{formatCreatedAt(balance.created_at)}
</TableCell>
<TableCell className='max-w-[14rem] font-mono text-xs break-all whitespace-normal'>
{balance.refund_address || '-'}
</TableCell>
<TableCell className='text-right font-mono text-xs'>
{balance.key_expiry_time ? (
<div className='inline-flex items-center justify-end gap-1'>
<Clock className='h-3 w-3' />
<span>
{new Date(
balance.key_expiry_time * 1000
).toLocaleDateString()}
</span>
</div>
) : (
'-'
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className='space-y-2 md:hidden'>
{rows.map((balance, index) => (
<Card
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
className={cn(
balance.balance === 0 &&
!balance.isChild &&
'opacity-80',
balance.isChild && 'bg-muted/30'
)}
>
<CardHeader className='p-4 pb-2'>
<div className='flex items-center justify-between gap-2'>
<CardDescription className='font-mono text-xs break-all'>
{balance.hashed_key}
</CardDescription>
{balance.isChild && (
<Badge
variant='outline'
className='h-4 px-1.5 text-[10px] uppercase'
>
Child
</Badge>
)}
</div>
</CardHeader>
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
<div>
<p className='text-muted-foreground text-xs'>
Balance
</p>
<p className='font-mono text-sm'>
{balance.isChild
? '(Uses Parent)'
: formatBalance(balance.balance)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Spent</p>
<p className='font-mono text-sm'>
{formatBalance(balance.total_spent)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Requests
</p>
<p className='font-mono text-sm'>
{balance.total_requests.toLocaleString()}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Expires
</p>
<p className='font-mono text-xs'>
{balance.key_expiry_time ? (
<span className='inline-flex items-center gap-1'>
<Clock className='h-3 w-3' />
{new Date(
balance.key_expiry_time * 1000
).toLocaleDateString()}
</span>
) : (
'-'
{rows.map((balance, index) => {
const isChild = Boolean(balance.parent_key_hash);
return (
<Card
key={`${balance.hashed_key}-${balance.parent_key_hash ?? 'root'}-mobile-${index}`}
className={cn(
balance.balance === 0 && !isChild && 'opacity-80',
isChild && 'bg-muted/30'
)}
>
<CardHeader className='p-4 pb-2'>
<div className='flex items-center justify-between gap-2'>
<CardDescription className='font-mono text-xs break-all'>
{balance.hashed_key}
</CardDescription>
{isChild && (
<Badge
variant='outline'
className='h-4 px-1.5 text-[10px] uppercase'
>
Child
</Badge>
)}
</p>
</div>
{balance.refund_address && (
<div className='col-span-2'>
</div>
</CardHeader>
<CardContent className='grid grid-cols-2 gap-3 p-4 pt-0'>
<div>
<p className='text-muted-foreground text-xs'>
Refund Address
Balance
</p>
<p className='font-mono text-xs break-all'>
{balance.refund_address}
<p className='font-mono text-sm'>
{isChild
? '(Uses Parent)'
: formatBalance(balance.balance)}
</p>
</div>
)}
</CardContent>
</Card>
))}
<div>
<p className='text-muted-foreground text-xs'>
Spent
</p>
<p className='font-mono text-sm'>
{formatBalance(balance.total_spent)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Requests
</p>
<p className='font-mono text-sm'>
{balance.total_requests.toLocaleString()}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Created
</p>
<p className='font-mono text-xs'>
{formatCreatedAt(balance.created_at)}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>
Expires
</p>
<p className='font-mono text-xs'>
{balance.key_expiry_time ? (
<span className='inline-flex items-center gap-1'>
<Clock className='h-3 w-3' />
{new Date(
balance.key_expiry_time * 1000
).toLocaleDateString()}
</span>
) : (
'-'
)}
</p>
</div>
{balance.refund_address && (
<div className='col-span-2'>
<p className='text-muted-foreground text-xs'>
Refund Address
</p>
<p className='font-mono text-xs break-all'>
{balance.refund_address}
</p>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</>
) : (
<Empty className='py-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
{searchTerm ? (
{debouncedSearch ? (
<AlertCircle className='h-4 w-4' />
) : (
<Key className='h-4 w-4' />
)}
</EmptyMedia>
<EmptyTitle>
{searchTerm
? 'No temporary balances match your search'
: 'No temporary balances found'}
{debouncedSearch
? 'No API keys match your search'
: 'No API keys found'}
</EmptyTitle>
<EmptyDescription>
{searchTerm
{debouncedSearch
? 'Try a different key hash or refund address.'
: 'Temporary balances will appear here once API keys are used.'}
: 'API keys will appear here once they are created.'}
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{data && data.length > 0 && (
{total > 0 && (
<p className='text-muted-foreground text-xs'>
Showing {filteredData.length} of {data.length} temporary
balances
Showing {rows.length} of {total} API keys
</p>
)}
</div>

View File

@@ -831,9 +831,18 @@ export class AdminService {
return await apiClient.get<{ dates: string[] }>('/admin/api/logs/dates');
}
static async getTemporaryBalances(): Promise<TemporaryBalance[]> {
return await apiClient.get<TemporaryBalance[]>(
'/admin/api/temporary-balances'
static async getTemporaryBalances(
search?: string,
limit: number = 50,
offset: number = 0
): Promise<TemporaryBalancesResponse> {
const params = new URLSearchParams();
if (search) params.append('search', search);
params.append('limit', limit.toString());
params.append('offset', offset.toString());
return await apiClient.get<TemporaryBalancesResponse>(
`/admin/api/temporary-balances?${params.toString()}`
);
}
@@ -1034,10 +1043,21 @@ export const TemporaryBalanceSchema = z.object({
refund_address: z.string().nullable(),
key_expiry_time: z.number().nullable(),
parent_key_hash: z.string().nullable().optional(),
created_at: z.number().nullable().optional(),
});
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
export interface TemporaryBalancesResponse {
balances: TemporaryBalance[];
total: number;
totals: {
total_balance: number;
total_spent: number;
total_requests: number;
};
}
export interface UsageMetricData {
timestamp: string;
total_requests: number;