From e331b834fe4907be97230f15c6f93cf7edb2d476 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Thu, 13 Aug 2026 19:17:41 -0700 Subject: [PATCH] fix(usage): preserve OpenAI-wire cache writes at canonical accounting boundary (#85706) Salvaged from PR #85702 by @JoaoMarcos44, composed onto the mapping-safe _usage_get reads (PR #74591 by @RelaxJonh) and the flat cached_tokens / Anthropic-name fallbacks (PRs #66105, #52571): - cache-write precedence in the chat_completions branch: details.cache_write_tokens > details.cache_creation_input_tokens > usage.cache_creation_input_tokens > usage.cache_write_tokens - codex_responses branch reads details.cache_write_tokens (GPT-5.6+ documented name) with cache_creation_tokens fallback (from PR #70522) - _usage_count(): clamp malformed negative counters to 0 - all reads in every branch are mapping-safe via _usage_get --- agent/usage_pricing.py | 85 +++++++++++++------ tests/agent/test_usage_pricing.py | 130 ++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 25 deletions(-) diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index c3787728cb..c2186eb437 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -1043,6 +1043,16 @@ def _usage_get(obj: Any, name: str, default: Any = 0) -> Any: return getattr(obj, name, default) +def _usage_count(value: Any) -> int: + """Coerce a usage counter to a non-negative integer. + + Providers occasionally emit malformed negative counters; clamp them to 0 + so a bad field cannot corrupt session accounting (#85706). + """ + return max(0, _to_int(value)) + + + def resolve_billing_route( model_name: str, provider: Optional[str] = None, @@ -1282,30 +1292,42 @@ def normalize_usage( mode = (api_mode or "").strip().lower() if mode == "anthropic_messages" or provider_name == "anthropic": - input_tokens = _to_int(_usage_get(response_usage, "input_tokens", 0)) - output_tokens = _to_int(_usage_get(response_usage, "output_tokens", 0)) - cache_read_tokens = _to_int(_usage_get(response_usage, "cache_read_input_tokens", 0)) - cache_write_tokens = _to_int(_usage_get(response_usage, "cache_creation_input_tokens", 0)) - elif mode == "codex_responses": - input_total = _to_int(_usage_get(response_usage, "input_tokens", 0)) - output_tokens = _to_int(_usage_get(response_usage, "output_tokens", 0)) - details = _usage_get(response_usage, "input_tokens_details", None) - cache_read_tokens = _to_int(_usage_get(details, "cached_tokens", 0) if details else 0) - cache_write_tokens = _to_int( - _usage_get(details, "cache_creation_tokens", 0) if details else 0 + input_tokens = _usage_count(_usage_get(response_usage, "input_tokens", 0)) + output_tokens = _usage_count(_usage_get(response_usage, "output_tokens", 0)) + cache_read_tokens = _usage_count(_usage_get(response_usage, "cache_read_input_tokens", 0)) + cache_write_tokens = _usage_count( + _usage_get(response_usage, "cache_creation_input_tokens", 0) ) + elif mode == "codex_responses": + input_total = _usage_count(_usage_get(response_usage, "input_tokens", 0)) + output_tokens = _usage_count(_usage_get(response_usage, "output_tokens", 0)) + details = _usage_get(response_usage, "input_tokens_details", None) + cache_read_tokens = _usage_count( + _usage_get(details, "cached_tokens", 0) if details else 0 + ) + # OpenAI's documented field for GPT-5.6+ explicit cache writes is + # `cache_write_tokens` (billed at 1.25x); `cache_creation_tokens` is + # kept as a fallback for older/alternate Responses-compatible + # endpoints (#70543). + cache_write_tokens = _usage_count( + _usage_get(details, "cache_write_tokens", 0) if details else 0 + ) + if not cache_write_tokens: + cache_write_tokens = _usage_count( + _usage_get(details, "cache_creation_tokens", 0) if details else 0 + ) input_tokens = max(0, input_total - cache_read_tokens - cache_write_tokens) else: # OpenAI-style names first; fall back to Anthropic-style # (input_tokens/output_tokens). Local OpenAI-compatible servers like # mlx_vlm.server emit the Anthropic names in chat_completions responses, # and the OpenAI Python client preserves them as extra attributes. - prompt_total = _to_int(_usage_get(response_usage, "prompt_tokens", 0)) or _to_int( - _usage_get(response_usage, "input_tokens", 0) - ) - output_tokens = _to_int(_usage_get(response_usage, "completion_tokens", 0)) or _to_int( - _usage_get(response_usage, "output_tokens", 0) - ) + prompt_total = _usage_count( + _usage_get(response_usage, "prompt_tokens", 0) + ) or _usage_count(_usage_get(response_usage, "input_tokens", 0)) + output_tokens = _usage_count( + _usage_get(response_usage, "completion_tokens", 0) + ) or _usage_count(_usage_get(response_usage, "output_tokens", 0)) details = _usage_get(response_usage, "prompt_tokens_details", None) # Primary: OpenAI-style prompt_tokens_details. Fallback: Anthropic-style # top-level fields that some OpenAI-compatible proxies (OpenRouter, Vercel @@ -1313,16 +1335,20 @@ def normalize_usage( # fallback, cache writes are undercounted as 0 and cache reads can be # missed when the proxy only surfaces them at the top level. # Port of cline/cline#10266. - cache_read_tokens = _to_int(_usage_get(details, "cached_tokens", 0) if details else 0) + cache_read_tokens = _usage_count( + _usage_get(details, "cached_tokens", 0) if details else 0 + ) if not cache_read_tokens: - cache_read_tokens = _to_int(_usage_get(response_usage, "cache_read_input_tokens", 0)) + cache_read_tokens = _usage_count( + _usage_get(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( + cache_read_tokens = _usage_count( _usage_get(response_usage, "prompt_cache_hit_tokens", 0) ) if not cache_read_tokens: @@ -1331,16 +1357,25 @@ def normalize_usage( # OpenAI nested prompt_tokens_details.cached_tokens shape. Without # this, direct Kimi sessions always showed 0 cache-hit tokens and # the hits were billed at the full input rate (#65722). - cache_read_tokens = _to_int( + cache_read_tokens = _usage_count( _usage_get(response_usage, "cached_tokens", 0) ) - cache_write_tokens = _to_int( + cache_write_tokens = _usage_count( _usage_get(details, "cache_write_tokens", 0) if details else 0 ) if not cache_write_tokens: - cache_write_tokens = _to_int( + cache_write_tokens = _usage_count( + _usage_get(details, "cache_creation_input_tokens", 0) + if details else 0 + ) + if not cache_write_tokens: + cache_write_tokens = _usage_count( _usage_get(response_usage, "cache_creation_input_tokens", 0) ) + if not cache_write_tokens: + cache_write_tokens = _usage_count( + _usage_get(response_usage, "cache_write_tokens", 0) + ) input_tokens = max(0, prompt_total - cache_read_tokens - cache_write_tokens) reasoning_tokens = 0 @@ -1353,11 +1388,11 @@ def normalize_usage( # single calls burning 21K reasoning tokens to emit 500 visible tokens). output_details = _usage_get(response_usage, "output_tokens_details", None) if output_details: - reasoning_tokens = _to_int(_usage_get(output_details, "reasoning_tokens", 0)) + reasoning_tokens = _usage_count(_usage_get(output_details, "reasoning_tokens", 0)) if not reasoning_tokens: completion_details = _usage_get(response_usage, "completion_tokens_details", None) if completion_details: - reasoning_tokens = _to_int( + reasoning_tokens = _usage_count( _usage_get(completion_details, "reasoning_tokens", 0) ) diff --git a/tests/agent/test_usage_pricing.py b/tests/agent/test_usage_pricing.py index 9e4c2f693c..756a6d203e 100644 --- a/tests/agent/test_usage_pricing.py +++ b/tests/agent/test_usage_pricing.py @@ -570,3 +570,133 @@ def test_normalize_usage_handles_dict_openai_chat_completions(): assert result.cache_read_tokens == 200 assert result.input_tokens == 500 - 200 # prompt_total - cache_read assert result.reasoning_tokens == 30 + + +def test_normalize_usage_openai_reads_nested_cache_creation_tokens(): + usage = SimpleNamespace( + prompt_tokens=1000, + completion_tokens=200, + prompt_tokens_details=SimpleNamespace( + cached_tokens=100, + cache_creation_input_tokens=300, + ), + ) + + normalized = normalize_usage(usage, provider="openrouter", api_mode="chat_completions") + + assert normalized.cache_read_tokens == 100 + assert normalized.cache_write_tokens == 300 + assert normalized.input_tokens == 600 + + +def test_normalize_usage_openai_reads_mapping_cache_creation_tokens(): + usage = { + "prompt_tokens": 1000, + "completion_tokens": 200, + "prompt_tokens_details": {"cache_creation_input_tokens": 300}, + } + + normalized = normalize_usage(usage, provider="openrouter", api_mode="chat_completions") + + assert normalized.cache_write_tokens == 300 + assert normalized.input_tokens == 700 + + +def test_normalize_usage_openai_prefers_nested_cache_write_tokens(): + usage = SimpleNamespace( + prompt_tokens=1000, + prompt_tokens_details=SimpleNamespace( + cache_write_tokens=200, + cache_creation_input_tokens=300, + ), + cache_creation_input_tokens=400, + cache_write_tokens=500, + ) + + normalized = normalize_usage(usage, provider="openrouter", api_mode="chat_completions") + + assert normalized.cache_write_tokens == 200 + + +def test_normalize_usage_mapping_preserves_reasoning_tokens(): + usage = { + "prompt_tokens": 100, + "completion_tokens": 20, + "prompt_tokens_details": {"cached_tokens": 40}, + "completion_tokens_details": {"reasoning_tokens": 12}, + } + + normalized = normalize_usage(usage, provider="openrouter", api_mode="chat_completions") + + assert normalized.reasoning_tokens == 12 + + +def test_normalize_usage_mapping_anthropic_fields(): + usage = { + "input_tokens": 80, + "output_tokens": 20, + "cache_read_input_tokens": 50, + "cache_creation_input_tokens": 10, + "output_tokens_details": {"reasoning_tokens": 7}, + } + + normalized = normalize_usage(usage, provider="anthropic", api_mode="anthropic_messages") + + assert normalized.cache_read_tokens == 50 + assert normalized.cache_write_tokens == 10 + assert normalized.reasoning_tokens == 7 + + +def test_normalize_usage_mapping_codex_fields(): + usage = { + "input_tokens": 100, + "output_tokens": 20, + "input_tokens_details": { + "cached_tokens": 60, + "cache_creation_tokens": 10, + }, + "output_tokens_details": {"reasoning_tokens": 5}, + } + + normalized = normalize_usage(usage, provider="openai-codex", api_mode="codex_responses") + + assert normalized.input_tokens == 30 + assert normalized.cache_read_tokens == 60 + assert normalized.cache_write_tokens == 10 + assert normalized.reasoning_tokens == 5 + + +def test_normalize_usage_clamps_negative_counters(): + usage = SimpleNamespace( + prompt_tokens=100, + completion_tokens=-5, + prompt_tokens_details=SimpleNamespace( + cached_tokens=-10, + cache_write_tokens=-20, + ), + completion_tokens_details=SimpleNamespace(reasoning_tokens=-3), + ) + + normalized = normalize_usage(usage, provider="openrouter", api_mode="chat_completions") + + assert normalized.input_tokens == 100 + assert normalized.output_tokens == 0 + assert normalized.cache_read_tokens == 0 + assert normalized.cache_write_tokens == 0 + assert normalized.reasoning_tokens == 0 + + +def test_normalize_usage_clamps_inconsistent_cache_total(): + usage = { + "prompt_tokens": 100, + "completion_tokens": 10, + "prompt_tokens_details": { + "cached_tokens": 80, + "cache_creation_input_tokens": 50, + }, + } + + normalized = normalize_usage(usage, provider="openrouter", api_mode="chat_completions") + + assert normalized.input_tokens == 0 + assert normalized.prompt_tokens == 130