chore: merge main

This commit is contained in:
9qeklajc
2026-10-01 11:58:00 +02:00
39 changed files with 2482 additions and 164 deletions
+6
View File
@@ -72,6 +72,12 @@ ROUTSTR_SECRET_KEY=
# UPSTREAM_POOL_TIMEOUT=5 # UPSTREAM_POOL_TIMEOUT=5
# UPSTREAM_READ_TIMEOUT=900 # UPSTREAM_READ_TIMEOUT=900
# Request and reservation lifetime limits (seconds)
# STALE_RESERVATION_TIMEOUT_SECONDS=300
# MAX_REQUEST_LIFETIME_SECONDS=1800
# DOWNSTREAM_SEND_TIMEOUT_SECONDS=60
# REQUEST_CLEANUP_TIMEOUT_SECONDS=30
# Logging # Logging
# LOG_LEVEL=INFO # LOG_LEVEL=INFO
# ENABLE_CONSOLE_LOGGING=true # ENABLE_CONSOLE_LOGGING=true
@@ -0,0 +1,42 @@
"""Immutable reservation start and absolute recovery deadline.
Revision ID: a73d19b6c204
Revises: e4c7a1b9d520
"""
import time
import sqlalchemy as sa
from alembic import op
revision = "a73d19b6c204"
down_revision = "e4c7a1b9d520"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"reservation_releases", sa.Column("started_at", sa.Integer(), nullable=True)
)
op.add_column(
"reservation_releases", sa.Column("expires_at", sa.Integer(), nullable=True)
)
op.create_index(
"ix_reservation_releases_expires_at", "reservation_releases", ["expires_at"]
)
# Original ages are unknowable for renewed legacy rows. Give them a finite
# migration grace period; deploy only after draining old workers.
op.execute(
sa.text(
"UPDATE reservation_releases SET expires_at = :expiry WHERE status = 'active'"
).bindparams(expiry=int(time.time()) + 1830)
)
def downgrade() -> None:
op.drop_index(
"ix_reservation_releases_expires_at", table_name="reservation_releases"
)
op.drop_column("reservation_releases", "expires_at")
op.drop_column("reservation_releases", "started_at")
+4 -2
View File
@@ -21,7 +21,7 @@ dependencies = [
"mdurl==0.1.2", "mdurl==0.1.2",
"pillow>=10", "pillow>=10",
"openai>=1.98.0", "openai>=1.98.0",
"litellm>=1.93.0,<1.94", # 1.93 is the first line supporting Python 3.14 "litellm>=1.101.2,<1.102",
"orjson>=3.10", "orjson>=3.10",
] ]
@@ -72,10 +72,12 @@ build-backend = "setuptools.build_meta"
[tool.setuptools] [tool.setuptools]
packages = ["routstr"] packages = ["routstr"]
[tool.ruff]
extend-exclude = ["examples"]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I"] select = ["E", "F", "I"]
ignore = ["E501"] ignore = ["E501"]
exclude = ["examples"]
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.11"
+34 -4
View File
@@ -49,9 +49,7 @@ payments_logger = get_logger("routstr.payments")
# Routstr platform fee constants # Routstr platform fee constants
ROUTSTR_FEE_PERCENT: float = 2.1 ROUTSTR_FEE_PERCENT: float = 2.1
ROUTSTR_LN_ADDRESS: str = ( ROUTSTR_LN_ADDRESS: str = "routstr-fees@rizful.com"
"npub130mznv74rxs032peqym6g3wqavh472623mt3z5w73xq9r6qqdufs7ql29s@npub.cash"
)
ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS: int = 900 ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS: int = 900
ROUTSTR_FEE_DEFAULT_PAYOUT: int = 200 ROUTSTR_FEE_DEFAULT_PAYOUT: int = 200
@@ -651,6 +649,14 @@ async def pay_for_request(
) )
# Charge the base cost for the request atomically to avoid race conditions # Charge the base cost for the request atomically to avoid race conditions
from .core.lifecycle import request_lifetime
lifetime = request_lifetime.get()
remaining_lifetime = (
max(0, lifetime.deadline - asyncio.get_running_loop().time())
if lifetime is not None
else settings.max_request_lifetime_seconds
)
reserved_at_now = int(time.time()) reserved_at_now = int(time.time())
stmt = ( stmt = (
update(ApiKey) update(ApiKey)
@@ -700,6 +706,13 @@ async def pay_for_request(
billing_key_hash=reservation.billing_key_hash, billing_key_hash=reservation.billing_key_hash,
reserved_msats=reservation.reserved_msats, reserved_msats=reservation.reserved_msats,
status="active", status="active",
started_at=reserved_at_now,
# reserved_at_now floors to the second; add 1s margin so a
# finalizer finishing right at the nominal deadline isn't fenced
# out by truncation.
expires_at=reserved_at_now
+ math.ceil(remaining_lifetime + settings.request_cleanup_timeout_seconds)
+ 1,
) )
) )
# Publish the identity before commit. If the commit succeeds but its # Publish the identity before commit. If the commit succeeds but its
@@ -889,6 +902,10 @@ async def renew_reservation(
update(ReservationRelease) update(ReservationRelease)
.where(col(ReservationRelease.id) == snapshot.release_id) .where(col(ReservationRelease.id) == snapshot.release_id)
.where(col(ReservationRelease.status) == "active") .where(col(ReservationRelease.status) == "active")
.where(
(col(ReservationRelease.expires_at).is_(None))
| (col(ReservationRelease.expires_at) > int(time.time()))
)
.values(created_at=int(time.time())) .values(created_at=int(time.time()))
) )
await session.commit() await session.commit()
@@ -916,12 +933,21 @@ def _start_reservation_heartbeat(snapshot: ReservationSnapshot) -> None:
""" """
interval = max(1, settings.stale_reservation_timeout_seconds // 3) interval = max(1, settings.stale_reservation_timeout_seconds // 3)
owner = asyncio.current_task() owner = asyncio.current_task()
from .core.lifecycle import request_lifetime
lifetime = request_lifetime.get()
deadline = asyncio.get_running_loop().time() + settings.max_request_lifetime_seconds
async def beat() -> None: async def beat() -> None:
try: try:
while True: while True:
await asyncio.sleep(interval) await asyncio.sleep(interval)
if owner is None or owner.done(): if (
owner is None
or owner.done()
or (lifetime is not None and lifetime.stopped)
or asyncio.get_running_loop().time() >= deadline
):
# Request control is gone; let the lease expire so the # Request control is gone; let the lease expire so the
# sweeper can release the reservation if no terminal # sweeper can release the reservation if no terminal
# transition ever ran. # transition ever ran.
@@ -1105,6 +1131,10 @@ async def _claim_reservation_for_charge(
update(ReservationRelease) update(ReservationRelease)
.where(col(ReservationRelease.id) == snapshot.release_id) .where(col(ReservationRelease.id) == snapshot.release_id)
.where(col(ReservationRelease.status) == "active") .where(col(ReservationRelease.status) == "active")
.where(
col(ReservationRelease.expires_at).is_(None)
| (col(ReservationRelease.expires_at) > int(time.time()))
)
.where(col(ReservationRelease.key_hash) == snapshot.key_hash) .where(col(ReservationRelease.key_hash) == snapshot.key_hash)
.where(col(ReservationRelease.billing_key_hash) == snapshot.billing_key_hash) .where(col(ReservationRelease.billing_key_hash) == snapshot.billing_key_hash)
.where(col(ReservationRelease.reserved_msats) == snapshot.reserved_msats) .where(col(ReservationRelease.reserved_msats) == snapshot.reserved_msats)
+10 -2
View File
@@ -174,7 +174,10 @@ async def _transition_stale_reservation(
update(ReservationRelease) update(ReservationRelease)
.where(col(ReservationRelease.id) == reservation_id) .where(col(ReservationRelease.id) == reservation_id)
.where(col(ReservationRelease.status) == "active") .where(col(ReservationRelease.status) == "active")
.where(col(ReservationRelease.created_at) < cutoff) .where(
(col(ReservationRelease.created_at) < cutoff)
| (col(ReservationRelease.expires_at) <= int(time.time()))
)
.values(status="released") .values(status="released")
) )
return bool(transition.rowcount == 1) return bool(transition.rowcount == 1)
@@ -221,7 +224,10 @@ async def release_stale_reservations(
query = ( query = (
select(ReservationRelease) select(ReservationRelease)
.where(col(ReservationRelease.status) == "active") .where(col(ReservationRelease.status) == "active")
.where(col(ReservationRelease.created_at) < cutoff) .where(
(col(ReservationRelease.created_at) < cutoff)
| (col(ReservationRelease.expires_at) <= int(time.time()))
)
) )
if key_hash is not None: if key_hash is not None:
query = query.where( query = query.where(
@@ -784,6 +790,8 @@ class ReservationRelease(SQLModel, table=True): # type: ignore
key_hash: str = Field(index=True) key_hash: str = Field(index=True)
billing_key_hash: str = Field(index=True) billing_key_hash: str = Field(index=True)
reserved_msats: int reserved_msats: int
started_at: int | None = Field(default=None)
expires_at: int | None = Field(default=None, index=True)
status: str = Field(default="active") status: str = Field(default="active")
created_at: int = Field(default_factory=lambda: int(time.time())) created_at: int = Field(default_factory=lambda: int(time.time()))
+134
View File
@@ -0,0 +1,134 @@
"""Supervise the real downstream connection, outside HTTP middleware wrappers."""
from __future__ import annotations
import asyncio
from contextvars import ContextVar
from dataclasses import dataclass
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from . import get_logger
from .settings import settings
logger = get_logger(__name__)
class DownstreamTerminated(OSError):
"""Raised by downstream_send after disconnect; expected, not a server error."""
@dataclass
class RequestLifetime:
deadline: float = 0
stopped: bool = False
request_lifetime: ContextVar[RequestLifetime | None] = ContextVar(
"request_lifetime", default=None
)
class RequestLifecycleMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
lifetime = RequestLifetime(
deadline=asyncio.get_running_loop().time()
+ settings.max_request_lifetime_seconds
)
token = request_lifetime.set(lifetime)
disconnected = asyncio.Event()
# One receive consumer. Backpressure uploads until consumed; after the
# final body message, continue listening independently of the app.
messages: asyncio.Queue[Message] = asyncio.Queue(maxsize=1)
response_started = False
async def pump() -> None:
while True:
message = await receive()
if message["type"] == "http.disconnect":
disconnected.set()
return
await messages.put(message)
async def downstream_receive() -> Message:
if disconnected.is_set():
return {"type": "http.disconnect"}
get = asyncio.create_task(messages.get())
gone = asyncio.create_task(disconnected.wait())
try:
await asyncio.wait((get, gone), return_when=asyncio.FIRST_COMPLETED)
if disconnected.is_set():
return {"type": "http.disconnect"}
return get.result()
finally:
for task in (get, gone):
task.cancel()
await asyncio.gather(get, gone, return_exceptions=True)
async def downstream_send(message: Message) -> None:
nonlocal response_started
if disconnected.is_set() or lifetime.stopped:
raise DownstreamTerminated("Downstream request terminated")
async with asyncio.timeout(settings.downstream_send_timeout_seconds):
await send(message)
if message["type"] == "http.response.start":
response_started = True
receiver = asyncio.create_task(pump())
work: asyncio.Future[None] = asyncio.ensure_future(
self.app(scope, downstream_receive, downstream_send)
)
gone = asyncio.create_task(disconnected.wait())
timed_out = False
try:
done, _ = await asyncio.wait(
(work, gone),
timeout=settings.max_request_lifetime_seconds,
return_when=asyncio.FIRST_COMPLETED,
)
if gone in done and not response_started and work not in done:
# A pre-response wallet or billing operation may have accepted
# funds already. Let it reach its own settlement before closing.
done, _ = await asyncio.wait(
(work,),
timeout=max(0, lifetime.deadline - asyncio.get_running_loop().time()),
)
if work in done:
try:
await work
except DownstreamTerminated:
if not disconnected.is_set():
raise
logger.debug("Client disconnected before response completed")
elif not disconnected.is_set():
timed_out = True
finally:
lifetime.stopped = True
for task in (receiver, gone, work):
task.cancel()
# Detached stream finalizers own settlement. The heartbeat stops
# with the request; durable expiry recovers any abandoned row.
done, pending = await asyncio.wait(
(receiver, gone, work), timeout=settings.request_cleanup_timeout_seconds
)
for task in done:
if not task.cancelled():
task.exception()
for task in pending:
task.cancel()
task.add_done_callback(
lambda t: t.exception() if not t.cancelled() else None
)
request_lifetime.reset(token)
if timed_out and work.done() and not disconnected.is_set() and not response_started:
async with asyncio.timeout(settings.downstream_send_timeout_seconds):
await send({"type": "http.response.start", "status": 504, "headers": []})
await send(
{"type": "http.response.body", "body": b"Request deadline exceeded"}
)
+5
View File
@@ -45,6 +45,7 @@ from .exceptions import (
http_exception_handler, http_exception_handler,
validation_exception_handler, validation_exception_handler,
) )
from .lifecycle import RequestLifecycleMiddleware
from .logging import get_logger, setup_logging from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware from .middleware import LoggingMiddleware
from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401 from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401
@@ -315,6 +316,10 @@ app.add_middleware(
# Add logging middleware # Add logging middleware
app.add_middleware(LoggingMiddleware) app.add_middleware(LoggingMiddleware)
# Outermost: observe the actual downstream connection, not middleware streams.
app.add_middleware(RequestLifecycleMiddleware)
# Add exception handlers # Add exception handlers
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler)
+162 -29
View File
@@ -1,7 +1,7 @@
import time import time
import uuid import uuid
from contextvars import ContextVar from contextvars import ContextVar
from typing import Callable from typing import AsyncIterator, Callable
from urllib.parse import urlsplit from urllib.parse import urlsplit
from fastapi import Request, Response from fastapi import Request, Response
@@ -9,6 +9,7 @@ from starlette.datastructures import Headers
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from .logging import get_logger from .logging import get_logger
from .settings import settings
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -86,11 +87,18 @@ _SKIP_LOG_EXACT: frozenset[str] = frozenset(
) )
def _should_log(method: str, path: str) -> bool: def _should_log(method: str, path: str, status_code: int | None = None) -> bool:
if method in _SKIP_LOG_METHODS: if method in _SKIP_LOG_METHODS:
return False return False
# Our own faults are never noise, whatever the path.
if status_code is not None and status_code >= 500:
return True
if path in _SKIP_LOG_EXACT: if path in _SKIP_LOG_EXACT:
return False # A 4xx storm on a UI-polled path is exactly what we need to see.
return status_code is not None and status_code >= 400
# Client errors on the skipped prefixes stay hidden: 404s under /_next/ are
# driven by whoever scans the node, and the admin UI's timer-driven polling
# turns one expired session into a 401 per poll.
return not any(path.startswith(prefix) for prefix in _SKIP_LOG_PREFIXES) return not any(path.startswith(prefix) for prefix in _SKIP_LOG_PREFIXES)
@@ -103,12 +111,116 @@ def _attribution(request: Request) -> dict[str, object]:
} }
def mark(request: Request, name: str) -> None:
"""Record that stage ``name`` finished, for the completion log's timings."""
marks = getattr(request.state, "stage_marks", None)
if marks is not None:
marks[name] = time.monotonic()
def _request_content_length(headers: Headers) -> int | None:
"""Client-supplied length, dropped unless it is a plausible byte count."""
raw = headers.get("content-length")
if raw is None:
return None
try:
value = int(raw)
except ValueError:
return None
return value if value >= 0 else None
class LoggingMiddleware(BaseHTTPMiddleware): class LoggingMiddleware(BaseHTTPMiddleware):
"""Middleware to log proxy interactions and page navigation. """Middleware to log proxy interactions and page navigation.
Skips logging for static assets and Next.js chunks to avoid noise. Skips logging for static assets and Next.js chunks to avoid noise.
""" """
def _log_completion(
self,
*,
request: Request,
request_id: str,
path: str,
status_code: int,
duration: float,
headers_duration: float | None,
stage_start: float,
stage_marks: dict[str, float],
incoming_logged: bool,
) -> None:
if not _should_log(request.method, path, status_code):
return
extra: dict[str, object] = {
"request_id": request_id,
"method": request.method,
"path": path,
"status_code": status_code,
"duration_ms": round(duration * 1000, 2),
"content_length": _request_content_length(request.headers),
**_attribution(request),
}
if headers_duration is not None:
extra["time_to_headers_ms"] = round(headers_duration * 1000, 2)
if not incoming_logged:
# Tells log consumers that join on request_id why the matching
# "Incoming request" record is missing.
extra["incoming_suppressed"] = True
for name, marked_at in stage_marks.items():
extra[f"{name}_ms"] = round((marked_at - stage_start) * 1000, 2)
if status_code >= 400:
error_detail = getattr(request.state, "error_detail", None)
if isinstance(error_detail, dict):
extra["error_type"] = error_detail.get("error_type")
extra["error_code"] = error_detail.get("error_code")
extra["error_message"] = error_detail.get("error_message")
log = (
logger.warning
if duration > settings.slow_request_warn_seconds
else logger.info
)
log("Request completed", extra=extra)
async def _timed_body(
self,
body_iterator: AsyncIterator[bytes],
*,
request: Request,
request_id: str,
client_app: str,
path: str,
status_code: int,
stage_start: float,
stage_marks: dict[str, float],
headers_duration: float,
incoming_logged: bool,
) -> AsyncIterator[bytes]:
try:
async for chunk in body_iterator:
yield chunk
finally:
duration = time.monotonic() - stage_start
# dispatch() has already reset both context vars by now, and the
# logging filters read request_id/client_app from them.
request_token = request_id_context.set(request_id)
app_token = client_app_context.set(client_app)
try:
self._log_completion(
request=request,
request_id=request_id,
path=path,
status_code=status_code,
duration=duration,
headers_duration=headers_duration,
stage_start=stage_start,
stage_marks=stage_marks,
incoming_logged=incoming_logged,
)
finally:
request_id_context.reset(request_token)
client_app_context.reset(app_token)
async def dispatch(self, request: Request, call_next: Callable) -> Response: async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Generate request ID # Generate request ID
request_id = str(uuid.uuid4()) request_id = str(uuid.uuid4())
@@ -117,15 +229,17 @@ class LoggingMiddleware(BaseHTTPMiddleware):
# Set request ID in context for logging # Set request ID in context for logging
token = request_id_context.set(request_id) token = request_id_context.set(request_id)
client_app_token = client_app_context.set( client_app = client_app_from_headers(request.headers)
client_app_from_headers(request.headers) client_app_token = client_app_context.set(client_app)
)
path = request.url.path path = request.url.path
should_log = _should_log(request.method, path) should_log = _should_log(request.method, path)
# Start timing # Start timing. Monotonic throughout: a wall-clock step would otherwise
start_time = time.time() # produce negative durations and bogus slow-request warnings.
stage_start = time.monotonic()
stage_marks: dict[str, float] = {}
request.state.stage_marks = stage_marks
if should_log: if should_log:
logger.info( logger.info(
@@ -144,34 +258,52 @@ class LoggingMiddleware(BaseHTTPMiddleware):
try: try:
response = await call_next(request) response = await call_next(request)
if should_log: headers_duration = time.monotonic() - stage_start
duration = time.time() - start_time
extra: dict[str, object] = {
"request_id": request_id,
"method": request.method,
"path": path,
"status_code": response.status_code,
"duration_ms": round(duration * 1000, 2),
**_attribution(request),
}
if response.status_code >= 400:
error_detail = getattr(request.state, "error_detail", None)
if isinstance(error_detail, dict):
extra["error_type"] = error_detail.get("error_type")
extra["error_code"] = error_detail.get("error_code")
extra["error_message"] = error_detail.get("error_message")
logger.info(
"Request completed",
extra=extra,
)
if hasattr(response, "headers"): if hasattr(response, "headers"):
response.headers["x-routstr-request-id"] = request_id response.headers["x-routstr-request-id"] = request_id
# Headers are already on the wire before a streamed body ends,
# so this can only ever be time-to-headers.
response.headers["x-routstr-duration-ms"] = str(
round(headers_duration * 1000, 2)
)
body_iterator = getattr(response, "body_iterator", None)
if body_iterator is None:
self._log_completion(
request=request,
request_id=request_id,
path=path,
status_code=response.status_code,
duration=headers_duration,
headers_duration=None,
stage_start=stage_start,
stage_marks=stage_marks,
incoming_logged=should_log,
)
return response
# A StreamingResponse is barely started here: most of the time a
# slow completion spends in the node is spent relaying its body, so
# the completion log has to wait for the iterator to drain.
response.body_iterator = self._timed_body(
body_iterator,
request=request,
request_id=request_id,
client_app=client_app,
path=path,
status_code=response.status_code,
stage_start=stage_start,
stage_marks=stage_marks,
headers_duration=headers_duration,
incoming_logged=should_log,
)
return response return response
except Exception as e: except Exception as e:
# Always log failures, even for skipped paths, so we don't lose errors. # Always log failures, even for skipped paths, so we don't lose errors.
duration = time.time() - start_time duration = time.monotonic() - stage_start
logger.error( logger.error(
"Request failed", "Request failed",
extra={ extra={
@@ -196,5 +328,6 @@ __all__ = [
"LoggingMiddleware", "LoggingMiddleware",
"UNKNOWN_CLIENT_APP", "UNKNOWN_CLIENT_APP",
"client_app_context", "client_app_context",
"mark",
"request_id_context", "request_id_context",
] ]
+13
View File
@@ -117,6 +117,16 @@ class Settings(BaseSettings):
default=604_800, env="DEAD_KEY_MIN_AGE_SECONDS" default=604_800, env="DEAD_KEY_MIN_AGE_SECONDS"
) )
max_request_lifetime_seconds: float = Field(
default=1800, gt=0, env="MAX_REQUEST_LIFETIME_SECONDS"
)
downstream_send_timeout_seconds: float = Field(
default=60, gt=0, env="DOWNSTREAM_SEND_TIMEOUT_SECONDS"
)
request_cleanup_timeout_seconds: float = Field(
default=30, gt=0, env="REQUEST_CLEANUP_TIMEOUT_SECONDS"
)
# Network # Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS") cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
# Comma-separated METHOD:path pairs adding to the proxy's canonical # Comma-separated METHOD:path pairs adding to the proxy's canonical
@@ -197,6 +207,9 @@ class Settings(BaseSettings):
# Logging # Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL") log_level: str = Field(default="INFO", env="LOG_LEVEL")
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING") enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
slow_request_warn_seconds: float = Field(
default=60.0, gt=0, env="SLOW_REQUEST_WARN_SECONDS"
)
# Other # Other
chat_completions_api_version: str = Field( chat_completions_api_version: str = Field(
+4 -1
View File
@@ -28,6 +28,7 @@ from .core.error_scope import (
UPSTREAM_UNAVAILABLE, UPSTREAM_UNAVAILABLE,
) )
from .core.exceptions import UpstreamError from .core.exceptions import UpstreamError
from .core.middleware import mark
from .core.not_found import build_not_found_response from .core.not_found import build_not_found_response
from .core.settings import settings from .core.settings import settings
from .payment.helpers import ( from .payment.helpers import (
@@ -474,6 +475,7 @@ async def proxy(request: Request, path: str) -> Response | StreamingResponse:
request_body = await _read_bounded_body(request) request_body = await _read_bounded_body(request)
if isinstance(request_body, Response): if isinstance(request_body, Response):
return request_body return request_body
mark(request, "body_read")
async with create_session() as session: async with create_session() as session:
try: try:
@@ -777,6 +779,7 @@ async def _proxy(
key = await get_bearer_token_key( key = await get_bearer_token_key(
headers, path, session, auth, max_cost_for_model, model_id headers, path, session, auth, max_cost_for_model, model_id
) )
mark(request, "auth")
else: else:
if request.method not in ["GET"]: if request.method not in ["GET"]:
@@ -1012,7 +1015,7 @@ async def _proxy(
already_stripped.add(bad_param) already_stripped.add(bad_param)
logger.warning( logger.warning(
"Upstream %s rejected param '%s' for model=%s; " "Upstream %s rejected param '%s' for model=%s; "
"stripping and retrying same upstream", "correcting and retrying same upstream",
upstream.provider_type, upstream.provider_type,
bad_param, bad_param,
model_id, model_id,
+52 -9
View File
@@ -214,6 +214,20 @@ def _responses_usage_payload(data_json: dict) -> dict:
return nested if isinstance(nested, dict) else data_json return nested if isinstance(nested, dict) else data_json
def _reported_provider(payload: dict) -> str | None:
"""Provider named by an upstream payload, if any.
Checked at top level first, then inside the Anthropic ``message`` and
Responses ``response`` envelopes, which is where those dialects nest it.
"""
for obj in (payload, payload.get("message"), payload.get("response")):
if isinstance(obj, dict):
value = obj.get("provider")
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _render_sse_event(field_lines: list[str], data: str) -> str: def _render_sse_event(field_lines: list[str], data: str) -> str:
"""Re-frame one parsed event, re-prefixing every line of a multi-line data.""" """Re-frame one parsed event, re-prefixing every line of a multi-line data."""
body = "".join(f"{line}\n" for line in field_lines) body = "".join(f"{line}\n" for line in field_lines)
@@ -485,8 +499,7 @@ class BaseUpstreamProvider:
return return
response_json["provider_url"] = public_provider_url(self.base_url) response_json["provider_url"] = public_provider_url(self.base_url)
provider_type = (self.provider_type or "").strip() provider_type = (self.provider_type or "").strip()
existing = response_json.get("provider") existing_str = _reported_provider(response_json) or ""
existing_str = existing.strip() if isinstance(existing, str) else ""
if not existing_str: if not existing_str:
response_json["provider"] = provider_type response_json["provider"] = provider_type
return return
@@ -498,6 +511,17 @@ class BaseUpstreamProvider:
return return
response_json["provider"] = f"{provider_type}:{existing_str}" response_json["provider"] = f"{provider_type}:{existing_str}"
def _stamp_streamed_provider(
self, payload: dict, carried: str | None
) -> str | None:
"""Stamp a streamed payload, falling back to a provider an earlier event
reported. Returns the provider to carry forward to later payloads."""
reported = _reported_provider(payload)
if reported is None and carried is not None:
payload["provider"] = carried
self._apply_provider_field(payload)
return reported or carried
def _log_full_refund( def _log_full_refund(
self, self,
*, *,
@@ -1169,6 +1193,7 @@ class BaseUpstreamProvider:
usage_finalized = False usage_finalized = False
last_model_seen: str | None = None last_model_seen: str | None = None
provider_seen: str | None = None
async def finalize_db_only() -> None: async def finalize_db_only() -> None:
nonlocal usage_finalized nonlocal usage_finalized
@@ -1243,6 +1268,7 @@ class BaseUpstreamProvider:
end of stream. end of stream.
""" """
nonlocal last_model_seen, usage_chunk_data, done_seen, stream_id nonlocal last_model_seen, usage_chunk_data, done_seen, stream_id
nonlocal provider_seen
event = raw_event.strip(b"\r\n") event = raw_event.strip(b"\r\n")
if not event: if not event:
@@ -1282,7 +1308,7 @@ class BaseUpstreamProvider:
if isinstance(obj, dict): if isinstance(obj, dict):
usage_estimator.observe(obj) usage_estimator.observe(obj)
self._apply_provider_field(obj) provider_seen = self._stamp_streamed_provider(obj, provider_seen)
if obj.get("model"): if obj.get("model"):
last_model_seen = str(obj.get("model")) last_model_seen = str(obj.get("model"))
if requested_model: if requested_model:
@@ -1408,6 +1434,7 @@ class BaseUpstreamProvider:
if legacy_completion if legacy_completion
else "chat.completion.chunk", else "chat.completion.chunk",
"model": last_model_seen or "unknown", "model": last_model_seen or "unknown",
"provider": provider_seen,
"choices": [], "choices": [],
"usage": { "usage": {
"prompt_tokens": cost_data.get("input_tokens", 0), "prompt_tokens": cost_data.get("input_tokens", 0),
@@ -1652,6 +1679,7 @@ class BaseUpstreamProvider:
usage_finalized = False usage_finalized = False
last_model_seen: str | None = None last_model_seen: str | None = None
provider_seen: str | None = None
async def finalize_db_only() -> None: async def finalize_db_only() -> None:
nonlocal usage_finalized nonlocal usage_finalized
@@ -1715,7 +1743,7 @@ class BaseUpstreamProvider:
and preserves ``event:``/``id:`` fields attached to their data and preserves ``event:``/``id:`` fields attached to their data
line so Responses API event framing stays intact. line so Responses API event framing stays intact.
""" """
nonlocal last_model_seen, usage_chunk_data, done_seen nonlocal last_model_seen, usage_chunk_data, done_seen, provider_seen
nonlocal reasoning_tokens nonlocal reasoning_tokens
event = raw_event.strip(b"\r\n") event = raw_event.strip(b"\r\n")
@@ -1751,7 +1779,7 @@ class BaseUpstreamProvider:
obj = json_codec.loads(data) obj = json_codec.loads(data)
if isinstance(obj, dict): if isinstance(obj, dict):
self._apply_provider_field(obj) provider_seen = self._stamp_streamed_provider(obj, provider_seen)
if obj.get("model"): if obj.get("model"):
last_model_seen = str(obj.get("model")) last_model_seen = str(obj.get("model"))
if requested_model: if requested_model:
@@ -1840,6 +1868,7 @@ class BaseUpstreamProvider:
if usage_chunk_data is None: if usage_chunk_data is None:
usage_chunk_data = { usage_chunk_data = {
"type": "response.completed", "type": "response.completed",
"provider": provider_seen,
"response": { "response": {
"model": last_model_seen or "unknown", "model": last_model_seen or "unknown",
"usage": { "usage": {
@@ -2195,6 +2224,7 @@ class BaseUpstreamProvider:
usage_estimator = MissingUsageEstimator(request_body, model_obj) usage_estimator = MissingUsageEstimator(request_body, model_obj)
usage_finalized = False usage_finalized = False
last_model_seen: str | None = None last_model_seen: str | None = None
provider_seen: str | None = None
async def finalize_without_usage() -> bytes | None: async def finalize_without_usage() -> bytes | None:
nonlocal usage_finalized nonlocal usage_finalized
@@ -2244,7 +2274,7 @@ class BaseUpstreamProvider:
async def stream_with_cost( async def stream_with_cost(
max_cost_for_model: int, max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]: ) -> AsyncGenerator[bytes, None]:
nonlocal usage_finalized, last_model_seen nonlocal usage_finalized, last_model_seen, provider_seen
stored_chunks: list[bytes] = [] stored_chunks: list[bytes] = []
input_tokens: int = 0 input_tokens: int = 0
output_tokens: int = 0 output_tokens: int = 0
@@ -2301,7 +2331,9 @@ class BaseUpstreamProvider:
last_model_seen = str(msg.get("model")) last_model_seen = str(msg.get("model"))
provider_added = "provider" not in data provider_added = "provider" not in data
self._apply_provider_field(data) provider_seen = self._stamp_streamed_provider(
data, provider_seen
)
if requested_model: if requested_model:
# Apply requested_model override # Apply requested_model override
@@ -2419,6 +2451,7 @@ class BaseUpstreamProvider:
try: try:
combined_data = { combined_data = {
"model": last_model_seen or "unknown", "model": last_model_seen or "unknown",
"provider": provider_seen,
"usage": usage_data, "usage": usage_data,
} }
cost_data = await adjust_payment_for_tokens( cost_data = await adjust_payment_for_tokens(
@@ -2579,6 +2612,11 @@ class BaseUpstreamProvider:
) -> dict: ) -> dict:
return await messages_dispatch.aggregate_anthropic_events_to_message(iterator) return await messages_dispatch.aggregate_anthropic_events_to_message(iterator)
def transform_messages_stream(
self, stream: AsyncIterator[Any]
) -> AsyncIterator[Any]:
return stream
def adapt_messages_request(self, body: dict, model_obj: Model) -> str: def adapt_messages_request(self, body: dict, model_obj: Model) -> str:
"""Rewrite an allowlisted /v1/messages body for this upstream. """Rewrite an allowlisted /v1/messages body for this upstream.
@@ -2604,6 +2642,7 @@ class BaseUpstreamProvider:
provider_prefix=self.get_litellm_provider_prefix(), provider_prefix=self.get_litellm_provider_prefix(),
transform_model_name=self.transform_model_name, transform_model_name=self.transform_model_name,
adapt_request=lambda body: self.adapt_messages_request(body, model_obj), adapt_request=lambda body: self.adapt_messages_request(body, model_obj),
transform_stream=self.transform_messages_stream,
log_extra=log_extra, log_extra=log_extra,
) )
@@ -4197,6 +4236,7 @@ class BaseUpstreamProvider:
}, },
) )
provider_seen: str | None = None
for i, line in enumerate(lines): for i, line in enumerate(lines):
if line.startswith("data: "): if line.startswith("data: "):
try: try:
@@ -4204,7 +4244,9 @@ class BaseUpstreamProvider:
if not isinstance(data_json, dict): if not isinstance(data_json, dict):
continue continue
provider_before = data_json.get("provider") provider_before = data_json.get("provider")
self._apply_provider_field(data_json) provider_seen = self._stamp_streamed_provider(
data_json, provider_seen
)
changed = data_json.get("provider") != provider_before changed = data_json.get("provider") != provider_before
if cost_data and "usage" in data_json and data_json["usage"]: if cost_data and "usage" in data_json and data_json["usage"]:
_inject_cost_into_usage(data_json, cost_data) _inject_cost_into_usage(data_json, cost_data)
@@ -5265,6 +5307,7 @@ class BaseUpstreamProvider:
}, },
) )
provider_seen: str | None = None
for i, (fields, data) in enumerate(events): for i, (fields, data) in enumerate(events):
if data.strip() == "[DONE]": if data.strip() == "[DONE]":
continue continue
@@ -5275,7 +5318,7 @@ class BaseUpstreamProvider:
if not isinstance(data_json, dict): if not isinstance(data_json, dict):
continue continue
provider_before = data_json.get("provider") provider_before = data_json.get("provider")
self._apply_provider_field(data_json) provider_seen = self._stamp_streamed_provider(data_json, provider_seen)
changed = data_json.get("provider") != provider_before changed = data_json.get("provider") != provider_before
payload = _responses_usage_payload(data_json) payload = _responses_usage_payload(data_json)
if cost_data and isinstance(payload.get("usage"), dict): if cost_data and isinstance(payload.get("usage"), dict):
+2 -3
View File
@@ -5,7 +5,7 @@ from urllib.parse import urlparse
import httpx import httpx
from .base import BaseUpstreamProvider from .base import BaseUpstreamProvider, _reported_provider
from .model_paths import public_provider_url from .model_paths import public_provider_url
from .pricing_resolver import ( from .pricing_resolver import (
FallbackPricingResolver, FallbackPricingResolver,
@@ -60,8 +60,7 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
""" """
if not isinstance(response_json, dict): if not isinstance(response_json, dict):
return return
existing = response_json.get("provider") if _reported_provider(response_json) is None:
if not (isinstance(existing, str) and existing.strip()):
response_json["provider"] = ( response_json["provider"] = (
urlparse(public_provider_url(self.base_url)).hostname urlparse(public_provider_url(self.base_url)).hostname
or self.upstream_name or self.upstream_name
+80 -9
View File
@@ -36,6 +36,12 @@ from .reasoning_effort import adapt_messages_body_for_litellm
logger = get_logger(__name__) logger = get_logger(__name__)
# Sent in place of a blank upstream key. LiteLLM treats ``""`` as missing and
# falls back to the provider's env var (e.g. ``OPENAI_API_KEY``), failing with
# an AuthenticationError for keyless upstreams such as self-hosted
# OpenAI-compatible servers, which the chat path reaches without auth.
KEYLESS_UPSTREAM_API_KEY = "no-key"
# Anthropic-Messages-only fields that don't translate to OpenAI # Anthropic-Messages-only fields that don't translate to OpenAI
# Chat Completions. ``litellm.drop_params`` only filters *known* # Chat Completions. ``litellm.drop_params`` only filters *known*
# unsupported params; these newer/extension fields get passed through # unsupported params; these newer/extension fields get passed through
@@ -78,6 +84,34 @@ ALLOWED_MESSAGES_REQUEST_FIELDS: frozenset[str] = frozenset(
) )
def prune_blank_system_blocks(body: dict) -> None:
"""Drop whitespace-only ``system`` text.
Anthropic accepts a blank system prompt; OpenAI-compatible upstreams
reject it with ``text content blocks must contain non-whitespace text``.
"""
system = body.get("system")
if isinstance(system, str):
if not system.strip():
body.pop("system", None)
return
if not isinstance(system, list):
return
kept = [
block
for block in system
if not (
isinstance(block, dict)
and block.get("type") == "text"
and not str(block.get("text") or "").strip()
)
]
if kept:
body["system"] = kept
else:
body.pop("system", None)
def coerce_litellm_payload(payload: object) -> dict: def coerce_litellm_payload(payload: object) -> dict:
"""Convert a litellm event into a plain dict. """Convert a litellm event into a plain dict.
@@ -372,16 +406,9 @@ def annotate_event(event: dict, requested_model: str | None) -> AnnotatedEvent:
_coerce_float(root_cost_details.get("output_cost")), _coerce_float(root_cost_details.get("output_cost")),
) )
event_type = str(event.get("type") or "")
payload = json.dumps(event)
if event_type:
sse_bytes = f"event: {event_type}\ndata: {payload}\n\n".encode()
else:
sse_bytes = f"data: {payload}\n\n".encode()
return AnnotatedEvent( return AnnotatedEvent(
event, event,
sse_bytes, encode_sse(event),
in_tokens, in_tokens,
out_tokens, out_tokens,
cache_read_tokens, cache_read_tokens,
@@ -393,6 +420,14 @@ def annotate_event(event: dict, requested_model: str | None) -> AnnotatedEvent:
) )
def encode_sse(event: dict) -> bytes:
event_type = str(event.get("type") or "")
payload = json.dumps(event)
if event_type:
return f"event: {event_type}\ndata: {payload}\n\n".encode()
return f"data: {payload}\n\n".encode()
async def stream_annotated_events( async def stream_annotated_events(
iterator: AsyncIterator[Any], iterator: AsyncIterator[Any],
requested_model: str | None, requested_model: str | None,
@@ -459,6 +494,7 @@ async def dispatch_anthropic_messages(
provider_prefix: str, provider_prefix: str,
transform_model_name: Callable[[str], str], transform_model_name: Callable[[str], str],
adapt_request: Callable[[dict], str] | None = None, adapt_request: Callable[[dict], str] | None = None,
transform_stream: Callable[[AsyncIterator[Any]], AsyncIterator[Any]] | None = None,
log_extra: dict[str, Any] | None = None, log_extra: dict[str, Any] | None = None,
) -> tuple[bool, Any, str | None]: ) -> tuple[bool, Any, str | None]:
"""Call ``litellm.anthropic.messages.acreate`` and return """Call ``litellm.anthropic.messages.acreate`` and return
@@ -471,6 +507,10 @@ async def dispatch_anthropic_messages(
may rewrite it in place and returns a suffix for the upstream model name, may rewrite it in place and returns a suffix for the upstream model name,
which is how a provider expresses a feature litellm would otherwise which is how a provider expresses a feature litellm would otherwise
translate into a parameter the upstream rejects. translate into a parameter the upstream rejects.
``transform_stream`` rewrites the upstream event stream before it is
aggregated or handed to the client, so a provider can repair events
litellm translates faithfully but clients cannot use.
""" """
if not request_body: if not request_body:
raise UpstreamError("Missing request body for /v1/messages", status_code=400) raise UpstreamError("Missing request body for /v1/messages", status_code=400)
@@ -505,8 +545,36 @@ async def dispatch_anthropic_messages(
) )
body = {k: v for k, v in body.items() if k in ALLOWED_MESSAGES_REQUEST_FIELDS} body = {k: v for k, v in body.items() if k in ALLOWED_MESSAGES_REQUEST_FIELDS}
prune_blank_system_blocks(body)
model_suffix = adapt_request(body) if adapt_request else "" model_suffix = adapt_request(body) if adapt_request else ""
# LiteLLM turns Anthropic's server-side web_search tool into the OpenAI
# `web_search_options` parameter. Generic OpenAI-compatible chat endpoints
# (including those serving Claude through a proxy) may reject that field.
# Only a provider with an explicit adaptation (e.g. Venice's model suffix)
# can preserve search semantics; do not silently remove the tool and return
# an answer that never searched. Native /v1/messages providers bypass this
# dispatcher and receive the original tool unchanged.
tools = body.get("tools")
if provider_prefix == "openai/" and isinstance(tools, list) and any(
isinstance(tool, dict)
and (
(
isinstance(tool.get("type"), str)
and tool["type"].startswith("web_search")
)
or tool.get("name") == "web_search"
)
for tool in tools
):
raise UpstreamError(
"This upstream does not support Anthropic web search through "
"OpenAI-compatible /v1/messages translation",
status_code=400,
code="UNSUPPORTED_WEB_SEARCH",
)
# Convention: `model.id` is the canonical upstream model name; # Convention: `model.id` is the canonical upstream model name;
# `forwarded_model_id` is the public alias the internal API exposes # `forwarded_model_id` is the public alias the internal API exposes
# and echoes back to the client. # and echoes back to the client.
@@ -519,7 +587,7 @@ async def dispatch_anthropic_messages(
kwargs: dict = { kwargs: dict = {
"model": litellm_model, "model": litellm_model,
"api_base": base_url, "api_base": base_url,
"api_key": api_key, "api_key": api_key or KEYLESS_UPSTREAM_API_KEY,
"stream": upstream_stream, "stream": upstream_stream,
**body, **body,
} }
@@ -578,6 +646,9 @@ async def dispatch_anthropic_messages(
from_upstream_response=True, from_upstream_response=True,
) from exc ) from exc
if transform_stream is not None and hasattr(result, "__aiter__"):
result = transform_stream(cast(AsyncIterator[Any], result))
if not client_stream and hasattr(result, "__aiter__"): if not client_stream and hasattr(result, "__aiter__"):
# Client asked for a non-streaming response but we always stream # Client asked for a non-streaming response but we always stream
# from upstream — drain the events into a single Anthropic Message # from upstream — drain the events into a single Anthropic Message
+39
View File
@@ -1,11 +1,23 @@
import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config
from litellm.llms.openai.chat.o_series_transformation import OpenAIOSeriesConfig
from ..payment.models import Model, async_fetch_openrouter_models from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider from .base import BaseUpstreamProvider
if TYPE_CHECKING: if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow from ..core.db import UpstreamProviderRow
_O_SERIES = OpenAIOSeriesConfig()
def _rejects_max_tokens(model: str) -> bool:
return OpenAIGPT5Config.is_model_gpt_5_model(
model
) or _O_SERIES.is_model_o_series_model(model)
class OpenAIUpstreamProvider(BaseUpstreamProvider): class OpenAIUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for OpenAI API.""" """Upstream provider specifically configured for OpenAI API."""
@@ -42,6 +54,33 @@ class OpenAIUpstreamProvider(BaseUpstreamProvider):
"""Strip 'openai/' prefix for OpenAI API compatibility.""" """Strip 'openai/' prefix for OpenAI API compatibility."""
return model_id.removeprefix("openai/") return model_id.removeprefix("openai/")
def prepare_request_body(
self,
body: bytes | None,
model_obj: Model,
include_stream_usage: bool = False,
) -> bytes | None:
body = super().prepare_request_body(body, model_obj, include_stream_usage)
if not body:
return body
try:
data = json.loads(body)
except ValueError:
return body
# Reasoning models 400 on max_tokens; renaming up front saves the
# reject-and-retry round trip. Names litellm doesn't know yet still
# fall through to request_correction's reactive rename.
if (
isinstance(data, dict)
and "messages" in data
and "max_tokens" in data
and "max_completion_tokens" not in data
and _rejects_max_tokens(self.transform_model_name(model_obj.id))
):
data["max_completion_tokens"] = data.pop("max_tokens")
return json.dumps(data).encode()
return body
async def fetch_models(self) -> list[Model]: async def fetch_models(self) -> list[Model]:
"""Fetch OpenAI models from OpenRouter API filtered by openai source.""" """Fetch OpenAI models from OpenRouter API filtered by openai source."""
models_data = await async_fetch_openrouter_models(source_filter="openai") models_data = await async_fetch_openrouter_models(source_filter="openai")
+33 -5
View File
@@ -2,13 +2,27 @@ from typing import TYPE_CHECKING
import httpx import httpx
from ..core.logging import get_logger
from ..payment.models import Model, async_fetch_openrouter_models from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider from .base import BaseUpstreamProvider, _reported_provider
from .model_paths import public_provider_url from .model_paths import public_provider_url
if TYPE_CHECKING: if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow from ..core.db import UpstreamProviderRow
logger = get_logger(__name__)
_UNKNOWN_SUB_PROVIDER = "unknown"
def _carries_usage(payload: dict) -> bool:
"""Whether a payload holds usage, at top level or in the Anthropic
``message`` / Responses ``response`` envelope."""
return any(
isinstance(obj, dict) and isinstance(obj.get("usage"), dict)
for obj in (payload, payload.get("message"), payload.get("response"))
)
class OpenRouterUpstreamProvider(BaseUpstreamProvider): class OpenRouterUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for OpenRouter API.""" """Upstream provider specifically configured for OpenRouter API."""
@@ -27,7 +41,8 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
- Real upstream sub-provider (e.g. ``"GMICloud"``) -> ``"openrouter:GMICloud"``. - Real upstream sub-provider (e.g. ``"GMICloud"``) -> ``"openrouter:GMICloud"``.
- Missing sub-provider, or one that merely echoes ``"openrouter"`` -> - Missing sub-provider, or one that merely echoes ``"openrouter"`` ->
``"unknown"``. ``"openrouter:unknown"``: the router is still known even when the
serving provider is not (e.g. the Responses API never reports it).
- Idempotent: re-stamping never produces ``"openrouter:openrouter:..."``; - Idempotent: re-stamping never produces ``"openrouter:openrouter:..."``;
the ``openrouter:`` prefix appears at most once. the ``openrouter:`` prefix appears at most once.
""" """
@@ -35,15 +50,28 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
return return
response_json["provider_url"] = public_provider_url(self.base_url) response_json["provider_url"] = public_provider_url(self.base_url)
provider_type = (self.provider_type or "").strip() provider_type = (self.provider_type or "").strip()
existing = response_json.get("provider") sub = _reported_provider(response_json) or ""
sub = existing.strip() if isinstance(existing, str) else ""
# Strip any already-applied "openrouter:" prefixes (idempotency). # Strip any already-applied "openrouter:" prefixes (idempotency).
prefix = f"{provider_type}:" prefix = f"{provider_type}:"
while sub.lower().startswith(prefix.lower()): while sub.lower().startswith(prefix.lower()):
sub = sub[len(prefix) :].strip() sub = sub[len(prefix) :].strip()
# Already stamped as unknown on an earlier pass; keep it without
# warning again.
if sub.lower() == _UNKNOWN_SUB_PROVIDER:
response_json["provider"] = f"{provider_type}:{_UNKNOWN_SUB_PROVIDER}"
return
# No real sub-provider, or it just echoes our own router name. # No real sub-provider, or it just echoes our own router name.
if not sub or sub.lower() == provider_type.lower(): if not sub or sub.lower() == provider_type.lower():
response_json["provider"] = "unknown" # Warn only on the billed payload, not on every stream chunk.
if _carries_usage(response_json):
logger.warning(
"OpenRouter did not report the serving provider",
extra={
"model": response_json.get("model"),
"response_id": response_json.get("id"),
},
)
response_json["provider"] = f"{provider_type}:{_UNKNOWN_SUB_PROVIDER}"
return return
response_json["provider"] = f"{provider_type}:{sub}" response_json["provider"] = f"{provider_type}:{sub}"
+63 -1
View File
@@ -43,6 +43,18 @@ _UNSUPPORTED_PARAM_RE = re.compile(
) )
# Matches upstream error text that rejects a param and names its replacement,
# e.g. OpenAI's "Unsupported parameter: 'max_tokens' is not supported with this
# model. Use 'max_completion_tokens' instead." Both names must be quoted so a
# free-form hint like "use gpt-4 instead" never reads as a rename.
_RENAMED_PARAM_RE = re.compile(
r"[`'\"](?P<param>[a-zA-Z_][a-zA-Z0-9_]*)[`'\"]\s+is\s+"
r"(?:deprecated|not\s+supported|unsupported|no\s+longer\s+supported)\b"
r".*?\buse\s+[`'\"](?P<replacement>[a-zA-Z_][a-zA-Z0-9_]*)[`'\"]\s+instead",
re.IGNORECASE | re.DOTALL,
)
# A corrector inspects the parsed request body and the upstream error message # A corrector inspects the parsed request body and the upstream error message
# and returns ``(new_body_dict, label)`` for a fix it can apply, or ``None`` to # and returns ``(new_body_dict, label)`` for a fix it can apply, or ``None`` to
# decline. ``label`` identifies the fix so it is applied at most once per request. # decline. ``label`` identifies the fix so it is applied at most once per request.
@@ -100,6 +112,51 @@ _SPEND_SHAPING_PARAMS = frozenset(
} }
) )
# Output caps are interchangeable spellings of the same limit, so moving the
# value from one to another keeps the priced bound intact.
_OUTPUT_CAP_PARAMS = frozenset(
{
"max_tokens",
"max_completion_tokens",
"max_output_tokens",
"max_tokens_to_sample",
}
)
def rename_unsupported_param(body: dict, error_message: str) -> tuple[dict, str] | None:
"""Move a rejected top-level param to the name the upstream asked for.
Returns ``(new_body, label)`` with the value carried over unchanged, or
``None`` when the error names no replacement, the param is absent, or the
replacement is already set.
A spend-shaping field is only renamed to another output cap: that keeps the
reservation's bound, whereas renaming into or out of any other spend-shaping
field could uncap or fan out the retry.
"""
match = _RENAMED_PARAM_RE.search(error_message)
if not match:
return None
param, replacement = match.group("param"), match.group("replacement")
if param == replacement or param not in body or replacement in body:
return None
param_spend = param.lower() in _SPEND_SHAPING_PARAMS
replacement_spend = replacement.lower() in _SPEND_SHAPING_PARAMS
if (param_spend or replacement_spend) and not (
param.lower() in _OUTPUT_CAP_PARAMS
and replacement.lower() in _OUTPUT_CAP_PARAMS
):
logger.warning(
"Upstream asked to rename '%s' to '%s'; refusing because it would "
"change the request's spend bound — surfacing the error",
param,
replacement,
)
return None
new_body = {(replacement if k == param else k): v for k, v in body.items()}
return new_body, f"{param}->{replacement}"
def strip_unsupported_param(body: dict, error_message: str) -> tuple[dict, str] | None: def strip_unsupported_param(body: dict, error_message: str) -> tuple[dict, str] | None:
"""Drop a top-level param the upstream named as unsupported/deprecated. """Drop a top-level param the upstream named as unsupported/deprecated.
@@ -130,7 +187,12 @@ def strip_unsupported_param(body: dict, error_message: str) -> tuple[dict, str]
# Ordered pipeline of correctors tried on each recoverable rejection. # Ordered pipeline of correctors tried on each recoverable rejection.
DEFAULT_CORRECTORS: tuple[Corrector, ...] = (strip_unsupported_param,) # Renaming runs first so a param with a named replacement keeps its value
# instead of being dropped.
DEFAULT_CORRECTORS: tuple[Corrector, ...] = (
rename_unsupported_param,
strip_unsupported_param,
)
def correct_request( def correct_request(
+6 -1
View File
@@ -10,6 +10,7 @@ from fastapi.responses import StreamingResponse
from starlette.types import Receive, Scope, Send from starlette.types import Receive, Scope, Send
from ..core import get_logger from ..core import get_logger
from ..core.settings import settings
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -74,10 +75,14 @@ class PersistentStreamFinalizer:
self._task: asyncio.Future[None] | None = None self._task: asyncio.Future[None] | None = None
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def _bounded_finalize(self) -> None:
async with asyncio.timeout(settings.request_cleanup_timeout_seconds):
await self._finalize()
async def run(self) -> None: async def run(self) -> None:
async with self._lock: async with self._lock:
if self._task is None: if self._task is None:
self._task = asyncio.ensure_future(self._finalize()) self._task = asyncio.ensure_future(self._bounded_finalize())
task = self._task task = self._task
await asyncio.shield(task) await asyncio.shield(task)
+109 -1
View File
@@ -1,13 +1,16 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from collections.abc import AsyncGenerator, AsyncIterator
from typing import TYPE_CHECKING, Any, cast
import httpx import httpx
from ..core.exceptions import UpstreamError from ..core.exceptions import UpstreamError
from ..core.logging import get_logger from ..core.logging import get_logger
from ..payment.models import Architecture, Model, Pricing, TopProvider from ..payment.models import Architecture, Model, Pricing, TopProvider
from . import messages_dispatch
from .base import BaseUpstreamProvider from .base import BaseUpstreamProvider
from .stream_ownership import aclose_if_needed
if TYPE_CHECKING: if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow from ..core.db import UpstreamProviderRow
@@ -50,6 +53,73 @@ _UNENFORCEABLE_WEB_SEARCH_KEYS = frozenset(
{"allowed_domains", "blocked_domains", "user_location"} {"allowed_domains", "blocked_domains", "user_location"}
) )
# Venice streams OpenAI reasoning models' encrypted reasoning as a trailing
# ``reasoning_content`` delta carrying this marker. litellm turns it into a
# plaintext ``thinking`` block after the answer, which clients render as
# gibberish and which makes Claude Code report an empty final result.
_ENCRYPTED_REASONING_MARKER = "__ENCRYPTED_REASONING__"
async def _drop_encrypted_reasoning(
upstream: AsyncIterator[Any],
) -> AsyncGenerator[bytes, None]:
"""A thinking block's start carries no text, so it is held until its first
delta shows whether it is the encrypted payload; later indices shift down
to close the gap."""
encode = messages_dispatch.encode_sse
sse_buffer = b""
dropped: set[int] = set()
held: list[dict] | None = None
held_index: int | None = None
def shift(event: dict) -> dict:
index = event.get("index")
if not isinstance(index, int):
return event
gap = sum(1 for d in dropped if d < index)
return {**event, "index": index - gap} if gap else event
try:
async for chunk in upstream:
events, sse_buffer = messages_dispatch.events_from_chunk(chunk, sse_buffer)
for event in events:
etype = event.get("type")
index = event.get("index")
if held is not None:
delta = event.get("delta") or {}
is_own_delta = (
index == held_index and etype == "content_block_delta"
)
thinking = str(delta.get("thinking") or "")
if is_own_delta and thinking.startswith(
_ENCRYPTED_REASONING_MARKER
):
dropped.add(cast(int, index))
held = None
continue
if is_own_delta and not thinking:
held.append(event)
continue
for pending in held:
yield encode(shift(pending))
held = None
if index in dropped:
continue
block = event.get("content_block") or {}
if (
etype == "content_block_start"
and block.get("type") == "thinking"
and not block.get("thinking")
):
held, held_index = [event], index
continue
yield encode(shift(event))
if held is not None:
for pending in held:
yield encode(shift(pending))
finally:
await aclose_if_needed(upstream)
def _is_web_search_tool(tool: Any) -> bool: def _is_web_search_tool(tool: Any) -> bool:
"""An Anthropic server-side web-search tool, by either of its markers. """An Anthropic server-side web-search tool, by either of its markers.
@@ -66,6 +136,35 @@ def _is_web_search_tool(tool: Any) -> bool:
) or tool.get("name") == "web_search" ) or tool.get("name") == "web_search"
def _merge_cache_marked_system(body: dict) -> None:
"""Venice rejects an OpenAI ``system`` message with two or more text parts
when any part carries ``cache_control`` (``400 system: text content blocks
must contain non-whitespace text``), even though every part is non-blank.
Claude Code always sends that shape. A single marked block is accepted and
still caches, so the prefix stays cacheable under the last marker.
"""
system = body.get("system")
if not isinstance(system, list) or len(system) < 2:
return
if not all(
isinstance(block, dict)
and block.get("type") == "text"
and isinstance(block.get("text"), str)
for block in system
):
return
markers = [block["cache_control"] for block in system if block.get("cache_control")]
if not markers:
return
body["system"] = [
{
"type": "text",
"text": "\n\n".join(block["text"] for block in system),
"cache_control": markers[-1],
}
]
def _usd(entry: Any) -> float | None: def _usd(entry: Any) -> float | None:
"""Read the USD leg of a Venice ``{usd, diem}`` price pair.""" """Read the USD leg of a Venice ``{usd, diem}`` price pair."""
if isinstance(entry, dict): if isinstance(entry, dict):
@@ -114,7 +213,16 @@ class VeniceUpstreamProvider(BaseUpstreamProvider):
def transform_model_name(self, model_id: str) -> str: def transform_model_name(self, model_id: str) -> str:
return model_id.removeprefix("venice/") return model_id.removeprefix("venice/")
def transform_messages_stream(
self, stream: AsyncIterator[Any]
) -> AsyncIterator[Any]:
return _drop_encrypted_reasoning(stream)
def adapt_messages_request(self, body: dict, model_obj: Model) -> str: def adapt_messages_request(self, body: dict, model_obj: Model) -> str:
_merge_cache_marked_system(body)
return self._adapt_web_search(body)
def _adapt_web_search(self, body: dict) -> str:
"""Trade an Anthropic web-search tool for Venice's own search switch. """Trade an Anthropic web-search tool for Venice's own search switch.
Left in the body, litellm's Anthropic adapter rewrites the tool into a Left in the body, litellm's Anthropic adapter rewrites the tool into a
+21
View File
@@ -91,3 +91,24 @@ def test_detect_litellm_prefix_custom_default() -> None:
assert detect_litellm_prefix("https://example.com", default="anthropic/") == ( assert detect_litellm_prefix("https://example.com", default="anthropic/") == (
"anthropic/" "anthropic/"
) )
@pytest.mark.parametrize("model", ["gpt-6", "gpt-6-luna", "gpt-5.5"])
def test_litellm_sends_max_completion_tokens_for_gpt_5_and_later(model: str) -> None:
"""OpenAI rejects ``max_tokens`` on these models; litellm <1.101 only
rewrote it for names containing ``gpt-5``, so gpt-6 got a 400."""
import litellm
from litellm.utils import ProviderConfigManager
config = ProviderConfigManager.get_provider_chat_config(
model=model, provider=litellm.LlmProviders.OPENAI
)
assert config is not None
mapped = config.map_openai_params(
non_default_params={"max_tokens": 10},
optional_params={},
model=model,
drop_params=True,
)
assert mapped == {"max_completion_tokens": 10}
@@ -23,6 +23,9 @@ from routstr.core.db import ApiKey # noqa: E402
from routstr.payment.cost_calculation import CostData # noqa: E402 from routstr.payment.cost_calculation import CostData # noqa: E402
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402 from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
from routstr.upstream.base import BaseUpstreamProvider # noqa: E402 from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
from routstr.upstream.messages_dispatch import ( # noqa: E402
prune_blank_system_blocks,
)
from routstr.wallet import MintConnectionError, TokenConsumedError # noqa: E402 from routstr.wallet import MintConnectionError, TokenConsumedError # noqa: E402
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -113,6 +116,39 @@ def _make_request(request_id: str | None = "req-test") -> Any:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_prune_blank_system_blocks_drops_blank_blocks() -> None:
body = {
"system": [
{"type": "text", "text": " \n"},
{"type": "text", "text": "real prompt"},
]
}
prune_blank_system_blocks(body)
assert body["system"] == [{"type": "text", "text": "real prompt"}]
def test_prune_blank_system_blocks_drops_key_when_all_blank() -> None:
body = {"system": [{"type": "text", "text": "\n"}], "max_tokens": 8}
prune_blank_system_blocks(body)
assert body == {"max_tokens": 8}
def test_prune_blank_system_blocks_handles_string_system() -> None:
blank = {"system": " "}
prune_blank_system_blocks(blank)
assert blank == {}
kept = {"system": "be brief"}
prune_blank_system_blocks(kept)
assert kept == {"system": "be brief"}
def test_prune_blank_system_blocks_keeps_non_text_blocks() -> None:
body = {"system": [{"type": "image", "source": {}}]}
prune_blank_system_blocks(body)
assert body["system"] == [{"type": "image", "source": {}}]
def test_coerce_litellm_payload_handles_dict() -> None: def test_coerce_litellm_payload_handles_dict() -> None:
out = BaseUpstreamProvider._coerce_litellm_payload({"a": 1}) out = BaseUpstreamProvider._coerce_litellm_payload({"a": 1})
assert out == {"a": 1} assert out == {"a": 1}
@@ -1755,3 +1791,31 @@ async def test_x_cashu_zero_value_rejected_not_forwarded(
assert body["error"]["code"] == "cashu_token_zero_value" assert body["error"]["code"] == "cashu_token_zero_value"
# Spent-to-zero token must not be echoed back for retry. # Spent-to-zero token must not be echoed back for retry.
assert "X-Cashu" not in response.headers assert "X-Cashu" not in response.headers
@pytest.mark.asyncio
async def test_dispatch_passes_placeholder_key_for_keyless_upstream() -> None:
"""A blank upstream key must not reach litellm, which would fall back to
OPENAI_API_KEY and fail with an AuthenticationError."""
provider = BaseUpstreamProvider(base_url="http://localhost:8000/v1", api_key="")
captured_kwargs: dict[str, Any] = {}
async def fake_acreate(**kwargs: Any) -> AsyncIterator[dict]:
captured_kwargs.update(kwargs)
async def no_events() -> AsyncIterator[dict]:
return
yield
return no_events()
with patch(
"litellm.anthropic.messages.acreate",
new=AsyncMock(side_effect=fake_acreate),
):
await provider._dispatch_anthropic_messages(
request_body=_anthropic_request_body(stream=True),
model_obj=_make_model(),
)
assert captured_kwargs["api_key"] == "no-key"
+69
View File
@@ -706,6 +706,75 @@ async def test_pinned_recovery_preserves_routing_fields(
fallback.forward_request.assert_not_awaited() fallback.forward_request.assert_not_awaited()
_OPENAI_MAX_TOKENS_ERROR = json.dumps(
{
"error": {
"message": "Unsupported parameter: 'max_tokens' is not supported "
"with this model. Use 'max_completion_tokens' instead.",
"type": "invalid_request_error",
"param": "max_tokens",
"code": "unsupported_parameter",
}
}
).encode()
@pytest.mark.asyncio
@pytest.mark.parametrize("pinned", [False, True])
async def test_rejected_max_tokens_is_renamed_and_retried_on_same_upstream(
pinned: bool,
) -> None:
selected, fallback = _make_upstream(1), _make_upstream(2)
selected.forward_request = AsyncMock(
side_effect=[
MagicMock(status_code=400, body=_OPENAI_MAX_TOKENS_ERROR),
MagicMock(status_code=200, body=b"{}"),
]
)
headers = {"authorization": "Bearer key"}
if pinned:
headers["x-routstr-model-path"] = encode_model_path(selected.base_url, MODEL_ID)
request = _make_request(
headers,
json.dumps(
{"model": MODEL_ID, "max_tokens": 300, "messages": [], "stream": True}
).encode(),
)
response = await _run_proxy(
request, [(MagicMock(), selected), (MagicMock(), fallback)]
)
assert response.status_code == 200
assert selected.forward_request.await_count == 2
before, after = [
json.loads(call.args[3]) for call in selected.forward_request.await_args_list
]
assert before["max_tokens"] == 300 and "max_completion_tokens" not in before
assert after["max_completion_tokens"] == 300 and "max_tokens" not in after
assert {k: v for k, v in after.items() if k != "max_completion_tokens"} == {
k: v for k, v in before.items() if k != "max_tokens"
}
fallback.forward_request.assert_not_awaited()
@pytest.mark.asyncio
async def test_rename_that_changes_spend_bound_is_not_retried() -> None:
selected = _make_upstream(1, 400)
selected.forward_request.return_value.body = json.dumps(
{"error": {"message": "'max_tokens' is not supported. Use 'n' instead."}}
).encode()
request = _make_request(
{"authorization": "Bearer key"},
json.dumps({"model": MODEL_ID, "max_tokens": 300}).encode(),
)
response = await _run_proxy(request, [(MagicMock(), selected)])
assert response.status_code == 400
selected.forward_request.assert_awaited_once()
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Upstream 5xx -> 424 + UPSTREAM_UNAVAILABLE + scope header; node faults stay 500. # Upstream 5xx -> 424 + UPSTREAM_UNAVAILABLE + scope header; node faults stay 500.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+90
View File
@@ -0,0 +1,90 @@
"""OpenAI reasoning models get ``max_completion_tokens`` before the request is sent."""
from __future__ import annotations
import json
import os
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to")
import pytest
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream import GenericUpstreamProvider
from routstr.upstream.openai import OpenAIUpstreamProvider
def _model(model_id: str) -> Model:
return Model(
id=model_id,
name="test",
created=0,
description="",
context_length=128000,
architecture=Architecture(
modality="text->text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="x",
instruct_type=None,
),
pricing=Pricing(prompt=0.0, completion=0.0),
)
def _chat(model_id: str, **fields: object) -> bytes:
return json.dumps(
{"model": model_id, "messages": [{"role": "user", "content": "hi"}], **fields}
).encode()
def _prepare(provider: object, model_id: str, body: bytes) -> dict:
out = provider.prepare_request_body(body, _model(model_id)) # type: ignore[attr-defined]
assert out is not None
return json.loads(out)
@pytest.mark.parametrize(
"model_id", ["gpt-5.6-sol", "openai/gpt-6-sol", "openai/gpt-5", "o3", "o4-mini"]
)
def test_reasoning_model_max_tokens_is_renamed(model_id: str) -> None:
provider = OpenAIUpstreamProvider(api_key="k")
data = _prepare(provider, model_id, _chat(model_id, max_tokens=300))
assert data["max_completion_tokens"] == 300
assert "max_tokens" not in data
@pytest.mark.parametrize("model_id", ["gpt-4o", "openai/gpt-4.1"])
def test_non_reasoning_model_keeps_max_tokens(model_id: str) -> None:
provider = OpenAIUpstreamProvider(api_key="k")
data = _prepare(provider, model_id, _chat(model_id, max_tokens=300))
assert data["max_tokens"] == 300
assert "max_completion_tokens" not in data
def test_both_caps_set_is_left_for_upstream() -> None:
provider = OpenAIUpstreamProvider(api_key="k")
data = _prepare(
provider,
"gpt-5.6-sol",
_chat("gpt-5.6-sol", max_tokens=300, max_completion_tokens=200),
)
assert data["max_tokens"] == 300
assert data["max_completion_tokens"] == 200
def test_non_chat_body_is_untouched() -> None:
provider = OpenAIUpstreamProvider(api_key="k")
body = json.dumps({"model": "gpt-5.6-sol", "input": "hi", "max_tokens": 5}).encode()
data = _prepare(provider, "gpt-5.6-sol", body)
assert data["max_tokens"] == 5
assert "max_completion_tokens" not in data
def test_other_upstreams_keep_max_tokens() -> None:
provider = GenericUpstreamProvider(base_url="http://test", api_key="k")
data = _prepare(provider, "gpt-5.6-sol", _chat("gpt-5.6-sol", max_tokens=300))
assert data["max_tokens"] == 300
assert "max_completion_tokens" not in data
+63 -6
View File
@@ -1,3 +1,5 @@
from unittest.mock import patch
from routstr.upstream.anthropic import AnthropicUpstreamProvider from routstr.upstream.anthropic import AnthropicUpstreamProvider
from routstr.upstream.base import BaseUpstreamProvider from routstr.upstream.base import BaseUpstreamProvider
from routstr.upstream.generic import GenericUpstreamProvider from routstr.upstream.generic import GenericUpstreamProvider
@@ -33,12 +35,12 @@ def test_apply_provider_field_openrouter_passthrough() -> None:
def test_apply_provider_field_openrouter_no_upstream_provider() -> None: def test_apply_provider_field_openrouter_no_upstream_provider() -> None:
"""If OpenRouter omits the provider field, the real serving provider is """If OpenRouter omits the provider field, the serving provider is
unknown — a bare ``openrouter`` value carries no information.""" unknown but the router is not."""
p = _make_provider(OpenRouterUpstreamProvider, "openrouter") p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"id": "gen-abc"} data: dict = {"id": "gen-abc"}
p._apply_provider_field(data) p._apply_provider_field(data)
assert data["provider"] == "unknown" assert data["provider"] == "openrouter:unknown"
def test_apply_provider_field_openrouter_echoes_router_name() -> None: def test_apply_provider_field_openrouter_echoes_router_name() -> None:
@@ -46,7 +48,35 @@ def test_apply_provider_field_openrouter_echoes_router_name() -> None:
p = _make_provider(OpenRouterUpstreamProvider, "openrouter") p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"provider": "openrouter"} data: dict = {"provider": "openrouter"}
p._apply_provider_field(data) p._apply_provider_field(data)
assert data["provider"] == "unknown" assert data["provider"] == "openrouter:unknown"
def test_apply_provider_field_openrouter_unknown_is_idempotent() -> None:
"""Re-stamping an unknown payload (e.g. in inject_cost_metadata) keeps
``openrouter:unknown`` instead of reading ``unknown`` as a sub-provider."""
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"id": "gen-abc"}
p._apply_provider_field(data)
p._apply_provider_field(data)
assert data["provider"] == "openrouter:unknown"
def test_apply_provider_field_openrouter_warns_once_on_billed_payload() -> None:
"""A missing provider is logged on the payload carrying usage, not on
every stream chunk or on a re-stamp."""
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
chunk: dict = {"type": "response.output_text.delta", "delta": "hi"}
completed: dict = {
"type": "response.completed",
"response": {"id": "gen-abc", "usage": {"input_tokens": 1}},
}
with patch("routstr.upstream.openrouter.logger.warning") as warning:
p._apply_provider_field(chunk)
p._apply_provider_field(completed)
p._apply_provider_field(completed)
warning.assert_called_once()
assert chunk["provider"] == completed["provider"] == "openrouter:unknown"
def test_apply_provider_field_openrouter_idempotent_no_double_prefix() -> None: def test_apply_provider_field_openrouter_idempotent_no_double_prefix() -> None:
@@ -79,14 +109,41 @@ def test_apply_provider_field_blank_upstream_treated_as_missing() -> None:
p = _make_provider(OpenRouterUpstreamProvider, "openrouter") p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"provider": " "} data: dict = {"provider": " "}
p._apply_provider_field(data) p._apply_provider_field(data)
assert data["provider"] == "unknown" assert data["provider"] == "openrouter:unknown"
def test_apply_provider_field_non_string_upstream_treated_as_missing() -> None: def test_apply_provider_field_non_string_upstream_treated_as_missing() -> None:
p = _make_provider(OpenRouterUpstreamProvider, "openrouter") p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
data: dict = {"provider": 42} data: dict = {"provider": 42}
p._apply_provider_field(data) p._apply_provider_field(data)
assert data["provider"] == "unknown" assert data["provider"] == "openrouter:unknown"
def test_apply_provider_field_openrouter_reads_nested_envelopes() -> None:
"""Anthropic ``message`` and Responses ``response`` envelopes nest the
upstream provider; it must not be reported as unknown."""
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
message_start: dict = {
"type": "message_start",
"message": {"provider": "Anthropic"},
}
p._apply_provider_field(message_start)
assert message_start["provider"] == "openrouter:Anthropic"
created: dict = {"type": "response.created", "response": {"provider": "OpenAI"}}
p._apply_provider_field(created)
assert created["provider"] == "openrouter:OpenAI"
def test_stamp_streamed_provider_carries_earlier_provider() -> None:
"""Events without their own provider inherit the one reported earlier in
the stream instead of becoming ``unknown``."""
p = _make_provider(OpenRouterUpstreamProvider, "openrouter")
first: dict = {"provider": "Fireworks"}
carried = p._stamp_streamed_provider(first, None)
delta: dict = {"type": "content_block_delta"}
assert p._stamp_streamed_provider(delta, carried) == "Fireworks"
assert first["provider"] == delta["provider"] == "openrouter:Fireworks"
def test_apply_provider_field_idempotent_for_direct_upstream() -> None: def test_apply_provider_field_idempotent_for_direct_upstream() -> None:
+190
View File
@@ -16,9 +16,15 @@ from routstr.upstream.request_correction import (
Correction, Correction,
correct_request, correct_request,
extract_error_message, extract_error_message,
rename_unsupported_param,
strip_unsupported_param, strip_unsupported_param,
) )
OPENAI_MAX_TOKENS_ERROR = (
"Unsupported parameter: 'max_tokens' is not supported with this model. "
"Use 'max_completion_tokens' instead."
)
def _body(**kwargs: object) -> bytes: def _body(**kwargs: object) -> bytes:
return json.dumps(kwargs).encode() return json.dumps(kwargs).encode()
@@ -139,6 +145,190 @@ class TestStripUnsupportedParam:
assert strip_unsupported_param(body, "`Max_Tokens` is deprecated") is None assert strip_unsupported_param(body, "`Max_Tokens` is deprecated") is None
class TestRenameUnsupportedParam:
def test_renames_max_tokens_for_openai_reasoning_models(self) -> None:
body = {"model": "gpt-5.6-sol", "max_tokens": 256, "messages": []}
result = rename_unsupported_param(body, OPENAI_MAX_TOKENS_ERROR)
assert result is not None
new_body, label = result
assert label == "max_tokens->max_completion_tokens"
assert new_body == {
"model": "gpt-5.6-sol",
"max_completion_tokens": 256,
"messages": [],
}
def test_preserves_key_order(self) -> None:
body = {"model": "m", "max_tokens": 1, "stream": True}
result = rename_unsupported_param(body, OPENAI_MAX_TOKENS_ERROR)
assert result is not None
assert list(result[0]) == ["model", "max_completion_tokens", "stream"]
def test_does_not_mutate_input(self) -> None:
body = {"model": "m", "max_tokens": 8}
assert rename_unsupported_param(body, OPENAI_MAX_TOKENS_ERROR) is not None
assert body == {"model": "m", "max_tokens": 8}
def test_renames_between_any_output_caps(self) -> None:
caps = (
"max_tokens",
"max_completion_tokens",
"max_output_tokens",
"max_tokens_to_sample",
)
for param in caps:
for replacement in caps:
if param == replacement:
continue
message = f"`{param}` is deprecated. Use `{replacement}` instead."
result = rename_unsupported_param({param: 7}, message)
assert result == ({replacement: 7}, f"{param}->{replacement}"), (
param,
replacement,
)
def test_renames_non_spend_param(self) -> None:
message = "'functions' is deprecated. Use 'tools' instead."
result = rename_unsupported_param({"functions": [{"name": "f"}]}, message)
assert result == ({"tools": [{"name": "f"}]}, "functions->tools")
def test_matches_across_quote_styles_case_and_newlines(self) -> None:
for message in (
'Unsupported parameter: "max_tokens" is not supported.\nUse '
'"max_completion_tokens" instead.',
"`max_tokens` IS UNSUPPORTED here; please USE `max_completion_tokens`"
" INSTEAD",
"'max_tokens' is no longer supported, use 'max_completion_tokens' instead",
):
result = rename_unsupported_param({"max_tokens": 3}, message)
assert result is not None, message
assert result[0] == {"max_completion_tokens": 3}
def test_refuses_renames_that_change_the_spend_bound(self) -> None:
for param, replacement in (
("max_tokens", "n"),
("n", "best_of"),
("best_of", "n"),
("temperature", "max_tokens"),
("max_tokens", "temperature"),
("n", "max_tokens"),
):
message = f"'{param}' is not supported. Use '{replacement}' instead."
assert rename_unsupported_param({param: 2}, message) is None, (
param,
replacement,
)
def test_spend_guard_is_case_insensitive(self) -> None:
ok = "'Max_Tokens' is not supported. Use 'MAX_COMPLETION_TOKENS' instead."
assert rename_unsupported_param({"Max_Tokens": 4}, ok) == (
{"MAX_COMPLETION_TOKENS": 4},
"Max_Tokens->MAX_COMPLETION_TOKENS",
)
bad = "'Max_Tokens' is not supported. Use 'N' instead."
assert rename_unsupported_param({"Max_Tokens": 4}, bad) is None
def test_declines_when_replacement_already_present(self) -> None:
body = {"max_tokens": 4, "max_completion_tokens": 8}
assert rename_unsupported_param(body, OPENAI_MAX_TOKENS_ERROR) is None
def test_declines_when_param_absent(self) -> None:
assert rename_unsupported_param({"model": "m"}, OPENAI_MAX_TOKENS_ERROR) is None
def test_declines_self_rename(self) -> None:
message = "'max_tokens' is deprecated. Use 'max_tokens' instead."
assert rename_unsupported_param({"max_tokens": 1}, message) is None
def test_declines_unquoted_or_missing_replacement(self) -> None:
for message in (
"`gpt-3` is deprecated, use gpt-4 instead",
"'max_tokens' is not supported, use max_completion_tokens instead",
"'max_tokens' is not supported with this model.",
"Use 'max_completion_tokens' instead.",
):
assert rename_unsupported_param({"max_tokens": 1}, message) is None, message
def test_declines_nested_only_param(self) -> None:
body = {"reasoning": {"max_tokens": 5}}
assert rename_unsupported_param(body, OPENAI_MAX_TOKENS_ERROR) is None
class TestCorrectRequestRename:
def test_openai_max_tokens_error_is_renamed_not_refused(self) -> None:
body = _body(model="gpt-5.6-sol", max_tokens=512, messages=[])
result = correct_request(body, OPENAI_MAX_TOKENS_ERROR, set())
assert isinstance(result, Correction)
assert result.label == "max_tokens->max_completion_tokens"
decoded = json.loads(result.body)
assert "max_tokens" not in decoded
assert decoded["max_completion_tokens"] == 512
def test_rename_wins_over_strip_for_non_spend_param(self) -> None:
body = _body(model="m", functions=[1])
result = correct_request(
body, "'functions' is deprecated. Use 'tools' instead.", set()
)
assert result is not None
assert json.loads(result.body) == {"model": "m", "tools": [1]}
def test_unsafe_rename_of_cap_still_surfaces_error(self) -> None:
body = _body(model="m", max_tokens=5)
assert (
correct_request(
body, "'max_tokens' is not supported. Use 'n' instead.", set()
)
is None
)
def test_applied_rename_does_not_repeat_or_strip_cap(self) -> None:
body = _body(model="m", max_tokens=5)
applied = {"max_tokens->max_completion_tokens"}
assert correct_request(body, OPENAI_MAX_TOKENS_ERROR, applied) is None
def test_rename_ping_pong_terminates(self) -> None:
"""An upstream that flip-flops between names cannot loop forever."""
forward = OPENAI_MAX_TOKENS_ERROR
backward = "'max_completion_tokens' is not supported. Use 'max_tokens' instead."
body = _body(model="m", max_tokens=5)
applied: set[str] = set()
for attempt in range(10):
message = forward if attempt % 2 == 0 else backward
result = correct_request(body, message, applied)
if result is None:
break
body, applied = result.body, applied | {result.label}
else:
raise AssertionError("correction loop did not terminate")
assert applied == {
"max_tokens->max_completion_tokens",
"max_completion_tokens->max_tokens",
}
assert json.loads(body) == {"model": "m", "max_tokens": 5}
def test_buffered_openai_error_response_is_renamed(self) -> None:
resp = Response(
content=json.dumps(
{
"error": {
"message": OPENAI_MAX_TOKENS_ERROR,
"type": "invalid_request_error",
"param": "max_tokens",
"code": "unsupported_parameter",
}
}
).encode(),
status_code=400,
)
body = _body(model="gpt-5.6-sol", max_tokens=64, stream=True)
result = correct_request(body, extract_error_message(resp), set())
assert result is not None
assert json.loads(result.body) == {
"model": "gpt-5.6-sol",
"max_completion_tokens": 64,
"stream": True,
}
class TestExtractErrorMessage: class TestExtractErrorMessage:
def test_extracts_nested_error_message(self) -> None: def test_extracts_nested_error_message(self) -> None:
resp = Response( resp = Response(
+285
View File
@@ -0,0 +1,285 @@
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from unittest.mock import patch
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.types import Message, Receive, Scope, Send
import routstr.core.db as db_module
from routstr.auth import (
ReservationSnapshot,
_claim_reservation_for_charge,
_stop_reservation_heartbeat,
pay_for_request,
)
from routstr.core.db import ApiKey, ReservationRelease
from routstr.core.lifecycle import RequestLifecycleMiddleware
from routstr.core.middleware import LoggingMiddleware
from routstr.core.settings import settings
@pytest.mark.asyncio
@pytest.mark.parametrize("reason", ["disconnect", "deadline", "send"])
async def test_lifecycle_stops_live_work(reason: str) -> None:
closed = asyncio.Event()
receive_queue: asyncio.Queue[Message] = asyncio.Queue()
await receive_queue.put({"type": "http.request", "body": b"", "more_body": False})
sent: list[Message] = []
async def app(scope: Scope, receive: Receive, send: Send) -> None:
try:
assert (await receive())["type"] == "http.request"
await send({"type": "http.response.start", "status": 200, "headers": []})
while True:
await send(
{"type": "http.response.body", "body": b"x", "more_body": True}
)
await asyncio.sleep(0.01)
finally:
closed.set()
async def send(message: Message) -> None:
sent.append(message)
if reason == "send" and message["type"] == "http.response.body":
await asyncio.sleep(100)
async def disconnect() -> None:
await asyncio.sleep(0.02)
await receive_queue.put({"type": "http.disconnect"})
task = asyncio.create_task(disconnect()) if reason == "disconnect" else None
with (
patch.object(settings, "max_request_lifetime_seconds", 0.08),
patch.object(settings, "downstream_send_timeout_seconds", 0.03),
patch.object(settings, "request_cleanup_timeout_seconds", 0.1),
):
try:
await asyncio.wait_for(
RequestLifecycleMiddleware(app)(
{"type": "http"}, receive_queue.get, send
),
1,
)
except TimeoutError:
assert reason == "send"
if task:
await task
assert closed.is_set()
assert sent
@pytest.mark.asyncio
async def test_unrelated_oserror_still_propagates() -> None:
receive_queue: asyncio.Queue[Message] = asyncio.Queue()
await receive_queue.put({"type": "http.request", "body": b"", "more_body": False})
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await receive()
raise OSError("Connection reset by peer")
async def send(message: Message) -> None:
pass
with pytest.raises(OSError, match="Connection reset by peer"):
await asyncio.wait_for(
RequestLifecycleMiddleware(app)({"type": "http"}, receive_queue.get, send),
1,
)
@pytest.mark.asyncio
async def test_disconnect_before_headers_preserves_wallet_work() -> None:
receive_queue: asyncio.Queue[Message] = asyncio.Queue()
await receive_queue.put({"type": "http.request", "body": b"", "more_body": False})
entered = asyncio.Event()
finish_wallet = asyncio.Event()
wallet_credited = asyncio.Event()
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await receive()
entered.set()
await finish_wallet.wait() # The mint accepted the token; credit is still pending.
wallet_credited.set()
await send({"type": "http.response.start", "status": 200, "headers": []})
run = asyncio.create_task(
RequestLifecycleMiddleware(app)(
{"type": "http"}, receive_queue.get, lambda message: asyncio.sleep(0)
)
)
await asyncio.wait_for(entered.wait(), 1)
await receive_queue.put({"type": "http.disconnect"})
await asyncio.sleep(0.02)
assert not run.done()
finish_wallet.set()
await asyncio.wait_for(run, 1) # No propagated exception for an expected disconnect.
assert wallet_credited.is_set()
@pytest.mark.asyncio
async def test_disconnect_before_headers_with_logging_middleware() -> None:
receive_queue: asyncio.Queue[Message] = asyncio.Queue()
await receive_queue.put({"type": "http.request", "body": b"", "more_body": False})
entered = asyncio.Event()
finish_wallet = asyncio.Event()
wallet_credited = asyncio.Event()
async def wallet(request: Request) -> PlainTextResponse:
await request.body()
entered.set()
await finish_wallet.wait()
wallet_credited.set()
return PlainTextResponse("settled")
app = RequestLifecycleMiddleware(
LoggingMiddleware(Starlette(routes=[Route("/wallet", wallet, methods=["POST"])]))
)
scope: Scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.4"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/wallet",
"raw_path": b"/wallet",
"root_path": "",
"query_string": b"",
"headers": [],
"client": ("test", 1234),
"server": ("test", 80),
}
async def send(message: Message) -> None:
pass
run = asyncio.create_task(app(scope, receive_queue.get, send))
try:
await asyncio.wait_for(entered.wait(), 1)
await receive_queue.put({"type": "http.disconnect"})
await asyncio.sleep(0.02)
assert not run.done()
finish_wallet.set()
await asyncio.wait_for(run, 1) # No propagated exception for an expected disconnect.
assert wallet_credited.is_set()
finally:
finish_wallet.set()
if not run.done():
run.cancel()
await asyncio.gather(run, return_exceptions=True)
@pytest.mark.asyncio
async def test_deadline_cancels_app_before_sending_504() -> None:
receive_queue: asyncio.Queue[Message] = asyncio.Queue()
await receive_queue.put({"type": "http.request", "body": b"", "more_body": False})
sent: list[Message] = []
app_stopped = asyncio.Event()
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await receive()
try:
await asyncio.sleep(100)
finally:
with pytest.raises(OSError, match="Downstream request terminated"):
await send({"type": "http.response.start", "status": 200, "headers": []})
app_stopped.set()
async def send(message: Message) -> None:
assert app_stopped.is_set()
sent.append(message)
with (
patch.object(settings, "max_request_lifetime_seconds", 0.02),
patch.object(settings, "request_cleanup_timeout_seconds", 0.1),
):
await asyncio.wait_for(
RequestLifecycleMiddleware(app)({"type": "http"}, receive_queue.get, send),
1,
)
assert [message["type"] for message in sent] == [
"http.response.start",
"http.response.body",
]
assert sent[0]["status"] == 504
@pytest.mark.asyncio
async def test_disconnect_does_not_release_before_stream_settles(tmp_path: Path) -> None:
engine = create_async_engine(
f"sqlite+aiosqlite:///{tmp_path / 'reservations.db'}", poolclass=NullPool
)
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
@asynccontextmanager
async def session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine, expire_on_commit=False) as db:
yield db
with patch.object(db_module, "create_session", session):
async with session() as db:
db.add(ApiKey(hashed_key="stream-key", balance=10_000))
await db.commit()
started = asyncio.Event()
finalizer_started = asyncio.Event()
settle = asyncio.Event()
result: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
snapshot: ReservationSnapshot | None = None
receive_queue: asyncio.Queue[Message] = asyncio.Queue()
await receive_queue.put({"type": "http.request", "body": b"", "more_body": False})
async def app(scope: Scope, receive: Receive, send: Send) -> None:
nonlocal snapshot
async with session() as db:
key = await db.get(ApiKey, "stream-key")
assert key is not None
snapshot = await pay_for_request(key, 1000, db)
await receive()
await send({"type": "http.response.start", "status": 200, "headers": []})
started.set()
try:
await asyncio.sleep(100)
finally:
async def finalize() -> None:
assert snapshot is not None
finalizer_started.set()
await settle.wait()
async with session() as db:
claimed = await _claim_reservation_for_charge(snapshot, db)
await db.commit()
await _stop_reservation_heartbeat(snapshot.release_id)
result.set_result(claimed)
asyncio.create_task(finalize())
run = asyncio.create_task(
RequestLifecycleMiddleware(app)(
{"type": "http"}, receive_queue.get, lambda message: asyncio.sleep(0)
)
)
try:
await asyncio.wait_for(started.wait(), 1)
await receive_queue.put({"type": "http.disconnect"})
await asyncio.wait_for(finalizer_started.wait(), 1)
await asyncio.wait_for(run, 1)
settle.set()
assert await asyncio.wait_for(result, 1)
assert snapshot is not None
async with session() as db:
row = await db.get(ReservationRelease, snapshot.release_id)
assert row is not None and row.status == "charged"
finally:
settle.set()
if snapshot is not None:
await _stop_reservation_heartbeat(snapshot.release_id)
await engine.dispose()
+191
View File
@@ -0,0 +1,191 @@
"""Tests for stage timings, the duration header and skipped-path error logging."""
import asyncio
import logging
from collections.abc import AsyncIterator, Iterator
import pytest
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
from routstr.core.middleware import LoggingMiddleware, mark
from routstr.core.settings import settings
class _RecordingHandler(logging.Handler):
def __init__(self) -> None:
super().__init__(level=logging.DEBUG)
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
def completions(self) -> list[logging.LogRecord]:
return [r for r in self.records if r.getMessage() == "Request completed"]
@pytest.fixture
def records() -> Iterator[_RecordingHandler]:
handler = _RecordingHandler()
middleware_logger = logging.getLogger("routstr.core.middleware")
middleware_logger.setLevel(logging.INFO)
original_propagate = middleware_logger.propagate
original_handlers = middleware_logger.handlers
middleware_logger.propagate = False
middleware_logger.handlers = [handler]
try:
yield handler
finally:
middleware_logger.handlers = original_handlers
middleware_logger.propagate = original_propagate
@pytest.fixture
def client() -> Iterator[TestClient]:
app = FastAPI()
app.add_middleware(LoggingMiddleware)
# /v1/wallet/info is in _SKIP_LOG_EXACT, so it exercises the suppression path.
@app.get("/v1/wallet/info")
async def wallet_info(fail: bool = False) -> dict[str, str]:
if fail:
raise HTTPException(status_code=400, detail="spent token")
return {"status": "ok"}
@app.post("/v1/chat/completions")
async def completions(request: Request) -> dict[str, str]:
mark(request, "body_read")
mark(request, "auth")
return {"status": "ok"}
@app.post("/v1/chat/completions/stream")
async def streamed(request: Request) -> StreamingResponse:
mark(request, "body_read")
async def body() -> AsyncIterator[bytes]:
yield b"data: one\n\n"
await asyncio.sleep(0.05)
yield b"data: [DONE]\n\n"
return StreamingResponse(body(), media_type="text/event-stream")
@app.get("/admin/api/boom")
async def boom() -> dict[str, str]:
raise HTTPException(status_code=500, detail="boom")
with TestClient(app, raise_server_exceptions=False) as test_client:
yield test_client
def test_skipped_path_logs_4xx(client: TestClient, records: _RecordingHandler) -> None:
assert client.get("/v1/wallet/info", params={"fail": True}).status_code == 400
completions = records.completions()
assert len(completions) == 1
record = completions[0]
assert record.status_code == 400 # type: ignore[attr-defined]
assert record.path == "/v1/wallet/info" # type: ignore[attr-defined]
assert record.method == "GET" # type: ignore[attr-defined]
assert record.duration_ms >= 0 # type: ignore[attr-defined]
def test_skipped_path_does_not_log_2xx(
client: TestClient, records: _RecordingHandler
) -> None:
assert client.get("/v1/wallet/info").status_code == 200
assert records.completions() == []
def test_duration_header_present(client: TestClient) -> None:
response = client.get("/v1/wallet/info")
assert float(response.headers["x-routstr-duration-ms"]) >= 0
def test_stage_fields_on_completion_log(
client: TestClient, records: _RecordingHandler
) -> None:
response = client.post("/v1/chat/completions", json={"model": "m"})
assert response.status_code == 200
completions = records.completions()
assert len(completions) == 1
record = completions[0]
assert record.body_read_ms >= 0 # type: ignore[attr-defined]
assert record.auth_ms >= record.body_read_ms # type: ignore[attr-defined]
assert record.content_length == int( # type: ignore[attr-defined]
response.request.headers["content-length"]
)
def test_bogus_content_length_is_dropped(
client: TestClient, records: _RecordingHandler
) -> None:
assert (
client.get(
"/v1/wallet/info",
params={"fail": True},
headers={"content-length": "not-a-number"},
).status_code
== 400
)
assert records.completions()[0].content_length is None # type: ignore[attr-defined]
def test_streamed_duration_covers_the_body(
client: TestClient, records: _RecordingHandler
) -> None:
response = client.post("/v1/chat/completions/stream", json={"model": "m"})
assert response.status_code == 200
assert response.text.endswith("data: [DONE]\n\n")
completions = records.completions()
assert len(completions) == 1
record = completions[0]
# The body sleeps 50ms, so a duration that stopped at the headers would be
# well under it.
assert record.duration_ms >= 50 # type: ignore[attr-defined]
assert record.time_to_headers_ms < record.duration_ms # type: ignore[attr-defined]
logged_request_id = record.request_id # type: ignore[attr-defined]
assert logged_request_id == response.headers["x-routstr-request-id"]
assert record.body_read_ms >= 0 # type: ignore[attr-defined]
def test_slow_streamed_request_logs_warning(
client: TestClient, records: _RecordingHandler, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "slow_request_warn_seconds", 0.02)
assert (
client.post("/v1/chat/completions/stream", json={"model": "m"}).status_code
== 200
)
assert records.completions()[0].levelno == logging.WARNING
def test_prefix_skipped_path_still_hides_client_errors(
client: TestClient, records: _RecordingHandler
) -> None:
# /admin/api/* is polled on a timer, so an expired session must not turn
# into one log line per poll; a 500 on the same prefix must still be logged.
assert client.get("/admin/api/balances").status_code == 404
assert records.completions() == []
assert client.get("/admin/api/boom").status_code == 500
assert len(records.completions()) == 1
def test_slow_request_logs_warning(
client: TestClient, records: _RecordingHandler, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "slow_request_warn_seconds", 0.0)
assert client.post("/v1/chat/completions", json={"model": "m"}).status_code == 200
completions = records.completions()
assert len(completions) == 1
assert completions[0].levelno == logging.WARNING
+88 -1
View File
@@ -9,6 +9,7 @@ Covers:
""" """
import asyncio import asyncio
import math
import time import time
from typing import AsyncGenerator from typing import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -69,7 +70,6 @@ async def test_pay_for_request_sets_reserved_at(
payments_info = MagicMock() payments_info = MagicMock()
monkeypatch.setattr(auth_module.logger, "info", logger_info) monkeypatch.setattr(auth_module.logger, "info", logger_info)
monkeypatch.setattr(auth_module.payments_logger, "info", payments_info) monkeypatch.setattr(auth_module.payments_logger, "info", payments_info)
before = int(time.time()) before = int(time.time())
await pay_for_request(key, 1_000, session) await pay_for_request(key, 1_000, session)
@@ -87,6 +87,34 @@ async def test_pay_for_request_sets_reserved_at(
assert payments_info.call_args.args == ("RESERVE",) assert payments_info.call_args.args == ("RESERVE",)
@pytest.mark.asyncio
async def test_pay_for_request_expires_at_has_floor_margin(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""reserved_at_now floors to the second; expires_at must add 1s so a
finalizer finishing exactly at the nominal deadline isn't fenced out."""
key = ApiKey(hashed_key="floorkey", balance=10_000)
session.add(key)
await session.commit()
fixed_time = 1_700_000_000.9 # fractional second, floors when int()'d
monkeypatch.setattr(auth_module.time, "time", lambda: fixed_time)
snapshot = await pay_for_request(key, 1_000, session)
row = await session.get(ReservationRelease, snapshot.release_id)
assert row is not None
expected = (
int(fixed_time)
+ math.ceil(
auth_module.settings.max_request_lifetime_seconds
+ auth_module.settings.request_cleanup_timeout_seconds
)
+ 1
)
assert row.expires_at == expected
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pay_for_request_releases_reservation_when_validation_fails( async def test_pay_for_request_releases_reservation_when_validation_fails(
@@ -428,3 +456,62 @@ async def test_proxy_reverts_reservation_on_client_disconnect() -> None:
await proxy_module.proxy(request, "v1/chat/completions") await proxy_module.proxy(request, "v1/chat/completions")
revert_mock.assert_awaited_once_with(key, session, 1000, reservation_snapshot) revert_mock.assert_awaited_once_with(key, session, 1000, reservation_snapshot)
@pytest.mark.asyncio
async def test_absolute_expiry_releases_fresh_lease(session: AsyncSession) -> None:
now = int(time.time())
key = ApiKey(
hashed_key="expired-deadline",
balance=5000,
reserved_balance=1000,
reserved_at=now,
)
session.add(key)
session.add(
ReservationRelease(
id="expired",
key_hash=key.hashed_key,
billing_key_hash=key.hashed_key,
reserved_msats=1000,
created_at=now,
started_at=now - 100,
expires_at=now - 1,
)
)
await session.commit()
assert await release_stale_reservations(session, 300) == 1
await session.refresh(key)
assert key.reserved_balance == 0
assert key.balance == 5000
@pytest.mark.asyncio
async def test_expired_reservation_cannot_renew_or_claim_charge(
session: AsyncSession,
) -> None:
from routstr.auth import (
ReservationSnapshot,
_claim_reservation_for_charge,
renew_reservation,
)
snapshot = ReservationSnapshot(
release_id="fenced",
key_hash="fenced-key",
billing_key_hash="fenced-key",
reserved_msats=1000,
)
session.add(ApiKey(hashed_key="fenced-key", balance=5000, reserved_balance=1000))
session.add(
ReservationRelease(
id="fenced",
key_hash="fenced-key",
billing_key_hash="fenced-key",
reserved_msats=1000,
expires_at=int(time.time()) - 1,
)
)
await session.commit()
assert not await renew_reservation(snapshot, session)
assert not await _claim_reservation_for_charge(snapshot, session)
@@ -0,0 +1,210 @@
import json
from collections.abc import AsyncIterator
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from routstr.upstream import messages_dispatch
from routstr.upstream.base import BaseUpstreamProvider
from routstr.upstream.venice import VeniceUpstreamProvider, _drop_encrypted_reasoning
from .test_venice_web_search import _model
ENCRYPTED = "__ENCRYPTED_REASONING__id=rs_0b04\ngAAAAABqvDJD"
def _block(index: int, block: dict, deltas: list[dict]) -> list[dict]:
return [
{"type": "content_block_start", "index": index, "content_block": block},
*({"type": "content_block_delta", "index": index, "delta": d} for d in deltas),
{"type": "content_block_stop", "index": index},
]
def _thinking(index: int, text: str) -> list[dict]:
return _block(
index,
{"type": "thinking", "thinking": "", "signature": ""},
[{"type": "thinking_delta", "thinking": text}],
)
def _text(index: int, text: str) -> list[dict]:
return _block(
index,
{"type": "text", "text": ""},
[{"type": "text_delta", "text": text}],
)
def _tool(index: int) -> list[dict]:
return _block(
index,
{"type": "tool_use", "id": "call_1", "name": "Bash", "input": {}},
[{"type": "input_json_delta", "partial_json": '{"command":"ls"}'}],
)
def _message(blocks: list[dict], stop_reason: str = "end_turn") -> list[dict]:
return [
{
"type": "message_start",
"message": {"id": "msg_1", "role": "assistant", "content": []},
},
*blocks,
{"type": "message_delta", "delta": {"stop_reason": stop_reason}},
{"type": "message_stop"},
]
async def _upstream(events: list[dict], *, split: bool = False) -> AsyncIterator[Any]:
payload = b"".join(messages_dispatch.encode_sse(e) for e in events)
if split:
for i in range(0, len(payload), 7):
yield payload[i : i + 7]
else:
yield payload
async def _filtered(events: list[dict], **kwargs: Any) -> list[dict]:
buffer = b""
out: list[dict] = []
async for chunk in _drop_encrypted_reasoning(_upstream(events, **kwargs)):
parsed, buffer = messages_dispatch.events_from_chunk(chunk, buffer)
out.extend(parsed)
return out
def _starts(events: list[dict]) -> list[tuple[int, str]]:
return [
(e["index"], e["content_block"]["type"])
for e in events
if e["type"] == "content_block_start"
]
@pytest.mark.asyncio
async def test_trailing_encrypted_reasoning_is_dropped() -> None:
events = _message([*_text(0, "a.txt contains: hello"), *_thinking(1, ENCRYPTED)])
out = await _filtered(events)
assert _starts(out) == [(0, "text")]
assert all(ENCRYPTED not in json.dumps(e) for e in out)
assert out[-2]["delta"]["stop_reason"] == "end_turn"
@pytest.mark.asyncio
async def test_leading_encrypted_reasoning_closes_index_gap() -> None:
events = _message(
[*_thinking(0, ENCRYPTED), *_text(1, "hi"), *_tool(2)], "tool_use"
)
out = await _filtered(events, split=True)
assert _starts(out) == [(0, "text"), (1, "tool_use")]
assert {e["index"] for e in out if "index" in e} == {0, 1}
@pytest.mark.asyncio
async def test_plaintext_thinking_is_kept_in_order() -> None:
events = _message([*_thinking(0, "Let me list files."), *_tool(1)], "tool_use")
out = await _filtered(events)
assert out == events
@pytest.mark.asyncio
async def test_thinking_start_without_delta_is_flushed() -> None:
events = _message(
[
{
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking", "thinking": "", "signature": ""},
},
{"type": "content_block_stop", "index": 0},
*_text(1, "ok"),
]
)
out = await _filtered(events)
assert out == events
@pytest.mark.asyncio
async def test_aggregated_message_ends_with_answer_text() -> None:
events = _message([*_text(0, "hello"), *_thinking(1, ENCRYPTED)])
message = await messages_dispatch.aggregate_anthropic_events_to_message(
_drop_encrypted_reasoning(_upstream(events))
)
assert [b["type"] for b in message["content"]] == ["text"]
assert message["content"][0]["text"] == "hello"
async def _dispatched_blocks(
provider: BaseUpstreamProvider, *, stream: bool
) -> list[str]:
events = _message([*_text(0, "hello"), *_thinking(1, ENCRYPTED)])
with patch(
"litellm.anthropic.messages.acreate",
new=AsyncMock(return_value=_upstream(events)),
):
_, result, _ = await provider._dispatch_anthropic_messages(
request_body=json.dumps(
{
"model": "x",
"stream": stream,
"max_tokens": 64,
"messages": [{"role": "user", "content": "hi"}],
}
).encode(),
model_obj=_model(),
)
if not stream:
return [b["type"] for b in result["content"]]
buffer = b""
out: list[dict] = []
async for chunk in result:
parsed, buffer = messages_dispatch.events_from_chunk(chunk, buffer)
out.extend(parsed)
return [t for _, t in _starts(out)]
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [True, False])
async def test_venice_dispatch_drops_encrypted_reasoning(stream: bool) -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
assert await _dispatched_blocks(provider, stream=stream) == ["text"]
@pytest.mark.asyncio
async def test_other_providers_keep_thinking_blocks() -> None:
provider = BaseUpstreamProvider(base_url="https://example.com/v1", api_key="k")
assert await _dispatched_blocks(provider, stream=True) == ["text", "thinking"]
@pytest.mark.asyncio
async def test_closing_the_filter_closes_upstream() -> None:
closed = False
async def upstream() -> AsyncIterator[bytes]:
nonlocal closed
try:
for event in _message(_text(0, "hello")):
yield messages_dispatch.encode_sse(event)
finally:
closed = True
filtered = _drop_encrypted_reasoning(upstream())
await filtered.__anext__()
await filtered.aclose()
assert closed
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import pytest
from routstr.upstream.venice import VeniceUpstreamProvider
from .test_venice_web_search import _body, _dispatch
EPHEMERAL = {"type": "ephemeral"}
CLAUDE_CODE_SYSTEM = [
{
"type": "text",
"text": "x-anthropic-billing-header: cc_version=2.1.281; cc_entrypoint=cli;",
},
{"type": "text", "text": "You are a Claude agent.", "cache_control": EPHEMERAL},
{
"type": "text",
"text": "\nYou are an interactive agent.",
"cache_control": {"type": "ephemeral", "ttl": "1h"},
},
]
@pytest.mark.asyncio
async def test_cache_marked_multi_block_system_is_merged_into_one_block() -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
kwargs = await _dispatch(provider, _body(system=CLAUDE_CODE_SYSTEM))
assert kwargs["system"] == [
{
"type": "text",
"text": (
"x-anthropic-billing-header: cc_version=2.1.281; cc_entrypoint=cli;"
"\n\nYou are a Claude agent.\n\n\nYou are an interactive agent."
),
"cache_control": {"type": "ephemeral", "ttl": "1h"},
}
]
@pytest.mark.asyncio
async def test_unmarked_multi_block_system_is_untouched() -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
system = [{"type": "text", "text": "A."}, {"type": "text", "text": "B."}]
kwargs = await _dispatch(provider, _body(system=system))
assert kwargs["system"] == system
@pytest.mark.asyncio
async def test_single_marked_block_and_string_system_are_untouched() -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
single = [{"type": "text", "text": "A.", "cache_control": EPHEMERAL}]
assert (await _dispatch(provider, _body(system=single)))["system"] == single
assert (await _dispatch(provider, _body(system="A.")))["system"] == "A."
@pytest.mark.asyncio
async def test_message_and_tool_cache_markers_are_kept() -> None:
provider = VeniceUpstreamProvider(api_key="sk-test")
messages = [
{
"role": "user",
"content": [{"type": "text", "text": "hi", "cache_control": EPHEMERAL}],
}
]
tools = [
{
"name": "Bash",
"description": "Run a command",
"input_schema": {"type": "object", "properties": {}},
"cache_control": EPHEMERAL,
}
]
kwargs = await _dispatch(
provider,
_body(system=CLAUDE_CODE_SYSTEM, messages=messages, tools=tools),
)
assert kwargs["messages"] == messages
assert kwargs["tools"] == tools
+25 -3
View File
@@ -123,13 +123,35 @@ async def test_requests_without_web_search_are_untouched() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_other_providers_keep_their_existing_behaviour() -> None: async def test_generic_openai_upstream_rejects_untranslatable_web_search() -> None:
"""The base hook is a no-op, so no non-Venice upstream changes shape.""" """Do not let LiteLLM send unsupported web_search_options to a generic API."""
provider = BaseUpstreamProvider(base_url="http://test", api_key="k") provider = BaseUpstreamProvider(base_url="http://test", api_key="k")
with pytest.raises(UpstreamError) as excinfo:
await _dispatch(provider, _body(tools=[WEB_SEARCH_TOOL, FUNCTION_TOOL]))
assert excinfo.value.status_code == 400
assert excinfo.value.code == "UNSUPPORTED_WEB_SEARCH"
@pytest.mark.asyncio
async def test_generic_openai_upstream_still_accepts_function_tools() -> None:
provider = BaseUpstreamProvider(base_url="http://test", api_key="k")
kwargs = await _dispatch(provider, _body(tools=[FUNCTION_TOOL]))
assert kwargs["model"] == "openai/deepseek-v4-flash-0731"
assert kwargs["tools"] == [FUNCTION_TOOL]
@pytest.mark.asyncio
async def test_non_openai_adapter_can_still_handle_search_tool() -> None:
provider = BaseUpstreamProvider(
base_url="https://openrouter.ai/api/v1", api_key="k"
)
kwargs = await _dispatch(provider, _body(tools=[WEB_SEARCH_TOOL])) kwargs = await _dispatch(provider, _body(tools=[WEB_SEARCH_TOOL]))
assert kwargs["model"] == "openai/deepseek-v4-flash-0731"
assert kwargs["tools"] == [WEB_SEARCH_TOOL] assert kwargs["tools"] == [WEB_SEARCH_TOOL]
+47
View File
@@ -52,3 +52,50 @@ async def test_x_cashu_responses_stream_reports_complete_provider_path() -> None
payload = json.loads((await _body(response)).decode().removeprefix("data: ")) payload = json.loads((await _body(response)).decode().removeprefix("data: "))
assert payload["provider"] == "openrouter:z.ai" assert payload["provider"] == "openrouter:z.ai"
@pytest.mark.asyncio
async def test_x_cashu_messages_stream_carries_provider_to_later_events() -> None:
provider = OpenRouterUpstreamProvider(api_key="test-key")
events = [
{"type": "message_start", "message": {"provider": "Anthropic"}},
{"type": "content_block_delta", "delta": {"text": "hi"}},
]
content = "".join(f"data: {json.dumps(e)}\n" for e in events)
response = await provider.handle_x_cashu_streaming_response(
content,
httpx.Response(200, headers={"content-type": "text/event-stream"}),
amount=1,
unit="sat",
max_cost_for_model=1,
)
lines = (await _body(response)).decode().splitlines()
stamped = [json.loads(line.removeprefix("data: ")) for line in lines if line]
assert [e["provider"] for e in stamped] == ["openrouter:Anthropic"] * 2
@pytest.mark.asyncio
async def test_x_cashu_responses_stream_carries_nested_provider() -> None:
provider = OpenRouterUpstreamProvider(api_key="test-key")
events = [
{"type": "response.created", "response": {"provider": "OpenAI"}},
{"type": "response.output_text.delta", "delta": "hi"},
]
content = "".join(f"data: {json.dumps(e)}\n\n" for e in events)
with patch.object(
provider, "get_x_cashu_cost", new=AsyncMock(return_value=None)
):
response = await provider.handle_x_cashu_streaming_responses_response(
content,
httpx.Response(200, headers={"content-type": "text/event-stream"}),
amount=1,
unit="sat",
max_cost_for_model=1,
)
lines = (await _body(response)).decode().splitlines()
stamped = [json.loads(line.removeprefix("data: ")) for line in lines if line]
assert [e["provider"] for e in stamped] == ["openrouter:OpenAI"] * 2
+23
View File
@@ -0,0 +1,23 @@
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import { Skeleton } from '@/components/ui/skeleton';
/**
* Route-level fallback so clicking "Models" lands on the page immediately
* instead of holding the previous route until this one's chunk is parsed.
*/
export default function ModelPageLoading() {
return (
<AppPageShell contentClassName='mx-auto w-full max-w-5xl'>
<div className='space-y-3 sm:space-y-4'>
<PageHeader
title='Model Management'
description='Manage provider model catalogs and validate endpoints from one place.'
/>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-16 w-full' />
<Skeleton className='h-[420px] w-full' />
</div>
</AppPageShell>
);
}
+17 -2
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { Model } from '@/lib/api/schemas/models'; import type { Model } from '@/lib/api/schemas/models';
import { useProgressiveList } from '@/lib/hooks/use-progressive-list';
import type { AdminModelGroup } from '@/lib/api/services/admin'; import type { AdminModelGroup } from '@/lib/api/services/admin';
import type { DisplayUnit } from '@/lib/types/units'; import type { DisplayUnit } from '@/lib/types/units';
import { ModelItemCard } from '@/components/model-item-card'; import { ModelItemCard } from '@/components/model-item-card';
@@ -24,6 +25,7 @@ import {
Edit3, Edit3,
Globe, Globe,
Key, Key,
Loader2,
MoreVertical, MoreVertical,
RefreshCw, RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
@@ -103,10 +105,21 @@ export function ModelProviderSection({
}); });
}, [provider, providerModels]); }, [provider, providerModels]);
const { visibleItems: visibleProviderModels, hiddenCount } =
useProgressiveList(keyedProviderModels);
const pendingRowsNotice =
hiddenCount > 0 ? (
<div className='text-muted-foreground flex items-center justify-center gap-2 p-3 text-xs sm:text-sm'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
Rendering {hiddenCount} more model{hiddenCount === 1 ? '' : 's'}…
</div>
) : null;
if (filterProvider) { if (filterProvider) {
return ( return (
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'> <div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
{keyedProviderModels.map(({ model, renderKey }) => ( {visibleProviderModels.map(({ model, renderKey }) => (
<ModelItemCard <ModelItemCard
key={renderKey} key={renderKey}
model={model} model={model}
@@ -125,6 +138,7 @@ export function ModelProviderSection({
onDelete={() => onDeleteModel(model.id)} onDelete={() => onDeleteModel(model.id)}
/> />
))} ))}
{pendingRowsNotice}
</div> </div>
); );
} }
@@ -217,7 +231,7 @@ export function ModelProviderSection({
<CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'> <CardContent className='px-3 pt-0 pb-3 sm:px-6 sm:pb-6'>
<div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'> <div className='bg-card/35 border-border/70 md:divide-border/75 overflow-hidden rounded-lg border md:divide-y'>
{keyedProviderModels.map(({ model, renderKey }) => ( {visibleProviderModels.map(({ model, renderKey }) => (
<ModelItemCard <ModelItemCard
key={renderKey} key={renderKey}
model={model} model={model}
@@ -236,6 +250,7 @@ export function ModelProviderSection({
onDelete={() => onDeleteModel(model.id)} onDelete={() => onDeleteModel(model.id)}
/> />
))} ))}
{pendingRowsNotice}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+6 -10
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type Model, type GroupSettings } from '@/lib/api/schemas/models'; import { type Model, type GroupSettings } from '@/lib/api/schemas/models';
import { import {
AdminService, AdminService,
@@ -13,6 +13,7 @@ import { AddProviderModelDialog } from '@/components/add-provider-model-dialog';
import { EditGroupForm } from '@/components/edit-group-form'; import { EditGroupForm } from '@/components/edit-group-form';
import { ModelProviderSection } from '@/components/model-provider-section'; import { ModelProviderSection } from '@/components/model-provider-section';
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency'; import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
import { useModelsWithProviders } from '@/lib/hooks/use-models-with-providers';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -131,19 +132,14 @@ export function ModelSelector({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Fetch models and groups // Shared with the page shell, so mounting this panel costs no extra fetch.
const { const {
data: modelsData, models,
groups,
isLoading: isLoadingModels, isLoading: isLoadingModels,
error: modelsError, error: modelsError,
refetch: refetchModels, refetch: refetchModels,
} = useQuery({ } = useModelsWithProviders();
queryKey: ['models-with-providers'],
queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false,
});
const { models = [], groups = [] } = modelsData || {};
const allOverrideModels = useMemo( const allOverrideModels = useMemo(
() => models.filter(isOverrideModel), () => models.filter(isOverrideModel),
[models] [models]
+18 -12
View File
@@ -1,16 +1,14 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import dynamic from 'next/dynamic';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import type { Model } from '@/lib/api/schemas/models'; import type { Model } from '@/lib/api/schemas/models';
import { AdminService } from '@/lib/api/services/admin'; import { useModelsWithProviders } from '@/lib/hooks/use-models-with-providers';
import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort'; import { groupAndSortModelsByProvider } from '@/lib/utils/model-sort';
import { AppPageShell } from '@/components/app-page-shell'; import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { ModelSelector } from '@/components/model-selector'; import { ModelSelector } from '@/components/model-selector';
import { ModelTester } from '@/components/model-tester';
import { ApiEndpointTester } from '@/components/api-endpoint-tester';
import { ModelSearchFilter } from '@/components/model-search-filter'; import { ModelSearchFilter } from '@/components/model-search-filter';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { import {
@@ -23,6 +21,19 @@ import {
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
// The testing tabs are never the landing view, so keeping them out of this
// route's chunk is what lets the navigation itself resolve quickly.
const ModelTester = dynamic(
() => import('@/components/model-tester').then((m) => m.ModelTester),
{ loading: () => <Skeleton className='h-[420px] w-full' />, ssr: false }
);
const ApiEndpointTester = dynamic(
() =>
import('@/components/api-endpoint-tester').then((m) => m.ApiEndpointTester),
{ loading: () => <Skeleton className='h-[420px] w-full' />, ssr: false }
);
export function ModelsPage() { export function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>( const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
undefined undefined
@@ -31,16 +42,11 @@ export function ModelsPage() {
useState<string>('all'); useState<string>('all');
const { const {
data: modelsData, models,
groups,
isLoading: isLoadingModels, isLoading: isLoadingModels,
error: modelsError, error: modelsError,
} = useQuery({ } = useModelsWithProviders();
queryKey: ['admin-models-with-providers'],
queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false,
});
const { models = [], groups = [] } = modelsData || {};
const groupedModels = useMemo( const groupedModels = useMemo(
() => groupAndSortModelsByProvider(models), () => groupAndSortModelsByProvider(models),
+22 -8
View File
@@ -446,10 +446,30 @@ export class AdminService {
const allModels: AdminModelAsModel[] = []; const allModels: AdminModelAsModel[] = [];
const seenModelIds = new Set<string>(); const seenModelIds = new Set<string>();
for (const provider of providers) { // One provider's catalog never depends on another's, and each miss costs an
// upstream round trip, so the whole fan-out happens in a single wave.
const providerResults = await Promise.all(
providers.map(async (provider) => {
try { try {
const providerModels = await this.getProviderModels(provider.id); return {
provider,
models: await this.getProviderModels(provider.id),
};
} catch (error) {
console.error(
`Failed to fetch models for provider ${provider.id}:`,
error
);
return null;
}
})
);
for (const result of providerResults) {
if (!result) {
continue;
}
const { provider, models: providerModels } = result;
providerModels.db_models.forEach((dbModel) => { providerModels.db_models.forEach((dbModel) => {
seenModelIds.add(dbModel.id); seenModelIds.add(dbModel.id);
const modelWithProvider = { const modelWithProvider = {
@@ -483,12 +503,6 @@ export class AdminService {
}); });
} }
}); });
} catch (error) {
console.error(
`Failed to fetch models for provider ${provider.id}:`,
error
);
}
} }
return { models: allModels, groups }; return { models: allModels, groups };
+26
View File
@@ -0,0 +1,26 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { AdminService } from '@/lib/api/services/admin';
export const modelsWithProvidersQueryKey = ['models-with-providers'] as const;
/**
* Shared catalog read for every models view, so the page shell and the
* selector panel share one request instead of each fanning out to providers.
*/
export function useModelsWithProviders() {
const query = useQuery({
queryKey: modelsWithProvidersQueryKey,
queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false,
});
return {
models: query.data?.models ?? [],
groups: query.data?.groups ?? [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
+46
View File
@@ -0,0 +1,46 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Reveal a long list in frame-sized batches.
*
* A provider catalog can hold thousands of rows, and mounting them in one
* commit blocks the main thread long enough that the page looks frozen right
* after navigation. Each batch yields back to the browser, so the first rows
* paint immediately and the rest fill in without freezing input.
*/
export function useProgressiveList<T>(
items: T[],
initialCount = 40,
step = 80
): { visibleItems: T[]; hiddenCount: number } {
const [count, setCount] = useState(initialCount);
const [trackedItems, setTrackedItems] = useState(items);
// Reset during render, not in an effect: an effect would first commit the new
// list at the old (possibly full) count, which is the freeze this avoids.
if (trackedItems !== items) {
setTrackedItems(items);
setCount(initialCount);
}
useEffect(() => {
if (count >= items.length) {
return;
}
const frame = requestAnimationFrame(() => {
setCount((current) => Math.min(items.length, current + step));
});
return () => cancelAnimationFrame(frame);
}, [count, items.length, step]);
const visibleCount = Math.min(count, items.length);
return {
visibleItems: items.slice(0, visibleCount),
hiddenCount: items.length - visibleCount,
};
}
Generated
+73 -31
View File
@@ -337,6 +337,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/9e/78e59887cbf94116bdc890af7726ae264d55df14f1c777724c656e8a35fe/bolt11-2.1.1-py3-none-any.whl", hash = "sha256:fd4edb9e73e27bf5e017f47c97f7c6827b523fcf9cab152b123961ca78323e2d", size = 17102, upload-time = "2025-03-12T13:33:08.142Z" }, { url = "https://files.pythonhosted.org/packages/9d/9e/78e59887cbf94116bdc890af7726ae264d55df14f1c777724c656e8a35fe/bolt11-2.1.1-py3-none-any.whl", hash = "sha256:fd4edb9e73e27bf5e017f47c97f7c6827b523fcf9cab152b123961ca78323e2d", size = 17102, upload-time = "2025-03-12T13:33:08.142Z" },
] ]
[[package]]
name = "boto3"
version = "1.43.105"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/46/d8c87ada70a7647fb3d206c7f19eafca3580a0ae4c06d62da539a1ee1207/boto3-1.43.105.tar.gz", hash = "sha256:e51260aed9cc1474778b5488bc6f97ad28f27a0a7002f4bbaaf8191aff1422ea", size = 112682, upload-time = "2026-09-29T19:37:40.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8e/0310a37ff609529dab9153cbc9fd0b66c685364d741bb6d1ae31134b728e/boto3-1.43.105-py3-none-any.whl", hash = "sha256:b8b6236ae7fe2724eee608c9b0649afbb86f0ec98158f39f67e64e678ec47499", size = 140042, upload-time = "2026-09-29T19:37:39.415Z" },
]
[[package]]
name = "botocore"
version = "1.43.105"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/30/668f3c0533a440787e212cf56404cb6ec234ae8e6baf97fe17329d512d88/botocore-1.43.105.tar.gz", hash = "sha256:afb3e7706b123ab069d1c34571ca1fdf82528a48425574fe4693df3d039d503f", size = 16263910, upload-time = "2026-09-29T19:37:36.456Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/94/50923cd46840e4d2b56cad1dcf5008fb20c099b04b2d93d32231f2d0bfaa/botocore-1.43.105-py3-none-any.whl", hash = "sha256:7abd19e1ef2c5e4a0314ca493fa7cebefabe33e559d7dd570fe2432a5431e6ec", size = 15958067, upload-time = "2026-09-29T19:37:33.373Z" },
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "1.2.0" version = "1.2.0"
@@ -1509,6 +1537,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213, upload-time = "2025-05-18T19:04:41.894Z" }, { url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213, upload-time = "2025-05-18T19:04:41.894Z" },
] ]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]] [[package]]
name = "jsonschema" name = "jsonschema"
version = "4.26.0" version = "4.26.0"
@@ -1552,10 +1589,11 @@ wheels = [
[[package]] [[package]]
name = "litellm" name = "litellm"
version = "1.93.2" version = "1.101.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "boto3" },
{ name = "click" }, { name = "click" },
{ name = "fastuuid" }, { name = "fastuuid" },
{ name = "httpx", extra = ["socks"] }, { name = "httpx", extra = ["socks"] },
@@ -1564,40 +1602,20 @@ dependencies = [
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "openai" }, { name = "openai" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "tiktoken" }, { name = "tiktoken" },
{ name = "tokenizers" }, { name = "tokenizers" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/97/dd/28024c0e4cf2dc6ab1bad59b8357af7f460e952c69526eae28f12ac4ee5e/litellm-1.93.2.tar.gz", hash = "sha256:c5d5223ef07f36e0886397fb45cc9db4150f86a0c6f6835cee1d5524cab69dfd", size = 15955441, upload-time = "2026-08-09T02:17:49.646Z" } sdist = { url = "https://files.pythonhosted.org/packages/26/c9/cb2730c6c763233e322fe7c5b2f53783eb10893cea9304e5474f1f20c306/litellm-1.101.2.tar.gz", hash = "sha256:790adf4ce19116d7bf4342492b1be5a90dd56e08d89795979bf6c1c3446a9670", size = 17493188, upload-time = "2026-09-24T00:04:22.712Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/c7/cb3f49dc60d57dda7fe368310fd5da2a94ec9b6a746bcf343a61e10bdeda/litellm-1.93.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:1bd0690efc94357e559de97927fd98437555cd5b5dd832544cfcca87297ccb80", size = 19938326, upload-time = "2026-08-09T02:16:38.041Z" }, { url = "https://files.pythonhosted.org/packages/44/a7/4bccec0ac9cb1b2e94e391b666458d07480d342039c66383ac191819e8e7/litellm-1.101.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:48c42c2c2cf9d4b0d75f4e1670b1b64b9e0513488d0b737fe057fda0bc716551", size = 23827328, upload-time = "2026-09-24T00:04:03.196Z" },
{ url = "https://files.pythonhosted.org/packages/0c/bd/d77184fdaaf57d67d65da91dcfc61c7f656703e7ce4f950e07523e7de4e3/litellm-1.93.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:845ececc628737909b1422d1af18bd19ae453727a66244aa9da3ca37a3773111", size = 19862606, upload-time = "2026-08-09T02:16:40.653Z" }, { url = "https://files.pythonhosted.org/packages/3d/3d/faf394e5ac5a1469de5cbd3939c9e330f744e648351934b981078ccc40d9/litellm-1.101.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:77195c8ed502c052bb31d4c3887356a308ac2e8c3b0b30c97e2b04b0be10dd44", size = 23484770, upload-time = "2026-09-24T00:04:06.248Z" },
{ url = "https://files.pythonhosted.org/packages/53/99/d8dd58b6840754a13cc2e1111b283aa28cbfc0ccc653a8725050916bb08e/litellm-1.93.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:498f9878ea773305e0638b6159d7e1ef27bb0b9a4292538d6634312d18a4e781", size = 20168532, upload-time = "2026-08-09T02:16:42.997Z" }, { url = "https://files.pythonhosted.org/packages/ba/84/60f70aa2683666626c4abe7aa44b52acca52cad911ee870ea244fd3b0796/litellm-1.101.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:abb7b3ac04f56ced46e53cca2369a5dd29539cab9fdcd6cc9a94987aa55a38d1", size = 23618000, upload-time = "2026-09-24T00:04:08.827Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/559ca0f5e0b99b9f641086ae924c782f8d521d09384fbe9abbe0bddb6e61/litellm-1.93.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1e5618ef495b2e02299b376ca84ffb2647837aafee478cf3a1be17d47a8f0f73", size = 20162696, upload-time = "2026-08-09T02:16:45.283Z" }, { url = "https://files.pythonhosted.org/packages/e2/8e/c57a4e157f97b1bcef9b410d51e17507047bbb11c676f5c81b22e7190c7c/litellm-1.101.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:210c89194225778759aa6649f5c0d605572bf14708ec85712462ace479f47f04", size = 23994795, upload-time = "2026-09-24T00:04:12.292Z" },
{ url = "https://files.pythonhosted.org/packages/92/3e/18c31b27c7d1271b43bdc8ffbef01bfba68d90248bbe60bb2130dd17e43c/litellm-1.93.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2da463d70c9fffbea9532fd000e035328f5266b399a2fb4c6c76b3470478337", size = 20233518, upload-time = "2026-08-09T02:16:47.87Z" }, { url = "https://files.pythonhosted.org/packages/b3/49/8737aee5a5a15cac7eb8a972b800529923e2837bbbadf0617f56111344ab/litellm-1.101.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:144dd8d1ead7174a718d1748deffcda7438cf7dfe8dc7c20761b72c35117e9a3", size = 23693332, upload-time = "2026-09-24T00:04:14.886Z" },
{ url = "https://files.pythonhosted.org/packages/d9/98/a6bae7c52f09cd03487a040f98eeedb899b3cf3fc541b87c6d051ee92e0d/litellm-1.93.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2cf122399f84f8f04621ed6ef8f276dd6d61f4fab108932ce0e30368de34dd42", size = 20291180, upload-time = "2026-08-09T02:16:50.549Z" }, { url = "https://files.pythonhosted.org/packages/04/50/4e711caa0374309d6aaf5696549449c2078f0225dd36a22ee0ca44dd068f/litellm-1.101.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ae95e7ef15e109472f2cec69da6028a874b416e56a704cd9b797362993fc63cf", size = 24092655, upload-time = "2026-09-24T00:04:17.677Z" },
{ url = "https://files.pythonhosted.org/packages/77/2d/81d974f2533cf039afda7e3e0f769dc73dc692c75ec867cf29ec6f41c06f/litellm-1.93.2-cp311-cp311-win_amd64.whl", hash = "sha256:8eaaf780fab9a19234735ef94225172179d15bc28b67ddbec125194249a504b7", size = 19775654, upload-time = "2026-08-09T02:16:53.162Z" }, { url = "https://files.pythonhosted.org/packages/c1/7d/32d391ddcb30d4d5d08fddd0abe918e836f9b3f753237c2b12ecb3d7425a/litellm-1.101.2-cp310-abi3-win_amd64.whl", hash = "sha256:0f5ee6daf9082b7efca1dc851c10c0d4884a2f1e0d7ea410bd508961b5a2cdae", size = 23894930, upload-time = "2026-09-24T00:04:20.432Z" },
{ url = "https://files.pythonhosted.org/packages/d0/05/72fd8051f0f2f3c84b90986e6f4551db7c8b190ba3300f111461b7701689/litellm-1.93.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3bf532c164ad7cb1b76f2c62afefdcc656b9b296374d075a4150e2ce10bb74c3", size = 19937403, upload-time = "2026-08-09T02:16:55.545Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4d/5081b39bdb73cab04f8a86294a4534a029cf0434ac6932c7ae8049d55723/litellm-1.93.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:526b7afc037f79dfdd5c607f5085ac597c7fd301a6dedabea40baae899b27f19", size = 19853652, upload-time = "2026-08-09T02:16:57.977Z" },
{ url = "https://files.pythonhosted.org/packages/70/3f/fb70691266a7fd08c202406abea0153e82fa17f134cd9d58e4029cc741db/litellm-1.93.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:294ad19f356f821ce97a5428d09439be5f38d22b218c73008d8a49e3e42eb145", size = 20165680, upload-time = "2026-08-09T02:17:00.65Z" },
{ url = "https://files.pythonhosted.org/packages/81/91/84424ce2a25595463e5d24e9cf8949877cd4ce93c0fcbf6486ecd685094f/litellm-1.93.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6f6a5e3907f0a1c9d8ff8d71a6cbac8a592e47a40da3f97167074947b5ba7d11", size = 20157772, upload-time = "2026-08-09T02:17:03.027Z" },
{ url = "https://files.pythonhosted.org/packages/8f/8d/b0eac7ee6d174564f820565c8c9a726ae83dbb8c4d3522daf175b95da002/litellm-1.93.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8541f1b7fd5c437ad249ad68d0a11f68e5e2866b0649da5fa7d63b595e9b8b22", size = 20229256, upload-time = "2026-08-09T02:17:05.271Z" },
{ url = "https://files.pythonhosted.org/packages/ee/6d/03e931c1cb2d1e1b7a968de21aa9e4db853928200da856c35c940ee6faa9/litellm-1.93.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:712c9387419d7b06a10df59973f5e530592d61b2314102b0fa3142f3743f9a9e", size = 20287257, upload-time = "2026-08-09T02:17:08.175Z" },
{ url = "https://files.pythonhosted.org/packages/16/05/6c0fe2fcf31c260474c55fabe4ecb0e9e1343c9b9132e28589391b2ad33e/litellm-1.93.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc0d58ccabd22ef7ef44a9e6f7247deb54ae42f5e126e6f00360c2b28b41bc2b", size = 19772580, upload-time = "2026-08-09T02:17:11.254Z" },
{ url = "https://files.pythonhosted.org/packages/70/74/e9046cffa69b32b710452480598e418b26a29896ece680c80ec23997fd16/litellm-1.93.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f4071bef03e4c2942cd2ddc752727345b85447d6a7fee1ff5a4f8b92187966b0", size = 19938095, upload-time = "2026-08-09T02:17:13.929Z" },
{ url = "https://files.pythonhosted.org/packages/fa/db/6ef38a7a2f73d5cc507423954fa535a8546ead375c4c71265c093bdb4e9e/litellm-1.93.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a99ac7c0c1b78acd6bfd1959e9f203dca71fdbceb5f0c8691c2ad8eee450d7d", size = 19854187, upload-time = "2026-08-09T02:17:16.588Z" },
{ url = "https://files.pythonhosted.org/packages/cb/b3/80ee0143b88e2921f8c8f24c7331478258a8bf25a3d4d4450bd96043403e/litellm-1.93.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a81ceff44c58ef504ab8bd787d03b82618765b9cfd530942386ae6d23c58be94", size = 20166307, upload-time = "2026-08-09T02:17:19.078Z" },
{ url = "https://files.pythonhosted.org/packages/98/60/cb326e1094f7042f28f9e21543d9f367a8aa25af6915bf4253b77da5c2a2/litellm-1.93.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:dee1b02b7f52a5a408bf7c8d499f0834e49194651743758a511dcdd926c0b692", size = 20158336, upload-time = "2026-08-09T02:17:21.507Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/bad75146863531172c9dbae189486c7f4425b56a6641b55ab20745316048/litellm-1.93.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d2edfa14b99bce706b35981703692e3ee631f9b87bf6dc28fb53b574f6480b20", size = 20229711, upload-time = "2026-08-09T02:17:24.073Z" },
{ url = "https://files.pythonhosted.org/packages/df/28/040b1853021ed8fd57be19eb2affb024d168951fe7e7abdbad91da3f6f3f/litellm-1.93.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae75a61c9abc827aa3131b7e640c952367a450830bb7c531b426b4ec2bb45f85", size = 20287584, upload-time = "2026-08-09T02:17:26.542Z" },
{ url = "https://files.pythonhosted.org/packages/d9/0b/4208815b0d666636cbf7afbd571eec3004d3a15d3150a23a9009fc2ce930/litellm-1.93.2-cp313-cp313-win_amd64.whl", hash = "sha256:c54a09ab20f94120a9d60a30d9970439dcefa00d2565d190505ff006a80c7a69", size = 19772641, upload-time = "2026-08-09T02:17:29.308Z" },
{ url = "https://files.pythonhosted.org/packages/09/4a/ff7a9c000519d2bab362318bf744a24c2500228e5fceaa6ac23acab96fa0/litellm-1.93.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:204cb0763fff9285bc87eb2dc0fc59b591999e5d94863d0964f806424d3c0cd6", size = 19943639, upload-time = "2026-08-09T02:17:31.811Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/29e9276ce4aa8ed133d9fd5ecc07375017d2228215547c6bbb17ccbc59b4/litellm-1.93.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3126c84361606b9fb07fde7d57eccd8a1747304d64c4143e2e5e40ae6e7693fb", size = 19855435, upload-time = "2026-08-09T02:17:34.376Z" },
{ url = "https://files.pythonhosted.org/packages/f8/20/2c9c818248ae019b2d496ca41900a9a5651ab05e2400794cd8dc8b89b6d2/litellm-1.93.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:1c84f7c4acb4e926a79b93145ab23231b300fc687bde7172ef884fc52d6011e0", size = 20166947, upload-time = "2026-08-09T02:17:36.828Z" },
{ url = "https://files.pythonhosted.org/packages/50/af/4016682be48350407837941ad1a1ae8185cca65b102e04e89eee2a2abccb/litellm-1.93.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:cacf35cf703b12c54516fc6464a3e08c6dbb1dcfb97239e1f629294fe36a1cba", size = 20160055, upload-time = "2026-08-09T02:17:39.674Z" },
{ url = "https://files.pythonhosted.org/packages/5b/b5/c25d7fbe08490d8211bd6b69af23f3a922b68ad8c87c776480b0de64a505/litellm-1.93.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a7f3e5138e307e429bd8fa29cc0c48bb1e2b827792e8f7799ca4c8cff736103", size = 20230910, upload-time = "2026-08-09T02:17:42.159Z" },
{ url = "https://files.pythonhosted.org/packages/21/27/341b18a40d4d98a2ac09025c248a3a7edddaf15ce4096ac4a783ff2f70db/litellm-1.93.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8684629be3f7b5f8e2b6e5fe5ea27ff957c63a8d525d81c1460d8436e2e1857", size = 20288903, upload-time = "2026-08-09T02:17:44.433Z" },
{ url = "https://files.pythonhosted.org/packages/8d/45/dd9ef72075a83854f852b1bf9a97ec7029a2be9fb4e338fc6623eb09fc90/litellm-1.93.2-cp314-cp314-win_amd64.whl", hash = "sha256:a783b8b18ed68cb6a3b79d2b00273ec21aef92442e9b2712a50036cb84bfe583", size = 19772974, upload-time = "2026-08-09T02:17:46.972Z" },
] ]
[[package]] [[package]]
@@ -2439,6 +2457,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -2722,7 +2752,7 @@ requires-dist = [
{ name = "greenlet", specifier = ">=3.2.1" }, { name = "greenlet", specifier = ">=3.2.1" },
{ name = "h11", specifier = ">=0.16" }, { name = "h11", specifier = ">=0.16" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "litellm", specifier = ">=1.93.0,<1.94" }, { name = "litellm", specifier = ">=1.101.2,<1.102" },
{ name = "marshmallow", specifier = ">=3.13,<4.0" }, { name = "marshmallow", specifier = ">=3.13,<4.0" },
{ name = "mdurl", specifier = "==0.1.2" }, { name = "mdurl", specifier = "==0.1.2" },
{ name = "nostr-sdk", specifier = ">=0.45.1,<0.46" }, { name = "nostr-sdk", specifier = ">=0.45.1,<0.46" },
@@ -2882,6 +2912,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083, upload-time = "2025-07-29T22:32:33.881Z" }, { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083, upload-time = "2025-07-29T22:32:33.881Z" },
] ]
[[package]]
name = "s3transfer"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" },
]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "84.0.0" version = "84.0.0"