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

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