Compare commits

..
Author SHA1 Message Date
9qeklajc 9385b01e58 add pass through test 2026-06-19 10:57:45 +02:00
9qeklajc c5adfd9c3a fix: bill DeepSeek prompt cache hits at the cache rate instead of the full input rate 2026-06-19 10:02:43 +02:00
9qeklajcandGitHub d4318dcc07 Merge pull request #556 from jeroenubbink/fix/trusted-mint-input-fee
fix: deduct mint NUT-02 input fee when crediting trusted-mint topups
2026-06-18 21:48:24 +02:00
Jeroen UbbinkandClaude Opus 4.8 75ed865a5f fix: deduct input fee in swap_to_primary_mint same-mint shortcut
The same-mint shortcut in swap_to_primary_mint did a same-mint
split(include_fees=True) — which burns the mint's NUT-02 per-proof input
fee — but returned the full token amount, over-crediting the user (the
same bug already fixed for the trusted-mint receive path). It also
skipped DLEQ verification that the trusted path performs.

Extract the shared same-mint redeem into _redeem_same_mint (load mint,
verify DLEQ, split, credit amount - input_fees) and delegate from both
recieve_token and the shortcut, so the two paths can't drift again. This
shortcut is reachable when PRIMARY_MINT_URL is set outside CASHU_MINTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:48:49 +02:00
Jeroen UbbinkandClaude Opus 4.8 7969a8da55 test: clarify mocked input-fee comment in trusted-mint test
The comment described "21 proofs @ 100 ppk" arithmetic, but the mocked
token has one proof and get_fees_for_proofs is hard-mocked to 3, so the
math wasn't exercised. Describe what the mock actually does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 12:26:44 +02:00
9qeklajcandGitHub 830c299130 Merge pull request #557 from Routstr/rollback-token-generation-when-request-failed
rollback when token generation failed
2026-06-17 22:17:55 +02:00
9qeklajc e9dced8148 rollback when token generation failed 2026-06-17 17:27:08 +02:00
Jeroen UbbinkandClaude Opus 4.8 ecd46975b4 fix: deduct mint NUT-02 input fee when crediting trusted-mint topups
recieve_token swaps the incoming proofs at the same mint with
include_fees=True (paying the mint's NUT-02 per-proof input fee) but credited
the full face value. On every topup from a fee-charging trusted mint, routstr
over-credited the user by the fee and its own wallet drifted toward insolvency.

Subtract get_fees_for_proofs(proofs) from the credited amount, mirroring the
foreign-mint swap path which already accounts for it. Adds a fee-charging
trusted-mint unit test (credited == face - input_fee).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:10:37 +02:00
16 changed files with 568 additions and 1271 deletions
+3 -3
View File
@@ -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:
+3 -8
View File
@@ -364,6 +364,7 @@ async def validate_bearer_key(
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(
"Cashu token redemption failed",
extra={
@@ -708,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")
@@ -802,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)",
+85 -27
View File
@@ -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.
@@ -418,11 +478,9 @@ def _calculate_from_tokens(
},
)
visible_input_msats = int(calc_input_msats + calc_cache_read_msats)
return CostData(
base_msats=0,
input_msats=visible_input_msats,
input_msats=int(calc_input_msats),
output_msats=int(calc_output_msats),
total_msats=token_based_cost,
total_usd=total_usd,
-42
View File
@@ -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", {})
-131
View File
@@ -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,
)
+70 -144
View File
@@ -35,17 +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 ..payment.usage import normalize_usage
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
@@ -158,56 +152,48 @@ class BaseUpstreamProvider:
@staticmethod
def _fold_cache_into_input_tokens(usage: object) -> None:
"""Fold additive cache token counts into Anthropic ``input_tokens``.
"""Fold cache token counts into ``input_tokens`` / ``prompt_tokens``.
Cost calculation has already used the per-bucket counts to bill the
request correctly; what the client sees in Anthropic-shaped visible
token totals should be a single rolled-up input count *including* the
cache portion. OpenAI-compatible ``prompt_tokens`` is already inclusive
(DeepSeek, OpenAI, OpenRouter, litellm), so adding cache fields there
would double-count.
The standalone ``cache_read_input_tokens`` /
request correctly; what the client sees in the visible token total
should be a single rolled-up prompt count *including* the cache
portion. The standalone ``cache_read_input_tokens`` /
``cache_creation_input_tokens`` fields are left in place for clients
that want the breakdown.
For Anthropic-shaped responses (``input_tokens`` present), the cache
fields are forced to ``0`` when the upstream omitted them, so the
client always sees a consistent shape.
"""
if not isinstance(usage, dict):
return
# ``prompt_tokens`` is an inclusive OpenAI-compatible grand total.
# Fold only native Anthropic-style usage where ``input_tokens`` excludes
# cache reads/writes.
if "input_tokens" not in usage or "prompt_tokens" in usage:
return
usage.setdefault("cache_read_input_tokens", 0)
usage.setdefault("cache_creation_input_tokens", 0)
# Normalise missing cache fields to 0 on Anthropic-shaped usage so
# downstream consumers can rely on them being present.
if "input_tokens" in usage:
usage.setdefault("cache_read_input_tokens", 0)
usage.setdefault("cache_creation_input_tokens", 0)
try:
cache_read = int(usage.get("cache_read_input_tokens") or 0)
cache_creation = int(usage.get("cache_creation_input_tokens") or 0)
input_tokens = int(usage.get("input_tokens") or 0)
except (TypeError, ValueError):
return
extra = cache_read + cache_creation
if extra > 0:
usage["input_tokens"] = input_tokens + extra
@staticmethod
def _add_normalized_usage_fields(response_json: object) -> None:
"""Preserve raw usage while adding canonical fields for billing/display."""
if not isinstance(response_json, dict):
if extra <= 0:
return
usage = response_json.get("usage")
if not isinstance(usage, dict):
return
normalized = normalize_usage(usage)
if normalized is None:
return
usage.setdefault("input_tokens", normalized.input_tokens)
usage.setdefault("output_tokens", normalized.output_tokens)
usage.setdefault("cache_read_input_tokens", normalized.cache_read_tokens)
usage.setdefault("cache_creation_input_tokens", normalized.cache_write_tokens)
if "input_tokens" in usage:
try:
usage["input_tokens"] = int(usage.get("input_tokens") or 0) + extra
except (TypeError, ValueError):
pass
if "prompt_tokens" in usage:
try:
usage["prompt_tokens"] = (
int(usage.get("prompt_tokens") or 0) + extra
)
except (TypeError, ValueError):
pass
def _apply_provider_field(self, response_json: object) -> None:
"""Stamp the routstr ``provider`` field onto an upstream response payload.
@@ -446,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:
@@ -528,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
@@ -867,7 +819,6 @@ class BaseUpstreamProvider:
k: v for k, v in obj.items() if k != "choices"
}
usage_chunk_data["choices"] = []
self._add_normalized_usage_fields(usage_chunk_data)
# Forward the content now, without usage, so token
# usage is reported exactly once (in the trailer).
forward = {k: v for k, v in obj.items() if k != "usage"}
@@ -879,7 +830,6 @@ class BaseUpstreamProvider:
)
return
usage_chunk_data = obj
self._add_normalized_usage_fields(usage_chunk_data)
return
yield prefix + b"data: " + json.dumps(obj).encode() + b"\n\n"
else:
@@ -917,8 +867,6 @@ class BaseUpstreamProvider:
if fresh_key:
cost_data: dict
try:
if usage_chunk_data is not None:
self._add_normalized_usage_fields(usage_chunk_data)
adjustment_input = (
usage_chunk_data
if usage_chunk_data is not None
@@ -1070,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)
@@ -1491,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)
@@ -1689,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()
@@ -1911,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)
@@ -2016,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)
@@ -3092,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(
@@ -4631,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(
-157
View File
@@ -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
+25 -12
View File
@@ -35,6 +35,24 @@ async def get_balance(unit: str) -> int:
return wallet.available_balance.amount
async def _redeem_same_mint(
wallet: Wallet, token_obj: Token
) -> tuple[int, str, str]: # amount, unit, mint_url
"""Redeem proofs at their own issuing mint (no cross-mint swap).
split() re-mints the incoming proofs into fresh ones we own so the sender
can't double-spend them. With include_fees=True the mint deducts its NUT-02
per-proof input fee, so we end up holding only `amount - input_fees`. Credit
that, not the face value, or routstr over-credits the user and its wallet
drifts insolvent.
"""
await wallet.load_mint(keyset_id=token_obj.keysets[0])
wallet.verify_proofs_dleq(token_obj.proofs)
input_fees = wallet.get_fees_for_proofs(token_obj.proofs)
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
return int(token_obj.amount) - input_fees, token_obj.unit, token_obj.mint
async def recieve_token(
token: str,
) -> tuple[int, str, str]: # amount, unit, mint_url
@@ -48,12 +66,7 @@ async def recieve_token(
if token_obj.mint not in settings.cashu_mints:
return await swap_to_primary_mint(token_obj, wallet)
await wallet.load_mint(keyset_id=token_obj.keysets[0])
wallet.verify_proofs_dleq(token_obj.proofs)
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
return token_obj.amount, token_obj.unit, token_obj.mint
return await _redeem_same_mint(wallet, token_obj)
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
@@ -213,10 +226,9 @@ async def swap_to_primary_mint(
amount_msat = token_amount
else:
raise ValueError("Invalid unit")
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
# If the token is already from the primary mint, we don't need to swap
# and we definitely don't want to calculate or pay fees.
# If the token is already from the primary mint, we don't need a cross-mint
# swap — redeem it same-mint. There's no melt/Lightning fee, but the mint's
# NUT-02 input fee still applies; _redeem_same_mint accounts for it.
if token_obj.mint == settings.primary_mint:
logger.info(
"swap_to_primary_mint: token already on primary mint, skipping swap",
@@ -226,8 +238,9 @@ async def swap_to_primary_mint(
"unit": token_obj.unit,
},
)
await token_wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
return token_amount, token_obj.unit, token_obj.mint
return await _redeem_same_mint(token_wallet, token_obj)
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
minted_amount = await _calculate_swap_amount(
amount_msat,
+59
View File
@@ -0,0 +1,59 @@
import hashlib
from types import SimpleNamespace
from typing import AsyncGenerator
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import validate_bearer_key
from routstr.core.db import ApiKey
def _make_engine() -> AsyncEngine:
return create_async_engine(
"sqlite+aiosqlite://",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
@pytest.fixture
async def session() -> AsyncGenerator[AsyncSession, None]:
engine = _make_engine()
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
db_session = AsyncSession(engine, expire_on_commit=False)
try:
yield db_session
finally:
await db_session.close()
await engine.dispose()
@pytest.mark.asyncio
async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
session: AsyncSession,
) -> None:
token = "cashuAfirst_seen_but_redemption_fails"
hashed_key = hashlib.sha256(token.encode()).hexdigest()
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
from routstr.core.settings import settings
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
patch(
"routstr.auth.credit_balance",
new=AsyncMock(side_effect=ValueError("token already spent")),
),
):
with pytest.raises(HTTPException):
await validate_bearer_key(token, session)
assert await session.get(ApiKey, hashed_key) is None
-189
View File
@@ -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
-243
View File
@@ -1,243 +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.
# Client-visible input_msats includes cache-read input cost for display,
# while cache_read_msats keeps the detailed breakdown.
assert result.input_msats == 1900
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
+136 -99
View File
@@ -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
+105 -2
View File
@@ -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
# ---------------------------------------------------------------------------
@@ -20,7 +20,6 @@ comment ever reaches the client. That invariant is exactly what the buggy
import json
from collections.abc import AsyncGenerator
from typing import cast
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -108,76 +107,6 @@ def _assert_clean(out: list[bytes]) -> list[dict]:
return objs
@pytest.mark.asyncio
async def test_deepseek_usage_chunk_is_normalized_before_billing() -> None:
"""DeepSeek stream trailers keep raw fields and add canonical billing fields."""
usage = {
"prompt_tokens": 10000,
"completion_tokens": 500,
"total_tokens": 10500,
"prompt_cache_hit_tokens": 9000,
"prompt_cache_miss_tokens": 1000,
}
chunks = [
b'data: {"id":"ds","model":"deepseek-chat","choices":[{"delta":{"content":"ok"}}]}\n\n',
b"data: "
+ json.dumps(
{
"id": "ds",
"model": "deepseek-chat",
"choices": [],
"usage": usage,
}
).encode()
+ b"\n\n",
b"data: [DONE]\n\n",
]
await _drive(chunks)
adjust_mock = cast(AsyncMock, base.adjust_payment_for_tokens)
adjustment_input = adjust_mock.call_args.args[1]
billed_usage = adjustment_input["usage"]
assert billed_usage["prompt_tokens"] == 10000
assert billed_usage["prompt_cache_hit_tokens"] == 9000
assert billed_usage["prompt_cache_miss_tokens"] == 1000
assert billed_usage["input_tokens"] == 1000
assert billed_usage["output_tokens"] == 500
assert billed_usage["cache_read_input_tokens"] == 9000
assert billed_usage["cache_creation_input_tokens"] == 0
@pytest.mark.asyncio
async def test_fold_cache_tokens_does_not_double_count_inclusive_prompt_tokens() -> None:
"""Visible usage mutation must not inflate OpenAI-compatible prompt totals."""
usage = {
"prompt_tokens": 10000,
"completion_tokens": 500,
"cache_read_input_tokens": 9000,
"prompt_cache_hit_tokens": 9000,
}
BaseUpstreamProvider._fold_cache_into_input_tokens(usage)
assert usage["prompt_tokens"] == 10000
assert usage["cache_read_input_tokens"] == 9000
@pytest.mark.asyncio
async def test_fold_cache_tokens_still_rolls_up_anthropic_input_tokens() -> None:
"""Anthropic native input_tokens excludes cache and still needs rollup."""
usage = {
"input_tokens": 1000,
"output_tokens": 500,
"cache_read_input_tokens": 9000,
"cache_creation_input_tokens": 200,
}
BaseUpstreamProvider._fold_cache_into_input_tokens(usage)
assert usage["input_tokens"] == 10200
@pytest.mark.asyncio
async def test_openai_style_plain_stream() -> None:
"""OpenAI / Groq / Fireworks / xAI / Perplexity: plain data + [DONE]."""
-140
View File
@@ -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
+82 -3
View File
@@ -39,6 +39,8 @@ async def test_recieve_token_valid() -> None:
mock_wallet = Mock()
mock_wallet.split = AsyncMock()
# Fee-free trusted mint (e.g. Minibits): nothing deducted.
mock_wallet.get_fees_for_proofs = Mock(return_value=0)
from routstr.core.settings import settings
@@ -61,6 +63,69 @@ async def test_recieve_token_valid() -> None:
assert mint == "http://mint:3338"
@pytest.mark.asyncio
async def test_recieve_token_trusted_mint_deducts_input_fee() -> None:
"""A trusted mint that charges NUT-02 input fees.
The same-mint receive (`wallet.split(..., include_fees=True)`, a NUT-03 swap
at the same mint — not swap_to_primary_mint) pays the mint's per-proof fee,
so routstr only ends up with `face - input_fee` in fresh proofs. The credited
amount must reflect that, otherwise routstr over-credits the user and its own
wallet drifts toward insolvency.
"""
token_data = {
"token": [
{
"mint": "http://mint:3338",
"proofs": [
{"amount": 1000, "id": "test", "secret": "secret", "C": "curve"}
],
}
],
"unit": "sat",
}
token_json = json.dumps(token_data)
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
token_str = f"cashuA{token_b64}"
mock_wallet = Mock()
mock_wallet.split = AsyncMock()
# Mock a 3-sat input fee from the Cashu wallet API.
mock_wallet.get_fees_for_proofs = Mock(return_value=3)
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
mock_token.mint = "http://mint:3338"
mock_token.unit = "sat"
mock_token.amount = 1000
mock_token.proofs = [{"amount": 1000}]
mock_deserialize.return_value = mock_token
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
# Patch get_wallet directly so the module-level `_wallets` cache
# (keyed by mint URL) can't hand back a wallet from another test.
with patch(
"routstr.wallet.get_wallet",
AsyncMock(return_value=mock_wallet),
):
amount, unit, mint = await recieve_token(token_str)
assert amount == 997 # 1000 face - 3 sat input fee paid on swap
assert unit == "sat"
assert mint == "http://mint:3338"
mock_wallet.get_fees_for_proofs.assert_called_once_with(
mock_token.proofs
)
# DLEQ is verified before re-minting the incoming proofs.
mock_wallet.verify_proofs_dleq.assert_called_once_with(
mock_token.proofs
)
@pytest.mark.asyncio
async def test_send_token() -> None:
mock_wallet = Mock()
@@ -254,19 +319,31 @@ async def test_recieve_token_untrusted_mint() -> None:
assert mint == "http://mint:3338"
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_swap_to_primary_mint_already_on_primary() -> None:
"""Same-mint shortcut: the token is already on the primary mint.
No cross-mint swap (no melt/mint), but the same-mint split(include_fees=True)
still burns the mint's NUT-02 input fee, so the credited amount must be face
minus the input fee — not full face value (the over-credit bug). DLEQ is
verified too, matching the trusted same-mint receive path.
"""
from routstr.core.settings import settings
from routstr.wallet import swap_to_primary_mint
mock_token = Mock()
mock_token.mint = settings.primary_mint
mock_token.keysets = ["keyset1"]
mock_token.amount = 1000
mock_token.unit = "sat"
mock_token.proofs = []
mock_token.proofs = [{"amount": 1000}]
mock_token_wallet = Mock()
mock_token_wallet.load_mint = AsyncMock()
mock_token_wallet.load_proofs = AsyncMock()
mock_token_wallet.verify_proofs_dleq = Mock()
# Mock a 3-sat input fee from the Cashu wallet API.
mock_token_wallet.get_fees_for_proofs = Mock(return_value=3)
mock_token_wallet.split = AsyncMock(return_value=None)
mock_token_wallet.request_mint = AsyncMock()
mock_token_wallet.melt_quote = AsyncMock()
@@ -274,9 +351,11 @@ async def test_swap_to_primary_mint_already_on_primary() -> None:
with patch("routstr.wallet.get_wallet", AsyncMock(return_value=mock_token_wallet)):
amount, unit, mint = await swap_to_primary_mint(mock_token, mock_token_wallet)
assert amount == 1000
assert amount == 997 # 1000 face - 3 sat input fee
assert unit == "sat"
assert mint == settings.primary_mint
mock_token_wallet.verify_proofs_dleq.assert_called_once_with(mock_token.proofs)
mock_token_wallet.get_fees_for_proofs.assert_called_once_with(mock_token.proofs)
mock_token_wallet.split.assert_called_once()
mock_token_wallet.request_mint.assert_not_called()
mock_token_wallet.melt_quote.assert_not_called()