diff --git a/open-sse/handlers/chatCore/cacheUsageMeta.ts b/open-sse/handlers/chatCore/cacheUsageMeta.ts index ccb3b5707b..fdd7c721b1 100644 --- a/open-sse/handlers/chatCore/cacheUsageMeta.ts +++ b/open-sse/handlers/chatCore/cacheUsageMeta.ts @@ -18,17 +18,26 @@ export function buildCacheUsageLogMeta(usage: Record | null | u usage.prompt_tokens_details && typeof usage.prompt_tokens_details === "object" ? (usage.prompt_tokens_details as Record) : undefined; + // `cache_write_tokens` is the cache-creation alias emitted by OpenRouter, Devin + // Desktop and the codex-chatgpt-web bridge; without it an OpenAI-shaped usage + // payload logged a cache write of 0 for a model that actually reported one. const hasCacheFields = "cache_read_input_tokens" in usage || "cached_tokens" in usage || "cache_creation_input_tokens" in usage || + "cache_write_tokens" in usage || (!!promptTokenDetails && - ("cached_tokens" in promptTokenDetails || "cache_creation_tokens" in promptTokenDetails)); + ("cached_tokens" in promptTokenDetails || + "cache_creation_tokens" in promptTokenDetails || + "cache_write_tokens" in promptTokenDetails)); const cacheReadTokens = toPositiveNumber( usage.cache_read_input_tokens ?? usage.cached_tokens ?? promptTokenDetails?.cached_tokens ); const cacheCreationTokens = toPositiveNumber( - usage.cache_creation_input_tokens ?? promptTokenDetails?.cache_creation_tokens + usage.cache_creation_input_tokens ?? + promptTokenDetails?.cache_creation_tokens ?? + promptTokenDetails?.cache_write_tokens ?? + usage.cache_write_tokens ); if (!hasCacheFields) return null; return { diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 5989874527..0038f7625b 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -319,10 +319,15 @@ export function translateNonStreamingResponse( promptTokensDetails.cached_tokens, usage.cache_read_input_tokens ); + // `cache_write_tokens` is the alias emitted by the codex-chatgpt-web bridge + // (input_tokens_details) and by OpenRouter/Devin Desktop (top level). const cacheCreationInputTokens = firstPositiveNumber( inputTokensDetails.cache_creation_tokens, promptTokensDetails.cache_creation_tokens, - usage.cache_creation_input_tokens + usage.cache_creation_input_tokens, + inputTokensDetails.cache_write_tokens, + promptTokensDetails.cache_write_tokens, + usage.cache_write_tokens ); const reasoningTokens = firstPositiveNumber( outputTokensDetails.reasoning_tokens, diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index f424996ca4..789b1019ca 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -16,6 +16,13 @@ export function extractUsageFromResponse(responseBody, provider) { typeof responseBody.usage === "object" && responseBody.usage.prompt_tokens !== undefined ) { + const cacheCreationTokens = + responseBody.usage.cache_creation_input_tokens ?? + responseBody.usage.prompt_tokens_details?.cache_creation_tokens ?? + responseBody.usage.input_tokens_details?.cache_creation_tokens ?? + responseBody.usage.prompt_tokens_details?.cache_write_tokens ?? + responseBody.usage.input_tokens_details?.cache_write_tokens ?? + responseBody.usage.cache_write_tokens; return { prompt_tokens: responseBody.usage.prompt_tokens || 0, completion_tokens: responseBody.usage.completion_tokens || 0, @@ -28,6 +35,17 @@ export function extractUsageFromResponse(responseBody, provider) { responseBody.usage.prompt_cache_hit_tokens ?? responseBody.usage.cached_tokens ?? responseBody.usage.cache_read_input_tokens, + // Cache WRITE tokens. Anthropic models reached through an OpenAI-compatible + // endpoint carry the count nested in prompt/input token details (see + // translator/response/claude-to-openai.ts, #2215) or under the + // `cache_write_tokens` alias used by OpenRouter/Devin/codex-chatgpt-web. + // Reading only the flat Anthropic key made the dashboard show "Cache Write: + // N/A" for the very same model that reports a real count natively. + // Only emit the key when a provider actually reported one, so a provider + // with no cache-write concept (plain gpt/codex) stays N/A instead of 0. + ...(cacheCreationTokens !== undefined + ? { cache_creation_input_tokens: cacheCreationTokens } + : {}), reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens ?? responseBody.usage.output_tokens_details?.reasoning_tokens ?? diff --git a/open-sse/utils/pickCacheCreationTokens.ts b/open-sse/utils/pickCacheCreationTokens.ts new file mode 100644 index 0000000000..1b8d0921c5 --- /dev/null +++ b/open-sse/utils/pickCacheCreationTokens.ts @@ -0,0 +1,41 @@ +type CacheWriteDetails = { + cache_creation_tokens?: number; + cache_write_tokens?: number; +}; + +type CacheWriteUsageSource = { + cache_creation_input_tokens?: number; + cache_write_tokens?: number; + prompt_tokens_details?: CacheWriteDetails; + input_tokens_details?: CacheWriteDetails; +}; + +/** + * Resolve prompt cache-CREATION (write) tokens from any container shape. + * + * Anthropic reports a flat `cache_creation_input_tokens`, but the same count + * arrives nested under prompt/input token details once usage has been translated + * into OpenAI shape (translator/response/claude-to-openai.ts, #2215), and several + * gateways (OpenRouter, Devin Desktop, the codex-chatgpt-web bridge) spell it + * `cache_write_tokens`. Reading only the flat Anthropic key made every + * OpenAI-shaped path drop the value, so the dashboard showed "Cache Write: N/A" + * for a model that reports a real count natively. + * + * Mirrors the key precedence of getPromptCacheCreationTokens() in + * src/lib/usage/tokenAccounting.ts, but returns `undefined` (not 0) when no + * provider reported anything, so normalizeUsage() keeps omitting the key and the + * dashboard can still tell "not reported" (N/A) from "reported as zero". + */ +export function pickCacheCreationTokens(usage: CacheWriteUsageSource | null | undefined) { + if (!usage || typeof usage !== "object") return undefined; + const promptDetails = usage.prompt_tokens_details; + const inputDetails = usage.input_tokens_details; + return ( + usage.cache_creation_input_tokens ?? + promptDetails?.cache_creation_tokens ?? + inputDetails?.cache_creation_tokens ?? + promptDetails?.cache_write_tokens ?? + inputDetails?.cache_write_tokens ?? + usage.cache_write_tokens + ); +} diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 24fe802fda..2736f84b3b 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -11,10 +11,15 @@ import { getPromptCacheReadTokens, } from "@/lib/usage/tokenAccounting"; import { FORMATS } from "../translator/formats.ts"; +import { pickCacheCreationTokens } from "./pickCacheCreationTokens.ts"; + +export { pickCacheCreationTokens }; /** Nested `*_tokens_details` containers ({ cached_tokens, reasoning_tokens, … }). */ interface UsageTokenDetail { cached_tokens?: number; + cache_creation_tokens?: number; + cache_write_tokens?: number; reasoning_tokens?: number; thinking_tokens?: number; [field: string]: unknown; @@ -38,6 +43,8 @@ export interface UsageLike { cost_in_usd_ticks?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; + /** OpenRouter / Devin Desktop / codex-chatgpt-web alias for cache creation. */ + cache_write_tokens?: number; prompt_cache_hit_tokens?: number; prompt_cache_miss_tokens?: number; promptTokenCount?: number; @@ -612,7 +619,7 @@ export function normalizeUsage(usage: UsageLike | null | undefined) { assignNumber("input_tokens", usage?.input_tokens); assignNumber("output_tokens", usage?.output_tokens); assignNumber("cache_read_input_tokens", usage?.cache_read_input_tokens); - assignNumber("cache_creation_input_tokens", usage?.cache_creation_input_tokens); + assignNumber("cache_creation_input_tokens", pickCacheCreationTokens(usage)); assignNumber("cached_tokens", usage?.cached_tokens); assignNumber("no_cache_tokens", usage?.no_cache_tokens); assignNumber("reasoning_tokens", usage?.reasoning_tokens); @@ -719,7 +726,7 @@ export function extractUsage(chunk: UsagePayloadLike | null | undefined) { usage.input_tokens_details?.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.cache_read_input_tokens, - cache_creation_input_tokens: usage.cache_creation_input_tokens, + cache_creation_input_tokens: pickCacheCreationTokens(usage), reasoning_tokens: usage.output_tokens_details?.reasoning_tokens ?? usage.completion_tokens_details?.reasoning_tokens ?? @@ -742,7 +749,7 @@ export function extractUsage(chunk: UsagePayloadLike | null | undefined) { chunk.usage.prompt_cache_hit_tokens ?? chunk.usage.cached_tokens, cache_read_input_tokens: chunk.usage.cache_read_input_tokens, - cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens, + cache_creation_input_tokens: pickCacheCreationTokens(chunk.usage), no_cache_tokens: chunk.usage.no_cache_tokens, reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens ?? diff --git a/src/lib/usage/tokenAccounting.ts b/src/lib/usage/tokenAccounting.ts index 932d71223a..a14f2fedca 100644 --- a/src/lib/usage/tokenAccounting.ts +++ b/src/lib/usage/tokenAccounting.ts @@ -31,13 +31,34 @@ export function getPromptCacheReadTokens(tokens: unknown): number { ); } +/** + * Every key a provider may use to report prompt cache-CREATION (write) tokens, + * in precedence order. Anthropic uses `cache_creation_input_tokens`; the OpenAI + * -shaped containers nest it under prompt/input token details, and several + * gateways (OpenRouter, Devin Desktop, the codex-chatgpt-web bridge) spell it + * `cache_write_tokens`. Consumers that only read the Anthropic key silently + * dropped the value whenever usage travelled in OpenAI shape (#Cache Write N/A). + */ +export const CACHE_CREATION_TOKEN_KEYS = [ + "cacheCreation", + "cache_creation_input_tokens", + "cache_write_tokens", +] as const; + +export const CACHE_CREATION_TOKEN_DETAIL_KEYS = [ + "cache_creation_tokens", + "cache_write_tokens", +] as const; + export function getPromptCacheCreationTokens(tokens: unknown): number { const tokenRecord = asRecord(tokens); const promptDetails = getPromptTokenDetails(tokenRecord); return toFiniteNumber( tokenRecord.cacheCreation ?? tokenRecord.cache_creation_input_tokens ?? - promptDetails.cache_creation_tokens + tokenRecord.cache_write_tokens ?? + promptDetails.cache_creation_tokens ?? + promptDetails.cache_write_tokens ); } @@ -167,8 +188,8 @@ export function getPromptCacheCreationTokensOrNull(tokens: unknown): number | nu const tokenRecord = asRecord(tokens); const promptDetails = getPromptTokenDetails(tokenRecord); if ( - hasAnyKey(tokenRecord, ["cacheCreation", "cache_creation_input_tokens"]) || - hasAnyKey(promptDetails, ["cache_creation_tokens"]) + hasAnyKey(tokenRecord, [...CACHE_CREATION_TOKEN_KEYS]) || + hasAnyKey(promptDetails, [...CACHE_CREATION_TOKEN_DETAIL_KEYS]) ) { return getPromptCacheCreationTokens(tokens); } diff --git a/tests/unit/cache-write-openai-shape.test.ts b/tests/unit/cache-write-openai-shape.test.ts new file mode 100644 index 0000000000..995c4a0801 --- /dev/null +++ b/tests/unit/cache-write-openai-shape.test.ts @@ -0,0 +1,198 @@ +/** + * Regression: cache-write (cache creation) tokens were dropped whenever the usage + * payload arrived in an OpenAI-shaped container. + * + * Symptom: the same Claude model shows "Cache Write: 1,911" through an + * anthropic-compatible provider but "Cache Write: N/A" through an + * openai-compatible `/v1/chat/completions` provider, because every consumer only + * recognised the top-level Claude key `cache_creation_input_tokens`. + * + * Three shapes are produced inside this repo and none of them were read back: + * - `prompt_tokens_details.cache_creation_tokens` + * (open-sse/translator/response/claude-to-openai.ts, #2215) + * - `input_tokens_details.cache_write_tokens` + * (open-sse/vendor/codex-chatgpt-web/bridge.ts) + * - top-level `cache_write_tokens` + * (open-sse/executors/devin-desktop.ts, OpenRouter) + * + * A provider that genuinely has no cache-write concept (plain gpt/codex) must + * still report `null`, NOT `0` — `null` means "not reported", `0` means + * "reported as zero". That distinction is load-bearing for cache debugging. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + getPromptCacheCreationTokens, + getPromptCacheCreationTokensOrNull, +} from "../../src/lib/usage/tokenAccounting.ts"; +import { buildCacheUsageLogMeta } from "../../open-sse/handlers/chatCore/cacheUsageMeta.ts"; +import { extractUsage, normalizeUsage } from "../../open-sse/utils/usageTracking.ts"; +import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts"; + +describe("cache-write tokens survive OpenAI-shaped usage", () => { + describe("tokenAccounting alias coverage", () => { + it("reads prompt_tokens_details.cache_creation_tokens (claude-to-openai #2215 shape)", () => { + const tokens = { + prompt_tokens: 5000, + completion_tokens: 100, + prompt_tokens_details: { cached_tokens: 0, cache_creation_tokens: 1911 }, + }; + assert.equal(getPromptCacheCreationTokens(tokens), 1911); + assert.equal(getPromptCacheCreationTokensOrNull(tokens), 1911); + }); + + it("reads input_tokens_details.cache_write_tokens (codex-chatgpt-web bridge shape)", () => { + const tokens = { + prompt_tokens: 5000, + completion_tokens: 100, + input_tokens_details: { cached_tokens: 0, cache_write_tokens: 1911 }, + }; + assert.equal(getPromptCacheCreationTokens(tokens), 1911); + assert.equal(getPromptCacheCreationTokensOrNull(tokens), 1911); + }); + + it("reads top-level cache_write_tokens (devin-desktop / OpenRouter shape)", () => { + const tokens = { + prompt_tokens: 5, + completion_tokens: 100, + prompt_tokens_details: { cached_tokens: 0 }, + cache_write_tokens: 1911, + }; + assert.equal(getPromptCacheCreationTokens(tokens), 1911); + assert.equal(getPromptCacheCreationTokensOrNull(tokens), 1911); + }); + + it("reported-zero cache_write_tokens stays 0, never null", () => { + const tokens = { + prompt_tokens: 5, + completion_tokens: 100, + prompt_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, + }; + assert.equal(getPromptCacheCreationTokensOrNull(tokens), 0); + }); + + it("plain gpt/codex usage (no cache-write concept) still returns null", () => { + const tokens = { + prompt_tokens: 54042, + completion_tokens: 8000, + prompt_tokens_details: { cached_tokens: 53221 }, + completion_tokens_details: { reasoning_tokens: 6433 }, + }; + assert.equal( + getPromptCacheCreationTokensOrNull(tokens), + null, + "not reported must stay null, not 0" + ); + }); + }); + + describe("extractUsageFromResponse (non-streaming)", () => { + it("keeps cache creation from an OpenAI-shaped body", () => { + const usage = extractUsageFromResponse( + { + usage: { + prompt_tokens: 5000, + completion_tokens: 100, + prompt_tokens_details: { cached_tokens: 0, cache_creation_tokens: 1911 }, + }, + }, + "openai-compatible" + ); + assert.equal(getPromptCacheCreationTokensOrNull(usage), 1911); + }); + + it("keeps a top-level cache_write_tokens alias", () => { + const usage = extractUsageFromResponse( + { + usage: { + prompt_tokens: 5000, + completion_tokens: 100, + cache_write_tokens: 1911, + }, + }, + "openai-compatible" + ); + assert.equal(getPromptCacheCreationTokensOrNull(usage), 1911); + }); + + it("does not invent a cache-creation field when the provider omits it", () => { + const usage = extractUsageFromResponse( + { usage: { prompt_tokens: 10, completion_tokens: 2 } }, + "openai-compatible" + ); + assert.equal(getPromptCacheCreationTokensOrNull(usage), null); + }); + }); + + describe("extractUsage (streaming chunk)", () => { + it("keeps cache creation nested in prompt_tokens_details", () => { + const usage = extractUsage({ + usage: { + prompt_tokens: 5000, + completion_tokens: 100, + prompt_tokens_details: { cached_tokens: 0, cache_creation_tokens: 1911 }, + }, + }); + assert.equal(getPromptCacheCreationTokensOrNull(usage), 1911); + }); + + it("keeps cache creation from a Responses-API completed event", () => { + const usage = extractUsage({ + type: "response.completed", + response: { + usage: { + input_tokens: 5000, + output_tokens: 100, + input_tokens_details: { cached_tokens: 0, cache_write_tokens: 1911 }, + }, + }, + }); + assert.equal(getPromptCacheCreationTokensOrNull(usage), 1911); + }); + }); + + describe("normalizeUsage", () => { + it("maps the cache_write_tokens alias onto the canonical key", () => { + const normalized = normalizeUsage({ + prompt_tokens: 5000, + completion_tokens: 100, + cache_write_tokens: 1911, + }); + assert.equal(normalized?.cache_creation_input_tokens, 1911); + }); + + it("does not overwrite an explicit canonical value", () => { + const normalized = normalizeUsage({ + prompt_tokens: 5000, + completion_tokens: 100, + cache_creation_input_tokens: 1911, + cache_write_tokens: 7, + }); + assert.equal(normalized?.cache_creation_input_tokens, 1911); + }); + }); + + describe("buildCacheUsageLogMeta", () => { + it("reports cache creation from the OpenAI-shaped nested key", () => { + const meta = buildCacheUsageLogMeta({ + prompt_tokens: 5000, + prompt_tokens_details: { cached_tokens: 0, cache_creation_tokens: 1911 }, + }); + assert.equal(meta?.cacheCreationTokens, 1911); + }); + + it("reports cache creation from the cache_write_tokens alias", () => { + const meta = buildCacheUsageLogMeta({ + prompt_tokens: 5000, + cache_write_tokens: 1911, + }); + assert.equal(meta?.cacheCreationTokens, 1911); + }); + + it("still returns null when no cache field is present at all", () => { + assert.equal(buildCacheUsageLogMeta({ prompt_tokens: 10, completion_tokens: 2 }), null); + }); + }); +}); diff --git a/tests/unit/call-log-detailed-tokens.test.ts b/tests/unit/call-log-detailed-tokens.test.ts index 160f213edd..194fe59ef9 100644 --- a/tests/unit/call-log-detailed-tokens.test.ts +++ b/tests/unit/call-log-detailed-tokens.test.ts @@ -1,127 +1,28 @@ -/** +/** * Unit tests for detailed token tracking in call logs. * * Verifies that getPromptCacheReadTokensOrNull, getPromptCacheCreationTokensOrNull, * and getReasoningTokensOrNull correctly distinguish between: - * - Provider didn't report the field → null - * - Provider reported zero → 0 + * - Provider didn't report the field -> null + * - Provider reported zero -> 0 * * Also tests getLoggedInputTokens for each provider format. + * + * These import the real implementations. They used to inline a hand-copied clone + * of tokenAccounting.ts, which drifted from the source and ended up asserting a + * bug as expected behaviour (`cache_write_tokens` silently dropped). */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -// ── Inline the logic from tokenAccounting.ts ──────────────────────────── - -function asRecord(value) { - return value && typeof value === "object" && !Array.isArray(value) ? value : {}; -} - -function toFiniteNumber(value) { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; - } - return 0; -} - -function getPromptTokenDetails(tokens) { - const tokenRecord = asRecord(tokens); - const promptDetails = asRecord(tokenRecord.prompt_tokens_details); - if (Object.keys(promptDetails).length > 0) return promptDetails; - return asRecord(tokenRecord.input_tokens_details); -} - -function getPromptCacheReadTokens(tokens) { - const tokenRecord = asRecord(tokens); - const promptDetails = getPromptTokenDetails(tokenRecord); - return toFiniteNumber( - tokenRecord.cacheRead ?? - tokenRecord.cache_read_input_tokens ?? - tokenRecord.cached_tokens ?? - promptDetails.cached_tokens - ); -} - -function getPromptCacheCreationTokens(tokens) { - const tokenRecord = asRecord(tokens); - const promptDetails = getPromptTokenDetails(tokenRecord); - return toFiniteNumber( - tokenRecord.cacheCreation ?? - tokenRecord.cache_creation_input_tokens ?? - promptDetails.cache_creation_tokens - ); -} - -function getReasoningTokens(tokens) { - const tokenRecord = asRecord(tokens); - const completionDetails = asRecord(tokenRecord.completion_tokens_details); - return toFiniteNumber( - tokenRecord.reasoning ?? tokenRecord.reasoning_tokens ?? completionDetails.reasoning_tokens - ); -} - -function hasAnyKey(record, keys) { - return keys.some((k) => record[k] !== undefined && record[k] !== null); -} - -function getPromptCacheReadTokensOrNull(tokens) { - const tokenRecord = asRecord(tokens); - const promptDetails = getPromptTokenDetails(tokenRecord); - if ( - hasAnyKey(tokenRecord, ["cacheRead", "cache_read_input_tokens", "cached_tokens"]) || - hasAnyKey(promptDetails, ["cached_tokens"]) - ) { - return getPromptCacheReadTokens(tokens); - } - return null; -} - -function getPromptCacheCreationTokensOrNull(tokens) { - const tokenRecord = asRecord(tokens); - const promptDetails = getPromptTokenDetails(tokenRecord); - if ( - hasAnyKey(tokenRecord, ["cacheCreation", "cache_creation_input_tokens"]) || - hasAnyKey(promptDetails, ["cache_creation_tokens"]) - ) { - return getPromptCacheCreationTokens(tokens); - } - return null; -} - -function getReasoningTokensOrNull(tokens) { - const tokenRecord = asRecord(tokens); - const completionDetails = asRecord(tokenRecord.completion_tokens_details); - if ( - hasAnyKey(tokenRecord, ["reasoning", "reasoning_tokens"]) || - hasAnyKey(completionDetails, ["reasoning_tokens"]) - ) { - return getReasoningTokens(tokens); - } - return null; -} - -function getLoggedInputTokens(tokens) { - const tokenRecord = asRecord(tokens); - if (tokenRecord.input !== undefined && tokenRecord.input !== null) { - return toFiniteNumber(tokenRecord.input); - } - if (tokenRecord.input_tokens !== undefined && tokenRecord.input_tokens !== null) { - return ( - toFiniteNumber(tokenRecord.input_tokens) + - toFiniteNumber(tokenRecord.cache_read_input_tokens) + - toFiniteNumber(tokenRecord.cache_creation_input_tokens) - ); - } - const promptTokens = toFiniteNumber(tokenRecord.prompt_tokens); - return promptTokens; -} - -// ── Provider format tests ─────────────────────────────────────────────── - -describe("detailed token extraction — per provider format", () => { +import { + getLoggedInputTokens, + getPromptCacheCreationTokensOrNull, + getPromptCacheReadTokensOrNull, + getReasoningTokensOrNull, +} from "../../src/lib/usage/tokenAccounting.ts"; +describe("detailed token extraction — per provider format", () => { it("Anthropic (streaming extracted): input_tokens=3, cache_creation=113613, cache_read=0", () => { // Raw Anthropic streaming usage (from message_start event) const tokens = { @@ -171,13 +72,12 @@ describe("detailed token extraction — per provider format", () => { }; assert.equal(getLoggedInputTokens(tokens), 5); assert.equal(getPromptCacheReadTokensOrNull(tokens), 0, "Cache read = 0 (reported)"); - // cache_write_tokens is in prompt_tokens_details but our function checks - // cache_creation_input_tokens / cache_creation_tokens - // OpenRouter uses cache_write_tokens which is NOT recognized → null + // OpenRouter spells cache creation `cache_write_tokens`. It is a reported + // zero, so it must map to 0 -- not to null, which means "not reported". assert.equal( getPromptCacheCreationTokensOrNull(tokens), - null, - "OpenRouter cache_write_tokens not mapped to creation" + 0, + "OpenRouter cache_write_tokens maps to cache creation" ); assert.equal(getReasoningTokensOrNull(tokens), 60, "Reasoning = 60"); });