1/**
2 * Model rate lookup and cost arithmetic.
3 *
4 * Rates come from LiteLLM's `model_prices_and_context_window.json`, the same
5 * table `ccusage` prices against. Everything here is pure: fetching and caching
6 * the table lives in `UsageService`.
7 *
8 * @module usagePricing
9 */
10import type {
11 UsageCategoryCost,
12 UsageCostSource,
13 UsageModelPriceOverride,
14 UsageTokenTotals,
15} from "@t3tools/contracts";
16
17import type { UsageRecord, UsageSpeed } from "@t3tools/provider-core/server/usage";
18
19/** Token rates for one billing speed. All values are USD per token. */
20export interface TokenRates {
21 readonly inputCostPerToken: number;
22 readonly outputCostPerToken: number;
23 readonly cacheReadCostPerToken: number;
24 readonly cacheCreationCostPerToken: number;
25}
26
27/**
28 * The subset of a LiteLLM entry we price against: standard rates, plus rates
29 * for each faster speed the model publishes. A request at a speed with no
30 * published rates bills at the standard rates.
31 *
32 * LiteLLM also publishes `*_above_272k_tokens`, `*_flex`, and `*_batches`
33 * variants. Transcripts don't record those, so we don't price them.
34 */
35export interface ModelRate extends TokenRates {
36 /**
37 * From LiteLLM's `provider_specific_entry.fast` multiple (Claude fast mode),
38 * or else its `*_priority` rates (Codex `priority`).
39 */
40 readonly fast: TokenRates | null;
41 /** From LiteLLM's `*_ultrafast` rates (Codex `ultrafast`). */
42 readonly ultrafast: TokenRates | null;
43}
44
45export type RateTable = ReadonlyMap<string, ModelRate>;
46
47/**
48 * Custom IDs keep their case, provider prefix, and variant suffix. Custom rates
49 * apply as entered, at every speed.
50 */
51export function createOverrideRateTable(
52 overrides: Readonly<Record<string, UsageModelPriceOverride>>,
53): RateTable {
54 return new Map(
55 Object.entries(overrides).map(([model, prices]) => [
56 model.trim(),
57 {
58 inputCostPerToken: prices.inputCostPerMillionTokens / 1_000_000,
59 outputCostPerToken: prices.outputCostPerMillionTokens / 1_000_000,
60 cacheReadCostPerToken:
61 (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
62 cacheCreationCostPerToken:
63 (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
64 fast: null,
65 ultrafast: null,
66 },
67 ]),
68 );
69}
70
71/** One raw LiteLLM entry. Field names carry a tier suffix, e.g. `_priority`. */
72type LiteLlmEntry = Readonly<Record<string, unknown>>;
73
74function finiteNumber(value: unknown): number | null {
75 return typeof value === "number" && Number.isFinite(value) ? value : null;
76}
77
78/**
79 * Reads one rate set, `suffix` selecting a tier such as `_priority`. Returns
80 * `null` without both an input and an output rate.
81 *
82 * Anthropic bills cache reads at a discount and cache writes at a premium.
83 * When the standard tier omits them, cached input is priced as plain input
84 * rather than as free. A faster tier that omits them keeps the standard tier's
85 * cache-to-input ratio.
86 */
87function readTokenRates(
88 entry: LiteLlmEntry,
89 suffix: string,
90 standard?: TokenRates,
91): TokenRates | null {
92 const input = finiteNumber(entry[`input_cost_per_token${suffix}`]);
93 const output = finiteNumber(entry[`output_cost_per_token${suffix}`]);
94 if (input === null || output === null) return null;
95 const cacheRate = (name: string, field: "cacheReadCostPerToken" | "cacheCreationCostPerToken") =>
96 finiteNumber(entry[`${name}${suffix}`]) ??
97 (standard !== undefined && standard.inputCostPerToken > 0
98 ? (standard[field] / standard.inputCostPerToken) * input
99 : input);
100 return {
101 inputCostPerToken: input,
102 outputCostPerToken: output,
103 cacheReadCostPerToken: cacheRate("cache_read_input_token_cost", "cacheReadCostPerToken"),
104 cacheCreationCostPerToken: cacheRate(
105 "cache_creation_input_token_cost",
106 "cacheCreationCostPerToken",
107 ),
108 };
109}
110
111function scaleTokenRates(rates: TokenRates, multiple: number): TokenRates {
112 return {
113 inputCostPerToken: rates.inputCostPerToken * multiple,
114 outputCostPerToken: rates.outputCostPerToken * multiple,
115 cacheReadCostPerToken: rates.cacheReadCostPerToken * multiple,
116 cacheCreationCostPerToken: rates.cacheCreationCostPerToken * multiple,
117 };
118}
119
120/** Reads `provider_specific_entry.fast`, e.g. `2` for Claude Opus 5.5. */
121function fastMultiplier(entry: LiteLlmEntry): number | null {
122 const specific = entry["provider_specific_entry"];
123 if (typeof specific !== "object" || specific === null) return null;
124 const fast = finiteNumber((specific as Record<string, unknown>)["fast"]);
125 return fast !== null && fast > 0 ? fast : null;
126}
127
128/**
129 * Projects the LiteLLM document into a rate table.
130 *
131 * Entries without both an input and an output rate are dropped: a half-priced
132 * model would silently under-report cost, which is worse than reporting the
133 * model as unpriced.
134 *
135 * Entries keep their full normalized key; a bare name is aliased only when no
136 * canonical entry exists and every qualified entry has the same rate.
137 */
138export function parseRateTable(document: unknown): RateTable {
139 const table = new Map<string, ModelRate>();
140 if (typeof document !== "object" || document === null) return table;
141
142 for (const [name, raw] of Object.entries(document as Record<string, unknown>)) {
143 if (typeof raw !== "object" || raw === null) continue;
144 const entry = raw as LiteLlmEntry;
145 const standard = readTokenRates(entry, "");
146 if (standard === null) continue;
147
148 const key = normalizeRateKey(name);
149 if (key.length === 0) continue;
150 const multiple = fastMultiplier(entry);
151 table.set(key, {
152 ...standard,
153 fast:
154 multiple === null
155 ? readTokenRates(entry, "_priority", standard)
156 : scaleTokenRates(standard, multiple),
157 ultrafast: readTokenRates(entry, "_ultrafast", standard),
158 });
159 }
160
161 // `null` marks a bare name claimed at conflicting rates: no alias for it.
162 const aliasCandidates = new Map<string, ModelRate | null>();
163 for (const [key, rate] of table) {
164 const alias = bareModelName(key);
165 if (alias.length === 0 || alias === key || table.has(alias)) continue;
166 const held = aliasCandidates.get(alias);
167 if (held === undefined) {
168 aliasCandidates.set(alias, rate);
169 } else if (held !== null && !sameRate(held, rate)) {
170 aliasCandidates.set(alias, null);
171 }
172 }
173 for (const [alias, rate] of aliasCandidates) {
174 if (rate !== null) table.set(alias, rate);
175 }
176
177 return table;
178}
179
180function sameTokenRates(a: TokenRates | null, b: TokenRates | null): boolean {
181 if (a === null || b === null) return a === b;
182 return (
183 a.inputCostPerToken === b.inputCostPerToken &&
184 a.outputCostPerToken === b.outputCostPerToken &&
185 a.cacheReadCostPerToken === b.cacheReadCostPerToken &&
186 a.cacheCreationCostPerToken === b.cacheCreationCostPerToken
187 );
188}
189
190function sameRate(a: ModelRate, b: ModelRate): boolean {
191 return (
192 sameTokenRates(a, b) &&
193 sameTokenRates(a.fast, b.fast) &&
194 sameTokenRates(a.ultrafast, b.ultrafast)
195 );
196}
197
198/** The rates a request at `speed` bills at. */
199function ratesAt(rate: ModelRate, speed: UsageSpeed): TokenRates {
200 return (speed === "standard" ? null : rate[speed]) ?? rate;
201}
202
203function normalizeRateKey(model: string): string {
204 return model.trim().toLowerCase();
205}
206
207function bareModelName(key: string): string {
208 const slash = key.lastIndexOf("/");
209 return slash === -1 ? key : key.slice(slash + 1);
210}
211
212/**
213 * Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which
214 * Claude Code writes for the 1M context tier. The rate table only knows the
215 * base name, and we price at the base tier anyway.
216 */
217function stripVariantSuffix(key: string): string {
218 const bracket = key.indexOf("[");
219 return bracket === -1 ? key : key.slice(0, bracket);
220}
221
222/**
223 * Models we never price, regardless of the table.
224 *
225 * `<synthetic>` marks locally generated messages that were never billed. Bare
226 * family names ("opus", "sonnet") are genuinely ambiguous across generations,
227 * so we report them as unpriced instead of guessing a generation.
228 */
229const UNPRICEABLE_MODELS = new Set([
230 "<synthetic>",
231 "synthetic",
232 "opus",
233 "sonnet",
234 "haiku",
235 "fable",
236]);
237
238/**
239 * Lookups per table, by raw model name. A scan prices every record twice
240 * against a few dozen models, and tables are never mutated once built.
241 */
242const resolvedRates = new WeakMap<RateTable, Map<string, ModelRate | null>>();
243
244export function lookupRate(table: RateTable, model: string): ModelRate | null {
245 let resolved = resolvedRates.get(table);
246 if (resolved === undefined) {
247 resolved = new Map();
248 resolvedRates.set(table, resolved);
249 }
250 let rate = resolved.get(model);
251 if (rate === undefined) {
252 rate = resolveRate(table, model);
253 resolved.set(model, rate);
254 }
255 return rate;
256}
257
258function resolveRate(table: RateTable, model: string): ModelRate | null {
259 const key = stripVariantSuffix(normalizeRateKey(model));
260 const bareName = bareModelName(key);
261 if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null;
262 return table.get(key) ?? null;
263}
264
265/** The parts of a transcript record that decide its price. */
266export type PricedRecord = Pick<
267 UsageRecord,
268 "model" | "rateModel" | "totals" | "speed" | "reportedCostUsd"
269>;
270
271export interface PricedUsage {
272 readonly costUsd: number;
273 readonly costSource: UsageCostSource;
274 /** `costUsd` by token category, or `null` when no rates are known to split it. */
275 readonly categoryCostUsd: UsageCategoryCost | null;
276 /** What `costUsd` exceeds the same tokens at standard rates. `0` without rates. */
277 readonly speedPremiumUsd: number;
278}
279
280function costByCategory(totals: UsageTokenTotals, rates: TokenRates): UsageCategoryCost {
281 return {
282 input: totals.uncachedInputTokens * rates.inputCostPerToken,
283 cacheRead: totals.cachedInputTokens * rates.cacheReadCostPerToken,
284 cacheWrite: totals.cacheCreationTokens * rates.cacheCreationCostPerToken,
285 output: totals.outputTokens * rates.outputCostPerToken,
286 };
287}
288
289function sumCategories(cost: UsageCategoryCost): number {
290 return cost.input + cost.cacheRead + cost.cacheWrite + cost.output;
291}
292
293/**
294 * Prices one record's tokens.
295 *
296 * A provider-reported cost is kept as is, and split by category and speed in
297 * proportion to the model's list rates when those are known.
298 *
299 * `reasoningTokens` is intentionally not charged separately: it is already
300 * counted inside `outputTokens`.
301 */
302export function priceUsage(
303 table: RateTable,
304 record: PricedRecord,
305 overrides?: RateTable,
306): PricedUsage {
307 const { model, totals, reportedCostUsd } = record;
308 const override = overrides?.get(model.trim());
309 const reported =
310 override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)
311 ? reportedCostUsd
312 : null;
313 const unsplit = (costUsd: number, costSource: UsageCostSource): PricedUsage => ({
314 costUsd,
315 costSource,
316 categoryCostUsd: null,
317 speedPremiumUsd: 0,
318 });
319 const rate = override ?? lookupRate(table, record.rateModel ?? model);
320 if (rate === null) {
321 return reported === null ? unsplit(0, "unpriced") : unsplit(reported, "providerReported");
322 }
323
324 const listCost = costByCategory(totals, ratesAt(rate, record.speed));
325 const listCostUsd = sumCategories(listCost);
326 if (reported !== null && listCostUsd <= 0) return unsplit(reported, "providerReported");
327 const premiumUsd =
328 record.speed === "standard" ? 0 : listCostUsd - sumCategories(costByCategory(totals, rate));
329 const scale = reported === null ? 1 : reported / listCostUsd;
330 return {
331 costUsd: reported ?? listCostUsd,
332 costSource: reported === null ? "modelPriced" : "providerReported",
333 categoryCostUsd: {
334 input: listCost.input * scale,
335 cacheRead: listCost.cacheRead * scale,
336 cacheWrite: listCost.cacheWrite * scale,
337 output: listCost.output * scale,
338 },
339 speedPremiumUsd: premiumUsd * scale,
340 };
341}
342
343/**
344 * What the cached input would have cost at full input rates, minus what it
345 * actually cost. Drives the "cache savings" figure.
346 */
347export function cacheSavingsUsd(
348 table: RateTable,
349 record: PricedRecord,
350 overrides?: RateTable,
351): number {
352 const rate =
353 overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model);
354 if (rate === null) return 0;
355 const rates = ratesAt(rate, record.speed);
356 return record.totals.cachedInputTokens * (rates.inputCostPerToken - rates.cacheReadCostPerToken);
357}