From 11de734331f7161adbaaa1130932b6a67a81ab02 Mon Sep 17 00:00:00 2001 From: RelaxJonh <92573950+RelaxJonh@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:30:27 +0700 Subject: [PATCH] fix(usage): support dict-shaped usage objects in normalize_usage (#74314) When the Responses API returns usage as a plain dict (e.g. from a middleware or proxy that deserialises JSON to dict instead of a typed SDK object), normalize_usage() used getattr() exclusively, which silently returned 0 for every field on a dict. Add _usage_get() helper that reads via .get() for dicts and getattr() for attribute-style objects. All accessor sites in normalize_usage() now use this helper, so token counts and cost are correct regardless of the usage object's type. Regression tests: two new tests feed the same payload as both a dict and a SimpleNamespace through the codex_responses and chat_completions branches, asserting identical output and non-zero values. --- agent/usage_pricing.py | 61 +++++++++++++++++++------------ tests/agent/test_usage_pricing.py | 52 ++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 6db5ec7c5596b..c3787728cbf11 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -1030,6 +1030,19 @@ def _to_int(value: Any) -> int: return 0 +def _usage_get(obj: Any, name: str, default: Any = 0) -> Any: + """Read a field from a usage object that may be a dict or an attribute object. + + The Responses API can return usage as either a typed SDK object (accessible + via ``getattr``) or a plain ``dict`` (from JSON deserialisation). Using + ``getattr`` on a dict silently yields the default, zeroing out all token + counts. This helper normalises access so both shapes work transparently. + """ + if isinstance(obj, dict): + return obj.get(name, default) + return getattr(obj, name, default) + + def resolve_billing_route( model_name: str, provider: Optional[str] = None, @@ -1269,17 +1282,17 @@ def normalize_usage( mode = (api_mode or "").strip().lower() if mode == "anthropic_messages" or provider_name == "anthropic": - input_tokens = _to_int(getattr(response_usage, "input_tokens", 0)) - output_tokens = _to_int(getattr(response_usage, "output_tokens", 0)) - cache_read_tokens = _to_int(getattr(response_usage, "cache_read_input_tokens", 0)) - cache_write_tokens = _to_int(getattr(response_usage, "cache_creation_input_tokens", 0)) + 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(getattr(response_usage, "input_tokens", 0)) - output_tokens = _to_int(getattr(response_usage, "output_tokens", 0)) - details = getattr(response_usage, "input_tokens_details", None) - cache_read_tokens = _to_int(getattr(details, "cached_tokens", 0) if details else 0) + 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( - getattr(details, "cache_creation_tokens", 0) if details else 0 + _usage_get(details, "cache_creation_tokens", 0) if details else 0 ) input_tokens = max(0, input_total - cache_read_tokens - cache_write_tokens) else: @@ -1287,22 +1300,22 @@ def normalize_usage( # (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(getattr(response_usage, "prompt_tokens", 0)) or _to_int( - getattr(response_usage, "input_tokens", 0) + 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(getattr(response_usage, "completion_tokens", 0)) or _to_int( - getattr(response_usage, "output_tokens", 0) + output_tokens = _to_int(_usage_get(response_usage, "completion_tokens", 0)) or _to_int( + _usage_get(response_usage, "output_tokens", 0) ) - details = getattr(response_usage, "prompt_tokens_details", None) + 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 # AI Gateway, Cline) expose when routing Claude models — without this # 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(getattr(details, "cached_tokens", 0) if details else 0) + cache_read_tokens = _to_int(_usage_get(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)) + cache_read_tokens = _to_int(_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 @@ -1310,7 +1323,7 @@ def normalize_usage( # 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) + _usage_get(response_usage, "prompt_cache_hit_tokens", 0) ) if not cache_read_tokens: # Kimi/Moonshot's native API (api.moonshot.cn / .ai) reports @@ -1319,14 +1332,14 @@ def normalize_usage( # 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( - getattr(response_usage, "cached_tokens", 0) + _usage_get(response_usage, "cached_tokens", 0) ) cache_write_tokens = _to_int( - getattr(details, "cache_write_tokens", 0) if details else 0 + _usage_get(details, "cache_write_tokens", 0) if details else 0 ) if not cache_write_tokens: cache_write_tokens = _to_int( - getattr(response_usage, "cache_creation_input_tokens", 0) + _usage_get(response_usage, "cache_creation_input_tokens", 0) ) input_tokens = max(0, prompt_total - cache_read_tokens - cache_write_tokens) @@ -1338,14 +1351,14 @@ def normalize_usage( # hidden thinking was invisible in session accounting even though it # dominates output spend on models like deepseek-v4-flash (measured: # single calls burning 21K reasoning tokens to emit 500 visible tokens). - output_details = getattr(response_usage, "output_tokens_details", None) + output_details = _usage_get(response_usage, "output_tokens_details", None) if output_details: - reasoning_tokens = _to_int(getattr(output_details, "reasoning_tokens", 0)) + reasoning_tokens = _to_int(_usage_get(output_details, "reasoning_tokens", 0)) if not reasoning_tokens: - completion_details = getattr(response_usage, "completion_tokens_details", None) + completion_details = _usage_get(response_usage, "completion_tokens_details", None) if completion_details: reasoning_tokens = _to_int( - getattr(completion_details, "reasoning_tokens", 0) + _usage_get(completion_details, "reasoning_tokens", 0) ) # Cache observability for MiniMax's Anthropic wire: on MiniMax-M3, diff --git a/tests/agent/test_usage_pricing.py b/tests/agent/test_usage_pricing.py index 5a74df47d5c0d..9e4c2f693c79a 100644 --- a/tests/agent/test_usage_pricing.py +++ b/tests/agent/test_usage_pricing.py @@ -518,3 +518,55 @@ def test_usage_without_any_cache_fields_still_normalizes(): assert normalized.cache_read_tokens == 0 assert normalized.input_tokens == 500 + + +def test_normalize_usage_handles_dict_shaped_usage(): + """Regression test for #74314: when the Responses API returns usage as a + plain dict (e.g. from a middleware/proxy that deserialises JSON to dict + instead of a typed SDK object), normalize_usage() must read the same + token counts as it would from an attribute-style object. + + Before this fix, getattr() on a dict silently returned 0 for every field, + so token counts and cost appeared as zero for dict-shaped usage. + """ + # Same payload as both a dict and a SimpleNamespace + payload = { + "input_tokens": 100, + "output_tokens": 20, + "input_tokens_details": {"cached_tokens": 60, "cache_creation_tokens": 10}, + } + ns = SimpleNamespace( + input_tokens=100, + output_tokens=20, + input_tokens_details=SimpleNamespace(cached_tokens=60, cache_creation_tokens=10), + ) + + dict_result = normalize_usage(payload, api_mode="codex_responses") + ns_result = normalize_usage(ns, api_mode="codex_responses") + + assert dict_result.input_tokens == ns_result.input_tokens, f"input_tokens: dict={dict_result.input_tokens} vs ns={ns_result.input_tokens}" + assert dict_result.output_tokens == ns_result.output_tokens, f"output_tokens: dict={dict_result.output_tokens} vs ns={ns_result.output_tokens}" + assert dict_result.cache_read_tokens == ns_result.cache_read_tokens, f"cache_read: dict={dict_result.cache_read_tokens} vs ns={ns_result.cache_read_tokens}" + assert dict_result.cache_write_tokens == ns_result.cache_write_tokens, f"cache_write: dict={dict_result.cache_write_tokens} vs ns={ns_result.cache_write_tokens}" + # Sanity: values must be non-zero (the whole point of the bug) + assert dict_result.input_tokens > 0 + assert dict_result.cache_read_tokens > 0 + + +def test_normalize_usage_handles_dict_openai_chat_completions(): + """Dict-shaped usage must also work in the default (OpenAI chat-completions) + branch, not just the codex_responses branch. + """ + payload = { + "prompt_tokens": 500, + "completion_tokens": 100, + "prompt_tokens_details": {"cached_tokens": 200}, + "completion_tokens_details": {"reasoning_tokens": 30}, + } + + result = normalize_usage(payload, api_mode="chat_completions") + + assert result.output_tokens == 100 + assert result.cache_read_tokens == 200 + assert result.input_tokens == 500 - 200 # prompt_total - cache_read + assert result.reasoning_tokens == 30