From 4a371d1d9f0f5914ec0dab7194336ff557af5cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Fonseca?= Date: Fri, 28 Aug 2026 16:41:08 +0700 Subject: [PATCH] fix(usage): preserve nested cached_tokens in canonicalizeUsage buildUsage() only emits cache reads under prompt_tokens_details, so the top-level-only read dropped the count for every Responses-format provider (codex, grok-cli, ...), persisting cached_tokens: 0 and billing cache hits at the full input rate. Mirror the cache_creation fallback already used just above. --- open-sse/utils/usageTracking.js | 5 ++++- tests/unit/cached-token-usage.test.js | 14 +++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js index 24518ef3..0d66f6f3 100644 --- a/open-sse/utils/usageTracking.js +++ b/open-sse/utils/usageTracking.js @@ -190,7 +190,10 @@ export function canonicalizeUsage(usage) { prompt = prompt + cached + cacheCreation; } else { // OpenAI/Gemini path (or already-canonical input): prompt already includes cached_tokens. - cached = num(usage.cached_tokens); + // Mirror the cacheCreation fallback above: buildUsage() only ever emits the + // nested prompt_tokens_details.cached_tokens shape, so without this the + // cache-read count is silently dropped on every buildUsage()-derived usage. + cached = num(usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens); } const result = { diff --git a/tests/unit/cached-token-usage.test.js b/tests/unit/cached-token-usage.test.js index 878110d0..9b3ebd97 100644 --- a/tests/unit/cached-token-usage.test.js +++ b/tests/unit/cached-token-usage.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { canonicalizeUsage, extractUsage, mergeUsage } from "../../open-sse/utils/usageTracking.js"; import { calculateCostFromTokens } from "../../open-sse/providers/pricing.js"; -import { toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js"; +import { buildUsage, toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js"; // Canonical convention (single source of truth for storage + cost): // prompt_tokens = total input INCLUDING cache read + cache creation @@ -49,6 +49,18 @@ describe("canonicalizeUsage", () => { expect(out.reasoning_tokens).toBe(40); }); + it("reads cached_tokens from the nested buildUsage() shape", () => { + // buildUsage() only emits cache reads under prompt_tokens_details. The + // Responses translator overwrites state.usage with that shape on + // response.completed, so a top-level-only read silently drops the cache + // count for every Responses provider (codex, grok-cli, ...). + const out = canonicalizeUsage( + buildUsage({ promptTokens: 330, completionTokens: 50, totalTokens: 380, cachedTokens: 200 }) + ); + expect(out.prompt_tokens).toBe(330); + expect(out.cached_tokens).toBe(200); + }); + it("handles no-cache usage", () => { const out = canonicalizeUsage({ prompt_tokens: 100, completion_tokens: 50 }); expect(out.prompt_tokens).toBe(100);