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.
This commit is contained in:
Vegard Stikbakke 2026-10-22 09:02:01 +02:00 • committed by GitHub
parent 58c0b02ce5
commit 7c22a93d0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 52 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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