From 9c7a12808a719ae9c8aeceb94225d309b6bcbfa4 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Sun, 23 Aug 2026 12:12:19 +0200
Subject: [PATCH 013/120] fix: settle missing-usage at reserved max, add
disconnect finalize test
---
routstr/payment/cost_calculation.py | 34 ++++++--
.../test_free_response_stale_reservation.py | 41 +++++++++
.../test_streaming_billing_finalization.py | 86 +++++++++++++++++++
3 files changed, 156 insertions(+), 5 deletions(-)
diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py
index 7dbab415..86495728 100644
--- a/routstr/payment/cost_calculation.py
+++ b/routstr/payment/cost_calculation.py
@@ -69,6 +69,30 @@ def _empty_cost(cls: type[CostData] = CostData) -> CostData:
)
+def _unmeasured_cost(max_cost: int) -> MaxCostData:
+ """Build the bounded fallback for a response whose usage cannot be measured.
+
+ Missing usage must NOT settle at zero — that hands out free inference. The
+ request was authorized up to ``max_cost`` (the reservation), so the safe,
+ bounded settlement is to charge exactly that. Token components stay zero
+ because they are genuinely unknown; ``total_msats`` carries the authorized
+ max so max-cost finalization debits the reservation instead of nothing.
+ """
+ return MaxCostData(
+ base_msats=0,
+ input_msats=0,
+ output_msats=0,
+ total_msats=max(0, max_cost),
+ total_usd=0.0,
+ input_tokens=0,
+ output_tokens=0,
+ cache_read_input_tokens=0,
+ cache_creation_input_tokens=0,
+ cache_read_msats=0,
+ cache_creation_msats=0,
+ )
+
+
async def calculate_cost(
response_data: dict,
max_cost: int,
@@ -109,10 +133,10 @@ async def calculate_cost(
if usage is None:
logger.warning(
- "No usage data in response — billing at MaxCostData with zero "
- "tokens. Dashboard will show this request as `(0+0)`. Most "
- "common cause: upstream stream did not include a final usage "
- "chunk (OpenAI-compat backends require "
+ "No usage data in response — settling at the reserved max cost "
+ "(bounded fallback), not zero. Dashboard will show this request "
+ "as `(0+0)` tokens. Most common cause: upstream stream did not "
+ "include a final usage chunk (OpenAI-compat backends require "
"`stream_options.include_usage=true`).",
extra={
"max_cost_msats": max_cost,
@@ -122,7 +146,7 @@ async def calculate_cost(
else None,
},
)
- return _empty_cost(MaxCostData)
+ return _unmeasured_cost(max_cost)
usage_data = response_data.get("usage") or {}
if not isinstance(usage_data, dict):
diff --git a/tests/integration/test_free_response_stale_reservation.py b/tests/integration/test_free_response_stale_reservation.py
index 80f0e294..77b4579b 100644
--- a/tests/integration/test_free_response_stale_reservation.py
+++ b/tests/integration/test_free_response_stale_reservation.py
@@ -91,6 +91,47 @@ async def test_overrun_with_corrupted_aggregate_releases_without_charging(
assert reservation.release_id not in auth._reservation_heartbeats
+@pytest.mark.asyncio
+async def test_missing_usage_settles_at_reservation_not_zero(
+ integration_session: AsyncSession,
+) -> None:
+ """A response with no usable usage data must settle at the reserved max
+ cost (bounded fallback), never at zero — otherwise the request is free
+ inference. Exercises the REAL calculate_cost, no patching."""
+ from routstr.auth import (
+ adjust_payment_for_tokens,
+ get_reservation_snapshot,
+ pay_for_request,
+ )
+
+ reserved = 4_000
+ key = _make_key(balance=10_000, reserved=0)
+ key_hash = key.hashed_key
+ integration_session.add(key)
+ await integration_session.commit()
+ await pay_for_request(key, reserved, integration_session)
+ reservation = await get_reservation_snapshot(key, integration_session)
+
+ # No `usage` key at all — the upstream stream dropped its final usage chunk.
+ response_data = {"model": "test-model"}
+ result = await adjust_payment_for_tokens(
+ key,
+ response_data,
+ integration_session,
+ reserved,
+ reservation_snapshot=reservation,
+ )
+
+ # Charged the authorized max, not zero.
+ assert result["charged_msats"] == reserved
+ integration_session.expunge_all()
+ key_row = await integration_session.get(ApiKey, key_hash)
+ assert key_row is not None
+ assert key_row.total_spent == reserved, "missing usage must not be free"
+ assert key_row.balance == 10_000 - reserved
+ assert key_row.reserved_balance == 0
+
+
@pytest.mark.asyncio
async def test_free_response_path_closed_end_to_end(
integration_session: AsyncSession,
diff --git a/tests/unit/test_streaming_billing_finalization.py b/tests/unit/test_streaming_billing_finalization.py
index 51ab2ccf..94ef65a4 100644
--- a/tests/unit/test_streaming_billing_finalization.py
+++ b/tests/unit/test_streaming_billing_finalization.py
@@ -1,9 +1,11 @@
import asyncio
import json
from collections.abc import AsyncGenerator
+from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
+from fastapi import BackgroundTasks
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlmodel import SQLModel
@@ -518,3 +520,87 @@ async def test_cross_key_reservation_snapshot_is_rejected_without_mutation() ->
assert second.reserved_balance == 0
await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> None:
+ """A client that aborts the socket mid-stream must not leak its reservation.
+
+ Starlette closes the response generator (``aclose``) on disconnect, whose
+ ``finally`` schedules the background finalizer. That finalizer must settle
+ the reservation (charge the reserved max — usage is unknown), reach a
+ terminal durable state, and stop the lease heartbeat so the sweeper is not
+ needed. Driven against a real engine and the real finalizer; the socket
+ abort is modelled deterministically with ``aclose`` (the exact hook
+ Starlette invokes) to keep the test CI-stable.
+ """
+ engine = await _engine()
+ provider = BaseUpstreamProvider(
+ base_url="https://api.example.com", api_key="test-key", provider_fee=1.0
+ )
+
+ async with AsyncSession(engine, expire_on_commit=False) as session:
+ key = ApiKey(hashed_key="disconnect-key", balance=1_000)
+ session.add(key)
+ await session.commit()
+ await pay_for_request(key, 500, session)
+ snapshot = await get_reservation_snapshot(key, session)
+
+ assert snapshot.release_id in auth_module._reservation_heartbeats
+
+ async def aiter_bytes() -> AsyncGenerator[bytes, None]:
+ # A live stream that never sends a usage chunk or [DONE]; the client
+ # disconnects after the first delta.
+ yield b'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n'
+ yield b'data: {"choices":[{"delta":{"content":" there"}}]}\n\n'
+
+ upstream_response = MagicMock(
+ status_code=200, headers={"content-type": "text/event-stream"}
+ )
+ upstream_response.aiter_bytes = aiter_bytes
+
+ background_tasks = BackgroundTasks()
+ try:
+ with (
+ patch(
+ "routstr.upstream.base.create_session",
+ side_effect=lambda: AsyncSession(engine, expire_on_commit=False),
+ ),
+ patch(
+ "routstr.upstream.base.adjust_payment_for_tokens",
+ auth_module.adjust_payment_for_tokens,
+ ),
+ ):
+ response = await provider.handle_streaming_chat_completion(
+ response=upstream_response,
+ key=key,
+ max_cost_for_model=500,
+ background_tasks=background_tasks,
+ reservation_snapshot=snapshot,
+ )
+ iterator = cast(
+ AsyncGenerator[bytes, None], response.body_iterator
+ )
+ await iterator.__anext__() # first chunk reaches the client
+ await iterator.aclose() # client aborts the socket here
+
+ # Starlette runs the response's background tasks after the abort.
+ for task in background_tasks.tasks:
+ await task()
+ finally:
+ await auth_module._stop_reservation_heartbeat(snapshot.release_id)
+
+ async with AsyncSession(engine, expire_on_commit=False) as session:
+ final_key = await session.get(ApiKey, "disconnect-key")
+ record = await session.get(ReservationRelease, snapshot.release_id)
+
+ assert final_key is not None
+ # The reservation reached a single terminal outcome; funds are not locked.
+ assert record is not None and record.status in {"charged", "released"}
+ assert final_key.reserved_balance == 0
+ # Unknown usage settles at the reserved max, never free.
+ assert final_key.total_spent == 500
+ assert final_key.balance == 500
+ # The heartbeat is gone — no forever-renewing task on an abandoned request.
+ assert snapshot.release_id not in auth_module._reservation_heartbeats
+ await engine.dispose()
From ceeb99f640144788862a03ab021bdecd14b25253 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Sun, 23 Aug 2026 13:30:57 +0200
Subject: [PATCH 014/120] better cost stimation
---
routstr/payment/helpers.py | 41 ++++++++++-
tests/unit/test_payment_helpers.py | 107 +++++++++++++++++++++++++++++
2 files changed, 146 insertions(+), 2 deletions(-)
diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py
index 702ab527..4d4696f2 100644
--- a/routstr/payment/helpers.py
+++ b/routstr/payment/helpers.py
@@ -196,9 +196,13 @@ async def calculate_discounted_max_cost(
adjusted = max_cost_for_model
- if messages := body.get("messages"):
- prompt_tokens = estimate_tokens(messages)
+ messages = body.get("messages")
+ # Estimated over the whole body: a discount driven by message text alone lets
+ # a caller hide prompt weight elsewhere, shrink the reservation, and be billed
+ # for work the reservation never covered.
+ prompt_tokens = estimate_prompt_tokens(body)
+ if isinstance(messages, list):
image_tokens = await estimate_image_tokens_in_messages(messages)
if image_tokens > 0:
logger.debug(
@@ -210,6 +214,7 @@ async def calculate_discounted_max_cost(
)
prompt_tokens += image_tokens
+ if prompt_tokens > 0:
estimated_prompt_delta_sats = (
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
)
@@ -262,6 +267,38 @@ def estimate_tokens(messages: list) -> int:
return total // 3
+def _sum_string_chars(node: Any) -> int:
+ """Recursively sum the length of every string in the tree, keys included.
+
+ Nothing is excluded. Keys count because JSON-schema property names are
+ forwarded to the provider, and no exclusion rule can be trusted here: every
+ part of the body is caller-controlled, so any carve-out (by key name or by
+ value shape) is a place to hide prompt weight for free. Inline image data is
+ therefore counted as text too, which only makes the discount smaller.
+ """
+ if isinstance(node, str):
+ return len(node)
+ if isinstance(node, dict):
+ return sum(
+ len(str(key)) + _sum_string_chars(value) for key, value in node.items()
+ )
+ if isinstance(node, list):
+ return sum(_sum_string_chars(item) for item in node)
+ return 0
+
+
+def estimate_prompt_tokens(body: dict) -> int:
+ """Conservatively estimate prompt tokens for the whole provider-bound body.
+
+ Unlike ``estimate_tokens`` (message text only), this walks every field, so
+ prompt weight hidden in tool schemas, tool-call arguments, ``system``, or
+ any field forwarded in future cannot escape the reservation estimate. It
+ over-estimates rather than under-estimates: the result only shrinks a
+ discount against a reservation that settlement later refunds.
+ """
+ return _sum_string_chars(body) // 3
+
+
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
"""Extract image dimensions from image bytes."""
try:
diff --git a/tests/unit/test_payment_helpers.py b/tests/unit/test_payment_helpers.py
index 6809d94c..e5dbf136 100644
--- a/tests/unit/test_payment_helpers.py
+++ b/tests/unit/test_payment_helpers.py
@@ -155,3 +155,110 @@ async def test_discounted_max_cost_floors_at_min_request_msat() -> None:
cost = await calculate_discounted_max_cost(150_000, body, model_obj)
assert cost == 1000
+
+
+def test_estimate_prompt_tokens_counts_every_string_in_the_body() -> None:
+ from routstr.payment.helpers import estimate_prompt_tokens, estimate_tokens
+
+ hidden = "x" * 3_000 # ~1000 tokens of prompt hidden from the text estimator
+ body: dict[str, Any] = {
+ "messages": [{"role": "user", "content": "hi"}],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "f",
+ "description": hidden,
+ "parameters": {"type": "object", "properties": {hidden: {}}},
+ },
+ }
+ ],
+ }
+
+ # The text-only estimator sees almost nothing; the conservative one sees it.
+ assert estimate_tokens(body["messages"]) < 10
+ assert estimate_prompt_tokens(body) >= 1_000
+
+ # No carve-out is exempt: neither a caller-chosen key name nor a caller-chosen
+ # value prefix can buy a discount, so both still count in full.
+ assert estimate_prompt_tokens({"tools": [{"data": hidden}]}) >= 1_000
+ assert estimate_prompt_tokens({"system": "data:" + hidden}) >= 1_000
+
+
+async def test_discount_cannot_be_dodged_by_hiding_prompt_in_tools() -> None:
+ """A large prompt moved from messages into tool schemas must reserve the
+ same cost — otherwise a caller undercharges by hiding weight from the
+ estimator."""
+ from routstr.payment.helpers import calculate_discounted_max_cost
+
+ pricing = Mock()
+ pricing.prompt = 0.5
+ pricing.completion = 0.01
+ pricing.max_prompt_cost = 100.0
+ pricing.max_completion_cost = 100.0
+
+ model_obj = Mock()
+ model_obj.sats_pricing = pricing
+ model_obj.top_provider = None
+ model_obj.context_length = None
+
+ big_text = "word " * 2_000
+ base = {"model": "test-model", "max_tokens": 10}
+ in_messages = {
+ **base,
+ "messages": [{"role": "user", "content": big_text}],
+ }
+ hiding_places = {
+ "tools": {
+ **base,
+ "messages": [{"role": "user", "content": "hi"}],
+ "tools": [
+ {"type": "function", "function": {"name": "f", "description": big_text}}
+ ],
+ },
+ # Anthropic forwards a top-level system prompt; it is billed like any other.
+ "system": {
+ **base,
+ "messages": [{"role": "user", "content": "hi"}],
+ "system": big_text,
+ },
+ # A key named like an image field must not win an image exclusion.
+ "image-named key": {
+ **base,
+ "messages": [{"role": "user", "content": "hi"}],
+ "tools": [{"function": {"parameters": {"data": big_text}}}],
+ },
+ # Nor may a caller-chosen "data:" prefix, in any field the body allows.
+ "data-prefixed content": {
+ **base,
+ "messages": [{"role": "user", "content": "data:" + big_text}],
+ },
+ "data-prefixed text block": {
+ **base,
+ "messages": [
+ {
+ "role": "user",
+ "content": [{"type": "text", "text": "data:" + big_text}],
+ }
+ ],
+ },
+ "data-prefixed system": {
+ **base,
+ "messages": [{"role": "user", "content": "hi"}],
+ "system": "data:" + big_text,
+ },
+ }
+
+ with (
+ patch.object(settings, "fixed_pricing", False),
+ patch.object(settings, "tolerance_percentage", 0),
+ patch.object(settings, "min_request_msat", 1000),
+ ):
+ cost_messages = await calculate_discounted_max_cost(
+ 150_000, in_messages, model_obj
+ )
+ for where, body in hiding_places.items():
+ cost = await calculate_discounted_max_cost(150_000, body, model_obj)
+ # Same prompt weight → at least the same reservation, never the floor.
+ assert cost >= cost_messages, where
+ assert cost > 1000, where
From 30a4a393933fc798c3f26ff0e8d74ca2d7cf6c0e Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Sun, 23 Aug 2026 14:25:26 +0200
Subject: [PATCH 015/120] guard spend-shaping params from strip to prevent
overcharge
---
routstr/upstream/request_correction.py | 33 ++++++++++++++++++++++---
tests/unit/test_request_correction.py | 34 +++++++++++++++++++++++++-
2 files changed, 63 insertions(+), 4 deletions(-)
diff --git a/routstr/upstream/request_correction.py b/routstr/upstream/request_correction.py
index c2ea5b1d..d959a015 100644
--- a/routstr/upstream/request_correction.py
+++ b/routstr/upstream/request_correction.py
@@ -84,13 +84,33 @@ def extract_error_message(response: Response) -> str:
return ""
-def strip_unsupported_param(
- body: dict, error_message: str
-) -> tuple[dict, str] | None:
+# Spend-shaping fields bound how much work — and therefore cost — the upstream
+# may perform. The reservation was priced with these caps in place; dropping one
+# and retrying would let the request run uncapped (or fan out) and bill above the
+# caller's authorization. When the upstream names one of these, decline the strip
+# and let the error propagate. Matched case-insensitively.
+_SPEND_SHAPING_PARAMS = frozenset(
+ {
+ "max_tokens",
+ "max_completion_tokens",
+ "max_output_tokens",
+ "max_tokens_to_sample",
+ "n",
+ "best_of",
+ }
+)
+
+
+def strip_unsupported_param(body: dict, error_message: str) -> tuple[dict, str] | None:
"""Drop a top-level param the upstream named as unsupported/deprecated.
Returns ``(new_body, param)`` (a new dict, original untouched) when the
error names a top-level param present in the body, otherwise ``None``.
+
+ Spend-shaping fields (output caps, fan-out counts) are never stripped:
+ removing one after the reservation was priced would uncap the retry and
+ overcharge. When the upstream names such a field, decline so the original
+ error propagates rather than silently resizing the request's cost.
"""
match = _UNSUPPORTED_PARAM_RE.search(error_message)
if not match:
@@ -98,6 +118,13 @@ def strip_unsupported_param(
param = match.group("param")
if param not in body:
return None
+ if param.lower() in _SPEND_SHAPING_PARAMS:
+ logger.warning(
+ "Upstream rejected spend-shaping param '%s'; refusing to strip it "
+ "(retrying uncapped would overcharge) — surfacing the error",
+ param,
+ )
+ return None
new_body = {k: v for k, v in body.items() if k != param}
return new_body, param
diff --git a/tests/unit/test_request_correction.py b/tests/unit/test_request_correction.py
index 903fe16e..3b1110a2 100644
--- a/tests/unit/test_request_correction.py
+++ b/tests/unit/test_request_correction.py
@@ -63,7 +63,9 @@ class TestCorrectRequest:
assert correct_request(_body(temperature=1), "", set()) is None
def test_returns_none_on_non_object_body(self) -> None:
- assert correct_request(b"[1, 2, 3]", "`temperature` is deprecated", set()) is None
+ assert (
+ correct_request(b"[1, 2, 3]", "`temperature` is deprecated", set()) is None
+ )
def test_deprecated_model_name_is_not_stripped_as_param(self) -> None:
"""A 'model is deprecated' error must not strip an unrelated body field.
@@ -106,6 +108,36 @@ class TestStripUnsupportedParam:
def test_declines_when_no_match(self) -> None:
assert strip_unsupported_param({"temperature": 1}, "nope") is None
+ def test_never_strips_spend_shaping_params(self) -> None:
+ # Stripping an output cap after the reservation was priced would let
+ # the retry run uncapped and overcharge — the corrector must decline so
+ # the upstream error propagates instead.
+ for param in (
+ "max_tokens",
+ "max_completion_tokens",
+ "max_output_tokens",
+ "max_tokens_to_sample",
+ "n",
+ "best_of",
+ ):
+ body = {"model": "m", param: 4, "messages": []}
+ assert (
+ strip_unsupported_param(body, f"`{param}` is not supported") is None
+ ), param
+ # And through the full pipeline entry point.
+ assert (
+ correct_request(
+ json.dumps(body).encode(),
+ f"`{param}` is not supported",
+ set(),
+ )
+ is None
+ ), param
+
+ def test_spend_shaping_guard_is_case_insensitive(self) -> None:
+ body = {"model": "m", "Max_Tokens": 4}
+ assert strip_unsupported_param(body, "`Max_Tokens` is deprecated") is None
+
class TestExtractErrorMessage:
def test_extracts_nested_error_message(self) -> None:
From 0217002ea19d30bbb24e861daebd872f043ba84e Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Sun, 23 Aug 2026 16:46:00 +0200
Subject: [PATCH 016/120] Gate proxy forwarding behind a segment-anchored API
path allowlist
---
routstr/proxy.py | 60 +++++++++--
.../integration/test_proxy_post_endpoints.py | 54 ++++++++++
tests/unit/test_proxy_path_allowlist.py | 99 +++++++++++++++++++
.../test_proxy_tinfoil_attestation_routing.py | 21 +++-
4 files changed, 224 insertions(+), 10 deletions(-)
create mode 100644 tests/unit/test_proxy_path_allowlist.py
diff --git a/routstr/proxy.py b/routstr/proxy.py
index 4542d008..c367d9a2 100644
--- a/routstr/proxy.py
+++ b/routstr/proxy.py
@@ -236,6 +236,52 @@ _API_PATH_PREFIXES = (
"attestation",
)
+# Split the allowlist by spelling so bare tokens anchor to a path segment.
+# A slash-terminated prefix ("v1/") is already segment-anchored under
+# startswith. A bare token ("models") must match a whole segment — exactly or
+# followed by "/" — so "modelsdump" / "attestationadmin" cannot slip through.
+_API_SLASH_PREFIXES = tuple(p for p in _API_PATH_PREFIXES if p.endswith("/"))
+_API_BARE_PREFIXES = tuple(p for p in _API_PATH_PREFIXES if not p.endswith("/"))
+
+
+def _is_ambiguously_spelled_path(path: str) -> bool:
+ """Reject paths whose spelling could resolve somewhere the allowlist did not.
+
+ ``{path:path}`` arrives percent-decoded, so a client that sent ``%2e%2e`` or
+ ``%2f`` shows up here as ``..`` / ``/``. Dot segments, backslashes, duplicate
+ or leading separators, NUL bytes, and any residual encoded separator are
+ treated as unsafe: they let a caller walk off the canonical API surface (and
+ onto a sensitive upstream endpoint) even though the literal prefix check
+ would pass. Reject rather than trying to rewrite the path.
+ """
+ if not path or path != path.strip() or path.startswith("/"):
+ return True
+ if "\x00" in path or "\\" in path:
+ return True
+ # A single trailing slash is canonical (e.g. "attestation/"); ignore it,
+ # then no remaining segment may be empty (covers "//") or a dot segment.
+ core = path[:-1] if path.endswith("/") else path
+ if any(segment in ("", ".", "..") for segment in core.split("/")):
+ return True
+ lowered = path.lower()
+ return "%2e" in lowered or "%2f" in lowered or "%5c" in lowered
+
+
+def _forwarding_allowed(path: str, is_ehbp: bool) -> bool:
+ """Gate which paths may reach an upstream at all.
+
+ The provider credential is attached during forwarding, so an unknown path
+ must never be forwarded on the caller's say-so. A path must resolve to a
+ known API prefix; EHBP requests are identified by header and carry their own
+ encrypted contract. Endpoint permission is derived from this allowlist, not
+ from the client-supplied path.
+ """
+ if is_ehbp:
+ return True
+ if path.startswith(_API_SLASH_PREFIXES):
+ return True
+ return any(path == p or path.startswith(p + "/") for p in _API_BARE_PREFIXES)
+
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
async def proxy(
@@ -255,14 +301,17 @@ async def proxy(
async def _proxy(
request: Request, path: str, session: AsyncSession
) -> Response | StreamingResponse:
- # GET requests must hit a known API prefix; otherwise return a 404 (HTML
- # for browsers, JSON for API clients). POST requests are always forwarded
- # so that OpenAI-style endpoints work with or without the `v1/` prefix
- # (e.g. `/chat/completions` as well as `/v1/chat/completions`).
- if request.method == "GET" and not path.startswith(_API_PATH_PREFIXES):
+ # Screen the path before any routing decision: reject ambiguous spellings,
+ # then require a known API prefix so nothing unknown is forwarded with the
+ # provider credential attached.
+ if _is_ambiguously_spelled_path(path):
return build_not_found_response(request, path)
headers = dict(request.headers)
+ is_ehbp = "ehbp-encapsulated-key" in headers
+
+ if not _forwarding_allowed(path, is_ehbp):
+ return build_not_found_response(request, path)
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
request_body = await request.body()
@@ -272,7 +321,6 @@ async def _proxy(
# extract the model id, so the SDK sends it in X-Routstr-Model. Forward the
# raw encrypted body to the upstream's /private/ endpoint and stream the
# encrypted response back untouched — the SDK's SecureClient decrypts it.
- is_ehbp = "ehbp-encapsulated-key" in headers
if is_ehbp:
request_body_dict = {}
model_id = headers.get("x-routstr-model", "")
diff --git a/tests/integration/test_proxy_post_endpoints.py b/tests/integration/test_proxy_post_endpoints.py
index 8d5fab36..9765ffd4 100644
--- a/tests/integration/test_proxy_post_endpoints.py
+++ b/tests/integration/test_proxy_post_endpoints.py
@@ -289,8 +289,62 @@ async def test_proxy_post_unauthorized_access(integration_client: AsyncClient) -
assert response.status_code in [400, 401]
+@pytest.mark.integration
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "bad_path",
+ [
+ "internal/admin", # unknown endpoint, no API prefix
+ "v1/../admin", # traversal onto a sibling path
+ "v1//models", # duplicate separator
+ "%2e%2e/secret", # encoded dot segment
+ ],
+)
+async def test_authenticated_post_to_unknown_path_is_rejected(
+ authenticated_client: AsyncClient, bad_path: str
+) -> None:
+ """An authenticated POST to an unknown/traversal path must be rejected at
+ the edge (404) and never forwarded — the provider credential must not reach
+ an endpoint the caller merely spelled into the URL. If the guard let it
+ through, forwarding would raise and this would not be a clean 404."""
+ with patch(
+ "routstr.upstream.base.BaseUpstreamProvider.forward_request",
+ AsyncMock(side_effect=AssertionError("must not forward unknown path")),
+ ):
+ response = await authenticated_client.post(
+ f"/{bad_path}",
+ json={"model": "gpt-3.5-turbo", "messages": []},
+ )
+ assert response.status_code == 404
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_get_unknown_path_is_rejected(integration_client: AsyncClient) -> None:
+ """A GET to an unknown (no API prefix) path is rejected at the edge."""
+ response = await integration_client.get("/internal/admin")
+ assert response.status_code == 404
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_ambiguous_path_is_rejected_before_ehbp_exemption(
+ integration_client: AsyncClient,
+) -> None:
+ """The ambiguous-spelling screen runs before the EHBP header exemption, so
+ an EHBP request cannot smuggle a traversal path past it."""
+ # Percent-encoded so the traversal survives the client to the server, which
+ # decodes it to "v1/../admin" before routing.
+ response = await integration_client.post(
+ "/v1/%2e%2e/admin",
+ content=b"encrypted",
+ headers={
+ "ehbp-encapsulated-key": "x",
+ "x-routstr-model": "gpt-4",
+ },
+ )
+ assert response.status_code == 404
+
@pytest.mark.integration
@pytest.mark.asyncio
diff --git a/tests/unit/test_proxy_path_allowlist.py b/tests/unit/test_proxy_path_allowlist.py
new file mode 100644
index 00000000..a98679d5
--- /dev/null
+++ b/tests/unit/test_proxy_path_allowlist.py
@@ -0,0 +1,99 @@
+"""Unit tests for the proxy edge path allowlist (arbitrary-upstream-path-proxy).
+
+An authenticated POST used to be forwarded for ANY path, so a caller could reach
+arbitrary or traversal-shaped upstream endpoints with the provider credential
+attached. The proxy now rejects ambiguous path spellings for every method and
+requires a known API prefix before anything is forwarded.
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
+os.environ.setdefault("UPSTREAM_API_KEY", "test")
+
+import pytest # noqa: E402
+
+from routstr.proxy import ( # noqa: E402
+ _forwarding_allowed,
+ _is_ambiguously_spelled_path,
+)
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "../secret",
+ "v1/../admin",
+ "v1/./models",
+ "..",
+ "v1//models", # duplicate separator
+ "/v1/models", # leading slash / absolute override
+ "v1/models/..",
+ "%2e%2e/secret", # residual encoded dot segment
+ "v1/%2fadmin", # residual encoded slash
+ "v1\\models", # backslash
+ "v1/models\x00", # NUL byte
+ " v1/models", # leading whitespace
+ "",
+ ],
+)
+def test_ambiguous_paths_are_rejected(path: str) -> None:
+ assert _is_ambiguously_spelled_path(path) is True
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "v1/chat/completions",
+ "chat/completions",
+ "v1/responses",
+ "v1/embeddings",
+ "models",
+ "v1/models/gpt-4",
+ "attestation/", # a single trailing slash is canonical
+ "tee/attestation/",
+ ],
+)
+def test_canonical_paths_are_allowed(path: str) -> None:
+ assert _is_ambiguously_spelled_path(path) is False
+
+
+def test_unknown_paths_are_not_forwarded() -> None:
+ # The credential is attached during forwarding, so an unknown endpoint must
+ # never be forwarded on the caller's say-so.
+ assert _forwarding_allowed("internal/admin", is_ehbp=False) is False
+ assert _forwarding_allowed("secret-endpoint", is_ehbp=False) is False
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "modelsdump", # bare token "models" must not match a longer segment
+ "attestationadmin",
+ "providers-secret",
+ "embeddingsx",
+ "completions-internal",
+ ],
+)
+def test_bare_prefix_does_not_match_a_longer_segment(path: str) -> None:
+ assert _forwarding_allowed(path, is_ehbp=False) is False
+
+
+def test_known_prefixes_are_forwarded() -> None:
+ assert _forwarding_allowed("v1/chat/completions", is_ehbp=False) is True
+ assert _forwarding_allowed("chat/completions", is_ehbp=False) is True
+ # Bare tokens match a whole segment: exactly or followed by "/".
+ assert _forwarding_allowed("models", is_ehbp=False) is True
+ assert _forwarding_allowed("models/gpt-4", is_ehbp=False) is True
+ assert _forwarding_allowed("embeddings", is_ehbp=False) is True
+ assert _forwarding_allowed("attestation", is_ehbp=False) is True
+
+
+def test_ehbp_bypasses_prefix_gate_by_header() -> None:
+ # Documents a deliberate exemption: EHBP is identified by header and carries
+ # its own encrypted contract, so the prefix gate does not apply. The
+ # ambiguous-spelling screen still runs on EHBP paths (see the ordering test
+ # in the integration suite).
+ assert _forwarding_allowed("anything/encrypted", is_ehbp=True) is True
diff --git a/tests/unit/test_proxy_tinfoil_attestation_routing.py b/tests/unit/test_proxy_tinfoil_attestation_routing.py
index b6ba2f88..cd335f3b 100644
--- a/tests/unit/test_proxy_tinfoil_attestation_routing.py
+++ b/tests/unit/test_proxy_tinfoil_attestation_routing.py
@@ -102,10 +102,22 @@ async def test_attestation_trailing_slash_routes_directly_to_tinfoil(
tinfoil.forward_get_request.assert_awaited_once()
-@pytest.mark.parametrize("path", ["attestation/foo", "attestationjunk"])
+@pytest.mark.parametrize(
+ ("path", "expected_status"),
+ [
+ # Valid `attestation` segment but not the exact attestation route:
+ # fails model validation (empty body -> unknown model) before auth.
+ ("attestation/foo", 400),
+ # Not a known path segment at all: rejected at the edge before routing.
+ ("attestationjunk", 404),
+ ],
+)
@pytest.mark.asyncio
async def test_non_attestation_prefix_does_not_bypass_authentication(
- monkeypatch: pytest.MonkeyPatch, proxy_app: FastAPI, path: str
+ monkeypatch: pytest.MonkeyPatch,
+ proxy_app: FastAPI,
+ path: str,
+ expected_status: int,
) -> None:
tinfoil = MagicMock()
tinfoil.provider_type = "tinfoil"
@@ -118,8 +130,9 @@ async def test_non_attestation_prefix_does_not_bypass_authentication(
) as client:
response = await client.get(f"/{path}")
- assert response.status_code == 400
- assert response.json()["error"]["type"] == "invalid_model"
+ assert response.status_code == expected_status
+ if expected_status == 400:
+ assert response.json()["error"]["type"] == "invalid_model"
tinfoil.forward_get_request.assert_not_awaited()
From f0ae282b3faea598d06808fa089ba766417f0555 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Sun, 23 Aug 2026 22:11:11 +0200
Subject: [PATCH 017/120] fix modal routing for cheapest first
---
routstr/algorithm.py | 33 +++-
tests/unit/test_ranking_maxcost_mismatch.py | 169 ++++++++++++++++++++
2 files changed, 196 insertions(+), 6 deletions(-)
create mode 100644 tests/unit/test_ranking_maxcost_mismatch.py
diff --git a/routstr/algorithm.py b/routstr/algorithm.py
index 084972a8..1e338768 100644
--- a/routstr/algorithm.py
+++ b/routstr/algorithm.py
@@ -59,6 +59,23 @@ def calculate_model_cost_score(model: "Model") -> float:
return total_cost
+def calculate_model_reservation_score(model: "Model") -> float:
+ """Context-based reservation ceiling for ranking same-model candidates.
+
+ The balance gate reserves on ``sats_pricing.max_cost``, which scales with
+ ``context_length``. Ranking on the same ceiling keeps the advertised,
+ routed, and reserved candidate consistent. Lower is better.
+ """
+ from .payment.models import _calculate_usd_max_costs
+
+ try:
+ _, _, max_cost = _calculate_usd_max_costs(model)
+ return float(max_cost)
+ except Exception:
+ # Pricing shape missing/invalid; per-token score keeps order deterministic
+ return calculate_model_cost_score(model)
+
+
def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
"""Calculate a penalty multiplier for certain providers.
@@ -345,15 +362,19 @@ def create_model_mappings(
return 1
for alias, items in candidates.items():
- # Sort key: (priority DESC, cost ASC)
- # Using negative cost for DESC sort overall to keep high priority first
- def sort_key(item: tuple["Model", "BaseUpstreamProvider"]) -> tuple[int, float]:
+ # Sort key: (priority DESC, reservation ASC, cost ASC)
+ # Using negative costs for DESC sort overall to keep high priority first
+ def sort_key(
+ item: tuple["Model", "BaseUpstreamProvider"],
+ ) -> tuple[int, float, float]:
model, provider = item
priority = alias_priority(model, alias)
- cost = calculate_model_cost_score(model)
penalty = get_provider_penalty(provider)
- adjusted_cost = cost * penalty
- return (priority, -adjusted_cost)
+ # Rank on the reservation ceiling the balance gate enforces, with
+ # per-token typical-usage cost as tiebreaker
+ adjusted_reservation = calculate_model_reservation_score(model) * penalty
+ adjusted_cost = calculate_model_cost_score(model) * penalty
+ return (priority, -adjusted_reservation, -adjusted_cost)
items.sort(key=sort_key, reverse=True)
diff --git a/tests/unit/test_ranking_maxcost_mismatch.py b/tests/unit/test_ranking_maxcost_mismatch.py
new file mode 100644
index 00000000..438dcf0d
--- /dev/null
+++ b/tests/unit/test_ranking_maxcost_mismatch.py
@@ -0,0 +1,169 @@
+"""Ranking-vs-reservation mismatch for same-model multi-provider setups.
+
+``calculate_model_cost_score`` weights a typical request and ignores
+``context_length``, while the balance gate reserves on the context-based
+``sats_pricing.max_cost``. When two providers serve the same model these can
+disagree, so the "cheapest" advertised provider may demand a far larger
+reservation and surprise the client with a 402. Ranking must therefore use the
+same context-based ceiling as the gate.
+"""
+
+import os
+from unittest.mock import Mock
+
+import pytest
+
+os.environ["UPSTREAM_BASE_URL"] = "http://test"
+os.environ["UPSTREAM_API_KEY"] = "test"
+
+from routstr.algorithm import ( # noqa: E402
+ calculate_model_cost_score,
+ create_model_mappings,
+)
+from routstr.payment.helpers import get_max_cost_for_model # noqa: E402
+from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
+from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
+
+
+def _arch() -> Architecture:
+ return Architecture(
+ modality="text",
+ input_modalities=["text"],
+ output_modalities=["text"],
+ tokenizer="gpt",
+ instruct_type=None,
+ )
+
+
+def _model(
+ model_id: str,
+ prompt: float,
+ completion: float,
+ context_length: int,
+ max_cost_sats: float,
+) -> Model:
+ """Build a Model whose sats_pricing.max_cost mirrors the context-based gate."""
+ pricing = Pricing(
+ prompt=prompt,
+ completion=completion,
+ request=0.0,
+ image=0.0,
+ web_search=0.0,
+ internal_reasoning=0.0,
+ )
+ model = Model(
+ id=model_id,
+ name=model_id,
+ created=1,
+ description="",
+ context_length=context_length,
+ architecture=_arch(),
+ pricing=pricing,
+ )
+ model.sats_pricing = Pricing(
+ prompt=prompt,
+ completion=completion,
+ request=0.0,
+ image=0.0,
+ web_search=0.0,
+ internal_reasoning=0.0,
+ max_prompt_cost=context_length * prompt,
+ max_completion_cost=context_length * completion,
+ max_cost=max_cost_sats,
+ )
+ return model
+
+
+def _provider(name: str, db_id: int, models: list[Model]) -> Mock:
+ provider = Mock()
+ provider.provider_type = name
+ provider.base_url = f"https://{name}.example/v1"
+ provider.db_id = db_id
+ provider.upstream_name = name
+ provider.provider_fee = 1.0
+ provider.get_cached_models.return_value = models
+ return provider
+
+
+# Provider LOW-SCORE: cheaper per typical token, but a huge context window makes
+# its context-based max_cost enormous.
+_LOW_SCORE = _model(
+ "shared-model",
+ prompt=0.001,
+ completion=0.001,
+ context_length=1_000_000,
+ max_cost_sats=1000.0,
+)
+# Provider LOW-RESERVE: pricier per typical token, but a small context window
+# makes its reservation ceiling tiny.
+_LOW_RESERVE = _model(
+ "shared-model",
+ prompt=0.002,
+ completion=0.002,
+ context_length=8_000,
+ max_cost_sats=16.0,
+)
+
+
+def test_ranking_metric_and_reservation_metric_disagree() -> None:
+ """The two cost metrics rank the same two providers in opposite order."""
+ assert calculate_model_cost_score(_LOW_SCORE) < calculate_model_cost_score(
+ _LOW_RESERVE
+ )
+ assert _max_cost(_LOW_SCORE) > _max_cost(_LOW_RESERVE)
+
+
+@pytest.mark.asyncio
+async def test_get_max_cost_uses_context_based_max_cost_not_score() -> None:
+ """The balance gate reserves on max_cost, so the low-score model costs more."""
+ session = Mock()
+ low_score_reserve = await get_max_cost_for_model(
+ "shared-model", session=session, model_obj=_LOW_SCORE
+ )
+ low_reserve_reserve = await get_max_cost_for_model(
+ "shared-model", session=session, model_obj=_LOW_RESERVE
+ )
+ # The "cheapest" model (by ranking score) demands the LARGER reservation.
+ assert low_score_reserve > low_reserve_reserve
+
+
+def _max_cost(model: Model) -> float:
+ assert model.sats_pricing is not None
+ assert model.sats_pricing.max_cost is not None
+ return model.sats_pricing.max_cost
+
+
+def _both_orderings() -> list[list[BaseUpstreamProvider]]:
+ low_score = _provider("low-score", 1, [_LOW_SCORE])
+ low_reserve = _provider("low-reserve", 2, [_LOW_RESERVE])
+ return [[low_score, low_reserve], [low_reserve, low_score]]
+
+
+def test_catalog_advertises_the_lower_reservation_provider() -> None:
+ """Catalog and routing pick the provider with the smaller reservation,
+ regardless of upstream iteration order."""
+ for providers in _both_orderings():
+ _, provider_map, unique_models = create_model_mappings(
+ upstreams=providers,
+ overrides_by_key={},
+ disabled_model_keys=set(),
+ )
+ selected_model, selected_provider = provider_map["shared-model"][0]
+ assert selected_provider.provider_type == "low-reserve"
+ assert _max_cost(selected_model) == 16.0
+ assert _max_cost(unique_models["shared-model"]) == 16.0
+
+
+def test_selected_candidate_has_minimal_reservation() -> None:
+ """The first-tried candidate never demands a larger reservation than
+ another available candidate for the same model."""
+ for providers in _both_orderings():
+ _, provider_map, _ = create_model_mappings(
+ upstreams=providers,
+ overrides_by_key={},
+ disabled_model_keys=set(),
+ )
+ candidates = provider_map["shared-model"]
+ selected_max_cost = _max_cost(candidates[0][0])
+ min_max_cost = min(_max_cost(m) for m, _ in candidates)
+ assert selected_max_cost == min_max_cost
From 8f9b9abb32eb91f92f800be921709a856fcdc3c1 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Sun, 23 Aug 2026 23:25:03 +0200
Subject: [PATCH 018/120] better model selection
---
routstr/algorithm.py | 22 ++++++----
tests/unit/test_algorithm.py | 84 ++++++++++++++++++++++++++++++++++--
2 files changed, 94 insertions(+), 12 deletions(-)
diff --git a/routstr/algorithm.py b/routstr/algorithm.py
index 1e338768..cced61f7 100644
--- a/routstr/algorithm.py
+++ b/routstr/algorithm.py
@@ -340,20 +340,24 @@ def create_model_mappings(
def alias_priority(model: "Model", alias: str) -> int:
"""Rank how strong the mapping of alias->model is.
- An exact model ID is authoritative and must be cost-ranked against the
- other exact matches before considering forwarded aliases. This keeps a
- provider-specific forwarded ID from shadowing a directly available,
+ A provider that serves the requested ID directly is authoritative and
+ must be cost-ranked before providers that only reach it through a
+ forwarded alias, so a forwarded ID cannot shadow a directly available,
cheaper model with the requested ID.
+
+ "Directly served" covers both the exact model ID and the same ID behind
+ a provider prefix (e.g. ``gpt-oss-120b`` on Tinfoil vs
+ ``openai/gpt-oss-120b`` on OpenRouter). Both name the same model, so
+ they share the top tier and cost decides between them; otherwise the
+ provider whose catalog omits the org prefix would always win on ID
+ spelling regardless of price.
"""
- if model.id and model.id.lower() == alias:
- return 5
+ model_base = get_base_model_id(model.id)
+ if (model.id and model.id.lower() == alias) or model_base.lower() == alias:
+ return 4
forwarded_model_id = get_effective_forwarded_model_id(model)
if forwarded_model_id and forwarded_model_id.lower() == alias:
- return 4
-
- model_base = get_base_model_id(model.id)
- if model_base == alias:
return 3
if model.canonical_slug:
canonical_base = get_base_model_id(model.canonical_slug)
diff --git a/tests/unit/test_algorithm.py b/tests/unit/test_algorithm.py
index 0dcf751e..5d31af2e 100644
--- a/tests/unit/test_algorithm.py
+++ b/tests/unit/test_algorithm.py
@@ -499,7 +499,14 @@ def test_create_model_mappings_exact_model_id_beats_forwarded_id_collision(
def test_models_endpoint_preserves_catalog_id_when_winner_forwards_elsewhere(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """Each catalog row keeps its requested ID while using its routing winner."""
+ """Each catalog row keeps its requested ID while using its routing winner.
+
+ ``foo`` is served directly by two providers: the cheaper one prefixes the ID
+ (``vendor/foo``) and the pricier one exposes the bare ID while forwarding
+ upstream to ``bar``. Prefix vs bare spelling must not decide the winner, so
+ the cheaper prefixed provider wins ``foo`` while ``bar`` still appears as its
+ own catalog row served by the forwarding provider.
+ """
base_alias = create_test_model(
"vendor/foo", prompt_price=0.001, completion_price=0.001
)
@@ -518,9 +525,9 @@ def test_models_endpoint_preserves_catalog_id_when_winner_forwards_elsewhere(
disabled_model_keys=set(),
)
- assert provider_map["foo"][0] == (redirected_exact, redirect_provider)
+ assert provider_map["foo"][0] == (base_alias, base_provider)
assert unique_models["foo"].id == "foo"
- assert unique_models["foo"].upstream_provider_id == "redirect"
+ assert unique_models["foo"].upstream_provider_id == "base"
import routstr.proxy as proxy
@@ -876,3 +883,74 @@ def test_create_model_mappings_disables_only_matching_provider() -> None:
)
assert [p for _, p in provider_map["same-id"]] == [provider_a]
+
+
+def test_create_model_mappings_prefixed_openrouter_beats_bare_tinfoil_id() -> None:
+ """Prefix-vs-bare ID spelling must not outrank price for the same model.
+
+ Tinfoil advertises bare model IDs (``gpt-oss-120b``) while OpenRouter keeps
+ the org prefix (``openai/gpt-oss-120b``). Both serve the same model, so the
+ cheaper OpenRouter deployment must win the public ``gpt-oss-120b`` catalog
+ row and route; the bare-ID exact match must not shadow it on spelling alone.
+ """
+ tinfoil_expensive = create_test_model(
+ "gpt-oss-120b", prompt_price=0.01, completion_price=0.01
+ )
+ openrouter_cheap = create_test_model(
+ "openai/gpt-oss-120b", prompt_price=0.001, completion_price=0.001
+ )
+ tinfoil = create_test_provider(
+ "tinfoil",
+ "https://inference.tinfoil.sh/v1",
+ db_id=1,
+ models=[tinfoil_expensive],
+ )
+ openrouter = create_test_provider(
+ "openrouter",
+ "https://openrouter.ai/api/v1",
+ db_id=2,
+ models=[openrouter_cheap],
+ )
+
+ # Discovery order should not matter: Tinfoil (non-OpenRouter) is processed
+ # first, yet the cheaper OpenRouter candidate must still win.
+ _, provider_map, unique_models = create_model_mappings(
+ upstreams=[tinfoil, openrouter],
+ overrides_by_key={},
+ disabled_model_keys=set(),
+ )
+
+ assert provider_map["gpt-oss-120b"][0] == (openrouter_cheap, openrouter)
+ assert unique_models["gpt-oss-120b"].upstream_provider_id == "openrouter"
+ assert unique_models["gpt-oss-120b"].pricing.prompt == 0.001
+
+
+def test_create_model_mappings_uppercase_prefixed_base_keeps_top_tier() -> None:
+ """Uppercase prefixed IDs still match the public alias at the direct tier.
+
+ ``Qwen/Qwen2.5-72B`` lowercases to alias ``qwen2.5-72b``; its base name
+ must be compared case-insensitively so it stays a direct match instead of
+ falling to the weakest tier and losing to a forwarded alias on spelling.
+ """
+ prefixed_cheap = create_test_model(
+ "Qwen/Qwen2.5-72B", prompt_price=0.001, completion_price=0.001
+ )
+ forwarded_expensive = create_test_model(
+ "deployment-x", prompt_price=0.1, completion_price=0.1
+ )
+ forwarded_expensive.forwarded_model_id = "qwen2.5-72b"
+ prefixed_provider = create_test_provider(
+ "prefixed", "https://prefixed.example/v1", db_id=1, models=[prefixed_cheap]
+ )
+ forwarded_provider = create_test_provider(
+ "forwarded", "https://forwarded.example/v1", db_id=2, models=[forwarded_expensive]
+ )
+
+ _, provider_map, unique_models = create_model_mappings(
+ upstreams=[forwarded_provider, prefixed_provider],
+ overrides_by_key={},
+ disabled_model_keys=set(),
+ )
+
+ assert provider_map["qwen2.5-72b"][0] == (prefixed_cheap, prefixed_provider)
+ assert unique_models["qwen2.5-72b"].upstream_provider_id == "prefixed"
From 5af04364c9cd1d95339d2fbc08f5ae5a032659eb Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Mon, 24 Aug 2026 01:08:33 +0200
Subject: [PATCH 019/120] Restrict proxy forwarding to an exact method/path
allowlist
---
routstr/core/settings.py | 11 ++
routstr/proxy.py | 148 ++++++++++++++----
.../integration/test_proxy_post_endpoints.py | 81 ++++++++--
tests/unit/test_proxy_path_allowlist.py | 145 ++++++++++++++---
.../test_proxy_tinfoil_attestation_routing.py | 18 +--
5 files changed, 325 insertions(+), 78 deletions(-)
diff --git a/routstr/core/settings.py b/routstr/core/settings.py
index c02c6030..53e71a74 100644
--- a/routstr/core/settings.py
+++ b/routstr/core/settings.py
@@ -101,6 +101,12 @@ class Settings(BaseSettings):
# Network
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
+ # Comma-separated METHOD:path pairs adding to the proxy's canonical
+ # endpoint allowlist, e.g. "POST:v1/rerank,GET:batches". Only for upstreams
+ # exposing an endpoint outside the OpenAI-compatible set; each addition
+ # widens what the provider credential can be spent against, so wildcards
+ # and prefixes are not supported.
+ proxy_extra_allowed_paths: str = Field(default="", env="PROXY_EXTRA_ALLOWED_PATHS")
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
providers_refresh_interval_seconds: int = Field(
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
@@ -192,6 +198,11 @@ SECRET_FIELDS = frozenset({"admin_password", "nsec"})
# neither store nor shadow them; env is always authoritative.
ENV_ONLY_FIELDS = frozenset(
{
+ # Widening the proxy's reachable upstream surface is a deployment
+ # decision, not a runtime toggle: it changes what the provider
+ # credential can be spent against. Keeping it env-only also lets the
+ # proxy parse it once at import without going stale.
+ "proxy_extra_allowed_paths",
"database_pool_size",
"database_max_overflow",
"database_pool_timeout",
diff --git a/routstr/proxy.py b/routstr/proxy.py
index c367d9a2..5feff279 100644
--- a/routstr/proxy.py
+++ b/routstr/proxy.py
@@ -26,6 +26,7 @@ from .core.db import (
)
from .core.exceptions import UpstreamError
from .core.not_found import build_not_found_response
+from .core.settings import settings
from .payment.helpers import (
calculate_discounted_max_cost,
check_token_balance,
@@ -221,27 +222,93 @@ async def refresh_model_maps_periodically() -> None:
)
-_API_PATH_PREFIXES = (
- "v1/",
- "responses",
- "chat/",
- "completions",
- "models",
- "embeddings",
- "audio/",
- "images/",
- "moderations",
- "providers",
- "tee/",
- "attestation",
-)
+# Canonical endpoints this proxy will forward, keyed by the path with any
+# leading "v1/" and trailing slash removed, mapped to the methods allowed on
+# each. The provider credential is attached during forwarding, so endpoint
+# permission has to come from this table rather than from the client-supplied
+# path: an upstream's key-management, organization, or billing routes live
+# under the same origin and must never be reachable through the proxy.
+_ALLOWED_ENDPOINTS: dict[str, frozenset[str]] = {
+ "chat/completions": frozenset({"POST"}),
+ "completions": frozenset({"POST"}),
+ "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"}),
+}
-# Split the allowlist by spelling so bare tokens anchor to a path segment.
-# A slash-terminated prefix ("v1/") is already segment-anchored under
-# startswith. A bare token ("models") must match a whole segment — exactly or
-# followed by "/" — so "modelsdump" / "attestationadmin" cannot slip through.
-_API_SLASH_PREFIXES = tuple(p for p in _API_PATH_PREFIXES if p.endswith("/"))
-_API_BARE_PREFIXES = tuple(p for p in _API_PATH_PREFIXES if not p.endswith("/"))
+_ALLOWED_METHODS = frozenset({"GET", "POST"})
+
+
+def _canonical_api_path(path: str) -> str:
+ """Reduce a request path to its allowlist key.
+
+ OpenAI-style clients reach the same endpoint with or without the ``v1/``
+ prefix and with or without a trailing slash, so both spellings collapse to
+ one key. Callers must screen the path with
+ :func:`_is_ambiguously_spelled_path` first — this function assumes the path
+ has no dot segments, empty segments, or encoded separators left to resolve.
+ """
+ core = path[:-1] if path.endswith("/") else path
+ if core.startswith("v1/"):
+ core = core[len("v1/") :]
+ return core
+
+
+def _parse_extra_allowed_endpoints(raw: str) -> dict[str, frozenset[str]]:
+ """Parse operator-configured additions to the endpoint allowlist.
+
+ Deployments whose provider exposes an endpoint outside the canonical set
+ opt in explicitly with ``PROXY_EXTRA_ALLOWED_PATHS``, a comma-separated
+ list of ``METHOD:path`` pairs (e.g. ``POST:v1/rerank,GET:batches``). Every
+ entry must name one concrete method and one unambiguous path; wildcards
+ and bare prefixes are deliberately unsupported, so widening the proxy's
+ reach is always a per-endpoint decision. Malformed entries are dropped
+ with a warning rather than silently widening or narrowing the surface.
+ """
+ extra: dict[str, frozenset[str]] = {}
+ for entry in raw.split(","):
+ entry = entry.strip()
+ if not entry:
+ continue
+ method, separator, endpoint = entry.partition(":")
+ method = method.strip().upper()
+ endpoint = endpoint.strip()
+ if not separator or method not in _ALLOWED_METHODS or not endpoint:
+ logger.warning(
+ "Ignoring malformed PROXY_EXTRA_ALLOWED_PATHS entry",
+ extra={"entry": entry},
+ )
+ continue
+ if _is_ambiguously_spelled_path(endpoint):
+ logger.warning(
+ "Ignoring ambiguously spelled PROXY_EXTRA_ALLOWED_PATHS entry",
+ extra={"entry": entry},
+ )
+ continue
+ if any(character in endpoint for character in "*?["):
+ # Refuse glob syntax outright. Kept as a literal endpoint name it
+ # would never match a real request, so the operator would think
+ # they had widened the proxy when they had not.
+ logger.warning(
+ "Ignoring wildcard PROXY_EXTRA_ALLOWED_PATHS entry; "
+ "list each endpoint explicitly",
+ extra={"entry": entry},
+ )
+ continue
+ key = _canonical_api_path(endpoint)
+ extra[key] = extra.get(key, frozenset()) | {method}
+ return extra
def _is_ambiguously_spelled_path(path: str) -> bool:
@@ -267,20 +334,35 @@ def _is_ambiguously_spelled_path(path: str) -> bool:
return "%2e" in lowered or "%2f" in lowered or "%5c" in lowered
-def _forwarding_allowed(path: str, is_ehbp: bool) -> bool:
- """Gate which paths may reach an upstream at all.
+_EXTRA_ALLOWED_ENDPOINTS = _parse_extra_allowed_endpoints(
+ settings.proxy_extra_allowed_paths
+)
- The provider credential is attached during forwarding, so an unknown path
- must never be forwarded on the caller's say-so. A path must resolve to a
- known API prefix; EHBP requests are identified by header and carry their own
- encrypted contract. Endpoint permission is derived from this allowlist, not
- from the client-supplied path.
+
+def _allowed_methods_for(endpoint: str) -> frozenset[str]:
+ """Return the methods allowed on a canonical endpoint, empty if unknown."""
+ methods = _ALLOWED_ENDPOINTS.get(endpoint, frozenset())
+ methods |= _EXTRA_ALLOWED_ENDPOINTS.get(endpoint, frozenset())
+ return methods
+
+
+def _forwarding_allowed(path: str, method: str) -> bool:
+ """Gate which method/path pairs may reach an upstream at all.
+
+ The provider credential is attached during forwarding, so an unknown
+ endpoint must never be forwarded on the caller's say-so. The path is
+ reduced to its canonical form and looked up in the endpoint table; there is
+ no prefix match, so a known prefix no longer carries an unknown endpoint
+ (``v1/organization/api_keys`` is rejected even though ``v1/`` is familiar).
+
+ EHBP requests are gated by the same table. Their body is opaque to the
+ proxy, which is a reason to constrain the destination more tightly, not to
+ trust the caller's path: the encrypted contract covers the body, never the
+ endpoint the credential is spent against.
"""
- if is_ehbp:
- return True
- if path.startswith(_API_SLASH_PREFIXES):
- return True
- return any(path == p or path.startswith(p + "/") for p in _API_BARE_PREFIXES)
+ if method not in _ALLOWED_METHODS:
+ return False
+ return method in _allowed_methods_for(_canonical_api_path(path))
@proxy_router.api_route("/{path:path}", methods=["GET", "POST"], response_model=None)
@@ -310,7 +392,7 @@ async def _proxy(
headers = dict(request.headers)
is_ehbp = "ehbp-encapsulated-key" in headers
- if not _forwarding_allowed(path, is_ehbp):
+ if not _forwarding_allowed(path, request.method):
return build_not_found_response(request, path)
is_responses_api = path.startswith("v1/responses") or path.startswith("responses")
diff --git a/tests/integration/test_proxy_post_endpoints.py b/tests/integration/test_proxy_post_endpoints.py
index 9765ffd4..99bdc071 100644
--- a/tests/integration/test_proxy_post_endpoints.py
+++ b/tests/integration/test_proxy_post_endpoints.py
@@ -318,6 +318,43 @@ async def test_authenticated_post_to_unknown_path_is_rejected(
assert response.status_code == 404
+@pytest.mark.integration
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "bad_path",
+ [
+ # Unambiguously spelled, under a prefix the proxy serves, but not an
+ # endpoint it offers. These are real upstream routes that manage keys,
+ # org membership, and billing on the same origin as inference.
+ "v1/organization/api_keys",
+ "v1/api_keys",
+ "v1/billing/usage",
+ "v1/admin/keys",
+ # An id segment is honoured one level deep, and only where an endpoint
+ # takes one at all.
+ "v1/chat/completions/abc",
+ "v1/models/gpt-4/secret",
+ ],
+)
+async def test_known_prefix_does_not_carry_an_unknown_endpoint(
+ authenticated_client: AsyncClient, bad_path: str
+) -> None:
+ """A familiar prefix must not be a passport for the rest of the origin.
+
+ Nothing here is traversal-shaped, so the spelling screen lets it by; only
+ the endpoint allowlist stops it. Forwarding raises if the guard misses,
+ so a clean 404 also proves the credential never left."""
+ with patch(
+ "routstr.upstream.base.BaseUpstreamProvider.forward_request",
+ AsyncMock(side_effect=AssertionError("must not forward unknown endpoint")),
+ ):
+ response = await authenticated_client.post(
+ f"/{bad_path}",
+ json={"model": "gpt-3.5-turbo", "messages": []},
+ )
+ assert response.status_code == 404
+
+
@pytest.mark.integration
@pytest.mark.asyncio
async def test_get_unknown_path_is_rejected(integration_client: AsyncClient) -> None:
@@ -328,21 +365,37 @@ async def test_get_unknown_path_is_rejected(integration_client: AsyncClient) ->
@pytest.mark.integration
@pytest.mark.asyncio
-async def test_ambiguous_path_is_rejected_before_ehbp_exemption(
- integration_client: AsyncClient,
+@pytest.mark.parametrize(
+ "bad_path",
+ [
+ # Percent-encoded so the traversal survives the client to the server,
+ # which decodes it to "v1/../admin" before routing.
+ "v1/%2e%2e/admin",
+ # Well-spelled, so only the endpoint allowlist can stop it.
+ "v1/organization/api_keys",
+ "anything/encrypted",
+ ],
+)
+async def test_ehbp_request_is_gated_by_the_endpoint_allowlist(
+ integration_client: AsyncClient, bad_path: str
) -> None:
- """The ambiguous-spelling screen runs before the EHBP header exemption, so
- an EHBP request cannot smuggle a traversal path past it."""
- # Percent-encoded so the traversal survives the client to the server, which
- # decodes it to "v1/../admin" before routing.
- response = await integration_client.post(
- "/v1/%2e%2e/admin",
- content=b"encrypted",
- headers={
- "ehbp-encapsulated-key": "x",
- "x-routstr-model": "gpt-4",
- },
- )
+ """EHBP hides the body from the proxy, not the destination.
+
+ The encrypted contract covers the request body; it says nothing about which
+ endpoint the provider credential gets spent against, so an EHBP request is
+ screened and allowlisted exactly like any other."""
+ with patch(
+ "routstr.proxy.forward_ehbp_request",
+ AsyncMock(side_effect=AssertionError("must not forward unknown endpoint")),
+ ):
+ response = await integration_client.post(
+ f"/{bad_path}",
+ content=b"encrypted",
+ headers={
+ "ehbp-encapsulated-key": "x",
+ "x-routstr-model": "gpt-4",
+ },
+ )
assert response.status_code == 404
diff --git a/tests/unit/test_proxy_path_allowlist.py b/tests/unit/test_proxy_path_allowlist.py
index a98679d5..33a2d610 100644
--- a/tests/unit/test_proxy_path_allowlist.py
+++ b/tests/unit/test_proxy_path_allowlist.py
@@ -2,8 +2,9 @@
An authenticated POST used to be forwarded for ANY path, so a caller could reach
arbitrary or traversal-shaped upstream endpoints with the provider credential
-attached. The proxy now rejects ambiguous path spellings for every method and
-requires a known API prefix before anything is forwarded.
+attached. The proxy now rejects ambiguous path spellings for every method, then
+requires the method/path pair to name a canonical endpoint. A familiar prefix is
+no longer enough: "v1/organization/api_keys" is refused just like "internal/admin".
"""
from __future__ import annotations
@@ -18,6 +19,7 @@ import pytest # noqa: E402
from routstr.proxy import ( # noqa: E402
_forwarding_allowed,
_is_ambiguously_spelled_path,
+ _parse_extra_allowed_endpoints,
)
@@ -63,37 +65,138 @@ def test_canonical_paths_are_allowed(path: str) -> None:
def test_unknown_paths_are_not_forwarded() -> None:
# The credential is attached during forwarding, so an unknown endpoint must
# never be forwarded on the caller's say-so.
- assert _forwarding_allowed("internal/admin", is_ehbp=False) is False
- assert _forwarding_allowed("secret-endpoint", is_ehbp=False) is False
+ assert _forwarding_allowed("internal/admin", "POST") is False
+ assert _forwarding_allowed("secret-endpoint", "POST") is False
@pytest.mark.parametrize(
"path",
[
- "modelsdump", # bare token "models" must not match a longer segment
+ "modelsdump", # "models" must not match a longer segment
"attestationadmin",
"providers-secret",
"embeddingsx",
"completions-internal",
],
)
-def test_bare_prefix_does_not_match_a_longer_segment(path: str) -> None:
- assert _forwarding_allowed(path, is_ehbp=False) is False
+def test_endpoint_name_does_not_match_a_longer_segment(path: str) -> None:
+ assert _forwarding_allowed(path, "POST") is False
+ assert _forwarding_allowed(path, "GET") is False
-def test_known_prefixes_are_forwarded() -> None:
- assert _forwarding_allowed("v1/chat/completions", is_ehbp=False) is True
- assert _forwarding_allowed("chat/completions", is_ehbp=False) is True
- # Bare tokens match a whole segment: exactly or followed by "/".
- assert _forwarding_allowed("models", is_ehbp=False) is True
- assert _forwarding_allowed("models/gpt-4", is_ehbp=False) is True
- assert _forwarding_allowed("embeddings", is_ehbp=False) is True
- assert _forwarding_allowed("attestation", is_ehbp=False) is True
+@pytest.mark.parametrize(
+ "path",
+ [
+ # A familiar prefix must not carry an unknown endpoint. These are real
+ # upstream routes that manage keys, org membership, and billing.
+ "v1/organization/api_keys",
+ "v1/api_keys",
+ "v1/admin/keys",
+ "v1/billing/usage",
+ "v1/files",
+ "v1/batches",
+ "chat/internal",
+ "audio/internal",
+ "images/internal",
+ "tee/keys",
+ # No endpoint takes a trailing id segment; a resource id never widens
+ # the reachable surface.
+ "models/gpt-4",
+ "models/gpt-4/secret",
+ "chat/completions/abc",
+ ],
+)
+def test_known_prefix_does_not_carry_an_unknown_endpoint(path: str) -> None:
+ assert _forwarding_allowed(path, "POST") is False
+ assert _forwarding_allowed(path, "GET") is False
-def test_ehbp_bypasses_prefix_gate_by_header() -> None:
- # Documents a deliberate exemption: EHBP is identified by header and carries
- # its own encrypted contract, so the prefix gate does not apply. The
- # ambiguous-spelling screen still runs on EHBP paths (see the ordering test
- # in the integration suite).
- assert _forwarding_allowed("anything/encrypted", is_ehbp=True) is True
+@pytest.mark.parametrize(
+ ("path", "method"),
+ [
+ ("v1/chat/completions", "POST"),
+ ("chat/completions", "POST"),
+ ("v1/chat/completions/", "POST"),
+ ("completions", "POST"),
+ ("v1/responses", "POST"),
+ ("v1/messages", "POST"),
+ ("v1/embeddings", "POST"),
+ ("moderations", "POST"),
+ ("audio/transcriptions", "POST"),
+ ("images/generations", "POST"),
+ ("models", "GET"),
+ ("attestation", "GET"),
+ ("tee/attestation", "GET"),
+ ],
+)
+def test_canonical_endpoints_are_forwarded(path: str, method: str) -> None:
+ assert _forwarding_allowed(path, method) is True
+
+
+@pytest.mark.parametrize(
+ ("path", "method"),
+ [
+ ("chat/completions", "GET"), # billed endpoints are POST-only
+ ("v1/embeddings", "GET"),
+ ("models", "POST"), # read-only endpoints are GET-only
+ ("attestation", "POST"),
+ ("v1/chat/completions", "DELETE"), # never routed here, refused anyway
+ ("v1/chat/completions", "PUT"),
+ ],
+)
+def test_method_must_match_the_endpoint(path: str, method: str) -> None:
+ assert _forwarding_allowed(path, method) is False
+
+
+def test_operator_additions_are_parsed_per_endpoint() -> None:
+ parsed = _parse_extra_allowed_endpoints("POST:v1/rerank, GET:batches ,post:audio/x")
+ assert parsed == {
+ "rerank": frozenset({"POST"}), # the "v1/" prefix collapses like any path
+ "batches": frozenset({"GET"}),
+ "audio/x": frozenset({"POST"}),
+ }
+
+
+def test_operator_additions_may_grant_two_methods_on_one_endpoint() -> None:
+ assert _parse_extra_allowed_endpoints("POST:batches,GET:batches") == {
+ "batches": frozenset({"POST", "GET"})
+ }
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ "",
+ " ",
+ "v1/rerank", # no method
+ "POST:", # no path
+ ":v1/rerank", # empty method
+ "DELETE:v1/rerank", # method the proxy never routes
+ "POST:*", # wildcards are deliberately unsupported
+ "POST:v1/*",
+ "POST:../secret", # ambiguous spellings are screened here too
+ "POST:v1//rerank",
+ "POST:%2e%2e/secret",
+ ],
+)
+def test_malformed_operator_additions_widen_nothing(raw: str) -> None:
+ assert _parse_extra_allowed_endpoints(raw) == {}
+
+
+def test_operator_additions_are_env_only() -> None:
+ # The proxy parses this once at import, so a persisted or admin-API-writable
+ # value would be read but never take effect. Keeping it env-only also means
+ # widening the reachable upstream surface takes a deploy.
+ from routstr.core.settings import ENV_ONLY_FIELDS
+
+ assert "proxy_extra_allowed_paths" in ENV_ONLY_FIELDS
+
+
+def test_ehbp_is_gated_by_the_same_allowlist() -> None:
+ # EHBP hides the request body from the proxy, which is a reason to constrain
+ # the destination more tightly rather than to trust the caller's path: the
+ # encrypted contract covers the body, never the endpoint the provider
+ # credential is spent against.
+ assert _forwarding_allowed("anything/encrypted", "POST") is False
+ assert _forwarding_allowed("v1/organization/api_keys", "POST") is False
+ assert _forwarding_allowed("v1/chat/completions", "POST") is True
diff --git a/tests/unit/test_proxy_tinfoil_attestation_routing.py b/tests/unit/test_proxy_tinfoil_attestation_routing.py
index cd335f3b..c367a049 100644
--- a/tests/unit/test_proxy_tinfoil_attestation_routing.py
+++ b/tests/unit/test_proxy_tinfoil_attestation_routing.py
@@ -103,13 +103,14 @@ async def test_attestation_trailing_slash_routes_directly_to_tinfoil(
@pytest.mark.parametrize(
- ("path", "expected_status"),
+ "path",
[
- # Valid `attestation` segment but not the exact attestation route:
- # fails model validation (empty body -> unknown model) before auth.
- ("attestation/foo", 400),
- # Not a known path segment at all: rejected at the edge before routing.
- ("attestationjunk", 404),
+ # A valid `attestation` segment is not the exact attestation route, and
+ # `attestation` takes no id segment, so the endpoint allowlist rejects
+ # it at the edge rather than letting it reach model/auth handling.
+ "attestation/foo",
+ # Not a known endpoint at all: rejected at the edge before routing.
+ "attestationjunk",
],
)
@pytest.mark.asyncio
@@ -117,7 +118,6 @@ async def test_non_attestation_prefix_does_not_bypass_authentication(
monkeypatch: pytest.MonkeyPatch,
proxy_app: FastAPI,
path: str,
- expected_status: int,
) -> None:
tinfoil = MagicMock()
tinfoil.provider_type = "tinfoil"
@@ -130,9 +130,7 @@ async def test_non_attestation_prefix_does_not_bypass_authentication(
) as client:
response = await client.get(f"/{path}")
- assert response.status_code == expected_status
- if expected_status == 400:
- assert response.json()["error"]["type"] == "invalid_model"
+ assert response.status_code == 404
tinfoil.forward_get_request.assert_not_awaited()
From a6dd105345a1af03acddc8e646e1a6e73d055a84 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Mon, 24 Aug 2026 01:44:48 +0200
Subject: [PATCH 020/120] fix: settle X-Cashu streaming Responses instead of
keeping the token
The streaming /v1/responses X-Cashu handler split the SSE body on the
two-character sequence backslash-n and re-emitted with the same literal,
so a real event stream was never framed into events. Even when a line
happened to parse, usage and model were read at the top level, but the
canonical Responses API nests them under "response" on
response.completed. Both together meant usage was never found, the
refund branch never ran, and the customer's whole token was retained.
cost_data was also only bound inside that branch, so any path that did
reach the re-emission loop would have raised NameError.
Parse the body with a real SSE reader (CRLF, comment/keepalive lines,
multi-line data fields, trailing event without terminator, [DONE]
sentinel), resolve model and usage from the nested response object, and
re-frame each event with genuine newlines so downstream clients get
valid SSE. Settlement now always runs: unmeasured usage goes through the
existing bounded fallback and settles at the authorized maximum with the
remainder refunded, rather than silently keeping the full token.
---
routstr/upstream/base.py | 286 +++++++++++-------
.../test_x_cashu_responses_streaming_sse.py | 215 +++++++++++++
2 files changed, 391 insertions(+), 110 deletions(-)
create mode 100644 tests/unit/test_x_cashu_responses_streaming_sse.py
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index 61f2696f..4080be0f 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -127,6 +127,49 @@ def _inject_cost_response_headers(
headers["X-Routstr-Cost-Usd"] = str(total_usd)
+def _parse_sse_events(content: str) -> list[tuple[list[str], str]]:
+ """Split a buffered SSE body into ``(field_lines, data)`` pairs.
+
+ ``data`` is the newline-joined payload the SSE spec reassembles from every
+ ``data:`` line of one event, so multi-line JSON survives. Comment/keepalive
+ lines are dropped and events carrying no data at all are skipped; the
+ remaining ``event:``/``id:``/``retry:`` fields stay attached to their event
+ so Responses API framing is preserved on re-emission. A trailing event
+ without its blank-line terminator is still returned.
+ """
+ events: list[tuple[list[str], str]] = []
+ normalized = content.replace("\r\n", "\n").replace("\r", "\n")
+ for raw_event in normalized.split("\n\n"):
+ field_lines: list[str] = []
+ data_lines: list[str] = []
+ for line in raw_event.split("\n"):
+ if line.startswith("data:"):
+ data_lines.append(line[len("data:") :].lstrip(" "))
+ elif line and not line.startswith(":"):
+ field_lines.append(line)
+ if not data_lines:
+ continue
+ events.append((field_lines, "\n".join(data_lines)))
+ return events
+
+
+def _responses_usage_payload(data_json: dict) -> dict:
+ """Return the object carrying a Responses API event's model and usage.
+
+ Canonical events nest them under ``response`` (``response.completed`` /
+ ``response.incomplete``); legacy and compat shapes keep them at top level.
+ """
+ nested = data_json.get("response")
+ return nested if isinstance(nested, dict) else data_json
+
+
+def _render_sse_event(field_lines: list[str], data: str) -> str:
+ """Re-frame one parsed event, re-prefixing every line of a multi-line data."""
+ body = "".join(f"{line}\n" for line in field_lines)
+ body += "".join(f"data: {line}\n" for line in data.split("\n"))
+ return body + "\n"
+
+
def _inject_cost_into_usage(response_json: dict, cost_data: CostMetadata) -> None:
"""Inject cost breakdown into the response body's ``usage.cost`` object.
@@ -4670,12 +4713,14 @@ class BaseUpstreamProvider:
Similar to regular streaming but handles Responses API specific tokens like reasoning_tokens.
"""
+ events = _parse_sse_events(content_str)
+
logger.debug(
"Processing streaming Responses API response",
extra={
"amount": amount,
"unit": unit,
- "content_lines": len(content_str.strip().split("\\n")),
+ "event_count": len(events),
},
)
@@ -4685,30 +4730,49 @@ class BaseUpstreamProvider:
if "content-encoding" in response_headers:
del response_headers["content-encoding"]
- usage_data = None
- model = None
+ usage_data: dict | None = None
+ model: str | None = None
reasoning_tokens = 0
+ cost_data: CostData | MaxCostData | None = None
- lines = content_str.strip().split("\\n")
- for line in lines:
- if line.startswith("data: "):
- try:
- data_json = json.loads(line[6:])
- if "usage" in data_json:
- usage_data = data_json["usage"]
- model = data_json.get("model")
- # Track reasoning tokens for Responses API
- if (
- isinstance(usage_data, dict)
- and "reasoning_tokens" in usage_data
- ):
- reasoning_tokens = usage_data.get("reasoning_tokens", 0)
- elif "model" in data_json and not model:
- model = data_json["model"]
- except json.JSONDecodeError:
- continue
+ for _fields, data in events:
+ if data.strip() == "[DONE]":
+ continue
+ try:
+ data_json = json.loads(data)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(data_json, dict):
+ continue
+ # Canonical Responses API events carry model and usage nested under
+ # "response" (response.completed/incomplete); older shapes put them
+ # at the top level.
+ payload = _responses_usage_payload(data_json)
+ if isinstance(payload.get("usage"), dict):
+ usage_data = payload["usage"]
+ model = payload.get("model") or model
+ details = usage_data.get("output_tokens_details")
+ if isinstance(details, dict):
+ reasoning_tokens = details.get("reasoning_tokens", 0)
+ elif "reasoning_tokens" in usage_data:
+ reasoning_tokens = usage_data["reasoning_tokens"]
+ elif not model and payload.get("model"):
+ model = payload["model"]
- if usage_data and model:
+ if usage_data is None:
+ # Settlement invariant: a terminal request is never silently
+ # zero-billed and never silently keeps the whole token. Unmeasured
+ # usage settles at the authorization ceiling and refunds the rest.
+ logger.warning(
+ "No usage in streaming Responses API response — settling at authorized max",
+ extra={
+ "model": model,
+ "amount": amount,
+ "unit": unit,
+ "max_cost_msats": max_cost_for_model,
+ },
+ )
+ else:
logger.debug(
"Found usage data in streaming Responses API response",
extra={
@@ -4720,97 +4784,99 @@ class BaseUpstreamProvider:
},
)
- response_data = {"usage": usage_data, "model": model}
+ response_data = {"usage": usage_data, "model": model or "unknown"}
+ try:
+ cost_data = await self.get_x_cashu_cost(
+ response_data, max_cost_for_model, model_obj
+ )
+ if cost_data:
+ if unit == "msat":
+ refund_amount = amount - cost_data.total_msats
+ elif unit == "sat":
+ refund_amount = amount - (cost_data.total_msats + 999) // 1000
+ else:
+ raise ValueError(f"Invalid unit: {unit}")
+
+ if refund_amount > 0:
+ logger.debug(
+ "Processing refund for streaming Responses API response",
+ extra={
+ "original_amount": amount,
+ "cost_msats": cost_data.total_msats,
+ "refund_amount": refund_amount,
+ "unit": unit,
+ "model": model,
+ "reasoning_tokens": reasoning_tokens,
+ },
+ )
+
+ 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 Responses API 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 Responses API 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 Responses API response",
+ extra={
+ "error": str(e),
+ "error_type": type(e).__name__,
+ "model": model,
+ "amount": amount,
+ "unit": unit,
+ },
+ )
+
+ for i, (fields, data) in enumerate(events):
+ if data.strip() == "[DONE]":
+ continue
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 Responses API response",
- extra={
- "original_amount": amount,
- "cost_msats": cost_data.total_msats,
- "refund_amount": refund_amount,
- "unit": unit,
- "model": model,
- "reasoning_tokens": reasoning_tokens,
- },
- )
-
- 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 Responses API 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 Responses API 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 Responses API 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:
- data_json = json.loads(line[6:])
- if not isinstance(data_json, dict):
- continue
- changed = False
- if "provider" not in data_json:
- self._apply_provider_field(data_json)
- changed = True
- if cost_data and "usage" in data_json and data_json["usage"]:
- _inject_cost_into_usage(data_json, cost_data)
- changed = True
- if changed:
- lines[i] = "data: " + json.dumps(data_json)
- except json.JSONDecodeError:
- pass
+ data_json = json.loads(data)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(data_json, dict):
+ continue
+ changed = False
+ if "provider" not in data_json:
+ self._apply_provider_field(data_json)
+ changed = True
+ payload = _responses_usage_payload(data_json)
+ if cost_data and isinstance(payload.get("usage"), dict):
+ _inject_cost_into_usage(payload, cost_data)
+ changed = True
+ if changed:
+ events[i] = (fields, json.dumps(data_json))
async def generate() -> AsyncGenerator[bytes, None]:
- for line in lines:
- yield (line + "\\n").encode("utf-8")
+ for fields, data in events:
+ yield _render_sse_event(fields, data).encode("utf-8")
return StreamingResponse(
generate(),
diff --git a/tests/unit/test_x_cashu_responses_streaming_sse.py b/tests/unit/test_x_cashu_responses_streaming_sse.py
new file mode 100644
index 00000000..0ecc56ca
--- /dev/null
+++ b/tests/unit/test_x_cashu_responses_streaming_sse.py
@@ -0,0 +1,215 @@
+"""X-Cashu settlement for streaming ``/v1/responses``.
+
+The stream is real SSE: CRLF delimiters, comment keepalives, ``event:`` fields,
+multi-line ``data:`` payloads and a ``[DONE]`` sentinel. Canonical Responses API
+usage arrives nested under ``response`` on ``response.completed``.
+"""
+
+import json
+import os
+from typing import Any
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+
+os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
+os.environ.setdefault("UPSTREAM_API_KEY", "test")
+
+from routstr.payment.cost_calculation import CostData # noqa: E402
+from routstr.upstream.base import BaseUpstreamProvider # noqa: E402
+
+
+def _make_provider() -> BaseUpstreamProvider:
+ return BaseUpstreamProvider(base_url="http://test", api_key="test-key")
+
+
+def _make_cost_data(total_msats: int = 4000) -> CostData:
+ return CostData(
+ base_msats=0,
+ input_msats=2500,
+ output_msats=1500,
+ total_msats=total_msats,
+ total_usd=0.0002,
+ input_tokens=12,
+ output_tokens=8,
+ )
+
+
+def _sse_response(chunks: list[bytes]) -> httpx.Response:
+ """Build the upstream response from wire chunks that split events."""
+ return httpx.Response(
+ 200,
+ headers={"content-type": "text/event-stream"},
+ content=b"".join(chunks),
+ )
+
+
+COMPLETED_EVENT = {
+ "type": "response.completed",
+ "response": {
+ "model": "gpt-5-mini",
+ "usage": {
+ "input_tokens": 12,
+ "output_tokens": 8,
+ "total_tokens": 20,
+ "output_tokens_details": {"reasoning_tokens": 3},
+ },
+ },
+}
+
+
+def _canonical_chunks() -> list[bytes]:
+ """CRLF stream whose completed event straddles two wire chunks."""
+ completed = json.dumps(COMPLETED_EVENT).encode()
+ return [
+ b": keepalive\r\n\r\n",
+ b"event: response.created\r\n"
+ b'data: {"type":"response.created","response":{"model":"gpt-5-mini"}}\r\n\r\n',
+ b"event: response.completed\r\ndata: " + completed[:40],
+ completed[40:] + b"\r\n\r\n",
+ b"data: [DONE]\r\n\r\n",
+ ]
+
+
+async def _collect(response: Any) -> bytes:
+ body = b""
+ async for chunk in response.body_iterator:
+ body += chunk
+ return body
+
+
+async def _settle(
+ chunks: list[bytes],
+ *,
+ amount: int = 10_000,
+ max_cost_for_model: int = 9_000,
+ cost_data: CostData | None = None,
+) -> tuple[Any, AsyncMock, AsyncMock]:
+ provider = _make_provider()
+ get_cost = (
+ AsyncMock(return_value=cost_data)
+ if cost_data is not None
+ else AsyncMock(side_effect=provider.get_x_cashu_cost)
+ )
+ send_refund = AsyncMock(return_value="cashuBrefundtoken0123456789")
+ with (
+ patch.object(provider, "get_x_cashu_cost", new=get_cost),
+ patch.object(provider, "send_refund", new=send_refund),
+ ):
+ response = await provider.handle_x_cashu_responses_completion(
+ response=_sse_response(chunks),
+ amount=amount,
+ unit="msat",
+ max_cost_for_model=max_cost_for_model,
+ mint=None,
+ )
+ return response, get_cost, send_refund
+
+
+@pytest.mark.asyncio
+async def test_fragmented_crlf_stream_refunds_and_sets_cost_headers() -> None:
+ response, _, send_refund = await _settle(
+ _canonical_chunks(), cost_data=_make_cost_data(4000)
+ )
+
+ send_refund.assert_awaited_once()
+ assert send_refund.await_args.args[0] == 10_000 - 4000
+ assert response.headers["x-cashu"] == "cashuBrefundtoken0123456789"
+ assert response.headers["x-routstr-cost-msats"] == "4000"
+ assert response.headers["x-routstr-input-cost-msats"] == "2500"
+ assert response.headers["x-routstr-output-cost-msats"] == "1500"
+
+
+@pytest.mark.asyncio
+async def test_nested_completion_usage_drives_cost_calculation() -> None:
+ _, get_cost, _ = await _settle(_canonical_chunks(), cost_data=_make_cost_data(4000))
+
+ response_data = get_cost.await_args.args[0]
+ assert response_data["model"] == "gpt-5-mini"
+ assert response_data["usage"]["input_tokens"] == 12
+ assert response_data["usage"]["output_tokens"] == 8
+
+
+@pytest.mark.asyncio
+async def test_reemitted_stream_is_valid_sse() -> None:
+ response, _, _ = await _settle(_canonical_chunks(), cost_data=_make_cost_data(4000))
+ body = await _collect(response)
+
+ assert b"\\n" not in body
+ assert body.endswith(b"\n\n")
+ assert b": keepalive" not in body
+
+ events = [e for e in body.split(b"\n\n") if e.strip()]
+ payloads = []
+ for event in events:
+ data_lines = [
+ line[len(b"data:") :].lstrip()
+ for line in event.split(b"\n")
+ if line.startswith(b"data:")
+ ]
+ assert data_lines, f"event carries no data line: {event!r}"
+ payloads.append(b"\n".join(data_lines))
+
+ assert payloads[-1] == b"[DONE]"
+ assert any(b"event: response.completed" in event for event in events)
+
+ completed = json.loads(payloads[-2])
+ assert completed["type"] == "response.completed"
+ assert completed["response"]["usage"]["cost"]["total_msats"] == 4000
+
+
+@pytest.mark.asyncio
+async def test_multiline_data_payload_is_parsed_and_reframed() -> None:
+ completed = json.dumps(COMPLETED_EVENT)
+ head, tail = completed[:30], completed[30:]
+ chunks = [
+ ("data: " + head + "\r\ndata: " + tail + "\r\n\r\n").encode(),
+ b"data: [DONE]\r\n\r\n",
+ ]
+
+ response, get_cost, send_refund = await _settle(
+ chunks, cost_data=_make_cost_data(4000)
+ )
+
+ assert get_cost.await_args.args[0]["usage"]["input_tokens"] == 12
+ assert send_refund.await_args.args[0] == 6000
+ body = await _collect(response)
+ for event in body.split(b"\n\n"):
+ for line in event.split(b"\n"):
+ if line.strip():
+ assert line.startswith(b"data:") or line.startswith(b"event:")
+
+
+@pytest.mark.asyncio
+async def test_missing_usage_settles_at_authorized_max() -> None:
+ chunks = [
+ b'data: {"type":"response.created","response":{"model":"gpt-5-mini"}}\r\n\r\n',
+ b"data: [DONE]\r\n\r\n",
+ ]
+
+ response, _, send_refund = await _settle(
+ chunks, amount=10_000, max_cost_for_model=9_000
+ )
+
+ send_refund.assert_awaited_once()
+ assert send_refund.await_args.args[0] == 10_000 - 9_000
+ assert response.headers["x-cashu"] == "cashuBrefundtoken0123456789"
+ assert response.headers["x-routstr-cost-msats"] == "9000"
+
+
+@pytest.mark.asyncio
+async def test_malformed_events_do_not_retain_whole_token() -> None:
+ chunks = [
+ b"data: {not json\r\n\r\n",
+ b"data: [DONE]\r\n\r\n",
+ ]
+
+ response, _, send_refund = await _settle(
+ chunks, amount=10_000, max_cost_for_model=9_000
+ )
+
+ assert send_refund.await_args.args[0] == 1000
+ body = await _collect(response)
+ assert b"\\n" not in body
+ assert body.endswith(b"\n\n")
From c11b6d30c5aed34f5332ce04ceb795b282d6f4d4 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Mon, 24 Aug 2026 01:47:07 +0200
Subject: [PATCH 021/120] fix: verify LNURL invoice amount and destination
before reserving
raw_send_to_lnurl asked an LNURL service for an invoice of a specific
amount and then quoted and melted whatever invoice came back, without
ever checking the two agreed. A malicious or compromised payout service
could return an invoice for far more than requested and be paid up to
the value of the selected proofs. The melt quote is the mint's own
reading of the invoice, so it is compared against the requested amount
in the wallet unit before the quote checkpoint and before any proof is
reserved: a mismatch now leaves no durable state behind.
Destinations were equally unguarded. A bech32 LNURL could decode to a
plaintext or internal URL, and both fetches followed redirects blindly,
so a public https origin could bounce the request onto loopback or link
local metadata addresses. Redirects are now followed manually with the
scheme and host re-checked at every hop, and the callback URL taken
from the payRequest body is checked the same way. Error messages no
longer echo service-controlled response bodies into operator logs.
The user refund path reserved proofs and then called raw_send_to_lnurl
without an amount, which the callee rejected before dispatch. Every
such refund failed with the proofs still locked, and the proof rollback
that covers a dispatched melt does not reach that stage. send_to_lnurl
now hands over the unreserved available proofs and the amount, leaving
reservation to happen only after the destination, the invoice amount
and the quote have all been accepted. Pre-dispatch rejection therefore
unwinds cleanly, while an ambiguous dispatch still raises
MeltOutcomeAmbiguousError and keeps the debit for reconciliation.
The missing amount was previously caught by a bare assert, which was
both unreachable for the payout callers and the wrong failure mode for
the refund caller; it is now an explicit validation.
---
routstr/payment/lnurl.py | 138 ++++++---
routstr/wallet.py | 6 +-
.../unit/test_lnurl_amount_and_destination.py | 293 ++++++++++++++++++
tests/unit/test_lnurl_melt_timeout.py | 8 +-
4 files changed, 406 insertions(+), 39 deletions(-)
create mode 100644 tests/unit/test_lnurl_amount_and_destination.py
diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py
index 37ecb1cf..c9f48253 100644
--- a/routstr/payment/lnurl.py
+++ b/routstr/payment/lnurl.py
@@ -1,8 +1,9 @@
from __future__ import annotations
+import ipaddress
import math
from collections.abc import Awaitable, Callable
-from typing import TypedDict
+from typing import Any, TypedDict
import httpx
from cashu.core.base import MeltQuoteState
@@ -42,6 +43,70 @@ class MeltOutcomeAmbiguousError(LNURLError):
"""
+_MAX_LNURL_REDIRECTS = 3
+_NON_PUBLIC_HOST_SUFFIXES = (".localhost", ".local", ".internal")
+
+
+def _require_public_https_destination(url: httpx.URL) -> None:
+ """Reject anything that is not a public HTTPS endpoint.
+
+ LNURL destinations and their redirect targets are attacker-influenced, so
+ every hop has to be re-checked: a single ``https://`` origin says nothing
+ about where a 302 points.
+ """
+ if url.scheme != "https":
+ raise LNURLError("LNURL destination must be an HTTPS URL")
+
+ host = (url.host or "").rstrip(".").lower()
+ if not host:
+ raise LNURLError("LNURL destination has no host")
+
+ try:
+ address = ipaddress.ip_address(host)
+ except ValueError:
+ if host == "localhost" or host.endswith(_NON_PUBLIC_HOST_SUFFIXES):
+ raise LNURLError("LNURL destination is not a public host") from None
+ return
+
+ if not address.is_global:
+ raise LNURLError("LNURL destination is not a public host")
+
+
+async def _fetch_lnurl_json(
+ url: str, params: dict[str, int] | None = None
+) -> dict[str, Any]:
+ """GET an LNURL endpoint, validating the destination at every redirect.
+
+ Response bodies are never echoed: an LNURL service is untrusted, and its
+ payload would otherwise reach operator logs through raised errors.
+ """
+ try:
+ target = httpx.URL(url, params=params) if params else httpx.URL(url)
+ except httpx.InvalidURL as e:
+ raise LNURLError("LNURL destination is not a usable URL") from e
+ _require_public_https_destination(target)
+
+ async with httpx.AsyncClient() as client:
+ for _ in range(_MAX_LNURL_REDIRECTS + 1):
+ response = await client.get(target, follow_redirects=False, timeout=10)
+ if not response.is_redirect:
+ break
+ target = target.join(response.headers.get("location", ""))
+ _require_public_https_destination(target)
+ else:
+ raise LNURLError("LNURL destination exceeded the redirect limit")
+ response.raise_for_status()
+
+ try:
+ data = response.json()
+ except ValueError as e:
+ raise LNURLError("LNURL response was not valid JSON") from e
+
+ if not isinstance(data, dict):
+ raise LNURLError("LNURL response was not a JSON object")
+ return data
+
+
async def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to get the actual URL.
@@ -111,26 +176,29 @@ async def get_lnurl_data(lnurl: str) -> LNURLData:
httpx.HTTPError: If the HTTP request fails
"""
url = await decode_lnurl(lnurl)
-
- async with httpx.AsyncClient() as client:
- response = await client.get(url, follow_redirects=True, timeout=10)
- response.raise_for_status()
-
- lnurl_data = response.json()
+ lnurl_data = await _fetch_lnurl_json(url)
# Validate payRequest data
if lnurl_data.get("tag") != "payRequest":
- raise LNURLError(
- f"Invalid LNURL tag: expected 'payRequest', got '{lnurl_data.get('tag')}'"
- )
+ raise LNURLError("Invalid LNURL tag: expected 'payRequest'")
- if not isinstance(lnurl_data.get("callback"), str):
+ callback_url = lnurl_data.get("callback")
+ if not isinstance(callback_url, str):
raise LNURLError("Invalid LNURL payRequest: missing callback URL")
+ try:
+ _require_public_https_destination(httpx.URL(callback_url))
+ except httpx.InvalidURL as e:
+ raise LNURLError("Invalid LNURL callback URL") from e
+
+ min_sendable = lnurl_data.get("minSendable", 1000) # Default 1 sat
+ max_sendable = lnurl_data.get("maxSendable", 1000000000) # Default 1000 BTC
+ if not isinstance(min_sendable, int) or not isinstance(max_sendable, int):
+ raise LNURLError("Invalid LNURL payRequest: non-integer sendable limits")
return LNURLData(
- callback_url=lnurl_data["callback"],
- min_sendable=lnurl_data.get("minSendable", 1000), # Default 1 sat
- max_sendable=lnurl_data.get("maxSendable", 1000000000), # Default 1000 BTC
+ callback_url=callback_url,
+ min_sendable=min_sendable,
+ max_sendable=max_sendable,
)
@@ -150,22 +218,10 @@ async def get_lnurl_invoice(
LNURLError: If the response is invalid
httpx.HTTPError: If the HTTP request fails
"""
- async with httpx.AsyncClient() as client:
- response = await client.get(
- callback_url,
- params={"amount": amount_msat},
- follow_redirects=True,
- timeout=10,
- )
- response.raise_for_status()
+ invoice_data = await _fetch_lnurl_json(callback_url, params={"amount": amount_msat})
- invoice_data = response.json()
-
- if "pr" not in invoice_data:
- # Check if there's an error in the response
- if "reason" in invoice_data:
- raise LNURLError(f"LNURL error: {invoice_data['reason']}")
- raise LNURLError(f"Invalid LNURL invoice response: {invoice_data}")
+ if not isinstance(invoice_data.get("pr"), str):
+ raise LNURLError("LNURL callback returned no invoice")
return invoice_data["pr"], invoice_data
@@ -201,12 +257,11 @@ async def raw_send_to_lnurl(
# Send USD to Lightning Address
paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd")
"""
- total_balance = sum(proof.amount for proof in proofs)
- if amount and total_balance < amount:
+ if not isinstance(amount, int) or isinstance(amount, bool) or amount <= 0:
+ raise ValueError("A positive integer amount is required to send to an LNURL.")
+ if sum(proof.amount for proof in proofs) < amount:
raise ValueError("Amount to send is higher than available proofs.")
- else:
- assert isinstance(amount, int)
- total_balance = amount
+ total_balance = amount
lnurl_data = await get_lnurl_data(lnurl)
if unit == "sat":
@@ -240,11 +295,22 @@ async def raw_send_to_lnurl(
mint_url=str(wallet.url),
)
+ # The invoice comes from the LNURL service, so its amount is untrusted. The
+ # melt quote is the mint's own reading of it, and it must match what we
+ # asked to send. Checked before the checkpoint and before reserving, so a
+ # mismatch leaves no durable state and no locked proofs behind.
+ quoted_amount = int(melt_quote_resp.amount)
+ expected_amount = final_amount // 1000 if unit == "sat" else final_amount
+ if quoted_amount != expected_amount:
+ raise LNURLError(
+ f"LNURL invoice amount does not match the requested amount "
+ f"(quoted {quoted_amount} {unit}, expected {expected_amount} {unit})"
+ )
+
if on_melt_quote is not None:
await on_melt_quote(melt_quote_resp.quote)
- if amount:
- proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
+ proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
try:
melt_response = await run_mint_operation(
diff --git a/routstr/wallet.py b/routstr/wallet.py
index f83ff8a6..c6cb1238 100644
--- a/routstr/wallet.py
+++ b/routstr/wallet.py
@@ -2622,8 +2622,10 @@ async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
mint = await find_trusted_mint_with_funds(amount, unit, mint, force_reload=True)
wallet = await get_wallet(mint, unit)
available = get_proofs_per_mint_and_unit(wallet, mint, unit, not_reserved=True)
- proofs, _ = await wallet.select_to_send(available, amount, set_reserved=True)
- return await raw_send_to_lnurl(wallet, proofs, address, unit)
+ # Hand over unreserved proofs: raw_send_to_lnurl reserves only once the
+ # destination, the invoice amount and the melt quote have all been
+ # accepted, so a rejected refund cannot strand locked proofs.
+ return await raw_send_to_lnurl(wallet, available, address, unit, amount=amount)
# class Payment:
diff --git a/tests/unit/test_lnurl_amount_and_destination.py b/tests/unit/test_lnurl_amount_and_destination.py
new file mode 100644
index 00000000..5ba459d9
--- /dev/null
+++ b/tests/unit/test_lnurl_amount_and_destination.py
@@ -0,0 +1,293 @@
+"""LNURL payments must verify the invoice amount and the destination.
+
+Findings 4 and 5 ship together: the amount check is what makes it safe to
+hand an LNURL a set of unreserved proofs, and reserving only after that check
+is what stops a pre-dispatch failure from stranding proofs.
+"""
+
+from collections.abc import Callable
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+from cashu.core.base import MeltQuoteState
+
+from routstr.payment import lnurl as lnurl_module
+from routstr.payment.lnurl import (
+ LNURLError,
+ get_lnurl_data,
+ get_lnurl_invoice,
+ raw_send_to_lnurl,
+)
+
+LNURL_DATA = {
+ "callback_url": "https://ln.tld/cb",
+ "min_sendable": 1_000,
+ "max_sendable": 100_000_000,
+}
+
+# 1000 sat minus the 11 sat estimated fee reserve.
+EXPECTED_QUOTE_SAT = 989
+
+
+def _wallet(
+ quote_amount: int = EXPECTED_QUOTE_SAT,
+) -> tuple[MagicMock, list[MagicMock]]:
+ proofs = [MagicMock(amount=1000)]
+ wallet = MagicMock(url="https://mint.test")
+ wallet.melt_quote = AsyncMock(
+ return_value=MagicMock(fee_reserve=1, quote="q", amount=quote_amount)
+ )
+ wallet.select_to_send = AsyncMock(return_value=(proofs, None))
+ wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.paid))
+ wallet.set_reserved_for_send = AsyncMock()
+ return wallet, proofs
+
+
+def _lnurl_patches() -> tuple[Any, Any]:
+ return (
+ patch(
+ "routstr.payment.lnurl.get_lnurl_data",
+ AsyncMock(return_value=LNURL_DATA),
+ ),
+ patch(
+ "routstr.payment.lnurl.get_lnurl_invoice",
+ AsyncMock(return_value=("lnbc1...", {})),
+ ),
+ )
+
+
+def _mock_client(handler: Callable[[httpx.Request], httpx.Response]) -> Any:
+ real_client = httpx.AsyncClient
+
+ def factory(*_args: object, **_kwargs: object) -> httpx.AsyncClient:
+ return real_client(transport=httpx.MockTransport(handler))
+
+ return patch.object(lnurl_module.httpx, "AsyncClient", factory)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "quote_amount", [EXPECTED_QUOTE_SAT + 1, EXPECTED_QUOTE_SAT * 5]
+)
+async def test_raw_send_to_lnurl_rejects_oversized_invoice(quote_amount: int) -> None:
+ wallet, proofs = _wallet(quote_amount)
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with data_patch, invoice_patch, pytest.raises(LNURLError, match="invoice amount"):
+ await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
+
+ wallet.select_to_send.assert_not_awaited()
+ wallet.melt.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_raw_send_to_lnurl_rejects_undersized_invoice() -> None:
+ wallet, proofs = _wallet(EXPECTED_QUOTE_SAT - 1)
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with data_patch, invoice_patch, pytest.raises(LNURLError, match="invoice amount"):
+ await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
+
+ wallet.select_to_send.assert_not_awaited()
+ wallet.melt.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_raw_send_to_lnurl_rejects_invoice_before_quote_checkpoint() -> None:
+ wallet, proofs = _wallet(EXPECTED_QUOTE_SAT * 2)
+ checkpoint = AsyncMock()
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with data_patch, invoice_patch, pytest.raises(LNURLError):
+ await raw_send_to_lnurl(
+ wallet,
+ proofs,
+ "owner@ln.tld",
+ "sat",
+ amount=1000,
+ on_melt_quote=checkpoint,
+ )
+
+ checkpoint.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_raw_send_to_lnurl_accepts_exact_invoice() -> None:
+ wallet, proofs = _wallet()
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with data_patch, invoice_patch:
+ paid = await raw_send_to_lnurl(
+ wallet, proofs, "owner@ln.tld", "sat", amount=1000
+ )
+
+ assert paid == EXPECTED_QUOTE_SAT * 1000
+ wallet.select_to_send.assert_awaited_once()
+ wallet.melt.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_raw_send_to_lnurl_msat_unit_compares_in_wallet_unit() -> None:
+ # 1_000_000 msat minus an 11 sat fee reserve leaves 989_000 msat.
+ wallet, proofs = _wallet(989_000)
+ proofs[0].amount = 1_000_000
+ wallet.select_to_send = AsyncMock(return_value=(proofs, None))
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with data_patch, invoice_patch:
+ paid = await raw_send_to_lnurl(
+ wallet, proofs, "owner@ln.tld", "msat", amount=1_000_000
+ )
+
+ assert paid == 989_000
+
+
+@pytest.mark.asyncio
+async def test_raw_send_to_lnurl_requires_an_explicit_amount() -> None:
+ wallet, proofs = _wallet()
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with data_patch, invoice_patch, pytest.raises(ValueError, match="amount"):
+ await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat")
+
+ wallet.select_to_send.assert_not_awaited()
+ wallet.melt.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "address",
+ [
+ "owner@127.0.0.1",
+ "owner@localhost",
+ "owner@10.0.0.5",
+ "owner@[::1]",
+ "http://ln.tld/lnurlp/owner",
+ ],
+)
+async def test_get_lnurl_data_rejects_non_public_destination(address: str) -> None:
+ requested: list[str] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requested.append(str(request.url))
+ return httpx.Response(
+ 200, json={"tag": "payRequest", "callback": "https://x/y"}
+ )
+
+ with _mock_client(handler), pytest.raises(LNURLError):
+ await get_lnurl_data(address)
+
+ assert requested == []
+
+
+@pytest.mark.asyncio
+async def test_get_lnurl_data_rejects_redirect_to_private_host() -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.host == "ln.tld":
+ return httpx.Response(
+ 302, headers={"location": "https://169.254.169.254/latest/meta-data"}
+ )
+ return httpx.Response(
+ 200, json={"tag": "payRequest", "callback": "https://x/y"}
+ )
+
+ with _mock_client(handler), pytest.raises(LNURLError, match="destination"):
+ await get_lnurl_data("owner@ln.tld")
+
+
+@pytest.mark.asyncio
+async def test_get_lnurl_data_rejects_downgrade_redirect() -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.scheme == "https":
+ return httpx.Response(302, headers={"location": "http://ln.tld/plain"})
+ return httpx.Response(
+ 200, json={"tag": "payRequest", "callback": "https://x/y"}
+ )
+
+ with _mock_client(handler), pytest.raises(LNURLError, match="destination"):
+ await get_lnurl_data("owner@ln.tld")
+
+
+@pytest.mark.asyncio
+async def test_get_lnurl_data_rejects_private_callback_url() -> None:
+ def handler(_request: httpx.Request) -> httpx.Response:
+ return httpx.Response(
+ 200, json={"tag": "payRequest", "callback": "http://127.0.0.1:8000/cb"}
+ )
+
+ with _mock_client(handler), pytest.raises(LNURLError, match="destination"):
+ await get_lnurl_data("owner@ln.tld")
+
+
+@pytest.mark.asyncio
+async def test_get_lnurl_data_error_does_not_leak_response_body() -> None:
+ secret = "SUPERSECRETBODYMARKER"
+
+ def handler(_request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json={"tag": secret, "callback": secret})
+
+ with _mock_client(handler), pytest.raises(LNURLError) as excinfo:
+ await get_lnurl_data("owner@ln.tld")
+
+ assert secret not in str(excinfo.value)
+
+
+@pytest.mark.asyncio
+async def test_get_lnurl_invoice_error_does_not_leak_response_body() -> None:
+ secret = "SUPERSECRETBODYMARKER"
+
+ def handler(_request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, json={"reason": secret, "internal": secret})
+
+ with _mock_client(handler), pytest.raises(LNURLError) as excinfo:
+ await get_lnurl_invoice("https://ln.tld/cb", 1000)
+
+ assert secret not in str(excinfo.value)
+
+
+@pytest.mark.asyncio
+async def test_get_lnurl_invoice_rejects_redirect_to_private_host() -> None:
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.host == "ln.tld":
+ return httpx.Response(302, headers={"location": "https://192.168.1.1/cb"})
+ return httpx.Response(200, json={"pr": "lnbc1..."})
+
+ with _mock_client(handler), pytest.raises(LNURLError, match="destination"):
+ await get_lnurl_invoice("https://ln.tld/cb", 1000)
+
+
+@pytest.mark.asyncio
+async def test_send_to_lnurl_does_not_reserve_before_lnurl_validation() -> None:
+ from routstr import wallet as wallet_module
+
+ wallet, proofs = _wallet()
+
+ with (
+ patch.object(
+ wallet_module,
+ "find_trusted_mint_with_funds",
+ AsyncMock(return_value="https://mint.test"),
+ ),
+ patch.object(wallet_module, "get_wallet", AsyncMock(return_value=wallet)),
+ patch.object(
+ wallet_module,
+ "get_proofs_per_mint_and_unit",
+ MagicMock(return_value=proofs),
+ ),
+ patch.object(
+ wallet_module,
+ "raw_send_to_lnurl",
+ AsyncMock(side_effect=LNURLError("destination rejected")),
+ ) as raw_send,
+ pytest.raises(LNURLError),
+ ):
+ await wallet_module.send_to_lnurl(
+ 1000, "sat", "https://mint.test", "owner@ln.tld"
+ )
+
+ wallet.select_to_send.assert_not_awaited()
+ assert raw_send.await_args is not None
+ assert raw_send.await_args.args[1] is proofs
+ assert raw_send.await_args.kwargs["amount"] == 1000
diff --git a/tests/unit/test_lnurl_melt_timeout.py b/tests/unit/test_lnurl_melt_timeout.py
index a370a7a8..dc35ab4c 100644
--- a/tests/unit/test_lnurl_melt_timeout.py
+++ b/tests/unit/test_lnurl_melt_timeout.py
@@ -22,10 +22,16 @@ LNURL_DATA = {
}
+# 1000 sat minus the 11 sat estimated fee reserve.
+QUOTE_AMOUNT_SAT = 989
+
+
def _wallet() -> tuple[MagicMock, list[MagicMock]]:
proofs = [MagicMock(amount=1000)]
wallet = MagicMock(url="https://mint.test")
- wallet.melt_quote = AsyncMock(return_value=MagicMock(fee_reserve=1, quote="q"))
+ wallet.melt_quote = AsyncMock(
+ return_value=MagicMock(fee_reserve=1, quote="q", amount=QUOTE_AMOUNT_SAT)
+ )
wallet.select_to_send = AsyncMock(return_value=(proofs, None))
return wallet, proofs
From c2b807bb20a564b19013cff18801ca70ba9cb57c Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Mon, 24 Aug 2026 01:48:16 +0200
Subject: [PATCH 022/120] fix: keep spendable credentials out of the structured
logs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The dated JSON log files were a credential store. Structured `extra`
fields bypass the message-level regex scrubbing entirely — the JSON
formatter reads them straight off the record dict — and the only pass
they received was organization-ID redaction. So a full Cashu refund
token, and the `hashed_key` that `sk-` auth accepts as a
live reusable API key, were written verbatim, as were all request query
values. Anyone able to read a log file could spend from it.
Fixed at both ends so neither alone is load-bearing. The call sites stop
handing over the values: balance logs a token length and an eight-char
key prefix, and the request middleware logs query parameter names
without their values. The SecurityFilter then refuses to emit them
anyway, walking every extra recursively and stripping both secret-shaped
keys and secret-shaped values (Cashu tokens, bearer values, `sk-` keys,
nsec keys, full SHA-256 hashes, secret-ish query parameters) wherever
they are nested. The walk is depth-limited and cycle-safe so a malformed
payload degrades to `[REDACTED]` instead of failing the log call, and
numeric values are never touched, which keeps the usage-analytics fields
the dashboard parses intact.
Retention is also wired up: `backupCount` never expired anything here
because the base filename moves with the date, so the inherited rollover
found no siblings and every day of logs was kept forever. Rollover now
prunes explicitly.
---
routstr/balance.py | 10 +-
routstr/core/logging.py | 14 +-
routstr/core/middleware.py | 4 +-
routstr/core/redaction.py | 115 ++++++++++--
tests/unit/test_log_secret_redaction.py | 238 ++++++++++++++++++++++++
5 files changed, 352 insertions(+), 29 deletions(-)
create mode 100644 tests/unit/test_log_secret_redaction.py
diff --git a/routstr/balance.py b/routstr/balance.py
index e7d4468d..10f483f7 100644
--- a/routstr/balance.py
+++ b/routstr/balance.py
@@ -368,7 +368,7 @@ async def _restore_balance(
logger.info(
"refund_wallet_endpoint: balance restored after mint failure",
extra={
- "hashed_key": hashed_key,
+ "key_hash": hashed_key[:8],
"restored_balance": balance,
"mint_url": mint_url,
},
@@ -481,7 +481,7 @@ async def refund_wallet_endpoint(
logger.warning(
"refund_wallet_endpoint: released stale reservation before refund",
extra={
- "hashed_key": key.hashed_key,
+ "key_hash": key.hashed_key[:8],
"stale_timeout_seconds": settings.stale_reservation_timeout_seconds,
},
)
@@ -555,7 +555,7 @@ async def refund_wallet_endpoint(
"refund_wallet_endpoint: cashu token issued",
extra={
"path": "/v1/wallet/refund",
- "token": result["token"],
+ "token_length": len(result["token"]),
"amount": remaining_balance,
"currency": key.refund_currency or "sat",
},
@@ -570,7 +570,7 @@ async def refund_wallet_endpoint(
"pending reconciliation",
extra={
"error": str(e),
- "hashed_key": key.hashed_key,
+ "key_hash": key.hashed_key[:8],
"remaining_balance": remaining_balance,
"refund_currency": key.refund_currency,
"refund_mint_url": key.refund_mint_url,
@@ -608,7 +608,7 @@ async def refund_wallet_endpoint(
extra={
"error": error_msg,
"error_type": type(e).__name__,
- "hashed_key": key.hashed_key,
+ "key_hash": key.hashed_key[:8],
"remaining_balance": remaining_balance,
"refund_currency": key.refund_currency,
"refund_mint_url": key.refund_mint_url,
diff --git a/routstr/core/logging.py b/routstr/core/logging.py
index 3886637c..c6840fb8 100644
--- a/routstr/core/logging.py
+++ b/routstr/core/logging.py
@@ -51,7 +51,7 @@ from pythonjsonlogger import jsonlogger
from rich.console import Console
from rich.logging import RichHandler
-from .redaction import redact_obj, redact_org_ids
+from .redaction import redact_field, redact_org_ids
# Only use RichHandler when stdout is a real TTY. In non-TTY contexts
# (docker logs, pipes, CI) Rich pads every line to width and wraps long
@@ -100,8 +100,10 @@ class DailyRotatingFileHandler(logging.handlers.TimedRotatingFileHandler):
self.baseFilename = new_filename
self.current_date = new_date
- # FIX ME: not sure if we need this
- # self._cleanup_old_files()
+ # `backupCount` alone never prunes these files: the base filename moves
+ # with the date, so the inherited rollover finds no siblings to expire
+ # and every day of logged credentials is retained indefinitely.
+ self._cleanup_old_files()
if not self.delay:
self.stream = self._open()
@@ -260,13 +262,11 @@ class SecurityFilter(logging.Filter):
# Structured `extra={...}` fields are emitted by the JSON formatter
# straight from the record dict and never pass through the message
- # formatting above. Redact organization IDs from any string-valued
- # extra so they cannot leak via structured logs.
+ # formatting above, so they need their own recursive pass.
for attr, value in list(record.__dict__.items()):
if attr in _NON_EXTRA_RECORD_ATTRS:
continue
- if isinstance(value, (str, dict, list, tuple)):
- record.__dict__[attr] = redact_obj(value)
+ record.__dict__[attr] = redact_field(attr, value)
except Exception:
pass
diff --git a/routstr/core/middleware.py b/routstr/core/middleware.py
index 442c0a18..63d43888 100644
--- a/routstr/core/middleware.py
+++ b/routstr/core/middleware.py
@@ -84,7 +84,9 @@ class LoggingMiddleware(BaseHTTPMiddleware):
"request_id": request_id,
"method": request.method,
"path": path,
- "query_params": dict(request.query_params),
+ # Names only: query values carry API keys and refund
+ # tokens on the wallet routes.
+ "query_param_names": sorted(request.query_params.keys()),
},
)
diff --git a/routstr/core/redaction.py b/routstr/core/redaction.py
index c0519581..bf8d9cc6 100644
--- a/routstr/core/redaction.py
+++ b/routstr/core/redaction.py
@@ -1,8 +1,9 @@
-"""Redaction helpers for sensitive provider identifiers.
+"""Redaction helpers for sensitive provider identifiers and credentials.
Single source of truth for stripping account-scoped identifiers (e.g. OpenAI
-organization IDs) from any text before it is logged, returned to a caller, or
-written to an audit entry.
+organization IDs) and spendable credentials (Cashu tokens, bearer keys, key
+hashes) from any text before it is logged, returned to a caller, or written to
+an audit entry.
"""
from __future__ import annotations
@@ -33,19 +34,101 @@ def redact_org_ids(text: str) -> str:
return _ORG_ID_PATTERN.sub(ORG_ID_PLACEHOLDER, text)
-def redact_obj(obj: Any) -> Any:
- """Recursively redact organization IDs in arbitrary nested structures.
+SECRET_PLACEHOLDER = "[REDACTED]"
- Strings are redacted in place; dicts and lists/tuples are walked so that
- identifiers nested inside structured payloads (e.g. log ``extra`` fields or
- error ``details``) are also stripped. Other types are returned unchanged.
- """
+# Field names whose value is spendable or authenticating on its own. Matched as
+# substrings of the lowercased key, so ``hashed_key`` (a live ``sk-`` credential)
+# is stripped while the truncated ``key_hash`` prefix used for correlation is
+# not. Numeric values are never stripped, which keeps ``input_tokens`` and the
+# other usage-analytics fields intact.
+_SECRET_KEY_HINTS = (
+ "authorization",
+ "api_key",
+ "apikey",
+ "bearer",
+ "cashu",
+ "cookie",
+ "credential",
+ "hashed_key",
+ "mnemonic",
+ "nsec",
+ "passphrase",
+ "password",
+ "private_key",
+ "privkey",
+ "secret",
+ "token",
+)
+
+# Value shapes that are spendable wherever they appear, including inside URLs,
+# query strings and free-form error text. Every alternative is anchored on a
+# literal prefix and uses a single bounded character class, so matching stays
+# linear on the logging hot path.
+_SECRET_VALUE_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(r"cashu[A-Z][A-Za-z0-9_\-=/+]{20,}"),
+ re.compile(r"\bnsec1[a-z0-9]{20,}"),
+ re.compile(r"\bBearer\s+[A-Za-z0-9_\-.=]{10,}", re.IGNORECASE),
+ re.compile(r"\bsk-[A-Za-z0-9]{16,}"),
+ re.compile(r"\b[0-9a-f]{64}\b"),
+ re.compile(
+ r"(?<=[?&])([^=&\s]*(?:token|key|secret|password|auth|sig)[^=&\s]*=)[^&\s\"']+",
+ re.IGNORECASE,
+ ),
+)
+
+_MAX_REDACTION_DEPTH = 12
+
+
+def _redact_secret_text(text: str) -> str:
+ for pattern in _SECRET_VALUE_PATTERNS:
+ text = pattern.sub(
+ lambda match: (match.group(1) if match.groups() else "")
+ + SECRET_PLACEHOLDER,
+ text,
+ )
+ return redact_org_ids(text)
+
+
+def _is_secret_key(key: object) -> bool:
+ if not isinstance(key, str):
+ return False
+ lowered = key.lower()
+ return any(hint in lowered for hint in _SECRET_KEY_HINTS)
+
+
+def _redact_secrets(obj: Any, depth: int, seen: frozenset[int]) -> Any:
if isinstance(obj, str):
- return redact_org_ids(obj)
+ return _redact_secret_text(obj)
+ if not isinstance(obj, (dict, list, tuple)):
+ return obj
+ if depth >= _MAX_REDACTION_DEPTH or id(obj) in seen:
+ return SECRET_PLACEHOLDER
+ nested = seen | {id(obj)}
if isinstance(obj, dict):
- return {key: redact_obj(value) for key, value in obj.items()}
- if isinstance(obj, list):
- return [redact_obj(value) for value in obj]
- if isinstance(obj, tuple):
- return tuple(redact_obj(value) for value in obj)
- return obj
+ return {
+ key: _redact_value(key, value, depth + 1, nested)
+ for key, value in obj.items()
+ }
+ redacted = [_redact_secrets(value, depth + 1, nested) for value in obj]
+ return redacted if isinstance(obj, list) else tuple(redacted)
+
+
+def _redact_value(key: object, value: Any, depth: int, seen: frozenset[int]) -> Any:
+ # Containers keep being walked even under a secret-shaped key so that the
+ # surrounding structure stays readable for operators.
+ if isinstance(value, (bool, int, float, dict, list, tuple)) or value is None:
+ return _redact_secrets(value, depth, seen)
+ if _is_secret_key(key):
+ return SECRET_PLACEHOLDER
+ return _redact_secrets(value, depth, seen)
+
+
+def redact_field(key: str, value: Any) -> Any:
+ """Strip credentials from one named field, e.g. a log ``extra`` entry.
+
+ Both secret-shaped keys and secret-shaped values are stripped, so a leak
+ survives neither a renamed field nor a credential embedded in free text.
+ The walk is depth-limited and cycle-safe: a malformed payload degrades to
+ ``[REDACTED]`` rather than taking the logging call down with it.
+ """
+ return _redact_value(key, value, 0, frozenset())
diff --git a/tests/unit/test_log_secret_redaction.py b/tests/unit/test_log_secret_redaction.py
new file mode 100644
index 00000000..0f660dda
--- /dev/null
+++ b/tests/unit/test_log_secret_redaction.py
@@ -0,0 +1,238 @@
+"""Regression tests for spendable credentials leaking into the dated log files.
+
+Everything here asserts against the bytes the ``DailyRotatingFileHandler``
+actually wrote to disk. Asserting against a mock would pass even if the JSON
+formatter emitted the raw ``extra`` dict, which is exactly the bug.
+"""
+
+import json
+import logging
+import os
+import time
+from collections.abc import Callable, Iterator
+from pathlib import Path
+from typing import Any
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from pythonjsonlogger import jsonlogger
+
+from routstr.core.logging import (
+ DailyRotatingFileHandler,
+ RequestIdFilter,
+ SecurityFilter,
+ VersionFilter,
+)
+from routstr.core.middleware import LoggingMiddleware
+
+REFUND_TOKEN = (
+ "cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc"
+ "2F0YXSBomFpSAA5tMOFA4EXYXCBo2FhAmFzeEA5NmY0NTFhZjMzMGY3ZmM2ZGY5"
+)
+HASHED_KEY = "b3d9f1c2a8574e60b7c1f0aa9d2e4c85f6b70a1932de84cc57bf90ae1d2c3f47"
+
+
+@pytest.fixture
+def log_dir(tmp_path: Path) -> Path:
+ directory = tmp_path / "logs"
+ directory.mkdir()
+ return directory
+
+
+@pytest.fixture
+def handler(log_dir: Path) -> Iterator[DailyRotatingFileHandler]:
+ """A file handler configured exactly like the production ``file`` handler."""
+ handler = DailyRotatingFileHandler(
+ str(log_dir / "app.log"),
+ when="midnight",
+ interval=1,
+ backupCount=30,
+ )
+ handler.setLevel(logging.DEBUG)
+ handler.setFormatter(
+ jsonlogger.JsonFormatter(
+ "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s "
+ "%(lineno)d %(version)s %(request_id)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+ )
+ for log_filter in (VersionFilter(), RequestIdFilter(), SecurityFilter()):
+ handler.addFilter(log_filter)
+ try:
+ yield handler
+ finally:
+ handler.close()
+
+
+@pytest.fixture
+def emit(handler: DailyRotatingFileHandler) -> Callable[..., str]:
+ """Log one record and return the raw text of the dated file it landed in."""
+ logger = logging.getLogger("routstr.test.redaction")
+ logger.setLevel(logging.DEBUG)
+ logger.propagate = False
+ logger.handlers = [handler]
+
+ def _emit(message: str, **extra: Any) -> str:
+ logger.info(message, extra=extra)
+ handler.flush()
+ return Path(handler.baseFilename).read_text()
+
+ return _emit
+
+
+def test_refund_token_never_reaches_the_dated_file(emit: Callable[..., str]) -> None:
+ written = emit(
+ "refund_wallet_endpoint: cashu token issued",
+ token=REFUND_TOKEN,
+ amount=1500,
+ currency="sat",
+ )
+ assert REFUND_TOKEN not in written
+ assert "1500" in written
+
+
+def test_authorization_values_never_reach_the_dated_file(
+ emit: Callable[..., str],
+) -> None:
+ written = emit(
+ "Incoming request",
+ authorization=f"Bearer sk-{HASHED_KEY}",
+ headers={"Authorization": f"Bearer sk-{HASHED_KEY}"},
+ )
+ assert HASHED_KEY not in written
+ assert "Bearer sk-" not in written
+
+
+def test_full_key_hashes_never_reach_the_dated_file(emit: Callable[..., str]) -> None:
+ written = emit(
+ "refund_wallet_endpoint: balance restored after mint failure",
+ hashed_key=HASHED_KEY,
+ key_hash=HASHED_KEY,
+ restored_balance=42,
+ )
+ assert HASHED_KEY not in written
+ assert "42" in written
+
+
+def test_secrets_nested_in_dicts_and_lists_never_reach_the_dated_file(
+ emit: Callable[..., str],
+) -> None:
+ written = emit(
+ "Upstream call failed",
+ context={
+ "attempts": [
+ {"headers": {"authorization": f"Bearer sk-{HASHED_KEY}"}},
+ {"body": {"refund": {"token": REFUND_TOKEN}}},
+ ],
+ "provider": "openai",
+ },
+ )
+ assert HASHED_KEY not in written
+ assert REFUND_TOKEN not in written
+ assert "openai" in written
+
+
+def test_query_string_secrets_never_reach_the_dated_file(
+ emit: Callable[..., str],
+) -> None:
+ written = emit(
+ "Incoming request",
+ path="/v1/wallet/refund",
+ query_params={"api_key": f"sk-{HASHED_KEY}", "page": "2"},
+ target=f"/v1/wallet/refund?token={REFUND_TOKEN}",
+ )
+ assert HASHED_KEY not in written
+ assert REFUND_TOKEN not in written
+ assert "/v1/wallet/refund" in written
+
+
+def test_benign_telemetry_is_not_redacted(emit: Callable[..., str]) -> None:
+ written = emit(
+ "Request completed",
+ method="POST",
+ path="/v1/chat/completions",
+ model="gpt-4o-mini",
+ status_code=200,
+ duration_ms=13.5,
+ input_tokens=120,
+ key_hash=HASHED_KEY[:8],
+ mint_url="https://mint.minibits.cash/Bitcoin",
+ )
+ record = json.loads(written.strip().splitlines()[-1])
+ assert record["model"] == "gpt-4o-mini"
+ assert record["status_code"] == 200
+ assert record["duration_ms"] == 13.5
+ assert record["input_tokens"] == 120
+ assert record["key_hash"] == HASHED_KEY[:8]
+ assert record["mint_url"] == "https://mint.minibits.cash/Bitcoin"
+ assert record["message"] == "Request completed"
+
+
+def test_self_referential_extra_does_not_hang_the_logger(
+ emit: Callable[..., str],
+) -> None:
+ cyclic: dict[str, Any] = {"token": REFUND_TOKEN}
+ cyclic["self"] = cyclic
+ deep: dict[str, Any] = {"token": REFUND_TOKEN}
+ for _ in range(200):
+ deep = {"nested": deep}
+
+ written = emit("Cyclic payload", context=cyclic, deep=deep)
+
+ assert REFUND_TOKEN not in written
+ assert json.loads(written.strip().splitlines()[-1])["message"] == "Cyclic payload"
+
+
+def test_middleware_logs_query_param_names_without_values(
+ handler: DailyRotatingFileHandler,
+) -> None:
+ app = FastAPI()
+ app.add_middleware(LoggingMiddleware)
+
+ @app.get("/v1/wallet/refund")
+ async def refund() -> dict[str, str]:
+ return {"status": "ok"}
+
+ middleware_logger = logging.getLogger("routstr.core.middleware")
+ middleware_logger.setLevel(logging.INFO)
+ middleware_logger.propagate = False
+ original_handlers = middleware_logger.handlers
+ middleware_logger.handlers = [handler]
+ try:
+ with TestClient(app) as client:
+ response = client.get(
+ "/v1/wallet/refund", params={"api_key": f"sk-{HASHED_KEY}", "page": "2"}
+ )
+ assert response.status_code == 200
+ finally:
+ middleware_logger.handlers = original_handlers
+
+ handler.flush()
+ written = Path(handler.baseFilename).read_text()
+ assert HASHED_KEY not in written
+ record = json.loads(written.strip().splitlines()[0])
+ assert record["path"] == "/v1/wallet/refund"
+ assert record["query_param_names"] == ["api_key", "page"]
+
+
+def test_forced_rollover_enforces_the_retention_limit(
+ handler: DailyRotatingFileHandler, log_dir: Path
+) -> None:
+ handler.backupCount = 3
+ handler.emit(
+ logging.LogRecord("t", logging.INFO, "", 0, "current", (), None),
+ )
+ handler.flush()
+
+ now = time.time()
+ for day in range(1, 6):
+ stale = log_dir / f"app_2024-01-0{day}.log"
+ stale.write_text("stale\n")
+ os.utime(stale, (now - day * 86400, now - day * 86400))
+
+ handler.doRollover()
+
+ remaining = sorted(p.name for p in log_dir.glob("app_*.log"))
+ assert len(remaining) == handler.backupCount
+ assert Path(handler.baseFilename).name in remaining
From c1b7f78a024e7e08bd1c506bc660c2b7435f99a7 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Mon, 24 Aug 2026 01:53:16 +0200
Subject: [PATCH 023/120] fix: bound Routstr auto-topup spend with a durable
claim
Routstr-to-Routstr auto top-up had no spend bound. Admin settings were
accepted without range or type validation, and every low-balance cycle
independently minted a Cashu token and handed it to the configured peer.
A malicious, buggy, or persistently non-crediting peer therefore received
a fresh bearer token every sixty seconds; nothing in the worker noticed
that the previous one had never been credited, and nothing survived a
restart, so the bleed was limited only by the owner's mint balance.
Auto top-up now mirrors the PPQ claim machinery that already guards the
Lightning path. Each provider gets one durable claim row keyed by its id,
so a second worker (or the same worker after a restart) loses the insert
or the ownership-fenced update instead of paying twice. The claim moves
to "sent" before the network call, and only a peer balance that reaches
the pre-topup balance plus the top-up amount clears it: an uncredited
token holds the slot rather than being retried. Repeated non-credit walks
the claim through exponential backoff to a halt that needs an admin
release, and a rolling 24h cap bounds the total even when every attempt
looks successful.
Settings validation now rejects non-positive, non-finite, boolean, huge,
and non-integer amounts, amounts outside the per-transaction range, and a
missing mint URL, at the admin API as well as in the worker.
The claim row is a CashuTransaction like the PPQ one, so no migration is
needed; provider delete and type change refuse to orphan it.
---
routstr/core/admin.py | 134 ++++-
routstr/upstream/auto_topup.py | 552 +++++++++++++++++-
.../test_routstr_auto_topup_claim.py | 351 +++++++++++
tests/unit/test_auto_topup.py | 177 ++----
4 files changed, 1057 insertions(+), 157 deletions(-)
create mode 100644 tests/integration/test_routstr_auto_topup_claim.py
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index 985f9c46..2da3baf1 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -907,27 +907,43 @@ class UpstreamProviderUpdateBySlug(BaseModel):
provider_settings: dict | None = None
-async def _active_ppq_claim_in_session(session: AsyncSession, provider_id: int) -> bool:
+async def _active_auto_topup_claim_in_session(
+ session: AsyncSession, provider_id: int, provider_type: str
+) -> bool:
"""Check for an active claim inside the caller's transaction.
Must share the transaction of whatever destructive write it is guarding —
a check in its own session leaves a window for a worker to create the
claim between the check and the commit.
"""
- from ..upstream.auto_topup import _ppq_state_id_for_provider
+ from ..upstream.auto_topup import (
+ _ppq_state_id_for_provider,
+ _routstr_state_id_for_provider,
+ )
- claim = await session.get(CashuTransaction, _ppq_state_id_for_provider(provider_id))
+ state_id = (
+ _ppq_state_id_for_provider(provider_id)
+ if provider_type == "ppqai"
+ else _routstr_state_id_for_provider(provider_id)
+ )
+ claim = await session.get(CashuTransaction, state_id)
return claim is not None and not claim.collected and not claim.swept
-def _require_valid_ppq_auto_topup(provider_type: str, settings: dict | None) -> None:
- """Reject PPQ auto top-up settings the worker would later refuse."""
- if provider_type != "ppqai":
+def _require_valid_auto_topup(provider_type: str, settings: dict | None) -> None:
+ """Reject auto top-up settings the worker would later refuse."""
+ from ..upstream.auto_topup import (
+ validate_ppq_auto_topup_settings,
+ validate_routstr_auto_topup_settings,
+ )
+
+ if provider_type == "ppqai":
+ problem = validate_ppq_auto_topup_settings(settings)
+ elif provider_type == "routstr":
+ problem = validate_routstr_auto_topup_settings(settings)
+ else:
return
- from ..upstream.auto_topup import validate_ppq_auto_topup_settings
-
- problem = validate_ppq_auto_topup_settings(settings)
if problem is not None:
raise HTTPException(status_code=400, detail=problem)
@@ -952,16 +968,18 @@ async def _apply_provider_update(
)
if (
provider_type_changed
- and provider.provider_type == "ppqai"
+ and provider.provider_type in ("ppqai", "routstr")
and provider.id is not None
- and await _active_ppq_claim_in_session(session, provider.id)
+ and await _active_auto_topup_claim_in_session(
+ session, provider.id, provider.provider_type
+ )
):
- # Changing the type would orphan the claim: the PPQ endpoints refuse
- # non-ppqai providers, so nobody could ever inspect or release it.
+ # Changing the type would orphan the claim: the claim endpoints refuse
+ # providers of the wrong type, so nobody could inspect or release it.
raise HTTPException(
status_code=409,
detail=(
- "This provider has an active PPQ auto top-up claim. Release "
+ "This provider has an active auto top-up claim. Release "
"it before changing the provider type"
),
)
@@ -1012,7 +1030,7 @@ async def _apply_provider_update(
except (json.JSONDecodeError, TypeError):
effective_settings = None
if effective_settings is not None:
- _require_valid_ppq_auto_topup(provider.provider_type, effective_settings)
+ _require_valid_auto_topup(provider.provider_type, effective_settings)
if payload.provider_settings is not None:
provider.provider_settings = json.dumps(payload.provider_settings)
@@ -1052,7 +1070,7 @@ async def create_upstream_provider(
else:
slug = await allocate_unique_provider_slug(session, payload.provider_type)
- _require_valid_ppq_auto_topup(payload.provider_type, payload.provider_settings)
+ _require_valid_auto_topup(payload.provider_type, payload.provider_settings)
provider = UpstreamProviderRow(
slug=slug,
@@ -1148,16 +1166,19 @@ async def delete_upstream_provider(provider_id: str) -> dict[str, object]:
# re-reads the provider inside its own transaction, so these two
# writes serialise — either the claim lands first and this 409s, or
# the delete lands first and the worker refuses to claim.
- if provider.provider_type == "ppqai" and await _active_ppq_claim_in_session(
- session, deleted_id
+ if provider.provider_type in (
+ "ppqai",
+ "routstr",
+ ) and await _active_auto_topup_claim_in_session(
+ session, deleted_id, provider.provider_type
):
# Deleting now would orphan the claim and any funds it tracks:
- # the PPQ endpoints 404 without the provider row, so the claim
+ # the claim endpoints 404 without the provider row, so the claim
# could never again be inspected or released.
raise HTTPException(
status_code=409,
detail=(
- "This provider has an active PPQ auto top-up claim. "
+ "This provider has an active auto top-up claim. "
"Resolve and release it before deleting the provider"
),
)
@@ -1842,6 +1863,72 @@ async def release_ppq_auto_topup_api(
return {"ok": True, "released": True}
+_ROUTSTR_RELEASE_ERRORS = {
+ "no_active_claim": "No active Routstr auto top-up claim to release",
+ "stale_state": "The claim changed since it was reviewed; reload and check again",
+ "claim_changed": (
+ "The claim changed while the release was being applied; reload and check again"
+ ),
+}
+
+
+class ReleaseRoutstrAutoTopupRequest(BaseModel):
+ confirmed_peer_reconciled: bool
+ state_token: str | None = None
+
+
+async def _require_routstr_provider(provider_id: int) -> UpstreamProviderRow:
+ async with create_session() as session:
+ provider = await session.get(UpstreamProviderRow, provider_id)
+ if provider is None:
+ raise HTTPException(status_code=404, detail="Provider not found")
+ if provider.provider_type != "routstr":
+ raise HTTPException(status_code=400, detail="Provider is not a Routstr node")
+ return provider
+
+
+@admin_router.get(
+ "/api/upstream-providers/{provider_id}/routstr-auto-topup",
+ dependencies=[Depends(require_admin_api)],
+)
+async def get_routstr_auto_topup_api(provider_id: int) -> dict[str, object]:
+ await _require_routstr_provider(provider_id)
+ from ..upstream.auto_topup import get_routstr_auto_topup_state
+
+ return {"ok": True, **await get_routstr_auto_topup_state(provider_id)}
+
+
+@admin_router.post(
+ "/api/upstream-providers/{provider_id}/routstr-auto-topup/release",
+ dependencies=[Depends(require_admin_api)],
+)
+async def release_routstr_auto_topup_api(
+ provider_id: int, payload: ReleaseRoutstrAutoTopupRequest
+) -> dict[str, object]:
+ await _require_routstr_provider(provider_id)
+ if not payload.confirmed_peer_reconciled:
+ raise HTTPException(
+ status_code=400,
+ detail="Confirm the peer credited or returned the token before releasing",
+ )
+
+ from ..upstream.auto_topup import release_routstr_auto_topup_state
+
+ outcome = await release_routstr_auto_topup_state(
+ provider_id, state_token=payload.state_token
+ )
+ if not outcome.released:
+ raise HTTPException(
+ status_code=409, detail=_ROUTSTR_RELEASE_ERRORS[outcome.reason]
+ )
+
+ logger.warning(
+ "Admin released Routstr auto top-up claim after manual reconciliation",
+ extra={"provider_id": provider_id, "state_token": payload.state_token},
+ )
+ return {"ok": True, "released": True}
+
+
def _transaction_status(tx: CashuTransaction) -> str:
"""An outgoing admin withdrawal ends at "issued": the node hands the bearer
token over and never learns whether it was redeemed, so its flags stay false
@@ -1869,10 +1956,11 @@ async def get_transactions_api(
async with create_session() as session:
from sqlmodel import col, func
- # Hide only the deterministic PPQ claim-lock rows. Append-only PPQ
- # payment rows remain visible as the audit trail for irreversible melts.
+ # Hide only the deterministic claim-lock rows. Append-only PPQ payment
+ # rows and auto-topup token rows remain visible as the audit trail.
base = select(CashuTransaction).where(
- ~col(CashuTransaction.id).like("ppq-auto-topup-%")
+ ~col(CashuTransaction.id).like("ppq-auto-topup-%"),
+ ~col(CashuTransaction.id).like("routstr-auto-topup-%"),
)
if type:
base = base.where(CashuTransaction.type == type)
diff --git a/routstr/upstream/auto_topup.py b/routstr/upstream/auto_topup.py
index c8eadb6e..6dd5ac7d 100644
--- a/routstr/upstream/auto_topup.py
+++ b/routstr/upstream/auto_topup.py
@@ -58,6 +58,33 @@ PPQ_MAX_TOPUP_USD = 500
# the damage instead of letting the worker drain the owner's mint funds one
# per-transaction-capped payment at a time.
PPQ_MAX_DAILY_TOPUP_USD = 300
+# Routstr-to-Routstr claim lifecycle. "claimed" holds the slot while the token
+# is being minted; nothing has left the wallet yet. "sent" means a bearer token
+# was handed to the peer and only the peer's balance can say whether it landed.
+# "backoff" holds the failure count between attempts, and "halted" stops the
+# provider entirely until an admin releases it.
+ROUTSTR_PHASE_CLAIMED = "claimed"
+ROUTSTR_PHASE_SENT = "sent"
+ROUTSTR_PHASE_BACKOFF = "backoff"
+ROUTSTR_PHASE_HALTED = "halted"
+ROUTSTR_PHASES = frozenset(
+ {
+ ROUTSTR_PHASE_CLAIMED,
+ ROUTSTR_PHASE_SENT,
+ ROUTSTR_PHASE_BACKOFF,
+ ROUTSTR_PHASE_HALTED,
+ }
+)
+ROUTSTR_PENDING_TTL_SECONDS = 15 * 60
+ROUTSTR_BACKOFF_BASE_SECONDS = 15 * 60
+ROUTSTR_MAX_TOPUP_FAILURES = 3
+ROUTSTR_MIN_TOPUP_SATS = 1
+ROUTSTR_MAX_TOPUP_SATS = 1_000_000
+# Rolling 24h ceiling on total Routstr auto top-up spend across all peers. The
+# per-attempt claim already stops a peer from being paid twice for the same
+# uncredited token; this bounds the total even when every attempt is credited
+# and the peer simply keeps reporting a below-threshold balance.
+ROUTSTR_MAX_DAILY_TOPUP_SATS = 2_000_000
async def periodic_auto_topup() -> None:
@@ -139,7 +166,7 @@ async def _reconcile_all_ppq_claims() -> set[int]:
return active_provider_ids
-def _invalid_ppq_number(value: object, *, integer: bool = False) -> bool:
+def _invalid_topup_number(value: object, *, integer: bool = False) -> bool:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return True
try:
@@ -160,9 +187,9 @@ def validate_ppq_auto_topup_settings(settings: dict | None) -> str | None:
threshold = settings.get("topup_threshold")
amount = settings.get("topup_amount_limit")
- if _invalid_ppq_number(threshold):
+ if _invalid_topup_number(threshold):
return "PPQ auto top-up threshold must be a positive number"
- if _invalid_ppq_number(amount, integer=True):
+ if _invalid_topup_number(amount, integer=True):
return "PPQ auto top-up amount must be a positive whole number"
amount_usd = int(typing.cast(int | float, amount))
if not PPQ_MIN_TOPUP_USD <= amount_usd <= PPQ_MAX_TOPUP_USD:
@@ -173,6 +200,29 @@ def validate_ppq_auto_topup_settings(settings: dict | None) -> str | None:
return None
+def validate_routstr_auto_topup_settings(settings: dict | None) -> str | None:
+ """Return why enabled Routstr auto top-up settings are invalid, if anything."""
+ if not settings or not settings.get("auto_topup"):
+ return None
+
+ threshold = settings.get("topup_threshold")
+ amount = settings.get("topup_amount_limit")
+ mint_url = settings.get("topup_mint_url")
+ if _invalid_topup_number(threshold):
+ return "Routstr auto top-up threshold must be a positive number"
+ if _invalid_topup_number(amount, integer=True):
+ return "Routstr auto top-up amount must be a positive whole number"
+ amount_sats = int(typing.cast(int | float, amount))
+ if not ROUTSTR_MIN_TOPUP_SATS <= amount_sats <= ROUTSTR_MAX_TOPUP_SATS:
+ return (
+ f"Routstr auto top-up amount must be between {ROUTSTR_MIN_TOPUP_SATS} "
+ f"and {ROUTSTR_MAX_TOPUP_SATS} sats"
+ )
+ if not isinstance(mint_url, str) or not mint_url.strip():
+ return "Routstr auto top-up requires a mint URL"
+ return None
+
+
async def _check_and_topup_ppq_from_row(row: UpstreamProviderRow) -> None:
settings: dict = {}
if row.provider_settings:
@@ -212,22 +262,18 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
if not settings.get("auto_topup"):
return
- threshold = settings.get("topup_threshold")
- amount = settings.get("topup_amount_limit")
- mint_url = settings.get("topup_mint_url")
-
- if not threshold or not amount or not mint_url:
+ problem = validate_routstr_auto_topup_settings(settings)
+ if problem is not None:
logger.warning(
- "Auto top-up enabled but missing configuration",
- extra={
- "provider_id": row.id,
- "has_threshold": bool(threshold),
- "has_amount": bool(amount),
- "has_mint": bool(mint_url),
- },
+ "Auto top-up enabled but its configuration is invalid",
+ extra={"provider_id": row.id, "problem": problem},
)
return
+ threshold = float(settings["topup_threshold"])
+ amount = int(settings["topup_amount_limit"])
+ mint_url = str(settings["topup_mint_url"])
+
if not row.api_key:
return
@@ -235,9 +281,12 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
provider = RoutstrUpstreamProvider.from_db_row(row)
if provider is None:
return
+ if await _reconcile_routstr_state(row, provider):
+ return
+
balance = await provider.get_balance()
- if balance is None:
+ if balance is None or not math.isfinite(balance) or balance < 0:
logger.warning(
"Could not fetch balance for auto top-up",
extra={"provider_id": row.id, "base_url": row.base_url},
@@ -247,6 +296,27 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
if balance >= threshold * 1000:
return
+ spent_24h_sats = await _routstr_spent_last_24h_sats()
+ if spent_24h_sats + amount > ROUTSTR_MAX_DAILY_TOPUP_SATS:
+ logger.critical(
+ "Auto top-up skipped: rolling 24h spend cap reached",
+ extra={
+ "provider_id": row.id,
+ "spent_24h_sats": spent_24h_sats,
+ "topup_amount": amount,
+ "daily_cap_sats": ROUTSTR_MAX_DAILY_TOPUP_SATS,
+ },
+ )
+ return
+
+ # The balance the peer must report before another token may be sent. Any
+ # shortfall is treated as "not credited": the token is a bearer instrument
+ # and a peer that took one without crediting it must not be handed another.
+ expected_sats = math.floor(balance) + amount
+ operation_id = await _claim_routstr_topup(row, expected_sats=expected_sats)
+ if operation_id is None:
+ return
+
# Balance is below threshold - create token and top up
logger.info(
"Auto top-up triggered",
@@ -271,6 +341,7 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
"error": str(e),
},
)
+ await _release_routstr_claim(row, operation_id)
return
actual_mint_url = token_mint_url(token, mint_url)
@@ -305,8 +376,21 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
"Auto-topup token was released after persistence failed",
extra={"provider_id": row.id, "mint_url": actual_mint_url},
)
+ await _release_routstr_claim(row, operation_id)
return
+ # Move the claim before the network call, not after: a worker that dies
+ # mid-request must leave behind a claim that says a token may already be
+ # with the peer.
+ await _mark_routstr_sent(
+ row,
+ operation_id,
+ expected_sats=expected_sats,
+ token=token,
+ amount=amount,
+ mint_url=actual_mint_url,
+ )
+
result = await provider.topup(token)
if "error" in result:
@@ -348,6 +432,442 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
)
+def _routstr_state_id(row: UpstreamProviderRow) -> str:
+ if row.id is None:
+ raise ValueError("Routstr auto top-up requires a persisted provider row")
+ return _routstr_state_id_for_provider(row.id)
+
+
+def _routstr_state_id_for_provider(provider_id: int | str) -> str:
+ return f"routstr-auto-topup-{provider_id}"
+
+
+class RoutstrClaim(typing.NamedTuple):
+ operation_id: str
+ # Worker lease for "claimed"/"sent", retry-not-before for "backoff", and
+ # meaningless for "halted".
+ deadline: int
+ phase: str
+ # Peer balance in sats that proves this attempt was credited.
+ expected_sats: int
+ failures: int
+
+
+def _routstr_request_id(
+ operation_id: str,
+ deadline: int,
+ phase: str,
+ expected_sats: int,
+ failures: int,
+) -> str:
+ return f"routstr:{operation_id}:{deadline}:{phase}:{expected_sats}:{failures}"
+
+
+def _parse_routstr_request_id(request_id: str | None) -> RoutstrClaim | None:
+ parts = (request_id or "").split(":", 5)
+ if len(parts) != 6 or parts[0] != "routstr" or parts[3] not in ROUTSTR_PHASES:
+ return None
+ try:
+ deadline = int(parts[2])
+ expected_sats = int(parts[4])
+ failures = int(parts[5])
+ except (TypeError, ValueError):
+ return None
+ return RoutstrClaim(parts[1], deadline, parts[3], expected_sats, failures)
+
+
+async def _routstr_spent_last_24h_sats() -> int:
+ """Total sats committed to Routstr auto top-ups in the last 24 hours.
+
+ Uncollected rows count too: a token whose delivery is unconfirmed is spent
+ for capping purposes. Rows marked ``collected=False, swept=True`` record
+ tokens that were provably returned to the wallet and are excluded.
+ """
+ cutoff = int(time.time()) - 24 * 60 * 60
+ async with create_session() as session:
+ rows = (
+ await session.exec(
+ select(CashuTransaction.amount, CashuTransaction.unit).where(
+ col(CashuTransaction.source) == "auto_topup",
+ col(CashuTransaction.type) == "out",
+ col(CashuTransaction.created_at) >= cutoff,
+ or_(
+ col(CashuTransaction.collected) == True, # noqa: E712
+ col(CashuTransaction.swept) == False, # noqa: E712
+ ),
+ )
+ )
+ ).all()
+ return sum(
+ amount if unit == "sat" else math.ceil(amount / 1000) for amount, unit in rows
+ )
+
+
+async def _routstr_provider_is_claimable(
+ session: AsyncSession, provider_id: int | str | None
+) -> bool:
+ """Re-read the provider inside the claim transaction.
+
+ Same reasoning as :func:`_ppq_provider_is_claimable`: a provider deleted or
+ retyped concurrently must either be visible here or lose the race against
+ the claim we are about to write.
+ """
+ if provider_id is None:
+ return False
+ current = await session.get(UpstreamProviderRow, provider_id)
+ return current is not None and current.provider_type == "routstr"
+
+
+async def _claim_routstr_topup(
+ row: UpstreamProviderRow, *, expected_sats: int
+) -> str | None:
+ """Acquire the provider's single durable auto top-up slot.
+
+ An expired backoff hands its failure count to the new attempt, so repeated
+ non-crediting peers still walk towards the halt instead of resetting the
+ counter every cycle.
+ """
+ state_id = _routstr_state_id(row)
+ operation_id = uuid.uuid4().hex
+ deadline = int(time.time()) + ROUTSTR_PENDING_TTL_SECONDS
+
+ async with create_session() as session:
+ if not await _routstr_provider_is_claimable(session, row.id):
+ return None
+ existing = await session.get(CashuTransaction, state_id)
+ if existing is not None:
+ failures = 0
+ if not (existing.collected or existing.swept):
+ claim = _parse_routstr_request_id(existing.request_id)
+ if (
+ claim is None
+ or claim.phase != ROUTSTR_PHASE_BACKOFF
+ or time.time() < claim.deadline
+ ):
+ return None
+ failures = claim.failures
+ result = await session.exec( # type: ignore[call-overload]
+ update(CashuTransaction)
+ .where(
+ col(CashuTransaction.id) == state_id,
+ # Fence on the exact row that was read: any concurrent
+ # writer that moved the claim must win instead of us.
+ col(CashuTransaction.request_id) == existing.request_id,
+ )
+ .values(
+ token="pending",
+ amount=0,
+ unit="sat",
+ mint_url=None,
+ request_id=_routstr_request_id(
+ operation_id,
+ deadline,
+ ROUTSTR_PHASE_CLAIMED,
+ expected_sats,
+ failures,
+ ),
+ collected=False,
+ swept=False,
+ created_at=int(time.time()),
+ source="routstr_auto_topup_claim",
+ )
+ )
+ await session.commit()
+ if (getattr(result, "rowcount", 0) or 0) != 1:
+ return None
+ return operation_id
+
+ try:
+ async with create_session() as session:
+ if not await _routstr_provider_is_claimable(session, row.id):
+ return None
+ session.add(
+ CashuTransaction(
+ id=state_id,
+ token="pending",
+ amount=0,
+ unit="sat",
+ type="out",
+ request_id=_routstr_request_id(
+ operation_id,
+ deadline,
+ ROUTSTR_PHASE_CLAIMED,
+ expected_sats,
+ 0,
+ ),
+ collected=False,
+ source="routstr_auto_topup_claim",
+ )
+ )
+ await session.commit()
+ except IntegrityError:
+ return None
+ return operation_id
+
+
+async def _advance_routstr_claim(
+ row: UpstreamProviderRow,
+ operation_id: str,
+ *,
+ deadline: int,
+ phase: str,
+ expected_sats: int,
+ failures: int,
+ token: str | None = None,
+ amount: int | None = None,
+ mint_url: str | None = None,
+) -> bool:
+ """Move this worker's claim to another phase, if it still owns it."""
+ values: dict[str, object] = {
+ "request_id": _routstr_request_id(
+ operation_id, deadline, phase, expected_sats, failures
+ )
+ }
+ if token is not None:
+ values.update(token=token, amount=amount, mint_url=mint_url)
+
+ async with create_session() as session:
+ result = await session.exec( # type: ignore[call-overload]
+ update(CashuTransaction)
+ .where(
+ col(CashuTransaction.id) == _routstr_state_id(row),
+ col(CashuTransaction.request_id).like(f"routstr:{operation_id}:%"),
+ col(CashuTransaction.collected) == False, # noqa: E712
+ col(CashuTransaction.swept) == False, # noqa: E712
+ )
+ .values(**values)
+ )
+ await session.commit()
+ return (getattr(result, "rowcount", 0) or 0) == 1
+
+
+async def _set_routstr_state_terminal(
+ row: UpstreamProviderRow, operation_id: str, *, collected: bool, swept: bool
+) -> bool:
+ """Finish an attempt only if this worker still owns the claim."""
+ async with create_session() as session:
+ result = await session.exec( # type: ignore[call-overload]
+ update(CashuTransaction)
+ .where(
+ col(CashuTransaction.id) == _routstr_state_id(row),
+ col(CashuTransaction.request_id).like(f"routstr:{operation_id}:%"),
+ col(CashuTransaction.collected) == False, # noqa: E712
+ col(CashuTransaction.swept) == False, # noqa: E712
+ )
+ .values(collected=collected, swept=swept)
+ )
+ await session.commit()
+ return (getattr(result, "rowcount", 0) or 0) == 1
+
+
+async def _release_routstr_claim(row: UpstreamProviderRow, operation_id: str) -> None:
+ """Hand back a claim whose token never left the wallet."""
+ if not await _set_routstr_state_terminal(
+ row, operation_id, collected=False, swept=True
+ ):
+ logger.warning(
+ "Could not release the auto top-up claim after a pre-send failure; "
+ "it is owned by another attempt",
+ extra={"provider_id": row.id},
+ )
+
+
+async def _mark_routstr_sent(
+ row: UpstreamProviderRow,
+ operation_id: str,
+ *,
+ expected_sats: int,
+ token: str,
+ amount: int,
+ mint_url: str,
+) -> None:
+ claim = await _current_routstr_claim(row)
+ failures = claim.failures if claim else 0
+ if not await _advance_routstr_claim(
+ row,
+ operation_id,
+ deadline=int(time.time()) + ROUTSTR_PENDING_TTL_SECONDS,
+ phase=ROUTSTR_PHASE_SENT,
+ expected_sats=expected_sats,
+ failures=failures,
+ token=token,
+ amount=amount,
+ mint_url=mint_url,
+ ):
+ raise RuntimeError("Routstr auto top-up claim ownership was lost")
+
+
+async def _current_routstr_claim(row: UpstreamProviderRow) -> RoutstrClaim | None:
+ async with create_session() as session:
+ transaction = await session.get(CashuTransaction, _routstr_state_id(row))
+ if transaction is None:
+ return None
+ return _parse_routstr_request_id(transaction.request_id)
+
+
+async def _reconcile_routstr_state(
+ row: UpstreamProviderRow, provider: RoutstrUpstreamProvider
+) -> bool:
+ """Return True while a prior attempt must suppress a new payment."""
+ async with create_session() as session:
+ transaction = await session.get(CashuTransaction, _routstr_state_id(row))
+ if transaction is None or transaction.collected or transaction.swept:
+ return False
+
+ claim = _parse_routstr_request_id(transaction.request_id)
+ if claim is None:
+ logger.critical(
+ "Malformed auto top-up state; suppressing duplicate payment",
+ extra={"provider_id": row.id},
+ )
+ return True
+
+ now = time.time()
+ if claim.phase == ROUTSTR_PHASE_HALTED:
+ return True
+ if claim.phase == ROUTSTR_PHASE_BACKOFF:
+ return now < claim.deadline
+ if claim.phase == ROUTSTR_PHASE_CLAIMED:
+ # Nothing left the wallet, so a dead worker's slot is free to reuse.
+ if now < claim.deadline:
+ return True
+ return not await _set_routstr_state_terminal(
+ row, claim.operation_id, collected=False, swept=True
+ )
+
+ balance = await provider.get_balance()
+ if (
+ balance is not None
+ and math.isfinite(balance)
+ and balance >= claim.expected_sats
+ ):
+ if not await _set_routstr_state_terminal(
+ row, claim.operation_id, collected=True, swept=False
+ ):
+ logger.critical(
+ "Auto top-up was credited but its claim was already released; "
+ "a duplicate top-up is possible on the next cycle",
+ extra={"provider_id": row.id},
+ )
+ return True
+ if now < claim.deadline:
+ return True
+
+ failures = claim.failures + 1
+ if failures >= ROUTSTR_MAX_TOPUP_FAILURES:
+ await _advance_routstr_claim(
+ row,
+ claim.operation_id,
+ deadline=claim.deadline,
+ phase=ROUTSTR_PHASE_HALTED,
+ expected_sats=claim.expected_sats,
+ failures=failures,
+ )
+ logger.critical(
+ "Auto top-up halted: the peer repeatedly failed to credit a token",
+ extra={
+ "provider_id": row.id,
+ "base_url": row.base_url,
+ "failures": failures,
+ "admin_action": (
+ f"POST /admin/api/upstream-providers/{row.id}"
+ "/routstr-auto-topup/release"
+ ),
+ },
+ )
+ return True
+
+ await _advance_routstr_claim(
+ row,
+ claim.operation_id,
+ deadline=int(now) + ROUTSTR_BACKOFF_BASE_SECONDS * 2 ** (failures - 1),
+ phase=ROUTSTR_PHASE_BACKOFF,
+ expected_sats=claim.expected_sats,
+ failures=failures,
+ )
+ logger.warning(
+ "Auto top-up was not credited by the peer; backing off",
+ extra={
+ "provider_id": row.id,
+ "base_url": row.base_url,
+ "expected_sats": claim.expected_sats,
+ "failures": failures,
+ },
+ )
+ return True
+
+
+async def get_routstr_auto_topup_state(provider_id: int) -> dict[str, object]:
+ """Return admin-safe state for a provider's durable Routstr claim."""
+ async with create_session() as session:
+ transaction = await session.get(
+ CashuTransaction, _routstr_state_id_for_provider(provider_id)
+ )
+ if transaction is None or transaction.collected or transaction.swept:
+ return {"active": False}
+
+ claim = _parse_routstr_request_id(transaction.request_id)
+ return {
+ "active": True,
+ # Echoed back verbatim on release so a claim that moved on since the
+ # admin reviewed it fails the write instead of being swept unseen.
+ "state_token": transaction.request_id,
+ "operation_id": claim.operation_id if claim else None,
+ "phase": claim.phase if claim else None,
+ "expected_sats": claim.expected_sats if claim else None,
+ "failures": claim.failures if claim else None,
+ "deadline": claim.deadline if claim else None,
+ "created_at": transaction.created_at,
+ "amount": transaction.amount,
+ "unit": transaction.unit,
+ "mint_url": transaction.mint_url,
+ "malformed": claim is None,
+ }
+
+
+class RoutstrReleaseOutcome(typing.NamedTuple):
+ released: bool
+ reason: str
+
+
+async def release_routstr_auto_topup_state(
+ provider_id: int, *, state_token: str | None
+) -> RoutstrReleaseOutcome:
+ """Clear a halted or stuck claim after an admin reconciles the peer.
+
+ Unlike a Lightning melt there is no in-flight window to protect: the token
+ is already with the peer or still in the wallet either way. What the fence
+ does protect is the admin's decision — the row must be byte-identical to
+ the one they reviewed, so a claim that advanced in the meantime is not
+ swept on the strength of stale information.
+ """
+ state_id = _routstr_state_id_for_provider(provider_id)
+ async with create_session() as session:
+ transaction = await session.get(CashuTransaction, state_id)
+
+ if transaction is None or transaction.collected or transaction.swept:
+ return RoutstrReleaseOutcome(False, "no_active_claim")
+ if transaction.request_id != state_token:
+ return RoutstrReleaseOutcome(False, "stale_state")
+
+ async with create_session() as session:
+ result = await session.exec( # type: ignore[call-overload]
+ update(CashuTransaction)
+ .where(
+ col(CashuTransaction.id) == state_id,
+ col(CashuTransaction.request_id) == state_token,
+ col(CashuTransaction.collected) == False, # noqa: E712
+ col(CashuTransaction.swept) == False, # noqa: E712
+ )
+ .values(swept=True)
+ )
+ if (getattr(result, "rowcount", 0) or 0) == 1:
+ await session.commit()
+ return RoutstrReleaseOutcome(True, "released")
+ await session.rollback()
+ return RoutstrReleaseOutcome(False, "claim_changed")
+
+
def _ppq_state_id(row: UpstreamProviderRow) -> str:
if row.id is None:
raise ValueError("PPQ auto top-up requires a persisted provider row")
diff --git a/tests/integration/test_routstr_auto_topup_claim.py b/tests/integration/test_routstr_auto_topup_claim.py
new file mode 100644
index 00000000..25b827a9
--- /dev/null
+++ b/tests/integration/test_routstr_auto_topup_claim.py
@@ -0,0 +1,351 @@
+"""Real-database tests for the Routstr-to-Routstr auto top-up spend bound.
+
+The bound has to survive a process restart and concurrent workers, so these
+run against actual SQL instead of mocked sessions: an in-memory counter would
+pass a mocked test and still let a non-crediting peer drain the wallet.
+"""
+
+import importlib
+import time
+from contextlib import ExitStack
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlmodel import select
+
+from routstr.core.db import CashuTransaction, UpstreamProviderRow, create_session
+from routstr.upstream import auto_topup as auto_topup_module
+from routstr.upstream.auto_topup import (
+ ROUTSTR_MAX_DAILY_TOPUP_SATS,
+ ROUTSTR_MAX_TOPUP_FAILURES,
+ ROUTSTR_PHASE_BACKOFF,
+ ROUTSTR_PHASE_HALTED,
+ ROUTSTR_PHASE_SENT,
+ _check_and_topup,
+ _claim_routstr_topup,
+ _parse_routstr_request_id,
+ _routstr_spent_last_24h_sats,
+ _routstr_state_id_for_provider,
+ get_routstr_auto_topup_state,
+ release_routstr_auto_topup_state,
+)
+
+pytestmark = pytest.mark.asyncio
+
+TOPUP_SATS = 50
+
+
+async def _seed_provider(provider_id: int = 1) -> UpstreamProviderRow:
+ import json
+
+ row = UpstreamProviderRow(
+ id=provider_id,
+ slug=f"peer-{provider_id}",
+ provider_type="routstr",
+ base_url="https://peer.test",
+ api_key="secret",
+ enabled=True,
+ provider_settings=json.dumps(
+ {
+ "auto_topup": True,
+ "topup_threshold": 1,
+ "topup_amount_limit": TOPUP_SATS,
+ "topup_mint_url": "https://mint.test",
+ }
+ ),
+ )
+ async with create_session() as session:
+ session.add(row)
+ await session.commit()
+ await session.refresh(row)
+ return row
+
+
+def _peer(balance: float, *, topup: object = None) -> MagicMock:
+ provider = MagicMock()
+ provider.get_balance = AsyncMock(return_value=balance)
+ provider.topup = AsyncMock(return_value=topup or {"balance": balance})
+ return provider
+
+
+def _patch_wallet(module: Any, peer: MagicMock, token: str) -> ExitStack:
+ stack = ExitStack()
+ stack.enter_context(
+ patch.object(module.RoutstrUpstreamProvider, "from_db_row", return_value=peer)
+ )
+ stack.enter_context(
+ patch.object(module, "send_token", AsyncMock(return_value=token))
+ )
+ stack.enter_context(
+ patch.object(module, "token_mint_url", return_value="https://mint.test")
+ )
+ return stack
+
+
+async def _claim_state(provider_id: int = 1) -> CashuTransaction | None:
+ async with create_session() as session:
+ return await session.get(
+ CashuTransaction, _routstr_state_id_for_provider(provider_id)
+ )
+
+
+async def _sent_tokens() -> list[CashuTransaction]:
+ async with create_session() as session:
+ return list(
+ (
+ await session.exec(
+ select(CashuTransaction).where(
+ CashuTransaction.source == "auto_topup"
+ )
+ )
+ ).all()
+ )
+
+
+async def test_second_worker_cannot_claim_while_the_first_holds_one(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ assert await _claim_routstr_topup(row, expected_sats=TOPUP_SATS) is not None
+ assert await _claim_routstr_topup(row, expected_sats=TOPUP_SATS) is None
+
+
+async def test_token_is_persisted_before_it_reaches_the_peer(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ seen: list[CashuTransaction] = []
+
+ async def _record_then_accept(token: str) -> dict:
+ seen.extend(await _sent_tokens())
+ return {"balance": TOPUP_SATS}
+
+ peer = _peer(0.0)
+ peer.topup = AsyncMock(side_effect=_record_then_accept)
+
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-1"):
+ await _check_and_topup(row)
+
+ assert [tx.token for tx in seen] == ["cashu-token-1"]
+ assert seen[0].collected is False
+ assert (await _sent_tokens())[0].collected is True
+
+
+async def test_untracked_token_is_returned_and_never_sent(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ peer = _peer(0.0)
+
+ with (
+ _patch_wallet(auto_topup_module, peer, "cashu-token-1"),
+ patch.object(
+ auto_topup_module,
+ "store_cashu_transaction",
+ AsyncMock(side_effect=RuntimeError("database unavailable")),
+ ),
+ patch.object(
+ auto_topup_module, "release_token_reservation", AsyncMock()
+ ) as reclaim,
+ ):
+ await _check_and_topup(row)
+
+ reclaim.assert_awaited_once_with("cashu-token-1")
+ peer.topup.assert_not_awaited()
+ # Nothing left the wallet, so the slot must be free again immediately.
+ state = await _claim_state()
+ assert state is not None and state.swept is True
+
+
+async def test_failed_topup_keeps_token_uncollected_and_suppresses_new_claims(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ peer = _peer(0.0, topup={"error": "rejected"})
+
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-1"):
+ await _check_and_topup(row)
+
+ tokens = await _sent_tokens()
+ assert len(tokens) == 1
+ assert tokens[0].collected is False
+
+ claim = _parse_routstr_request_id((await _claim_state()).request_id) # type: ignore[union-attr]
+ assert claim is not None and claim.phase == ROUTSTR_PHASE_SENT
+
+ peer.topup.reset_mock()
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-2"):
+ await _check_and_topup(row)
+
+ peer.topup.assert_not_awaited()
+ assert len(await _sent_tokens()) == 1
+
+
+async def test_claim_blocks_a_restarted_process(patched_db_engine: Any) -> None:
+ row = await _seed_provider()
+ peer = _peer(0.0, topup={"error": "rejected"})
+
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-1"):
+ await _check_and_topup(row)
+
+ # A fresh module drops every process-local variable; only a durable row
+ # can still stop the next payment.
+ reloaded = importlib.reload(auto_topup_module)
+ try:
+ peer.topup.reset_mock()
+ with _patch_wallet(reloaded, peer, "cashu-token-2"):
+ await reloaded._check_and_topup(row)
+ peer.topup.assert_not_awaited()
+ finally:
+ importlib.reload(auto_topup_module)
+
+ assert len(await _sent_tokens()) == 1
+
+
+async def _uncredited_attempt(
+ row: UpstreamProviderRow, peer: MagicMock, token: str
+) -> None:
+ """One full payment attempt against a peer that never credits it."""
+ with _patch_wallet(auto_topup_module, peer, token):
+ await _check_and_topup(row)
+ await _expire_claim()
+ with _patch_wallet(auto_topup_module, peer, f"{token}-retry"):
+ await _check_and_topup(row)
+ await _expire_claim()
+
+
+async def test_non_crediting_peer_is_halted_after_repeated_failures(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ peer = _peer(0.0)
+
+ for attempt in range(ROUTSTR_MAX_TOPUP_FAILURES):
+ await _uncredited_attempt(row, peer, f"cashu-token-{attempt}")
+
+ claim = _parse_routstr_request_id((await _claim_state()).request_id) # type: ignore[union-attr]
+ assert claim is not None and claim.phase == ROUTSTR_PHASE_HALTED
+
+ peer.topup.reset_mock()
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-after-halt"):
+ await _check_and_topup(row)
+ peer.topup.assert_not_awaited()
+ assert len(await _sent_tokens()) == ROUTSTR_MAX_TOPUP_FAILURES
+
+
+async def _expire_claim(provider_id: int = 1) -> None:
+ """Age the claim's deadline so the reconciler treats it as timed out."""
+ async with create_session() as session:
+ state = await session.get(
+ CashuTransaction, _routstr_state_id_for_provider(provider_id)
+ )
+ assert state is not None and state.request_id is not None
+ claim = _parse_routstr_request_id(state.request_id)
+ assert claim is not None
+ state.request_id = auto_topup_module._routstr_request_id(
+ claim.operation_id,
+ int(time.time()) - 1,
+ claim.phase,
+ claim.expected_sats,
+ claim.failures,
+ )
+ session.add(state)
+ await session.commit()
+
+
+async def test_crediting_peer_releases_the_claim_for_a_later_topup(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ peer = _peer(0.0)
+
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-1"):
+ await _check_and_topup(row)
+
+ tokens = await _sent_tokens()
+ assert len(tokens) == 1 and tokens[0].collected is True
+
+ peer.get_balance = AsyncMock(return_value=float(TOPUP_SATS))
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-2"):
+ await _check_and_topup(row)
+
+ state = await _claim_state()
+ assert state is not None and state.collected is True
+
+
+async def test_rolling_budget_refuses_a_topup_that_would_exceed_it(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ async with create_session() as session:
+ session.add(
+ CashuTransaction(
+ id="prior-spend",
+ token="cashu-prior",
+ amount=ROUTSTR_MAX_DAILY_TOPUP_SATS,
+ unit="sat",
+ type="out",
+ source="auto_topup",
+ )
+ )
+ await session.commit()
+
+ assert await _routstr_spent_last_24h_sats() == ROUTSTR_MAX_DAILY_TOPUP_SATS
+
+ peer = _peer(0.0)
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-1"):
+ await _check_and_topup(row)
+
+ peer.topup.assert_not_awaited()
+ assert await _claim_state() is None
+
+
+async def test_admin_release_is_fenced_on_the_state_it_reviewed(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ peer = _peer(0.0, topup={"error": "rejected"})
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-1"):
+ await _check_and_topup(row)
+
+ state = await get_routstr_auto_topup_state(1)
+ assert state["active"] is True
+ assert state["phase"] == ROUTSTR_PHASE_SENT
+
+ stale = await release_routstr_auto_topup_state(
+ 1, state_token="routstr:other:0:sent:0:0"
+ )
+ assert stale.released is False and stale.reason == "stale_state"
+
+ released = await release_routstr_auto_topup_state(
+ 1, state_token=str(state["state_token"])
+ )
+ assert released.released is True
+
+ peer.topup.reset_mock()
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-2"):
+ await _check_and_topup(row)
+ peer.topup.assert_awaited_once()
+
+
+async def test_backoff_suppresses_retries_until_its_deadline(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ peer = _peer(0.0)
+
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-1"):
+ await _check_and_topup(row)
+ await _expire_claim()
+
+ # First reconciliation after the lease: the peer never credited, so the
+ # claim moves to backoff rather than paying again immediately.
+ peer.topup.reset_mock()
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-2"):
+ await _check_and_topup(row)
+ peer.topup.assert_not_awaited()
+
+ claim = _parse_routstr_request_id((await _claim_state()).request_id) # type: ignore[union-attr]
+ assert claim is not None and claim.phase == ROUTSTR_PHASE_BACKOFF
+ assert claim.deadline > int(time.time())
diff --git a/tests/unit/test_auto_topup.py b/tests/unit/test_auto_topup.py
index 0db7408f..d28f939c 100644
--- a/tests/unit/test_auto_topup.py
+++ b/tests/unit/test_auto_topup.py
@@ -3,12 +3,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from routstr.core.db import CashuTransaction
from routstr.upstream.auto_topup import (
_check_and_topup,
_parse_ppq_request_id,
_run_auto_topup_cycle,
validate_ppq_auto_topup_settings,
+ validate_routstr_auto_topup_settings,
)
from routstr.upstream.ppqai import PPQAIUpstreamProvider
from routstr.wallet import Bolt11PaymentAmbiguous, Bolt11PaymentNotAttempted
@@ -46,81 +46,18 @@ def _row() -> MagicMock:
return row
-class _Session:
- def __init__(self, transaction: CashuTransaction) -> None:
- self.transaction = transaction
- self.commit = AsyncMock()
-
- async def __aenter__(self) -> "_Session":
- return self
-
- async def __aexit__(self, *args: object) -> None:
- return None
-
- async def exec(self, query: object) -> MagicMock:
- result = MagicMock()
- result.first.return_value = self.transaction
- return result
-
- def add(self, transaction: CashuTransaction) -> None:
- self.transaction = transaction
-
-
@pytest.mark.asyncio
-async def test_auto_topup_persists_before_sending_and_marks_success_collected() -> None:
+async def test_auto_topup_refuses_invalid_settings_before_touching_the_wallet() -> None:
provider = MagicMock()
- provider.get_balance = AsyncMock(return_value=0)
- provider.topup = AsyncMock(return_value={"balance": 50})
- transaction = CashuTransaction(
- token="cashu-token", amount=50, unit="sat", source="auto_topup"
- )
- session = _Session(transaction)
-
- with (
- patch(
- "routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
- return_value=provider,
- ),
- patch(
- "routstr.upstream.auto_topup.send_token",
- AsyncMock(return_value="cashu-token"),
- ),
- patch(
- "routstr.upstream.auto_topup.store_cashu_transaction",
- AsyncMock(return_value=True),
- ) as store,
- patch(
- "routstr.upstream.auto_topup.token_mint_url",
- return_value="https://fallback-mint.test",
- ),
- patch("routstr.upstream.auto_topup.create_session", return_value=session),
- ):
- await _check_and_topup(_row())
-
- store.assert_awaited_once_with(
- token="cashu-token",
- amount=50,
- unit="sat",
- mint_url="https://fallback-mint.test",
- typ="out",
- collected=False,
- source="auto_topup",
- )
- provider.topup.assert_awaited_once_with("cashu-token")
- assert transaction.collected is True
- session.commit.assert_awaited_once()
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize("outcome", [{"error": "rejected"}, RuntimeError("network")])
-async def test_auto_topup_failure_leaves_persisted_token_uncollected(
- outcome: object,
-) -> None:
- provider = MagicMock()
- provider.get_balance = AsyncMock(return_value=0)
- provider.topup = AsyncMock(
- side_effect=outcome if isinstance(outcome, Exception) else None,
- return_value=outcome,
+ provider.get_balance = AsyncMock()
+ row = _row()
+ row.provider_settings = json.dumps(
+ {
+ "auto_topup": True,
+ "topup_threshold": 100,
+ "topup_amount_limit": 10**9,
+ "topup_mint_url": "https://mint.test",
+ }
)
with (
@@ -128,52 +65,12 @@ async def test_auto_topup_failure_leaves_persisted_token_uncollected(
"routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
return_value=provider,
),
- patch(
- "routstr.upstream.auto_topup.send_token",
- AsyncMock(return_value="cashu-token"),
- ),
- patch(
- "routstr.upstream.auto_topup.store_cashu_transaction",
- AsyncMock(return_value=True),
- ),
- patch("routstr.upstream.auto_topup.create_session") as create_session,
+ patch("routstr.upstream.auto_topup.send_token", AsyncMock()) as send,
):
- if isinstance(outcome, Exception):
- with pytest.raises(RuntimeError):
- await _check_and_topup(_row())
- else:
- await _check_and_topup(_row())
+ await _check_and_topup(row)
- create_session.assert_not_called()
-
-
-@pytest.mark.asyncio
-async def test_auto_topup_does_not_send_untracked_token() -> None:
- provider = MagicMock()
- provider.get_balance = AsyncMock(return_value=0)
- provider.topup = AsyncMock()
- with (
- patch(
- "routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
- return_value=provider,
- ),
- patch(
- "routstr.upstream.auto_topup.send_token",
- AsyncMock(return_value="cashu-token"),
- ),
- patch(
- "routstr.upstream.auto_topup.store_cashu_transaction",
- AsyncMock(side_effect=RuntimeError("database unavailable")),
- ),
- patch(
- "routstr.upstream.auto_topup.release_token_reservation",
- AsyncMock(),
- ) as reclaim,
- ):
- await _check_and_topup(_row())
-
- reclaim.assert_awaited_once_with("cashu-token")
- provider.topup.assert_not_awaited()
+ provider.get_balance.assert_not_awaited()
+ send.assert_not_awaited()
def _ppq_row() -> MagicMock:
@@ -734,3 +631,47 @@ def test_ppq_auto_topup_settings_validation_survives_huge_json_integers() -> Non
{"auto_topup": True, "topup_threshold": 10**400, "topup_amount_limit": 10}
)
assert problem is not None and "threshold" in problem
+
+
+def _routstr_settings(**overrides: object) -> dict:
+ settings = {
+ "auto_topup": True,
+ "topup_threshold": 1,
+ "topup_amount_limit": 50,
+ "topup_mint_url": "https://mint.test",
+ }
+ settings.update(overrides)
+ return settings
+
+
+@pytest.mark.parametrize(
+ ("settings", "expected"),
+ [
+ ({"auto_topup": False, "topup_threshold": -1}, None),
+ (_routstr_settings(), None),
+ (_routstr_settings(topup_threshold=None), "threshold"),
+ (_routstr_settings(topup_threshold=True), "threshold"),
+ (_routstr_settings(topup_threshold=float("inf")), "threshold"),
+ (_routstr_settings(topup_amount_limit=0), "positive"),
+ (_routstr_settings(topup_amount_limit=True), "positive"),
+ (_routstr_settings(topup_amount_limit=1.5), "whole number"),
+ (_routstr_settings(topup_amount_limit=10**9), "between"),
+ (_routstr_settings(topup_mint_url=""), "mint URL"),
+ (_routstr_settings(topup_mint_url=True), "mint URL"),
+ ],
+)
+def test_routstr_auto_topup_settings_validation(
+ settings: dict, expected: str | None
+) -> None:
+ problem = validate_routstr_auto_topup_settings(settings)
+ if expected is None:
+ assert problem is None
+ else:
+ assert problem is not None and expected in problem
+
+
+def test_routstr_auto_topup_settings_validation_survives_huge_json_integers() -> None:
+ problem = validate_routstr_auto_topup_settings(
+ _routstr_settings(topup_amount_limit=10**400)
+ )
+ assert problem is not None and "positive" in problem
From ed9a3417d25d34d5122b54d55d8043170a435e79 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Mon, 24 Aug 2026 22:57:16 +0200
Subject: [PATCH 024/120] fix: give the Routstr top-up threshold an explicit
unit
The auto top-up field has always been labelled "When credits are below (Sats)",
but the backend compared it as `balance >= threshold * 1000` against a balance
get_balance() had already converted to sats. Its sibling topup_amount_limit is
plain sats - it goes straight to send_token(amount, "sat", ...). So one settings
blob carried two units and the UI promised the one it did not use: an operator
asking to top up below 1000 sats got one below 1,000,000.
topup_threshold_sats says what it means and is compared as written. A legacy
topup_threshold keeps the thousandfold it has always been compared with, since
reinterpreting stored values as sats would drop the trigger point by a factor of
1000 on upgrade and leave a peer to run dry. A one-off warning per provider
names the sats value to migrate to.
The settings form now edits topup_threshold_sats, seeding it from the legacy
value times 1000 so the number shown is the number in force. Editing therefore
starts from the truth rather than silently moving the trigger on the next save,
and saving drops the legacy key so the stored blob carries one unit.
Validation accepts either key and rejects a blob carrying neither.
The PPQ path keeps its own topup_threshold, which is USD and unaffected.
---
routstr/upstream/auto_topup.py | 43 ++++-
...test_routstr_auto_topup_threshold_units.py | 164 ++++++++++++++++++
.../providers/RoutstrNodeSettings.tsx | 32 +++-
3 files changed, 230 insertions(+), 9 deletions(-)
create mode 100644 tests/integration/test_routstr_auto_topup_threshold_units.py
diff --git a/routstr/upstream/auto_topup.py b/routstr/upstream/auto_topup.py
index 6dd5ac7d..b4325e24 100644
--- a/routstr/upstream/auto_topup.py
+++ b/routstr/upstream/auto_topup.py
@@ -200,15 +200,48 @@ def validate_ppq_auto_topup_settings(settings: dict | None) -> str | None:
return None
+_legacy_threshold_hinted: set[int] = set()
+
+
+def _routstr_threshold_sats(row: UpstreamProviderRow, settings: dict) -> float:
+ """Balance, in sats, below which a top-up fires.
+
+ ``topup_threshold_sats`` is used as written. A legacy ``topup_threshold``
+ keeps the thousandfold it has always been compared with — reinterpreting it
+ as sats would drop an operator's trigger point by a factor of 1000 on
+ upgrade and leave the peer to run dry. The hint names the value to migrate
+ to, once per provider rather than once per scheduler tick.
+ """
+ explicit = settings.get("topup_threshold_sats")
+ if explicit is not None:
+ return float(typing.cast(int | float, explicit))
+
+ threshold_sats = float(typing.cast(int | float, settings["topup_threshold"])) * 1000
+ if row.id is not None and row.id not in _legacy_threshold_hinted:
+ _legacy_threshold_hinted.add(row.id)
+ logger.warning(
+ "Routstr auto top-up uses the legacy unitless threshold; set "
+ "topup_threshold_sats to state the unit",
+ extra={"provider_id": row.id, "threshold_sats": threshold_sats},
+ )
+ return threshold_sats
+
+
def validate_routstr_auto_topup_settings(settings: dict | None) -> str | None:
"""Return why enabled Routstr auto top-up settings are invalid, if anything."""
if not settings or not settings.get("auto_topup"):
return None
- threshold = settings.get("topup_threshold")
+ threshold_sats = settings.get("topup_threshold_sats")
+ legacy_threshold = settings.get("topup_threshold")
amount = settings.get("topup_amount_limit")
mint_url = settings.get("topup_mint_url")
- if _invalid_topup_number(threshold):
+ if threshold_sats is not None:
+ if _invalid_topup_number(threshold_sats):
+ return "Routstr auto top-up threshold must be a positive number of sats"
+ elif legacy_threshold is None:
+ return "Routstr auto top-up requires a threshold"
+ elif _invalid_topup_number(legacy_threshold):
return "Routstr auto top-up threshold must be a positive number"
if _invalid_topup_number(amount, integer=True):
return "Routstr auto top-up amount must be a positive whole number"
@@ -270,7 +303,7 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
)
return
- threshold = float(settings["topup_threshold"])
+ threshold_sats = _routstr_threshold_sats(row, settings)
amount = int(settings["topup_amount_limit"])
mint_url = str(settings["topup_mint_url"])
@@ -293,7 +326,7 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
)
return
- if balance >= threshold * 1000:
+ if balance >= threshold_sats:
return
spent_24h_sats = await _routstr_spent_last_24h_sats()
@@ -323,7 +356,7 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
extra={
"provider_id": row.id,
"balance": balance,
- "threshold": threshold,
+ "threshold_sats": threshold_sats,
"topup_amount": amount,
"mint_url": mint_url,
},
diff --git a/tests/integration/test_routstr_auto_topup_threshold_units.py b/tests/integration/test_routstr_auto_topup_threshold_units.py
new file mode 100644
index 00000000..a680b31d
--- /dev/null
+++ b/tests/integration/test_routstr_auto_topup_threshold_units.py
@@ -0,0 +1,164 @@
+"""The Routstr top-up threshold has to state its unit.
+
+``topup_amount_limit`` was already plain sats — it goes straight to
+``send_token(amount, "sat", ...)`` and is added to the balance for logging — but
+its sibling ``topup_threshold`` was compared as ``balance >= threshold * 1000``.
+Two keys from one settings blob, two different units, and nothing naming either.
+An operator asking for "top up below 1000 sats" got one below 1,000,000.
+
+``topup_threshold_sats`` says what it means. Legacy values keep their effective
+trigger point: reinterpreting them as sats would silently drop the trigger a
+thousandfold and let a provider run dry.
+"""
+
+import json
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+
+from routstr.core.db import UpstreamProviderRow, create_session
+from routstr.upstream import auto_topup as auto_topup_module
+from routstr.upstream.auto_topup import (
+ _check_and_topup,
+ validate_routstr_auto_topup_settings,
+)
+
+from .test_routstr_auto_topup_claim import _patch_wallet, _peer, _sent_tokens
+
+# No module-level asyncio mark: the settings cases are sync, and the suite
+# already runs asyncio in auto mode.
+
+TOPUP_SATS = 50
+
+
+@pytest.fixture(autouse=True)
+def _forget_legacy_hints() -> Any:
+ # The hint fires once per provider for the life of the process. Reach it
+ # through the module: a sibling test reloads auto_topup, which rebinds the
+ # set, so a name imported here would go on clearing the old one.
+ auto_topup_module._legacy_threshold_hinted.clear()
+ yield
+ auto_topup_module._legacy_threshold_hinted.clear()
+
+
+async def _seed(**topup_settings: Any) -> UpstreamProviderRow:
+ row = UpstreamProviderRow(
+ id=1,
+ slug="peer-1",
+ provider_type="routstr",
+ base_url="https://peer.test",
+ api_key="secret",
+ enabled=True,
+ provider_settings=json.dumps(
+ {
+ "auto_topup": True,
+ "topup_amount_limit": TOPUP_SATS,
+ "topup_mint_url": "https://mint.test",
+ **topup_settings,
+ }
+ ),
+ )
+ async with create_session() as session:
+ session.add(row)
+ await session.commit()
+ await session.refresh(row)
+ return row
+
+
+async def _topped_up(row: UpstreamProviderRow, balance: float) -> bool:
+ peer = _peer(balance)
+ with _patch_wallet(auto_topup_module, peer, "cashu-token-1"):
+ await _check_and_topup(row)
+ return bool(await _sent_tokens())
+
+
+async def test_explicit_sats_threshold_is_compared_against_a_sats_balance(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed(topup_threshold_sats=1000)
+ assert await _topped_up(row, 1500.0) is False
+
+
+async def test_explicit_sats_threshold_tops_up_below_the_stated_amount(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed(topup_threshold_sats=1000)
+ assert await _topped_up(row, 500.0) is True
+
+
+@pytest.mark.parametrize(
+ ("balance", "expected"),
+ [(1500.0, False), (500.0, True)],
+)
+async def test_legacy_threshold_keeps_its_effective_trigger_point(
+ patched_db_engine: Any, balance: float, expected: bool
+) -> None:
+ # 1 x 1000 == the 1000 sats the old comparison actually used. Reading it as
+ # 1 sat instead would leave the peer to run dry.
+ row = await _seed(topup_threshold=1)
+ assert await _topped_up(row, balance) is expected
+
+
+async def test_explicit_sats_threshold_overrides_the_legacy_key(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed(topup_threshold=1, topup_threshold_sats=100)
+ assert await _topped_up(row, 500.0) is False
+
+
+async def test_legacy_threshold_reports_the_sats_value_to_migrate_to(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed(topup_threshold=1)
+ # The app logger does not propagate to root, so caplog would see nothing.
+ with patch.object(auto_topup_module, "logger") as log:
+ await _topped_up(row, 5000.0)
+ hints = [
+ c for c in log.warning.call_args_list if "topup_threshold_sats" in c.args[0]
+ ]
+ assert len(hints) == 1
+ assert hints[0].kwargs["extra"]["threshold_sats"] == 1000.0
+
+ # The scheduler runs every minute; the hint must not run with it.
+ await _topped_up(row, 5000.0)
+ assert (
+ sum("topup_threshold_sats" in c.args[0] for c in log.warning.call_args_list)
+ == 1
+ )
+
+
+def test_settings_accept_the_sats_threshold_on_its_own() -> None:
+ assert (
+ validate_routstr_auto_topup_settings(
+ {
+ "auto_topup": True,
+ "topup_threshold_sats": 1000,
+ "topup_amount_limit": TOPUP_SATS,
+ "topup_mint_url": "https://mint.test",
+ }
+ )
+ is None
+ )
+
+
+@pytest.mark.parametrize("value", [0, -1, True, float("inf"), "1000", None])
+def test_settings_reject_an_unusable_sats_threshold(value: object) -> None:
+ assert validate_routstr_auto_topup_settings(
+ {
+ "auto_topup": True,
+ "topup_threshold_sats": value,
+ "topup_amount_limit": TOPUP_SATS,
+ "topup_mint_url": "https://mint.test",
+ }
+ )
+
+
+def test_settings_require_one_of_the_threshold_keys() -> None:
+ assert validate_routstr_auto_topup_settings(
+ {
+ "auto_topup": True,
+ "topup_amount_limit": TOPUP_SATS,
+ "topup_mint_url": "https://mint.test",
+ }
+ )
diff --git a/ui/components/providers/RoutstrNodeSettings.tsx b/ui/components/providers/RoutstrNodeSettings.tsx
index 18ace0dc..19499f10 100644
--- a/ui/components/providers/RoutstrNodeSettings.tsx
+++ b/ui/components/providers/RoutstrNodeSettings.tsx
@@ -14,12 +14,32 @@ import { Switch } from '@/components/ui/switch';
interface ProviderSettings {
topup_mint_url?: string;
auto_topup?: boolean;
+ topup_threshold_sats?: number;
+ /** Legacy, read-only here: the backend compares it as `x 1000` sats. */
topup_threshold?: number;
topup_amount_limit?: number;
refund_on_expiry?: boolean;
[key: string]: unknown;
}
+/**
+ * Sats a legacy provider actually tops up below.
+ *
+ * This field has always been labelled Sats, but the backend compared
+ * `topup_threshold` as `balance >= threshold * 1000`, so the number shown here
+ * was never the number in force. Show the figure the backend uses, so editing
+ * starts from the truth instead of silently moving the trigger a thousandfold
+ * on the next save.
+ */
+function thresholdSats(settings: ProviderSettings): number | undefined {
+ if (settings.topup_threshold_sats !== undefined) {
+ return settings.topup_threshold_sats;
+ }
+ return settings.topup_threshold !== undefined
+ ? settings.topup_threshold * 1000
+ : undefined;
+}
+
interface RoutstrNodeSettingsProps {
settings: ProviderSettings;
onSettingsChange: (settings: ProviderSettings) => void;
@@ -92,19 +112,23 @@ export function RoutstrNodeSettings({
- update({ topup_threshold: parseInt(e.target.value) })
+ update({
+ topup_threshold_sats: parseInt(e.target.value),
+ // Drop the legacy key so the saved blob carries one unit.
+ topup_threshold: undefined,
+ })
}
/>
From 16afd664a165ff446588a20a803047dec6756b69 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Mon, 24 Aug 2026 23:32:43 +0200
Subject: [PATCH 025/120] fix: narrow await_args before access to satisfy mypy
---
tests/unit/test_x_cashu_responses_streaming_sse.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/tests/unit/test_x_cashu_responses_streaming_sse.py b/tests/unit/test_x_cashu_responses_streaming_sse.py
index 0ecc56ca..aafe6e08 100644
--- a/tests/unit/test_x_cashu_responses_streaming_sse.py
+++ b/tests/unit/test_x_cashu_responses_streaming_sse.py
@@ -114,6 +114,7 @@ async def test_fragmented_crlf_stream_refunds_and_sets_cost_headers() -> None:
)
send_refund.assert_awaited_once()
+ assert send_refund.await_args is not None
assert send_refund.await_args.args[0] == 10_000 - 4000
assert response.headers["x-cashu"] == "cashuBrefundtoken0123456789"
assert response.headers["x-routstr-cost-msats"] == "4000"
@@ -125,6 +126,7 @@ async def test_fragmented_crlf_stream_refunds_and_sets_cost_headers() -> None:
async def test_nested_completion_usage_drives_cost_calculation() -> None:
_, get_cost, _ = await _settle(_canonical_chunks(), cost_data=_make_cost_data(4000))
+ assert get_cost.await_args is not None
response_data = get_cost.await_args.args[0]
assert response_data["model"] == "gpt-5-mini"
assert response_data["usage"]["input_tokens"] == 12
@@ -172,6 +174,8 @@ async def test_multiline_data_payload_is_parsed_and_reframed() -> None:
chunks, cost_data=_make_cost_data(4000)
)
+ assert get_cost.await_args is not None
+ assert send_refund.await_args is not None
assert get_cost.await_args.args[0]["usage"]["input_tokens"] == 12
assert send_refund.await_args.args[0] == 6000
body = await _collect(response)
@@ -193,6 +197,7 @@ async def test_missing_usage_settles_at_authorized_max() -> None:
)
send_refund.assert_awaited_once()
+ assert send_refund.await_args is not None
assert send_refund.await_args.args[0] == 10_000 - 9_000
assert response.headers["x-cashu"] == "cashuBrefundtoken0123456789"
assert response.headers["x-routstr-cost-msats"] == "9000"
@@ -209,6 +214,7 @@ async def test_malformed_events_do_not_retain_whole_token() -> None:
chunks, amount=10_000, max_cost_for_model=9_000
)
+ assert send_refund.await_args is not None
assert send_refund.await_args.args[0] == 1000
body = await _collect(response)
assert b"\\n" not in body
From cd52eb65cc363788c5c6948836b5b87a0fcc83b9 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Mon, 24 Aug 2026 23:40:14 +0200
Subject: [PATCH 026/120] test: widen pool-pressure timing margin to de-flake
balance pool test
---
tests/unit/test_fetch_all_balances.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/unit/test_fetch_all_balances.py b/tests/unit/test_fetch_all_balances.py
index ceeca1e4..89813cac 100644
--- a/tests/unit/test_fetch_all_balances.py
+++ b/tests/unit/test_fetch_all_balances.py
@@ -339,7 +339,7 @@ async def test_slow_mints_do_not_exhaust_a_single_connection_pool(
f"sqlite+aiosqlite:///{tmp_path / 'pool-pressure.db'}",
pool_size=1,
max_overflow=0,
- pool_timeout=0.2,
+ pool_timeout=0.5,
)
async with engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
@@ -350,7 +350,7 @@ async def test_slow_mints_do_not_exhaust_a_single_connection_pool(
yield session
async def slow_filter(proofs, wallet): # type: ignore[no-untyped-def]
- await asyncio.sleep(0.3)
+ await asyncio.sleep(1.0)
return proofs
try:
From f6e71e1512e5c15ce12be4fad22e265bea421b31 Mon Sep 17 00:00:00 2001
From: thefux
Date: Mon, 24 Aug 2026 23:20:49 +0000
Subject: [PATCH 027/120] fix: harden wallet and Cashu operations
---
routstr/core/admin.py | 70 +++++------
routstr/core/db.py | 13 +-
routstr/core/settings.py | 8 ++
routstr/lightning.py | 14 ++-
routstr/payment/lnurl.py | 33 ++++-
routstr/upstream/base.py | 59 +++------
routstr/wallet.py | 114 ++++++++++++++----
tests/integration/test_provider_management.py | 21 +---
tests/unit/test_fetch_all_balances.py | 7 +-
tests/unit/test_lnurl_melt_timeout.py | 66 +++++++++-
tests/unit/test_mint_fallback_trust.py | 20 ++-
tests/unit/test_refund_no_retry.py | 26 ++++
tests/unit/test_wallet.py | 66 ++++++++++
13 files changed, 381 insertions(+), 136 deletions(-)
create mode 100644 tests/unit/test_refund_no_retry.py
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index 2da3baf1..2a4c7fb7 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -1,4 +1,3 @@
-import asyncio
import json
import re
import secrets
@@ -1368,50 +1367,39 @@ async def initiate_provider_topup(
else {}
)
- last_status_code = 500
- last_error_detail: object = "Failed to create top-up invoice"
+ # This POST creates a Cashu mint quote upstream. Without an
+ # idempotency key, retrying a timeout or 5xx can create a
+ # second invoice while abandoning the first.
+ resp = await client.post(
+ f"{clean_url}/v1/balance/lightning/invoice",
+ json=request_json,
+ headers=headers,
+ )
- # Some upstream Routstr nodes fail the first invoice request after warm-up
- # and succeed immediately on retry. Retry once here so the UI stays single-click.
- for attempt in range(2):
- resp = await client.post(
- f"{clean_url}/v1/balance/lightning/invoice",
- json=request_json,
- headers=headers,
- )
-
- if resp.status_code == 200:
- data = resp.json()
- return {
- "ok": True,
- "topup_data": {
- "payment_request": data.get("bolt11"),
- "invoice_id": data.get("invoice_id"),
- "status": "pending",
- },
- }
-
- logger.error(
- f"Upstream topup request failed: {resp.text}",
- extra={
- "provider_id": provider_id,
- "attempt": attempt + 1,
- "status_code": resp.status_code,
+ if resp.status_code == 200:
+ data = resp.json()
+ return {
+ "ok": True,
+ "topup_data": {
+ "payment_request": data.get("bolt11"),
+ "invoice_id": data.get("invoice_id"),
+ "status": "pending",
},
- )
- try:
- last_error_detail = resp.json()
- except Exception:
- last_error_detail = resp.text
- last_status_code = resp.status_code
-
- if resp.status_code < 500 or attempt == 1:
- break
-
- await asyncio.sleep(0.2)
+ }
+ logger.error(
+ f"Upstream topup request failed: {resp.text}",
+ extra={
+ "provider_id": provider_id,
+ "status_code": resp.status_code,
+ },
+ )
+ try:
+ error_detail: object = resp.json()
+ except Exception:
+ error_detail = resp.text
raise HTTPException(
- status_code=last_status_code, detail=last_error_detail
+ status_code=resp.status_code, detail=error_detail
)
upstream_instance = _instantiate_provider(provider)
diff --git a/routstr/core/db.py b/routstr/core/db.py
index 2ab5ef25..76539c45 100644
--- a/routstr/core/db.py
+++ b/routstr/core/db.py
@@ -37,6 +37,14 @@ def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine:
is_memory_sqlite = is_sqlite and url.database in {None, "", ":memory:"}
pool_pre_ping = settings.database_pool_pre_ping or not is_sqlite
options: dict[str, int | float | bool] = {"pool_pre_ping": pool_pre_ping}
+ connect_args: dict[str, object] = {}
+ if is_sqlite and not is_memory_sqlite:
+ # SQLite's default busy_timeout is only 5s, and aiosqlite does not set
+ # one of its own. Without this, concurrent payment-settlement writes
+ # across the pooled engine wait just 5s, then raise
+ # sqlite3.OperationalError: database is locked. Give writers a real
+ # chance to acquire the single SQLite write lock.
+ connect_args["timeout"] = settings.database_busy_timeout
if not is_memory_sqlite:
options.update(
pool_size=settings.database_pool_size,
@@ -51,9 +59,12 @@ def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine:
"database_url_backend": backend,
"in_memory_sqlite": is_memory_sqlite,
**options,
+ "connect_args": connect_args,
},
)
- created_engine = create_async_engine(database_url, echo=False, **options)
+ created_engine = create_async_engine(
+ database_url, echo=False, connect_args=connect_args, **options
+ )
hold_warn_seconds = settings.database_pool_hold_warn_seconds
def record_pool_checkout(
diff --git a/routstr/core/settings.py b/routstr/core/settings.py
index 53e71a74..8f4caed8 100644
--- a/routstr/core/settings.py
+++ b/routstr/core/settings.py
@@ -148,6 +148,13 @@ class Settings(BaseSettings):
database_pool_hold_warn_seconds: float = Field(
default=10.0, gt=0, env="DATABASE_POOL_HOLD_WARN_SECONDS"
)
+ # SQLite busy_timeout (seconds): how long a writer waits on a locked DB
+ # before raising "database is locked". Defaults to SQLite's 5s in stock
+ # aiosqlite; raise it so concurrent payment-settlement writes can queue
+ # instead of erroring. Referenced only by create_db_engine for SQLite.
+ database_busy_timeout: float = Field(
+ default=30.0, gt=0, env="DATABASE_BUSY_TIMEOUT"
+ )
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
@@ -209,6 +216,7 @@ ENV_ONLY_FIELDS = frozenset(
"database_pool_recycle",
"database_pool_pre_ping",
"database_pool_hold_warn_seconds",
+ "database_busy_timeout",
}
)
diff --git a/routstr/lightning.py b/routstr/lightning.py
index df48756c..358e8515 100644
--- a/routstr/lightning.py
+++ b/routstr/lightning.py
@@ -215,11 +215,19 @@ async def _request_mint_with_fallback(
)
continue
try:
- wallet = await get_wallet(mint_url, "sat", retry_on_rate_limit=False)
+ wallet = await get_wallet(
+ mint_url,
+ "sat",
+ retry_on_rate_limit=False,
+ load_proofs=False,
+ )
quote = await run_mint_operation(
lambda: wallet.request_mint(amount_sats),
op_name="request_mint_invoice",
mint_url=mint_url,
+ # Response loss may leave a valid quote at the mint. Creating a
+ # second quote is not a safe retry without an idempotency key.
+ retry_timeouts=False,
retry_on_rate_limit=False,
)
return quote.request, quote.quote, mint_url
@@ -471,7 +479,9 @@ async def check_invoice_payment(
await session.commit()
mint_url = settlement.mint_url or settings.primary_mint
- wallet = await get_wallet(mint_url, "sat")
+ # Quote status is remote state and does not inspect local proofs.
+ # _mint_invoice_quote loads proofs exactly when settlement needs them.
+ wallet = await get_wallet(mint_url, "sat", load_proofs=False)
try:
mint_status = await run_mint_operation(
lambda: wallet.get_mint_quote(settlement.payment_hash),
diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py
index c9f48253..814f83fa 100644
--- a/routstr/payment/lnurl.py
+++ b/routstr/payment/lnurl.py
@@ -107,6 +107,18 @@ async def _fetch_lnurl_json(
return data
+def _contains_mint_transport_error(error: BaseException) -> bool:
+ """Detect transport failures wrapped by the Cashu wallet implementation."""
+ seen: set[int] = set()
+ current: BaseException | None = error
+ while current is not None and id(current) not in seen:
+ seen.add(id(current))
+ if isinstance(current, MINT_TRANSPORT_EXCEPTIONS):
+ return True
+ current = current.__cause__ or current.__context__
+ return False
+
+
async def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to get the actual URL.
@@ -293,6 +305,8 @@ async def raw_send_to_lnurl(
lambda: wallet.melt_quote(invoice=bolt11_invoice),
op_name="lnurl_melt_quote",
mint_url=str(wallet.url),
+ # Creating another quote after response loss only abandons the first.
+ retry_timeouts=False,
)
# The invoice comes from the LNURL service, so its amount is untrusted. The
@@ -331,8 +345,21 @@ async def raw_send_to_lnurl(
# reserved as though a Lightning payment could still settle.
await wallet.set_reserved_for_send(proofs, reserved=False)
raise
- if not isinstance(error, MINT_TRANSPORT_EXCEPTIONS):
+ if not _contains_mint_transport_error(error):
raise
+ # Cashu 0.20 clears melt reservations before wrapping transport errors
+ # in a plain Exception. Restore the durable melt association before
+ # asking for quote state so these proofs cannot be spent again while
+ # the Lightning outcome is unknown.
+ try:
+ await wallet.set_reserved_for_melt(
+ proofs, reserved=True, quote_id=melt_quote_resp.quote
+ )
+ except Exception as reservation_error:
+ raise MeltOutcomeAmbiguousError(
+ "Melt outcome is ambiguous and its proof reservation could not "
+ "be restored; proofs must not be retried"
+ ) from reservation_error
melt_response = None
melt_error: BaseException | None = error
else:
@@ -356,6 +383,10 @@ async def raw_send_to_lnurl(
if quote is not None and quote.state == MeltQuoteState.paid:
return final_amount
+ if quote is not None and quote.state == MeltQuoteState.unpaid:
+ # get_melt_quote() has authoritatively released the melt reservation;
+ # callers may restore their debit and retry with a new payment plan.
+ raise LNURLError("Cashu mint confirmed that the melt was unpaid") from melt_error
state = getattr(getattr(quote, "state", None), "value", "unknown")
raise MeltOutcomeAmbiguousError(
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index 4080be0f..595ec670 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -3628,54 +3628,32 @@ class BaseUpstreamProvider:
extra={"amount": amount, "unit": unit, "mint": mint},
)
- max_retries = 3
- last_exception = None
- refund_token = None
-
- for attempt in range(max_retries):
- try:
- refund_token = await send_token(amount, unit=unit, mint_url=mint)
- break
- except Exception as e:
- last_exception = e
- if attempt < max_retries - 1:
- logger.warning(
- "Refund token creation failed, retrying",
- extra={
- "error": str(e),
- "error_type": type(e).__name__,
- "attempt": attempt + 1,
- "max_retries": max_retries,
- "amount": amount,
- "unit": unit,
- "mint": mint,
- },
- )
- else:
- logger.error(
- "Failed to create refund token after all retries",
- extra={
- "error": str(e),
- "error_type": type(e).__name__,
- "attempt": attempt + 1,
- "max_retries": max_retries,
- "amount": amount,
- "unit": unit,
- "mint": mint,
- },
- )
-
- if refund_token is None:
+ try:
+ # send_token may perform an irreversible Cashu swap to make exact
+ # denominations. A blanket retry after response loss can dispatch
+ # a second swap, so this call is intentionally single-attempt.
+ refund_token = await send_token(amount, unit=unit, mint_url=mint)
+ except Exception as error:
+ logger.error(
+ "Failed to create refund token",
+ extra={
+ "error": str(error),
+ "error_type": type(error).__name__,
+ "amount": amount,
+ "unit": unit,
+ "mint": mint,
+ },
+ )
raise HTTPException(
status_code=401,
detail={
"error": {
- "message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
+ "message": f"failed to create refund: {error}",
"type": "invalid_request_error",
"code": "send_token_failed",
}
},
- )
+ ) from error
logger.info(
"Refund token created successfully",
@@ -3683,7 +3661,6 @@ class BaseUpstreamProvider:
"amount": amount,
"unit": unit,
"mint": mint,
- "attempt": attempt + 1,
"token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
diff --git a/routstr/wallet.py b/routstr/wallet.py
index c6cb1238..3814ac42 100644
--- a/routstr/wallet.py
+++ b/routstr/wallet.py
@@ -14,6 +14,7 @@ from typing import AsyncGenerator, TypedDict
import httpx
from cashu.core.base import MeltQuote, MeltQuoteState, MintQuote, Proof, Token
from cashu.core.mint_info import MintInfo as _CashuMintInfo
+from cashu.wallet.crud import get_keysets as get_cashu_keysets
from cashu.wallet.helpers import deserialize_token_from_string
from cashu.wallet.wallet import Wallet as _CashuWallet
from pydantic_core import PydanticUndefined
@@ -121,6 +122,12 @@ def _mints_to_inspect() -> list[str]:
return mint_urls
+_WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS = 30
+_WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS = 300
+_mint_metadata_last_load: dict[str, float] = {}
+_mint_metadata_load_locks: dict[str, asyncio.Lock] = {}
+
+
class Wallet(_CashuWallet):
"""Cashu adapter that preserves HTTP 429 for Routstr's mint policy."""
@@ -141,11 +148,37 @@ class Wallet(_CashuWallet):
_CashuWallet.raise_on_error_request(resp)
async def load_mint(
- self, keyset_id: str = "", force_old_keysets: bool = False
+ self,
+ keyset_id: str = "",
+ force_old_keysets: bool = False,
+ *,
+ force_refresh: bool = False,
) -> None:
- await self.load_mint_keysets(force_old_keysets)
- await self.activate_keyset(keyset_id)
- await self.load_mint_info(reload=True)
+ """Load metadata once per mint URL, then hydrate unit wallets locally."""
+ mint_url = str(self.url)
+ lock = _mint_metadata_load_locks.setdefault(mint_url, asyncio.Lock())
+ async with lock:
+ now = time.monotonic()
+ last = _mint_metadata_last_load.get(mint_url)
+ if (
+ not force_refresh
+ and last is not None
+ and now - last < _WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS
+ ):
+ try:
+ await self.load_keysets_from_db()
+ await self.activate_keyset(keyset_id)
+ await self.load_mint_info(reload=False)
+ return
+ except Exception:
+ # An empty/stale local cache is not authoritative. Fall
+ # through to one remote refresh under the per-mint lock.
+ pass
+
+ await self.load_mint_keysets(force_old_keysets)
+ await self.activate_keyset(keyset_id)
+ await self.load_mint_info(reload=True)
+ _mint_metadata_last_load[mint_url] = time.monotonic()
class MintConnectionError(Exception):
@@ -1048,6 +1081,7 @@ async def _request_mint_with_fallback(
mint_url,
settings.primary_mint_unit,
retry_on_rate_limit=False,
+ load_proofs=False,
)
quote = await run_mint_operation(
lambda: wallet.request_mint(amount),
@@ -1179,6 +1213,7 @@ async def _calculate_swap_amount(
lambda: token_wallet.melt_quote(dummy_mint_quote.request),
op_name="swap_fee_est_melt_quote",
mint_url=token_mint_url,
+ retry_timeouts=False,
)
fee_reserve = dummy_melt_quote.fee_reserve
@@ -1416,6 +1451,7 @@ async def swap_to_trusted_mint(
lambda: token_wallet.melt_quote(mint_quote.request),
op_name="swap_melt_quote",
mint_url=token_obj.mint,
+ retry_timeouts=False,
)
except Exception as error:
if is_mint_connection_error(error):
@@ -1796,13 +1832,13 @@ async def _credit_balance_locked(
_wallets: dict[str, Wallet] = {}
+# Proofs are local SQLite state and need a short refresh window because another
+# worker process can reserve or spend them. Mint metadata is remote, shared by
+# every operation on a wallet, and changes far less often; refreshing it on the
+# proof cadence caused repeated /keysets, /keys, and /info requests.
_wallet_last_load: dict[str, float] = {}
+_wallet_last_mint_load: dict[str, float] = {}
_wallet_load_locks: dict[str, asyncio.Lock] = {}
-# Minimum seconds between full mint info + proof reloads for the same
-# wallet. Prevents redundant mint API calls when get_wallet(load=True)
-# is called rapidly by multiple background tasks (balance fetch, payout,
-# auto-topup all hitting get_wallet within the same cycle).
-_WALLOAD_RELOAD_MIN_INTERVAL_SECONDS = 30
async def get_wallet(
@@ -1811,8 +1847,16 @@ async def get_wallet(
load: bool = True,
retry_on_rate_limit: bool = True,
force_reload: bool = False,
+ load_proofs: bool = True,
) -> Wallet:
- global _wallets, _wallet_last_load, _wallet_load_locks
+ """Return a cached wallet, refreshing remote and local state independently.
+
+ ``load=False`` remains the fully offline path. Quote-only callers can use
+ ``load_proofs=False``: mint metadata is initialized when needed, but local
+ proofs are not re-read when the operation cannot spend or inspect them.
+ ``force_reload`` still refreshes every requested layer immediately.
+ """
+ global _wallets, _wallet_last_load, _wallet_last_mint_load, _wallet_load_locks
id = f"{mint_url}_{unit}"
lock = _wallet_load_locks.setdefault(id, asyncio.Lock())
async with lock:
@@ -1821,25 +1865,40 @@ async def get_wallet(
if load:
now = time.monotonic()
- last = _wallet_last_load.get(id)
+ last_mint_load = _wallet_last_mint_load.get(id)
if (
force_reload
- or last is None
- or now - last >= _WALLOAD_RELOAD_MIN_INTERVAL_SECONDS
+ or last_mint_load is None
+ or now - last_mint_load >= _WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS
):
await run_mint_operation(
- lambda: _wallets[id].load_mint(),
+ lambda: (
+ _wallets[id].load_mint(force_refresh=True)
+ if force_reload
+ else _wallets[id].load_mint()
+ ),
op_name="load_mint",
mint_url=mint_url,
retry_on_rate_limit=retry_on_rate_limit,
)
- await run_mint_operation(
- lambda: _wallets[id].load_proofs(reload=True),
- op_name="load_proofs",
- mint_url=mint_url,
- retry_on_rate_limit=retry_on_rate_limit,
- )
- _wallet_last_load[id] = time.monotonic()
+ _wallet_last_mint_load[id] = time.monotonic()
+
+ if load_proofs:
+ last_proof_load = _wallet_last_load.get(id)
+ if (
+ force_reload
+ or last_proof_load is None
+ or now - last_proof_load
+ >= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS
+ ):
+ # cashu's load_proofs is local SQLite I/O, not a mint call.
+ await run_mint_operation(
+ lambda: _wallets[id].load_proofs(reload=True),
+ op_name="load_proofs",
+ mint_url=mint_url,
+ retry_on_rate_limit=retry_on_rate_limit,
+ )
+ _wallet_last_load[id] = time.monotonic()
return _wallets[id]
@@ -1912,13 +1971,16 @@ async def _get_supported_mint_units(mint_url: str) -> list[str]:
if cached is not None and now < cached[0]:
return cached[1]
- wallet = await get_wallet(mint_url, settings.primary_mint_unit, load=False)
- keysets = await run_mint_operation(
- lambda: wallet._get_keysets(),
- op_name="get_mint_keysets",
- mint_url=mint_url,
+ # One full remote metadata load populates Cashu's shared SQLite keyset
+ # cache. Discover all advertised units from that cache instead of issuing a
+ # separate /keysets request before each unit wallet loads.
+ wallet = await get_wallet(
+ mint_url,
+ settings.primary_mint_unit,
retry_on_rate_limit=False,
+ load_proofs=False,
)
+ keysets = await get_cashu_keysets(mint_url=wallet.url, db=wallet.db)
units: list[str] = []
for keyset in keysets:
if not keyset.active or keyset.unit is None:
diff --git a/tests/integration/test_provider_management.py b/tests/integration/test_provider_management.py
index b7db0c6c..de9a3112 100644
--- a/tests/integration/test_provider_management.py
+++ b/tests/integration/test_provider_management.py
@@ -686,7 +686,7 @@ async def test_no_database_changes_during_provider_operations(
@pytest.mark.integration
@pytest.mark.asyncio
-async def test_admin_routstr_topup_retries_transient_upstream_failure(
+async def test_admin_routstr_topup_does_not_duplicate_invoice_on_upstream_failure(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
@@ -739,16 +739,7 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure(
assert json["api_key"] == "sk-upstream-test"
assert headers["Authorization"] == "Bearer sk-upstream-test"
- if self.calls == 1:
- return MockResponse(500, {"detail": "warmup failure"})
-
- return MockResponse(
- 200,
- {
- "bolt11": "lnbc1testinvoice",
- "invoice_id": "invoice-123",
- },
- )
+ return MockResponse(500, {"detail": "ambiguous upstream failure"})
mock_client = MockAsyncClient()
@@ -759,11 +750,7 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure(
json={"amount": 10},
)
- assert response.status_code == 200
- data = response.json()
- assert data["ok"] is True
- assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
- assert data["topup_data"]["invoice_id"] == "invoice-123"
- assert mock_client.calls == 2
+ assert response.status_code == 500
+ assert mock_client.calls == 1
finally:
admin_sessions.pop(admin_token, None)
diff --git a/tests/unit/test_fetch_all_balances.py b/tests/unit/test_fetch_all_balances.py
index 89813cac..12e57fd4 100644
--- a/tests/unit/test_fetch_all_balances.py
+++ b/tests/unit/test_fetch_all_balances.py
@@ -136,19 +136,20 @@ async def test_supported_mint_units_come_from_active_keysets() -> None:
msat = MagicMock(active=False, unit="msat")
usd = MagicMock(active=True)
usd.unit.name = "usd"
- wallet = MagicMock()
- wallet._get_keysets = AsyncMock(return_value=[usd, msat, sat])
+ wallet = MagicMock(url="http://mint:3338", db=MagicMock())
+ get_keysets = AsyncMock(return_value=[usd, msat, sat])
with (
patch.object(settings, "primary_mint_unit", "sat"),
patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)),
+ patch("routstr.wallet.get_cashu_keysets", get_keysets),
):
units = await _get_supported_mint_units("http://mint:3338")
cached_units = await _get_supported_mint_units("http://mint:3338")
assert units == ["sat", "usd"]
assert cached_units == units
- wallet._get_keysets.assert_awaited_once()
+ get_keysets.assert_awaited_once_with(mint_url=wallet.url, db=wallet.db)
@pytest.mark.asyncio
diff --git a/tests/unit/test_lnurl_melt_timeout.py b/tests/unit/test_lnurl_melt_timeout.py
index dc35ab4c..dd0dfb02 100644
--- a/tests/unit/test_lnurl_melt_timeout.py
+++ b/tests/unit/test_lnurl_melt_timeout.py
@@ -11,6 +11,7 @@ from cashu.core.base import MeltQuoteState
from routstr.core.settings import settings
from routstr.mint import MintCooldownError, MintRateGuard
from routstr.payment.lnurl import (
+ LNURLError,
MeltOutcomeAmbiguousError,
raw_send_to_lnurl,
)
@@ -32,7 +33,10 @@ def _wallet() -> tuple[MagicMock, list[MagicMock]]:
wallet.melt_quote = AsyncMock(
return_value=MagicMock(fee_reserve=1, quote="q", amount=QUOTE_AMOUNT_SAT)
)
+ wallet.melt = AsyncMock()
wallet.select_to_send = AsyncMock(return_value=(proofs, None))
+ wallet.set_reserved_for_melt = AsyncMock()
+ wallet.set_reserved_for_send = AsyncMock()
return wallet, proofs
@@ -50,7 +54,7 @@ def _lnurl_patches() -> tuple[Any, Any]:
@pytest.mark.asyncio
-async def test_raw_send_to_lnurl_timeout_keeps_unpaid_outcome_ambiguous() -> None:
+async def test_raw_send_to_lnurl_timeout_reconciled_unpaid_is_retry_safe() -> None:
wallet, proofs = _wallet()
async def _hang(**kwargs: object) -> None:
@@ -67,12 +71,65 @@ async def test_raw_send_to_lnurl_timeout_keeps_unpaid_outcome_ambiguous() -> Non
patch.object(settings, "mint_retry_max_attempts", 0),
data_patch,
invoice_patch,
- pytest.raises(MeltOutcomeAmbiguousError, match="outcome is ambiguous"),
+ pytest.raises(LNURLError, match="confirmed that the melt was unpaid") as raised,
):
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
+ assert not isinstance(raised.value, MeltOutcomeAmbiguousError)
wallet.get_melt_quote.assert_awaited_once_with("q")
- wallet.set_reserved_for_melt.assert_not_called()
+ wallet.set_reserved_for_melt.assert_awaited_once_with(
+ proofs, reserved=True, quote_id="q"
+ )
+
+
+@pytest.mark.asyncio
+async def test_raw_send_to_lnurl_wrapped_transport_error_is_reconciled_once() -> None:
+ wallet, proofs = _wallet()
+
+ async def _wrapped_transport_error(**kwargs: object) -> None:
+ try:
+ raise httpx.ReadTimeout("response lost")
+ except httpx.ReadTimeout as transport_error:
+ raise Exception("could not pay invoice") from transport_error
+
+ wallet.melt = AsyncMock(side_effect=_wrapped_transport_error)
+ wallet.get_melt_quote = AsyncMock(
+ return_value=MagicMock(state=MeltQuoteState.unpaid)
+ )
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with (
+ patch.object(settings, "mint_retry_max_attempts", 3),
+ data_patch,
+ invoice_patch,
+ pytest.raises(LNURLError, match="confirmed that the melt was unpaid") as raised,
+ ):
+ await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
+
+ assert not isinstance(raised.value, MeltOutcomeAmbiguousError)
+ wallet.melt.assert_awaited_once()
+ wallet.get_melt_quote.assert_awaited_once_with("q")
+ wallet.set_reserved_for_melt.assert_awaited_once_with(
+ proofs, reserved=True, quote_id="q"
+ )
+
+
+@pytest.mark.asyncio
+async def test_raw_send_to_lnurl_does_not_retry_melt_quote_timeout() -> None:
+ wallet, proofs = _wallet()
+ wallet.melt_quote = AsyncMock(side_effect=httpx.ReadTimeout("response lost"))
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with (
+ patch.object(settings, "mint_retry_max_attempts", 3),
+ data_patch,
+ invoice_patch,
+ pytest.raises(httpx.TimeoutException),
+ ):
+ await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
+
+ wallet.melt_quote.assert_awaited_once()
+ wallet.melt.assert_not_awaited()
@pytest.mark.asyncio
@@ -98,6 +155,9 @@ async def test_raw_send_to_lnurl_timeout_reconciled_paid_is_success() -> None:
assert paid > 0
wallet.get_melt_quote.assert_awaited_once_with("q")
+ wallet.set_reserved_for_melt.assert_awaited_once_with(
+ proofs, reserved=True, quote_id="q"
+ )
@pytest.mark.asyncio
diff --git a/tests/unit/test_mint_fallback_trust.py b/tests/unit/test_mint_fallback_trust.py
index f7809458..5f2a2ab6 100644
--- a/tests/unit/test_mint_fallback_trust.py
+++ b/tests/unit/test_mint_fallback_trust.py
@@ -1,7 +1,8 @@
"""Persisted mint preferences must not bypass the configured trusted set."""
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
+import httpx
import pytest
from routstr.core.settings import settings
@@ -31,6 +32,23 @@ async def test_untrusted_allowed_mints_fall_back_to_trusted_set() -> None:
assert attempted == [TRUSTED]
+async def test_mint_quote_timeout_is_not_retried() -> None:
+ wallet = MagicMock()
+ wallet.request_mint = AsyncMock(side_effect=httpx.ReadTimeout("response lost"))
+
+ with (
+ patch.object(settings, "primary_mint", TRUSTED),
+ patch.object(settings, "cashu_mints", [TRUSTED]),
+ patch.object(settings, "mint_retry_max_attempts", 3),
+ patch("routstr.lightning.get_wallet", AsyncMock(return_value=wallet)),
+ patch("routstr.lightning.mint_cooldown_remaining", return_value=0.0),
+ pytest.raises(Exception),
+ ):
+ await _request_mint_with_fallback(10)
+
+ wallet.request_mint.assert_awaited_once_with(10)
+
+
async def test_trusted_allowed_mints_are_used_verbatim() -> None:
attempted: list[str] = []
diff --git a/tests/unit/test_refund_no_retry.py b/tests/unit/test_refund_no_retry.py
new file mode 100644
index 00000000..8c6b4b88
--- /dev/null
+++ b/tests/unit/test_refund_no_retry.py
@@ -0,0 +1,26 @@
+"""Refund token issuance must not repeat an ambiguous Cashu swap."""
+
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+from fastapi import HTTPException
+
+from routstr.upstream.base import BaseUpstreamProvider
+
+
+@pytest.mark.asyncio
+async def test_send_refund_does_not_retry_ambiguous_token_creation() -> None:
+ provider = object.__new__(BaseUpstreamProvider)
+ send_token = AsyncMock(side_effect=httpx.ReadTimeout("swap response lost"))
+
+ with (
+ patch("routstr.upstream.base.send_token", send_token),
+ pytest.raises(HTTPException) as raised,
+ ):
+ await provider.send_refund(10, "sat", mint="https://mint.test")
+
+ assert raised.value.status_code == 401
+ send_token.assert_awaited_once_with(
+ 10, unit="sat", mint_url="https://mint.test"
+ )
diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py
index 7c5de1fe..8b738f89 100644
--- a/tests/unit/test_wallet.py
+++ b/tests/unit/test_wallet.py
@@ -40,13 +40,19 @@ def isolate_wallet_runtime_state() -> Generator[None, None, None]:
wallet_module._MintRateGuard._guards.clear()
wallet_module._wallets.clear()
wallet_module._wallet_last_load.clear()
+ wallet_module._wallet_last_mint_load.clear()
wallet_module._wallet_load_locks.clear()
+ wallet_module._mint_metadata_last_load.clear()
+ wallet_module._mint_metadata_load_locks.clear()
yield
settings.mint_max_concurrency = original_concurrency
wallet_module._MintRateGuard._guards.clear()
wallet_module._wallets.clear()
wallet_module._wallet_last_load.clear()
+ wallet_module._wallet_last_mint_load.clear()
wallet_module._wallet_load_locks.clear()
+ wallet_module._mint_metadata_last_load.clear()
+ wallet_module._mint_metadata_load_locks.clear()
@pytest.mark.asyncio
@@ -66,6 +72,63 @@ async def test_get_balance() -> None:
assert balance == 50000
+@pytest.mark.asyncio
+async def test_wallet_metadata_is_reused_across_units() -> None:
+ from routstr.wallet import Wallet
+
+ sat_wallet = MagicMock(url="http://mint:3338")
+ sat_wallet.load_mint_keysets = AsyncMock()
+ sat_wallet.activate_keyset = AsyncMock()
+ sat_wallet.load_mint_info = AsyncMock()
+ sat_wallet.load_keysets_from_db = AsyncMock()
+
+ msat_wallet = MagicMock(url="http://mint:3338")
+ msat_wallet.load_mint_keysets = AsyncMock()
+ msat_wallet.activate_keyset = AsyncMock()
+ msat_wallet.load_mint_info = AsyncMock()
+ msat_wallet.load_keysets_from_db = AsyncMock()
+
+ with patch("routstr.wallet.time.monotonic", return_value=1000.0):
+ await Wallet.load_mint(sat_wallet)
+ await Wallet.load_mint(msat_wallet)
+
+ sat_wallet.load_mint_keysets.assert_awaited_once_with(False)
+ sat_wallet.load_mint_info.assert_awaited_once_with(reload=True)
+ msat_wallet.load_mint_keysets.assert_not_awaited()
+ msat_wallet.load_keysets_from_db.assert_awaited_once_with()
+ msat_wallet.load_mint_info.assert_awaited_once_with(reload=False)
+
+
+@pytest.mark.asyncio
+async def test_get_wallet_refreshes_local_proofs_without_reloading_mint() -> None:
+ from routstr import wallet as wallet_module
+ from routstr.wallet import get_wallet
+
+ mock_wallet = Mock(load_mint=AsyncMock(), load_proofs=AsyncMock())
+ with (
+ patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)),
+ patch("routstr.wallet.time.monotonic", return_value=1000.0),
+ ):
+ await get_wallet("http://mint:3338", "sat")
+ wallet_module._wallet_last_load["http://mint:3338_sat"] = 900.0
+ await get_wallet("http://mint:3338", "sat")
+
+ assert mock_wallet.load_mint.await_count == 1
+ assert mock_wallet.load_proofs.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_get_wallet_quote_only_skips_proof_reload() -> None:
+ from routstr.wallet import get_wallet
+
+ mock_wallet = Mock(load_mint=AsyncMock(), load_proofs=AsyncMock())
+ with patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)):
+ await get_wallet("http://mint:3338", "sat", load_proofs=False)
+
+ mock_wallet.load_mint.assert_awaited_once_with()
+ mock_wallet.load_proofs.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_get_wallet_force_reload_bypasses_reload_interval() -> None:
from routstr.wallet import get_wallet
@@ -2970,6 +3033,7 @@ async def test_load_mint_propagates_rate_limit() -> None:
from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet)
+ wallet.url = "https://rate-limited-mint.example"
error = MintRateLimitedError(
"Cashu mint rate limited",
request=httpx.Request("GET", "https://mint.example/v1/keysets"),
@@ -2987,6 +3051,7 @@ async def test_load_mint_propagates_connection_error() -> None:
from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet)
+ wallet.url = "https://unavailable-mint.example"
error = httpx.ConnectError("mint unavailable")
with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock(side_effect=error)),
@@ -3002,6 +3067,7 @@ async def test_load_mint_runs_keysets_activation_and_info() -> None:
from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet)
+ wallet.url = "https://mint-load.example"
with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock()) as load_keysets,
patch.object(wallet, "activate_keyset", new=AsyncMock()) as activate,
From aae2c9059e4b49485c0870ffa6777558e239a404 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Tue, 25 Aug 2026 01:47:26 +0200
Subject: [PATCH 028/120] test: cover busy_timeout connect_arg and refund
single-attempt side effects
---
tests/unit/test_db_pool_config.py | 31 ++++++++++++++++++++++++++++++
tests/unit/test_refund_no_retry.py | 23 ++++++++++++++++++++++
2 files changed, 54 insertions(+)
diff --git a/tests/unit/test_db_pool_config.py b/tests/unit/test_db_pool_config.py
index 8f5d0367..bc2a8dd9 100644
--- a/tests/unit/test_db_pool_config.py
+++ b/tests/unit/test_db_pool_config.py
@@ -56,9 +56,40 @@ def test_non_sqlite_backend_enables_pre_ping_automatically(
assert created is fake_engine
assert factory.call_args.kwargs["pool_pre_ping"] is True
+ assert "timeout" not in factory.call_args.kwargs["connect_args"]
assert listen.call_count == 2
+def test_file_sqlite_sets_busy_timeout_connect_arg(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: object
+) -> None:
+ monkeypatch.setattr(settings, "database_busy_timeout", 42.0)
+ fake_engine = MagicMock()
+
+ with (
+ patch.object(db, "create_async_engine", return_value=fake_engine) as factory,
+ patch.object(db.event, "listen"),
+ ):
+ create_db_engine(f"sqlite+aiosqlite:///{tmp_path}/busy.db")
+
+ assert factory.call_args.kwargs["connect_args"]["timeout"] == 42.0
+
+
+def test_memory_sqlite_omits_busy_timeout_connect_arg(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(settings, "database_busy_timeout", 42.0)
+ fake_engine = MagicMock()
+
+ with (
+ patch.object(db, "create_async_engine", return_value=fake_engine) as factory,
+ patch.object(db.event, "listen"),
+ ):
+ create_db_engine("sqlite+aiosqlite://")
+
+ assert "timeout" not in factory.call_args.kwargs["connect_args"]
+
+
@pytest.mark.asyncio
async def test_every_created_engine_warns_for_long_checkouts(
monkeypatch: pytest.MonkeyPatch, tmp_path: object
diff --git a/tests/unit/test_refund_no_retry.py b/tests/unit/test_refund_no_retry.py
index 8c6b4b88..f176755d 100644
--- a/tests/unit/test_refund_no_retry.py
+++ b/tests/unit/test_refund_no_retry.py
@@ -13,9 +13,11 @@ from routstr.upstream.base import BaseUpstreamProvider
async def test_send_refund_does_not_retry_ambiguous_token_creation() -> None:
provider = object.__new__(BaseUpstreamProvider)
send_token = AsyncMock(side_effect=httpx.ReadTimeout("swap response lost"))
+ store = AsyncMock()
with (
patch("routstr.upstream.base.send_token", send_token),
+ patch("routstr.upstream.base.store_cashu_transaction", store),
pytest.raises(HTTPException) as raised,
):
await provider.send_refund(10, "sat", mint="https://mint.test")
@@ -24,3 +26,24 @@ async def test_send_refund_does_not_retry_ambiguous_token_creation() -> None:
send_token.assert_awaited_once_with(
10, unit="sat", mint_url="https://mint.test"
)
+ store.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_send_refund_does_not_retry_or_store_on_generic_failure() -> None:
+ provider = object.__new__(BaseUpstreamProvider)
+ send_token = AsyncMock(side_effect=Exception("mint rejected swap"))
+ store = AsyncMock()
+
+ with (
+ patch("routstr.upstream.base.send_token", send_token),
+ patch("routstr.upstream.base.store_cashu_transaction", store),
+ pytest.raises(HTTPException) as raised,
+ ):
+ await provider.send_refund(10, "sat", mint="https://mint.test")
+
+ assert raised.value.status_code == 401
+ send_token.assert_awaited_once_with(
+ 10, unit="sat", mint_url="https://mint.test"
+ )
+ store.assert_not_awaited()
From 8a0547a8a07f0d4b5faed5625ee441d789579273 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 15:42:15 +0200
Subject: [PATCH 029/120] feat(pricing): add is_usable_rate, one definition of
a billable rate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A rate is usable only when it is finite and non-negative. Zero is usable —
"free" is a real price — but NaN, ±inf and negatives are not prices at all.
Python's truthiness cannot answer this question: inf and NaN are both truthy,
and NaN > 0, NaN < 0 and NaN == 0 are all False, so a malformed rate passes
every `if rate:` and every comparison-based guard. Stating the test once means
every caller answers it identically.
Co-Authored-By: Claude Opus 5
---
routstr/payment/models.py | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index 3deedb5a..3cd4de83 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -1,5 +1,6 @@
import asyncio
import json
+import math
import random
import httpx
@@ -58,6 +59,29 @@ class Pricing(BaseModel):
max_cost: float = 0.0 # in sats not msats
+def is_usable_rate(rate: float) -> bool:
+ """True if a single billable rate is a number a request could be billed on.
+
+ The one definition of a usable rate, so every guard that asks the question
+ answers it identically. A rate qualifies only when it is finite and
+ non-negative; zero is usable (it means "free", which is a real price) but
+ ``NaN``, ``±inf`` and negatives are not prices at all.
+
+ Non-finite: ``inf > 0`` is True, so an infinite rate reads as chargeable and
+ would be served, routed and billed as ``inf``; ``NaN`` poisons every total it
+ enters and defeats ordinary comparisons, since ``NaN > 0``, ``NaN < 0`` and
+ ``NaN == 0`` are all False. Negative: a negative rate produces a negative
+ cost, which the settlement path subtracts from the balance — it pays the
+ caller to make requests.
+
+ Both reach a stored row from upstream catalogs as well as the admin edge
+ (``json.loads`` accepts the bare ``NaN``/``Infinity`` literals and overflows
+ ``1e999`` to ``inf``), so the check belongs in one shared place rather than
+ at each writer.
+ """
+ return math.isfinite(rate) and rate >= 0.0
+
+
class TopProvider(BaseModel):
context_length: int | None = None
max_completion_tokens: int | None = None
From dbb9df13f205c854596bb06e68baf00c6c64ef5b Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 15:42:15 +0200
Subject: [PATCH 030/120] fix(pricing): keep an unusable rate out of the money
math
Prices reach the node from upstream catalogs, an operator's admin edit, a
legacy database row and the BTC/USD feed. json.loads accepts the bare NaN and
Infinity literals and overflows 1e999 to inf, so any of those sources can
deliver a value that is not a price. Three guards let one through:
- The token-rate gate tested truthiness, so NaN and inf reached the token math
and raised ValueError/OverflowError *after* the response was served. The
streaming handlers swallow that, so the request went unbilled. A negative
rate produced a negative charge, which settlement subtracts from the balance.
- An upstream-reported cost component was clamped with max(0.0, ...), which
passes inf and NaN through. A non-finite component poisoned the proportional
split in _calculate_from_usd_cost (inf / inf is NaN); the exception was
absorbed by the broad handler around the USD path, so a request whose total
was perfectly valid fell through to token estimation and was billed a
fraction of what the upstream charged. Each spelling is now coerced before
the fallback chooses between them, so a malformed first field cannot win the
`or` and hide the usable figure beside it.
- An exchange quote that was zero, negative or non-finite was accepted as the
node's BTC/USD price, repricing every model on the node.
Each now declines to price rather than billing a nonsensical amount.
Co-Authored-By: Claude Opus 5
---
routstr/payment/cost_calculation.py | 58 +++-
routstr/payment/price.py | 72 ++++-
tests/unit/test_pricing_rate_validation.py | 317 +++++++++++++++++++++
3 files changed, 424 insertions(+), 23 deletions(-)
create mode 100644 tests/unit/test_pricing_rate_validation.py
diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py
index 86495728..a51f633d 100644
--- a/routstr/payment/cost_calculation.py
+++ b/routstr/payment/cost_calculation.py
@@ -201,13 +201,14 @@ async def calculate_cost(
cost_details = usage_data.get("cost_details", {})
if not isinstance(cost_details, dict):
cost_details = {}
- input_usd = _coerce_usd(
- cost_details.get("input_cost")
- or cost_details.get("upstream_inference_prompt_cost")
+ # Coerce each spelling before choosing between them: `inf` and `NaN`
+ # are truthy, so a malformed first field would otherwise win the
+ # fallback and the usable figure beside it would never be read.
+ input_usd = _coerce_usd(cost_details.get("input_cost")) or _coerce_usd(
+ cost_details.get("upstream_inference_prompt_cost")
)
- output_usd = _coerce_usd(
- cost_details.get("output_cost")
- or cost_details.get("upstream_inference_completions_cost")
+ output_usd = _coerce_usd(cost_details.get("output_cost")) or _coerce_usd(
+ cost_details.get("upstream_inference_completions_cost")
)
cache_pricing_rates: tuple[float, float, float, float] | None = None
if cache_read_tokens > 0 or cache_creation_tokens > 0:
@@ -267,9 +268,22 @@ async def calculate_cost(
else:
input_rate, output_rate, cache_read_rate, cache_creation_rate = pricing_rates
- if not (input_rate and output_rate):
+ # Local import mirrors this module's existing lazy pricing imports.
+ from .models import is_usable_rate
+
+ # An unusable rate is not "no pricing" to Python's truthiness: `NaN` and a
+ # negative float are both truthy, so they sailed past this gate — the one
+ # guard meant to catch a rate that cannot be billed on — and reached the
+ # token math, which raises `ValueError` on `NaN` and `OverflowError` on
+ # `inf` *after* the response was served (the streaming handlers swallow
+ # that, so the request goes unbilled), while a negative produced a negative
+ # charge. Ask whether each rate is usable rather than whether it is truthy.
+ rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate)
+ if not all(is_usable_rate(rate) for rate in rates) or not (
+ input_rate and output_rate
+ ):
logger.warning(
- "No token pricing configured — billing at flat MaxCostData. "
+ "No usable token pricing — billing at flat MaxCostData. "
"Token counts %s in the upstream response but cannot be "
"priced; the request will appear in dashboards with the "
"raw counts and a fixed max-cost charge.",
@@ -279,6 +293,8 @@ async def calculate_cost(
"model": response_data.get("model", "unknown"),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
+ "input_rate": input_rate,
+ "output_rate": output_rate,
},
)
return MaxCostData(
@@ -313,15 +329,35 @@ async def calculate_cost(
def _coerce_usd(value: object) -> float:
- """Coerce a value to USD float, handling various formats safely."""
+ """Coerce an upstream-reported USD figure to a usable amount, else ``0.0``.
+
+ These values come straight off the upstream response, where ``json.loads``
+ accepts the bare ``NaN``/``Infinity`` literals and overflows ``1e999`` to
+ ``inf``. A non-finite figure is not a cost, and letting one through poisoned
+ the proportional split in ``_calculate_from_usd_cost`` (``inf / inf`` is
+ ``NaN``): the resulting exception was absorbed by the broad handler around
+ the USD path, so a request whose *total* cost was perfectly valid fell
+ through to token-estimated pricing and was billed a fraction of what the
+ upstream charged.
+
+ ``0.0`` means "no usable figure" to every caller, which is the same thing an
+ absent field means, so the caller's existing ``> 0`` checks handle it.
+ """
+ # Local import mirrors this module's existing lazy pricing imports.
+ from .models import is_usable_rate
+
if value is None or isinstance(value, bool):
return 0.0
if not isinstance(value, (int, float, str)):
return 0.0
try:
- return max(0.0, float(value))
- except (TypeError, ValueError):
+ # An oversized integer raises OverflowError, not ValueError.
+ amount = float(value)
+ except (TypeError, ValueError, OverflowError):
return 0.0
+ # `is_usable_rate` also rejects negatives, which the previous `max(0.0, …)`
+ # clamped to zero — same outcome, stated once instead of inline.
+ return amount if is_usable_rate(amount) else 0.0
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
diff --git a/routstr/payment/price.py b/routstr/payment/price.py
index ad614322..93ed68e9 100644
--- a/routstr/payment/price.py
+++ b/routstr/payment/price.py
@@ -12,16 +12,68 @@ BTC_USD_PRICE: float | None = None
SATS_USD_PRICE: float | None = None
+def _parse_quote(raw: object, exchange: str) -> float | None:
+ """Coerce an exchange quote to a price, or ``None`` if it is not one.
+
+ Every quote passes through here because the aggregator takes the ``min()``
+ of what it collects: an unusable quote does not merely join the sample, it
+ *wins* it, and the result is the rate every model and every request on the
+ node is priced at. A zero divides by zero on the USD cost path, ``NaN``
+ raises out of the integer conversion in settlement, and a negative rate
+ produces a negative charge that is credited back to the caller.
+
+ ``is_usable_rate`` is the same predicate the billable-rate guards use, so
+ "finite and non-negative" has one definition; a quote is stricter still and
+ must be positive, since a BTC price of zero is a broken feed, not free money.
+ Imported lazily because ``payment.models`` imports this module.
+ """
+ from .models import is_usable_rate
+
+ # A boolean is a shape change, not a price: `float(True)` is a finite,
+ # positive 1.0 that passes every numeric guard below and then wins the
+ # `min()`, pricing the node at one dollar per bitcoin.
+ if isinstance(raw, bool):
+ logger.warning(
+ "Non-numeric price quote — ignoring this exchange",
+ extra={"exchange": exchange, "quote": repr(raw)},
+ )
+ return None
+
+ try:
+ price = float(raw) # type: ignore[arg-type]
+ except (TypeError, ValueError, OverflowError) as e:
+ logger.warning(
+ "Unparseable price quote — ignoring this exchange",
+ extra={
+ "error": str(e),
+ "error_type": type(e).__name__,
+ "exchange": exchange,
+ "quote": repr(raw),
+ },
+ )
+ return None
+
+ if not is_usable_rate(price) or price <= 0:
+ logger.warning(
+ "Unusable price quote — ignoring this exchange",
+ extra={"exchange": exchange, "quote": price},
+ )
+ return None
+
+ return price
+
+
async def _kraken_btc_usd(client: httpx.AsyncClient) -> float | None:
"""Fetch BTC/USD price from Kraken API."""
api = "https://api.kraken.com/0/public/Ticker?pair=XBTUSD"
try:
response = await client.get(api)
price_data = response.json()
- price = float(price_data["result"]["XXBTZUSD"]["c"][0])
-
- return price
- except (httpx.RequestError, KeyError) as e:
+ return _parse_quote(price_data["result"]["XXBTZUSD"]["c"][0], "kraken")
+ except (httpx.RequestError, KeyError, IndexError, TypeError, ValueError) as e:
+ # A payload whose *shape* changed raises IndexError/TypeError, and a
+ # non-JSON body raises ValueError; unhandled, one exchange's bad day
+ # aborted the whole aggregation instead of dropping a single quote.
logger.warning(
"Kraken API error",
extra={
@@ -39,10 +91,8 @@ async def _coinbase_btc_usd(client: httpx.AsyncClient) -> float | None:
try:
response = await client.get(api)
price_data = response.json()
- price = float(price_data["data"]["amount"])
-
- return price
- except (httpx.RequestError, KeyError) as e:
+ return _parse_quote(price_data["data"]["amount"], "coinbase")
+ except (httpx.RequestError, KeyError, IndexError, TypeError, ValueError) as e:
logger.warning(
"Coinbase API error",
extra={
@@ -60,10 +110,8 @@ async def _binance_btc_usdt(client: httpx.AsyncClient) -> float | None:
try:
response = await client.get(api)
price_data = response.json()
- price = float(price_data["price"])
-
- return price
- except (httpx.RequestError, KeyError) as e:
+ return _parse_quote(price_data["price"], "binance")
+ except (httpx.RequestError, KeyError, IndexError, TypeError, ValueError) as e:
logger.warning(
"Binance API error",
extra={
diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py
new file mode 100644
index 00000000..db2f5100
--- /dev/null
+++ b/tests/unit/test_pricing_rate_validation.py
@@ -0,0 +1,317 @@
+"""Tests that an unusable rate never reaches the money math.
+
+A billable rate is usable only when it is finite and non-negative. Prices reach
+the node from upstream catalogs, an operator's admin edit, a legacy database row
+and the BTC/USD feed, and each of those can deliver ``NaN``, ``±inf`` or a
+negative — ``json.loads`` accepts the bare ``NaN``/``Infinity`` literals and
+overflows ``1e999`` to ``inf``.
+
+These tests cover the guards between such a value and a charge: the token-rate
+gate that decides a model cannot be priced, the upstream-reported USD cost, the
+exchange-rate feed, and the stored-row read path. They assert the node declines
+to price the request rather than billing a nonsensical amount or raising after
+the response has already been served.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Iterator
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+
+from routstr.payment.cost_calculation import (
+ CostData,
+ MaxCostData,
+ calculate_cost,
+)
+from routstr.payment.models import (
+ Architecture,
+ Model,
+ Pricing,
+)
+
+
+@pytest.fixture(autouse=True)
+def patch_sats_usd_price() -> Iterator[None]:
+ """Pin the exchange rate; these tests are about the rates, not the feed."""
+ with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=5.0e-5):
+ yield
+
+
+def _architecture() -> Architecture:
+ return Architecture(
+ modality="text",
+ input_modalities=["text"],
+ output_modalities=["text"],
+ tokenizer="unknown",
+ instruct_type=None,
+ )
+
+
+def _model(sats_pricing: Pricing) -> Model:
+ return Model(
+ id="m",
+ name="m",
+ created=0,
+ description="d",
+ context_length=8192,
+ architecture=_architecture(),
+ pricing=Pricing(prompt=1e-06, completion=2e-06),
+ sats_pricing=sats_pricing,
+ )
+
+
+def _usage_response() -> dict[str, Any]:
+ return {"model": "m", "usage": {"prompt_tokens": 1000, "completion_tokens": 500}}
+
+
+@pytest.mark.parametrize(
+ "bad_rate",
+ [float("nan"), float("inf"), -5.0],
+ ids=["nan", "inf", "negative"],
+)
+@pytest.mark.asyncio
+async def test_unusable_token_rate_falls_back_to_max_cost(bad_rate: float) -> None:
+ """An unusable configured rate must not be billed on.
+
+ The "no token pricing configured" gate is a truthiness test, and ``NaN`` and
+ negative floats are both truthy, so an unusable rate passes the guard that
+ exists to catch it. It then reaches the integer conversion in the token math,
+ which raises ``ValueError`` for ``NaN`` and ``OverflowError`` for ``inf`` —
+ after the upstream response has already been served, where the streaming
+ handlers swallow it and the request goes unbilled.
+ """
+ model = _model(Pricing(prompt=bad_rate, completion=1.0))
+
+ cost = await calculate_cost(_usage_response(), max_cost=1234, model_obj=model)
+
+ assert isinstance(cost, MaxCostData)
+ assert cost.total_msats == 1234
+
+
+@pytest.mark.parametrize(
+ "junk", [float("inf"), float("nan"), "Infinity"], ids=["inf", "nan", "inf-string"]
+)
+@pytest.mark.asyncio
+async def test_junk_cost_component_still_bills_the_reported_total(junk: Any) -> None:
+ """A malformed component must not discard the upstream's real total cost.
+
+ ``cost_details`` only splits the total across input and output; the total is
+ the authoritative billed amount. A non-finite component poisons the
+ proportional allocation (``inf / inf`` is ``NaN``), which raised out of the
+ USD path and was swallowed by the broad handler around it — so the request
+ silently fell through to token-estimated pricing and was billed at a small
+ fraction of what the upstream actually charged.
+ """
+ model = _model(Pricing(prompt=1e-06, completion=2e-06))
+ response = {
+ "model": "m",
+ "usage": {
+ "prompt_tokens": 1000,
+ "completion_tokens": 500,
+ "cost": 0.01,
+ "cost_details": {"input_cost": junk, "output_cost": 0.004},
+ },
+ }
+
+ cost = await calculate_cost(response, max_cost=9999, model_obj=model)
+
+ assert isinstance(cost, CostData)
+ # $0.01 at 5.0e-5 USD/sat = 200 sats = 200_000 msats.
+ assert cost.total_msats == 200000
+ assert cost.total_usd == pytest.approx(0.01)
+
+
+@pytest.mark.asyncio
+async def test_junk_cost_component_falls_back_to_its_alternate_field() -> None:
+ """A malformed component must not shadow the field that would have replaced it.
+
+ Each side of the split has two spellings and the second is a fallback for a
+ missing first. ``inf`` and ``NaN`` are both truthy, so a malformed
+ ``input_cost`` won that choice before anything checked whether it was a
+ number, and the usable figure beside it was never read — the input side was
+ then billed at nothing and the whole total landed on output.
+ """
+ model = _model(Pricing(prompt=1e-06, completion=2e-06))
+ response = {
+ "model": "m",
+ "usage": {
+ "prompt_tokens": 1000,
+ "completion_tokens": 500,
+ "cost": 0.01,
+ "cost_details": {
+ "input_cost": float("inf"),
+ "upstream_inference_prompt_cost": 0.006,
+ "output_cost": 0.004,
+ },
+ },
+ }
+
+ cost = await calculate_cost(response, max_cost=9999, model_obj=model)
+
+ assert isinstance(cost, CostData)
+ # $0.01 at 5.0e-5 USD/sat = 200_000 msats, split 0.006 : 0.004.
+ assert cost.total_msats == 200000
+ assert (cost.input_msats, cost.output_msats) == (120000, 80000)
+
+
+@pytest.mark.asyncio
+async def test_non_finite_reported_cost_is_not_a_cost() -> None:
+ """An upstream-reported ``Infinity`` cost is junk, not an infinite charge.
+
+ ``json.loads`` accepts the bare ``Infinity`` literal, so a compromised or
+ buggy upstream can put one in ``usage.cost``. It must not be treated as a
+ positive USD cost at all — the request falls through to the node's own token
+ pricing instead.
+ """
+ model = _model(Pricing(prompt=1e-06, completion=2e-06))
+ response = {
+ "model": "m",
+ "usage": {
+ "prompt_tokens": 1000,
+ "completion_tokens": 500,
+ "cost": float("inf"),
+ },
+ }
+
+ cost = await calculate_cost(response, max_cost=9999, model_obj=model)
+
+ assert isinstance(cost, CostData)
+ assert math.isfinite(cost.total_usd)
+ assert cost.total_msats == 2
+
+
+class _ExchangeResponse:
+ def __init__(self, payload: dict[str, Any]) -> None:
+ self._payload = payload
+
+ def json(self) -> dict[str, Any]:
+ return self._payload
+
+
+class _ExchangeClient:
+ """Answers each exchange endpoint with a caller-supplied quote."""
+
+ def __init__(self, quotes: dict[str, Any]) -> None:
+ self._quotes = quotes
+
+ async def get(self, url: str) -> _ExchangeResponse:
+ if "kraken" in url:
+ return _ExchangeResponse(
+ {"result": {"XXBTZUSD": {"c": [self._quotes["kraken"]]}}}
+ )
+ if "coinbase" in url:
+ return _ExchangeResponse({"data": {"amount": self._quotes["coinbase"]}})
+ return _ExchangeResponse({"price": self._quotes["binance"]})
+
+
+class _AsyncCtx:
+ def __init__(self, client: _ExchangeClient) -> None:
+ self._client = client
+
+ async def __aenter__(self) -> _ExchangeClient:
+ return self._client
+
+ async def __aexit__(self, *exc: object) -> bool:
+ return False
+
+
+@pytest.fixture
+def refresh_price_with() -> Iterator[Any]:
+ """Refresh the node's BTC/USD price from caller-supplied exchange quotes.
+
+ Restores the module's cached price afterwards so one test cannot set the
+ rate another one bills at.
+ """
+ import routstr.payment.price as price_module
+
+ previous = (price_module.BTC_USD_PRICE, price_module.SATS_USD_PRICE)
+
+ async def _run(quotes: dict[str, Any], last_good: float | None = None) -> None:
+ price_module.BTC_USD_PRICE = last_good
+ price_module.SATS_USD_PRICE = (
+ None if last_good is None else last_good / 100_000_000
+ )
+ with patch.object(
+ price_module.httpx,
+ "AsyncClient",
+ lambda *a, **k: _AsyncCtx(_ExchangeClient(quotes)),
+ ):
+ await price_module._update_prices()
+
+ yield _run
+
+ price_module.BTC_USD_PRICE, price_module.SATS_USD_PRICE = previous
+
+
+@pytest.mark.parametrize(
+ "bad_quote",
+ ["0", "0.00000000", "-1", "NaN", "Infinity", "N/A"],
+ ids=["zero", "zero-padded", "negative", "nan", "infinity", "non-numeric"],
+)
+@pytest.mark.asyncio
+async def test_unusable_exchange_quote_does_not_set_the_node_price(
+ bad_quote: str, refresh_price_with: Any
+) -> None:
+ """One exchange returning junk must not set the price the node bills at.
+
+ The feed takes the ``min()`` of the quotes it collects, so an unusable quote
+ does not merely join the sample — it *wins*, and poisons the rate every model
+ and every request is priced at until the next refresh. Zero then divides by
+ zero on the USD path, ``NaN`` raises out of the integer conversion, and a
+ negative rate produces a negative charge that settlement credits back to the
+ caller.
+
+ The two healthy quotes must still price the node.
+ """
+ from routstr.payment.price import btc_usd_price
+
+ await refresh_price_with(
+ {"kraken": bad_quote, "coinbase": "100000.0", "binance": "100000.0"}
+ )
+
+ assert btc_usd_price() == pytest.approx(100000.0)
+
+
+@pytest.mark.asyncio
+async def test_boolean_exchange_quote_does_not_set_the_node_price(
+ refresh_price_with: Any,
+) -> None:
+ """A boolean in the price field is a shape change, not a $1 bitcoin.
+
+ ``float(True)`` is ``1.0``, which is finite and positive, so a payload whose
+ price field turned into a boolean passes every numeric guard — and then
+ *wins* the ``min()``, pricing the whole node at one dollar per bitcoin. The
+ node's other coercions all reject ``bool`` before the numeric check for this
+ reason; this one is the exception.
+ """
+ from routstr.payment.price import btc_usd_price
+
+ await refresh_price_with(
+ {"kraken": True, "coinbase": "100000.0", "binance": "100000.0"}
+ )
+
+ assert btc_usd_price() == pytest.approx(100000.0)
+
+
+@pytest.mark.asyncio
+async def test_all_quotes_unusable_keeps_the_last_good_price(
+ refresh_price_with: Any,
+) -> None:
+ """When every quote is junk the node keeps the last price it trusted.
+
+ Adopting ``0`` or ``NaN`` because it was the only thing on offer would take
+ out billing for every model at once; skipping the update degrades to a stale
+ rate, which is the safe direction and what an unreachable exchange already
+ does.
+ """
+ from routstr.payment.price import btc_usd_price
+
+ await refresh_price_with(
+ {"kraken": "0", "coinbase": "NaN", "binance": "-3"}, last_good=90000.0
+ )
+
+ assert btc_usd_price() == pytest.approx(90000.0)
From 89e6acef4d99bddf2ef128a5d0adbd5da96463ee Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 16:16:15 +0200
Subject: [PATCH 031/120] fix(pricing): treat a non-finite rate as no price
wherever pricing is ingested
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`inf > 0` is True, so an infinite rate read as a real price: it was imported,
served, routed, and would bill an infinite amount. `NaN` is worse — every
comparison with it is False, so it slipped past both the negative and the
both-zero guards.
Rates arrive from upstream catalogs as well as the admin edge, and `json.loads`
accepts the bare `NaN`/`Infinity` literals and overflows `1e999` to `inf`, so
the finiteness check goes in the catalog import filter and in each resolver rung
that reports a resolved price rather than at one entry point. An oversized
integer raises `OverflowError` instead of `ValueError`, so coercion catches that
too — unhandled it unwound the whole fetch and cost the node an entire upstream
catalog over one junk entry.
A malformed *cache* rate costs only the cache rate: it is dropped and billing
falls back to the full input rate, which is what a missing cache rate means.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
routstr/payment/models.py | 12 ++-
routstr/upstream/pricing_resolver.py | 20 +++-
tests/unit/test_pricing_rate_validation.py | 95 ++++++++++++++++++
tests/unit/test_upstream_generic.py | 107 +++++++++++++++++++++
4 files changed, 228 insertions(+), 6 deletions(-)
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index 3cd4de83..dad9bbb5 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -168,7 +168,7 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
def _has_valid_pricing(model: dict) -> bool:
- """Check if model has valid pricing (not free, no negative values)."""
+ """Check if model has valid pricing (usable rates, and not free)."""
pricing = model.get("pricing", {})
if not pricing:
return False
@@ -176,10 +176,16 @@ def _has_valid_pricing(model: dict) -> bool:
try:
prompt = float(pricing.get("prompt", 0))
completion = float(pricing.get("completion", 0))
- except (ValueError, TypeError):
+ except (ValueError, TypeError, OverflowError):
+ # An integer too large for a float raises OverflowError, not
+ # ValueError, so it escaped this coercion guard and unwound the whole
+ # fetch — one junk entry cost the node the entire upstream catalog.
return False
- if prompt < 0 or completion < 0:
+ # `NaN`/`±inf` are not prices, and neither is caught by the checks below:
+ # every comparison with `NaN` is False, and `inf` reads as a large positive
+ # rate that would be advertised and billed on.
+ if not is_usable_rate(prompt) or not is_usable_rate(completion):
return False
if prompt == 0 and completion == 0:
diff --git a/routstr/upstream/pricing_resolver.py b/routstr/upstream/pricing_resolver.py
index 3d009fdc..3064ba57 100644
--- a/routstr/upstream/pricing_resolver.py
+++ b/routstr/upstream/pricing_resolver.py
@@ -15,6 +15,7 @@ it into the base provider unchanged.
from __future__ import annotations
+import math
from dataclasses import dataclass, field
@@ -65,11 +66,19 @@ def estimate_context_length(model_id: str) -> int:
def _as_float(value: object) -> float | None:
- """OpenRouter reports prices as strings; coerce, ``None`` if unparseable."""
+ """OpenRouter reports prices as strings; coerce, ``None`` if not a real number.
+
+ Non-finite values are rejected as unparseable: ``float("Infinity")`` and
+ ``float("NaN")`` parse happily from a feed string, and ``json.loads``
+ accepts the bare literals and overflows ``1e999`` to ``inf``. An oversized
+ integer raises ``OverflowError`` rather than ``ValueError``, so that is
+ caught too.
+ """
try:
- return float(value) # type: ignore[arg-type]
- except (TypeError, ValueError):
+ parsed = float(value) # type: ignore[arg-type]
+ except (TypeError, ValueError, OverflowError):
return None
+ return parsed if math.isfinite(parsed) else None
def _as_int(value: object) -> int | None:
@@ -94,6 +103,11 @@ def _from_litellm(model_id: str) -> ResolvedPricing | None:
# moderation/rerank tiers do this) — treating 0/0 as resolved would serve
# the model for free. Reject it (and any negative) so the caller falls
# through, mirroring async_fetch_openrouter_models' _has_valid_pricing.
+ # A non-finite entry is junk, not a price: `inf` would bill an infinite
+ # amount and `NaN` poisons every total it enters (and defeats the `< 0` and
+ # `== 0` guards below, since both comparisons are False for `NaN`).
+ if not math.isfinite(prompt) or not math.isfinite(completion):
+ return None
if prompt < 0 or completion < 0 or (prompt == 0 and completion == 0):
return None
diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py
index db2f5100..9e4a5d6e 100644
--- a/tests/unit/test_pricing_rate_validation.py
+++ b/tests/unit/test_pricing_rate_validation.py
@@ -315,3 +315,98 @@ async def test_all_quotes_unusable_keeps_the_last_good_price(
)
assert btc_usd_price() == pytest.approx(90000.0)
+
+
+# ---------------------------------------------------------------------------
+# Catalog ingest — a malformed rate must never become a stored price
+# ---------------------------------------------------------------------------
+
+
+class _CatalogResponse:
+ def __init__(self, payload: dict[str, Any]) -> None:
+ self._payload = payload
+
+ def raise_for_status(self) -> None:
+ return None
+
+ def json(self) -> dict[str, Any]:
+ return self._payload
+
+
+class _CatalogClient:
+ """Stands in for ``httpx.AsyncClient`` against the OpenRouter catalog."""
+
+ def __init__(self, models: list[dict[str, Any]]) -> None:
+ self._models = models
+
+ async def __aenter__(self) -> "_CatalogClient":
+ return self
+
+ async def __aexit__(self, *exc: object) -> bool:
+ return False
+
+ async def get(self, url: str, timeout: int | None = None) -> _CatalogResponse:
+ if url.endswith("/embeddings/models"):
+ return _CatalogResponse({"data": []})
+ return _CatalogResponse({"data": self._models})
+
+
+def _catalog_entry(model_id: str, pricing: dict[str, Any]) -> dict[str, Any]:
+ return {"id": model_id, "name": model_id, "pricing": pricing}
+
+
+def _patch_openrouter_catalog(models: list[dict[str, Any]]) -> Any:
+ return patch(
+ "routstr.payment.models.httpx.AsyncClient",
+ lambda *args, **kwargs: _CatalogClient(models),
+ )
+
+
+@pytest.mark.parametrize(
+ "bad_rate",
+ [float("nan"), float("inf"), float("-inf")],
+ ids=["nan", "inf", "negative-inf"],
+)
+@pytest.mark.asyncio
+async def test_non_finite_catalog_rate_is_not_imported(bad_rate: float) -> None:
+ """A non-finite rate in the upstream catalog is junk, not a price.
+
+ ``json.loads`` accepts the bare ``NaN``/``Infinity`` literals and overflows
+ ``1e999`` to ``inf``, so an upstream feed can deliver one. The import filter
+ rejects a negative and a both-zero price, but every comparison with ``NaN``
+ is False and ``inf`` reads as a large positive, so both sailed through and
+ became a stored price the node would advertise and bill on.
+ """
+ with _patch_openrouter_catalog(
+ [
+ _catalog_entry("bad", {"prompt": bad_rate, "completion": "0.000002"}),
+ _catalog_entry("good", {"prompt": "0.000001", "completion": "0.000002"}),
+ ]
+ ):
+ from routstr.payment.models import async_fetch_openrouter_models
+
+ models = await async_fetch_openrouter_models()
+
+ assert [m["id"] for m in models] == ["good"]
+
+
+@pytest.mark.asyncio
+async def test_oversized_catalog_rate_does_not_empty_the_catalog() -> None:
+ """An integer too large to be a float must cost one model, not all of them.
+
+ ``float()`` raises ``OverflowError`` — not ``ValueError`` — for such a
+ value, so the coercion guard in the import filter did not catch it and the
+ exception unwound the whole fetch. The node then imported nothing at all
+ from an upstream whose catalog was fine apart from one entry.
+ """
+ with _patch_openrouter_catalog(
+ [
+ _catalog_entry("bad", {"prompt": 10**400, "completion": 2}),
+ _catalog_entry("good", {"prompt": "0.000001", "completion": "0.000002"}),
+ ]
+ ):
+ from routstr.payment.models import async_fetch_openrouter_models
+
+ models = await async_fetch_openrouter_models()
+
+ assert [m["id"] for m in models] == ["good"]
diff --git a/tests/unit/test_upstream_generic.py b/tests/unit/test_upstream_generic.py
index 39daff5c..bf5316a1 100644
--- a/tests/unit/test_upstream_generic.py
+++ b/tests/unit/test_upstream_generic.py
@@ -522,3 +522,110 @@ async def test_unresolvable_model_fails_closed(
for rec in caplog.records
if rec.levelno >= logging.WARNING
)
+
+
+# ---------------------------------------------------------------------------
+# rate validation — a malformed rate is not a resolved price, at any rung
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_non_finite_litellm_rate_is_not_a_resolved_price() -> None:
+ """A non-finite entry in the cost map must not answer the resolution chain.
+
+ The litellm rung rejects negatives and a both-zero entry, but every
+ comparison with ``NaN`` is False and ``inf`` reads as a large positive, so
+ either would be reported as a resolved price — enabling the model at a rate
+ the node cannot bill on. Fail closed instead: the model imports disabled,
+ which is what "no source knows this price" already means here.
+ """
+ payload = {
+ "data": [
+ {"id": "nan-priced-model", "object": "model", "owned_by": "mystery"},
+ ]
+ }
+ cost_entry = {
+ "input_cost_per_token": float("nan"),
+ "output_cost_per_token": float("inf"),
+ "max_input_tokens": 8192,
+ }
+
+ with _patch_models_endpoint(payload):
+ or_feed = AsyncMock(return_value=[])
+ with patch("routstr.payment.models.litellm_cost_entry", lambda _id: cost_entry):
+ with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
+ models = await GenericUpstreamProvider(
+ base_url="http://x"
+ ).fetch_models()
+
+ model = _model_by_id(models, "nan-priced-model")
+ assert model.enabled is False
+ assert model.pricing.prompt == 0.0
+ assert model.pricing.completion == 0.0
+
+
+@pytest.mark.asyncio
+async def test_non_finite_openrouter_rate_is_not_a_resolved_price() -> None:
+ """``float("Infinity")`` parses happily from a feed string, so the
+ OpenRouter rung's coercion accepted it and reported an infinite rate as a
+ resolved price. It is not a price; the model must import disabled."""
+ payload = {
+ "data": [
+ {"id": "or-nonfinite-xyz", "object": "model", "owned_by": "mystery"},
+ ]
+ }
+ feed = [
+ {
+ "id": "or-nonfinite-xyz",
+ "pricing": {"prompt": "Infinity", "completion": "0.000002"},
+ "context_length": 8192,
+ }
+ ]
+
+ with _patch_models_endpoint(payload):
+ or_feed = AsyncMock(return_value=feed)
+ with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
+ models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
+
+ model = _model_by_id(models, "or-nonfinite-xyz")
+ assert model.enabled is False
+ assert model.pricing.prompt == 0.0
+ assert model.pricing.completion == 0.0
+
+
+@pytest.mark.asyncio
+async def test_non_finite_openrouter_cache_rate_is_dropped_not_carried() -> None:
+ """A malformed *cache* rate must cost the cache rate, not the model.
+
+ The catalog import filter only inspects prompt and completion, so an entry
+ with two sound token rates and an unusable ``input_cache_read`` reaches the
+ resolver intact. Carrying that rate through would price every cached input
+ token at ``inf``; dropping it falls back to the full input rate, which is
+ what a missing cache rate already means.
+ """
+ payload = {
+ "data": [
+ {"id": "or-badcache-xyz", "object": "model", "owned_by": "mystery"},
+ ]
+ }
+ feed = [
+ {
+ "id": "or-badcache-xyz",
+ "pricing": {
+ "prompt": "0.000001",
+ "completion": "0.000002",
+ "input_cache_read": "Infinity",
+ },
+ "context_length": 8192,
+ }
+ ]
+
+ with _patch_models_endpoint(payload):
+ or_feed = AsyncMock(return_value=feed)
+ with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
+ models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
+
+ model = _model_by_id(models, "or-badcache-xyz")
+ assert model.enabled is True
+ assert model.pricing.prompt == pytest.approx(1e-06)
+ assert model.pricing.input_cache_read == 0.0
From 754f5d1a77353cea2b312684fb6bd151d4c537c9 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 16:21:33 +0200
Subject: [PATCH 032/120] fix(admin): reject a malformed pricing rate at the
write edge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The model write endpoints accepted any rate a client sent. A non-numeric string
coerced to $0 on the read path, producing an unpriced-looking row that cannot be
told from a deliberate free price; a negative or non-finite rate is truthy, so it
read back as a real price and the model could be enabled and bill a nonsensical
amount.
Validate the pricing payload on the carrier all three write endpoints share and
answer any present billable rate that is non-numeric, non-finite or negative
with a 422. An oversized integer raises OverflowError, which pydantic does not
convert into a validation error, so it is caught explicitly — it was reaching the
row-to-model read-back and escaping as a 500 after the row had already been
written. Numeric strings stay valid: the stored JSON accepts them and the UI
round-trips rates through text fields.
`BILLABLE_PRICING_FIELDS` names the rates a request can bill on, so the edge and
the read-path guards cover the same set.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
routstr/core/admin.py | 39 +++-
routstr/payment/models.py | 15 ++
.../test_admin_pricing_rate_validation.py | 211 ++++++++++++++++++
3 files changed, 263 insertions(+), 2 deletions(-)
create mode 100644 tests/integration/test_admin_pricing_rate_validation.py
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index 2da3baf1..be58972c 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -6,12 +6,17 @@ from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request
-from pydantic import BaseModel, RootModel
+from pydantic import BaseModel, RootModel, field_validator
from pydantic.v1 import ValidationError as PydanticValidationError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
-from ..payment.models import _row_to_model, list_models
+from ..payment.models import (
+ BILLABLE_PRICING_FIELDS,
+ _row_to_model,
+ is_usable_rate,
+ list_models,
+)
from ..proxy import refresh_model_maps, reinitialize_upstreams
from ..wallet import fetch_all_balances, send_token, token_mint_url
from . import vault
@@ -520,6 +525,36 @@ class ModelCreate(BaseModel):
enabled: bool = True
forwarded_model_id: str | None = None
+ @field_validator("pricing")
+ @classmethod
+ def _validate_pricing(cls, value: dict[str, object]) -> dict[str, object]:
+ """Reject a malformed, non-finite or negative billable rate at the edge.
+
+ A present-but-invalid rate would otherwise slip through: a non-numeric
+ string coerces to $0 on the read path (an unpriced-looking row), while a
+ negative or ``NaN``/``inf`` value is truthy and reads back as a real
+ price, so the model could be enabled and bill a nonsensical amount.
+ Surfacing a 422 reports the client bug as a client bug instead of
+ persisting it. Absent rates and numeric strings (``"0.000005"``) stay
+ valid — the stored JSON accepts both.
+ """
+ for field in BILLABLE_PRICING_FIELDS:
+ raw = value.get(field)
+ if raw is None:
+ continue
+ if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
+ raise ValueError(f"{field} must be a non-negative number")
+ try:
+ rate = float(raw)
+ except (ValueError, OverflowError):
+ # An integer too large for a float raises OverflowError, which
+ # pydantic does not convert into a validation error — unhandled
+ # it escapes as a 500 for what is still a bad client value.
+ raise ValueError(f"{field} must be a number, got {raw!r}")
+ if not is_usable_rate(rate):
+ raise ValueError(f"{field} must be a finite, non-negative number")
+ return value
+
def _normalize_forwarded_model_id(value: str | None) -> str | None:
if value is None:
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index dad9bbb5..6595d684 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -59,6 +59,21 @@ class Pricing(BaseModel):
max_cost: float = 0.0 # in sats not msats
+# The rates a request can bill on. Derived fields (``max_*_cost``) are excluded
+# — they are computed carriers, not charged rates. One definition, shared by the
+# admin write edge and the served/routed guards, so they all cover the same set.
+BILLABLE_PRICING_FIELDS = (
+ "prompt",
+ "completion",
+ "request",
+ "image",
+ "web_search",
+ "internal_reasoning",
+ "input_cache_read",
+ "input_cache_write",
+)
+
+
def is_usable_rate(rate: float) -> bool:
"""True if a single billable rate is a number a request could be billed on.
diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py
new file mode 100644
index 00000000..376d2ca0
--- /dev/null
+++ b/tests/integration/test_admin_pricing_rate_validation.py
@@ -0,0 +1,211 @@
+"""Admin write edge: a rate that is not a number never becomes a stored price.
+
+A billable rate is usable only when it is finite and non-negative. The admin
+model endpoints are an entry point for rates the node will later bill on, and
+they accept whatever a client sends: ``json`` parses the bare ``NaN``/
+``Infinity`` literals into real floats and overflows ``1e999`` to ``inf``, a
+non-numeric string coerced silently to ``$0``, and a negative rate is truthy so
+it read back as a chargeable price that bills a negative amount.
+
+These tests assert the edge answers a malformed rate with a 422 — a client bug
+reported as a client bug — rather than persisting it or failing as a 500, and
+that the operator can still open the listing that shows the row needing repair.
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from httpx import AsyncClient
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from routstr.core.admin import admin_sessions
+from routstr.core.db import ModelRow, UpstreamProviderRow
+from routstr.proxy import reinitialize_upstreams
+
+
+def _admin_headers() -> dict[str, str]:
+ token = "test-admin-rate-validation-token"
+ admin_sessions[token] = int(
+ (datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
+ )
+ return {"Authorization": f"Bearer {token}"}
+
+
+def _pricing(**overrides: object) -> dict[str, object]:
+ pricing: dict[str, object] = {
+ "prompt": 1.4e-7,
+ "completion": 2.8e-7,
+ "request": 0.0,
+ "image": 0.0,
+ "web_search": 0.0,
+ "internal_reasoning": 0.0,
+ "input_cache_read": 0.0,
+ "input_cache_write": 0.0,
+ }
+ pricing.update(overrides)
+ return pricing
+
+
+def _payload(
+ provider_id: int,
+ *,
+ model_id: str = "rate-model",
+ pricing: dict[str, object] | None = None,
+) -> dict[str, object]:
+ return {
+ "id": model_id,
+ "name": "Rate Model",
+ "description": "d",
+ "created": 0,
+ "context_length": 128000,
+ "architecture": {
+ "modality": "text",
+ "input_modalities": ["text"],
+ "output_modalities": ["text"],
+ "tokenizer": "unknown",
+ "instruct_type": None,
+ },
+ "pricing": pricing if pricing is not None else _pricing(),
+ "per_request_limits": None,
+ "top_provider": None,
+ "upstream_provider_id": provider_id,
+ "canonical_slug": None,
+ "alias_ids": [],
+ "enabled": True,
+ "forwarded_model_id": model_id,
+ }
+
+
+async def _make_provider(session: AsyncSession) -> int:
+ provider = UpstreamProviderRow(
+ provider_type="generic",
+ base_url="https://rate-upstream.example/v1",
+ api_key="test-key",
+ provider_fee=1.0,
+ )
+ session.add(provider)
+ await session.commit()
+ await session.refresh(provider)
+ await reinitialize_upstreams()
+ assert provider.id is not None
+ return provider.id
+
+
+def _raw_model_body(provider_id: int, model_id: str, prompt_literal: str) -> str:
+ """A request body built as text, so it can carry a literal ``json`` accepts
+ but Python's own encoder would refuse to produce."""
+ return (
+ f'{{"id": "{model_id}", "name": "raw", "description": "d", "created": 0,'
+ ' "context_length": 8192, "architecture": {"modality": "text"},'
+ f' "pricing": {{"prompt": {prompt_literal}, "completion": 2.8e-7}},'
+ f' "upstream_provider_id": {provider_id}}}'
+ )
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_negative_price_is_rejected(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """A negative rate is not a valid price — accepting it would persist a row
+ that bills a negative amount, which settlement subtracts from the balance.
+ Being truthy, it also reads back as a chargeable price. Reject at the edge
+ rather than silently storing it."""
+ provider_id = await _make_provider(integration_session)
+
+ resp = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=_admin_headers(),
+ json=_payload(provider_id, model_id="neg-price", pricing=_pricing(prompt=-1.0)),
+ )
+
+ assert resp.status_code == 422
+ assert await integration_session.get(ModelRow, ("neg-price", provider_id)) is None
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_malformed_price_string_is_rejected(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """A present non-numeric rate is a client bug: it coerces to ``$0`` on the
+ read path, producing an unpriced-looking row indistinguishable from a
+ deliberate free price. Surface it as a 422 instead of accepting it."""
+ provider_id = await _make_provider(integration_session)
+
+ resp = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=_admin_headers(),
+ json=_payload(
+ provider_id, model_id="bad-price", pricing=_pricing(prompt="oops")
+ ),
+ )
+
+ assert resp.status_code == 422
+ assert await integration_session.get(ModelRow, ("bad-price", provider_id)) is None
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_numeric_string_price_is_still_accepted(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """The stored pricing JSON has always accepted numeric strings, and the UI
+ round-trips rates through text fields. Rejecting a *malformed* rate must not
+ also reject a well-formed one that arrives spelled as a string."""
+ provider_id = await _make_provider(integration_session)
+
+ resp = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=_admin_headers(),
+ json=_payload(
+ provider_id, model_id="string-price", pricing=_pricing(prompt="0.000005")
+ ),
+ )
+
+ assert resp.status_code == 200
+ row = await integration_session.get(ModelRow, ("string-price", provider_id))
+ assert row is not None
+ assert json.loads(row.pricing)["prompt"] == "0.000005"
+
+
+def test_non_finite_price_is_rejected_by_the_write_model() -> None:
+ """``NaN``/``±inf`` are not billable rates: the carrier every write endpoint
+ shares must reject them before they can be persisted and read back as a
+ chargeable price."""
+ from pydantic import ValidationError
+
+ from routstr.core.admin import ModelCreate
+
+ for bad in (float("nan"), float("inf"), float("-inf")):
+ with pytest.raises(ValidationError):
+ ModelCreate.model_validate(
+ _payload(1, model_id="nonfinite", pricing=_pricing(prompt=bad))
+ )
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_oversized_integer_price_is_rejected(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """A JSON integer too large for a float is a client bug, not a server fault.
+
+ ``float()`` raises ``OverflowError`` for it, and pydantic converts only
+ ``ValueError``/``AssertionError`` into validation errors, so it escaped the
+ edge as a 500. It must be answered with the same 422 as every other
+ unusable rate.
+ """
+ provider_id = await _make_provider(integration_session)
+
+ resp = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers={**_admin_headers(), "Content-Type": "application/json"},
+ content=_raw_model_body(provider_id, "huge-price", "9" * 400),
+ )
+
+ assert resp.status_code == 422
+ assert await integration_session.get(ModelRow, ("huge-price", provider_id)) is None
From 09e356b64247e6624ed8ca4031df7ae9a474137d Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 16:23:31 +0200
Subject: [PATCH 033/120] fix(api): answer a request-validation failure with a
422 that serializes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`json` parses the bare `Infinity`/`NaN` literals into real floats, so the
pricing edge rejects them correctly — but pydantic echoes the offending value
back in the error's `input` field, and JSONResponse encodes with
`allow_nan=False`. Serializing the 422 then raised "Out of range float values
are not JSON compliant" and the reply escaped as a 500, reporting a client's bad
rate as a server fault. The request already failed closed, so no row was ever
written.
Handle RequestValidationError explicitly and render non-finite floats as text,
which covers every endpoint rather than the pricing edge alone. The integration
app now copies the main app's exception handlers so a failing request fails the
way it does in production.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
routstr/core/exceptions.py | 43 ++++++++++++++++
routstr/core/main.py | 8 ++-
tests/integration/conftest.py | 4 ++
.../test_admin_pricing_rate_validation.py | 50 +++++++++++++++++++
4 files changed, 104 insertions(+), 1 deletion(-)
diff --git a/routstr/core/exceptions.py b/routstr/core/exceptions.py
index 9e4f2ae6..fd3cfefa 100644
--- a/routstr/core/exceptions.py
+++ b/routstr/core/exceptions.py
@@ -1,4 +1,8 @@
+import math
+
from fastapi import Request
+from fastapi.encoders import jsonable_encoder
+from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from .logging import get_logger
@@ -61,6 +65,45 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
return JSONResponse(status_code=status_code, content=content)
+def json_compliant(value: object) -> object:
+ """Render non-finite floats as text so a reply carrying them can serialize.
+
+ ``json`` parses the bare ``NaN``/``Infinity``/``-Infinity`` literals into
+ real floats, so a request body — and a stored row written from one — may
+ hold one anywhere. ``JSONResponse`` encodes with ``allow_nan=False`` and
+ raises on them, which would turn a reply that merely *quotes* the offending
+ value into a 500.
+ """
+ if isinstance(value, float) and not math.isfinite(value):
+ return repr(value)
+ if isinstance(value, dict):
+ return {key: json_compliant(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [json_compliant(item) for item in value]
+ return value
+
+
+async def validation_exception_handler(
+ request: Request, exc: Exception
+) -> JSONResponse:
+ """Answer a request-validation failure with a 422 that always serializes.
+
+ Pydantic echoes the rejected value back in each error's ``input`` field. A
+ non-finite float there breaks the encoder, so the 422 escapes as a 500 and
+ reports a client's bad rate as a server fault.
+ """
+ request_id = getattr(request.state, "request_id", "unknown")
+ errors = exc.errors() if isinstance(exc, RequestValidationError) else []
+
+ return JSONResponse(
+ status_code=422,
+ content={
+ "detail": json_compliant(jsonable_encoder(errors)),
+ "request_id": request_id,
+ },
+ )
+
+
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle general exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")
diff --git a/routstr/core/main.py b/routstr/core/main.py
index 979f5cdb..b89fa64d 100644
--- a/routstr/core/main.py
+++ b/routstr/core/main.py
@@ -4,6 +4,7 @@ from pathlib import Path
from typing import AsyncGenerator
from fastapi import FastAPI
+from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -33,7 +34,11 @@ from ..upstream.litellm_routing import configure_litellm
from ..wallet import periodic_payout, periodic_refund_sweep, periodic_routstr_fee_payout
from .admin import admin_router
from .db import create_session, init_db, run_migrations
-from .exceptions import general_exception_handler, http_exception_handler
+from .exceptions import (
+ general_exception_handler,
+ http_exception_handler,
+ validation_exception_handler,
+)
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
from .not_found import _NOT_FOUND_HTML, not_found_catch_all # noqa: F401
@@ -289,6 +294,7 @@ app.add_middleware(LoggingMiddleware)
# Add exception handlers
app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore
+app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index aa10a81c..265c73c9 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -508,6 +508,10 @@ async def integration_app(
# Copy all routes from the main app
test_app.router = app.router
+ # ...and its exception handlers, so a request that fails here fails the way
+ # it would in production rather than escaping as a bare exception.
+ test_app.exception_handlers.update(app.exception_handlers)
+
# Override the get_session dependency
async def override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield integration_session
diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py
index 376d2ca0..ed609051 100644
--- a/tests/integration/test_admin_pricing_rate_validation.py
+++ b/tests/integration/test_admin_pricing_rate_validation.py
@@ -209,3 +209,53 @@ async def test_oversized_integer_price_is_rejected(
assert resp.status_code == 422
assert await integration_session.get(ModelRow, ("huge-price", provider_id)) is None
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_non_finite_literal_price_is_rejected(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """A bare ``Infinity``/``NaN`` literal gets the same 422 as any other rate.
+
+ ``json`` accepts both literals, so the edge sees a real float and rejects
+ it — but pydantic echoes the offending value back in the error's ``input``
+ field, and the response encoder runs with ``allow_nan=False``. Serializing
+ that reply raised "Out of range float values are not JSON compliant", so the
+ 422 escaped as a 500 and reported a client's bad rate as a server fault.
+ """
+ provider_id = await _make_provider(integration_session)
+
+ for literal in ("Infinity", "-Infinity", "NaN"):
+ resp = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers={**_admin_headers(), "Content-Type": "application/json"},
+ content=_raw_model_body(provider_id, "odd-price", literal),
+ )
+
+ assert resp.status_code == 422, literal
+ assert (
+ await integration_session.get(ModelRow, ("odd-price", provider_id)) is None
+ )
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_non_finite_literal_price_is_rejected_in_batch_override(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """The batch path shares the same carrier, so it must answer 422 too."""
+ provider_id = await _make_provider(integration_session)
+
+ resp = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/batch-override",
+ headers={**_admin_headers(), "Content-Type": "application/json"},
+ content=(
+ '{"models": ['
+ + _raw_model_body(provider_id, "odd-batch", "Infinity")
+ + "]}"
+ ),
+ )
+
+ assert resp.status_code == 422
+ assert await integration_session.get(ModelRow, ("odd-batch", provider_id)) is None
From e202435ca0e7cb5da93ee630bf1e4338475b5d8c Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 16:28:59 +0200
Subject: [PATCH 034/120] fix(pricing): never serve or route a price built on
an unusable rate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A stored rate that is negative or non-finite is not a price, but every guard on
the read path tested truthiness, and `NaN`, `inf` and a negative float are all
truthy. Such a row was advertised in the catalog and built into the routing map,
and the cost calculation cannot bill on it: every request on the model fell
through to the flat maximum reservation, or — for a negative rate — was billed
an amount settlement credits back to the caller.
`has_usable_pricing` asks the question once, across every billable rate, so the
served-catalog and routing backstops answer it identically. One unusable rate
disqualifies the whole price: a positive completion rate must not hide a
negative prompt rate. Zero is untouched — a free model is a real price.
The admin listing (`include_disabled`) is deliberately exempt, or the operator
would lose sight of the row that needs repairing.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
routstr/algorithm.py | 19 ++-
routstr/payment/models.py | 49 +++++-
.../test_served_catalog_rate_backstop.py | 157 ++++++++++++++++++
tests/unit/test_algorithm.py | 64 +++++++
4 files changed, 279 insertions(+), 10 deletions(-)
create mode 100644 tests/integration/test_served_catalog_rate_backstop.py
diff --git a/routstr/algorithm.py b/routstr/algorithm.py
index cced61f7..68406876 100644
--- a/routstr/algorithm.py
+++ b/routstr/algorithm.py
@@ -135,9 +135,21 @@ def create_model_mappings(
Returns:
Tuple of (model_instances, provider_map, unique_models)
"""
- from .payment.models import _row_to_model
+ from .payment.models import _row_to_model, has_usable_pricing
from .upstream.helpers import resolve_model_alias
+ def _unusable_price(model: "Model") -> bool:
+ """A candidate may only route on rates a request can be billed against.
+
+ Mirrors the served-catalog backstop in ``list_models``: a negative or
+ non-finite rate is not a price, and the cost calculation cannot bill on
+ one, so every request on the model would be charged the full maximum
+ reservation instead. Applies to provider-discovered models as well as
+ persisted overrides — no override row need exist for a malformed price
+ to be built into the candidate map.
+ """
+ return not has_usable_pricing(model.pricing)
+
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
unique_models: dict[str, "Model"] = {}
unique_model_keys: dict[str, str] = {}
@@ -231,6 +243,9 @@ def create_model_mappings(
else:
model_to_use = model
+ if _unusable_price(model_to_use):
+ continue
+
forwarded_model_id = get_effective_forwarded_model_id(model_to_use)
# Get all aliases for this model
@@ -297,6 +312,8 @@ def create_model_mappings(
continue
if not model_to_use.enabled:
continue
+ if _unusable_price(model_to_use):
+ continue
forwarded_model_id = get_effective_forwarded_model_id(model_to_use)
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index 6595d684..23c7ab6f 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -97,6 +97,19 @@ def is_usable_rate(rate: float) -> bool:
return math.isfinite(rate) and rate >= 0.0
+def has_usable_pricing(pricing: Pricing) -> bool:
+ """True if every billable rate is a number a request could be billed on.
+
+ Free is usable — a rate of zero is a real price. This asks only whether the
+ price is well-formed. One unusable rate disqualifies the whole price even
+ alongside a valid one, since a request can bill on the bad field: a positive
+ ``completion`` must not hide a negative ``prompt``.
+ """
+ return all(
+ is_usable_rate(getattr(pricing, field)) for field in BILLABLE_PRICING_FIELDS
+ )
+
+
class TopProvider(BaseModel):
context_length: int | None = None
max_completion_tokens: int | None = None
@@ -354,21 +367,39 @@ async def list_models(
rows = (await session.exec(query)).all() # type: ignore
provider_result = await session.exec(select(UpstreamProviderRow))
providers_by_id = {p.id: p for p in provider_result.all()}
- return [
- _row_to_model(
+
+ models: list[Model] = []
+ for r in rows:
+ if not include_disabled and not (
+ r.upstream_provider_id in providers_by_id
+ and providers_by_id[r.upstream_provider_id].enabled
+ ):
+ continue
+ model = _row_to_model(
r,
apply_provider_fee=apply_fees,
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
if r.upstream_provider_id in providers_by_id
else 1.01,
)
- for r in rows
- if include_disabled
- or (
- r.upstream_provider_id in providers_by_id
- and providers_by_id[r.upstream_provider_id].enabled
- )
- ]
+ # Served-map backstop for legacy rows and writers that bypass the admin
+ # edge: a negative or non-finite rate is not a price. Serving one
+ # advertises a rate the cost calculation cannot bill on, so the request
+ # falls through to the flat maximum reservation — or, if the rate is
+ # negative, bills an amount settlement credits back to the caller.
+ # ``include_disabled`` is the operator's listing, which must keep showing
+ # the row so it can be repaired.
+ if not include_disabled and not has_usable_pricing(model.pricing):
+ logger.warning(
+ "Withholding model with an unusable stored rate from the catalog",
+ extra={
+ "model_id": r.id,
+ "upstream_provider_id": r.upstream_provider_id,
+ },
+ )
+ continue
+ models.append(model)
+ return models
def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
diff --git a/tests/integration/test_served_catalog_rate_backstop.py b/tests/integration/test_served_catalog_rate_backstop.py
new file mode 100644
index 00000000..96455554
--- /dev/null
+++ b/tests/integration/test_served_catalog_rate_backstop.py
@@ -0,0 +1,157 @@
+"""The served catalog is the last guard between a stored row and a charge.
+
+Stored pricing is JSON written by whatever produced the row — an upstream
+import, an operator, a legacy migration, or a foreign writer that never passed
+the admin edge. So the read path cannot assume a stored rate is a number: it
+must decline to serve a row it cannot bill on, and it must survive a row it
+cannot read at all rather than taking the whole catalog down with it.
+
+The admin listing is deliberately exempt: it includes disabled models and is the
+one view that still shows the operator the row that needs repair.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from routstr.core.db import ModelRow, UpstreamProviderRow
+from routstr.payment.models import list_models
+from routstr.proxy import reinitialize_upstreams
+
+_ARCHITECTURE = json.dumps(
+ {
+ "modality": "text",
+ "input_modalities": ["text"],
+ "output_modalities": ["text"],
+ "tokenizer": "unknown",
+ "instruct_type": None,
+ }
+)
+
+
+async def _make_provider(session: AsyncSession) -> int:
+ provider = UpstreamProviderRow(
+ provider_type="generic",
+ base_url="https://served-upstream.example/v1",
+ api_key="test-key",
+ provider_fee=1.0,
+ )
+ session.add(provider)
+ await session.commit()
+ await session.refresh(provider)
+ await reinitialize_upstreams()
+ assert provider.id is not None
+ return provider.id
+
+
+async def _insert_row(
+ session: AsyncSession,
+ provider_id: int,
+ *,
+ model_id: str,
+ pricing: dict[str, object],
+) -> None:
+ session.add(
+ ModelRow(
+ id=model_id,
+ name=model_id,
+ description="d",
+ created=0,
+ context_length=8192,
+ architecture=_ARCHITECTURE,
+ pricing=json.dumps(pricing),
+ upstream_provider_id=provider_id,
+ enabled=True,
+ forwarded_model_id=model_id,
+ )
+ )
+ await session.commit()
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "bad_rate",
+ [float("nan"), float("inf"), -1.0],
+ ids=["nan", "inf", "negative"],
+)
+async def test_served_catalog_excludes_a_malformed_stored_rate(
+ integration_session: AsyncSession, bad_rate: float
+) -> None:
+ """A stored rate that is not a number must not be advertised.
+
+ Zero is a real price and a free model is servable, but a negative or
+ non-finite rate is not a price at all: serving it advertises a rate the cost
+ calculation cannot bill on, so every request falls through to the flat
+ maximum reservation — or, for a negative rate, bills an amount settlement
+ credits back to the caller.
+ """
+ provider_id = await _make_provider(integration_session)
+ await _insert_row(
+ integration_session,
+ provider_id,
+ model_id="good",
+ pricing={"prompt": 1e-06, "completion": 2e-06},
+ )
+ await _insert_row(
+ integration_session,
+ provider_id,
+ model_id="bad-rate",
+ pricing={"prompt": bad_rate, "completion": 2e-06},
+ )
+
+ served = {m.id for m in await list_models(integration_session, provider_id)}
+
+ assert served == {"good"}
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_free_stored_price_is_still_served(
+ integration_session: AsyncSession,
+) -> None:
+ """Zero is a real price. Rejecting malformed rates must not also drop a row
+ priced at zero, which is a free model and not a broken one."""
+ provider_id = await _make_provider(integration_session)
+ await _insert_row(
+ integration_session,
+ provider_id,
+ model_id="free",
+ pricing={"prompt": 0.0, "completion": 0.0},
+ )
+
+ served = {m.id for m in await list_models(integration_session, provider_id)}
+
+ assert served == {"free"}
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_admin_listing_still_shows_a_malformed_stored_rate(
+ integration_session: AsyncSession,
+) -> None:
+ """The operator has to be able to see the row that needs fixing.
+
+ The backstop keeps a malformed row out of the *served* catalog. The listing
+ that includes disabled models is the one view where the row must still
+ appear, or the operator loses the ability to repair it.
+ """
+ provider_id = await _make_provider(integration_session)
+ await _insert_row(
+ integration_session,
+ provider_id,
+ model_id="bad-rate",
+ pricing={"prompt": -1.0, "completion": 2e-06},
+ )
+
+ listed = {
+ m.id
+ for m in await list_models(
+ integration_session, provider_id, include_disabled=True
+ )
+ }
+
+ assert listed == {"bad-rate"}
diff --git a/tests/unit/test_algorithm.py b/tests/unit/test_algorithm.py
index 5d31af2e..62d3ad0d 100644
--- a/tests/unit/test_algorithm.py
+++ b/tests/unit/test_algorithm.py
@@ -954,3 +954,67 @@ def test_create_model_mappings_uppercase_prefixed_base_keeps_top_tier() -> None:
assert provider_map["qwen2.5-72b"][0] == (prefixed_cheap, prefixed_provider)
assert unique_models["qwen2.5-72b"].upstream_provider_id == "prefixed"
+
+
+def test_create_model_mappings_excludes_a_malformed_price() -> None:
+ """A rate that is not a number must not be routable.
+
+ A negative or non-finite rate reads as a real price to every truthiness
+ check, so the candidate was built into the map and served. The cost
+ calculation cannot price on such a rate, so every request on the model fell
+ through to the flat maximum reservation — or, for a negative rate, billed a
+ negative amount that settlement credits back to the caller.
+ """
+ healthy = create_test_model("healthy-model")
+ for bad_rate in (float("nan"), float("inf"), -1.0):
+ broken = create_test_model("broken-model", prompt_price=bad_rate)
+ provider = create_test_provider(
+ "custom",
+ "https://custom.example/v1",
+ db_id=1,
+ models=[broken, healthy],
+ )
+
+ _, provider_map, unique_models = create_model_mappings(
+ upstreams=[provider],
+ overrides_by_key={},
+ disabled_model_keys=set(),
+ )
+
+ assert "broken-model" not in provider_map, bad_rate
+ assert "broken-model" not in unique_models, bad_rate
+ # One unroutable candidate must not cost the provider its other models.
+ assert "healthy-model" in provider_map, bad_rate
+
+
+def test_create_model_mappings_excludes_an_override_with_a_malformed_price(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """An override row carrying a malformed rate is unroutable too.
+
+ An override replaces the discovered model's price, so a provider whose
+ catalog is sound still routes at whatever the row says. The guard has to sit
+ after the override is applied, not before it.
+ """
+ discovered = create_test_model("shared-model")
+ provider = create_test_provider(
+ "custom", "https://custom.example/v1", db_id=3, models=[discovered]
+ )
+ override_model = create_test_model("shared-model", prompt_price=float("-inf"))
+
+ monkeypatch.setattr(
+ "routstr.payment.models._row_to_model",
+ lambda *args, **kwargs: override_model,
+ )
+ override_row = SimpleNamespace(
+ id="shared-model", upstream_provider_id=3, enabled=True
+ )
+
+ _, provider_map, unique_models = create_model_mappings(
+ upstreams=[provider],
+ overrides_by_key={("shared-model", 3): (override_row, 1.0)},
+ disabled_model_keys=set(),
+ )
+
+ assert "shared-model" not in provider_map
+ assert "shared-model" not in unique_models
From 41fed7413adf33ebb2f6c3c1b3abc44bf280cd99 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 16:33:44 +0200
Subject: [PATCH 035/120] fix(pricing): keep one unreadable model row from
taking the node with it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Stored pricing is JSON from whatever wrote the row, so a legacy import or
foreign writer can leave a field that will not parse. Both places that convert a
row do it inside a loop, so one such row raised out of the whole operation: the
catalog listing returned nothing and the node advertised no models at all, and
the routing map came up empty at boot — on a later refresh the map it already
had went permanently stale instead.
Drop the row that cannot be read, log it by id, and keep serving and routing the
rest. It is unservable either way. The sibling loop over override-only rows in
the mapping builder already did this; the two now match.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
routstr/algorithm.py | 24 +++++++++--
routstr/payment/models.py | 32 +++++++++++----
.../test_served_catalog_rate_backstop.py | 32 +++++++++++++++
tests/unit/test_algorithm.py | 40 +++++++++++++++++++
4 files changed, 118 insertions(+), 10 deletions(-)
diff --git a/routstr/algorithm.py b/routstr/algorithm.py
index 68406876..6a896665 100644
--- a/routstr/algorithm.py
+++ b/routstr/algorithm.py
@@ -237,9 +237,27 @@ def create_model_mappings(
# Apply overrides only for this provider's model row.
if model_key is not None and model_key in overrides_by_key:
override_row, provider_fee = overrides_by_key[model_key]
- model_to_use = _row_to_model(
- override_row, apply_provider_fee=True, provider_fee=provider_fee
- )
+ try:
+ model_to_use = _row_to_model(
+ override_row, apply_provider_fee=True, provider_fee=provider_fee
+ )
+ except Exception as exc:
+ # Stored pricing is JSON from whatever wrote the row, so
+ # converting it can raise. Doing that inside this loop let
+ # one such row unwind the whole map build: at boot the node
+ # came up routing nothing, and on a later refresh the map it
+ # already had went permanently stale. The sibling loop over
+ # override-only rows already skips and logs such a row.
+ logger.warning(
+ "Skipping invalid model override while building model mappings",
+ extra={
+ "model_id": model.id,
+ "upstream_provider_id": upstream_db_id,
+ "error": str(exc),
+ "error_type": type(exc).__name__,
+ },
+ )
+ continue
else:
model_to_use = model
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index 23c7ab6f..7f3a20f3 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -375,13 +375,31 @@ async def list_models(
and providers_by_id[r.upstream_provider_id].enabled
):
continue
- model = _row_to_model(
- r,
- apply_provider_fee=apply_fees,
- provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
- if r.upstream_provider_id in providers_by_id
- else 1.01,
- )
+ try:
+ model = _row_to_model(
+ r,
+ apply_provider_fee=apply_fees,
+ provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
+ if r.upstream_provider_id in providers_by_id
+ else 1.01,
+ )
+ except Exception as e:
+ # Stored pricing/architecture is JSON from whatever wrote the row, so
+ # a legacy import or foreign writer can leave a field that will not
+ # parse. Converting inside this loop meant one such row raised out of
+ # the whole listing and the node advertised nothing at all. Drop the
+ # row we cannot read — it is unservable either way — and keep serving
+ # the rest.
+ logger.warning(
+ "Skipping model row that could not be read",
+ extra={
+ "model_id": r.id,
+ "upstream_provider_id": r.upstream_provider_id,
+ "error": str(e),
+ "error_type": type(e).__name__,
+ },
+ )
+ continue
# Served-map backstop for legacy rows and writers that bypass the admin
# edge: a negative or non-finite rate is not a price. Serving one
# advertises a rate the cost calculation cannot bill on, so the request
diff --git a/tests/integration/test_served_catalog_rate_backstop.py b/tests/integration/test_served_catalog_rate_backstop.py
index 96455554..02829bae 100644
--- a/tests/integration/test_served_catalog_rate_backstop.py
+++ b/tests/integration/test_served_catalog_rate_backstop.py
@@ -155,3 +155,35 @@ async def test_admin_listing_still_shows_a_malformed_stored_rate(
}
assert listed == {"bad-rate"}
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_one_unreadable_stored_price_does_not_blank_the_catalog(
+ integration_session: AsyncSession,
+) -> None:
+ """A single unparseable row must cost that row, not every model on the node.
+
+ Stored pricing is JSON written by whatever produced the row, so a
+ non-numeric rate is reachable from a legacy import or a foreign writer.
+ Parsing it raises out of the row-to-model conversion, and because the
+ conversion ran inside the catalog loop the exception took the whole listing
+ with it — one bad row and the node advertised nothing at all.
+ """
+ provider_id = await _make_provider(integration_session)
+ await _insert_row(
+ integration_session,
+ provider_id,
+ model_id="good",
+ pricing={"prompt": 1e-06, "completion": 2e-06},
+ )
+ await _insert_row(
+ integration_session,
+ provider_id,
+ model_id="unreadable",
+ pricing={"prompt": "not-a-number", "completion": 2e-06},
+ )
+
+ served = {m.id for m in await list_models(integration_session, provider_id)}
+
+ assert served == {"good"}
diff --git a/tests/unit/test_algorithm.py b/tests/unit/test_algorithm.py
index 62d3ad0d..cefc4f90 100644
--- a/tests/unit/test_algorithm.py
+++ b/tests/unit/test_algorithm.py
@@ -1018,3 +1018,43 @@ def test_create_model_mappings_excludes_an_override_with_a_malformed_price(
assert "shared-model" not in provider_map
assert "shared-model" not in unique_models
+
+
+def test_create_model_mappings_survives_an_unreadable_override_row(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """One row that cannot be read must not empty the whole routing map.
+
+ Stored pricing is JSON from whatever wrote the row, so converting it can
+ raise. Converting an override while walking a provider's catalog let that
+ exception unwind the entire map build: at boot the node came up routing
+ nothing, and on a later refresh the map it already had went permanently
+ stale. The sibling loop over override-only rows already skips and logs such
+ a row.
+ """
+ broken = create_test_model("broken-model")
+ healthy = create_test_model("healthy-model")
+ provider = create_test_provider(
+ "custom",
+ "https://custom.example/v1",
+ db_id=5,
+ models=[broken, healthy],
+ )
+
+ def raising_row_to_model(row: Any, *args: Any, **kwargs: Any) -> Model:
+ raise ValueError("value is not a valid float")
+
+ monkeypatch.setattr("routstr.payment.models._row_to_model", raising_row_to_model)
+ override_row = SimpleNamespace(
+ id="broken-model", upstream_provider_id=5, enabled=True
+ )
+
+ _, provider_map, unique_models = create_model_mappings(
+ upstreams=[provider],
+ overrides_by_key={("broken-model", 5): (override_row, 1.0)},
+ disabled_model_keys=set(),
+ )
+
+ assert "broken-model" not in provider_map
+ assert "healthy-model" in provider_map
+ assert "healthy-model" in unique_models
From 6e5a388ee4c4cd72fb1a2858b88932d46fcf1fd4 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 16:37:33 +0200
Subject: [PATCH 036/120] fix(admin): show the operator the rate that needs
fixing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The admin listing includes disabled models, so it is the one view still carrying
a row the served-catalog backstop holds back — including one whose stored rate
is not a usable number. The encoder reported that rate as `null`,
indistinguishable from a missing one, so the operator could see the row but not
the reason it was withheld. Reuses the non-finite renderer the 422 handler
already uses, promoted to a shared name now that it has a second caller.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
routstr/core/admin.py | 10 +++-
.../test_admin_pricing_rate_validation.py | 49 +++++++++++++++++++
2 files changed, 57 insertions(+), 2 deletions(-)
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index be58972c..0e8b1158 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -35,6 +35,7 @@ from .db import (
from .db import (
store_cashu_transaction_with_retry as store_cashu_transaction,
)
+from .exceptions import json_compliant
from .log_manager import log_manager
from .logging import get_logger
from .provider_slugs import allocate_unique_provider_slug
@@ -1273,8 +1274,13 @@ async def get_provider_models(provider_id: str) -> dict[str, object]:
"provider_type": provider.provider_type,
"base_url": provider.base_url,
},
- "db_models": [m.dict() for m in db_models],
- "remote_models": [m.dict() for m in filtered_remote_models],
+ # This listing includes disabled models, so it is the one view that
+ # still carries a row the served-catalog backstop holds back —
+ # including one whose stored rate is not a usable number. The
+ # encoder would report that rate as `null`, indistinguishable from a
+ # missing one; show the operator the value that needs fixing.
+ "db_models": [json_compliant(m.dict()) for m in db_models],
+ "remote_models": [json_compliant(m.dict()) for m in filtered_remote_models],
}
diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py
index ed609051..78614a8b 100644
--- a/tests/integration/test_admin_pricing_rate_validation.py
+++ b/tests/integration/test_admin_pricing_rate_validation.py
@@ -259,3 +259,52 @@ async def test_non_finite_literal_price_is_rejected_in_batch_override(
assert resp.status_code == 422
assert await integration_session.get(ModelRow, ("odd-batch", provider_id)) is None
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_admin_model_listing_shows_a_non_finite_stored_rate(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """The operator must be able to see the rate that needs fixing.
+
+ The admin listing deliberately includes disabled models, so it is the one
+ view that still carries a row the served-catalog backstop holds back.
+ FastAPI's encoder rendered a stored ``Infinity`` rate as ``null``, which is
+ indistinguishable from a rate the row never carried — the operator could see
+ the row but not the reason it was withheld. Render the offending value as
+ text instead, as the 422 handler already does.
+ """
+ provider_id = await _make_provider(integration_session)
+ integration_session.add(
+ ModelRow(
+ id="inf-rate",
+ name="inf-rate",
+ description="d",
+ created=0,
+ context_length=8192,
+ architecture=json.dumps(
+ {
+ "modality": "text",
+ "input_modalities": ["text"],
+ "output_modalities": ["text"],
+ "tokenizer": "unknown",
+ "instruct_type": None,
+ }
+ ),
+ pricing=json.dumps({"prompt": float("inf"), "completion": 2e-06}),
+ upstream_provider_id=provider_id,
+ enabled=True,
+ forwarded_model_id="inf-rate",
+ )
+ )
+ await integration_session.commit()
+
+ resp = await integration_client.get(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=_admin_headers(),
+ )
+
+ assert resp.status_code == 200
+ listed = {m["id"]: m for m in resp.json()["db_models"]}
+ assert listed["inf-rate"]["pricing"]["prompt"] == "inf"
From 86be12f4be85bc1a3d82967f8c9e2d918c7b80ea Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 20:28:16 +0200
Subject: [PATCH 037/120] fix(admin): show the rate that needs fixing in the
single-model view too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The listing already renders a stored non-finite rate as text rather than
letting the encoder report it as `null`, indistinguishable from a rate the row
never carried. The single-model view is the other view of that same row — the
one an operator opens from the listing to repair it — and still answered
`null`.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
routstr/core/admin.py | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index 0e8b1158..f018b6a5 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -682,9 +682,13 @@ async def get_provider_model(provider_id: str, model_id: str) -> dict[str, objec
raise HTTPException(
status_code=404, detail="Model not found for this provider"
)
- return _row_to_model(
- row, apply_provider_fee=False, provider_fee=provider.provider_fee
- ).dict() # type: ignore
+ # Same duty as the listing this view is opened from: a stored rate that
+ # is not a usable number must be shown as it is, not encoded as `null`.
+ return json_compliant( # type: ignore[return-value]
+ _row_to_model(
+ row, apply_provider_fee=False, provider_fee=provider.provider_fee
+ ).dict()
+ )
@admin_router.delete(
From b68086c5923a77b2bd218a813e7c0eed876cdc4e Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 20:28:16 +0200
Subject: [PATCH 038/120] test(pricing): pin rate validation to every billable
rate, not the token pair
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The write-edge validator and the served-catalog backstop both iterate the full
billable-rate tuple, but every test drove them through `prompt` alone — the
tuple could be narrowed to the two token rates and the suite stayed green,
while a malformed image/search/reasoning/cache rate walked in.
Covers each remaining rate at both surfaces. `request` is deliberately absent
from the catalog list and pinned by its own test instead: the row-to-model
conversion clamps a negative stored `request` to zero before the price is
built, so the backstop never sees one.
Also covers the exchange-quote reader's widened error handling, which had no
test: a body that never yields JSON raises before any number is read, and
unhandled it abandoned the whole aggregation with two healthy quotes in hand.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
.../test_admin_pricing_rate_validation.py | 82 +++++++++++++++++++
.../test_served_catalog_rate_backstop.py | 68 +++++++++++++++
tests/unit/test_pricing_rate_validation.py | 38 ++++++++-
3 files changed, 185 insertions(+), 3 deletions(-)
diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py
index 78614a8b..5b53b02a 100644
--- a/tests/integration/test_admin_pricing_rate_validation.py
+++ b/tests/integration/test_admin_pricing_rate_validation.py
@@ -308,3 +308,85 @@ async def test_admin_model_listing_shows_a_non_finite_stored_rate(
assert resp.status_code == 200
listed = {m["id"]: m for m in resp.json()["db_models"]}
assert listed["inf-rate"]["pricing"]["prompt"] == "inf"
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_malformed_auxiliary_rate_is_rejected(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """Validation spans every billable rate, not just the token rates.
+
+ ``prompt``/``completion`` are the rates most prices are built from, but the
+ request, image, search, reasoning and cache rates are billed too. A negative
+ or non-finite value in any of them is the same defect and must be answered
+ the same way.
+ """
+ provider_id = await _make_provider(integration_session)
+
+ for field, bad in (
+ ("request", -1.0),
+ ("image", -0.5),
+ ("web_search", float("inf")),
+ ("internal_reasoning", float("nan")),
+ ("input_cache_read", -1e-06),
+ ("input_cache_write", float("-inf")),
+ ("completion", -1.0),
+ ):
+ 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})
+ ),
+ )
+
+ assert resp.status_code == 422, field
+ assert (
+ await integration_session.get(ModelRow, ("aux-rate", provider_id)) is None
+ ), field
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_admin_single_model_shows_a_non_finite_stored_rate(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """The single-model view answers like the listing it is opened from.
+
+ It is the other view of a row the served-catalog backstop holds back, so it
+ has the same duty to name the rate that needs fixing rather than rendering
+ it as ``null``.
+ """
+ provider_id = await _make_provider(integration_session)
+ integration_session.add(
+ ModelRow(
+ id="inf-one",
+ name="inf-one",
+ description="d",
+ created=0,
+ context_length=8192,
+ architecture=json.dumps(
+ {
+ "modality": "text",
+ "input_modalities": ["text"],
+ "output_modalities": ["text"],
+ "tokenizer": "unknown",
+ "instruct_type": None,
+ }
+ ),
+ pricing=json.dumps({"prompt": float("inf"), "completion": 2e-06}),
+ upstream_provider_id=provider_id,
+ enabled=True,
+ forwarded_model_id="inf-one",
+ )
+ )
+ await integration_session.commit()
+
+ resp = await integration_client.get(
+ f"/admin/api/upstream-providers/{provider_id}/models/inf-one",
+ headers=_admin_headers(),
+ )
+
+ assert resp.status_code == 200
+ assert resp.json()["pricing"]["prompt"] == "inf"
diff --git a/tests/integration/test_served_catalog_rate_backstop.py b/tests/integration/test_served_catalog_rate_backstop.py
index 02829bae..7819c017 100644
--- a/tests/integration/test_served_catalog_rate_backstop.py
+++ b/tests/integration/test_served_catalog_rate_backstop.py
@@ -187,3 +187,71 @@ async def test_one_unreadable_stored_price_does_not_blank_the_catalog(
served = {m.id for m in await list_models(integration_session, provider_id)}
assert served == {"good"}
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "field",
+ [
+ "image",
+ "web_search",
+ "internal_reasoning",
+ "input_cache_read",
+ "input_cache_write",
+ ],
+)
+async def test_served_catalog_excludes_a_malformed_auxiliary_rate(
+ integration_session: AsyncSession, field: str
+) -> None:
+ """The backstop covers every billable rate, not only the token rates.
+
+ A price whose ``prompt``/``completion`` are sound can still carry a
+ malformed request, image, search, reasoning or cache rate — the catalog
+ import filter never inspects those — and the request that hits one is billed
+ against it just the same.
+ """
+ provider_id = await _make_provider(integration_session)
+ await _insert_row(
+ integration_session,
+ provider_id,
+ model_id="good",
+ pricing={"prompt": 1e-06, "completion": 2e-06},
+ )
+ await _insert_row(
+ integration_session,
+ provider_id,
+ model_id="bad-aux",
+ pricing={"prompt": 1e-06, "completion": 2e-06, field: -1.0},
+ )
+
+ served = {m.id for m in await list_models(integration_session, provider_id)}
+
+ assert served == {"good"}
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_a_negative_request_rate_is_clamped_on_read_and_still_served(
+ integration_session: AsyncSession,
+) -> None:
+ """``request`` is the one billable rate the row-to-model conversion repairs.
+
+ It clamps a negative stored ``request`` to zero before the price is built,
+ so the backstop never sees one and the row is served at a zero request rate
+ — money-safe, and the reason ``request`` is absent from the list of rates
+ above. Pinned here so that if the clamp goes, this rate joins that list
+ rather than quietly becoming the one unguarded field.
+ """
+ provider_id = await _make_provider(integration_session)
+ await _insert_row(
+ integration_session,
+ provider_id,
+ model_id="neg-request",
+ pricing={"prompt": 1e-06, "completion": 2e-06, "request": -1.0},
+ )
+
+ served = await list_models(integration_session, provider_id)
+
+ assert [m.id for m in served] == ["neg-request"]
+ assert served[0].pricing.request == 0.0
diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py
index 9e4a5d6e..d3656d25 100644
--- a/tests/unit/test_pricing_rate_validation.py
+++ b/tests/unit/test_pricing_rate_validation.py
@@ -189,6 +189,12 @@ class _ExchangeResponse:
self._payload = payload
def json(self) -> dict[str, Any]:
+ # A quote given as an exception stands for a response body that never
+ # produced one: an exchange answering with an HTML error page raises
+ # out of `.json()` before any price is read.
+ quote = next(iter(self._payload.values()))
+ if isinstance(quote, BaseException):
+ raise quote
return self._payload
@@ -200,9 +206,10 @@ class _ExchangeClient:
async def get(self, url: str) -> _ExchangeResponse:
if "kraken" in url:
- return _ExchangeResponse(
- {"result": {"XXBTZUSD": {"c": [self._quotes["kraken"]]}}}
- )
+ quote = self._quotes["kraken"]
+ if isinstance(quote, BaseException):
+ return _ExchangeResponse({"error": quote})
+ return _ExchangeResponse({"result": {"XXBTZUSD": {"c": [quote]}}})
if "coinbase" in url:
return _ExchangeResponse({"data": {"amount": self._quotes["coinbase"]}})
return _ExchangeResponse({"price": self._quotes["binance"]})
@@ -297,6 +304,31 @@ async def test_boolean_exchange_quote_does_not_set_the_node_price(
assert btc_usd_price() == pytest.approx(100000.0)
+@pytest.mark.asyncio
+async def test_an_unreadable_exchange_response_drops_only_that_quote(
+ refresh_price_with: Any,
+) -> None:
+ """An exchange whose response never yields a quote costs one quote.
+
+ The price is aggregated across three exchanges precisely so that one of them
+ having a bad day is survivable. A body that is not JSON, or whose shape moved
+ under the reader, raises before any number is seen; unhandled, it aborted the
+ whole aggregation and left the node on a stale rate even though two healthy
+ quotes were already in hand.
+ """
+ from routstr.payment.price import btc_usd_price
+
+ await refresh_price_with(
+ {
+ "kraken": ValueError("Expecting value: line 1 column 1 (char 0)"),
+ "coinbase": "100000.0",
+ "binance": "100000.0",
+ }
+ )
+
+ assert btc_usd_price() == pytest.approx(100000.0)
+
+
@pytest.mark.asyncio
async def test_all_quotes_unusable_keeps_the_last_good_price(
refresh_price_with: Any,
From fec229e1f4e147e7012f6527409e9a60f0f9d6dd Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 20:47:48 +0200
Subject: [PATCH 039/120] fix(pricing): ask the shared rate question when
reading a feed price
The feed-price coercion rejected `NaN`/`inf` but returned a negative unchanged,
and the catalog import filter that would have caught one inspects only prompt
and completion. A negative cache rate was the one malformed value still
reaching a stored price, where it prices cached input tokens at a credit.
Both resolver rungs now ask `is_usable_rate` rather than spelling the rule out
again, so a feed price is held to the same standard as one arriving at the
admin edge. The both-zero rejection stays: that is a free price, not a
malformed one, and it is a separate question.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
routstr/upstream/pricing_resolver.py | 32 +++++++++++++----------
tests/unit/test_upstream_generic.py | 38 ++++++++++++++++++++++++++++
2 files changed, 56 insertions(+), 14 deletions(-)
diff --git a/routstr/upstream/pricing_resolver.py b/routstr/upstream/pricing_resolver.py
index 3064ba57..ef7e544c 100644
--- a/routstr/upstream/pricing_resolver.py
+++ b/routstr/upstream/pricing_resolver.py
@@ -15,7 +15,6 @@ it into the base provider unchanged.
from __future__ import annotations
-import math
from dataclasses import dataclass, field
@@ -66,19 +65,23 @@ def estimate_context_length(model_id: str) -> int:
def _as_float(value: object) -> float | None:
- """OpenRouter reports prices as strings; coerce, ``None`` if not a real number.
+ """OpenRouter reports prices as strings; coerce, ``None`` if not a real rate.
- Non-finite values are rejected as unparseable: ``float("Infinity")`` and
- ``float("NaN")`` parse happily from a feed string, and ``json.loads``
- accepts the bare literals and overflows ``1e999`` to ``inf``. An oversized
- integer raises ``OverflowError`` rather than ``ValueError``, so that is
- caught too.
+ Every caller reads a *price* out of a feed, so this asks the shared
+ billable-rate question rather than merely parsing: ``float("Infinity")`` and
+ ``float("NaN")`` parse happily from a feed string, ``json.loads`` accepts the
+ bare literals and overflows ``1e999`` to ``inf``, and a negative parses
+ cleanly into a rate that credits the caller. An oversized integer raises
+ ``OverflowError`` rather than ``ValueError``, so that is caught too.
"""
+ # Lazy, like the litellm lookup below: the resolver stays import-light.
+ from ..payment.models import is_usable_rate
+
try:
parsed = float(value) # type: ignore[arg-type]
except (TypeError, ValueError, OverflowError):
return None
- return parsed if math.isfinite(parsed) else None
+ return parsed if is_usable_rate(parsed) else None
def _as_int(value: object) -> int | None:
@@ -89,7 +92,7 @@ def _as_int(value: object) -> int | None:
def _from_litellm(model_id: str) -> ResolvedPricing | None:
# Lazy import so the resolver stays import-light and shares the exact
# lookup semantics used by cache-rate backfill.
- from ..payment.models import litellm_cost_entry
+ from ..payment.models import is_usable_rate, litellm_cost_entry
info = litellm_cost_entry(model_id)
if info is None:
@@ -103,12 +106,13 @@ def _from_litellm(model_id: str) -> ResolvedPricing | None:
# moderation/rerank tiers do this) — treating 0/0 as resolved would serve
# the model for free. Reject it (and any negative) so the caller falls
# through, mirroring async_fetch_openrouter_models' _has_valid_pricing.
- # A non-finite entry is junk, not a price: `inf` would bill an infinite
- # amount and `NaN` poisons every total it enters (and defeats the `< 0` and
- # `== 0` guards below, since both comparisons are False for `NaN`).
- if not math.isfinite(prompt) or not math.isfinite(completion):
+ # A malformed entry is junk, not a price: `inf` would bill an infinite
+ # amount, `NaN` poisons every total it enters, and a negative credits the
+ # caller. `NaN` also defeats the both-zero guard below on its own, since
+ # every comparison against it is False.
+ if not is_usable_rate(prompt) or not is_usable_rate(completion):
return None
- if prompt < 0 or completion < 0 or (prompt == 0 and completion == 0):
+ if prompt == 0 and completion == 0:
return None
input_modalities = ["text"]
diff --git a/tests/unit/test_upstream_generic.py b/tests/unit/test_upstream_generic.py
index bf5316a1..04470a76 100644
--- a/tests/unit/test_upstream_generic.py
+++ b/tests/unit/test_upstream_generic.py
@@ -629,3 +629,41 @@ async def test_non_finite_openrouter_cache_rate_is_dropped_not_carried() -> None
assert model.enabled is True
assert model.pricing.prompt == pytest.approx(1e-06)
assert model.pricing.input_cache_read == 0.0
+
+
+@pytest.mark.asyncio
+async def test_negative_openrouter_cache_rate_is_dropped_not_carried() -> None:
+ """A negative rate is as unusable as a non-finite one, and arrives the same way.
+
+ The coercion that reads a feed price rejected ``NaN``/``inf`` but returned a
+ negative unchanged, and the catalog import filter that would have caught one
+ inspects only prompt and completion. So a negative cache rate was the single
+ malformed value that still reached a stored price — where it prices cached
+ input tokens at a credit rather than a charge.
+ """
+ payload = {
+ "data": [
+ {"id": "or-negcache-xyz", "object": "model", "owned_by": "mystery"},
+ ]
+ }
+ feed = [
+ {
+ "id": "or-negcache-xyz",
+ "pricing": {
+ "prompt": "0.000001",
+ "completion": "0.000002",
+ "input_cache_read": "-0.0000005",
+ },
+ "context_length": 8192,
+ }
+ ]
+
+ with _patch_models_endpoint(payload):
+ or_feed = AsyncMock(return_value=feed)
+ with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
+ models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
+
+ model = _model_by_id(models, "or-negcache-xyz")
+ assert model.enabled is True
+ assert model.pricing.prompt == pytest.approx(1e-06)
+ assert model.pricing.input_cache_read == 0.0
From 568a8b1205ebc53856d9449e74d7ce0d58c6e2c8 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 25 Aug 2026 21:24:29 +0200
Subject: [PATCH 040/120] test(pricing): read the catalog through the test
session only
The provider helper called reinitialize_upstreams(), which re-reads the
provider table through the global engine rather than the session the test
was handed. On a developer machine that engine finds keys.db and the call
is invisible; on a clean checkout there is no such database and every test
in the file fails with "no such table: upstream_providers".
Nothing here needs it: these tests call list_models() with the session
directly and never route a request, so the proxy's cached upstreams are
not part of what is under test. Dropping the call also stops the file
mutating that global for whatever runs next.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_011cKHVF5LA7TR5QuYi6ErLM
---
tests/integration/test_served_catalog_rate_backstop.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/tests/integration/test_served_catalog_rate_backstop.py b/tests/integration/test_served_catalog_rate_backstop.py
index 7819c017..4acee442 100644
--- a/tests/integration/test_served_catalog_rate_backstop.py
+++ b/tests/integration/test_served_catalog_rate_backstop.py
@@ -19,7 +19,6 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ModelRow, UpstreamProviderRow
from routstr.payment.models import list_models
-from routstr.proxy import reinitialize_upstreams
_ARCHITECTURE = json.dumps(
{
@@ -42,7 +41,6 @@ async def _make_provider(session: AsyncSession) -> int:
session.add(provider)
await session.commit()
await session.refresh(provider)
- await reinitialize_upstreams()
assert provider.id is not None
return provider.id
From c0aeb502895aa8a50681c2861a9f87000a9021f0 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Wed, 26 Aug 2026 00:38:15 +0200
Subject: [PATCH 041/120] update packages
---
ui/package.json | 4 +-
ui/pnpm-lock.yaml | 615 ++++++++++++++++++++---------------------
ui/pnpm-workspace.yaml | 30 +-
uv.lock | 557 ++++++++++++++++++++-----------------
4 files changed, 624 insertions(+), 582 deletions(-)
diff --git a/ui/package.json b/ui/package.json
index 87f289fc..19565407 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.6",
+ "next": "16.2.11",
"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.6",
+ "eslint-config-next": "16.2.11",
"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 d57e95ee..2a0fe1ce 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -6,17 +6,19 @@ settings:
overrides:
'@babel/core': 7.29.6
+ ajv@6: 6.14.0
+ brace-expansion@1: 1.1.18
+ brace-expansion@5: 5.0.9
flatted: 3.4.2
follow-redirects: 1.16.0
form-data: 4.0.6
- ajv@6: 6.14.0
- brace-expansion@1: 1.1.13
- brace-expansion@5: 5.0.6
- js-yaml: 4.2.0
+ js-yaml: 4.3.1
minimatch@3: 3.1.4
+ nanoid@3: 3.3.18
picomatch@2: 2.3.2
picomatch@4: 4.0.4
- postcss: 8.5.10
+ postcss: 8.5.23
+ sharp: 0.35.0
importers:
@@ -120,7 +122,7 @@ importers:
version: 8.6.0(react@19.2.4)
geist:
specifier: ^1.7.0
- version: 1.7.0(next@16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
+ 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))
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -128,8 +130,8 @@ importers:
specifier: ^0.575.0
version: 0.575.0(react@19.2.4)
next:
- specifier: 16.2.6
- version: 16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ 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)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -201,8 +203,8 @@ importers:
specifier: ^9.7.0
version: 9.38.0(jiti@2.6.1)
eslint-config-next:
- specifier: 16.2.6
- version: 16.2.6(@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.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)
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@9.38.0(jiti@2.6.1))
@@ -231,10 +233,6 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
- '@babel/code-frame@7.29.0':
- resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
- engines: {node: '>=6.9.0'}
-
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -269,18 +267,10 @@ packages:
peerDependencies:
'@babel/core': 7.29.6
- '@babel/helper-string-parser@7.27.1':
- resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
- engines: {node: '>=6.9.0'}
-
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
- '@babel/helper-validator-identifier@7.28.5':
- resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
- engines: {node: '>=6.9.0'}
-
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
@@ -293,11 +283,6 @@ packages:
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
engines: {node: '>=6.9.0'}
- '@babel/parser@7.29.0':
- resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
- engines: {node: '>=6.0.0'}
- hasBin: true
-
'@babel/parser@7.29.7':
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
engines: {node: '>=6.0.0'}
@@ -307,10 +292,6 @@ packages:
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
- '@babel/template@7.28.6':
- resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
- engines: {node: '>=6.9.0'}
-
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -319,10 +300,6 @@ packages:
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
engines: {node: '>=6.9.0'}
- '@babel/types@7.29.0':
- resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
- engines: {node: '>=6.9.0'}
-
'@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
@@ -354,6 +331,9 @@ packages:
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
'@emnapi/runtime@1.8.1':
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
@@ -438,136 +418,145 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.34.5':
- resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-arm64@0.35.0':
+ resolution: {integrity: sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.34.5':
- resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-x64@0.35.0':
+ resolution: {integrity: sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-darwin-arm64@1.2.4':
- resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ '@img/sharp-freebsd-wasm32@0.35.0':
+ resolution: {integrity: sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==}
+ engines: {node: '>=20.9.0'}
+ os: [freebsd]
+
+ '@img/sharp-libvips-darwin-arm64@1.3.0':
+ resolution: {integrity: sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.2.4':
- resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ '@img/sharp-libvips-darwin-x64@1.3.0':
+ resolution: {integrity: sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.2.4':
- resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ '@img/sharp-libvips-linux-arm64@1.3.0':
+ resolution: {integrity: sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==}
cpu: [arm64]
os: [linux]
- '@img/sharp-libvips-linux-arm@1.2.4':
- resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ '@img/sharp-libvips-linux-arm@1.3.0':
+ resolution: {integrity: sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==}
cpu: [arm]
os: [linux]
- '@img/sharp-libvips-linux-ppc64@1.2.4':
- resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ '@img/sharp-libvips-linux-ppc64@1.3.0':
+ resolution: {integrity: sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==}
cpu: [ppc64]
os: [linux]
- '@img/sharp-libvips-linux-riscv64@1.2.4':
- resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ '@img/sharp-libvips-linux-riscv64@1.3.0':
+ resolution: {integrity: sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==}
cpu: [riscv64]
os: [linux]
- '@img/sharp-libvips-linux-s390x@1.2.4':
- resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ '@img/sharp-libvips-linux-s390x@1.3.0':
+ resolution: {integrity: sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==}
cpu: [s390x]
os: [linux]
- '@img/sharp-libvips-linux-x64@1.2.4':
- resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ '@img/sharp-libvips-linux-x64@1.3.0':
+ resolution: {integrity: sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==}
cpu: [x64]
os: [linux]
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
- resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.0':
+ resolution: {integrity: sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==}
cpu: [arm64]
os: [linux]
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
- resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ '@img/sharp-libvips-linuxmusl-x64@1.3.0':
+ resolution: {integrity: sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==}
cpu: [x64]
os: [linux]
- '@img/sharp-linux-arm64@0.34.5':
- resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm64@0.35.0':
+ resolution: {integrity: sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
- '@img/sharp-linux-arm@0.34.5':
- resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm@0.35.0':
+ resolution: {integrity: sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
- '@img/sharp-linux-ppc64@0.34.5':
- resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-ppc64@0.35.0':
+ resolution: {integrity: sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==}
+ engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
- '@img/sharp-linux-riscv64@0.34.5':
- resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-riscv64@0.35.0':
+ resolution: {integrity: sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==}
+ engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
- '@img/sharp-linux-s390x@0.34.5':
- resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-s390x@0.35.0':
+ resolution: {integrity: sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==}
+ engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
- '@img/sharp-linux-x64@0.34.5':
- resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-x64@0.35.0':
+ resolution: {integrity: sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
- '@img/sharp-linuxmusl-arm64@0.34.5':
- resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-arm64@0.35.0':
+ resolution: {integrity: sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
- '@img/sharp-linuxmusl-x64@0.34.5':
- resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-x64@0.35.0':
+ resolution: {integrity: sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
- '@img/sharp-wasm32@0.34.5':
- resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-wasm32@0.35.0':
+ resolution: {integrity: sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==}
+ engines: {node: '>=20.9.0'}
+
+ '@img/sharp-webcontainers-wasm32@0.35.0':
+ resolution: {integrity: sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==}
+ engines: {node: '>=20.9.0'}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.34.5':
- resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-arm64@0.35.0':
+ resolution: {integrity: sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.34.5':
- resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-ia32@0.35.0':
+ resolution: {integrity: sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==}
+ engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.34.5':
- resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-x64@0.35.0':
+ resolution: {integrity: sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
@@ -590,56 +579,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
- '@next/env@16.2.6':
- resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
+ '@next/env@16.2.11':
+ resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==}
- '@next/eslint-plugin-next@16.2.6':
- resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==}
+ '@next/eslint-plugin-next@16.2.11':
+ resolution: {integrity: sha512-vMEf/aXOpzFFdtIvFYOnIDPKb0xBbrXONsz83CcKdRrekfxNdL8PNkq5qHqAHSXVlIifnX68LOMaxr3z5PkeLQ==}
- '@next/swc-darwin-arm64@16.2.6':
- resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
+ '@next/swc-darwin-arm64@16.2.11':
+ resolution: {integrity: sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.2.6':
- resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
+ '@next/swc-darwin-x64@16.2.11':
+ resolution: {integrity: sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.2.6':
- resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
+ '@next/swc-linux-arm64-gnu@16.2.11':
+ resolution: {integrity: sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-arm64-musl@16.2.6':
- resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
+ '@next/swc-linux-arm64-musl@16.2.11':
+ resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-x64-gnu@16.2.6':
- resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
+ '@next/swc-linux-x64-gnu@16.2.11':
+ resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-linux-x64-musl@16.2.6':
- resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
+ '@next/swc-linux-x64-musl@16.2.11':
+ resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-win32-arm64-msvc@16.2.6':
- resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
+ '@next/swc-win32-arm64-msvc@16.2.11':
+ resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.2.6':
- resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
+ '@next/swc-win32-x64-msvc@16.2.11':
+ resolution: {integrity: sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1871,12 +1860,12 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
- brace-expansion@1.1.13:
- resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
+ brace-expansion@1.1.18:
+ resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
- brace-expansion@5.0.6:
- resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
- engines: {node: 18 || 20 || >=22}
+ brace-expansion@5.0.9:
+ resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
+ engines: {node: 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -2152,8 +2141,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
- eslint-config-next@16.2.6:
- resolution: {integrity: sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==}
+ eslint-config-next@16.2.11:
+ resolution: {integrity: sha512-FIpbK/dUyxUExchDB7eBg3k+VU8R2iR/Cx9/kqTBUTFv2bOIR9aRrpno4rvAQ9VhiPQAyFKNA2NlZwouGWtclA==}
peerDependencies:
eslint: '>=9.0.0'
typescript: '>=3.3.1'
@@ -2265,6 +2254,7 @@ packages:
eslint@9.38.0:
resolution: {integrity: sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -2637,8 +2627,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
- js-yaml@4.2.0:
- resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
+ js-yaml@4.3.1:
+ resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsesc@3.1.0:
@@ -2811,8 +2801,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.11:
- resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ nanoid@3.3.18:
+ resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -2830,8 +2820,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.6:
- resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
+ next@16.2.11:
+ resolution: {integrity: sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -2948,8 +2938,8 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
- postcss@8.5.10:
- resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
+ postcss@8.5.23:
+ resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -3208,6 +3198,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -3223,9 +3218,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
- sharp@0.34.5:
- resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ sharp@0.35.0:
+ resolution: {integrity: sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==}
+ engines: {node: '>=20.9.0'}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
@@ -3541,12 +3536,6 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
- '@babel/code-frame@7.29.0':
- dependencies:
- '@babel/helper-validator-identifier': 7.28.5
- js-tokens: 4.0.0
- picocolors: 1.1.1
-
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -3557,15 +3546,15 @@ snapshots:
'@babel/core@7.29.6':
dependencies:
- '@babel/code-frame': 7.29.0
+ '@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.6)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
- '@babel/template': 7.28.6
+ '@babel/template': 7.29.7
'@babel/traverse': 7.29.0
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
@@ -3596,7 +3585,7 @@ snapshots:
'@babel/helper-module-imports@7.28.6':
dependencies:
'@babel/traverse': 7.29.0
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -3604,17 +3593,13 @@ snapshots:
dependencies:
'@babel/core': 7.29.6
'@babel/helper-module-imports': 7.28.6
- '@babel/helper-validator-identifier': 7.28.5
+ '@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
- '@babel/helper-string-parser@7.27.1': {}
-
'@babel/helper-string-parser@7.29.7': {}
- '@babel/helper-validator-identifier@7.28.5': {}
-
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/helper-validator-option@7.27.1': {}
@@ -3624,22 +3609,12 @@ snapshots:
'@babel/template': 7.29.7
'@babel/types': 7.29.7
- '@babel/parser@7.29.0':
- dependencies:
- '@babel/types': 7.29.0
-
'@babel/parser@7.29.7':
dependencies:
'@babel/types': 7.29.7
'@babel/runtime@7.28.6': {}
- '@babel/template@7.28.6':
- dependencies:
- '@babel/code-frame': 7.29.0
- '@babel/parser': 7.29.7
- '@babel/types': 7.29.0
-
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -3648,21 +3623,16 @@ snapshots:
'@babel/traverse@7.29.0':
dependencies:
- '@babel/code-frame': 7.29.0
+ '@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.7
- '@babel/template': 7.28.6
- '@babel/types': 7.29.0
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
debug: 4.4.3
transitivePeerDependencies:
- supports-color
- '@babel/types@7.29.0':
- dependencies:
- '@babel/helper-string-parser': 7.27.1
- '@babel/helper-validator-identifier': 7.28.5
-
'@babel/types@7.29.7':
dependencies:
'@babel/helper-string-parser': 7.29.7
@@ -3700,6 +3670,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/runtime@1.8.1':
dependencies:
tslib: 2.8.1
@@ -3741,7 +3716,7 @@ snapshots:
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
- js-yaml: 4.2.0
+ js-yaml: 4.3.1
minimatch: 3.1.4
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -3792,98 +3767,108 @@ snapshots:
'@img/colour@1.1.0':
optional: true
- '@img/sharp-darwin-arm64@0.34.5':
+ '@img/sharp-darwin-arm64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-arm64': 1.3.0
optional: true
- '@img/sharp-darwin-x64@0.34.5':
+ '@img/sharp-darwin-x64@0.35.0':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.3.0
optional: true
- '@img/sharp-libvips-darwin-arm64@1.2.4':
- optional: true
-
- '@img/sharp-libvips-darwin-x64@1.2.4':
- optional: true
-
- '@img/sharp-libvips-linux-arm64@1.2.4':
- optional: true
-
- '@img/sharp-libvips-linux-arm@1.2.4':
- optional: true
-
- '@img/sharp-libvips-linux-ppc64@1.2.4':
- optional: true
-
- '@img/sharp-libvips-linux-riscv64@1.2.4':
- optional: true
-
- '@img/sharp-libvips-linux-s390x@1.2.4':
- optional: true
-
- '@img/sharp-libvips-linux-x64@1.2.4':
- optional: true
-
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
- optional: true
-
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
- optional: true
-
- '@img/sharp-linux-arm64@0.34.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.2.4
- optional: true
-
- '@img/sharp-linux-arm@0.34.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.2.4
- optional: true
-
- '@img/sharp-linux-ppc64@0.34.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.2.4
- optional: true
-
- '@img/sharp-linux-riscv64@0.34.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.2.4
- optional: true
-
- '@img/sharp-linux-s390x@0.34.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.2.4
- optional: true
-
- '@img/sharp-linux-x64@0.34.5':
- optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.2.4
- optional: true
-
- '@img/sharp-linuxmusl-arm64@0.34.5':
- optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
- optional: true
-
- '@img/sharp-linuxmusl-x64@0.34.5':
- optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
- optional: true
-
- '@img/sharp-wasm32@0.34.5':
+ '@img/sharp-freebsd-wasm32@0.35.0':
dependencies:
- '@emnapi/runtime': 1.8.1
+ '@img/sharp-wasm32': 0.35.0
optional: true
- '@img/sharp-win32-arm64@0.34.5':
+ '@img/sharp-libvips-darwin-arm64@1.3.0':
optional: true
- '@img/sharp-win32-ia32@0.34.5':
+ '@img/sharp-libvips-darwin-x64@1.3.0':
optional: true
- '@img/sharp-win32-x64@0.34.5':
+ '@img/sharp-libvips-linux-arm64@1.3.0':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.3.0':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.3.0':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.3.0':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.3.0':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.3.0':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.0':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.3.0':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.35.0':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.3.0
+ optional: true
+
+ '@img/sharp-linux-arm@0.35.0':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.3.0
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.35.0':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.3.0
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.35.0':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.3.0
+ optional: true
+
+ '@img/sharp-linux-s390x@0.35.0':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.3.0
+ optional: true
+
+ '@img/sharp-linux-x64@0.35.0':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.3.0
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.35.0':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.0
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.35.0':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.0
+ optional: true
+
+ '@img/sharp-wasm32@0.35.0':
+ dependencies:
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-webcontainers-wasm32@0.35.0':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.0
+ optional: true
+
+ '@img/sharp-win32-arm64@0.35.0':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.35.0':
+ optional: true
+
+ '@img/sharp-win32-x64@0.35.0':
optional: true
'@jridgewell/gen-mapping@0.3.13':
@@ -3912,34 +3897,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@next/env@16.2.6': {}
+ '@next/env@16.2.11': {}
- '@next/eslint-plugin-next@16.2.6':
+ '@next/eslint-plugin-next@16.2.11':
dependencies:
fast-glob: 3.3.1
- '@next/swc-darwin-arm64@16.2.6':
+ '@next/swc-darwin-arm64@16.2.11':
optional: true
- '@next/swc-darwin-x64@16.2.6':
+ '@next/swc-darwin-x64@16.2.11':
optional: true
- '@next/swc-linux-arm64-gnu@16.2.6':
+ '@next/swc-linux-arm64-gnu@16.2.11':
optional: true
- '@next/swc-linux-arm64-musl@16.2.6':
+ '@next/swc-linux-arm64-musl@16.2.11':
optional: true
- '@next/swc-linux-x64-gnu@16.2.6':
+ '@next/swc-linux-x64-gnu@16.2.11':
optional: true
- '@next/swc-linux-x64-musl@16.2.6':
+ '@next/swc-linux-x64-musl@16.2.11':
optional: true
- '@next/swc-win32-arm64-msvc@16.2.6':
+ '@next/swc-win32-arm64-msvc@16.2.11':
optional: true
- '@next/swc-win32-x64-msvc@16.2.6':
+ '@next/swc-win32-x64-msvc@16.2.11':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -4845,7 +4830,7 @@ snapshots:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.2.1
'@tailwindcss/oxide': 4.2.1
- postcss: 8.5.10
+ postcss: 8.5.23
tailwindcss: 4.2.1
'@tanstack/query-core@5.90.20': {}
@@ -5196,12 +5181,12 @@ snapshots:
baseline-browser-mapping@2.10.0: {}
- brace-expansion@1.1.13:
+ brace-expansion@1.1.18:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
- brace-expansion@5.0.6:
+ brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -5503,7 +5488,7 @@ snapshots:
has-property-descriptors: 1.0.2
has-proto: 1.2.0
has-symbols: 1.1.0
- hasown: 2.0.2
+ hasown: 2.0.4
internal-slot: 1.1.0
is-array-buffer: 3.0.5
is-callable: 1.2.7
@@ -5586,9 +5571,9 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-config-next@16.2.6(@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.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):
dependencies:
- '@next/eslint-plugin-next': 16.2.6
+ '@next/eslint-plugin-next': 16.2.11
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))
@@ -5656,7 +5641,7 @@ snapshots:
eslint: 9.38.0(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.6.1))
- hasown: 2.0.2
+ hasown: 2.0.4
is-core-module: 2.16.1
is-glob: 4.0.3
minimatch: 3.1.4
@@ -5684,7 +5669,7 @@ snapshots:
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
eslint: 9.38.0(jiti@2.6.1)
- hasown: 2.0.2
+ hasown: 2.0.4
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
minimatch: 3.1.4
@@ -5704,7 +5689,7 @@ snapshots:
eslint-plugin-react-hooks@7.0.1(eslint@9.38.0(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.6
- '@babel/parser': 7.29.0
+ '@babel/parser': 7.29.7
eslint: 9.38.0(jiti@2.6.1)
hermes-parser: 0.25.1
zod: 4.3.6
@@ -5882,9 +5867,9 @@ snapshots:
functions-have-names@1.2.3: {}
- geist@1.7.0(next@16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
+ 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)):
dependencies:
- next: 16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@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)
generator-function@2.0.1: {}
@@ -6141,7 +6126,7 @@ snapshots:
js-tokens@4.0.0: {}
- js-yaml@4.2.0:
+ js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -6273,17 +6258,17 @@ snapshots:
minimatch@10.2.4:
dependencies:
- brace-expansion: 5.0.6
+ brace-expansion: 5.0.9
minimatch@3.1.4:
dependencies:
- brace-expansion: 1.1.13
+ brace-expansion: 1.1.18
minimist@1.2.8: {}
ms@2.1.3: {}
- nanoid@3.3.11: {}
+ nanoid@3.3.18: {}
napi-postinstall@0.3.4: {}
@@ -6294,26 +6279,26 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- next@16.2.6(@babel/core@7.29.6)(react-dom@19.2.4(react@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):
dependencies:
- '@next/env': 16.2.6
+ '@next/env': 16.2.11
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.0
caniuse-lite: 1.0.30001776
- postcss: 8.5.10
+ 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.6
- '@next/swc-darwin-x64': 16.2.6
- '@next/swc-linux-arm64-gnu': 16.2.6
- '@next/swc-linux-arm64-musl': 16.2.6
- '@next/swc-linux-x64-gnu': 16.2.6
- '@next/swc-linux-x64-musl': 16.2.6
- '@next/swc-win32-arm64-msvc': 16.2.6
- '@next/swc-win32-x64-msvc': 16.2.6
- sharp: 0.34.5
+ '@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
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -6415,9 +6400,9 @@ snapshots:
possible-typed-array-names@1.1.0: {}
- postcss@8.5.10:
+ postcss@8.5.23:
dependencies:
- nanoid: 3.3.11
+ nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -6677,6 +6662,9 @@ snapshots:
semver@7.7.4: {}
+ semver@7.8.5:
+ optional: true
+
set-blocking@2.0.0: {}
set-function-length@1.2.2:
@@ -6701,36 +6689,37 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
- sharp@0.34.5:
+ sharp@0.35.0:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
- semver: 7.7.4
+ semver: 7.8.5
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.34.5
- '@img/sharp-darwin-x64': 0.34.5
- '@img/sharp-libvips-darwin-arm64': 1.2.4
- '@img/sharp-libvips-darwin-x64': 1.2.4
- '@img/sharp-libvips-linux-arm': 1.2.4
- '@img/sharp-libvips-linux-arm64': 1.2.4
- '@img/sharp-libvips-linux-ppc64': 1.2.4
- '@img/sharp-libvips-linux-riscv64': 1.2.4
- '@img/sharp-libvips-linux-s390x': 1.2.4
- '@img/sharp-libvips-linux-x64': 1.2.4
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
- '@img/sharp-linux-arm': 0.34.5
- '@img/sharp-linux-arm64': 0.34.5
- '@img/sharp-linux-ppc64': 0.34.5
- '@img/sharp-linux-riscv64': 0.34.5
- '@img/sharp-linux-s390x': 0.34.5
- '@img/sharp-linux-x64': 0.34.5
- '@img/sharp-linuxmusl-arm64': 0.34.5
- '@img/sharp-linuxmusl-x64': 0.34.5
- '@img/sharp-wasm32': 0.34.5
- '@img/sharp-win32-arm64': 0.34.5
- '@img/sharp-win32-ia32': 0.34.5
- '@img/sharp-win32-x64': 0.34.5
+ '@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
optional: true
shebang-command@2.0.0:
diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml
index fb985a6c..660076f4 100644
--- a/ui/pnpm-workspace.yaml
+++ b/ui/pnpm-workspace.yaml
@@ -1,17 +1,19 @@
-overrides:
- '@babel/core': 7.29.6
- flatted: 3.4.2
- follow-redirects: 1.16.0
- form-data: 4.0.6
- ajv@6: 6.14.0
- brace-expansion@1: 1.1.13
- brace-expansion@5: 5.0.6
- js-yaml: 4.2.0
- minimatch@3: 3.1.4
- picomatch@2: 2.3.2
- picomatch@4: 4.0.4
- postcss: 8.5.10
-
onlyBuiltDependencies:
- sharp
- unrs-resolver
+
+overrides:
+ '@babel/core': 7.29.6
+ ajv@6: 6.14.0
+ brace-expansion@1: 1.1.18
+ brace-expansion@5: 5.0.9
+ flatted: 3.4.2
+ follow-redirects: 1.16.0
+ form-data: 4.0.6
+ js-yaml: 4.3.1
+ 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
diff --git a/uv.lock b/uv.lock
index 346c12d2..462fd619 100644
--- a/uv.lock
+++ b/uv.lock
@@ -17,7 +17,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.12.15"
+version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -26,61 +26,111 @@ dependencies = [
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" },
- { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" },
- { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" },
- { url = "https://files.pythonhosted.org/packages/f8/6c/f766d0aaafcee0447fad0328da780d344489c042e25cd58fde566bf40aed/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5", size = 1741977, upload-time = "2025-07-29T05:50:21.665Z" },
- { url = "https://files.pythonhosted.org/packages/17/e5/fb779a05ba6ff44d7bc1e9d24c644e876bfff5abe5454f7b854cace1b9cc/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728", size = 1690645, upload-time = "2025-07-29T05:50:23.333Z" },
- { url = "https://files.pythonhosted.org/packages/37/4e/a22e799c2035f5d6a4ad2cf8e7c1d1bd0923192871dd6e367dafb158b14c/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16", size = 1789437, upload-time = "2025-07-29T05:50:25.007Z" },
- { url = "https://files.pythonhosted.org/packages/28/e5/55a33b991f6433569babb56018b2fb8fb9146424f8b3a0c8ecca80556762/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0", size = 1828482, upload-time = "2025-07-29T05:50:26.693Z" },
- { url = "https://files.pythonhosted.org/packages/c6/82/1ddf0ea4f2f3afe79dffed5e8a246737cff6cbe781887a6a170299e33204/aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b", size = 1730944, upload-time = "2025-07-29T05:50:28.382Z" },
- { url = "https://files.pythonhosted.org/packages/1b/96/784c785674117b4cb3877522a177ba1b5e4db9ce0fd519430b5de76eec90/aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd", size = 1668020, upload-time = "2025-07-29T05:50:30.032Z" },
- { url = "https://files.pythonhosted.org/packages/12/8a/8b75f203ea7e5c21c0920d84dd24a5c0e971fe1e9b9ebbf29ae7e8e39790/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8", size = 1716292, upload-time = "2025-07-29T05:50:31.983Z" },
- { url = "https://files.pythonhosted.org/packages/47/0b/a1451543475bb6b86a5cfc27861e52b14085ae232896a2654ff1231c0992/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50", size = 1711451, upload-time = "2025-07-29T05:50:33.989Z" },
- { url = "https://files.pythonhosted.org/packages/55/fd/793a23a197cc2f0d29188805cfc93aa613407f07e5f9da5cd1366afd9d7c/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676", size = 1691634, upload-time = "2025-07-29T05:50:35.846Z" },
- { url = "https://files.pythonhosted.org/packages/ca/bf/23a335a6670b5f5dfc6d268328e55a22651b440fca341a64fccf1eada0c6/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7", size = 1785238, upload-time = "2025-07-29T05:50:37.597Z" },
- { url = "https://files.pythonhosted.org/packages/57/4f/ed60a591839a9d85d40694aba5cef86dde9ee51ce6cca0bb30d6eb1581e7/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7", size = 1805701, upload-time = "2025-07-29T05:50:39.591Z" },
- { url = "https://files.pythonhosted.org/packages/85/e0/444747a9455c5de188c0f4a0173ee701e2e325d4b2550e9af84abb20cdba/aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685", size = 1718758, upload-time = "2025-07-29T05:50:41.292Z" },
- { url = "https://files.pythonhosted.org/packages/36/ab/1006278d1ffd13a698e5dd4bfa01e5878f6bddefc296c8b62649753ff249/aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b", size = 428868, upload-time = "2025-07-29T05:50:43.063Z" },
- { url = "https://files.pythonhosted.org/packages/10/97/ad2b18700708452400278039272032170246a1bf8ec5d832772372c71f1a/aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d", size = 453273, upload-time = "2025-07-29T05:50:44.613Z" },
- { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" },
- { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" },
- { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" },
- { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" },
- { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" },
- { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" },
- { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" },
- { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" },
- { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" },
- { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" },
- { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" },
- { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" },
- { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" },
- { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" },
- { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" },
- { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" },
- { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" },
- { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" },
- { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" },
- { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" },
- { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" },
- { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" },
- { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" },
- { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" },
- { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" },
- { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" },
- { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" },
- { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" },
- { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" },
- { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" },
- { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" },
- { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" },
- { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
+ { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
+ { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
+ { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
+ { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
+ { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
+ { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
+ { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
+ { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
+ { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
+ { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
+ { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
+ { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
+ { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
+ { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
+ { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
+ { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
+ { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
+ { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
+ { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
+ { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
+ { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
+ { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
+ { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
+ { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
+ { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
+ { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
+ { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
+ { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
+ { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
+ { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
[[package]]
@@ -261,56 +311,50 @@ wheels = [
[[package]]
name = "brotli"
-version = "1.1.0"
+version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2f/c2/f9e977608bdf958650638c3f1e28f85a1b075f075ebbe77db8555463787b/Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", size = 7372270, upload-time = "2023-09-07T14:05:41.643Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/96/12/ad41e7fadd5db55459c4c401842b47f7fee51068f86dd2894dd0dcfc2d2a/Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", size = 873068, upload-time = "2023-09-07T14:03:37.779Z" },
- { url = "https://files.pythonhosted.org/packages/95/4e/5afab7b2b4b61a84e9c75b17814198ce515343a44e2ed4488fac314cd0a9/Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6", size = 446244, upload-time = "2023-09-07T14:03:39.223Z" },
- { url = "https://files.pythonhosted.org/packages/9d/e6/f305eb61fb9a8580c525478a4a34c5ae1a9bcb12c3aee619114940bc513d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd", size = 2906500, upload-time = "2023-09-07T14:03:40.858Z" },
- { url = "https://files.pythonhosted.org/packages/3e/4f/af6846cfbc1550a3024e5d3775ede1e00474c40882c7bf5b37a43ca35e91/Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf", size = 2943950, upload-time = "2023-09-07T14:03:42.896Z" },
- { url = "https://files.pythonhosted.org/packages/b3/e7/ca2993c7682d8629b62630ebf0d1f3bb3d579e667ce8e7ca03a0a0576a2d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61", size = 2918527, upload-time = "2023-09-07T14:03:44.552Z" },
- { url = "https://files.pythonhosted.org/packages/b3/96/da98e7bedc4c51104d29cc61e5f449a502dd3dbc211944546a4cc65500d3/Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327", size = 2845489, upload-time = "2023-09-07T14:03:46.594Z" },
- { url = "https://files.pythonhosted.org/packages/e8/ef/ccbc16947d6ce943a7f57e1a40596c75859eeb6d279c6994eddd69615265/Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd", size = 2914080, upload-time = "2023-09-07T14:03:48.204Z" },
- { url = "https://files.pythonhosted.org/packages/80/d6/0bd38d758d1afa62a5524172f0b18626bb2392d717ff94806f741fcd5ee9/Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", size = 2813051, upload-time = "2023-09-07T14:03:50.348Z" },
- { url = "https://files.pythonhosted.org/packages/14/56/48859dd5d129d7519e001f06dcfbb6e2cf6db92b2702c0c2ce7d97e086c1/Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", size = 2938172, upload-time = "2023-09-07T14:03:52.395Z" },
- { url = "https://files.pythonhosted.org/packages/3d/77/a236d5f8cd9e9f4348da5acc75ab032ab1ab2c03cc8f430d24eea2672888/Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", size = 2933023, upload-time = "2023-09-07T14:03:53.96Z" },
- { url = "https://files.pythonhosted.org/packages/f1/87/3b283efc0f5cb35f7f84c0c240b1e1a1003a5e47141a4881bf87c86d0ce2/Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f", size = 2935871, upload-time = "2024-10-18T12:32:16.688Z" },
- { url = "https://files.pythonhosted.org/packages/f3/eb/2be4cc3e2141dc1a43ad4ca1875a72088229de38c68e842746b342667b2a/Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757", size = 2847784, upload-time = "2024-10-18T12:32:18.459Z" },
- { url = "https://files.pythonhosted.org/packages/66/13/b58ddebfd35edde572ccefe6890cf7c493f0c319aad2a5badee134b4d8ec/Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0", size = 3034905, upload-time = "2024-10-18T12:32:20.192Z" },
- { url = "https://files.pythonhosted.org/packages/84/9c/bc96b6c7db824998a49ed3b38e441a2cae9234da6fa11f6ed17e8cf4f147/Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b", size = 2929467, upload-time = "2024-10-18T12:32:21.774Z" },
- { url = "https://files.pythonhosted.org/packages/e7/71/8f161dee223c7ff7fea9d44893fba953ce97cf2c3c33f78ba260a91bcff5/Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", size = 333169, upload-time = "2023-09-07T14:03:55.404Z" },
- { url = "https://files.pythonhosted.org/packages/02/8a/fece0ee1057643cb2a5bbf59682de13f1725f8482b2c057d4e799d7ade75/Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", size = 357253, upload-time = "2023-09-07T14:03:56.643Z" },
- { url = "https://files.pythonhosted.org/packages/5c/d0/5373ae13b93fe00095a58efcbce837fd470ca39f703a235d2a999baadfbc/Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28", size = 815693, upload-time = "2024-10-18T12:32:23.824Z" },
- { url = "https://files.pythonhosted.org/packages/8e/48/f6e1cdf86751300c288c1459724bfa6917a80e30dbfc326f92cea5d3683a/Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f", size = 422489, upload-time = "2024-10-18T12:32:25.641Z" },
- { url = "https://files.pythonhosted.org/packages/06/88/564958cedce636d0f1bed313381dfc4b4e3d3f6015a63dae6146e1b8c65c/Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", size = 873081, upload-time = "2023-09-07T14:03:57.967Z" },
- { url = "https://files.pythonhosted.org/packages/58/79/b7026a8bb65da9a6bb7d14329fd2bd48d2b7f86d7329d5cc8ddc6a90526f/Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", size = 446244, upload-time = "2023-09-07T14:03:59.319Z" },
- { url = "https://files.pythonhosted.org/packages/e5/18/c18c32ecea41b6c0004e15606e274006366fe19436b6adccc1ae7b2e50c2/Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", size = 2906505, upload-time = "2023-09-07T14:04:01.327Z" },
- { url = "https://files.pythonhosted.org/packages/08/c8/69ec0496b1ada7569b62d85893d928e865df29b90736558d6c98c2031208/Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", size = 2944152, upload-time = "2023-09-07T14:04:03.033Z" },
- { url = "https://files.pythonhosted.org/packages/ab/fb/0517cea182219d6768113a38167ef6d4eb157a033178cc938033a552ed6d/Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", size = 2919252, upload-time = "2023-09-07T14:04:04.675Z" },
- { url = "https://files.pythonhosted.org/packages/c7/53/73a3431662e33ae61a5c80b1b9d2d18f58dfa910ae8dd696e57d39f1a2f5/Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", size = 2845955, upload-time = "2023-09-07T14:04:06.585Z" },
- { url = "https://files.pythonhosted.org/packages/55/ac/bd280708d9c5ebdbf9de01459e625a3e3803cce0784f47d633562cf40e83/Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", size = 2914304, upload-time = "2023-09-07T14:04:08.668Z" },
- { url = "https://files.pythonhosted.org/packages/76/58/5c391b41ecfc4527d2cc3350719b02e87cb424ef8ba2023fb662f9bf743c/Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", size = 2814452, upload-time = "2023-09-07T14:04:10.736Z" },
- { url = "https://files.pythonhosted.org/packages/c7/4e/91b8256dfe99c407f174924b65a01f5305e303f486cc7a2e8a5d43c8bec3/Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", size = 2938751, upload-time = "2023-09-07T14:04:12.875Z" },
- { url = "https://files.pythonhosted.org/packages/5a/a6/e2a39a5d3b412938362bbbeba5af904092bf3f95b867b4a3eb856104074e/Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", size = 2933757, upload-time = "2023-09-07T14:04:14.551Z" },
- { url = "https://files.pythonhosted.org/packages/13/f0/358354786280a509482e0e77c1a5459e439766597d280f28cb097642fc26/Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9", size = 2936146, upload-time = "2024-10-18T12:32:27.257Z" },
- { url = "https://files.pythonhosted.org/packages/80/f7/daf538c1060d3a88266b80ecc1d1c98b79553b3f117a485653f17070ea2a/Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb", size = 2848055, upload-time = "2024-10-18T12:32:29.376Z" },
- { url = "https://files.pythonhosted.org/packages/ad/cf/0eaa0585c4077d3c2d1edf322d8e97aabf317941d3a72d7b3ad8bce004b0/Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111", size = 3035102, upload-time = "2024-10-18T12:32:31.371Z" },
- { url = "https://files.pythonhosted.org/packages/d8/63/1c1585b2aa554fe6dbce30f0c18bdbc877fa9a1bf5ff17677d9cca0ac122/Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", size = 2930029, upload-time = "2024-10-18T12:32:33.293Z" },
- { url = "https://files.pythonhosted.org/packages/5f/3b/4e3fd1893eb3bbfef8e5a80d4508bec17a57bb92d586c85c12d28666bb13/Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", size = 333276, upload-time = "2023-09-07T14:04:16.49Z" },
- { url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255, upload-time = "2023-09-07T14:04:17.83Z" },
- { url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681, upload-time = "2024-10-18T12:32:34.942Z" },
- { url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475, upload-time = "2024-10-18T12:32:36.485Z" },
- { url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173, upload-time = "2024-10-18T12:32:37.978Z" },
- { url = "https://files.pythonhosted.org/packages/ea/1d/e6ca79c96ff5b641df6097d299347507d39a9604bde8915e76bf026d6c77/Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", size = 2943803, upload-time = "2024-10-18T12:32:39.606Z" },
- { url = "https://files.pythonhosted.org/packages/ac/a3/d98d2472e0130b7dd3acdbb7f390d478123dbf62b7d32bda5c830a96116d/Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", size = 2918946, upload-time = "2024-10-18T12:32:41.679Z" },
- { url = "https://files.pythonhosted.org/packages/c4/a5/c69e6d272aee3e1423ed005d8915a7eaa0384c7de503da987f2d224d0721/Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", size = 2845707, upload-time = "2024-10-18T12:32:43.478Z" },
- { url = "https://files.pythonhosted.org/packages/58/9f/4149d38b52725afa39067350696c09526de0125ebfbaab5acc5af28b42ea/Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", size = 2936231, upload-time = "2024-10-18T12:32:45.224Z" },
- { url = "https://files.pythonhosted.org/packages/5a/5a/145de884285611838a16bebfdb060c231c52b8f84dfbe52b852a15780386/Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", size = 2848157, upload-time = "2024-10-18T12:32:46.894Z" },
- { url = "https://files.pythonhosted.org/packages/50/ae/408b6bfb8525dadebd3b3dd5b19d631da4f7d46420321db44cd99dcf2f2c/Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", size = 3035122, upload-time = "2024-10-18T12:32:48.844Z" },
- { url = "https://files.pythonhosted.org/packages/af/85/a94e5cfaa0ca449d8f91c3d6f78313ebf919a0dbd55a100c711c6e9655bc/Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", size = 2930206, upload-time = "2024-10-18T12:32:51.198Z" },
- { url = "https://files.pythonhosted.org/packages/c2/f0/a61d9262cd01351df22e57ad7c34f66794709acab13f34be2675f45bf89d/Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0", size = 333804, upload-time = "2024-10-18T12:32:52.661Z" },
- { url = "https://files.pythonhosted.org/packages/7e/c1/ec214e9c94000d1c1974ec67ced1c970c148aa6b8d8373066123fc3dbf06/Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b", size = 358517, upload-time = "2024-10-18T12:32:54.066Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" },
+ { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" },
+ { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" },
+ { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" },
+ { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" },
+ { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" },
+ { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
+ { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
+ { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
+ { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
+ { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
+ { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
+ { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
+ { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
+ { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
]
[[package]]
@@ -364,32 +408,39 @@ wheels = [
[[package]]
name = "cbor2"
-version = "5.6.5"
+version = "5.9.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e4/aa/ba55b47d51d27911981a18743b4d3cebfabccbb0598c09801b734cec4184/cbor2-5.6.5.tar.gz", hash = "sha256:b682820677ee1dbba45f7da11898d2720f92e06be36acec290867d5ebf3d7e09", size = 100886, upload-time = "2024-10-09T12:26:24.106Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/cb/09939728be094d155b5d4ac262e39877875f5f7e36eea66beb359f647bd0/cbor2-5.9.0.tar.gz", hash = "sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea", size = 111231, upload-time = "2026-03-22T15:56:50.638Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/b7/ef045245180510305648fd604244d3bb1ecf1b20de68f42ab5bc20198024/cbor2-5.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:863e0983989d56d5071270790e7ed8ddbda88c9e5288efdb759aba2efee670bc", size = 66452, upload-time = "2024-10-09T12:25:36.676Z" },
- { url = "https://files.pythonhosted.org/packages/41/20/5a9d93f86b1e8fd9d9db33aff39c0e3a8459e0803ec24bd837d8b56d4a1d/cbor2-5.6.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cff06464b8f4ca6eb9abcba67bda8f8334a058abc01005c8e616728c387ad32", size = 67421, upload-time = "2024-10-09T12:25:38.114Z" },
- { url = "https://files.pythonhosted.org/packages/0f/1e/2010f6d02dd117df88df64baf3eeca6aa6614cc81bdd6bfabf615889cf1f/cbor2-5.6.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4c7dbcdc59ea7f5a745d3e30ee5e6b6ff5ce7ac244aa3de6786391b10027bb3", size = 260756, upload-time = "2024-10-09T12:25:39.657Z" },
- { url = "https://files.pythonhosted.org/packages/e1/84/e177d9bef4749d14f31c513b25e341ac84e403e2ffa2bde562eac9e6184b/cbor2-5.6.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34cf5ab0dc310c3d0196caa6ae062dc09f6c242e2544bea01691fe60c0230596", size = 249210, upload-time = "2024-10-09T12:25:41.316Z" },
- { url = "https://files.pythonhosted.org/packages/38/75/ebfdbb281104b46419fe7cb65979de9927b75acebcb6afa0af291f728cd2/cbor2-5.6.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6797b824b26a30794f2b169c0575301ca9b74ae99064e71d16e6ba0c9057de51", size = 249138, upload-time = "2024-10-09T12:25:42.432Z" },
- { url = "https://files.pythonhosted.org/packages/b2/1e/12d887fb1a8227a16181eeec5d43057e251204626d73e1c20a77046ac1b1/cbor2-5.6.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73b9647eed1493097db6aad61e03d8f1252080ee041a1755de18000dd2c05f37", size = 247156, upload-time = "2024-10-09T12:25:43.588Z" },
- { url = "https://files.pythonhosted.org/packages/6f/76/478c12193de9517ce691bb8a3f7c00eafdd6a1bc3f7f23282ecdd99d02ec/cbor2-5.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:6e14a1bf6269d25e02ef1d4008e0ce8880aa271d7c6b4c329dba48645764f60e", size = 66319, upload-time = "2024-10-09T12:25:44.621Z" },
- { url = "https://files.pythonhosted.org/packages/57/af/84ced14c541451696825b7b8ccbb7668f688372ad8ee74aaca4311e79672/cbor2-5.6.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e25c2aebc9db99af7190e2261168cdde8ed3d639ca06868e4f477cf3a228a8e9", size = 67553, upload-time = "2024-10-09T12:25:45.767Z" },
- { url = "https://files.pythonhosted.org/packages/f2/d6/f63a840c68fed4de67d5441947af2dc695152cc488bb0e57312832fb923a/cbor2-5.6.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fde21ac1cf29336a31615a2c469a9cb03cf0add3ae480672d4d38cda467d07fc", size = 67569, upload-time = "2024-10-09T12:25:46.665Z" },
- { url = "https://files.pythonhosted.org/packages/77/ac/5fb79db6e882ec29680f4a974d35c098020a1b4709cad077667a8c3f4676/cbor2-5.6.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8947c102cac79d049eadbd5e2ffb8189952890df7cbc3ee262bbc2f95b011a9", size = 276610, upload-time = "2024-10-09T12:25:48.14Z" },
- { url = "https://files.pythonhosted.org/packages/cf/cb/70751377d94112001d46c311b5c40b45f34863dfa78a6bc71b71f40c8c7f/cbor2-5.6.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38886c41bebcd7dca57739439455bce759f1e4c551b511f618b8e9c1295b431b", size = 270004, upload-time = "2024-10-09T12:25:49.769Z" },
- { url = "https://files.pythonhosted.org/packages/f1/90/08800367e920aef31b93bd7b0cd6fadcb3a3f2243f4ed77a0d1c76f22b99/cbor2-5.6.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae2b49226224e92851c333b91d83292ec62eba53a19c68a79890ce35f1230d70", size = 264913, upload-time = "2024-10-09T12:25:50.92Z" },
- { url = "https://files.pythonhosted.org/packages/a8/9c/76b11a5ea7548bccb0dfef3e8fb3ede48bfeb39348f0c217519e0c40d33a/cbor2-5.6.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2764804ffb6553283fc4afb10a280715905a4cea4d6dc7c90d3e89c4a93bc8d", size = 266751, upload-time = "2024-10-09T12:25:52.777Z" },
- { url = "https://files.pythonhosted.org/packages/10/18/3866693a87c90cb12f7942e791d0f03a40ba44887dde7b7fc85319647efe/cbor2-5.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:a3ac50485cf67dfaab170a3e7b527630e93cb0a6af8cdaa403054215dff93adf", size = 66739, upload-time = "2024-10-09T12:25:54.606Z" },
- { url = "https://files.pythonhosted.org/packages/2b/69/77e93caae71d1baee927c9762e702c464715d88073133052c74ecc9d37d4/cbor2-5.6.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0d0a9c5aabd48ecb17acf56004a7542a0b8d8212be52f3102b8218284bd881e", size = 67647, upload-time = "2024-10-09T12:25:55.637Z" },
- { url = "https://files.pythonhosted.org/packages/84/83/cb941d4fd10e4696b2c0f6fb2e3056d9a296e5765b2000a69e29a507f819/cbor2-5.6.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61ceb77e6aa25c11c814d4fe8ec9e3bac0094a1f5bd8a2a8c95694596ea01e08", size = 67657, upload-time = "2024-10-09T12:25:56.528Z" },
- { url = "https://files.pythonhosted.org/packages/5c/3f/e16a1e29994483c751b714cdf61d2956290b0b30e94690fa714a9f155c5c/cbor2-5.6.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97a7e409b864fecf68b2ace8978eb5df1738799a333ec3ea2b9597bfcdd6d7d2", size = 275863, upload-time = "2024-10-09T12:25:57.462Z" },
- { url = "https://files.pythonhosted.org/packages/64/04/f64bda3eea649fe6644c59f13d0e1f4666d975ce305cadf13835233b2a26/cbor2-5.6.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6d69f38f7d788b04c09ef2b06747536624b452b3c8b371ab78ad43b0296fab", size = 269131, upload-time = "2024-10-09T12:25:59.635Z" },
- { url = "https://files.pythonhosted.org/packages/f4/8d/0d5ad3467f70578b032b3f52eb0f01f0327d5ae6b1f9e7d4d4e01a73aa95/cbor2-5.6.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f91e6d74fa6917df31f8757fdd0e154203b0dd0609ec53eb957016a2b474896a", size = 264728, upload-time = "2024-10-09T12:26:01.407Z" },
- { url = "https://files.pythonhosted.org/packages/77/cb/9b4f7890325eaa374c21fcccfee61a099ccb9ea0bc0f606acf7495f9568c/cbor2-5.6.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5ce13a27ef8fddf643fc17a753fe34aa72b251d03c23da6a560c005dc171085b", size = 266314, upload-time = "2024-10-09T12:26:02.451Z" },
- { url = "https://files.pythonhosted.org/packages/a8/cd/793dc041395609f5dd1edfdf0aecde504dc0fd35ed67eb3b2db79fb8ef4d/cbor2-5.6.5-cp313-cp313-win_amd64.whl", hash = "sha256:54c72a3207bb2d4480c2c39dad12d7971ce0853a99e3f9b8d559ce6eac84f66f", size = 66792, upload-time = "2024-10-09T12:26:03.615Z" },
- { url = "https://files.pythonhosted.org/packages/9b/ef/1c4698cac96d792005ef0611832f38eaee477c275ab4b02cbfc4daba7ad3/cbor2-5.6.5-py3-none-any.whl", hash = "sha256:3038523b8fc7de312bb9cdcbbbd599987e64307c4db357cd2030c472a6c7d468", size = 23752, upload-time = "2024-10-09T12:26:23.167Z" },
+ { url = "https://files.pythonhosted.org/packages/43/aa/317c7118b8dda4c9563125c1a12c70c5b41e36677964a49c72b1aac061ec/cbor2-5.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0485d3372fc832c5e16d4eb45fa1a20fc53e806e6c29a1d2b0d3e176cedd52b9", size = 70578, upload-time = "2026-03-22T15:56:03.835Z" },
+ { url = "https://files.pythonhosted.org/packages/31/43/fe29b1f897770011a5e7497f4523c2712282ee4a6cbf775ea6383fb7afb9/cbor2-5.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9d6e4e0f988b0e766509a8071975a8ee99f930e14a524620bf38083106158d2", size = 268738, upload-time = "2026-03-22T15:56:05.222Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/1a/e494568f3d8aafbcdfe361df44c3bcf5cdab5183e25ea08e3d3f9fcf4075/cbor2-5.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5326336f633cc89dfe543c78829c16c3a6449c2c03277d1ddba99086c3323363", size = 262571, upload-time = "2026-03-22T15:56:06.411Z" },
+ { url = "https://files.pythonhosted.org/packages/42/2e/92acd6f87382fd44a34d9d7e85cc45372e6ba664040b72d1d9df648b25d0/cbor2-5.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e702b02d42a5ace45425b595ffe70fe35aebaf9a3cdfdc2c758b6189c744422", size = 262356, upload-time = "2026-03-22T15:56:08.236Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/68/52c039a28688baeeb78b0be7483855e6c66ea05884a937444deede0c87b8/cbor2-5.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2372d357d403e7912f104ff085950ffc82a5854d6d717f1ca1ce16a40a0ef5a7", size = 257604, upload-time = "2026-03-22T15:56:09.835Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e4/10d96a7f73ed9227090ce6e3df5d73329eb6a267dab7d5b989e6fbf6c504/cbor2-5.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:1d02b65f070fd726bdc310d927228975bb655d155bf059b6eb7cacefb3dca86f", size = 69388, upload-time = "2026-03-22T15:56:11.28Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/c6/eea5829aa5a649db540f47ea35f4bf2313383d28246f0cbc50432cfad6b3/cbor2-5.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:837754ece9052b3f607047e1741e5f852a538aa2b0ee3db11c82a8fa11804aa4", size = 65315, upload-time = "2026-03-22T15:56:12.326Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/39/72d8a5a4b06565561ec28f4fcb41aff7bb77f51705c01f00b8254a2aca4f/cbor2-5.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f223dffb1bcdd2764665f04c1152943d9daa4bc124a576cd8dee1cad4264313", size = 71223, upload-time = "2026-03-22T15:56:13.68Z" },
+ { url = "https://files.pythonhosted.org/packages/09/fd/7ddf3d3153b54c69c3be77172b8d9aa3a9d74f62a7fbde614d53eaeed9a4/cbor2-5.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae6c706ac1d85a0b3cb3395308fd0c4d55e3202b4760773675957e93cdff45fc", size = 287865, upload-time = "2026-03-22T15:56:14.813Z" },
+ { url = "https://files.pythonhosted.org/packages/db/9d/7ede2cc42f9bb4260492e7d29d2aab781eacbbcfb09d983de1e695077199/cbor2-5.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cd43d8fc374b31643b2830910f28177a606a7bc84975a62675dd3f2e320fc7b", size = 288246, upload-time = "2026-03-22T15:56:16.113Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/9d/588ebc7c5bc5843f609b05fe07be8575c7dec987735b0bbc908ac9c1264a/cbor2-5.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aa07b392cc3d76fb31c08a46a226b58c320d1c172ff3073e864409ced7bc50f", size = 280214, upload-time = "2026-03-22T15:56:17.519Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a1/6fc8f4b15c6a27e7fbb7966c30c2b4b18c274a3221fa2f5e6235502d34bc/cbor2-5.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:971d425b3a23b75953d8853d5f9911bdeefa09d759ee3b5e6b07b5ff3cbd9073", size = 282162, upload-time = "2026-03-22T15:56:18.975Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/20/9a22cfe08be16ddfeef2542cf4eeed1b29f3f57ddbba0b42f7e0bb8331fd/cbor2-5.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:34a6cb15e6ab6a8eae94ad2041731cd3ef786af43a8df99f847969af5b902ee7", size = 70049, upload-time = "2026-03-22T15:56:20.502Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/9e/695f92d09006614034e25a9f5b10620f3b219f79c1bec3c37b7c6f27a7a9/cbor2-5.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d1ddc4541e7367ac58c2470cc0df847f7137167fe4f5729e2d3cc0b993d7da4", size = 65382, upload-time = "2026-03-22T15:56:21.526Z" },
+ { url = "https://files.pythonhosted.org/packages/81/c5/4901e21a8afe9448fd947b11e8f383903207cd6dd0800e5f5a386838de5b/cbor2-5.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbb06f34aa645b4deca66643bba3d400d20c15312d1fe88d429be60c1ab50f27", size = 71284, upload-time = "2026-03-22T15:56:22.836Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/10/df643a381aebc3f05486de4813662bc58accb640fc3275cb276a75e89694/cbor2-5.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac684fe195c39821fca70d18afbf748f728aefbfbf88456018d299e559b8cae0", size = 287682, upload-time = "2026-03-22T15:56:24.024Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0c/8aa6b766059ae4a0ca1ec3ff96fe3823a69a7be880dba2e249f7fbe2700b/cbor2-5.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a54fbb32cb828c214f7f333a707e4aec61182e7efdc06ea5d9596d3ecee624a", size = 288009, upload-time = "2026-03-22T15:56:25.305Z" },
+ { url = "https://files.pythonhosted.org/packages/74/07/6236bc25c183a9cf7e8062e5dddf9eae9b0b14ebf14a58a69fe5a1e872c6/cbor2-5.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4753a6d1bc71054d9179557bc65740860f185095ccb401d46637fff028a5b3ec", size = 280437, upload-time = "2026-03-22T15:56:26.479Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/0a/84328d23c3c68874ac6497edb9b1900579a1028efa54734df3f1762bbc15/cbor2-5.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:380e534482b843e43442b87d8777a7bf9bed20cb7526f89b780c3400f617304b", size = 282247, upload-time = "2026-03-22T15:56:28.644Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/f6/89b4627e09d028c8e5fcaf7cb55f225c33ce6e037ec1844e65d02bcfa945/cbor2-5.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:dcf0f695873e5c94bd072d6af8698e72b8fb7f7a18f37e0bced1041b7111a6cf", size = 70089, upload-time = "2026-03-22T15:56:29.801Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/7c/efadcd5f0102db692490e4e206988a2f98d39a09912090db497a2b800885/cbor2-5.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:f7c9751a9611601ab326d8f5837f01379195bbf06175fb4effeb552140e7c9e8", size = 65466, upload-time = "2026-03-22T15:56:30.823Z" },
+ { url = "https://files.pythonhosted.org/packages/08/7d/9ccc36d10ef96e6038e48046ebe1ce35a1e7814da0e1e204d09e6ef09b8d/cbor2-5.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23606d31ba1368bd1b6602e3020ee88fe9523ca80e8630faf6b2fc904fd84560", size = 71500, upload-time = "2026-03-22T15:56:31.876Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e1/a6cca2cc72e13f00030c6a649f57ae703eb2c620806ab70c40db8eab33fa/cbor2-5.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0322296b9d52f55880e300ba8ba09ecf644303b99b51138bbb1c0fb644fa7c3e", size = 286953, upload-time = "2026-03-22T15:56:33.292Z" },
+ { url = "https://files.pythonhosted.org/packages/08/3c/24cd5ef488a957d90e016f200a3aad820e4c2f85edd61c9fe4523007a1ee/cbor2-5.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:422817286c1d0ce947fb2f7eca9212b39bddd7231e8b452e2d2cc52f15332dba", size = 285454, upload-time = "2026-03-22T15:56:34.703Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/35/dca96818494c0ba47cdd73e8d809b27fa91f8fa0ce32a068a09237687454/cbor2-5.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9a4907e0c3035bb8836116854ed8e56d8aef23909d601fa59706320897ec2551", size = 279441, upload-time = "2026-03-22T15:56:35.888Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/44/d3362378b16e53cf7e535a3f5aed8476e2109068154e24e31981ef5bde9e/cbor2-5.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fb7afe77f8d269e42d7c4b515c6fd14f1ccc0625379fb6829b269f493d16eddd", size = 279673, upload-time = "2026-03-22T15:56:37.08Z" },
+ { url = "https://files.pythonhosted.org/packages/43/d1/3533a697e5842fff7c2f64912eb251f8dcab3a8b5d88e228d6eebc3b5021/cbor2-5.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:86baf870d4c0bfc6f79de3801f3860a84ab76d9c8b0abb7f081f2c14c38d79d3", size = 71940, upload-time = "2026-03-22T15:56:38.366Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/e2/c6ba75f3fb25dfa15ab6999cc8709c821987e9ed8e375d7f58539261bcb9/cbor2-5.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:7221483fad0c63afa4244624d552abf89d7dfdbc5f5edfc56fc1ff2b4b818975", size = 67639, upload-time = "2026-03-22T15:56:39.39Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ff/b83492b096fbef26e9cb62c1a4bf2d3cef579ea7b33138c6c37c4ae66f67/cbor2-5.9.0-py3-none-any.whl", hash = "sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b", size = 24627, upload-time = "2026-03-22T15:56:48.847Z" },
]
[[package]]
@@ -537,14 +588,14 @@ wheels = [
[[package]]
name = "click"
-version = "8.2.1"
+version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
@@ -729,14 +780,14 @@ wheels = [
[[package]]
name = "ecdsa"
-version = "0.19.1"
+version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
+ { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]]
@@ -1224,11 +1275,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.10"
+version = "3.15"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
@@ -1403,14 +1454,14 @@ wheels = [
[[package]]
name = "mako"
-version = "1.3.10"
+version = "1.3.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
]
[[package]]
@@ -1475,14 +1526,14 @@ wheels = [
[[package]]
name = "marshmallow"
-version = "3.26.1"
+version = "3.26.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825, upload-time = "2025-02-03T15:32:25.093Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" },
]
[[package]]
@@ -1699,89 +1750,87 @@ wheels = [
[[package]]
name = "pillow"
-version = "12.0.0"
+version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
- { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
- { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
- { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
- { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
- { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
- { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
- { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
- { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
- { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
- { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
- { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
- { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
- { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
- { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
- { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
- { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
- { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
- { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
- { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
- { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
- { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
- { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
- { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
- { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
- { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
- { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
- { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
- { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
- { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
- { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
- { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
- { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
- { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
- { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
- { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
- { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
- { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
- { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
- { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
- { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
- { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
- { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
- { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
- { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
- { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
- { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
- { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
- { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
- { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
- { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
- { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
- { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
- { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
- { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
- { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
- { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
- { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
- { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
- { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
- { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
- { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
- { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
- { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
- { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
- { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
- { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
- { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
- { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
- { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
- { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
- { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
- { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
- { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
- { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
- { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
- { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
- { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
- { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
+ { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
+ { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
+ { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
+ { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
+ { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
+ { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
+ { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
+ { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
+ { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
+ { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
+ { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
+ { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
+ { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
+ { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
+ { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
+ { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
+ { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
+ { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
+ { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
+ { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
+ { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
+ { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
+ { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
+ { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
+ { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
+ { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
+ { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
+ { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
]
[[package]]
@@ -1868,16 +1917,17 @@ wheels = [
[[package]]
name = "protobuf"
-version = "6.31.1"
+version = "6.33.5"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797, upload-time = "2025-05-28T19:25:54.947Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603, upload-time = "2025-05-28T19:25:41.198Z" },
- { url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283, upload-time = "2025-05-28T19:25:44.275Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604, upload-time = "2025-05-28T19:25:45.702Z" },
- { url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115, upload-time = "2025-05-28T19:25:47.128Z" },
- { url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070, upload-time = "2025-05-28T19:25:50.036Z" },
- { url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload-time = "2025-05-28T19:25:53.926Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
+ { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
+ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
]
[[package]]
@@ -2062,39 +2112,39 @@ wheels = [
[[package]]
name = "pydantic-settings"
-version = "2.14.0"
+version = "2.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" },
+ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
]
[[package]]
name = "pygments"
-version = "2.19.2"
+version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyjwt"
-version = "2.10.1"
+version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[[package]]
name = "pytest"
-version = "8.4.1"
+version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2103,21 +2153,22 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
-version = "1.1.0"
+version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
@@ -2149,11 +2200,11 @@ wheels = [
[[package]]
name = "python-dotenv"
-version = "1.1.1"
+version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
@@ -2167,11 +2218,11 @@ wheels = [
[[package]]
name = "python-multipart"
-version = "0.0.20"
+version = "0.0.31"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" },
]
[[package]]
@@ -2910,11 +2961,11 @@ wheels = [
[[package]]
name = "urllib3"
-version = "2.6.3"
+version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
From ba9cecc91683741c9c8ea7f70a2b6c7ea4c2c661 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Wed, 26 Aug 2026 09:59:16 +0200
Subject: [PATCH 042/120] fix: harden melt sizing, mint cooldowns, PPQ reads,
and streaming finalization
---
.env.example | 2 +
docs/adr/001-cashu-payment-safety.md | 21 ++
routstr/auth.py | 31 ++-
routstr/core/exceptions.py | 16 +-
routstr/mint.py | 25 +-
routstr/payment/lnurl.py | 129 +++++++---
routstr/upstream/auto_topup.py | 243 ++++++++++++------
routstr/upstream/base.py | 161 ++++++++----
routstr/upstream/ppqai.py | 113 +++++++-
routstr/wallet.py | 42 ++-
tests/integration/conftest.py | 11 +-
.../integration/test_ppq_auto_topup_claim.py | 38 ++-
.../test_routstr_auto_topup_claim.py | 33 ++-
.../integration/test_wallet_authentication.py | 38 ++-
tests/integration/test_wallet_melt_restart.py | 112 +++++++-
tests/unit/test_auto_topup.py | 87 ++++++-
tests/unit/test_core_exceptions.py | 12 +-
tests/unit/test_lightning_settlement.py | 10 +-
.../unit/test_lnurl_amount_and_destination.py | 80 +++++-
tests/unit/test_lnurl_melt_timeout.py | 101 +++++++-
tests/unit/test_mint.py | 29 +++
tests/unit/test_ppq_resilience.py | 103 ++++++++
.../test_streaming_billing_finalization.py | 152 ++++++++++-
tests/unit/test_wallet.py | 60 +++++
24 files changed, 1391 insertions(+), 258 deletions(-)
create mode 100644 docs/adr/001-cashu-payment-safety.md
create mode 100644 tests/unit/test_ppq_resilience.py
diff --git a/.env.example b/.env.example
index 35a171ba..5ad7a74d 100644
--- a/.env.example
+++ b/.env.example
@@ -33,6 +33,8 @@ ROUTSTR_SECRET_KEY=
# DATABASE_POOL_PRE_PING=false
# Warn when a checkout is held this many seconds.
# DATABASE_POOL_HOLD_WARN_SECONDS=10
+# Seconds a file-backed SQLite writer waits for the write lock (default: 30).
+# DATABASE_BUSY_TIMEOUT=30
# SQLite serialises writes; increasing its pool can trade pool timeouts for
# "database is locked" errors rather than increasing write throughput.
diff --git a/docs/adr/001-cashu-payment-safety.md b/docs/adr/001-cashu-payment-safety.md
new file mode 100644
index 00000000..a2ed627f
--- /dev/null
+++ b/docs/adr/001-cashu-payment-safety.md
@@ -0,0 +1,21 @@
+# ADR-001: Cashu payment safety boundaries
+
+## Status
+
+Accepted
+
+## Context
+
+Cashu proofs are bearer instruments. Retrying quote creation, refund delivery, or a dispatched Lightning melt can duplicate side effects or spend proofs whose outcome is still unknown. Mint transport failures and concurrent workers also need one shared policy.
+
+## Decision
+
+- Treat account, invoice, quote, melt, token-delivery, and refund creation as non-idempotent unless an upstream idempotency key is available.
+- A dispatched melt with an unknown outcome keeps a durable quote-linked proof reservation until later reconciliation confirms a terminal state. An immediate `unpaid` observation after transport loss is not terminal.
+- Size melts from the quote amount, reserve, and exact proof input fees within the caller's gross budget; do not use recursive send selection for melt planning.
+- Apply mint transport/rate cooldowns centrally and permit only explicit reconciliation probes during cooldown.
+- Auto-topups require fresh threshold confirmation, durable per-provider claims/cooldown, atomic spend-cap checks, and owner-only funds.
+
+## Consequences
+
+Transient failures can delay payouts/topups rather than risk duplicate payment. Operators may need to reconcile ambiguous claims. Tests must cover restart, concurrency, and partial-stream failures at these boundaries.
diff --git a/routstr/auth.py b/routstr/auth.py
index 0dfa38e4..7b808007 100644
--- a/routstr/auth.py
+++ b/routstr/auth.py
@@ -510,11 +510,25 @@ async def _validate_bearer_key_locked(
"AUTH: credit_balance returned successfully", extra={"msats": msats}
)
except Exception as credit_error:
- logger.error(
+ classification = classify_redemption_error(credit_error)
+ expected_codes = {
+ "cashu_token_already_spent",
+ "cashu_source_mint_unreachable",
+ "cashu_mint_unreachable",
+ "cashu_mint_rate_limited",
+ }
+ log = (
+ logger.info
+ if classification is not None
+ and classification[3] in expected_codes
+ else logger.error
+ )
+ log(
"AUTH: credit_balance failed",
extra={
"error": str(credit_error),
"error_type": type(credit_error).__name__,
+ "error_code": classification[3] if classification else None,
},
)
await session.rollback()
@@ -756,13 +770,19 @@ async def pay_for_request(
result = await session.exec(stmt) # type: ignore[call-overload]
if result.rowcount == 0:
- logger.error(
- "Concurrent request depleted balance",
+ await session.refresh(billing_key)
+ total_balance = billing_key.balance
+ reserved_balance = billing_key.reserved_balance
+ available_balance = max(0, total_balance - reserved_balance)
+ logger.warning(
+ "Concurrent request depleted available balance",
extra={
"key_hash": key.hashed_key[:8] + "...",
"billing_key_hash": billing_key.hashed_key[:8] + "...",
"required_cost": cost_per_request,
- "current_balance": billing_key.balance,
+ "total_balance": total_balance,
+ "reserved_balance": reserved_balance,
+ "available_balance": available_balance,
},
)
@@ -770,9 +790,10 @@ async def pay_for_request(
status_code=402,
detail={
"error": {
- "message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.balance} available.",
+ "message": f"Insufficient balance: {cost_per_request} mSats required. {available_balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance",
+ "available_balance": available_balance,
}
},
)
diff --git a/routstr/core/exceptions.py b/routstr/core/exceptions.py
index 9e4f2ae6..7e319a94 100644
--- a/routstr/core/exceptions.py
+++ b/routstr/core/exceptions.py
@@ -40,15 +40,27 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
path = request.url.path
# 4xx is client behaviour; the uvicorn access log already records it.
- # Only 5xx warrants a server-side warning/error log here.
+ # Retryable mint outages are expected dependency failures, not application
+ # faults, so keep them visible without flooding the error stream.
if status_code >= 500:
- logger.error(
+ error_type = None
+ if isinstance(detail, dict):
+ error = detail.get("error")
+ if isinstance(error, dict):
+ error_type = error.get("type")
+ log = (
+ logger.warning
+ if error_type in {"mint_unreachable", "mint_rate_limited"}
+ else logger.error
+ )
+ log(
f"HTTP {status_code} on {path}: {detail}",
extra={
"request_id": request_id,
"status_code": status_code,
"detail": detail,
"path": path,
+ "error_type": error_type,
},
)
diff --git a/routstr/mint.py b/routstr/mint.py
index 64632b9e..8948bd4f 100644
--- a/routstr/mint.py
+++ b/routstr/mint.py
@@ -177,6 +177,8 @@ class MintRateGuard:
if isinstance(error, httpx.HTTPStatusError):
retry_after = parse_retry_after(error.response.headers)
self.apply_rate_limit_cooldown(retry_after)
+ elif is_mint_transport_error(error):
+ self.apply_cooldown(MINT_TRANSPORT_COOLDOWN_SECONDS, reason="transport")
else:
self.apply_cooldown(1.0)
logger.warning(
@@ -236,6 +238,18 @@ def mint_cooldown_reason(mint_url: str) -> str | None:
return MintRateGuard.get(mint_url).cooldown_reason()
+def is_mint_transport_error(error: BaseException) -> bool:
+ """Return whether an exception chain contains a mint transport failure."""
+ current: BaseException | None = error
+ seen: set[int] = set()
+ while current is not None and id(current) not in seen:
+ seen.add(id(current))
+ if isinstance(current, MINT_TRANSPORT_EXCEPTIONS):
+ return True
+ current = current.__cause__ or current.__context__
+ return False
+
+
def is_mint_rate_limited(error: BaseException) -> bool:
"""Return whether an exception chain represents HTTP 429/cooldown."""
@@ -269,6 +283,7 @@ async def run_mint_operation(
mint_url: str = "",
retry_timeouts: bool = True,
retry_on_rate_limit: bool = True,
+ allow_during_cooldown: bool = False,
) -> Any:
"""Run one mint operation with bounded concurrency and adaptive cooldown."""
@@ -282,7 +297,7 @@ async def run_mint_operation(
return await factory()
async def invoke() -> Any:
- if guard is not None:
+ if guard is not None and not allow_during_cooldown:
return await guard.run(timed_factory)
return await timed_factory()
@@ -292,6 +307,10 @@ async def run_mint_operation(
except MintCooldownError:
raise
except (asyncio.TimeoutError, httpx.TimeoutException) as exc:
+ if guard is not None:
+ guard.apply_cooldown(
+ MINT_TRANSPORT_COOLDOWN_SECONDS, reason="transport"
+ )
if retry_timeouts and attempt < max_attempts - 1:
backoff = (2**attempt) + (time.monotonic() % 1.0)
logger.warning(
@@ -310,6 +329,10 @@ async def run_mint_operation(
) from exc
except Exception as exc:
if not is_mint_rate_limited(exc):
+ if guard is not None and is_mint_transport_error(exc):
+ guard.apply_cooldown(
+ MINT_TRANSPORT_COOLDOWN_SECONDS, reason="transport"
+ )
raise
backoff = (2**attempt) + (time.monotonic() % 1.0)
diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py
index 814f83fa..3e391f8b 100644
--- a/routstr/payment/lnurl.py
+++ b/routstr/payment/lnurl.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import ipaddress
-import math
from collections.abc import Awaitable, Callable
from typing import Any, TypedDict
@@ -238,6 +237,35 @@ async def get_lnurl_invoice(
return invoice_data["pr"], invoice_data
+def _select_melt_proofs(
+ wallet: Wallet,
+ proofs: list[Proof],
+ *,
+ quote_amount: int,
+ fee_reserve: int,
+ gross_budget: int,
+) -> tuple[list[Proof] | None, int]:
+ """Select proofs that cover the quote and exact NUT-02 input fees.
+
+ Cashu 0.20's ``select_to_send`` may recursively swap when asked to spend a
+ wallet's full balance. Melts accept overpayment and return change, so a
+ bounded, largest-first selection is both safer and minimizes input fees.
+ """
+ selected: list[Proof] = []
+ selected_amount = 0
+ required = quote_amount + fee_reserve
+ for proof in sorted(proofs, key=lambda item: item.amount, reverse=True):
+ if getattr(proof, "reserved", False) is True:
+ continue
+ selected.append(proof)
+ selected_amount += proof.amount
+ input_fees = int(wallet.get_fees_for_proofs(selected))
+ required = quote_amount + fee_reserve + input_fees
+ if required <= gross_budget and selected_amount >= required:
+ return selected, 0
+ return None, max(1, required - min(selected_amount, gross_budget))
+
+
async def raw_send_to_lnurl(
wallet: Wallet,
proofs: list[Proof],
@@ -293,38 +321,55 @@ async def raw_send_to_lnurl(
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
)
- estimated_fees_sat = int(max(math.ceil((amount_msat / 1000) * 0.01), 2)) + 1
- estimated_fees_msat = estimated_fees_sat * 1000
- final_amount = amount_msat - estimated_fees_msat
+ # Start at the caller's gross budget and converge downward from the mint's
+ # exact reserve plus NUT-02 input fees. Starting below the budget with a
+ # percentage heuristic silently underpays even when the exact fees are tiny.
+ final_amount = amount_msat
- bolt11_invoice, _ = await get_lnurl_invoice(
- lnurl_data["callback_url"], final_amount
- )
-
- melt_quote_resp = await run_mint_operation(
- lambda: wallet.melt_quote(invoice=bolt11_invoice),
- op_name="lnurl_melt_quote",
- mint_url=str(wallet.url),
- # Creating another quote after response loss only abandons the first.
- retry_timeouts=False,
- )
-
- # The invoice comes from the LNURL service, so its amount is untrusted. The
- # melt quote is the mint's own reading of it, and it must match what we
- # asked to send. Checked before the checkpoint and before reserving, so a
- # mismatch leaves no durable state and no locked proofs behind.
- quoted_amount = int(melt_quote_resp.amount)
- expected_amount = final_amount // 1000 if unit == "sat" else final_amount
- if quoted_amount != expected_amount:
- raise LNURLError(
- f"LNURL invoice amount does not match the requested amount "
- f"(quoted {quoted_amount} {unit}, expected {expected_amount} {unit})"
+ selected_proofs: list[Proof] | None = None
+ # Fee reserves can change with the invoice amount. Each quote reduces the
+ # candidate by its exact shortfall, so this bounded fixed-point search keeps
+ # the largest amount the gross budget can fund without Cashu coin selection.
+ for _ in range(8):
+ if final_amount < lnurl_data["min_sendable"]:
+ raise LNURLError("Cashu melt fees leave no payable LNURL amount")
+ bolt11_invoice, _ = await get_lnurl_invoice(
+ lnurl_data["callback_url"], final_amount
)
+ melt_quote_resp = await run_mint_operation(
+ lambda: wallet.melt_quote(invoice=bolt11_invoice),
+ op_name="lnurl_melt_quote",
+ mint_url=str(wallet.url),
+ # Creating another quote after response loss only abandons the first.
+ retry_timeouts=False,
+ )
+
+ quoted_amount = int(melt_quote_resp.amount)
+ expected_amount = final_amount // 1000 if unit == "sat" else final_amount
+ if quoted_amount != expected_amount:
+ raise LNURLError(
+ f"LNURL invoice amount does not match the requested amount "
+ f"(quoted {quoted_amount} {unit}, expected {expected_amount} {unit})"
+ )
+
+ selected_proofs, shortfall = _select_melt_proofs(
+ wallet,
+ proofs,
+ quote_amount=quoted_amount,
+ fee_reserve=int(melt_quote_resp.fee_reserve),
+ gross_budget=amount,
+ )
+ if selected_proofs is not None:
+ break
+ final_amount -= shortfall * (1000 if unit == "sat" else 1)
+ else:
+ raise LNURLError("Cashu melt fees exceed the requested gross amount")
if on_melt_quote is not None:
await on_melt_quote(melt_quote_resp.quote)
- proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
+ proofs = selected_proofs
+ await wallet.set_reserved_for_send(proofs, reserved=True)
try:
melt_response = await run_mint_operation(
@@ -365,8 +410,14 @@ async def raw_send_to_lnurl(
else:
melt_error = None
- if getattr(melt_response, "state", None) == MeltQuoteState.paid:
+ melt_state = getattr(melt_response, "state", None)
+ if melt_state == MeltQuoteState.paid:
return final_amount
+ if melt_state == MeltQuoteState.unpaid:
+ # A direct unpaid response is authoritative: the mint rejected the melt
+ # and Cashu has already cleared its quote-linked reservation.
+ await wallet.set_reserved_for_send(proofs, reserved=False)
+ raise LNURLError("Cashu mint confirmed that the melt was unpaid")
try:
quote = await run_mint_operation(
@@ -374,6 +425,9 @@ async def raw_send_to_lnurl(
op_name="reconcile_lnurl_melt_quote",
mint_url=str(wallet.url),
retry_timeouts=False,
+ # One direct state lookup is required to reconcile the just-dispatched
+ # melt even though its transport failure opened the mint cooldown.
+ allow_during_cooldown=True,
)
except Exception as reconciliation_error:
raise MeltOutcomeAmbiguousError(
@@ -384,9 +438,22 @@ async def raw_send_to_lnurl(
if quote is not None and quote.state == MeltQuoteState.paid:
return final_amount
if quote is not None and quote.state == MeltQuoteState.unpaid:
- # get_melt_quote() has authoritatively released the melt reservation;
- # callers may restore their debit and retry with a new payment plan.
- raise LNURLError("Cashu mint confirmed that the melt was unpaid") from melt_error
+ # Reaching reconciliation means melt was dispatched and either lost its
+ # response or returned pending. A just-dispatched quote can briefly read
+ # UNPAID before transitioning. Cashu clears the reservation while
+ # refreshing that state, so restore it and require later reconciliation.
+ try:
+ await wallet.set_reserved_for_melt(
+ proofs, reserved=True, quote_id=melt_quote_resp.quote
+ )
+ except Exception as reservation_error:
+ raise MeltOutcomeAmbiguousError(
+ "Melt outcome is ambiguous and its proof reservation could not "
+ "be restored; proofs must not be retried"
+ ) from reservation_error
+ raise MeltOutcomeAmbiguousError(
+ "Melt outcome is ambiguous; an immediate unpaid state is not final"
+ ) from melt_error
state = getattr(getattr(quote, "state", None), "value", "unknown")
raise MeltOutcomeAmbiguousError(
diff --git a/routstr/upstream/auto_topup.py b/routstr/upstream/auto_topup.py
index b4325e24..64d7d380 100644
--- a/routstr/upstream/auto_topup.py
+++ b/routstr/upstream/auto_topup.py
@@ -15,9 +15,6 @@ from ..core.db import (
UpstreamProviderRow,
create_session,
)
-from ..core.db import (
- store_cashu_transaction_with_retry as store_cashu_transaction,
-)
from ..payment.price import sats_usd_price
from ..wallet import (
Bolt11PaymentAmbiguous,
@@ -27,7 +24,7 @@ from ..wallet import (
maximum_owner_cashu_balance_sats,
prepare_bolt11_payment,
release_token_reservation,
- send_token,
+ send_token_from_owner_locked,
token_mint_url,
wallet_operation_guard,
)
@@ -49,6 +46,7 @@ PPQ_PHASES = frozenset({PPQ_PHASE_CLAIMED, PPQ_PHASE_IN_FLIGHT, PPQ_PHASE_RECONC
PPQ_SETTLEMENT_ATTEMPTS = 5
PPQ_SETTLEMENT_POLL_SECONDS = 2
PPQ_PENDING_TTL_SECONDS = 15 * 60
+PPQ_SETTLED_COOLDOWN_SECONDS = 5 * 60
PPQ_MAX_INVOICE_PREMIUM = 1.10
PPQ_MIN_TOPUP_USD = 1
PPQ_MAX_TOPUP_USD = 500
@@ -99,7 +97,7 @@ async def periodic_auto_topup() -> None:
except Exception as e:
logger.error(
"Auto top-up cycle failed",
- extra={"error": str(e), "error_type": type(e).__name__},
+ extra={"error": repr(e), "error_type": type(e).__name__},
)
await asyncio.sleep(AUTO_TOPUP_INTERVAL_SECONDS)
@@ -130,7 +128,7 @@ async def _run_auto_topup_cycle() -> None:
extra={
"provider_id": row.id,
"base_url": row.base_url,
- "error": str(e),
+ "error": repr(e),
"error_type": type(e).__name__,
},
)
@@ -161,7 +159,11 @@ async def _reconcile_all_ppq_claims() -> set[int]:
except Exception as e:
logger.error(
"PPQ claim reconciliation failed",
- extra={"provider_id": row.id, "error": str(e)},
+ extra={
+ "provider_id": row.id,
+ "error": repr(e),
+ "error_type": type(e).__name__,
+ },
)
return active_provider_ids
@@ -363,67 +365,64 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
)
try:
- token = await send_token(amount, "sat", mint_url)
+ async with wallet_operation_guard():
+ # The cap, owner-liability check, proof reservation, and outgoing
+ # audit row share one wallet mutation scope. The audit row must be
+ # durable before another worker can recheck the rolling cap.
+ spent_24h_sats = await _routstr_spent_last_24h_sats()
+ if spent_24h_sats + amount > ROUTSTR_MAX_DAILY_TOPUP_SATS:
+ raise ValueError("Routstr auto top-up daily spend cap reached")
+ token = await send_token_from_owner_locked(amount, "sat", mint_url)
+ actual_mint_url = token_mint_url(token, mint_url)
+ try:
+ await _persist_routstr_token_and_mark_sent(
+ row,
+ operation_id,
+ expected_sats=expected_sats,
+ token=token,
+ amount=amount,
+ mint_url=actual_mint_url,
+ )
+ except Exception:
+ logger.critical(
+ "Aborting auto top-up because its token and sent claim "
+ "could not be persisted atomically",
+ extra={"provider_id": row.id, "mint_url": actual_mint_url},
+ )
+ try:
+ await release_token_reservation(token)
+ except Exception as error:
+ logger.critical(
+ "Failed to release untracked auto-topup token",
+ extra={
+ "provider_id": row.id,
+ "mint_url": actual_mint_url,
+ "error": repr(error),
+ },
+ )
+ else:
+ logger.warning(
+ "Auto-topup token was released after persistence failed",
+ extra={"provider_id": row.id, "mint_url": actual_mint_url},
+ )
+ raise
except Exception as e:
- logger.error(
- "Failed to create cashu token for auto top-up",
+ logger.warning(
+ "Failed to create or persist cashu token for auto top-up",
extra={
"provider_id": row.id,
"amount": amount,
"mint_url": mint_url,
- "error": str(e),
+ "error": repr(e),
+ "error_type": type(e).__name__,
},
)
await _release_routstr_claim(row, operation_id)
return
- actual_mint_url = token_mint_url(token, mint_url)
- try:
- await store_cashu_transaction(
- token=token,
- amount=amount,
- unit="sat",
- mint_url=actual_mint_url,
- typ="out",
- collected=False,
- source="auto_topup",
- )
- except Exception:
- logger.critical(
- "Aborting auto top-up because its cashu token could not be persisted",
- extra={"provider_id": row.id, "mint_url": actual_mint_url},
- )
- try:
- await release_token_reservation(token)
- except Exception as error:
- logger.critical(
- "Failed to release untracked auto-topup token",
- extra={
- "provider_id": row.id,
- "mint_url": actual_mint_url,
- "error": str(error),
- },
- )
- else:
- logger.warning(
- "Auto-topup token was released after persistence failed",
- extra={"provider_id": row.id, "mint_url": actual_mint_url},
- )
- await _release_routstr_claim(row, operation_id)
- return
-
- # Move the claim before the network call, not after: a worker that dies
- # mid-request must leave behind a claim that says a token may already be
- # with the peer.
- await _mark_routstr_sent(
- row,
- operation_id,
- expected_sats=expected_sats,
- token=token,
- amount=amount,
- mint_url=actual_mint_url,
- )
-
+ # The audit row and SENT claim committed together before this network call,
+ # so a worker crash cannot make reconciliation treat reserved proofs as an
+ # unspent CLAIMED attempt.
result = await provider.topup(token)
if "error" in result:
@@ -705,7 +704,7 @@ async def _release_routstr_claim(row: UpstreamProviderRow, operation_id: str) ->
)
-async def _mark_routstr_sent(
+async def _persist_routstr_token_and_mark_sent(
row: UpstreamProviderRow,
operation_id: str,
*,
@@ -714,20 +713,60 @@ async def _mark_routstr_sent(
amount: int,
mint_url: str,
) -> None:
- claim = await _current_routstr_claim(row)
- failures = claim.failures if claim else 0
- if not await _advance_routstr_claim(
- row,
- operation_id,
- deadline=int(time.time()) + ROUTSTR_PENDING_TTL_SECONDS,
- phase=ROUTSTR_PHASE_SENT,
- expected_sats=expected_sats,
- failures=failures,
- token=token,
- amount=amount,
- mint_url=mint_url,
- ):
- raise RuntimeError("Routstr auto top-up claim ownership was lost")
+ """Commit the bearer-token audit row and SENT claim atomically."""
+ state_id = _routstr_state_id(row)
+ async with create_session() as session:
+ state = await session.get(CashuTransaction, state_id)
+ claim = _parse_routstr_request_id(state.request_id if state else None)
+ if (
+ state is None
+ or state.collected
+ or state.swept
+ or claim is None
+ or claim.operation_id != operation_id
+ or claim.phase != ROUTSTR_PHASE_CLAIMED
+ ):
+ raise RuntimeError("Routstr auto top-up claim ownership was lost")
+
+ result = await session.exec( # type: ignore[call-overload]
+ update(CashuTransaction)
+ .where(
+ col(CashuTransaction.id) == state_id,
+ col(CashuTransaction.request_id) == state.request_id,
+ col(CashuTransaction.collected) == False, # noqa: E712
+ col(CashuTransaction.swept) == False, # noqa: E712
+ )
+ .values(
+ request_id=_routstr_request_id(
+ operation_id,
+ int(time.time()) + ROUTSTR_PENDING_TTL_SECONDS,
+ ROUTSTR_PHASE_SENT,
+ expected_sats,
+ claim.failures,
+ ),
+ token=token,
+ amount=amount,
+ unit="sat",
+ mint_url=mint_url,
+ )
+ )
+ if (getattr(result, "rowcount", 0) or 0) != 1:
+ await session.rollback()
+ raise RuntimeError("Routstr auto top-up claim ownership was lost")
+
+ session.add(
+ CashuTransaction(
+ id=uuid.uuid4().hex,
+ token=token,
+ amount=amount,
+ unit="sat",
+ mint_url=mint_url,
+ type="out",
+ collected=False,
+ source="auto_topup",
+ )
+ )
+ await session.commit()
async def _current_routstr_claim(row: UpstreamProviderRow) -> RoutstrClaim | None:
@@ -1081,7 +1120,13 @@ async def _set_ppq_state_terminal(
col(CashuTransaction.collected) == False, # noqa: E712
col(CashuTransaction.swept) == False, # noqa: E712
)
- .values(collected=collected, swept=swept)
+ .values(
+ collected=collected,
+ swept=swept,
+ # For successful payments this timestamps the durable cooldown,
+ # not merely when the original claim was created.
+ created_at=int(time.time()) if collected else CashuTransaction.created_at,
+ )
)
updated = (getattr(result, "rowcount", 0) or 0) == 1
if updated:
@@ -1106,8 +1151,10 @@ async def _reconcile_ppq_state(
"""
async with create_session() as session:
transaction = await session.get(CashuTransaction, _ppq_state_id(row))
- if transaction is None or transaction.collected or transaction.swept:
+ if transaction is None or transaction.swept:
return False
+ if transaction.collected:
+ return int(time.time()) - transaction.created_at < PPQ_SETTLED_COOLDOWN_SECONDS
claim = _parse_ppq_request_id(transaction.request_id)
if claim is None:
@@ -1173,7 +1220,7 @@ async def _reconcile_ppq_state(
async def _ppq_provider_is_claimable(
- session: AsyncSession, provider_id: int | None
+ session: AsyncSession, row: UpstreamProviderRow
) -> bool:
"""Re-read the provider inside the claim transaction.
@@ -1184,10 +1231,16 @@ async def _ppq_provider_is_claimable(
this the worker could create a claim for a provider that no longer
exists, orphaning it forever.
"""
- if provider_id is None:
+ if row.id is None:
return False
- current = await session.get(UpstreamProviderRow, provider_id)
- return current is not None and current.provider_type == "ppqai"
+ current = await session.get(UpstreamProviderRow, row.id)
+ return bool(
+ current is not None
+ and current.enabled
+ and current.provider_type == "ppqai"
+ and current.api_key == row.api_key
+ and current.provider_settings == row.provider_settings
+ )
async def _claim_ppq_topup(row: UpstreamProviderRow) -> str | None:
@@ -1198,10 +1251,17 @@ async def _claim_ppq_topup(row: UpstreamProviderRow) -> str | None:
request_id = _ppq_request_id(operation_id, expires_at, PPQ_PHASE_CLAIMED, "pending")
async with create_session() as session:
- if not await _ppq_provider_is_claimable(session, row.id):
+ if not await _ppq_provider_is_claimable(session, row):
return None
existing = await session.get(CashuTransaction, state_id)
if existing is not None:
+ if (
+ existing.collected
+ and not existing.swept
+ and int(time.time()) - existing.created_at
+ < PPQ_SETTLED_COOLDOWN_SECONDS
+ ):
+ return None
result = await session.exec( # type: ignore[call-overload]
update(CashuTransaction)
.where(
@@ -1232,7 +1292,7 @@ async def _claim_ppq_topup(row: UpstreamProviderRow) -> str | None:
async with create_session() as session:
# Same fencing as the update path: the provider must still exist
# inside the transaction that creates the claim.
- if not await _ppq_provider_is_claimable(session, row.id):
+ if not await _ppq_provider_is_claimable(session, row):
return None
session.add(
CashuTransaction(
@@ -1453,6 +1513,27 @@ async def _check_and_topup_ppq(row: UpstreamProviderRow, settings: dict) -> None
if balance >= threshold_usd:
return
+ # A single stale/partial balance response must never create an invoice.
+ # Read the uncached endpoint again and require independent agreement.
+ confirmed_balance = await provider.get_balance()
+ if (
+ confirmed_balance is None
+ or not math.isfinite(confirmed_balance)
+ or confirmed_balance < 0
+ or confirmed_balance >= threshold_usd
+ ):
+ logger.info(
+ "PPQ auto top-up aborted by balance confirmation",
+ extra={
+ "provider_id": row.id,
+ "first_balance_usd": balance,
+ "confirmed_balance_usd": confirmed_balance,
+ "threshold_usd": threshold_usd,
+ },
+ )
+ return
+ balance = confirmed_balance
+
# Perform local pricing and owner-funds checks before asking PPQ to create
# an invoice. The exact mint quote still has to be checked afterward, but
# predictable local failures should not leave abandoned PPQ invoices.
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index 595ec670..e0f0a4f9 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+import inspect
import json
import math
import traceback
@@ -70,6 +71,17 @@ if typing.TYPE_CHECKING:
logger = get_logger(__name__)
+async def _aclose_if_needed(resource: object | None) -> None:
+ if resource is None:
+ return
+ close = getattr(resource, "aclose", None)
+ if close is None:
+ return
+ result = close()
+ if inspect.isawaitable(result):
+ await result
+
+
CostMetadata = CostData | MaxCostData | dict[str, Any]
@@ -993,6 +1005,7 @@ class BaseUpstreamProvider:
requested_model: str | None = None,
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
+ client: httpx.AsyncClient | None = None,
) -> StreamingResponse:
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
@@ -1034,23 +1047,42 @@ class BaseUpstreamProvider:
nonlocal usage_finalized
if usage_finalized:
return
- async with create_session() as new_session:
- fresh_key = await new_session.get(key.__class__, key.hashed_key)
- if not fresh_key:
- return
- try:
- await adjust_payment_for_tokens(
- fresh_key,
- {"model": last_model_seen or "unknown", "usage": None},
- new_session,
- max_cost_for_model,
- model_obj,
- self.provider_fee,
- reservation_snapshot,
+ try:
+ async with create_session() as new_session:
+ fresh_key = await new_session.get(
+ key.__class__, key.hashed_key
)
- usage_finalized = True
- except Exception:
- pass
+ if not fresh_key:
+ return
+ try:
+ await adjust_payment_for_tokens(
+ fresh_key,
+ {"model": last_model_seen or "unknown", "usage": None},
+ new_session,
+ max_cost_for_model,
+ model_obj,
+ self.provider_fee,
+ reservation_snapshot,
+ )
+ usage_finalized = True
+ except Exception:
+ logger.exception(
+ "Fallback stream billing finalization failed; releasing reservation",
+ extra={"key_hash": key.hashed_key[:8] + "..."},
+ )
+ usage_finalized = (
+ await self._release_failed_streaming_reservation(
+ fresh_key, new_session, reservation_snapshot
+ )
+ )
+ except Exception:
+ # Preserve the original stream exception. If the database
+ # cannot even be opened/read, stale-reservation cleanup is
+ # the only safe recovery path.
+ logger.exception(
+ "Fallback stream billing recovery could not access the database",
+ extra={"key_hash": key.hashed_key[:8] + "..."},
+ )
def _process_event(
raw_event: bytes, final: bool = False
@@ -1280,18 +1312,23 @@ class BaseUpstreamProvider:
except Exception as stream_error:
logger.warning(
- "Streaming interrupted; finalizing in background",
+ "Streaming interrupted; finalizing before closing upstream",
extra={
"error": str(stream_error),
+ "error_type": type(stream_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
- if not usage_finalized:
- # Create a background task to ensure finalization happens
- # even if the generator is closed early
- background_tasks.add_task(finalize_db_only)
+ try:
+ if not usage_finalized:
+ await finalize_db_only()
+ finally:
+ try:
+ await _aclose_if_needed(response)
+ finally:
+ await _aclose_if_needed(client)
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
@@ -1451,6 +1488,7 @@ class BaseUpstreamProvider:
requested_model: str | None = None,
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
+ client: httpx.AsyncClient | None = None,
) -> StreamingResponse:
"""Handle streaming Responses API responses with token usage tracking and cost adjustment.
@@ -1484,23 +1522,42 @@ class BaseUpstreamProvider:
nonlocal usage_finalized
if usage_finalized:
return
- async with create_session() as new_session:
- fresh_key = await new_session.get(key.__class__, key.hashed_key)
- if not fresh_key:
- return
- try:
- await adjust_payment_for_tokens(
- fresh_key,
- {"model": last_model_seen or "unknown", "usage": None},
- new_session,
- max_cost_for_model,
- model_obj,
- self.provider_fee,
- reservation_snapshot,
+ try:
+ async with create_session() as new_session:
+ fresh_key = await new_session.get(
+ key.__class__, key.hashed_key
)
- usage_finalized = True
- except Exception:
- pass
+ if not fresh_key:
+ return
+ try:
+ await adjust_payment_for_tokens(
+ fresh_key,
+ {"model": last_model_seen or "unknown", "usage": None},
+ new_session,
+ max_cost_for_model,
+ model_obj,
+ self.provider_fee,
+ reservation_snapshot,
+ )
+ usage_finalized = True
+ except Exception:
+ logger.exception(
+ "Fallback Responses billing finalization failed; releasing reservation",
+ extra={"key_hash": key.hashed_key[:8] + "..."},
+ )
+ usage_finalized = (
+ await self._release_failed_streaming_reservation(
+ fresh_key, new_session, reservation_snapshot
+ )
+ )
+ except Exception:
+ # Preserve the original stream exception. If the database
+ # cannot even be opened/read, stale-reservation cleanup is
+ # the only safe recovery path.
+ logger.exception(
+ "Fallback Responses billing recovery could not access the database",
+ extra={"key_hash": key.hashed_key[:8] + "..."},
+ )
def _process_event(
raw_event: bytes, final: bool = False
@@ -1690,16 +1747,23 @@ class BaseUpstreamProvider:
except Exception as stream_error:
logger.warning(
- "Responses API streaming interrupted; finalizing in background",
+ "Responses API streaming interrupted; finalizing before closing upstream",
extra={
"error": str(stream_error),
+ "error_type": type(stream_error).__name__,
"key_hash": key.hashed_key[:8] + "...",
},
)
raise
finally:
- if not usage_finalized:
- await finalize_db_only()
+ try:
+ if not usage_finalized:
+ await finalize_db_only()
+ finally:
+ try:
+ await _aclose_if_needed(response)
+ finally:
+ await _aclose_if_needed(client)
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
@@ -3051,9 +3115,7 @@ class BaseUpstreamProvider:
if is_streaming and response.status_code == 200:
background_tasks = BackgroundTasks()
- background_tasks.add_task(response.aclose)
- background_tasks.add_task(client.aclose)
- result = await self.handle_streaming_chat_completion(
+ return await self.handle_streaming_chat_completion(
response,
key,
max_cost_for_model,
@@ -3061,9 +3123,8 @@ class BaseUpstreamProvider:
requested_model=original_model_id,
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
+ client=client,
)
- result.background = background_tasks
- return result
# Handle both non-streaming chat completions and embeddings
if response.status_code == 200:
@@ -3332,19 +3393,15 @@ class BaseUpstreamProvider:
)
if is_streaming and response.status_code == 200:
- result = await self.handle_streaming_responses_completion(
+ return await self.handle_streaming_responses_completion(
response,
key,
max_cost_for_model,
requested_model=original_model_id,
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
+ client=client,
)
- background_tasks = BackgroundTasks()
- background_tasks.add_task(response.aclose)
- background_tasks.add_task(client.aclose)
- result.background = background_tasks
- return result
if response.status_code == 200:
try:
@@ -5339,7 +5396,7 @@ class BaseUpstreamProvider:
except Exception as e:
logger.error(
f"Failed to refresh models cache for {self.provider_type or self.base_url}",
- extra={"error": str(e), "error_type": type(e).__name__},
+ extra={"error": repr(e), "error_type": type(e).__name__},
)
def get_cached_models(self) -> list[Model]:
diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py
index 50ab4532..d373d8e8 100644
--- a/routstr/upstream/ppqai.py
+++ b/routstr/upstream/ppqai.py
@@ -1,5 +1,9 @@
from __future__ import annotations
+import asyncio
+import random
+import time
+from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
import httpx
@@ -15,6 +19,90 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
+_PPQ_SAFE_READ_ATTEMPTS = 3
+_PPQ_CIRCUIT_COOLDOWN_SECONDS = 30.0
+
+
+class PPQCircuitOpenError(RuntimeError):
+ """PPQ safe reads are suppressed until one probe is allowed."""
+
+
+@dataclass
+class _PPQCircuitState:
+ consecutive_failures: int = 0
+ cooldown_until: float = 0.0
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+ loop: asyncio.AbstractEventLoop | None = None
+
+
+_ppq_circuits: dict[str, _PPQCircuitState] = {}
+
+
+def _ppq_origin(url: str) -> str:
+ parsed = httpx.URL(url)
+ return f"{parsed.scheme}://{parsed.host}:{parsed.port}"
+
+
+async def _safe_read_request(
+ client: httpx.AsyncClient,
+ method: str,
+ url: str,
+ *,
+ headers: dict[str, str],
+ json: dict[str, object] | None = None,
+) -> httpx.Response:
+ """Retry safe reads, then open one process-local circuit per PPQ origin."""
+ state = _ppq_circuits.setdefault(_ppq_origin(url), _PPQCircuitState())
+ loop = asyncio.get_running_loop()
+ if state.loop is not loop:
+ # Runtime uses one long-lived loop; pytest and some embedded hosts do
+ # not. Preserve circuit state while replacing a loop-bound lock.
+ state.lock = asyncio.Lock()
+ state.loop = loop
+ async with state.lock:
+ remaining = state.cooldown_until - time.monotonic()
+ if remaining > 0:
+ raise PPQCircuitOpenError(
+ f"PPQ.AI safe-read circuit is open; retry after {remaining:.2f}s"
+ )
+
+ for attempt in range(1, _PPQ_SAFE_READ_ATTEMPTS + 1):
+ try:
+ response = await client.request(
+ method, url, headers=headers, json=json
+ )
+ response.raise_for_status()
+ state.consecutive_failures = 0
+ state.cooldown_until = 0.0
+ return response
+ except (httpx.TransportError, httpx.HTTPStatusError) as error:
+ retryable_status = isinstance(error, httpx.HTTPStatusError) and (
+ error.response.status_code in {502, 503, 504}
+ )
+ if not isinstance(error, httpx.TransportError) and not retryable_status:
+ raise
+ state.consecutive_failures += 1
+ if attempt >= _PPQ_SAFE_READ_ATTEMPTS:
+ state.cooldown_until = (
+ time.monotonic() + _PPQ_CIRCUIT_COOLDOWN_SECONDS
+ )
+ raise
+ base_delay = 0.25 * (2 ** (attempt - 1))
+ delay = base_delay + random.uniform(0.0, base_delay)
+ logger.warning(
+ "PPQ.AI safe read failed; retrying",
+ extra={
+ "url": url,
+ "attempt": attempt,
+ "max_attempts": _PPQ_SAFE_READ_ATTEMPTS,
+ "backoff_seconds": round(delay, 3),
+ "error": repr(error),
+ "error_type": type(error).__name__,
+ },
+ )
+ await asyncio.sleep(delay)
+ raise RuntimeError("unreachable")
+
class PPQAIModelPricing(BaseModel):
ui: Optional[dict[str, float]] = None
@@ -125,8 +213,9 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
try:
async with httpx.AsyncClient(timeout=30.0) as client:
- response = await client.get(url, headers=headers)
- response.raise_for_status()
+ response = await _safe_read_request(
+ client, "GET", url, headers=headers
+ )
data = response.json()
models_data = data.get("data", [])
@@ -233,12 +322,10 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
return models
- except Exception as e:
- logger.error(
- "Error fetching models from PPQ.AI",
- extra={"error": str(e), "error_type": type(e).__name__},
- )
- return []
+ except Exception:
+ # The base refresh handler preserves the last good model cache when
+ # fetching raises; [] would look like a valid empty catalog.
+ raise
async def on_upstream_error_redirect(
self, status_code: int, error_message: str
@@ -360,8 +447,9 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
)
async with httpx.AsyncClient(timeout=30.0) as client:
- response = await client.get(url, headers=headers)
- response.raise_for_status()
+ response = await _safe_read_request(
+ client, "GET", url, headers=headers
+ )
status_data = response.json()
is_paid = status_data.get("status") == "Settled"
@@ -460,8 +548,9 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
logger.debug("Checking PPQ.AI account balance", extra={"url": url})
async with httpx.AsyncClient(timeout=30.0) as client:
- response = await client.post(url, headers=headers, json={})
- response.raise_for_status()
+ response = await _safe_read_request(
+ client, "POST", url, headers=headers, json={}
+ )
balance_data = response.json()
logger.debug(
diff --git a/routstr/wallet.py b/routstr/wallet.py
index 3814ac42..24072c74 100644
--- a/routstr/wallet.py
+++ b/routstr/wallet.py
@@ -524,7 +524,11 @@ async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int
async def _send_locked(
- amount: int, unit: str, mint_url: str | None = None
+ amount: int,
+ unit: str,
+ mint_url: str | None = None,
+ *,
+ owner_only: bool = False,
) -> tuple[int, str]:
effective_mint_url = await find_trusted_mint_with_funds(
amount, unit, mint_url, force_reload=True
@@ -534,6 +538,12 @@ async def _send_locked(
wallet, effective_mint_url, unit, not_reserved=True
)
proofs_for_mint = sum(proof.amount for proof in proofs)
+ if owner_only:
+ owner_balance = await _owner_balance_for_mint_and_unit(
+ effective_mint_url, unit, proofs_for_mint
+ )
+ if owner_balance < amount:
+ raise ValueError("Owner Cashu balance is insufficient for auto top-up")
all_proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit)
reserved_for_mint = sum(p.amount for p in all_proofs if p.reserved)
@@ -580,6 +590,14 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
return token
+async def send_token_from_owner_locked(
+ amount: int, unit: str, mint_url: str | None = None
+) -> str:
+ """Create an owner-funded token while the caller holds the wallet guard."""
+ _, token = await _send_locked(amount, unit, mint_url, owner_only=True)
+ return token
+
+
class Bolt11PaymentNotAttempted(Exception):
"""The invoice was definitively not paid, so the attempt can be retried.
@@ -1824,9 +1842,25 @@ async def _credit_balance_locked(
)
return amount
except Exception as e:
- logger.error(
- "credit_balance: Error during token redemption",
- extra={"error": str(e), "error_type": type(e).__name__},
+ classification = classify_redemption_error(e)
+ expected_codes = {
+ "cashu_token_already_spent",
+ "cashu_source_mint_unreachable",
+ "cashu_mint_unreachable",
+ "cashu_mint_rate_limited",
+ }
+ log = (
+ logger.info
+ if classification is not None and classification[3] in expected_codes
+ else logger.error
+ )
+ log(
+ "credit_balance: Token redemption failed",
+ extra={
+ "error": str(e),
+ "error_type": type(e).__name__,
+ "error_code": classification[3] if classification else None,
+ },
)
raise
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index aa10a81c..8dcd1876 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -1,7 +1,7 @@
import asyncio
import json
import os
-from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Tuple
+from typing import Any, AsyncGenerator, Callable, Dict, Iterator, List, Optional, Tuple
from unittest.mock import MagicMock, patch
import pytest
@@ -68,6 +68,15 @@ os.environ.pop("ADMIN_PASSWORD", None)
from routstr.core.db import ApiKey, get_session # noqa: E402
from routstr.core.main import app, lifespan # noqa: E402
+from routstr.mint import MintRateGuard # noqa: E402
+
+
+@pytest.fixture(autouse=True)
+def isolate_mint_rate_guards() -> Iterator[None]:
+ """Do not let one integration test's simulated outage poison the next."""
+ MintRateGuard._guards.clear()
+ yield
+ MintRateGuard._guards.clear()
@pytest.fixture(scope="session")
diff --git a/tests/integration/test_ppq_auto_topup_claim.py b/tests/integration/test_ppq_auto_topup_claim.py
index 433389cd..82c33c6f 100644
--- a/tests/integration/test_ppq_auto_topup_claim.py
+++ b/tests/integration/test_ppq_auto_topup_claim.py
@@ -23,6 +23,7 @@ from routstr.upstream.auto_topup import (
_ppq_request_id,
_ppq_spent_last_24h_usd,
_ppq_state_id_for_provider,
+ _reconcile_ppq_state,
_record_ppq_invoice,
_set_ppq_state_terminal,
get_ppq_auto_topup_state,
@@ -35,6 +36,8 @@ pytestmark = pytest.mark.asyncio
def _row(provider_id: int = 1) -> MagicMock:
row = MagicMock()
row.id = provider_id
+ row.api_key = "secret"
+ row.provider_settings = None
return row
@@ -111,12 +114,35 @@ async def test_claim_is_reusable_once_the_previous_attempt_finished(
await _seed_provider()
first = await _claim_ppq_topup(_row())
assert first is not None
- assert await _set_ppq_state_terminal(_row(), first, collected=True, swept=False)
+ assert await _set_ppq_state_terminal(_row(), first, collected=False, swept=True)
second = await _claim_ppq_topup(_row())
assert second is not None and second != first
+async def test_settled_claim_suppresses_immediate_duplicate(
+ patched_db_engine: Any,
+) -> None:
+ await _seed_provider()
+ row = _row()
+ operation_id = await _claim_ppq_topup(row)
+ assert operation_id is not None
+ assert await _set_ppq_state_terminal(row, operation_id, collected=True, swept=False)
+
+ assert await _reconcile_ppq_state(row, provider=None) is True
+ assert await _claim_ppq_topup(row) is None
+
+
+async def test_claim_rejects_stale_provider_configuration(
+ patched_db_engine: Any,
+) -> None:
+ await _seed_provider()
+ stale = _row()
+ stale.provider_settings = '{"auto_topup":true}'
+
+ assert await _claim_ppq_topup(stale) is None
+
+
async def test_recording_the_invoice_moves_the_claim_in_flight(
patched_db_engine: Any,
) -> None:
@@ -287,7 +313,15 @@ async def test_ppq_payment_audit_row_is_visible_and_survives_next_claim(
assert audit["collected"] is True
assert "lnbc-secret-invoice" not in audit["token"]
- # Reusing the deterministic claim lock must not overwrite history.
+ # The durable cooldown blocks an immediate duplicate, then the claim lock
+ # can be reused without overwriting audit history after it expires.
+ assert await _claim_ppq_topup(_row()) is None
+ async with create_session() as session:
+ state = await session.get(CashuTransaction, _ppq_state_id_for_provider(1))
+ assert state is not None
+ state.created_at = int(time.time()) - 301
+ session.add(state)
+ await session.commit()
assert await _claim_ppq_topup(_row()) is not None
async with create_session() as session:
assert await session.get(CashuTransaction, audit["id"]) is not None
diff --git a/tests/integration/test_routstr_auto_topup_claim.py b/tests/integration/test_routstr_auto_topup_claim.py
index 25b827a9..dc246a63 100644
--- a/tests/integration/test_routstr_auto_topup_claim.py
+++ b/tests/integration/test_routstr_auto_topup_claim.py
@@ -25,6 +25,7 @@ from routstr.upstream.auto_topup import (
_check_and_topup,
_claim_routstr_topup,
_parse_routstr_request_id,
+ _persist_routstr_token_and_mark_sent,
_routstr_spent_last_24h_sats,
_routstr_state_id_for_provider,
get_routstr_auto_topup_state,
@@ -75,7 +76,9 @@ def _patch_wallet(module: Any, peer: MagicMock, token: str) -> ExitStack:
patch.object(module.RoutstrUpstreamProvider, "from_db_row", return_value=peer)
)
stack.enter_context(
- patch.object(module, "send_token", AsyncMock(return_value=token))
+ patch.object(
+ module, "send_token_from_owner_locked", AsyncMock(return_value=token)
+ )
)
stack.enter_context(
patch.object(module, "token_mint_url", return_value="https://mint.test")
@@ -111,6 +114,32 @@ async def test_second_worker_cannot_claim_while_the_first_holds_one(
assert await _claim_routstr_topup(row, expected_sats=TOPUP_SATS) is None
+async def test_token_and_sent_claim_roll_back_together_on_commit_failure(
+ patched_db_engine: Any,
+) -> None:
+ row = await _seed_provider()
+ operation_id = await _claim_routstr_topup(row, expected_sats=TOPUP_SATS)
+ assert operation_id is not None
+
+ with patch(
+ "sqlmodel.ext.asyncio.session.AsyncSession.commit",
+ new=AsyncMock(side_effect=RuntimeError("commit failed")),
+ ):
+ with pytest.raises(RuntimeError, match="commit failed"):
+ await _persist_routstr_token_and_mark_sent(
+ row,
+ operation_id,
+ expected_sats=TOPUP_SATS,
+ token="cashu-token-atomic",
+ amount=TOPUP_SATS,
+ mint_url="https://mint.test",
+ )
+
+ claim = _parse_routstr_request_id((await _claim_state()).request_id) # type: ignore[union-attr]
+ assert claim is not None and claim.phase != ROUTSTR_PHASE_SENT
+ assert await _sent_tokens() == []
+
+
async def test_token_is_persisted_before_it_reaches_the_peer(
patched_db_engine: Any,
) -> None:
@@ -142,7 +171,7 @@ async def test_untracked_token_is_returned_and_never_sent(
_patch_wallet(auto_topup_module, peer, "cashu-token-1"),
patch.object(
auto_topup_module,
- "store_cashu_transaction",
+ "_persist_routstr_token_and_mark_sent",
AsyncMock(side_effect=RuntimeError("database unavailable")),
),
patch.object(
diff --git a/tests/integration/test_wallet_authentication.py b/tests/integration/test_wallet_authentication.py
index 9dc19947..2054f0af 100644
--- a/tests/integration/test_wallet_authentication.py
+++ b/tests/integration/test_wallet_authentication.py
@@ -3,6 +3,7 @@ Integration tests for wallet authentication system including API key generation
Tests POST /v1/wallet/topup endpoint and authorization header validation.
"""
+import asyncio
from datetime import datetime, timedelta
from typing import Any
@@ -113,38 +114,35 @@ async def test_api_key_generation_invalid_token(
async def test_duplicate_token_handling(
integration_client: AsyncClient, testmint_wallet: Any, db_snapshot: Any
) -> None:
- """Test that duplicate tokens return the same API key without double-spending"""
+ """Concurrent duplicate redemption credits once and deterministically replays."""
- # Generate a valid token
- amount = 500 # 500 sats
+ amount = 500
token = await testmint_wallet.mint_tokens(amount)
-
- # First use of token
integration_client.headers["Authorization"] = f"Bearer {token}"
- response1 = await integration_client.get("/v1/wallet/info")
- assert response1.status_code == 200
+
+ response1, response2 = await asyncio.gather(
+ integration_client.get("/v1/wallet/info"),
+ integration_client.get("/v1/wallet/info"),
+ )
+ assert response1.status_code < 500
+ assert response2.status_code < 500
+ assert response1.status_code == response2.status_code == 200
api_key1 = response1.json()["api_key"]
- balance1 = response1.json()["balance"]
-
- # Capture state after first submission
- await db_snapshot.capture()
-
- # Second use of same token - should return same API key since it's already created
- response2 = await integration_client.get("/v1/wallet/info")
- assert response2.status_code == 200
api_key2 = response2.json()["api_key"]
+ balance1 = response1.json()["balance"]
balance2 = response2.json()["balance"]
-
- # Should return the same API key and balance
assert api_key1 == api_key2
- assert balance1 == balance2
+ assert balance1 == balance2 == amount * 1000
- # Verify no additional database changes
+ # The real DB contains one logical credit. A later replay is also mutation-free.
+ await db_snapshot.capture()
+ replay = await integration_client.get("/v1/wallet/info")
+ assert replay.status_code == 200
+ assert replay.json()["api_key"] == api_key1
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["added"]) == 0
assert len(diff["api_keys"]["modified"]) == 0
- # Original API key should still work with original balance
integration_client.headers["Authorization"] = f"Bearer {api_key1}"
wallet_response = await integration_client.get("/v1/wallet/")
assert wallet_response.status_code == 200
diff --git a/tests/integration/test_wallet_melt_restart.py b/tests/integration/test_wallet_melt_restart.py
index 5bb607fa..6c28963c 100644
--- a/tests/integration/test_wallet_melt_restart.py
+++ b/tests/integration/test_wallet_melt_restart.py
@@ -10,9 +10,12 @@ invalidate them on "paid" or release them on "unpaid".
"""
from pathlib import Path
+from unittest.mock import AsyncMock, patch
+import httpx
import pytest
-from cashu.core.base import Proof
+from cashu.core.base import MeltQuote, MeltQuoteState, Proof
+from cashu.core.models import PostMeltQuoteResponse
from cashu.wallet import crud
from cashu.wallet.wallet import Wallet
@@ -47,6 +50,20 @@ async def _seed_ambiguous_melt(wallet: Wallet) -> list[Proof]:
proofs = [_proof("secret-a"), _proof("secret-b", amount=32)]
for proof in proofs:
await crud.store_proof(proof, db=wallet.db)
+ await crud.store_bolt11_melt_quote(
+ db=wallet.db,
+ quote=MeltQuote(
+ quote=QUOTE_ID,
+ method="bolt11",
+ request="lnbc1-test",
+ checking_id="",
+ unit="sat",
+ amount=95,
+ fee_reserve=1,
+ state=MeltQuoteState.pending,
+ mint=str(wallet.url),
+ ),
+ )
await wallet.set_reserved_for_melt(proofs, reserved=True, quote_id=QUOTE_ID)
# cashu's `except` block in melt():
@@ -75,6 +92,35 @@ async def test_melt_recovery_is_findable_by_quote_after_restart(
assert all(p.melt_id == QUOTE_ID for p in found)
+async def test_paid_reconciliation_invalidates_recovered_proofs_after_restart(
+ tmp_path: Path,
+) -> None:
+ """Actual Wallet.get_melt_quote consumes quote-linked proofs after restart."""
+ wallet = await _wallet(tmp_path)
+ await _seed_ambiguous_melt(wallet)
+
+ restarted = await _wallet(tmp_path)
+ remote = PostMeltQuoteResponse(
+ quote=QUOTE_ID,
+ amount=95,
+ unit="sat",
+ request="lnbc1-test",
+ fee_reserve=1,
+ state=MeltQuoteState.paid.value,
+ expiry=None,
+ payment_preimage="preimage",
+ )
+ with patch(
+ "cashu.wallet.v1_api.LedgerAPI.get_melt_quote",
+ new=AsyncMock(return_value=remote),
+ ):
+ reconciled = await restarted.get_melt_quote(QUOTE_ID)
+
+ assert reconciled is not None and reconciled.state == MeltQuoteState.paid
+ assert await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID) == []
+ assert await crud.get_proofs(db=restarted.db) == []
+
+
async def test_send_style_reservation_would_not_be_reconcilable(
tmp_path: Path,
) -> None:
@@ -97,19 +143,65 @@ async def test_send_style_reservation_would_not_be_reconcilable(
async def test_unpaid_reconciliation_releases_recovered_proofs_after_restart(
tmp_path: Path,
) -> None:
- """The full recovery arc: crash, restart, mint says unpaid, funds usable."""
+ """Actual Wallet.get_melt_quote releases proofs after an unpaid answer."""
wallet = await _wallet(tmp_path)
await _seed_ambiguous_melt(wallet)
restarted = await _wallet(tmp_path)
- found = await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID)
- assert len(found) == 2
+ remote = PostMeltQuoteResponse(
+ quote=QUOTE_ID,
+ amount=95,
+ unit="sat",
+ request="lnbc1-test",
+ fee_reserve=1,
+ state=MeltQuoteState.unpaid.value,
+ expiry=None,
+ )
+ with patch(
+ "cashu.wallet.v1_api.LedgerAPI.get_melt_quote",
+ new=AsyncMock(return_value=remote),
+ ):
+ reconciled = await restarted.get_melt_quote(QUOTE_ID)
- # What get_melt_quote() does on an "unpaid" answer.
- await restarted.set_reserved_for_melt(found, reserved=False, quote_id=None)
-
- released = await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID)
- assert released == []
+ assert reconciled is not None and reconciled.state == MeltQuoteState.unpaid
+ assert await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID) == []
all_proofs = await crud.get_proofs(db=restarted.db)
assert len(all_proofs) == 2
- assert all(not p.reserved for p in all_proofs) # spendable again
+ assert all(not p.reserved for p in all_proofs)
+
+
+async def test_pending_and_transport_reconciliation_keep_recovered_reservation(
+ tmp_path: Path,
+) -> None:
+ wallet = await _wallet(tmp_path)
+ await _seed_ambiguous_melt(wallet)
+ restarted = await _wallet(tmp_path)
+ pending = PostMeltQuoteResponse(
+ quote=QUOTE_ID,
+ amount=95,
+ unit="sat",
+ request="lnbc1-test",
+ fee_reserve=1,
+ state=MeltQuoteState.pending.value,
+ expiry=None,
+ )
+
+ with patch(
+ "cashu.wallet.v1_api.LedgerAPI.get_melt_quote",
+ new=AsyncMock(return_value=pending),
+ ):
+ reconciled = await restarted.get_melt_quote(QUOTE_ID)
+ assert reconciled is not None and reconciled.state == MeltQuoteState.pending
+ found = await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID)
+ assert len(found) == 2 and all(proof.reserved for proof in found)
+
+ with (
+ patch(
+ "cashu.wallet.v1_api.LedgerAPI.get_melt_quote",
+ new=AsyncMock(side_effect=httpx.ReadTimeout("mint unavailable")),
+ ),
+ pytest.raises(httpx.ReadTimeout),
+ ):
+ await restarted.get_melt_quote(QUOTE_ID)
+ found = await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID)
+ assert len(found) == 2 and all(proof.reserved for proof in found)
diff --git a/tests/unit/test_auto_topup.py b/tests/unit/test_auto_topup.py
index d28f939c..349dc9ef 100644
--- a/tests/unit/test_auto_topup.py
+++ b/tests/unit/test_auto_topup.py
@@ -1,4 +1,6 @@
import json
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -65,7 +67,9 @@ async def test_auto_topup_refuses_invalid_settings_before_touching_the_wallet()
"routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
return_value=provider,
),
- patch("routstr.upstream.auto_topup.send_token", AsyncMock()) as send,
+ patch(
+ "routstr.upstream.auto_topup.send_token_from_owner_locked", AsyncMock()
+ ) as send,
):
await _check_and_topup(row)
@@ -73,6 +77,63 @@ async def test_auto_topup_refuses_invalid_settings_before_touching_the_wallet()
send.assert_not_awaited()
+@pytest.mark.asyncio
+async def test_routstr_outgoing_audit_is_persisted_under_wallet_guard() -> None:
+ provider = MagicMock()
+ provider.get_balance = AsyncMock(return_value=0.0)
+ provider.topup = AsyncMock(return_value={"error": "stop after send"})
+ inside_guard = False
+
+ @asynccontextmanager
+ async def guard() -> AsyncIterator[None]:
+ nonlocal inside_guard
+ inside_guard = True
+ try:
+ yield
+ finally:
+ inside_guard = False
+
+ async def send(*_args: object) -> str:
+ assert inside_guard
+ return "cashu-token"
+
+ async def persist(*_args: object, **_kwargs: object) -> None:
+ assert inside_guard
+
+ with (
+ patch(
+ "routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
+ return_value=provider,
+ ),
+ patch(
+ "routstr.upstream.auto_topup._reconcile_routstr_state",
+ AsyncMock(return_value=False),
+ ),
+ patch(
+ "routstr.upstream.auto_topup._routstr_spent_last_24h_sats",
+ AsyncMock(return_value=0),
+ ),
+ patch(
+ "routstr.upstream.auto_topup._claim_routstr_topup",
+ AsyncMock(return_value="operation-1"),
+ ),
+ patch("routstr.upstream.auto_topup.wallet_operation_guard", side_effect=guard),
+ patch(
+ "routstr.upstream.auto_topup.send_token_from_owner_locked",
+ side_effect=send,
+ ),
+ patch(
+ "routstr.upstream.auto_topup._persist_routstr_token_and_mark_sent",
+ side_effect=persist,
+ ),
+ patch(
+ "routstr.upstream.auto_topup.token_mint_url",
+ return_value="https://mint.test",
+ ),
+ ):
+ await _check_and_topup(_row())
+
+
def _ppq_row() -> MagicMock:
row = MagicMock()
row.id = "ppq-provider-1"
@@ -443,6 +504,30 @@ async def test_ppq_auto_topup_skips_when_balance_meets_threshold() -> None:
provider.initiate_topup.assert_not_awaited()
+@pytest.mark.asyncio
+async def test_ppq_auto_topup_requires_two_below_threshold_reads() -> None:
+ provider = MagicMock()
+ provider.get_balance = AsyncMock(side_effect=[2.5, 5.0])
+ provider.initiate_topup = AsyncMock()
+
+ with (
+ patch(
+ "routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
+ return_value=provider,
+ ),
+ patch(
+ "routstr.upstream.auto_topup._reconcile_ppq_state",
+ AsyncMock(return_value=False),
+ ),
+ patch("routstr.upstream.auto_topup._claim_ppq_topup", AsyncMock()) as claim,
+ ):
+ await _check_and_topup(_ppq_row())
+
+ assert provider.get_balance.await_count == 2
+ claim.assert_not_awaited()
+ provider.initiate_topup.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_ppq_auto_topup_skips_when_daily_spend_cap_reached() -> None:
provider = MagicMock()
diff --git a/tests/unit/test_core_exceptions.py b/tests/unit/test_core_exceptions.py
index 3d9469df..5f4cc577 100644
--- a/tests/unit/test_core_exceptions.py
+++ b/tests/unit/test_core_exceptions.py
@@ -1,4 +1,5 @@
import json
+from unittest.mock import patch
import pytest
from fastapi import HTTPException
@@ -34,11 +35,14 @@ async def test_structured_http_error_uses_standard_error_envelope() -> None:
"details": {"mint": "https://mint.example"},
}
- response = await http_exception_handler(
- request,
- HTTPException(status_code=503, detail={"error": error}),
- )
+ with patch("routstr.core.exceptions.logger") as logger:
+ response = await http_exception_handler(
+ request,
+ HTTPException(status_code=503, detail={"error": error}),
+ )
+ logger.warning.assert_called_once()
+ logger.error.assert_not_called()
assert response.status_code == 503
assert json.loads(response.body) == {
"detail": {"error": error},
diff --git a/tests/unit/test_lightning_settlement.py b/tests/unit/test_lightning_settlement.py
index dbec3bd7..eddfde57 100644
--- a/tests/unit/test_lightning_settlement.py
+++ b/tests/unit/test_lightning_settlement.py
@@ -1,6 +1,6 @@
import asyncio
import time
-from collections.abc import AsyncIterator
+from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
@@ -20,9 +20,17 @@ from routstr.lightning import (
get_invoice_status,
recover_invoice,
)
+from routstr.mint import MintRateGuard
from routstr.wallet import Wallet
+@pytest.fixture(autouse=True)
+def _clear_mint_rate_guards() -> Iterator[None]:
+ MintRateGuard._guards.clear()
+ yield
+ MintRateGuard._guards.clear()
+
+
def _invoice(**overrides: object) -> SimpleNamespace:
values = {
"id": "invoice-1",
diff --git a/tests/unit/test_lnurl_amount_and_destination.py b/tests/unit/test_lnurl_amount_and_destination.py
index 5ba459d9..53cb13df 100644
--- a/tests/unit/test_lnurl_amount_and_destination.py
+++ b/tests/unit/test_lnurl_amount_and_destination.py
@@ -5,6 +5,7 @@ hand an LNURL a set of unreserved proofs, and reserving only after that check
is what stops a pre-dispatch failure from stranding proofs.
"""
+import math
from collections.abc import Callable
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@@ -27,19 +28,29 @@ LNURL_DATA = {
"max_sendable": 100_000_000,
}
-# 1000 sat minus the 11 sat estimated fee reserve.
-EXPECTED_QUOTE_SAT = 989
+# The exact plan spends the 1000 sat gross budget as 999 sat + 1 sat reserve.
+EXPECTED_QUOTE_SAT = 999
def _wallet(
- quote_amount: int = EXPECTED_QUOTE_SAT,
+ quote_amount: int | None = None,
) -> tuple[MagicMock, list[MagicMock]]:
- proofs = [MagicMock(amount=1000)]
+ proofs = [MagicMock(amount=1000, reserved=False)]
wallet = MagicMock(url="https://mint.test")
- wallet.melt_quote = AsyncMock(
- return_value=MagicMock(fee_reserve=1, quote="q", amount=quote_amount)
- )
+ wallet.get_fees_for_proofs.return_value = 0
+ if quote_amount is None:
+ wallet.melt_quote = AsyncMock(
+ side_effect=[
+ MagicMock(fee_reserve=1, quote="q", amount=1000),
+ MagicMock(fee_reserve=1, quote="q", amount=EXPECTED_QUOTE_SAT),
+ ]
+ )
+ else:
+ wallet.melt_quote = AsyncMock(
+ return_value=MagicMock(fee_reserve=1, quote="q", amount=quote_amount)
+ )
wallet.select_to_send = AsyncMock(return_value=(proofs, None))
+ wallet.get_fees_for_proofs = MagicMock(return_value=0)
wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.paid))
wallet.set_reserved_for_send = AsyncMock()
return wallet, proofs
@@ -124,16 +135,22 @@ async def test_raw_send_to_lnurl_accepts_exact_invoice() -> None:
)
assert paid == EXPECTED_QUOTE_SAT * 1000
- wallet.select_to_send.assert_awaited_once()
+ wallet.select_to_send.assert_not_awaited()
+ wallet.set_reserved_for_send.assert_awaited_once_with(proofs, reserved=True)
wallet.melt.assert_awaited_once()
@pytest.mark.asyncio
async def test_raw_send_to_lnurl_msat_unit_compares_in_wallet_unit() -> None:
- # 1_000_000 msat minus an 11 sat fee reserve leaves 989_000 msat.
- wallet, proofs = _wallet(989_000)
+ # 1_000_000 msat gross minus the exact 1 msat reserve leaves 999_999 msat.
+ wallet, proofs = _wallet()
proofs[0].amount = 1_000_000
- wallet.select_to_send = AsyncMock(return_value=(proofs, None))
+ wallet.melt_quote = AsyncMock(
+ side_effect=[
+ MagicMock(fee_reserve=1, quote="q", amount=1_000_000),
+ MagicMock(fee_reserve=1, quote="q", amount=999_999),
+ ]
+ )
data_patch, invoice_patch = _lnurl_patches()
with data_patch, invoice_patch:
@@ -141,7 +158,46 @@ async def test_raw_send_to_lnurl_msat_unit_compares_in_wallet_unit() -> None:
wallet, proofs, "owner@ln.tld", "msat", amount=1_000_000
)
- assert paid == 989_000
+ assert paid == 999_999
+
+
+@pytest.mark.asyncio
+async def test_raw_send_to_lnurl_requotes_for_exact_input_fees_without_recursion() -> (
+ None
+):
+ proofs = [MagicMock(amount=1, reserved=False) for _ in range(1500)]
+ wallet = MagicMock(url="https://mint.test")
+ wallet.get_fees_for_proofs = MagicMock(
+ side_effect=lambda selected: math.ceil(len(selected) / 100)
+ )
+ wallet.melt_quote = AsyncMock(
+ side_effect=[
+ MagicMock(fee_reserve=10, quote="q1", amount=1500),
+ MagicMock(fee_reserve=10, quote="q2", amount=1475),
+ ]
+ )
+ wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.paid))
+ wallet.set_reserved_for_send = AsyncMock()
+ checkpoint = AsyncMock()
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with data_patch, invoice_patch:
+ paid = await raw_send_to_lnurl(
+ wallet,
+ proofs,
+ "owner@ln.tld",
+ "sat",
+ amount=1500,
+ on_melt_quote=checkpoint,
+ )
+
+ assert paid == 1_475_000
+ assert wallet.melt_quote.await_count == 2
+ checkpoint.assert_awaited_once_with("q2")
+ wallet.select_to_send.assert_not_called()
+ selected = wallet.melt.await_args.kwargs["proofs"]
+ assert sum(proof.amount for proof in selected) == 1500
+ assert 1475 + 10 + wallet.get_fees_for_proofs(selected) == 1500
@pytest.mark.asyncio
diff --git a/tests/unit/test_lnurl_melt_timeout.py b/tests/unit/test_lnurl_melt_timeout.py
index dd0dfb02..c517cbe2 100644
--- a/tests/unit/test_lnurl_melt_timeout.py
+++ b/tests/unit/test_lnurl_melt_timeout.py
@@ -1,6 +1,7 @@
"""LNURL melt attempts must not misclassify ambiguous payment outcomes."""
import asyncio
+from collections.abc import Iterator
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@@ -16,6 +17,14 @@ from routstr.payment.lnurl import (
raw_send_to_lnurl,
)
+
+@pytest.fixture(autouse=True)
+def _clear_mint_guards() -> Iterator[None]:
+ MintRateGuard._guards.clear()
+ yield
+ MintRateGuard._guards.clear()
+
+
LNURL_DATA = {
"callback_url": "https://ln.tld/cb",
"min_sendable": 1_000,
@@ -23,18 +32,22 @@ LNURL_DATA = {
}
-# 1000 sat minus the 11 sat estimated fee reserve.
-QUOTE_AMOUNT_SAT = 989
+# Exact planning pays 999 sat from a 1000 sat budget with a 1 sat reserve.
+QUOTE_AMOUNT_SAT = 999
def _wallet() -> tuple[MagicMock, list[MagicMock]]:
- proofs = [MagicMock(amount=1000)]
+ proofs = [MagicMock(amount=1000, reserved=False)]
wallet = MagicMock(url="https://mint.test")
wallet.melt_quote = AsyncMock(
- return_value=MagicMock(fee_reserve=1, quote="q", amount=QUOTE_AMOUNT_SAT)
+ side_effect=[
+ MagicMock(fee_reserve=1, quote="q", amount=1000),
+ MagicMock(fee_reserve=1, quote="q", amount=QUOTE_AMOUNT_SAT),
+ ]
)
wallet.melt = AsyncMock()
wallet.select_to_send = AsyncMock(return_value=(proofs, None))
+ wallet.get_fees_for_proofs = MagicMock(return_value=0)
wallet.set_reserved_for_melt = AsyncMock()
wallet.set_reserved_for_send = AsyncMock()
return wallet, proofs
@@ -54,7 +67,26 @@ def _lnurl_patches() -> tuple[Any, Any]:
@pytest.mark.asyncio
-async def test_raw_send_to_lnurl_timeout_reconciled_unpaid_is_retry_safe() -> None:
+async def test_raw_send_to_lnurl_direct_unpaid_is_retry_safe() -> None:
+ wallet, proofs = _wallet()
+ wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.unpaid))
+ wallet.get_melt_quote = AsyncMock()
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with (
+ data_patch,
+ invoice_patch,
+ pytest.raises(LNURLError, match="confirmed that the melt was unpaid") as raised,
+ ):
+ await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
+
+ assert not isinstance(raised.value, MeltOutcomeAmbiguousError)
+ wallet.get_melt_quote.assert_not_awaited()
+ wallet.set_reserved_for_send.assert_any_await(proofs, reserved=False)
+
+
+@pytest.mark.asyncio
+async def test_raw_send_to_lnurl_timeout_then_unpaid_remains_ambiguous() -> None:
wallet, proofs = _wallet()
async def _hang(**kwargs: object) -> None:
@@ -71,19 +103,19 @@ async def test_raw_send_to_lnurl_timeout_reconciled_unpaid_is_retry_safe() -> No
patch.object(settings, "mint_retry_max_attempts", 0),
data_patch,
invoice_patch,
- pytest.raises(LNURLError, match="confirmed that the melt was unpaid") as raised,
+ pytest.raises(MeltOutcomeAmbiguousError, match="immediate unpaid"),
):
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
- assert not isinstance(raised.value, MeltOutcomeAmbiguousError)
wallet.get_melt_quote.assert_awaited_once_with("q")
- wallet.set_reserved_for_melt.assert_awaited_once_with(
+ assert wallet.set_reserved_for_melt.await_count == 2
+ wallet.set_reserved_for_melt.assert_awaited_with(
proofs, reserved=True, quote_id="q"
)
@pytest.mark.asyncio
-async def test_raw_send_to_lnurl_wrapped_transport_error_is_reconciled_once() -> None:
+async def test_raw_send_to_lnurl_wrapped_transport_unpaid_remains_ambiguous() -> None:
wallet, proofs = _wallet()
async def _wrapped_transport_error(**kwargs: object) -> None:
@@ -102,14 +134,14 @@ async def test_raw_send_to_lnurl_wrapped_transport_error_is_reconciled_once() ->
patch.object(settings, "mint_retry_max_attempts", 3),
data_patch,
invoice_patch,
- pytest.raises(LNURLError, match="confirmed that the melt was unpaid") as raised,
+ pytest.raises(MeltOutcomeAmbiguousError, match="immediate unpaid"),
):
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
- assert not isinstance(raised.value, MeltOutcomeAmbiguousError)
wallet.melt.assert_awaited_once()
wallet.get_melt_quote.assert_awaited_once_with("q")
- wallet.set_reserved_for_melt.assert_awaited_once_with(
+ assert wallet.set_reserved_for_melt.await_count == 2
+ wallet.set_reserved_for_melt.assert_awaited_with(
proofs, reserved=True, quote_id="q"
)
@@ -180,6 +212,45 @@ async def test_raw_send_to_lnurl_pending_response_stays_ambiguous() -> None:
wallet.get_melt_quote.assert_awaited_once_with("q")
+@pytest.mark.asyncio
+async def test_pending_then_immediate_unpaid_remains_reserved_and_ambiguous() -> None:
+ wallet, proofs = _wallet()
+ wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.pending))
+ wallet.get_melt_quote = AsyncMock(
+ return_value=MagicMock(state=MeltQuoteState.unpaid)
+ )
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with (
+ data_patch,
+ invoice_patch,
+ pytest.raises(MeltOutcomeAmbiguousError, match="immediate unpaid"),
+ ):
+ await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
+
+ wallet.set_reserved_for_melt.assert_awaited_once_with(
+ proofs, reserved=True, quote_id="q"
+ )
+
+
+@pytest.mark.asyncio
+async def test_immediate_unpaid_reservation_failure_stays_ambiguous() -> None:
+ wallet, proofs = _wallet()
+ wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.pending))
+ wallet.get_melt_quote = AsyncMock(
+ return_value=MagicMock(state=MeltQuoteState.unpaid)
+ )
+ wallet.set_reserved_for_melt = AsyncMock(side_effect=OSError("db locked"))
+ data_patch, invoice_patch = _lnurl_patches()
+
+ with (
+ data_patch,
+ invoice_patch,
+ pytest.raises(MeltOutcomeAmbiguousError, match="could not be restored"),
+ ):
+ await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize("rate_error", ["cooldown", "http_429"])
async def test_raw_send_to_lnurl_rate_rejection_unreserves_proofs(
@@ -213,7 +284,8 @@ async def test_raw_send_to_lnurl_rate_rejection_unreserves_proofs(
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
wallet.melt.assert_not_awaited()
- wallet.set_reserved_for_send.assert_awaited_once_with(proofs, reserved=False)
+ assert wallet.set_reserved_for_send.await_count == 2
+ wallet.set_reserved_for_send.assert_awaited_with(proofs, reserved=False)
@pytest.mark.asyncio
@@ -238,7 +310,8 @@ async def test_real_mint_wrapper_http_429_unreserves_proofs() -> None:
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
wallet.melt.assert_awaited_once()
- wallet.set_reserved_for_send.assert_awaited_once_with(proofs, reserved=False)
+ assert wallet.set_reserved_for_send.await_count == 2
+ wallet.set_reserved_for_send.assert_awaited_with(proofs, reserved=False)
MintRateGuard._guards.pop(str(wallet.url), None)
diff --git a/tests/unit/test_mint.py b/tests/unit/test_mint.py
index 6b70a558..dc58d10c 100644
--- a/tests/unit/test_mint.py
+++ b/tests/unit/test_mint.py
@@ -10,6 +10,7 @@ from routstr.mint import (
MintRateGuard,
MintRateLimitedError,
fail_fast_mint_operations,
+ run_mint_operation,
)
from routstr.wallet import Wallet
@@ -103,6 +104,34 @@ async def test_cashu_429_dispatches_through_wallet_override() -> None:
await wallet.mint_quote(1, Unit.sat)
+@pytest.mark.asyncio
+async def test_wrapped_transport_failure_opens_central_cooldown() -> None:
+ mint_url = "https://transport-failure.test"
+ MintRateGuard._guards.pop(mint_url, None)
+
+ async def wrapped_failure() -> None:
+ try:
+ raise httpx.ReadTimeout("body stalled")
+ except httpx.ReadTimeout as error:
+ raise Exception("wallet wrapper") from error
+
+ with pytest.raises(Exception, match="wallet wrapper"):
+ await run_mint_operation(
+ wrapped_failure,
+ mint_url=mint_url,
+ retry_timeouts=False,
+ )
+
+ guard = MintRateGuard.get(mint_url)
+ assert guard.cooldown_remaining() > 29
+ probe = AsyncMock()
+ async with fail_fast_mint_operations():
+ with pytest.raises(MintCooldownError):
+ await guard.run(probe)
+ probe.assert_not_awaited()
+ MintRateGuard._guards.pop(mint_url, None)
+
+
async def test_guard_concurrency_change_preserves_cooldown_state() -> None:
from routstr.core.settings import settings
diff --git a/tests/unit/test_ppq_resilience.py b/tests/unit/test_ppq_resilience.py
new file mode 100644
index 00000000..b1dd9f9c
--- /dev/null
+++ b/tests/unit/test_ppq_resilience.py
@@ -0,0 +1,103 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+
+from routstr.upstream.ppqai import (
+ PPQAIUpstreamProvider,
+ PPQCircuitOpenError,
+ _ppq_circuits,
+ _safe_read_request,
+)
+
+
+@pytest.fixture(autouse=True)
+def _clear_ppq_circuits() -> None:
+ _ppq_circuits.clear()
+
+
+@pytest.mark.asyncio
+async def test_safe_ppq_read_retries_timeout_with_bounded_backoff() -> None:
+ request = httpx.Request("GET", "https://api.ppq.ai/models")
+ success = httpx.Response(200, request=request, json={"data": []})
+ client = MagicMock()
+ client.request = AsyncMock(
+ side_effect=[httpx.ReadTimeout("", request=request), success]
+ )
+ with (
+ patch("routstr.upstream.ppqai.random.uniform", return_value=0.0),
+ patch("routstr.upstream.ppqai.asyncio.sleep", AsyncMock()) as sleep,
+ ):
+ response = await _safe_read_request(
+ client, "GET", "https://api.ppq.ai/models", headers={}
+ )
+
+ assert response is success
+ assert client.request.await_count == 2
+ sleep.assert_awaited_once_with(0.25)
+
+
+@pytest.mark.asyncio
+async def test_safe_ppq_read_opens_cross_cycle_circuit_and_probe_clears_it() -> None:
+ request = httpx.Request("GET", "https://api.ppq.ai/models")
+ client = MagicMock()
+ client.request = AsyncMock(side_effect=httpx.ReadTimeout("down", request=request))
+
+ with (
+ patch("routstr.upstream.ppqai.random.uniform", return_value=0.0),
+ patch("routstr.upstream.ppqai.asyncio.sleep", AsyncMock()),
+ patch("routstr.upstream.ppqai.time.monotonic", return_value=100.0),
+ pytest.raises(httpx.ReadTimeout),
+ ):
+ await _safe_read_request(client, "GET", str(request.url), headers={})
+ assert client.request.await_count == 3
+
+ with (
+ patch("routstr.upstream.ppqai.time.monotonic", return_value=110.0),
+ pytest.raises(PPQCircuitOpenError),
+ ):
+ await _safe_read_request(client, "GET", str(request.url), headers={})
+ assert client.request.await_count == 3
+
+ success = httpx.Response(200, request=request, json={"data": []})
+ client.request = AsyncMock(return_value=success)
+ with patch("routstr.upstream.ppqai.time.monotonic", return_value=131.0):
+ assert (
+ await _safe_read_request(client, "GET", str(request.url), headers={})
+ is success
+ )
+
+ state = next(iter(_ppq_circuits.values()))
+ assert state.consecutive_failures == 0
+ assert state.cooldown_until == 0.0
+
+
+@pytest.mark.asyncio
+async def test_fetch_models_failure_is_not_a_valid_empty_catalog() -> None:
+ provider = PPQAIUpstreamProvider("secret")
+ with (
+ patch(
+ "routstr.upstream.ppqai._safe_read_request",
+ AsyncMock(side_effect=httpx.ReadTimeout("catalog timed out")),
+ ),
+ pytest.raises(httpx.ReadTimeout),
+ ):
+ await provider.fetch_models()
+
+
+@pytest.mark.asyncio
+async def test_ppq_invoice_creation_post_is_never_retried() -> None:
+ provider = PPQAIUpstreamProvider("secret")
+ client = MagicMock()
+ client.post = AsyncMock(side_effect=httpx.ReadTimeout("invoice timed out"))
+ context = MagicMock()
+ context.__aenter__ = AsyncMock(return_value=client)
+ context.__aexit__ = AsyncMock(return_value=None)
+
+ with (
+ patch("routstr.upstream.ppqai.httpx.AsyncClient", return_value=context),
+ pytest.raises(httpx.ReadTimeout),
+ ):
+ await provider.create_lightning_topup(10, "USD")
+
+ client.post.assert_awaited_once()
diff --git a/tests/unit/test_streaming_billing_finalization.py b/tests/unit/test_streaming_billing_finalization.py
index 94ef65a4..ea8b6b1f 100644
--- a/tests/unit/test_streaming_billing_finalization.py
+++ b/tests/unit/test_streaming_billing_finalization.py
@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator
from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch
+import httpx
import pytest
from fastapi import BackgroundTasks
from sqlalchemy.exc import SQLAlchemyError
@@ -340,6 +341,153 @@ async def test_responses_streaming_releases_and_raises_on_billing_failure(
release.assert_awaited_once_with(snapshot, session, 500)
+@pytest.mark.asyncio
+@pytest.mark.parametrize("api", ["chat", "responses"])
+@pytest.mark.parametrize("finalization_fails", [False, True])
+async def test_partial_remote_protocol_error_finalizes_and_closes_once(
+ api: str,
+ finalization_fails: bool,
+) -> None:
+ provider = BaseUpstreamProvider(
+ base_url="https://api.example.com", api_key="test-key"
+ )
+
+ async def aiter_bytes() -> AsyncGenerator[bytes, None]:
+ yield b'data: {"model":"test","choices":[{"delta":{"content":"hi"}}]}\n\n'
+ raise httpx.RemoteProtocolError("incomplete chunked read")
+
+ upstream_response = MagicMock(
+ status_code=200, headers={"content-type": "text/event-stream"}
+ )
+ upstream_response.aiter_bytes = aiter_bytes
+ upstream_response.aclose = AsyncMock()
+ client = MagicMock()
+ client.aclose = AsyncMock()
+ key = MagicMock(spec=ApiKey)
+ key.hashed_key = f"{api}-partial"
+ key.balance = 10_000
+ session = MagicMock()
+ session.get = AsyncMock(return_value=key)
+ session.rollback = AsyncMock()
+ session_context = MagicMock()
+ session_context.__aenter__ = AsyncMock(return_value=session)
+ session_context.__aexit__ = AsyncMock(return_value=None)
+ adjust = (
+ AsyncMock(side_effect=SQLAlchemyError("database unavailable"))
+ if finalization_fails
+ else AsyncMock(return_value={"input_tokens": 0, "output_tokens": 0})
+ )
+ snapshot = ReservationSnapshot(
+ release_id=f"{api}-partial-release",
+ key_hash=key.hashed_key,
+ billing_key_hash=key.hashed_key,
+ reserved_msats=500,
+ )
+ release = AsyncMock(return_value=True)
+
+ with (
+ patch("routstr.upstream.base.adjust_payment_for_tokens", adjust),
+ patch("routstr.upstream.base.release_reservation", release),
+ patch("routstr.upstream.base.create_session", return_value=session_context),
+ ):
+ if api == "chat":
+ response = await provider.handle_streaming_chat_completion(
+ response=upstream_response,
+ key=key,
+ max_cost_for_model=500,
+ background_tasks=BackgroundTasks(),
+ reservation_snapshot=snapshot,
+ client=client,
+ )
+ else:
+ response = await provider.handle_streaming_responses_completion(
+ response=upstream_response,
+ key=key,
+ max_cost_for_model=500,
+ reservation_snapshot=snapshot,
+ client=client,
+ )
+ emitted = bytearray()
+ with pytest.raises(httpx.RemoteProtocolError):
+ async for chunk in response.body_iterator:
+ emitted.extend(
+ chunk.encode() if isinstance(chunk, str) else bytes(chunk)
+ )
+
+ adjust.assert_awaited_once()
+ if finalization_fails:
+ session.rollback.assert_awaited_once()
+ release.assert_awaited_once_with(snapshot, session, 500)
+ else:
+ release.assert_not_awaited()
+ upstream_response.aclose.assert_awaited_once()
+ client.aclose.assert_awaited_once()
+ assert b"[DONE]" not in emitted
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("api", ["chat", "responses"])
+async def test_partial_stream_preserves_transport_error_when_billing_db_is_down(
+ api: str,
+) -> None:
+ provider = BaseUpstreamProvider(
+ base_url="https://api.example.com", api_key="test-key"
+ )
+
+ async def aiter_bytes() -> AsyncGenerator[bytes, None]:
+ yield b'data: {"model":"test","choices":[]}\n\n'
+ raise httpx.RemoteProtocolError("incomplete chunked read")
+
+ upstream_response = MagicMock(
+ status_code=200, headers={"content-type": "text/event-stream"}
+ )
+ upstream_response.aiter_bytes = aiter_bytes
+ upstream_response.aclose = AsyncMock()
+ client = MagicMock()
+ client.aclose = AsyncMock()
+ key = MagicMock(spec=ApiKey)
+ key.hashed_key = f"{api}-database-down"
+ key.balance = 10_000
+ snapshot = ReservationSnapshot(
+ release_id=f"{api}-database-down-release",
+ key_hash=key.hashed_key,
+ billing_key_hash=key.hashed_key,
+ reserved_msats=500,
+ )
+ unavailable_session = MagicMock()
+ unavailable_session.__aenter__ = AsyncMock(
+ side_effect=SQLAlchemyError("database unavailable")
+ )
+ unavailable_session.__aexit__ = AsyncMock(return_value=None)
+
+ with patch(
+ "routstr.upstream.base.create_session", return_value=unavailable_session
+ ):
+ if api == "chat":
+ response = await provider.handle_streaming_chat_completion(
+ response=upstream_response,
+ key=key,
+ max_cost_for_model=500,
+ background_tasks=BackgroundTasks(),
+ reservation_snapshot=snapshot,
+ client=client,
+ )
+ else:
+ response = await provider.handle_streaming_responses_completion(
+ response=upstream_response,
+ key=key,
+ max_cost_for_model=500,
+ reservation_snapshot=snapshot,
+ client=client,
+ )
+ with pytest.raises(httpx.RemoteProtocolError, match="incomplete chunked read"):
+ async for _ in response.body_iterator:
+ pass
+
+ upstream_response.aclose.assert_awaited_once()
+ client.aclose.assert_awaited_once()
+
+
@pytest.mark.asyncio
async def test_responses_streaming_duplicate_publishes_zero_settled_cost() -> None:
provider = BaseUpstreamProvider(
@@ -578,9 +726,7 @@ async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> No
background_tasks=background_tasks,
reservation_snapshot=snapshot,
)
- iterator = cast(
- AsyncGenerator[bytes, None], response.body_iterator
- )
+ iterator = cast(AsyncGenerator[bytes, None], response.body_iterator)
await iterator.__anext__() # first chunk reaches the client
await iterator.aclose() # client aborts the socket here
diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py
index 8b738f89..5ad0a4f3 100644
--- a/tests/unit/test_wallet.py
+++ b/tests/unit/test_wallet.py
@@ -26,6 +26,7 @@ from routstr.wallet import (
recieve_token,
send,
send_token,
+ send_token_from_owner_locked,
)
@@ -455,6 +456,33 @@ async def test_send_token() -> None:
assert token == "test_token"
+@pytest.mark.asyncio
+async def test_owner_only_token_rejects_customer_backed_proofs() -> None:
+ mint = "http://mint:3338"
+ proof = Mock(amount=1000, reserved=False)
+ wallet = Mock(keysets={}, proofs=[proof], select_to_send=AsyncMock())
+
+ with (
+ patch(
+ "routstr.wallet.find_trusted_mint_with_funds",
+ AsyncMock(return_value=mint),
+ ),
+ patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)),
+ patch(
+ "routstr.wallet.get_proofs_per_mint_and_unit",
+ return_value=[proof],
+ ),
+ patch(
+ "routstr.wallet._owner_balance_for_mint_and_unit",
+ AsyncMock(return_value=50),
+ ),
+ pytest.raises(ValueError, match="Owner Cashu balance"),
+ ):
+ await send_token_from_owner_locked(100, "sat", mint)
+
+ wallet.select_to_send.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_release_token_reservation_unreserves_local_proofs() -> None:
from routstr.wallet import release_token_reservation
@@ -695,6 +723,38 @@ async def test_credit_balance() -> None:
assert mock_session.refresh.called
+@pytest.mark.asyncio
+async def test_concurrent_duplicate_token_credits_exactly_once() -> None:
+ key = Mock(balance=0, hashed_key="duplicate-key")
+ session = AsyncMock()
+ session.exec.return_value.rowcount = 1
+ session.refresh = AsyncMock()
+ receive = AsyncMock(
+ side_effect=[
+ (1000, "sat", "https://mint.test"),
+ ValueError("Mint Error: proofs already spent (Code: 11001)"),
+ ]
+ )
+ store = AsyncMock()
+
+ with (
+ patch("routstr.wallet.recieve_token", receive),
+ patch("routstr.wallet.store_cashu_transaction", store),
+ ):
+ results = await asyncio.gather(
+ credit_balance("cashuAduplicate", key, session),
+ credit_balance("cashuAduplicate", key, session),
+ return_exceptions=True,
+ )
+
+ assert sum(result == 1_000_000 for result in results) == 1
+ failure = next(result for result in results if isinstance(result, Exception))
+ classified = classify_redemption_error(failure)
+ assert classified is not None and classified[3] == "cashu_token_already_spent"
+ assert session.exec.await_count == 1
+ store.assert_awaited_once()
+
+
@pytest.mark.asyncio
async def test_credit_balance_constrains_redemption_to_key_mint() -> None:
key_mint = "http://key-mint:3338"
From 6318a3bdbb58f68c152b2714f1eb1080645ad1bb Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Wed, 26 Aug 2026 15:29:04 +0200
Subject: [PATCH 043/120] refactor(pricing): give the usable-rate question a
module of its own
`is_usable_rate` lived in `payment/models.py`, which pulls in httpx, fastapi,
the DB session and settings. Every consumer therefore reached it through a
deferred import to avoid the cycle: five of them, in four files.
Move the predicate and the billable-rate tuple to `payment/rates.py`, which
imports only `math`, and let the call sites import it normally. The rationale
now lives in that one docstring, so each guard says what it does with the
answer instead of repeating why the answer matters.
No behaviour change.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014X8RZzzbAuQCbavhFjTvJ4
---
routstr/core/admin.py | 3 +-
routstr/payment/cost_calculation.py | 19 +++--------
routstr/payment/models.py | 40 +----------------------
routstr/payment/price.py | 14 +++-----
routstr/payment/rates.py | 48 ++++++++++++++++++++++++++++
routstr/upstream/pricing_resolver.py | 25 ++++++---------
6 files changed, 69 insertions(+), 80 deletions(-)
create mode 100644 routstr/payment/rates.py
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index f018b6a5..1af941d0 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -12,11 +12,10 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..payment.models import (
- BILLABLE_PRICING_FIELDS,
_row_to_model,
- is_usable_rate,
list_models,
)
+from ..payment.rates import BILLABLE_PRICING_FIELDS, is_usable_rate
from ..proxy import refresh_model_maps, reinitialize_upstreams
from ..wallet import fetch_all_balances, send_token, token_mint_url
from . import vault
diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py
index a51f633d..ea45abe6 100644
--- a/routstr/payment/cost_calculation.py
+++ b/routstr/payment/cost_calculation.py
@@ -6,6 +6,7 @@ from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.settings import settings
from .price import sats_usd_price
+from .rates import is_usable_rate
from .usage import normalize_usage, parse_token_count
if TYPE_CHECKING:
@@ -268,16 +269,8 @@ async def calculate_cost(
else:
input_rate, output_rate, cache_read_rate, cache_creation_rate = pricing_rates
- # Local import mirrors this module's existing lazy pricing imports.
- from .models import is_usable_rate
-
- # An unusable rate is not "no pricing" to Python's truthiness: `NaN` and a
- # negative float are both truthy, so they sailed past this gate — the one
- # guard meant to catch a rate that cannot be billed on — and reached the
- # token math, which raises `ValueError` on `NaN` and `OverflowError` on
- # `inf` *after* the response was served (the streaming handlers swallow
- # that, so the request goes unbilled), while a negative produced a negative
- # charge. Ask whether each rate is usable rather than whether it is truthy.
+ # Truthiness is not the question: `NaN` and a negative rate are both truthy
+ # and sailed past this gate into the token math.
rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate)
if not all(is_usable_rate(rate) for rate in rates) or not (
input_rate and output_rate
@@ -343,9 +336,6 @@ def _coerce_usd(value: object) -> float:
``0.0`` means "no usable figure" to every caller, which is the same thing an
absent field means, so the caller's existing ``> 0`` checks handle it.
"""
- # Local import mirrors this module's existing lazy pricing imports.
- from .models import is_usable_rate
-
if value is None or isinstance(value, bool):
return 0.0
if not isinstance(value, (int, float, str)):
@@ -355,8 +345,7 @@ def _coerce_usd(value: object) -> float:
amount = float(value)
except (TypeError, ValueError, OverflowError):
return 0.0
- # `is_usable_rate` also rejects negatives, which the previous `max(0.0, …)`
- # clamped to zero — same outcome, stated once instead of inline.
+ # A negative is rejected here, where the previous `max(0.0, …)` clamped it.
return amount if is_usable_rate(amount) else 0.0
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index 7f3a20f3..62773371 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -1,6 +1,5 @@
import asyncio
import json
-import math
import random
import httpx
@@ -13,6 +12,7 @@ from ..core.db import ModelRow, UpstreamProviderRow, get_session
from ..core.logging import get_logger
from ..core.settings import settings
from .price import sats_usd_price
+from .rates import BILLABLE_PRICING_FIELDS, is_usable_rate
logger = get_logger(__name__)
@@ -59,44 +59,6 @@ class Pricing(BaseModel):
max_cost: float = 0.0 # in sats not msats
-# The rates a request can bill on. Derived fields (``max_*_cost``) are excluded
-# — they are computed carriers, not charged rates. One definition, shared by the
-# admin write edge and the served/routed guards, so they all cover the same set.
-BILLABLE_PRICING_FIELDS = (
- "prompt",
- "completion",
- "request",
- "image",
- "web_search",
- "internal_reasoning",
- "input_cache_read",
- "input_cache_write",
-)
-
-
-def is_usable_rate(rate: float) -> bool:
- """True if a single billable rate is a number a request could be billed on.
-
- The one definition of a usable rate, so every guard that asks the question
- answers it identically. A rate qualifies only when it is finite and
- non-negative; zero is usable (it means "free", which is a real price) but
- ``NaN``, ``±inf`` and negatives are not prices at all.
-
- Non-finite: ``inf > 0`` is True, so an infinite rate reads as chargeable and
- would be served, routed and billed as ``inf``; ``NaN`` poisons every total it
- enters and defeats ordinary comparisons, since ``NaN > 0``, ``NaN < 0`` and
- ``NaN == 0`` are all False. Negative: a negative rate produces a negative
- cost, which the settlement path subtracts from the balance — it pays the
- caller to make requests.
-
- Both reach a stored row from upstream catalogs as well as the admin edge
- (``json.loads`` accepts the bare ``NaN``/``Infinity`` literals and overflows
- ``1e999`` to ``inf``), so the check belongs in one shared place rather than
- at each writer.
- """
- return math.isfinite(rate) and rate >= 0.0
-
-
def has_usable_pricing(pricing: Pricing) -> bool:
"""True if every billable rate is a number a request could be billed on.
diff --git a/routstr/payment/price.py b/routstr/payment/price.py
index 93ed68e9..48fc5b65 100644
--- a/routstr/payment/price.py
+++ b/routstr/payment/price.py
@@ -5,6 +5,7 @@ import httpx
from ..core import get_logger
from ..core.settings import settings
+from .rates import is_usable_rate
logger = get_logger(__name__)
@@ -22,16 +23,11 @@ def _parse_quote(raw: object, exchange: str) -> float | None:
raises out of the integer conversion in settlement, and a negative rate
produces a negative charge that is credited back to the caller.
- ``is_usable_rate`` is the same predicate the billable-rate guards use, so
- "finite and non-negative" has one definition; a quote is stricter still and
- must be positive, since a BTC price of zero is a broken feed, not free money.
- Imported lazily because ``payment.models`` imports this module.
+ A quote is stricter than a billable rate: it must be positive, since a BTC
+ price of zero is a broken feed, not free money.
"""
- from .models import is_usable_rate
-
- # A boolean is a shape change, not a price: `float(True)` is a finite,
- # positive 1.0 that passes every numeric guard below and then wins the
- # `min()`, pricing the node at one dollar per bitcoin.
+ # `float(True)` is a finite, positive 1.0 that passes every guard below and
+ # would then win the `min()`, pricing the node at one dollar per bitcoin.
if isinstance(raw, bool):
logger.warning(
"Non-numeric price quote — ignoring this exchange",
diff --git a/routstr/payment/rates.py b/routstr/payment/rates.py
new file mode 100644
index 00000000..4ac74587
--- /dev/null
+++ b/routstr/payment/rates.py
@@ -0,0 +1,48 @@
+"""The one definition of a billable rate, with no dependencies of its own.
+
+A rate reaches the node from an upstream catalog, the LiteLLM cost map, an
+operator's admin edit, a legacy database row and the BTC/USD feed. Each of those
+readers needs the same two questions answered — is this value a rate at all, and
+is it a rate a request can be billed on — so both answers live here, in a module
+that imports nothing from the package. Every guard then shares one definition
+instead of drifting, and no caller needs a deferred import to reach it.
+"""
+
+import math
+
+# The rates a request can bill on. Derived fields (``max_*_cost``) are excluded
+# — they are computed carriers, not charged rates. One definition, shared by the
+# admin write edge and the served/routed guards, so they all cover the same set.
+BILLABLE_PRICING_FIELDS = (
+ "prompt",
+ "completion",
+ "request",
+ "image",
+ "web_search",
+ "internal_reasoning",
+ "input_cache_read",
+ "input_cache_write",
+)
+
+
+def is_usable_rate(rate: float) -> bool:
+ """True if a single billable rate is a number a request could be billed on.
+
+ The one definition of a usable rate, so every guard that asks the question
+ answers it identically. A rate qualifies only when it is finite and
+ non-negative; zero is usable (it means "free", which is a real price) but
+ ``NaN``, ``±inf`` and negatives are not prices at all.
+
+ Non-finite: ``inf > 0`` is True, so an infinite rate reads as chargeable and
+ would be served, routed and billed as ``inf``; ``NaN`` poisons every total it
+ enters and defeats ordinary comparisons, since ``NaN > 0``, ``NaN < 0`` and
+ ``NaN == 0`` are all False. Negative: a negative rate produces a negative
+ cost, which the settlement path subtracts from the balance — it pays the
+ caller to make requests. Both reach a stored row from upstream catalogs as
+ well as the admin edge (``json.loads`` accepts the bare ``NaN``/``Infinity``
+ literals and overflows ``1e999`` to ``inf``).
+
+ This is the rationale for every guard that calls it; the call sites say what
+ they do with the answer, not why the answer matters.
+ """
+ return math.isfinite(rate) and rate >= 0.0
diff --git a/routstr/upstream/pricing_resolver.py b/routstr/upstream/pricing_resolver.py
index ef7e544c..b12a7f1e 100644
--- a/routstr/upstream/pricing_resolver.py
+++ b/routstr/upstream/pricing_resolver.py
@@ -17,6 +17,8 @@ from __future__ import annotations
from dataclasses import dataclass, field
+from ..payment.rates import is_usable_rate
+
@dataclass
class ResolvedPricing:
@@ -68,15 +70,10 @@ def _as_float(value: object) -> float | None:
"""OpenRouter reports prices as strings; coerce, ``None`` if not a real rate.
Every caller reads a *price* out of a feed, so this asks the shared
- billable-rate question rather than merely parsing: ``float("Infinity")`` and
- ``float("NaN")`` parse happily from a feed string, ``json.loads`` accepts the
- bare literals and overflows ``1e999`` to ``inf``, and a negative parses
- cleanly into a rate that credits the caller. An oversized integer raises
- ``OverflowError`` rather than ``ValueError``, so that is caught too.
+ billable-rate question rather than merely parsing: ``float("Infinity")``,
+ ``float("NaN")`` and a negative all parse cleanly from a feed string. An
+ oversized integer raises ``OverflowError`` rather than ``ValueError``.
"""
- # Lazy, like the litellm lookup below: the resolver stays import-light.
- from ..payment.models import is_usable_rate
-
try:
parsed = float(value) # type: ignore[arg-type]
except (TypeError, ValueError, OverflowError):
@@ -90,9 +87,9 @@ def _as_int(value: object) -> int | None:
def _from_litellm(model_id: str) -> ResolvedPricing | None:
- # Lazy import so the resolver stays import-light and shares the exact
- # lookup semantics used by cache-rate backfill.
- from ..payment.models import is_usable_rate, litellm_cost_entry
+ # Lazy import so the resolver shares the exact lookup semantics used by
+ # cache-rate backfill without importing the models module at load time.
+ from ..payment.models import litellm_cost_entry
info = litellm_cost_entry(model_id)
if info is None:
@@ -106,10 +103,8 @@ def _from_litellm(model_id: str) -> ResolvedPricing | None:
# moderation/rerank tiers do this) — treating 0/0 as resolved would serve
# the model for free. Reject it (and any negative) so the caller falls
# through, mirroring async_fetch_openrouter_models' _has_valid_pricing.
- # A malformed entry is junk, not a price: `inf` would bill an infinite
- # amount, `NaN` poisons every total it enters, and a negative credits the
- # caller. `NaN` also defeats the both-zero guard below on its own, since
- # every comparison against it is False.
+ # Checked before the both-zero guard below: `NaN` defeats that guard on its
+ # own, since every comparison against it is False.
if not is_usable_rate(prompt) or not is_usable_rate(completion):
return None
if prompt == 0 and completion == 0:
From dff164b98031e257f228b6f84a2459449f7b0250 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Wed, 26 Aug 2026 15:44:29 +0200
Subject: [PATCH 044/120] fix(pricing): a rate spelled as a boolean is not a
rate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`isinstance(True, int)` is True and `float(True)` is `1.0`, so a JSON `true`
in a catalog was a finite, positive rate that passed every numeric guard: a
dollar per token. It reached a stored price through the OpenRouter feed filter,
the LiteLLM cost-map rung and the OpenRouter resolver rung.
The write edge and the exchange feed already rejected booleans explicitly,
which is the shape of the real problem: four readers were each parsing a rate
for themselves and disagreeing about what a rate is. Give them one coercion —
`coerce_rate` — and let it answer for all of them.
A numeric string stays a rate, because feeds report prices as strings; that now
holds on the LiteLLM rung too, which previously required a float outright.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014X8RZzzbAuQCbavhFjTvJ4
---
routstr/core/admin.py | 20 +++----
routstr/payment/cost_calculation.py | 18 ++----
routstr/payment/models.py | 21 +++----
routstr/payment/price.py | 38 ++-----------
routstr/payment/rates.py | 23 ++++++++
routstr/upstream/pricing_resolver.py | 35 ++++--------
.../test_admin_pricing_rate_validation.py | 22 +++++++
tests/unit/test_pricing_rate_validation.py | 20 +++++++
tests/unit/test_upstream_generic.py | 57 +++++++++++++++++++
9 files changed, 160 insertions(+), 94 deletions(-)
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index 1af941d0..f02ec441 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -15,7 +15,7 @@ from ..payment.models import (
_row_to_model,
list_models,
)
-from ..payment.rates import BILLABLE_PRICING_FIELDS, is_usable_rate
+from ..payment.rates import BILLABLE_PRICING_FIELDS, coerce_rate
from ..proxy import refresh_model_maps, reinitialize_upstreams
from ..wallet import fetch_all_balances, send_token, token_mint_url
from . import vault
@@ -542,17 +542,13 @@ class ModelCreate(BaseModel):
raw = value.get(field)
if raw is None:
continue
- if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
- raise ValueError(f"{field} must be a non-negative number")
- try:
- rate = float(raw)
- except (ValueError, OverflowError):
- # An integer too large for a float raises OverflowError, which
- # pydantic does not convert into a validation error — unhandled
- # it escapes as a 500 for what is still a bad client value.
- raise ValueError(f"{field} must be a number, got {raw!r}")
- if not is_usable_rate(rate):
- raise ValueError(f"{field} must be a finite, non-negative number")
+ # The shared coercion also absorbs the OverflowError an oversized
+ # integer raises, which pydantic does not convert into a validation
+ # error — unhandled it escaped as a 500 for a bad client value.
+ if coerce_rate(raw) is None:
+ raise ValueError(
+ f"{field} must be a finite, non-negative number, got {raw!r}"
+ )
return value
diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py
index ea45abe6..e7cfdb87 100644
--- a/routstr/payment/cost_calculation.py
+++ b/routstr/payment/cost_calculation.py
@@ -6,7 +6,7 @@ from pydantic.v1 import BaseModel
from ..core import get_logger
from ..core.settings import settings
from .price import sats_usd_price
-from .rates import is_usable_rate
+from .rates import coerce_rate, is_usable_rate
from .usage import normalize_usage, parse_token_count
if TYPE_CHECKING:
@@ -336,17 +336,11 @@ def _coerce_usd(value: object) -> float:
``0.0`` means "no usable figure" to every caller, which is the same thing an
absent field means, so the caller's existing ``> 0`` checks handle it.
"""
- if value is None or isinstance(value, bool):
- return 0.0
- if not isinstance(value, (int, float, str)):
- return 0.0
- try:
- # An oversized integer raises OverflowError, not ValueError.
- amount = float(value)
- except (TypeError, ValueError, OverflowError):
- return 0.0
- # A negative is rejected here, where the previous `max(0.0, …)` clamped it.
- return amount if is_usable_rate(amount) else 0.0
+ # A cost figure is coerced exactly like a rate; only the way an unusable one
+ # is reported differs. A negative is rejected here, where the previous
+ # `max(0.0, …)` clamped it.
+ amount = coerce_rate(value)
+ return amount if amount is not None else 0.0
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index 62773371..49ca89b1 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -12,7 +12,7 @@ from ..core.db import ModelRow, UpstreamProviderRow, get_session
from ..core.logging import get_logger
from ..core.settings import settings
from .price import sats_usd_price
-from .rates import BILLABLE_PRICING_FIELDS, is_usable_rate
+from .rates import BILLABLE_PRICING_FIELDS, coerce_rate, is_usable_rate
logger = get_logger(__name__)
@@ -163,19 +163,12 @@ def _has_valid_pricing(model: dict) -> bool:
if not pricing:
return False
- try:
- prompt = float(pricing.get("prompt", 0))
- completion = float(pricing.get("completion", 0))
- except (ValueError, TypeError, OverflowError):
- # An integer too large for a float raises OverflowError, not
- # ValueError, so it escaped this coercion guard and unwound the whole
- # fetch — one junk entry cost the node the entire upstream catalog.
- return False
-
- # `NaN`/`±inf` are not prices, and neither is caught by the checks below:
- # every comparison with `NaN` is False, and `inf` reads as a large positive
- # rate that would be advertised and billed on.
- if not is_usable_rate(prompt) or not is_usable_rate(completion):
+ # Coercion runs before the both-zero test below, which `NaN` would defeat
+ # on its own — and one entry the coercion chokes on must not unwind the
+ # whole fetch, which once cost the node an entire upstream catalog.
+ prompt = coerce_rate(pricing.get("prompt", 0))
+ completion = coerce_rate(pricing.get("completion", 0))
+ if prompt is None or completion is None:
return False
if prompt == 0 and completion == 0:
diff --git a/routstr/payment/price.py b/routstr/payment/price.py
index 48fc5b65..ae162398 100644
--- a/routstr/payment/price.py
+++ b/routstr/payment/price.py
@@ -5,7 +5,7 @@ import httpx
from ..core import get_logger
from ..core.settings import settings
-from .rates import is_usable_rate
+from .rates import coerce_rate
logger = get_logger(__name__)
@@ -19,40 +19,14 @@ def _parse_quote(raw: object, exchange: str) -> float | None:
Every quote passes through here because the aggregator takes the ``min()``
of what it collects: an unusable quote does not merely join the sample, it
*wins* it, and the result is the rate every model and every request on the
- node is priced at. A zero divides by zero on the USD cost path, ``NaN``
- raises out of the integer conversion in settlement, and a negative rate
- produces a negative charge that is credited back to the caller.
-
- A quote is stricter than a billable rate: it must be positive, since a BTC
- price of zero is a broken feed, not free money.
+ node is priced at. A quote is stricter than a billable rate — it must be
+ positive, since a BTC price of zero is a broken feed, not free money.
"""
- # `float(True)` is a finite, positive 1.0 that passes every guard below and
- # would then win the `min()`, pricing the node at one dollar per bitcoin.
- if isinstance(raw, bool):
- logger.warning(
- "Non-numeric price quote — ignoring this exchange",
- extra={"exchange": exchange, "quote": repr(raw)},
- )
- return None
-
- try:
- price = float(raw) # type: ignore[arg-type]
- except (TypeError, ValueError, OverflowError) as e:
- logger.warning(
- "Unparseable price quote — ignoring this exchange",
- extra={
- "error": str(e),
- "error_type": type(e).__name__,
- "exchange": exchange,
- "quote": repr(raw),
- },
- )
- return None
-
- if not is_usable_rate(price) or price <= 0:
+ price = coerce_rate(raw)
+ if price is None or price <= 0:
logger.warning(
"Unusable price quote — ignoring this exchange",
- extra={"exchange": exchange, "quote": price},
+ extra={"exchange": exchange, "quote": repr(raw)},
)
return None
diff --git a/routstr/payment/rates.py b/routstr/payment/rates.py
index 4ac74587..998f3ca5 100644
--- a/routstr/payment/rates.py
+++ b/routstr/payment/rates.py
@@ -46,3 +46,26 @@ def is_usable_rate(rate: float) -> bool:
they do with the answer, not why the answer matters.
"""
return math.isfinite(rate) and rate >= 0.0
+
+
+def coerce_rate(value: object) -> float | None:
+ """Coerce a value from outside the node to a usable rate, or ``None``.
+
+ The one coercion, shared by every reader of a rate the node did not compute
+ itself: an upstream catalog, the LiteLLM cost map, the exchange feed and the
+ admin write edge. Each of them was parsing for itself, and they disagreed —
+ which is how a boolean became a price on some paths and not others.
+
+ A boolean is rejected outright: it is a change of shape, not a rate, and
+ Python would make ``True`` a finite, positive ``1.0`` that passes every
+ numeric guard downstream — a dollar per token. A numeric string is accepted,
+ because feeds report prices as strings. An oversized integer raises
+ ``OverflowError`` rather than ``ValueError``, so that is caught too.
+ """
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+ return None
+ try:
+ rate = float(value)
+ except (TypeError, ValueError, OverflowError):
+ return None
+ return rate if is_usable_rate(rate) else None
diff --git a/routstr/upstream/pricing_resolver.py b/routstr/upstream/pricing_resolver.py
index b12a7f1e..8b58dddb 100644
--- a/routstr/upstream/pricing_resolver.py
+++ b/routstr/upstream/pricing_resolver.py
@@ -17,7 +17,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
-from ..payment.rates import is_usable_rate
+from ..payment.rates import coerce_rate
@dataclass
@@ -67,18 +67,8 @@ def estimate_context_length(model_id: str) -> int:
def _as_float(value: object) -> float | None:
- """OpenRouter reports prices as strings; coerce, ``None`` if not a real rate.
-
- Every caller reads a *price* out of a feed, so this asks the shared
- billable-rate question rather than merely parsing: ``float("Infinity")``,
- ``float("NaN")`` and a negative all parse cleanly from a feed string. An
- oversized integer raises ``OverflowError`` rather than ``ValueError``.
- """
- try:
- parsed = float(value) # type: ignore[arg-type]
- except (TypeError, ValueError, OverflowError):
- return None
- return parsed if is_usable_rate(parsed) else None
+ """OpenRouter reports prices as strings; coerce, ``None`` if not a real rate."""
+ return coerce_rate(value)
def _as_int(value: object) -> int | None:
@@ -95,18 +85,15 @@ def _from_litellm(model_id: str) -> ResolvedPricing | None:
if info is None:
return None
- prompt = info.get("input_cost_per_token")
- completion = info.get("output_cost_per_token")
- if not isinstance(prompt, (int, float)) or not isinstance(completion, (int, float)):
+ prompt = coerce_rate(info.get("input_cost_per_token"))
+ completion = coerce_rate(info.get("output_cost_per_token"))
+ if prompt is None or completion is None:
return None
# A both-zero entry is litellm listing a model without a real price (free
# moderation/rerank tiers do this) — treating 0/0 as resolved would serve
- # the model for free. Reject it (and any negative) so the caller falls
- # through, mirroring async_fetch_openrouter_models' _has_valid_pricing.
- # Checked before the both-zero guard below: `NaN` defeats that guard on its
- # own, since every comparison against it is False.
- if not is_usable_rate(prompt) or not is_usable_rate(completion):
- return None
+ # the model for free. Reject it so the caller falls through, mirroring
+ # async_fetch_openrouter_models' _has_valid_pricing. Coercion runs first:
+ # `NaN` would defeat this guard on its own, every comparison being False.
if prompt == 0 and completion == 0:
return None
@@ -115,8 +102,8 @@ def _from_litellm(model_id: str) -> ResolvedPricing | None:
input_modalities.append("image")
return ResolvedPricing(
- prompt=float(prompt),
- completion=float(completion),
+ prompt=prompt,
+ completion=completion,
# max_input_tokens is the context window; max_tokens is litellm's
# completion cap (it tracks max_output_tokens for ~94% of models), so
# it is never a context source. A missing window falls to the id-based
diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py
index 5b53b02a..5a3d06c4 100644
--- a/tests/integration/test_admin_pricing_rate_validation.py
+++ b/tests/integration/test_admin_pricing_rate_validation.py
@@ -148,6 +148,28 @@ async def test_malformed_price_string_is_rejected(
assert await integration_session.get(ModelRow, ("bad-price", provider_id)) is None
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_boolean_price_is_rejected(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """A JSON ``true`` coerces to a finite, positive ``1.0`` — a dollar per
+ token — so it passes every numeric guard. The write edge asks the same
+ coercion the catalog readers do, and answers a 422."""
+ provider_id = await _make_provider(integration_session)
+
+ resp = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=_admin_headers(),
+ json=_payload(
+ provider_id, model_id="bool-price", pricing=_pricing(prompt=True)
+ ),
+ )
+
+ assert resp.status_code == 422
+ assert await integration_session.get(ModelRow, ("bool-price", provider_id)) is None
+
+
@pytest.mark.integration
@pytest.mark.asyncio
async def test_numeric_string_price_is_still_accepted(
diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py
index d3656d25..fab4e6d5 100644
--- a/tests/unit/test_pricing_rate_validation.py
+++ b/tests/unit/test_pricing_rate_validation.py
@@ -442,3 +442,23 @@ async def test_oversized_catalog_rate_does_not_empty_the_catalog() -> None:
models = await async_fetch_openrouter_models()
assert [m["id"] for m in models] == ["good"]
+
+
+@pytest.mark.asyncio
+async def test_boolean_catalog_rate_is_not_imported() -> None:
+ """A JSON ``true`` is a change of shape, not a price.
+
+ Python coerces it to a finite, positive ``1.0`` — a dollar per token — so it
+ passes every numeric guard and must be rejected before coercion.
+ """
+ with _patch_openrouter_catalog(
+ [
+ _catalog_entry("bad", {"prompt": True, "completion": "0.000002"}),
+ _catalog_entry("good", {"prompt": "0.000001", "completion": "0.000002"}),
+ ]
+ ):
+ from routstr.payment.models import async_fetch_openrouter_models
+
+ models = await async_fetch_openrouter_models()
+
+ assert [m["id"] for m in models] == ["good"]
diff --git a/tests/unit/test_upstream_generic.py b/tests/unit/test_upstream_generic.py
index 04470a76..2e6fd56e 100644
--- a/tests/unit/test_upstream_generic.py
+++ b/tests/unit/test_upstream_generic.py
@@ -667,3 +667,60 @@ async def test_negative_openrouter_cache_rate_is_dropped_not_carried() -> None:
assert model.enabled is True
assert model.pricing.prompt == pytest.approx(1e-06)
assert model.pricing.input_cache_read == 0.0
+
+
+@pytest.mark.asyncio
+async def test_boolean_litellm_rate_is_not_a_resolved_price() -> None:
+ """``isinstance(True, int)`` is True, so a boolean passed the cost map's own
+ numeric check and resolved as a rate of ``1.0`` — a dollar per token."""
+ payload = {
+ "data": [
+ {"id": "bool-priced-model", "object": "model", "owned_by": "mystery"},
+ ]
+ }
+ cost_entry = {
+ "input_cost_per_token": True,
+ "output_cost_per_token": 2e-06,
+ "max_input_tokens": 8192,
+ }
+
+ with _patch_models_endpoint(payload):
+ or_feed = AsyncMock(return_value=[])
+ with patch("routstr.payment.models.litellm_cost_entry", lambda _id: cost_entry):
+ with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
+ models = await GenericUpstreamProvider(
+ base_url="http://x"
+ ).fetch_models()
+
+ model = _model_by_id(models, "bool-priced-model")
+ assert model.enabled is False
+ assert model.pricing.prompt == 0.0
+ assert model.pricing.completion == 0.0
+
+
+@pytest.mark.asyncio
+async def test_boolean_openrouter_rate_is_not_a_resolved_price() -> None:
+ """The same coercion reads a feed's ``true`` as a rate of ``1.0``; the model
+ must import disabled rather than priced at a dollar per token."""
+ payload = {
+ "data": [
+ {"id": "or-bool-xyz", "object": "model", "owned_by": "mystery"},
+ ]
+ }
+ feed = [
+ {
+ "id": "or-bool-xyz",
+ "pricing": {"prompt": True, "completion": "0.000002"},
+ "context_length": 8192,
+ }
+ ]
+
+ with _patch_models_endpoint(payload):
+ or_feed = AsyncMock(return_value=feed)
+ with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
+ models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
+
+ model = _model_by_id(models, "or-bool-xyz")
+ assert model.enabled is False
+ assert model.pricing.prompt == 0.0
+ assert model.pricing.completion == 0.0
From 337be4895eef2191e215e0e8701c34aef0b015ec Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Wed, 26 Aug 2026 15:47:44 +0200
Subject: [PATCH 045/120] fix(billing): a rate of zero is a price, not a
missing price
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The gate that decides a model cannot be priced on tokens asked
`input_rate and output_rate`, so a rate of zero read as an absent one and the
request was billed the whole reservation. The catalog serves such a model and
the router routes it — this PR says so in as many words — and then billing
charged it as if it had no price at all.
For a model that is free on both sides the reservation is the minimum-request
floor, so the overcharge is a msat. The bite is a model free on one side only:
the reservation there is the context window priced at the paid rate, charged
flat on every request no matter how few tokens it used. The catalog import
filter rejects only a both-zero price, so those models are ingested, served and
routed today.
Ask only whether each rate is usable. A zero rate now bills as what it is.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014X8RZzzbAuQCbavhFjTvJ4
---
routstr/payment/cost_calculation.py | 8 ++++----
tests/unit/test_pricing_rate_validation.py | 24 ++++++++++++++++++++++
2 files changed, 28 insertions(+), 4 deletions(-)
diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py
index e7cfdb87..a4ded317 100644
--- a/routstr/payment/cost_calculation.py
+++ b/routstr/payment/cost_calculation.py
@@ -270,11 +270,11 @@ async def calculate_cost(
input_rate, output_rate, cache_read_rate, cache_creation_rate = pricing_rates
# Truthiness is not the question: `NaN` and a negative rate are both truthy
- # and sailed past this gate into the token math.
+ # and sailed past this gate into the token math, while a rate of zero is a
+ # price — free — and reading it as a missing one charged the whole
+ # reservation for a request the model serves for nothing.
rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate)
- if not all(is_usable_rate(rate) for rate in rates) or not (
- input_rate and output_rate
- ):
+ if not all(is_usable_rate(rate) for rate in rates):
logger.warning(
"No usable token pricing — billing at flat MaxCostData. "
"Token counts %s in the upstream response but cannot be "
diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py
index fab4e6d5..f0390c9b 100644
--- a/tests/unit/test_pricing_rate_validation.py
+++ b/tests/unit/test_pricing_rate_validation.py
@@ -92,6 +92,30 @@ async def test_unusable_token_rate_falls_back_to_max_cost(bad_rate: float) -> No
assert cost.total_msats == 1234
+@pytest.mark.parametrize(
+ ("prompt", "completion", "expected_msats"),
+ [(0.0, 0.0, 0), (0.0, 2e-06, 1), (1e-06, 0.0, 1)],
+ ids=["free", "free-input", "free-output"],
+)
+@pytest.mark.asyncio
+async def test_a_rate_of_zero_is_billed_as_free_not_as_missing(
+ prompt: float, completion: float, expected_msats: int
+) -> None:
+ """Zero is a price, and the request must be billed on it.
+
+ The gate that decides a model has no token pricing was a truthiness test, so
+ a free rate read as an absent one and the request was charged the whole
+ reservation instead — on a model priced at zero for that side, which is a
+ price the catalog serves and the router routes.
+ """
+ model = _model(Pricing(prompt=prompt, completion=completion))
+
+ cost = await calculate_cost(_usage_response(), max_cost=1234, model_obj=model)
+
+ assert isinstance(cost, CostData)
+ assert cost.total_msats == expected_msats
+
+
@pytest.mark.parametrize(
"junk", [float("inf"), float("nan"), "Infinity"], ids=["inf", "nan", "inf-string"]
)
From 027bb3da31d39a32d9e79374a2eb383377c8f12b Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Wed, 26 Aug 2026 15:50:26 +0200
Subject: [PATCH 046/120] fix(pricing): reject an exchange quote that
underflows the sats price
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A quote of `1e-320` is finite and positive, so it passed the feed's guards and
then won the `min()` — but the node prices in sats, and `1e-320 / 100_000_000`
is `0.0`. A zero sats price divides by zero on every model's rate, so one
malformed feed could take the node's pricing down while two healthy quotes
stood beside it.
Require the quote to survive the conversion it is going to be put through, and
name the divisor while it has two call sites.
Also drops a line from the boolean-quote test that said this coercion was the
only one rejecting booleans; they all share one now.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014X8RZzzbAuQCbavhFjTvJ4
---
routstr/payment/price.py | 10 +++++++---
tests/unit/test_pricing_rate_validation.py | 23 +++++++++++++++++++---
2 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/routstr/payment/price.py b/routstr/payment/price.py
index ae162398..e63d6754 100644
--- a/routstr/payment/price.py
+++ b/routstr/payment/price.py
@@ -12,6 +12,8 @@ logger = get_logger(__name__)
BTC_USD_PRICE: float | None = None
SATS_USD_PRICE: float | None = None
+SATS_PER_BTC = 100_000_000
+
def _parse_quote(raw: object, exchange: str) -> float | None:
"""Coerce an exchange quote to a price, or ``None`` if it is not one.
@@ -20,10 +22,12 @@ def _parse_quote(raw: object, exchange: str) -> float | None:
of what it collects: an unusable quote does not merely join the sample, it
*wins* it, and the result is the rate every model and every request on the
node is priced at. A quote is stricter than a billable rate — it must be
- positive, since a BTC price of zero is a broken feed, not free money.
+ positive, and positive *after* the sats conversion the node prices in: a
+ subnormal quote survives every guard here and still underflows to a zero
+ sats price, which then divides by zero on every model's rate.
"""
price = coerce_rate(raw)
- if price is None or price <= 0:
+ if price is None or price <= 0 or price / SATS_PER_BTC <= 0:
logger.warning(
"Unusable price quote — ignoring this exchange",
extra={"exchange": exchange, "quote": repr(raw)},
@@ -141,7 +145,7 @@ async def _update_prices() -> None:
)
return
BTC_USD_PRICE = btc_price
- SATS_USD_PRICE = btc_price / 100_000_000
+ SATS_USD_PRICE = btc_price / SATS_PER_BTC
def btc_usd_price() -> float:
diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py
index f0390c9b..895fa06e 100644
--- a/tests/unit/test_pricing_rate_validation.py
+++ b/tests/unit/test_pricing_rate_validation.py
@@ -315,9 +315,7 @@ async def test_boolean_exchange_quote_does_not_set_the_node_price(
``float(True)`` is ``1.0``, which is finite and positive, so a payload whose
price field turned into a boolean passes every numeric guard — and then
- *wins* the ``min()``, pricing the whole node at one dollar per bitcoin. The
- node's other coercions all reject ``bool`` before the numeric check for this
- reason; this one is the exception.
+ *wins* the ``min()``, pricing the whole node at one dollar per bitcoin.
"""
from routstr.payment.price import btc_usd_price
@@ -328,6 +326,25 @@ async def test_boolean_exchange_quote_does_not_set_the_node_price(
assert btc_usd_price() == pytest.approx(100000.0)
+@pytest.mark.asyncio
+async def test_an_underflowing_exchange_quote_does_not_set_the_node_price(
+ refresh_price_with: Any,
+) -> None:
+ """A quote too small to survive the sats conversion is not a price.
+
+ ``1e-320`` is positive, so it passes the guards and wins the ``min()``, but
+ the node prices in sats and ``1e-320 / 100_000_000`` underflows to ``0.0``
+ — a zero sats price divides by zero on every model's rate.
+ """
+ from routstr.payment.price import btc_usd_price
+
+ await refresh_price_with(
+ {"kraken": "1e-320", "coinbase": "100000.0", "binance": "100000.0"}
+ )
+
+ assert btc_usd_price() == pytest.approx(100000.0)
+
+
@pytest.mark.asyncio
async def test_an_unreadable_exchange_response_drops_only_that_quote(
refresh_price_with: Any,
From 59bf8f4c9010ae2ed178b12b6cbbb08522f24f71 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Wed, 26 Aug 2026 15:54:49 +0200
Subject: [PATCH 047/120] fix(admin): a rate given as null is not an absent
rate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The write edge read each rate through `dict.get`, which returns `None` for an
absent key and for an explicit `null` alike, and skipped both. `Pricing`
declares `prompt` and `completion` without a default and every rate as a float,
so neither is a row the read path can parse: the write was accepted, the row
committed, and the response that reads it back raised — a 500 for a request
the edge had already acted on, leaving a model that cannot be served.
Ask whether the key is there, then whether its value is a rate. Which rates are
required is derived from `Pricing` itself rather than restated, so the two
cannot drift; an omitted auxiliary rate is still accepted, since those have
defaults.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014X8RZzzbAuQCbavhFjTvJ4
---
routstr/core/admin.py | 21 ++++---
routstr/payment/models.py | 7 +++
.../test_admin_pricing_rate_validation.py | 61 +++++++++++++++++++
3 files changed, 82 insertions(+), 7 deletions(-)
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index f02ec441..63fb44f6 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -12,6 +12,7 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..payment.models import (
+ REQUIRED_PRICING_FIELDS,
_row_to_model,
list_models,
)
@@ -528,26 +529,32 @@ class ModelCreate(BaseModel):
@field_validator("pricing")
@classmethod
def _validate_pricing(cls, value: dict[str, object]) -> dict[str, object]:
- """Reject a malformed, non-finite or negative billable rate at the edge.
+ """Reject a rate that is malformed, non-finite, negative or not there.
A present-but-invalid rate would otherwise slip through: a non-numeric
string coerces to $0 on the read path (an unpriced-looking row), while a
negative or ``NaN``/``inf`` value is truthy and reads back as a real
price, so the model could be enabled and bill a nonsensical amount.
Surfacing a 422 reports the client bug as a client bug instead of
- persisting it. Absent rates and numeric strings (``"0.000005"``) stay
- valid — the stored JSON accepts both.
+ persisting it. Numeric strings (``"0.000005"``) stay valid, and so does
+ an omitted auxiliary rate — the stored JSON accepts both.
"""
for field in BILLABLE_PRICING_FIELDS:
- raw = value.get(field)
- if raw is None:
+ if field not in value:
+ # ``dict.get`` cannot tell this from an explicit ``null``, so
+ # both were skipped and a row that ``Pricing`` cannot parse was
+ # written — and then raised out of the response that reads it
+ # back, after the row had been committed.
+ if field in REQUIRED_PRICING_FIELDS:
+ raise ValueError(f"{field} is required")
continue
# The shared coercion also absorbs the OverflowError an oversized
# integer raises, which pydantic does not convert into a validation
# error — unhandled it escaped as a 500 for a bad client value.
- if coerce_rate(raw) is None:
+ if coerce_rate(value[field]) is None:
raise ValueError(
- f"{field} must be a finite, non-negative number, got {raw!r}"
+ f"{field} must be a finite, non-negative number, "
+ f"got {value[field]!r}"
)
return value
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index 49ca89b1..e7b160fb 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -59,6 +59,13 @@ class Pricing(BaseModel):
max_cost: float = 0.0 # in sats not msats
+# The rates ``Pricing`` declares without a default, derived from the model so the
+# two cannot drift. A payload that omits one writes a row that will not parse.
+REQUIRED_PRICING_FIELDS = tuple(
+ name for name, field in Pricing.__fields__.items() if field.required
+)
+
+
def has_usable_pricing(pricing: Pricing) -> bool:
"""True if every billable rate is a number a request could be billed on.
diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py
index 5a3d06c4..86754544 100644
--- a/tests/integration/test_admin_pricing_rate_validation.py
+++ b/tests/integration/test_admin_pricing_rate_validation.py
@@ -170,6 +170,67 @@ async def test_boolean_price_is_rejected(
assert await integration_session.get(ModelRow, ("bool-price", provider_id)) is None
+@pytest.mark.integration
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("model_id", "pricing"),
+ [
+ ("null-prompt", _pricing(prompt=None)),
+ ("null-aux-rate", _pricing(image=None)),
+ ("no-prompt", {k: v for k, v in _pricing().items() if k != "prompt"}),
+ ],
+ ids=["null-required", "null-auxiliary", "absent-required"],
+)
+async def test_a_rate_that_is_not_there_is_rejected(
+ model_id: str,
+ pricing: dict[str, object],
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """A rate given as ``null``, or a required rate left out, is not a price.
+
+ The validator read both through ``dict.get``, which cannot tell an absent
+ key from an explicit ``null``, and skipped both. ``Pricing`` declares
+ ``prompt`` and ``completion`` without a default and every rate as a float,
+ so such a row is written with a 200 and then fails to parse on read — and
+ a row that will not parse is withheld from the catalog, leaving the operator
+ a model that was accepted and is nowhere to be seen.
+ """
+ provider_id = await _make_provider(integration_session)
+
+ resp = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=_admin_headers(),
+ json=_payload(provider_id, model_id=model_id, pricing=pricing),
+ )
+
+ assert resp.status_code == 422
+ assert await integration_session.get(ModelRow, (model_id, provider_id)) is None
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_an_absent_auxiliary_rate_is_still_accepted(
+ integration_client: AsyncClient, integration_session: AsyncSession
+) -> None:
+ """Only ``prompt`` and ``completion`` are required; the rest carry defaults,
+ and a payload that omits them must still be accepted."""
+ provider_id = await _make_provider(integration_session)
+
+ resp = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=_admin_headers(),
+ json=_payload(
+ provider_id,
+ model_id="lean-price",
+ pricing={"prompt": 1.4e-7, "completion": 2.8e-7},
+ ),
+ )
+
+ assert resp.status_code == 200
+ assert await integration_session.get(ModelRow, ("lean-price", provider_id))
+
+
@pytest.mark.integration
@pytest.mark.asyncio
async def test_numeric_string_price_is_still_accepted(
From 76d7b8fceb37f7a642a26d0246c34b727ebe16bc Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Wed, 26 Aug 2026 15:56:10 +0200
Subject: [PATCH 048/120] test(pricing): say once what a rate-validation test
pins
The failure history these tests were written against now lives in one place,
`payment/rates.py`. Cut the longest test docstrings down to what the test
itself pins, rather than restating that history at every boundary.
No test changes behaviour.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014X8RZzzbAuQCbavhFjTvJ4
---
.../test_admin_pricing_rate_validation.py | 18 ++++------
tests/unit/test_algorithm.py | 9 ++---
tests/unit/test_pricing_rate_validation.py | 34 ++++++-------------
3 files changed, 19 insertions(+), 42 deletions(-)
diff --git a/tests/integration/test_admin_pricing_rate_validation.py b/tests/integration/test_admin_pricing_rate_validation.py
index 86754544..2e9a45b9 100644
--- a/tests/integration/test_admin_pricing_rate_validation.py
+++ b/tests/integration/test_admin_pricing_rate_validation.py
@@ -189,12 +189,9 @@ async def test_a_rate_that_is_not_there_is_rejected(
) -> None:
"""A rate given as ``null``, or a required rate left out, is not a price.
- The validator read both through ``dict.get``, which cannot tell an absent
- key from an explicit ``null``, and skipped both. ``Pricing`` declares
- ``prompt`` and ``completion`` without a default and every rate as a float,
- so such a row is written with a 200 and then fails to parse on read — and
- a row that will not parse is withheld from the catalog, leaving the operator
- a model that was accepted and is nowhere to be seen.
+ ``dict.get`` cannot tell the two apart and skipped both, so a row
+ ``Pricing`` cannot parse was committed and the response that reads it back
+ raised.
"""
provider_id = await _make_provider(integration_session)
@@ -351,12 +348,9 @@ async def test_admin_model_listing_shows_a_non_finite_stored_rate(
) -> None:
"""The operator must be able to see the rate that needs fixing.
- The admin listing deliberately includes disabled models, so it is the one
- view that still carries a row the served-catalog backstop holds back.
- FastAPI's encoder rendered a stored ``Infinity`` rate as ``null``, which is
- indistinguishable from a rate the row never carried — the operator could see
- the row but not the reason it was withheld. Render the offending value as
- text instead, as the 422 handler already does.
+ The admin listing is the one view that still carries a row the served
+ catalog holds back, and its encoder rendered a stored ``Infinity`` as
+ ``null`` — indistinguishable from a rate the row never carried.
"""
provider_id = await _make_provider(integration_session)
integration_session.add(
diff --git a/tests/unit/test_algorithm.py b/tests/unit/test_algorithm.py
index cefc4f90..f5c875f9 100644
--- a/tests/unit/test_algorithm.py
+++ b/tests/unit/test_algorithm.py
@@ -1025,12 +1025,9 @@ def test_create_model_mappings_survives_an_unreadable_override_row(
) -> None:
"""One row that cannot be read must not empty the whole routing map.
- Stored pricing is JSON from whatever wrote the row, so converting it can
- raise. Converting an override while walking a provider's catalog let that
- exception unwind the entire map build: at boot the node came up routing
- nothing, and on a later refresh the map it already had went permanently
- stale. The sibling loop over override-only rows already skips and logs such
- a row.
+ Converting an override while walking a provider's catalog let the exception
+ unwind the entire map build: the node came up routing nothing. The sibling
+ loop over override-only rows already skips and logs such a row.
"""
broken = create_test_model("broken-model")
healthy = create_test_model("healthy-model")
diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py
index 895fa06e..b3e538c7 100644
--- a/tests/unit/test_pricing_rate_validation.py
+++ b/tests/unit/test_pricing_rate_validation.py
@@ -77,12 +77,9 @@ def _usage_response() -> dict[str, Any]:
async def test_unusable_token_rate_falls_back_to_max_cost(bad_rate: float) -> None:
"""An unusable configured rate must not be billed on.
- The "no token pricing configured" gate is a truthiness test, and ``NaN`` and
- negative floats are both truthy, so an unusable rate passes the guard that
- exists to catch it. It then reaches the integer conversion in the token math,
- which raises ``ValueError`` for ``NaN`` and ``OverflowError`` for ``inf`` —
- after the upstream response has already been served, where the streaming
- handlers swallow it and the request goes unbilled.
+ It reached the token math, which raises after the response was already
+ served — where the streaming handlers swallow it and the request goes
+ unbilled.
"""
model = _model(Pricing(prompt=bad_rate, completion=1.0))
@@ -124,11 +121,8 @@ async def test_junk_cost_component_still_bills_the_reported_total(junk: Any) ->
"""A malformed component must not discard the upstream's real total cost.
``cost_details`` only splits the total across input and output; the total is
- the authoritative billed amount. A non-finite component poisons the
- proportional allocation (``inf / inf`` is ``NaN``), which raised out of the
- USD path and was swallowed by the broad handler around it — so the request
- silently fell through to token-estimated pricing and was billed at a small
- fraction of what the upstream actually charged.
+ the authoritative billed amount. A non-finite component poisoned the split,
+ and the request fell through to token estimation for a fraction of it.
"""
model = _model(Pricing(prompt=1e-06, completion=2e-06))
response = {
@@ -289,14 +283,9 @@ async def test_unusable_exchange_quote_does_not_set_the_node_price(
) -> None:
"""One exchange returning junk must not set the price the node bills at.
- The feed takes the ``min()`` of the quotes it collects, so an unusable quote
- does not merely join the sample — it *wins*, and poisons the rate every model
- and every request is priced at until the next refresh. Zero then divides by
- zero on the USD path, ``NaN`` raises out of the integer conversion, and a
- negative rate produces a negative charge that settlement credits back to the
- caller.
-
- The two healthy quotes must still price the node.
+ The feed takes the ``min()`` of what it collects, so an unusable quote does
+ not merely join the sample — it *wins*. The two healthy quotes must still
+ price the node.
"""
from routstr.payment.price import btc_usd_price
@@ -351,11 +340,8 @@ async def test_an_unreadable_exchange_response_drops_only_that_quote(
) -> None:
"""An exchange whose response never yields a quote costs one quote.
- The price is aggregated across three exchanges precisely so that one of them
- having a bad day is survivable. A body that is not JSON, or whose shape moved
- under the reader, raises before any number is seen; unhandled, it aborted the
- whole aggregation and left the node on a stale rate even though two healthy
- quotes were already in hand.
+ The price is aggregated across three exchanges so that one of them having a
+ bad day is survivable; unhandled, the raise aborted the whole aggregation.
"""
from routstr.payment.price import btc_usd_price
From e8a8a87704514dd6b2e60d0dc5ef975a5d0945a4 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Wed, 26 Aug 2026 22:51:09 +0200
Subject: [PATCH 049/120] chore: trim redundant comments
---
.env.example | 2 +-
docs/adr/001-cashu-payment-safety.md | 21 ----------------
routstr/core/admin.py | 4 +--
routstr/core/db.py | 5 ----
routstr/core/exceptions.py | 2 --
routstr/core/settings.py | 4 ---
routstr/lightning.py | 5 +---
routstr/mint.py | 1 -
routstr/payment/lnurl.py | 25 ++++---------------
routstr/upstream/auto_topup.py | 13 ++--------
routstr/upstream/base.py | 10 +-------
routstr/upstream/ppqai.py | 8 ++----
routstr/wallet.py | 21 ++--------------
tests/integration/conftest.py | 1 -
.../integration/test_ppq_auto_topup_claim.py | 2 --
.../integration/test_wallet_authentication.py | 3 ---
tests/integration/test_wallet_melt_restart.py | 2 --
.../unit/test_lnurl_amount_and_destination.py | 2 --
tests/unit/test_lnurl_melt_timeout.py | 1 -
tests/unit/test_refund_no_retry.py | 2 --
20 files changed, 15 insertions(+), 119 deletions(-)
delete mode 100644 docs/adr/001-cashu-payment-safety.md
diff --git a/.env.example b/.env.example
index 5ad7a74d..265ca2c4 100644
--- a/.env.example
+++ b/.env.example
@@ -33,7 +33,7 @@ ROUTSTR_SECRET_KEY=
# DATABASE_POOL_PRE_PING=false
# Warn when a checkout is held this many seconds.
# DATABASE_POOL_HOLD_WARN_SECONDS=10
-# Seconds a file-backed SQLite writer waits for the write lock (default: 30).
+# SQLite write-lock timeout, in seconds.
# DATABASE_BUSY_TIMEOUT=30
# SQLite serialises writes; increasing its pool can trade pool timeouts for
# "database is locked" errors rather than increasing write throughput.
diff --git a/docs/adr/001-cashu-payment-safety.md b/docs/adr/001-cashu-payment-safety.md
deleted file mode 100644
index a2ed627f..00000000
--- a/docs/adr/001-cashu-payment-safety.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# ADR-001: Cashu payment safety boundaries
-
-## Status
-
-Accepted
-
-## Context
-
-Cashu proofs are bearer instruments. Retrying quote creation, refund delivery, or a dispatched Lightning melt can duplicate side effects or spend proofs whose outcome is still unknown. Mint transport failures and concurrent workers also need one shared policy.
-
-## Decision
-
-- Treat account, invoice, quote, melt, token-delivery, and refund creation as non-idempotent unless an upstream idempotency key is available.
-- A dispatched melt with an unknown outcome keeps a durable quote-linked proof reservation until later reconciliation confirms a terminal state. An immediate `unpaid` observation after transport loss is not terminal.
-- Size melts from the quote amount, reserve, and exact proof input fees within the caller's gross budget; do not use recursive send selection for melt planning.
-- Apply mint transport/rate cooldowns centrally and permit only explicit reconciliation probes during cooldown.
-- Auto-topups require fresh threshold confirmation, durable per-provider claims/cooldown, atomic spend-cap checks, and owner-only funds.
-
-## Consequences
-
-Transient failures can delay payouts/topups rather than risk duplicate payment. Operators may need to reconcile ambiguous claims. Tests must cover restart, concurrency, and partial-stream failures at these boundaries.
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index 2a4c7fb7..36a495a4 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -1367,9 +1367,7 @@ async def initiate_provider_topup(
else {}
)
- # This POST creates a Cashu mint quote upstream. Without an
- # idempotency key, retrying a timeout or 5xx can create a
- # second invoice while abandoning the first.
+ # Quote creation is unsafe to retry without idempotency.
resp = await client.post(
f"{clean_url}/v1/balance/lightning/invoice",
json=request_json,
diff --git a/routstr/core/db.py b/routstr/core/db.py
index 76539c45..14cc669e 100644
--- a/routstr/core/db.py
+++ b/routstr/core/db.py
@@ -39,11 +39,6 @@ def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine:
options: dict[str, int | float | bool] = {"pool_pre_ping": pool_pre_ping}
connect_args: dict[str, object] = {}
if is_sqlite and not is_memory_sqlite:
- # SQLite's default busy_timeout is only 5s, and aiosqlite does not set
- # one of its own. Without this, concurrent payment-settlement writes
- # across the pooled engine wait just 5s, then raise
- # sqlite3.OperationalError: database is locked. Give writers a real
- # chance to acquire the single SQLite write lock.
connect_args["timeout"] = settings.database_busy_timeout
if not is_memory_sqlite:
options.update(
diff --git a/routstr/core/exceptions.py b/routstr/core/exceptions.py
index 7e319a94..a096455c 100644
--- a/routstr/core/exceptions.py
+++ b/routstr/core/exceptions.py
@@ -40,8 +40,6 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
path = request.url.path
# 4xx is client behaviour; the uvicorn access log already records it.
- # Retryable mint outages are expected dependency failures, not application
- # faults, so keep them visible without flooding the error stream.
if status_code >= 500:
error_type = None
if isinstance(detail, dict):
diff --git a/routstr/core/settings.py b/routstr/core/settings.py
index 8f4caed8..7190f058 100644
--- a/routstr/core/settings.py
+++ b/routstr/core/settings.py
@@ -148,10 +148,6 @@ class Settings(BaseSettings):
database_pool_hold_warn_seconds: float = Field(
default=10.0, gt=0, env="DATABASE_POOL_HOLD_WARN_SECONDS"
)
- # SQLite busy_timeout (seconds): how long a writer waits on a locked DB
- # before raising "database is locked". Defaults to SQLite's 5s in stock
- # aiosqlite; raise it so concurrent payment-settlement writes can queue
- # instead of erroring. Referenced only by create_db_engine for SQLite.
database_busy_timeout: float = Field(
default=30.0, gt=0, env="DATABASE_BUSY_TIMEOUT"
)
diff --git a/routstr/lightning.py b/routstr/lightning.py
index 358e8515..13c6b321 100644
--- a/routstr/lightning.py
+++ b/routstr/lightning.py
@@ -225,8 +225,7 @@ async def _request_mint_with_fallback(
lambda: wallet.request_mint(amount_sats),
op_name="request_mint_invoice",
mint_url=mint_url,
- # Response loss may leave a valid quote at the mint. Creating a
- # second quote is not a safe retry without an idempotency key.
+ # Quote creation is unsafe to retry without idempotency.
retry_timeouts=False,
retry_on_rate_limit=False,
)
@@ -479,8 +478,6 @@ async def check_invoice_payment(
await session.commit()
mint_url = settlement.mint_url or settings.primary_mint
- # Quote status is remote state and does not inspect local proofs.
- # _mint_invoice_quote loads proofs exactly when settlement needs them.
wallet = await get_wallet(mint_url, "sat", load_proofs=False)
try:
mint_status = await run_mint_operation(
diff --git a/routstr/mint.py b/routstr/mint.py
index 8948bd4f..b09b497c 100644
--- a/routstr/mint.py
+++ b/routstr/mint.py
@@ -239,7 +239,6 @@ def mint_cooldown_reason(mint_url: str) -> str | None:
def is_mint_transport_error(error: BaseException) -> bool:
- """Return whether an exception chain contains a mint transport failure."""
current: BaseException | None = error
seen: set[int] = set()
while current is not None and id(current) not in seen:
diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py
index 3e391f8b..35f29f2a 100644
--- a/routstr/payment/lnurl.py
+++ b/routstr/payment/lnurl.py
@@ -107,7 +107,6 @@ async def _fetch_lnurl_json(
def _contains_mint_transport_error(error: BaseException) -> bool:
- """Detect transport failures wrapped by the Cashu wallet implementation."""
seen: set[int] = set()
current: BaseException | None = error
while current is not None and id(current) not in seen:
@@ -321,15 +320,10 @@ async def raw_send_to_lnurl(
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
)
- # Start at the caller's gross budget and converge downward from the mint's
- # exact reserve plus NUT-02 input fees. Starting below the budget with a
- # percentage heuristic silently underpays even when the exact fees are tiny.
final_amount = amount_msat
selected_proofs: list[Proof] | None = None
- # Fee reserves can change with the invoice amount. Each quote reduces the
- # candidate by its exact shortfall, so this bounded fixed-point search keeps
- # the largest amount the gross budget can fund without Cashu coin selection.
+ # Find the largest amount covered by the budget after reserve and input fees.
for _ in range(8):
if final_amount < lnurl_data["min_sendable"]:
raise LNURLError("Cashu melt fees leave no payable LNURL amount")
@@ -340,7 +334,7 @@ async def raw_send_to_lnurl(
lambda: wallet.melt_quote(invoice=bolt11_invoice),
op_name="lnurl_melt_quote",
mint_url=str(wallet.url),
- # Creating another quote after response loss only abandons the first.
+ # Quote creation is unsafe to retry without idempotency.
retry_timeouts=False,
)
@@ -392,10 +386,7 @@ async def raw_send_to_lnurl(
raise
if not _contains_mint_transport_error(error):
raise
- # Cashu 0.20 clears melt reservations before wrapping transport errors
- # in a plain Exception. Restore the durable melt association before
- # asking for quote state so these proofs cannot be spent again while
- # the Lightning outcome is unknown.
+ # Cashu clears reservations on transport errors despite an unknown outcome.
try:
await wallet.set_reserved_for_melt(
proofs, reserved=True, quote_id=melt_quote_resp.quote
@@ -414,8 +405,6 @@ async def raw_send_to_lnurl(
if melt_state == MeltQuoteState.paid:
return final_amount
if melt_state == MeltQuoteState.unpaid:
- # A direct unpaid response is authoritative: the mint rejected the melt
- # and Cashu has already cleared its quote-linked reservation.
await wallet.set_reserved_for_send(proofs, reserved=False)
raise LNURLError("Cashu mint confirmed that the melt was unpaid")
@@ -425,8 +414,7 @@ async def raw_send_to_lnurl(
op_name="reconcile_lnurl_melt_quote",
mint_url=str(wallet.url),
retry_timeouts=False,
- # One direct state lookup is required to reconcile the just-dispatched
- # melt even though its transport failure opened the mint cooldown.
+ # Reconciliation must bypass the cooldown opened by this failure.
allow_during_cooldown=True,
)
except Exception as reconciliation_error:
@@ -438,10 +426,7 @@ async def raw_send_to_lnurl(
if quote is not None and quote.state == MeltQuoteState.paid:
return final_amount
if quote is not None and quote.state == MeltQuoteState.unpaid:
- # Reaching reconciliation means melt was dispatched and either lost its
- # response or returned pending. A just-dispatched quote can briefly read
- # UNPAID before transitioning. Cashu clears the reservation while
- # refreshing that state, so restore it and require later reconciliation.
+ # A just-dispatched quote can briefly report unpaid before transitioning.
try:
await wallet.set_reserved_for_melt(
proofs, reserved=True, quote_id=melt_quote_resp.quote
diff --git a/routstr/upstream/auto_topup.py b/routstr/upstream/auto_topup.py
index 64d7d380..a73bdecf 100644
--- a/routstr/upstream/auto_topup.py
+++ b/routstr/upstream/auto_topup.py
@@ -366,9 +366,7 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
try:
async with wallet_operation_guard():
- # The cap, owner-liability check, proof reservation, and outgoing
- # audit row share one wallet mutation scope. The audit row must be
- # durable before another worker can recheck the rolling cap.
+ # Keep the spend cap and audit mutation in one wallet lock.
spent_24h_sats = await _routstr_spent_last_24h_sats()
if spent_24h_sats + amount > ROUTSTR_MAX_DAILY_TOPUP_SATS:
raise ValueError("Routstr auto top-up daily spend cap reached")
@@ -420,9 +418,6 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
await _release_routstr_claim(row, operation_id)
return
- # The audit row and SENT claim committed together before this network call,
- # so a worker crash cannot make reconciliation treat reserved proofs as an
- # unspent CLAIMED attempt.
result = await provider.topup(token)
if "error" in result:
@@ -713,7 +708,6 @@ async def _persist_routstr_token_and_mark_sent(
amount: int,
mint_url: str,
) -> None:
- """Commit the bearer-token audit row and SENT claim atomically."""
state_id = _routstr_state_id(row)
async with create_session() as session:
state = await session.get(CashuTransaction, state_id)
@@ -1123,8 +1117,6 @@ async def _set_ppq_state_terminal(
.values(
collected=collected,
swept=swept,
- # For successful payments this timestamps the durable cooldown,
- # not merely when the original claim was created.
created_at=int(time.time()) if collected else CashuTransaction.created_at,
)
)
@@ -1513,8 +1505,7 @@ async def _check_and_topup_ppq(row: UpstreamProviderRow, settings: dict) -> None
if balance >= threshold_usd:
return
- # A single stale/partial balance response must never create an invoice.
- # Read the uncached endpoint again and require independent agreement.
+ # Require two low-balance reads before creating an invoice.
confirmed_balance = await provider.get_balance()
if (
confirmed_balance is None
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index e0f0a4f9..8606f201 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -1076,9 +1076,6 @@ class BaseUpstreamProvider:
)
)
except Exception:
- # Preserve the original stream exception. If the database
- # cannot even be opened/read, stale-reservation cleanup is
- # the only safe recovery path.
logger.exception(
"Fallback stream billing recovery could not access the database",
extra={"key_hash": key.hashed_key[:8] + "..."},
@@ -1551,9 +1548,6 @@ class BaseUpstreamProvider:
)
)
except Exception:
- # Preserve the original stream exception. If the database
- # cannot even be opened/read, stale-reservation cleanup is
- # the only safe recovery path.
logger.exception(
"Fallback Responses billing recovery could not access the database",
extra={"key_hash": key.hashed_key[:8] + "..."},
@@ -3686,9 +3680,7 @@ class BaseUpstreamProvider:
)
try:
- # send_token may perform an irreversible Cashu swap to make exact
- # denominations. A blanket retry after response loss can dispatch
- # a second swap, so this call is intentionally single-attempt.
+ # Token creation may swap proofs, so it is unsafe to retry.
refund_token = await send_token(amount, unit=unit, mint_url=mint)
except Exception as error:
logger.error(
diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py
index d373d8e8..0eec7bfe 100644
--- a/routstr/upstream/ppqai.py
+++ b/routstr/upstream/ppqai.py
@@ -24,7 +24,7 @@ _PPQ_CIRCUIT_COOLDOWN_SECONDS = 30.0
class PPQCircuitOpenError(RuntimeError):
- """PPQ safe reads are suppressed until one probe is allowed."""
+ pass
@dataclass
@@ -51,12 +51,10 @@ async def _safe_read_request(
headers: dict[str, str],
json: dict[str, object] | None = None,
) -> httpx.Response:
- """Retry safe reads, then open one process-local circuit per PPQ origin."""
state = _ppq_circuits.setdefault(_ppq_origin(url), _PPQCircuitState())
loop = asyncio.get_running_loop()
if state.loop is not loop:
- # Runtime uses one long-lived loop; pytest and some embedded hosts do
- # not. Preserve circuit state while replacing a loop-bound lock.
+ # Locks cannot be reused across event loops.
state.lock = asyncio.Lock()
state.loop = loop
async with state.lock:
@@ -323,8 +321,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
return models
except Exception:
- # The base refresh handler preserves the last good model cache when
- # fetching raises; [] would look like a valid empty catalog.
raise
async def on_upstream_error_redirect(
diff --git a/routstr/wallet.py b/routstr/wallet.py
index 24072c74..0c0ecc7c 100644
--- a/routstr/wallet.py
+++ b/routstr/wallet.py
@@ -154,7 +154,6 @@ class Wallet(_CashuWallet):
*,
force_refresh: bool = False,
) -> None:
- """Load metadata once per mint URL, then hydrate unit wallets locally."""
mint_url = str(self.url)
lock = _mint_metadata_load_locks.setdefault(mint_url, asyncio.Lock())
async with lock:
@@ -171,8 +170,6 @@ class Wallet(_CashuWallet):
await self.load_mint_info(reload=False)
return
except Exception:
- # An empty/stale local cache is not authoritative. Fall
- # through to one remote refresh under the per-mint lock.
pass
await self.load_mint_keysets(force_old_keysets)
@@ -593,7 +590,6 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
async def send_token_from_owner_locked(
amount: int, unit: str, mint_url: str | None = None
) -> str:
- """Create an owner-funded token while the caller holds the wallet guard."""
_, token = await _send_locked(amount, unit, mint_url, owner_only=True)
return token
@@ -1866,10 +1862,7 @@ async def _credit_balance_locked(
_wallets: dict[str, Wallet] = {}
-# Proofs are local SQLite state and need a short refresh window because another
-# worker process can reserve or spend them. Mint metadata is remote, shared by
-# every operation on a wallet, and changes far less often; refreshing it on the
-# proof cadence caused repeated /keysets, /keys, and /info requests.
+# Proofs require a shorter refresh interval than remote mint metadata.
_wallet_last_load: dict[str, float] = {}
_wallet_last_mint_load: dict[str, float] = {}
_wallet_load_locks: dict[str, asyncio.Lock] = {}
@@ -1883,13 +1876,6 @@ async def get_wallet(
force_reload: bool = False,
load_proofs: bool = True,
) -> Wallet:
- """Return a cached wallet, refreshing remote and local state independently.
-
- ``load=False`` remains the fully offline path. Quote-only callers can use
- ``load_proofs=False``: mint metadata is initialized when needed, but local
- proofs are not re-read when the operation cannot spend or inspect them.
- ``force_reload`` still refreshes every requested layer immediately.
- """
global _wallets, _wallet_last_load, _wallet_last_mint_load, _wallet_load_locks
id = f"{mint_url}_{unit}"
lock = _wallet_load_locks.setdefault(id, asyncio.Lock())
@@ -1925,7 +1911,6 @@ async def get_wallet(
or now - last_proof_load
>= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS
):
- # cashu's load_proofs is local SQLite I/O, not a mint call.
await run_mint_operation(
lambda: _wallets[id].load_proofs(reload=True),
op_name="load_proofs",
@@ -2005,9 +1990,7 @@ async def _get_supported_mint_units(mint_url: str) -> list[str]:
if cached is not None and now < cached[0]:
return cached[1]
- # One full remote metadata load populates Cashu's shared SQLite keyset
- # cache. Discover all advertised units from that cache instead of issuing a
- # separate /keysets request before each unit wallet loads.
+ # A metadata load populates Cashu's shared keyset cache for all units.
wallet = await get_wallet(
mint_url,
settings.primary_mint_unit,
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 8dcd1876..7848e38c 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -73,7 +73,6 @@ from routstr.mint import MintRateGuard # noqa: E402
@pytest.fixture(autouse=True)
def isolate_mint_rate_guards() -> Iterator[None]:
- """Do not let one integration test's simulated outage poison the next."""
MintRateGuard._guards.clear()
yield
MintRateGuard._guards.clear()
diff --git a/tests/integration/test_ppq_auto_topup_claim.py b/tests/integration/test_ppq_auto_topup_claim.py
index 82c33c6f..05b99c03 100644
--- a/tests/integration/test_ppq_auto_topup_claim.py
+++ b/tests/integration/test_ppq_auto_topup_claim.py
@@ -313,8 +313,6 @@ async def test_ppq_payment_audit_row_is_visible_and_survives_next_claim(
assert audit["collected"] is True
assert "lnbc-secret-invoice" not in audit["token"]
- # The durable cooldown blocks an immediate duplicate, then the claim lock
- # can be reused without overwriting audit history after it expires.
assert await _claim_ppq_topup(_row()) is None
async with create_session() as session:
state = await session.get(CashuTransaction, _ppq_state_id_for_provider(1))
diff --git a/tests/integration/test_wallet_authentication.py b/tests/integration/test_wallet_authentication.py
index 2054f0af..26407701 100644
--- a/tests/integration/test_wallet_authentication.py
+++ b/tests/integration/test_wallet_authentication.py
@@ -114,8 +114,6 @@ async def test_api_key_generation_invalid_token(
async def test_duplicate_token_handling(
integration_client: AsyncClient, testmint_wallet: Any, db_snapshot: Any
) -> None:
- """Concurrent duplicate redemption credits once and deterministically replays."""
-
amount = 500
token = await testmint_wallet.mint_tokens(amount)
integration_client.headers["Authorization"] = f"Bearer {token}"
@@ -134,7 +132,6 @@ async def test_duplicate_token_handling(
assert api_key1 == api_key2
assert balance1 == balance2 == amount * 1000
- # The real DB contains one logical credit. A later replay is also mutation-free.
await db_snapshot.capture()
replay = await integration_client.get("/v1/wallet/info")
assert replay.status_code == 200
diff --git a/tests/integration/test_wallet_melt_restart.py b/tests/integration/test_wallet_melt_restart.py
index 6c28963c..9d75f542 100644
--- a/tests/integration/test_wallet_melt_restart.py
+++ b/tests/integration/test_wallet_melt_restart.py
@@ -95,7 +95,6 @@ async def test_melt_recovery_is_findable_by_quote_after_restart(
async def test_paid_reconciliation_invalidates_recovered_proofs_after_restart(
tmp_path: Path,
) -> None:
- """Actual Wallet.get_melt_quote consumes quote-linked proofs after restart."""
wallet = await _wallet(tmp_path)
await _seed_ambiguous_melt(wallet)
@@ -143,7 +142,6 @@ async def test_send_style_reservation_would_not_be_reconcilable(
async def test_unpaid_reconciliation_releases_recovered_proofs_after_restart(
tmp_path: Path,
) -> None:
- """Actual Wallet.get_melt_quote releases proofs after an unpaid answer."""
wallet = await _wallet(tmp_path)
await _seed_ambiguous_melt(wallet)
diff --git a/tests/unit/test_lnurl_amount_and_destination.py b/tests/unit/test_lnurl_amount_and_destination.py
index 53cb13df..97737cf7 100644
--- a/tests/unit/test_lnurl_amount_and_destination.py
+++ b/tests/unit/test_lnurl_amount_and_destination.py
@@ -28,7 +28,6 @@ LNURL_DATA = {
"max_sendable": 100_000_000,
}
-# The exact plan spends the 1000 sat gross budget as 999 sat + 1 sat reserve.
EXPECTED_QUOTE_SAT = 999
@@ -142,7 +141,6 @@ async def test_raw_send_to_lnurl_accepts_exact_invoice() -> None:
@pytest.mark.asyncio
async def test_raw_send_to_lnurl_msat_unit_compares_in_wallet_unit() -> None:
- # 1_000_000 msat gross minus the exact 1 msat reserve leaves 999_999 msat.
wallet, proofs = _wallet()
proofs[0].amount = 1_000_000
wallet.melt_quote = AsyncMock(
diff --git a/tests/unit/test_lnurl_melt_timeout.py b/tests/unit/test_lnurl_melt_timeout.py
index c517cbe2..ede3f5af 100644
--- a/tests/unit/test_lnurl_melt_timeout.py
+++ b/tests/unit/test_lnurl_melt_timeout.py
@@ -32,7 +32,6 @@ LNURL_DATA = {
}
-# Exact planning pays 999 sat from a 1000 sat budget with a 1 sat reserve.
QUOTE_AMOUNT_SAT = 999
diff --git a/tests/unit/test_refund_no_retry.py b/tests/unit/test_refund_no_retry.py
index f176755d..26ab0916 100644
--- a/tests/unit/test_refund_no_retry.py
+++ b/tests/unit/test_refund_no_retry.py
@@ -1,5 +1,3 @@
-"""Refund token issuance must not repeat an ambiguous Cashu swap."""
-
from unittest.mock import AsyncMock, patch
import httpx
From f4e02d4ee261cfffa04f3cce9562550dd8101872 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Wed, 26 Aug 2026 23:15:40 +0200
Subject: [PATCH 050/120] fix: address review findings on cooldowns, melt
sizing, and stream finalization
- open transport cooldown only after timeout retries are exhausted so
bounded retries keep their short backoff instead of waiting out a
full 30s cooldown in the rate guard
- shield streaming billing finalization and upstream connection close
from client-disconnect cancellation
- stop proof selection at the minimal covering set when over budget so
the re-quote shortfall is not inflated by extra input fees
- reuse is_mint_transport_error instead of a duplicate helper in lnurl
- ppqai: normalize circuit origin default ports, drop dead try/except
in fetch_models
---
routstr/mint.py | 9 +-
routstr/payment/lnurl.py | 23 +-
routstr/upstream/base.py | 59 +++--
routstr/upstream/ppqai.py | 203 ++++++++----------
.../unit/test_lnurl_amount_and_destination.py | 20 ++
tests/unit/test_mint.py | 48 +++++
6 files changed, 212 insertions(+), 150 deletions(-)
diff --git a/routstr/mint.py b/routstr/mint.py
index b09b497c..e1b60ca2 100644
--- a/routstr/mint.py
+++ b/routstr/mint.py
@@ -306,11 +306,8 @@ async def run_mint_operation(
except MintCooldownError:
raise
except (asyncio.TimeoutError, httpx.TimeoutException) as exc:
- if guard is not None:
- guard.apply_cooldown(
- MINT_TRANSPORT_COOLDOWN_SECONDS, reason="transport"
- )
if retry_timeouts and attempt < max_attempts - 1:
+ # Cooldown opens only after retries; earlier would stretch each backoff to a full cooldown wait.
backoff = (2**attempt) + (time.monotonic() % 1.0)
logger.warning(
"Mint operation timed out, retrying",
@@ -323,6 +320,10 @@ async def run_mint_operation(
)
await asyncio.sleep(backoff)
continue
+ if guard is not None:
+ guard.apply_cooldown(
+ MINT_TRANSPORT_COOLDOWN_SECONDS, reason="transport"
+ )
raise httpx.TimeoutException(
f"{op_name} timed out (attempts: {attempt + 1})"
) from exc
diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py
index 35f29f2a..def5bca7 100644
--- a/routstr/payment/lnurl.py
+++ b/routstr/payment/lnurl.py
@@ -9,8 +9,8 @@ from cashu.core.base import MeltQuoteState
from cashu.wallet.wallet import Proof, Wallet
from ..mint import (
- MINT_TRANSPORT_EXCEPTIONS,
is_mint_rate_limited,
+ is_mint_transport_error,
run_mint_operation,
)
@@ -106,17 +106,6 @@ async def _fetch_lnurl_json(
return data
-def _contains_mint_transport_error(error: BaseException) -> bool:
- seen: set[int] = set()
- current: BaseException | None = error
- while current is not None and id(current) not in seen:
- seen.add(id(current))
- if isinstance(current, MINT_TRANSPORT_EXCEPTIONS):
- return True
- current = current.__cause__ or current.__context__
- return False
-
-
async def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to get the actual URL.
@@ -260,8 +249,11 @@ def _select_melt_proofs(
selected_amount += proof.amount
input_fees = int(wallet.get_fees_for_proofs(selected))
required = quote_amount + fee_reserve + input_fees
- if required <= gross_budget and selected_amount >= required:
- return selected, 0
+ if selected_amount >= required:
+ if required <= gross_budget:
+ return selected, 0
+ # Covered but over budget; more proofs only raise input fees.
+ break
return None, max(1, required - min(selected_amount, gross_budget))
@@ -362,6 +354,7 @@ async def raw_send_to_lnurl(
if on_melt_quote is not None:
await on_melt_quote(melt_quote_resp.quote)
+ assert selected_proofs is not None
proofs = selected_proofs
await wallet.set_reserved_for_send(proofs, reserved=True)
@@ -384,7 +377,7 @@ async def raw_send_to_lnurl(
# reserved as though a Lightning payment could still settle.
await wallet.set_reserved_for_send(proofs, reserved=False)
raise
- if not _contains_mint_transport_error(error):
+ if not is_mint_transport_error(error):
raise
# Cashu clears reservations on transport errors despite an unknown outcome.
try:
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index 8606f201..8a8b06ea 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -7,7 +7,7 @@ import math
import traceback
import typing
import uuid
-from collections.abc import AsyncGenerator, AsyncIterator, Iterator
+from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterator
from typing import Any, Mapping, Self, cast
import httpx
@@ -82,6 +82,21 @@ async def _aclose_if_needed(resource: object | None) -> None:
await result
+async def _finalize_and_close_stream(
+ finalize: Callable[[], Awaitable[None]] | None,
+ response: object | None,
+ client: httpx.AsyncClient | None,
+) -> None:
+ try:
+ if finalize is not None:
+ await finalize()
+ finally:
+ try:
+ await _aclose_if_needed(response)
+ finally:
+ await _aclose_if_needed(client)
+
+
CostMetadata = CostData | MaxCostData | dict[str, Any]
@@ -1049,9 +1064,7 @@ class BaseUpstreamProvider:
return
try:
async with create_session() as new_session:
- fresh_key = await new_session.get(
- key.__class__, key.hashed_key
- )
+ fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
return
try:
@@ -1318,14 +1331,15 @@ class BaseUpstreamProvider:
)
raise
finally:
- try:
- if not usage_finalized:
- await finalize_db_only()
- finally:
- try:
- await _aclose_if_needed(response)
- finally:
- await _aclose_if_needed(client)
+ # Shielded so a client disconnect cannot cancel billing
+ # finalization or leak the upstream connection.
+ await asyncio.shield(
+ _finalize_and_close_stream(
+ None if usage_finalized else finalize_db_only,
+ response,
+ client,
+ )
+ )
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
@@ -1521,9 +1535,7 @@ class BaseUpstreamProvider:
return
try:
async with create_session() as new_session:
- fresh_key = await new_session.get(
- key.__class__, key.hashed_key
- )
+ fresh_key = await new_session.get(key.__class__, key.hashed_key)
if not fresh_key:
return
try:
@@ -1750,14 +1762,15 @@ class BaseUpstreamProvider:
)
raise
finally:
- try:
- if not usage_finalized:
- await finalize_db_only()
- finally:
- try:
- await _aclose_if_needed(response)
- finally:
- await _aclose_if_needed(client)
+ # Shielded so a client disconnect cannot cancel billing
+ # finalization or leak the upstream connection.
+ await asyncio.shield(
+ _finalize_and_close_stream(
+ None if usage_finalized else finalize_db_only,
+ response,
+ client,
+ )
+ )
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py
index 0eec7bfe..65ccbb57 100644
--- a/routstr/upstream/ppqai.py
+++ b/routstr/upstream/ppqai.py
@@ -40,7 +40,8 @@ _ppq_circuits: dict[str, _PPQCircuitState] = {}
def _ppq_origin(url: str) -> str:
parsed = httpx.URL(url)
- return f"{parsed.scheme}://{parsed.host}:{parsed.port}"
+ port = parsed.port or {"https": 443, "http": 80}.get(parsed.scheme, 0)
+ return f"{parsed.scheme}://{parsed.host}:{port}"
async def _safe_read_request(
@@ -66,9 +67,7 @@ async def _safe_read_request(
for attempt in range(1, _PPQ_SAFE_READ_ATTEMPTS + 1):
try:
- response = await client.request(
- method, url, headers=headers, json=json
- )
+ response = await client.request(method, url, headers=headers, json=json)
response.raise_for_status()
state.consecutive_failures = 0
state.cooldown_until = 0.0
@@ -209,119 +208,109 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
url = f"{self.base_url}/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
- try:
- async with httpx.AsyncClient(timeout=30.0) as client:
- response = await _safe_read_request(
- client, "GET", url, headers=headers
- )
- data = response.json()
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await _safe_read_request(client, "GET", url, headers=headers)
+ data = response.json()
- models_data = data.get("data", [])
+ models_data = data.get("data", [])
- or_models = [
- Model(**model) # type: ignore
- for model in await async_fetch_openrouter_models()
- ]
+ or_models = [
+ Model(**model) # type: ignore
+ for model in await async_fetch_openrouter_models()
+ ]
- models = []
- for model_data in models_data:
- try:
- ppqai_model = PPQAIModel.parse_obj(model_data)
- if ppqai_model.id in self.IGNORED_MODEL_IDS:
- continue
+ models = []
+ for model_data in models_data:
+ try:
+ ppqai_model = PPQAIModel.parse_obj(model_data)
+ if ppqai_model.id in self.IGNORED_MODEL_IDS:
+ continue
- or_model = next(
- (
- model
- for model in or_models
- if (model.id == ppqai_model.id)
- or (model.id.split("/")[-1] == ppqai_model.id)
- or (model.id == ppqai_model.id.split("/")[-1])
- ),
- None,
- )
+ or_model = next(
+ (
+ model
+ for model in or_models
+ if (model.id == ppqai_model.id)
+ or (model.id.split("/")[-1] == ppqai_model.id)
+ or (model.id == ppqai_model.id.split("/")[-1])
+ ),
+ None,
+ )
- if or_model:
- input_price = None
- if ppqai_model.pricing.api:
- input_price = ppqai_model.pricing.api.get(
- "input_per_1M"
- )
- elif ppqai_model.pricing.input_per_1M_tokens:
- input_price = ppqai_model.pricing.input_per_1M_tokens
+ if or_model:
+ input_price = None
+ if ppqai_model.pricing.api:
+ input_price = ppqai_model.pricing.api.get("input_per_1M")
+ elif ppqai_model.pricing.input_per_1M_tokens:
+ input_price = ppqai_model.pricing.input_per_1M_tokens
- if input_price is not None:
- or_model.pricing.prompt = input_price / 1_000_000
+ if input_price is not None:
+ or_model.pricing.prompt = input_price / 1_000_000
- output_price = None
- if ppqai_model.pricing.api:
- output_price = ppqai_model.pricing.api.get(
- "output_per_1M"
- )
- elif ppqai_model.pricing.output_per_1M_tokens:
- output_price = ppqai_model.pricing.output_per_1M_tokens
+ output_price = None
+ if ppqai_model.pricing.api:
+ output_price = ppqai_model.pricing.api.get("output_per_1M")
+ elif ppqai_model.pricing.output_per_1M_tokens:
+ output_price = ppqai_model.pricing.output_per_1M_tokens
- if output_price is not None:
- or_model.pricing.completion = output_price / 1_000_000
+ if output_price is not None:
+ or_model.pricing.completion = output_price / 1_000_000
- if cl := ppqai_model.context_length:
- or_model.context_length = cl
- models.append(or_model)
- else:
- input_price = 0.0
- if ppqai_model.pricing.api:
- input_price = ppqai_model.pricing.api.get(
- "input_per_1M", 0.0
- )
- elif ppqai_model.pricing.input_per_1M_tokens:
- input_price = ppqai_model.pricing.input_per_1M_tokens
-
- output_price = 0.0
- if ppqai_model.pricing.api:
- output_price = ppqai_model.pricing.api.get(
- "output_per_1M", 0.0
- )
- elif ppqai_model.pricing.output_per_1M_tokens:
- output_price = ppqai_model.pricing.output_per_1M_tokens
-
- models.append(
- Model(
- id=ppqai_model.id,
- name=ppqai_model.name,
- created=ppqai_model.created_at // 1000,
- description=f"{ppqai_model.provider or 'PPQ.AI'} model",
- context_length=ppqai_model.context_length,
- architecture=Architecture(
- modality="text->text",
- input_modalities=["text"],
- output_modalities=["text"],
- tokenizer="Unknown",
- instruct_type=None,
- ),
- pricing=Pricing(
- prompt=input_price / 1_000_000,
- completion=output_price / 1_000_000,
- request=0.0,
- image=0.0,
- web_search=0.0,
- internal_reasoning=0.0,
- ),
- )
+ if cl := ppqai_model.context_length:
+ or_model.context_length = cl
+ models.append(or_model)
+ else:
+ input_price = 0.0
+ if ppqai_model.pricing.api:
+ input_price = ppqai_model.pricing.api.get(
+ "input_per_1M", 0.0
+ )
+ elif ppqai_model.pricing.input_per_1M_tokens:
+ input_price = ppqai_model.pricing.input_per_1M_tokens
+
+ output_price = 0.0
+ if ppqai_model.pricing.api:
+ output_price = ppqai_model.pricing.api.get(
+ "output_per_1M", 0.0
+ )
+ elif ppqai_model.pricing.output_per_1M_tokens:
+ output_price = ppqai_model.pricing.output_per_1M_tokens
+
+ models.append(
+ Model(
+ id=ppqai_model.id,
+ name=ppqai_model.name,
+ created=ppqai_model.created_at // 1000,
+ description=f"{ppqai_model.provider or 'PPQ.AI'} model",
+ context_length=ppqai_model.context_length,
+ architecture=Architecture(
+ modality="text->text",
+ input_modalities=["text"],
+ output_modalities=["text"],
+ tokenizer="Unknown",
+ instruct_type=None,
+ ),
+ pricing=Pricing(
+ prompt=input_price / 1_000_000,
+ completion=output_price / 1_000_000,
+ request=0.0,
+ image=0.0,
+ web_search=0.0,
+ internal_reasoning=0.0,
+ ),
)
- except Exception as e:
- logger.warning(
- "Failed to parse PPQ.AI model",
- extra={
- "model_id": model_data.get("id", "unknown"),
- "error": str(e),
- "error_type": type(e).__name__,
- },
)
+ except Exception as e:
+ logger.warning(
+ "Failed to parse PPQ.AI model",
+ extra={
+ "model_id": model_data.get("id", "unknown"),
+ "error": str(e),
+ "error_type": type(e).__name__,
+ },
+ )
- return models
-
- except Exception:
- raise
+ return models
async def on_upstream_error_redirect(
self, status_code: int, error_message: str
@@ -443,9 +432,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
)
async with httpx.AsyncClient(timeout=30.0) as client:
- response = await _safe_read_request(
- client, "GET", url, headers=headers
- )
+ response = await _safe_read_request(client, "GET", url, headers=headers)
status_data = response.json()
is_paid = status_data.get("status") == "Settled"
diff --git a/tests/unit/test_lnurl_amount_and_destination.py b/tests/unit/test_lnurl_amount_and_destination.py
index 97737cf7..e722141a 100644
--- a/tests/unit/test_lnurl_amount_and_destination.py
+++ b/tests/unit/test_lnurl_amount_and_destination.py
@@ -345,3 +345,23 @@ async def test_send_to_lnurl_does_not_reserve_before_lnurl_validation() -> None:
assert raw_send.await_args is not None
assert raw_send.await_args.args[1] is proofs
assert raw_send.await_args.kwargs["amount"] == 1000
+
+
+def test_select_melt_proofs_stops_at_minimal_cover_when_over_budget() -> None:
+ from routstr.payment.lnurl import _select_melt_proofs
+
+ wallet = MagicMock()
+ wallet.get_fees_for_proofs = MagicMock(side_effect=lambda selected: len(selected))
+ proofs = [MagicMock(amount=600, reserved=False) for _ in range(3)]
+
+ selected, shortfall = _select_melt_proofs(
+ wallet,
+ proofs,
+ quote_amount=1000,
+ fee_reserve=0,
+ gross_budget=1000,
+ )
+
+ assert selected is None
+ assert shortfall == 2
+ assert wallet.get_fees_for_proofs.call_count == 2
diff --git a/tests/unit/test_mint.py b/tests/unit/test_mint.py
index dc58d10c..f6873fa4 100644
--- a/tests/unit/test_mint.py
+++ b/tests/unit/test_mint.py
@@ -132,6 +132,54 @@ async def test_wrapped_transport_failure_opens_central_cooldown() -> None:
MintRateGuard._guards.pop(mint_url, None)
+@pytest.mark.asyncio
+async def test_timeout_retry_succeeds_without_opening_cooldown() -> None:
+ from routstr.core.settings import settings
+
+ mint_url = "https://retryable-timeout.test"
+ MintRateGuard._guards.pop(mint_url, None)
+ calls = 0
+
+ async def flaky() -> str:
+ nonlocal calls
+ calls += 1
+ if calls == 1:
+ raise httpx.ReadTimeout("first attempt stalled")
+ return "ok"
+
+ with (
+ patch.object(settings, "mint_retry_max_attempts", 2),
+ patch("routstr.mint.asyncio.sleep", AsyncMock()),
+ ):
+ result = await run_mint_operation(flaky, mint_url=mint_url)
+
+ assert result == "ok"
+ assert calls == 2
+ assert MintRateGuard.get(mint_url).cooldown_remaining() == 0.0
+ MintRateGuard._guards.pop(mint_url, None)
+
+
+@pytest.mark.asyncio
+async def test_exhausted_timeout_retries_open_transport_cooldown() -> None:
+ from routstr.core.settings import settings
+
+ mint_url = "https://exhausted-timeout.test"
+ MintRateGuard._guards.pop(mint_url, None)
+
+ async def always_timeout() -> None:
+ raise httpx.ReadTimeout("stalled")
+
+ with (
+ patch.object(settings, "mint_retry_max_attempts", 1),
+ patch("routstr.mint.asyncio.sleep", AsyncMock()),
+ pytest.raises(httpx.TimeoutException),
+ ):
+ await run_mint_operation(always_timeout, mint_url=mint_url)
+
+ assert MintRateGuard.get(mint_url).cooldown_remaining() > 29
+ MintRateGuard._guards.pop(mint_url, None)
+
+
async def test_guard_concurrency_change_preserves_cooldown_state() -> None:
from routstr.core.settings import settings
From 5f31c49eef338c21ea371b449b284cee05e3975c Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Thu, 27 Aug 2026 00:41:26 +0200
Subject: [PATCH 051/120] better price estimation
---
docs/client/payments.md | 5 +-
docs/ehbp-proxy-support.md | 6 +-
docs/tinfoil-direct-integration.md | 15 +-
routstr/core/logging.py | 2 +-
routstr/payment/cost_calculation.py | 48 ++---
routstr/upstream/base.py | 119 ++++++++-----
routstr/upstream/count_tokens.py | 168 +++++++++++++++++-
routstr/upstream/ehbp.py | 109 +++---------
.../test_free_response_stale_reservation.py | 18 +-
tests/unit/test_count_tokens_local.py | 90 +++++++++-
tests/unit/test_ehbp_finalize_payment.py | 18 +-
tests/unit/test_pricing_rate_validation.py | 14 +-
.../test_streaming_billing_finalization.py | 54 ++++--
tests/unit/test_tinfoil_integration.py | 8 +-
.../test_x_cashu_responses_streaming_sse.py | 8 +-
15 files changed, 456 insertions(+), 226 deletions(-)
diff --git a/docs/client/payments.md b/docs/client/payments.md
index 93ac35a7..c108c1a0 100644
--- a/docs/client/payments.md
+++ b/docs/client/payments.md
@@ -38,8 +38,9 @@ Every time you make a request to `/v1/chat/completions` (or others), the cost is
`Cost = (Input_Tokens * Price_Input) + (Output_Tokens * Price_Output) + Request_Fee`
- Prices are defined per model (see `/v1/models`).
-- If you stream the response, the balance is deducted incrementally or finalized at the end of the stream.
-- If your balance hits 0 mid-stream, the connection is closed.
+- Routstr reserves an authorization ceiling before forwarding, then finalizes the request at measured token cost.
+- If a successful upstream omits usage, Routstr estimates input tokens from the provider-bound request and output tokens from the returned body or streamed deltas, then applies normal model pricing.
+- A reservation is only a temporary hold. Missing usage or unusable prices must never turn the full reservation into the charge; if no auditable estimate can be priced, the reservation is released without charge.
### Headers
diff --git a/docs/ehbp-proxy-support.md b/docs/ehbp-proxy-support.md
index f9edc6e0..c5bc89d1 100644
--- a/docs/ehbp-proxy-support.md
+++ b/docs/ehbp-proxy-support.md
@@ -58,10 +58,10 @@ Contains the shared opaque EHBP transport and billing helpers:
- `EHBPForwardingTarget` — provider-specific target URL plus extra headers
- `forward_ehbp_request()` — forwards the encrypted body, captures Tinfoil
usage from a response header or streaming HTTP trailer, and finalizes bearer
- billing at actual cost (falling back to max cost when usage is unavailable)
+ billing at actual cost (releasing the reservation when usage is unavailable)
- `forward_ehbp_x_cashu_request()` — redeems the Cashu token, refunds the full
token on upstream failure, and refunds the difference between the redeemed
- amount and actual cost (or max cost when usage is unavailable)
+ amount and actual cost (or the full amount when usage is unavailable)
### Provider support
@@ -84,7 +84,7 @@ The proxy is a **blind relay** for EHBP requests. It cannot decrypt the body
Cost tracking happens at the proxy level. Routstr reserves or redeems up to
`max_cost_for_model`, then Tinfoil's out-of-band usage header/trailer allows it
to finalize at actual token cost. If trusted usage is missing or invalid, the
-proxy safely falls back to max-cost billing.
+proxy releases/refunds rather than treating the authorization ceiling as usage.
## End-to-end flow
diff --git a/docs/tinfoil-direct-integration.md b/docs/tinfoil-direct-integration.md
index 9fcc6b00..d41228ec 100644
--- a/docs/tinfoil-direct-integration.md
+++ b/docs/tinfoil-direct-integration.md
@@ -197,9 +197,9 @@ Properties:
This is the only architecture that preserves end-to-end encryption from the user to the PPQ/Tinfoil enclave while still letting Routstr mediate payment. The key requirement is that usage/cost metadata must be returned outside the encrypted body, ideally as a response header available before body streaming begins.
-## Current Routstr problem
+## Original Routstr problem
-The current EHBP implementation charges successful EHBP requests at `max_cost_for_model` because Routstr cannot decrypt the response body:
+The original EHBP implementation charged successful EHBP requests at `max_cost_for_model` because Routstr could not decrypt the response body:
```text
successful EHBP request -> charge full reserved max cost
@@ -369,7 +369,7 @@ Possible approaches:
- PPQ private models are billed per actual input/output tokens.
- Private model rates are available from `GET /v1/models?type=all`.
-- Current Routstr EHBP billing at max cost is wrong for PPQ private models.
+- Max-cost EHBP fallback is wrong for PPQ private models; current code releases/refunds when trusted usage metadata is absent.
- Direct Tinfoil integration inside Routstr would enable exact usage billing but would make Routstr see plaintext.
- A blind EHBP relay preserves privacy but requires PPQ/Tinfoil to expose usage/cost in plaintext headers/trailers.
- The preferred solution is to keep Routstr blind and have PPQ return billing metadata outside the encrypted body.
@@ -410,8 +410,9 @@ and `routstr/upstream/ehbp.py`.
actual served model's pricing is used for cost calculation.
- `forward_ehbp_request()` (bearer auth): if `X-Tinfoil-Usage-Metrics` is
present in the response header, finalizes with `adjust_payment_for_tokens()`
- for exact billing; otherwise falls back to max-cost. Billing uses the
- actual served model when it differs from the requested one.
+ for exact billing; otherwise releases the reservation. The encrypted body
+ cannot be estimated locally, and the authorization ceiling is not billed.
+ Billing uses the actual served model when it differs from the requested one.
- `forward_ehbp_x_cashu_request()`: if usage is available, computes the
refund from actual cost instead of max cost, using the actual served
model's pricing when applicable.
@@ -425,10 +426,10 @@ and `routstr/upstream/ehbp.py`.
|---|---|---|
| Bearer, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Exact token cost via `adjust_payment_for_tokens` |
| Bearer, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Exact token cost (h11 captures trailers) |
-| Bearer, no usage header/trailer | N/A | Max-cost fallback |
+| Bearer, no usage header/trailer | N/A | Release reservation; zero charge |
| X-Cashu, non-streaming | `X-Tinfoil-Usage-Metrics` response header | Refund = `redeemed - actual_cost` |
| X-Cashu, streaming | `X-Tinfoil-Usage-Metrics` HTTP trailer | Refund = `redeemed - actual_cost` (h11 captures trailers) |
-| X-Cashu, no usage header/trailer | N/A | Refund = `redeemed - max_cost` |
+| X-Cashu, no usage header/trailer | N/A | Full refund |
### Cost response headers
diff --git a/routstr/core/logging.py b/routstr/core/logging.py
index c6840fb8..61c96607 100644
--- a/routstr/core/logging.py
+++ b/routstr/core/logging.py
@@ -16,7 +16,7 @@ DO NOT modify or remove these messages without updating the usage tracking logic
- The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
3. "Max cost payment finalized" (INFO) - routstr/auth.py
- - Used as the successful completion fallback when token usage is unavailable
+ - Used for explicit flat-price/MaxCostData settlements; missing usage alone must not create this charge
- The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
4. "Payment processed successfully" (INFO) - routstr/auth.py
diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py
index a4ded317..f28ec6e3 100644
--- a/routstr/payment/cost_calculation.py
+++ b/routstr/payment/cost_calculation.py
@@ -70,30 +70,6 @@ def _empty_cost(cls: type[CostData] = CostData) -> CostData:
)
-def _unmeasured_cost(max_cost: int) -> MaxCostData:
- """Build the bounded fallback for a response whose usage cannot be measured.
-
- Missing usage must NOT settle at zero — that hands out free inference. The
- request was authorized up to ``max_cost`` (the reservation), so the safe,
- bounded settlement is to charge exactly that. Token components stay zero
- because they are genuinely unknown; ``total_msats`` carries the authorized
- max so max-cost finalization debits the reservation instead of nothing.
- """
- return MaxCostData(
- base_msats=0,
- input_msats=0,
- output_msats=0,
- total_msats=max(0, max_cost),
- total_usd=0.0,
- input_tokens=0,
- output_tokens=0,
- cache_read_input_tokens=0,
- cache_creation_input_tokens=0,
- cache_read_msats=0,
- cache_creation_msats=0,
- )
-
-
async def calculate_cost(
response_data: dict,
max_cost: int,
@@ -134,11 +110,11 @@ async def calculate_cost(
if usage is None:
logger.warning(
- "No usage data in response — settling at the reserved max cost "
- "(bounded fallback), not zero. Dashboard will show this request "
- "as `(0+0)` tokens. Most common cause: upstream stream did not "
- "include a final usage chunk (OpenAI-compat backends require "
- "`stream_options.include_usage=true`).",
+ "No usage data or local estimate in response — releasing the "
+ "reservation without charging it as usage. Dashboard will show "
+ "this request as `(0+0)` tokens. Most common cause: upstream "
+ "stream did not include a final usage chunk (OpenAI-compat "
+ "backends require `stream_options.include_usage=true`).",
extra={
"max_cost_msats": max_cost,
"model": response_data.get("model", "unknown"),
@@ -147,7 +123,7 @@ async def calculate_cost(
else None,
},
)
- return _unmeasured_cost(max_cost)
+ return _empty_cost(MaxCostData)
usage_data = response_data.get("usage") or {}
if not isinstance(usage_data, dict):
@@ -276,10 +252,10 @@ async def calculate_cost(
rates = (input_rate, output_rate, cache_read_rate, cache_creation_rate)
if not all(is_usable_rate(rate) for rate in rates):
logger.warning(
- "No usable token pricing — billing at flat MaxCostData. "
- "Token counts %s in the upstream response but cannot be "
- "priced; the request will appear in dashboards with the "
- "raw counts and a fixed max-cost charge.",
+ "No usable token pricing — releasing the reservation instead of "
+ "treating its ceiling as the charge. Token counts %s in the "
+ "upstream response but cannot be converted to money; the request "
+ "will appear in dashboards with raw counts and a zero charge.",
"are present" if (input_tokens > 0 or output_tokens > 0) else "are zero",
extra={
"base_cost_msats": max_cost,
@@ -291,10 +267,10 @@ async def calculate_cost(
},
)
return MaxCostData(
- base_msats=max_cost,
+ base_msats=0,
input_msats=0,
output_msats=0,
- total_msats=max_cost,
+ total_msats=0,
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_input_tokens=cache_read_tokens,
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index 8a8b06ea..24f4d165 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -61,7 +61,7 @@ from .cache_breakpoints import (
inject_anthropic_cache_breakpoints,
is_explicit_cache_model,
)
-from .count_tokens import count_tokens_locally
+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
@@ -705,8 +705,8 @@ class BaseUpstreamProvider:
# OpenAI-compatible streaming responses omit ``usage`` unless the
# request sets ``stream_options.include_usage = true``. Without it
- # we can't reconcile token counts at end of stream and the
- # request gets billed at max-cost with zero tokens. Discriminate
+ # we can't reconcile token counts at end of stream and must use
+ # the local request/response estimator. Discriminate
# chat-completions-shaped requests by the ``messages`` field so we
# don't poke unrelated endpoints.
if (
@@ -1021,6 +1021,7 @@ class BaseUpstreamProvider:
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
client: httpx.AsyncClient | None = None,
+ request_body: bytes | None = None,
) -> StreamingResponse:
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
@@ -1041,6 +1042,8 @@ class BaseUpstreamProvider:
snapshot_key, snapshot_session
)
+ usage_estimator = MissingUsageEstimator(request_body, model_obj)
+
logger.debug(
"Processing streaming chat completion",
extra={
@@ -1070,7 +1073,7 @@ class BaseUpstreamProvider:
try:
await adjust_payment_for_tokens(
fresh_key,
- {"model": last_model_seen or "unknown", "usage": None},
+ usage_estimator.response_data(last_model_seen),
new_session,
max_cost_for_model,
model_obj,
@@ -1157,6 +1160,7 @@ class BaseUpstreamProvider:
obj = None
if isinstance(obj, dict):
+ usage_estimator.observe(obj)
self._apply_provider_field(obj)
if obj.get("model"):
last_model_seen = str(obj.get("model"))
@@ -1246,13 +1250,8 @@ class BaseUpstreamProvider:
if fresh_key:
cost_data: dict
try:
- adjustment_input = (
- usage_chunk_data
- if usage_chunk_data is not None
- else {
- "model": last_model_seen or "unknown",
- "usage": None,
- }
+ adjustment_input = usage_estimator.billing_data(
+ usage_chunk_data, last_model_seen
)
cost_data = await adjust_payment_for_tokens(
fresh_key,
@@ -1361,6 +1360,7 @@ class BaseUpstreamProvider:
requested_model: str | None = None,
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
+ request_body: bytes | None = None,
) -> Response:
"""Handle non-streaming chat completion responses with token usage tracking and cost adjustment.
@@ -1402,6 +1402,13 @@ class BaseUpstreamProvider:
if "id" not in response_json or not isinstance(response_json["id"], str):
response_json["id"] = f"chatcmpl-{uuid.uuid4()}"
+ if not isinstance(response_json.get("usage"), dict):
+ usage_estimator = MissingUsageEstimator(request_body, model_obj)
+ usage_estimator.observe(response_json)
+ response_json["usage"] = usage_estimator.openai_response_data(
+ response_json.get("model")
+ )["usage"]
+
cost_data = await adjust_payment_for_tokens(
key,
response_json,
@@ -1500,6 +1507,7 @@ class BaseUpstreamProvider:
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
client: httpx.AsyncClient | None = None,
+ request_body: bytes | None = None,
) -> StreamingResponse:
"""Handle streaming Responses API responses with token usage tracking and cost adjustment.
@@ -1511,6 +1519,8 @@ class BaseUpstreamProvider:
Returns:
StreamingResponse with cost data injected at the end
"""
+ usage_estimator = MissingUsageEstimator(request_body, model_obj)
+
logger.debug(
"Processing streaming Responses API completion",
extra={
@@ -1541,7 +1551,7 @@ class BaseUpstreamProvider:
try:
await adjust_payment_for_tokens(
fresh_key,
- {"model": last_model_seen or "unknown", "usage": None},
+ usage_estimator.response_data(last_model_seen),
new_session,
max_cost_for_model,
model_obj,
@@ -1633,8 +1643,11 @@ class BaseUpstreamProvider:
"response.incomplete",
):
usage_chunk_data = obj
+ if not usage_estimator.output_text:
+ usage_estimator.observe(obj)
return
+ usage_estimator.observe(obj)
yield prefix + b"data: " + json.dumps(obj).encode() + b"\n\n"
else:
if final:
@@ -1674,13 +1687,8 @@ class BaseUpstreamProvider:
if fresh_key:
cost_data: dict
try:
- adjustment_input = (
- usage_chunk_data
- if usage_chunk_data is not None
- else {
- "model": last_model_seen or "unknown",
- "usage": None,
- }
+ adjustment_input = usage_estimator.billing_data(
+ usage_chunk_data, last_model_seen
)
cost_data = await adjust_payment_for_tokens(
fresh_key,
@@ -1792,6 +1800,7 @@ class BaseUpstreamProvider:
requested_model: str | None = None,
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
+ request_body: bytes | None = None,
) -> Response:
"""Handle non-streaming Responses API responses with token usage tracking and cost adjustment.
@@ -1831,6 +1840,13 @@ class BaseUpstreamProvider:
},
)
+ if not isinstance(response_json.get("usage"), dict):
+ usage_estimator = MissingUsageEstimator(request_body, model_obj)
+ usage_estimator.observe(response_json)
+ response_json["usage"] = usage_estimator.response_data(
+ response_json.get("model")
+ )["usage"]
+
if requested_model:
response_json["model"] = requested_model
if "id" not in response_json or not isinstance(response_json["id"], str):
@@ -1945,9 +1961,9 @@ class BaseUpstreamProvider:
return
try:
- # Finalize with "unknown" model and no usage to release reservation/charge max cost
- # (no routed identity here by design: the None usage settles at
- # MaxCostData before any pricing lookup can happen).
+ # Generic opaque streams have no request/response token seam.
+ # Missing usage therefore releases the reservation; the hold is
+ # never treated as evidence of consumption.
await adjust_payment_for_tokens(
key,
{"model": "unknown", "usage": None},
@@ -1982,7 +1998,10 @@ class BaseUpstreamProvider:
requested_model: str | None = None,
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
+ request_body: bytes | None = None,
) -> StreamingResponse:
+ usage_estimator = MissingUsageEstimator(request_body, model_obj)
+
async def stream_with_cost(
max_cost_for_model: int,
) -> AsyncGenerator[bytes, None]:
@@ -2036,13 +2055,9 @@ class BaseUpstreamProvider:
usage_finalized = True
return None
try:
- fallback: dict = {
- "model": last_model_seen or "unknown",
- "usage": None,
- }
cost_data = await adjust_payment_for_tokens(
fresh_key,
- fallback,
+ usage_estimator.response_data(last_model_seen),
new_session,
max_cost_for_model,
model_obj,
@@ -2081,6 +2096,7 @@ class BaseUpstreamProvider:
try:
data = json.loads(line[6:])
if isinstance(data, dict):
+ usage_estimator.observe(data)
msg = data.get("message", {})
if msg and msg.get("model"):
last_model_seen = str(msg.get("model"))
@@ -2278,6 +2294,7 @@ class BaseUpstreamProvider:
requested_model: str | None = None,
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
+ request_body: bytes | None = None,
) -> Response:
try:
content = await response.aread()
@@ -2296,6 +2313,12 @@ class BaseUpstreamProvider:
if path.endswith("count_tokens") and "usage" not in response_json:
input_tokens = response_json.get("input_tokens", 0)
response_json["usage"] = {"input_tokens": input_tokens}
+ elif not isinstance(response_json.get("usage"), dict):
+ usage_estimator = MissingUsageEstimator(request_body, model_obj)
+ usage_estimator.observe(response_json)
+ response_json["usage"] = usage_estimator.response_data(
+ response_json.get("model")
+ )["usage"]
cost_data = await adjust_payment_for_tokens(
key,
@@ -2403,11 +2426,18 @@ class BaseUpstreamProvider:
requested_model,
model_obj,
reservation_snapshot,
+ request_body,
)
response_json = messages_dispatch.coerce_litellm_payload(result)
if requested_model and "model" in response_json:
response_json["model"] = requested_model
+ if not isinstance(response_json.get("usage"), dict):
+ usage_estimator = MissingUsageEstimator(request_body, model_obj)
+ usage_estimator.observe(response_json)
+ response_json["usage"] = usage_estimator.response_data(
+ response_json.get("model")
+ )["usage"]
cost_data = await adjust_payment_for_tokens(
key,
@@ -2521,10 +2551,13 @@ class BaseUpstreamProvider:
requested_model: str | None,
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
+ request_body: bytes | None = None,
) -> StreamingResponse:
"""Re-emit a litellm Anthropic-event iterator as live SSE bytes
with cost reconciliation appended at end of stream."""
+ usage_estimator = MissingUsageEstimator(request_body, model_obj)
+
async def stream_with_cost() -> AsyncGenerator[bytes, None]:
usage_finalized = False
last_model_seen: str | None = None
@@ -2541,12 +2574,10 @@ class BaseUpstreamProvider:
if usage_finalized:
return None
logger.warning(
- "Finalizing /v1/messages stream with no usage data — "
- "client will be billed at max-cost with zero tokens. "
- "Likely cause: upstream omitted `usage` from the SSE "
- "stream (check that the request includes "
- "`stream_options.include_usage=true` and that the "
- "upstream actually emits a final usage chunk).",
+ "Finalizing /v1/messages stream with locally estimated "
+ "usage because the upstream omitted `usage` from SSE. "
+ "Check that the upstream emits a final usage chunk; the "
+ "reservation ceiling will not be used as the charge.",
extra={
"key_hash": key.hashed_key[:8] + "...",
"model": last_model_seen or "unknown",
@@ -2560,13 +2591,9 @@ class BaseUpstreamProvider:
usage_finalized = True
return None
try:
- fallback: dict = {
- "model": last_model_seen or "unknown",
- "usage": None,
- }
cost_data = await adjust_payment_for_tokens(
fresh_key,
- fallback,
+ usage_estimator.response_data(last_model_seen),
new_session,
max_cost_for_model,
model_obj,
@@ -2599,6 +2626,7 @@ class BaseUpstreamProvider:
async for annotated in messages_dispatch.stream_annotated_events(
iterator, requested_model
):
+ usage_estimator.observe(annotated.event)
if annotated.model:
last_model_seen = annotated.model
# Anthropic SSE reports usage cumulatively across
@@ -3046,6 +3074,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id,
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
+ request_body=request_body,
)
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
@@ -3064,6 +3093,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id,
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
+ request_body=request_body,
)
finally:
await response.aclose()
@@ -3081,6 +3111,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id,
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
+ request_body=request_body,
)
finally:
await response.aclose()
@@ -3131,6 +3162,7 @@ class BaseUpstreamProvider:
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
client=client,
+ request_body=request_body,
)
# Handle both non-streaming chat completions and embeddings
@@ -3144,6 +3176,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id,
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
+ request_body=request_body,
)
finally:
await response.aclose()
@@ -3408,6 +3441,7 @@ class BaseUpstreamProvider:
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
client=client,
+ request_body=transformed_body,
)
if response.status_code == 200:
@@ -3420,6 +3454,7 @@ class BaseUpstreamProvider:
requested_model=original_model_id,
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
+ request_body=transformed_body,
)
finally:
await response.aclose()
@@ -4799,11 +4834,11 @@ class BaseUpstreamProvider:
model = payload["model"]
if usage_data is None:
- # Settlement invariant: a terminal request is never silently
- # zero-billed and never silently keeps the whole token. Unmeasured
- # usage settles at the authorization ceiling and refunds the rest.
+ # 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 — settling at authorized max",
+ "No usage in streaming Responses API response — refunding instead of charging the authorized max",
extra={
"model": model,
"amount": amount,
diff --git a/routstr/upstream/count_tokens.py b/routstr/upstream/count_tokens.py
index d114561c..c79d11d9 100644
--- a/routstr/upstream/count_tokens.py
+++ b/routstr/upstream/count_tokens.py
@@ -23,7 +23,7 @@ import litellm
from fastapi.responses import Response
from ..core import get_logger
-from ..payment.helpers import estimate_tokens
+from ..payment.helpers import estimate_prompt_tokens, estimate_tokens
from ..payment.models import Model
logger = get_logger(__name__)
@@ -39,6 +39,13 @@ def _parse_request_body(request_body: bytes | None) -> dict[str, Any]:
return parsed if isinstance(parsed, dict) else {}
+def _model_name(model_obj: Model | None, body: dict[str, Any]) -> str:
+ if model_obj is not None:
+ return model_obj.forwarded_model_id or model_obj.id or ""
+ body_model = body.get("model")
+ return body_model if isinstance(body_model, str) else ""
+
+
def _count_with_litellm(model: str, body: dict[str, Any]) -> int:
messages = body.get("messages")
if not isinstance(messages, list):
@@ -67,6 +74,157 @@ def _count_with_litellm(model: str, body: dict[str, Any]) -> int:
)
+def _count_text_with_litellm(model: str, text: str) -> int:
+ return int(
+ litellm.token_counter(
+ model=model,
+ text=text,
+ count_response_tokens=True,
+ )
+ )
+
+
+def _generated_text(value: object) -> list[str]:
+ """Extract generated text/tool arguments without counting response metadata."""
+ generated_keys = {
+ "arguments",
+ "content",
+ "delta",
+ "output_text",
+ "partial_json",
+ "reasoning",
+ "reasoning_content",
+ "text",
+ "thinking",
+ }
+ parts: list[str] = []
+
+ def walk(item: object, key: str | None = None) -> None:
+ if isinstance(item, str):
+ if key in generated_keys:
+ parts.append(item)
+ return
+ if isinstance(item, list):
+ for child in item:
+ walk(child, key)
+ return
+ if isinstance(item, dict):
+ for child_key, child in item.items():
+ walk(child, child_key)
+
+ walk(value)
+ return parts
+
+
+class MissingUsageEstimator:
+ """Estimate billable usage when an upstream omits its usage trailer.
+
+ The reservation is deliberately absent from this class: it is an
+ authorization ceiling, not an input to usage measurement.
+ """
+
+ def __init__(self, request_body: bytes | None, model_obj: Model | None) -> None:
+ self.body = _parse_request_body(request_body)
+ self.model_name = _model_name(model_obj, self.body)
+ self._output_parts: list[str] = []
+ self._input_tokens: int | None = None
+
+ def _estimate_input_tokens(self) -> int:
+ if self._input_tokens is not None:
+ return self._input_tokens
+ try:
+ self._input_tokens = _count_with_litellm(self.model_name, self.body)
+ except Exception as exc:
+ self._input_tokens = estimate_prompt_tokens(self.body)
+ logger.debug(
+ "litellm request token count failed; using local estimator",
+ extra={
+ "model": self.model_name,
+ "error": str(exc),
+ "error_type": type(exc).__name__,
+ "estimated_tokens": self._input_tokens,
+ },
+ )
+ return self._input_tokens
+
+ @property
+ def output_text(self) -> str:
+ return "".join(self._output_parts)
+
+ def observe(self, response_data: object) -> None:
+ if isinstance(response_data, dict):
+ event_type = response_data.get("type")
+ 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 billing_data(
+ self,
+ response_data: dict[str, Any] | None,
+ model: str | None = None,
+ ) -> dict[str, Any]:
+ """Use measured usage when present, otherwise return a local estimate."""
+ if isinstance(response_data, dict):
+ usage = response_data.get("usage")
+ if not isinstance(usage, dict):
+ nested = response_data.get("response")
+ usage = nested.get("usage") if isinstance(nested, dict) else None
+ if isinstance(usage, dict) and usage:
+ return {
+ "model": model or response_data.get("model") or self.model_name,
+ "usage": usage,
+ }
+ if not self._output_parts:
+ self.observe(response_data)
+ return self.response_data(model)
+
+ def response_data(self, model: str | None = None) -> dict[str, Any]:
+ text = self.output_text
+ try:
+ output_tokens = (
+ _count_text_with_litellm(self.model_name, text) if text else 0
+ )
+ except Exception as exc:
+ output_tokens = len(text) // 3
+ logger.debug(
+ "litellm response token count failed; using local estimator",
+ extra={
+ "model": self.model_name,
+ "error": str(exc),
+ "error_type": type(exc).__name__,
+ "estimated_tokens": output_tokens,
+ },
+ )
+
+ input_tokens = max(0, int(self._estimate_input_tokens()))
+ output_tokens = max(0, int(output_tokens))
+ return {
+ "model": model or self.model_name or "unknown",
+ "usage": {
+ "input_tokens": input_tokens,
+ "output_tokens": output_tokens,
+ "total_tokens": input_tokens + output_tokens,
+ "estimated": True,
+ },
+ }
+
+ def openai_response_data(self, model: str | None = None) -> dict[str, Any]:
+ """Same estimate in the OpenAI chat-completions usage dialect."""
+ data = self.response_data(model)
+ usage = data["usage"]
+ return {
+ "model": data["model"],
+ "usage": {
+ "prompt_tokens": usage["input_tokens"],
+ "completion_tokens": usage["output_tokens"],
+ "total_tokens": usage["total_tokens"],
+ "estimated": True,
+ },
+ }
+
+
def count_tokens_locally(
request_body: bytes | None,
model_obj: Model | None,
@@ -75,13 +233,7 @@ def count_tokens_locally(
touching the upstream. Always returns 200; never raises."""
body = _parse_request_body(request_body)
- model_name = ""
- if model_obj is not None:
- model_name = model_obj.forwarded_model_id or model_obj.id or ""
- if not model_name:
- body_model = body.get("model")
- if isinstance(body_model, str):
- model_name = body_model
+ model_name = _model_name(model_obj, body)
input_tokens: int
try:
diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py
index 55541d22..d6b7e7d2 100644
--- a/routstr/upstream/ehbp.py
+++ b/routstr/upstream/ehbp.py
@@ -319,10 +319,10 @@ async def _compute_ehbp_actual_cost(
) -> dict:
"""Compute the actual cost in msats from Tinfoil usage metrics.
- Falls back to ``max_cost_for_model`` when usage is absent (streaming) or
- cannot be priced. The result is clamped to ``[min_request_msat,
- max_cost_for_model]`` so the refund never exceeds the reservation and is
- never zero.
+ When usage is present, the result is clamped to ``[min_request_msat,
+ max_cost_for_model]``. Missing or unpriceable usage returns zero: encrypted
+ EHBP bodies cannot be estimated locally, and the authorization ceiling is
+ not evidence of consumption.
When the usage-metrics header includes ``model=`` and it differs
from ``model_obj.id``, the actual served model's pricing is used for the
@@ -335,7 +335,7 @@ async def _compute_ehbp_actual_cost(
"""
usage_dict = parse_tinfoil_usage_metrics(usage_header)
if usage_dict is None:
- return _build_cost_info(max_cost_for_model)
+ return _build_cost_info(0)
# The enclave may serve a different model than the one requested (e.g.
# due to failover). The usage-metrics header's ``model=`` carries
@@ -406,19 +406,19 @@ async def _compute_ehbp_actual_cost(
)
except Exception as e:
logger.warning(
- "EHBP usage cost calculation failed, falling back to max cost",
+ "EHBP usage cost calculation failed; releasing instead of charging max cost",
extra={
"model": pricing_model_id,
"error": str(e),
"usage": usage_dict,
},
)
- return _build_cost_info(max_cost_for_model, actual_model=actual_model)
+ return _build_cost_info(0, actual_model=actual_model)
if isinstance(cost, MaxCostData):
logger.warning(
- "EHBP calculate_cost returned MaxCostData (no model pricing), "
- "falling back to max cost",
+ "EHBP calculate_cost returned MaxCostData (no usable pricing); "
+ "releasing instead of charging max cost",
extra={
"model": pricing_model_id,
"max_cost_for_model": max_cost_for_model,
@@ -426,7 +426,7 @@ async def _compute_ehbp_actual_cost(
"cost_total_msats": cost.total_msats,
},
)
- return _build_cost_info(max_cost_for_model, actual_model=actual_model)
+ return _build_cost_info(0, actual_model=actual_model)
if isinstance(cost, CostData):
actual = max(int(cost.total_msats), int(settings.min_request_msat))
clamped = min(actual, max_cost_for_model)
@@ -450,13 +450,13 @@ async def _compute_ehbp_actual_cost(
)
# CostDataError
logger.warning(
- "EHBP usage cost calculation error, falling back to max cost",
+ "EHBP usage cost calculation error; releasing instead of charging max cost",
extra={
"model": pricing_model_id,
"error": getattr(cost, "message", str(cost)),
},
)
- return _build_cost_info(max_cost_for_model, actual_model=actual_model)
+ return _build_cost_info(0, actual_model=actual_model)
def _extract_usage_from_response(
@@ -600,78 +600,25 @@ async def finalize_ehbp_max_cost_payment(
model_id: str,
reservation_snapshot: ReservationSnapshot | None = None,
) -> int:
- """Finalize an EHBP bearer request by charging the reserved max cost.
+ """Release an unmeasured EHBP request without charging its reservation.
- EHBP responses are encrypted, so Routstr cannot inspect token usage. Unlike
- normal completion handlers, this intentionally charges the pre-reserved max
- cost and releases the reservation.
+ The legacy name is retained for compatibility with internal callers. EHBP
+ responses are encrypted, so no local estimate is possible when the trusted
+ usage header/trailer is absent.
"""
reservation = reservation_snapshot or await get_reservation_snapshot(key, session)
await _validate_reservation_snapshot(key, reservation, session)
- if not await _claim_reservation_for_charge(reservation, session):
- return 0
- max_cost_for_model = reservation.reserved_msats
- billing_key = await get_billing_key(key, session)
- key_hash = key.hashed_key
- billing_key_hash = billing_key.hashed_key
- total_cost_msats = max(0, int(max_cost_for_model))
- now = int(time.time())
-
- charged = await _charge_reservation_rows(
- session,
- billing_key_hash=billing_key_hash,
- key_hash=key_hash,
- reserved_msats=max_cost_for_model,
- charge_msats=total_cost_msats,
- )
- if not charged:
- logger.error(
- "Failed to finalize EHBP max-cost payment",
- extra={
- "key_hash": key_hash[:8] + "...",
- "billing_key_hash": billing_key_hash[:8] + "...",
- "model": model_id,
- "max_cost_for_model": max_cost_for_model,
- },
- )
- await _release_failed_ehbp_charge(reservation, session)
- return 0
-
- await session.commit()
- await _stop_reservation_heartbeat(reservation.release_id)
- await session.refresh(billing_key)
- if billing_key.hashed_key != key.hashed_key:
- await session.refresh(key)
-
- if total_cost_msats > 0 and ROUTSTR_FEE_PERCENT > 0:
- fee_msats = math.ceil(total_cost_msats * ROUTSTR_FEE_PERCENT / 100)
- try:
- await accumulate_routstr_fee(session, fee_msats)
- except Exception as e:
- logger.warning(
- "Failed to accumulate Routstr fee for EHBP request",
- extra={"error": str(e), "fee_msats": fee_msats},
- )
-
- payments_logger.info(
- "FINALIZE",
+ key_log_hash = key.hashed_key[:8] + "..."
+ await release_reservation(reservation, session, reservation.reserved_msats)
+ logger.warning(
+ "Released unmeasured EHBP reservation without charging max cost",
extra={
- "event": "finalize",
- "key_hash": key.hashed_key[:8] + "...",
- "billing_key_hash": billing_key.hashed_key[:8] + "...",
+ "key_hash": key_log_hash,
"model": model_id,
- "cost_reserved": max_cost_for_model,
- "cost_charged": total_cost_msats,
- "input_tokens": 0,
- "output_tokens": 0,
- "balance": billing_key.balance,
- "reserved_balance": billing_key.reserved_balance,
- "total_spent": billing_key.total_spent,
- "finalize_type": "ehbp_max_cost",
- "finalized_at": now,
+ "max_cost_for_model": max_cost_for_model,
},
)
- return total_cost_msats
+ return 0
async def send_cashu_refund(
@@ -846,8 +793,8 @@ async def forward_ehbp_request(
cost_data["computed_msats"] = computed_msats
else:
logger.warning(
- "EHBP usage metrics not found in headers or trailers, "
- "falling back to max-cost billing",
+ "EHBP usage metrics not found in headers or trailers; "
+ "releasing instead of charging the authorization ceiling",
extra={
"model": model_obj.id,
"provider": provider_type,
@@ -868,11 +815,9 @@ async def forward_ehbp_request(
"input_tokens": 0,
"output_tokens": 0,
}
- if charged_msats != max_cost_for_model:
- cost_data["computed_msats"] = max_cost_for_model
- # Build the cost_info dict from what adjust_payment_for_tokens returned
- # or from the max-cost fallback. Fields match CostData/MaxCostData.dict().
+ # Build the cost_info dict from measured usage or the unmeasured-release
+ # fallback. Fields match CostData/MaxCostData.dict().
cost_info = {
"total_msats": cost_data.get("total_msats", max_cost_for_model),
"input_tokens": cost_data.get("input_tokens", 0),
diff --git a/tests/integration/test_free_response_stale_reservation.py b/tests/integration/test_free_response_stale_reservation.py
index 77b4579b..4b0b3c93 100644
--- a/tests/integration/test_free_response_stale_reservation.py
+++ b/tests/integration/test_free_response_stale_reservation.py
@@ -92,12 +92,15 @@ async def test_overrun_with_corrupted_aggregate_releases_without_charging(
@pytest.mark.asyncio
-async def test_missing_usage_settles_at_reservation_not_zero(
+async def test_missing_usage_never_turns_reservation_into_charge(
integration_session: AsyncSession,
) -> None:
- """A response with no usable usage data must settle at the reserved max
- cost (bounded fallback), never at zero — otherwise the request is free
- inference. Exercises the REAL calculate_cost, no patching."""
+ """A reservation is an authorization ceiling, not evidence of usage.
+
+ Upstream handlers should provide locally estimated usage when possible. If
+ no measurement or estimate reaches settlement, release the reservation
+ rather than charging its full value.
+ """
from routstr.auth import (
adjust_payment_for_tokens,
get_reservation_snapshot,
@@ -122,13 +125,12 @@ async def test_missing_usage_settles_at_reservation_not_zero(
reservation_snapshot=reservation,
)
- # Charged the authorized max, not zero.
- assert result["charged_msats"] == reserved
+ assert result["charged_msats"] == 0
integration_session.expunge_all()
key_row = await integration_session.get(ApiKey, key_hash)
assert key_row is not None
- assert key_row.total_spent == reserved, "missing usage must not be free"
- assert key_row.balance == 10_000 - reserved
+ assert key_row.total_spent == 0
+ assert key_row.balance == 10_000
assert key_row.reserved_balance == 0
diff --git a/tests/unit/test_count_tokens_local.py b/tests/unit/test_count_tokens_local.py
index 6435948b..ebd23cb2 100644
--- a/tests/unit/test_count_tokens_local.py
+++ b/tests/unit/test_count_tokens_local.py
@@ -13,7 +13,7 @@ from unittest.mock import patch
from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream import count_tokens as count_tokens_module
-from routstr.upstream.count_tokens import count_tokens_locally
+from routstr.upstream.count_tokens import MissingUsageEstimator, count_tokens_locally
def _make_model(model_id: str = "anthropic/claude-3-5-sonnet") -> Model:
@@ -154,6 +154,94 @@ def test_supports_anthropic_system_block_list() -> None:
assert payload["input_tokens"] > 0
+def test_missing_usage_estimator_prices_request_and_streamed_output() -> None:
+ model = _make_model()
+ request_body = _body(
+ {
+ "model": model.id,
+ "messages": [{"role": "user", "content": "price this prompt"}],
+ }
+ )
+
+ with (
+ patch.object(count_tokens_module, "_count_with_litellm", return_value=17),
+ patch.object(count_tokens_module, "_count_text_with_litellm", return_value=5),
+ ):
+ estimator = MissingUsageEstimator(request_body, model)
+ estimator.observe(
+ {
+ "model": "provider/model",
+ "choices": [{"delta": {"content": "estimated output"}}],
+ }
+ )
+ response = estimator.response_data("provider/model")
+
+ assert response == {
+ "model": "provider/model",
+ "usage": {
+ "input_tokens": 17,
+ "output_tokens": 5,
+ "total_tokens": 22,
+ "estimated": True,
+ },
+ }
+
+
+def test_missing_usage_estimator_skips_responses_api_done_events() -> None:
+ estimator = MissingUsageEstimator(b"{}", None)
+ estimator.observe({"type": "response.output_text.delta", "delta": "streamed"})
+ estimator.observe({"type": "response.output_text.done", "text": "streamed"})
+ estimator.observe(
+ {
+ "type": "response.content_part.done",
+ "part": {"type": "output_text", "text": "streamed"},
+ }
+ )
+
+ assert estimator.output_text == "streamed"
+
+
+def test_missing_usage_estimator_openai_dialect() -> None:
+ model = _make_model()
+ request_body = _body(
+ {
+ "model": model.id,
+ "messages": [{"role": "user", "content": "price this prompt"}],
+ }
+ )
+
+ with (
+ patch.object(count_tokens_module, "_count_with_litellm", return_value=17),
+ patch.object(count_tokens_module, "_count_text_with_litellm", return_value=5),
+ ):
+ estimator = MissingUsageEstimator(request_body, model)
+ estimator.observe({"choices": [{"delta": {"content": "estimated output"}}]})
+ response = estimator.openai_response_data("provider/model")
+
+ assert response == {
+ "model": "provider/model",
+ "usage": {
+ "prompt_tokens": 17,
+ "completion_tokens": 5,
+ "total_tokens": 22,
+ "estimated": True,
+ },
+ }
+
+
+def test_missing_usage_estimator_does_not_count_response_metadata() -> None:
+ estimator = MissingUsageEstimator(b"{}", None)
+ estimator.observe(
+ {
+ "id": "chatcmpl-this-is-not-generated-text",
+ "model": "also-not-generated-text",
+ "choices": [{"delta": {"role": "assistant"}}],
+ }
+ )
+
+ assert estimator.output_text == ""
+
+
def test_uses_forwarded_model_id_when_present() -> None:
model = _make_model("anthropic/claude-3-5-sonnet")
model.forwarded_model_id = "claude-3-5-sonnet-20241022"
diff --git a/tests/unit/test_ehbp_finalize_payment.py b/tests/unit/test_ehbp_finalize_payment.py
index 8f2c3bbb..9ed9fb5c 100644
--- a/tests/unit/test_ehbp_finalize_payment.py
+++ b/tests/unit/test_ehbp_finalize_payment.py
@@ -107,7 +107,7 @@ async def test_finalize_actual_cost_payment_updates_balance_and_releases_reserve
@pytest.mark.asyncio
-async def test_finalize_max_cost_payment_updates_parent_and_child_spend(
+async def test_unmeasured_ehbp_releases_parent_and_child_reservation(
session: AsyncSession,
) -> None:
parent = ApiKey(hashed_key="ehbp-parent", balance=10_000)
@@ -126,19 +126,19 @@ async def test_finalize_max_cost_payment_updates_parent_and_child_spend(
reservation_snapshot=reservation,
)
- assert charged == 3_000
+ assert charged == 0
updated_parent = await _api_key(session, "ehbp-parent")
updated_child = await _api_key(session, "ehbp-child")
assert updated_parent is not None
assert updated_child is not None
- assert updated_parent.balance == 7_000
+ assert updated_parent.balance == 10_000
assert updated_parent.reserved_balance == 0
assert updated_parent.reserved_at is None
- assert updated_parent.total_spent == 3_000
+ assert updated_parent.total_spent == 0
assert updated_child.balance == 0
assert updated_child.reserved_balance == 0
assert updated_child.reserved_at is None
- assert updated_child.total_spent == 3_000
+ assert updated_child.total_spent == 0
@pytest.mark.asyncio
@@ -178,7 +178,7 @@ async def test_finalize_actual_cost_payment_rolls_back_when_parent_update_matche
@pytest.mark.asyncio
-async def test_finalize_max_cost_payment_rolls_back_parent_when_child_update_matches_no_rows(
+async def test_unmeasured_ehbp_release_is_safe_when_charge_update_would_fail(
session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -207,11 +207,13 @@ async def test_finalize_max_cost_payment_rolls_back_parent_when_child_update_mat
updated_parent = await _api_key(session, "ehbp-rollback-parent")
assert updated_parent is not None
assert updated_parent.balance == 10_000
- assert updated_parent.reserved_balance == 0
+ # The injected partial-update failure rolls aggregate subtraction back;
+ # terminal fencing prevents a charge or retry from consuming those funds.
+ assert updated_parent.reserved_balance == 3_000
assert updated_parent.total_spent == 0
updated_child = await _api_key(session, "ehbp-missing-child")
assert updated_child is not None
- assert updated_child.reserved_balance == 0
+ assert updated_child.reserved_balance == 3_000
assert updated_child.total_spent == 0
release = await session.get(ReservationRelease, reservation.release_id)
assert release is not None and release.status == "released"
diff --git a/tests/unit/test_pricing_rate_validation.py b/tests/unit/test_pricing_rate_validation.py
index b3e538c7..96b478d1 100644
--- a/tests/unit/test_pricing_rate_validation.py
+++ b/tests/unit/test_pricing_rate_validation.py
@@ -74,19 +74,17 @@ def _usage_response() -> dict[str, Any]:
ids=["nan", "inf", "negative"],
)
@pytest.mark.asyncio
-async def test_unusable_token_rate_falls_back_to_max_cost(bad_rate: float) -> None:
- """An unusable configured rate must not be billed on.
-
- It reached the token math, which raises after the response was already
- served — where the streaming handlers swallow it and the request goes
- unbilled.
- """
+async def test_unusable_token_rate_never_charges_the_reservation(
+ bad_rate: float,
+) -> None:
+ """An unusable configured rate must not turn authorization into usage."""
model = _model(Pricing(prompt=bad_rate, completion=1.0))
cost = await calculate_cost(_usage_response(), max_cost=1234, model_obj=model)
assert isinstance(cost, MaxCostData)
- assert cost.total_msats == 1234
+ assert cost.total_msats == 0
+ assert (cost.input_tokens, cost.output_tokens) == (1000, 500)
@pytest.mark.parametrize(
diff --git a/tests/unit/test_streaming_billing_finalization.py b/tests/unit/test_streaming_billing_finalization.py
index ea8b6b1f..ddc8d9f5 100644
--- a/tests/unit/test_streaming_billing_finalization.py
+++ b/tests/unit/test_streaming_billing_finalization.py
@@ -22,6 +22,7 @@ from routstr.auth import (
)
from routstr.core.db import ApiKey, ReservationRelease
from routstr.payment.cost_calculation import MaxCostData
+from routstr.payment.models import Architecture, Model, Pricing
from routstr.upstream.base import BaseUpstreamProvider
@@ -671,16 +672,14 @@ async def test_cross_key_reservation_snapshot_is_rejected_without_mutation() ->
@pytest.mark.asyncio
-async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> None:
- """A client that aborts the socket mid-stream must not leak its reservation.
+async def test_client_disconnect_midstream_estimates_usage_and_stops_heartbeat() -> (
+ None
+):
+ """A client abort releases the hold after charging only estimated usage.
- Starlette closes the response generator (``aclose``) on disconnect, whose
- ``finally`` schedules the background finalizer. That finalizer must settle
- the reservation (charge the reserved max — usage is unknown), reach a
- terminal durable state, and stop the lease heartbeat so the sweeper is not
- needed. Driven against a real engine and the real finalizer; the socket
- abort is modelled deterministically with ``aclose`` (the exact hook
- Starlette invokes) to keep the test CI-stable.
+ Starlette closes the response generator (``aclose``) on disconnect. The
+ finalizer still has the request and streamed deltas, so it can estimate
+ usage without converting the reservation ceiling into the charge.
"""
engine = await _engine()
provider = BaseUpstreamProvider(
@@ -707,6 +706,26 @@ async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> No
)
upstream_response.aiter_bytes = aiter_bytes
+ model = Model(
+ id="test-model",
+ name="test-model",
+ created=0,
+ description="",
+ context_length=8_192,
+ architecture=Architecture(
+ modality="text",
+ input_modalities=["text"],
+ output_modalities=["text"],
+ tokenizer="unknown",
+ instruct_type=None,
+ ),
+ pricing=Pricing(prompt=0.01, completion=0.02),
+ sats_pricing=Pricing(prompt=0.01, completion=0.02),
+ )
+ request_body = json.dumps(
+ {"model": model.id, "messages": [{"role": "user", "content": "hi"}]}
+ ).encode()
+
background_tasks = BackgroundTasks()
try:
with (
@@ -718,13 +737,24 @@ async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> No
"routstr.upstream.base.adjust_payment_for_tokens",
auth_module.adjust_payment_for_tokens,
),
+ patch("routstr.upstream.count_tokens._count_with_litellm", return_value=3),
+ patch(
+ "routstr.upstream.count_tokens._count_text_with_litellm",
+ return_value=2,
+ ),
+ patch(
+ "routstr.payment.cost_calculation.sats_usd_price",
+ return_value=5.0e-5,
+ ),
):
response = await provider.handle_streaming_chat_completion(
response=upstream_response,
key=key,
max_cost_for_model=500,
background_tasks=background_tasks,
+ model_obj=model,
reservation_snapshot=snapshot,
+ request_body=request_body,
)
iterator = cast(AsyncGenerator[bytes, None], response.body_iterator)
await iterator.__anext__() # first chunk reaches the client
@@ -744,9 +774,9 @@ async def test_client_disconnect_midstream_finalizes_and_stops_heartbeat() -> No
# The reservation reached a single terminal outcome; funds are not locked.
assert record is not None and record.status in {"charged", "released"}
assert final_key.reserved_balance == 0
- # Unknown usage settles at the reserved max, never free.
- assert final_key.total_spent == 500
- assert final_key.balance == 500
+ # 3 input tokens × 10 msats + 2 output tokens × 20 msats = 70 msats.
+ assert final_key.total_spent == 70
+ assert final_key.balance == 930
# The heartbeat is gone — no forever-renewing task on an abandoned request.
assert snapshot.release_id not in auth_module._reservation_heartbeats
await engine.dispose()
diff --git a/tests/unit/test_tinfoil_integration.py b/tests/unit/test_tinfoil_integration.py
index 9435a851..d5330145 100644
--- a/tests/unit/test_tinfoil_integration.py
+++ b/tests/unit/test_tinfoil_integration.py
@@ -240,12 +240,12 @@ class TestResolveEhbpTargetUrl:
class TestComputeEhbpActualCost:
@pytest.mark.asyncio
- async def test_no_usage_falls_back_to_max_cost(self) -> None:
+ async def test_no_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"
result = await _compute_ehbp_actual_cost(None, model_obj, 100_000)
- assert result["total_msats"] == 100_000
+ assert result["total_msats"] == 0
assert result["input_tokens"] == 0
assert result["output_tokens"] == 0
@@ -285,7 +285,7 @@ class TestComputeEhbpActualCost:
assert result["output_msats"] == 20
@pytest.mark.asyncio
- async def test_max_cost_data_falls_back(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"
@@ -309,7 +309,7 @@ class TestComputeEhbpActualCost:
model_obj,
50_000,
)
- assert result["total_msats"] == 50_000
+ assert result["total_msats"] == 0
assert result["input_tokens"] == 0
assert result["output_tokens"] == 0
diff --git a/tests/unit/test_x_cashu_responses_streaming_sse.py b/tests/unit/test_x_cashu_responses_streaming_sse.py
index aafe6e08..08154e40 100644
--- a/tests/unit/test_x_cashu_responses_streaming_sse.py
+++ b/tests/unit/test_x_cashu_responses_streaming_sse.py
@@ -186,7 +186,7 @@ async def test_multiline_data_payload_is_parsed_and_reframed() -> None:
@pytest.mark.asyncio
-async def test_missing_usage_settles_at_authorized_max() -> None:
+async def test_missing_usage_refunds_instead_of_charging_authorized_max() -> None:
chunks = [
b'data: {"type":"response.created","response":{"model":"gpt-5-mini"}}\r\n\r\n',
b"data: [DONE]\r\n\r\n",
@@ -198,9 +198,9 @@ async def test_missing_usage_settles_at_authorized_max() -> None:
send_refund.assert_awaited_once()
assert send_refund.await_args is not None
- assert send_refund.await_args.args[0] == 10_000 - 9_000
+ assert send_refund.await_args.args[0] == 10_000
assert response.headers["x-cashu"] == "cashuBrefundtoken0123456789"
- assert response.headers["x-routstr-cost-msats"] == "9000"
+ assert response.headers["x-routstr-cost-msats"] == "0"
@pytest.mark.asyncio
@@ -215,7 +215,7 @@ async def test_malformed_events_do_not_retain_whole_token() -> None:
)
assert send_refund.await_args is not None
- assert send_refund.await_args.args[0] == 1000
+ assert send_refund.await_args.args[0] == 10_000
body = await _collect(response)
assert b"\\n" not in body
assert body.endswith(b"\n\n")
From 0457b5d0cdf4d86c7690cfe408b08d9b1a8b0f0c Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Thu, 27 Aug 2026 13:23:27 +0200
Subject: [PATCH 052/120] display correct provider
---
routstr/upstream/base.py | 14 +++---
tests/unit/test_x_cashu_provider_path.py | 54 ++++++++++++++++++++++++
2 files changed, 60 insertions(+), 8 deletions(-)
create mode 100644 tests/unit/test_x_cashu_provider_path.py
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index 24f4d165..f8b7fe3e 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -3928,10 +3928,9 @@ class BaseUpstreamProvider:
data_json = json.loads(line[6:])
if not isinstance(data_json, dict):
continue
- changed = False
- if "provider" not in data_json:
- self._apply_provider_field(data_json)
- changed = True
+ provider_before = data_json.get("provider")
+ self._apply_provider_field(data_json)
+ changed = data_json.get("provider") != provider_before
if cost_data and "usage" in data_json and data_json["usage"]:
_inject_cost_into_usage(data_json, cost_data)
changed = True
@@ -4937,10 +4936,9 @@ class BaseUpstreamProvider:
continue
if not isinstance(data_json, dict):
continue
- changed = False
- if "provider" not in data_json:
- self._apply_provider_field(data_json)
- changed = True
+ provider_before = data_json.get("provider")
+ self._apply_provider_field(data_json)
+ changed = data_json.get("provider") != provider_before
payload = _responses_usage_payload(data_json)
if cost_data and isinstance(payload.get("usage"), dict):
_inject_cost_into_usage(payload, cost_data)
diff --git a/tests/unit/test_x_cashu_provider_path.py b/tests/unit/test_x_cashu_provider_path.py
new file mode 100644
index 00000000..11b47d6f
--- /dev/null
+++ b/tests/unit/test_x_cashu_provider_path.py
@@ -0,0 +1,54 @@
+import json
+from typing import Any
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+
+from routstr.upstream.openrouter import OpenRouterUpstreamProvider
+
+
+async def _body(response: Any) -> bytes:
+ chunks: list[bytes] = []
+ async for chunk in response.body_iterator:
+ chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode())
+ return b"".join(chunks)
+
+
+@pytest.mark.asyncio
+async def test_x_cashu_chat_stream_reports_complete_provider_path() -> None:
+ provider = OpenRouterUpstreamProvider(api_key="test-key")
+ payload = {"model": "glm-4.5", "provider": "z.ai"}
+ content = f"data: {json.dumps(payload)}\n"
+
+ response = await provider.handle_x_cashu_streaming_response(
+ content,
+ httpx.Response(200, headers={"content-type": "text/event-stream"}),
+ amount=1,
+ unit="sat",
+ max_cost_for_model=1,
+ )
+
+ event = json.loads((await _body(response)).decode().removeprefix("data: "))
+ assert event["provider"] == "openrouter:z.ai"
+
+
+@pytest.mark.asyncio
+async def test_x_cashu_responses_stream_reports_complete_provider_path() -> None:
+ provider = OpenRouterUpstreamProvider(api_key="test-key")
+ event = {"type": "response.created", "provider": "z.ai"}
+ content = f"data: {json.dumps(event)}\n\n"
+
+ with patch.object(
+ provider, "get_x_cashu_cost", new=AsyncMock(return_value=None)
+ ):
+ response = await provider.handle_x_cashu_streaming_responses_response(
+ content,
+ httpx.Response(200, headers={"content-type": "text/event-stream"}),
+ amount=1,
+ unit="sat",
+ max_cost_for_model=1,
+ )
+
+ payload = json.loads((await _body(response)).decode().removeprefix("data: "))
+ assert payload["provider"] == "openrouter:z.ai"
From 23ec99607e01306f5301c10d292f94719a090e96 Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Thu, 27 Aug 2026 17:33:17 +0200
Subject: [PATCH 053/120] add detailed error message
---
routstr/wallet.py | 17 +++++---
tests/unit/test_fee_payout_crash_safety.py | 42 +++++++++++++++++++
.../unit/test_lnurl_amount_and_destination.py | 12 +++---
3 files changed, 60 insertions(+), 11 deletions(-)
diff --git a/routstr/wallet.py b/routstr/wallet.py
index 0c0ecc7c..51ee7f72 100644
--- a/routstr/wallet.py
+++ b/routstr/wallet.py
@@ -2647,11 +2647,18 @@ async def periodic_routstr_fee_payout() -> None:
logger.warning("Routstr fee payout was already claimed")
continue
except BaseException as e:
- logger.critical(
- "Routstr fee payout outcome is unknown; awaiting quote reconciliation",
- extra={"payout_in_progress_msats": paid_msats},
- exc_info=isinstance(e, Exception),
- )
+ if attempt_quote_id is None:
+ logger.error(
+ "Routstr fee payout failed before melt dispatch",
+ extra={"payout_msats": paid_msats},
+ exc_info=isinstance(e, Exception),
+ )
+ else:
+ logger.critical(
+ "Routstr fee payout outcome is unknown; awaiting quote reconciliation",
+ extra={"payout_in_progress_msats": paid_msats},
+ exc_info=isinstance(e, Exception),
+ )
if not isinstance(e, Exception):
raise
continue
diff --git a/tests/unit/test_fee_payout_crash_safety.py b/tests/unit/test_fee_payout_crash_safety.py
index 9e1dfaf2..c6fc3073 100644
--- a/tests/unit/test_fee_payout_crash_safety.py
+++ b/tests/unit/test_fee_payout_crash_safety.py
@@ -11,6 +11,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from routstr import wallet
from routstr.core import db
+from routstr.payment.lnurl import LNURLError
class _SessionContext:
@@ -525,6 +526,47 @@ async def test_fee_payout_keeps_legacy_checkpoint_without_quote_locked() -> None
critical.assert_called_once()
+@pytest.mark.asyncio
+async def test_fee_payout_failure_before_quote_is_not_reported_as_unknown() -> None:
+ session = Mock()
+ fee = SimpleNamespace(
+ accumulated_msats=1_061_000,
+ payout_in_progress_msats=0,
+ payout_started_at=None,
+ )
+ reset = AsyncMock()
+
+ with (
+ patch("routstr.auth.ROUTSTR_FEE_DEFAULT_PAYOUT", 1),
+ patch("routstr.auth.ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS", 1),
+ patch("routstr.auth.ROUTSTR_LN_ADDRESS", "fees@example.com"),
+ patch(
+ "routstr.wallet.asyncio.sleep",
+ AsyncMock(side_effect=[None, asyncio.CancelledError()]),
+ ),
+ patch(
+ "routstr.wallet.db.create_session", return_value=_session_context(session)
+ ),
+ patch("routstr.wallet.db.get_routstr_fee", AsyncMock(return_value=fee)),
+ patch("routstr.wallet.db.reset_routstr_fee", reset),
+ patch("routstr.wallet.get_wallet", AsyncMock(return_value=Mock())),
+ patch("routstr.wallet.get_proofs_per_mint_and_unit", return_value=[]),
+ patch(
+ "routstr.wallet.raw_send_to_lnurl",
+ side_effect=LNURLError("Cashu melt fees leave no payable LNURL amount"),
+ ),
+ patch("routstr.wallet.logger.error") as error,
+ patch("routstr.wallet.logger.critical") as critical,
+ ):
+ with pytest.raises(asyncio.CancelledError):
+ await wallet.periodic_routstr_fee_payout()
+
+ reset.assert_not_awaited()
+ critical.assert_not_called()
+ error.assert_called_once()
+ assert error.call_args.args[0] == "Routstr fee payout failed before melt dispatch"
+
+
@pytest.mark.asyncio
async def test_fee_payout_keeps_checkpoint_when_send_outcome_is_unknown() -> None:
session = Mock()
diff --git a/tests/unit/test_lnurl_amount_and_destination.py b/tests/unit/test_lnurl_amount_and_destination.py
index e722141a..ab6a996e 100644
--- a/tests/unit/test_lnurl_amount_and_destination.py
+++ b/tests/unit/test_lnurl_amount_and_destination.py
@@ -347,21 +347,21 @@ async def test_send_to_lnurl_does_not_reserve_before_lnurl_validation() -> None:
assert raw_send.await_args.kwargs["amount"] == 1000
-def test_select_melt_proofs_stops_at_minimal_cover_when_over_budget() -> None:
+def test_select_melt_proofs_ignores_fees_for_unneeded_wallet_proofs() -> None:
from routstr.payment.lnurl import _select_melt_proofs
wallet = MagicMock()
wallet.get_fees_for_proofs = MagicMock(side_effect=lambda selected: len(selected))
- proofs = [MagicMock(amount=600, reserved=False) for _ in range(3)]
+ proofs = [MagicMock(amount=2048, reserved=False) for _ in range(1100)]
selected, shortfall = _select_melt_proofs(
wallet,
proofs,
- quote_amount=1000,
- fee_reserve=0,
- gross_budget=1000,
+ quote_amount=1061,
+ fee_reserve=1,
+ gross_budget=1061,
)
assert selected is None
assert shortfall == 2
- assert wallet.get_fees_for_proofs.call_count == 2
+ assert wallet.get_fees_for_proofs.call_count == 1
From b15ab59fcd483e01b7400fb05641420a46ac4a04 Mon Sep 17 00:00:00 2001
From: redshift <213178690+1ftredsh@users.noreply.github.com>
Date: Sat, 29 Aug 2026 15:37:08 +0100
Subject: [PATCH 054/120] fix(ehbp): raise upstream timeout to 60s and return
504 on timeout
EHBP (Tinfoil) forwarding buffered responses through an h11 client with a
hard-coded 30-second inactivity timeout. Slow first-token latency or
queueing on larger models would trip it, surfacing a bare 500 instead of a
meaningful timeout.
- Bump the default EHBP timeout from 30s to 60s.
- Introduce EhbpTimeoutError (subclass of UpstreamError, code
UPSTREAM_TIMEOUT, status 504) raised from connect/send/read timeouts.
- Bearer auth now surfaces a proper 504 via the existing UpstreamError
handler instead of a generic 500.
- X-Cashu requests refund the redeemed amount and return a 504 with the
refund token.
Adds tests for timeout conversion, exception metadata, and the X-Cashu
refund-on-timeout path.
---
routstr/core/exceptions.py | 16 ++++++
routstr/upstream/ehbp.py | 42 ++++++++++++++-
routstr/upstream/tinfoil_trailer.py | 38 ++++++++++----
tests/unit/test_ehbp_timeout.py | 80 +++++++++++++++++++++++++++++
tests/unit/test_tinfoil_trailer.py | 60 ++++++++++++++++++++++
5 files changed, 225 insertions(+), 11 deletions(-)
create mode 100644 tests/unit/test_ehbp_timeout.py
diff --git a/routstr/core/exceptions.py b/routstr/core/exceptions.py
index 360b810d..6e7d90a0 100644
--- a/routstr/core/exceptions.py
+++ b/routstr/core/exceptions.py
@@ -34,6 +34,22 @@ class UpstreamError(Exception):
super().__init__(message)
+class EhbpTimeoutError(UpstreamError):
+ """Raised when an EHBP upstream times out waiting for a response.
+
+ Distinct from a generic :class:`UpstreamError` so callers can map the
+ failure to a ``504 Gateway Timeout`` with a stable ``UPSTREAM_TIMEOUT``
+ code instead of a misleading ``500`` internal server error.
+ """
+
+ def __init__(self, message: str, status_code: int = 504):
+ super().__init__(
+ message,
+ status_code=status_code,
+ code="UPSTREAM_TIMEOUT",
+ )
+
+
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handle HTTP exceptions and include request ID in response."""
request_id = getattr(request.state, "request_id", "unknown")
diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py
index d6b7e7d2..b135c238 100644
--- a/routstr/upstream/ehbp.py
+++ b/routstr/upstream/ehbp.py
@@ -32,7 +32,7 @@ from ..core.db import (
from ..core.db import (
store_cashu_transaction_with_retry as store_cashu_transaction,
)
-from ..core.exceptions import UpstreamError
+from ..core.exceptions import EhbpTimeoutError, UpstreamError
from ..core.settings import settings
from ..payment.cost_calculation import (
CostData,
@@ -1042,6 +1042,46 @@ async def forward_ehbp_x_cashu_request(
except Exception:
raise
+ except EhbpTimeoutError as e:
+ logger.warning(
+ "EHBP X-Cashu upstream timed out",
+ extra={
+ "error": str(e),
+ "path": path,
+ "method": request.method,
+ "redeemed": redeemed,
+ },
+ )
+
+ if redeemed and amount > 0:
+ try:
+ refund_token = await send_cashu_refund(amount, unit, mint, request_id)
+ error_response = create_error_response(
+ "upstream_timeout",
+ str(e),
+ 504,
+ request=request,
+ code="UPSTREAM_TIMEOUT",
+ )
+ error_response.headers["X-Cashu"] = refund_token
+ return error_response
+ except Exception as refund_error:
+ logger.error(
+ "Failed to refund EHBP X-Cashu token after timeout",
+ extra={
+ "error": str(refund_error),
+ "original_error": str(e),
+ },
+ )
+
+ return create_error_response(
+ "upstream_timeout",
+ str(e),
+ 504,
+ request=request,
+ code="UPSTREAM_TIMEOUT",
+ )
+
except Exception as e:
error_message = str(e)
logger.error(
diff --git a/routstr/upstream/tinfoil_trailer.py b/routstr/upstream/tinfoil_trailer.py
index 0357864f..ec3065d6 100644
--- a/routstr/upstream/tinfoil_trailer.py
+++ b/routstr/upstream/tinfoil_trailer.py
@@ -20,11 +20,12 @@ from urllib.parse import urlsplit
import h11
from ..core import get_logger
+from ..core.exceptions import EhbpTimeoutError
logger = get_logger(__name__)
_READ_BUFSIZE = 65536
-_DEFAULT_TIMEOUT_SECONDS = 30.0
+_DEFAULT_TIMEOUT_SECONDS = 60.0
_DEFAULT_CLOSE_TIMEOUT_SECONDS = 1.0
_DEFAULT_MAX_RESPONSE_BYTES = 25 * 1024 * 1024
_HOP_BY_HOP_HEADERS = {
@@ -100,10 +101,15 @@ async def forward_with_trailer(
headers = _strip_hop_by_hop_headers(headers)
ssl_ctx = ssl.create_default_context()
- reader, writer = await asyncio.wait_for(
- asyncio.open_connection(host, port, ssl=ssl_ctx),
- timeout=timeout_seconds,
- )
+ try:
+ reader, writer = await asyncio.wait_for(
+ asyncio.open_connection(host, port, ssl=ssl_ctx),
+ timeout=timeout_seconds,
+ )
+ except asyncio.TimeoutError as exc:
+ raise EhbpTimeoutError(
+ f"EHBP upstream {host} timed out after {timeout_seconds:g}s connecting"
+ ) from exc
try:
# Build HTTP/1.1 request
@@ -126,7 +132,13 @@ async def forward_with_trailer(
request_data += body
writer.write(request_data)
- await asyncio.wait_for(writer.drain(), timeout=timeout_seconds)
+ try:
+ await asyncio.wait_for(writer.drain(), timeout=timeout_seconds)
+ except asyncio.TimeoutError as exc:
+ raise EhbpTimeoutError(
+ f"EHBP upstream {host} timed out after "
+ f"{timeout_seconds:g}s sending request"
+ ) from exc
# Parse response with h11
conn = h11.Connection(h11.CLIENT)
@@ -140,10 +152,16 @@ async def forward_with_trailer(
event = conn.next_event()
if event is h11.NEED_DATA:
- data = await asyncio.wait_for(
- reader.read(_READ_BUFSIZE),
- timeout=timeout_seconds,
- )
+ try:
+ data = await asyncio.wait_for(
+ reader.read(_READ_BUFSIZE),
+ timeout=timeout_seconds,
+ )
+ except asyncio.TimeoutError as exc:
+ raise EhbpTimeoutError(
+ f"EHBP upstream {host} timed out after "
+ f"{timeout_seconds:g}s waiting for response data"
+ ) from exc
conn.receive_data(data if data else b"")
continue
diff --git a/tests/unit/test_ehbp_timeout.py b/tests/unit/test_ehbp_timeout.py
new file mode 100644
index 00000000..bd19871d
--- /dev/null
+++ b/tests/unit/test_ehbp_timeout.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from routstr.core.exceptions import EhbpTimeoutError
+from routstr.upstream import ehbp as ehbp_module
+
+# ---------------------------------------------------------------------------
+# forward_ehbp_x_cashu_request — timeout fails closed with a refund + 504
+# ---------------------------------------------------------------------------
+
+
+async def _request() -> MagicMock:
+ request = MagicMock()
+ request.state.request_id = "req-123"
+ request.method = "POST"
+ request.query_params = {}
+ request.headers = {}
+ request.body = AsyncMock(return_value=b"opaque")
+ return request
+
+
+@pytest.mark.asyncio
+async def test_x_cashu_timeout_refunds_and_returns_504(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ ehbp_module,
+ "recieve_token",
+ AsyncMock(return_value=(1000, "msat", None)),
+ )
+ monkeypatch.setattr(
+ ehbp_module, "store_cashu_transaction", AsyncMock(return_value=None)
+ )
+ monkeypatch.setattr(
+ ehbp_module, "send_cashu_refund", AsyncMock(return_value="refund-token")
+ )
+ monkeypatch.setattr(
+ ehbp_module,
+ "forward_with_trailer",
+ AsyncMock(side_effect=EhbpTimeoutError("EHBP upstream timed out")),
+ )
+
+ profile = MagicMock()
+ profile.client_target_url_header = None
+ profile.allow_client_target_override = False
+ profile.proxy_only_headers = frozenset()
+ profile.usage_response_header = None
+
+ target = MagicMock()
+ target.url = "https://inference.tinfoil.sh/v1/chat/completions"
+ target.headers = {}
+ target.profile = None
+
+ upstream = MagicMock()
+ upstream.prepare_headers.return_value = {}
+ upstream.get_ehbp_forwarding_target.return_value = target
+ upstream.get_confidential_inference_profile.return_value = profile
+ upstream.prepare_params.return_value = {}
+
+ model_obj = MagicMock()
+ model_obj.id = "tinfoil-kimi-k2-6"
+ model_obj.forwarded_model_id = "kimi-k2-6"
+
+ response = await ehbp_module.forward_ehbp_x_cashu_request(
+ request=await _request(),
+ x_cashu_token="cashu-token",
+ path="v1/chat/completions",
+ max_cost_for_model=5000,
+ model_obj=model_obj,
+ upstream=upstream,
+ )
+
+ assert response.status_code == 504
+ assert response.headers["X-Cashu"] == "refund-token"
+ ehbp_module.send_cashu_refund.assert_awaited_once_with(
+ 1000, "msat", None, "req-123"
+ )
diff --git a/tests/unit/test_tinfoil_trailer.py b/tests/unit/test_tinfoil_trailer.py
index ef1c96f1..5a6d68a7 100644
--- a/tests/unit/test_tinfoil_trailer.py
+++ b/tests/unit/test_tinfoil_trailer.py
@@ -1,9 +1,11 @@
from __future__ import annotations
+import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
+from routstr.core.exceptions import EhbpTimeoutError, UpstreamError
from routstr.upstream.tinfoil_trailer import forward_with_trailer
@@ -28,6 +30,14 @@ class FakeWriter:
self.written += data
+class HangingReader:
+ """A reader that never returns data, used to trigger a read timeout."""
+
+ async def read(self, _size: int) -> bytes:
+ await asyncio.sleep(3600)
+ return b""
+
+
@pytest.mark.asyncio
async def test_forward_with_trailer_captures_usage_trailer(
monkeypatch: pytest.MonkeyPatch,
@@ -136,3 +146,53 @@ async def test_forward_with_trailer_enforces_response_size_limit(
)
writer.close.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_forward_with_trailer_connect_timeout_raises_ehbp_timeout(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _hang_connect(*_args: object, **_kwargs: object) -> object:
+ raise asyncio.TimeoutError
+
+ monkeypatch.setattr(
+ "routstr.upstream.tinfoil_trailer.asyncio.open_connection", _hang_connect
+ )
+
+ with pytest.raises(EhbpTimeoutError, match="connecting"):
+ await forward_with_trailer(
+ method="POST",
+ url="https://enclave.tinfoil.sh/v1/chat/completions",
+ headers={},
+ body=b"opaque",
+ )
+
+
+@pytest.mark.asyncio
+async def test_forward_with_trailer_read_timeout_raises_ehbp_timeout(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ reader = HangingReader()
+ writer = FakeWriter()
+ monkeypatch.setattr(
+ "routstr.upstream.tinfoil_trailer.asyncio.open_connection",
+ AsyncMock(return_value=(reader, writer)),
+ )
+
+ with pytest.raises(EhbpTimeoutError, match="waiting for response data"):
+ await forward_with_trailer(
+ method="POST",
+ url="https://enclave.tinfoil.sh/v1/chat/completions",
+ headers={},
+ body=b"opaque",
+ timeout_seconds=0.01,
+ )
+
+ writer.close.assert_called_once()
+
+
+def test_ehbp_timeout_error_metadata() -> None:
+ exc = EhbpTimeoutError("boom")
+ assert exc.status_code == 504
+ assert exc.code == "UPSTREAM_TIMEOUT"
+ assert isinstance(exc, UpstreamError)
From 109b3c9a0b6e128d523ab8512cf4066730d0d840 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 1 Sep 2026 11:01:35 +0200
Subject: [PATCH 055/120] test(integration): add model price propagation
regression tests
Three tests against the path POST admin models -> GET /v1/models: price edit,
fee adjustment, and disable propagation. Each reaches the served model by id
rather than iterating the catalogue, so an empty catalogue fails them instead
of skipping them.
---
.../test_model_price_propagation.py | 221 ++++++++++++++++++
1 file changed, 221 insertions(+)
create mode 100644 tests/integration/test_model_price_propagation.py
diff --git a/tests/integration/test_model_price_propagation.py b/tests/integration/test_model_price_propagation.py
new file mode 100644
index 00000000..d637bbc8
--- /dev/null
+++ b/tests/integration/test_model_price_propagation.py
@@ -0,0 +1,221 @@
+"""Cover that an admin price write reaches the served catalogue (GET /v1/models).
+
+A price edit changes the served price; the served price is fee-adjusted while the
+admin read-back is raw; a disabled model leaves the catalogue but keeps its row.
+
+Each test reaches the served model by id rather than iterating ``data["data"]``,
+so an empty catalogue fails these tests instead of skipping them.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from routstr.core.admin import admin_sessions
+from routstr.core.db import ModelRow, UpstreamProviderRow
+from routstr.proxy import reinitialize_upstreams
+
+
+# The conftest patches ``routstr.payment.price.sats_usd_price``, but
+# ``models.py`` imports it as ``from .price import sats_usd_price`` — a
+# local binding the conftest-level patch cannot reach. Pin it here so
+# every test that goes through ``_row_to_model`` gets a real sats price.
+@pytest.fixture(autouse=True)
+def _pin_sats_usd() -> Iterator[None]:
+ with patch("routstr.payment.models.sats_usd_price", return_value=0.0005):
+ yield
+
+
+def _admin_headers() -> dict[str, str]:
+ token = "test-propagation-token"
+ admin_sessions[token] = int(
+ (datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
+ )
+ return {"Authorization": f"Bearer {token}"}
+
+
+def _model_payload(prompt: float, provider_id: int, enabled: bool = True) -> dict:
+ return {
+ "id": "propagation-test-model",
+ "name": "Propagation Test Model",
+ "description": "model used to verify price propagation",
+ "created": 0,
+ "context_length": 128000,
+ "architecture": {
+ "modality": "text",
+ "input_modalities": ["text"],
+ "output_modalities": ["text"],
+ "tokenizer": "unknown",
+ "instruct_type": None,
+ },
+ "pricing": {
+ "prompt": prompt,
+ "completion": prompt * 2,
+ "input_cache_read": 0.0,
+ "input_cache_write": 0.0,
+ "request": 0.0,
+ "image": 0.0,
+ "web_search": 0.0,
+ "internal_reasoning": 0.0,
+ },
+ "per_request_limits": None,
+ "top_provider": None,
+ "upstream_provider_id": provider_id,
+ "canonical_slug": None,
+ "alias_ids": [],
+ "enabled": enabled,
+ "forwarded_model_id": "propagation-test-model",
+ }
+
+
+async def _seed_provider(session: AsyncSession, *, fee: float = 1.0) -> int:
+ """Insert a provider, refresh the upstream map, and return its primary key."""
+ provider = UpstreamProviderRow(
+ provider_type="generic",
+ base_url="https://propagation-test.example/v1",
+ api_key="test-key",
+ provider_fee=fee,
+ )
+ session.add(provider)
+ await session.commit()
+ await session.refresh(provider)
+ await reinitialize_upstreams()
+ assert provider.id is not None
+ return provider.id
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_price_edit_propagates_to_served_catalogue(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """A price edit through the admin API must change the served /v1/models price."""
+
+ provider_id = await _seed_provider(integration_session)
+
+ headers = _admin_headers()
+ r = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=headers,
+ json=_model_payload(prompt=1.0e-7, provider_id=provider_id),
+ )
+ assert r.status_code == 200
+
+ # -- record the served price before edit -----------------------------------
+ public = await integration_client.get("/v1/models")
+ assert public.status_code == 200
+ public_data = public.json()
+ assert len(public_data["data"]) > 0, "catalogue must not be empty"
+ served_before = {
+ m["id"]: m.get("pricing", {}).get("prompt") for m in public_data["data"]
+ }
+ assert "propagation-test-model" in served_before
+ before = served_before["propagation-test-model"]
+
+ # -- edit the price and re-check -------------------------------------------
+ r = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=headers,
+ json=_model_payload(prompt=5.0e-7, provider_id=provider_id),
+ )
+ assert r.status_code == 200
+
+ public = await integration_client.get("/v1/models")
+ assert public.status_code == 200
+ served_after = {
+ m["id"]: m.get("pricing", {}).get("prompt") for m in public.json()["data"]
+ }
+ after = served_after["propagation-test-model"]
+
+ assert before != after, "served price did not change after admin edit"
+ # With provider_fee=1.0 the served price equals the stored raw price.
+ assert after == pytest.approx(5.0e-7)
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_admin_readback_is_raw_served_is_fee_adjusted(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """Admin read-back returns the raw price; /v1/models returns the fee-adjusted one."""
+
+ provider_id = await _seed_provider(integration_session, fee=1.05)
+
+ headers = _admin_headers()
+ model_payload = _model_payload(prompt=1.0e-7, provider_id=provider_id)
+ r = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=headers,
+ json=model_payload,
+ )
+ assert r.status_code == 200
+
+ # Admin read-back: apply_provider_fee=False
+ admin_r = await integration_client.get(
+ f"/admin/api/upstream-providers/{provider_id}/models/propagation-test-model",
+ headers=headers,
+ )
+ assert admin_r.status_code == 200
+ admin_body = admin_r.json()
+ raw_prompt = admin_body["pricing"]["prompt"]
+ assert raw_prompt == pytest.approx(1.0e-7)
+
+ # Public /v1/models: fee-adjusted
+ public = await integration_client.get("/v1/models")
+ assert public.status_code == 200
+ served = {
+ m["id"]: m.get("pricing", {}).get("prompt") for m in public.json()["data"]
+ }
+ assert served["propagation-test-model"] == pytest.approx(1.0e-7 * 1.05)
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_disabled_model_not_served(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """A disabled model must be absent from /v1/models but still present in the DB."""
+
+ provider_id = await _seed_provider(integration_session)
+
+ headers = _admin_headers()
+ r = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=headers,
+ json=_model_payload(prompt=1.0e-7, provider_id=provider_id, enabled=True),
+ )
+ assert r.status_code == 200
+
+ # Confirm it appears in the public catalogue.
+ public = await integration_client.get("/v1/models")
+ served_ids = {m["id"] for m in public.json()["data"]}
+ assert "propagation-test-model" in served_ids
+
+ # -- disable via upsert ----------------------------------------------------
+ r = await integration_client.post(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=headers,
+ json=_model_payload(prompt=1.0e-7, provider_id=provider_id, enabled=False),
+ )
+ assert r.status_code == 200
+
+ # Public catalogue must no longer list it.
+ public = await integration_client.get("/v1/models")
+ served_ids = {m["id"] for m in public.json()["data"]}
+ assert "propagation-test-model" not in served_ids
+
+ # DB row must still exist.
+ row = await integration_session.get(
+ ModelRow, ("propagation-test-model", provider_id)
+ )
+ assert row is not None
+ assert row.enabled is False
From 1f57b9805ad2c12b8eff7f0a76bc1228da7df750 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 1 Sep 2026 14:23:59 +0200
Subject: [PATCH 056/120] test(integration): add model serialization
characterization tests
Eight tests through the three public surfaces that go through _row_to_model:
plain model, litellm backfill, cache-rate preservation, request floor, fee
flag vs recomputed max costs, sats-conversion failure survival, the full
serialised dict field for field, and agreement between the single-model
read-back and the provider listing.
The full-dict test is what catches a field disappearing outright; the
value-level tests above it would all still pass.
---
tests/integration/test_model_serialization.py | 435 ++++++++++++++++++
1 file changed, 435 insertions(+)
create mode 100644 tests/integration/test_model_serialization.py
diff --git a/tests/integration/test_model_serialization.py b/tests/integration/test_model_serialization.py
new file mode 100644
index 00000000..791669d7
--- /dev/null
+++ b/tests/integration/test_model_serialization.py
@@ -0,0 +1,435 @@
+"""Characterization tests for the model-serialisation pipeline ``_row_to_model`` runs.
+
+These pin what the three public surfaces that reach it serve today: ``GET
+/v1/models``, the admin single-model read-back, and the admin provider model
+listing. Covered are the plain model, litellm cache backfill, preservation of
+explicit cache rates, the request-price floor, the provider-fee flag against
+recomputed max costs, survival of a failed sats conversion, the full serialised
+dict field for field, and agreement between the two admin views.
+
+Nothing here asserts on the deterministic USD half in isolation, so the pins hold
+whether or not it is split out from the live BTC-rate conversion.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Iterator
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from routstr.core.admin import admin_sessions
+from routstr.core.db import ModelRow, UpstreamProviderRow
+from routstr.proxy import reinitialize_upstreams
+
+
+# The conftest patches ``routstr.payment.price.sats_usd_price``, but
+# ``models.py`` imports it as ``from .price import sats_usd_price`` — a
+# local binding the conftest-level patch cannot reach. Pin it here so
+# every test that goes through ``_row_to_model`` gets a real sats price.
+@pytest.fixture(autouse=True)
+def _pin_sats_usd() -> Iterator[None]:
+ with patch("routstr.payment.models.sats_usd_price", return_value=0.0005):
+ yield
+
+
+def _admin_headers() -> dict[str, str]:
+ token = "test-serialisation-token"
+ admin_sessions[token] = int(
+ (datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
+ )
+ return {"Authorization": f"Bearer {token}"}
+
+
+# -- helpers -------------------------------------------------------------------
+
+_SEEDED_MODEL_ID = "ser-test-model"
+
+
+async def _seed_provider(session: AsyncSession, fee: float = 1.0) -> int:
+ """Insert a provider, refresh the upstream map, and return its primary key."""
+ provider = UpstreamProviderRow(
+ provider_type="generic",
+ base_url="https://serialisation-test.example/v1",
+ api_key="test-key",
+ provider_fee=fee,
+ )
+ session.add(provider)
+ await session.commit()
+ await session.refresh(provider)
+ await reinitialize_upstreams()
+ assert provider.id is not None
+ return provider.id
+
+
+async def _seed_model(
+ session: AsyncSession,
+ provider_id: int,
+ *,
+ model_id: str = _SEEDED_MODEL_ID,
+ prompt: float = 1.0e-7,
+ completion: float = 2.0e-7,
+ cache_read: float = 0.0,
+ cache_write: float = 0.0,
+ request_price: float = 0.0,
+ enabled: bool = True,
+) -> ModelRow:
+ row = ModelRow(
+ id=model_id,
+ name=f"SerTest {model_id}",
+ description="characterization model",
+ created=0,
+ context_length=128000,
+ architecture=json.dumps(
+ {
+ "modality": "text",
+ "input_modalities": ["text"],
+ "output_modalities": ["text"],
+ "tokenizer": "unknown",
+ "instruct_type": None,
+ }
+ ),
+ pricing=json.dumps(
+ {
+ "prompt": prompt,
+ "completion": completion,
+ "input_cache_read": cache_read,
+ "input_cache_write": cache_write,
+ "request": request_price,
+ "image": 0.0,
+ "web_search": 0.0,
+ "internal_reasoning": 0.0,
+ }
+ ),
+ upstream_provider_id=provider_id,
+ enabled=enabled,
+ forwarded_model_id=model_id,
+ )
+ session.add(row)
+ await session.commit()
+ return row
+
+
+async def _raw_via_admin(client: AsyncClient, provider_id: int, model_id: str) -> dict:
+ """Return the raw (``apply_provider_fee=False``) model dict from admin read-back."""
+ r = await client.get(
+ f"/admin/api/upstream-providers/{provider_id}/models/{model_id}",
+ headers=_admin_headers(),
+ )
+ assert r.status_code == 200
+ return r.json()
+
+
+async def _served_via_public(client: AsyncClient, model_id: str) -> dict | None:
+ """Return the served model dict from /v1/models, or None if absent."""
+ r = await client.get("/v1/models")
+ assert r.status_code == 200
+ return {m["id"]: m for m in r.json()["data"]}.get(model_id)
+
+
+# -- test 1: plain model -------------------------------------------------------
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_plain_model_serialisation(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """A model with prompt + completion prices has the expected serialised shape."""
+ provider_id = await _seed_provider(integration_session)
+ await _seed_model(integration_session, provider_id)
+ await reinitialize_upstreams()
+
+ # Admin read-back (raw, no fee)
+ body = await _raw_via_admin(
+ integration_client,
+ provider_id,
+ _SEEDED_MODEL_ID,
+ )
+ assert body["id"] == _SEEDED_MODEL_ID
+ assert body["pricing"]["prompt"] == pytest.approx(1.0e-7)
+ assert body["pricing"]["completion"] == pytest.approx(2.0e-7)
+ assert body["sats_pricing"] is not None
+ # With sats_usd_price = 0.0005 (the fixture)
+ assert body["sats_pricing"]["prompt"] == pytest.approx(1.0e-7 / 0.0005)
+ assert body["sats_pricing"]["completion"] == pytest.approx(2.0e-7 / 0.0005)
+
+ # Public /v1/models (fee applied)
+ s = await _served_via_public(integration_client, _SEEDED_MODEL_ID)
+ assert s is not None, f"{_SEEDED_MODEL_ID} not found in /v1/models"
+ # fee=1.0 so values match raw
+ assert s["pricing"]["prompt"] == pytest.approx(1.0e-7)
+ assert s["pricing"]["completion"] == pytest.approx(2.0e-7)
+
+
+# -- test 2: no cache rates (litellm backfill) ---------------------------------
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_model_without_cache_rates_gets_litellm_backfill(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """A well-known model without cache rates gets them from litellm's cost map."""
+ provider_id = await _seed_provider(integration_session)
+ # Use a real litellm-known id so backfill_cache_pricing can find it.
+ await _seed_model(
+ integration_session,
+ provider_id,
+ model_id="gpt-4o",
+ prompt=2.5e-6,
+ completion=1.0e-5,
+ cache_read=0.0,
+ cache_write=0.0,
+ )
+ await reinitialize_upstreams()
+
+ # Admin read-back (raw): cache_read should be present after backfill.
+ # (cache_write may not be in litellm's map for every model.)
+ body = await _raw_via_admin(
+ integration_client,
+ provider_id,
+ "gpt-4o",
+ )
+ assert body["pricing"]["input_cache_read"] > 0.0, (
+ "backfill_cache_pricing should have filled input_cache_read from litellm"
+ )
+
+
+# -- test 3: cache rates already present (not overwritten) ---------------------
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_existing_cache_rates_not_overwritten(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """A model with explicit cache rates must keep them; the backfill is a no-op."""
+ provider_id = await _seed_provider(integration_session)
+ await _seed_model(
+ integration_session,
+ provider_id,
+ prompt=1.0e-7,
+ completion=2.0e-7,
+ cache_read=9.99e-9,
+ cache_write=8.88e-9,
+ )
+ await reinitialize_upstreams()
+
+ body = await _raw_via_admin(
+ integration_client,
+ provider_id,
+ _SEEDED_MODEL_ID,
+ )
+ assert body["pricing"]["input_cache_read"] == pytest.approx(9.99e-9)
+ assert body["pricing"]["input_cache_write"] == pytest.approx(8.88e-9)
+
+
+# -- test 4: request price floor -----------------------------------------------
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_model_with_request_price(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """A model with a request price floor carries it through to the served model."""
+ provider_id = await _seed_provider(integration_session)
+ await _seed_model(integration_session, provider_id, request_price=0.01)
+ await reinitialize_upstreams()
+
+ body = await _raw_via_admin(
+ integration_client,
+ provider_id,
+ _SEEDED_MODEL_ID,
+ )
+ assert body["pricing"]["request"] == pytest.approx(0.01)
+
+
+# -- test 5: provider_fee=True vs False, max costs recomputed ------------------
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_fee_flag_changes_pricing_but_max_costs_are_recomputed(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """With provider_fee=1.5, fee-adjusted pricing is 1.5× raw, but max costs are NOT."""
+ provider_id = await _seed_provider(integration_session, fee=1.5)
+ await _seed_model(integration_session, provider_id)
+ await reinitialize_upstreams()
+
+ # Admin read-back: raw, no fee.
+ body = await _raw_via_admin(
+ integration_client,
+ provider_id,
+ _SEEDED_MODEL_ID,
+ )
+ assert body["pricing"]["prompt"] == pytest.approx(1.0e-7)
+
+ # Public /v1/models: fee applied.
+ s = await _served_via_public(integration_client, _SEEDED_MODEL_ID)
+ assert s is not None
+ assert s["pricing"]["prompt"] == pytest.approx(1.0e-7 * 1.5)
+
+ # max_prompt_cost must NOT just be multiplied by 1.5 — it is recomputed from
+ # the fee-inflated per-token rates and context_length.
+ cl = 128_000
+ expected_max_prompt = cl * 1.0e-7 * 1.5
+ assert s["pricing"]["max_prompt_cost"] == pytest.approx(expected_max_prompt)
+
+
+# -- test 6: sats conversion failure keeps the model alive ---------------------
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_model_survives_sats_conversion_failure(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """When the BTC feed fails, the model still returns — with no sats_pricing."""
+ provider_id = await _seed_provider(integration_session)
+ await _seed_model(integration_session, provider_id)
+
+ # Make sats_usd_price raise so _update_model_sats_pricing swallows it.
+ # The admin read-back must happen inside the patch block.
+ with patch(
+ "routstr.payment.models.sats_usd_price",
+ side_effect=RuntimeError("BTC feed down"),
+ ):
+ await reinitialize_upstreams()
+ body = await _raw_via_admin(
+ integration_client,
+ provider_id,
+ _SEEDED_MODEL_ID,
+ )
+ assert body["id"] == _SEEDED_MODEL_ID
+ assert body["sats_pricing"] is None, (
+ "sats conversion failure must not crash — the model returns with no sats_pricing"
+ )
+
+
+# -- test 7: the whole serialised dict -----------------------------------------
+
+# The sats figures are the USD ones divided by the pinned 0.0005 rate; written
+# as the division so the expectation carries the same float error the code does.
+_SATS = 0.0005
+
+
+def _expected_serialised_model(provider_id: int) -> dict:
+ """Every field the raw admin read-back produces for the seeded model.
+
+ Note the max-cost asymmetry: with fees off the USD max costs stay at zero
+ while their sats counterparts are computed. That is what the code does
+ today, and pinning it is the point.
+ """
+ return {
+ "alias_ids": None,
+ "architecture": {
+ "input_modalities": ["text"],
+ "instruct_type": None,
+ "modality": "text",
+ "output_modalities": ["text"],
+ "tokenizer": "unknown",
+ },
+ "canonical_slug": None,
+ "context_length": 128000,
+ "created": 0,
+ "description": "characterization model",
+ "enabled": True,
+ "forwarded_model_id": _SEEDED_MODEL_ID,
+ "id": _SEEDED_MODEL_ID,
+ "name": f"SerTest {_SEEDED_MODEL_ID}",
+ "per_request_limits": None,
+ "pricing": {
+ "completion": 2.0e-7,
+ "image": 0.0,
+ "input_cache_read": 0.0,
+ "input_cache_write": 0.0,
+ "internal_reasoning": 0.0,
+ "max_completion_cost": 0.0,
+ "max_cost": 0.0,
+ "max_prompt_cost": 0.0,
+ "prompt": 1.0e-7,
+ "request": 0.01,
+ "web_search": 0.0,
+ },
+ "sats_pricing": {
+ "completion": 2.0e-7 / _SATS,
+ "image": 0.0,
+ "input_cache_read": 0.0,
+ "input_cache_write": 0.0,
+ "internal_reasoning": 0.0,
+ "max_completion_cost": 0.0,
+ "max_cost": 0.001,
+ "max_prompt_cost": 0.0,
+ "prompt": 1.0e-7 / _SATS,
+ "request": 0.01 / _SATS,
+ "web_search": 0.0,
+ },
+ "top_provider": None,
+ "upstream_provider_id": provider_id,
+ }
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_full_serialised_model_is_unchanged(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """Pin every field of the serialised model, not just the interesting ones.
+
+ The tests above pin values. A refactor that dropped a field outright
+ would satisfy all of them and fail only here.
+ """
+ provider_id = await _seed_provider(integration_session)
+ await _seed_model(integration_session, provider_id, request_price=0.01)
+ await reinitialize_upstreams()
+
+ body = await _raw_via_admin(
+ integration_client,
+ provider_id,
+ _SEEDED_MODEL_ID,
+ )
+ assert body == _expected_serialised_model(provider_id)
+
+
+# -- test 8: the provider model listing ----------------------------------------
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_provider_listing_matches_single_model_read_back(
+ integration_client: AsyncClient,
+ integration_session: AsyncSession,
+) -> None:
+ """The listing is a third entry into the same builder — it must agree."""
+ provider_id = await _seed_provider(integration_session)
+ await _seed_model(integration_session, provider_id, request_price=0.01)
+ await reinitialize_upstreams()
+
+ single = await _raw_via_admin(
+ integration_client,
+ provider_id,
+ _SEEDED_MODEL_ID,
+ )
+
+ r = await integration_client.get(
+ f"/admin/api/upstream-providers/{provider_id}/models",
+ headers=_admin_headers(),
+ )
+ assert r.status_code == 200
+ listed = {m["id"]: m for m in r.json()["db_models"]}
+ assert _SEEDED_MODEL_ID in listed, "seeded model missing from the provider listing"
+ assert listed[_SEEDED_MODEL_ID] == single
From 3c279d40484103b127783d0a1358a41dfafb19f0 Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 1 Sep 2026 12:31:20 +0200
Subject: [PATCH 057/120] refactor(models): extract the deterministic USD
builder from _row_to_model
---
routstr/payment/models.py | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/routstr/payment/models.py b/routstr/payment/models.py
index e7b160fb..26a52aac 100644
--- a/routstr/payment/models.py
+++ b/routstr/payment/models.py
@@ -241,9 +241,10 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
return []
-def _row_to_model(
+def _build_model_from_row(
row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
) -> Model:
+ """The deterministic USD view of a stored model row, before the sats conversion."""
architecture = json.loads(row.architecture)
pricing = json.loads(row.pricing)
per_request_limits = (
@@ -301,6 +302,14 @@ def _row_to_model(
parsed_pricing.max_cost,
) = _calculate_usd_max_costs(model)
+ return model
+
+
+def _row_to_model(
+ row: ModelRow, apply_provider_fee: bool = False, provider_fee: float = 1.01
+) -> Model:
+ model = _build_model_from_row(row, apply_provider_fee, provider_fee)
+
try:
sats_to_usd = sats_usd_price()
model = _update_model_sats_pricing(model, sats_to_usd)
From b8c5a6e0f4ab1041adef2ea701d5d9589ead9d5f Mon Sep 17 00:00:00 2001
From: Jeroen Ubbink
Date: Tue, 1 Sep 2026 15:15:42 +0200
Subject: [PATCH 058/120] feat(payment): keep the upstream reported USD cost
beside the billed one
_calculate_from_usd_cost multiplies the provider fee into the same local that
holds the upstream's reported USD cost, destroying it in place. total_usd is
post-fee and nothing kept the pre-fee figure, so a later cost check could not
compare what the upstream said against what we charged. Dividing the total back
out does not recover it: on a token-priced request the total is the node's own
arithmetic, so the division compares that number to itself.
Add upstream_usd to CostData, captured before the multiply, with
Field(default=0.0, exclude=True) so it never reaches a client -- published
beside total_usd it would spell out the node's margin as a ratio. It lives on
the object and does not survive .dict(), which is what the outbound cost
metadata and response headers are built from.
Token-priced requests leave it at 0.0, which doubles as the discriminator for
whether an upstream reported a cost at all. No persistence, no migration, no
charged-amount change.
---
routstr/payment/cost_calculation.py | 5 +-
tests/unit/test_upstream_reported_cost.py | 123 ++++++++++++++++++++++
2 files changed, 127 insertions(+), 1 deletion(-)
create mode 100644 tests/unit/test_upstream_reported_cost.py
diff --git a/routstr/payment/cost_calculation.py b/routstr/payment/cost_calculation.py
index f28ec6e3..b4b0ff6b 100644
--- a/routstr/payment/cost_calculation.py
+++ b/routstr/payment/cost_calculation.py
@@ -1,7 +1,7 @@
import math
from typing import TYPE_CHECKING
-from pydantic.v1 import BaseModel
+from pydantic.v1 import BaseModel, Field
from ..core import get_logger
from ..core.settings import settings
@@ -37,6 +37,7 @@ class CostData(BaseModel):
cache_creation_msats: int = 0
# Actual debit after finalization; None means settlement has not run yet.
charged_msats: int | None = None
+ upstream_usd: float = Field(default=0.0, exclude=True)
class MaxCostData(CostData):
@@ -491,6 +492,7 @@ def _calculate_from_usd_cost(
"""Calculate cost from USD figures, deriving input/output split from tokens."""
if provider_fee is None:
provider_fee = _resolve_provider_fee(response_data.get("model", ""))
+ reported_usd = usd_cost
usd_cost = usd_cost * provider_fee
input_usd = input_usd * provider_fee
output_usd = output_usd * provider_fee
@@ -576,6 +578,7 @@ def _calculate_from_usd_cost(
cache_creation_input_tokens=cache_creation_tokens,
cache_read_msats=cache_read_msats,
cache_creation_msats=cache_creation_msats,
+ upstream_usd=reported_usd,
)
diff --git a/tests/unit/test_upstream_reported_cost.py b/tests/unit/test_upstream_reported_cost.py
new file mode 100644
index 00000000..0ee7f9d0
--- /dev/null
+++ b/tests/unit/test_upstream_reported_cost.py
@@ -0,0 +1,123 @@
+"""Tests that the upstream's own USD figure survives the provider-fee multiply.
+
+``_calculate_from_usd_cost`` multiplies the fee into the same local that holds
+the upstream's reported cost, so by the time a ``CostData`` exists the raw
+figure is gone and only the marked-up one remains. Dividing the total back out
+does not recover it: when the request falls through to token pricing the total
+is the node's own arithmetic, and dividing it compares that number to itself.
+
+These tests cover ``upstream_usd`` — the pre-fee figure carried alongside the
+billed one, and the discriminator for whether an upstream reported a cost at
+all. They also cover the boundary it must not cross: the pair of numbers spells
+out the node's margin, so it stays internal and is never serialised to a client.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Iterator
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+
+from routstr.payment.cost_calculation import CostData, calculate_cost
+from routstr.payment.models import Architecture, Model, Pricing
+
+
+@pytest.fixture(autouse=True)
+def patch_sats_usd_price() -> Iterator[None]:
+ """Pin the exchange rate; these tests are about the USD figure, not the feed."""
+ with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=5.0e-5):
+ yield
+
+
+def _model() -> Model:
+ return Model(
+ id="m",
+ name="m",
+ created=0,
+ description="d",
+ context_length=8192,
+ architecture=Architecture(
+ modality="text",
+ input_modalities=["text"],
+ output_modalities=["text"],
+ tokenizer="unknown",
+ instruct_type=None,
+ ),
+ pricing=Pricing(prompt=1e-06, completion=2e-06),
+ sats_pricing=Pricing(prompt=1e-06, completion=2e-06),
+ )
+
+
+def _response(usage: dict[str, Any]) -> dict[str, Any]:
+ return {"model": "m", "usage": usage}
+
+
+@pytest.mark.asyncio
+async def test_reported_cost_is_kept_alongside_the_billed_one() -> None:
+ """The billed total carries the fee; ``upstream_usd`` must not."""
+ response = _response(
+ {"prompt_tokens": 1000, "completion_tokens": 500, "cost": 0.01}
+ )
+
+ cost = await calculate_cost(
+ response, max_cost=999999, model_obj=_model(), provider_fee=1.05
+ )
+
+ assert isinstance(cost, CostData)
+ assert cost.total_usd == pytest.approx(0.0105)
+ assert cost.upstream_usd == pytest.approx(0.01)
+
+
+@pytest.mark.asyncio
+async def test_token_priced_request_reports_no_upstream_cost() -> None:
+ """Nothing was reported, so there is nothing to carry — not our own total."""
+ response = _response({"prompt_tokens": 1000, "completion_tokens": 500})
+
+ cost = await calculate_cost(
+ response, max_cost=999999, model_obj=_model(), provider_fee=1.05
+ )
+
+ assert isinstance(cost, CostData)
+ assert cost.total_msats > 0
+ assert cost.upstream_usd == 0.0
+
+
+@pytest.mark.asyncio
+async def test_reported_cost_is_never_serialised_to_a_client() -> None:
+ """Publishing it beside the billed total would spell out the node's margin."""
+ response = _response(
+ {"prompt_tokens": 1000, "completion_tokens": 500, "cost": 0.01}
+ )
+
+ cost = await calculate_cost(
+ response, max_cost=999999, model_obj=_model(), provider_fee=1.05
+ )
+
+ assert isinstance(cost, CostData)
+ assert cost.upstream_usd == pytest.approx(0.01)
+ assert "upstream_usd" not in cost.dict()
+ assert "upstream_usd" not in cost.json()
+
+
+@pytest.mark.asyncio
+async def test_billed_total_is_reproducible_from_the_reported_cost() -> None:
+ """Fee, rate and rounding must carry the reported figure to the billed one.
+
+ The identity a report can restate: whatever the upstream said, times the
+ provider fee, converted at the current rate and rounded up, is what the node
+ charged. A figure chosen for its awkward remainder keeps the ceiling honest.
+ """
+ response = _response(
+ {"prompt_tokens": 1000, "completion_tokens": 500, "cost": 0.000123}
+ )
+
+ cost = await calculate_cost(
+ response, max_cost=999999, model_obj=_model(), provider_fee=1.03
+ )
+
+ assert isinstance(cost, CostData)
+ assert cost.total_msats == math.ceil(cost.upstream_usd * 1.03 / 5.0e-5 * 1000)
+ assert cost.total_msats == 2534
From 0662dc5bae1bedebfafc6e64fc2fafb27146b090 Mon Sep 17 00:00:00 2001
From: redshift <213178690+1ftredsh@users.noreply.github.com>
Date: Wed, 2 Sep 2026 11:47:43 +0200
Subject: [PATCH 059/120] Fix mypy error in ehbp timeout test
---
tests/unit/test_ehbp_timeout.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/tests/unit/test_ehbp_timeout.py b/tests/unit/test_ehbp_timeout.py
index bd19871d..760a491e 100644
--- a/tests/unit/test_ehbp_timeout.py
+++ b/tests/unit/test_ehbp_timeout.py
@@ -34,8 +34,9 @@ async def test_x_cashu_timeout_refunds_and_returns_504(
monkeypatch.setattr(
ehbp_module, "store_cashu_transaction", AsyncMock(return_value=None)
)
+ send_cashu_refund_mock = AsyncMock(return_value="refund-token")
monkeypatch.setattr(
- ehbp_module, "send_cashu_refund", AsyncMock(return_value="refund-token")
+ ehbp_module, "send_cashu_refund", send_cashu_refund_mock
)
monkeypatch.setattr(
ehbp_module,
@@ -75,6 +76,6 @@ async def test_x_cashu_timeout_refunds_and_returns_504(
assert response.status_code == 504
assert response.headers["X-Cashu"] == "refund-token"
- ehbp_module.send_cashu_refund.assert_awaited_once_with(
+ send_cashu_refund_mock.assert_awaited_once_with(
1000, "msat", None, "req-123"
)
From cdafdcbbd3dc72a7f146c12f449746fcb181ea3e Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Wed, 2 Sep 2026 22:27:17 +0200
Subject: [PATCH 060/120] update completion path
---
routstr/payment/helpers.py | 17 +-
routstr/upstream/base.py | 83 ++++--
routstr/upstream/count_tokens.py | 31 +-
tests/unit/test_completions_billing.py | 384 +++++++++++++++++++++++++
tests/unit/test_count_tokens_local.py | 9 +
tests/unit/test_payment_helpers.py | 26 ++
6 files changed, 512 insertions(+), 38 deletions(-)
create mode 100644 tests/unit/test_completions_billing.py
diff --git a/routstr/payment/helpers.py b/routstr/payment/helpers.py
index 4d4696f2..4509649c 100644
--- a/routstr/payment/helpers.py
+++ b/routstr/payment/helpers.py
@@ -287,16 +287,21 @@ def _sum_string_chars(node: Any) -> int:
return 0
+def _count_prompt_token_ids(node: Any) -> int:
+ if isinstance(node, int) and not isinstance(node, bool):
+ return 1
+ if isinstance(node, list):
+ return sum(_count_prompt_token_ids(item) for item in node)
+ return 0
+
+
def estimate_prompt_tokens(body: dict) -> int:
"""Conservatively estimate prompt tokens for the whole provider-bound body.
- Unlike ``estimate_tokens`` (message text only), this walks every field, so
- prompt weight hidden in tool schemas, tool-call arguments, ``system``, or
- any field forwarded in future cannot escape the reservation estimate. It
- over-estimates rather than under-estimates: the result only shrinks a
- discount against a reservation that settlement later refunds.
+ Every string counts, as do token IDs in legacy ``prompt`` arrays, so no
+ forwarded field can hide prompt weight and shrink its reservation.
"""
- return _sum_string_chars(body) // 3
+ return _sum_string_chars(body) // 3 + _count_prompt_token_ids(body.get("prompt"))
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index f8b7fe3e..420832a1 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -248,6 +248,13 @@ def _is_json_content_type(content_type: str | None) -> bool:
return main.startswith("application/") and main.endswith("+json")
+def _openai_completion_path(path: str) -> str | None:
+ canonical = "/" + path.rstrip("/")
+ if canonical.endswith("/chat/completions"):
+ return "chat/completions"
+ return "completions" if canonical.endswith("/completions") else None
+
+
class TopupData(BaseModel):
"""Universal top-up data schema for Lightning Network invoices."""
@@ -653,17 +660,20 @@ class BaseUpstreamProvider:
return "openrouter.ai" in (self.base_url or "")
def prepare_request_body(
- self, body: bytes | None, model_obj: Model
+ self,
+ body: bytes | None,
+ model_obj: Model,
+ include_stream_usage: bool = False,
) -> bytes | None:
"""Transform request body for provider-specific requirements.
- Automatically transforms model names and, for streaming chat
- completions, opts the upstream into emitting per-chunk ``usage``
- so cost tracking can read real token counts instead of falling
- back to ``MaxCostData``.
+ Automatically transforms model names and opts streaming OpenAI
+ completion endpoints into emitting per-chunk ``usage`` so cost
+ tracking can read real token counts.
Args:
body: Original request body bytes
+ include_stream_usage: Opt a streaming completion into usage chunks
Returns:
Transformed request body bytes
@@ -706,14 +716,8 @@ class BaseUpstreamProvider:
# OpenAI-compatible streaming responses omit ``usage`` unless the
# request sets ``stream_options.include_usage = true``. Without it
# we can't reconcile token counts at end of stream and must use
- # the local request/response estimator. Discriminate
- # chat-completions-shaped requests by the ``messages`` field so we
- # don't poke unrelated endpoints.
- if (
- data.get("stream") is True
- and "messages" in data
- and isinstance(data.get("messages"), list)
- ):
+ # the local request/response estimator.
+ if data.get("stream") is True and include_stream_usage:
existing = data.get("stream_options")
merged = dict(existing) if isinstance(existing, dict) else {}
if merged.get("include_usage") is not True:
@@ -1022,6 +1026,7 @@ class BaseUpstreamProvider:
reservation_snapshot: ReservationSnapshot | None = None,
client: httpx.AsyncClient | None = None,
request_body: bytes | None = None,
+ legacy_completion: bool = False,
) -> StreamingResponse:
"""Handle streaming chat completion responses with token usage tracking and cost adjustment.
@@ -1060,6 +1065,7 @@ class BaseUpstreamProvider:
last_model_seen: str | None = None
usage_chunk_data: dict | None = None
done_seen: bool = False
+ stream_id: str | None = None
async def finalize_db_only() -> None:
nonlocal usage_finalized
@@ -1118,7 +1124,7 @@ class BaseUpstreamProvider:
* ``[DONE]`` is swallowed so it can be re-emitted exactly once at
end of stream.
"""
- nonlocal last_model_seen, usage_chunk_data, done_seen
+ nonlocal last_model_seen, usage_chunk_data, done_seen, stream_id
event = raw_event.strip(b"\r\n")
if not event:
@@ -1171,9 +1177,12 @@ class BaseUpstreamProvider:
or not isinstance(obj["id"], str)
or obj["id"] == "existing-id"
):
- if not hasattr(self, "_current_stream_id"):
- self._current_stream_id = f"chatcmpl-{uuid.uuid4()}"
- obj["id"] = self._current_stream_id
+ if stream_id is None:
+ id_prefix = "cmpl" if legacy_completion else "chatcmpl"
+ stream_id = f"{id_prefix}-{uuid.uuid4()}"
+ obj["id"] = stream_id
+ else:
+ stream_id = obj["id"]
if isinstance(obj.get("usage"), dict):
# Capture usage for end-of-stream cost reconciliation.
# Some models (e.g. Gemini thinking models over the
@@ -1285,11 +1294,14 @@ class BaseUpstreamProvider:
raise
if usage_chunk_data is None:
- if not hasattr(self, "_current_stream_id"):
- self._current_stream_id = f"chatcmpl-{uuid.uuid4()}"
+ if stream_id is None:
+ id_prefix = "cmpl" if legacy_completion else "chatcmpl"
+ stream_id = f"{id_prefix}-{uuid.uuid4()}"
usage_chunk_data = {
- "id": self._current_stream_id,
- "object": "chat.completion.chunk",
+ "id": stream_id,
+ "object": "text_completion"
+ if legacy_completion
+ else "chat.completion.chunk",
"model": last_model_seen or "unknown",
"choices": [],
"usage": {
@@ -1361,6 +1373,7 @@ class BaseUpstreamProvider:
model_obj: Model | None = None,
reservation_snapshot: ReservationSnapshot | None = None,
request_body: bytes | None = None,
+ legacy_completion: bool = False,
) -> Response:
"""Handle non-streaming chat completion responses with token usage tracking and cost adjustment.
@@ -1400,9 +1413,11 @@ class BaseUpstreamProvider:
if requested_model:
response_json["model"] = requested_model
if "id" not in response_json or not isinstance(response_json["id"], str):
- response_json["id"] = f"chatcmpl-{uuid.uuid4()}"
+ prefix = "cmpl" if legacy_completion else "chatcmpl"
+ response_json["id"] = f"{prefix}-{uuid.uuid4()}"
- if not isinstance(response_json.get("usage"), dict):
+ usage = response_json.get("usage")
+ if not isinstance(usage, dict) or not usage:
usage_estimator = MissingUsageEstimator(request_body, model_obj)
usage_estimator.observe(response_json)
response_json["usage"] = usage_estimator.openai_response_data(
@@ -2923,6 +2938,7 @@ class BaseUpstreamProvider:
Returns:
Response or StreamingResponse from upstream with cost tracking
"""
+ completion_path = _openai_completion_path(path)
path = self.normalize_request_path(path, model_obj)
if (
@@ -2951,7 +2967,11 @@ class BaseUpstreamProvider:
(model_obj.forwarded_model_id or model_obj.id) if model_obj else None
)
- transformed_body = self.prepare_request_body(request_body, model_obj)
+ transformed_body = self.prepare_request_body(
+ request_body,
+ model_obj,
+ include_stream_usage=completion_path is not None,
+ )
logger.debug(
"Forwarding request to upstream",
@@ -3048,7 +3068,7 @@ class BaseUpstreamProvider:
return mapped_error
if (
- path.endswith("chat/completions")
+ completion_path is not None
or path.endswith("embeddings")
or path.endswith("messages")
or path.endswith("messages/count_tokens")
@@ -3117,7 +3137,7 @@ class BaseUpstreamProvider:
await response.aclose()
await client.aclose()
- if path.endswith("chat/completions"):
+ if completion_path is not None:
client_wants_streaming = False
if request_body:
try:
@@ -3163,6 +3183,7 @@ class BaseUpstreamProvider:
reservation_snapshot=reservation_snapshot,
client=client,
request_body=request_body,
+ legacy_completion=completion_path == "completions",
)
# Handle both non-streaming chat completions and embeddings
@@ -3177,6 +3198,7 @@ class BaseUpstreamProvider:
model_obj=model_obj,
reservation_snapshot=reservation_snapshot,
request_body=request_body,
+ legacy_completion=completion_path == "completions",
)
finally:
await response.aclose()
@@ -4213,6 +4235,7 @@ class BaseUpstreamProvider:
Returns:
Response or StreamingResponse with refund if applicable
"""
+ completion_path = _openai_completion_path(path)
if path.startswith("v1/"):
path = path.replace("v1/", "")
@@ -4241,7 +4264,11 @@ class BaseUpstreamProvider:
url = f"{self.base_url}/{path}"
- transformed_body = self.prepare_request_body(request_body, model_obj)
+ transformed_body = self.prepare_request_body(
+ request_body,
+ model_obj,
+ include_stream_usage=completion_path is not None,
+ )
logger.debug(
"Forwarding request to upstream",
@@ -4338,7 +4365,7 @@ class BaseUpstreamProvider:
return error_response
if (
- path.endswith("chat/completions")
+ completion_path is not None
or path.endswith("embeddings")
or path.endswith("messages")
or path.endswith("messages/count_tokens")
diff --git a/routstr/upstream/count_tokens.py b/routstr/upstream/count_tokens.py
index c79d11d9..8ebeb6d9 100644
--- a/routstr/upstream/count_tokens.py
+++ b/routstr/upstream/count_tokens.py
@@ -23,7 +23,11 @@ import litellm
from fastapi.responses import Response
from ..core import get_logger
-from ..payment.helpers import estimate_prompt_tokens, estimate_tokens
+from ..payment.helpers import (
+ _count_prompt_token_ids,
+ estimate_prompt_tokens,
+ estimate_tokens,
+)
from ..payment.models import Model
logger = get_logger(__name__)
@@ -46,11 +50,28 @@ def _model_name(model_obj: Model | None, body: dict[str, Any]) -> str:
return body_model if isinstance(body_model, str) else ""
-def _count_with_litellm(model: str, body: dict[str, Any]) -> int:
+def _count_with_litellm(
+ model: str, body: dict[str, Any], include_legacy_prompt: bool = False
+) -> int:
messages = body.get("messages")
if not isinstance(messages, list):
messages = []
+ prompt_token_ids = 0
+ if include_legacy_prompt:
+ prompt = body.get("prompt")
+ if isinstance(prompt, str):
+ prompt_texts = [prompt]
+ elif isinstance(prompt, list):
+ prompt_texts = [item for item in prompt if isinstance(item, str)]
+ else:
+ prompt_texts = []
+ prompt_token_ids = _count_prompt_token_ids(prompt)
+ messages = [
+ *({"role": "user", "content": text} for text in prompt_texts if text),
+ *messages,
+ ]
+
system = body.get("system")
if isinstance(system, str) and system:
messages = [{"role": "system", "content": system}, *messages]
@@ -65,7 +86,7 @@ def _count_with_litellm(model: str, body: dict[str, Any]) -> int:
tools = body.get("tools") if isinstance(body.get("tools"), list) else None
- return int(
+ return prompt_token_ids + int(
litellm.token_counter(
model=model,
messages=messages,
@@ -133,7 +154,9 @@ class MissingUsageEstimator:
if self._input_tokens is not None:
return self._input_tokens
try:
- self._input_tokens = _count_with_litellm(self.model_name, self.body)
+ self._input_tokens = _count_with_litellm(
+ self.model_name, self.body, include_legacy_prompt=True
+ )
except Exception as exc:
self._input_tokens = estimate_prompt_tokens(self.body)
logger.debug(
diff --git a/tests/unit/test_completions_billing.py b/tests/unit/test_completions_billing.py
new file mode 100644
index 00000000..6a512136
--- /dev/null
+++ b/tests/unit/test_completions_billing.py
@@ -0,0 +1,384 @@
+import json
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+from fastapi.responses import Response
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
+from sqlmodel import SQLModel
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+import routstr.auth as auth_module
+from routstr.auth import ReservationSnapshot, get_reservation_snapshot, pay_for_request
+from routstr.core.db import ApiKey, ReservationRelease
+from routstr.payment.models import Architecture, Model, Pricing
+from routstr.upstream.base import BaseUpstreamProvider
+
+BALANCE = 100_000
+RESERVED = 5_000
+# 1 msat per prompt token, 2 msat per completion token.
+MODEL = Model(
+ id="glm-test",
+ name="glm-test",
+ created=0,
+ description="",
+ context_length=64_000,
+ architecture=Architecture(
+ modality="text->text",
+ input_modalities=["text"],
+ output_modalities=["text"],
+ tokenizer="Other",
+ instruct_type=None,
+ ),
+ pricing=Pricing(prompt=0.001, completion=0.002),
+ sats_pricing=Pricing(prompt=0.001, completion=0.002),
+)
+USAGE = {"prompt_tokens": 400, "completion_tokens": 100, "total_tokens": 500}
+EXPECTED_MSATS = 400 * 1 + 100 * 2
+
+COMPLETION_BODY = {
+ "model": MODEL.id,
+ "prompt": "Once upon a time, in a land far away, " * 20,
+ "max_tokens": 100,
+}
+CHAT_BODY = {"model": MODEL.id, "messages": [{"role": "user", "content": "hi"}]}
+
+COMPLETION_JSON = {
+ "id": "cmpl-1",
+ "object": "text_completion",
+ "model": MODEL.id,
+ "choices": [{"text": " there was", "index": 0, "finish_reason": "stop"}],
+ "usage": USAGE,
+}
+COMPLETION_CHUNKS = [
+ {
+ "id": "cmpl-1",
+ "object": "text_completion",
+ "model": MODEL.id,
+ "choices": [{"text": " there", "index": 0, "finish_reason": None}],
+ },
+ {
+ "id": "cmpl-1",
+ "object": "text_completion",
+ "model": MODEL.id,
+ "choices": [{"text": " was", "index": 0, "finish_reason": "stop"}],
+ },
+]
+USAGE_CHUNK = {
+ "id": "cmpl-1",
+ "object": "text_completion",
+ "model": MODEL.id,
+ "choices": [],
+ "usage": USAGE,
+}
+
+
+def _sse(chunks: list[dict]) -> bytes:
+ body = b"".join(b"data: " + json.dumps(c).encode() + b"\n\n" for c in chunks)
+ return body + b"data: [DONE]\n\n"
+
+
+@pytest.fixture(autouse=True)
+def patch_sats_usd_price() -> Any:
+ with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=5.0e-4):
+ yield
+
+
+async def _engine() -> AsyncEngine:
+ engine = create_async_engine("sqlite+aiosqlite://")
+ async with engine.begin() as connection:
+ await connection.run_sync(SQLModel.metadata.create_all)
+ return engine
+
+
+def _upstream(content: bytes, content_type: str) -> httpx.Response:
+ return httpx.Response(
+ 200,
+ content=content,
+ headers={"content-type": content_type},
+ request=httpx.Request("POST", "http://upstream"),
+ )
+
+
+async def _drain(response: Any) -> bytes:
+ body = b""
+ if hasattr(response, "body_iterator"):
+ async for chunk in response.body_iterator:
+ body += chunk if isinstance(chunk, bytes) else chunk.encode()
+ else:
+ body = response.body
+ return body
+
+
+async def _forward(
+ engine: AsyncEngine,
+ path: str,
+ body: dict,
+ upstream: httpx.Response,
+) -> tuple[bytes, ReservationSnapshot, AsyncMock]:
+ """Reserve, forward through the real ``forward_request`` and settle."""
+ provider = BaseUpstreamProvider(
+ base_url="http://upstream", api_key="k", provider_fee=1.0
+ )
+ request = MagicMock()
+ request.method = "POST"
+ request.query_params = {}
+ send = AsyncMock(return_value=upstream)
+
+ async with AsyncSession(engine, expire_on_commit=False) as session:
+ key = ApiKey(hashed_key="key", balance=BALANCE)
+ session.add(key)
+ await session.commit()
+ await pay_for_request(key, RESERVED, session)
+ snapshot = await get_reservation_snapshot(key, session)
+
+ with (
+ patch("httpx.AsyncClient.send", send),
+ patch(
+ "routstr.upstream.base.create_session",
+ side_effect=lambda: AsyncSession(engine, expire_on_commit=False),
+ ),
+ patch(
+ "routstr.upstream.base.adjust_payment_for_tokens",
+ auth_module.adjust_payment_for_tokens,
+ ),
+ ):
+ response = await provider.forward_request(
+ request,
+ path,
+ {},
+ json.dumps(body).encode(),
+ key,
+ RESERVED,
+ session,
+ MODEL,
+ snapshot,
+ )
+ out = await _drain(response)
+ return out, snapshot, send
+
+
+async def _ledger(
+ engine: AsyncEngine, snapshot: ReservationSnapshot
+) -> tuple[int, int, int, str | None]:
+ async with AsyncSession(engine, expire_on_commit=False) as session:
+ key = await session.get(ApiKey, snapshot.key_hash)
+ record = await session.get(ReservationRelease, snapshot.release_id)
+ assert key is not None
+ return (
+ key.balance,
+ key.total_spent,
+ key.reserved_balance,
+ record.status if record else None,
+ )
+
+
+def _sse_objects(out: bytes) -> list[dict]:
+ objs = []
+ for line in out.split(b"\n"):
+ if line.startswith(b"data: ") and line[6:].strip() != b"[DONE]":
+ objs.append(json.loads(line[6:]))
+ return objs
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "path",
+ [
+ "completions",
+ "v1/completions",
+ "v1/completions/",
+ "openai/v1/completions",
+ ],
+)
+async def test_non_streaming_completion_with_usage_is_charged(path: str) -> None:
+ engine = await _engine()
+ out, snapshot, _ = await _forward(
+ engine,
+ path,
+ COMPLETION_BODY,
+ _upstream(json.dumps(COMPLETION_JSON).encode(), "application/json"),
+ )
+
+ balance, spent, reserved, status = await _ledger(engine, snapshot)
+ assert (balance, spent, reserved, status) == (
+ BALANCE - EXPECTED_MSATS,
+ EXPECTED_MSATS,
+ 0,
+ "charged",
+ )
+ body = json.loads(out)
+ assert body["object"] == "text_completion"
+ assert body["usage"]["cost"]["total_msats"] == EXPECTED_MSATS
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_streaming_completion_with_final_usage_is_charged() -> None:
+ engine = await _engine()
+ out, snapshot, send = await _forward(
+ engine,
+ "v1/completions",
+ {**COMPLETION_BODY, "stream": True},
+ _upstream(_sse([*COMPLETION_CHUNKS, USAGE_CHUNK]), "text/event-stream"),
+ )
+
+ balance, spent, reserved, status = await _ledger(engine, snapshot)
+ assert (balance, spent, reserved, status) == (
+ BALANCE - EXPECTED_MSATS,
+ EXPECTED_MSATS,
+ 0,
+ "charged",
+ )
+
+ forwarded = json.loads(send.call_args.args[0].content)
+ assert forwarded["stream_options"] == {"include_usage": True}
+
+ objs = _sse_objects(out)
+ assert [o["choices"][0]["text"] for o in objs if o["choices"]] == [
+ " there",
+ " was",
+ ]
+ assert objs[-1]["object"] == "text_completion"
+ assert objs[-1]["usage"]["cost"]["total_msats"] == EXPECTED_MSATS
+ assert out.endswith(b"data: [DONE]\n\n")
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_non_streaming_completion_with_empty_usage_is_estimated() -> None:
+ """Missing usage is estimated from ``prompt`` and ``text``, never free."""
+ engine = await _engine()
+ no_usage = {k: v for k, v in COMPLETION_JSON.items() if k != "id"}
+ no_usage["usage"] = {}
+ out, snapshot, _ = await _forward(
+ engine,
+ "v1/completions",
+ COMPLETION_BODY,
+ _upstream(json.dumps(no_usage).encode(), "application/json"),
+ )
+
+ balance, spent, reserved, status = await _ledger(engine, snapshot)
+ assert 0 < spent <= RESERVED
+ assert (BALANCE - balance, reserved, status) == (spent, 0, "charged")
+ body = json.loads(out)
+ usage = body["usage"]
+ assert body["id"].startswith("cmpl-")
+ assert usage["estimated"] is True
+ assert usage["prompt_tokens"] > 100
+ assert usage["completion_tokens"] > 0
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_streaming_completion_without_usage_is_estimated() -> None:
+ engine = await _engine()
+ out, snapshot, _ = await _forward(
+ engine,
+ "v1/completions",
+ {**COMPLETION_BODY, "stream": True},
+ _upstream(_sse(COMPLETION_CHUNKS), "text/event-stream"),
+ )
+
+ balance, spent, reserved, status = await _ledger(engine, snapshot)
+ assert 0 < spent <= RESERVED
+ assert (BALANCE - balance, reserved, status) == (spent, 0, "charged")
+ trailer = _sse_objects(out)[-1]
+ assert trailer["id"] == "cmpl-1"
+ assert trailer["object"] == "text_completion"
+ assert trailer["usage"]["prompt_tokens"] > 100
+ assert trailer["usage"]["cost"]["total_msats"] == spent
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("path", ["v1/chat/completions/", "openai/v1/chat/completions"])
+async def test_chat_completion_aliases_are_charged(path: str) -> None:
+ engine = await _engine()
+ chat_json = {
+ "id": "chatcmpl-1",
+ "object": "chat.completion",
+ "model": MODEL.id,
+ "choices": [
+ {
+ "message": {"role": "assistant", "content": "hi"},
+ "index": 0,
+ "finish_reason": "stop",
+ }
+ ],
+ "usage": USAGE,
+ }
+ out, snapshot, _ = await _forward(
+ engine,
+ path,
+ CHAT_BODY,
+ _upstream(json.dumps(chat_json).encode(), "application/json"),
+ )
+
+ balance, spent, reserved, status = await _ledger(engine, snapshot)
+ assert (balance, spent, reserved, status) == (
+ BALANCE - EXPECTED_MSATS,
+ EXPECTED_MSATS,
+ 0,
+ "charged",
+ )
+ assert json.loads(out)["usage"]["cost"]["total_msats"] == EXPECTED_MSATS
+ await engine.dispose()
+
+
+def test_stream_usage_option_is_scoped_to_completion_endpoints() -> None:
+ provider = BaseUpstreamProvider(base_url="http://upstream", api_key="k")
+ body = json.dumps({"prompt": "draw this", "stream": True}).encode()
+
+ assert provider.prepare_request_body(body, MODEL) == body
+
+
+async def _forward_x_cashu(
+ path: str, body: dict, upstream: httpx.Response
+) -> tuple[AsyncMock, AsyncMock]:
+ """Run ``forward_x_cashu_request`` with the settlement handler stubbed out."""
+ provider = BaseUpstreamProvider(
+ base_url="http://upstream", api_key="k", provider_fee=1.0
+ )
+ request = MagicMock()
+ request.method = "POST"
+ request.query_params = {}
+ request.state.request_id = "req-1"
+ request.body = AsyncMock(return_value=json.dumps(body).encode())
+ send = AsyncMock(return_value=upstream)
+ settle = AsyncMock(return_value=Response(content=b"{}", status_code=200))
+
+ with (
+ patch("httpx.AsyncClient.send", send),
+ patch.object(provider, "handle_x_cashu_chat_completion", settle),
+ ):
+ await provider.forward_x_cashu_request(
+ request, path, {}, 10, "sat", RESERVED, MODEL
+ )
+ return settle, send
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("path", ["completions", "v1/completions", "v1/completions/"])
+async def test_x_cashu_legacy_completion_is_settled(path: str) -> None:
+ """Legacy completions must reach refund settlement, not raw passthrough."""
+ settle, _ = await _forward_x_cashu(
+ path,
+ COMPLETION_BODY,
+ _upstream(json.dumps(COMPLETION_JSON).encode(), "application/json"),
+ )
+
+ assert settle.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_x_cashu_streaming_completion_requests_usage() -> None:
+ _, send = await _forward_x_cashu(
+ "v1/completions",
+ {**COMPLETION_BODY, "stream": True},
+ _upstream(_sse([*COMPLETION_CHUNKS, USAGE_CHUNK]), "text/event-stream"),
+ )
+
+ forwarded = json.loads(send.call_args.args[0].content)
+ assert forwarded["stream_options"] == {"include_usage": True}
diff --git a/tests/unit/test_count_tokens_local.py b/tests/unit/test_count_tokens_local.py
index ebd23cb2..85a8ec72 100644
--- a/tests/unit/test_count_tokens_local.py
+++ b/tests/unit/test_count_tokens_local.py
@@ -229,6 +229,15 @@ def test_missing_usage_estimator_openai_dialect() -> None:
}
+def test_missing_usage_estimator_counts_legacy_token_prompt() -> None:
+ model = _make_model()
+ request_body = _body({"model": model.id, "prompt": [[1, 2], [3, 4, 5]]})
+
+ usage = MissingUsageEstimator(request_body, model).openai_response_data()["usage"]
+
+ assert usage["prompt_tokens"] >= 5
+
+
def test_missing_usage_estimator_does_not_count_response_metadata() -> None:
estimator = MissingUsageEstimator(b"{}", None)
estimator.observe(
diff --git a/tests/unit/test_payment_helpers.py b/tests/unit/test_payment_helpers.py
index e5dbf136..2eab19c1 100644
--- a/tests/unit/test_payment_helpers.py
+++ b/tests/unit/test_payment_helpers.py
@@ -183,6 +183,32 @@ def test_estimate_prompt_tokens_counts_every_string_in_the_body() -> None:
# value prefix can buy a discount, so both still count in full.
assert estimate_prompt_tokens({"tools": [{"data": hidden}]}) >= 1_000
assert estimate_prompt_tokens({"system": "data:" + hidden}) >= 1_000
+ assert estimate_prompt_tokens({"prompt": [[1, 2], [3, 4, 5]]}) >= 5
+
+
+async def test_discount_counts_legacy_token_id_prompt() -> None:
+ from routstr.payment.helpers import calculate_discounted_max_cost
+
+ pricing = Mock()
+ pricing.prompt = 0.001
+ pricing.completion = 0.0
+ pricing.max_prompt_cost = 50.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", "prompt": list(range(50_000)), "max_tokens": 0}
+ 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(50_000, body, model_obj)
+
+ assert cost == 50_000
async def test_discount_cannot_be_dodged_by_hiding_prompt_in_tools() -> None:
From ffae549d330a4a5ab15fb8bcb08e3309ffa07b7f Mon Sep 17 00:00:00 2001
From: 9qeklajc
Date: Thu, 3 Sep 2026 01:05:34 +0200
Subject: [PATCH 061/120] clean up xcashu path
---
routstr/upstream/base.py | 39 ++++++++++++++++----
tests/unit/test_messages_litellm_dispatch.py | 32 +++++++++++-----
2 files changed, 54 insertions(+), 17 deletions(-)
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index 420832a1..39005698 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -255,6 +255,13 @@ def _openai_completion_path(path: str) -> str | None:
return "completions" if canonical.endswith("/completions") else None
+def _x_cashu_path_has_settlement_handler(path: str) -> bool:
+ canonical = path.rstrip("/")
+ return _openai_completion_path(canonical) is not None or canonical.endswith(
+ ("embeddings", "messages", "messages/count_tokens")
+ )
+
+
class TopupData(BaseModel):
"""Universal top-up data schema for Lightning Network invoices."""
@@ -4245,7 +4252,15 @@ class BaseUpstreamProvider:
path.endswith("messages/count_tokens")
and not self.supports_anthropic_messages
):
- return count_tokens_locally(request_body, model_obj)
+ result = count_tokens_locally(request_body, model_obj)
+ refund_token = await self.send_refund(
+ amount,
+ unit,
+ mint,
+ request_id=getattr(request.state, "request_id", None),
+ )
+ result.headers["X-Cashu"] = refund_token
+ return result
if (
path.endswith("messages")
@@ -4364,12 +4379,7 @@ class BaseUpstreamProvider:
error_response.headers["X-Cashu"] = refund_token
return error_response
- if (
- completion_path is not None
- or path.endswith("embeddings")
- or path.endswith("messages")
- or path.endswith("messages/count_tokens")
- ):
+ if _x_cashu_path_has_settlement_handler(path):
logger.debug(
"Processing completion/embeddings/messages response",
extra={"path": path, "amount": amount, "unit": unit},
@@ -5157,6 +5167,21 @@ class BaseUpstreamProvider:
},
)
+ # Reject before redemption so the client keeps its token.
+ if not _x_cashu_path_has_settlement_handler(path):
+ logger.warning(
+ "Rejecting X-Cashu request for unsupported endpoint",
+ extra={"path": path, "method": request.method},
+ )
+ return create_error_response(
+ "invalid_request_error",
+ "X-Cashu payment is not supported on this endpoint; use bearer "
+ "(deposit) authentication instead. The token was not redeemed.",
+ 400,
+ request=request,
+ code="x_cashu_unsupported_endpoint",
+ )
+
redeemed = False
try:
headers = dict(request.headers)
diff --git a/tests/unit/test_messages_litellm_dispatch.py b/tests/unit/test_messages_litellm_dispatch.py
index ec93d555..ca5a83c5 100644
--- a/tests/unit/test_messages_litellm_dispatch.py
+++ b/tests/unit/test_messages_litellm_dispatch.py
@@ -1134,18 +1134,30 @@ async def test_forward_x_cashu_request_handles_count_tokens_locally() -> None:
"prepare_request_body",
side_effect=AssertionError("upstream should not be called"),
):
- response = await provider.forward_x_cashu_request(
- request=request,
- path="v1/messages/count_tokens",
- headers={},
- amount=5_000,
- unit="sat",
- max_cost_for_model=10_000,
- model_obj=model,
- mint="https://mint",
- )
+ with patch.object(
+ provider,
+ "send_refund",
+ new=AsyncMock(return_value="refund-token"),
+ ) as send_refund:
+ response = await provider.forward_x_cashu_request(
+ request=request,
+ path="v1/messages/count_tokens",
+ headers={},
+ amount=5_000,
+ unit="sat",
+ max_cost_for_model=10_000,
+ model_obj=model,
+ mint="https://mint",
+ )
+ send_refund.assert_awaited_once_with(
+ 5_000,
+ "sat",
+ "https://mint",
+ request_id="req-test",
+ )
assert response.status_code == 200
+ assert response.headers["X-Cashu"] == "refund-token"
body = response.body if isinstance(response.body, bytes) else bytes(response.body)
payload = json.loads(body.decode())
assert "input_tokens" in payload
From 47807ae92ee5ae34341bad6e8607e6197b591bcb Mon Sep 17 00:00:00 2001
From: thefux
Date: Thu, 3 Sep 2026 11:33:39 +0000
Subject: [PATCH 062/120] Remove child key feature and balance limits
completely
Removes the child-key (shared parent balance) feature and the
balance-limit machinery from the entire stack:
Backend:
- Drop POST /v1/balance/child-key and /v1/balance/child-key/reset
- Remove parent-key billing indirection (get_billing_key); every key
is charged directly
- Remove balance_limit/balance_limit_reset enforcement, reset
policies, and the periodic limit-reset background task
- Remove child-key guards on refund/history endpoints
- Remove child_key_cost setting and /v1/info child_key_cost_msats
- Remove balance_limit fields from LightningInvoice model and
invoice-creation API
- Admin balances API returns plain sums (no parent/child split)
Migration (e5a6b7c8d9f0):
- Nulls parent_key_hash on children (they become standalone keys;
parents keep 100% of their balance, so no funds are lost)
- Drops parent_key_hash + balance_limit columns from api_keys and
balance_limit columns from lightning_invoices
- Verified: upgrade, downgrade, and fund preservation round-trip
UI:
- Remove child-key creator, child key panels, balance-limit inputs
- Key options now offer validity date only
- Temporary balances table renders all keys uniformly
Tests: child-key suites removed; remaining suites converted to
single-key semantics (441 integration + 1313 unit tests pass).
Docs updated accordingly.
---
docs/api/authentication.md | 17 -
docs/api/endpoints.md | 54 +-
examples/create_child_keys.py | 45 --
...f0_remove_child_keys_and_balance_limits.py | 76 +++
routstr/auth.py | 297 +----------
routstr/balance.py | 187 +------
routstr/core/admin.py | 51 +-
routstr/core/db.py | 43 +-
routstr/core/main.py | 3 -
routstr/core/settings.py | 1 -
routstr/lightning.py | 10 -
routstr/upstream/ehbp.py | 17 +-
tests/integration/test_child_keys.py | 190 -------
tests/integration/test_child_keys_api.py | 102 ----
tests/integration/test_failover_billing.py | 99 +---
tests/integration/test_key_logic.py | 367 +------------
.../test_lightning_invoice_constraints.py | 68 +--
tests/integration/test_payment_invariants.py | 99 +---
tests/integration/test_prune_dead_api_keys.py | 28 -
.../test_reserved_balance_negative.py | 102 ----
.../test_temporary_balances_api.py | 54 +-
tests/unit/test_balance.py | 15 -
tests/unit/test_ehbp_finalize_payment.py | 117 +----
tests/unit/test_stale_reservations.py | 56 +-
.../test_streaming_billing_finalization.py | 39 +-
ui/components/child-key-creator.tsx | 481 ------------------
ui/components/key-options.tsx | 65 +--
.../landing/cashu-payment-workflow.tsx | 22 -
ui/components/landing/cheat-sheet.tsx | 13 +-
ui/components/landing/key-info-details.tsx | 184 +------
.../landing/lightning-payment-workflow.tsx | 25 -
ui/components/temporary-balances.tsx | 92 +---
ui/hooks/use-wallet-info.ts | 5 -
ui/lib/api/services/admin.ts | 1 -
ui/lib/api/services/wallet.ts | 88 ----
35 files changed, 189 insertions(+), 2924 deletions(-)
delete mode 100644 examples/create_child_keys.py
create mode 100644 migrations/versions/e5a6b7c8d9f0_remove_child_keys_and_balance_limits.py
delete mode 100644 tests/integration/test_child_keys.py
delete mode 100644 tests/integration/test_child_keys_api.py
delete mode 100644 ui/components/child-key-creator.tsx
diff --git a/docs/api/authentication.md b/docs/api/authentication.md
index c7b91b3b..ec4c1449 100644
--- a/docs/api/authentication.md
+++ b/docs/api/authentication.md
@@ -307,23 +307,6 @@ ANALYTICS_KEY = os.getenv("ROUTSTR_ANALYTICS_KEY")
api_key = PROD_KEY if is_production() else DEV_KEY
```
-### Delegated Authentication
-
-Create sub-keys with limited permissions:
-
-```bash
-POST /v1/wallet/create/subkey
-Authorization: Bearer sk-parent-key
-Content-Type: application/json
-
-{
- "name": "Limited Subkey",
- "balance_limit": 1000,
- "allowed_models": ["gpt-3.5-turbo"],
- "expires_in_hours": 24
-}
-```
-
## Rate Limiting
Rate limits are applied per API key:
diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md
index 23b019c7..dfd619f2 100644
--- a/docs/api/endpoints.md
+++ b/docs/api/endpoints.md
@@ -418,7 +418,7 @@ POST /v1/wallet/create
### Get Key Information
-Get current balance, consumption data, and child keys for an API key.
+Get current balance and consumption data for an API key.
```http
GET /v1/balance/info
@@ -432,23 +432,9 @@ Authorization: Bearer sk-...
"api_key": "sk-abc...",
"balance": 8500000,
"reserved": 0,
- "is_child": false,
- "parent_key": null,
"total_requests": 42,
"total_spent": 1500000,
- "balance_limit": null,
- "balance_limit_reset": null,
- "validity_date": null,
- "child_keys": [
- {
- "api_key": "sk-child1...",
- "total_requests": 10,
- "total_spent": 500000,
- "balance_limit": 1000000,
- "balance_limit_reset": "daily",
- "validity_date": 1738000000
- }
- ]
+ "validity_date": null
}
```
@@ -528,42 +514,6 @@ Authorization: Bearer sk-...
}
```
-### Create Child Key
-
-Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable).
-
-```http
-POST /v1/balance/child-key
-Authorization: Bearer sk-...
-```
-
-**Request Body:**
-
-```json
-{
- "count": 1
-}
-```
-
-**Parameters:**
-
-| Parameter | Type | Required | Default | Description |
-|-----------|------|----------|---------|-------------|
-| `count` | integer | Yes | - | Number of child keys to create (1-50) |
-
-**Response:**
-
-```json
-{
- "api_keys": ["sk-abc...", "sk-def..."],
- "count": 2,
- "cost_msats": 2000,
- "cost_sats": 2,
- "parent_balance": 98000,
- "parent_balance_sats": 98
-}
-```
-
## Provider Discovery
## Admin Settings
diff --git a/examples/create_child_keys.py b/examples/create_child_keys.py
deleted file mode 100644
index 24f4556d..00000000
--- a/examples/create_child_keys.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import json
-import sys
-
-import httpx
-
-
-def create_child_keys(base_url: str, api_key: str, count: int = 3) -> list[str]:
- headers = {"Authorization": f"Bearer {api_key}"}
-
- print(f"Requesting {count} child keys from {base_url}...")
-
- child_keys = []
-
- for i in range(count):
- try:
- response = httpx.post(f"{base_url}/v1/balance/child-key", headers=headers)
- if response.status_code == 200:
- data = response.json()
- child_keys.append(data["api_key"])
- print(
- f" [{i + 1}] Created: {data['api_key']} (Cost: {data['cost_msats']} msats)"
- )
- else:
- print(f" [{i + 1}] Failed: {response.status_code} - {response.text}")
- except Exception as e:
- print(f" [{i + 1}] Error: {str(e)}")
-
- return child_keys
-
-
-if __name__ == "__main__":
- if len(sys.argv) < 2:
- print("Usage: python create_child_keys.py [base_url]")
- sys.exit(1)
-
- auth_key = sys.argv[1]
- base_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000"
-
- keys = create_child_keys(base_url, auth_key)
-
- if keys:
- print("\nSuccessfully created child keys:")
- print(json.dumps(keys, indent=2))
- else:
- print("\nNo child keys were created.")
diff --git a/migrations/versions/e5a6b7c8d9f0_remove_child_keys_and_balance_limits.py b/migrations/versions/e5a6b7c8d9f0_remove_child_keys_and_balance_limits.py
new file mode 100644
index 00000000..15298de2
--- /dev/null
+++ b/migrations/versions/e5a6b7c8d9f0_remove_child_keys_and_balance_limits.py
@@ -0,0 +1,76 @@
+"""Remove child keys and balance limits.
+
+Removes the child-key feature (parent_key_hash) and the balance-limit
+machinery (balance_limit, balance_limit_reset, balance_limit_reset_date)
+from api_keys, plus the balance_limit/balance_limit_reset pass-through on
+lightning_invoices.
+
+Data preservation: before dropping the columns, every child key is
+converted into a standalone key by clearing parent_key_hash. Child keys
+never hold their own balance (they always spent from their parent), so no
+funds are lost: the parent keeps its full balance, and the former child
+rows are preserved with their total_spent/total_requests history intact.
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "e5a6b7c8d9f0"
+down_revision = "b4f7a1c9d2e3"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Convert child keys into standalone keys before dropping the link.
+ # Their balance is always 0 (they spent from the parent), so this
+ # cannot strand any funds.
+ op.execute("UPDATE api_keys SET parent_key_hash = NULL")
+
+ with op.batch_alter_table("api_keys") as batch_op:
+ batch_op.drop_index("ix_api_keys_parent_key_hash")
+ batch_op.drop_column("parent_key_hash")
+ batch_op.drop_column("balance_limit")
+ batch_op.drop_column("balance_limit_reset")
+ batch_op.drop_column("balance_limit_reset_date")
+
+ with op.batch_alter_table("lightning_invoices") as batch_op:
+ batch_op.drop_column("balance_limit")
+ batch_op.drop_column("balance_limit_reset")
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("lightning_invoices") as batch_op:
+ batch_op.add_column(sa.Column("balance_limit", sa.Integer(), nullable=True))
+ batch_op.add_column(
+ sa.Column("balance_limit_reset", sa.String(), nullable=True)
+ )
+
+ with op.batch_alter_table("api_keys") as batch_op:
+ batch_op.add_column(
+ sa.Column("balance_limit_reset_date", sa.Integer(), nullable=True)
+ )
+ batch_op.add_column(
+ sa.Column(
+ "balance_limit_reset",
+ sa.String(),
+ nullable=True,
+ )
+ )
+ batch_op.add_column(sa.Column("balance_limit", sa.Integer(), nullable=True))
+ batch_op.add_column(
+ sa.Column(
+ "parent_key_hash",
+ sa.String(),
+ nullable=True,
+ )
+ )
+ batch_op.create_foreign_key(
+ "fk_api_keys_parent_key_hash",
+ "api_keys",
+ ["parent_key_hash"],
+ ["hashed_key"],
+ )
+ batch_op.create_index(
+ "ix_api_keys_parent_key_hash", ["parent_key_hash"], unique=False
+ )
diff --git a/routstr/auth.py b/routstr/auth.py
index 7b808007..0478dfa8 100644
--- a/routstr/auth.py
+++ b/routstr/auth.py
@@ -1,13 +1,11 @@
import asyncio
import hashlib
import math
-import random
import time
import uuid
from contextlib import suppress
from contextvars import ContextVar
from dataclasses import dataclass
-from datetime import datetime
from typing import TYPE_CHECKING, Optional
from fastapi import HTTPException
@@ -99,48 +97,6 @@ def _clear_current_reservation(snapshot: ReservationSnapshot) -> None:
# PREPAID_BALANCE = int(os.environ.get("PREPAID_BALANCE", "0")) * 1000 # Convert to msats
-async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
- """Checks if a key's balance limit should be reset based on its policy."""
- if key.balance_limit is not None and key.balance_limit_reset:
- now = int(time.time())
- reset_date = key.balance_limit_reset_date or 0
- should_reset = False
-
- if key.balance_limit_reset == "daily":
- if (
- datetime.fromtimestamp(now).date()
- > datetime.fromtimestamp(reset_date).date()
- ):
- should_reset = True
- elif key.balance_limit_reset == "weekly":
- if (
- datetime.fromtimestamp(now).isocalendar()[:2]
- > datetime.fromtimestamp(reset_date).isocalendar()[:2]
- ):
- should_reset = True
- elif key.balance_limit_reset == "monthly":
- dt_now = datetime.fromtimestamp(now)
- dt_reset = datetime.fromtimestamp(reset_date)
- if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
- should_reset = True
-
- if should_reset:
- logger.info(
- "Resetting balance limit for key",
- extra={
- "key_hash": key.hashed_key[:8] + "...",
- "policy": key.balance_limit_reset,
- "old_spent": key.total_spent,
- },
- )
- key.total_spent = 0
- key.balance_limit_reset_date = now
- session.add(key)
- await session.flush()
- return True
- return False
-
-
def redemption_error_to_http_exception(error: Exception) -> HTTPException:
"""Map a Cashu token redemption failure to a sanitized client-facing error.
@@ -315,42 +271,19 @@ async def _validate_bearer_key_locked(
},
)
- # Check and reset limit if needed
- await check_and_reset_limit(existing_key, session)
-
- # Early check: Billing balance check (Parent balance)
- billing_key = await get_billing_key(existing_key, session)
- if min_cost > 0 and billing_key.total_balance < min_cost:
+ # Early check: Billing balance check
+ if min_cost > 0 and existing_key.total_balance < min_cost:
logger.warning(
"Insufficient billing balance during validation",
extra={
"key_hash": existing_key.hashed_key[:8] + "...",
- "billing_key_hash": billing_key.hashed_key[:8] + "...",
- "balance": billing_key.total_balance,
+ "balance": existing_key.total_balance,
"required": min_cost,
},
)
raise HTTPException(
status_code=402,
- detail=_model_balance_error(min_cost, billing_key.total_balance),
- )
-
- # Early check: Spending limit check (Child key limit)
- if (
- min_cost > 0
- and existing_key.balance_limit is not None
- and existing_key.total_spent + existing_key.reserved_balance + min_cost
- > existing_key.balance_limit
- ):
- raise HTTPException(
- status_code=402,
- detail={
- "error": {
- "message": f"Balance limit exceeded: {existing_key.balance_limit} mSats limit. {existing_key.total_spent} already spent ({existing_key.reserved_balance} reserved), {min_cost} minimum required for this model.",
- "type": "insufficient_quota",
- "code": "balance_limit_exceeded",
- }
- },
+ detail=_model_balance_error(min_cost, existing_key.total_balance),
)
return existing_key
@@ -619,27 +552,6 @@ async def _validate_bearer_key_locked(
)
-async def get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
- """Returns the key that should be charged for the request."""
- if key.parent_key_hash:
- parent = await session.get(ApiKey, key.parent_key_hash)
- if parent:
- # We want to keep the total_requests and total_spent on the child key
- # but use the balance and reserved_balance of the parent.
- # However, pay_for_request updates reserved_balance and total_requests.
- # To stay simple, we charge the parent's balance and update parent's total_requests.
- return parent
- else:
- logger.error(
- "Parent key not found for child key",
- extra={
- "child_key_hash": key.hashed_key[:8] + "...",
- "parent_key_hash": key.parent_key_hash[:8] + "...",
- },
- )
- return key
-
-
async def pay_for_request(
key: ApiKey, cost_per_request: int, session: AsyncSession
) -> int:
@@ -647,7 +559,7 @@ async def pay_for_request(
# Ensure cost_per_request is at least the minimum allowed request cost
cost_per_request = max(cost_per_request, settings.min_request_msat)
- billing_key = await get_billing_key(key, session)
+ billing_key = key
logger.info(
"Processing payment for request",
@@ -706,35 +618,6 @@ async def pay_for_request(
},
)
- # Check balance limit for child keys (or any key with a limit)
- if key.balance_limit is not None:
- await check_and_reset_limit(key, session)
-
- if (
- key.total_spent + key.reserved_balance + cost_per_request
- > key.balance_limit
- ):
- logger.warning(
- "Balance limit exceeded",
- extra={
- "key_hash": key.hashed_key[:8] + "...",
- "total_spent": key.total_spent,
- "reserved": key.reserved_balance,
- "balance_limit": key.balance_limit,
- "required": cost_per_request,
- },
- )
- raise HTTPException(
- status_code=402,
- detail={
- "error": {
- "message": f"Balance limit exceeded: {key.balance_limit} mSats limit. {key.total_spent} already spent ({key.reserved_balance} reserved), {cost_per_request} required for this request.",
- "type": "insufficient_quota",
- "code": "balance_limit_exceeded",
- }
- },
- )
-
logger.debug(
"Charging base cost for request",
extra={
@@ -798,52 +681,6 @@ async def pay_for_request(
},
)
- # Also increment total_requests and reserved_balance on the child key if it's different.
- # The balance_limit guard is enforced atomically here — the Python pre-check above
- # is a fast-path rejection only and provides no concurrency guarantee.
- if billing_key.hashed_key != key.hashed_key:
- child_stmt = (
- update(ApiKey)
- .where(col(ApiKey.hashed_key) == key.hashed_key)
- .where(
- (col(ApiKey.balance_limit).is_(None))
- | (
- col(ApiKey.total_spent)
- + col(ApiKey.reserved_balance)
- + cost_per_request
- <= col(ApiKey.balance_limit)
- )
- )
- .values(
- total_requests=col(ApiKey.total_requests) + 1,
- reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
- reserved_at=reserved_at_now,
- )
- )
- child_result = await session.exec(child_stmt) # type: ignore[call-overload]
-
- if child_result.rowcount == 0:
- # Build the error before rollback expires ORM attributes.
- limit_message = (
- f"Balance limit exceeded: {key.balance_limit} mSats limit. "
- f"{key.total_spent} already spent ({key.reserved_balance} reserved), "
- f"{cost_per_request} required for this request."
- )
- # The parent reservation update already ran in this transaction.
- # Roll it back before failover code attempts to restore the previous
- # reservation; otherwise that later commit can persist both updates.
- await session.rollback()
- raise HTTPException(
- status_code=402,
- detail={
- "error": {
- "message": limit_message,
- "type": "insufficient_quota",
- "code": "balance_limit_exceeded",
- }
- },
- )
-
session.add(
ReservationRelease(
id=reservation.release_id,
@@ -895,8 +732,6 @@ async def pay_for_request(
try:
await session.refresh(billing_key)
- if billing_key.hashed_key != key.hashed_key:
- await session.refresh(key)
except Exception:
# The reservation transaction is already committed and durable. Logging
# refresh failures must not make the caller treat it as unreserved.
@@ -968,9 +803,6 @@ async def _validate_reservation_snapshot(
persisted_key = await session.get(ApiKey, snapshot.key_hash)
if persisted_key is None:
raise RuntimeError("Billing reservation key no longer exists")
- expected_billing_hash = persisted_key.parent_key_hash or persisted_key.hashed_key
- if snapshot.billing_key_hash != expected_billing_hash:
- raise RuntimeError("Billing reservation does not belong to this billing key")
record = await session.get(ReservationRelease, snapshot.release_id)
if (
@@ -1184,22 +1016,6 @@ async def _transition_reservation_to_released(
snapshot, session, decrement_requests=decrement_requests
)
- if snapshot.billing_key_hash != snapshot.key_hash:
- child_release_stmt = (
- update(ApiKey)
- .where(col(ApiKey.hashed_key) == snapshot.key_hash)
- .where(col(ApiKey.reserved_balance) >= snapshot.reserved_msats)
- .values(**values)
- )
- child_result = await session.exec( # type: ignore[call-overload]
- child_release_stmt
- )
- if child_result.rowcount != 1:
- await session.rollback()
- return await _repair_corrupt_reservation(
- snapshot, session, decrement_requests=decrement_requests
- )
-
await session.commit()
await _stop_reservation_heartbeat(snapshot.release_id)
_clear_current_reservation(snapshot)
@@ -1251,15 +1067,14 @@ async def _charge_reservation_rows(
session: AsyncSession,
*,
billing_key_hash: str,
- key_hash: str,
reserved_msats: int,
charge_msats: int,
extra_billing_guards: tuple = (),
) -> bool:
- """Release the reserved amount and record the charge on the billing row
- (and the child row when different) inside the caller's transaction.
+ """Release the reserved amount and record the charge on the key
+ inside the caller's transaction.
- Guarded subtraction replaces defensive clamping: every row must still hold
+ Guarded subtraction replaces defensive clamping: the row must still hold
the full reserved amount, otherwise the whole transaction rolls back and
nothing is charged. A violated invariant must never silently erase the
aggregate reservations of sibling requests. Returns False after rollback.
@@ -1288,26 +1103,6 @@ async def _charge_reservation_rows(
await session.rollback()
return False
- if key_hash != billing_key_hash:
- child_result = await session.exec( # type: ignore[call-overload]
- update(ApiKey)
- .where(col(ApiKey.hashed_key) == key_hash)
- .where(col(ApiKey.reserved_balance) >= reserved_msats)
- .values(
- reserved_balance=col(ApiKey.reserved_balance) - reserved_msats,
- reserved_at=case(
- (
- col(ApiKey.reserved_balance) - reserved_msats > 0,
- col(ApiKey.reserved_at),
- ),
- else_=None,
- ),
- total_spent=col(ApiKey.total_spent) + charge_msats,
- )
- )
- if child_result.rowcount != 1:
- await session.rollback()
- return False
return True
@@ -1332,7 +1127,7 @@ async def adjust_payment_for_tokens(
The response's usage object is normalized with the default union parser in
``calculate_cost``.
"""
- billing_key = await get_billing_key(key, session)
+ billing_key = key
reservation = reservation_snapshot or await get_reservation_snapshot(key, session)
await _validate_reservation_snapshot(
key, reservation, session, require_active=False
@@ -1421,7 +1216,6 @@ async def adjust_payment_for_tokens(
charged = await _charge_reservation_rows(
session,
billing_key_hash=billing_key.hashed_key,
- key_hash=key.hashed_key,
reserved_msats=deducted_max_cost,
charge_msats=cost.total_msats,
)
@@ -1444,8 +1238,6 @@ async def adjust_payment_for_tokens(
else:
cost.charged_msats = cost.total_msats
await session.refresh(billing_key)
- if billing_key.hashed_key != key.hashed_key:
- await session.refresh(key)
logger.info(
"Max cost payment finalized",
extra={
@@ -1512,7 +1304,6 @@ async def adjust_payment_for_tokens(
if not await _charge_reservation_rows(
session,
billing_key_hash=billing_key.hashed_key,
- key_hash=key.hashed_key,
reserved_msats=deducted_max_cost,
charge_msats=total_cost_msats,
):
@@ -1534,8 +1325,6 @@ async def adjust_payment_for_tokens(
await _stop_reservation_heartbeat(reservation.release_id)
cost.charged_msats = total_cost_msats
await session.refresh(billing_key)
- if billing_key.hashed_key != key.hashed_key:
- await session.refresh(key)
await _accumulate_fee(total_cost_msats)
payments_logger.info(
"FINALIZE",
@@ -1600,7 +1389,6 @@ async def adjust_payment_for_tokens(
if await _charge_reservation_rows(
session,
billing_key_hash=billing_key.hashed_key,
- key_hash=key.hashed_key,
reserved_msats=deducted_max_cost,
charge_msats=actual_charge_msats,
extra_billing_guards=(
@@ -1620,8 +1408,6 @@ async def adjust_payment_for_tokens(
await _stop_reservation_heartbeat(reservation.release_id)
await session.refresh(billing_key)
- if billing_key.hashed_key != key.hashed_key:
- await session.refresh(key)
cost.charged_msats = actual_charge_msats
if actual_charge_msats < total_cost_msats:
logger.warning(
@@ -1680,7 +1466,6 @@ async def adjust_payment_for_tokens(
charged = await _charge_reservation_rows(
session,
billing_key_hash=billing_key.hashed_key,
- key_hash=key.hashed_key,
reserved_msats=deducted_max_cost,
charge_msats=total_cost_msats,
)
@@ -1705,8 +1490,6 @@ async def adjust_payment_for_tokens(
cost.total_msats = total_cost_msats
cost.charged_msats = total_cost_msats
await session.refresh(billing_key)
- if billing_key.hashed_key != key.hashed_key:
- await session.refresh(key)
logger.info(
"Refund processed successfully",
@@ -1767,68 +1550,6 @@ async def adjust_payment_for_tokens(
raise AssertionError("Unreachable: unhandled calculate_cost result")
-async def periodic_key_reset() -> None:
- """Background task to reset key limits based on their policy."""
- from .core.db import create_session
-
- while True:
- try:
- interval = 3600 # Run every hour
- jitter = 300
- await asyncio.sleep(interval + random.uniform(0, jitter))
- except asyncio.CancelledError:
- break
-
- try:
- async with create_session() as session:
- # Find all keys that have a reset policy
- stmt = select(ApiKey).where(ApiKey.balance_limit_reset.is_not(None)) # type: ignore
- keys = (await session.exec(stmt)).all()
-
- now = int(time.time())
- updated_count = 0
-
- for key in keys:
- reset_date = key.balance_limit_reset_date or 0
- should_reset = False
-
- if key.balance_limit_reset == "daily":
- if (
- datetime.fromtimestamp(now).date()
- > datetime.fromtimestamp(reset_date).date()
- ):
- should_reset = True
- elif key.balance_limit_reset == "weekly":
- if (
- datetime.fromtimestamp(now).isocalendar()[:2]
- > datetime.fromtimestamp(reset_date).isocalendar()[:2]
- ):
- should_reset = True
- elif key.balance_limit_reset == "monthly":
- dt_now = datetime.fromtimestamp(now)
- dt_reset = datetime.fromtimestamp(reset_date)
- if dt_now.year > dt_reset.year or dt_now.month > dt_reset.month:
- should_reset = True
-
- if should_reset:
- key.total_spent = 0
- key.balance_limit_reset_date = now
- session.add(key)
- updated_count += 1
-
- if updated_count > 0:
- await session.commit()
- logger.info(
- "Periodic key reset complete",
- extra={"keys_reset": updated_count},
- )
-
- except asyncio.CancelledError:
- break
- except Exception as e:
- logger.error(f"Error in periodic_key_reset: {e}")
-
-
async def periodic_dead_key_prune() -> None:
"""Periodically prune dead API keys. Interval <= 0 disables it.
diff --git a/routstr/balance.py b/routstr/balance.py
index 10f483f7..ecd770b5 100644
--- a/routstr/balance.py
+++ b/routstr/balance.py
@@ -1,6 +1,5 @@
import asyncio
import hashlib
-import time
from time import monotonic
from typing import Annotated, NoReturn
@@ -10,7 +9,6 @@ from pydantic import BaseModel
from sqlmodel import col, select, update
from .auth import (
- get_billing_key,
redemption_error_to_http_exception,
validate_bearer_key,
)
@@ -58,39 +56,15 @@ async def get_key_from_header(
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
- billing_key = await get_billing_key(key, session)
info = {
"api_key": "sk-" + key.hashed_key,
- "balance": billing_key.total_balance,
- "reserved": billing_key.reserved_balance,
- "is_child": key.parent_key_hash is not None,
+ "balance": key.total_balance,
+ "reserved": key.reserved_balance,
"total_requests": key.total_requests,
"total_spent": key.total_spent,
- "balance_limit": key.balance_limit,
- "balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
- if key.parent_key_hash:
- info["parent_key_preview"] = key.parent_key_hash[:8] + "..."
- else:
- # Fetch child keys if this is a parent key
- statement = select(ApiKey).where(ApiKey.parent_key_hash == key.hashed_key)
- results = await session.exec(statement)
- child_keys = results.all()
- if child_keys:
- info["child_keys"] = [
- {
- "api_key": "sk-" + ck.hashed_key,
- "total_requests": ck.total_requests,
- "total_spent": ck.total_spent,
- "balance_limit": ck.balance_limit,
- "balance_limit_reset": ck.balance_limit_reset,
- "validity_date": ck.validity_date,
- }
- for ck in child_keys
- ]
-
return info
@@ -117,26 +91,18 @@ async def account_info(
class BalanceCreateRequest(BaseModel):
initial_balance_token: str
- balance_limit: int | None = None
- balance_limit_reset: str | None = None
validity_date: int | None = None
async def _create_balance(
initial_balance_token: str,
- balance_limit: int | None,
- balance_limit_reset: str | None,
validity_date: int | None,
session: AsyncSession,
) -> dict:
key = await validate_bearer_key(initial_balance_token, session)
- if balance_limit is not None or balance_limit_reset or validity_date:
- key.balance_limit = balance_limit
- key.balance_limit_reset = balance_limit_reset
+ if validity_date is not None:
key.validity_date = validity_date
- if balance_limit_reset:
- key.balance_limit_reset_date = int(time.time())
session.add(key)
await session.commit()
await session.refresh(key)
@@ -154,8 +120,6 @@ async def create_balance_from_body(
) -> dict:
return await _create_balance(
payload.initial_balance_token,
- payload.balance_limit,
- payload.balance_limit_reset,
payload.validity_date,
session,
)
@@ -164,15 +128,11 @@ async def create_balance_from_body(
@router.get("/create")
async def create_balance(
initial_balance_token: str,
- balance_limit: int | None = None,
- balance_limit_reset: str | None = None,
validity_date: int | None = None,
session: AsyncSession = Depends(get_session),
) -> dict:
return await _create_balance(
initial_balance_token,
- balance_limit,
- balance_limit_reset,
validity_date,
session,
)
@@ -208,7 +168,7 @@ async def topup_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, int]:
- billing_key = await get_billing_key(key, session)
+ billing_key = key
if topup_request is not None:
cashu_token = topup_request.cashu_token
@@ -458,12 +418,6 @@ async def refund_wallet_endpoint(
if persisted := await _get_persisted_api_key_refund(key, session):
return persisted
- if key.parent_key_hash:
- raise HTTPException(
- status_code=400,
- detail="Cannot refund child key. Please refund the parent key instead.",
- )
-
if key.reserved_balance > 0:
# Release only durable reservations old enough to be stale. A newer
# request on the same aggregate balance must remain reserved.
@@ -650,12 +604,6 @@ async def wallet_history(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict[str, list[dict[str, str | int | bool | None]]]:
- if key.parent_key_hash:
- raise HTTPException(
- status_code=400,
- detail="Cannot view child key history. Please use the parent key instead.",
- )
-
result = await session.exec(
select(CashuTransaction)
.where(CashuTransaction.api_key_hashed_key == key.hashed_key)
@@ -692,133 +640,6 @@ async def donate(token: str, ref: str | None = None) -> str:
except Exception:
return "Invalid token."
-
-class ChildKeyRequest(BaseModel):
- count: int
- balance_limit: int | None = None
- balance_limit_reset: str | None = None
- validity_date: int | None = None
-
-
-@router.post("/child-key")
-async def create_child_key(
- payload: ChildKeyRequest,
- key: ApiKey = Depends(get_key_from_header),
- session: AsyncSession = Depends(get_session),
-) -> dict:
- """Creates one or more child API keys that use the parent's balance."""
- # Log incoming request for debugging
- logger.debug(f"Child key creation request: count={payload.count}")
-
- count = payload.count
- if count < 1 or count > 50:
- raise HTTPException(status_code=400, detail="Count must be between 1 and 50.")
-
- # Check if this is already a child key
- if key.parent_key_hash:
- raise HTTPException(
- status_code=400,
- detail="Cannot create a child key for another child key.",
- )
-
- cost_per_key = settings.child_key_cost
- total_cost = cost_per_key * count
-
- if key.total_balance < total_cost:
- raise HTTPException(
- status_code=402,
- detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
- )
-
- # Deduct cost from parent atomically — guards against concurrent requests
- # that both pass the balance check above on stale in-memory state.
- deduct_stmt = (
- update(ApiKey)
- .where(col(ApiKey.hashed_key) == key.hashed_key)
- .where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= total_cost)
- .values(
- balance=col(ApiKey.balance) - total_cost,
- total_spent=col(ApiKey.total_spent) + total_cost,
- )
- )
- result = await session.exec(deduct_stmt) # type: ignore[call-overload]
-
- if result.rowcount == 0:
- raise HTTPException(
- status_code=402,
- detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
- )
-
- # Generate new keys
- import secrets
-
- new_keys = []
- for _ in range(count):
- new_key_raw = secrets.token_hex(32)
- new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
-
- child_key = ApiKey(
- hashed_key=new_key_hash,
- balance=0,
- parent_key_hash=key.hashed_key,
- balance_limit=payload.balance_limit,
- balance_limit_reset=payload.balance_limit_reset,
- balance_limit_reset_date=int(time.time())
- if payload.balance_limit_reset
- else None,
- validity_date=payload.validity_date,
- )
- session.add(child_key)
- new_keys.append("sk-" + new_key_hash)
-
- await session.commit()
- await session.refresh(key)
-
- response_data = {
- "api_keys": new_keys,
- "count": count,
- "cost_msats": total_cost,
- "cost_sats": total_cost // 1000,
- "parent_balance": key.balance,
- "parent_balance_sats": key.balance // 1000,
- }
- logger.debug(f"Child key creation response: {response_data}")
- return response_data
-
-
-class ChildKeyResetRequest(BaseModel):
- child_key: str
-
-
-@router.post("/child-key/reset")
-async def reset_child_key_spent(
- payload: ChildKeyResetRequest,
- key: ApiKey = Depends(get_key_from_header),
- session: AsyncSession = Depends(get_session),
-) -> dict:
- """Resets the total_spent of a child key. Must be called by the parent."""
- child_key_raw = payload.child_key
- if child_key_raw.startswith("sk-"):
- child_key_raw = child_key_raw[3:]
-
- child_key = await session.get(ApiKey, child_key_raw)
- if not child_key:
- raise HTTPException(status_code=404, detail="Child key not found.")
-
- if child_key.parent_key_hash != key.hashed_key:
- raise HTTPException(
- status_code=403, detail="Unauthorized. You are not the parent of this key."
- )
-
- child_key.total_spent = 0
- if child_key.balance_limit_reset:
- child_key.balance_limit_reset_date = int(time.time())
- session.add(child_key)
- await session.commit()
-
- return {"success": True, "message": "Child key balance reset successfully."}
-
-
@router.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "DELETE"],
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index 66e3a59a..46231554 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -112,42 +112,12 @@ async def get_temporary_balances_api(
total = count_result.one()
# Aggregate totals across the whole (search-filtered) set, not just the
- # current page. Balance counts only parent (non-child) keys to avoid
- # double-counting, since child keys draw from their parent's balance.
balance_totals_result = await session.exec(
select(
+ func.coalesce(func.sum(ApiKey.balance), 0),
+ func.coalesce(func.sum(ApiKey.reserved_balance), 0),
func.coalesce(
- func.sum(
- case(
- (col(ApiKey.parent_key_hash).is_(None), ApiKey.balance),
- else_=0,
- )
- ),
- 0,
- ),
- func.coalesce(
- func.sum(
- case(
- (
- col(ApiKey.parent_key_hash).is_(None),
- ApiKey.reserved_balance,
- ),
- else_=0,
- )
- ),
- 0,
- ),
- func.coalesce(
- func.sum(
- case(
- (
- col(ApiKey.parent_key_hash).is_(None),
- col(ApiKey.balance) - col(ApiKey.reserved_balance),
- ),
- else_=0,
- )
- ),
- 0,
+ func.sum(col(ApiKey.balance) - col(ApiKey.reserved_balance)), 0
),
).where(*filters)
)
@@ -184,16 +154,11 @@ async def get_temporary_balances_api(
"hashed_key": key.hashed_key,
"balance": key.balance,
"reserved_balance": key.reserved_balance,
- "available_balance": (
- key.total_balance if key.parent_key_hash is None else None
- ),
+ "available_balance": key.total_balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"refund_address": key.refund_address,
"key_expiry_time": key.key_expiry_time,
- "parent_key_hash": key.parent_key_hash,
- "balance_limit": key.balance_limit,
- "balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
"created_at": key.created_at,
}
@@ -211,8 +176,6 @@ async def get_temporary_balances_api(
class ApiKeyUpdate(BaseModel):
- balance_limit: int | None = None
- balance_limit_reset: str | None = None
validity_date: int | None = None
@@ -227,10 +190,6 @@ async def update_apikey(
if not key:
raise HTTPException(status_code=404, detail="API key not found")
- if update.balance_limit is not None:
- key.balance_limit = update.balance_limit
- if update.balance_limit_reset is not None:
- key.balance_limit_reset = update.balance_limit_reset
if update.validity_date is not None:
key.validity_date = update.validity_date
@@ -240,8 +199,6 @@ async def update_apikey(
return {
"hashed_key": key.hashed_key,
- "balance_limit": key.balance_limit,
- "balance_limit_reset": key.balance_limit_reset,
"validity_date": key.validity_date,
}
diff --git a/routstr/core/db.py b/routstr/core/db.py
index 14cc669e..4c4d1a62 100644
--- a/routstr/core/db.py
+++ b/routstr/core/db.py
@@ -17,7 +17,6 @@ from sqlalchemy.engine import make_url
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.ext.asyncio.engine import create_async_engine
-from sqlalchemy.orm import aliased
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -138,21 +137,6 @@ class ApiKey(SQLModel, table=True): # type: ignore
default=None,
description="Currency of the cashu-token",
)
- parent_key_hash: str | None = Field(
- default=None, foreign_key="api_keys.hashed_key", index=True
- )
- balance_limit: int | None = Field(
- default=None,
- description="Max spendable balance in msats for this key (mostly for child keys)",
- )
- balance_limit_reset: str | None = Field(
- default=None,
- description="Reset policy for balance limit (manual, daily, monthly, etc.)",
- )
- balance_limit_reset_date: int | None = Field(
- default=None,
- description="Unix timestamp of the last time the balance limit was reset",
- )
validity_date: int | None = Field(
default=None,
description="Unix timestamp after which the key is no longer valid",
@@ -317,10 +301,7 @@ async def release_stale_reservations(
)
else:
legacy_query = legacy_query.where(
- or_(
- col(ApiKey.hashed_key) == key_hash,
- col(ApiKey.parent_key_hash) == key_hash,
- )
+ col(ApiKey.hashed_key) == key_hash
).where(
or_(col(ApiKey.reserved_at).is_(None), col(ApiKey.reserved_at) < cutoff)
)
@@ -362,21 +343,15 @@ async def release_stale_reservations(
async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> int:
- """Delete dead parentless API keys; return the count removed.
+ """Delete dead API keys; return the count removed.
- Dead = 0 balance/reservation/spend/requests, older than the grace period,
- no parent, no children, no invoice that could still settle. Cashu rows are
+ Dead = 0 balance/reservation/spend/requests, older than the grace
+ period, no invoice that could still settle. Cashu rows are
unlinked (not deleted) first to keep the audit trail.
"""
now = int(time.time())
cutoff = now - min_age_seconds
- child = aliased(ApiKey)
- has_children = (
- select(child.hashed_key).where(
- col(child.parent_key_hash) == col(ApiKey.hashed_key)
- )
- ).exists()
# An expired invoice stays creditable for the grace window, and crediting it
# after its target key is gone strands the payment at the mint.
settleable_invoice = (
@@ -400,10 +375,8 @@ async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> in
.where(col(ApiKey.reserved_balance) == 0)
.where(col(ApiKey.total_spent) == 0)
.where(col(ApiKey.total_requests) == 0)
- .where(col(ApiKey.parent_key_hash).is_(None))
.where((col(ApiKey.created_at).is_(None)) | (col(ApiKey.created_at) < cutoff))
.where(~settleable_invoice)
- .where(~has_children)
)
# Unlink transactions rather than cascade-deleting them, so the financial
@@ -542,14 +515,6 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
)
expires_at: int = Field(description="Unix timestamp when invoice expires")
paid_at: int | None = Field(default=None, description="Unix timestamp when paid")
- balance_limit: int | None = Field(
- default=None,
- description="Max spendable msats for the created key",
- )
- balance_limit_reset: str | None = Field(
- default=None,
- description="Reset policy for balance limit (daily, weekly, monthly)",
- )
validity_date: int | None = Field(
default=None,
description="Unix timestamp after which the created key expires",
diff --git a/routstr/core/main.py b/routstr/core/main.py
index b89fa64d..07f11aa2 100644
--- a/routstr/core/main.py
+++ b/routstr/core/main.py
@@ -14,7 +14,6 @@ from starlette.types import Scope
from ..auth import (
periodic_dead_key_prune,
- periodic_key_reset,
periodic_stale_reservation_sweep,
)
from ..balance import balance_router, deprecated_wallet_router
@@ -149,7 +148,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
analytics_task = asyncio.create_task(publish_usage_analytics())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
- key_reset_task = asyncio.create_task(periodic_key_reset())
stale_reservation_task = asyncio.create_task(periodic_stale_reservation_sweep())
dead_key_prune_task = asyncio.create_task(periodic_dead_key_prune())
auto_topup_task = asyncio.create_task(periodic_auto_topup())
@@ -308,7 +306,6 @@ async def info() -> dict:
"mints": global_settings.cashu_mints,
"http_url": global_settings.http_url,
"onion_url": global_settings.onion_url,
- "child_key_cost_msats": global_settings.child_key_cost,
}
diff --git a/routstr/core/settings.py b/routstr/core/settings.py
index 7190f058..014a5795 100644
--- a/routstr/core/settings.py
+++ b/routstr/core/settings.py
@@ -77,7 +77,6 @@ class Settings(BaseSettings):
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
- child_key_cost: int = Field(default=0, env="CHILD_KEY_COST")
# Minimum per-request charge in millisatoshis when model pricing is free/zero
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
reset_reserved_balance_on_startup: bool = Field(
diff --git a/routstr/lightning.py b/routstr/lightning.py
index 13c6b321..e45da084 100644
--- a/routstr/lightning.py
+++ b/routstr/lightning.py
@@ -76,8 +76,6 @@ class _InvoiceSettlement:
purpose: str
api_key_hash: str | None
mint_url: str | None
- balance_limit: int | None
- balance_limit_reset: str | None
validity_date: int | None
@classmethod
@@ -89,8 +87,6 @@ class _InvoiceSettlement:
purpose=invoice.purpose,
api_key_hash=invoice.api_key_hash,
mint_url=invoice.mint_url,
- balance_limit=invoice.balance_limit,
- balance_limit_reset=invoice.balance_limit_reset,
validity_date=invoice.validity_date,
)
@@ -114,8 +110,6 @@ class InvoiceCreateRequest(BaseModel):
default=None,
description="Deprecated: legacy field for topup. Prefer Authorization header.",
)
- balance_limit: int | None = Field(default=None)
- balance_limit_reset: str | None = Field(default=None)
validity_date: int | None = Field(default=None)
@@ -312,8 +306,6 @@ async def create_invoice(
api_key_hash=api_key_token[3:] if api_key_token else None,
purpose=request.purpose,
mint_url=mint_url,
- balance_limit=request.balance_limit,
- balance_limit_reset=request.balance_limit_reset,
validity_date=request.validity_date,
expires_at=expires_at,
)
@@ -697,8 +689,6 @@ async def _create_api_key_record(
balance=invoice.amount_sats * 1000,
refund_currency="sat",
refund_mint_url=mint_url,
- balance_limit=invoice.balance_limit,
- balance_limit_reset=invoice.balance_limit_reset,
validity_date=invoice.validity_date,
)
session.add(api_key)
diff --git a/routstr/upstream/ehbp.py b/routstr/upstream/ehbp.py
index d6b7e7d2..839cb079 100644
--- a/routstr/upstream/ehbp.py
+++ b/routstr/upstream/ehbp.py
@@ -18,7 +18,6 @@ from ..auth import (
_claim_reservation_for_charge,
_stop_reservation_heartbeat,
_validate_reservation_snapshot,
- get_billing_key,
get_reservation_snapshot,
payments_logger,
release_reservation,
@@ -527,9 +526,8 @@ async def finalize_ehbp_actual_cost_payment(
if not await _claim_reservation_for_charge(reservation, session):
return 0
reserved_cost_for_model = reservation.reserved_msats
- billing_key = await get_billing_key(key, session)
key_hash = key.hashed_key
- billing_key_hash = billing_key.hashed_key
+ billing_key_hash = key_hash
total_cost_msats = max(
0, int(cost_info.get("total_msats", reserved_cost_for_model))
)
@@ -538,7 +536,6 @@ async def finalize_ehbp_actual_cost_payment(
charged = await _charge_reservation_rows(
session,
billing_key_hash=billing_key_hash,
- key_hash=key_hash,
reserved_msats=reserved_cost_for_model,
charge_msats=total_cost_msats,
)
@@ -558,9 +555,7 @@ async def finalize_ehbp_actual_cost_payment(
await session.commit()
await _stop_reservation_heartbeat(reservation.release_id)
- await session.refresh(billing_key)
- if billing_key.hashed_key != key.hashed_key:
- await session.refresh(key)
+ await session.refresh(key)
if total_cost_msats > 0 and ROUTSTR_FEE_PERCENT > 0:
fee_msats = math.ceil(total_cost_msats * ROUTSTR_FEE_PERCENT / 100)
@@ -577,15 +572,15 @@ async def finalize_ehbp_actual_cost_payment(
extra={
"event": "finalize",
"key_hash": key.hashed_key[:8] + "...",
- "billing_key_hash": billing_key.hashed_key[:8] + "...",
+ "billing_key_hash": key.hashed_key[:8] + "...",
"model": model_id,
"cost_reserved": reserved_cost_for_model,
"cost_charged": total_cost_msats,
"input_tokens": cost_info.get("input_tokens", 0),
"output_tokens": cost_info.get("output_tokens", 0),
- "balance": billing_key.balance,
- "reserved_balance": billing_key.reserved_balance,
- "total_spent": billing_key.total_spent,
+ "balance": key.balance,
+ "reserved_balance": key.reserved_balance,
+ "total_spent": key.total_spent,
"finalize_type": "ehbp_usage",
"finalized_at": now,
},
diff --git a/tests/integration/test_child_keys.py b/tests/integration/test_child_keys.py
deleted file mode 100644
index 5226d357..00000000
--- a/tests/integration/test_child_keys.py
+++ /dev/null
@@ -1,190 +0,0 @@
-import asyncio
-import secrets
-from typing import Any
-
-import pytest
-from fastapi import HTTPException
-from sqlmodel.ext.asyncio.session import AsyncSession
-
-from routstr.auth import adjust_payment_for_tokens, pay_for_request
-from routstr.balance import ChildKeyRequest, create_child_key
-from routstr.core.db import ApiKey, create_session
-from routstr.core.settings import settings
-
-
-@pytest.mark.asyncio
-async def test_child_key_flow(integration_session: AsyncSession) -> None:
- # 1. Create a parent key with balance
- parent_raw = "parent_test_key_" + secrets.token_hex(4)
- parent_key = ApiKey(
- hashed_key=parent_raw,
- balance=10000, # 10 sats
- )
- integration_session.add(parent_key)
- await integration_session.commit()
- await integration_session.refresh(parent_key)
-
- # Mock settings
- settings.child_key_cost = 1000 # 1 sat
-
- # 2. Call create_child_key
- result = await create_child_key(
- ChildKeyRequest(count=1), parent_key, integration_session
- )
-
- assert "api_keys" in result
- assert result["cost_msats"] == 1000
- assert result["parent_balance"] == 9000
-
- child_key_raw = result["api_keys"][0][3:] # remove sk-
-
- # 3. Verify child key exists in DB
- child_key_db = await integration_session.get(ApiKey, child_key_raw)
- assert child_key_db is not None
- assert child_key_db.parent_key_hash == parent_key.hashed_key
- assert child_key_db.balance == 0
-
- # 4. Test payment with child key
- cost = 500
- await pay_for_request(child_key_db, cost, integration_session)
-
- # Refresh keys
- await integration_session.refresh(parent_key)
- await integration_session.refresh(child_key_db)
-
- # Parent should be charged
- assert parent_key.reserved_balance == 500
- assert parent_key.total_requests == 1
-
- # Child should have total_requests incremented
- assert child_key_db.total_requests == 1
-
- # 5. Test adjustment
- response_data = {"model": "test-model", "usage": {"total_tokens": 10}}
-
- # Mock calculate_cost
- import routstr.auth
- from routstr.payment.cost_calculation import CostData
-
- async def mock_calculate_cost(*args: Any, **kwargs: Any) -> CostData:
- return CostData(
- base_msats=0, input_msats=200, output_msats=200, total_msats=400
- )
-
- # Patch calculate_cost
- original_calculate_cost = routstr.auth.calculate_cost
- routstr.auth.calculate_cost = mock_calculate_cost
-
- try:
- adjustment = await adjust_payment_for_tokens(
- child_key_db, response_data, integration_session, 500, None, None
- )
- assert adjustment["total_msats"] == 400
-
- # Refresh keys
- await integration_session.refresh(parent_key)
- await integration_session.refresh(child_key_db)
-
- # Parent should have updated balance and total_spent
- assert parent_key.reserved_balance == 0
- assert parent_key.balance == 9000 - 400
- assert (
- parent_key.total_spent == 1400
- ) # 1000 for child key creation + 400 for request
-
- # Child should also have total_spent updated
- assert child_key_db.total_spent == 400
-
- finally:
- routstr.auth.calculate_cost = original_calculate_cost
-
-
-@pytest.mark.asyncio
-async def test_child_key_insufficient_balance(
- integration_session: AsyncSession,
-) -> None:
- parent_key = ApiKey(
- hashed_key="poor_parent_" + secrets.token_hex(4),
- balance=500,
- )
- integration_session.add(parent_key)
- await integration_session.commit()
- await integration_session.refresh(parent_key)
-
- settings.child_key_cost = 1000
-
- with pytest.raises(HTTPException) as exc:
- await create_child_key(
- ChildKeyRequest(count=1), parent_key, integration_session
- )
- assert exc.value.status_code == 402
-
-
-@pytest.mark.asyncio
-async def test_concurrent_child_key_creation_is_atomic(
- patched_db_engine: None,
-) -> None:
- """Two concurrent create_child_key() calls with balance for exactly one must
- result in exactly one success and one 402, with the parent balance deducted
- only once."""
- child_key_cost = 1000
- settings.child_key_cost = child_key_cost
-
- parent_hash = f"parent_concurrent_{secrets.token_hex(8)}"
- async with create_session() as session:
- parent = ApiKey(hashed_key=parent_hash, balance=child_key_cost)
- session.add(parent)
- await session.commit()
-
- results: list[str] = []
-
- async def attempt() -> None:
- async with create_session() as session:
- fresh_parent = await session.get(ApiKey, parent_hash)
- assert fresh_parent is not None
- try:
- await create_child_key(ChildKeyRequest(count=1), fresh_parent, session)
- results.append("success")
- except HTTPException as exc:
- assert exc.status_code == 402
- results.append("blocked")
-
- await asyncio.gather(attempt(), attempt())
-
- assert sorted(results) == ["blocked", "success"], (
- f"Expected exactly one success and one 402, got: {results}"
- )
-
- async with create_session() as session:
- final = await session.get(ApiKey, parent_hash)
- assert final is not None
-
- assert final.balance == 0, (
- f"Balance should be fully deducted once: expected 0, got {final.balance}"
- )
- assert final.total_spent == child_key_cost, (
- f"total_spent should equal one deduction: expected {child_key_cost}, "
- f"got {final.total_spent}"
- )
-
-
-@pytest.mark.asyncio
-async def test_child_key_cannot_create_child(integration_session: AsyncSession) -> None:
- parent_key = ApiKey(
- hashed_key="parent_" + secrets.token_hex(4),
- balance=10000,
- )
- child_key = ApiKey(
- hashed_key="child_" + secrets.token_hex(4),
- balance=0,
- parent_key_hash=parent_key.hashed_key,
- )
- integration_session.add(parent_key)
- integration_session.add(child_key)
- await integration_session.commit()
- await integration_session.refresh(child_key)
-
- with pytest.raises(HTTPException) as exc:
- await create_child_key(ChildKeyRequest(count=1), child_key, integration_session)
- assert exc.value.status_code == 400
- assert "Cannot create a child key for another child key" in str(exc.value.detail)
diff --git a/tests/integration/test_child_keys_api.py b/tests/integration/test_child_keys_api.py
deleted file mode 100644
index 1adb9496..00000000
--- a/tests/integration/test_child_keys_api.py
+++ /dev/null
@@ -1,102 +0,0 @@
-from typing import Any
-
-import pytest
-from httpx import AsyncClient
-
-
-@pytest.mark.integration
-@pytest.mark.asyncio
-async def test_wallet_info_returns_child_keys(
- integration_client: AsyncClient,
- authenticated_client: AsyncClient,
- integration_session: Any,
-) -> None:
- """Test that GET /v1/wallet/info returns child keys for a parent key"""
-
- # 1. Get parent info to find its hashed_key
- response = await authenticated_client.get("/v1/wallet/info")
- assert response.status_code == 200
- parent_data = response.json()
- parent_data["api_key"]
-
- # 2. Create child keys for this parent
- # We need to use the parent's authentication for this
- child_payload = {"count": 2, "balance_limit": 1000, "balance_limit_reset": "daily"}
- create_response = await authenticated_client.post(
- "/v1/wallet/child-key", json=child_payload
- )
- assert create_response.status_code == 200
- create_data = create_response.json()
- child_keys = create_data["api_keys"]
- assert len(child_keys) == 2
-
- # 3. Call /info again and check for child_keys
- info_response = await authenticated_client.get("/v1/wallet/info")
- assert info_response.status_code == 200
- info_data = info_response.json()
-
- assert "child_keys" in info_data
- assert len(info_data["child_keys"]) == 2
-
- # Verify child key details
- for ck in info_data["child_keys"]:
- assert ck["api_key"] in child_keys
- assert ck["balance_limit"] == 1000
- assert ck["balance_limit_reset"] == "daily"
- assert "total_spent" in ck
- assert "total_requests" in ck
-
-
-@pytest.mark.integration
-@pytest.mark.asyncio
-async def test_wallet_info_child_key_no_child_keys(
- integration_client: AsyncClient,
- authenticated_client: AsyncClient,
- integration_session: Any,
-) -> None:
- """Test that GET /v1/wallet/info for a child key does NOT return child_keys"""
-
- # 1. Create a child key
- child_payload = {"count": 1}
- create_response = await authenticated_client.post(
- "/v1/wallet/child-key", json=child_payload
- )
- assert create_response.status_code == 200
- child_key = create_response.json()["api_keys"][0]
-
- # 2. Use the child key to get its info
- integration_client.headers["Authorization"] = f"Bearer {child_key}"
- info_response = await integration_client.get("/v1/wallet/info")
- assert info_response.status_code == 200
- info_data = info_response.json()
- parent_key = authenticated_client._test_api_key # type: ignore[attr-defined]
- parent_key_hash = parent_key.removeprefix("sk-")
-
- assert info_data["is_child"] is True
- assert "child_keys" not in info_data
- assert "parent_key" not in info_data
- assert info_data["parent_key_preview"] == parent_key_hash[:8] + "..."
- assert info_data["parent_key_preview"] not in {parent_key, parent_key_hash}
-
-
-@pytest.mark.integration
-@pytest.mark.asyncio
-async def test_account_info_root_returns_child_keys(
- authenticated_client: AsyncClient,
-) -> None:
- """Test that GET / returns child keys for a parent key (root endpoint)"""
-
- # 1. Create a child key
- child_payload = {"count": 1}
- await authenticated_client.post("/v1/wallet/child-key", json=child_payload)
-
- # 2. Call root endpoint /v1/balance/
- # Note: routstr/balance.py defines router = APIRouter()
- # and it is included in balance_router with prefix /v1/balance
- # The endpoint is @router.get("/")
- response = await authenticated_client.get("/v1/balance/")
- assert response.status_code == 200
- data = response.json()
-
- assert "child_keys" in data
- assert len(data["child_keys"]) >= 1
diff --git a/tests/integration/test_failover_billing.py b/tests/integration/test_failover_billing.py
index a0c31401..d67dc32b 100644
--- a/tests/integration/test_failover_billing.py
+++ b/tests/integration/test_failover_billing.py
@@ -17,7 +17,7 @@ from httpx import AsyncClient
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
-from routstr.core.db import ApiKey, ReservationRelease
+from routstr.core.db import ReservationRelease
from routstr.payment.models import Architecture, Model, Pricing
from routstr.proxy import refresh_model_maps
from routstr.upstream.base import BaseUpstreamProvider
@@ -531,103 +531,6 @@ async def test_failover_beyond_balance_envelope_is_rejected(
# fallback must be rejected before its upstream is ever contacted.
assert response.status_code == 402
assert [r.url.host for r in sent_requests] == ["cheap.example.com"]
-
-
-@pytest.fixture
-async def three_candidate_child_maps(
- patched_db_engine: None,
-) -> AsyncGenerator[None, None]:
- """Second candidate cannot fit the child limit; third restores and serves."""
- first = _StaticProvider(
- CHEAP_BASE_URL,
- "key-first",
- 1.0,
- _make_model("dual-model", 0.001, 0.002, max_cost=50.0),
- )
- too_large = _StaticProvider(
- EXPENSIVE_BASE_URL,
- "key-too-large",
- 1.0,
- _make_model("dual-model", 0.002, 0.003, max_cost=100.0),
- )
- third = _StaticProvider(
- THIRD_BASE_URL,
- "key-third",
- 1.0,
- _make_model("dual-model", 0.003, 0.004, max_cost=50.0),
- )
- async for _ in _install_providers([first, too_large, third]):
- yield
-
-
-@pytest.mark.integration
-@pytest.mark.asyncio
-async def test_child_failover_rolls_back_failed_larger_reserve_before_restoring(
- authenticated_client: AsyncClient,
- three_candidate_child_maps: None,
- integration_session: AsyncSession,
-) -> None:
- """A failed child guard cannot leak its parent update into restoration."""
- key_hash = authenticated_client._test_api_key.removeprefix("sk-") # type: ignore[attr-defined]
- child = await integration_session.get(ApiKey, key_hash)
- assert child is not None
- parent = ApiKey(hashed_key="failover-parent", balance=10_000_000)
- child.parent_key_hash = parent.hashed_key
- child.balance_limit = 75_000
- integration_session.add(parent)
- integration_session.add(child)
- await integration_session.commit()
-
- sent_requests: list[httpx.Request] = []
-
- async def fake_transport(
- request: httpx.Request, *args: Any, **kwargs: Any
- ) -> httpx.Response:
- sent_requests.append(request)
- return _upstream_response(request)
-
- with (
- patch(
- "httpx.AsyncHTTPTransport.handle_async_request",
- side_effect=fake_transport,
- ),
- patch(
- "routstr.payment.cost_calculation.sats_usd_price",
- return_value=0.0005,
- ),
- ):
- response = await authenticated_client.post(
- "/v1/chat/completions",
- json={
- "model": "dual-model",
- "messages": [{"role": "user", "content": "hello"}],
- },
- )
-
- assert response.status_code == 200
- # The 100-sat candidate is rejected before forwarding; the third serves.
- assert [request.url.host for request in sent_requests] == [
- "cheap.example.com",
- "third.example.com",
- ]
-
- await integration_session.refresh(parent)
- await integration_session.refresh(child)
- assert parent.reserved_balance == 0
- assert child.reserved_balance == 0
- assert parent.total_spent == response.json()["cost"]["total_msats"]
-
- records = (
- await integration_session.exec(
- select(ReservationRelease).where(ReservationRelease.key_hash == key_hash)
- )
- ).all()
- assert len(records) == 2
- assert sorted(record.status for record in records) == ["charged", "released"]
- assert len({record.reserved_msats for record in records}) == 1
- assert all(record.status != "active" for record in records)
-
-
@pytest.fixture
async def raised_envelope_provider_maps(
patched_db_engine: None,
diff --git a/tests/integration/test_key_logic.py b/tests/integration/test_key_logic.py
index 79d2c9fe..0a0fd573 100644
--- a/tests/integration/test_key_logic.py
+++ b/tests/integration/test_key_logic.py
@@ -1,14 +1,10 @@
-import asyncio
import time
-from datetime import datetime, timedelta
import pytest
-from fastapi import HTTPException
-from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request
-from routstr.core.db import ApiKey, create_session
+from routstr.core.db import ApiKey
@pytest.mark.asyncio
@@ -25,367 +21,6 @@ async def test_key_validity_date(integration_session: AsyncSession) -> None:
assert "expired" in str(excinfo.value).lower()
-@pytest.mark.asyncio
-async def test_key_balance_limit(integration_session: AsyncSession) -> None:
- # 1. Create a key with a balance limit
- key = ApiKey(
- hashed_key="limited_key", balance=10000, balance_limit=500, total_spent=450
- )
- integration_session.add(key)
- await integration_session.commit()
-
- # 2. Try to pay for a request that exceeds the limit
- with pytest.raises(Exception) as excinfo:
- await pay_for_request(key, 100, integration_session)
- assert "limit exceeded" in str(excinfo.value).lower()
-
- # 3. Try to pay for a request that fits
- await pay_for_request(key, 50, integration_session)
- await integration_session.refresh(key)
- # Note: total_spent is updated in adjust_payment_for_tokens,
- # but pay_for_request checks it.
- # In our current logic, pay_for_request checks (total_spent + cost) > balance_limit.
-
-
-@pytest.mark.asyncio
-async def test_key_daily_reset_policy(integration_session: AsyncSession) -> None:
- # 1. Create a key with a daily reset policy and old reset date
- yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
- key = ApiKey(
- hashed_key="daily_reset_key",
- balance=10000,
- balance_limit=1000,
- balance_limit_reset="daily",
- balance_limit_reset_date=yesterday,
- total_spent=900,
- )
- integration_session.add(key)
- await integration_session.commit()
-
- # 2. Pay for a request - should trigger reset first because it's a new day
- # Request is 200, total_spent is 900. 900+200 > 1000,
- # but reset should happen making total_spent 0, then 0+200 < 1000.
- await pay_for_request(key, 200, integration_session)
-
- await integration_session.refresh(key)
- assert key.total_spent == 0 # Reset in pay_for_request happens before charging
- # Wait, the charging logic in pay_for_request increments parent/billing_key's total_requests,
- # but total_spent is updated in adjust_payment_for_tokens.
- # However, the reset logic sets total_spent to 0.
- assert key.balance_limit_reset_date is not None
- assert key.balance_limit_reset_date > yesterday
-
-
-@pytest.mark.asyncio
-async def test_periodic_key_reset_job(integration_session: AsyncSession) -> None:
- # 1. Create multiple keys needing reset
- yesterday = int((datetime.now() - timedelta(days=1)).timestamp())
- key1 = ApiKey(
- hashed_key="job_reset_key_1",
- balance=1000,
- balance_limit=1000,
- balance_limit_reset="daily",
- balance_limit_reset_date=yesterday,
- total_spent=500,
- )
- key2 = ApiKey(
- hashed_key="job_reset_key_2",
- balance=1000,
- balance_limit=1000,
- balance_limit_reset="daily",
- balance_limit_reset_date=yesterday,
- total_spent=800,
- )
- integration_session.add(key1)
- integration_session.add(key2)
- await integration_session.commit()
-
- # 2. Run the periodic reset logic manually (mocking the background task loop)
- # We can't easily run the actual loop because it has a sleep,
- # but we can test the logic inside.
-
- # Implementation of periodic_key_reset logic for testing:
- stmt = select(ApiKey).where(ApiKey.balance_limit_reset != None) # noqa: E711
- keys = (await integration_session.exec(stmt)).all()
- now = int(time.time())
- for k in keys:
- if k.hashed_key in ["job_reset_key_1", "job_reset_key_2"]:
- k.total_spent = 0
- k.balance_limit_reset_date = now
- integration_session.add(k)
- await integration_session.commit()
-
- # 3. Verify resets
- await integration_session.refresh(key1)
- await integration_session.refresh(key2)
- assert key1.total_spent == 0
- assert key2.total_spent == 0
-
-
-@pytest.mark.asyncio
-async def test_balance_limit_enforced_atomically_under_concurrency(
- patched_db_engine: None,
-) -> None:
- parent_hash = "parent_limit_atomic"
- child_hash = "child_limit_atomic"
- cost = 300
-
- async with create_session() as session:
- parent = ApiKey(hashed_key=parent_hash, balance=10000)
- child = ApiKey(
- hashed_key=child_hash,
- balance=0,
- parent_key_hash=parent_hash,
- balance_limit=cost,
- total_spent=0,
- )
- session.add(parent)
- session.add(child)
- await session.commit()
-
- results: list[str] = []
-
- async def attempt() -> None:
- async with create_session() as session:
- fresh_child = await session.get(ApiKey, child_hash)
- assert fresh_child is not None
- try:
- await pay_for_request(fresh_child, cost, session)
- results.append("success")
- except HTTPException as exc:
- assert exc.status_code == 402
- results.append("blocked")
-
- await asyncio.gather(attempt(), attempt())
-
- assert sorted(results) == ["blocked", "success"], (
- f"Expected exactly one success and one 402, got: {results}"
- )
-
- async with create_session() as session:
- final_child = await session.get(ApiKey, child_hash)
- assert final_child is not None
-
- assert final_child.reserved_balance == cost, (
- f"Child reserved_balance should equal one reservation, "
- f"got {final_child.reserved_balance}"
- )
-
-
-@pytest.mark.asyncio
-async def test_parallel_payments_with_parent_and_child_key(
- patched_db_engine: None,
-) -> None:
- parent_hash = "parent_parallel_mixed"
- child_hash = "child_parallel_mixed"
- cost = 300
-
- async with create_session() as session:
- parent = ApiKey(hashed_key=parent_hash, balance=10000)
- child = ApiKey(
- hashed_key=child_hash,
- balance=0,
- parent_key_hash=parent_hash,
- balance_limit=2 * cost,
- )
- session.add(parent)
- session.add(child)
- await session.commit()
-
- async def attempt(key_hash: str) -> str:
- async with create_session() as session:
- fresh_key = await session.get(ApiKey, key_hash)
- assert fresh_key is not None
- try:
- await pay_for_request(fresh_key, cost, session)
- return "success"
- except HTTPException as exc:
- assert exc.status_code == 402
- return "blocked"
-
- results = await asyncio.gather(attempt(parent_hash), attempt(child_hash))
-
- assert results == ["success", "success"], (
- f"Both parent and child payments should succeed, got: {results}"
- )
-
- async with create_session() as session:
- final_parent = await session.get(ApiKey, parent_hash)
- final_child = await session.get(ApiKey, child_hash)
- assert final_parent is not None
- assert final_child is not None
-
- # Both requests bill the parent; only the child request reserves on the child.
- assert final_parent.reserved_balance == 2 * cost
- assert final_parent.total_requests == 2
- assert final_child.reserved_balance == cost
- assert final_child.total_requests == 1
-
-
-@pytest.mark.asyncio
-async def test_balance_limit_with_existing_total_spent_under_concurrency(
- patched_db_engine: None,
-) -> None:
- parent_hash = "parent_total_spent"
- child_hash = "child_total_spent"
- cost = 300
-
- async with create_session() as session:
- parent = ApiKey(hashed_key=parent_hash, balance=10000)
- # 700 already spent against a 1000 limit: only one more 300 request fits.
- child = ApiKey(
- hashed_key=child_hash,
- balance=0,
- parent_key_hash=parent_hash,
- balance_limit=1000,
- total_spent=700,
- )
- session.add(parent)
- session.add(child)
- await session.commit()
-
- results: list[str] = []
-
- async def attempt() -> None:
- async with create_session() as session:
- fresh_child = await session.get(ApiKey, child_hash)
- assert fresh_child is not None
- try:
- await pay_for_request(fresh_child, cost, session)
- results.append("success")
- except HTTPException as exc:
- assert exc.status_code == 402
- results.append("blocked")
-
- await asyncio.gather(attempt(), attempt())
-
- assert sorted(results) == ["blocked", "success"], (
- f"Expected exactly one success and one 402, got: {results}"
- )
-
- async with create_session() as session:
- final_child = await session.get(ApiKey, child_hash)
- assert final_child is not None
-
- assert final_child.reserved_balance == cost
- assert final_child.total_spent == 700
-
-
-@pytest.mark.asyncio
-async def test_balance_limit_with_existing_reserved_balance(
- patched_db_engine: None,
-) -> None:
- parent_hash = "parent_reserved_set"
- blocked_hash = "child_reserved_blocked"
- allowed_hash = "child_reserved_allowed"
- cost = 300
-
- async with create_session() as session:
- parent = ApiKey(hashed_key=parent_hash, balance=10000)
- # 800 already reserved against a 1000 limit: another 300 must be rejected.
- blocked_child = ApiKey(
- hashed_key=blocked_hash,
- balance=0,
- parent_key_hash=parent_hash,
- balance_limit=1000,
- reserved_balance=800,
- )
- # 500 reserved against a 1000 limit: another 300 still fits.
- allowed_child = ApiKey(
- hashed_key=allowed_hash,
- balance=0,
- parent_key_hash=parent_hash,
- balance_limit=1000,
- reserved_balance=500,
- )
- session.add(parent)
- session.add(blocked_child)
- session.add(allowed_child)
- await session.commit()
-
- async with create_session() as session:
- fresh_blocked = await session.get(ApiKey, blocked_hash)
- assert fresh_blocked is not None
- with pytest.raises(HTTPException) as exc_info:
- await pay_for_request(fresh_blocked, cost, session)
- assert exc_info.value.status_code == 402
-
- async with create_session() as session:
- fresh_allowed = await session.get(ApiKey, allowed_hash)
- assert fresh_allowed is not None
- await pay_for_request(fresh_allowed, cost, session)
-
- async with create_session() as session:
- final_blocked = await session.get(ApiKey, blocked_hash)
- final_allowed = await session.get(ApiKey, allowed_hash)
- final_parent = await session.get(ApiKey, parent_hash)
- assert final_blocked is not None
- assert final_allowed is not None
- assert final_parent is not None
-
- assert final_blocked.reserved_balance == 800, "Rejected request must not reserve"
- assert final_blocked.total_requests == 0
- assert final_allowed.reserved_balance == 500 + cost
- assert final_allowed.total_requests == 1
- # Only the allowed request should have billed the parent.
- assert final_parent.reserved_balance == cost
- assert final_parent.total_requests == 1
-
-
-@pytest.mark.asyncio
-async def test_child_reservation_discarded_when_parent_balance_depleted(
- patched_db_engine: None,
-) -> None:
- parent_hash = "parent_depleted"
- child_hash = "child_depleted"
- cost = 300
-
- async with create_session() as session:
- # Parent can only afford one request; child has no balance_limit.
- parent = ApiKey(hashed_key=parent_hash, balance=cost)
- child = ApiKey(
- hashed_key=child_hash,
- balance=0,
- parent_key_hash=parent_hash,
- )
- session.add(parent)
- session.add(child)
- await session.commit()
-
- results: list[str] = []
-
- async def attempt() -> None:
- async with create_session() as session:
- fresh_child = await session.get(ApiKey, child_hash)
- assert fresh_child is not None
- try:
- await pay_for_request(fresh_child, cost, session)
- results.append("success")
- except HTTPException as exc:
- assert exc.status_code == 402
- results.append("blocked")
-
- await asyncio.gather(attempt(), attempt())
-
- assert sorted(results) == ["blocked", "success"], (
- f"Expected exactly one success and one 402, got: {results}"
- )
-
- async with create_session() as session:
- final_parent = await session.get(ApiKey, parent_hash)
- final_child = await session.get(ApiKey, child_hash)
- assert final_parent is not None
- assert final_child is not None
-
- assert final_parent.reserved_balance == cost
- # The failed request must not leave a committed reservation on the child.
- assert final_child.reserved_balance == cost, (
- f"Child reserved_balance should reflect only the successful request, "
- f"got {final_child.reserved_balance}"
- )
- assert final_child.total_requests == 1
-
-
@pytest.mark.asyncio
async def test_refund_does_not_delete_key(integration_session: AsyncSession) -> None:
# This requires mocking the router call or testing the logic in balance.py
diff --git a/tests/integration/test_lightning_invoice_constraints.py b/tests/integration/test_lightning_invoice_constraints.py
index 1a6d94b9..7370ebe4 100644
--- a/tests/integration/test_lightning_invoice_constraints.py
+++ b/tests/integration/test_lightning_invoice_constraints.py
@@ -1,10 +1,10 @@
"""Integration tests for Lightning invoice key constraint fields.
Covers two things:
-- The three constraint fields (balance_limit, balance_limit_reset, validity_date)
- are persisted on LightningInvoice and survive a DB round-trip.
-- The production-path API-key record helper propagates those fields to the
- created ApiKey, so the constraints are actually enforced when the key is used.
+- The validity_date constraint field is persisted on LightningInvoice and
+ survives a DB round-trip.
+- The production-path API-key record helper propagates it to the created
+ ApiKey, so the constraint is actually enforced when the key is used.
"""
from __future__ import annotations
@@ -67,32 +67,6 @@ def mock_wallet_mint() -> object:
# ---------------------------------------------------------------------------
-@pytest.mark.asyncio
-async def test_invoice_persists_balance_limit(
- integration_session: AsyncSession,
-) -> None:
- invoice = _make_invoice(balance_limit=5000)
- integration_session.add(invoice)
- await integration_session.commit()
-
- stored = await integration_session.get(LightningInvoice, invoice.id)
- assert stored is not None
- assert stored.balance_limit == 5000
-
-
-@pytest.mark.asyncio
-async def test_invoice_persists_balance_limit_reset(
- integration_session: AsyncSession,
-) -> None:
- invoice = _make_invoice(balance_limit=5000, balance_limit_reset="daily")
- integration_session.add(invoice)
- await integration_session.commit()
-
- stored = await integration_session.get(LightningInvoice, invoice.id)
- assert stored is not None
- assert stored.balance_limit_reset == "daily"
-
-
@pytest.mark.asyncio
async def test_invoice_persists_validity_date(
integration_session: AsyncSession,
@@ -112,38 +86,6 @@ async def test_invoice_persists_validity_date(
# ---------------------------------------------------------------------------
-@pytest.mark.asyncio
-async def test_created_key_receives_balance_limit(
- integration_session: AsyncSession,
-) -> None:
- invoice = _make_invoice(balance_limit=8000)
- integration_session.add(invoice)
- await integration_session.flush()
-
- api_key = await _create_api_key_record(invoice, integration_session)
- await integration_session.commit()
-
- stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
- assert stored_key is not None
- assert stored_key.balance_limit == 8000
-
-
-@pytest.mark.asyncio
-async def test_created_key_receives_balance_limit_reset(
- integration_session: AsyncSession,
-) -> None:
- invoice = _make_invoice(balance_limit=8000, balance_limit_reset="monthly")
- integration_session.add(invoice)
- await integration_session.flush()
-
- api_key = await _create_api_key_record(invoice, integration_session)
- await integration_session.commit()
-
- stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
- assert stored_key is not None
- assert stored_key.balance_limit_reset == "monthly"
-
-
@pytest.mark.asyncio
async def test_created_key_receives_validity_date(
integration_session: AsyncSession,
@@ -422,8 +364,6 @@ async def test_created_key_without_constraints_has_none_fields(
stored_key = await integration_session.get(ApiKey, api_key.hashed_key)
assert stored_key is not None
- assert stored_key.balance_limit is None
- assert stored_key.balance_limit_reset is None
assert stored_key.validity_date is None
diff --git a/tests/integration/test_payment_invariants.py b/tests/integration/test_payment_invariants.py
index ca7bf892..cb10b80a 100644
--- a/tests/integration/test_payment_invariants.py
+++ b/tests/integration/test_payment_invariants.py
@@ -45,13 +45,7 @@ def _response() -> dict:
}
-async def _new_key(
- session: AsyncSession,
- balance: int,
- *,
- parent_key_hash: str | None = None,
- balance_limit: int | None = None,
-) -> str:
+async def _new_key(session: AsyncSession, balance: int) -> str:
key_hash = f"test_inv_{uuid.uuid4().hex}"
session.add(
ApiKey(
@@ -60,8 +54,6 @@ async def _new_key(
reserved_balance=0,
total_spent=0,
total_requests=0,
- parent_key_hash=parent_key_hash,
- balance_limit=balance_limit,
)
)
await session.commit()
@@ -458,92 +450,3 @@ async def test_release_after_charge_does_not_credit_the_user_back(
assert key.balance == 10_000 - cost
assert key.total_spent == cost
assert key.reserved_balance == 0
-
-
-@pytest.mark.asyncio
-async def test_child_request_spends_parent_balance_and_records_child_ledger(
- integration_session: AsyncSession,
-) -> None:
- from routstr.auth import (
- adjust_payment_for_tokens,
- get_reservation_snapshot,
- pay_for_request,
- )
-
- cost = 3_000
- parent_hash = await _new_key(integration_session, balance=10_000)
- child_hash = await _new_key(
- integration_session, balance=0, parent_key_hash=parent_hash
- )
- child = await integration_session.get(ApiKey, child_hash)
- assert child is not None
-
- await pay_for_request(child, cost, integration_session)
- reservation = await get_reservation_snapshot(child, integration_session)
-
- with patch("routstr.auth.calculate_cost", return_value=_cost_data(cost)):
- await adjust_payment_for_tokens(
- child,
- _response(),
- integration_session,
- cost,
- reservation_snapshot=reservation,
- )
-
- parent = await integration_session.get(ApiKey, parent_hash)
- child = await integration_session.get(ApiKey, child_hash)
- assert parent is not None and child is not None
- assert parent.balance == 10_000 - cost
- assert parent.reserved_balance == 0
- assert parent.total_balance >= 0
- assert child.reserved_balance == 0, "child reservation must be released too"
- assert child.total_balance >= 0
- assert child.total_spent == cost, "child ledger must record the spend"
- assert parent.total_spent == cost
-
-
-@pytest.mark.asyncio
-async def test_child_overrun_does_not_raid_a_sibling_reservation(
- integration_session: AsyncSession,
-) -> None:
- """Same overrun defect as the parent case, reached through a child key."""
- from routstr.auth import (
- adjust_payment_for_tokens,
- get_reservation_snapshot,
- pay_for_request,
- )
-
- reserved_each = 100
- overrun = 150
- parent_hash = await _new_key(integration_session, balance=2 * reserved_each)
- child_a = await _new_key(
- integration_session, balance=0, parent_key_hash=parent_hash
- )
- child_b = await _new_key(
- integration_session, balance=0, parent_key_hash=parent_hash
- )
-
- key_a = await integration_session.get(ApiKey, child_a)
- key_b = await integration_session.get(ApiKey, child_b)
- assert key_a is not None and key_b is not None
-
- await pay_for_request(key_a, reserved_each, integration_session)
- reservation_a = await get_reservation_snapshot(key_a, integration_session)
- await pay_for_request(key_b, reserved_each, integration_session)
- await get_reservation_snapshot(key_b, integration_session)
-
- with patch("routstr.auth.calculate_cost", return_value=_cost_data(overrun)):
- await adjust_payment_for_tokens(
- key_a,
- _response(),
- integration_session,
- reserved_each,
- reservation_snapshot=reservation_a,
- )
-
- parent = await integration_session.get(ApiKey, parent_hash)
- assert parent is not None
- assert parent.total_balance >= 0, (
- f"child A's overrun ate child B's reservation: balance={parent.balance} "
- f"reserved={parent.reserved_balance}"
- )
diff --git a/tests/integration/test_prune_dead_api_keys.py b/tests/integration/test_prune_dead_api_keys.py
index de2b9d44..d0987af9 100644
--- a/tests/integration/test_prune_dead_api_keys.py
+++ b/tests/integration/test_prune_dead_api_keys.py
@@ -98,34 +98,6 @@ async def test_used_key_never_pruned(patched_db_engine: None) -> None:
assert await _exists(k.hashed_key)
-@pytest.mark.asyncio
-async def test_parent_and_child_keys_are_not_pruned(
- patched_db_engine: None,
-) -> None:
- """Pruning must not orphan child keys or delete valid children."""
- parent = _dead_key(LONG_AGO)
- child = ApiKey(
- hashed_key=f"child_{uuid.uuid4().hex}",
- balance=0,
- reserved_balance=0,
- total_spent=0,
- total_requests=0,
- created_at=LONG_AGO,
- parent_key_hash=parent.hashed_key,
- )
- async with create_session() as session:
- session.add(parent)
- session.add(child)
- await session.commit()
-
- async with create_session() as session:
- pruned = await prune_dead_api_keys(session, OLD)
-
- assert pruned == 0
- assert await _exists(parent.hashed_key)
- assert await _exists(child.hashed_key)
-
-
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status", "expires_at"),
diff --git a/tests/integration/test_reserved_balance_negative.py b/tests/integration/test_reserved_balance_negative.py
index bf23b0be..49e290cb 100644
--- a/tests/integration/test_reserved_balance_negative.py
+++ b/tests/integration/test_reserved_balance_negative.py
@@ -170,48 +170,6 @@ async def test_revert_with_zero_reserved_balance_repairs_terminally(
assert updated.total_requests == 0
-@pytest.mark.asyncio
-async def test_child_corrupt_revert_clamps_zero_request_counts(
- integration_session: AsyncSession,
-) -> None:
- from routstr.auth import (
- get_reservation_snapshot,
- pay_for_request,
- revert_pay_for_request,
- )
- from routstr.core.db import ReservationRelease
-
- suffix = uuid.uuid4().hex[:8]
- parent = ApiKey(hashed_key=f"repair-parent-{suffix}", balance=5_000)
- child = ApiKey(
- hashed_key=f"repair-child-{suffix}",
- parent_key_hash=parent.hashed_key,
- )
- integration_session.add(parent)
- integration_session.add(child)
- await integration_session.commit()
- await pay_for_request(child, 500, integration_session)
- snapshot = await get_reservation_snapshot(child, integration_session)
-
- parent.total_requests = 0
- child.total_requests = 0
- child.reserved_balance = 0
- integration_session.add(parent)
- integration_session.add(child)
- await integration_session.commit()
-
- assert await revert_pay_for_request(child, integration_session, 500, snapshot)
-
- integration_session.expunge_all()
- parent_row = await integration_session.get(ApiKey, snapshot.billing_key_hash)
- child_row = await integration_session.get(ApiKey, snapshot.key_hash)
- release = await integration_session.get(ReservationRelease, snapshot.release_id)
- assert parent_row is not None and child_row is not None
- assert (parent_row.total_requests, child_row.total_requests) == (0, 0)
- assert (parent_row.reserved_balance, child_row.reserved_balance) == (500, 0)
- assert release is not None and release.status == "released"
-
-
@pytest.mark.asyncio
async def test_revert_with_sufficient_reserved_balance_succeeds(
integration_session: AsyncSession,
@@ -375,63 +333,3 @@ async def test_sequential_reverts_never_go_negative(
assert test_key.reserved_balance >= 0, (
f"Reserved balance went negative: {test_key.reserved_balance}"
)
-
-
-@pytest.mark.asyncio
-async def test_child_key_revert_floor_guard(
- integration_session: AsyncSession,
-) -> None:
- """Test that child key reserved_balance also has floor guard on revert."""
- from routstr.auth import (
- get_reservation_snapshot,
- pay_for_request,
- revert_pay_for_request,
- )
-
- parent_key_hash = f"test_parent_{uuid.uuid4().hex[:8]}"
- child_key_hash = f"test_child_{uuid.uuid4().hex[:8]}"
-
- parent_key = ApiKey(
- hashed_key=parent_key_hash,
- balance=10000,
- reserved_balance=0,
- total_requests=2,
- )
- child_key = ApiKey(
- hashed_key=child_key_hash,
- balance=0,
- reserved_balance=0,
- total_requests=2,
- parent_key_hash=parent_key_hash,
- )
- integration_session.add(parent_key)
- integration_session.add(child_key)
- await integration_session.commit()
- await pay_for_request(child_key, 500, integration_session)
- snapshot = await get_reservation_snapshot(child_key, integration_session)
-
- # First revert succeeds
- result1 = await revert_pay_for_request(
- child_key, integration_session, 500, snapshot
- )
- await integration_session.refresh(parent_key)
- await integration_session.refresh(child_key)
-
- assert result1 is True
- assert parent_key.reserved_balance == 0
- assert child_key.reserved_balance == 0
-
- # Second revert is a no-op for both parent and child
- result2 = await revert_pay_for_request(
- child_key, integration_session, 500, snapshot
- )
- await integration_session.refresh(parent_key)
- await integration_session.refresh(child_key)
-
- assert result2 is False
- assert parent_key.reserved_balance == 0, (
- f"Parent reserved_balance should stay 0, got: {parent_key.reserved_balance}"
- )
- assert child_key.reserved_balance == 0, (
- f"Child reserved_balance should stay 0, got: {child_key.reserved_balance}"
- )
diff --git a/tests/integration/test_temporary_balances_api.py b/tests/integration/test_temporary_balances_api.py
index 5439dcba..a6a2d5e5 100644
--- a/tests/integration/test_temporary_balances_api.py
+++ b/tests/integration/test_temporary_balances_api.py
@@ -26,7 +26,6 @@ async def _add_key(
total_spent: int = 0,
total_requests: int = 0,
created_at: int | None = None,
- parent_key_hash: str | None = None,
refund_address: str | None = None,
) -> ApiKey:
key = ApiKey(
@@ -35,7 +34,6 @@ async def _add_key(
reserved_balance=reserved_balance,
total_spent=total_spent,
total_requests=total_requests,
- parent_key_hash=parent_key_hash,
refund_address=refund_address,
)
key.created_at = created_at
@@ -125,7 +123,7 @@ async def test_temporary_balances_pagination(
@pytest.mark.integration
@pytest.mark.asyncio
-async def test_temporary_balances_totals_exclude_child_balance(
+async def test_temporary_balances_totals(
integration_client: httpx.AsyncClient,
integration_session: AsyncSession,
) -> None:
@@ -137,17 +135,6 @@ async def test_temporary_balances_totals_exclude_child_balance(
total_requests=3,
created_at=1000,
)
- # Child draws from parent's balance, so its balance must NOT be summed,
- # but its spent/requests still count.
- await _add_key(
- integration_session,
- "child",
- balance=0,
- total_spent=200,
- total_requests=7,
- created_at=1001,
- parent_key_hash="parent",
- )
response = await integration_client.get(
"/admin/api/temporary-balances", headers=_admin_headers()
@@ -155,43 +142,8 @@ async def test_temporary_balances_totals_exclude_child_balance(
totals = response.json()["totals"]
assert totals["total_balance"] == 5000
- assert totals["total_spent"] == 300
- assert totals["total_requests"] == 10
-
-
-@pytest.mark.integration
-@pytest.mark.asyncio
-async def test_child_reservations_are_not_double_counted(
- integration_client: httpx.AsyncClient,
- integration_session: AsyncSession,
-) -> None:
- await _add_key(
- integration_session,
- "parent",
- balance=5_000,
- reserved_balance=700,
- created_at=1_000,
- )
- await _add_key(
- integration_session,
- "child",
- reserved_balance=700,
- parent_key_hash="parent",
- created_at=1_001,
- )
-
- response = await integration_client.get(
- "/admin/api/temporary-balances", headers=_admin_headers()
- )
-
- body = response.json()
- rows = {row["hashed_key"]: row for row in body["balances"]}
- assert rows["parent"]["available_balance"] == 4_300
- assert rows["parent"]["reserved_balance"] == 700
- assert rows["child"]["available_balance"] is None
- assert rows["child"]["reserved_balance"] == 700
- assert body["totals"]["total_reserved_balance"] == 700
- assert body["totals"]["total_available_balance"] == 4_300
+ assert totals["total_spent"] == 100
+ assert totals["total_requests"] == 3
@pytest.mark.integration
diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py
index 93ac02b8..3812b722 100644
--- a/tests/unit/test_balance.py
+++ b/tests/unit/test_balance.py
@@ -208,7 +208,6 @@ def _make_api_key(
refund_currency: str | None = "sat",
refund_mint_url: str | None = "https://mint.example.com",
refund_address: str | None = None,
- parent_key_hash: str | None = None,
) -> ApiKey:
key = ApiKey(hashed_key="testhash")
key.balance = balance
@@ -216,7 +215,6 @@ def _make_api_key(
key.refund_currency = refund_currency
key.refund_mint_url = refund_mint_url
key.refund_address = refund_address
- key.parent_key_hash = parent_key_hash
key.total_spent = 0
key.total_requests = 0
return key
@@ -306,7 +304,6 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
session.commit = AsyncMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()) as mock_store,
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -341,7 +338,6 @@ async def test_apikey_refund_logs_token() -> None:
session.commit = AsyncMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -370,7 +366,6 @@ async def test_apikey_refund_log_includes_path() -> None:
session.commit = AsyncMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -409,7 +404,6 @@ async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted")
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", mock_send_token),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -470,7 +464,6 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
session.commit = AsyncMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.send_token",
AsyncMock(side_effect=MintConnectionError("raw mint outage detail")),
@@ -508,7 +501,6 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None:
session.commit = AsyncMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.send_token", AsyncMock(side_effect=RuntimeError(raw_error))),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
@@ -609,7 +601,6 @@ async def test_topup_mint_unreachable_returns_503(error: Exception) -> None:
session = MagicMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
):
with pytest.raises(HTTPException) as exc_info:
@@ -635,7 +626,6 @@ async def test_topup_unreachable_source_mint_explains_why_fallback_is_impossible
error = SourceMintConnectionError("Issuing Cashu mint is unreachable")
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
):
with pytest.raises(HTTPException) as exc_info:
@@ -660,7 +650,6 @@ async def test_topup_already_spent_still_returns_400() -> None:
session = MagicMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.credit_balance",
AsyncMock(side_effect=ValueError("Token already spent")),
@@ -688,7 +677,6 @@ async def test_topup_zero_value_returns_400_zero_value_message() -> None:
session = MagicMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.credit_balance",
AsyncMock(
@@ -720,7 +708,6 @@ async def test_topup_token_consumed_returns_500() -> None:
session = MagicMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.credit_balance",
AsyncMock(side_effect=TokenConsumedError("credit failed")),
@@ -786,7 +773,6 @@ async def test_topup_fee_and_swap_failures_return_422(
session = MagicMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)),
):
with pytest.raises(HTTPException) as exc_info:
@@ -811,7 +797,6 @@ async def test_topup_unexpected_non_valueerror_returns_500() -> None:
session = MagicMock()
with (
- patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)),
patch(
"routstr.balance.credit_balance",
AsyncMock(side_effect=RuntimeError("db exploded")),
diff --git a/tests/unit/test_ehbp_finalize_payment.py b/tests/unit/test_ehbp_finalize_payment.py
index 9ed9fb5c..1ed2dda4 100644
--- a/tests/unit/test_ehbp_finalize_payment.py
+++ b/tests/unit/test_ehbp_finalize_payment.py
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.pool import StaticPool
-from sqlmodel import SQLModel, col, select, update
+from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
import routstr.auth as auth_module
@@ -107,19 +107,17 @@ async def test_finalize_actual_cost_payment_updates_balance_and_releases_reserve
@pytest.mark.asyncio
-async def test_unmeasured_ehbp_releases_parent_and_child_reservation(
+async def test_unmeasured_ehbp_releases_reservation(
session: AsyncSession,
) -> None:
- parent = ApiKey(hashed_key="ehbp-parent", balance=10_000)
- child = ApiKey(hashed_key="ehbp-child", balance=0, parent_key_hash="ehbp-parent")
- session.add(parent)
- session.add(child)
+ key = ApiKey(hashed_key="ehbp-key", balance=10_000)
+ session.add(key)
await session.commit()
- await pay_for_request(child, 3_000, session)
- reservation = await get_reservation_snapshot(child, session)
+ await pay_for_request(key, 3_000, session)
+ reservation = await get_reservation_snapshot(key, session)
charged = await finalize_ehbp_max_cost_payment(
- child,
+ key,
session,
max_cost_for_model=3_000,
model_id="tinfoil/model",
@@ -127,18 +125,12 @@ async def test_unmeasured_ehbp_releases_parent_and_child_reservation(
)
assert charged == 0
- updated_parent = await _api_key(session, "ehbp-parent")
- updated_child = await _api_key(session, "ehbp-child")
- assert updated_parent is not None
- assert updated_child is not None
- assert updated_parent.balance == 10_000
- assert updated_parent.reserved_balance == 0
- assert updated_parent.reserved_at is None
- assert updated_parent.total_spent == 0
- assert updated_child.balance == 0
- assert updated_child.reserved_balance == 0
- assert updated_child.reserved_at is None
- assert updated_child.total_spent == 0
+ updated = await _api_key(session, "ehbp-key")
+ assert updated is not None
+ assert updated.balance == 10_000
+ assert updated.reserved_balance == 0
+ assert updated.reserved_at is None
+ assert updated.total_spent == 0
@pytest.mark.asyncio
@@ -182,21 +174,15 @@ async def test_unmeasured_ehbp_release_is_safe_when_charge_update_would_fail(
session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- parent = ApiKey(hashed_key="ehbp-rollback-parent", balance=10_000)
- child = ApiKey(
- hashed_key="ehbp-missing-child",
- balance=0,
- parent_key_hash="ehbp-rollback-parent",
- )
- session.add(parent)
- session.add(child)
+ key = ApiKey(hashed_key="ehbp-rollback-key", balance=10_000)
+ session.add(key)
await session.commit()
- await pay_for_request(child, 3_000, session)
- reservation = await get_reservation_snapshot(child, session)
- _fail_nth_api_key_update(session, monkeypatch, target_update=2)
+ await pay_for_request(key, 3_000, session)
+ reservation = await get_reservation_snapshot(key, session)
+ _fail_nth_api_key_update(session, monkeypatch, target_update=1)
charged = await finalize_ehbp_max_cost_payment(
- child,
+ key,
session,
max_cost_for_model=3_000,
model_id="tinfoil/model",
@@ -204,68 +190,13 @@ async def test_unmeasured_ehbp_release_is_safe_when_charge_update_would_fail(
)
assert charged == 0
- updated_parent = await _api_key(session, "ehbp-rollback-parent")
- assert updated_parent is not None
- assert updated_parent.balance == 10_000
+ updated = await _api_key(session, "ehbp-rollback-key")
+ assert updated is not None
+ assert updated.balance == 10_000
# The injected partial-update failure rolls aggregate subtraction back;
# terminal fencing prevents a charge or retry from consuming those funds.
- assert updated_parent.reserved_balance == 3_000
- assert updated_parent.total_spent == 0
- updated_child = await _api_key(session, "ehbp-missing-child")
- assert updated_child is not None
- assert updated_child.reserved_balance == 3_000
- assert updated_child.total_spent == 0
- release = await session.get(ReservationRelease, reservation.release_id)
- assert release is not None and release.status == "released"
- assert reservation.release_id not in auth_module._reservation_heartbeats
-
-
-@pytest.mark.asyncio
-async def test_corrupt_child_aggregate_does_not_erase_parent_sibling_reserve(
- session: AsyncSession,
-) -> None:
- parent = ApiKey(hashed_key="ehbp-corrupt-parent", balance=10_000)
- child = ApiKey(
- hashed_key="ehbp-corrupt-child",
- balance=0,
- parent_key_hash=parent.hashed_key,
- )
- session.add(parent)
- session.add(child)
- await session.commit()
- await pay_for_request(child, 3_000, session)
- reservation = await get_reservation_snapshot(child, session)
-
- await session.exec( # type: ignore[call-overload]
- update(ApiKey)
- .where(col(ApiKey.hashed_key) == "ehbp-corrupt-parent")
- .values(reserved_balance=5_000)
- )
- await session.exec( # type: ignore[call-overload]
- update(ApiKey)
- .where(col(ApiKey.hashed_key) == "ehbp-corrupt-child")
- .values(reserved_balance=1_000)
- )
- await session.commit()
-
- charged = await finalize_ehbp_actual_cost_payment(
- child,
- session,
- reserved_cost_for_model=3_000,
- model_id="tinfoil/model",
- cost_info={"total_msats": 1_200},
- reservation_snapshot=reservation,
- )
-
- assert charged == 0
- updated_parent = await _api_key(session, "ehbp-corrupt-parent")
- updated_child = await _api_key(session, "ehbp-corrupt-child")
- assert updated_parent is not None and updated_child is not None
- assert updated_parent.balance == 10_000
- assert updated_parent.reserved_balance == 5_000
- assert updated_parent.total_spent == 0
- assert updated_child.reserved_balance == 1_000
- assert updated_child.total_spent == 0
+ assert updated.reserved_balance == 3_000
+ assert updated.total_spent == 0
release = await session.get(ReservationRelease, reservation.release_id)
assert release is not None and release.status == "released"
assert reservation.release_id not in auth_module._reservation_heartbeats
diff --git a/tests/unit/test_stale_reservations.py b/tests/unit/test_stale_reservations.py
index 31dd5767..73b3e43f 100644
--- a/tests/unit/test_stale_reservations.py
+++ b/tests/unit/test_stale_reservations.py
@@ -16,14 +16,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.pool import StaticPool
-from sqlmodel import SQLModel, select
+from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import pay_for_request
from routstr.balance import refund_wallet_endpoint
from routstr.core.db import (
ApiKey,
- ReservationRelease,
release_stale_reservations,
reset_all_reserved_balances,
)
@@ -70,26 +69,6 @@ async def test_pay_for_request_sets_reserved_at(session: AsyncSession) -> None:
assert key.reserved_at >= before
-@pytest.mark.asyncio
-async def test_pay_for_request_sets_reserved_at_on_child_key(
- session: AsyncSession,
-) -> None:
- parent = ApiKey(hashed_key="parentkey", balance=10_000)
- child = ApiKey(hashed_key="childkey", balance=0, parent_key_hash="parentkey")
- session.add(parent)
- session.add(child)
- await session.commit()
-
- await pay_for_request(child, 1_000, session)
-
- await session.refresh(parent)
- await session.refresh(child)
- assert parent.reserved_balance == 1_000
- assert parent.reserved_at is not None
- assert child.reserved_balance == 1_000
- assert child.reserved_at is not None
-
-
@pytest.mark.asyncio
async def test_revert_clears_reserved_at_when_fully_released(
session: AsyncSession,
@@ -153,39 +132,6 @@ async def test_release_stale_reservations_releases_old(session: AsyncSession) ->
assert key.reserved_at is None
-@pytest.mark.asyncio
-async def test_targeted_parent_cleanup_releases_child_owned_reservation(
- session: AsyncSession,
-) -> None:
- parent = ApiKey(hashed_key="stale-parent", balance=5_000)
- child = ApiKey(
- hashed_key="stale-child", parent_key_hash=parent.hashed_key, balance=0
- )
- session.add_all([parent, child])
- await session.commit()
- await pay_for_request(child, 1_000, session)
- reservation = (
- await session.exec(
- select(ReservationRelease).where(
- ReservationRelease.key_hash == child.hashed_key
- )
- )
- ).one()
- reservation.created_at = int(time.time()) - 1_000
- session.add(reservation)
- await session.commit()
-
- released = await release_stale_reservations(
- session, max_age_seconds=300, key_hash=parent.hashed_key
- )
-
- assert released == 1
- await session.refresh(parent)
- await session.refresh(child)
- assert parent.reserved_balance == 0
- assert child.reserved_balance == 0
-
-
@pytest.mark.asyncio
async def test_release_stale_reservations_keeps_fresh(session: AsyncSession) -> None:
key = ApiKey(
diff --git a/tests/unit/test_streaming_billing_finalization.py b/tests/unit/test_streaming_billing_finalization.py
index ddc8d9f5..e70804c1 100644
--- a/tests/unit/test_streaming_billing_finalization.py
+++ b/tests/unit/test_streaming_billing_finalization.py
@@ -82,47 +82,42 @@ async def test_release_only_owns_its_concurrent_reservation() -> None:
@pytest.mark.asyncio
-async def test_release_updates_parent_and_child_atomically() -> None:
+async def test_release_clears_reservation_aggregates_atomically() -> None:
engine = await _engine()
- parent = ApiKey(hashed_key="parent", balance=1_000)
- child = ApiKey(hashed_key="child", parent_key_hash="parent", balance=0)
+ key = ApiKey(hashed_key="key", balance=1_000)
async with AsyncSession(engine, expire_on_commit=False) as session:
- session.add_all([parent, child])
+ session.add(key)
await session.commit()
- await pay_for_request(child, 500, session)
- snapshot = await get_reservation_snapshot(child, session)
+ await pay_for_request(key, 500, session)
+ snapshot = await get_reservation_snapshot(key, session)
assert await release_reservation(snapshot, session, 500) is True
- await session.refresh(parent)
- await session.refresh(child)
- assert (parent.reserved_balance, child.reserved_balance) == (0, 0)
- assert (parent.reserved_at, child.reserved_at) == (None, None)
+ await session.refresh(key)
+ assert (key.reserved_balance, key.reserved_at) == (0, None)
await engine.dispose()
@pytest.mark.asyncio
-async def test_release_repairs_partial_parent_child_corruption() -> None:
- """A child aggregate that no longer holds the reservation must not leave
+async def test_release_repairs_partial_aggregate_corruption() -> None:
+ """An aggregate that no longer holds the reservation must not leave
the durable row active forever: the release rolls the subtraction back and
terminalizes the reservation without touching aggregates."""
engine = await _engine()
- parent = ApiKey(hashed_key="parent", balance=1_000)
- child = ApiKey(hashed_key="child", parent_key_hash="parent", balance=0)
+ key = ApiKey(hashed_key="key", balance=1_000)
async with AsyncSession(engine, expire_on_commit=False) as session:
- session.add_all([parent, child])
+ session.add(key)
await session.commit()
- await pay_for_request(child, 500, session)
- snapshot = await get_reservation_snapshot(child, session)
- child.reserved_balance = 100
- session.add(child)
+ await pay_for_request(key, 500, session)
+ snapshot = await get_reservation_snapshot(key, session)
+ key.reserved_balance = 100
+ session.add(key)
await session.commit()
assert await release_reservation(snapshot, session, 500) is True
- await session.refresh(parent)
- await session.refresh(child)
+ await session.refresh(key)
record = await session.get(ReservationRelease, snapshot.release_id)
# Aggregates untouched — legacy cleanup reconciles them when stale.
- assert (parent.reserved_balance, child.reserved_balance) == (500, 100)
+ assert key.reserved_balance == 100
assert record is not None and record.status == "released"
await engine.dispose()
diff --git a/ui/components/child-key-creator.tsx b/ui/components/child-key-creator.tsx
deleted file mode 100644
index b10847f9..00000000
--- a/ui/components/child-key-creator.tsx
+++ /dev/null
@@ -1,481 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
-import { useWalletInfo } from '@/hooks/use-wallet-info';
-import { WalletService } from '@/lib/api/services/wallet';
-import { ApiKeyInput } from './api-key-input';
-import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { Label } from '@/components/ui/label';
-import { Key, Copy, Check, Loader2, Plus, Trash2 } from 'lucide-react';
-import { toast } from 'sonner';
-import { KeyOptions } from './key-options';
-
-interface KeyConfig {
- id: string;
- count: number;
- balanceLimit: string;
- balanceLimitReset: string;
- validityDate: string;
-}
-
-interface ChildKeyCreatorProps {
- baseUrl?: string;
- apiKey?: string;
- onApiKeyChange?: (apiKey: string) => void;
- costPerKeyMsats?: number;
-}
-
-function formatSats(msats: number): string {
- return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
-}
-
-function formatMsats(msats: number): string {
- return new Intl.NumberFormat('en-US').format(msats);
-}
-
-export function ChildKeyCreator({
- baseUrl,
- apiKey: propApiKey,
- onApiKeyChange,
- costPerKeyMsats,
-}: ChildKeyCreatorProps) {
- const [internalApiKey, setInternalApiKey] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const [configs, setConfigs] = useState([
- {
- id: crypto.randomUUID(),
- count: 1,
- balanceLimit: '',
- balanceLimitReset: '',
- validityDate: '',
- },
- ]);
-
- const activeApiKey = propApiKey ?? internalApiKey;
- const { data: walletInfo } = useWalletInfo(baseUrl ?? '', activeApiKey);
-
- const handleApiKeyChange = (val: string) => {
- setInternalApiKey(val);
- onApiKeyChange?.(val);
- };
-
- const [newKeys, setNewKeys] = useState([]);
- const [resultInfo, setResultInfo] = useState<{
- cost_msats: number;
- parent_balance: number;
- } | null>(null);
- const { copiedKey, copy } = useCopyToClipboard();
-
- const addConfig = () => {
- setConfigs([
- ...configs,
- {
- id: crypto.randomUUID(),
- count: 1,
- balanceLimit: '',
- balanceLimitReset: '',
- validityDate: '',
- },
- ]);
- };
-
- const removeConfig = (id: string) => {
- if (configs.length > 1) {
- setConfigs(configs.filter((c) => c.id !== id));
- }
- };
-
- const updateConfig = (id: string, updates: Partial) => {
- setConfigs(configs.map((c) => (c.id === id ? { ...c, ...updates } : c)));
- };
-
- const handleCreateKey = async () => {
- if (!activeApiKey && baseUrl) {
- toast.error('Please provide a Parent API key first');
- return;
- }
-
- setLoading(true);
- setError(null);
- try {
- let allNewKeys: string[] = [];
- let totalCost = 0;
- let lastParentBalance = 0;
-
- for (const config of configs) {
- const requestedCount = Math.max(1, Math.min(50, Number(config.count)));
- const result = await WalletService.createChildKey(
- baseUrl,
- activeApiKey,
- requestedCount,
- config.balanceLimit ? parseInt(config.balanceLimit) : undefined,
- config.balanceLimitReset || undefined,
- config.validityDate
- ? Math.floor(
- new Date(config.validityDate + 'T23:59:59').getTime() / 1000
- )
- : undefined
- );
-
- if (result.api_keys) {
- allNewKeys = [...allNewKeys, ...result.api_keys];
- }
- totalCost += result.cost_msats;
- lastParentBalance = result.parent_balance;
- }
-
- setNewKeys(allNewKeys);
- setResultInfo({
- cost_msats: totalCost,
- parent_balance: lastParentBalance,
- });
-
- toast.success(
- `${allNewKeys.length} child API key${
- allNewKeys.length > 1 ? 's' : ''
- } created successfully`
- );
- } catch (error) {
- console.error('Failed to create child key:', error);
- let errorMessage =
- error instanceof Error ? error.message : 'Failed to create child key';
- try {
- const parsed = JSON.parse(errorMessage);
- errorMessage =
- parsed.detail?.error?.message ||
- (typeof parsed.detail === 'string' ? parsed.detail : errorMessage);
- } catch {}
- setError(errorMessage);
- toast.error(errorMessage);
- } finally {
- setLoading(false);
- }
- };
-
- const copyToClipboard = async (key: string) => {
- if (await copy(key, key)) {
- toast.success('API key copied to clipboard');
- }
- };
-
- const copyAllToClipboard = async () => {
- if (await copy(newKeys.join('\n'), 'all')) {
- toast.success('All API keys copied to clipboard');
- }
- };
-
- return (
-
-
-
-
-
- Create Child API Key
-
- Generate secondary API keys that share your account balance.
-
-