From 7c22a93d0ed02092142fa63823e63d76aa4e2406 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Thu, 22 Oct 2026 09:02:01 +0200 Subject: [PATCH] feat(ai): add totalFromProvider flag to usage cost (#11178) Add Usage.cost.totalFromProvider to record whether cost.total is the billed cost reported by the provider or an estimate from catalog rates. Catalog-derived totals set it to false; a missing value also means false. combineUsage marks a sum as provider-reported only when both parts are. --- packages/ai/README.md | 2 ++ packages/ai/src/api/openrouter-images.ts | 1 + packages/ai/src/models.ts | 1 + packages/ai/src/types.ts | 2 ++ packages/ai/test/models-runtime.test.ts | 13 ++++++++ .../coding-agent/src/core/usage-totals.ts | 2 ++ .../test/compaction-summary-reasoning.test.ts | 2 +- .../coding-agent/test/usage-totals.test.ts | 30 +++++++++++++++++++ 8 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/usage-totals.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 7728ba6a4..88b961cee 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -233,6 +233,8 @@ for (const block of response.content) { } ``` +`usage.cost.totalFromProvider` is `true` when `usage.cost.total` is the billed cost reported by the provider and `false` when it is estimated from the model's catalog rates. A missing value, as in sessions recorded before this field existed, also means `false`. + Snippets in the rest of this README assume a `models` collection set up like this (with the relevant providers registered). ## Providers and Models diff --git a/packages/ai/src/api/openrouter-images.ts b/packages/ai/src/api/openrouter-images.ts index be9c37007..6054c7238 100644 --- a/packages/ai/src/api/openrouter-images.ts +++ b/packages/ai/src/api/openrouter-images.ts @@ -193,6 +193,7 @@ function parseUsage( cacheRead: (model.cost.cacheRead / 1000000) * cacheReadTokens, cacheWrite: (model.cost.cacheWrite / 1000000) * cacheWriteTokens, total: 0, + totalFromProvider: false, }, }; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index ac0396f90..3d26dcb5f 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -1252,6 +1252,7 @@ export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] { usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead; usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000; usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; + usage.cost.totalFromProvider = false; return usage.cost; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index c0f3b5627..3470ef327 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -476,6 +476,8 @@ export interface Usage { cacheRead: number; cacheWrite: number; 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/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index 0789f940c..fab8d11f4 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -159,6 +159,19 @@ describe("Models runtime", () => { expect(long.cacheWrite).toBe(0.0000125); }); + it("marks catalog-derived totals as not provider-reported", () => { + const usage: Usage = { + input: 1_000_000, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1_000_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 9, totalFromProvider: true }, + }; + calculateCost(testModel("openai", "gpt-5.6-sol"), usage); + expect(usage.cost.totalFromProvider).toBe(false); + }); + it("registers, replaces, and deletes providers", () => { const models = createModels(); models.setProvider(testProvider({ id: "p1" })); diff --git a/packages/coding-agent/src/core/usage-totals.ts b/packages/coding-agent/src/core/usage-totals.ts index 6bcafbd47..fdc406bcb 100644 --- a/packages/coding-agent/src/core/usage-totals.ts +++ b/packages/coding-agent/src/core/usage-totals.ts @@ -47,6 +47,8 @@ export function combineUsage(first: Usage, second: Usage): Usage { cacheRead: first.cost.cacheRead + second.cost.cacheRead, cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite, total: first.cost.total + second.cost.total, + // A sum is provider-reported only when every part is. + totalFromProvider: first.cost.totalFromProvider === true && second.cost.totalFromProvider === true, }, }; } diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index cf3aca03a..a18c5ace2 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -314,7 +314,7 @@ describe("generateSummary reasoning options", () => { input: 20, output: 20, totalTokens: 40, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, totalFromProvider: false }, }); }); }); diff --git a/packages/coding-agent/test/usage-totals.test.ts b/packages/coding-agent/test/usage-totals.test.ts new file mode 100644 index 000000000..d759bf9ba --- /dev/null +++ b/packages/coding-agent/test/usage-totals.test.ts @@ -0,0 +1,30 @@ +import type { Usage } from "@earendil-works/pi-ai/compat"; +import { describe, expect, it } from "vitest"; +import { combineUsage } from "../src/core/usage-totals.ts"; + +function usage(total: number, totalFromProvider?: boolean): Usage { + return { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total, + ...(totalFromProvider === undefined ? {} : { totalFromProvider }), + }, + }; +} + +describe("combineUsage", () => { + it("marks the combined total as provider-reported only when both totals are", () => { + expect(combineUsage(usage(1, true), usage(2, true)).cost).toMatchObject({ total: 3, totalFromProvider: true }); + expect(combineUsage(usage(1, true), usage(2)).cost.totalFromProvider).toBe(false); + expect(combineUsage(usage(1), usage(2, true)).cost.totalFromProvider).toBe(false); + expect(combineUsage(usage(1), usage(2)).cost.totalFromProvider).toBe(false); + }); +});