From 03c0b00f45b0a8fd85ee1192991e839f7c61518a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:29:53 -0700 Subject: [PATCH] fix(usage): read DeepSeek's native prompt_cache_hit_tokens cache field (#65678) DeepSeek's own API (api.deepseek.com) reports context-cache hits as top-level usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens (prompt_tokens = hit + miss), not the OpenAI nested prompt_tokens_details.cached_tokens shape. Neither normalize_usage() nor the chat_completions transport's extract_cache_stats() read those fields, so direct DeepSeek sessions always showed 0 cache-hit tokens: invisible in accounting, mis-billed at the full input rate, and 0% cache display. Both layers now fall back to prompt_cache_hit_tokens when the nested shape is absent; the nested value wins when both are present (proxies). Fixes #61871. --- agent/transports/chat_completions.py | 13 ++++--- agent/usage_pricing.py | 9 +++++ tests/agent/test_usage_pricing.py | 38 +++++++++++++++++++ .../agent/transports/test_chat_completions.py | 27 +++++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 8d94ecbcbedd..d1ee4ef83b52 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -776,15 +776,18 @@ class ChatCompletionsTransport(ProviderTransport): return True def extract_cache_stats(self, response: Any) -> dict[str, int] | None: - """Extract OpenRouter/OpenAI cache stats from prompt_tokens_details.""" + """Extract cache stats from prompt_tokens_details (OpenRouter/OpenAI) + or DeepSeek's native top-level prompt_cache_hit_tokens field.""" usage = getattr(response, "usage", None) if usage is None: return None details = getattr(usage, "prompt_tokens_details", None) - if details is None: - return None - cached = getattr(details, "cached_tokens", 0) or 0 - written = getattr(details, "cache_write_tokens", 0) or 0 + cached = getattr(details, "cached_tokens", 0) or 0 if details else 0 + written = getattr(details, "cache_write_tokens", 0) or 0 if details else 0 + if not cached: + # DeepSeek native API shape (api.deepseek.com): top-level + # prompt_cache_hit_tokens / prompt_cache_miss_tokens (#61871). + cached = getattr(usage, "prompt_cache_hit_tokens", 0) or 0 if cached or written: return {"cached_tokens": cached, "creation_tokens": written} return None diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index caae20bee270..85aa1d180aec 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -1074,6 +1074,15 @@ def normalize_usage( cache_read_tokens = _to_int(getattr(details, "cached_tokens", 0) if details else 0) if not cache_read_tokens: cache_read_tokens = _to_int(getattr(response_usage, "cache_read_input_tokens", 0)) + if not cache_read_tokens: + # DeepSeek's native API (api.deepseek.com) reports context-cache + # hits as top-level prompt_cache_hit_tokens (+ the complementary + # prompt_cache_miss_tokens; prompt_tokens = hit + miss), not the + # OpenAI nested shape. Without this, direct DeepSeek sessions + # always showed 0 cache-hit tokens (#61871). + cache_read_tokens = _to_int( + getattr(response_usage, "prompt_cache_hit_tokens", 0) + ) cache_write_tokens = _to_int( getattr(details, "cache_write_tokens", 0) if details else 0 ) diff --git a/tests/agent/test_usage_pricing.py b/tests/agent/test_usage_pricing.py index 6458a9b0d3f8..04aa4ec4c9cf 100644 --- a/tests/agent/test_usage_pricing.py +++ b/tests/agent/test_usage_pricing.py @@ -39,6 +39,44 @@ def test_normalize_usage_openai_subtracts_cached_prompt_tokens(): assert normalized.output_tokens == 700 +def test_normalize_usage_reads_deepseek_native_cache_hit_tokens(): + """DeepSeek's native API (api.deepseek.com) reports context-cache hits as + top-level prompt_cache_hit_tokens / prompt_cache_miss_tokens (with + prompt_tokens = hit + miss), not OpenAI's nested + prompt_tokens_details.cached_tokens. Before this fix, direct DeepSeek + sessions always normalized to cache_read_tokens=0 — cache hits were + invisible in accounting and billed at the full input rate (#61871).""" + usage = SimpleNamespace( + prompt_tokens=2000, + completion_tokens=400, + prompt_cache_hit_tokens=1500, + prompt_cache_miss_tokens=500, + ) + + normalized = normalize_usage(usage, provider="deepseek", api_mode="chat_completions") + + assert normalized.cache_read_tokens == 1500 + # prompt_tokens includes cached; input = 2000 - 1500 = the miss bucket + assert normalized.input_tokens == 500 + assert normalized.output_tokens == 400 + + +def test_normalize_usage_nested_details_win_over_deepseek_top_level(): + """When a proxy forwards both shapes, the OpenAI nested value wins and + the DeepSeek top-level field is not double-read.""" + usage = SimpleNamespace( + prompt_tokens=2000, + completion_tokens=100, + prompt_tokens_details=SimpleNamespace(cached_tokens=900), + prompt_cache_hit_tokens=1500, + ) + + normalized = normalize_usage(usage, provider="deepseek", api_mode="chat_completions") + + assert normalized.cache_read_tokens == 900 + assert normalized.input_tokens == 1100 + + def test_normalize_usage_openai_reads_top_level_anthropic_cache_fields(): """Some OpenAI-compatible proxies (OpenRouter, Cline) expose Anthropic-style cache token counts at the top level of the usage object when diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 487225ac4040..cf245a2f3266 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -1115,6 +1115,33 @@ class TestChatCompletionsCacheStats: result = transport.extract_cache_stats(r) assert result == {"cached_tokens": 500, "creation_tokens": 100} + def test_deepseek_native_top_level_cache_hit_tokens(self, transport): + """DeepSeek's native API (api.deepseek.com) reports cache hits as + top-level prompt_cache_hit_tokens, not the OpenAI nested shape — + the extractor must read it or direct DeepSeek sessions show 0% + cache hit rate (#61871).""" + r = SimpleNamespace( + usage=SimpleNamespace( + prompt_tokens_details=None, + prompt_cache_hit_tokens=1500, + prompt_cache_miss_tokens=500, + ) + ) + result = transport.extract_cache_stats(r) + assert result == {"cached_tokens": 1500, "creation_tokens": 0} + + def test_nested_details_win_over_deepseek_top_level(self, transport): + """When both shapes are present, the OpenAI nested value wins.""" + details = SimpleNamespace(cached_tokens=800, cache_write_tokens=0) + r = SimpleNamespace( + usage=SimpleNamespace( + prompt_tokens_details=details, + prompt_cache_hit_tokens=1500, + ) + ) + result = transport.extract_cache_stats(r) + assert result == {"cached_tokens": 800, "creation_tokens": 0} + class TestChatCompletionsGeminiNativeExtraBodyStrip: """Profile extra_body (e.g. Nous portal tags) must not reach a native