mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 07:46:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9385b01e58 | ||
|
|
c5adfd9c3a |
@@ -38,8 +38,3 @@ proof_backups
|
||||
|
||||
*.todo
|
||||
ui_out
|
||||
|
||||
# local cashu wallet state (never commit)
|
||||
.wallet/
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
|
||||
@@ -98,7 +98,7 @@ docker-down:
|
||||
lint:
|
||||
@echo "🔍 Running linting checks..."
|
||||
$(RUFF) check .
|
||||
$(MYPY) .
|
||||
$(MYPY) routstr/ --ignore-missing-imports
|
||||
|
||||
format:
|
||||
@echo "✨ Formatting code..."
|
||||
@@ -107,7 +107,7 @@ format:
|
||||
|
||||
type-check:
|
||||
@echo "🔎 Running type checks..."
|
||||
$(MYPY) .
|
||||
$(MYPY) routstr/ --ignore-missing-imports
|
||||
|
||||
# Development setup
|
||||
dev-setup:
|
||||
@@ -234,7 +234,7 @@ ci-test:
|
||||
ci-lint:
|
||||
@echo "🤖 Running CI linting..."
|
||||
$(RUFF) check . --exit-non-zero-on-fix
|
||||
$(MYPY) . --no-error-summary
|
||||
$(MYPY) routstr/ --ignore-missing-imports --no-error-summary
|
||||
|
||||
# Debug helpers
|
||||
test-debug:
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""add balance_version to api_keys and refund_tokens table (core #412)
|
||||
|
||||
Durable, balance-versioned refund idempotency:
|
||||
* ``api_keys.balance_version`` is bumped atomically on every credit.
|
||||
* ``refund_tokens`` records the refund issued at each (api_key_hash,
|
||||
balance_version); an in-flight retry at the same version re-serves the
|
||||
stored token, while any later credit (which bumps the version) invalidates
|
||||
it.
|
||||
|
||||
Revision ID: c7f1a2b3d4e5
|
||||
Revises: b5e7c9d1f3a2
|
||||
Create Date: 2026-06-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c7f1a2b3d4e5"
|
||||
down_revision = "b5e7c9d1f3a2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Existing rows default to 0; the first credit moves them to 1. There are no
|
||||
# historical refund tokens to migrate (the old cache was in-memory only).
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column(
|
||||
"balance_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"refund_tokens",
|
||||
sa.Column("api_key_hash", sa.String(), nullable=False),
|
||||
sa.Column("balance_version", sa.Integer(), nullable=False),
|
||||
sa.Column("token", sa.String(), nullable=False),
|
||||
sa.Column("amount", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("unit", sa.String(), nullable=False, server_default="sat"),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("redeemed_at", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["api_key_hash"], ["api_keys.hashed_key"]
|
||||
),
|
||||
sa.PrimaryKeyConstraint("api_key_hash", "balance_version"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("refund_tokens")
|
||||
op.drop_column("api_keys", "balance_version")
|
||||
+2
-8
@@ -709,18 +709,12 @@ async def revert_pay_for_request(
|
||||
|
||||
|
||||
async def adjust_payment_for_tokens(
|
||||
key: ApiKey,
|
||||
response_data: dict,
|
||||
session: AsyncSession,
|
||||
deducted_max_cost: int,
|
||||
key: ApiKey, response_data: dict, session: AsyncSession, deducted_max_cost: int
|
||||
) -> dict:
|
||||
"""
|
||||
Adjusts the payment based on token usage in the response.
|
||||
This is called after the initial payment and the upstream request is complete.
|
||||
Returns cost data to be included in the response.
|
||||
|
||||
The response's usage object is normalized with the default union parser in
|
||||
``calculate_cost``.
|
||||
"""
|
||||
billing_key = await get_billing_key(key, session)
|
||||
model = response_data.get("model", "unknown")
|
||||
@@ -803,7 +797,7 @@ async def adjust_payment_for_tokens(
|
||||
extra={"error": str(e), "fee_msats": fee_msats},
|
||||
)
|
||||
|
||||
match await calculate_cost(response_data, deducted_max_cost):
|
||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost data (no token adjustment)",
|
||||
|
||||
+4
-79
@@ -14,7 +14,6 @@ from .core.db import (
|
||||
ApiKey,
|
||||
AsyncSession,
|
||||
CashuTransaction,
|
||||
RefundToken,
|
||||
get_session,
|
||||
store_cashu_transaction,
|
||||
)
|
||||
@@ -193,29 +192,6 @@ def _cache_key_for_authorization(authorization: str) -> str:
|
||||
return hashlib.sha256(authorization.strip().encode()).hexdigest()
|
||||
|
||||
|
||||
async def _stored_refund_for_version(
|
||||
session: AsyncSession, hashed_key: str, balance_version: int
|
||||
) -> RefundToken | None:
|
||||
"""Return the stored, UNREDEEMED refund token for this key at this exact
|
||||
balance_version, or None. Durable, worker-agnostic replacement for the
|
||||
in-process refund cache (core #412).
|
||||
|
||||
A row exists only for the version at which it was minted; any credit bumps
|
||||
balance_version, so a token minted at version k is structurally unreachable
|
||||
once a topup moves the key to k+1. Within the same version, a token whose
|
||||
``redeemed_at`` is set is never re-served.
|
||||
"""
|
||||
row = await session.get(RefundToken, (hashed_key, balance_version))
|
||||
# Defensive isinstance guard: some unit tests inject a MagicMock session
|
||||
# whose ``get`` returns an ApiKey regardless of the model queried. Only an
|
||||
# actual RefundToken row counts as an idempotency hit.
|
||||
if not isinstance(row, RefundToken):
|
||||
return None
|
||||
if row.redeemed_at is not None:
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
async def _refund_cache_get(authorization: str) -> dict[str, str] | None:
|
||||
key = _cache_key_for_authorization(authorization)
|
||||
async with _refund_cache_lock:
|
||||
@@ -326,31 +302,9 @@ async def refund_wallet_endpoint(
|
||||
detail="Key not found. Deposit first via /v1/wallet/create before requesting a refund.",
|
||||
)
|
||||
|
||||
# Durable, balance-versioned idempotency (core #412). Capture the version
|
||||
# the refund is being computed against. A stored refund token is only
|
||||
# re-served at the SAME balance_version and only if still unredeemed:
|
||||
# * in-flight retry (no intervening credit) -> same version -> same token
|
||||
# (true idempotency, balance debited exactly once);
|
||||
# * after ANY topup (cashu OR Lightning OR new-key credit) the version was
|
||||
# bumped, so the prior token is unreachable and never re-served.
|
||||
current_balance_version: int = key.balance_version
|
||||
stored = await _stored_refund_for_version(
|
||||
session, key.hashed_key, current_balance_version
|
||||
)
|
||||
if stored is not None:
|
||||
result: dict[str, str] = {"token": stored.token}
|
||||
if stored.unit == "sat":
|
||||
result["sats"] = str(stored.amount)
|
||||
else:
|
||||
result["msats"] = str(stored.amount)
|
||||
logger.info(
|
||||
"refund_wallet_endpoint: re-serving stored refund (idempotent)",
|
||||
extra={
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance_version": current_balance_version,
|
||||
},
|
||||
)
|
||||
return result
|
||||
if key.total_balance <= 0:
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
@@ -496,36 +450,7 @@ async def refund_wallet_endpoint(
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}")
|
||||
|
||||
# Durably record the issued refund token keyed on (hashed_key,
|
||||
# balance_version) so an in-flight retry at the SAME version re-serves this
|
||||
# exact token instead of minting a second one (core #412). Only cashu-token
|
||||
# refunds are re-servable; LNURL refunds ("recipient") have no token to
|
||||
# re-serve. The PK (hashed_key, balance_version) makes a concurrent
|
||||
# duplicate insert at the same version fail loudly rather than double-mint.
|
||||
if "token" in result:
|
||||
try:
|
||||
session.add(
|
||||
RefundToken(
|
||||
api_key_hash=key.hashed_key,
|
||||
balance_version=current_balance_version,
|
||||
token=result["token"],
|
||||
amount=remaining_balance,
|
||||
unit=key.refund_currency or "sat",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
# A row already exists for this version (concurrent in-flight
|
||||
# refund won the race) — roll back our duplicate; the stored token
|
||||
# is authoritative and will be re-served on the client's retry.
|
||||
await session.rollback()
|
||||
logger.warning(
|
||||
"refund_wallet_endpoint: refund token already stored for version",
|
||||
extra={
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance_version": current_balance_version,
|
||||
},
|
||||
)
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
|
||||
if "token" in result:
|
||||
try:
|
||||
|
||||
@@ -88,75 +88,12 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
default=None,
|
||||
description="Unix timestamp after which the key is no longer valid",
|
||||
)
|
||||
balance_version: int = Field(
|
||||
default=0,
|
||||
nullable=False,
|
||||
description=(
|
||||
"Monotonic counter bumped atomically on EVERY credit to this key "
|
||||
"(cashu topup, Lightning topup, new-key credit). Refund idempotency "
|
||||
"is keyed on (hashed_key, balance_version): a refund minted at "
|
||||
"version k is invalidated by any later credit, which moves the key "
|
||||
"to k+1. See core #412."
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
|
||||
class RefundToken(SQLModel, table=True): # type: ignore
|
||||
"""Durable, balance-versioned record of issued refund tokens (core #412).
|
||||
|
||||
One row per (api_key_hash, balance_version). A refund issued while the key
|
||||
is at balance_version=k is stored here; an in-flight retry at the same
|
||||
version re-serves the exact same token (true idempotency). Any credit bumps
|
||||
balance_version, so the row at k can never be re-served after a topup.
|
||||
Once the gateway/client confirms redemption, ``redeemed_at`` is set so even
|
||||
within the same version a redeemed token is never re-served.
|
||||
"""
|
||||
|
||||
__tablename__ = "refund_tokens"
|
||||
|
||||
api_key_hash: str = Field(primary_key=True, foreign_key="api_keys.hashed_key")
|
||||
balance_version: int = Field(primary_key=True)
|
||||
token: str = Field(description="Serialized refund token re-served on retry")
|
||||
amount: int = Field(default=0, description="Refunded amount in the key's unit")
|
||||
unit: str = Field(default="sat", description="Refund unit (sat or msat)")
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
redeemed_at: int | None = Field(
|
||||
default=None,
|
||||
description="Set once the refund token's proofs are observed spent / acked",
|
||||
)
|
||||
|
||||
|
||||
async def credit_key_balance(
|
||||
session: AsyncSession, hashed_key: str, amount_msats: int
|
||||
) -> int:
|
||||
"""Atomically credit ``amount_msats`` to a key AND bump its balance_version.
|
||||
|
||||
This is the single choke point that invalidates all prior refund tokens for
|
||||
the key, regardless of how the credit arrived (cashu topup, Lightning topup,
|
||||
new-key credit) or which worker handles it (state lives in the shared DB).
|
||||
See core #412.
|
||||
|
||||
Does NOT commit — the caller owns the transaction so the credit and any
|
||||
surrounding work (e.g. marking a Lightning invoice paid) are atomic.
|
||||
|
||||
Returns the number of rows updated (1 on success, 0 if the key vanished).
|
||||
"""
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + amount_msats,
|
||||
balance_version=col(ApiKey.balance_version) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
async def reset_all_reserved_balances(session: AsyncSession) -> None:
|
||||
stmt = update(ApiKey).values(reserved_balance=0, reserved_at=None)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
+2
-18
@@ -8,13 +8,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .core.db import (
|
||||
ApiKey,
|
||||
LightningInvoice,
|
||||
create_session,
|
||||
credit_key_balance,
|
||||
get_session,
|
||||
)
|
||||
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
|
||||
@@ -299,17 +293,7 @@ async def topup_api_key_from_invoice(
|
||||
if not api_key:
|
||||
raise ValueError("Associated API key not found")
|
||||
|
||||
# Atomically credit AND bump balance_version in the same DB txn. This is the
|
||||
# load-bearing fix for core #412 on the Lightning path: this credit runs in
|
||||
# the background invoice watcher with NO bearer/authorization in scope, so a
|
||||
# header-keyed refund-cache invalidation cannot fire here. Bumping
|
||||
# balance_version is data-driven and worker-agnostic, so the next refund
|
||||
# recomputes against the new version and never re-serves the stale token.
|
||||
updated = await credit_key_balance(
|
||||
session, api_key.hashed_key, invoice.amount_sats * 1000
|
||||
)
|
||||
if updated == 0:
|
||||
raise ValueError("Associated API key not found")
|
||||
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
|
||||
await session.flush()
|
||||
|
||||
|
||||
|
||||
@@ -3,17 +3,9 @@ import math
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
from .usage import normalize_usage, parse_token_count
|
||||
|
||||
__all__ = [
|
||||
"CostData",
|
||||
"CostDataError",
|
||||
"MaxCostData",
|
||||
"calculate_cost",
|
||||
"parse_token_count",
|
||||
]
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -42,8 +34,7 @@ class CostDataError(BaseModel):
|
||||
|
||||
|
||||
async def calculate_cost(
|
||||
response_data: dict,
|
||||
max_cost: int,
|
||||
response_data: dict, max_cost: int, session: AsyncSession
|
||||
) -> CostData | MaxCostData | CostDataError:
|
||||
"""Calculate the cost of an API request based on token usage.
|
||||
|
||||
@@ -53,9 +44,6 @@ async def calculate_cost(
|
||||
|
||||
Returns:
|
||||
Cost data or error information
|
||||
|
||||
The response's usage object is normalized with the default union parser;
|
||||
this function holds no vendor-dialect knowledge of its own.
|
||||
"""
|
||||
logger.debug(
|
||||
"Starting cost calculation",
|
||||
@@ -66,9 +54,8 @@ async def calculate_cost(
|
||||
},
|
||||
)
|
||||
|
||||
usage = normalize_usage(response_data.get("usage"))
|
||||
|
||||
if usage is None:
|
||||
# Check for usage data
|
||||
if "usage" not in response_data or response_data["usage"] is None:
|
||||
logger.warning(
|
||||
"No usage data in response — billing at MaxCostData with zero "
|
||||
"tokens. Dashboard will show this request as `(0+0)`. Most "
|
||||
@@ -97,14 +84,16 @@ async def calculate_cost(
|
||||
cache_creation_msats=0,
|
||||
)
|
||||
|
||||
usage_data = response_data.get("usage") or {}
|
||||
if not isinstance(usage_data, dict):
|
||||
usage_data = {}
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
input_tokens = usage.input_tokens
|
||||
output_tokens = usage.output_tokens
|
||||
cache_read_tokens = usage.cache_read_tokens
|
||||
cache_creation_tokens = usage.cache_write_tokens
|
||||
# Extract token counts
|
||||
input_tokens = _extract_token_pair(usage_data, "prompt_tokens", "input_tokens")
|
||||
output_tokens = _extract_token_pair(usage_data, "completion_tokens", "output_tokens")
|
||||
|
||||
# Extract cache tokens (handles OpenAI vs Anthropic formats)
|
||||
cache_read_tokens, cache_creation_tokens, input_tokens = _extract_cache_tokens(
|
||||
usage_data, input_tokens
|
||||
)
|
||||
|
||||
# Try USD cost first
|
||||
usd_cost = _resolve_usd_cost(usage_data, response_data)
|
||||
@@ -213,6 +202,22 @@ async def calculate_cost(
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def parse_token_count(value: object) -> int:
|
||||
"""Parse a token count from various formats (int, float, str, bool)."""
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
if isinstance(value, float):
|
||||
return max(0, int(value))
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _coerce_usd(value: object) -> float:
|
||||
"""Coerce a value to USD float, handling various formats safely."""
|
||||
if value is None or isinstance(value, bool):
|
||||
@@ -225,6 +230,61 @@ def _coerce_usd(value: object) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _extract_token_pair(
|
||||
usage_data: dict, standard_field: str, alt_field: str
|
||||
) -> int:
|
||||
"""Extract token count trying two field names in order."""
|
||||
value = parse_token_count(usage_data.get(standard_field, 0))
|
||||
if value > 0:
|
||||
return value
|
||||
return parse_token_count(usage_data.get(alt_field, 0))
|
||||
|
||||
|
||||
def _extract_cache_tokens(usage_data: dict, input_tokens: int) -> tuple[int, int, int]:
|
||||
"""Extract cache tokens, normalizing Anthropic, OpenAI, and DeepSeek shapes.
|
||||
|
||||
Each provider reports prompt caching differently; this normalizes them into
|
||||
non-overlapping buckets so cached tokens are never billed at the full input
|
||||
rate (and never double-counted):
|
||||
|
||||
- Anthropic: ``cache_read_input_tokens`` / ``cache_creation_input_tokens``
|
||||
are already mutually exclusive with ``input_tokens`` (the three sum to the
|
||||
total prompt), so ``input_tokens`` is left untouched.
|
||||
- OpenAI: ``prompt_tokens_details.cached_tokens`` is a cache READ that is
|
||||
INCLUDED in ``prompt_tokens``, so it is subtracted out of ``input_tokens``.
|
||||
- DeepSeek: ``prompt_cache_hit_tokens`` / ``prompt_cache_miss_tokens`` where
|
||||
``prompt_tokens = hit + miss``. The hit is a cache READ included in
|
||||
``prompt_tokens``, so it is subtracted out; DeepSeek has no cache-write
|
||||
charge (``cache_creation`` stays 0).
|
||||
|
||||
Returns: (cache_read_tokens, cache_creation_tokens, adjusted_input_tokens)
|
||||
"""
|
||||
cache_read = parse_token_count(usage_data.get("cache_read_input_tokens", 0))
|
||||
cache_creation = parse_token_count(
|
||||
usage_data.get("cache_creation_input_tokens", 0)
|
||||
)
|
||||
|
||||
# OpenAI: cache read is included in input_tokens, subtract it
|
||||
prompt_details = usage_data.get("prompt_tokens_details")
|
||||
if isinstance(prompt_details, dict) and not cache_read:
|
||||
openai_cached = parse_token_count(prompt_details.get("cached_tokens", 0))
|
||||
if openai_cached:
|
||||
cache_read = openai_cached
|
||||
input_tokens = max(0, input_tokens - cache_read)
|
||||
|
||||
# DeepSeek: prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens.
|
||||
# The hit is a cache read included in input_tokens, subtract it.
|
||||
if not cache_read:
|
||||
deepseek_hit = parse_token_count(
|
||||
usage_data.get("prompt_cache_hit_tokens", 0)
|
||||
)
|
||||
if deepseek_hit:
|
||||
cache_read = deepseek_hit
|
||||
input_tokens = max(0, input_tokens - cache_read)
|
||||
|
||||
return cache_read, cache_creation, input_tokens
|
||||
|
||||
|
||||
def _resolve_usd_cost(usage_data: dict, response_data: dict) -> float:
|
||||
"""Resolve USD cost with clear priority order.
|
||||
|
||||
|
||||
@@ -85,48 +85,6 @@ class Model(BaseModel):
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
def backfill_cache_pricing(model_id: str, pricing: Pricing) -> Pricing:
|
||||
"""Fill missing cache rates from litellm's bundled cost map.
|
||||
|
||||
The OpenRouter model feed omits ``input_cache_read``/``input_cache_write``
|
||||
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.
|
||||
|
||||
Rates already present (e.g. provided by OpenRouter) are authoritative and
|
||||
never overwritten. Unknown models are returned unchanged.
|
||||
"""
|
||||
needs_read = (pricing.input_cache_read or 0.0) <= 0.0
|
||||
needs_write = (pricing.input_cache_write or 0.0) <= 0.0
|
||||
if not (needs_read or needs_write):
|
||||
return pricing
|
||||
|
||||
import litellm
|
||||
|
||||
info: dict | None = None
|
||||
for key in (model_id, model_id.split("/", 1)[-1]):
|
||||
candidate = litellm.model_cost.get(key)
|
||||
if isinstance(candidate, dict):
|
||||
info = candidate
|
||||
break
|
||||
if info is None:
|
||||
return pricing
|
||||
|
||||
updated = Pricing.parse_obj(pricing.dict())
|
||||
if needs_read:
|
||||
read_rate = info.get("cache_read_input_token_cost")
|
||||
if isinstance(read_rate, (int, float)) and read_rate > 0:
|
||||
updated.input_cache_read = float(read_rate)
|
||||
if needs_write:
|
||||
write_rate = info.get("cache_creation_input_token_cost")
|
||||
if isinstance(write_rate, (int, float)) and write_rate > 0:
|
||||
updated.input_cache_write = float(write_rate)
|
||||
return updated
|
||||
|
||||
|
||||
def _has_valid_pricing(model: dict) -> bool:
|
||||
"""Check if model has valid pricing (not free, no negative values)."""
|
||||
pricing = model.get("pricing", {})
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Vendor-agnostic normalization of upstream usage objects.
|
||||
|
||||
Upstream providers report token usage in vendor dialects that differ in field
|
||||
names and in whether cached tokens are included in the input count:
|
||||
|
||||
* OpenAI / Azure / xAI / Groq / Moonshot / Qwen / Gemini-compat: cache reads in
|
||||
``prompt_tokens_details.cached_tokens``, included in ``prompt_tokens``.
|
||||
* OpenRouter: same as OpenAI plus cache *writes* in
|
||||
``prompt_tokens_details.cache_write_tokens``, also included in
|
||||
``prompt_tokens``.
|
||||
* litellm-normalized: same nesting, but names the write field
|
||||
``prompt_tokens_details.cache_creation_tokens`` (and additionally mirrors the
|
||||
Anthropic top-level fields), with ``prompt_tokens`` as the grand total.
|
||||
* Anthropic native: ``cache_read_input_tokens`` / ``cache_creation_input_tokens``
|
||||
top-level, additive to (not included in) ``input_tokens``.
|
||||
* DeepSeek: ``prompt_cache_hit_tokens`` / ``prompt_cache_miss_tokens``, with
|
||||
``prompt_tokens = hit + miss``.
|
||||
|
||||
What decides whether cached tokens must be subtracted out of the input count is
|
||||
**which prompt field the vendor uses**, not which cache field appears:
|
||||
|
||||
* ``prompt_tokens`` present -> cached + cache-write tokens are *included* in it
|
||||
(OpenAI family, DeepSeek, OpenRouter, litellm); subtract both so
|
||||
``input_tokens`` holds only the regular-rate portion.
|
||||
* only ``input_tokens`` (Anthropic native) -> cached tokens are *additive*;
|
||||
leave ``input_tokens`` untouched.
|
||||
|
||||
``normalize_usage`` maps all of them onto one canonical ``NormalizedUsage``
|
||||
shape so billing code needs no vendor knowledge. The known dialects' field
|
||||
names do not collide, so a single union parser is safe; a vendor whose fields
|
||||
would genuinely conflict needs a dedicated branch here.
|
||||
"""
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
|
||||
class NormalizedUsage(BaseModel):
|
||||
"""Canonical token usage: input_tokens never includes cached tokens."""
|
||||
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
cache_read_tokens: int = 0
|
||||
cache_write_tokens: int = 0
|
||||
|
||||
|
||||
def parse_token_count(value: object) -> int:
|
||||
"""Parse a token count from various formats (int, float, str, bool)."""
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(0, value)
|
||||
if isinstance(value, float):
|
||||
return max(0, int(value))
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(0, int(float(value)))
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _first_token_count(usage_data: dict, *fields: str) -> int:
|
||||
"""Return the first positive token count among the given fields."""
|
||||
for field in fields:
|
||||
value = parse_token_count(usage_data.get(field, 0))
|
||||
if value > 0:
|
||||
return value
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_cache_tokens(usage_data: dict) -> tuple[int, int]:
|
||||
"""Pull (cache_read, cache_write) across all known dialects.
|
||||
|
||||
Precedence (highest first), independent for reads and writes:
|
||||
|
||||
* Anthropic top-level: ``cache_read_input_tokens`` /
|
||||
``cache_creation_input_tokens``.
|
||||
* Nested ``prompt_tokens_details``: ``cached_tokens`` for reads;
|
||||
``cache_creation_tokens`` (litellm) or ``cache_write_tokens``
|
||||
(OpenRouter) for writes.
|
||||
* DeepSeek: ``prompt_cache_hit_tokens`` for reads (no write concept).
|
||||
"""
|
||||
cache_read = parse_token_count(usage_data.get("cache_read_input_tokens", 0))
|
||||
cache_write = parse_token_count(usage_data.get("cache_creation_input_tokens", 0))
|
||||
|
||||
prompt_details = usage_data.get("prompt_tokens_details")
|
||||
if isinstance(prompt_details, dict):
|
||||
if not cache_read:
|
||||
cache_read = parse_token_count(prompt_details.get("cached_tokens", 0))
|
||||
if not cache_write:
|
||||
cache_write = _first_token_count(
|
||||
prompt_details, "cache_creation_tokens", "cache_write_tokens"
|
||||
)
|
||||
|
||||
if not cache_read:
|
||||
# DeepSeek: prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens
|
||||
cache_read = parse_token_count(usage_data.get("prompt_cache_hit_tokens", 0))
|
||||
|
||||
return cache_read, cache_write
|
||||
|
||||
|
||||
def normalize_usage(usage_data: object) -> NormalizedUsage | None:
|
||||
"""Map a vendor usage dict onto the canonical shape, or None if absent.
|
||||
|
||||
Cached reads and writes are subtracted from the input count exactly once,
|
||||
only for dialects that report a ``prompt_tokens`` grand total that already
|
||||
includes them (OpenAI family, DeepSeek, OpenRouter, litellm). Anthropic
|
||||
native reports them additively under ``input_tokens`` and is left untouched.
|
||||
"""
|
||||
if not isinstance(usage_data, dict):
|
||||
return None
|
||||
|
||||
output_tokens = _first_token_count(
|
||||
usage_data, "completion_tokens", "output_tokens"
|
||||
)
|
||||
cache_read, cache_write = _extract_cache_tokens(usage_data)
|
||||
|
||||
# ``prompt_tokens`` is the inclusive grand total; ``input_tokens`` (Anthropic
|
||||
# native) excludes cached tokens. The field chosen decides whether to subtract.
|
||||
if "prompt_tokens" in usage_data:
|
||||
input_tokens = parse_token_count(usage_data.get("prompt_tokens", 0))
|
||||
input_tokens = max(0, input_tokens - cache_read - cache_write)
|
||||
else:
|
||||
input_tokens = parse_token_count(usage_data.get("input_tokens", 0))
|
||||
|
||||
return NormalizedUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_tokens=cache_read,
|
||||
cache_write_tokens=cache_write,
|
||||
)
|
||||
+44
-105
@@ -35,16 +35,11 @@ from ..payment.models import (
|
||||
Pricing,
|
||||
_calculate_usd_max_costs,
|
||||
_update_model_sats_pricing,
|
||||
backfill_cache_pricing,
|
||||
list_models,
|
||||
)
|
||||
from ..payment.price import sats_usd_price
|
||||
from ..wallet import recieve_token, send_token
|
||||
from . import messages_dispatch
|
||||
from .cache_breakpoints import (
|
||||
inject_anthropic_cache_breakpoints,
|
||||
is_explicit_cache_model,
|
||||
)
|
||||
from .count_tokens import count_tokens_locally
|
||||
from .litellm_routing import detect_litellm_prefix
|
||||
|
||||
@@ -437,19 +432,6 @@ class BaseUpstreamProvider:
|
||||
|
||||
return body
|
||||
|
||||
def _upstream_accepts_cache_control(self) -> bool:
|
||||
"""True when this upstream accepts explicit ``cache_control`` markers.
|
||||
|
||||
Only OpenRouter (documents Anthropic + Alibaba explicit caching) and the
|
||||
native Anthropic API accept the markers. Stamping them toward an
|
||||
automatic-cache or non-supporting upstream risks a 400, so injection is
|
||||
confined to these. Base URL is also checked so an OpenRouter endpoint
|
||||
configured through the generic provider is still recognised.
|
||||
"""
|
||||
if self.provider_type in ("openrouter", "anthropic"):
|
||||
return True
|
||||
return "openrouter.ai" in (self.base_url or "")
|
||||
|
||||
def prepare_request_body(
|
||||
self, body: bytes | None, model_obj: Model
|
||||
) -> bytes | None:
|
||||
@@ -519,27 +501,6 @@ class BaseUpstreamProvider:
|
||||
data["stream_options"] = merged
|
||||
changed = True
|
||||
|
||||
# Explicit-cache models (Anthropic Claude, Alibaba Qwen / deepseek-v3.2)
|
||||
# cache nothing without ``cache_control`` markers in the body. Clients
|
||||
# that don't recognise a routstr URL as one of these never send them, so
|
||||
# caching silently never engages over routstr even though it works
|
||||
# against OpenRouter directly. Stamp the standard breakpoints so caching
|
||||
# works by default, deferring to any client-set markers. Gated to
|
||||
# upstreams that accept the markers (OpenRouter / Anthropic) so they
|
||||
# never leak to an automatic-cache provider that would reject them.
|
||||
if (
|
||||
"messages" in data
|
||||
and isinstance(data.get("messages"), list)
|
||||
and self._upstream_accepts_cache_control()
|
||||
and is_explicit_cache_model(
|
||||
model_obj.id,
|
||||
model_obj.forwarded_model_id,
|
||||
model_obj.canonical_slug,
|
||||
)
|
||||
):
|
||||
if inject_anthropic_cache_breakpoints(data):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
return json.dumps(data).encode()
|
||||
return body
|
||||
@@ -1057,10 +1018,7 @@ class BaseUpstreamProvider:
|
||||
response_json["id"] = f"chatcmpl-{uuid.uuid4()}"
|
||||
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key,
|
||||
response_json,
|
||||
session,
|
||||
deducted_max_cost,
|
||||
key, response_json, session, deducted_max_cost
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
@@ -1478,10 +1436,7 @@ class BaseUpstreamProvider:
|
||||
response_json["id"] = f"chatcmpl-{uuid.uuid4()}"
|
||||
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key,
|
||||
response_json,
|
||||
session,
|
||||
deducted_max_cost,
|
||||
key, response_json, session, deducted_max_cost
|
||||
)
|
||||
|
||||
await session.refresh(key)
|
||||
@@ -1676,10 +1631,7 @@ class BaseUpstreamProvider:
|
||||
"usage": None,
|
||||
}
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
fresh_key,
|
||||
fallback,
|
||||
new_session,
|
||||
max_cost_for_model,
|
||||
fresh_key, fallback, new_session, max_cost_for_model
|
||||
)
|
||||
usage_finalized = True
|
||||
return f"event: cost\ndata: {json.dumps({'cost': cost_data})}\n\n".encode()
|
||||
@@ -1898,10 +1850,7 @@ class BaseUpstreamProvider:
|
||||
response_json["usage"] = {"input_tokens": input_tokens}
|
||||
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key,
|
||||
response_json,
|
||||
session,
|
||||
deducted_max_cost,
|
||||
key, response_json, session, deducted_max_cost
|
||||
)
|
||||
|
||||
self.inject_cost_metadata(response_json, cost_data, key)
|
||||
@@ -2003,10 +1952,7 @@ class BaseUpstreamProvider:
|
||||
response_json["model"] = requested_model
|
||||
|
||||
cost_data = await adjust_payment_for_tokens(
|
||||
key,
|
||||
response_json,
|
||||
session,
|
||||
max_cost_for_model,
|
||||
key, response_json, session, max_cost_for_model
|
||||
)
|
||||
self.inject_cost_metadata(response_json, cost_data, key)
|
||||
|
||||
@@ -3079,46 +3025,44 @@ class BaseUpstreamProvider:
|
||||
extra={"model": model, "has_usage": "usage" in response_data},
|
||||
)
|
||||
|
||||
match await calculate_cost(
|
||||
response_data,
|
||||
max_cost_for_model,
|
||||
):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost pricing",
|
||||
extra={"model": model, "max_cost_msats": cost.total_msats},
|
||||
)
|
||||
return cost
|
||||
case CostData() as cost:
|
||||
logger.debug(
|
||||
"Using token-based pricing",
|
||||
extra={
|
||||
"model": model,
|
||||
"total_cost_msats": cost.total_msats,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
return cost
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error",
|
||||
extra={
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
async with create_session() as session:
|
||||
match await calculate_cost(response_data, max_cost_for_model, session):
|
||||
case MaxCostData() as cost:
|
||||
logger.debug(
|
||||
"Using max cost pricing",
|
||||
extra={"model": model, "max_cost_msats": cost.total_msats},
|
||||
)
|
||||
return cost
|
||||
case CostData() as cost:
|
||||
logger.debug(
|
||||
"Using token-based pricing",
|
||||
extra={
|
||||
"model": model,
|
||||
"total_cost_msats": cost.total_msats,
|
||||
"input_msats": cost.input_msats,
|
||||
"output_msats": cost.output_msats,
|
||||
},
|
||||
)
|
||||
return cost
|
||||
case CostDataError() as error:
|
||||
logger.error(
|
||||
"Cost calculation error",
|
||||
extra={
|
||||
"model": model,
|
||||
"error_message": error.message,
|
||||
"error_code": error.code,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": {
|
||||
"message": error.message,
|
||||
"type": "invalid_request_error",
|
||||
"code": error.code,
|
||||
}
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
async def send_refund(
|
||||
@@ -4618,19 +4562,14 @@ class BaseUpstreamProvider:
|
||||
def _apply_provider_fee_to_model(self, model: Model) -> Model:
|
||||
"""Apply provider fee to model's USD pricing and calculate max costs.
|
||||
|
||||
Cache rates missing from the upstream pricing feed are backfilled from
|
||||
litellm's cost map first, so they carry the provider fee like every
|
||||
other price component.
|
||||
|
||||
Args:
|
||||
model: Model object to update
|
||||
|
||||
Returns:
|
||||
Model with provider fee applied to pricing and max costs calculated
|
||||
"""
|
||||
base_pricing = backfill_cache_pricing(model.id, model.pricing)
|
||||
adjusted_pricing = Pricing.parse_obj(
|
||||
{k: v * self.provider_fee for k, v in base_pricing.dict().items()}
|
||||
{k: v * self.provider_fee for k, v in model.pricing.dict().items()}
|
||||
)
|
||||
|
||||
temp_model = Model(
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Inject explicit prompt-cache breakpoints into OpenAI-shaped requests.
|
||||
|
||||
Some upstreams cache *explicitly*: the request must carry
|
||||
``cache_control: {"type": "ephemeral"}`` markers on the content blocks that
|
||||
should be cached. Two model families use this identical wire format:
|
||||
|
||||
* **Anthropic Claude** — direct or via OpenRouter's ``anthropic/*`` models.
|
||||
* **Alibaba's explicit-cache models on OpenRouter** — ``qwen/qwen3-max``,
|
||||
``qwen/qwen-plus``, ``qwen/qwen3.6-plus``, ``qwen/qwen3-coder-plus``,
|
||||
``qwen/qwen3-coder-flash`` and ``deepseek/deepseek-v3.2`` — which OpenRouter
|
||||
documents as using "the same syntax as Anthropic explicit caching".
|
||||
|
||||
Every other provider routstr proxies (OpenAI, Azure, xAI/Grok, Groq, Moonshot,
|
||||
default DeepSeek, Gemini implicit, Fireworks) caches *automatically* and needs
|
||||
no markers — they are left untouched.
|
||||
|
||||
A client that doesn't know it is talking to one of these models *through*
|
||||
routstr (e.g. an OpenAI-compatible coding agent pointed at a routstr URL) never
|
||||
emits the markers — it only adds them when it recognises the provider as
|
||||
OpenRouter. So caching silently never engages over routstr even though the same
|
||||
client caches fine talking to OpenRouter directly.
|
||||
|
||||
This module restores caching by stamping the standard breakpoints onto the
|
||||
forwarded body — the system prompt, the last tool, and the last conversation
|
||||
message (the format allows up to four; we use three, matching the common
|
||||
agent convention) — but only when the client supplied none of its own, so
|
||||
explicit client control always wins. The caller is responsible for only
|
||||
applying this toward an upstream that accepts the markers (OpenRouter /
|
||||
Anthropic), so they never leak to a provider that would reject them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# The single ephemeral marker stamped onto each chosen breakpoint. A 5-minute
|
||||
# TTL (the default for ``ephemeral``) — deliberately not the 1h tier, which
|
||||
# carries a higher cache-write premium and should stay opt-in.
|
||||
EPHEMERAL_CACHE_CONTROL: dict[str, str] = {"type": "ephemeral"}
|
||||
|
||||
# Alibaba's explicit-cache models on OpenRouter. Matched as substrings of the
|
||||
# model id (any spelling routstr carries). Snapshot endpoints that OpenRouter
|
||||
# documents as *not* supporting explicit caching (e.g. ``qwen3.5-plus-02-15``)
|
||||
# are different families and deliberately absent from this list.
|
||||
_ALIBABA_EXPLICIT_CACHE_SLUGS: tuple[str, ...] = (
|
||||
"qwen3-max",
|
||||
"qwen-plus",
|
||||
"qwen3.6-plus",
|
||||
"qwen3-coder-plus",
|
||||
"qwen3-coder-flash",
|
||||
"deepseek-v3.2",
|
||||
)
|
||||
|
||||
|
||||
def is_explicit_cache_model(model_id: str | None, *fallbacks: str | None) -> bool:
|
||||
"""True when the target model uses the explicit ``cache_control`` dialect.
|
||||
|
||||
Covers the Claude family (broadly — every Claude model supports it) and
|
||||
Alibaba's documented explicit-cache models, across the id spellings routstr
|
||||
carries: the OpenRouter id (``anthropic/claude-...``, ``qwen/qwen3-max``),
|
||||
the bare upstream id, and any forwarded/canonical alias.
|
||||
"""
|
||||
for candidate in (model_id, *fallbacks):
|
||||
if not candidate:
|
||||
continue
|
||||
lowered = candidate.lower()
|
||||
if "claude" in lowered or "anthropic/" in lowered:
|
||||
return True
|
||||
if any(slug in lowered for slug in _ALIBABA_EXPLICIT_CACHE_SLUGS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_cache_control(obj: Any) -> bool:
|
||||
"""Recursively detect any client-supplied ``cache_control`` marker."""
|
||||
if isinstance(obj, dict):
|
||||
if "cache_control" in obj:
|
||||
return True
|
||||
return any(_has_cache_control(v) for v in obj.values())
|
||||
if isinstance(obj, list):
|
||||
return any(_has_cache_control(v) for v in obj)
|
||||
return False
|
||||
|
||||
|
||||
def body_has_cache_control(data: dict) -> bool:
|
||||
"""True when the request already carries cache_control on messages/tools."""
|
||||
return _has_cache_control(data.get("messages")) or _has_cache_control(
|
||||
data.get("tools")
|
||||
)
|
||||
|
||||
|
||||
def _stamp_text_content(message: dict) -> bool:
|
||||
"""Add the ephemeral marker to a message's last text block.
|
||||
|
||||
A string content is promoted to the array form Anthropic requires for
|
||||
cache markers; an existing array gets the marker on its last text part.
|
||||
Returns True when a marker was placed.
|
||||
"""
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
if not content:
|
||||
return False
|
||||
message["content"] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
"cache_control": dict(EPHEMERAL_CACHE_CONTROL),
|
||||
}
|
||||
]
|
||||
return True
|
||||
if isinstance(content, list):
|
||||
for part in reversed(content):
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
part["cache_control"] = dict(EPHEMERAL_CACHE_CONTROL)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _stamp_system_prompt(messages: list) -> None:
|
||||
for message in messages:
|
||||
if isinstance(message, dict) and message.get("role") in (
|
||||
"system",
|
||||
"developer",
|
||||
):
|
||||
_stamp_text_content(message)
|
||||
return
|
||||
|
||||
|
||||
def _stamp_last_tool(tools: Any) -> None:
|
||||
if isinstance(tools, list) and tools and isinstance(tools[-1], dict):
|
||||
tools[-1]["cache_control"] = dict(EPHEMERAL_CACHE_CONTROL)
|
||||
|
||||
|
||||
def _stamp_last_conversation_message(messages: list) -> None:
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get("role") in ("user", "assistant"):
|
||||
if _stamp_text_content(message):
|
||||
return
|
||||
|
||||
|
||||
def inject_anthropic_cache_breakpoints(data: dict) -> bool:
|
||||
"""Stamp ephemeral cache breakpoints onto an OpenAI-shaped chat body.
|
||||
|
||||
Mutates ``data`` in place (the established convention in
|
||||
``prepare_request_body``) and returns True when anything changed. No-ops
|
||||
when the body isn't chat-shaped or the client already set cache_control.
|
||||
"""
|
||||
messages = data.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return False
|
||||
if body_has_cache_control(data):
|
||||
return False
|
||||
|
||||
_stamp_system_prompt(messages)
|
||||
_stamp_last_tool(data.get("tools"))
|
||||
_stamp_last_conversation_message(messages)
|
||||
return True
|
||||
+9
-15
@@ -8,7 +8,7 @@ from cashu.core.mint_info import MintInfo as _CashuMintInfo
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from cashu.wallet.wallet import Wallet
|
||||
from pydantic_core import PydanticUndefined
|
||||
from sqlmodel import select
|
||||
from sqlmodel import col, select, update
|
||||
|
||||
from .core import db, get_logger
|
||||
from .core.db import store_cashu_transaction
|
||||
@@ -435,11 +435,13 @@ async def credit_balance(
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
)
|
||||
|
||||
# Use atomic SQL UPDATE to prevent race conditions during concurrent
|
||||
# topups. credit_key_balance also bumps balance_version in the same
|
||||
# statement, which invalidates any prior refund token for this key
|
||||
# (core #412) — regardless of worker or auth context.
|
||||
await db.credit_key_balance(session, key.hashed_key, amount)
|
||||
# Use atomic SQL UPDATE to prevent race conditions during concurrent topups
|
||||
stmt = (
|
||||
update(db.ApiKey)
|
||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(db.ApiKey.balance) + amount)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
|
||||
@@ -578,19 +580,11 @@ async def fetch_all_balances(
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Build the set of mints to inspect. Received tokens are stored against
|
||||
# ``primary_mint`` (which defaults to a real mint even when ``cashu_mints``
|
||||
# is empty), so include it as a fallback — otherwise a node that accepts
|
||||
# payments would still report empty balances when ``cashu_mints`` is unset.
|
||||
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)
|
||||
|
||||
# Create tasks for all mint/unit combinations
|
||||
async with db.create_session() as session:
|
||||
tasks = [
|
||||
fetch_balance(session, mint_url, unit)
|
||||
for mint_url in mint_urls
|
||||
for mint_url in settings.cashu_mints
|
||||
for unit in units
|
||||
]
|
||||
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Tests for Anthropic cache-breakpoint injection on forwarded requests.
|
||||
|
||||
Anthropic prompt caching is explicit; a client that doesn't recognise a routstr
|
||||
URL as Anthropic-backed never sends ``cache_control`` markers, so caching never
|
||||
engages over routstr. ``prepare_request_body`` must stamp the standard
|
||||
breakpoints for Anthropic-family models while always deferring to client-set
|
||||
markers and never touching automatic-cache providers.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
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.cache_breakpoints import (
|
||||
body_has_cache_control,
|
||||
inject_anthropic_cache_breakpoints,
|
||||
is_explicit_cache_model,
|
||||
)
|
||||
|
||||
|
||||
def _chat_body() -> dict:
|
||||
return {
|
||||
"model": "anthropic/claude-sonnet-4.5",
|
||||
"stream": True,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are concise."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
"tools": [
|
||||
{"type": "function", "function": {"name": "a"}},
|
||||
{"type": "function", "function": {"name": "b"}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id,expected",
|
||||
[
|
||||
("anthropic/claude-sonnet-4.5", True),
|
||||
("claude-haiku-4-5-20251001", True),
|
||||
# Alibaba explicit-cache models share Anthropic's wire format
|
||||
("qwen/qwen3-max", True),
|
||||
("qwen/qwen3-coder-plus", True),
|
||||
("deepseek/deepseek-v3.2", True),
|
||||
# Automatic-cache providers need no markers
|
||||
("openai/gpt-4o", False),
|
||||
("google/gemini-2.5-flash", False),
|
||||
("deepseek/deepseek-chat", False),
|
||||
("qwen/qwen3.5-plus-02-15", False), # snapshot, no explicit caching
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_is_explicit_cache_model(model_id: str | None, expected: bool) -> None:
|
||||
assert is_explicit_cache_model(model_id) is expected
|
||||
|
||||
|
||||
def test_is_explicit_cache_model_uses_fallbacks() -> None:
|
||||
# routstr id is opaque but a forwarded/canonical alias reveals the family.
|
||||
assert is_explicit_cache_model("model-xyz", None, "anthropic/claude-opus-4.1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Breakpoint placement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_injects_three_breakpoints() -> None:
|
||||
data = _chat_body()
|
||||
assert inject_anthropic_cache_breakpoints(data) is True
|
||||
|
||||
# system prompt promoted to array form with a marker
|
||||
system = data["messages"][0]["content"]
|
||||
assert system == [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "You are concise.",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
]
|
||||
# last tool marked
|
||||
assert data["tools"][-1]["cache_control"] == {"type": "ephemeral"}
|
||||
assert "cache_control" not in data["tools"][0]
|
||||
# last user message marked
|
||||
user = data["messages"][1]["content"]
|
||||
assert user[-1]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
|
||||
def test_defers_to_client_supplied_cache_control() -> None:
|
||||
data = _chat_body()
|
||||
data["messages"][1]["content"] = [
|
||||
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
|
||||
]
|
||||
assert body_has_cache_control(data) is True
|
||||
# No additional stamping when the client already controls caching.
|
||||
assert inject_anthropic_cache_breakpoints(data) is False
|
||||
assert "cache_control" not in data["tools"][-1]
|
||||
|
||||
|
||||
def test_marks_last_text_part_of_array_content() -> None:
|
||||
data = _chat_body()
|
||||
data["messages"][1]["content"] = [
|
||||
{"type": "text", "text": "first"},
|
||||
{"type": "image_url", "image_url": {"url": "x"}},
|
||||
{"type": "text", "text": "last"},
|
||||
]
|
||||
inject_anthropic_cache_breakpoints(data)
|
||||
parts = data["messages"][1]["content"]
|
||||
assert parts[2]["cache_control"] == {"type": "ephemeral"}
|
||||
assert "cache_control" not in parts[0]
|
||||
|
||||
|
||||
def test_noop_without_messages() -> None:
|
||||
assert inject_anthropic_cache_breakpoints({"prompt": "x"}) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prepare_request_body integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _model(model_id: str): # 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=200000,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="Claude",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=Pricing(prompt=0.0, completion=0.0),
|
||||
)
|
||||
|
||||
|
||||
def _openrouter_provider() -> "GenericUpstreamProvider":
|
||||
# OpenRouter endpoint via the generic provider — recognised by base URL.
|
||||
return GenericUpstreamProvider(base_url="https://openrouter.ai/api/v1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id", ["anthropic/claude-sonnet-4.5", "qwen/qwen3-max", "deepseek/deepseek-v3.2"]
|
||||
)
|
||||
def test_prepare_request_body_injects_for_explicit_models(model_id: str) -> None:
|
||||
provider = _openrouter_provider()
|
||||
body = json.dumps(_chat_body()).encode()
|
||||
out = provider.prepare_request_body(body, _model(model_id))
|
||||
assert out is not None
|
||||
data = json.loads(out)
|
||||
assert body_has_cache_control(data) is True
|
||||
assert data["tools"][-1]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
|
||||
def test_prepare_request_body_skips_for_automatic_provider_model() -> None:
|
||||
provider = _openrouter_provider()
|
||||
body = json.dumps(_chat_body()).encode()
|
||||
out = provider.prepare_request_body(body, _model("openai/gpt-4o"))
|
||||
assert out is not None
|
||||
data = json.loads(out)
|
||||
assert body_has_cache_control(data) is False
|
||||
|
||||
|
||||
def test_prepare_request_body_skips_when_upstream_rejects_markers() -> None:
|
||||
# Claude id but a non-OpenRouter/Anthropic upstream → must NOT inject,
|
||||
# since the markers could be rejected by an upstream that doesn't accept them.
|
||||
from routstr.upstream import GenericUpstreamProvider
|
||||
|
||||
provider = GenericUpstreamProvider(base_url="https://some-gateway.example/v1")
|
||||
body = json.dumps(_chat_body()).encode()
|
||||
out = provider.prepare_request_body(body, _model("anthropic/claude-sonnet-4.5"))
|
||||
assert out is not None
|
||||
data = json.loads(out)
|
||||
assert body_has_cache_control(data) is False
|
||||
@@ -1,241 +0,0 @@
|
||||
"""Tests for cache-aware pricing of cached input tokens.
|
||||
|
||||
Specifies two things:
|
||||
|
||||
1. ``backfill_cache_pricing`` — when the OpenRouter model feed omits cache
|
||||
rates (it does for most DeepSeek models and e.g. openai/gpt-4o), they are
|
||||
filled from litellm's bundled cost map instead of silently billing cache
|
||||
reads at the full input rate. Existing OpenRouter values are never
|
||||
overwritten, and provider fees apply to backfilled rates like any other.
|
||||
2. ``calculate_cost`` — cached tokens are billed at the cache rates from the
|
||||
model's sats_pricing; the full input rate remains only as the documented
|
||||
last resort when no cache rate could be resolved anywhere.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import litellm
|
||||
import pytest
|
||||
|
||||
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.core.settings import settings
|
||||
from routstr.payment.cost_calculation import CostData, calculate_cost
|
||||
from routstr.payment.models import (
|
||||
Architecture,
|
||||
Model,
|
||||
Pricing,
|
||||
backfill_cache_pricing,
|
||||
)
|
||||
from routstr.upstream import GenericUpstreamProvider
|
||||
|
||||
|
||||
def _make_model(model_id: str, pricing: Pricing) -> Model:
|
||||
return Model(
|
||||
id=model_id,
|
||||
name=model_id,
|
||||
created=0,
|
||||
description="",
|
||||
context_length=64000,
|
||||
architecture=Architecture(
|
||||
modality="text->text",
|
||||
input_modalities=["text"],
|
||||
output_modalities=["text"],
|
||||
tokenizer="Other",
|
||||
instruct_type=None,
|
||||
),
|
||||
pricing=pricing,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# backfill_cache_pricing — litellm as fallback source for missing cache rates
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_backfill_deepseek_cache_read_from_litellm() -> None:
|
||||
"""deepseek/deepseek-chat has no input_cache_read on OpenRouter; litellm
|
||||
knows the real rate (10x cheaper than input)."""
|
||||
pricing = Pricing(prompt=2.8e-07, completion=4.2e-07)
|
||||
|
||||
result = backfill_cache_pricing("deepseek/deepseek-chat", pricing)
|
||||
|
||||
expected = litellm.model_cost["deepseek/deepseek-chat"][
|
||||
"cache_read_input_token_cost"
|
||||
]
|
||||
assert result.input_cache_read == expected
|
||||
assert result.input_cache_read < pricing.prompt # sanity: it's a discount
|
||||
|
||||
|
||||
def test_backfill_strips_vendor_prefix_for_litellm_lookup() -> None:
|
||||
"""OpenRouter ids are vendor-prefixed (openai/gpt-4o); litellm keys most
|
||||
non-DeepSeek models without the prefix (gpt-4o)."""
|
||||
pricing = Pricing(prompt=2.5e-06, completion=1e-05)
|
||||
|
||||
result = backfill_cache_pricing("openai/gpt-4o", pricing)
|
||||
|
||||
expected = litellm.model_cost["gpt-4o"]["cache_read_input_token_cost"]
|
||||
assert result.input_cache_read == expected
|
||||
|
||||
|
||||
def test_backfill_fills_cache_write_rate() -> None:
|
||||
"""Anthropic cache writes cost more than input (1.25x); billing them at
|
||||
the input rate undercharges. litellm carries the write rate."""
|
||||
pricing = Pricing(prompt=3e-06, completion=1.5e-05)
|
||||
|
||||
result = backfill_cache_pricing("anthropic/claude-sonnet-4-5", pricing)
|
||||
|
||||
expected = litellm.model_cost["claude-sonnet-4-5"][
|
||||
"cache_creation_input_token_cost"
|
||||
]
|
||||
assert result.input_cache_write == expected
|
||||
assert result.input_cache_write > pricing.prompt # sanity: write premium
|
||||
|
||||
|
||||
def test_backfill_never_overwrites_openrouter_rates() -> None:
|
||||
"""When OpenRouter provides a cache rate, it is authoritative."""
|
||||
pricing = Pricing(
|
||||
prompt=2.1e-07, completion=7.9e-07, input_cache_read=1.3e-07
|
||||
)
|
||||
|
||||
result = backfill_cache_pricing("deepseek/deepseek-chat", pricing)
|
||||
|
||||
assert result.input_cache_read == 1.3e-07
|
||||
|
||||
|
||||
def test_backfill_unknown_model_unchanged() -> None:
|
||||
"""Models litellm doesn't know stay untouched (last-resort fallback to
|
||||
the input rate happens later, at billing time)."""
|
||||
pricing = Pricing(prompt=1e-06, completion=2e-06)
|
||||
|
||||
result = backfill_cache_pricing("artificial-dumbness/dumb-1", pricing)
|
||||
|
||||
assert result.input_cache_read == 0.0
|
||||
assert result.input_cache_write == 0.0
|
||||
|
||||
|
||||
def test_provider_fee_applies_to_backfilled_cache_rates() -> None:
|
||||
"""Backfill happens before the provider fee, so cache rates carry the
|
||||
same markup as every other price component."""
|
||||
provider = GenericUpstreamProvider(
|
||||
base_url="http://upstream.example", provider_fee=2.0
|
||||
)
|
||||
model = _make_model(
|
||||
"deepseek/deepseek-chat", Pricing(prompt=2.8e-07, completion=4.2e-07)
|
||||
)
|
||||
|
||||
adjusted = provider._apply_provider_fee_to_model(model)
|
||||
|
||||
litellm_read = litellm.model_cost["deepseek/deepseek-chat"][
|
||||
"cache_read_input_token_cost"
|
||||
]
|
||||
assert adjusted.pricing.input_cache_read == pytest.approx(litellm_read * 2.0)
|
||||
assert adjusted.pricing.prompt == pytest.approx(2.8e-07 * 2.0)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# calculate_cost — cached tokens billed at cache rates
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_sats_usd_price() -> None: # type: ignore[misc]
|
||||
with patch("routstr.payment.cost_calculation.sats_usd_price", return_value=5.0e-5):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_pricing(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
"""Model-based pricing: 1 msat per input token, 2 per output token,
|
||||
0.1 per cache-read token, 1.25 per cache-write token."""
|
||||
monkeypatch.setattr(settings, "fixed_pricing", False)
|
||||
model = Mock()
|
||||
model.sats_pricing = Pricing(
|
||||
prompt=0.001,
|
||||
completion=0.002,
|
||||
input_cache_read=0.0001,
|
||||
input_cache_write=0.00125,
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_cache_hits_billed_at_cache_rate(model_pricing: Mock) -> None:
|
||||
"""The reported overcharge scenario: a 10k-token prompt with 90% cache
|
||||
hits costs 2900 msats at honest rates, not the 11000 msats that billing
|
||||
every prompt token at the full input rate would charge."""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
}
|
||||
|
||||
with patch("routstr.proxy.get_model_instance", return_value=model_pricing):
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# 1000 input @ 1 msat + 9000 cache reads @ 0.1 msat + 500 output @ 2 msat
|
||||
assert result.input_msats == 1000
|
||||
assert result.cache_read_msats == 900
|
||||
assert result.output_msats == 1000
|
||||
assert result.total_msats == 2900
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_cache_write_billed_at_write_rate(model_pricing: Mock) -> None:
|
||||
"""Cache writes carry their premium rate (1.25x input here), instead of
|
||||
being silently billed at the plain input rate."""
|
||||
response = {
|
||||
"model": "claude-sonnet-4-5",
|
||||
"usage": {
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 100,
|
||||
"cache_read_input_tokens": 500,
|
||||
"cache_creation_input_tokens": 2000,
|
||||
},
|
||||
}
|
||||
|
||||
with patch("routstr.proxy.get_model_instance", return_value=model_pricing):
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# 300 @ 1 + 500 @ 0.1 + 2000 @ 1.25 + 100 @ 2
|
||||
assert result.cache_read_msats == 50
|
||||
assert result.cache_creation_msats == 2500
|
||||
assert result.total_msats == 3050
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_cache_rate_falls_back_to_input_rate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Documented last resort: when no cache rate could be resolved anywhere
|
||||
(OpenRouter and litellm both silent), cache reads bill at the input rate —
|
||||
never cheaper, never free."""
|
||||
monkeypatch.setattr(settings, "fixed_pricing", False)
|
||||
model = Mock()
|
||||
model.sats_pricing = Pricing(prompt=0.001, completion=0.002)
|
||||
|
||||
response = {
|
||||
"model": "dumb-1",
|
||||
"usage": {
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
}
|
||||
|
||||
with patch("routstr.proxy.get_model_instance", return_value=model):
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# 1000 @ 1 + 9000 @ 1 (fallback) + 500 @ 2
|
||||
assert result.total_msats == 11000
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Tests for cache token handling in cost calculation.
|
||||
|
||||
Covers OpenAI, Anthropic and DeepSeek caching formats, dialect precedence,
|
||||
edge cases, and billing accuracy.
|
||||
Covers OpenAI vs Anthropic caching formats, edge cases, and billing accuracy.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -17,6 +16,12 @@ from routstr.core.settings import settings
|
||||
from routstr.payment.cost_calculation import CostData, MaxCostData, calculate_cost
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session() -> AsyncMock:
|
||||
"""Mock AsyncSession for cost calculation tests."""
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_fixed_pricing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Mock settings and price to use fixed pricing."""
|
||||
@@ -36,7 +41,7 @@ def patch_sats_usd_price() -> None: # type: ignore[misc]
|
||||
# Test 1: OpenAI Cache Format
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_cache_subtraction() -> None:
|
||||
async def test_openai_cache_subtraction(mock_session: AsyncMock) -> None:
|
||||
"""OpenAI includes cached_tokens in prompt_tokens, subtract them."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -48,7 +53,7 @@ async def test_openai_cache_subtraction() -> None:
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000 # 2000 - 1000
|
||||
@@ -60,7 +65,7 @@ async def test_openai_cache_subtraction() -> None:
|
||||
# Test 2: Anthropic Cache Format
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_cache_additive(mock_fixed_pricing: None) -> None:
|
||||
async def test_anthropic_cache_additive(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Anthropic cache tokens are separate (additive) from input_tokens."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
@@ -71,7 +76,7 @@ async def test_anthropic_cache_additive(mock_fixed_pricing: None) -> None:
|
||||
"cache_read_input_tokens": 0,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 500
|
||||
@@ -84,7 +89,7 @@ async def test_anthropic_cache_additive(mock_fixed_pricing: None) -> None:
|
||||
# Test 3: Invalid Cache (Edge Case)
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_read_exceeds_prompt_tokens(mock_fixed_pricing: None) -> None:
|
||||
async def test_cache_read_exceeds_prompt_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle buggy upstream reporting cached > prompt_tokens."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -96,7 +101,7 @@ async def test_cache_read_exceeds_prompt_tokens(mock_fixed_pricing: None) -> Non
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
# Should not go negative
|
||||
assert isinstance(result, CostData)
|
||||
@@ -109,7 +114,7 @@ async def test_cache_read_exceeds_prompt_tokens(mock_fixed_pricing: None) -> Non
|
||||
# Test 4: Malformed Token Values
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_cache_tokens_coerce_to_zero(mock_fixed_pricing: None) -> None:
|
||||
async def test_malformed_cache_tokens_coerce_to_zero(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle non-numeric cache token values."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -122,7 +127,7 @@ async def test_malformed_cache_tokens_coerce_to_zero(mock_fixed_pricing: None) -
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
# Both should coerce to 0
|
||||
assert isinstance(result, CostData)
|
||||
@@ -134,7 +139,7 @@ async def test_malformed_cache_tokens_coerce_to_zero(mock_fixed_pricing: None) -
|
||||
# Test 5: Anthropic Cache Not Subtracted
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_cache_not_subtracted(mock_fixed_pricing: None) -> None:
|
||||
async def test_anthropic_cache_not_subtracted(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Anthropic cache fields should NOT be subtracted from input_tokens."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
@@ -144,7 +149,7 @@ async def test_anthropic_cache_not_subtracted(mock_fixed_pricing: None) -> None:
|
||||
"cache_read_input_tokens": 200, # ← Additive, don't subtract
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
# Anthropic: input_tokens stays as-is
|
||||
assert isinstance(result, CostData)
|
||||
@@ -156,7 +161,7 @@ async def test_anthropic_cache_not_subtracted(mock_fixed_pricing: None) -> None:
|
||||
# Test 6: Only Cache Read, No Regular Input
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_cache_read_tokens(mock_fixed_pricing: None) -> None:
|
||||
async def test_only_cache_read_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle response with only cache read tokens."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -168,7 +173,7 @@ async def test_only_cache_read_tokens(mock_fixed_pricing: None) -> None:
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 0 # max(0, 0 - 1000)
|
||||
@@ -180,7 +185,7 @@ async def test_only_cache_read_tokens(mock_fixed_pricing: None) -> None:
|
||||
# Test 7: Only Cache Creation
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_cache_creation_tokens(mock_fixed_pricing: None) -> None:
|
||||
async def test_only_cache_creation_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle response with only cache creation tokens (Anthropic)."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
@@ -191,7 +196,7 @@ async def test_only_cache_creation_tokens(mock_fixed_pricing: None) -> None:
|
||||
"cache_read_input_tokens": 0,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 500
|
||||
@@ -204,7 +209,7 @@ async def test_only_cache_creation_tokens(mock_fixed_pricing: None) -> None:
|
||||
# Test 8: Both Cache Read and Creation
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_cache_read_and_creation(mock_fixed_pricing: None) -> None:
|
||||
async def test_both_cache_read_and_creation(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle response with both cache read and creation."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
@@ -215,7 +220,7 @@ async def test_both_cache_read_and_creation(mock_fixed_pricing: None) -> None:
|
||||
"cache_read_input_tokens": 500,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 300
|
||||
@@ -228,7 +233,7 @@ async def test_both_cache_read_and_creation(mock_fixed_pricing: None) -> None:
|
||||
# Test 9: Token Field Fallback
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_field_fallback_order(mock_fixed_pricing: None) -> None:
|
||||
async def test_token_field_fallback_order(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Verify fallback order for token extraction."""
|
||||
# When prompt_tokens is not present, fall back to input_tokens
|
||||
response = {
|
||||
@@ -238,7 +243,7 @@ async def test_token_field_fallback_order(mock_fixed_pricing: None) -> None:
|
||||
"completion_tokens": 50,
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 250
|
||||
@@ -249,21 +254,20 @@ async def test_token_field_fallback_order(mock_fixed_pricing: None) -> None:
|
||||
# Test 10: Float Token Values
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_float_token_values_coerced_to_int(mock_fixed_pricing: None) -> None:
|
||||
async def test_float_token_values_coerced_to_int(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle float token values by converting to int."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
"usage": {
|
||||
"prompt_tokens": 100.7, # Float
|
||||
"completion_tokens": 50.3, # Float
|
||||
"prompt_tokens_details": {"cached_tokens": 25.9}, # Float
|
||||
"cache_read_input_tokens": 25.9, # Float
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# cached_tokens are part of prompt_tokens (OpenAI dialect) → subtracted: 100 - 25
|
||||
assert result.input_tokens == 75 # Floored
|
||||
assert result.input_tokens == 100 # Floored
|
||||
assert result.output_tokens == 50 # Floored
|
||||
assert result.cache_read_input_tokens == 25 # Floored
|
||||
|
||||
@@ -272,7 +276,7 @@ async def test_float_token_values_coerced_to_int(mock_fixed_pricing: None) -> No
|
||||
# Test 11: Boolean Cache Tokens
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_boolean_cache_tokens_coerced_to_zero(mock_fixed_pricing: None) -> None:
|
||||
async def test_boolean_cache_tokens_coerced_to_zero(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle boolean cache token values by coercing to zero."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -282,7 +286,7 @@ async def test_boolean_cache_tokens_coerced_to_zero(mock_fixed_pricing: None) ->
|
||||
"cache_read_input_tokens": True, # Boolean
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.cache_read_input_tokens == 0 # Boolean coerced to 0
|
||||
@@ -293,7 +297,7 @@ async def test_boolean_cache_tokens_coerced_to_zero(mock_fixed_pricing: None) ->
|
||||
# Test 12: Zero Cache Tokens
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_cache_tokens(mock_fixed_pricing: None) -> None:
|
||||
async def test_zero_cache_tokens(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""Handle explicit zero cache tokens."""
|
||||
response = {
|
||||
"model": "gpt-4",
|
||||
@@ -305,7 +309,7 @@ async def test_zero_cache_tokens(mock_fixed_pricing: None) -> None:
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.cache_read_input_tokens == 0
|
||||
@@ -313,105 +317,138 @@ async def test_zero_cache_tokens(mock_fixed_pricing: None) -> None:
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DeepSeek Cache Format
|
||||
# DeepSeek emits neither OpenAI's prompt_tokens_details nor Anthropic's
|
||||
# cache_read_input_tokens — only prompt_cache_hit_tokens and
|
||||
# prompt_cache_miss_tokens, with the documented guarantee
|
||||
# prompt_tokens = hit + miss. Hits are ~10x cheaper upstream, so billing
|
||||
# them as regular input is a large overcharge.
|
||||
# Test 12b: DeepSeek Cache Hit/Miss Format
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_cache_hit_tokens_extracted() -> None:
|
||||
"""DeepSeek cache hits are extracted and removed from regular input.
|
||||
|
||||
Payload shape verbatim from the DeepSeek API reference (usage object).
|
||||
"""
|
||||
async def test_deepseek_cache_hit_subtraction(
|
||||
mock_session: AsyncMock, mock_fixed_pricing: None
|
||||
) -> None:
|
||||
"""DeepSeek prompt_tokens = hit + miss; the hit is a cache read, subtract it."""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 10000, # = hit + miss
|
||||
"completion_tokens": 500,
|
||||
"total_tokens": 10500,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
"prompt_tokens": 10000, # ← hit + miss
|
||||
"completion_tokens": 200,
|
||||
"prompt_cache_hit_tokens": 9000, # ← cache read (0.1x upstream)
|
||||
"prompt_cache_miss_tokens": 1000, # ← uncached input
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000 # only the cache misses
|
||||
assert result.input_tokens == 1000 # 10000 - 9000
|
||||
assert result.cache_read_input_tokens == 9000
|
||||
assert result.output_tokens == 500
|
||||
assert result.cache_creation_input_tokens == 0 # DeepSeek has no cache write
|
||||
assert result.output_tokens == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_all_tokens_cached() -> None:
|
||||
"""A fully cached DeepSeek prompt bills zero regular input tokens."""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 5000,
|
||||
"completion_tokens": 100,
|
||||
"prompt_cache_hit_tokens": 5000,
|
||||
"prompt_cache_miss_tokens": 0,
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 0
|
||||
assert result.cache_read_input_tokens == 5000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialect_precedence_never_double_subtracts() -> None:
|
||||
"""If a vendor emits both OpenAI-style and DeepSeek-style cache fields for
|
||||
the same cached tokens, they are counted once, not subtracted twice."""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_tokens_details": {"cached_tokens": 9000},
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000 # 10000 - 9000, applied exactly once
|
||||
assert result.cache_read_input_tokens == 9000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_malformed_hit_tokens_coerce_to_zero() -> None:
|
||||
"""Malformed DeepSeek cache fields degrade to billing all input at full
|
||||
rate instead of crashing or going negative."""
|
||||
async def test_deepseek_no_cache_hit(
|
||||
mock_session: AsyncMock, mock_fixed_pricing: None
|
||||
) -> None:
|
||||
"""All-miss DeepSeek response leaves input_tokens untouched."""
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 50,
|
||||
"prompt_cache_hit_tokens": "garbage",
|
||||
"prompt_cache_miss_tokens": -5,
|
||||
"prompt_cache_hit_tokens": 0,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000
|
||||
assert result.cache_read_input_tokens == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_fields_take_priority_over_deepseek(
|
||||
mock_session: AsyncMock, mock_fixed_pricing: None
|
||||
) -> None:
|
||||
"""Explicit cache_read_input_tokens wins; DeepSeek hit must not double-subtract."""
|
||||
response = {
|
||||
"model": "claude-3-5-sonnet",
|
||||
"usage": {
|
||||
"input_tokens": 500,
|
||||
"output_tokens": 100,
|
||||
"cache_read_input_tokens": 200,
|
||||
"prompt_cache_hit_tokens": 9999, # ← ignored, Anthropic field present
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 500 # not subtracted
|
||||
assert result.cache_read_input_tokens == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 12c: DeepSeek billed at cache rate, not full input (the actual bug)
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_cache_hits_billed_at_cache_rate(
|
||||
mock_session: AsyncMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""10k prompt at 90% cache hit must bill hits at the cache-read rate.
|
||||
|
||||
Regression for the overcharge bug: cached prompt tokens were billed at the
|
||||
full input rate. With per-token prompt rate P and cache-read rate 0.1*P:
|
||||
honest = 1000*P (uncached) + 9000*0.1*P (cache read) = 1900*P
|
||||
buggy = 10000*P (all at full input rate)
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
monkeypatch.setattr(settings, "fixed_pricing", False)
|
||||
|
||||
# per-token sats rates; cache read is 0.1x the prompt rate (DeepSeek upstream)
|
||||
prompt_rate = 1.0e-6
|
||||
sats_pricing = SimpleNamespace(
|
||||
prompt=prompt_rate,
|
||||
completion=2.0e-6,
|
||||
input_cache_read=prompt_rate * 0.1,
|
||||
input_cache_write=0.0,
|
||||
)
|
||||
model_obj = SimpleNamespace(sats_pricing=sats_pricing)
|
||||
monkeypatch.setattr(
|
||||
"routstr.proxy.get_model_instance", lambda _id: model_obj
|
||||
)
|
||||
|
||||
response = {
|
||||
"model": "deepseek-chat",
|
||||
"usage": {
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 0,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
}
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
assert result.input_tokens == 1000
|
||||
assert result.cache_read_input_tokens == 9000
|
||||
|
||||
# input_rate (msats/1k) = prompt_rate * 1e6 ; per-token msats = prompt_rate*1000
|
||||
per_tok_input_msats = prompt_rate * 1000
|
||||
expected_input = round(1000 / 1000 * (prompt_rate * 1e6), 3)
|
||||
expected_cache = round(9000 / 1000 * (prompt_rate * 0.1 * 1e6), 3)
|
||||
assert result.input_msats == int(expected_input)
|
||||
assert result.cache_read_msats == int(expected_cache)
|
||||
# honest total (1900*P) must be far below the buggy all-input charge (10000*P)
|
||||
buggy_total = 10000 * per_tok_input_msats
|
||||
assert result.total_msats < buggy_total * 0.25
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 13: Missing Usage Block
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_usage_block(mock_fixed_pricing: None) -> None:
|
||||
async def test_missing_usage_block(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""When usage is missing, return MaxCostData with zero tokens."""
|
||||
response = {"model": "gpt-4", "choices": [{"message": {"content": "test"}}]}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, MaxCostData)
|
||||
assert result.input_tokens == 0
|
||||
@@ -423,10 +460,10 @@ async def test_missing_usage_block(mock_fixed_pricing: None) -> None:
|
||||
# Test 14: Null Usage Block
|
||||
# ============================================================================
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_usage_block(mock_fixed_pricing: None) -> None:
|
||||
async def test_null_usage_block(mock_session: AsyncMock, mock_fixed_pricing: None) -> None:
|
||||
"""When usage is null, return MaxCostData with zero tokens."""
|
||||
response = {"model": "gpt-4", "usage": None}
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
result = await calculate_cost(response, max_cost=100000, session=mock_session)
|
||||
|
||||
assert isinstance(result, MaxCostData)
|
||||
assert result.input_tokens == 0
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.wallet import fetch_all_balances
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session(): # type: ignore[no-untyped-def]
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
def _patches(proof_amount: int = 1000): # type: ignore[no-untyped-def]
|
||||
proof = MagicMock(amount=proof_amount)
|
||||
return [
|
||||
patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())),
|
||||
patch(
|
||||
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||
MagicMock(return_value=[proof]),
|
||||
),
|
||||
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.db.create_session", _fake_session),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_balances_falls_back_to_primary_mint() -> None:
|
||||
"""With empty cashu_mints, balances are still fetched for primary_mint."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "cashu_mints", []), patch.object(
|
||||
settings, "primary_mint", "http://primary:3338"
|
||||
):
|
||||
for p in _patches(proof_amount=1000):
|
||||
p.start()
|
||||
try:
|
||||
details, total_wallet, total_user, owner = await fetch_all_balances(
|
||||
units=["sat"]
|
||||
)
|
||||
finally:
|
||||
patch.stopall()
|
||||
|
||||
assert [d["mint_url"] for d in details] == ["http://primary:3338"]
|
||||
assert total_wallet == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_balances_no_duplicate_primary_mint() -> None:
|
||||
"""primary_mint already in cashu_mints is not inspected twice."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(
|
||||
settings, "cashu_mints", ["http://primary:3338"]
|
||||
), patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
for p in _patches(proof_amount=1000):
|
||||
p.start()
|
||||
try:
|
||||
details, total_wallet, _total_user, _owner = await fetch_all_balances(
|
||||
units=["sat"]
|
||||
)
|
||||
finally:
|
||||
patch.stopall()
|
||||
|
||||
assert [d["mint_url"] for d in details] == ["http://primary:3338"]
|
||||
assert total_wallet == 1000
|
||||
@@ -500,7 +500,7 @@ async def test_streaming_emits_sse_and_reconciles_cost_at_end() -> None:
|
||||
captured_cost_call: dict[str, Any] = {}
|
||||
|
||||
async def fake_adjust(
|
||||
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int, usage: Any = None
|
||||
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int
|
||||
) -> dict:
|
||||
captured_cost_call["combined_data"] = combined_data
|
||||
captured_cost_call["max_cost"] = max_cost
|
||||
@@ -589,7 +589,7 @@ async def test_streaming_handles_iterator_yielding_raw_sse_bytes() -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_adjust(
|
||||
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int, usage: Any = None
|
||||
fresh_key: Any, combined_data: Any, sess: Any, max_cost: int
|
||||
) -> dict:
|
||||
captured["combined_data"] = combined_data
|
||||
return fake_cost
|
||||
@@ -841,6 +841,109 @@ async def test_x_cashu_streaming_replays_events_and_sets_refund_header() -> None
|
||||
assert "event: message_stop" in joined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt-caching marker pass-through (Anthropic / OpenRouter)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Anthropic prompt caching is activated by `cache_control: {"type": "ephemeral"}`
|
||||
# markers embedded INSIDE the request JSON (messages[].content[], system[],
|
||||
# tools[]). Anthropic and OpenRouter providers set
|
||||
# ``supports_anthropic_messages = True`` and therefore forward the body through
|
||||
# ``prepare_request_body`` (raw pass-through) — NOT the litellm translation path
|
||||
# that strips ``ANTHROPIC_ONLY_FIELDS``. These tests lock in that the caching
|
||||
# markers survive that forwarding for both /v1/messages and /chat/completions.
|
||||
|
||||
|
||||
def _body_with_cache_control(*, stream: bool = True) -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"model": "claude-3-5-sonnet",
|
||||
"system": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "big reusable context",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "hello",
|
||||
"cache_control": {"type": "ephemeral", "ttl": "1h"},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{"name": "t", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
# top-level marker some clients send; must also survive
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
"stream": stream,
|
||||
"max_tokens": 64,
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def test_prepare_request_body_preserves_nested_cache_control() -> None:
|
||||
"""Raw-forward path (Anthropic/OpenRouter) must keep cache_control markers."""
|
||||
provider = _make_provider()
|
||||
model = _make_model(model_id="claude-3-5-sonnet")
|
||||
|
||||
out = provider.prepare_request_body(_body_with_cache_control(stream=True), model)
|
||||
assert out is not None
|
||||
data = json.loads(out)
|
||||
|
||||
# nested markers intact
|
||||
assert data["system"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
assert data["messages"][0]["content"][0]["cache_control"] == {
|
||||
"type": "ephemeral",
|
||||
"ttl": "1h",
|
||||
}
|
||||
assert data["tools"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
# top-level marker intact
|
||||
assert data["cache_control"] == {"type": "ephemeral"}
|
||||
# stream_options.include_usage injected without disturbing cache markers
|
||||
assert data["stream_options"]["include_usage"] is True
|
||||
|
||||
|
||||
def test_prepare_request_body_preserves_cache_control_when_no_other_changes() -> None:
|
||||
"""Non-streaming request with no model rewrite still preserves markers.
|
||||
|
||||
Even when ``prepare_request_body`` makes no changes it must return a body
|
||||
that still carries the cache_control markers (here: returns original bytes).
|
||||
"""
|
||||
provider = _make_provider()
|
||||
model = _make_model(model_id="claude-3-5-sonnet")
|
||||
|
||||
out = provider.prepare_request_body(_body_with_cache_control(stream=False), model)
|
||||
assert out is not None
|
||||
data = json.loads(out)
|
||||
assert data["messages"][0]["content"][0]["cache_control"] == {
|
||||
"type": "ephemeral",
|
||||
"ttl": "1h",
|
||||
}
|
||||
assert data["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
|
||||
def test_native_messages_providers_skip_anthropic_field_stripping() -> None:
|
||||
"""Anthropic & OpenRouter forward natively, so they never reach the
|
||||
litellm ANTHROPIC_ONLY_FIELDS strip that would drop cache_control."""
|
||||
from routstr.upstream.anthropic import AnthropicUpstreamProvider
|
||||
from routstr.upstream.openrouter import OpenRouterUpstreamProvider
|
||||
|
||||
assert AnthropicUpstreamProvider.supports_anthropic_messages is True
|
||||
assert OpenRouterUpstreamProvider.supports_anthropic_messages is True
|
||||
# `cache_control` IS in the strip list — proving it only matters on the
|
||||
# non-native litellm path, which these providers bypass.
|
||||
from routstr.upstream.messages_dispatch import ANTHROPIC_ONLY_FIELDS
|
||||
|
||||
assert "cache_control" in ANTHROPIC_ONLY_FIELDS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# forward_request gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Durable, balance-versioned refund idempotency — core #412.
|
||||
|
||||
These tests exercise the full ``POST /refund`` -> topup -> spend -> ``POST /refund``
|
||||
sequence against a **real in-memory SQLite database** (mints/network mocked).
|
||||
|
||||
The bug (#412): the refund endpoint cached the issued refund token keyed by
|
||||
``sha256(bearer)`` and re-served it whenever ``total_balance <= 0``. After a
|
||||
refund (token T1, balance debited to 0), a *topup* followed by a *spend* brings
|
||||
the balance back to 0 — but the cache still holds T1, so a second refund
|
||||
re-serves the already-spent T1 -> "proofs already spent" / fund loss.
|
||||
|
||||
The Lightning topup path is the load-bearing gap: it credits balance from a
|
||||
background watcher with no bearer/authorization in scope, so a header-keyed
|
||||
cache invalidation structurally cannot fire there.
|
||||
|
||||
The durable fix bumps ``ApiKey.balance_version`` atomically on *every* credit
|
||||
(cashu topup, Lightning topup, new-key credit) and keys refund idempotency on
|
||||
``(api_key_hash, balance_version)`` in a DB table, so any credit anywhere
|
||||
invalidates prior refund tokens regardless of worker or auth context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
||||
os.environ.setdefault("CASHU_MINTS", "https://mint.example.com")
|
||||
os.environ.setdefault("NSEC", "nsec1testkey")
|
||||
|
||||
import routstr.balance as balance_mod # noqa: E402
|
||||
from routstr.core.db import ApiKey # noqa: E402
|
||||
from routstr.lightning import topup_api_key_from_invoice # noqa: E402
|
||||
from routstr.wallet import credit_balance # noqa: E402
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def engine(): # type: ignore[no-untyped-def]
|
||||
eng = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
yield eng
|
||||
await eng.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session(engine) -> AsyncGenerator[AsyncSession, None]: # type: ignore[no-untyped-def]
|
||||
async with AsyncSession(engine, expire_on_commit=False) as s:
|
||||
yield s
|
||||
|
||||
|
||||
async def _new_key(session: AsyncSession, hashed_key: str, balance: int) -> ApiKey:
|
||||
key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=balance,
|
||||
refund_currency="sat",
|
||||
refund_mint_url="https://mint.example.com",
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
return key
|
||||
|
||||
|
||||
async def _spend_to_zero(session: AsyncSession, hashed_key: str) -> None:
|
||||
"""Simulate the user spending their whole balance (no version bump)."""
|
||||
key = await session.get(ApiKey, hashed_key)
|
||||
assert key is not None
|
||||
key.balance = 0
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _clear_refund_cache() -> None:
|
||||
# Defensively clear the legacy in-process cache if it still exists.
|
||||
cache = getattr(balance_mod, "_refund_cache", None)
|
||||
if isinstance(cache, dict):
|
||||
cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token minting fakes: each call returns a fresh, unique token so we can assert
|
||||
# whether a *new* token was minted (correct) or a *stale* one re-served (bug).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TokenMinter:
|
||||
def __init__(self) -> None:
|
||||
self.counter = 0
|
||||
self.minted: list[str] = []
|
||||
|
||||
async def __call__(self, amount: int, unit: str, mint_url: str | None = None) -> str:
|
||||
self.counter += 1
|
||||
tok = f"cashuMINTED_{self.counter}_amt{amount}"
|
||||
self.minted.append(tok)
|
||||
return tok
|
||||
|
||||
|
||||
async def _do_refund(session: AsyncSession, bearer: str) -> dict:
|
||||
result = await balance_mod.refund_wallet_endpoint(
|
||||
authorization=f"Bearer {bearer}",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
assert isinstance(result, dict), f"expected dict refund result, got {result!r}"
|
||||
return result
|
||||
|
||||
|
||||
async def _refund_token_or_none(session: AsyncSession, bearer: str) -> str | None:
|
||||
"""Return the refund token a /refund call yields, or None if the endpoint
|
||||
declines (e.g. 400 'No balance to refund'). The #412 bug manifests as a
|
||||
*stale token string* being returned where None (or a fresh token) is
|
||||
correct."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
result = await balance_mod.refund_wallet_endpoint(
|
||||
authorization=f"Bearer {bearer}",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
except HTTPException:
|
||||
return None
|
||||
if isinstance(result, dict):
|
||||
return result.get("token")
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. #412 across CASHU topup
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_412_cashu_topup_second_refund_is_not_stale_token(session: AsyncSession) -> None:
|
||||
_clear_refund_cache()
|
||||
hashed = "cashu412hash"
|
||||
bearer = f"sk-{hashed}"
|
||||
await _new_key(session, hashed, balance=1000) # 1000 msats
|
||||
|
||||
minter = TokenMinter()
|
||||
|
||||
async def fake_recieve(token: str) -> tuple[int, str, str]:
|
||||
# crediting 1 sat = 1000 msats
|
||||
return 1, "sat", "https://mint.example.com"
|
||||
|
||||
with (
|
||||
patch("routstr.balance.send_token", minter),
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(side_effect=lambda k, s: k)),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
patch("routstr.wallet.recieve_token", AsyncMock(side_effect=fake_recieve)),
|
||||
patch("routstr.wallet.store_cashu_transaction", AsyncMock()),
|
||||
):
|
||||
# 1) First refund: mints T1, debits balance to 0, caches/stores T1.
|
||||
r1 = await _do_refund(session, bearer)
|
||||
t1 = r1["token"]
|
||||
assert t1 == "cashuMINTED_1_amt1"
|
||||
|
||||
# 2) Cashu topup: credit balance again (user adds funds & uses the key).
|
||||
key = await session.get(ApiKey, hashed)
|
||||
assert key is not None
|
||||
await credit_balance("cashuAtopup", key, session)
|
||||
|
||||
# 3) Spend the topped-up balance back to zero (normal usage).
|
||||
await _spend_to_zero(session, hashed)
|
||||
|
||||
# 4) Second refund while balance == 0. The legacy cache serves the
|
||||
# STALE T1 here (fund loss: T1's proofs were already spent/minted
|
||||
# against and the user may have already redeemed it). The durable
|
||||
# fix must NOT re-serve T1 — the topup bumped balance_version, so
|
||||
# the stored T1 is no longer valid at the current version (and with
|
||||
# balance 0 there is nothing to refund -> declines).
|
||||
t2 = await _refund_token_or_none(session, bearer)
|
||||
|
||||
assert t2 != t1, (
|
||||
f"#412 REGRESSION: second refund re-served stale token {t1!r} "
|
||||
f"after a cashu topup + spend. The topup must invalidate T1."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. #412 across LIGHTNING topup (the load-bearing gap)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_412_lightning_topup_second_refund_is_not_stale_token(session: AsyncSession) -> None:
|
||||
_clear_refund_cache()
|
||||
hashed = "ln412hash"
|
||||
bearer = f"sk-{hashed}"
|
||||
await _new_key(session, hashed, balance=1000)
|
||||
|
||||
minter = TokenMinter()
|
||||
|
||||
# Fake the cashu wallet used by topup_api_key_from_invoice -> wallet.mint
|
||||
fake_wallet = AsyncMock()
|
||||
fake_wallet.mint = AsyncMock(return_value=None)
|
||||
|
||||
class _Invoice:
|
||||
amount_sats = 1
|
||||
api_key_hash = hashed
|
||||
id = "inv1"
|
||||
payment_hash = "ph1"
|
||||
|
||||
with (
|
||||
patch("routstr.balance.send_token", minter),
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(side_effect=lambda k, s: k)),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
patch("routstr.lightning.get_wallet", AsyncMock(return_value=fake_wallet)),
|
||||
):
|
||||
# 1) First refund: mint T1, debit to 0, cache/store T1.
|
||||
r1 = await _do_refund(session, bearer)
|
||||
t1 = r1["token"]
|
||||
|
||||
# 2) LIGHTNING topup via the background-watcher code path. No bearer in
|
||||
# scope here — a header-keyed cache invalidation cannot fire. The
|
||||
# durable fix must bump balance_version inside this DB txn.
|
||||
await topup_api_key_from_invoice(_Invoice(), session) # type: ignore[arg-type]
|
||||
await session.commit()
|
||||
|
||||
# 3) Spend the Lightning-topped-up balance back to zero.
|
||||
await _spend_to_zero(session, hashed)
|
||||
|
||||
# 4) Second refund at balance 0: must NOT re-serve the stale T1.
|
||||
t2 = await _refund_token_or_none(session, bearer)
|
||||
|
||||
assert t2 != t1, (
|
||||
f"#412 REGRESSION (Lightning): second refund re-served stale token "
|
||||
f"{t1!r} after a Lightning topup. The Lightning credit must bump "
|
||||
f"balance_version so the cached/stored refund token is invalidated."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. In-flight idempotency: two refunds at the SAME balance_version (no
|
||||
# intervening credit) return the SAME token and debit the balance once.
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inflight_idempotency_same_version_same_token(session: AsyncSession) -> None:
|
||||
_clear_refund_cache()
|
||||
hashed = "inflighthash"
|
||||
bearer = f"sk-{hashed}"
|
||||
await _new_key(session, hashed, balance=2000)
|
||||
|
||||
minter = TokenMinter()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.send_token", minter),
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(side_effect=lambda k, s: k)),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
):
|
||||
r1 = await _do_refund(session, bearer)
|
||||
# Second refund with NO intervening credit -> same balance_version.
|
||||
# The balance is now 0; idempotency must re-serve the same token.
|
||||
r2 = await _do_refund(session, bearer)
|
||||
|
||||
assert r1["token"] == r2["token"], (
|
||||
"in-flight idempotency broken: two refunds at the same balance_version "
|
||||
"with no intervening credit must return the SAME token"
|
||||
)
|
||||
# Exactly one token minted -> balance debited exactly once.
|
||||
assert len(minter.minted) == 1, (
|
||||
f"idempotent retry must NOT mint a second token; minted={minter.minted}"
|
||||
)
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Tests for vendor-agnostic usage normalization.
|
||||
|
||||
Specifies the seam that keeps vendor usage dialects out of generic billing
|
||||
code: a canonical ``NormalizedUsage`` shape produced by
|
||||
``routstr.payment.usage.normalize_usage`` (union parser for the known,
|
||||
non-colliding dialects). ``calculate_cost`` normalizes the response's usage
|
||||
object with this parser and needs no vendor knowledge of its own.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("UPSTREAM_BASE_URL", "http://test")
|
||||
os.environ.setdefault("UPSTREAM_API_KEY", "test")
|
||||
os.environ.setdefault("LIGHTNING_ADDRESS", "test@stm.to")
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.payment.usage import NormalizedUsage, normalize_usage
|
||||
|
||||
# ============================================================================
|
||||
# The union parser: one canonical shape for all known dialects
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"usage,expected",
|
||||
[
|
||||
# OpenAI: cached_tokens included in prompt_tokens → subtracted
|
||||
(
|
||||
{
|
||||
"prompt_tokens": 2000,
|
||||
"completion_tokens": 100,
|
||||
"prompt_tokens_details": {"cached_tokens": 800},
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=1200,
|
||||
output_tokens=100,
|
||||
cache_read_tokens=800,
|
||||
cache_write_tokens=0,
|
||||
),
|
||||
),
|
||||
# DeepSeek: hit/miss fields, prompt_tokens = hit + miss → hit subtracted
|
||||
(
|
||||
{
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=1000,
|
||||
output_tokens=500,
|
||||
cache_read_tokens=9000,
|
||||
cache_write_tokens=0,
|
||||
),
|
||||
),
|
||||
# Anthropic: cache fields additive, input_tokens NOT reduced
|
||||
(
|
||||
{
|
||||
"input_tokens": 300,
|
||||
"output_tokens": 100,
|
||||
"cache_read_input_tokens": 500,
|
||||
"cache_creation_input_tokens": 2000,
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=300,
|
||||
output_tokens=100,
|
||||
cache_read_tokens=500,
|
||||
cache_write_tokens=2000,
|
||||
),
|
||||
),
|
||||
# Plain OpenAI without caching
|
||||
(
|
||||
{"prompt_tokens": 100, "completion_tokens": 50},
|
||||
NormalizedUsage(input_tokens=100, output_tokens=50),
|
||||
),
|
||||
# OpenRouter: cache writes nested as prompt_tokens_details.cache_write_tokens,
|
||||
# both reads and writes included in prompt_tokens → both subtracted
|
||||
(
|
||||
{
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 60,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 5000,
|
||||
"cache_write_tokens": 2000,
|
||||
},
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=3000,
|
||||
output_tokens=60,
|
||||
cache_read_tokens=5000,
|
||||
cache_write_tokens=2000,
|
||||
),
|
||||
),
|
||||
# litellm-normalized Anthropic: prompt_tokens is the grand total and the
|
||||
# write field is named cache_creation_tokens; top-level fields mirror it.
|
||||
# prompt_tokens present → both subtracted (NOT additive like native).
|
||||
(
|
||||
{
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 100,
|
||||
"cache_read_input_tokens": 5000,
|
||||
"cache_creation_input_tokens": 2000,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 5000,
|
||||
"cache_creation_tokens": 2000,
|
||||
},
|
||||
},
|
||||
NormalizedUsage(
|
||||
input_tokens=3000,
|
||||
output_tokens=100,
|
||||
cache_read_tokens=5000,
|
||||
cache_write_tokens=2000,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_normalize_usage_dialects(usage: dict, expected: NormalizedUsage) -> None:
|
||||
"""Each known vendor dialect maps onto the same canonical shape."""
|
||||
assert normalize_usage(usage) == expected
|
||||
|
||||
|
||||
def test_normalize_usage_absent_usage() -> None:
|
||||
"""Missing/invalid usage yields None so callers can bill at max cost."""
|
||||
assert normalize_usage(None) is None
|
||||
assert normalize_usage("not a dict") is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_normalize_usage_never_negative() -> None:
|
||||
"""Buggy upstreams reporting more cached than prompt tokens clamp to 0."""
|
||||
result = normalize_usage(
|
||||
{
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"prompt_cache_hit_tokens": 150,
|
||||
}
|
||||
)
|
||||
assert result is not None
|
||||
assert result.input_tokens == 0
|
||||
assert result.cache_read_tokens == 150
|
||||
@@ -297,13 +297,16 @@ export default function ProvidersPage() {
|
||||
};
|
||||
|
||||
const toggleProviderExpansion = (providerId: number) => {
|
||||
if (expandedProviders.has(providerId)) {
|
||||
setExpandedProviders(new Set());
|
||||
const newExpanded = new Set(expandedProviders);
|
||||
if (newExpanded.has(providerId)) {
|
||||
newExpanded.delete(providerId);
|
||||
} else {
|
||||
newExpanded.add(providerId);
|
||||
}
|
||||
setExpandedProviders(newExpanded);
|
||||
if (!newExpanded.has(providerId)) {
|
||||
setViewingModels(null);
|
||||
} else {
|
||||
// Accordion: only one provider open at a time so switching to another
|
||||
// provider's models auto-collapses the previously expanded one.
|
||||
setExpandedProviders(new Set([providerId]));
|
||||
setViewingModels(providerId);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user