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.
This commit is contained in:
RelaxJonh 2026-07-30 11:30:27 +07:00 committed by Teknium
parent 5b82e69693
commit 11de734331
2 changed files with 89 additions and 24 deletions

View File

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

View File

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