#17297

fix(usage): getPromptCacheReadTokens ?? chain short-circuits on zero-valued compat fields — real cache reads recorded as 0

Openhuangyoudashiopened 2d ago

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_logs cache-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

0

No activity yet.