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:
joaomarcos
2026-08-13 19:17:41 -07:00
committed by Teknium
parent 11de734331
commit e331b834fe
2 changed files with 190 additions and 25 deletions
+60 -25
View File
@@ -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)
)