mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
41
open-sse/utils/pickCacheCreationTokens.ts
Normal file
41
open-sse/utils/pickCacheCreationTokens.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
198
tests/unit/cache-write-openai-shape.test.ts
Normal file
198
tests/unit/cache-write-openai-shape.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user