Summary
getPromptCacheReadTokens() resolves the prompt cache-read count with a nullish (??) chain over four candidate fields. Some OpenAI-compatible upstreams emit multi-format compatibility usage: the same usage object carries zero-valued legacy fields (cache_read_input_tokens: 0, cached_tokens: 0) alongside the real cache hit in prompt_tokens_details.cached_tokens. Because 0 is not nullish, the chain stops at the first zero and the real value in the later field is never read — prompt cache reads are recorded as 0 in call_logs / usage history even though the upstream reported them.
Observed on: 3.8.51; the function is byte-identical in 3.8.52 (src/lib/usage/tokenAccounting.ts:23).
Reproduction (shape of a real production usage frame from a bridged OpenAI-compatible upstream):
{
"prompt_tokens": 3590,
"completion_tokens": 5,
"prompt_tokens_details": { "cached_tokens": 3584 },
"cache_read_input_tokens": 0,
"cached_tokens": 0
}getPromptCacheReadTokens(usage) returns 0 (cache_read_input_tokens is present and zero → the ?? chain stops there), while the upstream actually reported 3584 cached tokens. The zero+positive mixed shape is that upstream's compatibility behavior, not an error — its own dashboard displays 3584 because it skips zeros.
Root cause
export function getPromptCacheReadTokens(tokens: unknown): number {
const tokenRecord = asRecord(tokens);
const promptDetails = getPromptTokenDetails(tokenRecord);
return toFiniteNumber(
tokenRecord.cacheRead ??
tokenRecord.cache_read_input_tokens ??
tokenRecord.cached_tokens ??
promptDetails.cached_tokens
);
}?? only skips null/undefined. A zero-valued compat dummy wins over a positive value in a later field.
Impact
call_logscache-read accounting and usage dashboards record 0 for such upstreams → cache-hit stats (and any cost-savings views built on them) are silently wrong.- Only affects upstreams emitting this zero+positive mixed shape; standard OpenAI/Anthropic usage is unaffected.
Suggested fix (minimal)
Keep the existing precedence order, but take the first candidate that converts to a finite positive number; fall back to the legacy chain when no candidate is positive (preserves current semantics: all-zero → 0, all-missing → the OrNull wrapper still returns null):
return (
[tokenRecord.cacheRead, tokenRecord.cache_read_input_tokens, tokenRecord.cached_tokens, promptDetails.cached_tokens]
.map((value) => toFiniteNumber(value))
.find((value) => value > 0) ??
toFiniteNumber(
tokenRecord.cacheRead ??
tokenRecord.cache_read_input_tokens ??
tokenRecord.cached_tokens ??
promptDetails.cached_tokens
)
);Deliberately: no summing, no Math.max, no history backfill — first-positive within the existing precedence order. We have been running this exact change in production since Oct 5 across all runtime copies, with cross-provider regression: positive cache reads preserved on five provider shapes (DeepSeek / MiniMax / ZAI / codex / StepFun) and zero/null semantics unchanged.
Related: #8591 (non-standard cache token fields), #13746 / #13760 (nested cache reads before cost calculation), #15619 (trailing usage-only summary) — different layers of the same accounting family.
Activity
0No activity yet.