mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
2 Commits
feat-prune
...
caching
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9385b01e58 | ||
|
|
c5adfd9c3a |
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user