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/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/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 776477e1..9f0eabd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,22 +6,22 @@ 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", + "sqlmodel>=0.0.42", # Python 3.14 deferred-annotation support + "httpx[socks]>=0.28.1", + "h11>=0.16", "greenlet>=3.2.1", "alembic>=1.13", "python-json-logger>=2.0.0", "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.55.0", + "litellm>=1.93.0,<1.94", # 1.93 is the first line supporting Python 3.14 ] [dependency-groups] @@ -87,3 +87,32 @@ disallow_untyped_decorators = true [tool.uv.sources] routstr = { workspace = true } + +# 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] +# 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.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", + "h11>=0.16.0", + "fastapi[standard-no-fastapi-cloud-cli]>=0.141", + "cryptography>=49.0.0", + "setuptools>=83.0.0", + "wheel>=0.46.2", +] + +# 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", + "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/auth.py b/routstr/auth.py index 76b421b1..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,28 +1536,6 @@ async def adjust_payment_for_tokens( return cost.dict() - case CostDataError() as error: - logger.error( - "Cost calculation error during payment adjustment - releasing reservation", - extra={ - "key_hash": key.hashed_key[:8] + "...", - "model": model, - "error_message": error.message, - "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, - } - }, - ) # All calculate_cost variants are handled above. raise AssertionError("Unreachable: unhandled calculate_cost result") diff --git a/routstr/cashu_compat.py b/routstr/cashu_compat.py new file mode 100644 index 00000000..02a56ca4 --- /dev/null +++ b/routstr/cashu_compat.py @@ -0,0 +1,108 @@ +"""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=``; 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 + 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 not distinct: + return None + if len(distinct) == 1: + return distinct.pop() + raise ValueError( + f"cannot represent proxies={proxies!r} as httpx 0.28 proxy=; " + "refusing to send proxied traffic direct" + ) + + +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 + 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) + + +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/core/exceptions.py b/routstr/core/exceptions.py index 360b810d..2fc34bb6 100644 --- a/routstr/core/exceptions.py +++ b/routstr/core/exceptions.py @@ -34,6 +34,26 @@ 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. + + ``details`` carries optional structured, redaction-safe context and is + forwarded to the client by ``create_upstream_error_response``. + """ + + def __init__(self, message: str, details: dict[str, object] | None = None): + super().__init__( + message, + status_code=504, + code="UPSTREAM_TIMEOUT", + details=details, + ) + + 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/core/logging.py b/routstr/core/logging.py index 61c96607..0fba407b 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -184,6 +184,18 @@ class RequestIdFilter(logging.Filter): return True +class ClientAppFilter(logging.Filter): + """Attach request-local app attribution to log records.""" + + def filter(self, record: logging.LogRecord) -> bool: + # 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 + 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 +335,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 +343,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 +352,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 +363,7 @@ def setup_logging() -> None: "filters": { "version_filter": {"()": VersionFilter}, "request_id_filter": {"()": RequestIdFilter}, + "client_app_filter": {"()": ClientAppFilter}, "security_filter": {"()": SecurityFilter}, }, "handlers": { @@ -364,7 +377,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/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/middleware.py b/routstr/core/middleware.py index 63d43888..d4ddfb8f 100644 --- a/routstr/core/middleware.py +++ b/routstr/core/middleware.py @@ -2,8 +2,10 @@ 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 from starlette.middleware.base import BaseHTTPMiddleware from .logging import get_logger @@ -13,6 +15,41 @@ logger = get_logger(__name__) # Context variable to store request ID across async context request_id_context: ContextVar[str | None] = ContextVar("request_id") +client_app_context: ContextVar[str | None] = ContextVar("client_app") + +UNKNOWN_CLIENT_APP = "unknown" + +# Prefer OpenRouter app headers, then browser and SDK fallbacks. +_CLIENT_APP_HEADERS: tuple[str, ...] = ( + "x-title", + "http-referer", + "referer", + "user-agent", +) + +# Limit untrusted header data repeated in every log record. +_CLIENT_APP_MAX_LENGTH = 120 + + +def client_app_from_headers(headers: Headers) -> str: + 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 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 + # Methods that are never logged: HEAD requests are health probes from # monitoring/load balancers, OPTIONS are CORS preflights — both are framework @@ -71,6 +108,10 @@ class LoggingMiddleware(BaseHTTPMiddleware): # Set request ID in context for logging token = request_id_context.set(request_id) + client_app_token = client_app_context.set( + client_app_from_headers(request.headers) + ) + path = request.url.path should_log = _should_log(request.method, path) @@ -130,6 +171,12 @@ 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", + "request_id_context", +] 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/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/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/routstr/payment/helpers.py b/routstr/payment/helpers.py index 0c012be8..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__) @@ -179,7 +185,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 @@ -225,17 +236,28 @@ 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 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", + extra={ + "model": model, + "image_tokens": image_tokens, + }, + ) + prompt_tokens += image_tokens if prompt_tokens > 0: estimated_prompt_delta_sats = ( @@ -244,21 +266,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", @@ -431,15 +471,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: @@ -492,7 +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"): + if content_type == "input_image": + content_item = input_image_part_to_image_url(content_item) + elif content_type != "image_url": continue image_url_data = content_item.get("image_url") @@ -504,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 @@ -512,56 +585,67 @@ 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 +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) + + +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") + + +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( error_type: str, message: str, 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/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/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/routstr/proxy.py b/routstr/proxy.py index 678be928..1f517779 100644 --- a/routstr/proxy.py +++ b/routstr/proxy.py @@ -262,14 +262,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/routstr/upstream/base.py b/routstr/upstream/base.py index 4f19ff92..217cf9fc 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 @@ -154,6 +155,35 @@ def _inject_cost_response_headers( headers["X-Routstr-Cost-Usd"] = str(total_usd) +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]]: """Split a buffered SSE body into ``(field_lines, data)`` pairs. @@ -463,6 +493,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, @@ -654,8 +712,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: @@ -769,6 +826,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 @@ -3729,23 +3789,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 @@ -3828,6 +3882,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 +3914,7 @@ class BaseUpstreamProvider: usage_data = None model = None cost_data: CostData | MaxCostData | None = None + usage_estimator = MissingUsageEstimator(request_body, model_obj) lines = content_str.strip().split("\n") for line in lines: @@ -3886,87 +3942,108 @@ class BaseUpstreamProvider: usage_data = merged except json.JSONDecodeError: continue + usage_estimator.observe(data_json) - if usage_data and model: - logger.debug( - "Found usage data in streaming response", + if not usage_data: + usage_data = usage_estimator.estimated_usage(model) + if usage_data: + logger.warning( + "No usage in streaming response, billing from local token estimate", + extra={ + "model": model, + "amount": amount, + "unit": unit, + "estimated_usage": usage_data, + }, + ) + + 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 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 + 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, - }, - ) - for i, line in enumerate(lines): if line.startswith("data: "): try: @@ -4005,6 +4082,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. @@ -4026,9 +4104,20 @@ class BaseUpstreamProvider: try: response_json = json.loads(content_str) self._apply_provider_field(response_json) + _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 @@ -4160,6 +4249,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. @@ -4206,6 +4296,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( @@ -4217,6 +4308,7 @@ class BaseUpstreamProvider: mint, request_id=request_id, model_obj=model_obj, + request_body=request_body, ) except Exception as e: @@ -4414,6 +4506,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) @@ -4710,6 +4803,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) @@ -4761,6 +4855,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. @@ -4808,6 +4903,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( @@ -4819,6 +4915,7 @@ class BaseUpstreamProvider: mint, request_id=request_id, model_obj=model_obj, + request_body=request_body, ) except Exception as e: @@ -4847,6 +4944,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. @@ -4873,6 +4971,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]": @@ -4883,6 +4982,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. @@ -4898,19 +4998,18 @@ class BaseUpstreamProvider: elif not model and payload.get("model"): 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. - logger.warning( - "No usage in streaming Responses API response — refunding instead of charging the authorized max", - extra={ - "model": model, - "amount": amount, - "unit": unit, - "max_cost_msats": max_cost_for_model, - }, - ) + if not 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", @@ -4928,6 +5027,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 @@ -5033,6 +5140,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( @@ -5043,9 +5151,20 @@ class BaseUpstreamProvider: try: response_json = json.loads(content_str) self._apply_provider_field(response_json) + _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) @@ -5319,22 +5438,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} ) ( @@ -5343,23 +5448,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/count_tokens.py b/routstr/upstream/count_tokens.py index 8ebeb6d9..ea067dd1 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,12 +208,26 @@ 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. 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/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py index 3f749795..61cd219c 100644 --- a/routstr/upstream/ehbp.py +++ b/routstr/upstream/ehbp.py @@ -31,7 +31,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, @@ -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 + media_type = ct.split(";", 1)[0].strip() + if media_type != "application/problem+json": + 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 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. + """ + return Response( + content=resp.body, + status_code=422, + headers={"content-type": "application/problem+json"}, + 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( @@ -1042,6 +1133,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/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/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/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/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_cashu_httpx_compat.py b/tests/unit/test_cashu_httpx_compat.py new file mode 100644 index 00000000..3e0162d7 --- /dev/null +++ b/tests/unit/test_cashu_httpx_compat.py @@ -0,0 +1,126 @@ +"""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 +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"), + ], +) +def test_single_proxy_collapses_cashu_mappings( + proxies: object, expected: str | None +) -> None: + 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( + proxies=proxies, base_url="http://mint.test" + ) as client: + 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() + + 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: + install_cashu_httpx_shim() + patched = v1_api.httpx + install_cashu_httpx_shim() + 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/tests/unit/test_client_app_logging.py b/tests/unit/test_client_app_logging.py new file mode 100644 index 00000000..107f8c2c --- /dev/null +++ b/tests/unit/test_client_app_logging.py @@ -0,0 +1,198 @@ +"""Tests for client-app identification in request logging.""" + +import asyncio +import logging + +import pytest +from fastapi import FastAPI, Request, Response +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 _record() -> logging.LogRecord: + return logging.LogRecord( + name="routstr.test", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="test", + args=None, + exc_info=None, + ) + + +@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 + + +@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 + + +def test_control_characters_are_stripped() -> None: + headers = Headers({"user-agent": "evil-app\x1b[0m fake INFO line"}) + assert client_app_from_headers(headers) == "evil-app[0m fake INFO line" + + +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 == "Goose" # type: ignore[attr-defined] + finally: + 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 + assert record.client_app == UNKNOWN_CLIENT_APP # type: ignore[attr-defined] + + +def test_handler_logs_carry_client_app(caplog: pytest.LogCaptureFixture) -> None: + 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: + TestClient(app).get("/whoami", headers={"X-Title": "Goose"}) + finally: + handler_logger.removeHandler(caplog.handler) + + record = next(r for r in caplog.records if r.name == "routstr.test.handler") + assert record.client_app == "Goose" # type: ignore[attr-defined] 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_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_ehbp_timeout.py b/tests/unit/test_ehbp_timeout.py new file mode 100644 index 00000000..c9e749cb --- /dev/null +++ b/tests/unit/test_ehbp_timeout.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from routstr.core.exceptions import EhbpTimeoutError, UpstreamError +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 + + +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 + 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" + 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(), + 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" + 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_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/tests/unit/test_payment_helpers.py b/tests/unit/test_payment_helpers.py index 2eab19c1..343d9cdc 100644 --- a/tests/unit/test_payment_helpers.py +++ b/tests/unit/test_payment_helpers.py @@ -288,3 +288,519 @@ 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_discounted_max_cost_counts_responses_input_images() -> None: + 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 + + +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}], + } + ] + + +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 PIL import Image + + image = Image.new("RGB", (512, 512), "red") + buffer = BytesIO() + image.save(buffer, format="JPEG") + image_bytes = buffer.getvalue() + + with patch( + "routstr.payment.helpers._fetch_image_from_url", + new=AsyncMock(return_value=image_bytes), + ): + # 256 patches * 1.2 + assert ( + await _responses_image_tokens(_responses_image("https://x.test/i.jpg")) + == 308 + ) + + with patch( + "routstr.payment.helpers._fetch_image_from_url", + new=AsyncMock(return_value=None), + ): + assert ( + await _responses_image_tokens(_responses_image("https://x.test/i.jpg")) + == 36_000 + ) + + +async def test_broken_data_url_reserves_declared_detail_worst_case() -> None: + from routstr.payment.helpers import estimate_image_tokens_in_messages + + 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 + ) + + 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: + """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.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_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 ( + 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) + + # 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 + ) + 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 + + +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 + + # 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. + 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") + buffer = BytesIO() + image.save(buffer, format="JPEG") + data_url = "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode() + + 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: + 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_responses_input_original_detail() -> None: + import base64 + from io import BytesIO + + from PIL import Image + + 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 _responses_image_tokens(_responses_image(data_url)) == 4_916 + + # file_id: dimensions unknown, so use the 30,000-patch worst case. + assert await _responses_image_tokens(_responses_file_image("original")) == 36_000 + + # Explicit null detail behaves like the auto default (tiled math). + 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: + """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 + + messages = [ + { + "role": "user", + "content": [ + {"type": "input_image", "file_id": "file-1", "detail": "original"} + ], + } + ] + assert await estimate_image_tokens_in_messages(messages) == 36_000 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"), diff --git a/tests/unit/test_reasoning_effort.py b/tests/unit/test_reasoning_effort.py new file mode 100644 index 00000000..55d3b980 --- /dev/null +++ b/tests/unit/test_reasoning_effort.py @@ -0,0 +1,264 @@ +"""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.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 == [ + "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) + assert out is not None + 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" diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py index d5330145..1319e5a0 100644 --- a/tests/unit/test_tinfoil_integration.py +++ b/tests/unit/test_tinfoil_integration.py @@ -11,18 +11,25 @@ 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 ( TinfoilModel, TinfoilUpstreamProvider, ) +from routstr.upstream.tinfoil_trailer import TrailerResponse # --------------------------------------------------------------------------- # parse_tinfoil_usage_metrics @@ -106,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, @@ -285,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" @@ -390,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( @@ -427,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( @@ -492,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( @@ -533,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( @@ -570,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( @@ -594,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 @@ -612,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( @@ -650,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 @@ -666,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: @@ -747,3 +762,276 @@ 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_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, + 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_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 "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 is not None + 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" diff --git a/tests/unit/test_tinfoil_trailer.py b/tests/unit/test_tinfoil_trailer.py index ef1c96f1..3e4d3e0f 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,62 @@ 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 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" 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..b2510d00 --- /dev/null +++ b/tests/unit/test_x_cashu_missing_usage.py @@ -0,0 +1,304 @@ +"""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 logging +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, + 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) + 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=unit, + 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: 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"}, + ] + _, 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 + + +@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" + + +@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" + ) + 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"] 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 2a0fe1ce..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==} @@ -398,12 +395,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': @@ -418,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] @@ -579,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] @@ -1433,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==} @@ -1855,8 +1856,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1871,8 +1872,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 @@ -1896,8 +1897,8 @@ 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==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -2068,8 +2069,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==} @@ -2141,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' @@ -2627,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: @@ -2820,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: @@ -2841,8 +2842,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==} @@ -3193,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'} @@ -3218,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==} @@ -3408,8 +3410,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 +3578,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 @@ -3675,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 @@ -3716,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: @@ -3753,13 +3750,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': {} @@ -3767,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': @@ -3893,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': @@ -4758,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 @@ -4969,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 @@ -5179,7 +5184,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.0: {} + baseline-browser-mapping@2.11.21: {} brace-expansion@1.1.18: dependencies: @@ -5194,13 +5199,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: @@ -5223,7 +5228,7 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001776: {} + caniuse-lite@1.0.30001810: {} chalk@4.1.2: dependencies: @@ -5384,7 +5389,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: @@ -5571,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)) @@ -5740,7 +5745,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 @@ -5867,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: {} @@ -6020,7 +6025,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 is-callable@1.2.7: {} @@ -6126,7 +6131,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -6279,31 +6284,32 @@ 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.36: {} + node-releases@2.0.54: {} object-assign@4.1.1: {} @@ -6660,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: {} @@ -6689,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: @@ -6968,9 +6972,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 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" + ] } diff --git a/uv.lock b/uv.lock index 462fd619..6ca3e9ed 100644 --- a/uv.lock +++ b/uv.lock @@ -3,9 +3,28 @@ 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 = "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" }, +] +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" }, +] +build-constraints = [{ name = "scikit-build-core", specifier = "<0.10" }] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -172,6 +191,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 +402,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 +482,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 +802,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 +926,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 = "httpx" }, + { name = "fastapi-cli", extra = ["standard-no-fastapi-cloud-cli"] }, + { name = "httpx", extra = ["socks"] }, { 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"] }, ] @@ -1070,92 +1182,115 @@ 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]] 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]] @@ -1192,15 +1327,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]] @@ -1234,18 +1369,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] @@ -1261,7 +1395,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" }, @@ -1284,14 +1418,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]] @@ -1418,13 +1552,13 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.0" +version = "1.93.2" 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" }, @@ -1434,9 +1568,36 @@ 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/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/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/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]] @@ -1696,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]] @@ -1718,7 +1883,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, @@ -2110,6 +2275,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,34 +2599,34 @@ 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"] }, { name = "litellm" }, { name = "marshmallow" }, { name = "mdurl" }, - { name = "nostr" }, + { name = "nostr-sdk" }, { name = "openai" }, { name = "pillow" }, { name = "python-json-logger" }, @@ -2459,7 +2637,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "aiohttp" }, - { name = "httpx" }, + { name = "httpx", extra = ["socks"] }, { name = "mypy" }, { name = "openai" }, { name = "psutil" }, @@ -2476,18 +2654,18 @@ 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 = "httpx", extras = ["socks"], specifier = ">=0.25.2" }, - { name = "litellm", specifier = ">=1.55.0" }, + { name = "h11", specifier = ">=0.16" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, + { 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" }, ] @@ -2639,33 +2817,13 @@ 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 = "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]] @@ -2722,6 +2880,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" } @@ -2760,27 +2919,29 @@ 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]] 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 +3077,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 +3305,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]]