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
This commit is contained in:
+60
-25
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user