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
This commit is contained in:
Vegard Stikbakke 2026-10-22 11:01:39 +02:00 • committed by GitHub
parent 5884ee6816
commit cae8d69e57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 324 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>;
};
// 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");

View file

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

View file

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

View file

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

View file

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