Compare commits

...

2 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
3 changed files with 254 additions and 2 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

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