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:
parent
5b82e69693
commit
11de734331
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue