Compare commits

...
Author SHA1 Message Date
9qeklajc 4fe8653537 add log 2026-07-11 23:13:50 +02:00
9qeklajc 3bd8bae543 Merge branch 'fix-too-low-request-costs' into token-storage-and-rate-limit 2026-07-11 22:31:31 +02:00
9qeklajc 527c4ae8a2 support new in/out token labels 2026-07-11 22:31:25 +02:00
9qeklajc 140af23c8e Merge branch 'fix-too-low-request-costs' into token-storage-and-rate-limit 2026-07-11 21:41:49 +02:00
9qeklajc d6de546279 fix too low prices calculation 2026-07-11 21:41:00 +02:00
9qeklajc 31ddfa96ad Merge branch 'fix-too-low-request-costs' into token-storage-and-rate-limit 2026-07-11 21:25:49 +02:00
9qeklajc 0178df4d25 fix too low prices calculation 2026-07-11 21:25:42 +02:00
9qeklajcandGitHub 5daa2602f5 Merge pull request #593 from Routstr/fix/security-pip-deps
Fix/security pip deps
2026-07-11 12:28:31 +02:00
9qeklajc 4231c62729 Merge branch 'fix/mint-rate-limit-and-fallback' into token-storage-and-rate-limit 2026-07-11 00:11:16 +02:00
9qeklajc acb630f6cf refactor: adapt mint throttling to 429 responses 2026-07-10 23:54:05 +02:00
9qeklajc 6ace3b48c1 Merge branch 'fix/mint-rate-limit-and-fallback' into token-storage-and-rate-limit 2026-07-10 23:46:23 +02:00
9qeklajc 1230d528de fix: avoid rate limiting balance proof checks 2026-07-10 23:46:09 +02:00
9qeklajc d2641da38f fix: linearize mint URL migration 2026-07-10 23:07:34 +02:00
9qeklajc c82d66da87 Merge branch 'enforce-out-token-creation' into token-storage-and-rate-limit 2026-07-10 22:57:22 +02:00
9qeklajc 77d14d928c Merge branch 'fix/mint-rate-limit-and-fallback' into token-storage-and-rate-limit 2026-07-10 22:57:18 +02:00
9qeklajcandGitHub d80912b10e Merge pull request #589 from Routstr/fix/provider-scoped-model-overrides
Fix provider-scoped model overrides
2026-07-10 22:44:16 +02:00
9qeklajc 8f087bf07a Merge branch 'main' into fix/provider-scoped-model-overrides 2026-07-10 22:00:25 +02:00
9qeklajc d23c90b939 fix: type wallet test fixture 2026-07-10 21:50:50 +02:00
9qeklajc d8db2a3051 fix: harden mint rate limiting and fallback 2026-07-10 21:46:56 +02:00
9qeklajc be33d2ee1b fix build 2026-07-10 21:19:59 +02:00
9qeklajc 0bbbf902cd Merge origin/main into fix/mint-rate-limit-and-fallback 2026-07-10 21:12:54 +02:00
9qeklajc 7ed18a9d02 fix: per-mint rate limiting, trusted-mint fallback, and retry factory fix 2026-07-10 20:43:07 +02:00
9qeklajcandGitHub 744321153f Merge pull request #585 from Routstr/fix-payout
fix payout
2026-07-10 20:37:10 +02:00
9qeklajc a3a05d2d5c enforce out token creation 2026-07-10 01:48:10 +02:00
9qeklajc a1118a510d Merge branch 'main' into fix-payout 2026-07-08 22:40:03 +02:00
9qeklajcandGitHub 27dedfdf77 Merge pull request #595 from Routstr/remove-require-parameters
Remove require_parameters injection from OpenRouter provider
2026-07-08 16:47:07 +02:00
redshift ebb2267844 Remove require_parameters injection from OpenRouter provider
The prepare_request_body override in OpenRouterUpstreamProvider injected
provider.require_parameters=true on tool-use requests. This is no longer
needed. Removed the override, the now-unused json import, and the
corresponding test suite.
2026-07-08 17:01:52 +05:30
9qeklajc 7c2e2ce512 Merge branch 'main' into fix/security-pip-deps 2026-07-07 17:22:58 +02:00
9qeklajc 89b93fde98 resolve rest 2026-07-07 17:22:48 +02:00
9qeklajcandGitHub c48693973b Merge pull request #594 from Routstr/update-lock-file
update lock file
2026-07-07 16:45:23 +02:00
9qeklajc 407f4745a0 update lock file 2026-07-07 16:29:00 +02:00
9qeklajcandGitHub 806771df13 Merge pull request #591 from Routstr/fix/refund-race-condition
fix(wallet): return 425 when refund is pending, not 404
2026-07-07 16:27:00 +02:00
9qeklajcandGitHub 3c1b6d5f17 Merge pull request #578 from Routstr/token-error-code
fix: specific, sanitized errors for failed Cashu token redemption
2026-07-07 15:59:49 +02:00
redshift a9d5a52b32 fix(wallet): return 425 when refund is pending, not 404
The X-Cashu refund endpoint raised 404 "Refund not found" when the
"in" transaction existed with a request_id but the "out" (refund)
transaction had not been written yet. This is a timing race: the
endpoint is polled while the upstream request is still in flight,
before send_refund() has minted and stored the refund token.

The 404 was indistinguishable from a genuinely-missing refund, so
clients had no signal that retrying would succeed — leading to
stranded refunds when clients gave up.

Replace the third 404 branch with 425 Too Early + Retry-After: 2.
The two earlier 404 branches (no "in" row, no request_id) remain 404
since those genuinely mean no refund will ever exist.

Also adds debug logging on all three not-found/pending branches so
the race is no longer invisible to operators (middleware does not log
request headers).

Adds unit tests for the new 425 pending path and the no-request_id
404 path.

Refs: refund-race-condition
2026-07-07 21:49:32 +08:00
9qeklajcandGitHub b36bb56a30 Merge pull request #571 from Routstr/fix/security-pip-deps
fix(deps): patch reachable pip security advisories (aiohttp, pyjwt, python-multipart, pydantic-settings)
2026-07-07 14:40:09 +02:00
9qeklajc 9b4b4d734f Merge branch 'main' into fix/security-pip-deps 2026-07-07 14:39:36 +02:00
9qeklajcandGitHub 97172641f8 Merge pull request #581 from jeroenubbink/fix/generic-provider-pricing
Resolve generic provider pricing instead of fabricating it
2026-07-07 14:38:32 +02:00
9qeklajc edfb0f127c fix provider-scoped model overrides 2026-07-07 13:59:23 +02:00
9qeklajc 65ccf83dbd update lock file 2026-07-07 12:30:02 +02:00
Jeroen UbbinkandClaude Opus 4.8 2baea4149e fix(upstream): break OpenRouter bare-tail ties on combined token cost
The bare-tail tie-break ranked candidates by prompt price alone, so two
entries sharing a tail where one is cheaper on prompt but far dearer on
completion could resolve to the entry that undercharges output-heavy
traffic — contradicting the "highest-priced wins for money safety" promise.

Rank by the combined prompt + completion per-token cost instead, so the
choice stays deterministic and money-safe whichever way traffic leans. As
before there are zero bare-tail collisions in the live feed, so this changes
no resolved price today; it only governs the latent case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:25:09 +02:00
Jeroen UbbinkandClaude Opus 4.8 64d9460711 fix(upstream): validate native model_spec prices before trusting them
The generic provider treated any Venice-style model_spec.pricing as
authoritative after only a None check, so a native both-zero price served
the model free, a negative one credited the caller on every request, and a
non-numeric string threw while parsing — the outer catch then dropped the
provider's entire catalog.

Coerce both native prices through the resolver's _as_float and reject
absent / non-numeric / negative / both-zero values, falling through to the
shared litellm→OpenRouter→fail-closed chain instead. This extends the same
money-safety guard the litellm and OpenRouter rungs already apply to the
native source, and keeps one malformed entry from emptying the catalog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:24:01 +02:00
9qeklajcandGitHub f55c3e9c8c Merge pull request #587 from Routstr/bump-version
bump version
2026-07-06 23:02:54 +02:00
9qeklajc e3ac06342f bump version 2026-07-06 23:00:41 +02:00
9qeklajc 6097193442 fix payout 2026-07-05 23:35:10 +02:00
Jeroen UbbinkandClaude Opus 4.8 ec0fd1143b fix(upstream): preserve source modality instead of flattening it
The generic provider computed modality as "text->text" for any vision model
and "text" otherwise, discarding the source's own modality and mislabelling
image-capable models. Carry OpenRouter's modality string through verbatim, and
when a source doesn't supply one, derive it from the captured input/output
modalities in the same "in->out" shape (e.g. "text+image->text").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:40:57 +02:00
Jeroen UbbinkandClaude Opus 4.8 ae9748db02 fix(upstream): break OpenRouter bare-tail ties by highest price
When a bare model id matched several OpenRouter entries by tail, the first feed
entry won, making the resolved price depend on feed order and risking an
undercharge if a cheaper reseller happened to sort first. Pick the
highest-priced candidate instead: deterministic and money-safe, since
undercharging is the hazard. An exact id match still wins ahead of any tail
match. Measured against the live feed there are zero bare-tail collisions
today, so this only governs the latent case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:40:43 +02:00
Jeroen UbbinkandClaude Opus 4.8 ccee76b31e fix(upstream): source litellm context from max_input_tokens only
litellm's max_tokens is the completion cap (it equals max_output_tokens for
~94% of models), not the context window, so falling back to it overstated the
context as the output limit. Take context from max_input_tokens alone; a model
that reports none falls through to the id-based estimate downstream, which is
honest about being a guess rather than mislabelling the output cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:39:41 +02:00
Jeroen UbbinkandClaude Opus 4.8 9f04525c82 fix(upstream): reject unusable litellm prices instead of resolving them
A litellm cost entry that lists a model but prices both tokens at 0 (free
moderation/rerank tiers) was resolved as a real price, importing the model
enabled and served for free. Reject a both-zero (or negative) litellm hit so
the caller falls through to the next source or fails closed, mirroring the
_has_valid_pricing check the OpenRouter feed already applies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:39:03 +02:00
9qeklajcandGitHub c81de0de0a Merge pull request #583 from Routstr/fix/cache-pricing-ui
make cache prices configurable
2026-07-04 21:54:30 +02:00
9qeklajc 12c4c1f030 make cache prices configurable 2026-07-04 21:49:05 +02:00
9qeklajcandGitHub 81658d0ba6 Merge pull request #561 from Routstr/fix/refund-truly-empty-responses
fix(payment): refund truly-empty upstream responses billed a non-zero USD cost
2026-07-04 21:42:46 +02:00
9qeklajc ee5ced10c7 refactor(payment): extract shared _empty_cost helper; drop .gitignore change
Address PR #561 review:
- Extract the all-zero refund cost object into _empty_cost(), reused by
  both the no-usage-data path and the truly-empty USD-cost path.
- Revert the .gitignore additions; those belong in a separate PR.
2026-07-04 20:13:01 +02:00
Jeroen UbbinkandClaude Opus 4.8 2a27fb239e fix(upstream): resolve generic provider pricing instead of fabricating it
A generic (OpenAI-compatible) upstream whose /models response omits pricing
was silently defaulting to $0.001/M tokens and a 4096 context window. For a
provider like DeepSeek that reports no price, this undercharged real usage by
~280x — a direct money leak — while presenting a plausible-looking price.

Resolve each model through trust-ordered sources instead: the provider's
native schema (Venice's model_spec) first, then litellm's bundled cost map
(curated list prices), then the OpenRouter feed. Capture the richer metadata
those sources carry (cache rates, modalities, max output tokens, context)
rather than only price and context. When no source knows the model, import it
disabled with a warning rather than invent a number, so an operator can price
it before it serves traffic.

Context has no trustworthy source of last resort, but it is not a billing
input, so a model priced without a reported context window falls back to an
id-based estimate. The whole source-incomplete fallback (price chain +
context estimate) lives in one pricing_resolver module so it can later be
hoisted into the base provider unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:42:33 +02:00
Jeroen UbbinkandClaude Opus 4.8 ffde661d93 refactor(payment): extract litellm_cost_entry lookup helper
Pull the litellm model_cost lookup (both id spellings + case-insensitive
fallback) out of backfill_cache_pricing into a reusable litellm_cost_entry
helper. No behaviour change; the upstream price resolver reuses the same
lookup semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:42:17 +02:00
9qeklajcandClaude Opus 4.8 b509581950 fix(deps): bump aiohttp, pyjwt, python-multipart, pydantic-settings for security advisories
Resolves Dependabot alerts:
- aiohttp 3.12.15 -> 3.14.1 (#192-200: websocket/parser/SSRF/CRLF fixes)
- pyjwt 2.10.1 -> 2.13.0 (#183-187: HMAC/JWK forgery, SSRF, DoS)
- python-multipart 0.0.20 -> 0.0.32 (#188-191: quadratic-time DoS, smuggling)
- pydantic-settings 2.14.0 -> 2.14.2 (#210: symlink secrets_dir escape)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:49:47 +02:00
ShroominicandClaude Opus 4.8 e972b62758 fix(payment): refund truly-empty responses with a non-zero USD cost
When an upstream reports a non-zero USD cost but the response carries no
tokens at all (input, output, cache-read and cache-creation all zero), the
USD path billed the full USD-derived amount for an empty response. Return an
all-zero cost (full refund) for that case only.

The gate is tightened relative to the superseded PR #489: a cache-read- or
cache-creation-only turn legitimately reports zero prompt/completion tokens
with a real cost and must still be billed, so the refund only fires when every
token bucket is zero.

Adds unit tests covering both the refund and the still-billed cache-only path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:10:24 +08:00
44 changed files with 4112 additions and 668 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ WORKDIR /app/ui
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
# Copy UI source
COPY ui/package.json ui/pnpm-lock.yaml* ./
COPY ui/package.json ui/pnpm-lock.yaml* ui/pnpm-workspace.yaml* ./
RUN pnpm install --frozen-lockfile
COPY ui/ ./
@@ -0,0 +1,21 @@
"""add mint_url to lightning_invoices
Revision ID: add_mint_url_li
Revises: d7e8f9a0b1c2
Create Date: 2026-07-10 23:07:19.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "add_mint_url_li"
down_revision = "d7e8f9a0b1c2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("lightning_invoices", sa.Column("mint_url", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("lightning_invoices", "mint_url")
@@ -0,0 +1,75 @@
"""unique (token, type) constraint on cashu_transactions
Revision ID: d7e8f9a0b1c2
Revises: c6d7e8f9a0b1
Create Date: 2026-07-10 00:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "d7e8f9a0b1c2"
down_revision = "c6d7e8f9a0b1"
branch_labels = None
depends_on = None
_CONSTRAINT_NAME = "uq_cashu_transactions_token_type"
def _dedup_rows(conn: sa.Connection) -> None:
"""Delete duplicate (token, type) rows, keeping the oldest per group."""
conn.execute(
sa.text(
"DELETE FROM cashu_transactions "
"WHERE id IN ("
" SELECT id FROM ("
" SELECT id, ROW_NUMBER() OVER ("
" PARTITION BY token, type "
" ORDER BY created_at ASC, id ASC"
" ) AS rn "
" FROM cashu_transactions"
" ) WHERE rn > 1"
")"
)
)
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
_dedup_rows(conn)
existing_indexes = {
idx["name"] for idx in inspector.get_indexes("cashu_transactions")
}
existing_constraints = {
uc["name"]
for uc in inspector.get_unique_constraints("cashu_transactions")
}
if _CONSTRAINT_NAME not in existing_indexes and _CONSTRAINT_NAME not in existing_constraints:
op.create_index(
_CONSTRAINT_NAME,
"cashu_transactions",
["token", "type"],
unique=True,
)
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_indexes = {
idx["name"] for idx in inspector.get_indexes("cashu_transactions")
}
existing_constraints = {
uc["name"]
for uc in inspector.get_unique_constraints("cashu_transactions")
}
if _CONSTRAINT_NAME in existing_indexes:
op.drop_index(_CONSTRAINT_NAME, table_name="cashu_transactions")
elif _CONSTRAINT_NAME in existing_constraints:
op.drop_constraint(_CONSTRAINT_NAME, "cashu_transactions")
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.4.3"
version = "0.4.4"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
+19 -13
View File
@@ -86,8 +86,8 @@ def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
def create_model_mappings(
upstreams: list["BaseUpstreamProvider"],
overrides_by_id: dict[str, tuple],
disabled_model_ids: set[str],
overrides_by_key: dict[tuple[str, int], tuple],
disabled_model_keys: set[tuple[str, int]],
) -> tuple[
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
]:
@@ -107,8 +107,9 @@ def create_model_mappings(
Args:
upstreams: List of all upstream provider instances
overrides_by_id: Dict of model overrides from database {model_id: (ModelRow, fee)}
disabled_model_ids: Set of model IDs that should be excluded
overrides_by_key: Dict of model overrides from database
{(model_id_lower, upstream_provider_id): (ModelRow, fee)}
disabled_model_keys: Set of provider-scoped model keys that should be excluded
Returns:
Tuple of (model_instances, provider_map, unique_models)
@@ -179,14 +180,22 @@ def create_model_mappings(
"""Process all models from a given provider."""
upstream_prefix = getattr(upstream, "upstream_name", None)
provider_key = get_provider_identity(upstream)
upstream_db_id = getattr(upstream, "db_id", None)
for model in upstream.get_cached_models():
if not model.enabled or model.id in disabled_model_ids:
model_key = (
(model.id.lower(), upstream_db_id)
if isinstance(upstream_db_id, int)
else None
)
if not model.enabled or (
model_key is not None and model_key in disabled_model_keys
):
continue
# Apply overrides if present
if model.id in overrides_by_id:
override_row, provider_fee = overrides_by_id[model.id]
# 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
)
@@ -237,13 +246,10 @@ def create_model_mappings(
# Include enabled DB overrides even when provider discovery misses models.
# This is important for deployment-based providers like Azure.
for model_id, override_data in overrides_by_id.items():
if model_id in disabled_model_ids:
for (model_id, upstream_provider_id), override_data in overrides_by_key.items():
if (model_id, upstream_provider_id) in disabled_model_keys:
continue
override_row, provider_fee = override_data
upstream_provider_id = getattr(override_row, "upstream_provider_id", None)
if not isinstance(upstream_provider_id, int):
continue
upstream_for_override = providers_by_db_id.get(upstream_provider_id)
if upstream_for_override is None:
+25 -15
View File
@@ -15,7 +15,7 @@ from .core.db import (
AsyncSession,
CashuTransaction,
get_session,
store_cashu_transaction,
store_cashu_transaction_with_retry,
)
from .core.logging import get_logger
from .core.settings import settings
@@ -269,7 +269,20 @@ async def refund_wallet_endpoint(
)
out_tx = out_tx_result.first()
if out_tx is None:
raise HTTPException(status_code=404, detail="Refund not found")
# The "in" row exists with a request_id, but the "out" (refund)
# row hasn't been written yet — the upstream request is still in
# flight and the refund will be minted once it completes. Tell the
# client to retry instead of 404ing permanently (race condition
# where /v1/wallet/refund is polled before the refund exists).
logger.debug(
"refund_wallet_endpoint: refund pending (in row exists, out row not yet created)",
extra={"request_id": in_tx.request_id},
)
raise HTTPException(
status_code=425,
detail="Refund is pending; retry shortly.",
headers={"Retry-After": "2"},
)
if out_tx.swept:
raise HTTPException(status_code=410, detail="Refund has been swept")
@@ -444,19 +457,16 @@ async def refund_wallet_endpoint(
await _refund_cache_set(bearer_value, result)
if "token" in result:
try:
await store_cashu_transaction(
token=result["token"],
amount=remaining_balance,
unit=key.refund_currency or "sat",
mint_url=key.refund_mint_url,
typ="out",
collected=False,
source="apikey",
api_key_hashed_key=key.hashed_key,
)
except Exception:
pass # store_cashu_transaction already logs
await store_cashu_transaction_with_retry(
token=result["token"],
amount=remaining_balance,
unit=key.refund_currency or "sat",
mint_url=key.refund_mint_url,
typ="out",
collected=False,
source="apikey",
api_key_hashed_key=key.hashed_key,
)
logger.info(
"refund_wallet_endpoint: refund successful",
+315 -6
View File
@@ -1,3 +1,5 @@
import asyncio
import json
import os
import pathlib
import sqlite3
@@ -10,7 +12,7 @@ from alembic import command
from alembic.config import Config
from alembic.util.exc import CommandError
from sqlalchemy import UniqueConstraint, delete
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio.engine import create_async_engine
from sqlalchemy.orm import aliased
from sqlmodel import Field, Relationship, SQLModel, col, func, select, update
@@ -26,6 +28,81 @@ DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///keys.db")
engine = create_async_engine(DATABASE_URL, echo=False) # echo=True for debugging SQL
# Durable JSONL outbox for cashu transactions when the DB is down.
_cashu_outbox_lock = asyncio.Lock()
def _cashu_outbox_path() -> pathlib.Path:
"""Resolve the per-process cashu outbox file path."""
override = os.environ.get("CASHU_OUTBOX_PATH")
if override:
return pathlib.Path(override)
filename = f"cashu_outbox.{os.getpid()}.jsonl"
if DATABASE_URL.startswith("sqlite"):
raw = DATABASE_URL.split("///", 1)[-1]
if not raw or raw == ":memory:":
return pathlib.Path("/tmp").resolve() / filename
db_file = pathlib.Path(raw)
return db_file.parent.resolve() / filename
return pathlib.Path(filename).resolve()
async def append_to_cashu_outbox(
*,
token: str,
amount: int,
unit: str,
mint_url: str | None,
typ: str,
request_id: str | None,
collected: bool,
created_at: int | None,
source: str,
api_key_hashed_key: str | None,
) -> None:
"""Append a transaction payload to the durable outbox. Never raises."""
entry = {
"outbox_id": uuid.uuid4().hex,
"queued_at": int(time.time()),
"token": token,
"amount": amount,
"unit": unit,
"mint_url": mint_url,
"type": typ,
"request_id": request_id,
"collected": collected,
"created_at": created_at,
"source": source,
"api_key_hashed_key": api_key_hashed_key,
}
line = json.dumps(entry, separators=(",", ":")) + "\n"
async with _cashu_outbox_lock:
try:
path = _cashu_outbox_path()
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "a", encoding="utf-8") as fh:
fh.write(line)
fh.flush()
os.fsync(fh.fileno())
logger.warning(
"Cashu transaction spooled to durable outbox",
extra={"outbox_path": str(path), "type": typ, "request_id": request_id},
)
except Exception as outbox_exc:
logger.critical(
"cashu outbox append failed; token may be unrecoverable",
extra={
"error": str(outbox_exc),
"type": typ,
"request_id": request_id,
"token": token,
},
)
class ApiKey(SQLModel, table=True): # type: ignore
__tablename__ = "api_keys"
@@ -225,6 +302,9 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
default=None, description="Associated API key hash for topup operations"
)
purpose: str = Field(description="create or topup")
mint_url: str | None = Field(
default=None, description="Mint URL where the quote was created (fallback tracking)"
)
created_at: int = Field(
default_factory=lambda: int(time.time()), description="Unix timestamp"
)
@@ -246,6 +326,11 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
class CashuTransaction(SQLModel, table=True): # type: ignore
__tablename__ = "cashu_transactions"
__table_args__ = (
UniqueConstraint(
"token", "type", name="uq_cashu_transactions_token_type"
),
)
id: str = Field(
primary_key=True,
@@ -287,9 +372,19 @@ async def store_cashu_transaction(
created_at: int | None = None,
source: str = "x-cashu",
api_key_hashed_key: str | None = None,
) -> None:
) -> bool:
"""Persist a cashu transaction; idempotent on (token, type). Returns True on success."""
try:
async with create_session() as session:
existing = await session.exec(
select(CashuTransaction).where(
CashuTransaction.token == token,
CashuTransaction.type == typ,
)
)
if existing.first() is not None:
return True
tx = CashuTransaction(
token=token,
amount=amount,
@@ -304,11 +399,225 @@ async def store_cashu_transaction(
)
session.add(tx)
await session.commit()
except Exception as e:
logger.warning(
f"Failed to store cashu transaction: {e} (type={typ})",
extra={"error": str(e), "type": typ},
return True
except IntegrityError:
# A concurrent insert of the same (token, type) won the race.
async with create_session() as session:
existing = await session.exec(
select(CashuTransaction).where(
CashuTransaction.token == token,
CashuTransaction.type == typ,
)
)
if existing.first() is not None:
return True
logger.error(
f"Integrity error storing cashu transaction: non-duplicate violation (type={typ})",
extra={
"error": "non-duplicate IntegrityError",
"type": typ,
"request_id": request_id,
"amount": amount,
"unit": unit,
"mint_url": mint_url,
},
)
return False
except Exception as e:
logger.error(
f"Failed to store cashu transaction: {e} (type={typ})",
extra={
"error": str(e),
"type": typ,
"request_id": request_id,
"amount": amount,
"unit": unit,
"mint_url": mint_url,
"token_preview": (token[:30] + "...") if len(token) > 30 else token,
},
)
return False
async def store_cashu_transaction_with_retry(
token: str,
amount: int,
unit: str,
mint_url: str | None = None,
typ: str = "out",
request_id: str | None = None,
collected: bool = False,
created_at: int | None = None,
source: str = "x-cashu",
api_key_hashed_key: str | None = None,
max_retries: int = 3,
) -> bool:
"""Retry ``store_cashu_transaction`` with backoff; spool to the outbox on exhaustion."""
for attempt in range(max_retries):
ok = await store_cashu_transaction(
token=token,
amount=amount,
unit=unit,
mint_url=mint_url,
typ=typ,
request_id=request_id,
collected=collected,
created_at=created_at,
source=source,
api_key_hashed_key=api_key_hashed_key,
)
if ok:
return True
if attempt < max_retries - 1:
backoff = 0.5 * (2 ** attempt)
logger.warning(
"Retrying store_cashu_transaction",
extra={
"attempt": attempt + 1,
"backoff_seconds": backoff,
"type": typ,
"request_id": request_id,
},
)
await asyncio.sleep(backoff)
logger.critical(
"Cashu transaction spooled to outbox after retries exhausted",
extra={
"type": typ,
"request_id": request_id,
"amount": amount,
"unit": unit,
"mint_url": mint_url,
"token": token,
},
)
await append_to_cashu_outbox(
token=token,
amount=amount,
unit=unit,
mint_url=mint_url,
typ=typ,
request_id=request_id,
collected=collected,
created_at=created_at or int(time.time()),
source=source,
api_key_hashed_key=api_key_hashed_key,
)
return False
async def replay_cashu_outbox(path: pathlib.Path | None = None) -> int:
"""Replay spooled outbox entries into the DB. Returns the count persisted."""
if path is None:
path = _cashu_outbox_path()
if not path.exists():
return 0
# Hold the lock for the full read-replay-rewrite cycle so a concurrent
# appender cannot slip a new line between the read and the rewrite.
async with _cashu_outbox_lock:
try:
raw_lines = path.read_text(encoding="utf-8").splitlines()
except Exception as e:
logger.error("Failed to read cashu outbox", extra={"error": str(e)})
return 0
entries: list[dict] = []
for line in raw_lines:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError as e:
logger.warning(
"Skipping malformed outbox line",
extra={"error": str(e), "line_preview": line[:80]},
)
if not entries:
return 0
persisted = 0
remaining: list[dict] = []
for entry in entries:
ok = await store_cashu_transaction(
token=entry["token"],
amount=entry["amount"],
unit=entry["unit"],
mint_url=entry.get("mint_url"),
typ=entry.get("type", "out"),
request_id=entry.get("request_id"),
collected=entry.get("collected", False),
created_at=entry.get("created_at"),
source=entry.get("source", "x-cashu"),
api_key_hashed_key=entry.get("api_key_hashed_key"),
)
if ok:
persisted += 1
logger.info(
"Outbox entry replayed into DB",
extra={
"outbox_id": entry.get("outbox_id"),
"type": entry.get("type"),
"request_id": entry.get("request_id"),
},
)
else:
remaining.append(entry)
try:
if remaining:
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as fh:
for entry in remaining:
fh.write(json.dumps(entry, separators=(",", ":")) + "\n")
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
else:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text("")
os.replace(tmp, path)
except Exception as e:
logger.error(
"Failed to rewrite cashu outbox after replay",
extra={"error": str(e), "remaining": len(remaining)},
)
return persisted
async def replay_all_outbox_files() -> int:
"""Replay every cashu outbox file in the outbox directory."""
override = os.environ.get("CASHU_OUTBOX_PATH")
if override:
return await replay_cashu_outbox(pathlib.Path(override))
outbox_dir = _cashu_outbox_path().parent
total = 0
for file in sorted(outbox_dir.glob("cashu_outbox.*.jsonl")):
total += await replay_cashu_outbox(file)
return total
async def periodic_cashu_outbox_replay() -> None:
"""Background loop that drains the cashu outbox into the database."""
interval = float(os.environ.get("CASHU_OUTBOX_REPLAY_INTERVAL", "30"))
logger.info("Starting cashu outbox replay loop", extra={"interval": interval})
while True:
try:
await asyncio.sleep(interval)
await replay_all_outbox_files()
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(
"Outbox replay iteration failed",
extra={"error": str(e), "error_type": type(e).__name__},
)
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
+7 -1
View File
@@ -32,7 +32,7 @@ from ..upstream.deepseek_v4_pricing_shim import register_deepseek_v4_pricing
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 .db import create_session, init_db, periodic_cashu_outbox_replay, run_migrations
from .exceptions import general_exception_handler, http_exception_handler
from .logging import get_logger, setup_logging
from .middleware import LoggingMiddleware
@@ -65,6 +65,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
refund_sweep_task = None
routstr_fee_task = None
invoice_watcher_task = None
cashu_outbox_task = None
try:
# Apply litellm-wide settings (drop_params, chat-completions URL,
@@ -142,6 +143,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
refund_sweep_task = asyncio.create_task(periodic_refund_sweep())
routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout())
invoice_watcher_task = asyncio.create_task(periodic_invoice_watcher())
cashu_outbox_task = asyncio.create_task(periodic_cashu_outbox_replay())
yield
@@ -187,6 +189,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
routstr_fee_task.cancel()
if invoice_watcher_task is not None:
invoice_watcher_task.cancel()
if cashu_outbox_task is not None:
cashu_outbox_task.cancel()
try:
tasks_to_wait = []
@@ -220,6 +224,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(routstr_fee_task)
if invoice_watcher_task is not None:
tasks_to_wait.append(invoice_watcher_task)
if cashu_outbox_task is not None:
tasks_to_wait.append(cashu_outbox_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
+22 -5
View File
@@ -49,6 +49,18 @@ class Settings(BaseSettings):
payout_interval_seconds: int = Field(
default=900, gt=0, env="PAYOUT_INTERVAL_SECONDS"
)
# Timeout (seconds) for individual mint API operations (melt, mint, swap,
# checkstate). When a mint is slow or rate-limiting, operations are
# cancelled after this delay instead of hanging indefinitely.
mint_operation_timeout_seconds: int = Field(
default=30, gt=0, env="MINT_OPERATION_TIMEOUT_SECONDS"
)
# Maximum concurrent API operations per mint. Actual mint quotas vary by
# endpoint, so 429 responses drive adaptive cooldown instead of fixed RPM
# pacing. 0 = unlimited concurrency.
mint_max_concurrency: int = Field(default=4, ge=0, env="MINT_MAX_CONCURRENCY")
# Max retries when a mint returns 429 or times out (exponential backoff).
mint_retry_max_attempts: int = Field(default=3, ge=0, env="MINT_RETRY_MAX_ATTEMPTS")
# Pricing
# Default behavior: derive pricing from MODELS
@@ -97,7 +109,9 @@ class Settings(BaseSettings):
enable_pricing_refresh: bool = Field(default=True, env="ENABLE_PRICING_REFRESH")
enable_models_refresh: bool = Field(default=True, env="ENABLE_MODELS_REFRESH")
refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS")
refund_sweep_ttl_seconds: int = Field(default=604800, env="REFUND_SWEEP_TTL_SECONDS")
refund_sweep_ttl_seconds: int = Field(
default=604800, env="REFUND_SWEEP_TTL_SECONDS"
)
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
@@ -116,9 +130,8 @@ class Settings(BaseSettings):
# Discovery
relays: list[str] = Field(default_factory=list, env="RELAYS")
enable_analytics_sharing: bool = Field(
default=True, env="ENABLE_ANALYTICS_SHARING"
)
enable_analytics_sharing: bool = Field(default=True, env="ENABLE_ANALYTICS_SHARING")
def _normalize_settings_data(data: dict[str, Any]) -> dict[str, Any]:
"""Discard unknown keys from persisted settings."""
@@ -281,7 +294,11 @@ class SettingsService:
valid_fields = set(env_resolved.dict().keys())
merged_dict: dict[str, Any] = dict(env_resolved.dict())
merged_dict.update(
{k: v for k, v in db_json.items() if v not in (None, "", [], {}) and k in valid_fields}
{
k: v
for k, v in db_json.items()
if v not in (None, "", [], {}) and k in valid_fields
}
)
merged_dict = Settings(**merged_dict).dict()
+1 -1
View File
@@ -20,7 +20,7 @@ import subprocess
from functools import lru_cache
from pathlib import Path
BASE_VERSION = "0.4.3"
BASE_VERSION = "0.4.4"
_REPO_ROOT = Path(__file__).resolve().parents[2]
_GIT_TIMEOUT_SECONDS = 2.0
+90 -18
View File
@@ -11,7 +11,13 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from .core.db import ApiKey, LightningInvoice, create_session, get_session
from .core.logging import get_logger
from .core.settings import settings
from .wallet import get_wallet
from .wallet import (
MintConnectionError,
_is_mint_rate_limited,
_mint_operation,
get_wallet,
is_mint_connection_error,
)
logger = get_logger(__name__)
@@ -64,12 +70,50 @@ class InvoiceRecoverRequest(BaseModel):
bolt11: str = Field(description="BOLT11 invoice string")
async def _request_mint_with_fallback(
amount_sats: int,
*,
allowed_mints: list[str] | None = None,
) -> tuple[str, str, str]:
"""Request a quote, falling back only among the allowed trusted mints."""
tried: list[str] = []
configured = allowed_mints or [settings.primary_mint, *settings.cashu_mints]
candidates = list(dict.fromkeys(configured))
for mint_url in candidates:
try:
wallet = await get_wallet(mint_url, "sat")
quote = await _mint_operation(
lambda: wallet.request_mint(amount_sats),
op_name="request_mint_invoice",
mint_url=mint_url,
)
return quote.request, quote.quote, mint_url
except Exception as e:
tried.append(f"{mint_url}: {type(e).__name__}")
if not is_mint_connection_error(e) and not _is_mint_rate_limited(e):
raise
logger.warning(
"request_mint failed, trying fallback mint",
extra={
"failed_mint": mint_url,
"error": str(e),
"tried": tried,
},
)
continue
raise MintConnectionError(f"All mints failed for request_mint: {tried}")
async def generate_lightning_invoice(
amount_sats: int, description: str
) -> tuple[str, str]:
wallet = await get_wallet(settings.primary_mint, "sat")
quote = await wallet.request_mint(amount_sats)
return quote.request, quote.quote
amount_sats: int,
description: str,
*,
allowed_mints: list[str] | None = None,
) -> tuple[str, str, str]:
bolt11, payment_hash, mint_url = await _request_mint_with_fallback(
amount_sats, allowed_mints=allowed_mints
)
return bolt11, payment_hash, mint_url
def generate_invoice_id() -> str:
@@ -83,6 +127,7 @@ async def create_invoice(
session: AsyncSession = Depends(get_session),
) -> InvoiceCreateResponse:
api_key_token = _extract_bearer_api_key(authorization) or request.api_key
topup_api_key: ApiKey | None = None
if request.purpose == "topup":
if not api_key_token:
@@ -93,14 +138,21 @@ async def create_invoice(
if not api_key_token.startswith("sk-"):
raise HTTPException(status_code=400, detail="Invalid API key format")
api_key = await session.get(ApiKey, api_key_token[3:])
if not api_key:
topup_api_key = await session.get(ApiKey, api_key_token[3:])
if not topup_api_key:
raise HTTPException(status_code=404, detail="API key not found")
try:
description = f"Routstr {request.purpose} {request.amount_sats} sats"
bolt11, payment_hash = await generate_lightning_invoice(
request.amount_sats, description
# An API key is backed by one mint. A top-up must use that same mint;
# falling back to another would create mixed-mint collateral that the
# current single refund_mint_url field cannot account for or refund.
allowed_mints = None
if request.purpose == "topup":
assert topup_api_key is not None
allowed_mints = [topup_api_key.refund_mint_url or settings.primary_mint]
bolt11, payment_hash, mint_url = await generate_lightning_invoice(
request.amount_sats, description, allowed_mints=allowed_mints
)
invoice_id = generate_invoice_id()
@@ -115,6 +167,7 @@ async def create_invoice(
status="pending",
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,
@@ -223,9 +276,14 @@ async def check_invoice_payment(
invoice: LightningInvoice, session: AsyncSession
) -> None:
try:
wallet = await get_wallet(settings.primary_mint, "sat")
mint_url = invoice.mint_url or settings.primary_mint
wallet = await get_wallet(mint_url, "sat")
mint_status = await wallet.get_mint_quote(invoice.payment_hash)
mint_status = await _mint_operation(
lambda: wallet.get_mint_quote(invoice.payment_hash),
op_name="get_mint_quote",
mint_url=mint_url,
)
if mint_status.paid:
invoice.status = "paid"
@@ -258,8 +316,14 @@ async def check_invoice_payment(
async def create_api_key_from_invoice(
invoice: LightningInvoice, session: AsyncSession
) -> ApiKey:
wallet = await get_wallet(settings.primary_mint, "sat")
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
mint_url = invoice.mint_url or settings.primary_mint
wallet = await get_wallet(mint_url, "sat")
await _mint_operation(
lambda: wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash),
op_name="invoice_mint_create",
mint_url=mint_url,
retry_timeouts=False,
)
dummy_token = f"invoice-{invoice.id}-{invoice.payment_hash}"
hashed_key = hashlib.sha256(dummy_token.encode()).hexdigest()
@@ -268,7 +332,7 @@ async def create_api_key_from_invoice(
hashed_key=hashed_key,
balance=invoice.amount_sats * 1000, # Convert to msats
refund_currency="sat",
refund_mint_url=settings.primary_mint,
refund_mint_url=mint_url,
balance_limit=invoice.balance_limit,
balance_limit_reset=invoice.balance_limit_reset,
validity_date=invoice.validity_date,
@@ -283,8 +347,14 @@ async def create_api_key_from_invoice(
async def topup_api_key_from_invoice(
invoice: LightningInvoice, session: AsyncSession
) -> None:
wallet = await get_wallet(settings.primary_mint, "sat")
await wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash)
mint_url = invoice.mint_url or settings.primary_mint
wallet = await get_wallet(mint_url, "sat")
await _mint_operation(
lambda: wallet.mint(invoice.amount_sats, quote_id=invoice.payment_hash),
op_name="invoice_mint_topup",
mint_url=mint_url,
retry_timeouts=False,
)
if not invoice.api_key_hash:
raise ValueError("No API key associated with topup invoice")
@@ -297,7 +367,9 @@ async def topup_api_key_from_invoice(
await session.flush()
INVOICE_WATCH_INTERVAL_SECONDS = 5
# Nutshell mints throttle Lightning backend lookups to once per 10s per
# quote, so polling faster just burns the global request budget for nothing.
INVOICE_WATCH_INTERVAL_SECONDS = 10
INVOICE_WATCH_BATCH_LIMIT = 100
+169 -37
View File
@@ -1,4 +1,5 @@
import math
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from pydantic.v1 import BaseModel
@@ -41,6 +42,28 @@ class CostDataError(BaseModel):
code: str
def _empty_cost(cls: type[CostData] = CostData) -> CostData:
"""Build an all-zero cost object — a full refund for an empty response.
Shared by the two paths that must not bill: an upstream response with no
usage data at all, and one that reports a USD cost but carries zero tokens
in every bucket.
"""
return cls(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
cache_read_msats=0,
cache_creation_msats=0,
)
async def calculate_cost(
response_data: dict,
max_cost: int,
@@ -83,19 +106,7 @@ async def calculate_cost(
else None,
},
)
return MaxCostData(
base_msats=0,
input_msats=0,
output_msats=0,
total_msats=0,
total_usd=0.0,
input_tokens=0,
output_tokens=0,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
cache_read_msats=0,
cache_creation_msats=0,
)
return _empty_cost(MaxCostData)
usage_data = response_data.get("usage") or {}
if not isinstance(usage_data, dict):
@@ -109,6 +120,27 @@ async def calculate_cost(
# Try USD cost first
usd_cost = _resolve_usd_cost(usage_data, response_data)
if usd_cost > 0:
truly_empty = (
input_tokens == 0
and output_tokens == 0
and cache_read_tokens == 0
and cache_creation_tokens == 0
)
if truly_empty:
logger.warning(
"Upstream reported a USD cost but the response carries no "
"tokens at all (input, output, cache-read and cache-creation "
"are all zero) — refunding in full rather than billing the "
"USD-derived cost for an empty response.",
extra={
"model": response_data.get("model", "unknown"),
"usd_cost": usd_cost,
"usage_keys": sorted(usage_data.keys())
if isinstance(usage_data, dict)
else None,
},
)
return _empty_cost()
if input_tokens == 0 and output_tokens == 0:
logger.warning(
"Upstream reported a USD cost but no token counts — "
@@ -126,11 +158,18 @@ async def calculate_cost(
},
)
try:
input_usd = _coerce_usd(
usage_data.get("cost_details", {}).get("input_cost", 0)
cost_details = usage_data.get("cost_details", {})
if not isinstance(cost_details, dict):
cost_details = {}
input_usd = _first_usd(
cost_details,
"input_cost",
"upstream_inference_prompt_cost",
)
output_usd = _coerce_usd(
usage_data.get("cost_details", {}).get("output_cost", 0)
output_usd = _first_usd(
cost_details,
"output_cost",
"upstream_inference_completions_cost",
)
return _calculate_from_usd_cost(
usd_cost,
@@ -225,6 +264,15 @@ def _coerce_usd(value: object) -> float:
return 0.0
def _first_usd(source: dict, *fields: str) -> float:
"""Return the first positive USD value among equivalent provider fields."""
for field in fields:
value = _coerce_usd(source.get(field))
if value > 0:
return value
return 0.0
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
"""Resolve USD cost with clear priority order.
@@ -232,7 +280,11 @@ def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
"""
cost_details = usage_data.get("cost_details")
if isinstance(cost_details, dict):
cost = _coerce_usd(cost_details.get("total_cost"))
cost = _first_usd(
cost_details,
"total_cost",
"upstream_inference_cost",
)
if cost > 0:
return cost
@@ -316,6 +368,43 @@ def _resolve_provider_fee(model_id: str) -> float:
return float(providers[0].provider_fee)
def _log_zero_cost_components(
cost: CostData,
response_data: dict,
calculation_source: str,
) -> None:
"""Log suspicious zero component costs without changing the billed total."""
input_usage = (
cost.input_tokens
+ cost.cache_read_input_tokens
+ cost.cache_creation_input_tokens
)
zero_components = []
if cost.total_msats > 0 and input_usage > 0 and cost.input_msats == 0:
zero_components.append("input")
if cost.total_msats > 0 and cost.output_tokens > 0 and cost.output_msats == 0:
zero_components.append("output")
if not zero_components:
return
logger.error(
"Positive token usage produced a zero millisatoshi cost component",
extra={
"zero_components": zero_components,
"calculation_source": calculation_source,
"model": response_data.get("model", "unknown"),
"input_tokens": cost.input_tokens,
"output_tokens": cost.output_tokens,
"cache_read_input_tokens": cost.cache_read_input_tokens,
"cache_creation_input_tokens": cost.cache_creation_input_tokens,
"input_msats": cost.input_msats,
"output_msats": cost.output_msats,
"total_msats": cost.total_msats,
"total_usd": cost.total_usd,
},
)
def _calculate_from_usd_cost(
usd_cost: float,
input_usd: float,
@@ -328,27 +417,66 @@ def _calculate_from_usd_cost(
) -> CostData:
"""Calculate cost from USD figures, deriving input/output split from tokens."""
provider_fee = _resolve_provider_fee(response_data.get("model", ""))
usd_cost = usd_cost * provider_fee
input_usd = input_usd * provider_fee
output_usd = output_usd * provider_fee
sats_per_usd = 1.0 / sats_usd_price()
cost_in_sats = usd_cost * sats_per_usd
cost_in_msats = math.ceil(cost_in_sats * 1000)
fee_decimal = Decimal(str(provider_fee))
usd_cost_decimal = Decimal(str(usd_cost)) * fee_decimal
input_usd_decimal = Decimal(str(input_usd)) * fee_decimal
output_usd_decimal = Decimal(str(output_usd)) * fee_decimal
sats_usd_decimal = Decimal(str(sats_usd_price()))
if input_usd > 0 or output_usd > 0:
input_msats = int((input_usd * sats_per_usd) * 1000)
output_msats = int((output_usd * sats_per_usd) * 1000)
else:
effective_input_tokens = (
input_tokens + cache_read_tokens + cache_creation_tokens
usd_cost = float(usd_cost_decimal)
input_usd = float(input_usd_decimal)
output_usd = float(output_usd_decimal)
cost_in_sats = float(usd_cost_decimal / sats_usd_decimal)
cost_in_msats = int(
(usd_cost_decimal * Decimal(1000) / sats_usd_decimal).to_integral_value(
rounding=ROUND_CEILING
)
total_tokens = effective_input_tokens + output_tokens
input_msats = (
int(cost_in_msats * effective_input_tokens / total_tokens)
if total_tokens > 0
else 0
)
if input_usd_decimal > 0 or output_usd_decimal > 0:
# The total is the authoritative billed amount. Allocating that integer
# total proportionally avoids losing sub-millisatoshi remainders when
# input and output components are each truncated independently.
component_usd = input_usd_decimal + output_usd_decimal
input_msats = int(
(
Decimal(cost_in_msats) * input_usd_decimal / component_usd
).to_integral_value(rounding=ROUND_FLOOR)
)
output_msats = cost_in_msats - input_msats
else:
# Providers often report only a total USD cost. Derive the visible
# input/output split from the model's relative token prices; raw token
# counts alone are misleading when completion tokens cost more.
try:
pricing_rates = _get_pricing_rates(response_data)
except ValueError:
pricing_rates = None
if pricing_rates is None:
input_rate = float(settings.fixed_per_1k_input_tokens) * 1000.0
output_rate = float(settings.fixed_per_1k_output_tokens) * 1000.0
cache_read_rate = input_rate
cache_creation_rate = input_rate
else:
input_rate, output_rate, cache_read_rate, cache_creation_rate = (
pricing_rates
)
input_weight = (
input_tokens * input_rate
+ cache_read_tokens * cache_read_rate
+ cache_creation_tokens * cache_creation_rate
)
output_weight = output_tokens * output_rate
total_weight = input_weight + output_weight
if total_weight > 0:
input_msats = math.floor(cost_in_msats * input_weight / total_weight)
output_msats = cost_in_msats - input_msats
else:
input_msats = 0
output_msats = cost_in_msats
logger.info(
"Using cost from usage data/details",
@@ -360,7 +488,7 @@ def _calculate_from_usd_cost(
},
)
return CostData(
cost = CostData(
base_msats=0,
input_msats=input_msats,
output_msats=output_msats,
@@ -373,6 +501,8 @@ def _calculate_from_usd_cost(
cache_read_msats=0,
cache_creation_msats=0,
)
_log_zero_cost_components(cost, response_data, "usd")
return cost
def _calculate_from_tokens(
@@ -429,7 +559,7 @@ def _calculate_from_tokens(
visible_output_msats = int(calc_output_msats)
visible_input_msats = token_based_cost - visible_output_msats
return CostData(
cost = CostData(
base_msats=0,
input_msats=visible_input_msats,
output_msats=visible_output_msats,
@@ -442,3 +572,5 @@ def _calculate_from_tokens(
cache_read_msats=int(calc_cache_read_msats),
cache_creation_msats=int(calc_cache_write_msats),
)
_log_zero_cost_components(cost, response_data, "tokens")
return cost
+33 -7
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
import asyncio
import math
from typing import TypedDict
import httpx
from cashu.wallet.wallet import Proof, Wallet
# The Cashu library issues POST /v1/melt/bolt11 with timeout=None, so a hung or
# very slow mint can block a melt (and any caller, e.g. the payout loop)
# indefinitely. _mint_operation (imported lazily in raw_send_to_lnurl to avoid
# a circular import with wallet.py) bounds it via MINT_OPERATION_TIMEOUT_SECONDS.
MELT_TIMEOUT_SECONDS = 60
try:
from bech32 import bech32_decode, convertbits # type: ignore
except ModuleNotFoundError: # pragma: no cover allow runtime miss
@@ -215,15 +222,34 @@ async def raw_send_to_lnurl(
lnurl_data["callback_url"], final_amount
)
melt_quote_resp = await wallet.melt_quote(invoice=bolt11_invoice)
from ..wallet import _mint_operation
melt_quote_resp = await _mint_operation(
lambda: wallet.melt_quote(invoice=bolt11_invoice),
op_name="lnurl_melt_quote",
mint_url=str(wallet.url),
)
if amount:
proofs, _ = await wallet.select_to_send(proofs, amount, set_reserved=True)
_ = await wallet.melt(
proofs=proofs,
invoice=bolt11_invoice,
fee_reserve_sat=melt_quote_resp.fee_reserve,
quote_id=melt_quote_resp.quote,
)
try:
_ = await asyncio.wait_for(
_mint_operation(
lambda: wallet.melt(
proofs=proofs,
invoice=bolt11_invoice,
fee_reserve_sat=melt_quote_resp.fee_reserve,
quote_id=melt_quote_resp.quote,
),
op_name="lnurl_melt",
mint_url=str(wallet.url),
retry_timeouts=False,
),
timeout=MELT_TIMEOUT_SECONDS,
)
except (httpx.TimeoutException, asyncio.TimeoutError) as e:
raise LNURLError(
f"Melt timed out after {MELT_TIMEOUT_SECONDS}s (mint unresponsive)"
) from e
return final_amount
+27 -28
View File
@@ -85,6 +85,30 @@ class Model(BaseModel):
return hash(self.id)
def litellm_cost_entry(model_id: str) -> dict | None:
"""Look up ``model_id`` in litellm's bundled cost map.
litellm ships per-model USD rates keyed by the exact OpenRouter id
(``deepseek/deepseek-chat``) or the bare model name (``gpt-4o``,
``claude-sonnet-4-5``), so both spellings are tried. Keys are lowercase, so
a mixed-case upstream id (``deepseek-ai/DeepSeek-V4-Flash``) is retried via
a case-insensitive scan. Returns the matched cost dict, or ``None``.
"""
import litellm
candidates = (model_id, model_id.split("/", 1)[-1])
for key in candidates:
info = litellm.model_cost.get(key)
if isinstance(info, dict):
return info
lowered = {c.lower() for c in candidates}
for key, info in litellm.model_cost.items():
if isinstance(key, str) and key.lower() in lowered and isinstance(info, dict):
return info
return None
def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
"""Fill missing cache rates from litellm's bundled cost map.
@@ -92,12 +116,8 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
for many models (most DeepSeek entries, openai/gpt-4o, ...). Without a
cache rate, billing falls back to the full input rate, which overcharges
cache reads (DeepSeek hits are 10x cheaper) and undercharges Anthropic
cache writes (1.25x). litellm ships per-model USD rates keyed by the exact
OpenRouter id (deepseek/deepseek-chat) or by the bare model name
(gpt-4o, claude-sonnet-4-5), so both spellings are tried. litellm keys are
lowercase, but a generic upstream may report a mixed-case id
(``deepseek-ai/DeepSeek-V4-Flash``); an exact match is attempted first, then
a case-insensitive fallback so such ids still resolve.
cache writes (1.25x). The lookup (see ``litellm_cost_entry``) tries both
id spellings and a case-insensitive fallback.
Rates already present (e.g. provided by OpenRouter) are authoritative and
never overwritten. Unknown models are returned unchanged.
@@ -107,28 +127,7 @@ def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
if not (needs_read or needs_write):
return pricing
import litellm
candidates = (model_id, model_id.split("/", 1)[-1])
info: dict | None = None
for key in candidates:
candidate = litellm.model_cost.get(key)
if isinstance(candidate, dict):
info = candidate
break
if info is None:
# Case-insensitive fallback: a mixed-case upstream id (e.g.
# ``deepseek-ai/DeepSeek-V4-Flash``) won't match litellm's lowercase
# keys exactly. Build a lowercased index once and retry.
lowered = {c.lower() for c in candidates}
for key, candidate in litellm.model_cost.items():
if (
isinstance(key, str)
and key.lower() in lowered
and isinstance(candidate, dict)
):
info = candidate
break
info = litellm_cost_entry(model_id)
if info is None:
return pricing
+7 -6
View File
@@ -118,22 +118,23 @@ async def refresh_model_maps() -> None:
result = await session.exec(query)
provider_rows = result.all()
overrides_by_id: dict[str, tuple[ModelRow, float]] = {}
disabled_model_ids: set[str] = set()
overrides_by_key: dict[tuple[str, int], tuple[ModelRow, float]] = {}
disabled_model_keys: set[tuple[str, int]] = set()
for provider in provider_rows:
if not provider.enabled:
continue
for model in provider.models:
model_key = (model.id.lower(), model.upstream_provider_id)
if model.enabled:
overrides_by_id[model.id] = (model, provider.provider_fee)
overrides_by_key[model_key] = (model, provider.provider_fee)
else:
disabled_model_ids.add(model.id)
disabled_model_keys.add(model_key)
_model_instances, _provider_map, _unique_models = create_model_mappings(
upstreams=_upstreams,
overrides_by_id=overrides_by_id,
disabled_model_ids=disabled_model_ids,
overrides_by_key=overrides_by_key,
disabled_model_keys=disabled_model_keys,
)
+60 -50
View File
@@ -20,7 +20,7 @@ from ..core.db import (
AsyncSession,
UpstreamProviderRow,
create_session,
store_cashu_transaction,
store_cashu_transaction_with_retry,
)
from ..core.exceptions import UpstreamError
from ..core.redaction import redact_org_ids
@@ -1748,14 +1748,19 @@ class BaseUpstreamProvider:
total_cost = max(
total_cost,
_coerce_usd(cd.get("total_cost")),
_coerce_usd(cd.get("upstream_inference_cost")),
)
input_cost = max(
input_cost,
_coerce_usd(cd.get("input_cost")),
_coerce_usd(cd.get("upstream_inference_prompt_cost")),
)
output_cost = max(
output_cost,
_coerce_usd(cd.get("output_cost")),
_coerce_usd(
cd.get("upstream_inference_completions_cost")
),
)
for field in ("total_cost", "cost"):
total_cost = max(
@@ -3286,7 +3291,7 @@ class BaseUpstreamProvider:
)
try:
await store_cashu_transaction(
await store_cashu_transaction_with_retry(
token=refund_token,
amount=amount,
unit=unit,
@@ -3294,8 +3299,25 @@ class BaseUpstreamProvider:
typ="out",
request_id=request_id,
)
except Exception:
pass # store_cashu_transaction already logs
except Exception as store_exc:
# store_cashu_transaction_with_retry returns False (and
# spools to the outbox) on normal DB failures, so this only
# fires on an unexpected raise. Catch it here so the outer
# retry loop does NOT re-mint a second token (which would
# double-spend). The full token is logged so it can be
# recovered manually if the outbox write also failed.
logger.critical(
"send_refund: store_cashu_transaction_with_retry raised — "
"refund token is minted; spooled to outbox if possible",
extra={
"error": str(store_exc),
"amount": amount,
"unit": unit,
"mint": mint,
"request_id": request_id,
"token": refund_token,
},
)
return refund_token
except Exception as e:
@@ -3640,17 +3662,14 @@ class BaseUpstreamProvider:
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token
try:
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
except Exception:
pass
await store_cashu_transaction_with_retry(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
logger.warning(
"Emergency refund issued due to JSON parse error",
@@ -4002,18 +4021,15 @@ class BaseUpstreamProvider:
headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None)
try:
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
except Exception:
pass
await store_cashu_transaction_with_retry(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
logger.info(
"X-Cashu token redeemed for Responses API",
@@ -4604,17 +4620,14 @@ class BaseUpstreamProvider:
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token
try:
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
except Exception:
pass
await store_cashu_transaction_with_retry(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
logger.warning(
"Emergency refund issued for Responses API due to JSON parse error",
@@ -4678,18 +4691,15 @@ class BaseUpstreamProvider:
headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None)
try:
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
except Exception:
pass
await store_cashu_transaction_with_retry(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
logger.info(
"X-Cashu token redeemed successfully",
+91 -44
View File
@@ -5,6 +5,12 @@ from typing import TYPE_CHECKING
import httpx
from .base import BaseUpstreamProvider
from .pricing_resolver import (
FallbackPricingResolver,
ResolvedPricing,
_as_float,
estimate_context_length,
)
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
@@ -64,6 +70,40 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
"platform_url": cls.platform_url,
}
def _native_pricing(
self, model_id: str, model_spec: dict
) -> ResolvedPricing | None:
"""Read pricing/metadata from Venice's bespoke ``model_spec`` schema.
Returns ``None`` when the upstream reported no *usable* native price —
absent, non-numeric, negative, or both-zero — so the caller falls
through to the shared resolution chain instead of fabricating a number
or trusting a bogus one. This mirrors the money-safety guards the
litellm and OpenRouter rungs already apply: a both-zero price would
serve the model free, a negative one would credit the caller, and a
non-numeric string would otherwise throw and drop the whole catalog.
"""
pricing_info = model_spec.get("pricing", {})
input_usd = _as_float(pricing_info.get("input", {}).get("usd"))
output_usd = _as_float(pricing_info.get("output", {}).get("usd"))
if input_usd is None or output_usd is None:
return None
if input_usd < 0 or output_usd < 0 or (input_usd == 0 and output_usd == 0):
return None
capabilities = model_spec.get("capabilities", {})
input_modalities = ["text"]
if capabilities.get("supportsVision", False):
input_modalities.append("image")
return ResolvedPricing(
prompt=input_usd / 1_000_000,
completion=output_usd / 1_000_000,
context_length=model_spec.get("availableContextTokens"),
source="native",
input_modalities=input_modalities,
)
async def fetch_models(self) -> list[Model]:
"""Fetch models from upstream API using /models endpoint."""
from ..payment.models import Architecture, Model, Pricing, TopProvider
@@ -78,6 +118,7 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
response.raise_for_status()
data = response.json()
resolver = FallbackPricingResolver()
models_list = []
for model_data in data.get("data", []):
model_id = model_data.get("id", "")
@@ -89,41 +130,44 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
owned_by = model_data.get("owned_by", "unknown")
model_spec = model_data.get("model_spec", {})
context_length = 4096
if model_spec.get("availableContextTokens"):
context_length = model_spec["availableContextTokens"]
elif any(
pattern in model_id.lower() for pattern in ["32k", "32000"]
):
context_length = 32768
elif any(
pattern in model_id.lower() for pattern in ["16k", "16000"]
):
context_length = 16384
elif any(pattern in model_id.lower() for pattern in ["8k", "8000"]):
context_length = 8192
elif "gpt-4" in model_id.lower():
context_length = 8192
elif "claude" in model_id.lower():
context_length = 200000
resolved = self._native_pricing(model_id, model_spec)
if resolved is None:
resolved = await resolver.resolve(model_id)
pricing_info = model_spec.get("pricing", {})
input_pricing = pricing_info.get("input", {})
output_pricing = pricing_info.get("output", {})
if resolved is None:
# Fail closed: never invent a price. Import the model
# disabled with a warning so the operator can price it
# (the admin UI surfaces disabled remote models).
logger.warning(
f"No pricing source resolved for '{model_id}' from "
f"{self.upstream_name}; importing it disabled",
extra={"model_id": model_id, "base_url": self.base_url},
)
resolved = ResolvedPricing(
prompt=0.0,
completion=0.0,
context_length=None,
source="unresolved",
)
enabled = False
else:
enabled = True
prompt_price = input_pricing.get("usd", 0.001) / 1000000
completion_price = output_pricing.get("usd", 0.001) / 1000000
# Prefer the source's own modality string (OpenRouter ships
# one, e.g. "text+image->text"); otherwise derive it from the
# captured input/output modalities in the same "in->out" shape
# rather than flattening vision models to "text->text".
modality = resolved.modality or (
f"{'+'.join(resolved.input_modalities)}"
f"->{'+'.join(resolved.output_modalities)}"
)
capabilities = model_spec.get("capabilities", {})
input_modalities = ["text"]
output_modalities = ["text"]
if capabilities.get("supportsVision", False):
input_modalities.append("image")
modality = "text"
if capabilities.get("supportsVision", False):
modality = "text->text"
# A source can carry a price but no context (e.g. a litellm
# entry missing max_input_tokens); fall back to an id-based
# estimate so we never persist a zero-length window.
context_length = resolved.context_length or estimate_context_length(
model_id
)
spec_name = model_spec.get("name", model_name)
description = f"{spec_name}"
@@ -139,30 +183,33 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
context_length=context_length,
architecture=Architecture(
modality=modality,
input_modalities=input_modalities,
output_modalities=output_modalities,
tokenizer="unknown",
instruct_type=None,
input_modalities=resolved.input_modalities,
output_modalities=resolved.output_modalities,
tokenizer=resolved.tokenizer,
instruct_type=resolved.instruct_type,
),
pricing=Pricing(
prompt=prompt_price,
completion=completion_price,
prompt=resolved.prompt,
completion=resolved.completion,
request=0.0,
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
max_prompt_cost=0.001,
max_completion_cost=0.001,
max_cost=0.001,
input_cache_read=resolved.input_cache_read,
input_cache_write=resolved.input_cache_write,
),
sats_pricing=None,
per_request_limits=None,
top_provider=TopProvider(
context_length=context_length,
max_completion_tokens=context_length // 2,
is_moderated=False,
max_completion_tokens=(
resolved.max_completion_tokens
if resolved.max_completion_tokens is not None
else context_length // 2
),
is_moderated=bool(resolved.is_moderated),
),
enabled=True,
enabled=enabled,
upstream_provider_id=None,
canonical_slug=None,
)
+19 -10
View File
@@ -94,12 +94,10 @@ async def get_all_models_with_overrides(
provider_result = await session.exec(select(UpstreamProviderRow))
providers_by_id = {p.id: p for p in provider_result.all()}
overrides_by_id: dict[str, tuple[ModelRow, float]] = {
row.id: (
overrides_by_key: dict[tuple[str, int], tuple[ModelRow, float]] = {
(row.id.lower(), row.upstream_provider_id): (
row,
providers_by_id[row.upstream_provider_id].provider_fee
if row.upstream_provider_id in providers_by_id
else 1.01,
providers_by_id[row.upstream_provider_id].provider_fee,
)
for row in override_rows
if row.upstream_provider_id is not None
@@ -107,17 +105,28 @@ async def get_all_models_with_overrides(
and providers_by_id[row.upstream_provider_id].enabled
}
all_models: dict[str, Model] = {}
all_models: dict[tuple[str, str], Model] = {}
for upstream in upstreams:
upstream_db_id = getattr(upstream, "db_id", None)
provider_key = (
f"db:{upstream_db_id}"
if isinstance(upstream_db_id, int)
else f"{getattr(upstream, 'provider_type', '')}|{getattr(upstream, 'base_url', '')}"
)
for model in upstream.get_cached_models():
if model.id in overrides_by_id:
override_row, provider_fee = overrides_by_id[model.id]
all_models[model.id] = _row_to_model(
model_key = (
(model.id.lower(), upstream_db_id)
if isinstance(upstream_db_id, int)
else None
)
if model_key is not None and model_key in overrides_by_key:
override_row, provider_fee = overrides_by_key[model_key]
all_models[(model.id.lower(), provider_key)] = _row_to_model(
override_row, apply_provider_fee=True, provider_fee=provider_fee
)
elif model.enabled:
all_models[model.id] = model
all_models[(model.id.lower(), provider_key)] = model
return list(all_models.values())
+28
View File
@@ -317,6 +317,27 @@ def annotate_event(event: dict, requested_model: str | None) -> AnnotatedEvent:
total_cost += _coerce_float(usage.get("total_cost"))
input_cost += _coerce_float(usage.get("input_cost"))
output_cost += _coerce_float(usage.get("output_cost"))
cost_details = usage.get("cost_details")
if isinstance(cost_details, dict):
total_cost = max(
total_cost,
_coerce_float(cost_details.get("total_cost")),
_coerce_float(cost_details.get("upstream_inference_cost")),
)
input_cost = max(
input_cost,
_coerce_float(cost_details.get("input_cost")),
_coerce_float(
cost_details.get("upstream_inference_prompt_cost")
),
)
output_cost = max(
output_cost,
_coerce_float(cost_details.get("output_cost")),
_coerce_float(
cost_details.get("upstream_inference_completions_cost")
),
)
msg_for_meta = event.get("message")
if isinstance(msg_for_meta, dict):
@@ -340,14 +361,21 @@ def annotate_event(event: dict, requested_model: str | None) -> AnnotatedEvent:
total_cost = max(
total_cost,
_coerce_float(root_cost_details.get("total_cost")),
_coerce_float(root_cost_details.get("upstream_inference_cost")),
)
input_cost = max(
input_cost,
_coerce_float(root_cost_details.get("input_cost")),
_coerce_float(
root_cost_details.get("upstream_inference_prompt_cost")
),
)
output_cost = max(
output_cost,
_coerce_float(root_cost_details.get("output_cost")),
_coerce_float(
root_cost_details.get("upstream_inference_completions_cost")
),
)
event_type = str(event.get("type") or "")
-33
View File
@@ -1,4 +1,3 @@
import json
from typing import TYPE_CHECKING
import httpx
@@ -19,38 +18,6 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
supports_anthropic_messages = True
litellm_provider_prefix = "openrouter/"
def prepare_request_body(
self, body: bytes | None, model_obj: Model
) -> bytes | None:
"""Set provider.require_parameters on tool-use requests.
Without it OpenRouter can route a tool call to an endpoint that doesn't
support function calling and 404 with "No endpoints found that support
tool use". We leave a client-supplied value untouched.
"""
body = super().prepare_request_body(body, model_obj)
if not body:
return body
try:
data = json.loads(body)
except json.JSONDecodeError:
return body
if not isinstance(data, dict) or not data.get("tools"):
return body
provider = data.get("provider")
if not isinstance(provider, dict):
provider = {}
if "require_parameters" in provider:
return body
provider["require_parameters"] = True
data["provider"] = provider
return json.dumps(data).encode()
def _apply_provider_field(self, response_json: object) -> None:
"""Stamp the ``provider`` field for OpenRouter responses.
+203
View File
@@ -0,0 +1,203 @@
"""Shared price/metadata resolution chain for upstream model discovery.
Most OpenAI-compatible ``/models`` responses carry no pricing. Rather than let
a provider fabricate one, this module resolves a model through decreasingly
trustworthy sources — litellm's bundled cost map (curated list prices, mirrors
provider docs), then the OpenRouter feed (resale prices, broader coverage) —
and returns ``None`` when none of them know the model, so the caller can fail
closed instead of inventing a number.
Provider-native pricing (a gateway's own ``/models`` schema, e.g. Venice's
``model_spec``) is authoritative and handled by the provider before this chain
is consulted; only the shared fallback lives here so a later refactor can hoist
it into the base provider unchanged.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class ResolvedPricing:
"""Per-token pricing plus whatever metadata the answering source carried.
Prices are USD per token. ``source`` records provenance
(``native``/``litellm``/``openrouter``/``unresolved``) so later work can
surface where each price came from.
"""
prompt: float
completion: float
context_length: int | None
source: str
modality: str | None = None
max_completion_tokens: int | None = None
input_cache_read: float = 0.0
input_cache_write: float = 0.0
input_modalities: list[str] = field(default_factory=lambda: ["text"])
output_modalities: list[str] = field(default_factory=lambda: ["text"])
tokenizer: str = "unknown"
instruct_type: str | None = None
is_moderated: bool | None = None
def estimate_context_length(model_id: str) -> int:
"""Best-effort context window from a model id when no source reports one.
The last rung of the fallback chain, reached only for a model whose price
resolved but whose context did not (or that imported disabled). Context is
not a billing input, so a rough id-based guess is acceptable here where a
guessed *price* never would be.
"""
lowered = model_id.lower()
if any(pattern in lowered for pattern in ["32k", "32000"]):
return 32768
if any(pattern in lowered for pattern in ["16k", "16000"]):
return 16384
if any(pattern in lowered for pattern in ["8k", "8000"]):
return 8192
if "gpt-4" in lowered:
return 8192
if "claude" in lowered:
return 200000
return 4096
def _as_float(value: object) -> float | None:
"""OpenRouter reports prices as strings; coerce, ``None`` if unparseable."""
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _as_int(value: object) -> int | None:
"""Coerce an already-numeric token count to ``int``, else ``None``."""
return int(value) if isinstance(value, (int, float)) else 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
info = litellm_cost_entry(model_id)
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)):
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.
if prompt < 0 or completion < 0 or (prompt == 0 and completion == 0):
return None
input_modalities = ["text"]
if info.get("supports_vision"):
input_modalities.append("image")
return ResolvedPricing(
prompt=float(prompt),
completion=float(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
# estimate downstream rather than borrowing the output cap.
context_length=_as_int(info.get("max_input_tokens")),
source="litellm",
max_completion_tokens=_as_int(info.get("max_output_tokens")),
input_cache_read=float(info.get("cache_read_input_token_cost") or 0.0),
input_cache_write=float(info.get("cache_creation_input_token_cost") or 0.0),
input_modalities=input_modalities,
)
def _match_openrouter(model_id: str, feed: list[dict]) -> dict | None:
"""Find ``model_id`` in the OpenRouter feed, exact id before bare tail.
Bare-tail matching (``deepseek-chat`` ↔ ``deepseek/deepseek-chat``) is a
looser, lower-trust match — OpenRouter fans a model out across resellers —
so an exact id match always wins first. When several entries share the bare
tail, the one with the highest *combined* (prompt + completion) per-token
cost wins: the choice must be deterministic (not feed-order-dependent) and
money-safe whichever way traffic leans, since undercharging is the hazard.
Ranking on prompt alone could pick an entry that is cheap on input but dear
on output. The live feed has no such collisions today; this only governs
the latent case.
"""
bare = model_id.split("/", 1)[-1]
exact = next((m for m in feed if m.get("id") == model_id), None)
if exact is not None:
return exact
matches = [m for m in feed if m.get("id", "").split("/", 1)[-1] == bare]
if not matches:
return None
def _combined_cost(m: dict) -> float:
pricing = m.get("pricing", {})
return (_as_float(pricing.get("prompt")) or 0.0) + (
_as_float(pricing.get("completion")) or 0.0
)
return max(matches, key=_combined_cost)
def _from_openrouter(model_id: str, feed: list[dict]) -> ResolvedPricing | None:
entry = _match_openrouter(model_id, feed)
if entry is None:
return None
pricing = entry.get("pricing", {})
prompt = _as_float(pricing.get("prompt"))
completion = _as_float(pricing.get("completion"))
if prompt is None or completion is None:
return None
architecture = entry.get("architecture", {})
top_provider = entry.get("top_provider", {})
return ResolvedPricing(
prompt=prompt,
completion=completion,
context_length=_as_int(entry.get("context_length")),
source="openrouter",
modality=architecture.get("modality"),
max_completion_tokens=_as_int(top_provider.get("max_completion_tokens")),
input_cache_read=_as_float(pricing.get("input_cache_read")) or 0.0,
input_cache_write=_as_float(pricing.get("input_cache_write")) or 0.0,
input_modalities=architecture.get("input_modalities") or ["text"],
output_modalities=architecture.get("output_modalities") or ["text"],
tokenizer=architecture.get("tokenizer") or "unknown",
instruct_type=architecture.get("instruct_type"),
is_moderated=top_provider.get("is_moderated"),
)
class FallbackPricingResolver:
"""Resolves models via litellm → OpenRouter for one discovery pass.
The OpenRouter catalog is fetched at most once and only when a model
actually misses litellm, so a provider full of litellm-known models never
touches the network. Instantiate one per ``fetch_models`` call.
"""
def __init__(self) -> None:
self._openrouter_feed: list[dict] | None = None
async def resolve(self, model_id: str) -> ResolvedPricing | None:
"""Resolve ``model_id``; ``None`` if no source knows it."""
resolved = _from_litellm(model_id)
if resolved is not None:
return resolved
if self._openrouter_feed is None:
# Lazy import so tests can patch the feed at its source.
from ..payment.models import async_fetch_openrouter_models
self._openrouter_feed = await async_fetch_openrouter_models()
return _from_openrouter(model_id, self._openrouter_feed)
+374 -78
View File
@@ -3,10 +3,10 @@ import re
import socket
import time
import typing
from typing import TypedDict
from typing import Any, Awaitable, Callable, TypedDict
import httpx
from cashu.core.base import Proof, Token
from cashu.core.base import MintQuote, Proof, Token
from cashu.core.mint_info import MintInfo as _CashuMintInfo
from cashu.wallet.helpers import deserialize_token_from_string
from cashu.wallet.wallet import Wallet
@@ -14,7 +14,7 @@ from pydantic_core import PydanticUndefined
from sqlmodel import col, select, update
from .core import db, get_logger
from .core.db import store_cashu_transaction
from .core.db import store_cashu_transaction_with_retry
from .core.settings import settings
from .payment.lnurl import raw_send_to_lnurl
@@ -62,6 +62,155 @@ _TRANSPORT_EXC_TYPES: tuple[type[BaseException], ...] = (
)
class _MintRateGuard:
"""Bound concurrency and adapt to actual per-mint 429 responses."""
_guards: dict[str, "_MintRateGuard"] = {}
@classmethod
def get(cls, mint_url: str) -> "_MintRateGuard | None":
concurrency = settings.mint_max_concurrency
if concurrency <= 0:
return None
guard = cls._guards.get(mint_url)
if guard is None or guard._max_concurrency != concurrency:
guard = cls(mint_url, concurrency)
cls._guards[mint_url] = guard
return guard
def __init__(self, mint_url: str, max_concurrency: int):
self._mint_url = mint_url
self._max_concurrency = max_concurrency
self._semaphore = asyncio.Semaphore(max_concurrency)
self._cooldown_until = 0.0
def apply_cooldown(self, delay: float) -> None:
self._cooldown_until = max(
self._cooldown_until, time.monotonic() + max(0.0, delay)
)
async def run(self, factory: Callable[[], Awaitable[Any]]) -> Any:
async with self._semaphore:
wait = self._cooldown_until - time.monotonic()
if wait > 0:
logger.debug(
"Mint rate guard: cooling down",
extra={
"mint_url": self._mint_url,
"wait_seconds": round(wait, 2),
},
)
await asyncio.sleep(wait)
return await factory()
def _is_mint_rate_limited(error: BaseException) -> bool:
"""True if the mint returned a 429 or rate-limit indication."""
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, httpx.HTTPStatusError):
if current.response.status_code == 429:
return True
lowered = str(current).lower()
if "rate limit" in lowered or "too many requests" in lowered:
return True
current = current.__cause__ or current.__context__
return False
async def _mint_operation(
factory: Callable[[], Awaitable[Any]],
*,
op_name: str = "mint_operation",
mint_url: str = "",
retry_timeouts: bool = True,
) -> Any:
"""Run a mint operation with bounded concurrency and adaptive cooldown.
The timeout covers concurrency queueing, 429 cooldown, backoff, and network
work together. ``factory`` must return a fresh coroutine for every retry.
"""
guard = _MintRateGuard.get(mint_url) if mint_url else None
timeout = settings.mint_operation_timeout_seconds
max_attempts = settings.mint_retry_max_attempts + 1
async def invoke() -> Any:
if guard is not None:
return await guard.run(factory)
return await factory()
async def run_with_retries() -> Any:
for attempt in range(max_attempts):
try:
return await invoke()
except (asyncio.TimeoutError, httpx.TimeoutException) as exc:
if retry_timeouts and attempt < max_attempts - 1:
backoff = (2**attempt) + (time.monotonic() % 1.0)
logger.warning(
"Mint operation timed out, retrying",
extra={
"op_name": op_name,
"mint_url": mint_url,
"attempt": attempt + 1,
"backoff_seconds": round(backoff, 2),
},
)
await asyncio.sleep(backoff)
continue
raise httpx.TimeoutException(
f"{op_name} timed out (attempts: {attempt + 1})"
) from exc
except Exception as exc:
if not _is_mint_rate_limited(exc):
raise
backoff = (2**attempt) + (time.monotonic() % 1.0)
if isinstance(exc, httpx.HTTPStatusError):
retry_after = _parse_retry_after(exc.response.headers)
if retry_after is not None:
backoff = max(retry_after, backoff)
if guard is not None:
guard.apply_cooldown(backoff)
if attempt >= max_attempts - 1:
raise
logger.warning(
"Mint rate-limited, applying cooldown",
extra={
"op_name": op_name,
"mint_url": mint_url,
"attempt": attempt + 1,
"cooldown_seconds": round(backoff, 2),
},
)
if guard is None:
await asyncio.sleep(backoff)
raise RuntimeError(f"{op_name}: exhausted retries unexpectedly")
try:
if timeout > 0:
return await asyncio.wait_for(run_with_retries(), timeout=timeout)
return await run_with_retries()
except asyncio.TimeoutError as exc:
raise httpx.TimeoutException(
f"{op_name} exceeded its {timeout}s total timeout"
) from exc
def _parse_retry_after(headers: Any) -> float | None:
"""Parse a Retry-After header (delta-seconds form) into seconds."""
raw = headers.get("retry-after") or headers.get("Retry-After")
if raw is None:
return None
try:
return float(str(raw).strip())
except (TypeError, ValueError):
return None
def is_mint_connection_error(error: BaseException) -> bool:
"""True if ``error`` (or anything in its cause/context chain) is a mint
transport failure. Walks the chain because some sites re-raise transport
@@ -192,10 +341,19 @@ async def _redeem_same_mint(
that, not the face value, or routstr over-credits the user and its wallet
drifts insolvent.
"""
await wallet.load_mint(keyset_id=token_obj.keysets[0])
await _mint_operation(
lambda: wallet.load_mint(keyset_id=token_obj.keysets[0]),
op_name="redeem_load_mint",
mint_url=token_obj.mint,
)
wallet.verify_proofs_dleq(token_obj.proofs)
input_fees = wallet.get_fees_for_proofs(token_obj.proofs)
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
await _mint_operation(
lambda: wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True),
op_name="redeem_split",
mint_url=token_obj.mint,
retry_timeouts=False,
)
return int(token_obj.amount) - input_fees, token_obj.unit, token_obj.mint
@@ -237,7 +395,9 @@ async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int
all_mint_urls = list({k.mint_url for k in wallet.keysets.values()})
proof_summary = {
f"{k.mint_url}/{k.unit.name}": sum(p.amount for p in wallet.proofs if p.id == k.id)
f"{k.mint_url}/{k.unit.name}": sum(
p.amount for p in wallet.proofs if p.id == k.id
)
for k in wallet.keysets.values()
}
# Show ALL proofs in DB by keyset_id, regardless of whether the loaded wallet
@@ -341,6 +501,44 @@ def _melt_insufficient_shortfall(error: Exception) -> int | None:
return 1
async def _request_mint_with_fallback(
amount: int, *, op_name: str, primary_wallet: Wallet | None = None
) -> tuple[Wallet, str, MintQuote]:
"""Try request_mint on the primary mint, fall back to other trusted mints
on transport or rate-limit failure. Returns the wallet, mint_url, and quote."""
candidates = [settings.primary_mint] + [
m for m in settings.cashu_mints if m != settings.primary_mint
]
tried: list[str] = []
for mint_url in candidates:
try:
if mint_url == settings.primary_mint and primary_wallet is not None:
wallet = primary_wallet
else:
wallet = await get_wallet(mint_url, settings.primary_mint_unit)
quote = await _mint_operation(
lambda: wallet.request_mint(amount),
op_name=op_name,
mint_url=mint_url,
)
return wallet, mint_url, quote
except Exception as e:
tried.append(f"{mint_url}: {type(e).__name__}")
if not is_mint_connection_error(e) and not _is_mint_rate_limited(e):
raise
logger.warning(
"request_mint failed, trying fallback mint",
extra={
"failed_mint": mint_url,
"error": str(e),
"tried": tried,
"op_name": op_name,
},
)
continue
raise MintConnectionError(f"All mints failed for {op_name}: {tried}")
async def _calculate_swap_amount(
amount_msat: int,
token_unit: str,
@@ -374,8 +572,16 @@ async def _calculate_swap_amount(
)
try:
dummy_mint_quote = await primary_wallet.request_mint(receive_amount)
dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request)
_, _, dummy_mint_quote = await _request_mint_with_fallback(
receive_amount,
op_name="swap_fee_est_mint_quote",
primary_wallet=primary_wallet,
)
dummy_melt_quote = await _mint_operation(
lambda: token_wallet.melt_quote(dummy_mint_quote.request),
op_name="swap_fee_est_melt_quote",
mint_url=token_mint_url,
)
fee_reserve = dummy_melt_quote.fee_reserve
input_fees = token_wallet.get_fees_for_proofs(proofs)
@@ -462,15 +668,27 @@ async def swap_to_primary_mint(
# amount recomputed from the fees the mint actually demands.
observed_extra_fee = 0
attempt = 0
dest_wallet = primary_wallet
dest_mint_url = settings.primary_mint
while True:
attempt += 1
mint_quote = await primary_wallet.request_mint(minted_amount)
dest_wallet, dest_mint_url, mint_quote = await _request_mint_with_fallback(
minted_amount, op_name="swap_request_mint", primary_wallet=primary_wallet
)
logger.info(
"swap_to_primary_mint: mint quote received",
extra={"mint_quote_id": mint_quote.quote, "attempt": attempt},
extra={
"mint_quote_id": mint_quote.quote,
"attempt": attempt,
"dest_mint": dest_mint_url,
},
)
melt_quote = await token_wallet.melt_quote(mint_quote.request)
melt_quote = await _mint_operation(
lambda: token_wallet.melt_quote(mint_quote.request),
op_name="swap_melt_quote",
mint_url=token_obj.mint,
)
input_fees = token_wallet.get_fees_for_proofs(token_obj.proofs)
total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees
logger.info(
@@ -523,11 +741,16 @@ async def swap_to_primary_mint(
continue
try:
_ = await token_wallet.melt(
proofs=token_obj.proofs,
invoice=mint_quote.request,
fee_reserve_sat=melt_quote.fee_reserve,
quote_id=melt_quote.quote,
_ = await _mint_operation(
lambda: token_wallet.melt(
proofs=token_obj.proofs,
invoice=mint_quote.request,
fee_reserve_sat=melt_quote.fee_reserve,
quote_id=melt_quote.quote,
),
op_name="swap_melt",
mint_url=token_obj.mint,
retry_timeouts=False,
)
except Exception as e:
# A down mint won't fix itself by retrying with a smaller amount.
@@ -576,14 +799,23 @@ async def swap_to_primary_mint(
break
logger.info(
"swap_to_primary_mint: melt succeeded, minting on primary",
extra={"minted_amount": minted_amount, "mint_quote_id": mint_quote.quote},
"swap_to_primary_mint: melt succeeded, minting on destination",
extra={
"minted_amount": minted_amount,
"mint_quote_id": mint_quote.quote,
"dest_mint": dest_mint_url,
},
)
await primary_wallet.load_proofs(reload=True)
pre_mint_balance = primary_wallet.available_balance.amount
await dest_wallet.load_proofs(reload=True)
pre_mint_balance = dest_wallet.available_balance.amount
try:
_ = await primary_wallet.mint(minted_amount, quote_id=mint_quote.quote)
_ = await _mint_operation(
lambda: dest_wallet.mint(minted_amount, quote_id=mint_quote.quote),
op_name="swap_mint_on_primary",
mint_url=dest_mint_url,
retry_timeouts=False,
)
except Exception as e:
if "11003" in str(e) or "outputs already signed" in str(e).lower():
# Previous mint call signed outputs at the mint but failed before
@@ -591,13 +823,18 @@ async def swap_to_primary_mint(
# advance the counter so the next request derives fresh secrets.
logger.warning(
"swap_to_primary_mint: outputs already signed — recovering orphaned proofs",
extra={"mint_quote_id": mint_quote.quote, "minted_amount": minted_amount},
extra={
"mint_quote_id": mint_quote.quote,
"minted_amount": minted_amount,
},
)
try:
for keyset_id in primary_wallet.keysets:
await primary_wallet.restore_tokens_for_keyset(keyset_id, to=1, batch=25)
await primary_wallet.load_proofs(reload=True)
post_recovery_balance = primary_wallet.available_balance.amount
for keyset_id in dest_wallet.keysets:
await dest_wallet.restore_tokens_for_keyset(
keyset_id, to=1, batch=25
)
await dest_wallet.load_proofs(reload=True)
post_recovery_balance = dest_wallet.available_balance.amount
balance_gained = post_recovery_balance - pre_mint_balance
logger.info(
"swap_to_primary_mint: recovery scan completed",
@@ -648,14 +885,14 @@ async def swap_to_primary_mint(
"swap_to_primary_mint: completed successfully",
extra={
"foreign_mint": token_obj.mint,
"primary_mint": settings.primary_mint,
"dest_mint": dest_mint_url,
"original_amount": token_amount,
"minted_amount": minted_amount,
"unit": settings.primary_mint_unit,
},
)
return int(minted_amount), settings.primary_mint_unit, settings.primary_mint
return int(minted_amount), settings.primary_mint_unit, dest_mint_url
async def credit_balance(
@@ -734,7 +971,7 @@ async def credit_balance(
)
try:
await store_cashu_transaction(
await store_cashu_transaction_with_retry(
token=cashu_token,
amount=original_amount,
unit=original_unit,
@@ -744,7 +981,7 @@ async def credit_balance(
api_key_hashed_key=key.hashed_key,
)
except Exception:
pass
pass # store_cashu_transaction_with_retry already logs + spools to outbox
logger.debug(
"Cashu token successfully redeemed and stored",
@@ -760,18 +997,39 @@ async def credit_balance(
_wallets: dict[str, Wallet] = {}
_wallet_last_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(mint_url: str, unit: str = "sat", load: bool = True) -> Wallet:
global _wallets
global _wallets, _wallet_last_load, _wallet_load_locks
id = f"{mint_url}_{unit}"
if id not in _wallets:
_wallets[id] = await Wallet.with_db(mint_url, db=".wallet", unit=unit)
lock = _wallet_load_locks.setdefault(id, asyncio.Lock())
async with lock:
if id not in _wallets:
_wallets[id] = await Wallet.with_db(mint_url, db=".wallet", unit=unit)
if load:
await _wallets[id].load_mint()
await _wallets[id].load_proofs(reload=True)
return _wallets[id]
if load:
now = time.monotonic()
last = _wallet_last_load.get(id, 0)
if now - last >= _WALLOAD_RELOAD_MIN_INTERVAL_SECONDS:
await _mint_operation(
lambda: _wallets[id].load_mint(),
op_name="load_mint",
mint_url=mint_url,
)
await _mint_operation(
lambda: _wallets[id].load_proofs(reload=True),
op_name="load_proofs",
mint_url=mint_url,
)
_wallet_last_load[id] = time.monotonic()
return _wallets[id]
def get_proofs_per_mint_and_unit(
@@ -793,15 +1051,28 @@ async def slow_filter_spend_proofs(proofs: list[Proof], wallet: Wallet) -> list[
return []
_proofs = []
_spent_proofs = []
for i in range(0, len(proofs), 1000):
pb = proofs[i : i + 1000]
proof_states = await wallet.check_proof_state(pb)
# Keep proof-state checks in large batches. Mint quotas count HTTP requests,
# so smaller batches make balance reads slower and more likely to hit 429s.
batch_size = 1000
for i in range(0, len(proofs), batch_size):
pb = proofs[i : i + batch_size]
proof_states = await _mint_operation(
lambda: wallet.check_proof_state(pb),
op_name="check_proof_state",
mint_url=str(wallet.url),
)
for proof, state in zip(pb, proof_states.states):
if str(state.state) != "spent":
_proofs.append(proof)
else:
_spent_proofs.append(proof)
await wallet.set_reserved_for_send(_spent_proofs, reserved=True)
if _spent_proofs:
await _mint_operation(
lambda: wallet.set_reserved_for_send(_spent_proofs, reserved=True),
op_name="set_reserved_spent_proofs",
mint_url=str(wallet.url),
retry_timeouts=False,
)
return _proofs
@@ -848,7 +1119,9 @@ async def fetch_all_balances(
"unit": unit,
"wallet_balance": proofs_balance,
"user_balance": user_balance,
"owner_balance": proofs_balance - user_balance if proofs_balance != 0 else 0,
"owner_balance": proofs_balance - user_balance
if proofs_balance != 0
else 0,
}
return result
except Exception as e:
@@ -919,52 +1192,71 @@ async def fetch_all_balances(
async def periodic_payout() -> None:
while True:
await asyncio.sleep(settings.payout_interval_seconds)
print(settings.payout_interval_seconds)
if not settings.receive_ln_address:
continue
try:
if not settings.receive_ln_address:
continue
# Include the primary mint even if it is not listed in cashu_mints,
# matching fetch_all_balances(); otherwise primary-mint funds never
# auto-payout.
mint_urls: list[str] = list(settings.cashu_mints)
if settings.primary_mint and settings.primary_mint not in mint_urls:
mint_urls.append(settings.primary_mint)
async with db.create_session() as session:
for mint_url in settings.cashu_mints:
for mint_url in mint_urls:
for unit in ["sat", "msat"]:
wallet = await get_wallet(mint_url, unit)
proofs = get_proofs_per_mint_and_unit(
wallet, mint_url, unit, not_reserved=True
)
proofs = await slow_filter_spend_proofs(proofs, wallet)
await asyncio.sleep(5)
user_balance = await db.balances_for_mint_and_unit(
session, mint_url, unit
)
if unit == "sat":
user_balance = user_balance // 1000
proofs_balance = sum(proof.amount for proof in proofs)
available_balance = proofs_balance - user_balance
# Threshold is configured in sats; convert for msat wallets.
min_amount = (
settings.min_payout_sat
if unit == "sat"
else settings.min_payout_sat * 1000
)
if available_balance > min_amount:
amount_received = await raw_send_to_lnurl(
wallet,
proofs,
settings.receive_ln_address,
unit,
amount=available_balance,
# Isolate failures per mint/unit so one slow or failing
# mint does not abort payout for every other mint/unit.
try:
wallet = await get_wallet(mint_url, unit)
proofs = get_proofs_per_mint_and_unit(
wallet, mint_url, unit, not_reserved=True
)
logger.info(
"Payout sent successfully",
proofs = await slow_filter_spend_proofs(proofs, wallet)
await asyncio.sleep(5)
user_balance = await db.balances_for_mint_and_unit(
session, mint_url, unit
)
if unit == "sat":
user_balance = user_balance // 1000
proofs_balance = sum(proof.amount for proof in proofs)
available_balance = proofs_balance - user_balance
# Threshold is configured in sats; convert for msat wallets.
min_amount = (
settings.min_payout_sat
if unit == "sat"
else settings.min_payout_sat * 1000
)
if available_balance > min_amount:
amount_received = await raw_send_to_lnurl(
wallet,
proofs,
settings.receive_ln_address,
unit,
amount=available_balance,
)
logger.info(
"Payout sent successfully",
extra={
"mint_url": mint_url,
"unit": unit,
"balance": available_balance,
"amount_received": amount_received,
},
)
except Exception as e:
logger.error(
f"Error sending payout: {type(e).__name__}",
extra={
"error": str(e),
"mint_url": mint_url,
"unit": unit,
"balance": available_balance,
"amount_received": amount_received,
},
)
except Exception as e:
logger.error(
f"Error sending payout: {type(e).__name__}",
f"Error in periodic payout cycle: {type(e).__name__}",
extra={"error": str(e)},
)
@@ -1046,7 +1338,11 @@ async def periodic_routstr_fee_payout() -> None:
wallet, settings.primary_mint, "sat", not_reserved=True
)
amount_received = await raw_send_to_lnurl(
wallet, proofs, ROUTSTR_LN_ADDRESS, "sat", amount=accumulated_sats
wallet,
proofs,
ROUTSTR_LN_ADDRESS,
"sat",
amount=accumulated_sats,
)
paid_msats = accumulated_sats * 1000
await db.reset_routstr_fee(session, paid_msats)
@@ -0,0 +1,229 @@
import json
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.payment.cost_calculation import CostData, calculate_cost
from routstr.proxy import get_model_instance, reinitialize_upstreams
def _admin_headers() -> dict[str, str]:
token = "test-admin-cache-pricing-token"
admin_sessions[token] = int(
(datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp()
)
return {"Authorization": f"Bearer {token}"}
def _model_payload(
provider_id: int,
*,
cache_read: float,
cache_write: float,
) -> dict[str, object]:
return {
"id": "custom-cache-model",
"name": "Custom Cache Model",
"description": "custom model with explicit cache pricing",
"created": 0,
"context_length": 128000,
"architecture": {
"modality": "text",
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "unknown",
"instruct_type": None,
},
"pricing": {
"prompt": 1.4e-7,
"completion": 2.8e-7,
"input_cache_read": cache_read,
"input_cache_write": cache_write,
"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": True,
"forwarded_model_id": "custom-cache-model",
}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_provider_model_api_persists_cache_pricing_on_create_and_update(
integration_client: AsyncClient,
integration_session: AsyncSession,
) -> None:
provider = UpstreamProviderRow(
provider_type="generic",
base_url="https://custom-upstream.example/v1",
api_key="test-key",
provider_fee=1.0,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
assert provider.id is not None
await reinitialize_upstreams()
headers = _admin_headers()
create_payload = _model_payload(
provider.id,
cache_read=2.8e-9,
cache_write=3.5e-9,
)
with patch("routstr.payment.models.sats_usd_price", return_value=1e-6):
create_response = await integration_client.post(
f"/admin/api/upstream-providers/{provider.id}/models",
headers=headers,
json=create_payload,
)
assert create_response.status_code == 200
create_body = create_response.json()
assert create_body["pricing"]["input_cache_read"] == pytest.approx(2.8e-9)
assert create_body["pricing"]["input_cache_write"] == pytest.approx(3.5e-9)
row = await integration_session.get(ModelRow, ("custom-cache-model", provider.id))
assert row is not None
stored_pricing = json.loads(row.pricing)
assert stored_pricing["input_cache_read"] == pytest.approx(2.8e-9)
assert stored_pricing["input_cache_write"] == pytest.approx(3.5e-9)
update_payload = _model_payload(
provider.id,
cache_read=1.25e-9,
cache_write=4.5e-9,
)
with patch("routstr.payment.models.sats_usd_price", return_value=1e-6):
update_response = await integration_client.post(
f"/admin/api/upstream-providers/{provider.id}/models",
headers=headers,
json=update_payload,
)
assert update_response.status_code == 200
update_body = update_response.json()
assert update_body["pricing"]["input_cache_read"] == pytest.approx(1.25e-9)
assert update_body["pricing"]["input_cache_write"] == pytest.approx(4.5e-9)
await integration_session.refresh(row)
updated_pricing = json.loads(row.pricing)
assert updated_pricing["input_cache_read"] == pytest.approx(1.25e-9)
assert updated_pricing["input_cache_write"] == pytest.approx(4.5e-9)
model = get_model_instance("custom-cache-model")
assert model is not None
assert model.sats_pricing is not None
assert model.sats_pricing.input_cache_read == pytest.approx(0.00125)
assert model.sats_pricing.input_cache_write == pytest.approx(0.0045)
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=1e-6):
cost = await calculate_cost(
{
"model": "custom-cache-model",
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 100,
"prompt_tokens_details": {"cached_tokens": 800},
},
},
max_cost=1_000_000,
)
assert isinstance(cost, CostData)
assert cost.input_tokens == 200
assert cost.cache_read_input_tokens == 800
assert cost.cache_read_msats == 1000
assert cost.output_msats == 28000
assert cost.input_msats == 29000
assert cost.total_msats == 57000
@pytest.mark.integration
@pytest.mark.asyncio
async def test_upstream_response_cost_uses_model_cache_pricing(
integration_session: AsyncSession,
patched_db_engine: None,
) -> None:
"""A model's configured cache price must discount upstream cached-token usage."""
provider = UpstreamProviderRow(
provider_type="generic",
base_url="https://cache-priced-upstream.example/v1",
api_key="test-key",
provider_fee=1.0,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
assert provider.id is not None
row = ModelRow(
id="cache-priced-model",
name="Cache Priced Model",
description="model seeded with explicit cache pricing",
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": 1.4e-7,
"completion": 2.8e-7,
"input_cache_read": 1.25e-9,
"input_cache_write": 4.5e-9,
}
),
upstream_provider_id=provider.id,
enabled=True,
forwarded_model_id="cache-priced-model",
)
integration_session.add(row)
await integration_session.commit()
with patch("routstr.payment.models.sats_usd_price", return_value=1e-6):
await reinitialize_upstreams()
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=1e-6):
cost = await calculate_cost(
{
"model": "cache-priced-model",
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 100,
"prompt_tokens_details": {"cached_tokens": 800},
},
},
max_cost=1_000_000,
)
assert isinstance(cost, CostData)
# prompt 1.4e-7 USD/token -> 0.14 sats/token -> 140 msats/token
# cache 1.25e-9 USD/token -> 0.00125 sats/token -> 1.25 msats/token
# completion 2.8e-7 USD/token -> 0.28 sats/token -> 280 msats/token
assert cost.input_tokens == 200
assert cost.cache_read_input_tokens == 800
assert cost.cache_read_msats == 1000
assert cost.input_msats == 29000
assert cost.output_msats == 28000
assert cost.total_msats == 57000
@@ -26,11 +26,17 @@ async def patch_invoice_generation() -> Any:
"""Stub out `generate_lightning_invoice` so no mint round-trip is needed."""
counter = {"n": 0}
async def fake_generate(amount_sats: int, description: str) -> tuple[str, str]:
async def fake_generate(
amount_sats: int,
description: str,
*,
allowed_mints: list[str] | None = None,
) -> tuple[str, str, str]:
counter["n"] += 1
return (
f"lnbc{amount_sats}n1pfakeinvoice{counter['n']}",
f"payment_hash_{counter['n']}",
"http://localhost:3338",
)
with patch(
@@ -95,6 +101,9 @@ async def test_topup_with_authorization_header(
body = resp.json()
assert body["amount_sats"] == 500
assert body["bolt11"].startswith("lnbc")
assert patch_invoice_generation.call_args.kwargs["allowed_mints"] == [
"http://localhost:3338"
]
@pytest.mark.integration
+66 -4
View File
@@ -137,8 +137,8 @@ def test_create_model_mappings_includes_db_override_for_missing_cached_model(
model_instances, provider_map, unique_models = create_model_mappings(
upstreams=[provider],
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
disabled_model_ids=set(),
overrides_by_key={("azure/gpt-4o", 7): (override_row, 1.01)},
disabled_model_keys=set(),
)
assert "azure/gpt-4o" in model_instances
@@ -182,11 +182,73 @@ def test_create_model_mappings_dedupes_with_provider_identity_not_provider_type(
_, provider_map, _ = create_model_mappings(
upstreams=[provider_a, provider_b],
overrides_by_id={"azure/gpt-4o": (override_row, 1.01)},
disabled_model_ids=set(),
overrides_by_key={("azure/gpt-4o", 2): (override_row, 1.01)},
disabled_model_keys=set(),
)
providers_for_alias = provider_map["azure/gpt-4o"]
assert provider_a in providers_for_alias
assert provider_b in providers_for_alias
assert len(providers_for_alias) == 2
def test_create_model_mappings_applies_override_only_to_matching_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Same-id overrides must not add provider-specific aliases to other providers."""
provider_a_model = create_test_model("same-id", prompt_price=0.01)
provider_a = create_test_provider(
"provider-a",
"https://provider-a.example/v1",
db_id=1,
models=[provider_a_model],
)
provider_b_model = create_test_model("same-id", prompt_price=0.02)
provider_b = create_test_provider(
"provider-b",
"https://provider-b.example/v1",
db_id=2,
models=[provider_b_model],
)
override_model = create_test_model("same-id", prompt_price=0.001)
override_model.alias_ids = ["provider-b-only"]
override_row = SimpleNamespace(id="same-id", upstream_provider_id=2, enabled=True)
def fake_row_to_model(*args, **kwargs) -> Model: # type: ignore[no-untyped-def]
return override_model
monkeypatch.setattr("routstr.payment.models._row_to_model", fake_row_to_model)
_, provider_map, _ = create_model_mappings(
upstreams=[provider_a, provider_b],
overrides_by_key={("same-id", 2): (override_row, 1.01)},
disabled_model_keys=set(),
)
assert provider_map["provider-b-only"] == [provider_b]
assert set(provider_map["same-id"]) == {provider_a, provider_b}
def test_create_model_mappings_disables_only_matching_provider() -> None:
"""Disabled overrides are scoped to the provider row, not the shared model id."""
provider_a = create_test_provider(
"provider-a",
"https://provider-a.example/v1",
db_id=1,
models=[create_test_model("same-id")],
)
provider_b = create_test_provider(
"provider-b",
"https://provider-b.example/v1",
db_id=2,
models=[create_test_model("same-id")],
)
_, provider_map, _ = create_model_mappings(
upstreams=[provider_a, provider_b],
overrides_by_key={},
disabled_model_keys={("same-id", 2)},
)
assert provider_map["same-id"] == [provider_a]
+68 -7
View File
@@ -104,6 +104,64 @@ async def test_refund_x_cashu_not_found_raises_404() -> None:
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_refund_x_cashu_pending_raises_425() -> None:
"""in row exists with a request_id but out row not yet created → 425.
This is the race condition where /v1/wallet/refund is polled while the
upstream request is still in flight. The endpoint must signal "retry"
rather than a permanent 404.
"""
from fastapi import HTTPException
x_cashu_token = "cashuApending_token"
in_tx = _make_cashu_tx(
token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-pending"
)
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(None)])
session.add = MagicMock()
session.commit = AsyncMock()
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu=x_cashu_token,
session=session,
)
assert exc_info.value.status_code == 425
assert exc_info.value.headers == {"Retry-After": "2"}
@pytest.mark.asyncio
async def test_refund_x_cashu_in_tx_without_request_id_raises_404() -> None:
"""in row exists but has no request_id (cannot link to a refund) → 404.
This is a genuine "no refund will ever exist" case, distinct from the
pending 425 path.
"""
from fastapi import HTTPException
x_cashu_token = "cashuAnoreqid_token"
in_tx = _make_cashu_tx(
token=x_cashu_token, amount=0, unit="msat", type="in", request_id=None
)
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx)])
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu=x_cashu_token,
session=session,
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_refund_x_cashu_swept_raises_410() -> None:
from fastapi import HTTPException
@@ -177,7 +235,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
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.store_cashu_transaction_with_retry", AsyncMock(return_value=True)) as mock_store,
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
):
@@ -212,7 +270,7 @@ async def test_apikey_refund_logs_token() -> None:
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.store_cashu_transaction_with_retry", AsyncMock(return_value=True)),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger") as mock_logger,
@@ -241,7 +299,7 @@ async def test_apikey_refund_log_includes_path() -> None:
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.store_cashu_transaction_with_retry", AsyncMock(return_value=True)),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger") as mock_logger,
@@ -280,7 +338,7 @@ async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None:
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.store_cashu_transaction_with_retry", AsyncMock(return_value=True)),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
):
@@ -309,7 +367,10 @@ async def test_credit_balance_stores_apikey_transaction_history() -> None:
"routstr.wallet.recieve_token",
AsyncMock(return_value=(100, "sat", "https://mint.example")),
),
patch("routstr.wallet.store_cashu_transaction", AsyncMock()) as mock_store,
patch(
"routstr.wallet.store_cashu_transaction_with_retry",
AsyncMock(return_value=True),
) as mock_store,
):
amount = await credit_balance("cashuAtopup_token", key, session)
@@ -344,7 +405,7 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
"routstr.balance.send_token",
AsyncMock(side_effect=MintConnectionError("raw mint outage detail")),
),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance.store_cashu_transaction_with_retry", AsyncMock(return_value=True)),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger"),
@@ -379,7 +440,7 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None:
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.store_cashu_transaction_with_retry", AsyncMock(return_value=True)),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
patch("routstr.balance.logger"),
+411
View File
@@ -0,0 +1,411 @@
"""Unit tests for the Cashu outbox + retry persistence path.
These cover the guarantees introduced by the refund-token persistence fix:
* ``store_cashu_transaction`` is idempotent (no duplicate rows on retry)
* ``store_cashu_transaction_with_retry`` retries, then spools the full
token to a durable outbox when the DB stays unavailable
* ``replay_cashu_outbox`` drains the outbox into the DB and is idempotent
"""
import asyncio
import json
import os
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, select
import routstr.core.db as db
from routstr.core.db import (
CashuTransaction,
create_session,
replay_all_outbox_files,
replay_cashu_outbox,
store_cashu_transaction,
store_cashu_transaction_with_retry,
)
def _in_memory_engine() -> AsyncEngine:
return create_async_engine(
"sqlite+aiosqlite://",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
@pytest.fixture
async def fresh_engine(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> AsyncIterator[tuple[AsyncEngine, Path]]:
"""Point db.engine at an isolated in-memory DB and the outbox at a tmp file."""
engine = _in_memory_engine()
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
monkeypatch.setattr(db, "engine", engine)
# create_session() looks up module-global `engine` at call time.
monkeypatch.setattr(db, "create_session", create_session, raising=False)
outbox = tmp_path / "cashu_outbox.jsonl"
monkeypatch.setenv("CASHU_OUTBOX_PATH", str(outbox))
try:
yield engine, outbox
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_store_is_idempotent_on_duplicate(
fresh_engine: tuple[AsyncEngine, Path]
) -> None:
"""Inserting the same (token, type) twice yields exactly one row."""
_, _ = fresh_engine
ok1 = await store_cashu_transaction(
token="cashuAtokenA", amount=100, unit="sat", typ="out", request_id="req-1"
)
ok2 = await store_cashu_transaction(
token="cashuAtokenA", amount=100, unit="sat", typ="out", request_id="req-1"
)
assert ok1 is True
assert ok2 is True # duplicate is treated as success, not a new row
async with create_session() as session:
rows = (await session.exec(select(CashuTransaction))).all()
assert len(rows) == 1
assert rows[0].token == "cashuAtokenA"
@pytest.mark.asyncio
async def test_with_retry_succeeds_first_try(
fresh_engine: tuple[AsyncEngine, Path]
) -> None:
_, _ = fresh_engine
ok = await store_cashu_transaction_with_retry(
token="cashuAtokenB", amount=50, unit="sat", typ="out", request_id="req-2"
)
assert ok is True
async with create_session() as session:
rows = (await session.exec(select(CashuTransaction))).all()
assert len(rows) == 1
@pytest.mark.asyncio
async def test_with_retry_succeeds_after_transient_failure(
fresh_engine: tuple[AsyncEngine, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
"""First attempt fails, second succeeds; no outbox spool, one row."""
_, _ = fresh_engine
calls = {"n": 0}
real_store = store_cashu_transaction
async def flaky_store(*args: Any, **kwargs: Any) -> bool:
calls["n"] += 1
if calls["n"] == 1:
return False
return await real_store(*args, **kwargs)
monkeypatch.setattr(db, "store_cashu_transaction", flaky_store)
# zero backoff so the test is fast
monkeypatch.setattr(db.asyncio, "sleep", lambda *_a, **_k: _asyncio_sleep_zero())
ok = await store_cashu_transaction_with_retry(
token="cashuAtokenC", amount=50, unit="sat", typ="out", request_id="req-3",
max_retries=3,
)
assert ok is True
assert calls["n"] == 2
async with create_session() as session:
rows = (await session.exec(select(CashuTransaction))).all()
assert len(rows) == 1
@pytest.mark.asyncio
async def test_with_retry_spools_full_token_to_outbox_on_exhaustion(
fresh_engine: tuple[AsyncEngine, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
"""When the DB stays down, the full token is spooled to the outbox file."""
_, outbox = fresh_engine
async def always_fail(*args: Any, **kwargs: Any) -> bool:
return False
monkeypatch.setattr(db, "store_cashu_transaction", always_fail)
monkeypatch.setattr(db.asyncio, "sleep", lambda *_a, **_k: _asyncio_sleep_zero())
full_token = "cashuAtokenD_super_secret_full_value_xyz"
ok = await store_cashu_transaction_with_retry(
token=full_token, amount=77, unit="sat", typ="out", request_id="req-4",
max_retries=3,
)
assert ok is False
assert outbox.exists()
lines = outbox.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 1
entry = json.loads(lines[0])
# The full token must be recoverable from the outbox, not a preview.
assert entry["token"] == full_token
assert entry["amount"] == 77
assert entry["request_id"] == "req-4"
assert entry["type"] == "out"
@pytest.mark.asyncio
async def test_replay_drains_outbox_into_db(
fresh_engine: tuple[AsyncEngine, Path]
) -> None:
"""Outbox entries are persisted on replay and the outbox is cleared."""
_, outbox = fresh_engine
entry = {
"outbox_id": "abc123",
"queued_at": 1,
"token": "cashuAtokenE",
"amount": 200,
"unit": "sat",
"mint_url": None,
"type": "out",
"request_id": "req-5",
"collected": False,
"created_at": None,
"source": "x-cashu",
"api_key_hashed_key": None,
}
outbox.write_text(json.dumps(entry) + "\n", encoding="utf-8")
persisted = await replay_cashu_outbox()
assert persisted == 1
# outbox should now be empty (truncated)
assert outbox.read_text(encoding="utf-8") == ""
async with create_session() as session:
rows = (await session.exec(select(CashuTransaction))).all()
assert len(rows) == 1
assert rows[0].token == "cashuAtokenE"
@pytest.mark.asyncio
async def test_replay_is_idempotent(fresh_engine: tuple[AsyncEngine, Path]) -> None:
"""Replaying an outbox whose entries already exist in the DB drops them."""
_, outbox = fresh_engine
# Pre-seed the DB row.
await store_cashu_transaction(
token="cashuAtokenF", amount=10, unit="sat", typ="out", request_id="req-6"
)
# Outbox references the same token.
entry = {
"outbox_id": "xyz",
"queued_at": 1,
"token": "cashuAtokenF",
"amount": 10,
"unit": "sat",
"mint_url": None,
"type": "out",
"request_id": "req-6",
"collected": False,
"created_at": None,
"source": "x-cashu",
"api_key_hashed_key": None,
}
outbox.write_text(json.dumps(entry) + "\n", encoding="utf-8")
persisted = await replay_cashu_outbox()
assert persisted == 1 # counted as persisted (already-present = success)
assert outbox.read_text(encoding="utf-8") == ""
async with create_session() as session:
rows = (await session.exec(select(CashuTransaction))).all()
assert len(rows) == 1 # no duplicate
@pytest.mark.asyncio
async def test_replay_keeps_entries_that_still_fail(
fresh_engine: tuple[AsyncEngine, Path], monkeypatch: pytest.MonkeyPatch
) -> None:
"""If replay still can't persist an entry, it stays in the outbox."""
_, outbox = fresh_engine
monkeypatch.setattr(db, "store_cashu_transaction", lambda *a, **k: _afail())
monkeypatch.setattr(db.asyncio, "sleep", lambda *_a, **_k: _asyncio_sleep_zero())
entry = {
"outbox_id": "keepme",
"queued_at": 1,
"token": "cashuAtokenG",
"amount": 5,
"unit": "sat",
"mint_url": None,
"type": "out",
"request_id": "req-7",
"collected": False,
"created_at": None,
"source": "x-cashu",
"api_key_hashed_key": None,
}
outbox.write_text(json.dumps(entry) + "\n", encoding="utf-8")
persisted = await replay_cashu_outbox()
assert persisted == 0
remaining = outbox.read_text(encoding="utf-8").strip().splitlines()
assert len(remaining) == 1
assert json.loads(remaining[0])["outbox_id"] == "keepme"
@pytest.mark.asyncio
async def test_replay_drops_corrupt_lines_and_persists_valid_ones(
fresh_engine: tuple[AsyncEngine, Path]
) -> None:
"""Corrupt JSONL lines are dropped; valid entries are persisted."""
_, outbox = fresh_engine
valid_entry = {
"outbox_id": "good1",
"queued_at": 1,
"token": "cashuAtokenH",
"amount": 300,
"unit": "sat",
"mint_url": None,
"type": "out",
"request_id": "req-8",
"collected": False,
"created_at": None,
"source": "x-cashu",
"api_key_hashed_key": None,
}
outbox.write_text(
json.dumps(valid_entry) + "\n" + '{"token": "broken\n',
encoding="utf-8",
)
persisted = await replay_cashu_outbox()
assert persisted == 1
assert outbox.read_text(encoding="utf-8") == ""
async with create_session() as session:
rows = (await session.exec(select(CashuTransaction))).all()
assert len(rows) == 1
assert rows[0].token == "cashuAtokenH"
@pytest.mark.asyncio
async def test_store_concurrent_duplicate_insert_yields_one_row(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Two concurrent stores of the same (token, type) yield one row, both True."""
db_file = tmp_path / "concurrent.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_file}")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
monkeypatch.setattr(db, "engine", engine)
monkeypatch.setattr(db, "create_session", create_session, raising=False)
monkeypatch.setenv("CASHU_OUTBOX_PATH", str(tmp_path / "outbox.jsonl"))
try:
results = await asyncio.gather(
store_cashu_transaction(
token="cashuAtokenI", amount=100, unit="sat", typ="out", request_id="req-9"
),
store_cashu_transaction(
token="cashuAtokenI", amount=100, unit="sat", typ="out", request_id="req-9"
),
)
assert all(results)
async with create_session() as session:
rows = (await session.exec(select(CashuTransaction))).all()
assert len(rows) == 1
assert rows[0].token == "cashuAtokenI"
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_replay_under_concurrent_replay_is_idempotent(
fresh_engine: tuple[AsyncEngine, Path]
) -> None:
"""Two concurrent replay calls on a seeded outbox yield one row, outbox empty."""
_, outbox = fresh_engine
entry = {
"outbox_id": "concurrent1",
"queued_at": 1,
"token": "cashuAtokenJ",
"amount": 42,
"unit": "sat",
"mint_url": None,
"type": "out",
"request_id": "req-10",
"collected": False,
"created_at": None,
"source": "x-cashu",
"api_key_hashed_key": None,
}
outbox.write_text(json.dumps(entry) + "\n", encoding="utf-8")
results = await asyncio.gather(replay_cashu_outbox(), replay_cashu_outbox())
# At least one call persisted the entry; both may report success.
assert sum(results) >= 1
assert outbox.read_text(encoding="utf-8") == ""
async with create_session() as session:
rows = (await session.exec(select(CashuTransaction))).all()
assert len(rows) == 1
assert rows[0].token == "cashuAtokenJ"
@pytest.mark.asyncio
async def test_replay_all_drains_orphaned_outbox_files(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""replay_all_outbox_files drains every per-PID outbox file, including orphans."""
engine = _in_memory_engine()
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
monkeypatch.setattr(db, "engine", engine)
monkeypatch.setattr(db, "create_session", create_session, raising=False)
# No CASHU_OUTBOX_PATH override; _cashu_outbox_path resolves into tmp_path.
monkeypatch.delenv("CASHU_OUTBOX_PATH", raising=False)
monkeypatch.setattr(
db, "_cashu_outbox_path",
lambda: tmp_path / f"cashu_outbox.{os.getpid()}.jsonl",
)
def _make_entry(token: str, oid: str) -> dict[str, object]:
return {
"outbox_id": oid,
"queued_at": 1,
"token": token,
"amount": 100,
"unit": "sat",
"mint_url": None,
"type": "out",
"request_id": f"req-{oid}",
"collected": False,
"created_at": None,
"source": "x-cashu",
"api_key_hashed_key": None,
}
orphan_a = tmp_path / "cashu_outbox.11111.jsonl"
orphan_b = tmp_path / "cashu_outbox.22222.jsonl"
orphan_a.write_text(json.dumps(_make_entry("cashuOrphanA", "a1")) + "\n", encoding="utf-8")
orphan_b.write_text(json.dumps(_make_entry("cashuOrphanB", "b2")) + "\n", encoding="utf-8")
try:
total = await replay_all_outbox_files()
assert total == 2
assert orphan_a.read_text(encoding="utf-8") == ""
assert orphan_b.read_text(encoding="utf-8") == ""
async with create_session() as session:
rows = (await session.exec(select(CashuTransaction))).all()
tokens = {r.token for r in rows}
assert tokens == {"cashuOrphanA", "cashuOrphanB"}
finally:
await engine.dispose()
# --- tiny async helpers (can't use `await` inside lambdas) ---
async def _asyncio_sleep_zero() -> None:
"""No-op async sleep replacement for fast tests."""
return None
async def _afail() -> bool:
return False
+186 -1
View File
@@ -5,7 +5,7 @@ edge cases, and billing accuracy.
"""
import os
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
@@ -404,6 +404,191 @@ async def test_deepseek_malformed_hit_tokens_coerce_to_zero() -> None:
assert result.cache_read_input_tokens == 0
# ============================================================================
# Truly-empty response with a non-zero USD cost → full refund
#
# When an upstream reports a USD cost but the response carries NO tokens at all
# (input, output, cache-read and cache-creation all zero), billing the
# USD-derived cost charges the user for nothing. Refund in full. The gate is
# tightened relative to PR #489: a cache-read/-creation-only turn legitimately
# reports zero prompt/completion tokens with a real cost and must still bill.
# ============================================================================
@pytest.mark.asyncio
async def test_truly_empty_usd_cost_response_is_refunded(
mock_fixed_pricing: None,
) -> None:
"""0 input + 0 output + 0 cache tokens with a non-zero USD cost → refund."""
response = {
"model": "gpt-4",
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_cost": 0.01, # non-zero USD cost despite no tokens
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.total_msats == 0 # full refund
assert result.input_msats == 0
assert result.output_msats == 0
assert result.total_usd == 0.0
assert result.input_tokens == 0
assert result.output_tokens == 0
assert result.cache_read_input_tokens == 0
assert result.cache_creation_input_tokens == 0
@pytest.mark.asyncio
async def test_cache_read_only_usd_cost_response_is_billed(
mock_fixed_pricing: None,
) -> None:
"""Cache-read-only turn (0 prompt/completion, non-zero cost) still bills."""
response = {
"model": "claude-3-5-sonnet",
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"cache_read_input_tokens": 1000, # real cached usage
"cache_creation_input_tokens": 0,
"total_cost": 0.01, # non-zero USD cost
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
# NOT refunded — the USD cost is billed in full. Pinning the exact value
# guards against any future regression that would over-refund a cache-only
# turn (the bug in PR #489, which refunded whenever prompt+completion == 0).
assert result.total_msats == 200000
assert result.total_usd == 0.01
assert result.cache_read_input_tokens == 1000
@pytest.mark.asyncio
@pytest.mark.parametrize(
("total_cost", "input_cost", "output_cost", "expected_msats"),
[
(0.000471, 0.00023451, 0.00023649, 9420),
(0.00000004, 0.00000002, 0.00000002, 1),
],
)
async def test_small_usd_cost_components_sum_to_rounded_total(
total_cost: float,
input_cost: float,
output_cost: float,
expected_msats: int,
) -> None:
"""Small USD component costs must retain every billed millisatoshi."""
response = {
"model": "gpt-4",
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"cost_details": {
"total_cost": total_cost,
"input_cost": input_cost,
"output_cost": output_cost,
},
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.total_msats == expected_msats
assert result.input_msats + result.output_msats == result.total_msats
@pytest.mark.asyncio
async def test_zero_msat_component_with_positive_tokens_logs_error() -> None:
"""A rounded-to-zero component is observable even when billing is valid."""
response = {
"model": "gpt-4",
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"cost_details": {
"total_cost": 0.00000004,
"input_cost": 0.00000002,
"output_cost": 0.00000002,
},
},
}
with patch("routstr.payment.cost_calculation.logger.error") as log_error:
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.total_msats == 1
log_error.assert_called_once()
assert log_error.call_args.args[0] == (
"Positive token usage produced a zero millisatoshi cost component"
)
assert log_error.call_args.kwargs["extra"]["zero_components"] == ["input"]
@pytest.mark.asyncio
async def test_total_only_usd_cost_uses_model_prices_for_component_split(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A reported total is split by priced tokens, not raw token counts."""
monkeypatch.setattr(settings, "fixed_pricing", False)
response = {
"model": "z-ai/glm-5.2-20260616",
"usage": {
"prompt_tokens": 375,
"completion_tokens": 218,
"total_cost": 0.00039088,
},
}
pricing = Mock(
prompt=0.0001,
completion=0.001,
input_cache_read=0.0001,
input_cache_write=0.0001,
)
model = Mock(sats_pricing=pricing)
with patch("routstr.proxy.get_model_instance", return_value=model):
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.total_msats == 7818
assert result.input_msats + result.output_msats == result.total_msats
assert result.input_msats == 1147
assert result.output_msats == 6671
@pytest.mark.asyncio
async def test_upstream_inference_cost_details_set_nonzero_components() -> None:
"""OpenAI-compatible upstream inference aliases retain their exact split."""
response = {
"model": "z-ai/glm-5.2-20260616",
"usage": {
"prompt_tokens": 211,
"completion_tokens": 500,
"total_tokens": 711,
"cost": 0.00242155,
"cost_details": {
"upstream_inference_cost": 0.00242155,
"upstream_inference_prompt_cost": 0.00022155,
"upstream_inference_completions_cost": 0.0022,
},
},
}
result = await calculate_cost(response, max_cost=100000)
assert isinstance(result, CostData)
assert result.input_tokens == 211
assert result.output_tokens == 500
assert result.input_msats == 4431
assert result.output_msats == 44000
assert result.total_msats == 48431
assert result.input_msats + result.output_msats == result.total_msats
# ============================================================================
# Test 13: Missing Usage Block
# ============================================================================
+74
View File
@@ -0,0 +1,74 @@
"""raw_send_to_lnurl() must not hang forever on an unresponsive mint.
The Cashu library issues POST /v1/melt/bolt11 with timeout=None, so a hung
mint would block the melt (and the payout loop) indefinitely. raw_send_to_lnurl
now wraps wallet.melt() in asyncio.wait_for(MELT_TIMEOUT_SECONDS) and surfaces a
timeout as LNURLError instead of hanging.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from routstr.payment import lnurl
from routstr.payment.lnurl import LNURLError, raw_send_to_lnurl
@pytest.mark.asyncio
async def test_raw_send_to_lnurl_times_out_on_hung_melt() -> None:
proofs = [MagicMock(amount=1000)]
wallet = MagicMock()
wallet.melt_quote = AsyncMock(return_value=MagicMock(fee_reserve=1, quote="q"))
wallet.select_to_send = AsyncMock(return_value=(proofs, None))
async def _hang(**kwargs: object) -> None:
await asyncio.sleep(5) # far longer than the patched timeout
wallet.melt = AsyncMock(side_effect=_hang)
lnurl_data = {
"callback_url": "https://ln.tld/cb",
"min_sendable": 1_000,
"max_sendable": 100_000_000,
}
with patch.object(lnurl, "MELT_TIMEOUT_SECONDS", 0.05), patch(
"routstr.payment.lnurl.get_lnurl_data", AsyncMock(return_value=lnurl_data)
), patch(
"routstr.payment.lnurl.get_lnurl_invoice",
AsyncMock(return_value=("lnbc1...", {})),
):
with pytest.raises(LNURLError, match="Melt timed out"):
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
@pytest.mark.asyncio
async def test_raw_send_to_lnurl_succeeds_within_timeout() -> None:
"""A prompt melt still returns the net amount, unaffected by the guard."""
proofs = [MagicMock(amount=1000)]
wallet = MagicMock()
wallet.melt_quote = AsyncMock(return_value=MagicMock(fee_reserve=1, quote="q"))
wallet.select_to_send = AsyncMock(return_value=(proofs, None))
wallet.melt = AsyncMock(return_value=MagicMock())
lnurl_data = {
"callback_url": "https://ln.tld/cb",
"min_sendable": 1_000,
"max_sendable": 100_000_000,
}
with patch.object(lnurl, "MELT_TIMEOUT_SECONDS", 5), patch(
"routstr.payment.lnurl.get_lnurl_data", AsyncMock(return_value=lnurl_data)
), patch(
"routstr.payment.lnurl.get_lnurl_invoice",
AsyncMock(return_value=("lnbc1...", {})),
):
paid = await raw_send_to_lnurl(
wallet, proofs, "owner@ln.tld", "sat", amount=1000
)
assert paid > 0
wallet.melt.assert_awaited_once()
@@ -161,6 +161,31 @@ def test_cost_details_extracted_from_event_root() -> None:
assert result.output_cost == 0.005
@pytest.mark.unit
def test_upstream_inference_cost_details_extracted_from_usage() -> None:
"""Provider inference aliases inside usage preserve the USD split."""
event = {
"usage": {
"input_tokens": 211,
"output_tokens": 500,
"cost": 0.00242155,
"cost_details": {
"upstream_inference_cost": 0.00242155,
"upstream_inference_prompt_cost": 0.00022155,
"upstream_inference_completions_cost": 0.0022,
},
}
}
result = annotate_event(event, None)
assert result.input_tokens == 211
assert result.output_tokens == 500
assert result.total_cost == 0.00242155
assert result.input_cost == 0.00022155
assert result.output_cost == 0.0022
# ============================================================================
# Test 7: No Duplicated Dict Lookups
# ============================================================================
+2 -2
View File
@@ -1481,7 +1481,7 @@ async def test_x_cashu_transport_error_after_redemption_is_not_retryable(
"routstr.upstream.base.recieve_token",
new=AsyncMock(return_value=(5_000, "sat", "https://mint")),
),
patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()),
patch("routstr.upstream.base.store_cashu_transaction_with_retry", new=AsyncMock(return_value=True)),
patch.object(
provider,
forward_attr,
@@ -1601,7 +1601,7 @@ async def test_x_cashu_zero_value_rejected_not_forwarded(
"routstr.upstream.base.recieve_token",
new=AsyncMock(return_value=(amount, "sat", "https://mint")),
),
patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()),
patch("routstr.upstream.base.store_cashu_transaction_with_retry", new=AsyncMock(return_value=True)),
patch.object(
provider,
forward_attr,
+154
View File
@@ -0,0 +1,154 @@
"""Tests for periodic_payout() resilience fixes.
Covers two regressions from the auto-payout / primary-mint audit
(docs/auto-payout-primary-mint-failure-report.md):
1. periodic_payout() must include settings.primary_mint even when it is not
listed in settings.cashu_mints, matching fetch_all_balances(); otherwise
primary-mint funds never auto-payout.
2. A failure on one mint/unit must not abort payout for the remaining
mint/units in the same cycle (the try/except is now per mint/unit).
"""
from collections.abc import Callable, Coroutine
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from routstr.wallet import periodic_payout
# Sentinel interval used to break the otherwise-infinite payout loop after
# exactly one full cycle.
_INTERVAL = 987
class _LoopBreak(Exception):
"""Raised via the patched sleep to stop periodic_payout after one cycle."""
@asynccontextmanager
async def _fake_session(): # type: ignore[no-untyped-def]
yield MagicMock()
def _one_cycle_sleep() -> Callable[[float], Coroutine[Any, Any, None]]:
"""Return an async sleep stub that lets exactly one payout cycle run.
The top-of-loop sleep uses the sentinel interval; the second time it is
seen (start of the second cycle) we raise to break out. The inner
``asyncio.sleep(5)`` pass-through is ignored.
"""
seen = {"interval": 0}
async def _sleep(seconds: float) -> None:
if seconds == _INTERVAL:
seen["interval"] += 1
if seen["interval"] >= 2:
raise _LoopBreak()
return _sleep
@pytest.mark.asyncio
async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> None:
"""primary_mint absent from cashu_mints is still paid out."""
from routstr.core.settings import settings
get_wallet = AsyncMock(return_value=MagicMock())
raw_send = AsyncMock(return_value=1000)
with patch.object(settings, "cashu_mints", []), patch.object(
settings, "primary_mint", "http://primary:3338"
), patch.object(settings, "receive_ln_address", "owner@ln.tld"), patch.object(
settings, "payout_interval_seconds", _INTERVAL
), patch.object(settings, "min_payout_sat", 10), patch(
"routstr.wallet.asyncio.sleep", _one_cycle_sleep()
), patch("routstr.wallet.db.create_session", _fake_session), patch(
"routstr.wallet.get_wallet", get_wallet
), patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
MagicMock(return_value=[MagicMock(amount=100_000)]),
), patch(
"routstr.wallet.slow_filter_spend_proofs",
AsyncMock(side_effect=lambda proofs, wallet: proofs),
), patch(
"routstr.wallet.db.balances_for_mint_and_unit", AsyncMock(return_value=0)
), patch("routstr.wallet.raw_send_to_lnurl", raw_send):
with pytest.raises(_LoopBreak):
await periodic_payout()
processed = {call.args[0] for call in get_wallet.await_args_list}
assert processed == {"http://primary:3338"}
assert raw_send.await_count >= 1
@pytest.mark.asyncio
async def test_periodic_payout_isolates_failing_mint() -> None:
"""A failing mint does not prevent payout for the other mints."""
from routstr.core.settings import settings
async def _get_wallet(mint_url: str, unit: str) -> MagicMock:
if mint_url == "http://bad:3338":
raise RuntimeError("mint unreachable")
return MagicMock()
get_wallet = AsyncMock(side_effect=_get_wallet)
raw_send = AsyncMock(return_value=1000)
with patch.object(
settings, "cashu_mints", ["http://bad:3338", "http://good:3338"]
), patch.object(settings, "primary_mint", "http://good:3338"), patch.object(
settings, "receive_ln_address", "owner@ln.tld"
), patch.object(settings, "payout_interval_seconds", _INTERVAL), patch.object(
settings, "min_payout_sat", 10
), patch("routstr.wallet.asyncio.sleep", _one_cycle_sleep()), patch(
"routstr.wallet.db.create_session", _fake_session
), patch("routstr.wallet.get_wallet", get_wallet), patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
MagicMock(return_value=[MagicMock(amount=100_000)]),
), patch(
"routstr.wallet.slow_filter_spend_proofs",
AsyncMock(side_effect=lambda proofs, wallet: proofs),
), patch(
"routstr.wallet.db.balances_for_mint_and_unit", AsyncMock(return_value=0)
), patch("routstr.wallet.raw_send_to_lnurl", raw_send):
with pytest.raises(_LoopBreak):
await periodic_payout()
# The bad mint raised on get_wallet for both units, yet the good mint was
# still reached and paid out for both units — failures are isolated.
good_calls = [
c for c in get_wallet.await_args_list if c.args[0] == "http://good:3338"
]
assert len(good_calls) == 2 # sat + msat
assert raw_send.await_count == 2 # good mint paid for both units
@pytest.mark.asyncio
async def test_periodic_payout_handles_session_creation_failure() -> None:
"""A db.create_session failure is logged and the payout loop continues."""
from routstr.core.settings import settings
create_session = MagicMock(side_effect=RuntimeError("db unavailable"))
logger = MagicMock()
with patch.object(settings, "cashu_mints", ["http://mint:3338"]), patch.object(
settings, "primary_mint", "http://mint:3338"
), patch.object(settings, "receive_ln_address", "owner@ln.tld"), patch.object(
settings, "payout_interval_seconds", _INTERVAL
), patch(
"routstr.wallet.asyncio.sleep", _one_cycle_sleep()
), patch(
"routstr.wallet.db.create_session", create_session
), patch("routstr.wallet.logger", logger):
with pytest.raises(_LoopBreak):
await periodic_payout()
create_session.assert_called_once()
logger.error.assert_called_once()
message = logger.error.call_args.args[0]
extra = logger.error.call_args.kwargs["extra"]
assert message == "Error in periodic payout cycle: RuntimeError"
assert extra == {"error": "db unavailable"}
-95
View File
@@ -1,95 +0,0 @@
import json
import os
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
os.environ.setdefault("UPSTREAM_API_KEY", "test")
os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to")
from routstr.upstream import GenericUpstreamProvider
from routstr.upstream.openrouter import OpenRouterUpstreamProvider
def _model(model_id: str = "openai/gpt-4o"): # type: ignore[no-untyped-def]
from routstr.payment.models import Architecture, Model, Pricing
return Model(
id=model_id,
name=model_id,
created=0,
description="",
context_length=128000,
architecture=Architecture(
modality="text->text",
input_modalities=["text"],
output_modalities=["text"],
tokenizer="GPT",
instruct_type=None,
),
pricing=Pricing(prompt=0.0, completion=0.0),
)
def _tool_body() -> dict:
return {
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [
{
"type": "function",
"function": {"name": "get_weather", "parameters": {}},
}
],
}
def _prepare(provider, body: dict) -> dict: # type: ignore[no-untyped-def]
out = provider.prepare_request_body(json.dumps(body).encode(), _model())
assert out is not None
return json.loads(out)
def test_injects_require_parameters_for_tool_request() -> None:
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), _tool_body())
assert data["provider"]["require_parameters"] is True
def test_generic_provider_on_openrouter_url_is_left_alone() -> None:
# Only OpenRouterUpstreamProvider injects; a generic provider pointed at the
# same base URL doesn't.
provider = GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1")
data = _prepare(provider, _tool_body())
assert "provider" not in data
def test_no_injection_without_tools() -> None:
body = {"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "hi"}]}
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
assert "provider" not in data
def test_empty_tools_list_does_not_inject() -> None:
body = _tool_body()
body["tools"] = []
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
assert "provider" not in data
def test_direct_provider_does_not_inject() -> None:
provider = GenericUpstreamProvider(base_url="https://api.openai.com/v1")
data = _prepare(provider, _tool_body())
assert "provider" not in data
def test_keeps_client_set_require_parameters() -> None:
body = _tool_body()
body["provider"] = {"require_parameters": False}
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
assert data["provider"]["require_parameters"] is False
def test_preserves_other_provider_fields() -> None:
body = _tool_body()
body["provider"] = {"order": ["openai", "azure"]}
data = _prepare(OpenRouterUpstreamProvider(api_key="test"), body)
assert data["provider"]["order"] == ["openai", "azure"]
assert data["provider"]["require_parameters"] is True
+1 -1
View File
@@ -215,7 +215,7 @@ async def test_reset_all_reserved_balances_clears_reserved_at(session: AsyncSess
def _refund_patches(refund_token: str = "cashuArefund"): # type: ignore[no-untyped-def]
return (
patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)),
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
patch("routstr.balance.store_cashu_transaction_with_retry", AsyncMock(return_value=True)),
patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)),
patch("routstr.balance._refund_cache_set", AsyncMock()),
)
+524
View File
@@ -0,0 +1,524 @@
"""Unit tests for ``GenericUpstreamProvider.fetch_models`` price/metadata resolution.
A generic upstream is any OpenAI-compatible API. Most (DeepSeek, OpenAI,
Groq, ...) answer ``/models`` with bare ``{id, object, owned_by}`` entries that
carry *no* pricing. The provider must not fabricate a price for those: it
resolves through native ``model_spec`` (Venice's bespoke schema) → litellm's
bundled cost map → the OpenRouter feed, and only when every source misses does
it import the model **disabled** with a warning rather than invent a number.
These tests drive that behaviour through the public ``fetch_models`` API. The
``/models`` HTTP call is faked at ``httpx.AsyncClient``; the OpenRouter feed is
patched at its source (``routstr.payment.models.async_fetch_openrouter_models``)
so the resolver's lazy import picks up the stub. litellm's real bundled cost map
is used unmocked — the DeepSeek rates it ships are the assertion's ground truth.
"""
from __future__ import annotations
import logging
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from routstr.upstream.generic import GenericUpstreamProvider
class _FakeResponse:
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 _FakeAsyncClient:
"""Stand-in for ``httpx.AsyncClient`` returning a canned ``/models`` body."""
def __init__(self, payload: dict[str, Any]) -> None:
self._payload = payload
async def __aenter__(self) -> "_FakeAsyncClient":
return self
async def __aexit__(self, *exc: object) -> bool:
return False
async def get(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse:
return _FakeResponse(self._payload)
def _patch_models_endpoint(payload: dict[str, Any]) -> Any:
"""Patch the provider's ``httpx.AsyncClient`` to serve ``payload``."""
return patch(
"routstr.upstream.generic.httpx.AsyncClient",
lambda *args, **kwargs: _FakeAsyncClient(payload),
)
def _model_by_id(models: list[Any], model_id: str) -> Any:
return next(m for m in models if m.id == model_id)
# ---------------------------------------------------------------------------
# native model_spec (Venice) — must keep resolving, and capture its metadata
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_native_model_spec_resolves_and_captures_metadata() -> None:
"""A Venice-shaped ``model_spec`` is authoritative: prices/context come
straight from it and vision capability becomes an image input modality."""
payload = {
"data": [
{
"id": "venice-llama",
"owned_by": "venice",
"model_spec": {
"name": "Venice Llama",
"availableContextTokens": 65536,
"pricing": {
"input": {"usd": 0.5},
"output": {"usd": 1.5},
},
"capabilities": {"supportsVision": True},
},
}
]
}
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
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, "venice-llama")
assert model.enabled is True
assert model.pricing.prompt == pytest.approx(0.5 / 1_000_000)
assert model.pricing.completion == pytest.approx(1.5 / 1_000_000)
assert model.context_length == 65536
assert "image" in model.architecture.input_modalities
# Vision capability must be reflected in the combined modality string, not
# flattened to "text->text".
assert model.architecture.modality == "text+image->text"
# A native price never needs the OpenRouter feed.
or_feed.assert_not_awaited()
# ---------------------------------------------------------------------------
# native model_spec validation — a bogus native price is not authoritative
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_native_both_zero_price_falls_through_to_litellm() -> None:
"""A native ``model_spec`` that prices both tokens at 0 is not a real price
(the same free-tier trap the litellm/OpenRouter rungs already reject). It
must not be treated as authoritative and served free; the resolver falls
through, so a litellm-known model lands on litellm's real rate instead."""
payload = {
"data": [
{
"id": "deepseek-chat",
"owned_by": "deepseek",
"model_spec": {
"pricing": {"input": {"usd": 0}, "output": {"usd": 0}},
},
}
]
}
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
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, "deepseek-chat")
assert model.enabled is True
assert model.pricing.prompt == pytest.approx(2.8e-07)
assert model.pricing.completion == pytest.approx(4.2e-07)
@pytest.mark.asyncio
async def test_native_negative_price_falls_through_to_litellm() -> None:
"""A negative native price would credit the caller's balance on every
request (a fund drain, not a discount). Reject it like any other unusable
price and fall through to the chain."""
payload = {
"data": [
{
"id": "deepseek-chat",
"owned_by": "deepseek",
"model_spec": {
"pricing": {"input": {"usd": -0.5}, "output": {"usd": -1.5}},
},
}
]
}
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
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, "deepseek-chat")
assert model.enabled is True
assert model.pricing.prompt == pytest.approx(2.8e-07)
assert model.pricing.completion == pytest.approx(4.2e-07)
@pytest.mark.asyncio
async def test_native_non_numeric_price_does_not_break_catalog() -> None:
"""A non-numeric native price (``"free"``) must not raise while parsing —
an unguarded ``"free" / 1_000_000`` throws and the outer catch drops the
*entire* provider catalog. It has to fail closed for that one model while
every other model in the same response still resolves."""
payload = {
"data": [
{
"id": "broken-price",
"owned_by": "mystery",
"model_spec": {
"pricing": {"input": {"usd": "free"}, "output": {"usd": "free"}},
},
},
{"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
]
}
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
# One malformed entry must not empty the catalog.
assert {m.id for m in models} == {"broken-price", "deepseek-chat"}
broken = _model_by_id(models, "broken-price")
assert broken.enabled is False
healthy = _model_by_id(models, "deepseek-chat")
assert healthy.enabled is True
assert healthy.pricing.prompt == pytest.approx(2.8e-07)
# ---------------------------------------------------------------------------
# litellm rescue — the money-critical case (DeepSeek bare /models)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_bare_deepseek_resolves_via_litellm() -> None:
"""DeepSeek's ``/models`` carries no pricing. The old code fabricated
``$0.001`` + ctx 4096; the resolver must instead pull DeepSeek's real
rates from litellm's bundled cost map (``$0.28``/``$0.42`` per 1M, ctx
131072) and keep the model enabled."""
payload = {
"data": [
{"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"},
]
}
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
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, "deepseek-chat")
assert model.enabled is True
assert model.pricing.prompt == pytest.approx(2.8e-07)
assert model.pricing.completion == pytest.approx(4.2e-07)
assert model.context_length == 131072
# Richer metadata than the two base prices is captured too.
assert model.pricing.input_cache_read == pytest.approx(2.8e-08)
assert model.top_provider is not None
assert model.top_provider.max_completion_tokens == 8192
# litellm answered, so the OpenRouter feed is never consulted.
or_feed.assert_not_awaited()
@pytest.mark.asyncio
async def test_litellm_zero_price_entry_fails_closed(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A litellm entry that lists a model but prices it at 0/0 (free-tier
moderation/rerank models do this) is not a real price — treating it as one
would silently serve the model for free. The resolver must reject a both-zero
litellm hit and fall through, so the model imports disabled, not at $0."""
payload = {
"data": [
{"id": "omni-moderation-latest", "object": "model", "owned_by": "openai"},
]
}
gen_logger = logging.getLogger("routstr.upstream.generic")
gen_logger.addHandler(caplog.handler)
try:
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
with patch(
"routstr.payment.models.async_fetch_openrouter_models", or_feed
):
models = await GenericUpstreamProvider(
base_url="http://x"
).fetch_models()
finally:
gen_logger.removeHandler(caplog.handler)
model = _model_by_id(models, "omni-moderation-latest")
assert model.enabled is False
assert model.pricing.prompt == 0.0
assert model.pricing.completion == 0.0
assert any(
"omni-moderation-latest" in rec.getMessage()
for rec in caplog.records
if rec.levelno >= logging.WARNING
)
@pytest.mark.asyncio
async def test_litellm_output_cap_not_used_as_context() -> None:
"""litellm's ``max_tokens`` is the completion cap, not the context window
(it tracks ``max_output_tokens`` for ~94% of models). When a model reports
no ``max_input_tokens``, the resolver must not smuggle the output cap in as
the context window; it falls back to the id-based estimate instead, while
``max_tokens`` still feeds the completion limit."""
payload = {
"data": [
{
"id": "gemini/gemini-gemma-2-9b-it",
"object": "model",
"owned_by": "google",
},
]
}
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
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, "gemini/gemini-gemma-2-9b-it")
assert model.enabled is True
# litellm gives this model max_input_tokens=None, max_tokens=8192 (an output
# cap). Context must come from the estimate (4096), never the 8192 cap.
assert model.context_length == 4096
# The 8192 output cap still lands where it belongs: the completion limit.
assert model.top_provider is not None
assert model.top_provider.max_completion_tokens == 8192
# ---------------------------------------------------------------------------
# OpenRouter fallback — litellm misses, OR carries a full payload
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unknown_to_litellm_resolves_via_openrouter() -> None:
"""A model litellm has never heard of still resolves if the OpenRouter
feed lists it, pulling price + context from that entry."""
payload = {
"data": [
{"id": "exotic/model-9000", "object": "model", "owned_by": "exotic"},
]
}
or_entry = {
"id": "exotic/model-9000",
"name": "Exotic 9000",
"context_length": 65536,
"architecture": {
"modality": "text+image->text",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"tokenizer": "Other",
"instruct_type": None,
},
"pricing": {"prompt": "0.000005", "completion": "0.000010"},
"top_provider": {
"context_length": 65536,
"max_completion_tokens": 4096,
"is_moderated": False,
},
}
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[or_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, "exotic/model-9000")
assert model.enabled is True
assert model.pricing.prompt == pytest.approx(5e-06)
assert model.pricing.completion == pytest.approx(1e-05)
assert model.context_length == 65536
# The feed's own modality string is carried through verbatim, not recomputed.
assert model.architecture.modality == "text+image->text"
or_feed.assert_awaited()
@pytest.mark.asyncio
async def test_openrouter_bare_tail_collision_picks_highest_price() -> None:
"""When a bare model id matches several OpenRouter entries by tail
(``model`` ↔ ``a/model``, ``b/model``), the match must be deterministic and
money-safe: pick the highest-priced candidate regardless of feed order, so
ordering can never leave the node charging below true cost. (The live feed
has zero such collisions today; this guards the latent case.)"""
payload = {
"data": [
{"id": "zzz-phantom-model", "object": "model", "owned_by": "mystery"},
]
}
# Same bare tail, different resellers; the pricier one is listed *second*
# so a first-wins match would pick the cheaper (undercharging) entry.
or_feed = AsyncMock(
return_value=[
{
"id": "cheapco/zzz-phantom-model",
"context_length": 8192,
"pricing": {"prompt": "0.000001", "completion": "0.000002"},
},
{
"id": "premiumco/zzz-phantom-model",
"context_length": 8192,
"pricing": {"prompt": "0.000009", "completion": "0.000010"},
},
]
)
with _patch_models_endpoint(payload):
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, "zzz-phantom-model")
assert model.pricing.prompt == pytest.approx(9e-06)
assert model.pricing.completion == pytest.approx(1e-05)
@pytest.mark.asyncio
async def test_openrouter_bare_tail_tie_breaks_on_combined_cost() -> None:
"""The bare-tail tie-break must weigh *both* rates, not prompt alone.
Given two colliding entries where one is cheaper on prompt but far dearer
on completion, ranking by prompt would pick the entry that undercharges
output-heavy traffic. Pick the highest *combined* per-token cost so the
money-safe choice holds whichever way the traffic leans."""
payload = {
"data": [
{"id": "yyy-phantom-model", "object": "model", "owned_by": "mystery"},
]
}
# dear-overall is listed first with the *lower* prompt, so a prompt-only max
# would wrongly pick the second (cheaper-overall) entry.
or_feed = AsyncMock(
return_value=[
{
"id": "dearco/yyy-phantom-model",
"context_length": 8192,
"pricing": {"prompt": "0.000001", "completion": "0.000100"},
},
{
"id": "cheapco/yyy-phantom-model",
"context_length": 8192,
"pricing": {"prompt": "0.000009", "completion": "0.000002"},
},
]
)
with _patch_models_endpoint(payload):
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, "yyy-phantom-model")
assert model.pricing.prompt == pytest.approx(1e-06)
assert model.pricing.completion == pytest.approx(1e-04)
@pytest.mark.asyncio
async def test_openrouter_feed_fetched_once_per_discovery() -> None:
"""Two models both missing litellm must share a single OpenRouter fetch —
the feed is not re-downloaded per model."""
payload = {
"data": [
{"id": "exotic/model-a", "object": "model", "owned_by": "exotic"},
{"id": "exotic/model-b", "object": "model", "owned_by": "exotic"},
]
}
or_feed = AsyncMock(
return_value=[
{
"id": "exotic/model-a",
"context_length": 8192,
"pricing": {"prompt": "0.000001", "completion": "0.000002"},
},
{
"id": "exotic/model-b",
"context_length": 8192,
"pricing": {"prompt": "0.000003", "completion": "0.000004"},
},
]
)
with _patch_models_endpoint(payload):
with patch("routstr.payment.models.async_fetch_openrouter_models", or_feed):
models = await GenericUpstreamProvider(base_url="http://x").fetch_models()
assert {m.id for m in models} == {"exotic/model-a", "exotic/model-b"}
assert or_feed.await_count == 1
# ---------------------------------------------------------------------------
# fail closed — no source resolves → disabled + warned, never fabricated
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unresolvable_model_fails_closed(
caplog: pytest.LogCaptureFixture,
) -> None:
"""When native, litellm and OpenRouter all miss, the model is imported
disabled with a warning naming it — and no price is invented (the old
``$0.001`` placeholder is gone)."""
payload = {
"data": [
{
"id": "nobody-has-priced-this-xyz",
"object": "model",
"owned_by": "mystery",
},
]
}
# routstr loggers set propagate=False, so caplog's root handler misses
# them; attach its handler to the provider logger directly.
gen_logger = logging.getLogger("routstr.upstream.generic")
gen_logger.addHandler(caplog.handler)
try:
with _patch_models_endpoint(payload):
or_feed = AsyncMock(return_value=[])
with patch(
"routstr.payment.models.async_fetch_openrouter_models", or_feed
):
models = await GenericUpstreamProvider(
base_url="http://x"
).fetch_models()
finally:
gen_logger.removeHandler(caplog.handler)
model = _model_by_id(models, "nobody-has-priced-this-xyz")
assert model.enabled is False
assert model.pricing.prompt == 0.0
assert model.pricing.completion == 0.0
assert any(
"nobody-has-priced-this-xyz" in rec.getMessage()
for rec in caplog.records
if rec.levelno >= logging.WARNING
)
+454 -23
View File
@@ -1,6 +1,8 @@
import asyncio
import base64
import json
import socket
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
import httpx
@@ -19,6 +21,26 @@ from routstr.wallet import (
)
@pytest.fixture(autouse=True)
def isolate_wallet_runtime_state() -> Generator[None, None, None]:
"""Keep production limiter/wallet caches from leaking across unit tests."""
from routstr import wallet as wallet_module
from routstr.core.settings import settings
original_concurrency = settings.mint_max_concurrency
settings.mint_max_concurrency = 0
wallet_module._MintRateGuard._guards.clear()
wallet_module._wallets.clear()
wallet_module._wallet_last_load.clear()
wallet_module._wallet_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_load_locks.clear()
@pytest.mark.asyncio
async def test_get_balance() -> None:
mock_wallet = Mock()
@@ -728,9 +750,7 @@ async def test_calculate_swap_amount_same_mint_short_circuit() -> None:
quotes are requested."""
from routstr.wallet import _calculate_swap_amount
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
1000, fee_reserves=[]
)
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(1000, fee_reserves=[])
from routstr.core.settings import settings
@@ -755,9 +775,7 @@ async def test_calculate_swap_amount_msat_primary_unit() -> None:
"""With an msat primary mint the dummy quote and result stay in msats."""
from routstr.wallet import _calculate_swap_amount
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
179, fee_reserves=[2]
)
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(179, fee_reserves=[2])
from routstr.core.settings import settings
@@ -805,12 +823,8 @@ async def test_calculate_swap_amount_wraps_estimation_failure() -> None:
"""Estimation infrastructure failures surface as a single clear ValueError."""
from routstr.wallet import _calculate_swap_amount
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
179, fee_reserves=[]
)
mock_primary_wallet.request_mint = AsyncMock(
side_effect=Exception("mint offline")
)
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(179, fee_reserves=[])
mock_primary_wallet.request_mint = AsyncMock(side_effect=Exception("mint offline"))
from routstr.core.settings import settings
@@ -1062,9 +1076,9 @@ async def test_credit_balance_msat_unit_not_converted() -> None:
@pytest.mark.asyncio
async def test_credit_balance_survives_audit_store_failure() -> None:
"""A failure writing the CashuTransaction history record must not undo the
already-committed balance credit. (The silent swallow is a known
audit-trail gap slated for its own fix — this test pins the financial
invariant that the user keeps their credit, not the swallow itself.)"""
already-committed balance credit. (Persistence is spooled to the outbox on
failure; this test pins the financial invariant that the user keeps their
credit, not the spool behavior itself.)"""
mock_key = Mock()
mock_key.balance = 0
mock_key.hashed_key = "test_hash"
@@ -1078,8 +1092,8 @@ async def test_credit_balance_survives_audit_store_failure() -> None:
return_value=(1000, "sat", "http://mint:3338"),
):
with patch(
"routstr.wallet.store_cashu_transaction",
side_effect=Exception("history table locked"),
"routstr.wallet.store_cashu_transaction_with_retry",
AsyncMock(return_value=False),
):
amount = await credit_balance("cashuAtest", mock_key, mock_session)
@@ -1158,7 +1172,9 @@ def test_is_mint_connection_error_detects_transport_failures(
ValueError("Invalid Cashu token"),
# Mint answered with an error status — reachable, so NOT a connection error.
httpx.HTTPStatusError(
"500", request=httpx.Request("POST", "http://m"), response=httpx.Response(500)
"500",
request=httpx.Request("POST", "http://m"),
response=httpx.Response(500),
),
RuntimeError("some internal fault"),
],
@@ -1213,7 +1229,9 @@ def test_classify_zero_value(error: ValueError) -> None:
def test_classify_generic_valueerror_is_not_zero_value() -> None:
"""A generic wallet ValueError still falls to the generic bucket — the
zero-value match must not over-trigger."""
classified = classify_redemption_error(ValueError("some unexpected wallet condition"))
classified = classify_redemption_error(
ValueError("some unexpected wallet condition")
)
assert classified is not None
type_, status, _msg, code = classified
assert (type_, status, code) == (
@@ -1276,7 +1294,9 @@ async def test_credit_balance_db_transport_error_is_token_consumed() -> None:
@pytest.mark.asyncio
async def test_swap_fee_estimation_transport_error_raises_mint_connection_error() -> None:
async def test_swap_fee_estimation_transport_error_raises_mint_connection_error() -> (
None
):
"""A transport failure while estimating fees is surfaced as
MintConnectionError (→ 503), not a generic fee ValueError (→ 422)."""
from routstr.wallet import swap_to_primary_mint
@@ -1308,9 +1328,7 @@ async def test_swap_melt_transport_error_raises_mint_connection_error() -> None:
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
1000, fee_reserves=[10, 10]
)
mock_token_wallet.melt = AsyncMock(
side_effect=httpx.ConnectTimeout("timed out")
)
mock_token_wallet.melt = AsyncMock(side_effect=httpx.ConnectTimeout("timed out"))
from routstr.core.settings import settings
@@ -1321,3 +1339,416 @@ async def test_swap_melt_transport_error_raises_mint_connection_error() -> None:
await swap_to_primary_mint(mock_token, mock_token_wallet)
assert mock_token_wallet.melt.call_count == 1
# ---------------------------------------------------------------------------
# Per-mint adaptive guard + _mint_operation factory/retry
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_balance_proof_check_uses_large_batches_to_avoid_rate_limit() -> None:
"""Balance reads must not turn a few hundred proofs into many mint requests."""
from routstr.wallet import slow_filter_spend_proofs
proofs = [Mock() for _ in range(250)]
states = [Mock(state="UNSPENT") for _ in proofs]
wallet = Mock()
wallet.url = "http://mint:3338"
wallet.check_proof_state = AsyncMock(return_value=Mock(states=states))
wallet.set_reserved_for_send = AsyncMock()
result = await slow_filter_spend_proofs(proofs, wallet)
assert result == proofs
wallet.check_proof_state.assert_awaited_once_with(proofs)
wallet.set_reserved_for_send.assert_not_awaited()
@pytest.mark.asyncio
async def test_mint_rate_guard_bounds_concurrency() -> None:
from routstr.wallet import _MintRateGuard
guard = _MintRateGuard("http://mint:3338", 2)
active = 0
peak = 0
async def operation() -> None:
nonlocal active, peak
active += 1
peak = max(peak, active)
await asyncio.sleep(0)
active -= 1
await asyncio.gather(*(guard.run(operation) for _ in range(5)))
assert peak == 2
@pytest.mark.asyncio
async def test_mint_rate_guard_waits_for_adaptive_cooldown() -> None:
from routstr.wallet import _MintRateGuard
guard = _MintRateGuard("http://mint:3338", 2)
guard._cooldown_until = 15.0
operation = AsyncMock(return_value="ok")
with patch("routstr.wallet.time.monotonic", return_value=10.0):
with patch("routstr.wallet.asyncio.sleep", AsyncMock()) as sleep:
assert await guard.run(operation) == "ok"
sleep.assert_awaited_once_with(5.0)
operation.assert_awaited_once()
def test_mint_rate_guard_rebuilds_when_setting_changes() -> None:
from routstr.core.settings import settings
from routstr.wallet import _MintRateGuard
with patch.object(settings, "mint_max_concurrency", 4):
first = _MintRateGuard.get("http://mint:3338")
with patch.object(settings, "mint_max_concurrency", 2):
second = _MintRateGuard.get("http://mint:3338")
assert first is not None
assert second is not None
assert first is not second
assert second._max_concurrency == 2
@pytest.mark.asyncio
async def test_mint_operation_honors_retry_after_as_minimum() -> None:
from routstr.core.settings import settings
from routstr.wallet import _mint_operation
request = httpx.Request("POST", "http://mint:3338/v1/mint/quote/bolt11")
response = httpx.Response(429, request=request, headers={"Retry-After": "60"})
calls = 0
async def factory() -> str:
nonlocal calls
calls += 1
if calls == 1:
raise httpx.HTTPStatusError(
"rate limited", request=request, response=response
)
return "ok"
sleep = AsyncMock()
with patch.object(settings, "mint_retry_max_attempts", 1):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch.object(settings, "mint_max_concurrency", 1):
with patch("routstr.wallet.time.monotonic", return_value=0.1):
with patch("routstr.wallet.asyncio.sleep", sleep):
result = await _mint_operation(
factory, mint_url="http://mint:3338"
)
assert result == "ok"
sleep.assert_awaited_once_with(60.0)
@pytest.mark.asyncio
async def test_mint_operation_timeout_includes_adaptive_cooldown() -> None:
from routstr.core.settings import settings
from routstr.wallet import _mint_operation, _MintRateGuard
operation = AsyncMock(return_value="unexpected")
with patch.object(settings, "mint_max_concurrency", 1):
guard = _MintRateGuard.get("http://mint:3338")
assert guard is not None
guard.apply_cooldown(60)
with patch.object(settings, "mint_operation_timeout_seconds", 0.01):
with pytest.raises(httpx.TimeoutException, match="total timeout"):
await _mint_operation(operation, mint_url="http://mint:3338")
operation.assert_not_awaited()
@pytest.mark.asyncio
async def test_mint_operation_retries_httpx_timeout_only_when_safe() -> None:
from routstr.core.settings import settings
from routstr.wallet import _mint_operation
retrying = AsyncMock(side_effect=[httpx.ReadTimeout("slow"), "ok"])
non_retrying = AsyncMock(side_effect=httpx.ReadTimeout("ambiguous"))
with patch.object(settings, "mint_retry_max_attempts", 2):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch("routstr.wallet.asyncio.sleep", AsyncMock()):
assert await _mint_operation(retrying) == "ok"
with pytest.raises(httpx.TimeoutException):
await _mint_operation(non_retrying, retry_timeouts=False)
assert retrying.await_count == 2
assert non_retrying.await_count == 1
@pytest.mark.asyncio
async def test_get_wallet_initializes_and_loads_once_concurrently() -> None:
from routstr.wallet import get_wallet
mock_wallet = Mock()
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
with patch(
"routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)
) as create:
with patch("routstr.wallet.time.monotonic", return_value=100.0):
first, second = await asyncio.gather(
get_wallet("http://mint:3338"), get_wallet("http://mint:3338")
)
assert first is second is mock_wallet
create.assert_awaited_once()
mock_wallet.load_mint.assert_awaited_once()
mock_wallet.load_proofs.assert_awaited_once_with(reload=True)
@pytest.mark.asyncio
async def test_mint_operation_factory_retry_succeeds() -> None:
"""_mint_operation accepts a zero-arg factory, not a dead coroutine.
A factory that raises twice then succeeds must be retried and return."""
from routstr.core.settings import settings
from routstr.wallet import _mint_operation
calls = 0
async def factory() -> str:
nonlocal calls
calls += 1
if calls < 3:
raise TimeoutError("timeout")
return "ok"
with patch.object(settings, "mint_retry_max_attempts", 3):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch.object(settings, "mint_max_concurrency", 0):
with patch("asyncio.sleep", AsyncMock()):
result = await _mint_operation(
factory, op_name="test_retry", mint_url="http://mint:3338"
)
assert calls == 3
assert result == "ok"
@pytest.mark.asyncio
async def test_mint_operation_factory_retry_exhausted() -> None:
"""When the factory always times out, _mint_operation raises
httpx.TimeoutException after mint_retry_max_attempts + 1 attempts."""
from routstr.core.settings import settings
from routstr.wallet import _mint_operation
calls = 0
async def factory() -> None:
nonlocal calls
calls += 1
raise TimeoutError("always timeout")
with patch.object(settings, "mint_retry_max_attempts", 2):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch.object(settings, "mint_max_concurrency", 0):
with patch("asyncio.sleep", AsyncMock()):
with pytest.raises(httpx.TimeoutException):
await _mint_operation(
factory, op_name="test_exhaust", mint_url="http://mint:3338"
)
assert calls == 3 # max_attempts(2) + 1
# ---------------------------------------------------------------------------
# Trusted-mint fallback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_lightning_mint_fallback_for_topups() -> None:
"""When the primary mint is unreachable, _request_mint_with_fallback
falls back to a secondary trusted mint."""
from routstr.core.settings import settings
from routstr.lightning import _request_mint_with_fallback
primary = "http://primary:3338"
secondary = "http://secondary:3338"
mock_primary_wallet = Mock()
mock_primary_wallet.request_mint = AsyncMock(
side_effect=httpx.ConnectError("primary down")
)
mock_quote = Mock()
mock_quote.request = "lnbc1secondary"
mock_quote.quote = "quote_secondary"
mock_secondary_wallet = Mock()
mock_secondary_wallet.request_mint = AsyncMock(return_value=mock_quote)
wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet}
mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m])
with patch.object(settings, "primary_mint", primary):
with patch.object(settings, "cashu_mints", [primary, secondary]):
with patch.object(settings, "mint_max_concurrency", 0):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch("routstr.lightning.get_wallet", side_effect=mock_get):
bolt11, quote_id, mint_url = await _request_mint_with_fallback(
1000
)
assert mint_url == secondary
assert bolt11 == "lnbc1secondary"
assert quote_id == "quote_secondary"
mock_primary_wallet.request_mint.assert_called_once()
mock_secondary_wallet.request_mint.assert_called_once()
@pytest.mark.asyncio
async def test_swap_falls_back_to_secondary_mint() -> None:
"""When the primary mint is unreachable, swap_to_primary_mint falls back
to a secondary trusted mint as the swap destination."""
from routstr.core.settings import settings
from routstr.wallet import _wallet_last_load, _wallets, swap_to_primary_mint
_wallets.clear()
_wallet_last_load.clear()
primary = "http://primary:3338"
secondary = "http://secondary:3338"
foreign = "http://foreign:3338"
mock_token = Mock()
mock_token.mint = foreign
mock_token.unit = "sat"
mock_token.amount = 1000
mock_token.keysets = ["keyset1"]
mock_token.proofs = [Mock(amount=1000)]
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
mock_token_wallet.melt_quote = AsyncMock(
return_value=Mock(quote="melt_q", amount=990, fee_reserve=10)
)
mock_token_wallet.melt = AsyncMock(return_value=Mock())
mock_primary_wallet = Mock()
mock_primary_wallet.request_mint = AsyncMock(
side_effect=httpx.ConnectError("primary down")
)
mint_quote = Mock(quote="mint_q_secondary", request="lnbc1secondary")
mock_secondary_wallet = Mock()
mock_secondary_wallet.load_mint = AsyncMock()
mock_secondary_wallet.load_proofs = AsyncMock()
mock_secondary_wallet.available_balance = Mock(amount=0)
mock_secondary_wallet.keysets = ["ks_secondary"]
mock_secondary_wallet.restore_tokens_for_keyset = AsyncMock()
mock_secondary_wallet.request_mint = AsyncMock(return_value=mint_quote)
mock_secondary_wallet.mint = AsyncMock(return_value=Mock())
wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet}
mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m])
with patch.object(settings, "primary_mint", primary):
with patch.object(settings, "primary_mint_unit", "sat"):
with patch.object(settings, "cashu_mints", [primary, secondary]):
with patch.object(settings, "mint_max_concurrency", 0):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch("asyncio.sleep", AsyncMock()):
with patch(
"routstr.wallet.get_wallet", side_effect=mock_get
):
amount, unit, mint_url = await swap_to_primary_mint(
mock_token, mock_token_wallet
)
assert mint_url == secondary
assert amount == 990 # 1000 - 10 fee_reserve
assert unit == "sat"
mock_secondary_wallet.mint.assert_called_once()
mock_primary_wallet.mint.assert_not_called()
@pytest.mark.asyncio
async def test_lightning_mint_fallback_on_429() -> None:
"""A 429 from the primary mint should trigger fallback to a secondary,
not just transport errors."""
from routstr.core.settings import settings
from routstr.lightning import _request_mint_with_fallback
primary = "http://primary:3338"
secondary = "http://secondary:3338"
mock_resp = Mock(status_code=429, headers={})
mock_resp.raise_for_status = Mock(
side_effect=httpx.HTTPStatusError(
"rate limited", request=Mock(), response=mock_resp
)
)
mock_primary_wallet = Mock()
mock_primary_wallet.request_mint = AsyncMock(
side_effect=httpx.HTTPStatusError(
"rate limited", request=Mock(), response=mock_resp
)
)
mock_quote = Mock(request="lnbc1secondary", quote="quote_secondary")
mock_secondary_wallet = Mock()
mock_secondary_wallet.request_mint = AsyncMock(return_value=mock_quote)
wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet}
mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m])
with patch.object(settings, "primary_mint", primary):
with patch.object(settings, "cashu_mints", [primary, secondary]):
with patch.object(settings, "mint_retry_max_attempts", 0):
with patch.object(settings, "mint_max_concurrency", 0):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch(
"routstr.lightning.get_wallet", side_effect=mock_get
):
(
bolt11,
quote_id,
mint_url,
) = await _request_mint_with_fallback(1000)
assert mint_url == secondary
mock_secondary_wallet.request_mint.assert_called_once()
@pytest.mark.asyncio
async def test_lightning_mint_fallback_all_fail() -> None:
"""When every trusted mint fails, _request_mint_with_fallback raises
MintConnectionError instead of trying indefinitely."""
from routstr.core.settings import settings
from routstr.lightning import _request_mint_with_fallback
from routstr.wallet import MintConnectionError
primary = "http://primary:3338"
secondary = "http://secondary:3338"
mock_primary_wallet = Mock()
mock_primary_wallet.request_mint = AsyncMock(side_effect=httpx.ConnectError("down"))
mock_secondary_wallet = Mock()
mock_secondary_wallet.request_mint = AsyncMock(
side_effect=httpx.ConnectError("down")
)
wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet}
mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m])
with patch.object(settings, "primary_mint", primary):
with patch.object(settings, "cashu_mints", [primary, secondary]):
with patch.object(settings, "mint_retry_max_attempts", 0):
with patch.object(settings, "mint_max_concurrency", 0):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch(
"routstr.lightning.get_wallet", side_effect=mock_get
):
with pytest.raises(MintConnectionError):
await _request_mint_with_fallback(1000)
+3 -3
View File
@@ -4,15 +4,15 @@ FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
COPY package.json pnpm-lock.yaml* ./
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
+3 -3
View File
@@ -6,14 +6,14 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
RUN pnpm install --frozen-lockfile
# Build the UI
FROM base AS builder
WORKDIR /app
RUN corepack enable pnpm && corepack prepare pnpm@latest --activate
RUN corepack enable pnpm && corepack prepare pnpm@10.15.0 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -68,6 +68,8 @@ const FormSchema = z.object({
upstream_provider_id: z.string().default(''),
input_cost: z.coerce.number().min(0).default(0),
output_cost: z.coerce.number().min(0).default(0),
cache_read_cost: z.coerce.number().min(0).default(0),
cache_write_cost: z.coerce.number().min(0).default(0),
request_cost: z.coerce.number().min(0).default(0),
image_cost: z.coerce.number().min(0).default(0),
web_search_cost: z.coerce.number().min(0).default(0),
@@ -125,6 +127,8 @@ export function AddProviderModelDialog({
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
cache_read_cost: 0,
cache_write_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
@@ -190,6 +194,8 @@ export function AddProviderModelDialog({
: initialData.upstream_provider_id?.toString() || '',
input_cost: pricing?.prompt ?? 0,
output_cost: pricing?.completion ?? 0,
cache_read_cost: pricing?.input_cache_read ?? 0,
cache_write_cost: pricing?.input_cache_write ?? 0,
request_cost: pricing?.request ?? 0,
image_cost: pricing?.image ?? 0,
web_search_cost: pricing?.web_search ?? 0,
@@ -231,6 +237,8 @@ export function AddProviderModelDialog({
upstream_provider_id: '',
input_cost: 0,
output_cost: 0,
cache_read_cost: 0,
cache_write_cost: 0,
request_cost: 0,
image_cost: 0,
web_search_cost: 0,
@@ -294,6 +302,8 @@ export function AddProviderModelDialog({
);
form.setValue('input_cost', pricing?.prompt ?? 0);
form.setValue('output_cost', pricing?.completion ?? 0);
form.setValue('cache_read_cost', pricing?.input_cache_read ?? 0);
form.setValue('cache_write_cost', pricing?.input_cache_write ?? 0);
form.setValue('request_cost', pricing?.request ?? 0);
form.setValue('image_cost', pricing?.image ?? 0);
form.setValue('web_search_cost', pricing?.web_search ?? 0);
@@ -365,6 +375,8 @@ export function AddProviderModelDialog({
pricing: {
prompt: data.input_cost,
completion: data.output_cost,
input_cache_read: data.cache_read_cost,
input_cache_write: data.cache_write_cost,
request: data.request_cost,
image: data.image_cost,
web_search: data.web_search_cost,
@@ -810,6 +822,38 @@ export function AddProviderModelDialog({
</FormItem>
)}
/>
<FormField
control={form.control}
name='cache_read_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Cache Read Cost</FormLabel>
<FormControl>
<Input type='number' step='0.000001' {...field} />
</FormControl>
<FormDescription>
Discounted cached-input read price per 1M tokens.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='cache_write_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Cache Write Cost</FormLabel>
<FormControl>
<Input type='number' step='0.000001' {...field} />
</FormControl>
<FormDescription>
Cached-input creation price per 1M tokens.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='request_cost'
+8 -2
View File
@@ -53,6 +53,8 @@ export const AdminModelPricingSchema = z.object({
image: z.number().optional(),
web_search: z.number().optional(),
internal_reasoning: z.number().optional(),
input_cache_read: z.number().optional(),
input_cache_write: z.number().optional(),
});
export const AdminModelArchitectureSchema = z.object({
@@ -149,7 +151,7 @@ export class AdminService {
if (!pricing) return pricing;
const result = { ...pricing };
// Only prompt and completion are per-token and need scaling to per-1M
// Token-priced fields are stored per-token by the API and shown per-1M in the UI.
const convertField = (field: string) => {
const val = result[field];
if (val !== undefined && val !== null) {
@@ -164,6 +166,8 @@ export class AdminService {
convertField('prompt');
convertField('completion');
convertField('input_cache_read');
convertField('input_cache_write');
// Other fields (request, image, etc.) are already flat fees (per item)
// so we do NOT scale them.
@@ -177,7 +181,7 @@ export class AdminService {
if (!pricing) return pricing;
const result = { ...pricing };
// Only prompt and completion are per-1M in UI and need scaling down to per-token
// Token-priced fields are per-1M in the UI and need scaling down to per-token.
const convertField = (field: string) => {
const val = result[field];
if (val !== undefined && val !== null) {
@@ -190,6 +194,8 @@ export class AdminService {
convertField('prompt');
convertField('completion');
convertField('input_cache_read');
convertField('input_cache_write');
// Other fields stay as flat fees
+3 -9
View File
@@ -45,7 +45,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.5",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -54,7 +54,7 @@
"geist": "^1.7.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"next": "16.2.6",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
@@ -81,7 +81,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.7.0",
"eslint-config-next": "16.1.6",
"eslint-config-next": "16.2.6",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
@@ -89,11 +89,5 @@
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"unrs-resolver"
]
}
}
+241 -162
View File
@@ -4,6 +4,20 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
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
importers:
.:
@@ -105,8 +119,8 @@ importers:
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
axios:
specifier: ^1.13.5
version: 1.13.6
specifier: ^1.16.0
version: 1.18.1
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -124,7 +138,7 @@ importers:
version: 8.6.0(react@19.2.4)
geist:
specifier: ^1.7.0
version: 1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
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))
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -132,8 +146,8 @@ importers:
specifier: ^0.575.0
version: 0.575.0(react@19.2.4)
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
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)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -208,8 +222,8 @@ importers:
specifier: ^9.7.0
version: 9.38.0(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.6
version: 16.1.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.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)
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@9.38.0(jiti@2.6.1))
@@ -242,16 +256,20 @@ packages:
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'}
'@babel/compat-data@7.29.0':
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
engines: {node: '>=6.9.0'}
'@babel/core@7.29.0':
resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
'@babel/core@7.29.6':
resolution: {integrity: sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.29.1':
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
'@babel/generator@7.29.7':
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.28.6':
@@ -270,22 +288,30 @@ packages:
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@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'}
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
'@babel/helpers@7.28.6':
resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
'@babel/helpers@7.29.7':
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.0':
@@ -293,6 +319,11 @@ packages:
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'}
hasBin: true
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
@@ -301,6 +332,10 @@ packages:
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'}
'@babel/traverse@7.29.0':
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
engines: {node: '>=6.9.0'}
@@ -309,6 +344,10 @@ packages:
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'}
'@base-ui/react@1.2.0':
resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
engines: {node: '>=14.0.0'}
@@ -600,56 +639,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/env@16.1.6':
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
'@next/env@16.2.6':
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
'@next/eslint-plugin-next@16.1.6':
resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==}
'@next/eslint-plugin-next@16.2.6':
resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==}
'@next/swc-darwin-arm64@16.1.6':
resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
'@next/swc-darwin-arm64@16.2.6':
resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.1.6':
resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
'@next/swc-darwin-x64@16.2.6':
resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.1.6':
resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
'@next/swc-linux-arm64-gnu@16.2.6':
resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
'@next/swc-linux-arm64-musl@16.2.6':
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
'@next/swc-linux-x64-gnu@16.2.6':
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
'@next/swc-linux-x64-musl@16.2.6':
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
'@next/swc-win32-arm64-msvc@16.2.6':
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.1.6':
resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==}
'@next/swc-win32-x64-msvc@16.2.6':
resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1810,8 +1849,12 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@@ -1882,8 +1925,8 @@ packages:
resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
engines: {node: '>=4'}
axios@1.13.6:
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
axios@1.18.1:
resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
@@ -1901,11 +1944,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@1.1.13:
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
brace-expansion@5.0.4:
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
@@ -2182,8 +2225,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-next@16.1.6:
resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==}
eslint-config-next@16.2.6:
resolution: {integrity: sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==}
peerDependencies:
eslint: '>=9.0.0'
typescript: '>=3.3.1'
@@ -2348,7 +2391,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
picomatch: 4.0.4
peerDependenciesMeta:
picomatch:
optional: true
@@ -2373,11 +2416,11 @@ packages:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -2389,8 +2432,8 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
function-bind@1.1.2:
@@ -2493,12 +2536,20 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -2659,8 +2710,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
js-yaml@4.2.0:
resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
hasBin: true
jsesc@3.1.0:
@@ -2824,11 +2875,8 @@ packages:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@3.1.4:
resolution: {integrity: sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -2855,8 +2903,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.1.6:
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
next@16.2.6:
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -2957,12 +3005,12 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pngjs@5.0.0:
@@ -2973,12 +3021,8 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
postcss@8.5.10:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -3052,8 +3096,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
@@ -3580,16 +3625,22 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/compat-data@7.29.0': {}
'@babel/core@7.29.0':
'@babel/core@7.29.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/generator': 7.29.7
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helpers': 7.28.6
'@babel/parser': 7.29.0
'@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/traverse': 7.29.0
'@babel/types': 7.29.0
@@ -3602,10 +3653,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/generator@7.29.1':
'@babel/generator@7.29.7':
dependencies:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
@@ -3627,9 +3678,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.6)':
dependencies:
'@babel/core': 7.29.0
'@babel/core': 7.29.6
'@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
'@babel/traverse': 7.29.0
@@ -3638,33 +3689,47 @@ snapshots:
'@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': {}
'@babel/helpers@7.28.6':
'@babel/helpers@7.29.7':
dependencies:
'@babel/template': 7.28.6
'@babel/types': 7.29.0
'@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.0
'@babel/parser': 7.29.7
'@babel/types': 7.29.0
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.0
'@babel/parser': 7.29.7
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
@@ -3676,6 +3741,11 @@ snapshots:
'@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
'@babel/helper-validator-identifier': 7.29.7
'@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
@@ -3761,7 +3831,7 @@ snapshots:
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
minimatch: 3.1.5
minimatch: 3.1.4
transitivePeerDependencies:
- supports-color
@@ -3775,14 +3845,14 @@ snapshots:
'@eslint/eslintrc@3.3.3':
dependencies:
ajv: 6.12.6
ajv: 6.14.0
debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.1
minimatch: 3.1.2
js-yaml: 4.2.0
minimatch: 3.1.4
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -3952,34 +4022,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@next/env@16.1.6': {}
'@next/env@16.2.6': {}
'@next/eslint-plugin-next@16.1.6':
'@next/eslint-plugin-next@16.2.6':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.1.6':
'@next/swc-darwin-arm64@16.2.6':
optional: true
'@next/swc-darwin-x64@16.1.6':
'@next/swc-darwin-x64@16.2.6':
optional: true
'@next/swc-linux-arm64-gnu@16.1.6':
'@next/swc-linux-arm64-gnu@16.2.6':
optional: true
'@next/swc-linux-arm64-musl@16.1.6':
'@next/swc-linux-arm64-musl@16.2.6':
optional: true
'@next/swc-linux-x64-gnu@16.1.6':
'@next/swc-linux-x64-gnu@16.2.6':
optional: true
'@next/swc-linux-x64-musl@16.1.6':
'@next/swc-linux-x64-musl@16.2.6':
optional: true
'@next/swc-win32-arm64-msvc@16.1.6':
'@next/swc-win32-arm64-msvc@16.2.6':
optional: true
'@next/swc-win32-x64-msvc@16.1.6':
'@next/swc-win32-x64-msvc@16.2.6':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -4898,7 +4968,7 @@ snapshots:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.2.1
'@tailwindcss/oxide': 4.2.1
postcss: 8.5.6
postcss: 8.5.10
tailwindcss: 4.2.1
'@tanstack/query-core@5.90.20': {}
@@ -5133,7 +5203,13 @@ snapshots:
acorn@8.15.0: {}
ajv@6.12.6:
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
@@ -5233,13 +5309,15 @@ snapshots:
axe-core@4.11.1: {}
axios@1.13.6:
axios@1.18.1:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
follow-redirects: 1.16.0
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
axobject-query@4.1.0: {}
@@ -5249,12 +5327,12 @@ snapshots:
baseline-browser-mapping@2.10.0: {}
brace-expansion@1.1.12:
brace-expansion@1.1.13:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@5.0.4:
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
@@ -5639,9 +5717,9 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-next@16.1.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.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):
dependencies:
'@next/eslint-plugin-next': 16.1.6
'@next/eslint-plugin-next': 16.2.6
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))
@@ -5712,7 +5790,7 @@ snapshots:
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
minimatch: 3.1.5
minimatch: 3.1.4
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.1
@@ -5740,7 +5818,7 @@ snapshots:
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
minimatch: 3.1.5
minimatch: 3.1.4
object.fromentries: 2.0.8
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
@@ -5756,7 +5834,7 @@ snapshots:
eslint-plugin-react-hooks@7.0.1(eslint@9.38.0(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.0
'@babel/core': 7.29.6
'@babel/parser': 7.29.0
eslint: 9.38.0(jiti@2.6.1)
hermes-parser: 0.25.1
@@ -5777,7 +5855,7 @@ snapshots:
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
minimatch: 3.1.2
minimatch: 3.1.4
object.entries: 1.1.9
object.fromentries: 2.0.8
object.values: 1.2.1
@@ -5812,7 +5890,7 @@ snapshots:
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.12.6
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
@@ -5831,7 +5909,7 @@ snapshots:
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.5
minimatch: 3.1.4
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
@@ -5879,9 +5957,9 @@ snapshots:
dependencies:
reusify: 1.1.0
fdir@6.5.0(picomatch@4.0.3):
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.3
picomatch: 4.0.4
file-entry-cache@8.0.0:
dependencies:
@@ -5903,23 +5981,23 @@ snapshots:
flat-cache@4.0.1:
dependencies:
flatted: 3.3.3
flatted: 3.4.2
keyv: 4.5.4
flatted@3.3.3: {}
flatted@3.4.2: {}
follow-redirects@1.15.11: {}
follow-redirects@1.16.0: {}
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
form-data@4.0.5:
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
hasown: 2.0.4
mime-types: 2.1.35
function-bind@1.1.2: {}
@@ -5935,9 +6013,9 @@ snapshots:
functions-have-names@1.2.3: {}
geist@1.7.0(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
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)):
dependencies:
next: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@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)
generator-function@2.0.1: {}
@@ -6018,12 +6096,23 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
dependencies:
hermes-estree: 0.25.1
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -6183,7 +6272,7 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.1.1:
js-yaml@4.2.0:
dependencies:
argparse: 2.0.1
@@ -6305,7 +6394,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
picomatch: 2.3.2
mime-db@1.52.0: {}
@@ -6315,15 +6404,11 @@ snapshots:
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
brace-expansion: 5.0.6
minimatch@3.1.2:
minimatch@3.1.4:
dependencies:
brace-expansion: 1.1.12
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.12
brace-expansion: 1.1.13
minimist@1.2.8: {}
@@ -6340,25 +6425,25 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@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):
dependencies:
'@next/env': 16.1.6
'@next/env': 16.2.6
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.0
caniuse-lite: 1.0.30001776
postcss: 8.4.31
postcss: 8.5.10
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
styled-jsx: 5.1.6(@babel/core@7.29.6)(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.6
'@next/swc-darwin-x64': 16.1.6
'@next/swc-linux-arm64-gnu': 16.1.6
'@next/swc-linux-arm64-musl': 16.1.6
'@next/swc-linux-x64-gnu': 16.1.6
'@next/swc-linux-x64-musl': 16.1.6
'@next/swc-win32-arm64-msvc': 16.1.6
'@next/swc-win32-x64-msvc': 16.1.6
'@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
transitivePeerDependencies:
- '@babel/core'
@@ -6453,21 +6538,15 @@ snapshots:
picocolors@1.1.1: {}
picomatch@2.3.1: {}
picomatch@2.3.2: {}
picomatch@4.0.3: {}
picomatch@4.0.4: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.6:
postcss@8.5.10:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -6491,7 +6570,7 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
proxy-from-env@1.1.0: {}
proxy-from-env@2.1.0: {}
punycode@2.3.1: {}
@@ -6901,12 +6980,12 @@ snapshots:
strip-json-comments@3.1.1: {}
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
styled-jsx@5.1.6(@babel/core@7.29.6)(react@19.2.4):
dependencies:
client-only: 0.0.1
react: 19.2.4
optionalDependencies:
'@babel/core': 7.29.0
'@babel/core': 7.29.6
supports-color@7.2.0:
dependencies:
@@ -6930,8 +7009,8 @@ snapshots:
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
to-regex-range@5.0.1:
dependencies:
+17
View File
@@ -0,0 +1,17 @@
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
Generated
+1 -1
View File
@@ -2384,7 +2384,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.4.3"
version = "0.4.4"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },