fix(sse): keep cache-write tokens in OpenAI-shaped usage (#11814)

Mantém tokens de cache-write no formato de usage do OpenAI, com teste próprio (`cache-write-openai-shape.test.ts`) e atualização do teste existente de tokens detalhados. Validado no worktree combinado (typecheck limpo, 351/351 testes focados). Obrigado!
This commit is contained in:
Nguyễn Viết Tuấn
2026-08-30 21:09:15 +07:00
committed by GitHub
parent 1f4dc830f3
commit 4d20d37974
8 changed files with 326 additions and 127 deletions

View File

@@ -18,17 +18,26 @@ export function buildCacheUsageLogMeta(usage: Record<string, unknown> | null | u
usage.prompt_tokens_details && typeof usage.prompt_tokens_details === "object"
? (usage.prompt_tokens_details as Record<string, unknown>)
: 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 {

View File

@@ -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,

View File

@@ -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 ??

View File

@@ -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
);
}

View File

@@ -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 ??

View File

@@ -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);
}

View File

@@ -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);
});
});
});

View File

@@ -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");
});