refactor: normalize cache usage parsing across ai-sdk providers

This commit is contained in:
Hannes Rudolph 2026-02-11 18:05:09 -07:00
parent 61fc45c06c
commit 94232eb644
6 changed files with 361 additions and 34 deletions

View file

@ -414,6 +414,88 @@ describe("OpenRouterHandler", () => {
})
})
it("includes cache metrics from usage.raw.prompt_tokens_details when metadata is absent", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockFullStream = (async function* () {
yield { type: "text-delta", text: "test", id: "1" }
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve({
inputTokens: 13_026,
outputTokens: 147,
totalTokens: 13_173,
raw: {
prompt_tokens_details: {
cached_tokens: 12_547,
cache_write_tokens: 470,
},
},
}),
totalUsage: Promise.resolve({ inputTokens: 13_026, outputTokens: 147, totalTokens: 13_173 }),
providerMetadata: Promise.resolve(undefined),
})
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toMatchObject({
type: "usage",
inputTokens: 13_026,
outputTokens: 147,
cacheReadTokens: 12_547,
cacheWriteTokens: 470,
})
})
it("derives input/output totals from raw prompt/completion tokens when top-level usage totals are missing", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockFullStream = (async function* () {
yield { type: "text-delta", text: "test", id: "1" }
})()
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: Promise.resolve({
raw: {
prompt_tokens: 13_026,
completion_tokens: 147,
prompt_tokens_details: {
cached_tokens: 12_547,
cache_write_tokens: 470,
},
},
}),
totalUsage: Promise.resolve({}),
providerMetadata: Promise.resolve(undefined),
})
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toMatchObject({
type: "usage",
inputTokens: 13_026,
outputTokens: 147,
cacheReadTokens: 12_547,
cacheWriteTokens: 470,
nonCachedInputTokens: 9,
})
})
it("handles experimental_providerMetadata fallback", async () => {
const handler = new OpenRouterHandler(mockOptions)

View file

@ -930,6 +930,47 @@ describe("RooHandler", () => {
expect(usageChunk.cacheWriteTokens).toBe(489)
expect(usageChunk.cacheReadTokens).toBe(12_572)
})
it("parses anthropic cache metrics from usage.raw.prompt_tokens_details when metadata cache fields are absent", async () => {
const anthropicHandler = new RooHandler({
apiModelId: "anthropic/claude-haiku-4.5",
})
mockStreamText.mockReturnValue(
createMockStreamResult({
usage: {
inputTokens: 13_026,
outputTokens: 147,
raw: {
prompt_tokens_details: {
cached_tokens: 12_547,
cache_write_tokens: 470,
},
},
} as any,
providerMetadata: {
gateway: {
cost: "0.01157975",
},
},
}),
)
const stream = anthropicHandler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk.inputTokens).toBe(13_026)
expect(usageChunk.outputTokens).toBe(147)
expect(usageChunk.nonCachedInputTokens).toBe(9)
expect(usageChunk.cacheWriteTokens).toBe(470)
expect(usageChunk.cacheReadTokens).toBe(12_547)
expect(usageChunk.totalCost).toBe(0.01157975)
})
})
describe("isAiSdkProvider", () => {

View file

@ -153,4 +153,110 @@ describe("normalizeProviderUsage", () => {
expect(normalized.chunk.cacheReadTokens).toBeUndefined()
expect(normalized.chunk.cacheWriteTokens).toBeUndefined()
})
it("parses OpenRouter-style prompt_tokens_details from raw usage", () => {
const normalized = normalizeProviderUsage({
provider: "openrouter",
apiProtocol: "openai",
usage: {
inputTokens: 13_026,
outputTokens: 147,
raw: {
prompt_tokens_details: {
cached_tokens: 12_547,
cache_write_tokens: 470,
},
},
},
modelInfo: baseModelInfo,
})
expect(normalized.chunk.inputTokens).toBe(13_026)
expect(normalized.chunk.cacheReadTokens).toBe(12_547)
expect(normalized.chunk.cacheWriteTokens).toBe(470)
expect(normalized.chunk.nonCachedInputTokens).toBe(9)
})
it("parses cache write/read tokens from prompt_tokens_details for anthropic protocol", () => {
const normalized = normalizeProviderUsage({
provider: "anthropic",
apiProtocol: "anthropic",
usage: {
inputTokens: 13_026,
outputTokens: 147,
raw: {
prompt_tokens_details: {
cached_tokens: 12_547,
cache_write_tokens: 470,
},
},
},
modelInfo: baseModelInfo,
})
expect(normalized.chunk.cacheReadTokens).toBe(12_547)
expect(normalized.chunk.cacheWriteTokens).toBe(470)
expect(normalized.chunk.nonCachedInputTokens).toBe(9)
})
it("parses reasoning tokens from completion_tokens_details", () => {
const normalized = normalizeProviderUsage({
provider: "openrouter",
apiProtocol: "openai",
usage: {
inputTokens: 100,
outputTokens: 30,
raw: {
completion_tokens_details: {
reasoning_tokens: 7,
},
},
},
modelInfo: baseModelInfo,
})
expect(normalized.chunk.reasoningTokens).toBe(7)
})
it("uses usage/raw cost candidates when provider metadata cost is absent", () => {
const normalized = normalizeProviderUsage({
provider: "openrouter",
apiProtocol: "openai",
usage: {
inputTokens: 100,
outputTokens: 20,
cost: 0.12345 as any,
raw: {
cost: 0.98765,
},
} as any,
modelInfo: baseModelInfo,
})
expect(normalized.chunk.totalCost).toBe(0.12345)
})
it("falls back to prompt/completion token totals from raw usage", () => {
const normalized = normalizeProviderUsage({
provider: "openrouter",
apiProtocol: "openai",
usage: {
raw: {
prompt_tokens: 200,
completion_tokens: 30,
prompt_tokens_details: {
cached_tokens: 100,
cache_write_tokens: 50,
},
},
},
modelInfo: baseModelInfo,
})
expect(normalized.chunk.inputTokens).toBe(200)
expect(normalized.chunk.outputTokens).toBe(30)
expect(normalized.chunk.cacheReadTokens).toBe(100)
expect(normalized.chunk.cacheWriteTokens).toBe(50)
expect(normalized.chunk.nonCachedInputTokens).toBe(50)
})
})

View file

@ -340,6 +340,46 @@ describe("VercelAiGatewayHandler", () => {
})
})
it("parses anthropic cache metrics from usage.raw.prompt_tokens_details when metadata cache fields are absent", async () => {
mockStreamText.mockReturnValue(
createMockStreamResult({
usage: {
inputTokens: 13_026,
outputTokens: 147,
raw: {
prompt_tokens_details: {
cached_tokens: 12_547,
cache_write_tokens: 470,
},
},
} as any,
providerMetadata: {},
}),
)
const handler = new VercelAiGatewayHandler(mockOptions)
const systemPrompt = "You are a helpful assistant."
const messages: RooMessage[] = [{ role: "user", content: "Hello" }]
const stream = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((chunk) => chunk.type === "usage")
expect(usageChunk).toEqual({
type: "usage",
inputTokens: 13_026,
nonCachedInputTokens: 9,
outputTokens: 147,
cacheWriteTokens: 470,
cacheReadTokens: 12_547,
totalCost: undefined,
reasoningTokens: undefined,
})
})
describe("native tool calling", () => {
const testTools = [
{

View file

@ -179,10 +179,45 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
const usage = await result.usage
const totalUsage = await result.totalUsage
const rawUsage = (usage as any)?.raw as Record<string, unknown> | undefined
const toFiniteNumber = (value: unknown): number | undefined => {
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}
const firstFiniteNumber = (...values: unknown[]): number | undefined => {
for (const value of values) {
const parsed = toFiniteNumber(value)
if (parsed !== undefined) {
return parsed
}
}
return undefined
}
const usageRecord = {
...(usage as any),
inputTokens: totalUsage.inputTokens ?? usage.inputTokens ?? 0,
outputTokens: totalUsage.outputTokens ?? usage.outputTokens ?? 0,
inputTokens: firstFiniteNumber(
totalUsage.inputTokens,
(usage as any).inputTokens,
(usage as any).promptTokens,
(usage as any).prompt_tokens,
rawUsage?.prompt_tokens,
rawUsage?.input_tokens,
),
outputTokens: firstFiniteNumber(
totalUsage.outputTokens,
(usage as any).outputTokens,
(usage as any).completionTokens,
(usage as any).completion_tokens,
rawUsage?.completion_tokens,
rawUsage?.output_tokens,
),
}
const { chunk } = normalizeProviderUsage({
provider: "openrouter",

View file

@ -29,14 +29,20 @@ const OPENAI_BASE_PROFILE: UsageProfile = {
deriveNonCachedInputFromTotalMinusCache: true,
metrics: {
inputTokensTotal: {
usage: ["inputTokens"],
usage: ["inputTokens", "promptTokens", "prompt_tokens"],
raw: ["input_tokens", "prompt_tokens"],
},
outputTokens: {
usage: ["outputTokens"],
usage: ["outputTokens", "completionTokens", "completion_tokens"],
raw: ["output_tokens", "completion_tokens"],
},
cacheWriteTokens: {
usage: ["inputTokenDetails.cacheWriteTokens", "cacheCreationInputTokens", "cache_creation_input_tokens"],
raw: ["cache_creation_input_tokens", "cacheCreationInputTokens"],
raw: [
"cache_creation_input_tokens",
"cacheCreationInputTokens",
"prompt_tokens_details.cache_write_tokens",
],
},
cacheReadTokens: {
usage: [
@ -47,6 +53,7 @@ const OPENAI_BASE_PROFILE: UsageProfile = {
],
raw: [
"input_tokens_details.cached_tokens",
"prompt_tokens_details.cached_tokens",
"cache_read_input_tokens",
"cacheReadInputTokens",
"cached_tokens",
@ -54,10 +61,12 @@ const OPENAI_BASE_PROFILE: UsageProfile = {
},
reasoningTokens: {
usage: ["outputTokenDetails.reasoningTokens", "reasoningTokens", "details.reasoningTokens"],
raw: ["output_tokens_details.reasoning_tokens"],
raw: ["output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"],
},
totalCostCandidate: {
providerMetadata: ["gateway.cost"],
usage: ["cost"],
raw: ["cost"],
},
},
}
@ -69,7 +78,8 @@ const ANTHROPIC_BASE_PROFILE: UsageProfile = {
deriveNonCachedInputFromTotalMinusCache: true,
metrics: {
inputTokensTotal: {
usage: ["inputTokens"],
usage: ["inputTokens", "promptTokens", "prompt_tokens"],
raw: ["input_tokens", "prompt_tokens"],
},
inputTokensNonCached: {
usage: ["inputTokenDetails.noCacheTokens"],
@ -77,14 +87,14 @@ const ANTHROPIC_BASE_PROFILE: UsageProfile = {
raw: ["input_tokens"],
},
outputTokens: {
usage: ["outputTokens"],
usage: ["outputTokens", "completionTokens", "completion_tokens"],
providerMetadata: ["anthropic.usage.output_tokens"],
raw: ["output_tokens"],
raw: ["output_tokens", "completion_tokens"],
},
cacheWriteTokens: {
providerMetadata: ["anthropic.usage.cache_creation_input_tokens", "anthropic.cacheCreationInputTokens"],
usage: ["inputTokenDetails.cacheWriteTokens", "cacheCreationInputTokens", "cache_creation_input_tokens"],
raw: ["cache_creation_input_tokens", "cacheCreationInputTokens"],
raw: ["cache_creation_input_tokens", "cacheCreationInputTokens", "prompt_tokens_details.cache_write_tokens"],
},
cacheReadTokens: {
providerMetadata: [
@ -98,19 +108,22 @@ const ANTHROPIC_BASE_PROFILE: UsageProfile = {
"details.cachedInputTokens",
"cached_tokens",
],
raw: [
"cache_read_input_tokens",
"cacheReadInputTokens",
"input_tokens_details.cached_tokens",
"cached_tokens",
],
raw: [
"cache_read_input_tokens",
"cacheReadInputTokens",
"input_tokens_details.cached_tokens",
"prompt_tokens_details.cached_tokens",
"cached_tokens",
],
},
reasoningTokens: {
usage: ["outputTokenDetails.reasoningTokens", "reasoningTokens", "details.reasoningTokens"],
raw: ["output_tokens_details.reasoning_tokens"],
raw: ["output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"],
},
totalCostCandidate: {
providerMetadata: ["gateway.cost"],
usage: ["cost"],
raw: ["cost"],
},
},
}
@ -144,7 +157,7 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
"details.cachedInputTokens",
"cached_tokens",
],
raw: ["input_tokens_details.cached_tokens", "cache_read_input_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cache_read_input_tokens", "cached_tokens"],
},
},
},
@ -200,12 +213,12 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
"details.cachedInputTokens",
"cached_tokens",
],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
reasoningTokens: {
providerMetadata: ["openai.reasoningTokens"],
usage: ["outputTokenDetails.reasoningTokens", "reasoningTokens", "details.reasoningTokens"],
raw: ["output_tokens_details.reasoning_tokens"],
raw: ["output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"],
},
},
},
@ -216,12 +229,12 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
cacheReadTokens: {
providerMetadata: ["openai.cachedPromptTokens"],
usage: ["inputTokenDetails.cacheReadTokens", "cachedInputTokens", "details.cachedInputTokens"],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
reasoningTokens: {
providerMetadata: ["openai.reasoningTokens"],
usage: ["outputTokenDetails.reasoningTokens", "reasoningTokens", "details.reasoningTokens"],
raw: ["output_tokens_details.reasoning_tokens"],
raw: ["output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"],
},
},
},
@ -232,12 +245,12 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
cacheReadTokens: {
providerMetadata: ["openai.cachedPromptTokens"],
usage: ["inputTokenDetails.cacheReadTokens", "cachedInputTokens", "details.cachedInputTokens"],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
reasoningTokens: {
providerMetadata: ["openai.reasoningTokens"],
usage: ["outputTokenDetails.reasoningTokens", "reasoningTokens", "details.reasoningTokens"],
raw: ["output_tokens_details.reasoning_tokens"],
raw: ["output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"],
},
},
},
@ -270,7 +283,7 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
"details.cachedInputTokens",
"cached_tokens",
],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
reasoningTokens: {
providerMetadata: [
@ -279,7 +292,7 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
"openrouter.output_tokens_details.reasoning_tokens",
],
usage: ["outputTokenDetails.reasoningTokens", "reasoningTokens", "details.reasoningTokens"],
raw: ["output_tokens_details.reasoning_tokens"],
raw: ["output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"],
},
},
},
@ -298,7 +311,7 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
cacheReadTokens: {
providerMetadata: ["requesty.usage.cachedTokens"],
usage: ["inputTokenDetails.cacheReadTokens", "cachedInputTokens", "details.cachedInputTokens"],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
},
},
@ -317,7 +330,7 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
cacheReadTokens: {
providerMetadata: ["fireworks.promptCacheHitTokens"],
usage: ["inputTokenDetails.cacheReadTokens", "cachedInputTokens", "details.cachedInputTokens"],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
},
},
@ -336,7 +349,7 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
cacheReadTokens: {
providerMetadata: ["deepseek.promptCacheHitTokens"],
usage: ["inputTokenDetails.cacheReadTokens", "cachedInputTokens", "details.cachedInputTokens"],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
},
},
@ -347,7 +360,7 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
cacheReadTokens: {
providerMetadata: ["xai.cachedPromptTokens"],
usage: ["inputTokenDetails.cacheReadTokens", "cachedInputTokens", "details.cachedInputTokens"],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
},
},
@ -358,7 +371,7 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
cacheReadTokens: {
providerMetadata: ["azure.promptCacheHitTokens"],
usage: ["inputTokenDetails.cacheReadTokens", "cachedInputTokens", "details.cachedInputTokens"],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
cacheWriteTokens: {
usage: ["inputTokenDetails.cacheWriteTokens"],
@ -380,7 +393,7 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
cacheReadTokens: {
providerMetadata: ["sambanova.promptCacheHitTokens"],
usage: ["inputTokenDetails.cacheReadTokens", "cachedInputTokens", "details.cachedInputTokens"],
raw: ["input_tokens_details.cached_tokens", "cached_tokens"],
raw: ["input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens", "cached_tokens"],
},
},
},
@ -451,7 +464,12 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
"details.cachedInputTokens",
"cached_tokens",
],
raw: ["input_tokens_details.cached_tokens", "cache_read_input_tokens", "cached_tokens"],
raw: [
"input_tokens_details.cached_tokens",
"prompt_tokens_details.cached_tokens",
"cache_read_input_tokens",
"cached_tokens",
],
},
totalCostCandidate: {
providerMetadata: ["roo.cost", "gateway.cost"],
@ -490,7 +508,12 @@ const PROFILES: Record<string, Partial<UsageProfile>> = {
"details.cachedInputTokens",
"cached_tokens",
],
raw: ["input_tokens_details.cached_tokens", "cache_read_input_tokens", "cached_tokens"],
raw: [
"input_tokens_details.cached_tokens",
"prompt_tokens_details.cached_tokens",
"cache_read_input_tokens",
"cached_tokens",
],
},
totalCostCandidate: {
providerMetadata: ["gateway.cost"],