From 94232eb644b395f58ecd7eb2365dd283a81e18f2 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 11 Feb 2026 18:05:09 -0700 Subject: [PATCH] refactor: normalize cache usage parsing across ai-sdk providers --- .../providers/__tests__/openrouter.spec.ts | 82 ++++++++++++++ src/api/providers/__tests__/roo.spec.ts | 41 +++++++ .../providers/__tests__/usage-metrics.spec.ts | 106 ++++++++++++++++++ .../__tests__/vercel-ai-gateway.spec.ts | 40 +++++++ src/api/providers/openrouter.ts | 39 ++++++- src/api/providers/utils/usage-profiles.ts | 87 ++++++++------ 6 files changed, 361 insertions(+), 34 deletions(-) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index f39470c691..bf1511472c 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -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) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 0a58496081..253421966f 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -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", () => { diff --git a/src/api/providers/__tests__/usage-metrics.spec.ts b/src/api/providers/__tests__/usage-metrics.spec.ts index f17aa7e3c8..7382dc75d2 100644 --- a/src/api/providers/__tests__/usage-metrics.spec.ts +++ b/src/api/providers/__tests__/usage-metrics.spec.ts @@ -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) + }) }) diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 8e6657e1ca..896681e9bd 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -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 = [ { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 3fcace84c8..2bd7807100 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -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 | 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", diff --git a/src/api/providers/utils/usage-profiles.ts b/src/api/providers/utils/usage-profiles.ts index 082a04ef95..14a46d9512 100644 --- a/src/api/providers/utils/usage-profiles.ts +++ b/src/api/providers/utils/usage-profiles.ts @@ -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> = { "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> = { "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> = { 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> = { 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> = { "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> = { "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> = { 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> = { 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> = { 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> = { 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> = { 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> = { 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> = { "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> = { "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"],