From 89d316a1401c2954277459f8065eae3896e5e6a2 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 18 Aug 2026 02:23:28 +0200 Subject: [PATCH 01/30] feat: identify client app in request and error logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the app or agent that made each request from the OpenRouter-convention identity headers — X-Title (app name), then HTTP-Referer (app URL), then User-Agent — falling back to 'unknown' when none are present. The value is stored in a context variable by the logging middleware, so every log line emitted while handling a request carries a client_app field, including error messages raised deep in the wallet/mint code, and is attached explicitly to the incoming/completed/ failed request log events. Header values are attacker-controlled free text: they are capped at 120 characters and stripped of control characters so a crafted header cannot bloat log lines or inject fake log records. The existing SecurityFilter still runs after the new filter, so secrets accidentally placed in identity headers are redacted as usual. --- routstr/core/logging.py | 40 +++++++++-- routstr/core/middleware.py | 50 +++++++++++++- tests/unit/test_client_app_logging.py | 95 +++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_client_app_logging.py diff --git a/routstr/core/logging.py b/routstr/core/logging.py index 3886637c..2ca5872e 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -182,6 +182,32 @@ class RequestIdFilter(logging.Filter): return True +class ClientAppFilter(logging.Filter): + """Filter to add the requesting client app to all log records. + + The client app (the app or agent that made the request) is resolved by the + logging middleware from the OpenRouter-convention identity headers + (``X-Title``/``HTTP-Referer``, falling back to ``User-Agent``) and stored + in a context variable, so every log line emitted while handling a request + carries it — including error messages raised deep in the wallet/mint code. + """ + + def filter(self, record: logging.LogRecord) -> bool: + """Add the client app to the log record unless set explicitly.""" + if hasattr(record, "client_app"): + return True + try: + # Import here to avoid circular imports + from .middleware import UNKNOWN_CLIENT_APP, client_app_context + + client_app = client_app_context.get(None) + record.client_app = client_app if client_app else UNKNOWN_CLIENT_APP + except ImportError: + # If middleware isn't available yet, just use default + record.client_app = "unknown" + return True + + # Standard ``LogRecord`` attributes that are never user-supplied ``extra`` # fields; skipped when redacting structured extras (``msg``/``message`` are # handled separately above). @@ -323,7 +349,7 @@ def setup_logging() -> None: "rich_tracebacks": True, "markup": True, "console": _console, - "filters": ["request_id_filter", "security_filter"], + "filters": ["request_id_filter", "client_app_filter", "security_filter"], } else: console_handler = { @@ -331,7 +357,7 @@ def setup_logging() -> None: "level": log_level, "formatter": "plain", "stream": "ext://sys.stdout", - "filters": ["request_id_filter", "security_filter"], + "filters": ["request_id_filter", "client_app_filter", "security_filter"], } LOGGING_CONFIG = { @@ -340,7 +366,7 @@ def setup_logging() -> None: "formatters": { "json": { "()": jsonlogger.JsonFormatter, - "format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s", + "format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s %(client_app)s", "datefmt": "%Y-%m-%d %H:%M:%S", }, "plain": { @@ -351,6 +377,7 @@ def setup_logging() -> None: "filters": { "version_filter": {"()": VersionFilter}, "request_id_filter": {"()": RequestIdFilter}, + "client_app_filter": {"()": ClientAppFilter}, "security_filter": {"()": SecurityFilter}, }, "handlers": { @@ -364,7 +391,12 @@ def setup_logging() -> None: "interval": 1, # Every 1 day "backupCount": 30, # Keep 30 days of logs "atTime": None, # Rotate at midnight (00:00) - "filters": ["version_filter", "request_id_filter", "security_filter"], + "filters": [ + "version_filter", + "request_id_filter", + "client_app_filter", + "security_filter", + ], }, }, "loggers": { diff --git a/routstr/core/middleware.py b/routstr/core/middleware.py index 442c0a18..557cb9f6 100644 --- a/routstr/core/middleware.py +++ b/routstr/core/middleware.py @@ -4,6 +4,7 @@ from contextvars import ContextVar from typing import Callable from fastapi import Request, Response +from starlette.datastructures import Headers from starlette.middleware.base import BaseHTTPMiddleware from .logging import get_logger @@ -13,6 +14,39 @@ logger = get_logger(__name__) # Context variable to store request ID across async context request_id_context: ContextVar[str | None] = ContextVar("request_id") +# Context variable holding the client app that made the current request, so +# every log line emitted while handling it (including deep wallet/mint errors) +# can say who triggered it. "unknown" when the client sent no identity headers. +client_app_context: ContextVar[str | None] = ContextVar("client_app") + +UNKNOWN_CLIENT_APP = "unknown" + +# Client identity headers, in priority order. Follows the OpenRouter +# convention: apps identify themselves with ``X-Title`` (human-readable app +# name) and/or ``HTTP-Referer`` (app URL); ``User-Agent`` is the fallback for +# SDKs and scripts that set neither. +_CLIENT_APP_HEADERS: tuple[str, ...] = ("x-title", "referer", "user-agent") + +# Header values are attacker-controlled free text; cap the length so a single +# request can't bloat every log line, and strip control characters so a crafted +# header can't inject fake log records. +_CLIENT_APP_MAX_LENGTH = 120 + + +def client_app_from_headers(headers: Headers) -> str: + """Resolve the client app identity from request headers. + + Priority: ``X-Title`` > ``HTTP-Referer`` > ``User-Agent`` > "unknown". + """ + for header in _CLIENT_APP_HEADERS: + raw = headers.get(header) + if raw is None: + continue + cleaned = "".join(ch for ch in raw if ch.isprintable()).strip() + if cleaned: + return cleaned[:_CLIENT_APP_MAX_LENGTH] + return UNKNOWN_CLIENT_APP + # Methods that are never logged: HEAD requests are health probes from # monitoring/load balancers, OPTIONS are CORS preflights — both are framework @@ -71,6 +105,10 @@ class LoggingMiddleware(BaseHTTPMiddleware): # Set request ID in context for logging token = request_id_context.set(request_id) + client_app = client_app_from_headers(request.headers) + request.state.client_app = client_app + client_app_token = client_app_context.set(client_app) + path = request.url.path should_log = _should_log(request.method, path) @@ -84,6 +122,7 @@ class LoggingMiddleware(BaseHTTPMiddleware): "request_id": request_id, "method": request.method, "path": path, + "client_app": client_app, "query_params": dict(request.query_params), }, ) @@ -100,6 +139,7 @@ class LoggingMiddleware(BaseHTTPMiddleware): "request_id": request_id, "method": request.method, "path": path, + "client_app": client_app, "status_code": response.status_code, "duration_ms": round(duration * 1000, 2), }, @@ -118,6 +158,7 @@ class LoggingMiddleware(BaseHTTPMiddleware): "request_id": request_id, "method": request.method, "path": path, + "client_app": client_app, "duration_ms": round(duration * 1000, 2), "error": str(e), "error_type": type(e).__name__, @@ -128,6 +169,13 @@ class LoggingMiddleware(BaseHTTPMiddleware): finally: # Reset context request_id_context.reset(token) + client_app_context.reset(client_app_token) -__all__ = ["LoggingMiddleware", "request_id_context"] +__all__ = [ + "LoggingMiddleware", + "UNKNOWN_CLIENT_APP", + "client_app_context", + "client_app_from_headers", + "request_id_context", +] diff --git a/tests/unit/test_client_app_logging.py b/tests/unit/test_client_app_logging.py new file mode 100644 index 00000000..935b168c --- /dev/null +++ b/tests/unit/test_client_app_logging.py @@ -0,0 +1,95 @@ +"""Unit tests for client-app identification in request logging.""" + +import logging + +from starlette.datastructures import Headers + +from routstr.core.logging import ClientAppFilter +from routstr.core.middleware import ( + UNKNOWN_CLIENT_APP, + client_app_context, + client_app_from_headers, +) + + +def _headers(**kwargs: str) -> Headers: + return Headers({k.replace("_", "-"): v for k, v in kwargs.items()}) + + +def _record() -> logging.LogRecord: + return logging.LogRecord( + name="routstr.test", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="test", + args=None, + exc_info=None, + ) + + +class TestClientAppFromHeaders: + def test_x_title_wins_over_all(self) -> None: + headers = _headers( + x_title="Goose", + referer="https://myapp.example.com", + user_agent="python-httpx/0.27", + ) + assert client_app_from_headers(headers) == "Goose" + + def test_referer_used_when_no_x_title(self) -> None: + headers = _headers( + referer="https://myapp.example.com", user_agent="python-httpx/0.27" + ) + assert client_app_from_headers(headers) == "https://myapp.example.com" + + def test_user_agent_is_last_fallback(self) -> None: + headers = _headers(user_agent="python-httpx/0.27") + assert client_app_from_headers(headers) == "python-httpx/0.27" + + def test_unknown_when_no_identity_headers(self) -> None: + assert client_app_from_headers(Headers({})) == UNKNOWN_CLIENT_APP + assert UNKNOWN_CLIENT_APP == "unknown" + + def test_blank_header_falls_through_to_next(self) -> None: + headers = _headers(x_title=" ", user_agent="curl/8.4.0") + assert client_app_from_headers(headers) == "curl/8.4.0" + + def test_all_blank_resolves_to_unknown(self) -> None: + headers = _headers(x_title=" ", user_agent="\t") + assert client_app_from_headers(headers) == UNKNOWN_CLIENT_APP + + def test_value_is_truncated(self) -> None: + headers = _headers(x_title="a" * 500) + assert client_app_from_headers(headers) == "a" * 120 + + def test_control_characters_are_stripped(self) -> None: + # A crafted header must not be able to inject fake log records. + headers = _headers(user_agent="evil-app\x1b[0m fake INFO line") + assert client_app_from_headers(headers) == "evil-app[0m fake INFO line" + + +class TestClientAppFilter: + def test_uses_context_variable(self) -> None: + token = client_app_context.set("Goose") + try: + record = _record() + assert ClientAppFilter().filter(record) is True + assert record.client_app == "Goose" # type: ignore[attr-defined] + finally: + client_app_context.reset(token) + + def test_defaults_to_unknown_outside_request_context(self) -> None: + record = _record() + assert ClientAppFilter().filter(record) is True + assert record.client_app == UNKNOWN_CLIENT_APP # type: ignore[attr-defined] + + def test_explicit_extra_is_not_overwritten(self) -> None: + token = client_app_context.set("context-app") + try: + record = _record() + record.client_app = "explicit-app" # type: ignore[attr-defined] + assert ClientAppFilter().filter(record) is True + assert record.client_app == "explicit-app" # type: ignore[attr-defined] + finally: + client_app_context.reset(token) From 56fb540383a20b9e90f323266e49d03464cfc770 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 18 Aug 2026 02:28:43 +0200 Subject: [PATCH 02/30] refactor: align client-app tests with suite style, tighten comments Flatten the test classes into plain test functions matching the rest of tests/unit, add LoggingMiddleware integration tests covering request.state.client_app and the unknown fallback, and trim docstrings and comments to house density. --- routstr/core/logging.py | 8 +- routstr/core/middleware.py | 24 ++-- tests/unit/test_client_app_logging.py | 182 +++++++++++++++++--------- 3 files changed, 131 insertions(+), 83 deletions(-) diff --git a/routstr/core/logging.py b/routstr/core/logging.py index 2ca5872e..4decdb84 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -185,11 +185,9 @@ class RequestIdFilter(logging.Filter): class ClientAppFilter(logging.Filter): """Filter to add the requesting client app to all log records. - The client app (the app or agent that made the request) is resolved by the - logging middleware from the OpenRouter-convention identity headers - (``X-Title``/``HTTP-Referer``, falling back to ``User-Agent``) and stored - in a context variable, so every log line emitted while handling a request - carries it — including error messages raised deep in the wallet/mint code. + The middleware resolves it from the OpenRouter-convention identity headers + (X-Title, then Referer, then User-Agent) into a context variable, so even + errors raised deep in the wallet/mint code carry it. """ def filter(self, record: logging.LogRecord) -> bool: diff --git a/routstr/core/middleware.py b/routstr/core/middleware.py index 557cb9f6..7adfe296 100644 --- a/routstr/core/middleware.py +++ b/routstr/core/middleware.py @@ -14,30 +14,26 @@ logger = get_logger(__name__) # Context variable to store request ID across async context request_id_context: ContextVar[str | None] = ContextVar("request_id") -# Context variable holding the client app that made the current request, so -# every log line emitted while handling it (including deep wallet/mint errors) -# can say who triggered it. "unknown" when the client sent no identity headers. +# Context variable holding the client app behind the current request, so log +# lines emitted while handling it (wallet/mint errors included) can say who +# triggered it. client_app_context: ContextVar[str | None] = ContextVar("client_app") UNKNOWN_CLIENT_APP = "unknown" -# Client identity headers, in priority order. Follows the OpenRouter -# convention: apps identify themselves with ``X-Title`` (human-readable app -# name) and/or ``HTTP-Referer`` (app URL); ``User-Agent`` is the fallback for -# SDKs and scripts that set neither. +# OpenRouter-convention identity headers, in priority order: X-Title carries +# the app name, HTTP-Referer its URL; User-Agent covers SDKs and scripts that +# set neither. _CLIENT_APP_HEADERS: tuple[str, ...] = ("x-title", "referer", "user-agent") -# Header values are attacker-controlled free text; cap the length so a single -# request can't bloat every log line, and strip control characters so a crafted -# header can't inject fake log records. +# Headers are attacker-controlled free text: cap the length so one request +# can't bloat every log line, strip control characters so a crafted value +# can't forge log records. _CLIENT_APP_MAX_LENGTH = 120 def client_app_from_headers(headers: Headers) -> str: - """Resolve the client app identity from request headers. - - Priority: ``X-Title`` > ``HTTP-Referer`` > ``User-Agent`` > "unknown". - """ + """Resolve the requesting app from identity headers, or "unknown".""" for header in _CLIENT_APP_HEADERS: raw = headers.get(header) if raw is None: diff --git a/tests/unit/test_client_app_logging.py b/tests/unit/test_client_app_logging.py index 935b168c..ddeaec97 100644 --- a/tests/unit/test_client_app_logging.py +++ b/tests/unit/test_client_app_logging.py @@ -1,21 +1,20 @@ -"""Unit tests for client-app identification in request logging.""" +"""Tests for client-app identification in request logging.""" import logging +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient from starlette.datastructures import Headers from routstr.core.logging import ClientAppFilter from routstr.core.middleware import ( UNKNOWN_CLIENT_APP, + LoggingMiddleware, client_app_context, client_app_from_headers, ) -def _headers(**kwargs: str) -> Headers: - return Headers({k.replace("_", "-"): v for k, v in kwargs.items()}) - - def _record() -> logging.LogRecord: return logging.LogRecord( name="routstr.test", @@ -28,68 +27,123 @@ def _record() -> logging.LogRecord: ) -class TestClientAppFromHeaders: - def test_x_title_wins_over_all(self) -> None: - headers = _headers( - x_title="Goose", - referer="https://myapp.example.com", - user_agent="python-httpx/0.27", - ) - assert client_app_from_headers(headers) == "Goose" - - def test_referer_used_when_no_x_title(self) -> None: - headers = _headers( - referer="https://myapp.example.com", user_agent="python-httpx/0.27" - ) - assert client_app_from_headers(headers) == "https://myapp.example.com" - - def test_user_agent_is_last_fallback(self) -> None: - headers = _headers(user_agent="python-httpx/0.27") - assert client_app_from_headers(headers) == "python-httpx/0.27" - - def test_unknown_when_no_identity_headers(self) -> None: - assert client_app_from_headers(Headers({})) == UNKNOWN_CLIENT_APP - assert UNKNOWN_CLIENT_APP == "unknown" - - def test_blank_header_falls_through_to_next(self) -> None: - headers = _headers(x_title=" ", user_agent="curl/8.4.0") - assert client_app_from_headers(headers) == "curl/8.4.0" - - def test_all_blank_resolves_to_unknown(self) -> None: - headers = _headers(x_title=" ", user_agent="\t") - assert client_app_from_headers(headers) == UNKNOWN_CLIENT_APP - - def test_value_is_truncated(self) -> None: - headers = _headers(x_title="a" * 500) - assert client_app_from_headers(headers) == "a" * 120 - - def test_control_characters_are_stripped(self) -> None: - # A crafted header must not be able to inject fake log records. - headers = _headers(user_agent="evil-app\x1b[0m fake INFO line") - assert client_app_from_headers(headers) == "evil-app[0m fake INFO line" +# --------------------------------------------------------------------------- +# client_app_from_headers +# --------------------------------------------------------------------------- -class TestClientAppFilter: - def test_uses_context_variable(self) -> None: - token = client_app_context.set("Goose") - try: - record = _record() - assert ClientAppFilter().filter(record) is True - assert record.client_app == "Goose" # type: ignore[attr-defined] - finally: - client_app_context.reset(token) +def test_x_title_takes_priority() -> None: + """X-Title wins over Referer and User-Agent.""" + headers = Headers( + { + "x-title": "Goose", + "referer": "https://myapp.example.com", + "user-agent": "python-httpx/0.27", + } + ) + assert client_app_from_headers(headers) == "Goose" - def test_defaults_to_unknown_outside_request_context(self) -> None: + +def test_referer_used_when_no_x_title() -> None: + headers = Headers( + {"referer": "https://myapp.example.com", "user-agent": "python-httpx/0.27"} + ) + assert client_app_from_headers(headers) == "https://myapp.example.com" + + +def test_user_agent_is_last_fallback() -> None: + assert ( + client_app_from_headers(Headers({"user-agent": "curl/8.4.0"})) == "curl/8.4.0" + ) + + +def test_unknown_when_no_identity_headers() -> None: + assert client_app_from_headers(Headers({})) == UNKNOWN_CLIENT_APP + + +def test_blank_header_falls_through_to_next() -> None: + """A whitespace-only X-Title must not shadow a usable User-Agent.""" + headers = Headers({"x-title": " ", "user-agent": "curl/8.4.0"}) + assert client_app_from_headers(headers) == "curl/8.4.0" + + +def test_all_blank_resolves_to_unknown() -> None: + headers = Headers({"x-title": " ", "user-agent": "\t"}) + assert client_app_from_headers(headers) == UNKNOWN_CLIENT_APP + + +def test_value_is_truncated_to_120_chars() -> None: + assert client_app_from_headers(Headers({"x-title": "a" * 500})) == "a" * 120 + + +def test_control_characters_are_stripped() -> None: + """A crafted header must not be able to forge log records.""" + headers = Headers({"user-agent": "evil-app\x1b[0m fake INFO line"}) + assert client_app_from_headers(headers) == "evil-app[0m fake INFO line" + + +# --------------------------------------------------------------------------- +# ClientAppFilter +# --------------------------------------------------------------------------- + + +def test_filter_reads_context_variable() -> None: + token = client_app_context.set("Goose") + try: record = _record() assert ClientAppFilter().filter(record) is True - assert record.client_app == UNKNOWN_CLIENT_APP # type: ignore[attr-defined] + assert record.client_app == "Goose" # type: ignore[attr-defined] + finally: + client_app_context.reset(token) - def test_explicit_extra_is_not_overwritten(self) -> None: - token = client_app_context.set("context-app") - try: - record = _record() - record.client_app = "explicit-app" # type: ignore[attr-defined] - assert ClientAppFilter().filter(record) is True - assert record.client_app == "explicit-app" # type: ignore[attr-defined] - finally: - client_app_context.reset(token) + +def test_filter_defaults_to_unknown_outside_request_context() -> None: + record = _record() + assert ClientAppFilter().filter(record) is True + assert record.client_app == UNKNOWN_CLIENT_APP # type: ignore[attr-defined] + + +def test_filter_keeps_explicit_extra() -> None: + """extra={"client_app": ...} on a log call wins over the context value.""" + token = client_app_context.set("context-app") + try: + record = _record() + record.client_app = "explicit-app" # type: ignore[attr-defined] + assert ClientAppFilter().filter(record) is True + assert record.client_app == "explicit-app" # type: ignore[attr-defined] + finally: + client_app_context.reset(token) + + +# --------------------------------------------------------------------------- +# LoggingMiddleware integration +# --------------------------------------------------------------------------- + + +def test_middleware_exposes_client_app_on_request_state() -> None: + app = FastAPI() + + @app.get("/whoami") + async def whoami(request: Request) -> dict: + return {"client_app": request.state.client_app} + + app.add_middleware(LoggingMiddleware) + client = TestClient(app) + + response = client.get("/whoami", headers={"X-Title": "Goose"}) + assert response.json() == {"client_app": "Goose"} + + +def test_middleware_reports_unknown_without_identity_headers() -> None: + app = FastAPI() + + @app.get("/whoami") + async def whoami(request: Request) -> dict: + return {"client_app": request.state.client_app} + + app.add_middleware(LoggingMiddleware) + # TestClient sets its own User-Agent; blank it out to simulate a bare client. + client = TestClient(app, headers={"user-agent": ""}) + + response = client.get("/whoami") + assert response.json() == {"client_app": UNKNOWN_CLIENT_APP} From 172cb87a998a12c035319aec5b5617409d9df53a Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 18 Aug 2026 14:47:31 +0200 Subject: [PATCH 03/30] clean up --- routstr/core/logging.py | 23 ++-- routstr/core/middleware.py | 32 +++--- tests/unit/test_client_app_logging.py | 146 ++++++++++---------------- 3 files changed, 76 insertions(+), 125 deletions(-) diff --git a/routstr/core/logging.py b/routstr/core/logging.py index 4decdb84..563b368d 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -183,26 +183,15 @@ class RequestIdFilter(logging.Filter): class ClientAppFilter(logging.Filter): - """Filter to add the requesting client app to all log records. - - The middleware resolves it from the OpenRouter-convention identity headers - (X-Title, then Referer, then User-Agent) into a context variable, so even - errors raised deep in the wallet/mint code carry it. - """ + """Filter to add the requesting client app to all log records.""" def filter(self, record: logging.LogRecord) -> bool: - """Add the client app to the log record unless set explicitly.""" - if hasattr(record, "client_app"): - return True - try: - # Import here to avoid circular imports - from .middleware import UNKNOWN_CLIENT_APP, client_app_context + """Add the client app to the log record if available.""" + # Import here to avoid circular imports + from .middleware import UNKNOWN_CLIENT_APP, client_app_context - client_app = client_app_context.get(None) - record.client_app = client_app if client_app else UNKNOWN_CLIENT_APP - except ImportError: - # If middleware isn't available yet, just use default - record.client_app = "unknown" + client_app = client_app_context.get(None) + record.client_app = client_app if client_app else UNKNOWN_CLIENT_APP return True diff --git a/routstr/core/middleware.py b/routstr/core/middleware.py index 7adfe296..cff7d39a 100644 --- a/routstr/core/middleware.py +++ b/routstr/core/middleware.py @@ -14,26 +14,26 @@ logger = get_logger(__name__) # Context variable to store request ID across async context request_id_context: ContextVar[str | None] = ContextVar("request_id") -# Context variable holding the client app behind the current request, so log -# lines emitted while handling it (wallet/mint errors included) can say who -# triggered it. +# Context variable to store the client app across async context client_app_context: ContextVar[str | None] = ContextVar("client_app") UNKNOWN_CLIENT_APP = "unknown" -# OpenRouter-convention identity headers, in priority order: X-Title carries -# the app name, HTTP-Referer its URL; User-Agent covers SDKs and scripts that -# set neither. -_CLIENT_APP_HEADERS: tuple[str, ...] = ("x-title", "referer", "user-agent") +# Identity headers in priority order. X-Title and HTTP-Referer are the +# OpenRouter convention; User-Agent covers SDKs and scripts that set neither. +_CLIENT_APP_HEADERS: tuple[str, ...] = ( + "x-title", + "http-referer", + "referer", + "user-agent", +) -# Headers are attacker-controlled free text: cap the length so one request -# can't bloat every log line, strip control characters so a crafted value -# can't forge log records. +# Header values are attacker-controlled: cap the length so one request can't +# bloat every log line. _CLIENT_APP_MAX_LENGTH = 120 def client_app_from_headers(headers: Headers) -> str: - """Resolve the requesting app from identity headers, or "unknown".""" for header in _CLIENT_APP_HEADERS: raw = headers.get(header) if raw is None: @@ -101,9 +101,9 @@ class LoggingMiddleware(BaseHTTPMiddleware): # Set request ID in context for logging token = request_id_context.set(request_id) - client_app = client_app_from_headers(request.headers) - request.state.client_app = client_app - client_app_token = client_app_context.set(client_app) + client_app_token = client_app_context.set( + client_app_from_headers(request.headers) + ) path = request.url.path should_log = _should_log(request.method, path) @@ -118,7 +118,6 @@ class LoggingMiddleware(BaseHTTPMiddleware): "request_id": request_id, "method": request.method, "path": path, - "client_app": client_app, "query_params": dict(request.query_params), }, ) @@ -135,7 +134,6 @@ class LoggingMiddleware(BaseHTTPMiddleware): "request_id": request_id, "method": request.method, "path": path, - "client_app": client_app, "status_code": response.status_code, "duration_ms": round(duration * 1000, 2), }, @@ -154,7 +152,6 @@ class LoggingMiddleware(BaseHTTPMiddleware): "request_id": request_id, "method": request.method, "path": path, - "client_app": client_app, "duration_ms": round(duration * 1000, 2), "error": str(e), "error_type": type(e).__name__, @@ -172,6 +169,5 @@ __all__ = [ "LoggingMiddleware", "UNKNOWN_CLIENT_APP", "client_app_context", - "client_app_from_headers", "request_id_context", ] diff --git a/tests/unit/test_client_app_logging.py b/tests/unit/test_client_app_logging.py index ddeaec97..3f527083 100644 --- a/tests/unit/test_client_app_logging.py +++ b/tests/unit/test_client_app_logging.py @@ -2,7 +2,8 @@ import logging -from fastapi import FastAPI, Request +import pytest +from fastapi import FastAPI from fastapi.testclient import TestClient from starlette.datastructures import Headers @@ -27,49 +28,42 @@ def _record() -> logging.LogRecord: ) -# --------------------------------------------------------------------------- -# client_app_from_headers -# --------------------------------------------------------------------------- - - -def test_x_title_takes_priority() -> None: - """X-Title wins over Referer and User-Agent.""" - headers = Headers( - { - "x-title": "Goose", - "referer": "https://myapp.example.com", - "user-agent": "python-httpx/0.27", - } - ) - assert client_app_from_headers(headers) == "Goose" - - -def test_referer_used_when_no_x_title() -> None: - headers = Headers( - {"referer": "https://myapp.example.com", "user-agent": "python-httpx/0.27"} - ) - assert client_app_from_headers(headers) == "https://myapp.example.com" - - -def test_user_agent_is_last_fallback() -> None: - assert ( - client_app_from_headers(Headers({"user-agent": "curl/8.4.0"})) == "curl/8.4.0" - ) - - -def test_unknown_when_no_identity_headers() -> None: - assert client_app_from_headers(Headers({})) == UNKNOWN_CLIENT_APP - - -def test_blank_header_falls_through_to_next() -> None: - """A whitespace-only X-Title must not shadow a usable User-Agent.""" - headers = Headers({"x-title": " ", "user-agent": "curl/8.4.0"}) - assert client_app_from_headers(headers) == "curl/8.4.0" - - -def test_all_blank_resolves_to_unknown() -> None: - headers = Headers({"x-title": " ", "user-agent": "\t"}) - assert client_app_from_headers(headers) == UNKNOWN_CLIENT_APP +@pytest.mark.parametrize( + ("headers", "expected"), + [ + ( + { + "x-title": "Goose", + "http-referer": "https://myapp.example.com", + "user-agent": "python-httpx/0.27", + }, + "Goose", + ), + ( + {"http-referer": "https://myapp.example.com", "user-agent": "curl/8.4.0"}, + "https://myapp.example.com", + ), + ( + {"referer": "https://myapp.example.com", "user-agent": "curl/8.4.0"}, + "https://myapp.example.com", + ), + ({"user-agent": "curl/8.4.0"}, "curl/8.4.0"), + ({}, UNKNOWN_CLIENT_APP), + ({"x-title": " ", "user-agent": "curl/8.4.0"}, "curl/8.4.0"), + ({"x-title": " ", "user-agent": "\t"}, UNKNOWN_CLIENT_APP), + ], + ids=[ + "x-title-wins", + "http-referer", + "referer", + "user-agent-fallback", + "no-identity-headers", + "blank-falls-through", + "all-blank", + ], +) +def test_client_app_from_headers(headers: dict[str, str], expected: str) -> None: + assert client_app_from_headers(Headers(headers)) == expected def test_value_is_truncated_to_120_chars() -> None: @@ -82,11 +76,6 @@ def test_control_characters_are_stripped() -> None: assert client_app_from_headers(headers) == "evil-app[0m fake INFO line" -# --------------------------------------------------------------------------- -# ClientAppFilter -# --------------------------------------------------------------------------- - - def test_filter_reads_context_variable() -> None: token = client_app_context.set("Goose") try: @@ -103,47 +92,24 @@ def test_filter_defaults_to_unknown_outside_request_context() -> None: assert record.client_app == UNKNOWN_CLIENT_APP # type: ignore[attr-defined] -def test_filter_keeps_explicit_extra() -> None: - """extra={"client_app": ...} on a log call wins over the context value.""" - token = client_app_context.set("context-app") +def test_handler_logs_carry_client_app(caplog: pytest.LogCaptureFixture) -> None: + """A log line emitted inside a handler still names the app that triggered it.""" + app = FastAPI() + handler_logger = logging.getLogger("routstr.test.handler") + + @app.get("/whoami") + async def whoami() -> dict[str, bool]: + handler_logger.warning("something went wrong") + return {"ok": True} + + app.add_middleware(LoggingMiddleware) + + caplog.handler.addFilter(ClientAppFilter()) + handler_logger.addHandler(caplog.handler) try: - record = _record() - record.client_app = "explicit-app" # type: ignore[attr-defined] - assert ClientAppFilter().filter(record) is True - assert record.client_app == "explicit-app" # type: ignore[attr-defined] + TestClient(app).get("/whoami", headers={"X-Title": "Goose"}) finally: - client_app_context.reset(token) + handler_logger.removeHandler(caplog.handler) - -# --------------------------------------------------------------------------- -# LoggingMiddleware integration -# --------------------------------------------------------------------------- - - -def test_middleware_exposes_client_app_on_request_state() -> None: - app = FastAPI() - - @app.get("/whoami") - async def whoami(request: Request) -> dict: - return {"client_app": request.state.client_app} - - app.add_middleware(LoggingMiddleware) - client = TestClient(app) - - response = client.get("/whoami", headers={"X-Title": "Goose"}) - assert response.json() == {"client_app": "Goose"} - - -def test_middleware_reports_unknown_without_identity_headers() -> None: - app = FastAPI() - - @app.get("/whoami") - async def whoami(request: Request) -> dict: - return {"client_app": request.state.client_app} - - app.add_middleware(LoggingMiddleware) - # TestClient sets its own User-Agent; blank it out to simulate a bare client. - client = TestClient(app, headers={"user-agent": ""}) - - response = client.get("/whoami") - assert response.json() == {"client_app": UNKNOWN_CLIENT_APP} + record = next(r for r in caplog.records if r.name == "routstr.test.handler") + assert record.client_app == "Goose" # type: ignore[attr-defined] From b15ab59fcd483e01b7400fb05641420a46ac4a04 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:37:08 +0100 Subject: [PATCH 04/30] fix(ehbp): raise upstream timeout to 60s and return 504 on timeout EHBP (Tinfoil) forwarding buffered responses through an h11 client with a hard-coded 30-second inactivity timeout. Slow first-token latency or queueing on larger models would trip it, surfacing a bare 500 instead of a meaningful timeout. - Bump the default EHBP timeout from 30s to 60s. - Introduce EhbpTimeoutError (subclass of UpstreamError, code UPSTREAM_TIMEOUT, status 504) raised from connect/send/read timeouts. - Bearer auth now surfaces a proper 504 via the existing UpstreamError handler instead of a generic 500. - X-Cashu requests refund the redeemed amount and return a 504 with the refund token. Adds tests for timeout conversion, exception metadata, and the X-Cashu refund-on-timeout path. --- routstr/core/exceptions.py | 16 ++++++ routstr/upstream/ehbp.py | 42 ++++++++++++++- routstr/upstream/tinfoil_trailer.py | 38 ++++++++++---- tests/unit/test_ehbp_timeout.py | 80 +++++++++++++++++++++++++++++ tests/unit/test_tinfoil_trailer.py | 60 ++++++++++++++++++++++ 5 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_ehbp_timeout.py diff --git a/routstr/core/exceptions.py b/routstr/core/exceptions.py index 360b810d..6e7d90a0 100644 --- a/routstr/core/exceptions.py +++ b/routstr/core/exceptions.py @@ -34,6 +34,22 @@ class UpstreamError(Exception): super().__init__(message) +class EhbpTimeoutError(UpstreamError): + """Raised when an EHBP upstream times out waiting for a response. + + Distinct from a generic :class:`UpstreamError` so callers can map the + failure to a ``504 Gateway Timeout`` with a stable ``UPSTREAM_TIMEOUT`` + code instead of a misleading ``500`` internal server error. + """ + + def __init__(self, message: str, status_code: int = 504): + super().__init__( + message, + status_code=status_code, + code="UPSTREAM_TIMEOUT", + ) + + async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Handle HTTP exceptions and include request ID in response.""" request_id = getattr(request.state, "request_id", "unknown") diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index d6b7e7d2..b135c238 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -32,7 +32,7 @@ from ..core.db import ( from ..core.db import ( store_cashu_transaction_with_retry as store_cashu_transaction, ) -from ..core.exceptions import UpstreamError +from ..core.exceptions import EhbpTimeoutError, UpstreamError from ..core.settings import settings from ..payment.cost_calculation import ( CostData, @@ -1042,6 +1042,46 @@ async def forward_ehbp_x_cashu_request( except Exception: raise + except EhbpTimeoutError as e: + logger.warning( + "EHBP X-Cashu upstream timed out", + extra={ + "error": str(e), + "path": path, + "method": request.method, + "redeemed": redeemed, + }, + ) + + if redeemed and amount > 0: + try: + refund_token = await send_cashu_refund(amount, unit, mint, request_id) + error_response = create_error_response( + "upstream_timeout", + str(e), + 504, + request=request, + code="UPSTREAM_TIMEOUT", + ) + error_response.headers["X-Cashu"] = refund_token + return error_response + except Exception as refund_error: + logger.error( + "Failed to refund EHBP X-Cashu token after timeout", + extra={ + "error": str(refund_error), + "original_error": str(e), + }, + ) + + return create_error_response( + "upstream_timeout", + str(e), + 504, + request=request, + code="UPSTREAM_TIMEOUT", + ) + except Exception as e: error_message = str(e) logger.error( diff --git a/routstr/upstream/tinfoil_trailer.py b/routstr/upstream/tinfoil_trailer.py index 0357864f..ec3065d6 100644 --- a/routstr/upstream/tinfoil_trailer.py +++ b/routstr/upstream/tinfoil_trailer.py @@ -20,11 +20,12 @@ from urllib.parse import urlsplit import h11 from ..core import get_logger +from ..core.exceptions import EhbpTimeoutError logger = get_logger(__name__) _READ_BUFSIZE = 65536 -_DEFAULT_TIMEOUT_SECONDS = 30.0 +_DEFAULT_TIMEOUT_SECONDS = 60.0 _DEFAULT_CLOSE_TIMEOUT_SECONDS = 1.0 _DEFAULT_MAX_RESPONSE_BYTES = 25 * 1024 * 1024 _HOP_BY_HOP_HEADERS = { @@ -100,10 +101,15 @@ async def forward_with_trailer( headers = _strip_hop_by_hop_headers(headers) ssl_ctx = ssl.create_default_context() - reader, writer = await asyncio.wait_for( - asyncio.open_connection(host, port, ssl=ssl_ctx), - timeout=timeout_seconds, - ) + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port, ssl=ssl_ctx), + timeout=timeout_seconds, + ) + except asyncio.TimeoutError as exc: + raise EhbpTimeoutError( + f"EHBP upstream {host} timed out after {timeout_seconds:g}s connecting" + ) from exc try: # Build HTTP/1.1 request @@ -126,7 +132,13 @@ async def forward_with_trailer( request_data += body writer.write(request_data) - await asyncio.wait_for(writer.drain(), timeout=timeout_seconds) + try: + await asyncio.wait_for(writer.drain(), timeout=timeout_seconds) + except asyncio.TimeoutError as exc: + raise EhbpTimeoutError( + f"EHBP upstream {host} timed out after " + f"{timeout_seconds:g}s sending request" + ) from exc # Parse response with h11 conn = h11.Connection(h11.CLIENT) @@ -140,10 +152,16 @@ async def forward_with_trailer( event = conn.next_event() if event is h11.NEED_DATA: - data = await asyncio.wait_for( - reader.read(_READ_BUFSIZE), - timeout=timeout_seconds, - ) + try: + data = await asyncio.wait_for( + reader.read(_READ_BUFSIZE), + timeout=timeout_seconds, + ) + except asyncio.TimeoutError as exc: + raise EhbpTimeoutError( + f"EHBP upstream {host} timed out after " + f"{timeout_seconds:g}s waiting for response data" + ) from exc conn.receive_data(data if data else b"") continue diff --git a/tests/unit/test_ehbp_timeout.py b/tests/unit/test_ehbp_timeout.py new file mode 100644 index 00000000..bd19871d --- /dev/null +++ b/tests/unit/test_ehbp_timeout.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from routstr.core.exceptions import EhbpTimeoutError +from routstr.upstream import ehbp as ehbp_module + +# --------------------------------------------------------------------------- +# forward_ehbp_x_cashu_request — timeout fails closed with a refund + 504 +# --------------------------------------------------------------------------- + + +async def _request() -> MagicMock: + request = MagicMock() + request.state.request_id = "req-123" + request.method = "POST" + request.query_params = {} + request.headers = {} + request.body = AsyncMock(return_value=b"opaque") + return request + + +@pytest.mark.asyncio +async def test_x_cashu_timeout_refunds_and_returns_504( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + ehbp_module, + "recieve_token", + AsyncMock(return_value=(1000, "msat", None)), + ) + monkeypatch.setattr( + ehbp_module, "store_cashu_transaction", AsyncMock(return_value=None) + ) + monkeypatch.setattr( + ehbp_module, "send_cashu_refund", AsyncMock(return_value="refund-token") + ) + monkeypatch.setattr( + ehbp_module, + "forward_with_trailer", + AsyncMock(side_effect=EhbpTimeoutError("EHBP upstream timed out")), + ) + + profile = MagicMock() + profile.client_target_url_header = None + profile.allow_client_target_override = False + profile.proxy_only_headers = frozenset() + profile.usage_response_header = None + + target = MagicMock() + target.url = "https://inference.tinfoil.sh/v1/chat/completions" + target.headers = {} + target.profile = None + + upstream = MagicMock() + upstream.prepare_headers.return_value = {} + upstream.get_ehbp_forwarding_target.return_value = target + upstream.get_confidential_inference_profile.return_value = profile + upstream.prepare_params.return_value = {} + + model_obj = MagicMock() + model_obj.id = "tinfoil-kimi-k2-6" + model_obj.forwarded_model_id = "kimi-k2-6" + + response = await ehbp_module.forward_ehbp_x_cashu_request( + request=await _request(), + x_cashu_token="cashu-token", + path="v1/chat/completions", + max_cost_for_model=5000, + model_obj=model_obj, + upstream=upstream, + ) + + assert response.status_code == 504 + assert response.headers["X-Cashu"] == "refund-token" + ehbp_module.send_cashu_refund.assert_awaited_once_with( + 1000, "msat", None, "req-123" + ) diff --git a/tests/unit/test_tinfoil_trailer.py b/tests/unit/test_tinfoil_trailer.py index ef1c96f1..5a6d68a7 100644 --- a/tests/unit/test_tinfoil_trailer.py +++ b/tests/unit/test_tinfoil_trailer.py @@ -1,9 +1,11 @@ from __future__ import annotations +import asyncio from unittest.mock import AsyncMock, MagicMock import pytest +from routstr.core.exceptions import EhbpTimeoutError, UpstreamError from routstr.upstream.tinfoil_trailer import forward_with_trailer @@ -28,6 +30,14 @@ class FakeWriter: self.written += data +class HangingReader: + """A reader that never returns data, used to trigger a read timeout.""" + + async def read(self, _size: int) -> bytes: + await asyncio.sleep(3600) + return b"" + + @pytest.mark.asyncio async def test_forward_with_trailer_captures_usage_trailer( monkeypatch: pytest.MonkeyPatch, @@ -136,3 +146,53 @@ async def test_forward_with_trailer_enforces_response_size_limit( ) writer.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_forward_with_trailer_connect_timeout_raises_ehbp_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _hang_connect(*_args: object, **_kwargs: object) -> object: + raise asyncio.TimeoutError + + monkeypatch.setattr( + "routstr.upstream.tinfoil_trailer.asyncio.open_connection", _hang_connect + ) + + with pytest.raises(EhbpTimeoutError, match="connecting"): + await forward_with_trailer( + method="POST", + url="https://enclave.tinfoil.sh/v1/chat/completions", + headers={}, + body=b"opaque", + ) + + +@pytest.mark.asyncio +async def test_forward_with_trailer_read_timeout_raises_ehbp_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + reader = HangingReader() + writer = FakeWriter() + monkeypatch.setattr( + "routstr.upstream.tinfoil_trailer.asyncio.open_connection", + AsyncMock(return_value=(reader, writer)), + ) + + with pytest.raises(EhbpTimeoutError, match="waiting for response data"): + await forward_with_trailer( + method="POST", + url="https://enclave.tinfoil.sh/v1/chat/completions", + headers={}, + body=b"opaque", + timeout_seconds=0.01, + ) + + writer.close.assert_called_once() + + +def test_ehbp_timeout_error_metadata() -> None: + exc = EhbpTimeoutError("boom") + assert exc.status_code == 504 + assert exc.code == "UPSTREAM_TIMEOUT" + assert isinstance(exc, UpstreamError) From 0662dc5bae1bedebfafc6e64fc2fafb27146b090 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:47:43 +0200 Subject: [PATCH 05/30] Fix mypy error in ehbp timeout test --- tests/unit/test_ehbp_timeout.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_ehbp_timeout.py b/tests/unit/test_ehbp_timeout.py index bd19871d..760a491e 100644 --- a/tests/unit/test_ehbp_timeout.py +++ b/tests/unit/test_ehbp_timeout.py @@ -34,8 +34,9 @@ async def test_x_cashu_timeout_refunds_and_returns_504( monkeypatch.setattr( ehbp_module, "store_cashu_transaction", AsyncMock(return_value=None) ) + send_cashu_refund_mock = AsyncMock(return_value="refund-token") monkeypatch.setattr( - ehbp_module, "send_cashu_refund", AsyncMock(return_value="refund-token") + ehbp_module, "send_cashu_refund", send_cashu_refund_mock ) monkeypatch.setattr( ehbp_module, @@ -75,6 +76,6 @@ async def test_x_cashu_timeout_refunds_and_returns_504( assert response.status_code == 504 assert response.headers["X-Cashu"] == "refund-token" - ehbp_module.send_cashu_refund.assert_awaited_once_with( + send_cashu_refund_mock.assert_awaited_once_with( 1000, "msat", None, "req-123" ) From e9f0534570d71e6bad14a05aebcf5d3cdc59fab1 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:50:50 +0200 Subject: [PATCH 06/30] fix(ehbp): pass through enclave key-config 422 as problem+json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Tinfoil enclave returns 422 application/problem+json with type=urn:ietf:params:ehbp:error:key-config, the proxy was wrapping it into its own application/json error envelope via create_upstream_error_response(). This destroyed the content type and body shape that EHBP clients (tinfoil SDK, routstr-sdk) use to detect key-config mismatch and trigger re-attestation. The result: every request with a stale HPKE key failed permanently with no recovery. Now the proxy detects the key-config response and passes it through with its original 422 status, application/problem+json content type, and unmodified body. This re-enables the client's KeyConfigMismatchError → re-attest → retry loop. Both bearer-auth and X-Cashu paths are covered. The X-Cashu path also refunds the full token before passing through, since the request was never processed by the enclave. --- routstr/upstream/ehbp.py | 93 +++++++++++++++++++++- tests/unit/test_tinfoil_integration.py | 102 +++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 3f749795..b2822366 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -46,7 +46,7 @@ from ..wallet import ( recieve_token, send_token, ) -from .tinfoil_trailer import forward_with_trailer +from .tinfoil_trailer import TrailerResponse, forward_with_trailer logger = get_logger(__name__) @@ -62,6 +62,55 @@ _TINFOIL_ALLOWED_ENCLAVE_HOST_SUFFIX = ".tinfoil.sh" _TINFOIL_ALLOWED_ENCLAVE_HOSTS = frozenset({"tinfoil.sh"}) +_KEY_CONFIG_PROBLEM_TYPE = "urn:ietf:params:ehbp:error:key-config" + + +def _is_ehbp_key_config_response(resp: TrailerResponse) -> bool: + """Check whether an upstream EHBP response is a key-config mismatch. + + The enclave returns ``422 application/problem+json`` with + ``type=urn:ietf:params:ehbp:error:key-config`` when it cannot decrypt the + request body — meaning the client's HPKE key is stale (the enclave rotated + keys). The proxy must pass this response through with its original + content type so EHBP clients can detect it and trigger re-attestation. + """ + if resp.status_code != 422: + return False + ct = "" + for k, v in resp.headers: + if k.lower() == "content-type": + ct = v.lower() + break + if "application/problem+json" not in ct: + return False + try: + body = json.loads(resp.body) + except (json.JSONDecodeError, UnicodeDecodeError): + return False + return isinstance(body, dict) and body.get("type") == _KEY_CONFIG_PROBLEM_TYPE + + +def _passthrough_key_config_response(resp: TrailerResponse) -> Response: + """Return the enclave's key-config 422 with its original body and content + type so the EHBP client's ``KeyConfigMismatchError`` detection fires. + + Only EHBP protocol headers are forwarded; everything else (hop-by-hop, + upstream-internal) is filtered out. + """ + passthrough_headers: dict[str, str] = { + "content-type": "application/problem+json", + } + for k, v in resp.headers: + if k.lower() in ("ehbp-response-nonce", "content-length"): + passthrough_headers[k] = v + return Response( + content=resp.body, + status_code=422, + headers=passthrough_headers, + media_type="application/problem+json", + ) + + def _normalize_upstream_model_id(model_id: str | None) -> str: """Normalize casing and whitespace for upstream identity comparisons.""" if not model_id: @@ -722,6 +771,25 @@ async def forward_ehbp_request( "body_preview": body_preview, }, ) + # Key-config mismatch (stale client HPKE key): return the + # enclave's 422 problem+json directly so the SDK's + # KeyConfigMismatchError detection fires and triggers + # re-attestation. Wrapping it as application/json would + # destroy the signal and cause permanent failure. + if _is_ehbp_key_config_response(resp): + logger.warning( + "EHBP upstream %s returned key-config mismatch for model=%s, " + "passing through for client re-attestation", + provider_type, + model_obj.id, + extra={ + "provider": provider_type, + "model": model_obj.id, + "path": path, + }, + ) + return _passthrough_key_config_response(resp) + raise UpstreamError( f"EHBP upstream {provider_type} returned {resp.status_code} " f"for model {model_obj.id}: {body_preview[:200] or ''}", @@ -938,6 +1006,29 @@ async def forward_ehbp_x_cashu_request( ) if resp.status_code != 200: + # Key-config mismatch (stale client HPKE key): refund the + # full token and pass the enclave's 422 problem+json through + # so the SDK's KeyConfigMismatchError detection fires. + if _is_ehbp_key_config_response(resp): + logger.warning( + "EHBP upstream %s returned key-config mismatch for " + "model=%s, refunding and passing through", + provider_type, + model_obj.id, + extra={ + "provider": provider_type, + "model": model_obj.id, + "path": path, + "refunded_amount": amount, + }, + ) + refund_token = await send_cashu_refund( + amount, unit, mint, request_id + ) + passthrough = _passthrough_key_config_response(resp) + passthrough.headers["X-Cashu"] = refund_token + return passthrough + refund_token = await send_cashu_refund(amount, unit, mint, request_id) error_response = Response( content=json.dumps( diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index d5330145..41a715a4 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -14,11 +14,14 @@ import pytest from routstr.upstream.ehbp import ( _PROXY_ONLY_HEADERS, _compute_ehbp_actual_cost, + _is_ehbp_key_config_response, + _passthrough_key_config_response, _prepare_ehbp_upstream_headers, _resolve_ehbp_target_url, _strip_proxy_headers, parse_tinfoil_usage_metrics, ) +from routstr.upstream.tinfoil_trailer import TrailerResponse from routstr.upstream.tinfoil import ( TinfoilModel, TinfoilUpstreamProvider, @@ -747,3 +750,102 @@ class TestTinfoilUpstreamProvider: models = await provider.fetch_models() assert models == [] + + +# --------------------------------------------------------------------------- +# EHBP key-config mismatch passthrough +# --------------------------------------------------------------------------- + + +def _key_config_trailer_response( + status_code: int = 422, + content_type: str = "application/problem+json", + body: bytes = b'{"type":"urn:ietf:params:ehbp:error:key-config","title":"failed to read decrypted request body"}', +) -> TrailerResponse: + return TrailerResponse( + status_code=status_code, + headers=[ + ("content-type", content_type), + ("content-length", str(len(body))), + ], + body=body, + trailers=[], + ) + + +class TestIsEhbpKeyConfigResponse: + def test_genuine_key_config_422(self) -> None: + resp = _key_config_trailer_response() + assert _is_ehbp_key_config_response(resp) is True + + def test_200_is_not_key_config(self) -> None: + resp = _key_config_trailer_response(status_code=200) + assert _is_ehbp_key_config_response(resp) is False + + def test_400_is_not_key_config(self) -> None: + resp = _key_config_trailer_response(status_code=400) + assert _is_ehbp_key_config_response(resp) is False + + def test_json_content_type_is_not_key_config(self) -> None: + """A 422 with application/json (e.g. proxy-wrapped error) must NOT be + treated as key-config — only the original problem+json counts.""" + resp = _key_config_trailer_response(content_type="application/json") + assert _is_ehbp_key_config_response(resp) is False + + def test_problem_json_with_different_type_is_not_key_config(self) -> None: + """A 422 problem+json with a different error type is not key-config.""" + body = b'{"type":"urn:ietf:params:ehbp:error:other","title":"other"}' + resp = _key_config_trailer_response(body=body) + assert _is_ehbp_key_config_response(resp) is False + + def test_empty_body_is_not_key_config(self) -> None: + resp = _key_config_trailer_response(body=b"") + assert _is_ehbp_key_config_response(resp) is False + + def test_invalid_json_body_is_not_key_config(self) -> None: + resp = _key_config_trailer_response(body=b"not json") + assert _is_ehbp_key_config_response(resp) is False + + def test_problem_json_with_charset(self) -> None: + resp = _key_config_trailer_response( + content_type="application/problem+json; charset=utf-8" + ) + assert _is_ehbp_key_config_response(resp) is True + + def test_missing_content_type_is_not_key_config(self) -> None: + resp = TrailerResponse( + status_code=422, + headers=[], + body=b'{"type":"urn:ietf:params:ehbp:error:key-config"}', + ) + assert _is_ehbp_key_config_response(resp) is False + + +class TestPassthroughKeyConfigResponse: + def test_status_and_content_type(self) -> None: + resp = _key_config_trailer_response() + result = _passthrough_key_config_response(resp) + assert result.status_code == 422 + assert result.media_type == "application/problem+json" + + def test_body_passed_through(self) -> None: + original_body = b'{"type":"urn:ietf:params:ehbp:error:key-config","title":"failed to read decrypted request body"}' + resp = _key_config_trailer_response(body=original_body) + result = _passthrough_key_config_response(resp) + assert result.body == original_body + + def test_ehbp_nonce_header_forwarded(self) -> None: + resp = TrailerResponse( + status_code=422, + headers=[ + ("content-type", "application/problem+json"), + ("ehbp-response-nonce", "abc123"), + ("server", "nginx"), + ("x-request-id", "some-id"), + ], + body=b'{"type":"urn:ietf:params:ehbp:error:key-config","title":"test"}', + ) + result = _passthrough_key_config_response(resp) + assert result.headers["ehbp-response-nonce"] == "abc123" + assert "server" not in result.headers + assert "x-request-id" not in result.headers From 4c0e84fe0acb0c6d4628a474c7a106b710207ec2 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:16:29 +0200 Subject: [PATCH 07/30] fix(tests): sort imports in tinfoil integration test ruff I001: tinfoil imports must precede tinfoil_trailer alphabetically --- tests/unit/test_tinfoil_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index 41a715a4..b7d52dfe 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -21,11 +21,11 @@ from routstr.upstream.ehbp import ( _strip_proxy_headers, parse_tinfoil_usage_metrics, ) -from routstr.upstream.tinfoil_trailer import TrailerResponse from routstr.upstream.tinfoil import ( TinfoilModel, TinfoilUpstreamProvider, ) +from routstr.upstream.tinfoil_trailer import TrailerResponse # --------------------------------------------------------------------------- # parse_tinfoil_usage_metrics From 49f2256e3b6324af623333b1fb4e511869297685 Mon Sep 17 00:00:00 2001 From: thefux Date: Sun, 6 Sep 2026 11:02:12 +0000 Subject: [PATCH 08/30] feat: missing_usage_policy for responses without usage or pricing Upstream responses that carry no usage trailer and no usable pricing were billed inconsistently: bearer/reservation mode released the reservation without charging (free request) or raised a 400 AFTER the content was already streamed, while x-cashu mode silently kept the entire prepayment with no refund and no estimate. - New MISSING_USAGE_POLICY setting (estimate | charge_max | refund, default charge_max): charge_max bills the pre-authorized ceiling, estimate/refund release it. - MaxCostData gains reason/estimated_flag; both cost-calculation dead-ends (no usage at all, tokens-but-unusable-rates) apply the policy and preserve raw token counts for dashboards. - adjust_payment_for_tokens no longer raises HTTPException(400) on CostDataError after delivery; it finalizes under the policy. - MissingUsageEstimator wired into all four x-cashu handlers: when the upstream omits usage, billing falls back to the local token estimate instead of keeping the full prepayment. - X-Routstr-Cost-Estimated: true header marks policy-derived charges. --- routstr/auth.py | 117 +++++++++- routstr/core/settings.py | 8 + routstr/payment/cost_calculation.py | 89 ++++++-- routstr/upstream/base.py | 125 +++++++++- tests/unit/test_missing_usage_policy.py | 216 ++++++++++++++++++ tests/unit/test_pricing_rate_validation.py | 11 +- .../test_x_cashu_responses_streaming_sse.py | 9 +- 7 files changed, 535 insertions(+), 40 deletions(-) create mode 100644 tests/unit/test_missing_usage_policy.py diff --git a/routstr/auth.py b/routstr/auth.py index 76b421b1..fb573835 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -1523,8 +1523,17 @@ async def adjust_payment_for_tokens( return cost.dict() case CostDataError() as error: + # Pricing derivation failed AFTER the upstream served the request. + # Raising here would hand the client a 400 for content it already + # received (streaming) while the provider eats the upstream cost. + # Apply missing_usage_policy instead: keep the pre-authorized + # ceiling (charge_max), or release without charging + # (estimate/refund) and let the response complete. + policy = (settings.missing_usage_policy or "charge_max").strip().lower() logger.error( - "Cost calculation error during payment adjustment - releasing reservation", + "Cost calculation error during payment adjustment — applying " + "missing_usage_policy=%s instead of raising", + policy, extra={ "key_hash": key.hashed_key[:8] + "...", "model": model, @@ -1532,18 +1541,102 @@ async def adjust_payment_for_tokens( "error_code": error.code, }, ) - await release_reservation_only() - - raise HTTPException( - status_code=400, - detail={ - "error": { - "message": error.message, - "type": "invalid_request_error", - "code": error.code, + if policy == "charge_max": + charged = await _charge_reservation_rows( + session, + billing_key_hash=billing_key.hashed_key, + reserved_msats=deducted_max_cost, + charge_msats=deducted_max_cost, + ) + if charged: + await session.commit() + await _stop_reservation_heartbeat(reservation.release_id) + await session.refresh(billing_key) + await _accumulate_fee(deducted_max_cost) + payments_logger.info( + "FINALIZE", + extra={ + "event": "finalize", + "key_hash": key.hashed_key[:8] + "...", + "billing_key_hash": billing_log_hash, + "model": model, + "cost_reserved": deducted_max_cost, + "cost_charged": deducted_max_cost, + "input_tokens": 0, + "output_tokens": 0, + "balance": billing_key.balance, + "reserved_balance": billing_key.reserved_balance, + "total_spent": billing_key.total_spent, + "finalize_type": "missing_usage_policy", + }, + ) + return { + "base_msats": 0, + "input_msats": 0, + "output_msats": 0, + "total_msats": deducted_max_cost, + "total_usd": 0.0, + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_msats": 0, + "cache_creation_msats": 0, + "charged_msats": deducted_max_cost, + "reason": "missing_usage", + "estimated": True, } - }, - ) + logger.error( + "Failed to charge reservation under missing_usage_policy=charge_max " + "— releasing instead", + extra={ + "key_hash": key_log_hash, + "model": model, + "error_message": error.message, + }, + ) + else: + if policy in ("estimate", "refund"): + logger.warning( + "Releasing reservation without charging under " + "missing_usage_policy=%s", + policy, + extra={ + "key_hash": key_log_hash, + "model": model, + "error_message": error.message, + "error_code": error.code, + }, + ) + else: + logger.warning( + "Unknown missing_usage_policy %r — treating as 'estimate'", + policy, + extra={ + "key_hash": key_log_hash, + "model": model, + "error_message": error.message, + "error_code": error.code, + }, + ) + await release_reservation_only() + return { + "base_msats": 0, + "input_msats": 0, + "output_msats": 0, + "total_msats": 0, + "total_usd": 0.0, + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_msats": 0, + "cache_creation_msats": 0, + "charged_msats": 0, + "reason": "missing_usage", + "estimated": True, + "error": {"message": error.message, "code": error.code}, + } # All calculate_cost variants are handled above. raise AssertionError("Unreachable: unhandled calculate_cost result") diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 014a5795..8005c453 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -79,6 +79,14 @@ class Settings(BaseSettings): tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE") # Minimum per-request charge in millisatoshis when model pricing is free/zero min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT") + # Policy when an upstream response carries no usable usage AND no usable + # pricing (content was served, cost cannot be measured or derived): + # estimate — charge whatever a local token estimate yields (may be 0) + # charge_max — keep the prepayment/reservation (user pre-authorized it) + # refund — release the reservation / refund the full prepayment + missing_usage_policy: str = Field( + default="charge_max", env="MISSING_USAGE_POLICY" + ) reset_reserved_balance_on_startup: bool = Field( default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP" ) # deactivate in horizontal scaling setups diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py index b4b0ff6b..7592958f 100644 --- a/routstr/payment/cost_calculation.py +++ b/routstr/payment/cost_calculation.py @@ -41,7 +41,26 @@ class CostData(BaseModel): class MaxCostData(CostData): - pass + """Reservation-ceiling billing. + + Two distinct meanings ride on this class: + + - ``reason="max_cost"`` — pricing is usable but the response is empty or + the upstream reports a USD cost with zero tokens; the ceiling is the + agreed charge for a served-but-unmeasurable request. + - ``reason="missing_usage"`` — usage AND pricing were both unusable and + the ``missing_usage_policy`` setting chose the ceiling (or a refund). + ``total_msats == 0`` under this reason means "charge nothing and + release", per the ``refund`` policy. + + Callers that need to distinguish these (dashboards, estimated markers) + read ``reason``; billing behavior only reads ``total_msats``. + """ + + reason: str = "max_cost" + # Integer because `_cost_field` only handles numeric fields; 1 marks a + # charge derived under missing_usage_policy rather than measured usage. + estimated_flag: int = 0 class CostDataError(BaseModel): @@ -71,6 +90,45 @@ def _empty_cost(cls: type[CostData] = CostData) -> CostData: ) +def _missing_usage_max_cost(max_cost: int) -> MaxCostData: + """Apply ``missing_usage_policy`` when usage AND pricing are unusable. + + The content was served, so the request cannot be free by accident of the + upstream omitting its usage trailer. ``estimate`` keeps legacy behavior + (charge 0, reservation released); ``charge_max`` bills the pre-authorized + ceiling; ``refund`` bills 0 explicitly. The zero-charge variants carry + ``reason="missing_usage"`` so callers can mark the charge as estimated + rather than measured. + """ + policy = (settings.missing_usage_policy or "charge_max").strip().lower() + if policy == "charge_max" and max_cost > 0: + logger.warning( + "No usage data and no usable pricing — applying " + "missing_usage_policy=charge_max: billing the pre-authorized " + "reservation ceiling.", + extra={"max_cost_msats": max_cost}, + ) + return MaxCostData( + base_msats=0, + input_msats=0, + output_msats=0, + total_msats=max_cost, + total_usd=0.0, + reason="missing_usage", + estimated_flag=1, + ) + if policy not in ("estimate", "charge_max", "refund"): + logger.warning( + "Unknown missing_usage_policy %r — treating as 'estimate'", + policy, + ) + zero = _empty_cost(MaxCostData) + assert isinstance(zero, MaxCostData) + zero.reason = "missing_usage" + zero.estimated_flag = 1 + return zero + + async def calculate_cost( response_data: dict, max_cost: int, @@ -124,7 +182,7 @@ async def calculate_cost( else None, }, ) - return _empty_cost(MaxCostData) + return _missing_usage_max_cost(max_cost) usage_data = response_data.get("usage") or {} if not isinstance(usage_data, dict): @@ -253,11 +311,8 @@ async def calculate_cost( rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate) if not all(is_usable_rate(rate) for rate in rates): logger.warning( - "No usable token pricing — releasing the reservation instead of " - "treating its ceiling as the charge. Token counts %s in the " - "upstream response but cannot be converted to money; the request " - "will appear in dashboards with raw counts and a zero charge.", - "are present" if (input_tokens > 0 or output_tokens > 0) else "are zero", + "No usable token pricing — applying missing_usage_policy instead of " + "treating the reservation ceiling as the charge or releasing for free.", extra={ "base_cost_msats": max_cost, "model": response_data.get("model", "unknown"), @@ -267,18 +322,14 @@ async def calculate_cost( "output_rate": output_rate, }, ) - return MaxCostData( - base_msats=0, - input_msats=0, - output_msats=0, - total_msats=0, - input_tokens=input_tokens, - output_tokens=output_tokens, - cache_read_input_tokens=cache_read_tokens, - cache_creation_input_tokens=cache_creation_tokens, - cache_read_msats=0, - cache_creation_msats=0, - ) + missing = _missing_usage_max_cost(max_cost) + # Preserve the raw token counts for dashboards even when no usable + # rate exists to convert them to money. + missing.input_tokens = input_tokens + missing.output_tokens = output_tokens + missing.cache_read_input_tokens = cache_read_tokens + missing.cache_creation_input_tokens = cache_creation_tokens + return missing return _calculate_from_tokens( input_tokens, diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 67a10531..0535dcbd 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -22,6 +22,7 @@ from ..auth import ( release_reservation, ) from ..core import get_logger +from ..core.settings import settings from ..core.db import ( ApiKey, AsyncSession, @@ -152,6 +153,8 @@ def _inject_cost_response_headers( total_usd = float(_cost_field(cost_data, "total_usd", 0.0)) if total_usd: headers["X-Routstr-Cost-Usd"] = str(total_usd) + if _cost_field(cost_data, "estimated_flag", 0) == 1: + headers["X-Routstr-Cost-Estimated"] = "true" def _parse_sse_events(content: str) -> list[tuple[list[str], str]]: @@ -3827,6 +3830,7 @@ class BaseUpstreamProvider: mint: str | None = None, request_id: str | None = None, model_obj: Model | None = None, + request_body: bytes | None = None, ) -> StreamingResponse: """Handle streaming response for X-Cashu payment, calculating refund if needed. @@ -3859,6 +3863,11 @@ class BaseUpstreamProvider: model = None cost_data: CostData | MaxCostData | None = None + # Local estimator fed with every streamed text event — used to build + # an auditable usage estimate when the upstream omits its usage + # trailer, instead of silently keeping the full prepayment. + usage_estimator = MissingUsageEstimator(request_body, model_obj) + lines = content_str.strip().split("\n") for line in lines: if line.startswith("data: "): @@ -3885,6 +3894,29 @@ class BaseUpstreamProvider: usage_data = merged except json.JSONDecodeError: continue + usage_estimator.observe(data_json) + + if usage_data is None: + # No usage trailer: bill from the local token estimate instead of + # keeping the whole prepayment (legacy behavior). Only when the + # estimator produced nothing at all do we fall through with no + # usage, letting `missing_usage_policy` decide. + estimated = usage_estimator.response_data(model) + if estimated["usage"]["output_tokens"] > 0 or ( + estimated["usage"]["input_tokens"] > 0 + ): + logger.warning( + "No usage in streaming x-cashu response — billing from " + "local token estimate", + extra={ + "model": model, + "amount": amount, + "unit": unit, + "estimated_usage": estimated["usage"], + }, + ) + usage_data = estimated["usage"] + model = model or estimated["model"] if usage_data and model: logger.debug( @@ -3965,6 +3997,51 @@ class BaseUpstreamProvider: "unit": unit, }, ) + else: + # Still nothing billable (no usage, no estimate): let + # missing_usage_policy decide instead of silently keeping the + # prepayment (legacy behavior). + try: + cost_data = await self.get_x_cashu_cost( + {"usage": None, "model": model or "unknown"}, + max_cost_for_model, + model_obj, + ) + if cost_data: + _inject_cost_response_headers(response_headers, cost_data) + refund_amount = ( + amount - cost_data.total_msats + if unit == "msat" + else amount - (cost_data.total_msats + 999) // 1000 + ) + if refund_amount > 0: + refund_token = await self.send_refund( + refund_amount, + unit, + mint, + request_id=request_id, + ) + response_headers["X-Cashu"] = refund_token + logger.warning( + "No usage and no estimate in streaming x-cashu " + "response — applied missing_usage_policy", + extra={ + "policy": settings.missing_usage_policy, + "charge_msats": cost_data.total_msats, + "refund_amount": refund_amount, + "model": model, + }, + ) + except Exception as e: + logger.error( + "Error applying missing_usage_policy for streaming x-cashu response", + extra={ + "error": str(e), + "error_type": type(e).__name__, + "amount": amount, + "unit": unit, + }, + ) for i, line in enumerate(lines): if line.startswith("data: "): @@ -4004,6 +4081,7 @@ class BaseUpstreamProvider: mint: str | None = None, request_id: str | None = None, model_obj: Model | None = None, + request_body: bytes | None = None, ) -> Response: """Handle non-streaming response for X-Cashu payment, calculating refund if needed. @@ -4025,6 +4103,29 @@ class BaseUpstreamProvider: try: response_json = json.loads(content_str) self._apply_provider_field(response_json) + if not isinstance(response_json.get("usage"), dict) or not response_json[ + "usage" + ]: + # No upstream usage: bill from the local token estimate rather + # than keeping the full prepayment (legacy behavior). + usage_estimator = MissingUsageEstimator(request_body, model_obj) + usage_estimator.observe(response_json) + estimated = usage_estimator.response_data(response_json.get("model")) + if ( + estimated["usage"]["output_tokens"] > 0 + or estimated["usage"]["input_tokens"] > 0 + ): + logger.warning( + "No usage in non-streaming x-cashu response — billing " + "from local token estimate", + extra={ + "model": response_json.get("model", "unknown"), + "amount": amount, + "unit": unit, + "estimated_usage": estimated["usage"], + }, + ) + response_json["usage"] = estimated["usage"] cost_data = await self.get_x_cashu_cost( response_json, max_cost_for_model, model_obj ) @@ -4184,6 +4285,10 @@ class BaseUpstreamProvider: is_streaming = _is_sse_body( response.headers.get("content-type"), content_str ) + # The original request body is not reachable at this settlement + # seam; pass None so the missing-usage estimator falls back to + # output-text counting only (no prompt-token estimate). + request_body: bytes | None = None logger.debug( "Chat completion response analysis", @@ -4205,6 +4310,7 @@ class BaseUpstreamProvider: mint, request_id=request_id, model_obj=model_obj, + request_body=request_body, ) else: return await self.handle_x_cashu_non_streaming_response( @@ -4216,6 +4322,7 @@ class BaseUpstreamProvider: mint, request_id=request_id, model_obj=model_obj, + request_body=request_body, ) except Exception as e: @@ -4777,6 +4884,10 @@ class BaseUpstreamProvider: is_streaming = _is_sse_body( response.headers.get("content-type"), content_str ) + # The original request body is not reachable at this settlement + # seam; pass None so the missing-usage estimator falls back to + # output-text counting only (no prompt-token estimate). + request_body: bytes | None = None logger.debug( "Responses API completion response analysis", @@ -4798,6 +4909,7 @@ class BaseUpstreamProvider: mint, request_id=request_id, model_obj=model_obj, + request_body=request_body, ) else: return await self.handle_x_cashu_non_streaming_responses_response( @@ -4809,6 +4921,7 @@ class BaseUpstreamProvider: mint, request_id=request_id, model_obj=model_obj, + request_body=request_body, ) except Exception as e: @@ -4837,6 +4950,7 @@ class BaseUpstreamProvider: mint: str | None = None, request_id: str | None = None, model_obj: Model | None = None, + request_body: bytes | None = None, ) -> StreamingResponse: """Handle streaming Responses API response for X-Cashu payment. @@ -4889,11 +5003,13 @@ class BaseUpstreamProvider: model = payload["model"] if usage_data is None: - # No request body is available at this X-Cashu settlement seam, so - # an auditable input/output estimate cannot be built. Refund the - # token rather than treating the authorization ceiling as usage. + # No usage in the stream: with no measured tokens there is no + # auditable estimate, so `missing_usage_policy` decides — + # charge_max keeps the pre-authorized ceiling, refund/estimate + # release it. The charge itself comes from get_x_cashu_cost -> + # calculate_cost below. logger.warning( - "No usage in streaming Responses API response — refunding instead of charging the authorized max", + "No usage in streaming Responses API response — applying missing_usage_policy", extra={ "model": model, "amount": amount, @@ -5023,6 +5139,7 @@ class BaseUpstreamProvider: mint: str | None = None, request_id: str | None = None, model_obj: Model | None = None, + request_body: bytes | None = None, ) -> Response: """Handle non-streaming Responses API response for X-Cashu payment.""" logger.debug( diff --git a/tests/unit/test_missing_usage_policy.py b/tests/unit/test_missing_usage_policy.py new file mode 100644 index 00000000..81cbf553 --- /dev/null +++ b/tests/unit/test_missing_usage_policy.py @@ -0,0 +1,216 @@ +"""Missing-usage billing policy tests. + +Covers the three money paths touched by `missing_usage_policy`: + +1. `calculate_cost` with NO usage at all (the `_empty_cost(MaxCostData)` dead-end). +2. `calculate_cost` with token counts but unusable pricing (the second dead-end). +3. `adjust_payment_for_tokens` `CostDataError` handling — must NOT raise a + post-delivery 400. +4. X-Cashu non-streaming handler bills from the local estimate when the + upstream omits usage. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from routstr.payment import cost_calculation +from routstr.payment.cost_calculation import ( + CostData, + MaxCostData, + calculate_cost, +) +from routstr.payment.usage import normalize_usage + + +def _response(usage=None): + data = {"model": "gpt-4o", "id": "x", "object": "chat.completion"} + if usage is not None: + data["usage"] = usage + return data + + +# --------------------------------------------------------------------------- +# calculate_cost: no usage at all +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_no_usage_charge_max_bills_ceiling(monkeypatch): + monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "charge_max") + cost = await calculate_cost(_response(), max_cost=50_000, model_obj=None) + assert isinstance(cost, MaxCostData) + assert cost.total_msats == 50_000 + assert cost.reason == "missing_usage" + assert cost.estimated_flag == 1 + + +@pytest.mark.asyncio +async def test_no_usage_estimate_bills_zero(monkeypatch): + monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "estimate") + cost = await calculate_cost(_response(), max_cost=50_000, model_obj=None) + assert isinstance(cost, MaxCostData) + assert cost.total_msats == 0 + assert cost.reason == "missing_usage" + + +@pytest.mark.asyncio +async def test_no_usage_refund_bills_zero(monkeypatch): + monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "refund") + cost = await calculate_cost(_response(), max_cost=50_000, model_obj=None) + assert cost.total_msats == 0 + assert cost.reason == "missing_usage" + + +@pytest.mark.asyncio +async def test_no_usage_unknown_policy_treated_as_estimate(monkeypatch): + monkeypatch.setattr( + cost_calculation.settings, "missing_usage_policy", "garbage" + ) + cost = await calculate_cost(_response(), max_cost=50_000, model_obj=None) + assert cost.total_msats == 0 + + +@pytest.mark.asyncio +async def test_no_usage_charge_max_zero_ceiling_stays_zero(monkeypatch): + monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "charge_max") + cost = await calculate_cost(_response(), max_cost=0, model_obj=None) + assert cost.total_msats == 0 + + +# --------------------------------------------------------------------------- +# calculate_cost: tokens present but pricing unusable +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_tokens_without_pricing_charge_max(monkeypatch): + monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "charge_max") + # NaN pricing rates fail the usable-rate gate -> policy applies. + with patch.object( + cost_calculation, + "_get_pricing_rates", + return_value=(float("nan"), 1.0, 1.0, 1.0), + ): + usage = {"prompt_tokens": 100, "completion_tokens": 50} + cost = await calculate_cost(_response(usage), max_cost=9_999, model_obj=None) + assert isinstance(cost, MaxCostData) + assert cost.total_msats == 9_999 + assert cost.reason == "missing_usage" + assert cost.input_tokens == 100 + assert cost.output_tokens == 50 + + +@pytest.mark.asyncio +async def test_tokens_without_pricing_estimate(monkeypatch): + monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "estimate") + with patch.object( + cost_calculation, + "_get_pricing_rates", + return_value=(float("nan"), 1.0, 1.0, 1.0), + ): + usage = {"prompt_tokens": 100, "completion_tokens": 50} + cost = await calculate_cost(_response(usage), max_cost=9_999, model_obj=None) + assert cost.total_msats == 0 + # Token counts still surface for dashboards. + assert cost.input_tokens == 100 + assert cost.output_tokens == 50 + + +# --------------------------------------------------------------------------- +# adjust_payment_for_tokens: CostDataError must not raise post-delivery +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cost_data_error_charge_max_charges_and_returns(monkeypatch): + from routstr.auth import ReservationSnapshot, adjust_payment_for_tokens + from routstr.core.db import ApiKey + + monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "charge_max") + + key = ApiKey(hashed_key="a" * 64, balance=1_000_000, reserved_balance=50_000) + + reservation = ReservationSnapshot( + release_id="rel-1", + key_hash=key.hashed_key, + billing_key_hash=key.hashed_key, + reserved_msats=50_000, + ) + + with ( + patch("routstr.auth._validate_reservation_snapshot", new=AsyncMock()), + patch("routstr.auth._stop_reservation_heartbeat", new=AsyncMock()), + patch("routstr.auth._claim_reservation_for_charge", new=AsyncMock(return_value=True)), + patch("routstr.auth._charge_reservation_rows", new=AsyncMock(return_value=True)), + patch("routstr.auth.get_reservation_snapshot", new=AsyncMock(return_value=reservation)), + patch( + "routstr.auth.accumulate_routstr_fee", + new=AsyncMock(), + ) as accumulate_fee, + ): + cost = await adjust_payment_for_tokens( + key, + _response(), # no usage -> policy path via MaxCostData + session=AsyncMock(), + deducted_max_cost=50_000, + reservation_snapshot=reservation, + ) + # The MaxCostData path bills the ceiling through normal finalization. + assert cost["total_msats"] == 50_000 + assert cost["charged_msats"] == 50_000 + + +@pytest.mark.asyncio +async def test_cost_data_error_path_returns_dict_not_raise(monkeypatch): + """Force a genuine CostDataError (pricing ValueError) under 'refund'.""" + from routstr.auth import ReservationSnapshot, adjust_payment_for_tokens + from routstr.core.db import ApiKey + + monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "refund") + + usage = {"prompt_tokens": 10, "completion_tokens": 5} + # No model_obj and no fixed pricing -> usable-rate gate... but tokens are + # present, so to force a CostDataError we patch _get_pricing_rates. + with ( + patch.object( + cost_calculation, + "_get_pricing_rates", + side_effect=ValueError("no pricing for model"), + ), + patch("routstr.auth._validate_reservation_snapshot", new=AsyncMock()), + patch("routstr.auth._stop_reservation_heartbeat", new=AsyncMock()), + patch("routstr.auth._claim_reservation_for_charge", new=AsyncMock(return_value=True)), + patch("routstr.auth._charge_reservation_rows", new=AsyncMock(return_value=True)), + patch("routstr.auth.release_reservation", new=AsyncMock(return_value=True)), + patch( + "routstr.auth.accumulate_routstr_fee", + new=AsyncMock(), + ), + ): + cost = await adjust_payment_for_tokens( + ApiKey(hashed_key="a" * 64), + _response(usage), + session=AsyncMock(), + deducted_max_cost=7_000, + reservation_snapshot=ReservationSnapshot( + release_id="rel-2", + key_hash="a" * 64, + billing_key_hash="a" * 64, + reserved_msats=7_000, + ), + ) + assert isinstance(cost, dict) + assert cost["total_msats"] == 0 + assert cost["reason"] == "missing_usage" + assert cost["estimated"] is True + assert cost["error"]["code"] == "pricing_error" + + +# --------------------------------------------------------------------------- +# X-Cashu: estimator wiring +# --------------------------------------------------------------------------- + + +def test_normalize_usage_rejects_none(): + assert normalize_usage(None) is None diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py index 96b478d1..4f33aacf 100644 --- a/tests/unit/test_pricing_rate_validation.py +++ b/tests/unit/test_pricing_rate_validation.py @@ -77,13 +77,20 @@ def _usage_response() -> dict[str, Any]: async def test_unusable_token_rate_never_charges_the_reservation( bad_rate: float, ) -> None: - """An unusable configured rate must not turn authorization into usage.""" + """An unusable configured rate must not turn authorization into usage. + + Under the default ``charge_max`` policy the request is still billed the + pre-authorized ceiling (never MORE than it), with the raw token counts + preserved for dashboards. A zero rate remains a price (see + ``test_a_rate_of_zero_is_billed_as_free_not_as_missing``). + """ model = _model(Pricing(prompt=bad_rate, completion=1.0)) cost = await calculate_cost(_usage_response(), max_cost=1234, model_obj=model) assert isinstance(cost, MaxCostData) - assert cost.total_msats == 0 + assert cost.total_msats == 1234 + assert cost.reason == "missing_usage" assert (cost.input_tokens, cost.output_tokens) == (1000, 500) diff --git a/tests/unit/test_x_cashu_responses_streaming_sse.py b/tests/unit/test_x_cashu_responses_streaming_sse.py index 08154e40..79769ae3 100644 --- a/tests/unit/test_x_cashu_responses_streaming_sse.py +++ b/tests/unit/test_x_cashu_responses_streaming_sse.py @@ -187,6 +187,8 @@ async def test_multiline_data_payload_is_parsed_and_reframed() -> None: @pytest.mark.asyncio async def test_missing_usage_refunds_instead_of_charging_authorized_max() -> None: + """No usage in the stream: ``missing_usage_policy`` (default charge_max) + bills the pre-authorized ceiling and refunds only the difference.""" chunks = [ b'data: {"type":"response.created","response":{"model":"gpt-5-mini"}}\r\n\r\n', b"data: [DONE]\r\n\r\n", @@ -198,9 +200,10 @@ async def test_missing_usage_refunds_instead_of_charging_authorized_max() -> Non send_refund.assert_awaited_once() assert send_refund.await_args is not None - assert send_refund.await_args.args[0] == 10_000 + assert send_refund.await_args.args[0] == 10_000 - 9_000 assert response.headers["x-cashu"] == "cashuBrefundtoken0123456789" - assert response.headers["x-routstr-cost-msats"] == "0" + assert response.headers["x-routstr-cost-msats"] == "9000" + assert response.headers["x-routstr-cost-estimated"] == "true" @pytest.mark.asyncio @@ -215,7 +218,7 @@ async def test_malformed_events_do_not_retain_whole_token() -> None: ) assert send_refund.await_args is not None - assert send_refund.await_args.args[0] == 10_000 + assert send_refund.await_args.args[0] == 10_000 - 9_000 body = await _collect(response) assert b"\\n" not in body assert body.endswith(b"\n\n") From ac40d70c3ac3a235f0329f7b6596344e01f9187b Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 7 Sep 2026 00:30:05 +0200 Subject: [PATCH 09/30] clean up --- routstr/auth.py | 143 ++------- routstr/core/settings.py | 8 - routstr/payment/cost_calculation.py | 89 ++---- routstr/upstream/base.py | 300 +++++++----------- tests/integration/test_payment_invariants.py | 20 +- tests/unit/test_cost_error_after_delivery.py | 47 +++ tests/unit/test_missing_usage_policy.py | 216 ------------- tests/unit/test_pricing_rate_validation.py | 11 +- tests/unit/test_x_cashu_missing_usage.py | 141 ++++++++ .../test_x_cashu_responses_streaming_sse.py | 9 +- 10 files changed, 365 insertions(+), 619 deletions(-) create mode 100644 tests/unit/test_cost_error_after_delivery.py delete mode 100644 tests/unit/test_missing_usage_policy.py create mode 100644 tests/unit/test_x_cashu_missing_usage.py diff --git a/routstr/auth.py b/routstr/auth.py index fb573835..bf29e86e 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -1191,13 +1191,27 @@ async def adjust_payment_for_tokens( calculated_cost = await calculate_cost( response_data, deducted_max_cost, model_obj, provider_fee ) - if not isinstance(calculated_cost, CostDataError): - if not await _claim_reservation_for_charge(reservation, session): - # A prior charge or release already owns this reservation. Returning - # the calculated metadata is safe; the aggregate balances must not - # be modified a second time. - calculated_cost.charged_msats = 0 - return calculated_cost.dict() + if isinstance(calculated_cost, CostDataError): + # Content was already served, so release instead of raising a 400. + logger.error( + "Cost calculation error during payment adjustment, releasing reservation", + extra={ + "key_hash": key_log_hash, + "model": model, + "error_message": calculated_cost.message, + "error_code": calculated_cost.code, + }, + ) + calculated_cost = MaxCostData( + base_msats=0, input_msats=0, output_msats=0, total_msats=0 + ) + + if not await _claim_reservation_for_charge(reservation, session): + # A prior charge or release already owns this reservation. Returning + # the calculated metadata is safe; the aggregate balances must not + # be modified a second time. + calculated_cost.charged_msats = 0 + return calculated_cost.dict() match calculated_cost: case MaxCostData() as cost: @@ -1522,121 +1536,6 @@ async def adjust_payment_for_tokens( return cost.dict() - case CostDataError() as error: - # Pricing derivation failed AFTER the upstream served the request. - # Raising here would hand the client a 400 for content it already - # received (streaming) while the provider eats the upstream cost. - # Apply missing_usage_policy instead: keep the pre-authorized - # ceiling (charge_max), or release without charging - # (estimate/refund) and let the response complete. - policy = (settings.missing_usage_policy or "charge_max").strip().lower() - logger.error( - "Cost calculation error during payment adjustment — applying " - "missing_usage_policy=%s instead of raising", - policy, - extra={ - "key_hash": key.hashed_key[:8] + "...", - "model": model, - "error_message": error.message, - "error_code": error.code, - }, - ) - if policy == "charge_max": - charged = await _charge_reservation_rows( - session, - billing_key_hash=billing_key.hashed_key, - reserved_msats=deducted_max_cost, - charge_msats=deducted_max_cost, - ) - if charged: - await session.commit() - await _stop_reservation_heartbeat(reservation.release_id) - await session.refresh(billing_key) - await _accumulate_fee(deducted_max_cost) - payments_logger.info( - "FINALIZE", - extra={ - "event": "finalize", - "key_hash": key.hashed_key[:8] + "...", - "billing_key_hash": billing_log_hash, - "model": model, - "cost_reserved": deducted_max_cost, - "cost_charged": deducted_max_cost, - "input_tokens": 0, - "output_tokens": 0, - "balance": billing_key.balance, - "reserved_balance": billing_key.reserved_balance, - "total_spent": billing_key.total_spent, - "finalize_type": "missing_usage_policy", - }, - ) - return { - "base_msats": 0, - "input_msats": 0, - "output_msats": 0, - "total_msats": deducted_max_cost, - "total_usd": 0.0, - "input_tokens": 0, - "output_tokens": 0, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, - "cache_read_msats": 0, - "cache_creation_msats": 0, - "charged_msats": deducted_max_cost, - "reason": "missing_usage", - "estimated": True, - } - logger.error( - "Failed to charge reservation under missing_usage_policy=charge_max " - "— releasing instead", - extra={ - "key_hash": key_log_hash, - "model": model, - "error_message": error.message, - }, - ) - else: - if policy in ("estimate", "refund"): - logger.warning( - "Releasing reservation without charging under " - "missing_usage_policy=%s", - policy, - extra={ - "key_hash": key_log_hash, - "model": model, - "error_message": error.message, - "error_code": error.code, - }, - ) - else: - logger.warning( - "Unknown missing_usage_policy %r — treating as 'estimate'", - policy, - extra={ - "key_hash": key_log_hash, - "model": model, - "error_message": error.message, - "error_code": error.code, - }, - ) - await release_reservation_only() - return { - "base_msats": 0, - "input_msats": 0, - "output_msats": 0, - "total_msats": 0, - "total_usd": 0.0, - "input_tokens": 0, - "output_tokens": 0, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, - "cache_read_msats": 0, - "cache_creation_msats": 0, - "charged_msats": 0, - "reason": "missing_usage", - "estimated": True, - "error": {"message": error.message, "code": error.code}, - } # All calculate_cost variants are handled above. raise AssertionError("Unreachable: unhandled calculate_cost result") diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 8005c453..014a5795 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -79,14 +79,6 @@ class Settings(BaseSettings): tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE") # Minimum per-request charge in millisatoshis when model pricing is free/zero min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT") - # Policy when an upstream response carries no usable usage AND no usable - # pricing (content was served, cost cannot be measured or derived): - # estimate — charge whatever a local token estimate yields (may be 0) - # charge_max — keep the prepayment/reservation (user pre-authorized it) - # refund — release the reservation / refund the full prepayment - missing_usage_policy: str = Field( - default="charge_max", env="MISSING_USAGE_POLICY" - ) reset_reserved_balance_on_startup: bool = Field( default=True, env="RESET_RESERVED_BALANCE_ON_STARTUP" ) # deactivate in horizontal scaling setups diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py index 7592958f..b4b0ff6b 100644 --- a/routstr/payment/cost_calculation.py +++ b/routstr/payment/cost_calculation.py @@ -41,26 +41,7 @@ class CostData(BaseModel): class MaxCostData(CostData): - """Reservation-ceiling billing. - - Two distinct meanings ride on this class: - - - ``reason="max_cost"`` — pricing is usable but the response is empty or - the upstream reports a USD cost with zero tokens; the ceiling is the - agreed charge for a served-but-unmeasurable request. - - ``reason="missing_usage"`` — usage AND pricing were both unusable and - the ``missing_usage_policy`` setting chose the ceiling (or a refund). - ``total_msats == 0`` under this reason means "charge nothing and - release", per the ``refund`` policy. - - Callers that need to distinguish these (dashboards, estimated markers) - read ``reason``; billing behavior only reads ``total_msats``. - """ - - reason: str = "max_cost" - # Integer because `_cost_field` only handles numeric fields; 1 marks a - # charge derived under missing_usage_policy rather than measured usage. - estimated_flag: int = 0 + pass class CostDataError(BaseModel): @@ -90,45 +71,6 @@ def _empty_cost(cls: type[CostData] = CostData) -> CostData: ) -def _missing_usage_max_cost(max_cost: int) -> MaxCostData: - """Apply ``missing_usage_policy`` when usage AND pricing are unusable. - - The content was served, so the request cannot be free by accident of the - upstream omitting its usage trailer. ``estimate`` keeps legacy behavior - (charge 0, reservation released); ``charge_max`` bills the pre-authorized - ceiling; ``refund`` bills 0 explicitly. The zero-charge variants carry - ``reason="missing_usage"`` so callers can mark the charge as estimated - rather than measured. - """ - policy = (settings.missing_usage_policy or "charge_max").strip().lower() - if policy == "charge_max" and max_cost > 0: - logger.warning( - "No usage data and no usable pricing — applying " - "missing_usage_policy=charge_max: billing the pre-authorized " - "reservation ceiling.", - extra={"max_cost_msats": max_cost}, - ) - return MaxCostData( - base_msats=0, - input_msats=0, - output_msats=0, - total_msats=max_cost, - total_usd=0.0, - reason="missing_usage", - estimated_flag=1, - ) - if policy not in ("estimate", "charge_max", "refund"): - logger.warning( - "Unknown missing_usage_policy %r — treating as 'estimate'", - policy, - ) - zero = _empty_cost(MaxCostData) - assert isinstance(zero, MaxCostData) - zero.reason = "missing_usage" - zero.estimated_flag = 1 - return zero - - async def calculate_cost( response_data: dict, max_cost: int, @@ -182,7 +124,7 @@ async def calculate_cost( else None, }, ) - return _missing_usage_max_cost(max_cost) + return _empty_cost(MaxCostData) usage_data = response_data.get("usage") or {} if not isinstance(usage_data, dict): @@ -311,8 +253,11 @@ async def calculate_cost( rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate) if not all(is_usable_rate(rate) for rate in rates): logger.warning( - "No usable token pricing — applying missing_usage_policy instead of " - "treating the reservation ceiling as the charge or releasing for free.", + "No usable token pricing — releasing the reservation instead of " + "treating its ceiling as the charge. Token counts %s in the " + "upstream response but cannot be converted to money; the request " + "will appear in dashboards with raw counts and a zero charge.", + "are present" if (input_tokens > 0 or output_tokens > 0) else "are zero", extra={ "base_cost_msats": max_cost, "model": response_data.get("model", "unknown"), @@ -322,14 +267,18 @@ async def calculate_cost( "output_rate": output_rate, }, ) - missing = _missing_usage_max_cost(max_cost) - # Preserve the raw token counts for dashboards even when no usable - # rate exists to convert them to money. - missing.input_tokens = input_tokens - missing.output_tokens = output_tokens - missing.cache_read_input_tokens = cache_read_tokens - missing.cache_creation_input_tokens = cache_creation_tokens - return missing + return MaxCostData( + base_msats=0, + input_msats=0, + output_msats=0, + total_msats=0, + input_tokens=input_tokens, + output_tokens=output_tokens, + cache_read_input_tokens=cache_read_tokens, + cache_creation_input_tokens=cache_creation_tokens, + cache_read_msats=0, + cache_creation_msats=0, + ) return _calculate_from_tokens( input_tokens, diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 0535dcbd..2ea06e0b 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -22,7 +22,6 @@ from ..auth import ( release_reservation, ) from ..core import get_logger -from ..core.settings import settings from ..core.db import ( ApiKey, AsyncSession, @@ -153,8 +152,15 @@ def _inject_cost_response_headers( total_usd = float(_cost_field(cost_data, "total_usd", 0.0)) if total_usd: headers["X-Routstr-Cost-Usd"] = str(total_usd) - if _cost_field(cost_data, "estimated_flag", 0) == 1: - headers["X-Routstr-Cost-Estimated"] = "true" + + +def _estimated_usage( + estimator: MissingUsageEstimator, model: str | None +) -> dict[str, Any] | None: + """Local usage estimate, or None when the upstream generated no text.""" + if not estimator.output_text: + return None + return estimator.response_data(model)["usage"] def _parse_sse_events(content: str) -> list[tuple[list[str], str]]: @@ -3731,23 +3737,17 @@ class BaseUpstreamProvider: ) return cost case CostDataError() as error: + # Content was already served, so refund instead of raising. logger.error( - "Cost calculation error", + "Cost calculation error, refunding the prepayment", extra={ "model": model, "error_message": error.message, "error_code": error.code, }, ) - raise HTTPException( - status_code=400, - detail={ - "error": { - "message": error.message, - "type": "invalid_request_error", - "code": error.code, - } - }, + return MaxCostData( + base_msats=0, input_msats=0, output_msats=0, total_msats=0 ) return None @@ -3862,10 +3862,6 @@ class BaseUpstreamProvider: usage_data = None model = None cost_data: CostData | MaxCostData | None = None - - # Local estimator fed with every streamed text event — used to build - # an auditable usage estimate when the upstream omits its usage - # trailer, instead of silently keeping the full prepayment. usage_estimator = MissingUsageEstimator(request_body, model_obj) lines = content_str.strip().split("\n") @@ -3897,152 +3893,97 @@ class BaseUpstreamProvider: usage_estimator.observe(data_json) if usage_data is None: - # No usage trailer: bill from the local token estimate instead of - # keeping the whole prepayment (legacy behavior). Only when the - # estimator produced nothing at all do we fall through with no - # usage, letting `missing_usage_policy` decide. - estimated = usage_estimator.response_data(model) - if estimated["usage"]["output_tokens"] > 0 or ( - estimated["usage"]["input_tokens"] > 0 - ): + usage_data = _estimated_usage(usage_estimator, model) + if usage_data: logger.warning( - "No usage in streaming x-cashu response — billing from " - "local token estimate", + "No usage in streaming response, billing from local token estimate", extra={ "model": model, "amount": amount, "unit": unit, - "estimated_usage": estimated["usage"], + "estimated_usage": usage_data, }, ) - usage_data = estimated["usage"] - model = model or estimated["model"] - if usage_data and model: - logger.debug( - "Found usage data in streaming response", + logger.debug( + "Calculating cost for streaming response", + extra={ + "model": model, + "usage_data": usage_data, + "amount": amount, + "unit": unit, + }, + ) + + response_data = {"usage": usage_data, "model": model or "unknown"} + try: + cost_data = await self.get_x_cashu_cost( + response_data, max_cost_for_model, model_obj + ) + if cost_data: + if unit == "msat": + refund_amount = amount - cost_data.total_msats + elif unit == "sat": + refund_amount = amount - (cost_data.total_msats + 999) // 1000 + else: + raise ValueError(f"Invalid unit: {unit}") + + if refund_amount > 0: + logger.debug( + "Processing refund for streaming response", + extra={ + "original_amount": amount, + "cost_msats": cost_data.total_msats, + "refund_amount": refund_amount, + "unit": unit, + "model": model, + }, + ) + + refund_token = await self.send_refund( + refund_amount, + unit, + mint, + request_id=request_id, + ) + response_headers["X-Cashu"] = refund_token + + logger.info( + "Refund processed for streaming response", + extra={ + "refund_amount": refund_amount, + "unit": unit, + "refund_token_preview": refund_token[:20] + "..." + if len(refund_token) > 20 + else refund_token, + }, + ) + else: + logger.debug( + "No refund needed for streaming response", + extra={ + "amount": amount, + "cost_msats": cost_data.total_msats, + "model": model, + }, + ) + + # Inject cost breakdown headers so the SDK's + # extractUsageFromResponseHeaders can populate + # inputMsats/outputMsats/totalMsats for x-cashu requests. + _inject_cost_response_headers(response_headers, cost_data) + except Exception as e: + logger.error( + "Error calculating cost for streaming response", extra={ + "error": str(e), + "error_type": type(e).__name__, "model": model, - "usage_data": usage_data, "amount": amount, "unit": unit, }, ) - response_data = {"usage": usage_data, "model": model} - try: - cost_data = await self.get_x_cashu_cost( - response_data, max_cost_for_model, model_obj - ) - if cost_data: - if unit == "msat": - refund_amount = amount - cost_data.total_msats - elif unit == "sat": - refund_amount = amount - (cost_data.total_msats + 999) // 1000 - else: - raise ValueError(f"Invalid unit: {unit}") - - if refund_amount > 0: - logger.debug( - "Processing refund for streaming response", - extra={ - "original_amount": amount, - "cost_msats": cost_data.total_msats, - "refund_amount": refund_amount, - "unit": unit, - "model": model, - }, - ) - - refund_token = await self.send_refund( - refund_amount, - unit, - mint, - request_id=request_id, - ) - response_headers["X-Cashu"] = refund_token - - logger.info( - "Refund processed for streaming response", - extra={ - "refund_amount": refund_amount, - "unit": unit, - "refund_token_preview": refund_token[:20] + "..." - if len(refund_token) > 20 - else refund_token, - }, - ) - else: - logger.debug( - "No refund needed for streaming response", - extra={ - "amount": amount, - "cost_msats": cost_data.total_msats, - "model": model, - }, - ) - - # Inject cost breakdown headers so the SDK's - # extractUsageFromResponseHeaders can populate - # inputMsats/outputMsats/totalMsats for x-cashu requests. - _inject_cost_response_headers(response_headers, cost_data) - except Exception as e: - logger.error( - "Error calculating cost for streaming response", - extra={ - "error": str(e), - "error_type": type(e).__name__, - "model": model, - "amount": amount, - "unit": unit, - }, - ) - else: - # Still nothing billable (no usage, no estimate): let - # missing_usage_policy decide instead of silently keeping the - # prepayment (legacy behavior). - try: - cost_data = await self.get_x_cashu_cost( - {"usage": None, "model": model or "unknown"}, - max_cost_for_model, - model_obj, - ) - if cost_data: - _inject_cost_response_headers(response_headers, cost_data) - refund_amount = ( - amount - cost_data.total_msats - if unit == "msat" - else amount - (cost_data.total_msats + 999) // 1000 - ) - if refund_amount > 0: - refund_token = await self.send_refund( - refund_amount, - unit, - mint, - request_id=request_id, - ) - response_headers["X-Cashu"] = refund_token - logger.warning( - "No usage and no estimate in streaming x-cashu " - "response — applied missing_usage_policy", - extra={ - "policy": settings.missing_usage_policy, - "charge_msats": cost_data.total_msats, - "refund_amount": refund_amount, - "model": model, - }, - ) - except Exception as e: - logger.error( - "Error applying missing_usage_policy for streaming x-cashu response", - extra={ - "error": str(e), - "error_type": type(e).__name__, - "amount": amount, - "unit": unit, - }, - ) - for i, line in enumerate(lines): if line.startswith("data: "): try: @@ -4103,29 +4044,23 @@ class BaseUpstreamProvider: try: response_json = json.loads(content_str) self._apply_provider_field(response_json) - if not isinstance(response_json.get("usage"), dict) or not response_json[ - "usage" - ]: - # No upstream usage: bill from the local token estimate rather - # than keeping the full prepayment (legacy behavior). + if not response_json.get("usage"): usage_estimator = MissingUsageEstimator(request_body, model_obj) usage_estimator.observe(response_json) - estimated = usage_estimator.response_data(response_json.get("model")) - if ( - estimated["usage"]["output_tokens"] > 0 - or estimated["usage"]["input_tokens"] > 0 - ): + estimated = _estimated_usage( + usage_estimator, response_json.get("model") + ) + if estimated: logger.warning( - "No usage in non-streaming x-cashu response — billing " - "from local token estimate", + "No usage in non-streaming response, billing from local token estimate", extra={ "model": response_json.get("model", "unknown"), "amount": amount, "unit": unit, - "estimated_usage": estimated["usage"], + "estimated_usage": estimated, }, ) - response_json["usage"] = estimated["usage"] + response_json["usage"] = estimated cost_data = await self.get_x_cashu_cost( response_json, max_cost_for_model, model_obj ) @@ -4260,6 +4195,7 @@ class BaseUpstreamProvider: mint: str | None = None, request_id: str | None = None, model_obj: Model | None = None, + request_body: bytes | None = None, ) -> StreamingResponse | Response: """Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming. @@ -4285,10 +4221,6 @@ class BaseUpstreamProvider: is_streaming = _is_sse_body( response.headers.get("content-type"), content_str ) - # The original request body is not reachable at this settlement - # seam; pass None so the missing-usage estimator falls back to - # output-text counting only (no prompt-token estimate). - request_body: bytes | None = None logger.debug( "Chat completion response analysis", @@ -4517,6 +4449,7 @@ class BaseUpstreamProvider: mint, request_id=getattr(request.state, "request_id", None), model_obj=model_obj, + request_body=request_body, ) background_tasks = BackgroundTasks() background_tasks.add_task(response.aclose) @@ -4807,6 +4740,7 @@ class BaseUpstreamProvider: mint, request_id=getattr(request.state, "request_id", None), model_obj=model_obj, + request_body=request_body, ) background_tasks = BackgroundTasks() background_tasks.add_task(response.aclose) @@ -4858,6 +4792,7 @@ class BaseUpstreamProvider: mint: str | None = None, request_id: str | None = None, model_obj: Model | None = None, + request_body: bytes | None = None, ) -> StreamingResponse | Response: """Handle Responses API completion response for X-Cashu payment. @@ -4884,10 +4819,6 @@ class BaseUpstreamProvider: is_streaming = _is_sse_body( response.headers.get("content-type"), content_str ) - # The original request body is not reachable at this settlement - # seam; pass None so the missing-usage estimator falls back to - # output-text counting only (no prompt-token estimate). - request_body: bytes | None = None logger.debug( "Responses API completion response analysis", @@ -4977,6 +4908,7 @@ class BaseUpstreamProvider: model: str | None = None reasoning_tokens = 0 cost_data: CostData | MaxCostData | None = None + usage_estimator = MissingUsageEstimator(request_body, model_obj) for _fields, data in events: if data.strip() == "[DONE]": @@ -4987,6 +4919,7 @@ class BaseUpstreamProvider: continue if not isinstance(data_json, dict): continue + usage_estimator.observe(data_json) # Canonical Responses API events carry model and usage nested under # "response" (response.completed/incomplete); older shapes put them # at the top level. @@ -5003,18 +4936,14 @@ class BaseUpstreamProvider: model = payload["model"] if usage_data is None: - # No usage in the stream: with no measured tokens there is no - # auditable estimate, so `missing_usage_policy` decides — - # charge_max keeps the pre-authorized ceiling, refund/estimate - # release it. The charge itself comes from get_x_cashu_cost -> - # calculate_cost below. + usage_data = _estimated_usage(usage_estimator, model) logger.warning( - "No usage in streaming Responses API response — applying missing_usage_policy", + "No usage in streaming Responses API response, billing from local token estimate", extra={ "model": model, "amount": amount, "unit": unit, - "max_cost_msats": max_cost_for_model, + "estimated_usage": usage_data, }, ) else: @@ -5150,6 +5079,23 @@ class BaseUpstreamProvider: try: response_json = json.loads(content_str) self._apply_provider_field(response_json) + if not response_json.get("usage"): + usage_estimator = MissingUsageEstimator(request_body, model_obj) + usage_estimator.observe(response_json) + estimated = _estimated_usage( + usage_estimator, response_json.get("model") + ) + if estimated: + logger.warning( + "No usage in non-streaming Responses API response, billing from local token estimate", + extra={ + "model": response_json.get("model", "unknown"), + "amount": amount, + "unit": unit, + "estimated_usage": estimated, + }, + ) + response_json["usage"] = estimated cost_data = await self.get_x_cashu_cost( response_json, max_cost_for_model, model_obj ) diff --git a/tests/integration/test_payment_invariants.py b/tests/integration/test_payment_invariants.py index 6209aff9..3d5460dd 100644 --- a/tests/integration/test_payment_invariants.py +++ b/tests/integration/test_payment_invariants.py @@ -12,7 +12,6 @@ import uuid from unittest.mock import patch import pytest -from fastapi import HTTPException from sqlmodel import col, select, update from sqlmodel.ext.asyncio.session import AsyncSession @@ -305,21 +304,20 @@ async def test_cost_error_releases_the_reservation_without_charging( "routstr.auth.calculate_cost", return_value=CostDataError(message="no pricing", code="pricing_error"), ): - with pytest.raises(HTTPException) as exc: - await adjust_payment_for_tokens( - key, - _response(), - integration_session, - reserved, - reservation_snapshot=reservation, - ) - assert exc.value.status_code == 400 + cost = await adjust_payment_for_tokens( + key, + _response(), + integration_session, + reserved, + reservation_snapshot=reservation, + ) + assert cost["charged_msats"] == 0 key = await integration_session.get(ApiKey, key_hash) assert key is not None assert key.balance == 10_000, "a pricing failure must not charge the user" assert key.total_spent == 0 - assert key.reserved_balance == 0, "funds must not stay locked after a 400" + assert key.reserved_balance == 0, "funds must not stay locked" assert await _active_reservations(integration_session) == 0 diff --git a/tests/unit/test_cost_error_after_delivery.py b/tests/unit/test_cost_error_after_delivery.py new file mode 100644 index 00000000..5540836d --- /dev/null +++ b/tests/unit/test_cost_error_after_delivery.py @@ -0,0 +1,47 @@ +"""A pricing failure after the upstream served content must not raise a 400.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from routstr.auth import ReservationSnapshot, adjust_payment_for_tokens +from routstr.core.db import ApiKey +from routstr.payment import cost_calculation + + +@pytest.mark.asyncio +async def test_cost_data_error_releases_without_raising() -> None: + key_hash = "a" * 64 + reservation = ReservationSnapshot( + release_id="rel-1", + key_hash=key_hash, + billing_key_hash=key_hash, + reserved_msats=7_000, + ) + with ( + patch.object( + cost_calculation, + "_get_pricing_rates", + side_effect=ValueError("no pricing for model"), + ), + patch("routstr.auth._validate_reservation_snapshot", new=AsyncMock()), + patch("routstr.auth._stop_reservation_heartbeat", new=AsyncMock()), + patch( + "routstr.auth._claim_reservation_for_charge", + new=AsyncMock(return_value=True), + ), + patch( + "routstr.auth._charge_reservation_rows", new=AsyncMock(return_value=True) + ), + patch("routstr.auth.accumulate_routstr_fee", new=AsyncMock()), + ): + cost = await adjust_payment_for_tokens( + ApiKey(hashed_key=key_hash), + {"model": "gpt-4o", "usage": {"prompt_tokens": 10, "completion_tokens": 5}}, + session=AsyncMock(), + deducted_max_cost=7_000, + reservation_snapshot=reservation, + ) + + assert cost["total_msats"] == 0 + assert cost["charged_msats"] == 0 diff --git a/tests/unit/test_missing_usage_policy.py b/tests/unit/test_missing_usage_policy.py deleted file mode 100644 index 81cbf553..00000000 --- a/tests/unit/test_missing_usage_policy.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Missing-usage billing policy tests. - -Covers the three money paths touched by `missing_usage_policy`: - -1. `calculate_cost` with NO usage at all (the `_empty_cost(MaxCostData)` dead-end). -2. `calculate_cost` with token counts but unusable pricing (the second dead-end). -3. `adjust_payment_for_tokens` `CostDataError` handling — must NOT raise a - post-delivery 400. -4. X-Cashu non-streaming handler bills from the local estimate when the - upstream omits usage. -""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from routstr.payment import cost_calculation -from routstr.payment.cost_calculation import ( - CostData, - MaxCostData, - calculate_cost, -) -from routstr.payment.usage import normalize_usage - - -def _response(usage=None): - data = {"model": "gpt-4o", "id": "x", "object": "chat.completion"} - if usage is not None: - data["usage"] = usage - return data - - -# --------------------------------------------------------------------------- -# calculate_cost: no usage at all -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_no_usage_charge_max_bills_ceiling(monkeypatch): - monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "charge_max") - cost = await calculate_cost(_response(), max_cost=50_000, model_obj=None) - assert isinstance(cost, MaxCostData) - assert cost.total_msats == 50_000 - assert cost.reason == "missing_usage" - assert cost.estimated_flag == 1 - - -@pytest.mark.asyncio -async def test_no_usage_estimate_bills_zero(monkeypatch): - monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "estimate") - cost = await calculate_cost(_response(), max_cost=50_000, model_obj=None) - assert isinstance(cost, MaxCostData) - assert cost.total_msats == 0 - assert cost.reason == "missing_usage" - - -@pytest.mark.asyncio -async def test_no_usage_refund_bills_zero(monkeypatch): - monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "refund") - cost = await calculate_cost(_response(), max_cost=50_000, model_obj=None) - assert cost.total_msats == 0 - assert cost.reason == "missing_usage" - - -@pytest.mark.asyncio -async def test_no_usage_unknown_policy_treated_as_estimate(monkeypatch): - monkeypatch.setattr( - cost_calculation.settings, "missing_usage_policy", "garbage" - ) - cost = await calculate_cost(_response(), max_cost=50_000, model_obj=None) - assert cost.total_msats == 0 - - -@pytest.mark.asyncio -async def test_no_usage_charge_max_zero_ceiling_stays_zero(monkeypatch): - monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "charge_max") - cost = await calculate_cost(_response(), max_cost=0, model_obj=None) - assert cost.total_msats == 0 - - -# --------------------------------------------------------------------------- -# calculate_cost: tokens present but pricing unusable -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_tokens_without_pricing_charge_max(monkeypatch): - monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "charge_max") - # NaN pricing rates fail the usable-rate gate -> policy applies. - with patch.object( - cost_calculation, - "_get_pricing_rates", - return_value=(float("nan"), 1.0, 1.0, 1.0), - ): - usage = {"prompt_tokens": 100, "completion_tokens": 50} - cost = await calculate_cost(_response(usage), max_cost=9_999, model_obj=None) - assert isinstance(cost, MaxCostData) - assert cost.total_msats == 9_999 - assert cost.reason == "missing_usage" - assert cost.input_tokens == 100 - assert cost.output_tokens == 50 - - -@pytest.mark.asyncio -async def test_tokens_without_pricing_estimate(monkeypatch): - monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "estimate") - with patch.object( - cost_calculation, - "_get_pricing_rates", - return_value=(float("nan"), 1.0, 1.0, 1.0), - ): - usage = {"prompt_tokens": 100, "completion_tokens": 50} - cost = await calculate_cost(_response(usage), max_cost=9_999, model_obj=None) - assert cost.total_msats == 0 - # Token counts still surface for dashboards. - assert cost.input_tokens == 100 - assert cost.output_tokens == 50 - - -# --------------------------------------------------------------------------- -# adjust_payment_for_tokens: CostDataError must not raise post-delivery -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_cost_data_error_charge_max_charges_and_returns(monkeypatch): - from routstr.auth import ReservationSnapshot, adjust_payment_for_tokens - from routstr.core.db import ApiKey - - monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "charge_max") - - key = ApiKey(hashed_key="a" * 64, balance=1_000_000, reserved_balance=50_000) - - reservation = ReservationSnapshot( - release_id="rel-1", - key_hash=key.hashed_key, - billing_key_hash=key.hashed_key, - reserved_msats=50_000, - ) - - with ( - patch("routstr.auth._validate_reservation_snapshot", new=AsyncMock()), - patch("routstr.auth._stop_reservation_heartbeat", new=AsyncMock()), - patch("routstr.auth._claim_reservation_for_charge", new=AsyncMock(return_value=True)), - patch("routstr.auth._charge_reservation_rows", new=AsyncMock(return_value=True)), - patch("routstr.auth.get_reservation_snapshot", new=AsyncMock(return_value=reservation)), - patch( - "routstr.auth.accumulate_routstr_fee", - new=AsyncMock(), - ) as accumulate_fee, - ): - cost = await adjust_payment_for_tokens( - key, - _response(), # no usage -> policy path via MaxCostData - session=AsyncMock(), - deducted_max_cost=50_000, - reservation_snapshot=reservation, - ) - # The MaxCostData path bills the ceiling through normal finalization. - assert cost["total_msats"] == 50_000 - assert cost["charged_msats"] == 50_000 - - -@pytest.mark.asyncio -async def test_cost_data_error_path_returns_dict_not_raise(monkeypatch): - """Force a genuine CostDataError (pricing ValueError) under 'refund'.""" - from routstr.auth import ReservationSnapshot, adjust_payment_for_tokens - from routstr.core.db import ApiKey - - monkeypatch.setattr(cost_calculation.settings, "missing_usage_policy", "refund") - - usage = {"prompt_tokens": 10, "completion_tokens": 5} - # No model_obj and no fixed pricing -> usable-rate gate... but tokens are - # present, so to force a CostDataError we patch _get_pricing_rates. - with ( - patch.object( - cost_calculation, - "_get_pricing_rates", - side_effect=ValueError("no pricing for model"), - ), - patch("routstr.auth._validate_reservation_snapshot", new=AsyncMock()), - patch("routstr.auth._stop_reservation_heartbeat", new=AsyncMock()), - patch("routstr.auth._claim_reservation_for_charge", new=AsyncMock(return_value=True)), - patch("routstr.auth._charge_reservation_rows", new=AsyncMock(return_value=True)), - patch("routstr.auth.release_reservation", new=AsyncMock(return_value=True)), - patch( - "routstr.auth.accumulate_routstr_fee", - new=AsyncMock(), - ), - ): - cost = await adjust_payment_for_tokens( - ApiKey(hashed_key="a" * 64), - _response(usage), - session=AsyncMock(), - deducted_max_cost=7_000, - reservation_snapshot=ReservationSnapshot( - release_id="rel-2", - key_hash="a" * 64, - billing_key_hash="a" * 64, - reserved_msats=7_000, - ), - ) - assert isinstance(cost, dict) - assert cost["total_msats"] == 0 - assert cost["reason"] == "missing_usage" - assert cost["estimated"] is True - assert cost["error"]["code"] == "pricing_error" - - -# --------------------------------------------------------------------------- -# X-Cashu: estimator wiring -# --------------------------------------------------------------------------- - - -def test_normalize_usage_rejects_none(): - assert normalize_usage(None) is None diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py index 4f33aacf..96b478d1 100644 --- a/tests/unit/test_pricing_rate_validation.py +++ b/tests/unit/test_pricing_rate_validation.py @@ -77,20 +77,13 @@ def _usage_response() -> dict[str, Any]: async def test_unusable_token_rate_never_charges_the_reservation( bad_rate: float, ) -> None: - """An unusable configured rate must not turn authorization into usage. - - Under the default ``charge_max`` policy the request is still billed the - pre-authorized ceiling (never MORE than it), with the raw token counts - preserved for dashboards. A zero rate remains a price (see - ``test_a_rate_of_zero_is_billed_as_free_not_as_missing``). - """ + """An unusable configured rate must not turn authorization into usage.""" model = _model(Pricing(prompt=bad_rate, completion=1.0)) cost = await calculate_cost(_usage_response(), max_cost=1234, model_obj=model) assert isinstance(cost, MaxCostData) - assert cost.total_msats == 1234 - assert cost.reason == "missing_usage" + assert cost.total_msats == 0 assert (cost.input_tokens, cost.output_tokens) == (1000, 500) diff --git a/tests/unit/test_x_cashu_missing_usage.py b/tests/unit/test_x_cashu_missing_usage.py new file mode 100644 index 00000000..19f5ae34 --- /dev/null +++ b/tests/unit/test_x_cashu_missing_usage.py @@ -0,0 +1,141 @@ +"""X-Cashu billing when the upstream omits usage. + +The local token estimator bills from the request body and the generated text. +When nothing can be estimated the prepayment is refunded in full. +""" + +import json +import os +from typing import Any +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +os.environ.setdefault("UPSTREAM_BASE_URL", "http://test") +os.environ.setdefault("UPSTREAM_API_KEY", "test") + +from routstr.upstream.base import BaseUpstreamProvider # noqa: E402 + +REQUEST_BODY = json.dumps( + {"model": "gpt-4o", "messages": [{"role": "user", "content": "Tell me a joke"}]} +).encode() + + +def _sse(events: list[dict[str, Any]]) -> httpx.Response: + body = "".join(f"data: {json.dumps(e)}\n\n" for e in events) + "data: [DONE]\n\n" + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, content=body.encode() + ) + + +def _json(payload: dict[str, Any]) -> httpx.Response: + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=json.dumps(payload) + ) + + +async def _settle( + response: httpx.Response, + *, + responses_api: bool = False, + request_body: bytes | None = REQUEST_BODY, +) -> tuple[Any, AsyncMock, AsyncMock]: + provider = BaseUpstreamProvider(base_url="http://test", api_key="test-key") + get_cost = AsyncMock(side_effect=provider.get_x_cashu_cost) + send_refund = AsyncMock(return_value="cashuBrefund") + handler = ( + provider.handle_x_cashu_responses_completion + if responses_api + else provider.handle_x_cashu_chat_completion + ) + with ( + patch.object(provider, "get_x_cashu_cost", new=get_cost), + patch.object(provider, "send_refund", new=send_refund), + ): + result = await handler( + response=response, + amount=10_000, + unit="msat", + max_cost_for_model=9_000, + mint=None, + request_body=request_body, + ) + return result, get_cost, send_refund + + +def _billed_usage(get_cost: AsyncMock) -> dict[str, Any] | None: + assert get_cost.await_args is not None + return get_cost.await_args.args[0].get("usage") + + +@pytest.mark.asyncio +async def test_streaming_chat_without_usage_bills_from_estimate() -> None: + events = [ + {"model": "gpt-4o", "choices": [{"delta": {"content": "Why did the "}}]}, + {"model": "gpt-4o", "choices": [{"delta": {"content": "chicken cross"}}]}, + ] + _, get_cost, _ = await _settle(_sse(events)) + + usage = _billed_usage(get_cost) + assert usage is not None + assert usage["input_tokens"] > 0 + assert usage["output_tokens"] > 0 + assert usage["estimated"] is True + + +@pytest.mark.asyncio +async def test_streaming_chat_without_text_refunds_everything() -> None: + _, get_cost, send_refund = await _settle( + _sse([{"model": "gpt-4o"}]), request_body=None + ) + + assert _billed_usage(get_cost) is None + assert send_refund.await_args is not None + assert send_refund.await_args.args[0] == 10_000 + + +@pytest.mark.asyncio +async def test_non_streaming_chat_without_usage_bills_from_estimate() -> None: + payload = { + "model": "gpt-4o", + "choices": [ + {"message": {"role": "assistant", "content": "To get to the other side."}} + ], + } + _, get_cost, _ = await _settle(_json(payload)) + + usage = _billed_usage(get_cost) + assert usage is not None + assert usage["output_tokens"] > 0 + assert usage["estimated"] is True + + +@pytest.mark.asyncio +async def test_streaming_responses_without_usage_bills_from_estimate() -> None: + events = [ + {"type": "response.created", "response": {"model": "gpt-5-mini"}}, + {"type": "response.output_text.delta", "delta": "Why did the chicken"}, + {"type": "response.output_text.done", "text": "Why did the chicken"}, + ] + _, get_cost, _ = await _settle(_sse(events), responses_api=True) + + usage = _billed_usage(get_cost) + assert usage is not None + assert usage["output_tokens"] > 0 + assert usage["estimated"] is True + + +@pytest.mark.asyncio +async def test_non_streaming_responses_without_usage_bills_from_estimate() -> None: + payload = { + "model": "gpt-5-mini", + "output": [ + {"type": "message", "content": [{"type": "output_text", "text": "Hi"}]} + ], + } + _, get_cost, _ = await _settle(_json(payload), responses_api=True) + + usage = _billed_usage(get_cost) + assert usage is not None + assert usage["output_tokens"] > 0 diff --git a/tests/unit/test_x_cashu_responses_streaming_sse.py b/tests/unit/test_x_cashu_responses_streaming_sse.py index 79769ae3..08154e40 100644 --- a/tests/unit/test_x_cashu_responses_streaming_sse.py +++ b/tests/unit/test_x_cashu_responses_streaming_sse.py @@ -187,8 +187,6 @@ async def test_multiline_data_payload_is_parsed_and_reframed() -> None: @pytest.mark.asyncio async def test_missing_usage_refunds_instead_of_charging_authorized_max() -> None: - """No usage in the stream: ``missing_usage_policy`` (default charge_max) - bills the pre-authorized ceiling and refunds only the difference.""" chunks = [ b'data: {"type":"response.created","response":{"model":"gpt-5-mini"}}\r\n\r\n', b"data: [DONE]\r\n\r\n", @@ -200,10 +198,9 @@ async def test_missing_usage_refunds_instead_of_charging_authorized_max() -> Non send_refund.assert_awaited_once() assert send_refund.await_args is not None - assert send_refund.await_args.args[0] == 10_000 - 9_000 + assert send_refund.await_args.args[0] == 10_000 assert response.headers["x-cashu"] == "cashuBrefundtoken0123456789" - assert response.headers["x-routstr-cost-msats"] == "9000" - assert response.headers["x-routstr-cost-estimated"] == "true" + assert response.headers["x-routstr-cost-msats"] == "0" @pytest.mark.asyncio @@ -218,7 +215,7 @@ async def test_malformed_events_do_not_retain_whole_token() -> None: ) assert send_refund.await_args is not None - assert send_refund.await_args.args[0] == 10_000 - 9_000 + assert send_refund.await_args.args[0] == 10_000 body = await _collect(response) assert b"\\n" not in body assert body.endswith(b"\n\n") From 623c55227c9343670902e6b1a52cfec558b8da9a Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:19:32 +0200 Subject: [PATCH 10/30] fix(ehbp): drop nonce on key-config passthrough, exact media type, call-site tests Address review feedback on the key-config 422 passthrough: - Drop Ehbp-Response-Nonce (and content-length) from the passthrough. A nonce only carries meaning on an encrypted response body, and the stock ehbp client (shouldDecryptResponse) checks the nonce BEFORE the key-config mismatch -- a forwarded nonce would push that client into the decrypt path on the plaintext error body, so re-attestation would never fire. routstr-sdk checks key-config first, but the passthrough must stay correct for any EHBP client. Content-length is recomputed from the body instead of forwarded. - Match the media type exactly (split on ';') like the ehbp client's isProblemJSONContentType, instead of a substring check that also accepted e.g. 'text/html; x=application/problem+json'. - Add call-site tests: the bearer path (driven through the proxy handler) pins that a key-config 422 passes through as problem+json AND releases the reservation (the early return skips the UpstreamError handler, so the release depends on 422 remaining non-retryable); the x-cashu path pins the full refund and the X-Cashu header on the passthrough response. - Apply ruff format to the touched test file. --- routstr/upstream/ehbp.py | 20 +- tests/unit/test_tinfoil_integration.py | 295 ++++++++++++++++++++----- 2 files changed, 250 insertions(+), 65 deletions(-) diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index b2822366..b15d5bad 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -81,7 +81,8 @@ def _is_ehbp_key_config_response(resp: TrailerResponse) -> bool: if k.lower() == "content-type": ct = v.lower() break - if "application/problem+json" not in ct: + media_type = ct.split(";", 1)[0].strip() + if media_type != "application/problem+json": return False try: body = json.loads(resp.body) @@ -94,19 +95,18 @@ def _passthrough_key_config_response(resp: TrailerResponse) -> Response: """Return the enclave's key-config 422 with its original body and content type so the EHBP client's ``KeyConfigMismatchError`` detection fires. - Only EHBP protocol headers are forwarded; everything else (hop-by-hop, - upstream-internal) is filtered out. + Only the content type is forwarded. ``Ehbp-Response-Nonce`` must be + dropped: a nonce only carries meaning for an *encrypted* response body, + and the stock ``ehbp`` client (``shouldDecryptResponse``) checks for the + nonce *before* checking for a key-config mismatch — forwarding it would + send that client down the decrypt path on this plaintext error body, so + the re-attestation loop would never fire. Content-length is recomputed + from the body, and upstream-internal headers are filtered out. """ - passthrough_headers: dict[str, str] = { - "content-type": "application/problem+json", - } - for k, v in resp.headers: - if k.lower() in ("ehbp-response-nonce", "content-length"): - passthrough_headers[k] = v return Response( content=resp.body, status_code=422, - headers=passthrough_headers, + headers={"content-type": "application/problem+json"}, media_type="application/problem+json", ) diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index b7d52dfe..1058a19a 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -11,14 +11,18 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from routstr import proxy as proxy_module +from routstr.core.db import ApiKey from routstr.upstream.ehbp import ( _PROXY_ONLY_HEADERS, + EHBPForwardingTarget, _compute_ehbp_actual_cost, _is_ehbp_key_config_response, _passthrough_key_config_response, _prepare_ehbp_upstream_headers, _resolve_ehbp_target_url, _strip_proxy_headers, + forward_ehbp_x_cashu_request, parse_tinfoil_usage_metrics, ) from routstr.upstream.tinfoil import ( @@ -109,9 +113,7 @@ class TestParseTinfoilUsageMetrics: def test_old_format_still_works(self) -> None: """Headers without the model field (pre-PR #385) still parse.""" - result = parse_tinfoil_usage_metrics( - "prompt=67,completion=42,total=109" - ) + result = parse_tinfoil_usage_metrics("prompt=67,completion=42,total=109") assert result == { "prompt_tokens": 67, "completion_tokens": 42, @@ -288,7 +290,9 @@ class TestComputeEhbpActualCost: assert result["output_msats"] == 20 @pytest.mark.asyncio - async def test_unpriceable_usage_does_not_charge_authorization_ceiling(self) -> None: + async def test_unpriceable_usage_does_not_charge_authorization_ceiling( + self, + ) -> None: model_obj = MagicMock() model_obj.id = "llama3-3-70b" model_obj.forwarded_model_id = "llama3-3-70b" @@ -393,13 +397,16 @@ class TestComputeEhbpActualCost: actual_model_obj.id = "tinfoil-llama3-3-70b" # client-facing of actual actual_model_obj.forwarded_model_id = "llama3-3-70b" - with patch( - "routstr.proxy.get_model_instance", - return_value=actual_model_obj, - ), patch( - "routstr.upstream.ehbp.calculate_cost", - new_callable=AsyncMock, - ) as mock_calc: + with ( + patch( + "routstr.proxy.get_model_instance", + return_value=actual_model_obj, + ), + patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc, + ): from routstr.payment.cost_calculation import CostData mock_calc.return_value = CostData( @@ -430,13 +437,16 @@ class TestComputeEhbpActualCost: model_obj.id = "gpt-oss-120b" model_obj.forwarded_model_id = "gpt-oss-120b" - with patch( - "routstr.proxy.get_model_instance", - return_value=None, - ), patch( - "routstr.upstream.ehbp.calculate_cost", - new_callable=AsyncMock, - ) as mock_calc: + with ( + patch( + "routstr.proxy.get_model_instance", + return_value=None, + ), + patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc, + ): from routstr.payment.cost_calculation import CostData mock_calc.return_value = CostData( @@ -495,12 +505,13 @@ class TestComputeEhbpActualCost: model_obj = MagicMock() model_obj.id = "tinfoil-glm-5-2" model_obj.forwarded_model_id = "glm-5-2" # lowercase - with patch( - "routstr.proxy.get_model_instance" - ) as mock_get_model, patch( - "routstr.upstream.ehbp.calculate_cost", - new_callable=AsyncMock, - ) as mock_calc: + with ( + patch("routstr.proxy.get_model_instance") as mock_get_model, + patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc, + ): from routstr.payment.cost_calculation import CostData mock_calc.return_value = CostData( @@ -536,13 +547,16 @@ class TestComputeEhbpActualCost: resolved_model_obj.id = "other-provider-glm-5-2" resolved_model_obj.forwarded_model_id = "glm-5-2" - with patch( - "routstr.proxy.get_model_instance", - return_value=resolved_model_obj, - ) as mock_get_model, patch( - "routstr.upstream.ehbp.calculate_cost", - new_callable=AsyncMock, - ) as mock_calc: + with ( + patch( + "routstr.proxy.get_model_instance", + return_value=resolved_model_obj, + ) as mock_get_model, + patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc, + ): from routstr.payment.cost_calculation import CostData mock_calc.return_value = CostData( @@ -573,12 +587,13 @@ class TestComputeEhbpActualCost: model_obj.id = "tinfoil-glm-5-2-20260415" model_obj.forwarded_model_id = "glm-5-2-20260415" - with patch( - "routstr.proxy.get_model_instance" - ) as mock_get_model, patch( - "routstr.upstream.ehbp.calculate_cost", - new_callable=AsyncMock, - ) as mock_calc: + with ( + patch("routstr.proxy.get_model_instance") as mock_get_model, + patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc, + ): from routstr.payment.cost_calculation import CostData mock_calc.return_value = CostData( @@ -597,10 +612,7 @@ class TestComputeEhbpActualCost: ) assert "actual_model" not in result - assert ( - mock_calc.call_args[0][0]["model"] - == "tinfoil-glm-5-2-20260415" - ) + assert mock_calc.call_args[0][0]["model"] == "tinfoil-glm-5-2-20260415" mock_get_model.assert_not_called() @pytest.mark.asyncio @@ -615,13 +627,16 @@ class TestComputeEhbpActualCost: resolved_model_obj.id = "other-provider-glm-5-2" resolved_model_obj.forwarded_model_id = "GLM-5-2" - with patch( - "routstr.proxy.get_model_instance", - return_value=resolved_model_obj, - ) as mock_get_model, patch( - "routstr.upstream.ehbp.calculate_cost", - new_callable=AsyncMock, - ) as mock_calc: + with ( + patch( + "routstr.proxy.get_model_instance", + return_value=resolved_model_obj, + ) as mock_get_model, + patch( + "routstr.upstream.ehbp.calculate_cost", + new_callable=AsyncMock, + ) as mock_calc, + ): from routstr.payment.cost_calculation import CostData mock_calc.return_value = CostData( @@ -653,8 +668,7 @@ class TestTinfoilUpstreamProvider: def test_provider_type_and_defaults(self) -> None: assert TinfoilUpstreamProvider.provider_type == "tinfoil" assert ( - TinfoilUpstreamProvider.default_base_url - == "https://inference.tinfoil.sh" + TinfoilUpstreamProvider.default_base_url == "https://inference.tinfoil.sh" ) assert TinfoilUpstreamProvider.supports_ehbp is True @@ -669,9 +683,7 @@ class TestTinfoilUpstreamProvider: model_obj.id = "llama3-3-70b" model_obj.forwarded_model_id = "llama3-3-70b" target = provider.get_ehbp_forwarding_target("v1/chat/completions", model_obj) - assert ( - target.headers["X-Tinfoil-Request-Usage-Metrics"] == "true" - ) + assert target.headers["X-Tinfoil-Request-Usage-Metrics"] == "true" assert "v1/chat/completions" in target.url def test_get_provider_metadata(self) -> None: @@ -812,6 +824,20 @@ class TestIsEhbpKeyConfigResponse: ) assert _is_ehbp_key_config_response(resp) is True + def test_content_type_parameter_disguising_other_media_type(self) -> None: + """A substring check would accept this; the media type must match + exactly, mirroring the ehbp client's isProblemJSONContentType.""" + resp = _key_config_trailer_response( + content_type="text/html; x=application/problem+json" + ) + assert _is_ehbp_key_config_response(resp) is False + + def test_uppercase_media_type_with_params_matches(self) -> None: + resp = _key_config_trailer_response( + content_type="Application/Problem+JSON; charset=UTF-8" + ) + assert _is_ehbp_key_config_response(resp) is True + def test_missing_content_type_is_not_key_config(self) -> None: resp = TrailerResponse( status_code=422, @@ -834,18 +860,177 @@ class TestPassthroughKeyConfigResponse: result = _passthrough_key_config_response(resp) assert result.body == original_body - def test_ehbp_nonce_header_forwarded(self) -> None: + def test_ehbp_nonce_header_dropped(self) -> None: + """The nonce must not survive the passthrough: the stock ehbp client + checks for the nonce before the key-config mismatch, so a forwarded + nonce would send it down the decrypt path on this plaintext error + body and the re-attestation loop would never fire.""" resp = TrailerResponse( status_code=422, headers=[ ("content-type", "application/problem+json"), ("ehbp-response-nonce", "abc123"), + ("content-length", "999"), ("server", "nginx"), ("x-request-id", "some-id"), ], body=b'{"type":"urn:ietf:params:ehbp:error:key-config","title":"test"}', ) result = _passthrough_key_config_response(resp) - assert result.headers["ehbp-response-nonce"] == "abc123" + assert "ehbp-response-nonce" not in result.headers assert "server" not in result.headers assert "x-request-id" not in result.headers + # Content-length is recomputed from the actual body, not forwarded. + assert result.headers["content-length"] == str(len(resp.body)) + + +# --------------------------------------------------------------------------- +# Key-config passthrough at the forwarding call sites +# --------------------------------------------------------------------------- + + +def _ehbp_tinfoil_upstream() -> MagicMock: + """A minimal EHBP-capable upstream stub shaped like the Tinfoil provider.""" + upstream = MagicMock() + upstream.provider_type = "tinfoil" + upstream.supports_ehbp = True + upstream.prepare_headers = MagicMock(side_effect=lambda h: h) + upstream.get_confidential_inference_profile = MagicMock(return_value=None) + upstream.get_ehbp_forwarding_target = MagicMock( + return_value=EHBPForwardingTarget( + url="https://inference.tinfoil.sh/private/v1/chat/completions" + ) + ) + upstream.prepare_params = MagicMock(return_value={}) + return upstream + + +@pytest.mark.asyncio +async def test_bearer_key_config_422_releases_reservation_and_passes_through() -> None: + """The bearer path returns the enclave's problem+json verbatim AND the + reservation is released. + + The early return inside ``forward_ehbp_request`` skips the UpstreamError + handler, so the release depends on the proxy's non-200 branch treating 422 + as non-retryable. Nothing else pins that; this does. + """ + key = ApiKey(hashed_key="keyconfig", balance=10_000) + session = MagicMock() + reservation_snapshot = MagicMock() + revert_mock = AsyncMock(return_value=True) + + request = MagicMock() + request.method = "POST" + request.headers = { + "authorization": "Bearer sk-keyconfig", + "ehbp-encapsulated-key": "abc123", + "x-routstr-model": "tinfoil/llama3-3-70b", + } + request.body = AsyncMock(return_value=b"sealed-body") + request.query_params = {} + + model_obj = MagicMock() + model_obj.id = "tinfoil/llama3-3-70b" + upstream = _ehbp_tinfoil_upstream() + + # The enclave may include a nonce even on the 422 — the passthrough must + # drop it, or stock ehbp clients (nonce checked before key-config) would + # try to decrypt this plaintext body instead of re-attesting. + upstream_resp = _key_config_trailer_response() + upstream_resp.headers.append(("ehbp-response-nonce", "nonce-value")) + + with ( + patch.object( + proxy_module, "get_candidates", return_value=[(model_obj, upstream)] + ), + patch.object( + proxy_module, "get_max_cost_for_model", AsyncMock(return_value=1_000) + ), + patch.object( + proxy_module, + "calculate_discounted_max_cost", + AsyncMock(return_value=1_000), + ), + patch.object(proxy_module, "check_token_balance", MagicMock()), + patch.object(proxy_module, "get_bearer_token_key", AsyncMock(return_value=key)), + patch.object(proxy_module, "pay_for_request", AsyncMock(return_value=1_000)), + patch.object( + proxy_module, + "get_reservation_snapshot", + AsyncMock(return_value=reservation_snapshot), + ), + patch.object(proxy_module, "revert_pay_for_request", revert_mock), + patch( + "routstr.upstream.ehbp.forward_with_trailer", + AsyncMock(return_value=upstream_resp), + ), + ): + response = await proxy_module.proxy( + request, "v1/chat/completions", session=session + ) + + # The reservation was released despite the early passthrough return. + revert_mock.assert_awaited_once_with(key, session, 1_000, reservation_snapshot) + # The client receives the enclave's problem+json verbatim... + assert response.status_code == 422 + assert response.headers["content-type"] == "application/problem+json" + assert response.body == upstream_resp.body + # ...without the nonce. + assert "ehbp-response-nonce" not in response.headers + + +@pytest.mark.asyncio +async def test_x_cashu_key_config_422_refunds_and_sets_x_cashu_header() -> None: + """The x-cashu path refunds the full redeemed amount and attaches the + refund token to the passthrough response.""" + request = MagicMock() + request.method = "POST" + request.headers = { + "ehbp-encapsulated-key": "abc123", + "x-routstr-model": "tinfoil/llama3-3-70b", + } + request.query_params = {} + request.body = AsyncMock(return_value=b"sealed-body") + request.state.request_id = "req-1" + + model_obj = MagicMock() + model_obj.id = "tinfoil/llama3-3-70b" + upstream = _ehbp_tinfoil_upstream() + + upstream_resp = _key_config_trailer_response() + refund_mock = AsyncMock(return_value="cashuArefund") + store_mock = AsyncMock() + + with ( + patch( + "routstr.upstream.ehbp.recieve_token", + AsyncMock(return_value=(50_000, "msat", "https://mint.example")), + ), + patch("routstr.upstream.ehbp.store_cashu_transaction", store_mock), + patch("routstr.upstream.ehbp.send_cashu_refund", refund_mock), + patch( + "routstr.upstream.ehbp.forward_with_trailer", + AsyncMock(return_value=upstream_resp), + ), + ): + response = await forward_ehbp_x_cashu_request( + request=request, + x_cashu_token="cashuAtoken", + path="v1/chat/completions", + max_cost_for_model=1_000, + model_obj=model_obj, + upstream=upstream, + ) + + # Full refund of the redeemed amount (the enclave never processed it). + refund_mock.assert_awaited_once_with( + 50_000, "msat", "https://mint.example", "req-1" + ) + # The redemption itself was recorded. + store_mock.assert_awaited_once() + assert store_mock.await_args.kwargs.get("typ") == "in" + # Passthrough shape with the refund attached. + assert response.status_code == 422 + assert response.headers["content-type"] == "application/problem+json" + assert response.body == upstream_resp.body + assert response.headers["x-cashu"] == "cashuArefund" From 54d1f4c79830a7900a5af7042f53e54767daab39 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:24:44 +0200 Subject: [PATCH 11/30] test(ehbp): cover bearer timeout path; tighten EhbpTimeoutError signature Addresses two review items on #700. - Add a bearer-path regression test. `forward_ehbp_request`'s trailing `except Exception` rewrites any error into a 500; only the `except UpstreamError: raise` above it preserves the 504. Deleting those two lines now fails this test and nothing else (verified locally). - Drop `EhbpTimeoutError`'s unused `status_code` parameter and accept `details`, which `create_upstream_error_response` already forwards. - Format the touched tests: `ruff format --check` was failing on tests/unit/test_ehbp_timeout.py. --- routstr/core/exceptions.py | 8 ++- tests/unit/test_ehbp_timeout.py | 100 +++++++++++++++++++++-------- tests/unit/test_tinfoil_trailer.py | 9 +++ 3 files changed, 90 insertions(+), 27 deletions(-) diff --git a/routstr/core/exceptions.py b/routstr/core/exceptions.py index 6e7d90a0..2fc34bb6 100644 --- a/routstr/core/exceptions.py +++ b/routstr/core/exceptions.py @@ -40,13 +40,17 @@ class EhbpTimeoutError(UpstreamError): Distinct from a generic :class:`UpstreamError` so callers can map the failure to a ``504 Gateway Timeout`` with a stable ``UPSTREAM_TIMEOUT`` code instead of a misleading ``500`` internal server error. + + ``details`` carries optional structured, redaction-safe context and is + forwarded to the client by ``create_upstream_error_response``. """ - def __init__(self, message: str, status_code: int = 504): + def __init__(self, message: str, details: dict[str, object] | None = None): super().__init__( message, - status_code=status_code, + status_code=504, code="UPSTREAM_TIMEOUT", + details=details, ) diff --git a/tests/unit/test_ehbp_timeout.py b/tests/unit/test_ehbp_timeout.py index 760a491e..c9e749cb 100644 --- a/tests/unit/test_ehbp_timeout.py +++ b/tests/unit/test_ehbp_timeout.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from routstr.core.exceptions import EhbpTimeoutError +from routstr.core.exceptions import EhbpTimeoutError, UpstreamError from routstr.upstream import ehbp as ehbp_module # --------------------------------------------------------------------------- @@ -22,28 +22,8 @@ async def _request() -> MagicMock: return request -@pytest.mark.asyncio -async def test_x_cashu_timeout_refunds_and_returns_504( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - ehbp_module, - "recieve_token", - AsyncMock(return_value=(1000, "msat", None)), - ) - monkeypatch.setattr( - ehbp_module, "store_cashu_transaction", AsyncMock(return_value=None) - ) - send_cashu_refund_mock = AsyncMock(return_value="refund-token") - monkeypatch.setattr( - ehbp_module, "send_cashu_refund", send_cashu_refund_mock - ) - monkeypatch.setattr( - ehbp_module, - "forward_with_trailer", - AsyncMock(side_effect=EhbpTimeoutError("EHBP upstream timed out")), - ) - +def _ehbp_upstream_mocks() -> tuple[MagicMock, MagicMock]: + """Upstream and model mocks sufficient to reach the forwarding call.""" profile = MagicMock() profile.client_target_url_header = None profile.allow_client_target_override = False @@ -64,6 +44,30 @@ async def test_x_cashu_timeout_refunds_and_returns_504( model_obj = MagicMock() model_obj.id = "tinfoil-kimi-k2-6" model_obj.forwarded_model_id = "kimi-k2-6" + return upstream, model_obj + + +@pytest.mark.asyncio +async def test_x_cashu_timeout_refunds_and_returns_504( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + ehbp_module, + "recieve_token", + AsyncMock(return_value=(1000, "msat", None)), + ) + monkeypatch.setattr( + ehbp_module, "store_cashu_transaction", AsyncMock(return_value=None) + ) + send_cashu_refund_mock = AsyncMock(return_value="refund-token") + monkeypatch.setattr(ehbp_module, "send_cashu_refund", send_cashu_refund_mock) + monkeypatch.setattr( + ehbp_module, + "forward_with_trailer", + AsyncMock(side_effect=EhbpTimeoutError("EHBP upstream timed out")), + ) + + upstream, model_obj = _ehbp_upstream_mocks() response = await ehbp_module.forward_ehbp_x_cashu_request( request=await _request(), @@ -76,6 +80,52 @@ async def test_x_cashu_timeout_refunds_and_returns_504( assert response.status_code == 504 assert response.headers["X-Cashu"] == "refund-token" - send_cashu_refund_mock.assert_awaited_once_with( - 1000, "msat", None, "req-123" + send_cashu_refund_mock.assert_awaited_once_with(1000, "msat", None, "req-123") + + +# --------------------------------------------------------------------------- +# forward_ehbp_request — the bearer path must let the timeout through, so +# proxy.py can answer 504 instead of flattening it to a generic 500 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_bearer_timeout_propagates_504( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A timed-out bearer request must not be rewritten to a 500. + + ``forward_ehbp_request`` ends in a bare ``except Exception`` that turns any + error into ``UpstreamError(..., status_code=500)``. The ``except + UpstreamError: raise`` above it is the only thing preserving the 504 that + ``proxy.py`` returns to the client, so this test pins that handler. + """ + monkeypatch.setattr( + ehbp_module, + "forward_with_trailer", + AsyncMock( + side_effect=EhbpTimeoutError( + "EHBP upstream inference.tinfoil.sh timed out after 60s connecting" + ) + ), ) + upstream, model_obj = _ehbp_upstream_mocks() + key = MagicMock() + key.hashed_key = "abcdef1234567890" + + with pytest.raises(EhbpTimeoutError) as exc_info: + await ehbp_module.forward_ehbp_request( + request=await _request(), + path="v1/chat/completions", + headers={}, + request_body=b"opaque", + upstream=upstream, + key=key, + max_cost_for_model=5000, + session=MagicMock(), + model_obj=model_obj, + ) + + assert exc_info.value.status_code == 504 + assert exc_info.value.code == "UPSTREAM_TIMEOUT" + assert isinstance(exc_info.value, UpstreamError) diff --git a/tests/unit/test_tinfoil_trailer.py b/tests/unit/test_tinfoil_trailer.py index 5a6d68a7..3e4d3e0f 100644 --- a/tests/unit/test_tinfoil_trailer.py +++ b/tests/unit/test_tinfoil_trailer.py @@ -195,4 +195,13 @@ def test_ehbp_timeout_error_metadata() -> None: exc = EhbpTimeoutError("boom") assert exc.status_code == 504 assert exc.code == "UPSTREAM_TIMEOUT" + assert exc.details is None assert isinstance(exc, UpstreamError) + + +def test_ehbp_timeout_error_forwards_details() -> None: + """``details`` must survive so the response builder can forward it.""" + exc = EhbpTimeoutError("boom", details={"phase": "connect"}) + assert exc.details == {"phase": "connect"} + assert exc.status_code == 504 + assert exc.code == "UPSTREAM_TIMEOUT" From 7b2918ad6e8047a27279cae1de195bc704e66888 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:00:15 +0200 Subject: [PATCH 12/30] fix(types): narrow await_args for mypy in x-cashu key-config test uv run mypy . failed with union-attr on store_mock.await_args.kwargs (AsyncMock.await_args is typed _Call | None, and assert_awaited_once() does not narrow it). Add an explicit None check before accessing kwargs. --- tests/unit/test_tinfoil_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index 1058a19a..1319e5a0 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -1028,6 +1028,7 @@ async def test_x_cashu_key_config_422_refunds_and_sets_x_cashu_header() -> None: ) # The redemption itself was recorded. store_mock.assert_awaited_once() + assert store_mock.await_args is not None assert store_mock.await_args.kwargs.get("typ") == "in" # Passthrough shape with the refund attached. assert response.status_code == 422 From efad99b938a2a56d64ff5a7b615df167f6b56d32 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 20:03:38 +0200 Subject: [PATCH 13/30] Keep client referrer attribution free of private URL data --- routstr/core/middleware.py | 10 +++ tests/unit/test_client_app_logging.py | 87 ++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/routstr/core/middleware.py b/routstr/core/middleware.py index cff7d39a..228cd68a 100644 --- a/routstr/core/middleware.py +++ b/routstr/core/middleware.py @@ -2,6 +2,7 @@ import time import uuid from contextvars import ContextVar from typing import Callable +from urllib.parse import urlsplit from fastapi import Request, Response from starlette.datastructures import Headers @@ -39,6 +40,15 @@ def client_app_from_headers(headers: Headers) -> str: if raw is None: continue cleaned = "".join(ch for ch in raw if ch.isprintable()).strip() + if header in ("http-referer", "referer"): + try: + url = urlsplit(cleaned) + if url.scheme not in ("http", "https") or not url.hostname: + continue + except ValueError: + continue + # Attribution needs the origin, not credentials or private page URLs. + cleaned = f"{url.scheme}://{url.netloc.rsplit('@', 1)[-1]}" if cleaned: return cleaned[:_CLIENT_APP_MAX_LENGTH] return UNKNOWN_CLIENT_APP diff --git a/tests/unit/test_client_app_logging.py b/tests/unit/test_client_app_logging.py index 3f527083..a76d411c 100644 --- a/tests/unit/test_client_app_logging.py +++ b/tests/unit/test_client_app_logging.py @@ -1,9 +1,10 @@ """Tests for client-app identification in request logging.""" +import asyncio import logging import pytest -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response from fastapi.testclient import TestClient from starlette.datastructures import Headers @@ -66,6 +67,27 @@ def test_client_app_from_headers(headers: dict[str, str], expected: str) -> None assert client_app_from_headers(Headers(headers)) == expected +@pytest.mark.parametrize("header", ["http-referer", "referer"]) +@pytest.mark.parametrize( + ("url", "expected"), + [ + ( + "https://alice:password@app.example:8443/private/chat?token=secret#access_token=secret", + "https://app.example:8443", + ), + ("http://[::1]:3000/chat?key=secret", "http://[::1]:3000"), + ("https://app.example/" + "a" * 200, "https://app.example"), + ("https://[invalid", "curl/8.4.0"), + ("/private/chat?token=secret", "curl/8.4.0"), + ("javascript:secret", "curl/8.4.0"), + ("https:///private", "curl/8.4.0"), + ], +) +def test_referrer_only_identifies_origin(header: str, url: str, expected: str) -> None: + headers = Headers({header: url, "user-agent": "curl/8.4.0"}) + assert client_app_from_headers(headers) == expected + + def test_value_is_truncated_to_120_chars() -> None: assert client_app_from_headers(Headers({"x-title": "a" * 500})) == "a" * 120 @@ -86,6 +108,69 @@ def test_filter_reads_context_variable() -> None: client_app_context.reset(token) +@pytest.mark.parametrize("fail", [False, True]) +async def test_context_is_restored_after_request(fail: bool) -> None: + middleware = LoggingMiddleware(FastAPI()) + request = Request( + { + "type": "http", + "method": "GET", + "path": "/test", + "query_string": b"", + "headers": [], + } + ) + + async def call_next(request: Request) -> Response: + assert client_app_context.get() == UNKNOWN_CLIENT_APP + if fail: + raise RuntimeError("handler failed") + return Response() + + token = client_app_context.set("outer") + try: + if fail: + with pytest.raises(RuntimeError, match="handler failed"): + await middleware.dispatch(request, call_next) + else: + await middleware.dispatch(request, call_next) + assert client_app_context.get() == "outer" + finally: + client_app_context.reset(token) + + +async def test_concurrent_requests_keep_their_own_client_app() -> None: + middleware = LoggingMiddleware(FastAPI()) + ready = asyncio.Event() + apps: list[str] = [] + + async def call_next(request: Request) -> Response: + apps.append(request.headers["x-title"]) + if len(apps) == 2: + ready.set() + await asyncio.wait_for(ready.wait(), timeout=5) + assert client_app_context.get() == request.headers["x-title"] + return Response() + + await asyncio.gather( + *( + middleware.dispatch( + Request( + { + "type": "http", + "method": "GET", + "path": "/test", + "query_string": b"", + "headers": [(b"x-title", app)], + } + ), + call_next, + ) + for app in (b"Goose", b"Pi") + ) + ) + + def test_filter_defaults_to_unknown_outside_request_context() -> None: record = _record() assert ClientAppFilter().filter(record) is True From e970e150149ace5336fbd3e83ae021335fa55ade Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 20:07:36 +0200 Subject: [PATCH 14/30] Trim redundant client attribution comments --- routstr/core/logging.py | 3 +-- routstr/core/middleware.py | 7 ++----- tests/unit/test_client_app_logging.py | 2 -- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/routstr/core/logging.py b/routstr/core/logging.py index 563b368d..bc088ed5 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -183,10 +183,9 @@ class RequestIdFilter(logging.Filter): class ClientAppFilter(logging.Filter): - """Filter to add the requesting client app to all log records.""" + """Attach request-local app attribution to log records.""" def filter(self, record: logging.LogRecord) -> bool: - """Add the client app to the log record if available.""" # Import here to avoid circular imports from .middleware import UNKNOWN_CLIENT_APP, client_app_context diff --git a/routstr/core/middleware.py b/routstr/core/middleware.py index 228cd68a..da073cd5 100644 --- a/routstr/core/middleware.py +++ b/routstr/core/middleware.py @@ -15,13 +15,11 @@ logger = get_logger(__name__) # Context variable to store request ID across async context request_id_context: ContextVar[str | None] = ContextVar("request_id") -# Context variable to store the client app across async context client_app_context: ContextVar[str | None] = ContextVar("client_app") UNKNOWN_CLIENT_APP = "unknown" -# Identity headers in priority order. X-Title and HTTP-Referer are the -# OpenRouter convention; User-Agent covers SDKs and scripts that set neither. +# Prefer OpenRouter app headers, then browser and SDK fallbacks. _CLIENT_APP_HEADERS: tuple[str, ...] = ( "x-title", "http-referer", @@ -29,8 +27,7 @@ _CLIENT_APP_HEADERS: tuple[str, ...] = ( "user-agent", ) -# Header values are attacker-controlled: cap the length so one request can't -# bloat every log line. +# Limit untrusted header data repeated in every log record. _CLIENT_APP_MAX_LENGTH = 120 diff --git a/tests/unit/test_client_app_logging.py b/tests/unit/test_client_app_logging.py index a76d411c..107f8c2c 100644 --- a/tests/unit/test_client_app_logging.py +++ b/tests/unit/test_client_app_logging.py @@ -93,7 +93,6 @@ def test_value_is_truncated_to_120_chars() -> None: def test_control_characters_are_stripped() -> None: - """A crafted header must not be able to forge log records.""" headers = Headers({"user-agent": "evil-app\x1b[0m fake INFO line"}) assert client_app_from_headers(headers) == "evil-app[0m fake INFO line" @@ -178,7 +177,6 @@ def test_filter_defaults_to_unknown_outside_request_context() -> None: def test_handler_logs_carry_client_app(caplog: pytest.LogCaptureFixture) -> None: - """A log line emitted inside a handler still names the app that triggered it.""" app = FastAPI() handler_logger = logging.getLogger("routstr.test.handler") From f58983b9eb0b2ad1e624be0064b6b4a63ef4e1a2 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 20:55:58 +0200 Subject: [PATCH 15/30] fix: account for Responses input and terminal usage snapshots --- routstr/upstream/base.py | 4 +- routstr/upstream/count_tokens.py | 39 +++++++ tests/unit/test_count_tokens_local.py | 36 +++++++ tests/unit/test_x_cashu_missing_usage.py | 132 ++++++++++++++++++++++- 4 files changed, 207 insertions(+), 4 deletions(-) diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 2ea06e0b..d41b2664 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -3892,7 +3892,7 @@ class BaseUpstreamProvider: continue usage_estimator.observe(data_json) - if usage_data is None: + if not usage_data: usage_data = _estimated_usage(usage_estimator, model) if usage_data: logger.warning( @@ -4935,7 +4935,7 @@ class BaseUpstreamProvider: elif not model and payload.get("model"): model = payload["model"] - if usage_data is None: + if not usage_data: usage_data = _estimated_usage(usage_estimator, model) logger.warning( "No usage in streaming Responses API response, billing from local token estimate", diff --git a/routstr/upstream/count_tokens.py b/routstr/upstream/count_tokens.py index 8ebeb6d9..51ffbfa4 100644 --- a/routstr/upstream/count_tokens.py +++ b/routstr/upstream/count_tokens.py @@ -57,6 +57,37 @@ def _count_with_litellm( if not isinstance(messages, list): messages = [] + if "input" in body: + response_input = body["input"] + if isinstance(response_input, str): + messages = [{"role": "user", "content": response_input}] + elif isinstance(response_input, list): + messages = [] + for item in response_input: + if not isinstance(item, dict) or "role" not in item: + raise ValueError( + "Responses input requires fallback token estimation" + ) + content = item.get("content", "") + if isinstance(content, list): + parts = [] + for part in content: + if not isinstance(part, dict) or part.get("type") not in ( + "input_text", + "output_text", + "text", + ): + raise ValueError( + "Non-text Responses input requires fallback token estimation" + ) + parts.append({"type": "text", "text": part.get("text", "")}) + content = parts + messages.append({"role": item["role"], "content": content}) + else: + raise ValueError("Unsupported Responses input") + if body.get("instructions"): + messages.insert(0, {"role": "system", "content": body["instructions"]}) + prompt_token_ids = 0 if include_legacy_prompt: prompt = body.get("prompt") @@ -177,6 +208,14 @@ class MissingUsageEstimator: def observe(self, response_data: object) -> None: if isinstance(response_data, dict): event_type = response_data.get("type") + if event_type in ("response.completed", "response.incomplete"): + response = response_data.get("response") + if isinstance(response, dict) and isinstance( + response.get("output"), list + ): + # Terminal output is a snapshot, not another text delta. + self._output_parts = _generated_text(response["output"]) + return if isinstance(event_type, str) and event_type.endswith(".done"): # Responses API ``*.done`` events repeat text already streamed # via ``*.delta`` events; counting both would double-bill. diff --git a/tests/unit/test_count_tokens_local.py b/tests/unit/test_count_tokens_local.py index 85a8ec72..23bf379c 100644 --- a/tests/unit/test_count_tokens_local.py +++ b/tests/unit/test_count_tokens_local.py @@ -272,3 +272,39 @@ def test_uses_forwarded_model_id_when_present() -> None: assert captured["model"] == "claude-3-5-sonnet-20241022" assert _read_payload(response)["input_tokens"] == 7 + + +def test_responses_instructions_are_counted_as_system_text() -> None: + body = {"model": "gpt-4o", "input": "Hi", "instructions": "Be concise."} + with patch.object( + count_tokens_module.litellm, "token_counter", return_value=12 + ) as counter: + usage = MissingUsageEstimator(_body(body), None).response_data()["usage"] + + assert usage["input_tokens"] == 12 + counter.assert_called_once_with( + model="gpt-4o", + messages=[ + {"role": "system", "content": "Be concise."}, + {"role": "user", "content": "Hi"}, + ], + tools=None, + ) + + +def test_responses_tool_results_use_fallback_instead_of_empty_messages() -> None: + body = { + "model": "gpt-4o", + "input": [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": "result " * 100, + } + ], + } + with patch.object(count_tokens_module.litellm, "token_counter") as counter: + usage = MissingUsageEstimator(_body(body), None).response_data()["usage"] + + counter.assert_not_called() + assert usage["input_tokens"] > 100 diff --git a/tests/unit/test_x_cashu_missing_usage.py b/tests/unit/test_x_cashu_missing_usage.py index 19f5ae34..ceaabade 100644 --- a/tests/unit/test_x_cashu_missing_usage.py +++ b/tests/unit/test_x_cashu_missing_usage.py @@ -40,6 +40,7 @@ async def _settle( *, responses_api: bool = False, request_body: bytes | None = REQUEST_BODY, + unit: str = "msat", ) -> tuple[Any, AsyncMock, AsyncMock]: provider = BaseUpstreamProvider(base_url="http://test", api_key="test-key") get_cost = AsyncMock(side_effect=provider.get_x_cashu_cost) @@ -56,7 +57,7 @@ async def _settle( result = await handler( response=response, amount=10_000, - unit="msat", + unit=unit, max_cost_for_model=9_000, mint=None, request_body=request_body, @@ -113,7 +114,7 @@ async def test_non_streaming_chat_without_usage_bills_from_estimate() -> None: @pytest.mark.asyncio async def test_streaming_responses_without_usage_bills_from_estimate() -> None: - events = [ + events: list[dict[str, Any]] = [ {"type": "response.created", "response": {"model": "gpt-5-mini"}}, {"type": "response.output_text.delta", "delta": "Why did the chicken"}, {"type": "response.output_text.done", "text": "Why did the chicken"}, @@ -139,3 +140,130 @@ async def test_non_streaming_responses_without_usage_bills_from_estimate() -> No usage = _billed_usage(get_cost) assert usage is not None assert usage["output_tokens"] > 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("responses_api", [False, True]) +async def test_empty_streaming_usage_uses_estimate(responses_api: bool) -> None: + payload: dict[str, Any] = {"model": "gpt-4o", "usage": {}} + if responses_api: + payload["output"] = [{"content": [{"type": "output_text", "text": "Hello"}]}] + else: + payload["choices"] = [{"delta": {"content": "Hello"}}] + + _, get_cost, _ = await _settle(_sse([payload]), responses_api=responses_api) + + usage = _billed_usage(get_cost) + assert usage is not None + assert usage.get("estimated") is True + assert usage["output_tokens"] > 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("terminal_type", ["response.completed", "response.incomplete"]) +async def test_responses_terminal_output_is_not_billed_twice( + terminal_type: str, +) -> None: + payload = { + "model": "gpt-4o", + "output": [{"content": [{"type": "output_text", "text": "Hello world"}]}], + } + events: list[dict[str, Any]] = [ + {"type": "response.output_text.delta", "delta": "Hello world"}, + {"type": terminal_type, "response": payload}, + ] + _, streaming_cost, _ = await _settle(_sse(events), responses_api=True) + _, json_cost, _ = await _settle(_json(payload), responses_api=True) + + stream_usage = _billed_usage(streaming_cost) + json_usage = _billed_usage(json_cost) + assert stream_usage is not None and json_usage is not None + for field in ("input_tokens", "output_tokens", "total_tokens"): + assert stream_usage[field] == json_usage[field] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("input_shape", ["string", "message", "content_blocks"]) +async def test_responses_estimate_includes_input( + stream: bool, input_shape: str +) -> None: + prompt = "Explain how payment reservations work. " * 100 + response_input: Any = prompt + if input_shape == "message": + response_input = [{"role": "user", "content": prompt}] + elif input_shape == "content_blocks": + response_input = [ + {"role": "user", "content": [{"type": "input_text", "text": prompt}]} + ] + request_body = json.dumps( + { + "model": "gpt-4o", + "instructions": "Answer concisely.", + "input": response_input, + } + ).encode() + payload = { + "model": "gpt-4o", + "output": [{"content": [{"type": "output_text", "text": "Hello"}]}], + } + response = _sse([payload]) if stream else _json(payload) + _, get_cost, _ = await _settle( + response, responses_api=True, request_body=request_body + ) + + usage = _billed_usage(get_cost) + assert usage is not None + assert usage["input_tokens"] > 100 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("responses_api", [False, True]) +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("tokens", [0, 17]) +async def test_reported_usage_takes_precedence( + responses_api: bool, stream: bool, tokens: int +) -> None: + payload: dict[str, Any] = { + "model": "gpt-4o", + "usage": {"input_tokens": tokens, "output_tokens": tokens}, + } + if responses_api: + payload["output"] = [{"content": [{"type": "output_text", "text": "Hello"}]}] + else: + payload["choices"] = [{"message": {"content": "Hello"}}] + + response = _sse([payload]) if stream else _json(payload) + _, get_cost, _ = await _settle(response, responses_api=responses_api) + + usage = _billed_usage(get_cost) + assert usage is not None + assert usage["input_tokens"] == tokens + assert usage["output_tokens"] == tokens + assert "estimated" not in usage + + +@pytest.mark.asyncio +@pytest.mark.parametrize("responses_api", [False, True]) +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("unit", ["sat", "msat"]) +async def test_pricing_error_refunds_full_prepayment( + responses_api: bool, stream: bool, unit: str +) -> None: + payload = { + "model": "unpriced-model", + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + response = _sse([payload]) if stream else _json(payload) + with patch( + "routstr.payment.cost_calculation._get_pricing_rates", + side_effect=ValueError("No pricing for model"), + ): + result, _, send_refund = await _settle( + response, responses_api=responses_api, unit=unit + ) + + send_refund.assert_awaited_once_with(10_000, unit, None, request_id=None) + assert result.status_code == 200 + assert result.headers["X-Cashu"] == "cashuBrefund" + assert result.headers["X-Routstr-Cost-Msats"] == "0" From 12befe7a3d93fdc0493d70afd6f8ab5e9377ec28 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 22:03:43 +0200 Subject: [PATCH 16/30] refactor: share x-cashu usage estimation and log full refunds --- routstr/upstream/base.py | 157 +++++++++++++++-------- routstr/upstream/count_tokens.py | 6 + tests/unit/test_x_cashu_missing_usage.py | 34 +++++ 3 files changed, 145 insertions(+), 52 deletions(-) diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index d41b2664..1f018f4e 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -154,13 +154,33 @@ def _inject_cost_response_headers( headers["X-Routstr-Cost-Usd"] = str(total_usd) -def _estimated_usage( - estimator: MissingUsageEstimator, model: str | None -) -> dict[str, Any] | None: - """Local usage estimate, or None when the upstream generated no text.""" - if not estimator.output_text: - return None - return estimator.response_data(model)["usage"] +def _apply_estimated_usage( + response_json: dict[str, Any], + request_body: bytes | None, + model_obj: Model | None, + amount: int, + unit: str, + api: str, +) -> None: + """Bill a buffered response from a local estimate when usage is missing.""" + if response_json.get("usage"): + return + estimator = MissingUsageEstimator(request_body, model_obj) + estimator.observe(response_json) + estimated = estimator.estimated_usage(response_json.get("model")) + if not estimated: + return + logger.warning( + "No usage in non-streaming response, billing from local token estimate", + extra={ + "api": api, + "model": response_json.get("model", "unknown"), + "amount": amount, + "unit": unit, + "estimated_usage": estimated, + }, + ) + response_json["usage"] = estimated def _parse_sse_events(content: str) -> list[tuple[list[str], str]]: @@ -472,6 +492,34 @@ class BaseUpstreamProvider: return response_json["provider"] = f"{provider_type}:{existing_str}" + def _log_full_refund( + self, + *, + route: str, + model: str | None, + content_str: str, + amount: int, + unit: str, + ) -> None: + """Record a settlement that serves content but charges nothing. + + The client keeps both the response and the whole prepayment, so the + model, the serving upstream and a redacted body preview are logged to + keep the unbilled request auditable. + """ + logger.warning( + "Zero-cost settlement, refunding the full prepayment", + extra={ + "route": route, + "model": model or "unknown", + "provider_type": self.provider_type, + "upstream_base_url": self.base_url, + "refund_amount": amount, + "unit": unit, + "response_body_preview": redact_org_ids(content_str.strip()[:500]), + }, + ) + def inject_cost_metadata( self, response_json: dict, @@ -3893,7 +3941,7 @@ class BaseUpstreamProvider: usage_estimator.observe(data_json) if not usage_data: - usage_data = _estimated_usage(usage_estimator, model) + usage_data = usage_estimator.estimated_usage(model) if usage_data: logger.warning( "No usage in streaming response, billing from local token estimate", @@ -3920,6 +3968,14 @@ class BaseUpstreamProvider: cost_data = await self.get_x_cashu_cost( response_data, max_cost_for_model, model_obj ) + if cost_data is not None and cost_data.total_msats == 0: + self._log_full_refund( + route="chat.streaming", + model=model, + content_str=content_str, + amount=amount, + unit=unit, + ) if cost_data: if unit == "msat": refund_amount = amount - cost_data.total_msats @@ -4044,26 +4100,20 @@ class BaseUpstreamProvider: try: response_json = json.loads(content_str) self._apply_provider_field(response_json) - if not response_json.get("usage"): - usage_estimator = MissingUsageEstimator(request_body, model_obj) - usage_estimator.observe(response_json) - estimated = _estimated_usage( - usage_estimator, response_json.get("model") - ) - if estimated: - logger.warning( - "No usage in non-streaming response, billing from local token estimate", - extra={ - "model": response_json.get("model", "unknown"), - "amount": amount, - "unit": unit, - "estimated_usage": estimated, - }, - ) - response_json["usage"] = estimated + _apply_estimated_usage( + response_json, request_body, model_obj, amount, unit, "chat" + ) cost_data = await self.get_x_cashu_cost( response_json, max_cost_for_model, model_obj ) + if cost_data is not None and cost_data.total_msats == 0: + self._log_full_refund( + route="chat", + model=response_json.get("model"), + content_str=content_str, + amount=amount, + unit=unit, + ) if cost_data and "usage" in response_json: # Inject cost breakdown into both the response body (so the @@ -4936,16 +4986,17 @@ class BaseUpstreamProvider: model = payload["model"] if not usage_data: - usage_data = _estimated_usage(usage_estimator, model) - logger.warning( - "No usage in streaming Responses API response, billing from local token estimate", - extra={ - "model": model, - "amount": amount, - "unit": unit, - "estimated_usage": usage_data, - }, - ) + usage_data = usage_estimator.estimated_usage(model) + if usage_data: + logger.warning( + "No usage in streaming Responses API response, billing from local token estimate", + extra={ + "model": model, + "amount": amount, + "unit": unit, + "estimated_usage": usage_data, + }, + ) else: logger.debug( "Found usage data in streaming Responses API response", @@ -4963,6 +5014,14 @@ class BaseUpstreamProvider: cost_data = await self.get_x_cashu_cost( response_data, max_cost_for_model, model_obj ) + if cost_data is not None and cost_data.total_msats == 0: + self._log_full_refund( + route="responses.streaming", + model=model, + content_str=content_str, + amount=amount, + unit=unit, + ) if cost_data: if unit == "msat": refund_amount = amount - cost_data.total_msats @@ -5079,26 +5138,20 @@ class BaseUpstreamProvider: try: response_json = json.loads(content_str) self._apply_provider_field(response_json) - if not response_json.get("usage"): - usage_estimator = MissingUsageEstimator(request_body, model_obj) - usage_estimator.observe(response_json) - estimated = _estimated_usage( - usage_estimator, response_json.get("model") - ) - if estimated: - logger.warning( - "No usage in non-streaming Responses API response, billing from local token estimate", - extra={ - "model": response_json.get("model", "unknown"), - "amount": amount, - "unit": unit, - "estimated_usage": estimated, - }, - ) - response_json["usage"] = estimated + _apply_estimated_usage( + response_json, request_body, model_obj, amount, unit, "responses" + ) cost_data = await self.get_x_cashu_cost( response_json, max_cost_for_model, model_obj ) + if cost_data is not None and cost_data.total_msats == 0: + self._log_full_refund( + route="responses", + model=response_json.get("model"), + content_str=content_str, + amount=amount, + unit=unit, + ) if cost_data and "usage" in response_json: _inject_cost_into_usage(response_json, cost_data) diff --git a/routstr/upstream/count_tokens.py b/routstr/upstream/count_tokens.py index 51ffbfa4..ea067dd1 100644 --- a/routstr/upstream/count_tokens.py +++ b/routstr/upstream/count_tokens.py @@ -222,6 +222,12 @@ class MissingUsageEstimator: return self._output_parts.extend(_generated_text(response_data)) + def estimated_usage(self, model: str | None = None) -> dict[str, Any] | None: + """Local usage estimate, or None when the upstream generated no text.""" + if not self.output_text: + return None + return self.response_data(model)["usage"] + def billing_data( self, response_data: dict[str, Any] | None, diff --git a/tests/unit/test_x_cashu_missing_usage.py b/tests/unit/test_x_cashu_missing_usage.py index ceaabade..fbeaa6e4 100644 --- a/tests/unit/test_x_cashu_missing_usage.py +++ b/tests/unit/test_x_cashu_missing_usage.py @@ -5,6 +5,7 @@ When nothing can be estimated the prepayment is refunded in full. """ import json +import logging import os from typing import Any from unittest.mock import AsyncMock, patch @@ -267,3 +268,36 @@ async def test_pricing_error_refunds_full_prepayment( assert result.status_code == 200 assert result.headers["X-Cashu"] == "cashuBrefund" assert result.headers["X-Routstr-Cost-Msats"] == "0" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("responses_api", [False, True]) +async def test_full_refund_is_logged_with_model_provider_and_body( + responses_api: bool, caplog: pytest.LogCaptureFixture +) -> None: + payload = { + "model": "unpriced-model", + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + base_logger = logging.getLogger("routstr.upstream.base") + base_logger.addHandler(caplog.handler) + try: + with patch( + "routstr.payment.cost_calculation._get_pricing_rates", + side_effect=ValueError("No pricing for model"), + ): + await _settle(_json(payload), responses_api=responses_api) + finally: + base_logger.removeHandler(caplog.handler) + + record = next( + r + for r in caplog.records + if r.getMessage() == "Zero-cost settlement, refunding the full prepayment" + ) + assert record.model == "unpriced-model" + assert record.provider_type == "base" + assert record.upstream_base_url == "http://test" + assert record.refund_amount == 10_000 + assert record.unit == "msat" + assert "unpriced-model" in record.response_body_preview From b333ee92613df75b882d523f36c058cecc2f4af5 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 22:07:02 +0200 Subject: [PATCH 17/30] fix(tests): read log extras from record dict for mypy --- tests/unit/test_x_cashu_missing_usage.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_x_cashu_missing_usage.py b/tests/unit/test_x_cashu_missing_usage.py index fbeaa6e4..b2510d00 100644 --- a/tests/unit/test_x_cashu_missing_usage.py +++ b/tests/unit/test_x_cashu_missing_usage.py @@ -295,9 +295,10 @@ async def test_full_refund_is_logged_with_model_provider_and_body( for r in caplog.records if r.getMessage() == "Zero-cost settlement, refunding the full prepayment" ) - assert record.model == "unpriced-model" - assert record.provider_type == "base" - assert record.upstream_base_url == "http://test" - assert record.refund_amount == 10_000 - assert record.unit == "msat" - assert "unpriced-model" in record.response_body_preview + logged = record.__dict__ + assert logged["model"] == "unpriced-model" + assert logged["provider_type"] == "base" + assert logged["upstream_base_url"] == "http://test" + assert logged["refund_amount"] == 10_000 + assert logged["unit"] == "msat" + assert "unpriced-model" in logged["response_body_preview"] From e7666bd75b704f011190499e7a4ba9efa2daf042 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 23:18:21 +0200 Subject: [PATCH 18/30] chore(deps): patch cryptography, setuptools, wheel, h11 and fastapi/starlette CVEs --- docs/contributing/code-structure.md | 2 +- pyproject.toml | 26 ++- uv.lock | 305 +++++++++++++++++++--------- 3 files changed, 238 insertions(+), 95 deletions(-) diff --git a/docs/contributing/code-structure.md b/docs/contributing/code-structure.md index 40432164..b73e2536 100644 --- a/docs/contributing/code-structure.md +++ b/docs/contributing/code-structure.md @@ -300,7 +300,7 @@ Project metadata and dependencies: name = "routstr" version = "0.2.2" dependencies = [ - "fastapi[standard]>=0.115", + "fastapi[standard-no-fastapi-cloud-cli]>=0.141", "sqlmodel>=0.0.24", "cashu", # ... diff --git a/pyproject.toml b/pyproject.toml index 776477e1..5cbef9b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,11 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ - "fastapi[standard]>=0.115", + "fastapi[standard-no-fastapi-cloud-cli]>=0.141", "aiosqlite>=0.20", "sqlmodel>=0.0.24", "httpx[socks]>=0.25.2", - "h11>=0.14", + "h11>=0.16", "greenlet>=3.2.1", "alembic>=1.13", "python-json-logger>=2.0.0", @@ -87,3 +87,25 @@ disallow_untyped_decorators = true [tool.uv.sources] routstr = { workspace = true } + +# Security overrides (2026-09): cashu 0.20.x pins conservative upper bounds +# (h11<0.15, fastapi<0.116, cryptography<44, setuptools<76, wheel<0.42) that +# conflict with the patched versions of these libraries. routstr only imports +# cashu's wallet-side modules, which never exercise the fastapi/starlette/h11 +# server paths those caps were set for. +[tool.uv] +override-dependencies = [ + "h11>=0.16.0", # CVE-2025-43859: chunked-encoding smuggling (critical) + "fastapi[standard-no-fastapi-cloud-cli]>=0.141", # gateway to the starlette>=1.3.1 line (cashu caps <0.116) + "cryptography>=49.0.0", # GHSA-jwv3-5hgf-82ww et al. (cashu caps <44) + "setuptools>=83.0.0", # CVE-2026-59890, CVE-2025-47273 (cashu caps <76) + "wheel>=0.46.2", # CVE-2026-24049 (cashu caps <0.42) +] + +# Floors for transitive deps whose locked versions are still in-range for their +# dependents but below the patched versions. Constraints (unlike overrides) +# don't bypass any upstream pin — they only force the resolver to take the +# fixed versions. +constraint-dependencies = [ + "starlette>=1.3.1", # CVE-2026-54283, CVE-2026-48818, CVE-2026-48817, CVE-2026-48710, CVE-2025-62727, CVE-2025-54121 +] diff --git a/uv.lock b/uv.lock index 462fd619..ac3db938 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,18 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version < '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[manifest] +constraints = [{ name = "starlette", specifier = ">=1.3.1" }] +overrides = [ + { name = "cryptography", specifier = ">=49.0.0" }, + { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.141" }, + { name = "h11", specifier = ">=0.16.0" }, + { name = "setuptools", specifier = ">=83.0.0" }, + { name = "wheel", specifier = ">=0.46.2" }, ] [[package]] @@ -172,6 +183,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/62/96b5217b742805236614f05904541000f55422a6060a90d7fd4ce26c172d/alembic-1.16.4-py3-none-any.whl", hash = "sha256:b05e51e8e82efc1abd14ba2af6392897e145930c3e0a2faf2b0da2f7f7fd660d", size = 247026, upload-time = "2025-07-10T16:17:21.845Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -374,7 +394,7 @@ dependencies = [ { name = "cryptography" }, { name = "ecdsa" }, { name = "environs" }, - { name = "fastapi" }, + { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] }, { name = "googleapis-common-protos" }, { name = "greenlet" }, { name = "grpcio" }, @@ -454,47 +474,100 @@ wheels = [ [[package]] name = "cffi" -version = "1.17.1" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] [[package]] @@ -721,31 +794,58 @@ toml = [ [[package]] name = "cryptography" -version = "43.0.3" +version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989, upload-time = "2024-10-18T15:58:32.918Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f3/01fdf26701a26f4b4dbc337a26883ad5bccaa6f1bbbdd29cd89e22f18a1c/cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", size = 6225303, upload-time = "2024-10-18T15:57:36.753Z" }, - { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905, upload-time = "2024-10-18T15:57:39.166Z" }, - { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271, upload-time = "2024-10-18T15:57:41.227Z" }, - { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606, upload-time = "2024-10-18T15:57:42.903Z" }, - { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484, upload-time = "2024-10-18T15:57:45.434Z" }, - { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131, upload-time = "2024-10-18T15:57:47.267Z" }, - { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647, upload-time = "2024-10-18T15:57:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/56/48/7b6b190f1462818b324e674fa20d1d5ef3e24f2328675b9b16189cbf0b3c/cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", size = 2623873, upload-time = "2024-10-18T15:57:51.822Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b1/0ebff61a004f7f89e7b65ca95f2f2375679d43d0290672f7713ee3162aff/cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", size = 3068039, upload-time = "2024-10-18T15:57:54.426Z" }, - { url = "https://files.pythonhosted.org/packages/30/d5/c8b32c047e2e81dd172138f772e81d852c51f0f2ad2ae8a24f1122e9e9a7/cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", size = 6222984, upload-time = "2024-10-18T15:57:56.174Z" }, - { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968, upload-time = "2024-10-18T15:57:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754, upload-time = "2024-10-18T15:58:00.683Z" }, - { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458, upload-time = "2024-10-18T15:58:02.225Z" }, - { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220, upload-time = "2024-10-18T15:58:04.331Z" }, - { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898, upload-time = "2024-10-18T15:58:06.113Z" }, - { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592, upload-time = "2024-10-18T15:58:08.673Z" }, - { url = "https://files.pythonhosted.org/packages/81/1e/ffcc41b3cebd64ca90b28fd58141c5f68c83d48563c88333ab660e002cd3/cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", size = 2623145, upload-time = "2024-10-18T15:58:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026, upload-time = "2024-10-18T15:58:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, ] [[package]] @@ -818,44 +918,48 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.14" +version = "0.141.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] [package.optional-dependencies] -standard = [ +standard-no-fastapi-cloud-cli = [ { name = "email-validator" }, - { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"] }, { name = "httpx" }, { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "uvicorn", extra = ["standard"] }, ] [[package]] name = "fastapi-cli" -version = "0.0.7" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload-time = "2024-12-15T14:28:10.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload-time = "2024-12-15T14:28:06.18Z" }, + { url = "https://files.pythonhosted.org/packages/d5/53/56ae5ae17bb0a5d89d1d31e5320eb1865553ebbfbde91cdc4c221245f2a8/fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4", size = 14670, upload-time = "2026-07-16T12:16:57.297Z" }, ] [package.optional-dependencies] -standard = [ +standard-no-fastapi-cloud-cli = [ { name = "uvicorn", extra = ["standard"] }, ] @@ -1151,11 +1255,11 @@ wheels = [ [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] @@ -2110,6 +2214,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, ] +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + [[package]] name = "pydantic-settings" version = "2.14.2" @@ -2421,27 +2538,27 @@ wheels = [ [[package]] name = "rich-toolkit" -version = "0.14.9" +version = "0.20.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/4f/ec4addb95da2abe9e988c206436193d3b4e678f3113b40dfd61628a2d7e6/rich_toolkit-0.14.9.tar.gz", hash = "sha256:090b6c3f87261bc1ca4fe7fc9b0d3625b5af917ccdbcd316a26719e5d3ab20b9", size = 111025, upload-time = "2025-07-28T13:25:39.604Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/1c/f134352beb393cc17e6241ecf0bf4dd41a6759e2e3971a69a6ad185b87a2/rich_toolkit-0.20.5.tar.gz", hash = "sha256:0c9e1c414ffb0720be26285d472e263d1e704b71d31a7e13274b9996db4969e1", size = 213207, upload-time = "2026-09-08T16:13:12.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/13/39030884b963a602041e4c0c90bd1a58b068f8ec9d33baddd62216eee56c/rich_toolkit-0.14.9-py3-none-any.whl", hash = "sha256:e2404f1f088286f2f9d7f3a1a7591c8057792db466f6fecabfae283fa64126e2", size = 25018, upload-time = "2025-07-28T13:25:38.542Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5f/ee8c39750ed9837f54021a8a0eb96f971cd788c1a7d40931867d6d0518bd/rich_toolkit-0.20.5-py3-none-any.whl", hash = "sha256:e21fd616db9c0539d3c50f3433fb1173db95c19d3cbf215615e224182fad6c18", size = 39370, upload-time = "2026-09-08T16:13:10.885Z" }, ] [[package]] name = "routstr" -version = "0.4.5" +version = "0.4.7" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, { name = "alembic" }, { name = "cashu" }, - { name = "fastapi", extra = ["standard"] }, + { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] }, { name = "greenlet" }, { name = "h11" }, { name = "httpx", extra = ["socks"] }, @@ -2476,9 +2593,9 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.20" }, { name = "alembic", specifier = ">=1.13" }, { name = "cashu", specifier = ">=0.20" }, - { name = "fastapi", extras = ["standard"], specifier = ">=0.115" }, + { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.141" }, { name = "greenlet", specifier = ">=3.2.1" }, - { name = "h11", specifier = ">=0.14" }, + { name = "h11", specifier = ">=0.16" }, { name = "httpx", extras = ["socks"], specifier = ">=0.25.2" }, { name = "litellm", specifier = ">=1.55.0" }, { name = "marshmallow", specifier = ">=3.13,<4.0" }, @@ -2661,11 +2778,11 @@ wheels = [ [[package]] name = "setuptools" -version = "75.9.1" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/df/ec5ad16b0ec305081c372bd0550fd638fa96e472cd5a03049c344076ea76/setuptools-75.9.1.tar.gz", hash = "sha256:b6eca2c3070cdc82f71b4cb4bb2946bc0760a210d11362278cf1ff394e6ea32c", size = 1345088, upload-time = "2025-03-09T03:18:44.129Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/28/19ad82a0549d73ec6feffa6711eacf9246035a9426b8a8b528440c9959d2/setuptools-75.9.1-py3-none-any.whl", hash = "sha256:0a6f876d62f4d978ca1a11ab4daf728d1357731f978543ff18ecdbf9fd071f73", size = 1231632, upload-time = "2025-03-09T03:18:42.453Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] @@ -2773,14 +2890,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.46.2" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] [[package]] @@ -2916,17 +3034,17 @@ wheels = [ [[package]] name = "typer" -version = "0.16.0" +version = "0.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" }, + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, ] [[package]] @@ -3144,11 +3262,14 @@ wheels = [ [[package]] name = "wheel" -version = "0.41.3" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/d0/0b4c18a0b85c20233b0c3bc33f792aefd7f12a5832b4da77419949ff6fd9/wheel-0.41.3.tar.gz", hash = "sha256:4d4987ce51a49370ea65c0bfd2234e8ce80a12780820d9dc462597a6e60d0841", size = 98880, upload-time = "2023-10-30T10:02:45.125Z" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/20/50ed6bdf27dec98b568a8ae25dc599f35baa3d9709f9e83fd1edb56b9a90/wheel-0.48.0.tar.gz", hash = "sha256:94800765601e9171bf5d58d066e640662842bcedcbab982b2c90787a2c987322", size = 66471, upload-time = "2026-08-11T22:02:27.327Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/7f/4c07234086edbce4a0a446209dc0cb08a19bb206a3ea53b2f56a403f983b/wheel-0.41.3-py3-none-any.whl", hash = "sha256:488609bc63a29322326e05560731bf7bfea8e48ad646e1f5e40d366607de0942", size = 65801, upload-time = "2023-10-30T10:02:42.995Z" }, + { url = "https://files.pythonhosted.org/packages/2e/29/69cfbb602cd91690c55d38ba9fe53e6a7e76a6fa647bf38f19c138d25449/wheel-0.48.0-py3-none-any.whl", hash = "sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab", size = 33320, upload-time = "2026-08-11T22:02:26.1Z" }, ] [[package]] From 5d9f9776af49982022c089edd6e584a342393416 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 23:19:11 +0200 Subject: [PATCH 19/30] chore(deps): bump browserslist and @humanfs/node in ui lockfile --- ui/pnpm-lock.yaml | 78 ++++++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 2a0fe1ce..24d32a19 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -398,12 +398,16 @@ packages: peerDependencies: react-hook-form: ^7.55.0 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -1860,6 +1864,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -1871,8 +1880,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1899,6 +1908,9 @@ packages: caniuse-lite@1.0.30001776: resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2068,8 +2080,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.307: - resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} embla-carousel-react@8.6.0: resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} @@ -2841,8 +2853,9 @@ packages: sass: optional: true - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -3408,8 +3421,8 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -3576,7 +3589,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.9 lru-cache: 5.1.1 semver: 6.3.1 @@ -3753,13 +3766,18 @@ snapshots: '@standard-schema/utils': 0.3.0 react-hook-form: 7.71.2(react@19.2.4) - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': + '@humanfs/core@0.19.2': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} @@ -5181,6 +5199,8 @@ snapshots: baseline-browser-mapping@2.10.0: {} + baseline-browser-mapping@2.11.21: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -5194,13 +5214,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001776 - electron-to-chromium: 1.5.307 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) call-bind-apply-helpers@1.0.2: dependencies: @@ -5225,6 +5245,8 @@ snapshots: caniuse-lite@1.0.30001776: {} + caniuse-lite@1.0.30001810: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5384,7 +5406,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.307: {} + electron-to-chromium@1.5.422: {} embla-carousel-react@8.6.0(react@19.2.4): dependencies: @@ -5740,7 +5762,7 @@ snapshots: '@eslint/eslintrc': 3.3.3 '@eslint/js': 9.38.0 '@eslint/plugin-kit': 0.4.0 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 @@ -6303,7 +6325,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - node-releases@2.0.36: {} + node-releases@2.0.54: {} object-assign@4.1.1: {} @@ -6968,9 +6990,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 From 419cc312e5f9b4194ab7836a1c8d7a25728e7d51 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 23:22:47 +0200 Subject: [PATCH 20/30] clean up unsupported endpoints --- routstr/proxy.py | 8 -------- tests/unit/test_proxy_path_allowlist.py | 21 ++++++++++++++++++--- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/routstr/proxy.py b/routstr/proxy.py index 5feff279..5e5f54fd 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -234,14 +234,6 @@ _ALLOWED_ENDPOINTS: dict[str, frozenset[str]] = { "responses": frozenset({"POST"}), "messages": frozenset({"POST"}), "embeddings": frozenset({"POST"}), - "moderations": frozenset({"POST"}), - "rerank": frozenset({"POST"}), - "audio/speech": frozenset({"POST"}), - "audio/transcriptions": frozenset({"POST"}), - "audio/translations": frozenset({"POST"}), - "images/generations": frozenset({"POST"}), - "images/edits": frozenset({"POST"}), - "images/variations": frozenset({"POST"}), "models": frozenset({"GET"}), "attestation": frozenset({"GET"}), "tee/attestation": frozenset({"GET"}), diff --git a/tests/unit/test_proxy_path_allowlist.py b/tests/unit/test_proxy_path_allowlist.py index 33a2d610..4cd1c67f 100644 --- a/tests/unit/test_proxy_path_allowlist.py +++ b/tests/unit/test_proxy_path_allowlist.py @@ -69,6 +69,24 @@ def test_unknown_paths_are_not_forwarded() -> None: assert _forwarding_allowed("secret-endpoint", "POST") is False +@pytest.mark.parametrize( + "path", + [ + "moderations", + "rerank", + "audio/speech", + "audio/transcriptions", + "audio/translations", + "images/generations", + "images/edits", + "images/variations", + ], +) +def test_unbilled_endpoints_are_not_forwarded_by_default(path: str) -> None: + assert _forwarding_allowed(path, "POST") is False + assert _forwarding_allowed(f"v1/{path}", "POST") is False + + @pytest.mark.parametrize( "path", [ @@ -121,9 +139,6 @@ def test_known_prefix_does_not_carry_an_unknown_endpoint(path: str) -> None: ("v1/responses", "POST"), ("v1/messages", "POST"), ("v1/embeddings", "POST"), - ("moderations", "POST"), - ("audio/transcriptions", "POST"), - ("images/generations", "POST"), ("models", "GET"), ("attestation", "GET"), ("tee/attestation", "GET"), From c1c6c811086a141ff0f36e3f4305ee75c9e8393f Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:31:19 +0200 Subject: [PATCH 21/30] feat: expose per-model reasoning effort on /v1/models Keep upstream reasoning metadata (supported_efforts, default_effort, mandatory) on the catalog instead of dropping it on ingest, and map client reasoning_effort / reasoning.effort / thinking onto each model's allowlist before forwarding so unsupported levels are not sent upstream. --- routstr/payment/models.py | 77 ++++++-- routstr/upstream/base.py | 43 +---- routstr/upstream/messages_dispatch.py | 21 +-- routstr/upstream/ollama.py | 42 +---- routstr/upstream/reasoning_effort.py | 197 +++++++++++++++++++ tests/unit/test_reasoning_effort.py | 261 ++++++++++++++++++++++++++ 6 files changed, 542 insertions(+), 99 deletions(-) create mode 100644 routstr/upstream/reasoning_effort.py create mode 100644 tests/unit/test_reasoning_effort.py diff --git a/routstr/payment/models.py b/routstr/payment/models.py index 26a52aac..bdd8fa73 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -5,7 +5,7 @@ import random import httpx from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel as V2BaseModel -from pydantic.v1 import BaseModel +from pydantic.v1 import BaseModel, validator from sqlmodel.ext.asyncio.session import AsyncSession from ..core.db import ModelRow, UpstreamProviderRow, get_session @@ -85,6 +85,30 @@ class TopProvider(BaseModel): is_moderated: bool | None = None +class Reasoning(BaseModel): + """Per-model reasoning-effort metadata, matching OpenRouter's shape.""" + + mandatory: bool | None = None + default_enabled: bool | None = None + supported_efforts: list[str] | None = None + default_effort: str | None = None + supports_max_tokens: bool | None = None + + class Config: + extra = "ignore" + + def is_empty(self) -> bool: + return not any( + ( + self.mandatory is not None, + self.default_enabled is not None, + self.supported_efforts, + self.default_effort, + self.supports_max_tokens is not None, + ) + ) + + class Model(BaseModel): id: str name: str @@ -101,10 +125,43 @@ class Model(BaseModel): canonical_slug: str | None = None alias_ids: list[str] | None = None forwarded_model_id: str | None = None + reasoning: Reasoning | None = None + + class Config: + extra = "ignore" def __hash__(self) -> int: return hash(self.id) + @validator("reasoning", pre=True) + def _coerce_reasoning(cls, value: object) -> object: + if value is None or value is False: + return None + if isinstance(value, Reasoning): + return None if value.is_empty() else value + if not isinstance(value, dict) or not value: + return None + try: + parsed = Reasoning.parse_obj(value) + except Exception: + return None + return None if parsed.is_empty() else parsed + + def dict(self, **kwargs: object) -> dict: + # Non-reasoning models omit the field entirely so the catalog stays + # additive: existing clients never see a new null key. + data = super().dict(**kwargs) # type: ignore[arg-type] + reasoning = data.get("reasoning") + if not reasoning: + data.pop("reasoning", None) + elif isinstance(reasoning, dict): + cleaned = {k: v for k, v in reasoning.items() if v is not None} + if cleaned: + data["reasoning"] = cleaned + else: + data.pop("reasoning", None) + return data + def litellm_cost_entry(model_id: str) -> dict | None: """Look up ``model_id`` in litellm's bundled cost map. @@ -474,23 +531,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model: if (sats.max_cost or 0.0) < min_req_sats: sats.max_cost = min_req_sats - return Model( - id=model.id, - name=model.name, - created=model.created, - description=model.description, - context_length=model.context_length, - architecture=model.architecture, - pricing=model.pricing, - sats_pricing=sats, - per_request_limits=model.per_request_limits, - top_provider=model.top_provider, - enabled=model.enabled, - upstream_provider_id=model.upstream_provider_id, - canonical_slug=model.canonical_slug, - alias_ids=model.alias_ids, - forwarded_model_id=model.forwarded_model_id, - ) + return model.copy(update={"sats_pricing": sats}) except Exception as e: logger.error( "Failed to update sats pricing for model", diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index 1f018f4e..9e08aad4 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -64,6 +64,7 @@ from .cache_breakpoints import ( from .count_tokens import MissingUsageEstimator, count_tokens_locally from .litellm_routing import detect_litellm_prefix from .rate_limit import UPSTREAM_RATE_LIMIT, classify_rate_limit +from .reasoning_effort import apply_reasoning_effort if typing.TYPE_CHECKING: from .ehbp import ConfidentialInferenceProfile, EHBPForwardingTarget @@ -710,8 +711,7 @@ class BaseUpstreamProvider: transformed_model = self.transform_model_name(original_model) data["input"]["model"] = transformed_model - # Ensure proper Responses API structure - # Add any Responses-specific transformations here + apply_reasoning_effort(data, model_obj) return json.dumps(data).encode() except Exception as e: @@ -825,6 +825,9 @@ class BaseUpstreamProvider: if inject_anthropic_cache_breakpoints(data): changed = True + if apply_reasoning_effort(data, model_obj): + changed = True + if changed: return json.dumps(data).encode() return body @@ -5422,22 +5425,8 @@ class BaseUpstreamProvider: {k: v * self.provider_fee for k, v in base_pricing.dict().items()} ) - temp_model = Model( - id=model.id, - name=model.name, - created=model.created, - description=model.description, - context_length=model.context_length, - architecture=model.architecture, - pricing=adjusted_pricing, - sats_pricing=None, - per_request_limits=model.per_request_limits, - top_provider=model.top_provider, - enabled=model.enabled, - upstream_provider_id=model.upstream_provider_id, - canonical_slug=model.canonical_slug, - alias_ids=model.alias_ids, - forwarded_model_id=model.forwarded_model_id, + temp_model = model.copy( + update={"pricing": adjusted_pricing, "sats_pricing": None} ) ( @@ -5446,23 +5435,7 @@ class BaseUpstreamProvider: adjusted_pricing.max_cost, ) = _calculate_usd_max_costs(temp_model) - return Model( - id=model.id, - name=model.name, - created=model.created, - description=model.description, - context_length=model.context_length, - architecture=model.architecture, - pricing=adjusted_pricing, - sats_pricing=model.sats_pricing, - per_request_limits=model.per_request_limits, - top_provider=model.top_provider, - enabled=model.enabled, - upstream_provider_id=model.upstream_provider_id, - canonical_slug=model.canonical_slug, - alias_ids=model.alias_ids, - forwarded_model_id=model.forwarded_model_id, - ) + return model.copy(update={"pricing": adjusted_pricing}) async def fetch_models(self) -> list[Model]: """Fetch available models from upstream API and update cache. diff --git a/routstr/upstream/messages_dispatch.py b/routstr/upstream/messages_dispatch.py index 9ef7f257..c7e698e3 100644 --- a/routstr/upstream/messages_dispatch.py +++ b/routstr/upstream/messages_dispatch.py @@ -32,6 +32,7 @@ from ..core.exceptions import UpstreamError from ..core.redaction import redact_org_ids from ..payment.models import Model from .rate_limit import classify_rate_limit +from .reasoning_effort import adapt_messages_body_for_litellm logger = get_logger(__name__) @@ -71,6 +72,8 @@ ALLOWED_MESSAGES_REQUEST_FIELDS: frozenset[str] = frozenset( "tools", "tool_choice", "metadata", + # OpenAI-shaped effort after thinking is lifted off Anthropic bodies. + "reasoning_effort", } ) @@ -131,9 +134,7 @@ def parse_sse_blocks(buffer: bytes) -> tuple[list[dict], bytes]: return events, buffer -def events_from_chunk( - chunk: object, sse_buffer: bytes -) -> tuple[list[dict], bytes]: +def events_from_chunk(chunk: object, sse_buffer: bytes) -> tuple[list[dict], bytes]: """Normalize a stream chunk into one or more event dicts. ``litellm.anthropic.messages.acreate(stream=True)`` yields raw SSE @@ -224,9 +225,7 @@ async def aggregate_anthropic_events_to_message( raw_json = partial_json.pop(idx, None) if raw_json is not None and idx < len(blocks): try: - blocks[idx]["input"] = ( - json.loads(raw_json) if raw_json else {} - ) + blocks[idx]["input"] = json.loads(raw_json) if raw_json else {} except json.JSONDecodeError: blocks[idx]["input"] = raw_json elif etype == "message_delta": @@ -468,9 +467,7 @@ async def dispatch_anthropic_messages( on bad input or upstream failure. """ 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) try: body: dict = json.loads(request_body) @@ -489,6 +486,8 @@ async def dispatch_anthropic_messages( client_stream = bool(body.pop("stream", False)) upstream_stream = True + adapt_messages_body_for_litellm(body, model_obj) + # Forward only allowlisted Anthropic Messages request fields. Any # other client-supplied key is dropped so it cannot leak into the # upstream request. See ALLOWED_MESSAGES_REQUEST_FIELDS. @@ -498,9 +497,7 @@ async def dispatch_anthropic_messages( "Dropped non-forwardable fields before litellm dispatch", extra={"dropped_keys": dropped}, ) - 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} # Convention: `model.id` is the canonical upstream model name; # `forwarded_model_id` is the public alias the internal API exposes diff --git a/routstr/upstream/ollama.py b/routstr/upstream/ollama.py index 9fed0154..46752169 100644 --- a/routstr/upstream/ollama.py +++ b/routstr/upstream/ollama.py @@ -66,9 +66,7 @@ class OllamaUpstreamProvider(BaseUpstreamProvider): """Strip 'ollama/' prefix for Ollama API compatibility.""" return model_id.removeprefix("ollama/") - def get_request_base_url( - self, path: str, model_obj: Model | None = None - ) -> str: + def get_request_base_url(self, path: str, model_obj: Model | None = None) -> str: """Route proxy traffic through Ollama's OpenAI-compatible /v1 endpoint.""" return f"{self.base_url.rstrip('/')}/v1" @@ -185,7 +183,9 @@ class OllamaUpstreamProvider(BaseUpstreamProvider): except Exception: self._models_cache = models_with_fees - self._models_by_id = {m.forwarded_model_id or m.id: m for m in self._models_cache} + self._models_by_id = { + m.forwarded_model_id or m.id: m for m in self._models_cache + } logger.info( f"Refreshed models cache for {self.base_url}", extra={"model_count": len(models)}, @@ -224,26 +224,14 @@ class OllamaUpstreamProvider(BaseUpstreamProvider): Returns: Model with provider fee applied to pricing and max costs calculated """ - from ..payment.models import Model, Pricing, _calculate_usd_max_costs + from ..payment.models import Pricing, _calculate_usd_max_costs adjusted_pricing = Pricing.parse_obj( {k: v * self.provider_fee for k, v in model.pricing.dict().items()} ) - temp_model = Model( - id=model.id, - name=model.name, - created=model.created, - description=model.description, - context_length=model.context_length, - architecture=model.architecture, - pricing=adjusted_pricing, - sats_pricing=None, - per_request_limits=model.per_request_limits, - top_provider=model.top_provider, - enabled=model.enabled, - upstream_provider_id=model.upstream_provider_id, - canonical_slug=model.canonical_slug, + temp_model = model.copy( + update={"pricing": adjusted_pricing, "sats_pricing": None} ) ( @@ -252,18 +240,4 @@ class OllamaUpstreamProvider(BaseUpstreamProvider): adjusted_pricing.max_cost, ) = _calculate_usd_max_costs(temp_model) - return Model( - id=model.id, - name=model.name, - created=model.created, - description=model.description, - context_length=model.context_length, - architecture=model.architecture, - pricing=adjusted_pricing, - sats_pricing=model.sats_pricing, - per_request_limits=model.per_request_limits, - top_provider=model.top_provider, - enabled=model.enabled, - upstream_provider_id=model.upstream_provider_id, - canonical_slug=model.canonical_slug, - ) + return model.copy(update={"pricing": adjusted_pricing}) diff --git a/routstr/upstream/reasoning_effort.py b/routstr/upstream/reasoning_effort.py new file mode 100644 index 00000000..994135ad --- /dev/null +++ b/routstr/upstream/reasoning_effort.py @@ -0,0 +1,197 @@ +"""Map client reasoning/thinking effort onto a model's allowlist. + +OpenRouter (and some other catalogs) publish per-model reasoning metadata: +which effort levels are legal, the default, and whether reasoning is +mandatory. Clients still send the generic OpenAI / Anthropic shapes +(``reasoning_effort``, ``reasoning.effort``, ``thinking``). This module +normalizes those into a supported effort and writes the fields the +upstream actually accepts, instead of dropping the parameter or +forwarding a value the model rejects. +""" + +from __future__ import annotations + +from typing import Any + +from ..payment.models import Model, Reasoning + +# Highest first. Unknown values are treated as unranked. +EFFORT_RANK: tuple[str, ...] = ( + "max", + "xhigh", + "high", + "medium", + "low", + "minimal", + "none", +) +_RANK_INDEX: dict[str, int] = {name: i for i, name in enumerate(EFFORT_RANK)} + +_REASONING_KEYS = ("reasoning", "reasoning_effort", "thinking") + + +def _normalize_effort(value: object) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.strip().lower() + return cleaned or None + + +def closest_supported_effort( + requested: str | None, + supported: list[str], + *, + default_effort: str | None = None, + mandatory: bool = False, +) -> str | None: + """Pick a legal effort for ``requested``. + + Exact match wins. Otherwise the nearest rank in ``EFFORT_RANK`` is + used (preferring the higher neighbour on a tie). ``none`` is rejected + when ``mandatory`` is set. Missing / unmapped requests fall back to + ``default_effort``, then the highest remaining supported level. + """ + allowed_efforts: list[str] = [ + normalized + for item in supported + if (normalized := _normalize_effort(item)) is not None + ] + if mandatory: + allowed_efforts = [item for item in allowed_efforts if item != "none"] + if not allowed_efforts: + if mandatory: + return _normalize_effort(default_effort) + return _normalize_effort(requested) or _normalize_effort(default_effort) + + default = _normalize_effort(default_effort) + if default not in allowed_efforts: + default = allowed_efforts[0] + + requested_norm = _normalize_effort(requested) + if requested_norm is None or (requested_norm == "none" and mandatory): + return default + + if requested_norm in allowed_efforts: + return requested_norm + + if requested_norm not in _RANK_INDEX: + return default + + target = _RANK_INDEX[requested_norm] + return min( + allowed_efforts, + key=lambda effort: ( + abs(_RANK_INDEX.get(effort, 10_000) - target), + _RANK_INDEX.get(effort, 10_000), + ), + ) + + +def resolve_effort(requested: str | None, reasoning: Reasoning | None) -> str | None: + """Map ``requested`` through ``reasoning`` metadata when present.""" + if reasoning is None: + return _normalize_effort(requested) + supported = reasoning.supported_efforts or [] + return closest_supported_effort( + requested, + supported, + default_effort=reasoning.default_effort, + mandatory=bool(reasoning.mandatory), + ) + + +def _effort_from_thinking(thinking: object) -> str | None: + if not isinstance(thinking, dict): + return None + effort = _normalize_effort(thinking.get("effort")) + if effort: + return effort + thinking_type = _normalize_effort(thinking.get("type")) + if thinking_type in {"disabled", "none"}: + return "none" + return None + + +def extract_requested_effort(data: dict[str, Any]) -> str | None: + """Best-effort effort string from the OpenAI / Anthropic request shapes.""" + if isinstance(data.get("reasoning"), dict): + nested = _normalize_effort(data["reasoning"].get("effort")) + if nested: + return nested + top_level = _normalize_effort(data.get("reasoning_effort")) + if top_level: + return top_level + return _effort_from_thinking(data.get("thinking")) + + +def _request_mentions_reasoning(data: dict[str, Any]) -> bool: + return any(key in data for key in _REASONING_KEYS) + + +def apply_reasoning_effort( + data: dict[str, Any], + model: Model, + *, + drop_thinking: bool = False, +) -> bool: + """Rewrite ``data`` in place so effort matches the model allowlist. + + Returns True when ``data`` changed. Leaves the body alone when the + caller did not send a reasoning field and the model does not require + one. Existing ``reasoning`` object keys (``max_tokens``, ``exclude``, + ``enabled``) are preserved; only ``effort`` is mapped. + + ``drop_thinking`` is for OpenAI-compatible backends that reject the + Anthropic ``thinking`` object: the effort is lifted onto + ``reasoning_effort`` / ``reasoning.effort`` and ``thinking`` is removed. + """ + if not isinstance(data, dict): + return False + + reasoning_meta = getattr(model, "reasoning", None) + mentioned = _request_mentions_reasoning(data) + if not mentioned and not (reasoning_meta and reasoning_meta.mandatory): + return False + + resolved = resolve_effort(extract_requested_effort(data), reasoning_meta) + changed = False + + if drop_thinking and "thinking" in data: + data.pop("thinking", None) + changed = True + + if resolved is None: + return changed + + if "reasoning_effort" in data: + if data.get("reasoning_effort") != resolved: + data["reasoning_effort"] = resolved + changed = True + elif drop_thinking or (reasoning_meta and reasoning_meta.mandatory): + # Invent the OpenAI-shaped field when we stripped Anthropic + # ``thinking``, or when the model will reject a request with no + # effort at all. + if not isinstance(data.get("reasoning"), dict): + data["reasoning_effort"] = resolved + changed = True + + existing = data.get("reasoning") + if isinstance(existing, dict): + if existing.get("effort") != resolved: + data["reasoning"] = {**existing, "effort": resolved} + changed = True + elif reasoning_meta and reasoning_meta.mandatory and "reasoning_effort" not in data: + data["reasoning"] = {"effort": resolved} + changed = True + + return changed + + +def adapt_messages_body_for_litellm(data: dict[str, Any], model: Model) -> None: + """Convert Anthropic ``thinking`` into OpenAI-shaped effort for litellm. + + Litellm's Anthropic-messages adapter talking to an OpenAI-compatible + upstream will 400 on ``thinking``. Lift the effort onto + ``reasoning_effort`` and drop the Anthropic-only object. + """ + apply_reasoning_effort(data, model, drop_thinking=True) diff --git a/tests/unit/test_reasoning_effort.py b/tests/unit/test_reasoning_effort.py new file mode 100644 index 00000000..21c405a3 --- /dev/null +++ b/tests/unit/test_reasoning_effort.py @@ -0,0 +1,261 @@ +"""Per-model reasoning-effort catalog metadata and request mapping.""" + +from __future__ import annotations + +import json +import os +from typing import Any + +os.environ.setdefault("UPSTREAM_BASE_URL", "http://test") +os.environ.setdefault("UPSTREAM_API_KEY", "test") +os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to") + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from routstr.core.db import get_session +from routstr.payment.models import ( + Architecture, + Model, + Pricing, + Reasoning, + models_router, +) +from routstr.upstream import GenericUpstreamProvider +from routstr.upstream.reasoning_effort import ( + adapt_messages_body_for_litellm, + apply_reasoning_effort, + closest_supported_effort, + extract_requested_effort, + resolve_effort, +) + + +def _model(**kwargs: Any) -> Model: + reasoning = kwargs.pop("reasoning", None) + return Model( + id=kwargs.get("id", "openai/gpt-5.6-sol"), + 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), + reasoning=reasoning, + ) + + +SOL_REASONING = { + "mandatory": False, + "default_enabled": True, + "supported_efforts": ["max", "xhigh", "high", "medium", "low", "none"], + "default_effort": "medium", +} + + +def test_model_parses_openrouter_reasoning_object() -> None: + model = Model( + id="openai/gpt-5.6-sol", + name="GPT", + created=0, + description="", + context_length=1, + architecture={ + "modality": "text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "x", + "instruct_type": None, + }, + pricing={"prompt": 1e-6, "completion": 1e-6}, + reasoning=SOL_REASONING, + extra_ignored_field="drop me", + ) + assert model.reasoning is not None + assert model.reasoning.supported_efforts == [ + "max", + "xhigh", + "high", + "medium", + "low", + "none", + ] + dumped = model.dict() + assert dumped["reasoning"]["supported_efforts"][0] == "max" + assert dumped["reasoning"]["default_effort"] == "medium" + assert "extra_ignored_field" not in dumped + + +def test_non_reasoning_models_omit_the_field() -> None: + dumped = _model().dict() + assert "reasoning" not in dumped + + +def test_malformed_reasoning_is_dropped_not_fatal() -> None: + model = _model(reasoning=["not", "a", "dict"]) + assert model.reasoning is None + assert "reasoning" not in model.dict() + + +def test_closest_effort_maps_minimal_to_low() -> None: + assert ( + closest_supported_effort( + "minimal", + ["max", "xhigh", "high", "medium", "low", "none"], + default_effort="medium", + ) + == "low" + ) + + +def test_closest_effort_maps_max_when_missing() -> None: + assert ( + closest_supported_effort( + "max", + ["high", "medium", "low", "none"], + default_effort="medium", + ) + == "high" + ) + + +def test_mandatory_rejects_none() -> None: + assert ( + closest_supported_effort( + "none", + ["max", "high", "medium", "low", "none"], + default_effort="high", + mandatory=True, + ) + == "high" + ) + + +def test_missing_request_uses_default() -> None: + reasoning = Reasoning.parse_obj(SOL_REASONING) + assert resolve_effort(None, reasoning) == "medium" + + +def test_extract_prefers_nested_reasoning_effort() -> None: + assert ( + extract_requested_effort( + {"reasoning_effort": "low", "reasoning": {"effort": "high"}} + ) + == "high" + ) + + +def test_prepare_request_body_rewrites_unsupported_effort() -> None: + provider = GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1") + model = _model(reasoning=SOL_REASONING) + body = json.dumps( + { + "model": "openai/gpt-5.6-sol", + "messages": [{"role": "user", "content": "hi"}], + "reasoning_effort": "minimal", + } + ).encode() + out = provider.prepare_request_body(body, model) + assert out is not None + data = json.loads(out) + assert data["reasoning_effort"] == "low" + + +def test_prepare_request_body_rewrites_nested_effort() -> None: + provider = GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1") + model = _model(reasoning=SOL_REASONING) + body = json.dumps( + { + "model": "openai/gpt-5.6-sol", + "messages": [{"role": "user", "content": "hi"}], + "reasoning": {"effort": "minimal", "exclude": False}, + } + ).encode() + out = provider.prepare_request_body(body, model) + data = json.loads(out) + assert data["reasoning"]["effort"] == "low" + assert data["reasoning"]["exclude"] is False + + +def test_prepare_request_body_leaves_plain_chat_alone() -> None: + provider = GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1") + model = _model(reasoning=SOL_REASONING) + payload = { + "model": "openai/gpt-5.6-sol", + "messages": [{"role": "user", "content": "hi"}], + } + body = json.dumps(payload).encode() + out = provider.prepare_request_body(body, model) + assert out == body + + +def test_apply_injects_default_when_mandatory() -> None: + data: dict[str, Any] = { + "model": "anthropic/claude-fable-5.1", + "messages": [{"role": "user", "content": "hi"}], + } + model = _model( + id="anthropic/claude-fable-5.1", + reasoning={ + "mandatory": True, + "supported_efforts": ["max", "xhigh", "high", "medium", "low"], + "default_effort": "high", + }, + ) + assert apply_reasoning_effort(data, model) is True + assert data["reasoning_effort"] == "high" + + +def test_fee_apply_preserves_reasoning() -> None: + provider = GenericUpstreamProvider( + base_url="https://openrouter.ai/api/v1", provider_fee=1.1 + ) + model = _model(reasoning=SOL_REASONING) + priced = provider._apply_provider_fee_to_model(model) + assert priced.reasoning is not None + assert priced.reasoning.supported_efforts == SOL_REASONING["supported_efforts"] + + +def test_v1_models_includes_reasoning_and_omits_when_absent( + monkeypatch: Any, +) -> None: + with_reasoning = _model(id="openai/gpt-5.6-sol", reasoning=SOL_REASONING) + without = _model(id="openai/gpt-4o") + unique = {"openai/gpt-5.6-sol": with_reasoning, "openai/gpt-4o": without} + + import routstr.proxy as proxy + + monkeypatch.setattr(proxy, "_unique_models", unique) + app = FastAPI() + app.include_router(models_router) + app.dependency_overrides[get_session] = lambda: None + response = TestClient(app).get("/v1/models") + assert response.status_code == 200 + by_id = {row["id"]: row for row in response.json()["data"]} + assert by_id["openai/gpt-5.6-sol"]["reasoning"]["supported_efforts"] == [ + "max", + "xhigh", + "high", + "medium", + "low", + "none", + ] + assert "reasoning" not in by_id["openai/gpt-4o"] + + +def test_messages_thinking_becomes_reasoning_effort() -> None: + model = _model(reasoning=SOL_REASONING) + body: dict[str, Any] = { + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 16, + "thinking": {"type": "enabled", "effort": "minimal"}, + } + adapt_messages_body_for_litellm(body, model) + assert "thinking" not in body + assert body["reasoning_effort"] == "low" From c95ef0f188c0d480e637bab5d4f23dd95a7c1aa1 Mon Sep 17 00:00:00 2001 From: thefux Date: Wed, 9 Sep 2026 12:58:27 +0000 Subject: [PATCH 22/30] fix: resolve mypy errors in reasoning effort tests --- tests/unit/test_reasoning_effort.py | 35 ++++++++++++++++------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/tests/unit/test_reasoning_effort.py b/tests/unit/test_reasoning_effort.py index 21c405a3..55d3b980 100644 --- a/tests/unit/test_reasoning_effort.py +++ b/tests/unit/test_reasoning_effort.py @@ -60,22 +60,24 @@ SOL_REASONING = { def test_model_parses_openrouter_reasoning_object() -> None: - model = Model( - id="openai/gpt-5.6-sol", - name="GPT", - created=0, - description="", - context_length=1, - architecture={ - "modality": "text", - "input_modalities": ["text"], - "output_modalities": ["text"], - "tokenizer": "x", - "instruct_type": None, - }, - pricing={"prompt": 1e-6, "completion": 1e-6}, - reasoning=SOL_REASONING, - extra_ignored_field="drop me", + model = Model.parse_obj( + { + "id": "openai/gpt-5.6-sol", + "name": "GPT", + "created": 0, + "description": "", + "context_length": 1, + "architecture": { + "modality": "text", + "input_modalities": ["text"], + "output_modalities": ["text"], + "tokenizer": "x", + "instruct_type": None, + }, + "pricing": {"prompt": 1e-6, "completion": 1e-6}, + "reasoning": SOL_REASONING, + "extra_ignored_field": "drop me", + } ) assert model.reasoning is not None assert model.reasoning.supported_efforts == [ @@ -178,6 +180,7 @@ def test_prepare_request_body_rewrites_nested_effort() -> None: } ).encode() out = provider.prepare_request_body(body, model) + assert out is not None data = json.loads(out) assert data["reasoning"]["effort"] == "low" assert data["reasoning"]["exclude"] is False From 5918cdf8398132c932b7163f55afc99e4f765d29 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 9 Sep 2026 22:19:04 +0200 Subject: [PATCH 23/30] clean up --- pyproject.toml | 27 ++++++++++++--------------- uv.lock | 11 +++++++---- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5cbef9b0..38f1ac94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,24 +88,21 @@ disallow_untyped_decorators = true [tool.uv.sources] routstr = { workspace = true } -# Security overrides (2026-09): cashu 0.20.x pins conservative upper bounds -# (h11<0.15, fastapi<0.116, cryptography<44, setuptools<76, wheel<0.42) that -# conflict with the patched versions of these libraries. routstr only imports -# cashu's wallet-side modules, which never exercise the fastapi/starlette/h11 -# server paths those caps were set for. +# Security floors. cashu 0.20.x caps h11, fastapi, cryptography, setuptools +# and wheel below their patched versions. routstr uses only cashu's wallet +# modules, not its server paths, so the caps are safe to lift here. [tool.uv] override-dependencies = [ - "h11>=0.16.0", # CVE-2025-43859: chunked-encoding smuggling (critical) - "fastapi[standard-no-fastapi-cloud-cli]>=0.141", # gateway to the starlette>=1.3.1 line (cashu caps <0.116) - "cryptography>=49.0.0", # GHSA-jwv3-5hgf-82ww et al. (cashu caps <44) - "setuptools>=83.0.0", # CVE-2026-59890, CVE-2025-47273 (cashu caps <76) - "wheel>=0.46.2", # CVE-2026-24049 (cashu caps <0.42) + "h11>=0.16.0", + "fastapi[standard-no-fastapi-cloud-cli]>=0.141", + "cryptography>=49.0.0", + "setuptools>=83.0.0", + "wheel>=0.46.2", ] -# Floors for transitive deps whose locked versions are still in-range for their -# dependents but below the patched versions. Constraints (unlike overrides) -# don't bypass any upstream pin — they only force the resolver to take the -# fixed versions. +# Transitive deps whose dependents allow the patched version but don't require +# it. Constraints raise the floor without bypassing any upstream pin. constraint-dependencies = [ - "starlette>=1.3.1", # CVE-2026-54283, CVE-2026-48818, CVE-2026-48817, CVE-2026-48710, CVE-2025-62727, CVE-2025-54121 + "starlette>=1.3.1", + "httpcore>=1.0.9", # 1.0.8 caps h11<0.15 ] diff --git a/uv.lock b/uv.lock index ac3db938..18e9d483 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,10 @@ resolution-markers = [ ] [manifest] -constraints = [{ name = "starlette", specifier = ">=1.3.1" }] +constraints = [ + { name = "httpcore", specifier = ">=1.0.9" }, + { name = "starlette", specifier = ">=1.3.1" }, +] overrides = [ { name = "cryptography", specifier = ">=49.0.0" }, { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.141" }, @@ -1296,15 +1299,15 @@ wheels = [ [[package]] name = "httpcore" -version = "1.0.8" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/45/ad3e1b4d448f22c0cff4f5692f5ed0666658578e358b8d58a19846048059/httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad", size = 85385, upload-time = "2025-04-11T14:42:46.661Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/8d/f052b1e336bb2c1fc7ed1aaed898aa570c0b61a09707b108979d9fc6e308/httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be", size = 78732, upload-time = "2025-04-11T14:42:44.896Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] From 8e97415c611b208202a367b71d1c28e880dc5d64 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 9 Sep 2026 23:23:11 +0200 Subject: [PATCH 24/30] update deps --- ui/package.json | 4 +- ui/pnpm-lock.yaml | 414 ++++++++++++++++++++--------------------- ui/pnpm-workspace.yaml | 4 +- ui/tsconfig.json | 7 +- 4 files changed, 208 insertions(+), 221 deletions(-) diff --git a/ui/package.json b/ui/package.json index 19565407..1d34f131 100644 --- a/ui/package.json +++ b/ui/package.json @@ -48,7 +48,7 @@ "geist": "^1.7.0", "input-otp": "^1.4.2", "lucide-react": "^0.575.0", - "next": "16.2.11", + "next": "16.3.4", "next-themes": "^0.4.6", "qrcode": "^1.5.4", "radix-ui": "^1.4.3", @@ -74,7 +74,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint": "^9.7.0", - "eslint-config-next": "16.2.11", + "eslint-config-next": "16.3.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react": "^7.37.5", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 24d32a19..097f3490 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -12,13 +12,13 @@ overrides: flatted: 3.4.2 follow-redirects: 1.16.0 form-data: 4.0.6 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch@3: 3.1.4 nanoid@3: 3.3.18 picomatch@2: 2.3.2 picomatch@4: 4.0.4 postcss: 8.5.23 - sharp: 0.35.0 + sharp: 0.35.4 importers: @@ -122,7 +122,7 @@ importers: version: 8.6.0(react@19.2.4) geist: specifier: ^1.7.0 - version: 1.7.0(next@16.2.11(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 1.7.0(next@16.3.4(@babel/core@7.29.6)(@types/node@25.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -130,8 +130,8 @@ importers: specifier: ^0.575.0 version: 0.575.0(react@19.2.4) next: - specifier: 16.2.11 - version: 16.2.11(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.3.4 + version: 16.3.4(@babel/core@7.29.6)(@types/node@25.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -203,8 +203,8 @@ importers: specifier: ^9.7.0 version: 9.38.0(jiti@2.6.1) eslint-config-next: - specifier: 16.2.11 - version: 16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) + specifier: 16.3.4 + version: 16.3.4(@typescript-eslint/parser@8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@9.38.0(jiti@2.6.1)) @@ -334,9 +334,6 @@ packages: '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -422,144 +419,144 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.0': - resolution: {integrity: sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.0': - resolution: {integrity: sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.0': - resolution: {integrity: sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.0': - resolution: {integrity: sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.0': - resolution: {integrity: sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.0': - resolution: {integrity: sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.3.0': - resolution: {integrity: sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.3.0': - resolution: {integrity: sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.3.0': - resolution: {integrity: sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.3.0': - resolution: {integrity: sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.3.0': - resolution: {integrity: sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.3.0': - resolution: {integrity: sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.3.0': - resolution: {integrity: sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.35.0': - resolution: {integrity: sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.35.0': - resolution: {integrity: sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.35.0': - resolution: {integrity: sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.35.0': - resolution: {integrity: sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.35.0': - resolution: {integrity: sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.35.0': - resolution: {integrity: sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.35.0': - resolution: {integrity: sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.35.0': - resolution: {integrity: sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.35.0': - resolution: {integrity: sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.0': - resolution: {integrity: sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.0': - resolution: {integrity: sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.0': - resolution: {integrity: sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.0': - resolution: {integrity: sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -583,56 +580,56 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@16.2.11': - resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/eslint-plugin-next@16.2.11': - resolution: {integrity: sha512-vMEf/aXOpzFFdtIvFYOnIDPKb0xBbrXONsz83CcKdRrekfxNdL8PNkq5qHqAHSXVlIifnX68LOMaxr3z5PkeLQ==} + '@next/eslint-plugin-next@16.3.4': + resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==} - '@next/swc-darwin-arm64@16.2.11': - resolution: {integrity: sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.11': - resolution: {integrity: sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.11': - resolution: {integrity: sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.11': - resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.11': - resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.11': - resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.2.11': - resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.11': - resolution: {integrity: sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1437,8 +1434,8 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@tabby_ai/hijri-converter@1.0.5': resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} @@ -1859,11 +1856,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.11.21: resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} @@ -1905,9 +1897,6 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001776: - resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} - caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} @@ -2153,8 +2142,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.2.11: - resolution: {integrity: sha512-FIpbK/dUyxUExchDB7eBg3k+VU8R2iR/Cx9/kqTBUTFv2bOIR9aRrpno4rvAQ9VhiPQAyFKNA2NlZwouGWtclA==} + eslint-config-next@16.3.4: + resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2639,8 +2628,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -2832,8 +2821,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.11: - resolution: {integrity: sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3206,11 +3195,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -3231,9 +3215,14 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sharp@0.35.0: - resolution: {integrity: sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -3688,11 +3677,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 @@ -3729,7 +3713,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch: 3.1.4 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3785,108 +3769,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.0': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.0 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.0': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.0 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.0': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.0 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.0': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.0': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.0': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.0': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.0': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.0': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.0': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.0': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.0': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.0': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.0': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.0 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.0': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.0 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.0': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.0 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.0': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.0 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.0': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.0 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.0': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.0 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.0': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.0': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.0 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.0': + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.0': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.0 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.0': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.0': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.0': + '@img/sharp-win32-x64@0.35.4': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -3911,38 +3895,41 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.11': {} + '@next/env@16.3.4': {} - '@next/eslint-plugin-next@16.2.11': + '@next/eslint-plugin-next@16.3.4(eslint@9.38.0(jiti@2.6.1))': dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.38.0(jiti@2.6.1)) fast-glob: 3.3.1 + transitivePeerDependencies: + - eslint - '@next/swc-darwin-arm64@16.2.11': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.2.11': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.2.11': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.2.11': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.2.11': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.2.11': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.2.11': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.2.11': + '@next/swc-win32-x64-msvc@16.3.4': optional: true '@nodelib/fs.scandir@2.1.5': @@ -4776,7 +4763,7 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -4987,7 +4974,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.57.0 debug: 4.4.3 minimatch: 10.2.4 - semver: 7.7.4 + semver: 7.8.5 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -5197,8 +5184,6 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.0: {} - baseline-browser-mapping@2.11.21: {} brace-expansion@1.1.18: @@ -5243,8 +5228,6 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001776: {} - caniuse-lite@1.0.30001810: {} chalk@4.1.2: @@ -5593,9 +5576,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.2.11(@typescript-eslint/parser@8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.3.4(@typescript-eslint/parser@8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.2.11 + '@next/eslint-plugin-next': 16.3.4(eslint@9.38.0(jiti@2.6.1)) eslint: 9.38.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.38.0(jiti@2.6.1)) @@ -5889,9 +5872,9 @@ snapshots: functions-have-names@1.2.3: {} - geist@1.7.0(next@16.2.11(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + geist@1.7.0(next@16.3.4(@babel/core@7.29.6)(@types/node@25.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): dependencies: - next: 16.2.11(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.6)(@types/node@25.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) generator-function@2.0.1: {} @@ -6042,7 +6025,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 is-callable@1.2.7: {} @@ -6148,7 +6131,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -6301,28 +6284,29 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.11(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.3.4(@babel/core@7.29.6)(@types/node@25.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.11 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001776 + '@next/env': 16.3.4 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 postcss: 8.5.23 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(@babel/core@7.29.6)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.11 - '@next/swc-darwin-x64': 16.2.11 - '@next/swc-linux-arm64-gnu': 16.2.11 - '@next/swc-linux-arm64-musl': 16.2.11 - '@next/swc-linux-x64-gnu': 16.2.11 - '@next/swc-linux-x64-musl': 16.2.11 - '@next/swc-win32-arm64-msvc': 16.2.11 - '@next/swc-win32-x64-msvc': 16.2.11 - sharp: 0.35.0 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 + sharp: 0.35.4(@types/node@25.4.0) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros node-releases@2.0.54: {} @@ -6682,10 +6666,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} - - semver@7.8.5: - optional: true + semver@7.8.5: {} set-blocking@2.0.0: {} @@ -6711,37 +6692,38 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - sharp@0.35.0: + sharp@0.35.4(@types/node@25.4.0): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.0 - '@img/sharp-darwin-x64': 0.35.0 - '@img/sharp-freebsd-wasm32': 0.35.0 - '@img/sharp-libvips-darwin-arm64': 1.3.0 - '@img/sharp-libvips-darwin-x64': 1.3.0 - '@img/sharp-libvips-linux-arm': 1.3.0 - '@img/sharp-libvips-linux-arm64': 1.3.0 - '@img/sharp-libvips-linux-ppc64': 1.3.0 - '@img/sharp-libvips-linux-riscv64': 1.3.0 - '@img/sharp-libvips-linux-s390x': 1.3.0 - '@img/sharp-libvips-linux-x64': 1.3.0 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.0 - '@img/sharp-libvips-linuxmusl-x64': 1.3.0 - '@img/sharp-linux-arm': 0.35.0 - '@img/sharp-linux-arm64': 0.35.0 - '@img/sharp-linux-ppc64': 0.35.0 - '@img/sharp-linux-riscv64': 0.35.0 - '@img/sharp-linux-s390x': 0.35.0 - '@img/sharp-linux-x64': 0.35.0 - '@img/sharp-linuxmusl-arm64': 0.35.0 - '@img/sharp-linuxmusl-x64': 0.35.0 - '@img/sharp-webcontainers-wasm32': 0.35.0 - '@img/sharp-win32-arm64': 0.35.0 - '@img/sharp-win32-ia32': 0.35.0 - '@img/sharp-win32-x64': 0.35.0 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 25.4.0 optional: true shebang-command@2.0.0: diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml index 660076f4..317f28e0 100644 --- a/ui/pnpm-workspace.yaml +++ b/ui/pnpm-workspace.yaml @@ -10,10 +10,10 @@ overrides: flatted: 3.4.2 follow-redirects: 1.16.0 form-data: 4.0.6 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch@3: 3.1.4 nanoid@3: 3.3.18 picomatch@2: 2.3.2 picomatch@4: 4.0.4 postcss: 8.5.23 - sharp: 0.35.0 + sharp: 0.35.4 diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 705f5ce5..77eb1167 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -29,5 +29,10 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "**/__tests__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } From bdbe31f1adb20b423baf0e52f075d87cb8b7ad3b Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 8 Sep 2026 23:24:41 +0200 Subject: [PATCH 25/30] chore(deps): bump httpx to 0.28 and litellm to 1.84, shimming cashu's removed proxies kwarg --- examples/tor.py | 2 +- pyproject.toml | 14 ++- routstr/cashu_compat.py | 98 +++++++++++++++++++ routstr/nostr/discovery.py | 6 +- routstr/payment/lnurl.py | 5 + routstr/wallet.py | 5 + .../test_admin_pricing_rate_validation.py | 15 ++- tests/unit/test_cashu_httpx_compat.py | 78 +++++++++++++++ uv.lock | 36 +++---- 9 files changed, 229 insertions(+), 30 deletions(-) create mode 100644 routstr/cashu_compat.py create mode 100644 tests/unit/test_cashu_httpx_compat.py diff --git a/examples/tor.py b/examples/tor.py index 7e67bb87..a534c005 100644 --- a/examples/tor.py +++ b/examples/tor.py @@ -7,7 +7,7 @@ from openai import OpenAI client = OpenAI( api_key=os.environ.get("TOKEN"), base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"), - http_client=httpx.Client(proxies="socks5://localhost:9050"), + http_client=httpx.Client(proxy="socks5://localhost:9050"), ) print( diff --git a/pyproject.toml b/pyproject.toml index 38f1ac94..2b67f965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "fastapi[standard-no-fastapi-cloud-cli]>=0.141", "aiosqlite>=0.20", "sqlmodel>=0.0.24", - "httpx[socks]>=0.25.2", + "httpx[socks]>=0.28.1", "h11>=0.16", "greenlet>=3.2.1", "alembic>=1.13", @@ -21,7 +21,7 @@ dependencies = [ "mdurl==0.1.2", "pillow>=10", "openai>=1.98.0", - "litellm>=1.55.0", + "litellm>=1.84.0,<1.85", ] [dependency-groups] @@ -88,11 +88,15 @@ disallow_untyped_decorators = true [tool.uv.sources] routstr = { workspace = true } -# Security floors. cashu 0.20.x caps h11, fastapi, cryptography, setuptools -# and wheel below their patched versions. routstr uses only cashu's wallet -# modules, not its server paths, so the caps are safe to lift here. +# Security floors. cashu 0.20.x caps httpx, h11, fastapi, cryptography, +# setuptools and wheel below their patched versions. routstr uses only cashu's +# wallet modules, not its server paths, so the caps are safe to lift here. [tool.uv] override-dependencies = [ + # httpx 0.28 (required by litellm 1.84) removed the `proxies` kwarg cashu + # passes on every mint call; routstr/cashu_compat.py restores it for cashu. + "httpx[socks]>=0.28.1,<1.0", + "importlib-metadata>=8.0.0,<9.0", "h11>=0.16.0", "fastapi[standard-no-fastapi-cloud-cli]>=0.141", "cryptography>=49.0.0", diff --git a/routstr/cashu_compat.py b/routstr/cashu_compat.py new file mode 100644 index 00000000..d97df06b --- /dev/null +++ b/routstr/cashu_compat.py @@ -0,0 +1,98 @@ +"""Compatibility shim that keeps cashu 0.20.x working on httpx>=0.28. + +cashu's ``async_set_httpx_client`` decorator builds the client for *every* mint +call as ``httpx.AsyncClient(proxies=proxies_dict, ...)``. httpx deprecated +``proxies`` in 0.26 and removed it in 0.28, so on httpx>=0.28 every wallet +operation routstr performs -- ``load_mint_keysets``, ``mint_quote``, +``melt_quote``, token redeem -- raises:: + + TypeError: AsyncClient.__init__() got an unexpected keyword argument 'proxies' + +Upstream cashu (0.20.3, the latest release) still caps ``httpx<0.26`` and has no +release that fixes this, while litellm>=1.84 requires ``httpx>=0.28``. We bridge +the gap by translating the keyword *inside cashu's module namespace only* -- +global httpx behaviour is untouched, and the shim is a no-op on httpx<0.28. + +Delete this module once cashu ships a release that passes ``proxy=``. +""" + +from typing import Any + +import httpx + +__all__ = ["install_cashu_httpx_shim"] + +_ALL_SCHEMES = "all://" + + +def _single_proxy(proxies: Any) -> str | None: + """Collapse an httpx<0.28 ``proxies`` mapping into a single ``proxy`` URL. + + cashu only ever builds ``{}`` or ``{"all://": url}``, so a mapping with one + distinct URL is all we need to support; anything richer is unrepresentable + as httpx 0.28's scalar ``proxy=`` and is dropped rather than guessed at. + """ + if not proxies: + return None + if isinstance(proxies, str): + return proxies + if isinstance(proxies, dict): + if _ALL_SCHEMES in proxies: + value = proxies[_ALL_SCHEMES] + return str(value) if value is not None else None + distinct = {str(v) for v in proxies.values() if v is not None} + if len(distinct) == 1: + return distinct.pop() + return None + + +class _ProxiesCompatAsyncClient(httpx.AsyncClient): + """``httpx.AsyncClient`` that still accepts the removed ``proxies`` kwarg.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + if "proxies" in kwargs: + proxies = kwargs.pop("proxies") + proxy = _single_proxy(proxies) + if proxy is not None and kwargs.get("proxy") is None: + kwargs["proxy"] = proxy + super().__init__(*args, **kwargs) + + +class _HttpxNamespace: + """Stand-in for the ``httpx`` module inside cashu's ``v1_api``. + + Every attribute resolves against the real module except ``AsyncClient``, + so cashu keeps using genuine httpx types everywhere else. + """ + + AsyncClient = _ProxiesCompatAsyncClient + + def __getattr__(self, name: str) -> Any: + return getattr(httpx, name) + + +def _httpx_accepts_proxies() -> bool: + import inspect + + try: + return "proxies" in inspect.signature(httpx.AsyncClient.__init__).parameters + except (TypeError, ValueError): # pragma: no cover - defensive + return False + + +def install_cashu_httpx_shim() -> bool: + """Patch cashu's mint client to survive httpx>=0.28. + + Returns True when the shim was installed, False when it wasn't needed. + Safe to call repeatedly. + """ + if _httpx_accepts_proxies(): + return False + + from cashu.wallet import v1_api + + if isinstance(getattr(v1_api, "httpx", None), _HttpxNamespace): + return True + + v1_api.httpx = _HttpxNamespace() # type: ignore[assignment] + return True diff --git a/routstr/nostr/discovery.py b/routstr/nostr/discovery.py index 268b4c7c..ce61d4e3 100644 --- a/routstr/nostr/discovery.py +++ b/routstr/nostr/discovery.py @@ -320,18 +320,18 @@ async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]: is_onion = ".onion" in endpoint_url # Set up client arguments conditionally - proxies = None + proxy: str | None = None if is_onion: try: tor_proxy = settings.tor_proxy_url except Exception: tor_proxy = "socks5://127.0.0.1:9050" - proxies = {"http://": tor_proxy, "https://": tor_proxy} # type: ignore[assignment] + proxy = tor_proxy async with httpx.AsyncClient( timeout=httpx.Timeout(30.0), follow_redirects=True, - proxies=proxies, # type: ignore[arg-type] + proxy=proxy, ) as client: # Prefer provider's /v1/info for full details info_url = f"{endpoint_url.rstrip('/')}/v1/info" diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py index def5bca7..2f5c59a6 100644 --- a/routstr/payment/lnurl.py +++ b/routstr/payment/lnurl.py @@ -8,12 +8,17 @@ import httpx from cashu.core.base import MeltQuoteState from cashu.wallet.wallet import Proof, Wallet +from ..cashu_compat import install_cashu_httpx_shim from ..mint import ( is_mint_rate_limited, is_mint_transport_error, run_mint_operation, ) +# cashu 0.20.x passes the `proxies` kwarg httpx removed in 0.28; see the module +# docstring. Installed at import so no mint call can run before the patch. +install_cashu_httpx_shim() + try: from bech32 import bech32_decode, convertbits # type: ignore except ModuleNotFoundError: # pragma: no cover – allow runtime miss diff --git a/routstr/wallet.py b/routstr/wallet.py index 5e1588a0..910d77e3 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -20,6 +20,7 @@ from cashu.wallet.wallet import Wallet as _CashuWallet from pydantic_core import PydanticUndefined from sqlmodel import col, select, update +from .cashu_compat import install_cashu_httpx_shim from .core import db, get_logger from .core.db import store_cashu_transaction_with_retry as store_cashu_transaction from .core.settings import settings @@ -36,6 +37,10 @@ from .mint import ( ) from .payment.lnurl import raw_send_to_lnurl +# cashu 0.20.x passes the `proxies` kwarg httpx removed in 0.28; see the module +# docstring. Installed at import so no mint call can run before the patch. +install_cashu_httpx_shim() + # Backwards-compatible aliases for callers/tests that imported the former # wallet-local policy. Production modules use the public routstr.mint API. _MintRateGuard = MintRateGuard diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py index 2e9a45b9..fb6cfa18 100644 --- a/tests/integration/test_admin_pricing_rate_validation.py +++ b/tests/integration/test_admin_pricing_rate_validation.py @@ -410,12 +410,19 @@ async def test_malformed_auxiliary_rate_is_rejected( ("input_cache_write", float("-inf")), ("completion", -1.0), ): + # Send raw bytes rather than `json=`: httpx>=0.28 refuses to encode + # non-finite floats itself (allow_nan=False), but the point of this + # test is that the SERVER answers the bare NaN/Infinity literals + # with a 422, so the literals must still reach it. + body = json.dumps( + _payload( + provider_id, model_id="aux-rate", pricing=_pricing(**{field: bad}) + ) + ).encode("utf-8") resp = await integration_client.post( f"/admin/api/upstream-providers/{provider_id}/models", - headers=_admin_headers(), - json=_payload( - provider_id, model_id="aux-rate", pricing=_pricing(**{field: bad}) - ), + headers={**_admin_headers(), "content-type": "application/json"}, + content=body, ) assert resp.status_code == 422, field diff --git a/tests/unit/test_cashu_httpx_compat.py b/tests/unit/test_cashu_httpx_compat.py new file mode 100644 index 00000000..191b7b56 --- /dev/null +++ b/tests/unit/test_cashu_httpx_compat.py @@ -0,0 +1,78 @@ +"""cashu 0.20.x builds its mint client with the `proxies` kwarg httpx removed in +0.28. These tests pin the shim that keeps every wallet call working.""" + +import httpx +import pytest +from cashu.wallet import v1_api +from httpx import AsyncClient + +from routstr.cashu_compat import ( + _ProxiesCompatAsyncClient, + _single_proxy, + install_cashu_httpx_shim, +) + + +def test_httpx_no_longer_accepts_proxies() -> None: + """The premise of the shim: plain httpx rejects what cashu passes.""" + with pytest.raises(TypeError): + httpx.AsyncClient(proxies={}) # type: ignore[call-arg] + + +@pytest.mark.parametrize( + "proxies, expected", + [ + ({}, None), + (None, None), + ({"all://": "socks5://localhost:9050"}, "socks5://localhost:9050"), + ("socks5://localhost:9050", "socks5://localhost:9050"), + ({"http://": "http://p:1", "https://": "http://p:1"}, "http://p:1"), + ({"http://": "http://a:1", "https://": "http://b:2"}, None), + ], +) +def test_single_proxy_collapses_cashu_mappings( + proxies: object, expected: str | None +) -> None: + assert _single_proxy(proxies) == expected + + +@pytest.mark.parametrize("proxies", [{}, {"all://": "socks5://localhost:9050"}]) +async def test_compat_client_accepts_proxies(proxies: dict) -> None: + async with _ProxiesCompatAsyncClient( + proxies=proxies, base_url="http://mint.test" + ) as client: + assert isinstance(client, httpx.AsyncClient) + + +async def test_cashu_decorator_builds_a_client_after_shim() -> None: + """The real cashu decorator — the code path every mint call goes through.""" + install_cashu_httpx_shim() + + class _Ledger: + url = "http://mint.test/" + # cashu's decorator assigns the client here; alias avoids shadowing. + httpx: AsyncClient + + @v1_api.async_set_httpx_client # type: ignore[misc] + async def call(self) -> AsyncClient: + return self.httpx + + client: httpx.AsyncClient = await _Ledger().call() + try: + assert isinstance(client, httpx.AsyncClient) + assert str(client.base_url) == "http://mint.test" + finally: + await client.aclose() + + +def test_install_is_idempotent() -> None: + assert install_cashu_httpx_shim() is True + patched = v1_api.httpx + assert install_cashu_httpx_shim() is True + assert v1_api.httpx is patched + + +def test_shim_namespace_passes_through_other_httpx_attributes() -> None: + install_cashu_httpx_shim() + assert v1_api.httpx.Response is httpx.Response + assert v1_api.httpx.AsyncClient is _ProxiesCompatAsyncClient diff --git a/uv.lock b/uv.lock index 18e9d483..41143715 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,8 @@ overrides = [ { name = "cryptography", specifier = ">=49.0.0" }, { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.141" }, { name = "h11", specifier = ">=0.16.0" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0" }, + { name = "importlib-metadata", specifier = ">=8.0.0,<9.0" }, { name = "setuptools", specifier = ">=83.0.0" }, { name = "wheel", specifier = ">=0.46.2" }, ] @@ -939,7 +941,7 @@ wheels = [ standard-no-fastapi-cloud-cli = [ { name = "email-validator" }, { name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"] }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "jinja2" }, { name = "pydantic-extra-types" }, { name = "pydantic-settings" }, @@ -1341,18 +1343,17 @@ wheels = [ [[package]] name = "httpx" -version = "0.25.2" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, - { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/23/911d93a022979d3ea295f659fbe7edb07b3f4561a477e83b3a6d0e0c914e/httpx-0.25.2.tar.gz", hash = "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8", size = 123889, upload-time = "2023-11-24T12:36:33.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/65/6940eeb21dcb2953778a6895281c179efd9100463ff08cb6232bb6480da7/httpx-0.25.2-py3-none-any.whl", hash = "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118", size = 74980, upload-time = "2023-11-24T12:36:31.403Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [package.optional-dependencies] @@ -1368,7 +1369,7 @@ dependencies = [ { name = "filelock" }, { name = "fsspec" }, { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "packaging" }, { name = "pyyaml" }, { name = "tqdm" }, @@ -1391,14 +1392,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "6.11.0" +version = "8.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/eb/58c2ab27ee628ad801f56d4017fe62afab0293116f6d0b08f1d5bd46e06f/importlib_metadata-6.11.0.tar.gz", hash = "sha256:1231cf92d825c9e03cfc4da076a16de6422c863558229ea0b22b675657463443", size = 54593, upload-time = "2023-12-03T17:33:10.693Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/9b/ecce94952ab5ea74c31dcf9ccf78ccd484eebebef06019bf8cb579ab4519/importlib_metadata-6.11.0-py3-none-any.whl", hash = "sha256:f0afba6205ad8f8947c7d338b5342d5db2afbfd82f9cbef7879a9539cc12eb9b", size = 23427, upload-time = "2023-12-03T17:33:08.965Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f", size = 27220, upload-time = "2026-03-20T16:56:25.07Z" }, ] [[package]] @@ -1525,13 +1526,13 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.0" +version = "1.84.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, { name = "fastuuid" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "importlib-metadata" }, { name = "jinja2" }, { name = "jsonschema" }, @@ -1541,9 +1542,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/92/6ce9737554994ca8e536e5f4f6a87cc7c4774b656c9eb9add071caf7d54b/litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a", size = 17333062, upload-time = "2026-03-31T05:08:25.331Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c4/512c8cb204450b585bb7bee2cef9466c8b79b90cf774766f319de5c444ed/litellm-1.84.10.tar.gz", hash = "sha256:5ccb6aec803c35f463a7ea1a446030fe99f7c556388b435dc2fb7ad91aa48a24", size = 15123874, upload-time = "2026-06-24T03:57:19.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/2c/a670cc050fcd6f45c6199eb99e259c73aea92edba8d5c2fc1b3686d36217/litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8", size = 15610306, upload-time = "2026-03-31T05:08:21.987Z" }, + { url = "https://files.pythonhosted.org/packages/5b/88/e45bcdefc7a85bbef8eb852111dd4e02b92ea25727b6009c78893a768deb/litellm-1.84.10-py3-none-any.whl", hash = "sha256:7e175ebec04aa92149794adc83e4dd82b60d2b833c1ec265d68c08e8f56edde5", size = 16753091, upload-time = "2026-06-24T03:57:16.759Z" }, ] [[package]] @@ -1825,7 +1826,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, @@ -2579,7 +2580,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "aiohttp" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "mypy" }, { name = "openai" }, { name = "psutil" }, @@ -2599,8 +2600,8 @@ requires-dist = [ { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.141" }, { name = "greenlet", specifier = ">=3.2.1" }, { name = "h11", specifier = ">=0.16" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.25.2" }, - { name = "litellm", specifier = ">=1.55.0" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, + { name = "litellm", specifier = ">=1.84.0,<1.85" }, { name = "marshmallow", specifier = ">=3.13,<4.0" }, { name = "mdurl", specifier = "==0.1.2" }, { name = "nostr", specifier = ">=0.0.2" }, @@ -2842,6 +2843,7 @@ version = "2.0.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "importlib-metadata" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/03/a0af991e3a43174d6b83fca4fb399745abceddd1171bdabae48ce877ff47/sqlalchemy-2.0.42.tar.gz", hash = "sha256:160bedd8a5c28765bd5be4dec2d881e109e33b34922e50a3b881a7681773ac5f", size = 9749972, upload-time = "2025-07-29T12:48:09.323Z" } From 8948b585329fce2d39bf7cc9b4cd1714396ecda9 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 10 Sep 2026 15:50:11 +0200 Subject: [PATCH 26/30] chore: support python 3.14 with litellm 1.93, nostr-sdk migration, and cashu httpx shim --- .github/workflows/test.yml | 10 +- .python-version | 2 +- Dockerfile | 4 +- Dockerfile.full | 5 +- pyproject.toml | 14 +- routstr/cashu_compat.py | 16 +- routstr/core/main.py | 7 + routstr/core/settings.py | 4 +- routstr/nostr/analytics.py | 25 +-- routstr/nostr/listing.py | 159 ++++------------- routstr/nostr/sdk.py | 94 ++++++++++ tests/unit/test_cashu_httpx_compat.py | 54 +++++- tests/unit/test_nostr_sdk.py | 129 ++++++++++++++ uv.lock | 248 +++++++++++++++----------- 14 files changed, 502 insertions(+), 269 deletions(-) create mode 100644 routstr/nostr/sdk.py create mode 100644 tests/unit/test_nostr_sdk.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd3ad5a4..b0600ba3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11", "3.12", "3.14"] steps: - name: Checkout code @@ -25,22 +25,22 @@ jobs: - name: Install dependencies run: | - uv sync --dev + uv sync --python ${{ matrix.python-version }} --dev - name: Run linting with ruff run: | - uv run ruff check . + uv run --python ${{ matrix.python-version }} ruff check . - name: Run type checking with mypy run: | - uv run mypy . + uv run --python ${{ matrix.python-version }} mypy . - name: Run tests with pytest env: UPSTREAM_BASE_URL: "http://test" UPSTREAM_API_KEY: "test" run: | - uv run pytest --verbose --tb=short + uv run --python ${{ matrix.python-version }} pytest --verbose --tb=short - name: Upload test results if: always() diff --git a/.python-version b/.python-version index 2c073331..6324d401 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11 +3.14 diff --git a/Dockerfile b/Dockerfile index 23140ccf..e4506e40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,12 @@ -FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim +ARG PYTHON_VERSION=3.14 +FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ git \ build-essential \ pkg-config \ + libffi-dev \ libsecp256k1-dev \ autoconf \ automake \ diff --git a/Dockerfile.full b/Dockerfile.full index 56406858..a959801f 100644 --- a/Dockerfile.full +++ b/Dockerfile.full @@ -1,4 +1,6 @@ # Multi-stage Dockerfile for Routstr (includes UI build) +ARG PYTHON_VERSION=3.14 + # Stage 1: Build the UI FROM node:23-alpine AS ui-builder WORKDIR /app/ui @@ -16,13 +18,14 @@ ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm run build # Stage 2: Build the Routstr Node -FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS runner +FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm-slim AS runner RUN apt-get update \ && apt-get install -y --no-install-recommends \ git \ build-essential \ pkg-config \ + libffi-dev \ libsecp256k1-dev \ autoconf \ automake \ diff --git a/pyproject.toml b/pyproject.toml index 2b67f965..9f0eabd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi[standard-no-fastapi-cloud-cli]>=0.141", "aiosqlite>=0.20", - "sqlmodel>=0.0.24", + "sqlmodel>=0.0.42", # Python 3.14 deferred-annotation support "httpx[socks]>=0.28.1", "h11>=0.16", "greenlet>=3.2.1", @@ -17,11 +17,11 @@ dependencies = [ "cashu>=0.20", "marshmallow>=3.13,<4.0", "websockets>=12.0", - "nostr>=0.0.2", + "nostr-sdk>=0.45.1,<0.46", "mdurl==0.1.2", "pillow>=10", "openai>=1.98.0", - "litellm>=1.84.0,<1.85", + "litellm>=1.93.0,<1.94", # 1.93 is the first line supporting Python 3.14 ] [dependency-groups] @@ -92,8 +92,11 @@ routstr = { workspace = true } # setuptools and wheel below their patched versions. routstr uses only cashu's # wallet modules, not its server paths, so the caps are safe to lift here. [tool.uv] +# coincurve 20's build config uses cmake.verbose, removed in +# scikit-build-core 0.10. This keeps its source build working on Python 3.14. +build-constraint-dependencies = ["scikit-build-core<0.10"] override-dependencies = [ - # httpx 0.28 (required by litellm 1.84) removed the `proxies` kwarg cashu + # httpx 0.28 (required by litellm 1.93) removed the `proxies` kwarg cashu # passes on every mint call; routstr/cashu_compat.py restores it for cashu. "httpx[socks]>=0.28.1,<1.0", "importlib-metadata>=8.0.0,<9.0", @@ -109,4 +112,7 @@ override-dependencies = [ constraint-dependencies = [ "starlette>=1.3.1", "httpcore>=1.0.9", # 1.0.8 caps h11<0.15 + # 1.76 is the first grpcio-tools release with CPython 3.14 wheels. + "grpcio>=1.76.0,<2.0.0", + "grpcio-tools>=1.76.0,<2.0.0", ] diff --git a/routstr/cashu_compat.py b/routstr/cashu_compat.py index d97df06b..02a56ca4 100644 --- a/routstr/cashu_compat.py +++ b/routstr/cashu_compat.py @@ -29,8 +29,10 @@ def _single_proxy(proxies: Any) -> str | None: """Collapse an httpx<0.28 ``proxies`` mapping into a single ``proxy`` URL. cashu only ever builds ``{}`` or ``{"all://": url}``, so a mapping with one - distinct URL is all we need to support; anything richer is unrepresentable - as httpx 0.28's scalar ``proxy=`` and is dropped rather than guessed at. + distinct URL is all we need to support. Anything richer is unrepresentable + as httpx 0.28's scalar ``proxy=``; we fail closed and raise rather than + return ``None``, because dropping the entry would silently send mint + traffic direct instead of through the configured Tor/SOCKS proxy. """ if not proxies: return None @@ -41,9 +43,14 @@ def _single_proxy(proxies: Any) -> str | None: value = proxies[_ALL_SCHEMES] return str(value) if value is not None else None distinct = {str(v) for v in proxies.values() if v is not None} + if not distinct: + return None if len(distinct) == 1: return distinct.pop() - return None + raise ValueError( + f"cannot represent proxies={proxies!r} as httpx 0.28 proxy=; " + "refusing to send proxied traffic direct" + ) class _ProxiesCompatAsyncClient(httpx.AsyncClient): @@ -55,6 +62,9 @@ class _ProxiesCompatAsyncClient(httpx.AsyncClient): proxy = _single_proxy(proxies) if proxy is not None and kwargs.get("proxy") is None: kwargs["proxy"] = proxy + elif isinstance(proxies, dict) and not proxies: + # In httpx<0.28, proxies={} disabled environment proxy discovery. + kwargs.setdefault("trust_env", False) super().__init__(*args, **kwargs) diff --git a/routstr/core/main.py b/routstr/core/main.py index 616a1c29..fdb3b913 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -17,6 +17,7 @@ from ..auth import ( periodic_stale_reservation_sweep, ) from ..balance import balance_router, deprecated_wallet_router +from ..cashu_compat import install_cashu_httpx_shim from ..lightning import lightning_router, periodic_invoice_watcher from ..nostr import ( announce_provider, @@ -71,6 +72,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: invoice_watcher_task = None try: + # cashu 0.20.x passes the `proxies` kwarg httpx removed in 0.28. + # routstr.wallet and routstr.payment.lnurl also install this at import; + # repeating it here keeps startup correct for any future module that + # reaches cashu's mint client without going through those two. + install_cashu_httpx_shim() + # Apply litellm-wide settings (drop_params, chat-completions URL, # debug logging) before any upstream provider dispatches a request. configure_litellm() diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 014a5795..971cec19 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -262,7 +262,7 @@ def derive_npub_from_nsec(nsec: str) -> str | None: boot. """ try: - from nostr.key import PublicKey # type: ignore + from nostr_sdk import PublicKey from ..nostr.listing import nsec_to_keypair except ImportError: @@ -274,7 +274,7 @@ def derive_npub_from_nsec(nsec: str) -> str | None: _privkey_hex, pubkey_hex = keypair try: - return PublicKey(bytes.fromhex(pubkey_hex)).bech32() + return PublicKey.parse(pubkey_hex).to_bech32() except (ValueError, AttributeError): return None diff --git a/routstr/nostr/analytics.py b/routstr/nostr/analytics.py index e568b5e0..54b7ecf4 100644 --- a/routstr/nostr/analytics.py +++ b/routstr/nostr/analytics.py @@ -12,13 +12,11 @@ import json import time from typing import Any -from nostr.event import Event -from nostr.key import PrivateKey - from ..core import get_logger from ..core.log_manager import log_manager from ..core.settings import settings from .listing import nsec_to_keypair, publish_to_relay +from .sdk import create_signed_event logger = get_logger(__name__) @@ -44,18 +42,6 @@ WINDOW_DEFINITIONS: tuple[tuple[str, int, int], ...] = ( ) -def _event_to_dict(ev: Event) -> dict[str, Any]: - return { - "id": ev.id, - "pubkey": ev.public_key, - "created_at": ev.created_at, - "kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind, - "tags": ev.tags, - "content": ev.content, - "sig": ev.signature, - } - - def _resolve_provider_id(public_key_hex: str) -> str: explicit_provider_id = (settings.provider_id or "").strip() if explicit_provider_id: @@ -293,21 +279,18 @@ def create_stats_snapshot_event( *, d_tag: str, ) -> dict[str, Any]: - private_key = PrivateKey(bytes.fromhex(private_key_hex)) tags = [ ["d", d_tag], ["provider", provider_id], ["schema", ANALYTICS_SCHEMA], ] - event = Event( - public_key=private_key.public_key.hex(), - content=payload_json, + return create_signed_event( + private_key_hex, kind=ANALYTICS_KIND, + content=payload_json, tags=tags, ) - private_key.sign_event(event) - return _event_to_dict(event) def _fingerprint_payload(payload: dict[str, Any]) -> str: diff --git a/routstr/nostr/listing.py b/routstr/nostr/listing.py index 7c60e9b1..9e6ec572 100644 --- a/routstr/nostr/listing.py +++ b/routstr/nostr/listing.py @@ -8,18 +8,12 @@ import asyncio import json import os import random -import ssl import time from typing import Any, cast -from nostr.event import Event -from nostr.filter import Filter, Filters -from nostr.key import PrivateKey -from nostr.message_type import ClientMessageType -from nostr.relay_manager import RelayManager - from ..core import get_logger from ..core.settings import settings +from .sdk import create_signed_event, fetch_events, parse_keypair, send_event logger = get_logger(__name__) @@ -33,18 +27,6 @@ def get_app_version() -> str | None: return None -def _event_to_dict(ev: Event) -> dict[str, Any]: - return { - "id": ev.id, - "pubkey": ev.public_key, - "created_at": ev.created_at, - "kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind, - "tags": ev.tags, - "content": ev.content, - "sig": ev.signature, - } - - def nsec_to_keypair(nsec: str) -> tuple[str, str] | None: """ Convert a Nostr private key (nsec) to a keypair (privkey_hex, pubkey_hex). @@ -56,16 +38,10 @@ def nsec_to_keypair(nsec: str) -> tuple[str, str] | None: Tuple of (private_key_hex, public_key_hex) or None if invalid """ try: - if nsec.startswith("nsec"): - pk = PrivateKey.from_nsec(nsec) - return (pk.hex(), pk.public_key.hex()) - - if len(nsec) == 64: - pk = PrivateKey(bytes.fromhex(nsec)) - return (pk.hex(), pk.public_key.hex()) - - logger.error(f"Invalid private key format/length: {len(nsec)}") - return None + if not (nsec.startswith("nsec") or len(nsec) == 64): + logger.error(f"Invalid private key format/length: {len(nsec)}") + return None + return parse_keypair(nsec) except Exception as e: logger.error(f"Failed to convert nsec to keypair: {e}") return None @@ -93,8 +69,6 @@ def create_listing_event( Returns: Complete signed nostr event as a dict ready for publishing """ - pk = PrivateKey(bytes.fromhex(private_key_hex)) - tags = [["d", provider_id]] for url in endpoint_urls: tags.append(["u", url]) @@ -107,9 +81,12 @@ def create_listing_event( content = json.dumps(metadata, separators=(",", ":")) if metadata else "" - ev = Event(pk.public_key.hex(), content, kind=38421, tags=tags) - pk.sign_event(ev) - return _event_to_dict(ev) + return create_signed_event( + private_key_hex, + kind=38421, + content=content, + tags=tags, + ) def _get_tag_values(event: dict[str, Any], key: str) -> list[str]: @@ -177,75 +154,25 @@ async def query_listing_events( succeeded without transport-level errors. """ - def _sync_query() -> tuple[list[dict[str, Any]], bool]: - rm = RelayManager() - rm.add_relay(relay_url) - events_out: list[dict[str, Any]] = [] - ok = True - try: - rm.open_connections({"cert_reqs": ssl.CERT_NONE}) - time.sleep(1.0) + try: + events_out = await fetch_events( + relay_url, + kind=38421, + author=pubkey, + limit=10, + timeout=timeout, + ) + except Exception as e: + logger.debug(f"Failed to query relay {relay_url}: {type(e).__name__}") + return [], False - flt = Filter(kinds=[38421], authors=[pubkey], limit=10) - filters = Filters([flt]) - sub_id = f"routstr_listing_{int(time.time())}" - rm.add_subscription(sub_id, filters) - req: list[Any] = [ClientMessageType.REQUEST, sub_id] - req.extend(filters.to_json_array()) - rm.publish_message(json.dumps(req)) - - start = time.time() - last_event_ts = start - while time.time() - start < timeout: - drained = False - while rm.message_pool.has_events(): - drained = True - ev_msg = rm.message_pool.get_event() - ev = ev_msg.event - ev_dict = _event_to_dict(ev) - if provider_id is not None: - tags = ev_dict.get("tags", []) - if not any( - isinstance(t, list) - and len(t) >= 2 - and t[0] == "d" - and t[1] == provider_id - for t in tags - ): - continue - events_out.append(ev_dict) - logger.debug( - f"Found listing event: {ev_dict.get('id', '')[:6]}...{ev_dict.get('id', '')[-6:]}" - ) - if drained: - last_event_ts = time.time() - - while rm.message_pool.has_notices(): - notice = rm.message_pool.get_notice() - try: - content = getattr(notice, "content", notice) - s = str(content) - if len(s) > 200: - s = s[:200] + "..." - logger.debug(f"Relay notice: {s}") - except Exception: - pass - - if time.time() - last_event_ts > 2.5: - break - - time.sleep(0.1) - except Exception as e: - ok = False - logger.debug(f"Failed to query relay {relay_url}: {type(e).__name__}") - finally: - try: - rm.close_connections() - except Exception: - pass - return events_out, ok - - return await asyncio.to_thread(_sync_query) + if provider_id is not None: + events_out = [ + event + for event in events_out + if _get_single_tag_value(event, "d") == provider_id + ] + return events_out, True def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None: @@ -333,27 +260,13 @@ async def publish_to_relay( Publish a listing event to a nostr relay via nostr library. """ - def _sync_publish() -> bool: - rm = RelayManager() - rm.add_relay(relay_url) - try: - rm.open_connections({"cert_reqs": ssl.CERT_NONE}) - time.sleep(1.0) - # Publish the event as-is via publish_message to preserve signature - rm.publish_message(json.dumps(["EVENT", event])) - logger.debug(f"Sent listing event {event.get('id', '')} to {relay_url}") - time.sleep(1.0) - return True - except Exception as e: - logger.debug(f"Failed to publish to {relay_url}: {type(e).__name__}") - return False - finally: - try: - rm.close_connections() - except Exception: - pass - - return await asyncio.to_thread(_sync_publish) + try: + await send_event(relay_url, event, timeout=timeout) + logger.debug(f"Sent listing event {event.get('id', '')} to {relay_url}") + return True + except Exception as e: + logger.debug(f"Failed to publish to {relay_url}: {type(e).__name__}") + return False async def announce_provider() -> None: diff --git a/routstr/nostr/sdk.py b/routstr/nostr/sdk.py new file mode 100644 index 00000000..e9e6a37f --- /dev/null +++ b/routstr/nostr/sdk.py @@ -0,0 +1,94 @@ +"""Small adapter around the maintained ``nostr-sdk`` package.""" + +from __future__ import annotations + +import json +from datetime import timedelta +from typing import Any, cast + +from nostr_sdk import ( + AckPolicy, + Client, + Event, + EventBuilder, + Filter, + Keys, + Kind, + PublicKey, + RelayUrl, + ReqExitPolicy, + ReqTarget, + SendEventTarget, + Tag, +) + + +def parse_keypair(secret_key: str) -> tuple[str, str]: + keys = Keys.parse(secret_key) + return keys.secret_key().to_hex(), keys.public_key().to_hex() + + +def create_signed_event( + secret_key_hex: str, + *, + kind: int, + content: str, + tags: list[list[str]], +) -> dict[str, Any]: + keys = Keys.parse(secret_key_hex) + event = ( + EventBuilder(Kind(kind), content) + .tags([Tag.parse(tag) for tag in tags]) + .finalize(keys) + ) + return cast(dict[str, Any], json.loads(event.as_json())) + + +async def fetch_events( + relay_url: str, + *, + kind: int, + author: str, + limit: int, + timeout: int, +) -> list[dict[str, Any]]: + relay = RelayUrl.parse(relay_url) + client = Client() + await client.add_relay(relay) + try: + await client.connect() + event_filter = ( + Filter().kinds([Kind(kind)]).authors([PublicKey.parse(author)]).limit(limit) + ) + events = await client.fetch_events( + ReqTarget.single(relay, [event_filter]), + timeout=timedelta(seconds=timeout), + policy=ReqExitPolicy.WAIT_DURATION_AFTER_EOSE(timedelta(seconds=2.5)), + max_events=limit, + ) + return [cast(dict[str, Any], json.loads(event.as_json())) for event in events] + finally: + await client.shutdown() + + +async def send_event(relay_url: str, event: dict[str, Any], *, timeout: int) -> None: + relay = RelayUrl.parse(relay_url) + client = Client() + await client.add_relay(relay) + try: + await client.connect() + output = await client.send_event( + Event.from_json(json.dumps(event)), + target=SendEventTarget.to([relay]), + ack_policy=AckPolicy.all(), + ok_timeout=timedelta(seconds=timeout), + ) + if output.failed or relay not in output.success: + reasons = ", ".join( + f"{url}: {reason}" for url, reason in output.failed.items() + ) + raise RuntimeError( + f"Relay did not accept event: {reasons or 'no OK from relay'}" + ) + finally: + await client.shutdown() diff --git a/tests/unit/test_cashu_httpx_compat.py b/tests/unit/test_cashu_httpx_compat.py index 191b7b56..3e0162d7 100644 --- a/tests/unit/test_cashu_httpx_compat.py +++ b/tests/unit/test_cashu_httpx_compat.py @@ -1,6 +1,8 @@ """cashu 0.20.x builds its mint client with the `proxies` kwarg httpx removed in 0.28. These tests pin the shim that keeps every wallet call working.""" +import asyncio + import httpx import pytest from cashu.wallet import v1_api @@ -27,7 +29,6 @@ def test_httpx_no_longer_accepts_proxies() -> None: ({"all://": "socks5://localhost:9050"}, "socks5://localhost:9050"), ("socks5://localhost:9050", "socks5://localhost:9050"), ({"http://": "http://p:1", "https://": "http://p:1"}, "http://p:1"), - ({"http://": "http://a:1", "https://": "http://b:2"}, None), ], ) def test_single_proxy_collapses_cashu_mappings( @@ -36,6 +37,12 @@ def test_single_proxy_collapses_cashu_mappings( assert _single_proxy(proxies) == expected +def test_single_proxy_fails_closed_on_unrepresentable_mapping() -> None: + """Never silently drop a proxy: that would send mint traffic direct.""" + with pytest.raises(ValueError, match="cannot represent proxies"): + _single_proxy({"http://": "http://a:1", "https://": "http://b:2"}) + + @pytest.mark.parametrize("proxies", [{}, {"all://": "socks5://localhost:9050"}]) async def test_compat_client_accepts_proxies(proxies: dict) -> None: async with _ProxiesCompatAsyncClient( @@ -44,6 +51,47 @@ async def test_compat_client_accepts_proxies(proxies: dict) -> None: assert isinstance(client, httpx.AsyncClient) +async def test_empty_proxy_map_disables_environment_proxies( + monkeypatch: pytest.MonkeyPatch, +) -> None: + proxy_url = "http://127.0.0.1:1" + for name in ( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ): + monkeypatch.setenv(name, proxy_url) + monkeypatch.setenv("NO_PROXY", "") + monkeypatch.setenv("no_proxy", "") + + async def respond(_: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await asyncio.sleep(0) + writer.write(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + await writer.drain() + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(respond, "127.0.0.1", 0) + try: + port = server.sockets[0].getsockname()[1] + url = f"http://127.0.0.1:{port}/" + + async with _ProxiesCompatAsyncClient(proxies={}, timeout=1) as client: + assert (await client.get(url)).status_code == 204 + + async with _ProxiesCompatAsyncClient( + proxies={}, trust_env=True, timeout=1 + ) as client: + with pytest.raises(httpx.ConnectError): + await client.get(url) + finally: + server.close() + await server.wait_closed() + + async def test_cashu_decorator_builds_a_client_after_shim() -> None: """The real cashu decorator — the code path every mint call goes through.""" install_cashu_httpx_shim() @@ -66,9 +114,9 @@ async def test_cashu_decorator_builds_a_client_after_shim() -> None: def test_install_is_idempotent() -> None: - assert install_cashu_httpx_shim() is True + install_cashu_httpx_shim() patched = v1_api.httpx - assert install_cashu_httpx_shim() is True + install_cashu_httpx_shim() assert v1_api.httpx is patched diff --git a/tests/unit/test_nostr_sdk.py b/tests/unit/test_nostr_sdk.py new file mode 100644 index 00000000..ae4a42c0 --- /dev/null +++ b/tests/unit/test_nostr_sdk.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest +from nostr_sdk import Event, SendEventOutput + +from routstr.nostr import sdk +from routstr.nostr.listing import create_listing_event, nsec_to_keypair + +PRIVATE_KEY_HEX = "11" * 32 +PRIVATE_KEY_NSEC = "nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz" +PUBLIC_KEY_HEX = "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" + + +def test_nsec_and_hex_parse_to_same_keypair() -> None: + expected = (PRIVATE_KEY_HEX, PUBLIC_KEY_HEX) + + assert nsec_to_keypair(PRIVATE_KEY_HEX) == expected + assert nsec_to_keypair(PRIVATE_KEY_NSEC) == expected + + +def test_listing_event_is_valid_nip01_event() -> None: + event = create_listing_event( + PRIVATE_KEY_HEX, + "provider123", + ["https://provider.example.com"], + mint_urls=["https://mint.example.com"], + version="1.2.3", + metadata={"name": "Provider"}, + ) + + assert event["pubkey"] == PUBLIC_KEY_HEX + assert event["kind"] == 38421 + assert Event.from_json(json.dumps(event)).verify() + + +class FakeClient: + def __init__( + self, + event: dict[str, Any] | None = None, + send_failure: str | None = None, + ) -> None: + self.event = event + self.send_failure = send_failure + self.relay: Any = None + self.connected = False + self.shutdown_called = False + self.sent_event: Event | None = None + + async def add_relay(self, relay: Any) -> bool: + self.relay = relay + return True + + async def connect(self) -> None: + self.connected = True + + async def fetch_events(self, *args: Any, **kwargs: Any) -> list[Event]: + assert self.event is not None + return [Event.from_json(json.dumps(self.event))] + + async def send_event(self, event: Event, **kwargs: Any) -> SendEventOutput: + self.sent_event = event + if self.send_failure is not None: + return SendEventOutput( + id=event.id(), success=[], failed={self.relay: self.send_failure} + ) + return SendEventOutput(id=event.id(), success=[self.relay], failed={}) + + async def shutdown(self) -> None: + self.shutdown_called = True + + +@pytest.mark.asyncio +async def test_fetch_events_uses_sdk_client_and_closes_it(monkeypatch: Any) -> None: + event = create_listing_event( + PRIVATE_KEY_HEX, + "provider123", + ["https://provider.example.com"], + ) + client = FakeClient(event) + monkeypatch.setattr(sdk, "Client", lambda: client) + + fetched = await sdk.fetch_events( + "wss://relay.example.com", + kind=38421, + author=PUBLIC_KEY_HEX, + limit=10, + timeout=30, + ) + + assert fetched == [event] + assert client.connected + assert client.shutdown_called + + +@pytest.mark.asyncio +async def test_send_event_uses_sdk_client_and_closes_it(monkeypatch: Any) -> None: + event = create_listing_event( + PRIVATE_KEY_HEX, + "provider123", + ["https://provider.example.com"], + ) + client = FakeClient() + monkeypatch.setattr(sdk, "Client", lambda: client) + + await sdk.send_event("wss://relay.example.com", event, timeout=30) + + assert client.connected + assert client.sent_event is not None + assert client.sent_event.verify() + assert client.shutdown_called + + +@pytest.mark.asyncio +async def test_send_event_raises_when_relay_rejects(monkeypatch: Any) -> None: + event = create_listing_event( + PRIVATE_KEY_HEX, + "provider123", + ["https://provider.example.com"], + ) + client = FakeClient(send_failure="blocked: rate limited") + monkeypatch.setattr(sdk, "Client", lambda: client) + + with pytest.raises(RuntimeError, match="rate limited"): + await sdk.send_event("wss://relay.example.com", event, timeout=30) + + assert client.shutdown_called diff --git a/uv.lock b/uv.lock index 41143715..6ca3e9ed 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,8 @@ resolution-markers = [ [manifest] constraints = [ + { name = "grpcio", specifier = ">=1.76.0,<2.0.0" }, + { name = "grpcio-tools", specifier = ">=1.76.0,<2.0.0" }, { name = "httpcore", specifier = ">=1.0.9" }, { name = "starlette", specifier = ">=1.3.1" }, ] @@ -21,6 +23,7 @@ overrides = [ { name = "setuptools", specifier = ">=83.0.0" }, { name = "wheel", specifier = ">=0.46.2" }, ] +build-constraints = [{ name = "scikit-build-core", specifier = "<0.10" }] [[package]] name = "aiohappyeyeballs" @@ -1179,83 +1182,106 @@ wheels = [ [[package]] name = "grpcio" -version = "1.74.0" +version = "1.83.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/b4/35feb8f7cab7239c5b94bd2db71abb3d6adb5f335ad8f131abb6060840b6/grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1", size = 12756048, upload-time = "2025-07-24T18:54:23.039Z" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/b1/46539f5050d7c316a13396d185451f95084a74ddc68b12d818595bef0377/grpcio-1.83.1.tar.gz", hash = "sha256:9cee6fcbf2eb57c4b49451787bfa87be8efc1ca02a0b327dd4b54d44502e362b", size = 13445033, upload-time = "2026-08-28T07:09:11.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/77/b2f06db9f240a5abeddd23a0e49eae2b6ac54d85f0e5267784ce02269c3b/grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31", size = 5487368, upload-time = "2025-07-24T18:53:03.548Z" }, - { url = "https://files.pythonhosted.org/packages/48/99/0ac8678a819c28d9a370a663007581744a9f2a844e32f0fa95e1ddda5b9e/grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4", size = 10999804, upload-time = "2025-07-24T18:53:05.095Z" }, - { url = "https://files.pythonhosted.org/packages/45/c6/a2d586300d9e14ad72e8dc211c7aecb45fe9846a51e558c5bca0c9102c7f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce", size = 5987667, upload-time = "2025-07-24T18:53:07.157Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/5f338bf56a7f22584e68d669632e521f0de460bb3749d54533fc3d0fca4f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f609a39f62a6f6f05c7512746798282546358a37ea93c1fcbadf8b2fed162e3", size = 6655612, upload-time = "2025-07-24T18:53:09.244Z" }, - { url = "https://files.pythonhosted.org/packages/82/ea/a4820c4c44c8b35b1903a6c72a5bdccec92d0840cf5c858c498c66786ba5/grpcio-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98e0b7434a7fa4e3e63f250456eaef52499fba5ae661c58cc5b5477d11e7182", size = 6219544, upload-time = "2025-07-24T18:53:11.221Z" }, - { url = "https://files.pythonhosted.org/packages/a4/17/0537630a921365928f5abb6d14c79ba4dcb3e662e0dbeede8af4138d9dcf/grpcio-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:662456c4513e298db6d7bd9c3b8df6f75f8752f0ba01fb653e252ed4a59b5a5d", size = 6334863, upload-time = "2025-07-24T18:53:12.925Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a6/85ca6cb9af3f13e1320d0a806658dca432ff88149d5972df1f7b51e87127/grpcio-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d14e3c4d65e19d8430a4e28ceb71ace4728776fd6c3ce34016947474479683f", size = 7019320, upload-time = "2025-07-24T18:53:15.002Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a7/fe2beab970a1e25d2eff108b3cf4f7d9a53c185106377a3d1989216eba45/grpcio-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bf949792cee20d2078323a9b02bacbbae002b9e3b9e2433f2741c15bdeba1c4", size = 6514228, upload-time = "2025-07-24T18:53:16.999Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c2/2f9c945c8a248cebc3ccda1b7a1bf1775b9d7d59e444dbb18c0014e23da6/grpcio-1.74.0-cp311-cp311-win32.whl", hash = "sha256:55b453812fa7c7ce2f5c88be3018fb4a490519b6ce80788d5913f3f9d7da8c7b", size = 3817216, upload-time = "2025-07-24T18:53:20.564Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d1/a9cf9c94b55becda2199299a12b9feef0c79946b0d9d34c989de6d12d05d/grpcio-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:86ad489db097141a907c559988c29718719aa3e13370d40e20506f11b4de0d11", size = 4495380, upload-time = "2025-07-24T18:53:22.058Z" }, - { url = "https://files.pythonhosted.org/packages/4c/5d/e504d5d5c4469823504f65687d6c8fb97b7f7bf0b34873b7598f1df24630/grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8", size = 5445551, upload-time = "2025-07-24T18:53:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/43/01/730e37056f96f2f6ce9f17999af1556df62ee8dab7fa48bceeaab5fd3008/grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6", size = 10979810, upload-time = "2025-07-24T18:53:25.349Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/09fd100473ea5c47083889ca47ffd356576173ec134312f6aa0e13111dee/grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5", size = 5941946, upload-time = "2025-07-24T18:53:27.387Z" }, - { url = "https://files.pythonhosted.org/packages/8a/99/12d2cca0a63c874c6d3d195629dcd85cdf5d6f98a30d8db44271f8a97b93/grpcio-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3601274bc0523f6dc07666c0e01682c94472402ac2fd1226fd96e079863bfa49", size = 6621763, upload-time = "2025-07-24T18:53:29.193Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2c/930b0e7a2f1029bbc193443c7bc4dc2a46fedb0203c8793dcd97081f1520/grpcio-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:176d60a5168d7948539def20b2a3adcce67d72454d9ae05969a2e73f3a0feee7", size = 6180664, upload-time = "2025-07-24T18:53:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/db/d5/ff8a2442180ad0867717e670f5ec42bfd8d38b92158ad6bcd864e6d4b1ed/grpcio-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e759f9e8bc908aaae0412642afe5416c9f983a80499448fcc7fab8692ae044c3", size = 6301083, upload-time = "2025-07-24T18:53:32.454Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ba/b361d390451a37ca118e4ec7dccec690422e05bc85fba2ec72b06cefec9f/grpcio-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e7c4389771855a92934b2846bd807fc25a3dfa820fd912fe6bd8136026b2707", size = 6994132, upload-time = "2025-07-24T18:53:34.506Z" }, - { url = "https://files.pythonhosted.org/packages/3b/0c/3a5fa47d2437a44ced74141795ac0251bbddeae74bf81df3447edd767d27/grpcio-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cce634b10aeab37010449124814b05a62fb5f18928ca878f1bf4750d1f0c815b", size = 6489616, upload-time = "2025-07-24T18:53:36.217Z" }, - { url = "https://files.pythonhosted.org/packages/ae/95/ab64703b436d99dc5217228babc76047d60e9ad14df129e307b5fec81fd0/grpcio-1.74.0-cp312-cp312-win32.whl", hash = "sha256:885912559974df35d92219e2dc98f51a16a48395f37b92865ad45186f294096c", size = 3807083, upload-time = "2025-07-24T18:53:37.911Z" }, - { url = "https://files.pythonhosted.org/packages/84/59/900aa2445891fc47a33f7d2f76e00ca5d6ae6584b20d19af9c06fa09bf9a/grpcio-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:42f8fee287427b94be63d916c90399ed310ed10aadbf9e2e5538b3e497d269bc", size = 4490123, upload-time = "2025-07-24T18:53:39.528Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d8/1004a5f468715221450e66b051c839c2ce9a985aa3ee427422061fcbb6aa/grpcio-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2bc2d7d8d184e2362b53905cb1708c84cb16354771c04b490485fa07ce3a1d89", size = 5449488, upload-time = "2025-07-24T18:53:41.174Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/33731a03f63740d7743dced423846c831d8e6da808fcd02821a4416df7fa/grpcio-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c14e803037e572c177ba54a3e090d6eb12efd795d49327c5ee2b3bddb836bf01", size = 10974059, upload-time = "2025-07-24T18:53:43.066Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c6/3d2c14d87771a421205bdca991467cfe473ee4c6a1231c1ede5248c62ab8/grpcio-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f6ec94f0e50eb8fa1744a731088b966427575e40c2944a980049798b127a687e", size = 5945647, upload-time = "2025-07-24T18:53:45.269Z" }, - { url = "https://files.pythonhosted.org/packages/c5/83/5a354c8aaff58594eef7fffebae41a0f8995a6258bbc6809b800c33d4c13/grpcio-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:566b9395b90cc3d0d0c6404bc8572c7c18786ede549cdb540ae27b58afe0fb91", size = 6626101, upload-time = "2025-07-24T18:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ca/4fdc7bf59bf6994aa45cbd4ef1055cd65e2884de6113dbd49f75498ddb08/grpcio-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1ea6176d7dfd5b941ea01c2ec34de9531ba494d541fe2057c904e601879f249", size = 6182562, upload-time = "2025-07-24T18:53:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/fd/48/2869e5b2c1922583686f7ae674937986807c2f676d08be70d0a541316270/grpcio-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:64229c1e9cea079420527fa8ac45d80fc1e8d3f94deaa35643c381fa8d98f362", size = 6303425, upload-time = "2025-07-24T18:53:50.847Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0e/bac93147b9a164f759497bc6913e74af1cb632c733c7af62c0336782bd38/grpcio-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0f87bddd6e27fc776aacf7ebfec367b6d49cad0455123951e4488ea99d9b9b8f", size = 6996533, upload-time = "2025-07-24T18:53:52.747Z" }, - { url = "https://files.pythonhosted.org/packages/84/35/9f6b2503c1fd86d068b46818bbd7329db26a87cdd8c01e0d1a9abea1104c/grpcio-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3b03d8f2a07f0fea8c8f74deb59f8352b770e3900d143b3d1475effcb08eec20", size = 6491489, upload-time = "2025-07-24T18:53:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/75/33/a04e99be2a82c4cbc4039eb3a76f6c3632932b9d5d295221389d10ac9ca7/grpcio-1.74.0-cp313-cp313-win32.whl", hash = "sha256:b6a73b2ba83e663b2480a90b82fdae6a7aa6427f62bf43b29912c0cfd1aa2bfa", size = 3805811, upload-time = "2025-07-24T18:53:56.798Z" }, - { url = "https://files.pythonhosted.org/packages/34/80/de3eb55eb581815342d097214bed4c59e806b05f1b3110df03b2280d6dfd/grpcio-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd3c71aeee838299c5887230b8a1822795325ddfea635edd82954c1eaa831e24", size = 4489214, upload-time = "2025-07-24T18:53:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e8/2a69fe506c992fc4b02ede5a6255a4b19b7922527d7f0ab2229695463fc1/grpcio-1.83.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:907a5e5afb31f7a46376afc1a1edddd7afa00a74bbbc5b78979bbc34479581f6", size = 6340700, upload-time = "2026-08-28T07:07:49.232Z" }, + { url = "https://files.pythonhosted.org/packages/7f/56/6628e935ca7c5b9270810dd1cb61e5d2ea53eb7d26c62d2987bfef46e022/grpcio-1.83.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:547645f02499c972f3edec9be4db9997f1d03df307c1c199772342ed6d8b3c6d", size = 12183471, upload-time = "2026-08-28T07:07:51.18Z" }, + { url = "https://files.pythonhosted.org/packages/14/c9/f748ae4bd2120c91cf07e7a74cfd3ceac0e36b06d082cd3db802163a372a/grpcio-1.83.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:34f1841fc6d1d76f8a2d74177eafa2d1ec7d7e039633488c9fcc1b375a1fc165", size = 6924669, upload-time = "2026-08-28T07:07:53.223Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d1/797b72d87b0ad8cce15fb4ad247472054655bce94bf64027b2285d7c3666/grpcio-1.83.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:05ba265193fbd9f63355311ec7567bba32a72aeb8e9fd7b3443e4fcad87b0750", size = 7654706, upload-time = "2026-08-28T07:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bf/7f0850aa13d98bb4dd8e7633b6beb639e6fd80a8c0813ad36dec3240f70e/grpcio-1.83.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5cce1d9fe2887239f054dc9c314597e04f33d2e6bd3150a91c4946d7e5be5d98", size = 7083816, upload-time = "2026-08-28T07:07:56.867Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8a/5047fc4041cb6499d836001b4ac2ced0b00aecd2fc6d88e81993066ebbf6/grpcio-1.83.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f732feb060ef57c1a040c24cee072ba9fab99bd0a7d2c916ef3f1c4d84b98974", size = 7607412, upload-time = "2026-08-28T07:07:58.654Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/b397786f79323c2c127733d6310a5dbe3898333bc5d1153785dbdbcfce9b/grpcio-1.83.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:145b0050d24eb38accd9dc7ae09a3c09b8e7330159f3cfb46b1dba8711d50c42", size = 8643027, upload-time = "2026-08-28T07:08:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3c/ac8ca4521760c8c5876af2f284109c3f826d3ba9836b2226c76ba06258ee/grpcio-1.83.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e844cdb25c3c93c7572e0a37137c12305efea493be4eb65801b3ee93f180c186", size = 8010270, upload-time = "2026-08-28T07:08:02.949Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/ec8680e53ead511e8e3e6efb98771b32b96ea7a0c6a3ad23ffe85a8f83d3/grpcio-1.83.1-cp311-cp311-win32.whl", hash = "sha256:0d07661944477517b12a239e18720c8d9038f80a62f2c56260fae80327f43d2a", size = 4405295, upload-time = "2026-08-28T07:08:04.961Z" }, + { url = "https://files.pythonhosted.org/packages/6f/77/c169e2cee593c49399273912bacab20e50422489c0f884c1e3ae95a1af08/grpcio-1.83.1-cp311-cp311-win_amd64.whl", hash = "sha256:e572da3e247b28a98f46636d33c756e81ffb0f5def96c231ba45332333060595", size = 5166265, upload-time = "2026-08-28T07:08:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/85/9e/a3ba13e08bbee5bf6e57597dfe4823961fd7e94c0b8afe3a4bb7dca639f3/grpcio-1.83.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:5acd14c6ddf047de62cbf8745b11103ea91abbf57d1b8edd5395ccd9fcd13abb", size = 6303170, upload-time = "2026-08-28T07:08:08.188Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ae/65ce56a2527faa17d02cba4c2231c74047ad898be339486ba87f093bfb66/grpcio-1.83.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:16138031a47b771860a16a975b53087f4fd5bbdbb2c03a188c5d90ad65d2bdae", size = 12165806, upload-time = "2026-08-28T07:08:10.309Z" }, + { url = "https://files.pythonhosted.org/packages/4e/91/40432480088a2243d360864de072ed5b78c4ebbaabd29c28918f1e1b1454/grpcio-1.83.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5ccc26715fd4defca5e129e280dd883b1737b65045ec50ffe22ce42104089519", size = 6872490, upload-time = "2026-08-28T07:08:12.355Z" }, + { url = "https://files.pythonhosted.org/packages/c8/62/3da2300c8c79fd20a78a8a4bb6251e5068d9af33bc8fd389b98fec35e8a3/grpcio-1.83.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b74f2a1d9ab1dfa3e263ef33d581613679b78d0884babf11671af26e45570ead", size = 7618367, upload-time = "2026-08-28T07:08:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/bc/19/9fc702e31a631262d7a752fa699f6022821e707fefc8bff49b1550a57729/grpcio-1.83.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72578aa07a4008f17521ef52debcc3acfd1e2c5426243bc3ffb56a38bfe610b7", size = 7040936, upload-time = "2026-08-28T07:08:15.963Z" }, + { url = "https://files.pythonhosted.org/packages/ec/56/95933cc44cba2429765fa065c951dd529e5771b119d9d2481b4646f1d6a5/grpcio-1.83.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12e1fc59c6dc26d10d9144453ddc6cbfe4cd4c31e874ed2d0132f88e685eb8b", size = 7573096, upload-time = "2026-08-28T07:08:17.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/80/af63359da06b016de48cb111f144703a10043850dafa43ae0a038907b9e8/grpcio-1.83.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4910b62f7d12197160bfb7de06d876d64dd12d43483e8292f98f49ca09b628d9", size = 8609442, upload-time = "2026-08-28T07:08:19.777Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fa/f0586c56bdfb8a7a2adda01e0ac2413447cde3141ab09411a5d5afdcffd3/grpcio-1.83.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e703effe3ae779925c82ac24fdb82cf4105e1096810151ed9501c5f34546b9c", size = 7984321, upload-time = "2026-08-28T07:08:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/14ec05669f9eb295801e26c2ea8c561a1b786b0e3557c2c22131165ab010/grpcio-1.83.1-cp312-cp312-win32.whl", hash = "sha256:a2aea8bd6e0a34f12cbaddb7bb70bec836818789fa5c7ab7572c6b745396a2d4", size = 4395604, upload-time = "2026-08-28T07:08:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/e9/37/8c2f7cc16089e36a3fbacaacc7a3d043912aa0d2dfae5556f6450414ea6e/grpcio-1.83.1-cp312-cp312-win_amd64.whl", hash = "sha256:583bf2e8255040a4a312f9572dfe62a05271437b149550e1a536d5c47d2d1e8a", size = 5161512, upload-time = "2026-08-28T07:08:25.81Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fd/d1fc58933bf88c9209f89dc570c810f1aa57cb04b3459cf2b26f61e32112/grpcio-1.83.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:8d228e253b77865efcbdd7b5894ca882c9e0ea98c02b7d20582e61ded8dfd4b5", size = 6305628, upload-time = "2026-08-28T07:08:27.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/49/0b40bae059c619505c9b751cee6caa208e4904e290aaefa1728c4c2c67a5/grpcio-1.83.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0468b627f2987c9a77f7580030207cbd85457ffe52998beff4f0b5c38c58a72c", size = 12156839, upload-time = "2026-08-28T07:08:30.191Z" }, + { url = "https://files.pythonhosted.org/packages/61/4b/e8c0d635da0ee5ddd9950c8d540f5dcdd0ef1854a382cc55496a487a8d31/grpcio-1.83.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6a282e81530cead60bbd752cc04950a57f224379e9821495d6a35bd5ce9b1f4", size = 6877036, upload-time = "2026-08-28T07:08:32.285Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d4/760a33f339a7dd3d5f4b3e0e9bec5472d95592a80f887b2e9dab4e41cfbc/grpcio-1.83.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:947d945f52e8ecf3cafd2bb7113502a16ccfda3e12c854443094de32d83ad432", size = 7624404, upload-time = "2026-08-28T07:08:34.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/bd798654b06fb42a92b57d1dc1b530084fa89ed442806fcd0a833a36f9b3/grpcio-1.83.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55656318d5dd387077396dffb929171ca3966e24bfead9a6c5dba9f889062cb4", size = 7042942, upload-time = "2026-08-28T07:08:36.208Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/c00f86614566dd0961825cf0f43d4f96a74371d9d95f952bcbc4b86d9a27/grpcio-1.83.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9daf5acf4fc9d5f5627229969c2580a91e511779d76e4ccdeb9f4770f05d8bc2", size = 7576937, upload-time = "2026-08-28T07:08:38.041Z" }, + { url = "https://files.pythonhosted.org/packages/b1/38/85eff43a5c89dc666a252b5c9f8e9ab03f89e11c95b6263d2933f08fdbe7/grpcio-1.83.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7b94174cbca93316888f805efbeb08f1c020f7b7493d2d50cc4f6b64ebb7e8bd", size = 8608391, upload-time = "2026-08-28T07:08:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/35/4e/82835483e2f812494be865e7965c0d626cb9e71ab0d83a420d75aea4ad67/grpcio-1.83.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5a7210911ffe0f67b1cdc5308f9854b6d1f1b345e3e49ab7cac1ba50fa346", size = 7980060, upload-time = "2026-08-28T07:08:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/68a98bef733fef704fbcfb3957c8dba67e3e38ca7a7fea851195bc97c648/grpcio-1.83.1-cp313-cp313-win32.whl", hash = "sha256:179368d9361854616ce6f397d4716e07480129652752fcbcfc5a7260455ad6f2", size = 4395226, upload-time = "2026-08-28T07:08:44.463Z" }, + { url = "https://files.pythonhosted.org/packages/85/a0/df4de3b51d37ac8fb0320bb9668381ce2bd3b7aa990880bfc56a8a26f665/grpcio-1.83.1-cp313-cp313-win_amd64.whl", hash = "sha256:2e57af456385491a76e13c4aada8c8f43a8e47051e06ea97a9dbe2a49654e6db", size = 5160273, upload-time = "2026-08-28T07:08:46.216Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/484d981d8b90c4e6abf3030bd2ed747e84d1eb192b3ec9cbb41e0b73e4bf/grpcio-1.83.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b3c87ca908296bf125f841d3e1a2225a2b39aaa8ed7a57e7ccde465ee519bab", size = 6306089, upload-time = "2026-08-28T07:08:48.379Z" }, + { url = "https://files.pythonhosted.org/packages/84/01/0afec1c92e4f292f74a44ecf75eabbf40903125b8c4df103c9868d6338da/grpcio-1.83.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c0f3f20c90e72a171917ae65706500b096a1c3eb5f162c3ce702a2e25635f132", size = 12170381, upload-time = "2026-08-28T07:08:50.653Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/e9a2383804433a0a61d6d93777ad321c7f36ac1cfdaa4c6d1a7c9ac846b7/grpcio-1.83.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81bbf35a46bf8cad2dfbb2eccc19c711befb58b288acb534bbcd0d74283202a6", size = 6883286, upload-time = "2026-08-28T07:08:53.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/f8ca8f76994e14c70b9a0052e82f10de497a23db450c36379c9716ebfc4d/grpcio-1.83.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:215cec07d11176507387bda4bf2751816e880f9bff8dc1ca524bfbb8ed8f2fad", size = 7624293, upload-time = "2026-08-28T07:08:55.709Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7a/4b672814b0cd0fe63bdd735379d88b165759f3144ab023ad8ec5fc4d53ac/grpcio-1.83.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:abce7d43ec29cd39230fa8339de1a07643b55adc412a454850fbd875349950ff", size = 7044346, upload-time = "2026-08-28T07:08:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/d89fe60e4239ad51be333dd9cc703741d449a35064e51f8a0b5bfa755432/grpcio-1.83.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e256f95a40e3b0183a98556fb7164d24b97eeb353123ccabfcba94712b35ee2a", size = 7584187, upload-time = "2026-08-28T07:08:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b2/b290d7402633d9166e4dd47e6f5f74a24ce10a8340b84455896ebc349f85/grpcio-1.83.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2110059146fb0ea216e1ffddb29377b5cc2fd412a5b0a92e102616bd5edf18c2", size = 8608730, upload-time = "2026-08-28T07:09:02.592Z" }, + { url = "https://files.pythonhosted.org/packages/f5/44/fa89e44d1b5cf5b9fa71b2fd7abf506f182fd43917231a92fbf1ea326b02/grpcio-1.83.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20d944d967843f8183f9f23d5916388362e5f8eeeae855bbe4354d906dc9f31b", size = 7983283, upload-time = "2026-08-28T07:09:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/9db73ed1f35ffa76124ac574bf296d06a359798dfd6b50d382f2b8a060a1/grpcio-1.83.1-cp314-cp314-win32.whl", hash = "sha256:623c87c6d4a1cb30d82c4e896f95477050f2e01b4a1f8cf91ff2b1abdf89c457", size = 4474327, upload-time = "2026-08-28T07:09:07.179Z" }, + { url = "https://files.pythonhosted.org/packages/65/22/fc9a622d885a7a37ff972a12faaef443d74e47407181da70d0ab62ab41f0/grpcio-1.83.1-cp314-cp314-win_amd64.whl", hash = "sha256:47e6934ad38779271e2e7cc5f78a63a407cf3d98114c65c1fdbcd3f5a716f29b", size = 5302032, upload-time = "2026-08-28T07:09:09.285Z" }, ] [[package]] name = "grpcio-tools" -version = "1.74.0" +version = "1.81.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/c8/bca79cb8c14bb63027831039919c801db9f593c7504c09433934f5dff6a4/grpcio_tools-1.74.0.tar.gz", hash = "sha256:88ab9eb18b6ac1b4872add6b394073bd8d44eee7c32e4dc60a022e25ffaffb95", size = 5390007, upload-time = "2025-07-24T18:57:23.852Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/b3/1c5951352d6777fd7f99a0ccee04617fdfd8a5dbf2918a1f58c8b2b280b8/grpcio_tools-1.81.1.tar.gz", hash = "sha256:a22a3870180927fdd84e2b27d079ef5b7f5f8c6110181b6736afc17a463481f1", size = 6236155, upload-time = "2026-06-11T12:51:21.235Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/50/7bafe168b4b3494e7b96d4838b0d35eab62e5c74bf9c91e8f14233c94f60/grpcio_tools-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:9d9e28fbbab9b9e923c3d286949e8ff81ebbb402458698f0a2b1183b539779db", size = 2545457, upload-time = "2025-07-24T18:56:12.589Z" }, - { url = "https://files.pythonhosted.org/packages/8b/1c/8a0eb4e101f2fe8edc12851ddfccf4f2498d5f23d444ea73d09c94202b46/grpcio_tools-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:41040eb1b5d1e582687f6f19cf2efc4c191b6eab56b16f6fba50ac085c5ca4dd", size = 5842973, upload-time = "2025-07-24T18:56:14.063Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f2/eb1bac2dd6397f5ca271e6cb2566b61d4a4bf8df07db0988bc55200f254d/grpcio_tools-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:1fdc013118e4e9054b6e1a64d16a0d4a17a4071042e674ada8673406ddb26e59", size = 2515918, upload-time = "2025-07-24T18:56:15.572Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fe/d270fd30ccd04d5faa9c3f2796ce56a0597eddf327a0fc746ccbb273cdd9/grpcio_tools-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f037414c527a2c4a3af15451d9e58d7856d0a62b3f6dd3f5b969ecba82f5e843", size = 2904944, upload-time = "2025-07-24T18:56:17.091Z" }, - { url = "https://files.pythonhosted.org/packages/91/9f/3adb6e1ae826d9097745f4ad38a84c8c2edb4d768871222c95aa541f8e54/grpcio_tools-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536f53a6a8d1ba1c469d085066cfa0dd3bb51f07013b71857bc3ad1eabe3ab49", size = 2656300, upload-time = "2025-07-24T18:56:18.51Z" }, - { url = "https://files.pythonhosted.org/packages/3f/15/e532439218674c9e451e7f965a0a6bcd53344c4178c62dc1acd66ed93797/grpcio_tools-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1e23ff54dea7f6e9543dcebd2c0f4b7c9af39812966c05e1c5289477cb2bf2f7", size = 3051857, upload-time = "2025-07-24T18:56:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/ca/06/a63aeb1a16ab1508f2ed349faafb4e2e1fb2b048168a033e7392adab14c7/grpcio_tools-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:76072dee9fa99b33eb0c334a16e70d694df762df705c7a2481f702af33d81a28", size = 3501682, upload-time = "2025-07-24T18:56:21.65Z" }, - { url = "https://files.pythonhosted.org/packages/47/1f/81da8c39874d9152fba5fa2bf3b6708c29ea3621fde30667509b9124ef06/grpcio_tools-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bdf91eb722f2990085b1342c277e212ec392e37bd493a2a21d9eb9238f28c3e", size = 3125364, upload-time = "2025-07-24T18:56:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/a3/64/a23256ecd34ceebe8aac8adedd4f65ed240572662899acb779cfcf5e0277/grpcio_tools-1.74.0-cp311-cp311-win32.whl", hash = "sha256:a036cd2a4223901e7a9f6a9b394326a9352a4ad70bdd3f1d893f1b231fcfdf7e", size = 993385, upload-time = "2025-07-24T18:56:25.054Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b8/a0d7359d93f0a2bbaf3b0d43eb8fa3e9f315e03ef4a4ebe05b4315a64644/grpcio_tools-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:d1fdf245178158a92a2dc78e3545b6d13b6c917d9b80931fc85cfb3e9534a07d", size = 1157908, upload-time = "2025-07-24T18:56:27.042Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9c/08a4018e19c937af14bfa052ad3d7826a1687da984992d31d15139c7c8d3/grpcio_tools-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:61d84f6050d7170712600f7ee1dac8849f5dc0bfe0044dd71132ee1e7aa2b373", size = 2546097, upload-time = "2025-07-24T18:56:28.565Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7b/b2985b1b8aa295d745b2e105c99401ad674fcdc2f5a9c8eb3ec0f57ad397/grpcio_tools-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f0129a62711dbc1f1efd51d069d2ce0631d69e033bf3a046606c623acf935e08", size = 5839819, upload-time = "2025-07-24T18:56:30.358Z" }, - { url = "https://files.pythonhosted.org/packages/de/40/de0fe696d50732c8b1f0f9271b05a3082f2a91e77e28d70dd3ffc1e4aaa5/grpcio_tools-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:5ec661f3bb41f0d2a30125ea382f4d5c874bf4f26d4d8e3839bb7e3b3c037b3e", size = 2517611, upload-time = "2025-07-24T18:56:32.371Z" }, - { url = "https://files.pythonhosted.org/packages/a0/6d/949d3b339c3ff3c631168b355ce7be937f10feb894fdabe66c48ebd82394/grpcio_tools-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7970a9cf3002bec2eff5a449ac7398b77e5d171cbb534c47258c72409d0aea74", size = 2905274, upload-time = "2025-07-24T18:56:33.872Z" }, - { url = "https://files.pythonhosted.org/packages/06/6b/f9b2e7b15c147ad6164e9ac7b20ee208435ca3243bcc97feb1ab74dcb902/grpcio_tools-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f56d67b04790f84e216353341c6b298f1aeb591e1797fe955f606516c640936", size = 2656414, upload-time = "2025-07-24T18:56:35.47Z" }, - { url = "https://files.pythonhosted.org/packages/bd/de/621dde431314f49668c25b26a12f624c3da8748ac29df9db7d0a2596e575/grpcio_tools-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e3d0c33cc984d21525f190cb1af479f8da46370df5f2ced1a4e50769ababd0c0", size = 3052690, upload-time = "2025-07-24T18:56:37.799Z" }, - { url = "https://files.pythonhosted.org/packages/40/82/d43c9484174feea5a153371a011e06eabe508b97519a1e9a338b7ebdf43b/grpcio_tools-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:88e535c1cf349e57e371529ea9918f811c5eff88161f322bbc06d6222bad6d50", size = 3501214, upload-time = "2025-07-24T18:56:39.493Z" }, - { url = "https://files.pythonhosted.org/packages/30/fc/195b90e4571f6c70665a25c7b748e13c2087025660d6d5aead9093f28b18/grpcio_tools-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c3cf9401ce72bc49582c2d80e0a2ee0e573e1c3c998c8bc5f739db8845e8e148", size = 3125689, upload-time = "2025-07-24T18:56:41.555Z" }, - { url = "https://files.pythonhosted.org/packages/cb/81/fe8980e5fb768090ffc531902ec1b7e5bf1d92108ecf8b7305405b297475/grpcio_tools-1.74.0-cp312-cp312-win32.whl", hash = "sha256:b63e250da44b15c67b9a34c5c30c81059bde528fc8af092d7f43194469f7c719", size = 993069, upload-time = "2025-07-24T18:56:43.088Z" }, - { url = "https://files.pythonhosted.org/packages/63/a9/7b081924d655787d56d2b409f703f0bf457b3dac10a67ad04dc7338e9aae/grpcio_tools-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:519d7cae085ae6695a8031bb990bf7766a922332b0a531e51342abc5431b78b5", size = 1157502, upload-time = "2025-07-24T18:56:44.814Z" }, - { url = "https://files.pythonhosted.org/packages/2f/65/307a72cf4bfa553a25e284bd1f27b94a53816ac01ddf432c398117b91b2a/grpcio_tools-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e2e22460355adbd0f25fdd7ed8b9ae53afb3875b9d5f34cdf1cf12559418245e", size = 2545750, upload-time = "2025-07-24T18:56:46.386Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8e/9b2217c15baadc7cfca3eba9f980e147452ca82f41767490f619edea3489/grpcio_tools-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0cab5a2c6ae75b555fee8a1a9a9b575205171e1de392fe2d4139a29e67d8f5bb", size = 5838169, upload-time = "2025-07-24T18:56:48.057Z" }, - { url = "https://files.pythonhosted.org/packages/ea/42/a6a158b7e91c0a358cddf3f9088b004c2bfa42d1f96154b9b8eb17e16d73/grpcio_tools-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:9b18afca48b55832402a716ea4634ef2b68927a8a17ddf4038f51812299255c9", size = 2517140, upload-time = "2025-07-24T18:56:49.696Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d4576a07b2d1211822a070f76a99a9f4f4cb63496a02964ce77c88df8a28/grpcio_tools-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85f442a9e89e276bf89a0c9c76ea71647a927d967759333c1fa40300c27f7bd", size = 2905214, upload-time = "2025-07-24T18:56:51.768Z" }, - { url = "https://files.pythonhosted.org/packages/77/dc/3713e75751f862d8c84f823ba935d486c0aac0b6f789fa61fbde04ad5019/grpcio_tools-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:051ce925b0b99ae2daf61b3cba19962b8655cc2a72758ce4081b89272206f5a3", size = 2656245, upload-time = "2025-07-24T18:56:53.877Z" }, - { url = "https://files.pythonhosted.org/packages/bd/e4/01f9e8e0401d8e11a70ae8aff6899eb8c16536f69a0a9ffb25873588721c/grpcio_tools-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:98c7b8eb0de6984cd7fa7335ce3383b3bb9a1559edc238c811df88008d5d3593", size = 3052327, upload-time = "2025-07-24T18:56:55.535Z" }, - { url = "https://files.pythonhosted.org/packages/28/c2/264b4e705375a834c9c7462847ae435c0be1644f03a705d3d7464af07bd5/grpcio_tools-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f8f7d17b7573b9a2a6b4183fa4a56a2ab17370c8d0541e1424cf0c9c6f863434", size = 3500706, upload-time = "2025-07-24T18:56:57.245Z" }, - { url = "https://files.pythonhosted.org/packages/ee/c0/cc034cec5871a1918e7888e8ce700e06fab5bbb328f998a2f2750cd603b5/grpcio_tools-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:db08b91ea0cd66dc4b1b929100e7aa84c9c10c51573c8282ec1ba05b41f887ef", size = 3125098, upload-time = "2025-07-24T18:56:59.02Z" }, - { url = "https://files.pythonhosted.org/packages/69/55/5792b681af82b3ff1e50ce0ccfbb6d52fc68a13932ed3da57e58d7dfb67b/grpcio_tools-1.74.0-cp313-cp313-win32.whl", hash = "sha256:4b6c5efb331ae9e5f614437f4a5938459a8a5a1ab3dfe133d2bbdeaba39b894d", size = 992431, upload-time = "2025-07-24T18:57:00.618Z" }, - { url = "https://files.pythonhosted.org/packages/94/9f/626f0fe6bfc1c6917785c6a5ee2eb8c07b5a30771e4bf4cff3c1ab5b431b/grpcio_tools-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8324cd67f61f7900d227b36913ee5f0302ba3ba8777c8bc705afa8174098d28", size = 1157064, upload-time = "2025-07-24T18:57:02.579Z" }, + { url = "https://files.pythonhosted.org/packages/18/76/14ff87090199a36f914388299a1148d0734a20cea1b0ca8480bae1f373f1/grpcio_tools-1.81.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:8161f398f957a376cae7385ea7c8684f439d460ef702b528912da3bcb31fc515", size = 2586251, upload-time = "2026-06-11T12:49:43.514Z" }, + { url = "https://files.pythonhosted.org/packages/87/a8/d5aa99de9d8b2dd2a8192c1779796eda8b0d0f1dd915422e0a8a61b80391/grpcio_tools-1.81.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:53ef76cc3b0493ff734a5e8c39d5b519e1822236fcccdfe7677c5e1efd767761", size = 5818063, upload-time = "2026-06-11T12:49:45.975Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/2e9a6dbc6a514dd3cd264fb3bf9217937453a4d45dbc3ca6ca4ee34ba1a7/grpcio_tools-1.81.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:690e6dcaa8b8a7886ce206ba344e2127211597e1a1ddab73df9f3d80c8f6707e", size = 2634061, upload-time = "2026-06-11T12:49:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2b/2ccd1a929e6c8ad84a0aa8d66ad9f615b4a8e79d9927373d86aa36b4ba2e/grpcio_tools-1.81.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ad7a997c07bd345e84842e60561e7e2cc090ce6c4e1d2f0407e31b85b40fc49a", size = 2958029, upload-time = "2026-06-11T12:49:50.466Z" }, + { url = "https://files.pythonhosted.org/packages/e7/67/2da8cd312edc348f44f26f82096b25cdb7d2905cd786acc6bf777b169502/grpcio_tools-1.81.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b6bd163ece4535726e5292b845ed80ae9b2cae73ba091c7d6c66033c430e3857", size = 2698031, upload-time = "2026-06-11T12:49:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ba/ad1680fbdf9317c4f1e54c37c96d1f422370df66ac9adbd175c7cb3531d7/grpcio_tools-1.81.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2baa7e735f35b2a648144c03348a126097b13e101d3c242d5edb6ac91437ccbe", size = 3147541, upload-time = "2026-06-11T12:49:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/57/c1/57cd08eef293d713cb8935295e4f08d8f0013480b2ba3aad1af0271eb7ba/grpcio_tools-1.81.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d602b410b2b2addc434cace9ce4fe2035974a3078228f98ffa049a5c90acc2f", size = 3708524, upload-time = "2026-06-11T12:49:56.544Z" }, + { url = "https://files.pythonhosted.org/packages/52/31/01ea8ca9c82fe2c79b5b594c3ae427d56699bc106b2d91caca129add8b10/grpcio_tools-1.81.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f8cb64f87c45ccca8234fa47e6b21f09e43801ff11b556deecb461b3b3e9f292", size = 3367022, upload-time = "2026-06-11T12:49:59.608Z" }, + { url = "https://files.pythonhosted.org/packages/7d/35/8140cd175602df3d17215cfb28a7ea55b7a67e2b872be76e1ee4af5c4df9/grpcio_tools-1.81.1-cp311-cp311-win32.whl", hash = "sha256:87b25ca0e27373a4a32a629a4ba976f5764b9887dd50d6fe017d38009a0363e8", size = 1008980, upload-time = "2026-06-11T12:50:01.422Z" }, + { url = "https://files.pythonhosted.org/packages/be/86/1bd29ab3c52457702b96536f1f208ab27695322d855f95c9666dfb713019/grpcio_tools-1.81.1-cp311-cp311-win_amd64.whl", hash = "sha256:204de03b539a4b08772c6553b92bcc112cbc965e0ac22f909f6d133b8ac33a8c", size = 1174840, upload-time = "2026-06-11T12:50:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8a/824a9ca20bcdce8a568bb8c9f98bfeb7fad62129235e6d2ae7576fd1250a/grpcio_tools-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:353b1fafcc739c31ed42271052709595b340d34f27c459beeb78a32938305bb5", size = 2585927, upload-time = "2026-06-11T12:50:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/2f/35/e5f9f671378b1b89a896150d3e4fa2c6ec61a5e1e9e5107ce4c140ccc931/grpcio_tools-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:768f584c2423cbeb6cb6867817a39365b987ff16b8259a3adbc6546b9e303a4e", size = 5815665, upload-time = "2026-06-11T12:50:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/c6/02/631b628e4072e988c669bd8f1b2406ef3c9a4cfcb2625bbf2a308a07b71d/grpcio_tools-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1680b35a84f4694401819ac4acac42dda6dbc7bb8fc74112fd1a60425a07adf4", size = 2635518, upload-time = "2026-06-11T12:50:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/de/7c/2e3537e3ea3d1c0ddd6766cf6a7c62b487d89fb005713df2781d5f21483a/grpcio_tools-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f64e665c8ec639278ecf009beb92cbdcc5994f617c1af3d58036e1f70b1423ec", size = 2958252, upload-time = "2026-06-11T12:50:12.677Z" }, + { url = "https://files.pythonhosted.org/packages/35/68/14013cb2942bdac354746b643b4c37dd91906da8dce00f41c616e88bf33d/grpcio_tools-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f1ae82ad199f43448995715445cc623fb20d3882382e4be61f0da8ccb3f0e", size = 2698439, upload-time = "2026-06-11T12:50:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/bd/45/000c14c0338a7ad36054b9f17ea41842deb7841c05c067dd36cc831bc0f4/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7b6d1e986d5923751bfe2b5cca9c4cb3d5653446e4fa4aacd438033e2dc360a", size = 3152160, upload-time = "2026-06-11T12:50:17.3Z" }, + { url = "https://files.pythonhosted.org/packages/41/97/881930ca3967d2c8a95649bea8ebc991a7cf2331bc96679fd3600450dccc/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7f208c207aca639dcb34648d3826c38d7cf3485118fb2065117e9fc4827406b3", size = 3710468, upload-time = "2026-06-11T12:50:19.479Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b5/67baeba7366162652cdc1dbd962289accde07241bc8f42f6f02b305efcc6/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724ecb69af63d2f6d4ccea3e6fa0ca110ed9c5824d48c2f887c631bbb03c1c3c", size = 3370797, upload-time = "2026-06-11T12:50:21.501Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5d/34f2dce2125ccb107e32b57f5a9c1257edcc0793b0d2fef1e8b13a6bac3c/grpcio_tools-1.81.1-cp312-cp312-win32.whl", hash = "sha256:895a6782cec86beac71ccebb4b9848259c6f04a3028b8e42fa8d40cfe5146593", size = 1008453, upload-time = "2026-06-11T12:50:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/8a/be/09da8256ec8d2a5ce8a1acc51cbbc4ca52a462d78ed3412778440a56502e/grpcio_tools-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:0265fd1386b7458302f79542558345880d484f8fa92ae196c0c0268242c5f23a", size = 1174857, upload-time = "2026-06-11T12:50:25.685Z" }, + { url = "https://files.pythonhosted.org/packages/76/90/5faa8b26e03495e5117f93bef8293cbada4af136362745dad7d1813ef0b0/grpcio_tools-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3d604b4fd114b79ebb9f865bf3e04fd3ae93c704e1fad96f7fd03b0865c263b7", size = 2586071, upload-time = "2026-06-11T12:50:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/e8/9a/85dc589fa6ae2439451eaa81a1578de31e29c676980d38bef7549b8a1f45/grpcio_tools-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3389e705460efa3f3758141ba5520e6743b131c9576197c944fb9cbe49048126", size = 5813299, upload-time = "2026-06-11T12:50:31.295Z" }, + { url = "https://files.pythonhosted.org/packages/77/fd/c53994e58a837e6eefe48f53eb3492afc04f2b8af255df4adb37d14378f8/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8a17d8ceeb6a855fadf39f5171c80a382d97c4db98d5943eca553497fdebf84b", size = 2634668, upload-time = "2026-06-11T12:50:33.938Z" }, + { url = "https://files.pythonhosted.org/packages/34/32/de988e86688686a2117e7ce6ce9eff4f638c929bb55b0afe60d6fbd2e45c/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:43baf71dc60fd653062da2e95e95c73b35dd130be8f9fa3d544c3af3f808a290", size = 2957930, upload-time = "2026-06-11T12:50:36.726Z" }, + { url = "https://files.pythonhosted.org/packages/72/97/3f18a0ea32b5f809d21961dbd0bc382b589a4c3d501e3d67c345d5456ed3/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136e90906af0df51ad929713244ba812d0dbb1844b4f467d5d86bdb054698f90", size = 2697760, upload-time = "2026-06-11T12:50:39.108Z" }, + { url = "https://files.pythonhosted.org/packages/49/c0/dbf5cbc877290ff7504a59959a8af4fdcfdaa1e84237948405ccf1aa82a6/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd6c3bf3ea6a61eb58c54368d72ada591f2a270f3a31a32e8536e773337e76d9", size = 3151456, upload-time = "2026-06-11T12:50:41.983Z" }, + { url = "https://files.pythonhosted.org/packages/de/ea/16fe2dc83140a59e5c0a0b9dc2693dd36bfaa6bd835724b4ec66a68eab7b/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c306c307f8f74cddc4056fdbb6f1da55de087a21120efbd02bd915daa5a52fd", size = 3710469, upload-time = "2026-06-11T12:50:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/22/7d/df987d7d81e7ad2f7516d9e9d56ff29c54dbc6d8587e425688dca9a28e49/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bdbdc927be2e0ea13c32564a72ee31d712a716fb6f8c0d53d37a77d8277c272c", size = 3370488, upload-time = "2026-06-11T12:50:47.199Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c5/5a63444d694ea47bf670138208f71830cc1759c402c8818092b28ab2dc5f/grpcio_tools-1.81.1-cp313-cp313-win32.whl", hash = "sha256:9d383724bcd67244b6def9e9164c640ee9380c0b7534ee7545a6fb0022a59afe", size = 1008229, upload-time = "2026-06-11T12:50:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/00/75/3945e26d5c94ae6ed9be5caef73d4d66c47dc8cfdd7b4995efaf942754e0/grpcio_tools-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:f3eb15849979ca7bb864ce81a74d68b0f225a7f111ed3fe212bfc08cf9812b10", size = 1174523, upload-time = "2026-06-11T12:50:51.755Z" }, + { url = "https://files.pythonhosted.org/packages/0d/08/e581ad42ae517a61172285047e4d710e2ac75f2f1915f7c91f284254e6d5/grpcio_tools-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:7d168ea26390717d0462c0d0408331dc98a60fc7f7e6118afac9b73f5a66d87c", size = 2585944, upload-time = "2026-06-11T12:50:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/c8/200d90ebad685af7eea5ff7e0360c504dd01ec053fe0f1f9c4abe3ea2d5a/grpcio_tools-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:43c528655b226375013036692d8db4cd59060c1f41dd62c77f4d17b69f6ce828", size = 5813492, upload-time = "2026-06-11T12:50:57.291Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/60da2a1af37aa8eb47308cec24d9f7709a8976fdec3a53fd35b56b358326/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a9c6fcc68c9d5a208967bfe4fd3224d3c3be9a950c3e827e8f4b17e15c2dc555", size = 2634991, upload-time = "2026-06-11T12:50:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7f/dede28b579ae9bf9079ba1aa913e8088d1dc0cdbe21c85caa22f0790cad2/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a987c85dcbe1b32066d7acd46266d1a428aecbd629331bf5b853e74c835bf876", size = 2957913, upload-time = "2026-06-11T12:51:02.31Z" }, + { url = "https://files.pythonhosted.org/packages/4c/38/4de2118adb58ec7ffba65ec623b5836db769665c192517cbf187db3f6145/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a882382507bb5ec6d7edc9648053dfd3bc8f9285cde56a6fa9b9a83b4bd07f1c", size = 2697709, upload-time = "2026-06-11T12:51:05.016Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e1/762ced51059e4f694fd337ecae491581d42a4e61dcb0415d8c5c60e6ddcb/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7746e508d4239a02f7e93638be5bc0ebb0120ddb796f7506aaae9d47a4599d97", size = 3151884, upload-time = "2026-06-11T12:51:07.593Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/9823090dc801e7229944874e7429c3b98e741ac778d8dc373f60240e1c43/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fc3d2a41a7a4467fa03b391394fffada9291fe8feebc8679b526f6bc36942b25", size = 3710404, upload-time = "2026-06-11T12:51:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/64/4e/4eae98d02148cb6f9f452f09942afba407afa6851e6c1fddc5ae9ec0b4ed/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:21bb3ba90e6d8df1ff663d4ee39a4e5b25a64e8ed4902476ca9ded0954d3917a", size = 3370525, upload-time = "2026-06-11T12:51:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3e/2206e597a128da6a03a6106d2eaf2c3e72c7d80843d4be933e3a3d10d02a/grpcio_tools-1.81.1-cp314-cp314-win32.whl", hash = "sha256:3dca56016d90a710c4d9861bae793dc089c1430a90c79ce672e948ddb65fa539", size = 1030582, upload-time = "2026-06-11T12:51:14.906Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f2/bbeef86c687225b7bbc7c0acdfbd25c8bcaa3f5b1c941db053e5c3d9e859/grpcio_tools-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:cb08172b7b629e75cb33866928d319a3196540a725eaab628ba721007140f1af", size = 1207490, upload-time = "2026-06-11T12:51:17.598Z" }, ] [[package]] @@ -1526,7 +1552,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.84.10" +version = "1.93.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1542,9 +1568,36 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/c4/512c8cb204450b585bb7bee2cef9466c8b79b90cf774766f319de5c444ed/litellm-1.84.10.tar.gz", hash = "sha256:5ccb6aec803c35f463a7ea1a446030fe99f7c556388b435dc2fb7ad91aa48a24", size = 15123874, upload-time = "2026-06-24T03:57:19.791Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/88/e45bcdefc7a85bbef8eb852111dd4e02b92ea25727b6009c78893a768deb/litellm-1.84.10-py3-none-any.whl", hash = "sha256:7e175ebec04aa92149794adc83e4dd82b60d2b833c1ec265d68c08e8f56edde5", size = 16753091, upload-time = "2026-06-24T03:57:16.759Z" }, + { 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/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/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/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/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/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/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/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]] @@ -1804,19 +1857,23 @@ wheels = [ ] [[package]] -name = "nostr" -version = "0.0.2" +name = "nostr-sdk" +version = "0.45.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, - { name = "cryptography" }, - { name = "pycparser" }, - { name = "secp256k1" }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/e1/1e24d8d2d75d28871f5b7d03304eda8250121ab665180872b9ba4ff70cc9/nostr-0.0.2.tar.gz", hash = "sha256:5c0c472f69764ae57870710d6b3bfe584df3ccdb0e2e3cd3f302f6b848124d24", size = 17189, upload-time = "2023-01-26T14:02:38.676Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/ef/468ed56f0bea8e8979acf561273f8af2e7c9b3d8dc37bf80e81df08372a3/nostr-0.0.2-py3-none-any.whl", hash = "sha256:3d17d22dbd3aecf1ddf8cc72e330f14702e159fb0f43320d1ac88142db96aaba", size = 15397, upload-time = "2023-01-26T14:02:36.366Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/dca0a65a44538448cc18ad358fe8c8cd861f7d2733781834a232f0cb13ae/nostr_sdk-0.45.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8f5a121884ca991f4563354305705f78fa1822bf9b3e54ffbf64d6b89e4ff196", size = 3839165, upload-time = "2026-08-19T16:10:20.253Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/aa8b46b84fea0a71873643b3e31fe927bb705f6426a22218dcde5ffdb265/nostr_sdk-0.45.1-cp39-abi3-macosx_11_0_x86_64.whl", hash = "sha256:2b13cea42c9329b85e3748680058c77eab66f760652da871e8ce8808afbd9400", size = 3893446, upload-time = "2026-08-19T16:10:21.632Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e3/084ac8f2ae947b86d3d8c5ae89e3f7b0389c6e4ab207397c95bdc76121d2/nostr_sdk-0.45.1-cp39-abi3-manylinux_2_17_aarch64.whl", hash = "sha256:b7d1ddb5e84c49d394c5a5290fd8982d75dd05684d07fda1f7cf10434d7671d0", size = 4319292, upload-time = "2026-08-19T16:10:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5b/0cfea91f8af05bc677aea4980a66759b7f75e5722dd4976de3f607c14300/nostr_sdk-0.45.1-cp39-abi3-manylinux_2_17_armv7l.whl", hash = "sha256:1d68cdf81ee7c0356731b1f148413bc393abd03fabda0845e66c9e0e7fba1605", size = 3859951, upload-time = "2026-08-19T16:10:24.282Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/37a5b1080f4b5bd12537325f23d1652be2c3c9a8e4d1b36c0259e96566a9/nostr_sdk-0.45.1-cp39-abi3-manylinux_2_17_i686.whl", hash = "sha256:4f778445d9fcfc764a941e8918089879e0af1f6d9b2d6bd53af865bac10ef361", size = 4379812, upload-time = "2026-08-19T16:10:25.579Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fe/f05d7e8b9fa2c3541d4a4eeb077a3db63c1d722f991c6b08d8d2a8e3afc5/nostr_sdk-0.45.1-cp39-abi3-manylinux_2_17_x86_64.whl", hash = "sha256:5e051a662fac81aa2e15e8221915a0ee4c38791fe6284bc286c5f4cc769cc22e", size = 4417903, upload-time = "2026-08-19T16:10:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9e/ed21138d709ad5fd11a2a23c699131be6a8d97bbf4d6482e90e3caceac08/nostr_sdk-0.45.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8c769eee70727d8f73aef2032aa2fb599538c871748c18dae2738c30f77da2b7", size = 4328833, upload-time = "2026-08-19T16:10:28.367Z" }, + { url = "https://files.pythonhosted.org/packages/a8/21/862d0dc7cda61174e5b4fa17d85e6f5565e024517b5dc834db6cbdb204b4/nostr_sdk-0.45.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:154eb4227948507b0cee98a73872443bb71a54c78726e2f55aa95e353694d618", size = 3850546, upload-time = "2026-08-19T16:10:29.627Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0f/0d43864bb71e41b7d5b84ec3e480693b283780948946fec6322a2ae1de45/nostr_sdk-0.45.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:fbdb80e0c9daa57804c50a78c2928fa00fe15deef24edbc296fe41db8b3eda68", size = 4209394, upload-time = "2026-08-19T16:10:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/efe77f4ba3c91d6cb4bb06845d157d51fa64e0bedf979f8e6e934ca6850e/nostr_sdk-0.45.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3375566eb6e35af9cfe8d0e4f459d485543a24f8bbe3a05c7a459529a764cd2d", size = 4416706, upload-time = "2026-08-19T16:10:32.07Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7b/865d81abd1b368dbe17a85f57ce468b130d5913a6bc30e50670f68380596/nostr_sdk-0.45.1-cp39-abi3-win32.whl", hash = "sha256:6bcd1f1462a6ce946bb8943a1c0c078431d645e1d7e2f2124c9bed508d3a22a0", size = 3544889, upload-time = "2026-08-19T16:10:33.393Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8d/4cd89a50d2c9eb568c6303f03c6c8f9b938e672c29ae01214af551764a27/nostr_sdk-0.45.1-cp39-abi3-win_amd64.whl", hash = "sha256:937d1e8eec83114bfd1821e78a5e1a7130473c8998e98cdf8408361021e9590b", size = 3864710, upload-time = "2026-08-19T16:10:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/fe/55/69ab2693809deba62311323ccac7d8aced80750893b1675dd1bff418562f/nostr_sdk-0.45.1-cp39-abi3-win_arm64.whl", hash = "sha256:cf246932c5a405a3acc0e921e94615fb8ef5b3e88280735b5a525f46fe906dac", size = 3706921, upload-time = "2026-08-19T16:10:36.232Z" }, ] [[package]] @@ -2569,7 +2626,7 @@ dependencies = [ { name = "litellm" }, { name = "marshmallow" }, { name = "mdurl" }, - { name = "nostr" }, + { name = "nostr-sdk" }, { name = "openai" }, { name = "pillow" }, { name = "python-json-logger" }, @@ -2601,14 +2658,14 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.2.1" }, { name = "h11", specifier = ">=0.16" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, - { name = "litellm", specifier = ">=1.84.0,<1.85" }, + { name = "litellm", specifier = ">=1.93.0,<1.94" }, { name = "marshmallow", specifier = ">=3.13,<4.0" }, { name = "mdurl", specifier = "==0.1.2" }, - { name = "nostr", specifier = ">=0.0.2" }, + { name = "nostr-sdk", specifier = ">=0.45.1,<0.46" }, { name = "openai", specifier = ">=1.98.0" }, { name = "pillow", specifier = ">=10" }, { name = "python-json-logger", specifier = ">=2.0.0" }, - { name = "sqlmodel", specifier = ">=0.0.24" }, + { name = "sqlmodel", specifier = ">=0.0.42" }, { name = "websockets", specifier = ">=12.0" }, ] @@ -2760,26 +2817,6 @@ 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" }, ] -[[package]] -name = "secp256k1" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/41/bb668a6e4192303542d2d90c3b38d564af3c17c61bd7d4039af4f29405fe/secp256k1-0.14.0.tar.gz", hash = "sha256:82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397", size = 2420607, upload-time = "2021-11-06T01:36:10.707Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/12/4c9815a819816587df70aa38fe7d09b54724a0b1b9b8e8ea2af1c205f2a5/secp256k1-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:539d1d9750299ec4e8df6211978ba78779f5095c7ef19985313f03d1d1b816bd", size = 1298105, upload-time = "2026-01-29T16:26:28.697Z" }, - { url = "https://files.pythonhosted.org/packages/b1/86/f01ee0f4c44e12933c460f2b868a3888b93a7c7f4e9fc9be173401b55e8d/secp256k1-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d597a59e3918b0e41181a1c872851ac2e6137882de7f0487b8c42b25333ada", size = 1498906, upload-time = "2026-01-29T16:26:30.138Z" }, - { url = "https://files.pythonhosted.org/packages/05/c8/79f2990b72556c3f416ecfde2116a08afb41e324f51b8bf61268d7b72715/secp256k1-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:393d189b4ada9ab3de0b053f484a3b7e86024f4b8cd36616c05f07dbae3ca180", size = 1494612, upload-time = "2026-01-29T16:26:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e8/8dd140270b4e12a7f5876f1641f996854d700866352875f161f770b69ebb/secp256k1-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e4ec14534c1e8b8991376915ef059b7a3e62366aeda60df50b3932ad6529d26a", size = 1298100, upload-time = "2026-01-29T16:26:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6c/e63892de8d7582ab30602ccc1cf0ecd88a30b1a09424eb847c863fd46d9f/secp256k1-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1041694e429eb465123cb742911d2aad5cbd9e0cf2891aaaf794a887938647d1", size = 1499269, upload-time = "2026-01-29T16:26:35.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/5c/2faa8c523c0204af249890eb51b697e9a19d59d101625149d7b4f482e894/secp256k1-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf03e6d45892172046d4e085d5cc91d13a73a465c0f4c8b5633d823b0ca667e2", size = 1494878, upload-time = "2026-01-29T16:26:37.857Z" }, - { url = "https://files.pythonhosted.org/packages/d3/27/702d5683d211644f4d286463d7b1c25aeed26275f7b0e2a5a8dc83e7a598/secp256k1-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d90725a63e8e1d6d1483a135649c30ba949185702d3e5acbc075cdab3a44a37f", size = 1298097, upload-time = "2026-01-29T16:26:39.653Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1e/928647ac138fddfb4c5ee8aa4140a5786e51c75e9062b7f8d1a0362565df/secp256k1-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cd60d76d95e2eb977edc6523d1178a496fa1634517b497d4cdc7c9aa5e93aa3", size = 1499198, upload-time = "2026-01-29T16:26:41.169Z" }, - { url = "https://files.pythonhosted.org/packages/e9/30/c4168076a3cd66ce8ddb28ea127a5f97b088452f1ccb2a3208219fc4f77b/secp256k1-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:245b91f4bfe3a151e3e361f7e7ed634744d35e87c9ac6cf3eb0e4269801d9f7e", size = 1494778, upload-time = "2026-01-29T16:26:43.167Z" }, -] - [[package]] name = "setuptools" version = "84.0.0" @@ -2882,15 +2919,16 @@ asyncio = [ [[package]] name = "sqlmodel" -version = "0.0.24" +version = "0.0.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780, upload-time = "2025-03-07T05:43:32.887Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/50/27188d8cbccabf9968d74d4095765399dbc4f9fcae95e928fda69aa44a21/sqlmodel-0.0.42.tar.gz", hash = "sha256:9ecec2b6aa4c2aa58da39d2572e7bef4373419dcd6ec52973c07de7be2b1c516", size = 91737, upload-time = "2026-08-28T19:42:01.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622, upload-time = "2025-03-07T05:43:30.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/37/d7d6b12b9066005c237de3d52b599298a2693ac423bff0e6b97a90b90bf8/sqlmodel-0.0.42-py3-none-any.whl", hash = "sha256:a732dab1a40e5cbd6ac250cf2b12e8d9b1bf1a47edf27c34d612f359e8875c63", size = 29991, upload-time = "2026-08-28T19:42:00.568Z" }, ] [[package]] From c1c32448404162e16548d0c8604eb89b0e04aee5 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:42:53 +0200 Subject: [PATCH 27/30] fix(payment): reserve patch-based pricing for detail="original" images Images sent with detail="original" are billed by 32x32px patches at their original resolution (ceil(patches * 1.2) tokens on the original-capable gpt-5.4/5.5/5.6 families), and the API accepts up to 30,000 patches per image. The tiled estimator capped every non-"low" image at 765 tokens, so a 2048x2048 original-detail image reserved 765 tokens while billing ~4,900, and file_id references with original detail reserved nothing. - Add _calculate_original_image_tokens: patch count from the decoded dimensions, bounded by the 30,000-patch rejection limit and billed at the documented 1.2x multiplier (exact integer math). - Use the 36,000-token worst case (30,000 patches * 1.2) for file_id references whose dimensions cannot be fetched. - Route input_image content parts inside messages through _estimate_input_image_tokens so their sibling detail and file_id are honored on the chat path too, and normalize null detail to auto. --- routstr/payment/helpers.py | 104 ++++++++++++++++++++++++++ tests/unit/test_payment_helpers.py | 115 +++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index 0c012be8..de3b67a5 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -431,15 +431,46 @@ async def _fetch_image_from_url(url: str) -> bytes | None: return None +# Patch-based image pricing (OpenAI ``detail: "original"``): the image is +# covered with 32x32px patches and billed as ceil(patches * multiplier) +# tokens, with no 512px-tile downscaling. The API rejects images above +# 30,000 patches, so at the 1.2x multiplier documented for the +# original-capable model families (gpt-5.4/5.5/5.6) the worst case a +# single image can bill is 36,000 tokens. +_IMAGE_PATCH_PX = 32 +_MAX_IMAGE_PATCHES = 30_000 +_MAX_ORIGINAL_IMAGE_TOKENS = (_MAX_IMAGE_PATCHES * 6 + 4) // 5 # 36,000 + + +def _calculate_original_image_tokens(width: int, height: int) -> int: + """Estimate tokens for an image billed at ``detail: "original"``. + + Patch-based models cover the image with 32x32px patches and bill + ``ceil(patches * 1.2)`` tokens. The estimate is bounded by the + 30,000-patch rejection limit, which is more conservative than the + per-model resizing patch budgets (e.g. 10,000 patches on gpt-5.4/5.5) + so it never under-reserves. + """ + patches = ((width + _IMAGE_PATCH_PX - 1) // _IMAGE_PATCH_PX) * ( + (height + _IMAGE_PATCH_PX - 1) // _IMAGE_PATCH_PX + ) + bounded = min(patches, _MAX_IMAGE_PATCHES) + return (bounded * 6 + 4) // 5 # ceil(bounded * 1.2) in exact integer math + + def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int: """Calculate image tokens based on OpenAI's vision pricing. For low detail: 85 tokens For high detail/auto: 85 base tokens + 170 tokens per 512px tile + For original detail: patch-based pricing at the original resolution """ if detail == "low": return 85 + if detail == "original": + return _calculate_original_image_tokens(width, height) + if width > 2048 or height > 2048: aspect_ratio = width / height if width > height: @@ -495,6 +526,16 @@ async def estimate_image_tokens_in_messages(messages: list) -> int: if content_type not in ("image_url", "input_image"): continue + # Responses-style ``input_image`` parts carry their detail and + # file_id as siblings of the image reference; route them through + # the input_image estimator so original detail / file_id are + # honored on the chat path too. + if content_type == "input_image": + total_image_tokens += await _estimate_input_image_tokens( + content_item + ) + continue + image_url_data = content_item.get("image_url") if not image_url_data: continue @@ -562,6 +603,69 @@ async def estimate_image_tokens_in_messages(messages: list) -> int: return total_image_tokens +async def _estimate_input_image_tokens(item: dict) -> int: + """Estimate tokens for a Responses API ``input_image`` item. + + Honors the item-level ``detail``. The dimensions of ``file_id`` + references can't be fetched here, so they get conservative + estimates: the max-size tile math for high/auto and the 30,000-patch + worst case (36,000 tokens) for original, so we never under-reserve. + """ + detail = item.get("detail") or "auto" + if image_url := item.get("image_url"): + if isinstance(image_url, dict): + image_url = image_url.get("url", "") + if isinstance(image_url, str) and image_url.startswith("data:image/"): + try: + _, base64_data = image_url.split(",", 1) + image_bytes = base64.b64decode(base64_data) + width, height = _get_image_dimensions(image_bytes) + return _calculate_image_tokens(width, height, detail) + except Exception as e: + logger.warning( + "Failed to process base64 image", extra={"error": str(e)} + ) + return 85 + # Remote URLs and file_id both have unfetchable dimensions here; fall + # through to the conservative estimates below. + if item.get("file_id") or item.get("image_url"): + if detail == "original": + return _MAX_ORIGINAL_IMAGE_TOKENS + # We can't fetch an uploaded file's dimensions here; assume the + # largest vision image so we don't under-reserve. + return _calculate_image_tokens(2048, 2048, detail) + return 0 + + +async def estimate_image_tokens_from_input(input_data: Any) -> int: + """Estimate total tokens for images embedded in a Responses API ``input``. + + Recognizes ``input_image`` items at the top level of the input list and + inside ``message`` content parts. + """ + if not isinstance(input_data, list): + return 0 + + total_image_tokens = 0 + for item in input_data: + if not isinstance(item, dict): + continue + + if item.get("type") == "input_image": + total_image_tokens += await _estimate_input_image_tokens(item) + continue + + content = item.get("content") + if not isinstance(content, list): + continue + + for part in content: + if isinstance(part, dict) and part.get("type") == "input_image": + total_image_tokens += await _estimate_input_image_tokens(part) + + return total_image_tokens + + def create_error_response( error_type: str, message: str, diff --git a/tests/unit/test_payment_helpers.py b/tests/unit/test_payment_helpers.py index 2eab19c1..a70b78dc 100644 --- a/tests/unit/test_payment_helpers.py +++ b/tests/unit/test_payment_helpers.py @@ -288,3 +288,118 @@ async def test_discount_cannot_be_dodged_by_hiding_prompt_in_tools() -> None: # Same prompt weight → at least the same reservation, never the floor. assert cost >= cost_messages, where assert cost > 1000, where + + +async def test_estimate_image_tokens_from_input_detail_and_file_id() -> None: + import base64 + from io import BytesIO + + from PIL import Image + + from routstr.payment.helpers import estimate_image_tokens_from_input + + # file_id: dimensions can't be fetched, so use a conservative max-size + # estimate (4 tiles for auto/high) and honor the detail sibling for low. + assert await estimate_image_tokens_from_input( + [{"type": "input_image", "file_id": "file-1"}] + ) == 85 + (170 * 4) + assert await estimate_image_tokens_from_input( + [{"type": "input_image", "file_id": "file-1", "detail": "low"}] + ) == 85 + + # image_url honors the sibling detail instead of always defaulting to auto. + image = Image.new("RGB", (512, 512), "red") + buffer = BytesIO() + image.save(buffer, format="JPEG") + data_url = "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode() + + assert await estimate_image_tokens_from_input( + [{"type": "input_image", "image_url": data_url, "detail": "low"}] + ) == 85 + assert await estimate_image_tokens_from_input( + [{"type": "input_image", "image_url": data_url, "detail": "high"}] + ) == 85 + 170 # 512x512 = 1 tile + + +def test_calculate_image_tokens_original_detail() -> None: + from routstr.payment.helpers import _calculate_image_tokens + + # Patch-based pricing: ceil(patches * 1.2) tokens at 32x32px patches. + assert _calculate_image_tokens(640, 640, "original") == 480 # 400 patches + assert _calculate_image_tokens(2048, 2048, "original") == 4_916 # 4,096 patches + # The same image on the tiled high-detail path caps at 765 tokens. + assert _calculate_image_tokens(2048, 2048, "high") == 765 + # Above the 30,000-patch rejection limit the estimate is capped at + # 36,000 tokens (30,000 patches * 1.2). + assert _calculate_image_tokens(10_000, 10_000, "original") == 36_000 + + +async def test_estimate_image_tokens_from_input_original_detail() -> None: + import base64 + from io import BytesIO + + from PIL import Image + + from routstr.payment.helpers import estimate_image_tokens_from_input + + image = Image.new("RGB", (2048, 2048), "red") + buffer = BytesIO() + image.save(buffer, format="JPEG") + data_url = "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode() + + # image_url: billed at the decoded original resolution (4,096 patches), + # not the 765-token tile cap. + assert await estimate_image_tokens_from_input( + [{"type": "input_image", "image_url": data_url, "detail": "original"}] + ) == 4_916 + + # file_id: dimensions unknown, so use the 30,000-patch worst case. + assert await estimate_image_tokens_from_input( + [{"type": "input_image", "file_id": "file-1", "detail": "original"}] + ) == 36_000 + + # Explicit null detail behaves like the auto default (tiled math). + assert await estimate_image_tokens_from_input( + [{"type": "input_image", "file_id": "file-1", "detail": None}] + ) == 85 + (170 * 4) + + +async def test_estimate_image_tokens_in_messages_original_detail() -> None: + """Chat Completions also accepts original detail via the nested dict.""" + import base64 + from io import BytesIO + + from PIL import Image + + from routstr.payment.helpers import estimate_image_tokens_in_messages + + image = Image.new("RGB", (640, 640), "blue") + buffer = BytesIO() + image.save(buffer, format="JPEG") + data_url = "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode() + + messages = [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": data_url, "detail": "original"}, + } + ], + } + ] + # 640x640 -> 20x20 = 400 patches -> ceil(400 * 1.2) = 480 tokens. + assert await estimate_image_tokens_in_messages(messages) == 480 + + # input_image parts inside messages honor their sibling detail and + # file_id through the Responses estimator as well. + messages = [ + { + "role": "user", + "content": [ + {"type": "input_image", "file_id": "file-1", "detail": "original"} + ], + } + ] + assert await estimate_image_tokens_in_messages(messages) == 36_000 From 81843e1e24e4d5d1977a8ad8e4881180766763ab Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:33:35 +0200 Subject: [PATCH 28/30] fix(payment): honor max_completion_tokens in completion cost reservations Modern chat-completions clients (newer OpenAI SDKs) send max_completion_tokens instead of max_tokens; previously such requests got no completion discount at all and reserved the model's full max_completion_cost. Completion caps are now collected from all three spellings (max_tokens, max_completion_tokens, max_output_tokens) and the reservation is trimmed by the largest valid one: upstream precedence between the fields varies by provider, so reserving against a smaller declared cap could under-cover what the upstream bills. Unparseable values warn and are skipped, preserving the previous no-discount behavior when no valid cap is present. --- routstr/payment/helpers.py | 47 +++++--- tests/unit/test_payment_helpers.py | 165 ++++++++++++++++++----------- 2 files changed, 141 insertions(+), 71 deletions(-) diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index de3b67a5..9cc34e56 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -179,7 +179,12 @@ async def calculate_discounted_max_cost( body: dict, model_obj: Any | None = None, ) -> int: - """Calculate the discounted max cost for a request using model pricing when available.""" + """Calculate the discounted max cost for a request using model pricing when available. + + Completion discounts are trimmed from the largest declared cap among + ``max_tokens`` and ``max_completion_tokens`` (chat/completions) or + ``max_output_tokens`` (responses). + """ if settings.fixed_pricing: return max_cost_for_model @@ -244,21 +249,39 @@ async def calculate_discounted_max_cost( if estimated_prompt_delta_sats > 0: adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000) - max_tokens_raw = body.get("max_tokens", None) - if max_tokens_raw is not None: + # Completion caps arrive under several names: ``max_tokens`` (legacy + # chat), ``max_completion_tokens`` (modern chat) and ``max_output_tokens`` + # (Responses API). When a request declares more than one, reserve against + # the largest: upstream precedence between the fields varies by provider, + # so the smaller cap may not be honored and the reservation must never + # under-cover what the upstream could bill. + max_tokens_int: int | None = None + for cap_field in ("max_tokens", "max_completion_tokens", "max_output_tokens"): + cap_raw = body.get(cap_field) + if cap_raw is None: + continue try: - max_tokens_int = int(max_tokens_raw) + cap_int = int(cap_raw) except (TypeError, ValueError): logger.warning( - "Invalid max_tokens; ignoring in cost adjustment", - extra={"max_tokens": str(max_tokens_raw)[:64], "model": model}, + "Invalid completion token cap; ignoring in cost adjustment", + extra={ + "field": cap_field, + "value": str(cap_raw)[:64], + "model": model, + }, ) - else: - estimated_completion_delta_sats = ( - max_completion_allowed_sats - max_tokens_int * model_pricing.completion - ) - if estimated_completion_delta_sats > 0: - adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000) + continue + max_tokens_int = ( + cap_int if max_tokens_int is None else max(max_tokens_int, cap_int) + ) + + if max_tokens_int is not None: + estimated_completion_delta_sats = ( + max_completion_allowed_sats - max_tokens_int * model_pricing.completion + ) + if estimated_completion_delta_sats > 0: + adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000) logger.debug( "Discounted max cost computed", diff --git a/tests/unit/test_payment_helpers.py b/tests/unit/test_payment_helpers.py index a70b78dc..391f8f1f 100644 --- a/tests/unit/test_payment_helpers.py +++ b/tests/unit/test_payment_helpers.py @@ -211,16 +211,14 @@ async def test_discount_counts_legacy_token_id_prompt() -> None: assert cost == 50_000 -async def test_discount_cannot_be_dodged_by_hiding_prompt_in_tools() -> None: - """A large prompt moved from messages into tool schemas must reserve the - same cost — otherwise a caller undercharges by hiding weight from the - estimator.""" +async def test_discounted_max_cost_body_max_output_tokens_fallback() -> None: + """Body ``max_output_tokens`` (Responses API) is honored as a completion cap.""" from routstr.payment.helpers import calculate_discounted_max_cost pricing = Mock() - pricing.prompt = 0.5 - pricing.completion = 0.01 - pricing.max_prompt_cost = 100.0 + pricing.prompt = 0.001 + pricing.completion = 0.001 + pricing.max_prompt_cost = 0.0 pricing.max_completion_cost = 100.0 model_obj = Mock() @@ -228,51 +226,69 @@ async def test_discount_cannot_be_dodged_by_hiding_prompt_in_tools() -> None: model_obj.top_provider = None model_obj.context_length = None - big_text = "word " * 2_000 - base = {"model": "test-model", "max_tokens": 10} - in_messages = { - **base, - "messages": [{"role": "user", "content": big_text}], - } - hiding_places = { - "tools": { - **base, - "messages": [{"role": "user", "content": "hi"}], - "tools": [ - {"type": "function", "function": {"name": "f", "description": big_text}} - ], - }, - # Anthropic forwards a top-level system prompt; it is billed like any other. - "system": { - **base, - "messages": [{"role": "user", "content": "hi"}], - "system": big_text, - }, - # A key named like an image field must not win an image exclusion. - "image-named key": { - **base, - "messages": [{"role": "user", "content": "hi"}], - "tools": [{"function": {"parameters": {"data": big_text}}}], - }, - # Nor may a caller-chosen "data:" prefix, in any field the body allows. - "data-prefixed content": { - **base, - "messages": [{"role": "user", "content": "data:" + big_text}], - }, - "data-prefixed text block": { - **base, - "messages": [ - { - "role": "user", - "content": [{"type": "text", "text": "data:" + big_text}], - } - ], - }, - "data-prefixed system": { - **base, - "messages": [{"role": "user", "content": "hi"}], - "system": "data:" + big_text, - }, + body = {"max_output_tokens": 80_000} + + with ( + patch.object(settings, "fixed_pricing", False), + patch.object(settings, "tolerance_percentage", 0), + patch.object(settings, "min_request_msat", 1000), + ): + cost = await calculate_discounted_max_cost(100_000, body, model_obj) + + assert cost == 80_000 + + +async def test_discounted_max_cost_body_max_completion_tokens_fallback() -> None: + """Body ``max_completion_tokens`` (modern chat) is honored as a completion cap.""" + from routstr.payment.helpers import calculate_discounted_max_cost + + pricing = Mock() + pricing.prompt = 0.001 + pricing.completion = 0.001 + pricing.max_prompt_cost = 0.0 + pricing.max_completion_cost = 100.0 + + model_obj = Mock() + model_obj.sats_pricing = pricing + model_obj.top_provider = None + model_obj.context_length = None + + body = {"max_completion_tokens": 80_000} + + with ( + patch.object(settings, "fixed_pricing", False), + patch.object(settings, "tolerance_percentage", 0), + patch.object(settings, "min_request_msat", 1000), + ): + cost = await calculate_discounted_max_cost(100_000, body, model_obj) + + assert cost == 80_000 + + +async def test_discounted_max_cost_uses_largest_completion_cap() -> None: + """With several completion caps declared, the largest bounds the reservation. + + Upstream precedence between ``max_tokens`` / ``max_completion_tokens`` / + ``max_output_tokens`` varies by provider, so reserving against anything + but the largest could under-cover what the upstream bills. + """ + from routstr.payment.helpers import calculate_discounted_max_cost + + pricing = Mock() + pricing.prompt = 0.001 + pricing.completion = 0.001 + pricing.max_prompt_cost = 0.0 + pricing.max_completion_cost = 100.0 + + model_obj = Mock() + model_obj.sats_pricing = pricing + model_obj.top_provider = None + model_obj.context_length = None + + body = { + "max_tokens": 50_000, + "max_completion_tokens": 10_000, + "max_output_tokens": 80_000, } with ( @@ -280,14 +296,45 @@ async def test_discount_cannot_be_dodged_by_hiding_prompt_in_tools() -> None: patch.object(settings, "tolerance_percentage", 0), patch.object(settings, "min_request_msat", 1000), ): - cost_messages = await calculate_discounted_max_cost( - 150_000, in_messages, model_obj + cost = await calculate_discounted_max_cost(100_000, body, model_obj) + + # 80_000 is the largest declared cap: 100.0 - 80.0 = 20 sats discount. + assert cost == 80_000 + + +async def test_discounted_max_cost_invalid_completion_cap_ignored() -> None: + """Unparseable caps yield no completion discount rather than under-reserving.""" + from routstr.payment.helpers import calculate_discounted_max_cost + + pricing = Mock() + pricing.prompt = 0.001 + pricing.completion = 0.001 + pricing.max_prompt_cost = 0.0 + pricing.max_completion_cost = 100.0 + + model_obj = Mock() + model_obj.sats_pricing = pricing + model_obj.top_provider = None + model_obj.context_length = None + + with ( + patch.object(settings, "fixed_pricing", False), + patch.object(settings, "tolerance_percentage", 0), + patch.object(settings, "min_request_msat", 1000), + ): + # No valid cap at all -> no completion discount. + cost = await calculate_discounted_max_cost( + 100_000, {"max_completion_tokens": "sixty-four-k"}, model_obj ) - for where, body in hiding_places.items(): - cost = await calculate_discounted_max_cost(150_000, body, model_obj) - # Same prompt weight → at least the same reservation, never the floor. - assert cost >= cost_messages, where - assert cost > 1000, where + assert cost == 100_000 + + # An invalid sibling does not poison a valid cap on another field. + cost = await calculate_discounted_max_cost( + 100_000, + {"max_tokens": "bad", "max_completion_tokens": 80_000}, + model_obj, + ) + assert cost == 80_000 async def test_estimate_image_tokens_from_input_detail_and_file_id() -> None: From 5920bb69da757387c99f380699e016788cb083aa Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:15:13 +0200 Subject: [PATCH 29/30] fix(payment): wire Responses input images into reservation, fix original-detail fallbacks Address review on PR #680: - calculate_discounted_max_cost now calls estimate_image_tokens_from_input on body["input"], so a Responses request carrying an input_image reserves image tokens (previously the estimator was defined but never called, reserving 0). - _estimate_input_image_tokens now fetches and measures remote original-detail images instead of blindly reserving the 36,000-token worst case (~117x over-reserve for a 512x512 image), falling back to the worst case only when the fetch fails or the file is a file_id reference. - Broken/undecodable data URLs now reserve the declared detail's worst case (36,000 for original) rather than the 85-token low-detail floor: base64 is validated and PIL failures fall back explicitly instead of silently using _get_image_dimensions' 512x512 default. - Restore test_discount_cannot_be_dodged_by_hiding_prompt_in_tools (dropped in the rebase) and add regression tests for the Responses input-image wiring, remote original-detail fetch, and broken-data-URL fallback. --- routstr/payment/helpers.py | 99 ++++++++++----- tests/unit/test_payment_helpers.py | 198 +++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+), 33 deletions(-) diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index 9cc34e56..05304684 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -230,17 +230,24 @@ async def calculate_discounted_max_cost( # for work the reservation never covered. prompt_tokens = estimate_prompt_tokens(body) + # Images are billed as tokens by the upstream but carry no text for + # ``estimate_prompt_tokens`` to count, so they are estimated separately and + # added on both the chat (``messages``) and Responses (``input``) paths. + image_tokens = 0 if isinstance(messages, list): - image_tokens = await estimate_image_tokens_in_messages(messages) - if image_tokens > 0: - logger.debug( - "Found images in request", - extra={ - "model": model, - "image_tokens": image_tokens, - }, - ) - prompt_tokens += image_tokens + image_tokens += await estimate_image_tokens_in_messages(messages) + input_data = body.get("input") + if input_data is not None: + image_tokens += await estimate_image_tokens_from_input(input_data) + if image_tokens > 0: + logger.debug( + "Found images in request", + extra={ + "model": model, + "image_tokens": image_tokens, + }, + ) + prompt_tokens += image_tokens if prompt_tokens > 0: estimated_prompt_delta_sats = ( @@ -629,34 +636,60 @@ async def estimate_image_tokens_in_messages(messages: list) -> int: async def _estimate_input_image_tokens(item: dict) -> int: """Estimate tokens for a Responses API ``input_image`` item. - Honors the item-level ``detail``. The dimensions of ``file_id`` - references can't be fetched here, so they get conservative - estimates: the max-size tile math for high/auto and the 30,000-patch - worst case (36,000 tokens) for original, so we never under-reserve. + Honors the item-level ``detail``. Data-URL images are measured from their + decoded bytes; remote URLs are fetched and measured like the chat path. + Only ``file_id`` references (whose dimensions cannot be fetched here) and + unfetchable/broken images fall back to conservative estimates: the + max-size tile math for high/auto and the 30,000-patch worst case (36,000 + tokens) for original, so we never under-reserve. """ detail = item.get("detail") or "auto" - if image_url := item.get("image_url"): - if isinstance(image_url, dict): - image_url = image_url.get("url", "") - if isinstance(image_url, str) and image_url.startswith("data:image/"): - try: - _, base64_data = image_url.split(",", 1) - image_bytes = base64.b64decode(base64_data) - width, height = _get_image_dimensions(image_bytes) - return _calculate_image_tokens(width, height, detail) - except Exception as e: - logger.warning( - "Failed to process base64 image", extra={"error": str(e)} - ) - return 85 - # Remote URLs and file_id both have unfetchable dimensions here; fall - # through to the conservative estimates below. - if item.get("file_id") or item.get("image_url"): + image_url = item.get("image_url") + if isinstance(image_url, dict): + image_url = image_url.get("url", "") + + def _worst_case() -> int: + # Dimensions unknown: reserve the worst case for the declared detail so + # a broken/unreadable image still covers what the upstream could bill. if detail == "original": return _MAX_ORIGINAL_IMAGE_TOKENS - # We can't fetch an uploaded file's dimensions here; assume the - # largest vision image so we don't under-reserve. return _calculate_image_tokens(2048, 2048, detail) + + if isinstance(image_url, str) and image_url: + if image_url.startswith("data:image/"): + image_bytes = None + try: + _, base64_data = image_url.split(",", 1) + image_bytes = base64.b64decode(base64_data, validate=True) + except Exception as e: + logger.warning( + "Failed to decode base64 image", extra={"error": str(e)} + ) + if image_bytes is not None: + try: + img = Image.open(BytesIO(image_bytes)) + return _calculate_image_tokens(img.size[0], img.size[1], detail) + except Exception as e: + logger.warning( + "Failed to read image dimensions", extra={"error": str(e)} + ) + # Undecodable / unreadable data URL: reserve the worst case. + return _worst_case() + # Remote URL: fetch and measure like the chat path so a small image + # does not reserve the original-detail worst case. + image_bytes = await _fetch_image_from_url(image_url) + if image_bytes: + try: + img = Image.open(BytesIO(image_bytes)) + return _calculate_image_tokens(img.size[0], img.size[1], detail) + except Exception as e: + logger.warning( + "Failed to read image dimensions", extra={"error": str(e)} + ) + # Unfetchable or unreadable: fall through to the conservative estimate. + + if item.get("file_id") or image_url: + return _worst_case() return 0 diff --git a/tests/unit/test_payment_helpers.py b/tests/unit/test_payment_helpers.py index 391f8f1f..84ab8959 100644 --- a/tests/unit/test_payment_helpers.py +++ b/tests/unit/test_payment_helpers.py @@ -211,6 +211,204 @@ async def test_discount_counts_legacy_token_id_prompt() -> None: assert cost == 50_000 +async def test_discount_cannot_be_dodged_by_hiding_prompt_in_tools() -> None: + """A large prompt moved from messages into tool schemas must reserve the + same cost — otherwise a caller undercharges by hiding weight from the + estimator.""" + from routstr.payment.helpers import calculate_discounted_max_cost + + pricing = Mock() + pricing.prompt = 0.5 + pricing.completion = 0.01 + pricing.max_prompt_cost = 100.0 + pricing.max_completion_cost = 100.0 + + model_obj = Mock() + model_obj.sats_pricing = pricing + model_obj.top_provider = None + model_obj.context_length = None + + big_text = "word " * 2_000 + base = {"model": "test-model", "max_tokens": 10} + in_messages = { + **base, + "messages": [{"role": "user", "content": big_text}], + } + hiding_places = { + "tools": { + **base, + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + {"type": "function", "function": {"name": "f", "description": big_text}} + ], + }, + # Anthropic forwards a top-level system prompt; it is billed like any other. + "system": { + **base, + "messages": [{"role": "user", "content": "hi"}], + "system": big_text, + }, + # A key named like an image field must not win an image exclusion. + "image-named key": { + **base, + "messages": [{"role": "user", "content": "hi"}], + "tools": [{"function": {"parameters": {"data": big_text}}}], + }, + # Nor may a caller-chosen "data:" prefix, in any field the body allows. + "data-prefixed content": { + **base, + "messages": [{"role": "user", "content": "data:" + big_text}], + }, + "data-prefixed text block": { + **base, + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "data:" + big_text}], + } + ], + }, + "data-prefixed system": { + **base, + "messages": [{"role": "user", "content": "hi"}], + "system": "data:" + big_text, + }, + } + + with ( + patch.object(settings, "fixed_pricing", False), + patch.object(settings, "tolerance_percentage", 0), + patch.object(settings, "min_request_msat", 1000), + ): + cost_messages = await calculate_discounted_max_cost( + 150_000, in_messages, model_obj + ) + for where, body in hiding_places.items(): + cost = await calculate_discounted_max_cost(150_000, body, model_obj) + # Same prompt weight → at least the same reservation, never the floor. + assert cost >= cost_messages, where + assert cost > 1000, where + + +async def test_discounted_max_cost_counts_responses_input_images() -> None: + """A Responses ``input_image`` must add image tokens to the reservation. + + Regression for the review finding that ``estimate_image_tokens_from_input`` + was defined but never called: a Responses body carries ``input``, not + ``messages``, so its images were previously reserved at zero tokens. + """ + import base64 + from io import BytesIO + + from PIL import Image + + from routstr.payment.helpers import calculate_discounted_max_cost + + pricing = Mock() + pricing.prompt = 0.001 + pricing.completion = 0.001 + pricing.max_prompt_cost = 100.0 + pricing.max_completion_cost = 0.0 + + model_obj = Mock() + model_obj.sats_pricing = pricing + model_obj.top_provider = None + model_obj.context_length = None + + image = Image.new("RGB", (512, 512), "red") + buffer = BytesIO() + image.save(buffer, format="JPEG") + data_url = "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode() + + no_image = { + "model": "test-model", + "input": [{"role": "user", "content": "hi"}], + } + with_image = { + "model": "test-model", + "input": [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "hi"}, + {"type": "input_image", "image_url": data_url, "detail": "high"}, + ], + } + ], + } + + with ( + patch.object(settings, "fixed_pricing", False), + patch.object(settings, "tolerance_percentage", 0), + patch.object(settings, "min_request_msat", 1000), + ): + cost_no_image = await calculate_discounted_max_cost(100_000, no_image, model_obj) + cost_with_image = await calculate_discounted_max_cost( + 100_000, with_image, model_obj + ) + + # The 512x512 high-detail image (85 + 170 = 255 tokens) is billed as prompt + # weight, so it reserves strictly more than the identical text-only body. + assert cost_with_image > cost_no_image + + +async def test_estimate_input_image_tokens_remote_original_fetches() -> None: + """A remote ``original`` image is fetched and measured, not worst-cased. + + Regression for the review finding that any non-data URL with + ``detail: \"original\"`` reserved the 36,000-token worst case without + trying to fetch — a 512x512 image reserved ~117x its real cost. + """ + from io import BytesIO + from unittest.mock import patch as mock_patch + + from PIL import Image + + from routstr.payment.helpers import _estimate_input_image_tokens + + image = Image.new("RGB", (512, 512), "red") + buffer = BytesIO() + image.save(buffer, format="JPEG") + image_bytes = buffer.getvalue() + + with mock_patch( + "routstr.payment.helpers._fetch_image_from_url", + new=AsyncMock(return_value=image_bytes), + ): + # 512x512 original -> 16x16 = 256 patches -> ceil(256 * 1.2) = 308 tokens, + # far below the 36,000 worst case a blind fallback would reserve. + assert await _estimate_input_image_tokens( + {"type": "input_image", "image_url": "https://x.test/i.jpg", "detail": "original"} + ) == 308 + + # When the fetch fails, fall back to the original-detail worst case. + with mock_patch( + "routstr.payment.helpers._fetch_image_from_url", + new=AsyncMock(return_value=None), + ): + assert await _estimate_input_image_tokens( + {"type": "input_image", "image_url": "https://x.test/i.jpg", "detail": "original"} + ) == 36_000 + + +async def test_estimate_input_image_tokens_broken_original_data_url() -> None: + """A broken data URL with ``detail: \"original\"`` reserves the worst case. + + Regression for the review finding that the ``except`` branch returned 85 + (low-detail) regardless of the declared detail. + """ + from routstr.payment.helpers import _estimate_input_image_tokens + + # "!!!" is not valid base64, so decoding raises before any dimension read. + assert await _estimate_input_image_tokens( + {"type": "input_image", "image_url": "data:image/jpeg;base64,!!!", "detail": "original"} + ) == 36_000 + # Non-original details fall back to the max-size tile math, not the 85 floor. + assert await _estimate_input_image_tokens( + {"type": "input_image", "image_url": "data:image/jpeg;base64,!!!", "detail": "high"} + ) == 85 + (170 * 4) + + async def test_discounted_max_cost_body_max_output_tokens_fallback() -> None: """Body ``max_output_tokens`` (Responses API) is honored as a completion cap.""" from routstr.payment.helpers import calculate_discounted_max_cost From 63a7227c2abd431a999b74b97968c262bc454042 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 12 Sep 2026 14:16:40 +0200 Subject: [PATCH 30/30] use litellm pre-existing functionality --- routstr/payment/helpers.py | 192 ++++++------------- routstr/payment/responses_input.py | 79 ++++++++ tests/unit/test_payment_helpers.py | 296 ++++++++++++++++++++++------- 3 files changed, 363 insertions(+), 204 deletions(-) create mode 100644 routstr/payment/responses_input.py diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py index 05304684..d088151f 100644 --- a/routstr/payment/helpers.py +++ b/routstr/payment/helpers.py @@ -24,6 +24,12 @@ from ..wallet import ( deserialize_token_from_string, is_trusted_source_mint, ) +from .responses_input import ( + FILE_ID_URL_PREFIX, + count_input_images, + input_image_part_to_image_url, + responses_input_to_messages, +) logger = get_logger(__name__) @@ -237,8 +243,12 @@ async def calculate_discounted_max_cost( if isinstance(messages, list): image_tokens += await estimate_image_tokens_in_messages(messages) input_data = body.get("input") - if input_data is not None: - image_tokens += await estimate_image_tokens_from_input(input_data) + if isinstance(input_data, list): + converted = responses_input_to_messages(input_data) + if converted is None: + image_tokens += count_input_images(input_data) * _MAX_ORIGINAL_IMAGE_TOKENS + else: + image_tokens += await estimate_image_tokens_in_messages(converted) if image_tokens > 0: logger.debug( "Found images in request", @@ -553,17 +563,9 @@ async def estimate_image_tokens_in_messages(messages: list) -> int: continue content_type = content_item.get("type") - if content_type not in ("image_url", "input_image"): - continue - - # Responses-style ``input_image`` parts carry their detail and - # file_id as siblings of the image reference; route them through - # the input_image estimator so original detail / file_id are - # honored on the chat path too. if content_type == "input_image": - total_image_tokens += await _estimate_input_image_tokens( - content_item - ) + content_item = input_image_part_to_image_url(content_item) + elif content_type != "image_url": continue image_url_data = content_item.get("image_url") @@ -575,7 +577,7 @@ async def estimate_image_tokens_in_messages(messages: list) -> int: detail = "auto" elif isinstance(image_url_data, dict): url = image_url_data.get("url", "") - detail = image_url_data.get("detail", "auto") + detail = image_url_data.get("detail") or "auto" else: continue @@ -583,143 +585,65 @@ async def estimate_image_tokens_in_messages(messages: list) -> int: continue if url.startswith("data:image/"): - try: - header, base64_data = url.split(",", 1) - image_bytes = base64.b64decode(base64_data) - width, height = _get_image_dimensions(image_bytes) - tokens = _calculate_image_tokens(width, height, detail) - total_image_tokens += tokens - logger.debug( - "Calculated tokens for base64 image", - extra={ - "width": width, - "height": height, - "detail": detail, - "tokens": tokens, - }, - ) - except Exception as e: - logger.warning( - "Failed to process base64 image", - extra={"error": str(e)}, - ) - total_image_tokens += 85 + total_image_tokens += _data_url_image_tokens(url, detail) + elif url.startswith(FILE_ID_URL_PREFIX): + total_image_tokens += _worst_case_image_tokens(detail) elif fetches >= IMAGE_FETCH_MAX_PER_REQUEST: logger.warning( "Skipping image URL fetch above per-request limit", extra={"url": url[:100], "limit": IMAGE_FETCH_MAX_PER_REQUEST}, ) - total_image_tokens += 85 + total_image_tokens += _worst_case_image_tokens(detail) else: fetches += 1 image_bytes_or_none = await _fetch_image_from_url(url) - if image_bytes_or_none: - width, height = _get_image_dimensions(image_bytes_or_none) - tokens = _calculate_image_tokens(width, height, detail) - total_image_tokens += tokens - logger.debug( - "Calculated tokens for URL image", - extra={ - "url": url[:100], - "width": width, - "height": height, - "detail": detail, - "tokens": tokens, - }, - ) - else: - total_image_tokens += 85 + total_image_tokens += _image_bytes_tokens( + image_bytes_or_none, detail, source=url[:100] + ) return total_image_tokens -async def _estimate_input_image_tokens(item: dict) -> int: - """Estimate tokens for a Responses API ``input_image`` item. - - Honors the item-level ``detail``. Data-URL images are measured from their - decoded bytes; remote URLs are fetched and measured like the chat path. - Only ``file_id`` references (whose dimensions cannot be fetched here) and - unfetchable/broken images fall back to conservative estimates: the - max-size tile math for high/auto and the 30,000-patch worst case (36,000 - tokens) for original, so we never under-reserve. - """ - detail = item.get("detail") or "auto" - image_url = item.get("image_url") - if isinstance(image_url, dict): - image_url = image_url.get("url", "") - - def _worst_case() -> int: - # Dimensions unknown: reserve the worst case for the declared detail so - # a broken/unreadable image still covers what the upstream could bill. - if detail == "original": - return _MAX_ORIGINAL_IMAGE_TOKENS - return _calculate_image_tokens(2048, 2048, detail) - - if isinstance(image_url, str) and image_url: - if image_url.startswith("data:image/"): - image_bytes = None - try: - _, base64_data = image_url.split(",", 1) - image_bytes = base64.b64decode(base64_data, validate=True) - except Exception as e: - logger.warning( - "Failed to decode base64 image", extra={"error": str(e)} - ) - if image_bytes is not None: - try: - img = Image.open(BytesIO(image_bytes)) - return _calculate_image_tokens(img.size[0], img.size[1], detail) - except Exception as e: - logger.warning( - "Failed to read image dimensions", extra={"error": str(e)} - ) - # Undecodable / unreadable data URL: reserve the worst case. - return _worst_case() - # Remote URL: fetch and measure like the chat path so a small image - # does not reserve the original-detail worst case. - image_bytes = await _fetch_image_from_url(image_url) - if image_bytes: - try: - img = Image.open(BytesIO(image_bytes)) - return _calculate_image_tokens(img.size[0], img.size[1], detail) - except Exception as e: - logger.warning( - "Failed to read image dimensions", extra={"error": str(e)} - ) - # Unfetchable or unreadable: fall through to the conservative estimate. - - if item.get("file_id") or image_url: - return _worst_case() - return 0 +def _worst_case_image_tokens(detail: str) -> int: + """Dimensions unknown: reserve the most ``detail`` can bill.""" + if detail == "original": + return _MAX_ORIGINAL_IMAGE_TOKENS + return _calculate_image_tokens(2048, 2048, detail) -async def estimate_image_tokens_from_input(input_data: Any) -> int: - """Estimate total tokens for images embedded in a Responses API ``input``. +def _data_url_image_tokens(url: str, detail: str) -> int: + try: + _, base64_data = url.split(",", 1) + image_bytes = base64.b64decode(base64_data, validate=True) + except Exception as e: + logger.warning("Failed to decode base64 image", extra={"error": str(e)}) + return _worst_case_image_tokens(detail) + return _image_bytes_tokens(image_bytes, detail, source="data-url") - Recognizes ``input_image`` items at the top level of the input list and - inside ``message`` content parts. - """ - if not isinstance(input_data, list): - return 0 - total_image_tokens = 0 - for item in input_data: - if not isinstance(item, dict): - continue - - if item.get("type") == "input_image": - total_image_tokens += await _estimate_input_image_tokens(item) - continue - - content = item.get("content") - if not isinstance(content, list): - continue - - for part in content: - if isinstance(part, dict) and part.get("type") == "input_image": - total_image_tokens += await _estimate_input_image_tokens(part) - - return total_image_tokens +def _image_bytes_tokens(image_bytes: bytes | None, detail: str, source: str) -> int: + if not image_bytes: + return _worst_case_image_tokens(detail) + try: + width, height = Image.open(BytesIO(image_bytes)).size + except Exception as e: + logger.warning( + "Failed to read image dimensions", + extra={"error": str(e), "source": source}, + ) + return _worst_case_image_tokens(detail) + tokens = _calculate_image_tokens(width, height, detail) + logger.debug( + "Calculated image tokens", + extra={ + "source": source, + "width": width, + "height": height, + "detail": detail, + "tokens": tokens, + }, + ) + return tokens def create_error_response( diff --git a/routstr/payment/responses_input.py b/routstr/payment/responses_input.py new file mode 100644 index 00000000..6a90a1e0 --- /dev/null +++ b/routstr/payment/responses_input.py @@ -0,0 +1,79 @@ +"""Convert a Responses API ``input`` into chat ``messages`` via litellm. + +litellm drops ``file_id`` (emits ``url: ""``) and nests a dict-form ``image_url`` +as-is, so ``input_image`` parts are flattened to ``{image_url: str, detail}`` first. +``file_id`` becomes a sentinel URL the image walker treats as unfetchable. +""" + +from typing import Any + +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) + +from ..core import get_logger + +logger = get_logger(__name__) + +FILE_ID_URL_PREFIX = "file-id:" + + +def _flatten_input_image(part: dict[str, Any]) -> tuple[str, str]: + raw = part.get("image_url") + url = raw.get("url", "") if isinstance(raw, dict) else raw + detail = part.get("detail") or ( + raw.get("detail") if isinstance(raw, dict) else None + ) + if not url and part.get("file_id"): + url = f"{FILE_ID_URL_PREFIX}{part['file_id']}" + return (url if isinstance(url, str) else ""), (detail or "auto") + + +def _normalize_item(item: Any) -> Any: + if not isinstance(item, dict): + return item + if item.get("type") == "input_image": + url, detail = _flatten_input_image(item) + return {**item, "image_url": url, "detail": detail} + content = item.get("content") + if isinstance(content, list): + return {**item, "content": [_normalize_item(part) for part in content]} + return item + + +def input_image_part_to_image_url(part: dict[str, Any]) -> dict[str, Any]: + """Reshape an ``input_image`` part found inside chat ``messages``.""" + url, detail = _flatten_input_image(part) + return {"type": "image_url", "image_url": {"url": url, "detail": detail}} + + +def count_input_images(input_data: Any) -> int: + if isinstance(input_data, dict): + own = 1 if input_data.get("type") == "input_image" else 0 + return own + count_input_images(input_data.get("content")) + if isinstance(input_data, list): + return sum(count_input_images(item) for item in input_data) + return 0 + + +def responses_input_to_messages(input_data: Any) -> list[dict[str, Any]] | None: + """Returns ``None`` when the transform fails so the caller can worst-case.""" + if isinstance(input_data, str): + return [{"role": "user", "content": input_data}] + if not isinstance(input_data, list): + return [] + try: + normalized = [_normalize_item(item) for item in input_data] + converted = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=normalized, # type: ignore[arg-type] + responses_api_request={}, + ) + ) + return [dict(message) for message in converted] + except Exception as e: + logger.warning( + "Responses input transform failed; using conservative image fallback", + extra={"error": str(e)}, + ) + return None diff --git a/tests/unit/test_payment_helpers.py b/tests/unit/test_payment_helpers.py index 84ab8959..343d9cdc 100644 --- a/tests/unit/test_payment_helpers.py +++ b/tests/unit/test_payment_helpers.py @@ -291,12 +291,6 @@ async def test_discount_cannot_be_dodged_by_hiding_prompt_in_tools() -> None: async def test_discounted_max_cost_counts_responses_input_images() -> None: - """A Responses ``input_image`` must add image tokens to the reservation. - - Regression for the review finding that ``estimate_image_tokens_from_input`` - was defined but never called: a Responses body carries ``input``, not - ``messages``, so its images were previously reserved at zero tokens. - """ import base64 from io import BytesIO @@ -342,7 +336,9 @@ async def test_discounted_max_cost_counts_responses_input_images() -> None: patch.object(settings, "tolerance_percentage", 0), patch.object(settings, "min_request_msat", 1000), ): - cost_no_image = await calculate_discounted_max_cost(100_000, no_image, model_obj) + cost_no_image = await calculate_discounted_max_cost( + 100_000, no_image, model_obj + ) cost_with_image = await calculate_discounted_max_cost( 100_000, with_image, model_obj ) @@ -352,61 +348,165 @@ async def test_discounted_max_cost_counts_responses_input_images() -> None: assert cost_with_image > cost_no_image -async def test_estimate_input_image_tokens_remote_original_fetches() -> None: - """A remote ``original`` image is fetched and measured, not worst-cased. +def _responses_image(url: str, detail: str | None = "original") -> list[dict[str, Any]]: + return [ + { + "role": "user", + "content": [{"type": "input_image", "image_url": url, "detail": detail}], + } + ] - Regression for the review finding that any non-data URL with - ``detail: \"original\"`` reserved the 36,000-token worst case without - trying to fetch — a 512x512 image reserved ~117x its real cost. - """ + +async def _responses_image_tokens(input_data: list[dict[str, Any]]) -> int: + from routstr.payment.helpers import estimate_image_tokens_in_messages + from routstr.payment.responses_input import responses_input_to_messages + + messages = responses_input_to_messages(input_data) + assert messages is not None + return await estimate_image_tokens_in_messages(messages) + + +async def test_remote_original_image_is_fetched_not_worst_cased() -> None: from io import BytesIO - from unittest.mock import patch as mock_patch from PIL import Image - from routstr.payment.helpers import _estimate_input_image_tokens - image = Image.new("RGB", (512, 512), "red") buffer = BytesIO() image.save(buffer, format="JPEG") image_bytes = buffer.getvalue() - with mock_patch( + with patch( "routstr.payment.helpers._fetch_image_from_url", new=AsyncMock(return_value=image_bytes), ): - # 512x512 original -> 16x16 = 256 patches -> ceil(256 * 1.2) = 308 tokens, - # far below the 36,000 worst case a blind fallback would reserve. - assert await _estimate_input_image_tokens( - {"type": "input_image", "image_url": "https://x.test/i.jpg", "detail": "original"} - ) == 308 + # 256 patches * 1.2 + assert ( + await _responses_image_tokens(_responses_image("https://x.test/i.jpg")) + == 308 + ) - # When the fetch fails, fall back to the original-detail worst case. - with mock_patch( + with patch( "routstr.payment.helpers._fetch_image_from_url", new=AsyncMock(return_value=None), ): - assert await _estimate_input_image_tokens( - {"type": "input_image", "image_url": "https://x.test/i.jpg", "detail": "original"} - ) == 36_000 + assert ( + await _responses_image_tokens(_responses_image("https://x.test/i.jpg")) + == 36_000 + ) -async def test_estimate_input_image_tokens_broken_original_data_url() -> None: - """A broken data URL with ``detail: \"original\"`` reserves the worst case. +async def test_broken_data_url_reserves_declared_detail_worst_case() -> None: + from routstr.payment.helpers import estimate_image_tokens_in_messages - Regression for the review finding that the ``except`` branch returned 85 - (low-detail) regardless of the declared detail. - """ - from routstr.payment.helpers import _estimate_input_image_tokens + broken = "data:image/jpeg;base64,!!!" + assert await _responses_image_tokens(_responses_image(broken)) == 36_000 + assert await _responses_image_tokens(_responses_image(broken, "high")) == 85 + ( + 170 * 4 + ) - # "!!!" is not valid base64, so decoding raises before any dimension read. - assert await _estimate_input_image_tokens( - {"type": "input_image", "image_url": "data:image/jpeg;base64,!!!", "detail": "original"} - ) == 36_000 - # Non-original details fall back to the max-size tile math, not the 85 floor. - assert await _estimate_input_image_tokens( - {"type": "input_image", "image_url": "data:image/jpeg;base64,!!!", "detail": "high"} - ) == 85 + (170 * 4) + chat = [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": broken, "detail": "original"}, + } + ], + } + ] + assert await estimate_image_tokens_in_messages(chat) == 36_000 + + +async def test_chat_original_image_fetch_failure_reserves_worst_case() -> None: + from routstr.payment.helpers import estimate_image_tokens_in_messages + + chat = [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "https://x.test/i.jpg", "detail": "original"}, + } + ], + } + ] + with patch( + "routstr.payment.helpers._fetch_image_from_url", + new=AsyncMock(return_value=None), + ): + assert await estimate_image_tokens_in_messages(chat) == 36_000 + + +async def test_responses_images_share_per_request_fetch_cap() -> None: + from routstr.payment.helpers import IMAGE_FETCH_MAX_PER_REQUEST + + input_data = [ + { + "role": "user", + "content": [ + { + "type": "input_image", + "image_url": f"https://x.test/{i}.jpg", + "detail": "original", + } + for i in range(IMAGE_FETCH_MAX_PER_REQUEST + 1) + ], + } + ] + fetch = AsyncMock(return_value=None) + with patch("routstr.payment.helpers._fetch_image_from_url", new=fetch): + tokens = await _responses_image_tokens(input_data) + + assert fetch.await_count == IMAGE_FETCH_MAX_PER_REQUEST + assert tokens == 36_000 * (IMAGE_FETCH_MAX_PER_REQUEST + 1) + + +async def test_responses_transform_failure_falls_back_to_worst_case() -> None: + from routstr.payment.helpers import calculate_discounted_max_cost + + pricing = Mock() + pricing.prompt = 0.001 + pricing.completion = 0.001 + pricing.max_prompt_cost = 100.0 + pricing.max_completion_cost = 0.0 + + model_obj = Mock() + model_obj.sats_pricing = pricing + model_obj.top_provider = None + model_obj.context_length = None + + body = { + "model": "test-model", + "input": [ + { + "role": "user", + "content": [ + {"type": "input_image", "image_url": "https://x.test/a.jpg"}, + {"type": "input_image", "image_url": "https://x.test/b.jpg"}, + ], + } + ], + } + fetch = AsyncMock(return_value=None) + with ( + patch.object(settings, "fixed_pricing", False), + patch.object(settings, "tolerance_percentage", 0), + patch.object(settings, "min_request_msat", 1000), + patch("routstr.payment.helpers._fetch_image_from_url", new=fetch), + patch( + "routstr.payment.responses_input.LiteLLMCompletionResponsesConfig." + "transform_responses_api_input_to_messages", + side_effect=RuntimeError("boom"), + ), + ): + cost = await calculate_discounted_max_cost(100_000, body, model_obj) + + fetch.assert_not_awaited() + # 2 * 36,000 tokens * 0.001 sats = 72 sats reserved + assert 72_000 <= cost < 100_000 async def test_discounted_max_cost_body_max_output_tokens_fallback() -> None: @@ -535,22 +635,28 @@ async def test_discounted_max_cost_invalid_completion_cap_ignored() -> None: assert cost == 80_000 -async def test_estimate_image_tokens_from_input_detail_and_file_id() -> None: +def _responses_file_image(detail: str | None) -> list[dict[str, Any]]: + part: dict[str, Any] = {"type": "input_image", "file_id": "file-1"} + if detail is not None: + part["detail"] = detail + return [{"role": "user", "content": [part]}] + + +async def test_responses_input_detail_and_file_id() -> None: import base64 from io import BytesIO from PIL import Image - from routstr.payment.helpers import estimate_image_tokens_from_input - # file_id: dimensions can't be fetched, so use a conservative max-size # estimate (4 tiles for auto/high) and honor the detail sibling for low. - assert await estimate_image_tokens_from_input( - [{"type": "input_image", "file_id": "file-1"}] - ) == 85 + (170 * 4) - assert await estimate_image_tokens_from_input( - [{"type": "input_image", "file_id": "file-1", "detail": "low"}] - ) == 85 + fetch = AsyncMock(return_value=None) + with patch("routstr.payment.helpers._fetch_image_from_url", new=fetch): + assert await _responses_image_tokens(_responses_file_image(None)) == 85 + ( + 170 * 4 + ) + assert await _responses_image_tokens(_responses_file_image("low")) == 85 + fetch.assert_not_awaited() # image_url honors the sibling detail instead of always defaulting to auto. image = Image.new("RGB", (512, 512), "red") @@ -558,12 +664,10 @@ async def test_estimate_image_tokens_from_input_detail_and_file_id() -> None: image.save(buffer, format="JPEG") data_url = "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode() - assert await estimate_image_tokens_from_input( - [{"type": "input_image", "image_url": data_url, "detail": "low"}] - ) == 85 - assert await estimate_image_tokens_from_input( - [{"type": "input_image", "image_url": data_url, "detail": "high"}] - ) == 85 + 170 # 512x512 = 1 tile + assert await _responses_image_tokens(_responses_image(data_url, "low")) == 85 + assert ( + await _responses_image_tokens(_responses_image(data_url, "high")) == 85 + 170 + ) # 512x512 = 1 tile def test_calculate_image_tokens_original_detail() -> None: @@ -579,14 +683,12 @@ def test_calculate_image_tokens_original_detail() -> None: assert _calculate_image_tokens(10_000, 10_000, "original") == 36_000 -async def test_estimate_image_tokens_from_input_original_detail() -> None: +async def test_responses_input_original_detail() -> None: import base64 from io import BytesIO from PIL import Image - from routstr.payment.helpers import estimate_image_tokens_from_input - image = Image.new("RGB", (2048, 2048), "red") buffer = BytesIO() image.save(buffer, format="JPEG") @@ -594,19 +696,75 @@ async def test_estimate_image_tokens_from_input_original_detail() -> None: # image_url: billed at the decoded original resolution (4,096 patches), # not the 765-token tile cap. - assert await estimate_image_tokens_from_input( - [{"type": "input_image", "image_url": data_url, "detail": "original"}] - ) == 4_916 + assert await _responses_image_tokens(_responses_image(data_url)) == 4_916 # file_id: dimensions unknown, so use the 30,000-patch worst case. - assert await estimate_image_tokens_from_input( - [{"type": "input_image", "file_id": "file-1", "detail": "original"}] - ) == 36_000 + assert await _responses_image_tokens(_responses_file_image("original")) == 36_000 # Explicit null detail behaves like the auto default (tiled math). - assert await estimate_image_tokens_from_input( - [{"type": "input_image", "file_id": "file-1", "detail": None}] - ) == 85 + (170 * 4) + assert await _responses_image_tokens(_responses_image(data_url, None)) == 85 + ( + 170 * 4 + ) + + +def test_responses_input_to_messages_shapes() -> None: + from routstr.payment.responses_input import ( + FILE_ID_URL_PREFIX, + count_input_images, + responses_input_to_messages, + ) + + input_data = [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "hi"}, + {"type": "input_image", "file_id": "file-1", "detail": "original"}, + ], + }, + {"type": "function_call_output", "call_id": "c1", "output": "out"}, + ] + messages = responses_input_to_messages(input_data) + assert messages is not None + assert messages[0]["role"] == "user" + parts = messages[0]["content"] + assert parts[0] == {"type": "text", "text": "hi"} + assert parts[1]["type"] == "image_url" + assert parts[1]["image_url"] == { + "url": f"{FILE_ID_URL_PREFIX}file-1", + "detail": "original", + } + assert messages[1]["role"] == "tool" + + # dict-form image_url: litellm nests it verbatim, so it is flattened first. + nested = responses_input_to_messages( + [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_image", + "image_url": { + "url": "https://x.test/a.jpg", + "detail": "original", + }, + } + ], + } + ] + ) + assert nested is not None + assert nested[0]["content"][0]["image_url"] == { + "url": "https://x.test/a.jpg", + "detail": "original", + } + + assert responses_input_to_messages("plain") == [ + {"role": "user", "content": "plain"} + ] + assert responses_input_to_messages(None) == [] + assert count_input_images(input_data) == 1 async def test_estimate_image_tokens_in_messages_original_detail() -> None: @@ -637,8 +795,6 @@ async def test_estimate_image_tokens_in_messages_original_detail() -> None: # 640x640 -> 20x20 = 400 patches -> ceil(400 * 1.2) = 480 tokens. assert await estimate_image_tokens_in_messages(messages) == 480 - # input_image parts inside messages honor their sibling detail and - # file_id through the Responses estimator as well. messages = [ { "role": "user",