Skip to content
Source
Sign inSign up
Open History to see the last commit

usageAggregation.ts

423 lines14.6 KB

1// @effect-diagnostics globalDate:off
2import type {
3 UsageBucket,
4 UsageCategoryCost,
5 UsageDay,
6 UsageModelFilter,
7 UsageModelSelector,
8 UsageResolution,
9 UsageTokenTotals,
10} from "@t3tools/contracts";
11​
12import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage";
13import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts";
14​
15// en-CA gives ISO-ordered parts in the requested zone; Date getters are host-local.
16function makeDayFormatter(timeZone: string): (timestampMs: number) => string {
17 let format: Intl.DateTimeFormat;
18 try {
19 format = new Intl.DateTimeFormat("en-CA", {
20 timeZone,
21 year: "numeric",
22 month: "2-digit",
23 day: "2-digit",
24 });
25 } catch {
26 format = new Intl.DateTimeFormat("en-CA", {
27 timeZone: "UTC",
28 year: "numeric",
29 month: "2-digit",
30 day: "2-digit",
31 });
32 }
33 // Cache quarter-hour slots to avoid per-record Intl calls; fixed-offset midnight can split slots and needs exact formatting.
34 const days = new Map<number, string | null>();
35 return (timestampMs) => {
36 const slot = Math.floor(timestampMs / QUARTER_HOUR_MS);
37 let day = days.get(slot);
38 if (day === undefined) {
39 const first = format.format(new Date(slot * QUARTER_HOUR_MS));
40 const last = format.format(new Date((slot + 1) * QUARTER_HOUR_MS - 1));
41 day = first === last ? first : null;
42 days.set(slot, day);
43 }
44 return day ?? format.format(new Date(timestampMs));
45 };
47​
48const QUARTER_HOUR_MS = 15 * 60 * 1000;
49const HOUR_MS = 60 * 60 * 1000;
50​
51interface MutableBucket {
52 totals: { -readonly [K in keyof UsageTokenTotals]: number };
53 costUsd: number;
54 cacheSavingsUsd: number;
55 categoryCostUsd: UsageCategoryCost | null;
56 fastCostUsd: number;
57 ultrafastCostUsd: number;
58 speedPremiumUsd: number;
59 records: number;
60 unpricedRecords: number;
61 unpricedTokens: number;
62 providerReportedRecords: number;
63 sessions: Set<string>;
65​
66export interface AggregateOptions {
67 readonly timeZone: string;
68 readonly sinceDay: string;
69 readonly untilDay: string;
70 readonly rates: RateTable;
71 readonly priceOverrides?: RateTable;
72 readonly modelAliases?: ReadonlyMap<string, string>;
73 readonly modelFilter?: UsageModelFilter;
74 readonly resolution?: UsageResolution;
75 readonly sinceTimeMs?: number;
76 readonly untilTimeMs?: number;
78​
79export function resolveModelAliases(
80 aliases: Readonly<Record<string, string>>,
81): ReadonlyMap<string, string> {
82 const resolved = new Map<string, string>();
83 for (const model of Object.keys(aliases)) {
84 const seen = new Set([model]);
85 let target = aliases[model]!;
86 while (Object.hasOwn(aliases, target) && !seen.has(target)) {
87 seen.add(target);
88 target = aliases[target]!;
89 }
90 if (!Object.hasOwn(aliases, target)) resolved.set(model, target);
91 }
92 return resolved;
94​
95/** Matches original IDs, so an alias cannot pull excluded usage into a kept model. */
96function keepsUsageRecord(filter: UsageModelFilter | undefined, record: UsageRecord): boolean {
97 if (filter === undefined) return true;
98 const model = record.model.trim();
99 const matches = (selector: UsageModelSelector) =>
100 selector.model === model &&
101 (selector.provider === undefined || selector.provider === record.provider);
102 return (filter.include?.some(matches) ?? true) && !(filter.exclude?.some(matches) ?? false);
104​
105const selectorKey = (selector: UsageModelSelector) =>
106 JSON.stringify([selector.provider ?? null, selector.model]);
107​
108const canonicalSelectors = (selectors: readonly UsageModelSelector[]) =>
109 [...new Map(selectors.map((selector) => [selectorKey(selector), selector]))]
110 // Code-unit order, so servers in different locales echo the same form.
111 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
112 .map(([, selector]) => selector);
113​
114/** Equal filters get one form, so they share scans and clients can compare echoed filters. */
115export function canonicalModelFilter(
116 filter: UsageModelFilter | undefined,
117): UsageModelFilter | undefined {
118 if (filter === undefined) return undefined;
119 const exclude = filter.exclude?.length ? canonicalSelectors(filter.exclude) : undefined;
120 if (filter.include === undefined && exclude === undefined) return undefined;
121 return {
122 ...(filter.include === undefined ? {} : { include: canonicalSelectors(filter.include) }),
123 ...(exclude === undefined ? {} : { exclude }),
124 };
126​
127export interface AggregateResult {
128 readonly buckets: readonly UsageBucket[];
129 readonly duplicatesDropped: number;
130 readonly outOfWindow: number;
132​
133/**
134 * Partial copies share a billed message's key: keep each token channel's maximum and the larger
135 * reported cost, and the first copy's time, model, session and speed so it stays in its bucket.
136 */
137export function mergeUsageCopies(held: UsageRecord, copy: UsageRecord): UsageRecord {
138 const a = held.totals;
139 const b = copy.totals;
140 const reportedCostUsd =
141 held.reportedCostUsd === null || copy.reportedCostUsd === null
142 ? (held.reportedCostUsd ?? copy.reportedCostUsd)
143 : Math.max(held.reportedCostUsd, copy.reportedCostUsd);
144 if (
145 b.uncachedInputTokens <= a.uncachedInputTokens &&
146 b.cachedInputTokens <= a.cachedInputTokens &&
147 b.cacheCreationTokens <= a.cacheCreationTokens &&
148 b.outputTokens <= a.outputTokens &&
149 b.reasoningTokens <= a.reasoningTokens &&
150 (copy.cacheCreation1hTokens ?? 0) <= (held.cacheCreation1hTokens ?? 0) &&
151 reportedCostUsd === held.reportedCostUsd
152 ) {
153 return held;
154 }
155 const cacheCreation1hTokens = Math.max(
156 held.cacheCreation1hTokens ?? 0,
157 copy.cacheCreation1hTokens ?? 0,
158 );
159 return {
160 ...held,
161 ...(cacheCreation1hTokens > 0 ? { cacheCreation1hTokens } : {}),
162 totals: {
163 uncachedInputTokens: Math.max(a.uncachedInputTokens, b.uncachedInputTokens),
164 cachedInputTokens: Math.max(a.cachedInputTokens, b.cachedInputTokens),
165 cacheCreationTokens: Math.max(a.cacheCreationTokens, b.cacheCreationTokens),
166 outputTokens: Math.max(a.outputTokens, b.outputTokens),
167 reasoningTokens: Math.max(a.reasoningTokens, b.reasoningTokens),
168 },
169 reportedCostUsd,
170 };
172​
173interface Contribution {
174 record: UsageRecord;
175 /** Null when the first copy fell outside the window. */
176 readonly bucket: MutableBucket | null;
178​
179/** Deduplicates across the whole scan; Claude copies records into resumed/forked transcripts. */
180export class UsageAggregator {
181 readonly #buckets = new Map<string, MutableBucket>();
182 readonly #seen = new Map<string, Contribution>();
183 readonly #toDay: (timestampMs: number) => string;
184 readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null;
185 readonly #options: AggregateOptions;
186 #lastBucket: {
187 readonly day: string;
188 readonly hourIndex: number;
189 readonly provider: string;
190 readonly model: string;
191 readonly source: string;
192 readonly bucket: MutableBucket;
193 } | null = null;
194 #duplicatesDropped = 0;
195 #outOfWindow = 0;
196​
197 constructor(options: AggregateOptions) {
198 this.#options = options;
199 this.#toDay = makeDayFormatter(options.timeZone);
200 if (options.resolution === "hour") {
201 if (options.sinceTimeMs === undefined || options.untilTimeMs === undefined) {
202 throw new Error("Hourly usage aggregation requires exact time bounds");
203 }
204 this.#hourlyWindow = {
205 sinceTimeMs: options.sinceTimeMs,
206 untilTimeMs: options.untilTimeMs,
207 };
208 } else {
209 this.#hourlyWindow = null;
210 }
211 }
212​
213 add(input: UsageRecord, sourcePath?: string): boolean {
214 if (!keepsUsageRecord(this.#options.modelFilter, input)) return false;
215 const record = this.#mapModel(input);
216 if (record.dedupeKey !== null) {
217 const held = this.#seen.get(record.dedupeKey);
218 if (held !== undefined) {
219 this.#duplicatesDropped += 1;
220 if (held.bucket !== null) held.record = mergeUsageCopies(held.record, record);
221 return false;
222 }
223 }
224​
225 const bucket = this.#placeInWindow(record, sourcePath ?? "");
226 if (record.dedupeKey !== null) this.#seen.set(record.dedupeKey, { record, bucket });
227 if (bucket === null) {
228 this.#outOfWindow += 1;
229 return false;
230 }
231 // Keyed usage is counted in finish(), once all its copies are merged.
232 if (record.dedupeKey === null) this.#accumulate(bucket, record);
233 if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId);
234 return true;
235 }
236​
237 #placeInWindow(record: UsageRecord, source: string): MutableBucket | null {
238 if (
239 this.#hourlyWindow !== null &&
240 (record.timestampMs < this.#hourlyWindow.sinceTimeMs ||
241 record.timestampMs >= this.#hourlyWindow.untilTimeMs)
242 ) {
243 return null;
244 }
245​
246 const day = this.#toDay(record.timestampMs);
247 if (
248 this.#hourlyWindow === null &&
249 (day < this.#options.sinceDay || day > this.#options.untilDay)
250 ) {
251 return null;
252 }
253​
254 const hourIndex =
255 this.#hourlyWindow === null
256 ? -1
257 : Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS);
258 return this.#bucketFor(day, hourIndex, record.provider, record.model, source);
259 }
260​
261 #accumulate(bucket: MutableBucket, record: UsageRecord): void {
262 const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides);
263​
264 const totals = bucket.totals;
265 totals.uncachedInputTokens += record.totals.uncachedInputTokens;
266 totals.cachedInputTokens += record.totals.cachedInputTokens;
267 totals.cacheCreationTokens += record.totals.cacheCreationTokens;
268 totals.outputTokens += record.totals.outputTokens;
269 totals.reasoningTokens += record.totals.reasoningTokens;
270 bucket.costUsd += priced.costUsd;
271 if (priced.categoryCostUsd !== null) {
272 const sum = bucket.categoryCostUsd;
273 const add = priced.categoryCostUsd;
274 bucket.categoryCostUsd =
275 sum === null
276 ? add
277 : {
278 input: sum.input + add.input,
279 cacheRead: sum.cacheRead + add.cacheRead,
280 cacheWrite: sum.cacheWrite + add.cacheWrite,
281 output: sum.output + add.output,
282 };
283 }
284 if (record.speed === "fast") bucket.fastCostUsd += priced.costUsd;
285 if (record.speed === "ultrafast") bucket.ultrafastCostUsd += priced.costUsd;
286 bucket.speedPremiumUsd += priced.speedPremiumUsd;
287 bucket.cacheSavingsUsd += cacheSavingsUsd(
288 this.#options.rates,
289 record,
290 this.#options.priceOverrides,
291 );
292 bucket.records += 1;
293 if (priced.costSource === "unpriced") {
294 bucket.unpricedRecords += 1;
295 bucket.unpricedTokens +=
296 record.totals.uncachedInputTokens +
297 record.totals.cachedInputTokens +
298 record.totals.cacheCreationTokens +
299 record.totals.outputTokens;
300 }
301 if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1;
302 }
303​
304 // Aliases use the target's rate, replacing provider-specific rateModel.
305 #mapModel(record: UsageRecord): UsageRecord {
306 const model = this.#options.modelAliases?.get(record.model);
307 if (model === undefined) return record;
308 const { rateModel: _rateModel, ...rest } = record;
309 return { ...rest, model };
310 }
311​
312 // Time-ordered records usually reuse the previous bucket; avoid rebuilding its key.
313 #bucketFor(
314 day: string,
315 hourIndex: number,
316 provider: string,
317 model: string,
318 source: string,
319 ): MutableBucket {
320 const last = this.#lastBucket;
321 if (
322 last !== null &&
323 last.day === day &&
324 last.hourIndex === hourIndex &&
325 last.provider === provider &&
326 last.model === model &&
327 last.source === source
328 ) {
329 return last.bucket;
330 }
331 const window = this.#hourlyWindow;
332 const hourStart =
333 window === null ? "" : new Date(window.sinceTimeMs + hourIndex * HOUR_MS).toISOString();
334 const key = `${day}\u0000${hourStart}\u0000${provider}\u0000${model}\u0000${source}`;
335 let bucket = this.#buckets.get(key);
336 if (bucket === undefined) {
337 bucket = {
338 totals: { ...EMPTY_TOTALS },
339 costUsd: 0,
340 cacheSavingsUsd: 0,
341 categoryCostUsd: null,
342 fastCostUsd: 0,
343 ultrafastCostUsd: 0,
344 speedPremiumUsd: 0,
345 records: 0,
346 unpricedRecords: 0,
347 unpricedTokens: 0,
348 providerReportedRecords: 0,
349 sessions: new Set<string>(),
350 };
351 this.#buckets.set(key, bucket);
352 }
353 this.#lastBucket = { day, hourIndex, provider, model, source, bucket };
354 return bucket;
355 }
356​
357 finish(): AggregateResult {
358 for (const { record, bucket } of this.#seen.values()) {
359 if (bucket !== null) this.#accumulate(bucket, record);
360 }
361 this.#seen.clear();
362 const buckets: UsageBucket[] = [];
363 for (const [key, bucket] of this.#buckets) {
364 const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] =
365 key.split("\u0000");
366 const category = bucket.categoryCostUsd;
367 const fastCostUsd = roundUsd(bucket.fastCostUsd);
368 const ultrafastCostUsd = roundUsd(bucket.ultrafastCostUsd);
369 const speedPremiumUsd = roundUsd(bucket.speedPremiumUsd);
370 buckets.push({
371 day: day as UsageDay,
372 ...(hourStart === "" ? {} : { hourStart }),
373 provider: provider as UsageBucket["provider"],
374 model,
375 ...(sourcePath === "" ? {} : { sourcePath }),
376 totals: { ...bucket.totals },
377 costUsd: bucket.costUsd,
378 cacheSavingsUsd: bucket.cacheSavingsUsd,
379 ...(category === null
380 ? {}
381 : {
382 categoryCostUsd: {
383 input: roundUsd(category.input),
384 cacheRead: roundUsd(category.cacheRead),
385 cacheWrite: roundUsd(category.cacheWrite),
386 output: roundUsd(category.output),
387 },
388 }),
389 ...(fastCostUsd === 0 ? {} : { fastCostUsd }),
390 ...(ultrafastCostUsd === 0 ? {} : { ultrafastCostUsd }),
391 ...(speedPremiumUsd === 0 ? {} : { speedPremiumUsd }),
392 costSource: resolveCostSource(bucket),
393 records: bucket.records,
394 unpricedRecords: bucket.unpricedRecords,
395 unpricedTokens: bucket.unpricedTokens,
396 sessions: bucket.sessions.size,
397 });
398 }
399 buckets.sort(
400 (a, b) =>
401 a.day.localeCompare(b.day) ||
402 (a.hourStart ?? "").localeCompare(b.hourStart ?? "") ||
403 a.provider.localeCompare(b.provider) ||
404 a.model.localeCompare(b.model),
405 );
406​
407 return {
408 buckets,
409 duplicatesDropped: this.#duplicatesDropped,
410 outOfWindow: this.#outOfWindow,
411 };
412 }
414​
415function roundUsd(value: number): number {
416 return Math.round(value * 1e6) / 1e6;
418​
419function resolveCostSource(bucket: MutableBucket): UsageBucket["costSource"] {
420 if (bucket.unpricedRecords === bucket.records) return "unpriced";
421 if (bucket.providerReportedRecords === bucket.records) return "providerReported";
422 return "modelPriced";