From cae8d69e572e038d584c881efd1818e40571fb7c Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 22 Oct 2026 11:01:39 +0200 Subject: [PATCH] fix(ai): use OpenRouter-reported total cost (#10286) Use authoritative OpenRouter billing for Chat Completions and Anthropic Messages while retaining catalog component estimates and fallback behavior. Fixes #9980 --- packages/ai/README.md | 2 + packages/ai/src/api/anthropic-messages.ts | 13 ++++ packages/ai/src/api/openai-completions.ts | 10 +++ packages/ai/src/types.ts | 5 ++ packages/ai/src/utils/openrouter-cost.ts | 55 +++++++++++++ .../ai/test/anthropic-sse-parsing.test.ts | 36 +++++++++ ...-completions-provider-stream-event.test.ts | 65 +++++++++++++++- packages/ai/test/openrouter-cost.test.ts | 78 +++++++++++++++++++ .../src/core/export-html/template.js | 12 ++- .../test/export-html-cost.test.ts | 52 +++++++++++++ 10 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 packages/ai/src/utils/openrouter-cost.ts create mode 100644 packages/ai/test/openrouter-cost.test.ts create mode 100644 packages/coding-agent/test/export-html-cost.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 88b961cee..61038c7cf 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -237,6 +237,8 @@ for (const block of response.content) { Snippets in the rest of this README assume a `models` collection set up like this (with the relevant providers registered). +`usage.cost.total` is the best available total cost. For OpenRouter chat responses, it uses the cost reported by OpenRouter, including both the OpenRouter charge and upstream inference cost for BYOK requests. When provider accounting is unavailable or invalid, it falls back to the model's catalog rates. The `input`, `output`, `cacheRead`, and `cacheWrite` cost components remain catalog estimates and may not sum to a provider-reported total. + ## Providers and Models A **provider** is the runtime unit: it owns its model catalog, its auth (API key resolution, OAuth flows), and its stream behavior. A `Models` collection holds providers and routes every request to the provider that owns the model. diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 2754794c3..761ccd1e9 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -42,6 +42,11 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts"; import { parseJsonWithRepair, parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts"; +import { + applyOpenRouterReportedCost, + isOpenRouterModel, + type OpenRouterUsageAccounting, +} from "../utils/openrouter-cost.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -888,6 +893,14 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = ( output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost(usageModel, output.usage); + if (isOpenRouterModel(model) && event.usage) { + // OpenRouter adds billing fields to streamed message_delta usage at runtime, + // although its OpenAPI schema and the Anthropic SDK do not type those fields. + applyOpenRouterReportedCost( + output.usage, + event.usage as typeof event.usage & OpenRouterUsageAccounting, + ); + } } } diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 083b6dc8f..a752d304e 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -41,6 +41,11 @@ import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { type ClientIdentity, getIdentityHeaders } from "../utils/identity.ts"; import { parseStreamingJson, parseToolCallArguments } from "../utils/json-parse.ts"; +import { + applyOpenRouterReportedCost, + isOpenRouterModel, + type OpenRouterUsageAccounting, +} from "../utils/openrouter-cost.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; @@ -1527,6 +1532,8 @@ function convertTools( } function parseChunkUsage( + // OpenRouter adds billing fields to streamed usage at runtime, + // although the OpenAI SDK does not type those fields. rawUsage: { prompt_tokens?: number; completion_tokens?: number; @@ -1566,6 +1573,9 @@ function parseChunkUsage( cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; calculateCost(model, usage); + if (isOpenRouterModel(model)) { + applyOpenRouterReportedCost(usage, rawUsage as typeof rawUsage & OpenRouterUsageAccounting); + } return usage; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 3470ef327..d1fcee314 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -471,10 +471,15 @@ export interface Usage { reasoning?: number; totalTokens: number; cost: { + /** Catalog-derived input cost. */ input: number; + /** Catalog-derived output cost. */ output: number; + /** Catalog-derived cache-read cost. */ cacheRead: number; + /** Catalog-derived cache-write cost. */ cacheWrite: number; + /** Best available total: provider-reported billed cost when supported, otherwise the catalog estimate. */ total: number; /** True when `total` is the billed cost reported by the provider. False when it is a catalog estimate; absent also means false. */ totalFromProvider?: boolean; diff --git a/packages/ai/src/utils/openrouter-cost.ts b/packages/ai/src/utils/openrouter-cost.ts new file mode 100644 index 000000000..0eb12613e --- /dev/null +++ b/packages/ai/src/utils/openrouter-cost.ts @@ -0,0 +1,55 @@ +import type { Usage } from "../types.ts"; + +export interface OpenRouterUsageAccounting { + /** Amount charged to the user's OpenRouter account. */ + cost: number | null; + /** Whether OpenRouter used the user's upstream provider key. */ + is_byok: boolean; + cost_details: { + /** Total inference cost billed through the user's upstream provider account, when available. */ + upstream_inference_cost: number | null; + /** Upstream cost for prompt processing, including any provider-side cache pricing. */ + upstream_inference_prompt_cost: number; + /** Upstream cost for completion generation. */ + upstream_inference_completions_cost: number; + /** Metered server-tool cost already included in OpenRouter's charged `cost`. */ + server_tool_cost?: number | null; + } | null; +} + +function isValidCost(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +export function isOpenRouterModel(model: { provider: string; baseUrl: string }): boolean { + return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai"); +} + +/** Replace the catalog total with OpenRouter's billed cost when the response contains complete, valid accounting. */ +export function applyOpenRouterReportedCost(usage: Usage, reported: OpenRouterUsageAccounting): void { + if (!isValidCost(reported.cost)) return; + + if (reported.is_byok === false) { + usage.cost.total = reported.cost; + usage.cost.totalFromProvider = true; + return; + } + + if (reported.is_byok === true) { + const details = reported.cost_details; + if (!details) return; + let upstreamCost: unknown = details.upstream_inference_cost; + if (upstreamCost === null) { + if ( + !isValidCost(details.upstream_inference_prompt_cost) || + !isValidCost(details.upstream_inference_completions_cost) + ) { + return; + } + upstreamCost = details.upstream_inference_prompt_cost + details.upstream_inference_completions_cost; + } + if (!isValidCost(upstreamCost)) return; + usage.cost.total = reported.cost + upstreamCost; + usage.cost.totalFromProvider = true; + } +} diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 66f4b3192..9f65f831a 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -139,6 +139,42 @@ describe("Anthropic raw SSE parsing", () => { expect(eventModels).toEqual([model, model, model, model, model, model]); }); + // Regression test for #9980. + it("uses the total reported by OpenRouter's Anthropic Messages endpoint", async () => { + const events = minimalAnthropicEvents.map((event) => ({ ...event })); + const delta = JSON.parse(events[4].data) as { + usage: Record; + }; + // These fields were captured from a production OpenRouter stream even though its + // MessagesDeltaEvent OpenAPI schema and the Anthropic SDK omit them. + delta.usage = { + ...delta.usage, + cost: 0.004, + is_byok: false, + cost_details: { upstream_inference_cost: 0.003 }, + }; + events[4].data = JSON.stringify(delta); + + const model = { + ...getModel("openrouter", "anthropic/claude-haiku-4.5"), + provider: "openrouter-work", + }; + const result = await streamAnthropic( + model, + normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }), + { client: createFakeAnthropicClient(createSseResponse(events)) }, + ).result(); + + expect(result.usage.cost.total).toBe(0.004); + expect(result.usage.cost.totalFromProvider).toBe(true); + expect( + result.usage.cost.input + + result.usage.cost.output + + result.usage.cost.cacheRead + + result.usage.cost.cacheWrite, + ).not.toBe(result.usage.cost.total); + }); + it("keeps signed thinking replayable when a proxy relabels the model", async () => { // Regression test for earendil-works/pi#9188. const model = getModel("anthropic", "claude-opus-5"); diff --git a/packages/ai/test/openai-completions-provider-stream-event.test.ts b/packages/ai/test/openai-completions-provider-stream-event.test.ts index b025599d3..bc7415f4a 100644 --- a/packages/ai/test/openai-completions-provider-stream-event.test.ts +++ b/packages/ai/test/openai-completions-provider-stream-event.test.ts @@ -34,12 +34,12 @@ vi.mock("openai", () => { return { default: FakeOpenAI }; }); -function openRouterModel(): Model<"openai-completions"> { +function openRouterModel(provider = "openrouter"): Model<"openai-completions"> { return { id: "openrouter/auto", name: "OpenRouter Auto", api: "openai-completions", - provider: "openrouter", + provider, baseUrl: "https://openrouter.ai/api/v1", reasoning: false, input: ["text"], @@ -91,4 +91,65 @@ describe("openai-completions provider stream events", () => { expect(message.content).toEqual([{ type: "text", text: "hello" }]); expect(events).toEqual([firstChunk, finalChunk]); }); + + // Regression test for #9980. + it("uses OpenRouter's reported total without double-counting non-BYOK upstream cost", async () => { + mockState.chunks = [ + { + id: "chatcmpl-cost", + model: "openrouter/auto", + choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }], + // These billing fields were captured from a production OpenRouter stream + // even though the OpenAI SDK omits them from CompletionUsage. + usage: { + prompt_tokens: 100, + completion_tokens: 20, + cost: 0.004, + is_byok: false, + cost_details: { upstream_inference_cost: 0.003 }, + }, + }, + ]; + + const message = await completeSimple( + openRouterModel("openrouter-work"), + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test" }, + ); + + expect(message.usage.cost).toEqual({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0.004, + totalFromProvider: true, + }); + }); + + it("includes upstream inference cost for OpenRouter BYOK requests", async () => { + mockState.chunks = [ + { + id: "chatcmpl-byok", + model: "openrouter/auto", + choices: [{ index: 0, delta: { content: "done" }, finish_reason: "stop" }], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + cost: 0.0002, + is_byok: true, + cost_details: { upstream_inference_cost: 0.003 }, + }, + }, + ]; + + const message = await completeSimple( + openRouterModel(), + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test" }, + ); + + expect(message.usage.cost.total).toBeCloseTo(0.0032, 10); + expect(message.usage.cost.totalFromProvider).toBe(true); + }); }); diff --git a/packages/ai/test/openrouter-cost.test.ts b/packages/ai/test/openrouter-cost.test.ts new file mode 100644 index 000000000..dd9bc5069 --- /dev/null +++ b/packages/ai/test/openrouter-cost.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { Usage } from "../src/types.ts"; +import { applyOpenRouterReportedCost, isOpenRouterModel } from "../src/utils/openrouter-cost.ts"; + +function usage(total = 1): Usage { + return { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0.25, output: 0.75, cacheRead: 0, cacheWrite: 0, total }, + }; +} + +describe("isOpenRouterModel", () => { + it("recognizes both the built-in provider and custom providers using OpenRouter's endpoint", () => { + expect(isOpenRouterModel({ provider: "openrouter", baseUrl: "https://example.com" })).toBe(true); + expect(isOpenRouterModel({ provider: "openrouter-work", baseUrl: "https://openrouter.ai/api/v1" })).toBe(true); + expect(isOpenRouterModel({ provider: "custom", baseUrl: "https://example.com/v1" })).toBe(false); + }); +}); + +describe("applyOpenRouterReportedCost", () => { + it("uses OpenRouter's reported cost for non-BYOK requests", () => { + const value = usage(); + applyOpenRouterReportedCost(value, { + cost: 0, + is_byok: false, + cost_details: { + upstream_inference_cost: 0, + upstream_inference_prompt_cost: 0, + upstream_inference_completions_cost: 0, + }, + }); + expect(value.cost.total).toBe(0); + expect(value.cost.totalFromProvider).toBe(true); + }); + + it("adds the upstream inference cost for BYOK requests", () => { + const value = usage(); + applyOpenRouterReportedCost(value, { + cost: 0.1, + is_byok: true, + cost_details: { + upstream_inference_cost: 0.9, + upstream_inference_prompt_cost: 0.6, + upstream_inference_completions_cost: 0.3, + }, + }); + expect(value.cost.total).toBe(1); + expect(value.cost.totalFromProvider).toBe(true); + }); + + it("uses upstream cost components when OpenRouter omits their total", () => { + const value = usage(); + applyOpenRouterReportedCost(value, { + cost: 0.1, + is_byok: true, + cost_details: { + upstream_inference_cost: null, + upstream_inference_prompt_cost: 0.6, + upstream_inference_completions_cost: 0.3, + }, + }); + expect(value.cost.total).toBeCloseTo(1); + expect(value.cost.totalFromProvider).toBe(true); + }); + + it("keeps the catalog total when OpenRouter's accounting is incomplete", () => { + const value = usage(); + value.cost.totalFromProvider = false; + applyOpenRouterReportedCost(value, { cost: null, is_byok: false, cost_details: null }); + applyOpenRouterReportedCost(value, { cost: 0.1, is_byok: true, cost_details: null }); + expect(value.cost.total).toBe(1); + expect(value.cost.totalFromProvider).toBe(false); + }); +}); diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index f845fce75..737adbdb5 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -1351,7 +1351,7 @@ let userMessages = 0, assistantMessages = 0, toolResults = 0; let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0; const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; const models = new Set(); for (const entry of entryList) { @@ -1367,10 +1367,18 @@ tokens.cacheRead += msg.usage.cacheRead || 0; tokens.cacheWrite += msg.usage.cacheWrite || 0; if (msg.usage.cost) { + const componentTotal = + (msg.usage.cost.input || 0) + + (msg.usage.cost.output || 0) + + (msg.usage.cost.cacheRead || 0) + + (msg.usage.cost.cacheWrite || 0); cost.input += msg.usage.cost.input || 0; cost.output += msg.usage.cost.output || 0; cost.cacheRead += msg.usage.cost.cacheRead || 0; cost.cacheWrite += msg.usage.cost.cacheWrite || 0; + // Provider-reported totals can intentionally differ from catalog-estimated components. + // Fall back to the component sum for sessions written before cost.total was recorded. + cost.total += typeof msg.usage.cost.total === 'number' ? msg.usage.cost.total : componentTotal; } } toolCalls += msg.content.filter(c => c.type === 'toolCall').length; @@ -1391,7 +1399,7 @@ const globalStats = computeStats(entries); function renderHeader() { - const totalCost = globalStats.cost.input + globalStats.cost.output + globalStats.cost.cacheRead + globalStats.cost.cacheWrite; + const totalCost = globalStats.cost.total; const tokenParts = []; if (globalStats.tokens.input) tokenParts.push(`↑${formatTokens(globalStats.tokens.input)}`); diff --git a/packages/coding-agent/test/export-html-cost.test.ts b/packages/coding-agent/test/export-html-cost.test.ts new file mode 100644 index 000000000..f7291ae06 --- /dev/null +++ b/packages/coding-agent/test/export-html-cost.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +type ExportStats = { + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +}; + +describe("export HTML cost", () => { + it("uses recorded totals while falling back to component sums for old sessions", () => { + // Regression test for #9980: provider-reported totals may differ from catalog-estimated components. + const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); + const functionStart = templateJs.indexOf("function computeStats(entryList)"); + const functionEnd = templateJs.indexOf("const globalStats = computeStats(entries);"); + expect(functionStart).toBeGreaterThanOrEqual(0); + expect(functionEnd).toBeGreaterThan(functionStart); + + const context: { result?: ExportStats } = {}; + runInNewContext( + `${templateJs.slice(functionStart, functionEnd)} + result = computeStats(${JSON.stringify([ + { + type: "message", + message: { + role: "assistant", + content: [], + usage: { cost: { input: 0.1, output: 0.2, cacheRead: 0, cacheWrite: 0, total: 0.9 } }, + }, + }, + { + type: "message", + message: { + role: "assistant", + content: [], + usage: { cost: { input: 0.4, output: 0.1, cacheRead: 0, cacheWrite: 0 } }, + }, + }, + ])});`, + context, + ); + + expect(context.result?.cost.input).toBeCloseTo(0.5); + expect(context.result?.cost.output).toBeCloseTo(0.3); + expect(context.result?.cost.total).toBeCloseTo(1.4); + }); +});