Compare commits

...

7 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
9qeklajc
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 Ubbink
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 Ubbink
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
9qeklajc
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
Jeroen Ubbink
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
5 changed files with 361 additions and 17 deletions

View File

@@ -241,7 +241,21 @@ def _extract_token_pair(
def _extract_cache_tokens(usage_data: dict, input_tokens: int) -> tuple[int, int, int]:
"""Extract cache tokens, handling OpenAI vs Anthropic formats.
"""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)
"""
@@ -250,7 +264,7 @@ def _extract_cache_tokens(usage_data: dict, input_tokens: int) -> tuple[int, int
usage_data.get("cache_creation_input_tokens", 0)
)
# OpenAI: cache is included in input_tokens, subtract it
# 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))
@@ -258,6 +272,16 @@ def _extract_cache_tokens(usage_data: dict, input_tokens: int) -> tuple[int, int
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

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,

View File

@@ -316,6 +316,131 @@ async def test_zero_cache_tokens(mock_session: AsyncMock, mock_fixed_pricing: No
assert result.input_tokens == 100
# ============================================================================
# Test 12b: DeepSeek Cache Hit/Miss Format
# ============================================================================
@pytest.mark.asyncio
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": 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, session=mock_session)
assert isinstance(result, CostData)
assert result.input_tokens == 1000 # 10000 - 9000
assert result.cache_read_input_tokens == 9000
assert result.cache_creation_input_tokens == 0 # DeepSeek has no cache write
assert result.output_tokens == 200
@pytest.mark.asyncio
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": 0,
"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 == 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
# ============================================================================

View File

@@ -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
# ---------------------------------------------------------------------------

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()