1/**
2 * Merges per-environment usage summaries into the single view the page renders.
3 *
4 * Pure, so the de-duplication and derivation rules can be tested without a
5 * connected environment.
6 *
7 * @module usageMerge
8 */
9import {
10 USAGE_MERGE_COMPATIBLE_SINCE,
11 type EnvironmentId,
12 type UsageBucket,
13 type UsageProviderKind,
14 type UsageSource,
15 type UsageSourceFingerprint,
16 type UsageSummary,
17 type UsageTokenTotals,
18} from "@t3tools/contracts";
19
20export interface EnvironmentUsage {
21 readonly environmentId: EnvironmentId;
22 readonly label: string;
23 readonly summary: UsageSummary;
24}
25
26export interface ProviderTotals {
27 readonly provider: UsageProviderKind;
28 readonly costUsd: number;
29 readonly totalTokens: number;
30 readonly records: number;
31 readonly sessions: number;
32 readonly costShare: number;
33 readonly tokenShare: number;
34}
35
36export interface ModelTotals {
37 readonly model: string;
38 readonly provider: UsageProviderKind;
39 readonly costUsd: number;
40 readonly totalTokens: number;
41 readonly tokens: UsageTokenTotals;
42 readonly records: number;
43 /**
44 * Records whose tokens are counted here but which contributed nothing to
45 * `costUsd`. When it equals `records` the cost is unknown, not zero.
46 */
47 readonly unpricedRecords: number;
48 /**
49 * Tokens with no known rates, which a custom price would cover. A cell that
50 * mixes these with reported costs counts its tokens by record share.
51 */
52 readonly unpricedTokens: number;
53 readonly costShare: number;
54 readonly tokenShare: number;
55}
56
57/**
58 * A model whose every record lacked rates has an unknown cost, not a zero one.
59 * Clients must not present its `costUsd` as a real dollar figure.
60 */
61export function isModelCostUnknown(model: ModelTotals): boolean {
62 return model.records > 0 && model.unpricedRecords >= model.records;
63}
64
65export interface DailyTotals {
66 readonly day: string;
67 readonly costUsd: number;
68 readonly totalTokens: number;
69 readonly byProvider: ReadonlyMap<UsageProviderKind, { costUsd: number; totalTokens: number }>;
70}
71
72export interface HourlyTotals {
73 readonly day: string;
74 readonly hourStart: string;
75 readonly costUsd: number;
76 readonly totalTokens: number;
77 readonly byProvider: ReadonlyMap<UsageProviderKind, { costUsd: number; totalTokens: number }>;
78}
79
80export interface CostQuality {
81 readonly providerReportedShare: number;
82 readonly modelPricedShare: number;
83 readonly unpricedShare: number;
84 readonly cacheSavingsUsd: number;
85}
86
87/**
88 * `costUsd` by token category. `unsplit` is cost no rates could split,
89 * including all cost from servers that predate the split.
90 */
91export interface CategoryCost {
92 readonly input: number;
93 readonly cacheRead: number;
94 readonly cacheWrite: number;
95 readonly output: number;
96 readonly unsplit: number;
97}
98
99/** `costUsd` by request speed. Servers that predate speeds count as standard. */
100export interface SpeedCost {
101 readonly standard: number;
102 readonly fast: number;
103 readonly ultrafast: number;
104 /** What fast and ultrafast requests cost above the standard rate. */
105 readonly premium: number;
106}
107
108export interface UsageContractMismatch {
109 readonly environmentId: EnvironmentId;
110 readonly direction: "serverBehind" | "clientBehind";
111 readonly contractVersion: number;
112}
113
114export interface MergedUsage {
115 readonly costUsd: number;
116 readonly uncachedInputTokens: number;
117 readonly cachedInputTokens: number;
118 readonly cacheCreationTokens: number;
119 readonly outputTokens: number;
120 readonly reasoningTokens: number;
121 readonly totalTokens: number;
122 readonly records: number;
123 readonly sessions: number;
124 readonly providers: readonly ProviderTotals[];
125 readonly models: readonly ModelTotals[];
126 readonly daily: readonly DailyTotals[];
127 readonly hourly: readonly HourlyTotals[];
128 readonly costQuality: CostQuality;
129 readonly categoryCost: CategoryCost;
130 readonly speedCost: SpeedCost;
131 /** Environments whose data was dropped as a duplicate of another's. */
132 readonly duplicateSources: readonly string[];
133 readonly contributingEnvironments: readonly EnvironmentId[];
134 readonly contractMismatches: readonly UsageContractMismatch[];
135}
136
137/**
138 * Two sources are the same physical transcript directory only when host,
139 * provider, path and filesystem identity all agree.
140 *
141 * `volumeId` is what stops two machines that happen to share a hostname and a
142 * home path, which is every Mac in a fleet, from collapsing into one source and
143 * having one of them silently dropped.
144 */
145function fingerprintKey(fingerprint: UsageSourceFingerprint): string {
146 return [
147 fingerprint.hostId,
148 fingerprint.provider,
149 fingerprint.resolvedHomePath,
150 fingerprint.volumeId,
151 ].join(" ");
152}
153
154function bucketsForSource(summary: UsageSummary, source: UsageSource): readonly UsageBucket[] {
155 const providerSources = summary.sources.filter(
156 (entry) => entry.fingerprint.provider === source.fingerprint.provider,
157 );
158 return summary.buckets.filter(
159 (bucket) =>
160 bucket.provider === source.fingerprint.provider &&
161 (bucket.sourcePath === source.fingerprint.resolvedHomePath ||
162 (bucket.sourcePath === undefined && providerSources.length === 1)),
163 );
164}
165
166function bucketKey(bucket: UsageBucket): string {
167 return JSON.stringify([bucket.day, bucket.hourStart ?? null, bucket.provider, bucket.model]);
168}
169
170/**
171 * Decides which environment owns each physical transcript directory.
172 *
173 * Several environments on one machine (worktree servers, for instance) resolve
174 * the same provider home and would otherwise double count every token. The
175 * Complete scans claim a fingerprint ahead of partial scans, then the most
176 * recently read scan wins within each status. A newer partial scan can still
177 * contribute cells absent from an older complete scan. Environment ids break
178 * ties so the result is stable when summaries have the same read time.
179 */
180function claimSources(environments: readonly EnvironmentUsage[]): {
181 readonly ownerByFingerprint: ReadonlyMap<string, EnvironmentId>;
182 readonly supplementalBucketsByEnvironment: ReadonlyMap<EnvironmentId, ReadonlySet<UsageBucket>>;
183 readonly sessionsByFingerprint: ReadonlyMap<string, number>;
184 readonly duplicates: readonly string[];
185} {
186 const ownerByFingerprint = new Map<string, EnvironmentId>();
187 const ownerScanByFingerprint = new Map<
188 string,
189 { environment: EnvironmentUsage; source: UsageSource }
190 >();
191 const seenBucketKeysByFingerprint = new Map<string, Set<string>>();
192 const supplementalBucketsByEnvironment = new Map<EnvironmentId, Set<UsageBucket>>();
193 const sessionsByFingerprint = new Map<string, number>();
194 const duplicates: string[] = [];
195
196 const ordered = [...environments].sort(
197 (a, b) =>
198 (Date.parse(b.summary.readAt) || 0) - (Date.parse(a.summary.readAt) || 0) ||
199 a.environmentId.localeCompare(b.environmentId),
200 );
201
202 // A complete scan takes precedence over a newer partial scan of the same
203 // directory. Partial history still contributes when no complete copy exists.
204 for (const status of ["ok", "partial", "failed"] as const) {
205 for (const environment of ordered) {
206 for (const source of environment.summary.sources) {
207 if (source.status !== status) continue;
208 const key = fingerprintKey(source.fingerprint);
209 if (ownerByFingerprint.has(key)) {
210 duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);
211 continue;
212 }
213 ownerByFingerprint.set(key, environment.environmentId);
214 ownerScanByFingerprint.set(key, { environment, source });
215 sessionsByFingerprint.set(key, source.distinctSessions);
216 }
217 }
218 }
219
220 // A newer partial scan may contain usage recorded after an older complete
221 // scan. Keep cells absent from the complete scan. Aggregated cells do not
222 // reveal enough to reconcile overlapping records without double counting.
223 for (const environment of ordered) {
224 for (const source of environment.summary.sources) {
225 if (source.status !== "partial") continue;
226 const key = fingerprintKey(source.fingerprint);
227 const owner = ownerScanByFingerprint.get(key);
228 if (
229 owner?.source.status !== "ok" ||
230 Date.parse(environment.summary.readAt) <= Date.parse(owner.environment.summary.readAt)
231 ) {
232 continue;
233 }
234 let seen = seenBucketKeysByFingerprint.get(key);
235 if (seen === undefined) {
236 seen = new Set(bucketsForSource(owner.environment.summary, owner.source).map(bucketKey));
237 seenBucketKeysByFingerprint.set(key, seen);
238 }
239 const supplemental =
240 supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set<UsageBucket>();
241 let added = false;
242 for (const bucket of bucketsForSource(environment.summary, source)) {
243 const cell = bucketKey(bucket);
244 if (seen.has(cell)) continue;
245 seen.add(cell);
246 supplemental.add(bucket);
247 added = true;
248 }
249 if (!added) continue;
250 supplementalBucketsByEnvironment.set(environment.environmentId, supplemental);
251 sessionsByFingerprint.set(
252 key,
253 Math.max(sessionsByFingerprint.get(key) ?? 0, source.distinctSessions),
254 );
255 }
256 }
257
258 return {
259 ownerByFingerprint,
260 supplementalBucketsByEnvironment,
261 sessionsByFingerprint,
262 duplicates,
263 };
264}
265
266/** Sources this environment owns after fingerprint claims, plus their buckets. */
267function ownedContribution(
268 environment: EnvironmentUsage,
269 ownerByFingerprint: ReadonlyMap<string, EnvironmentId>,
270 supplementalBuckets: ReadonlySet<UsageBucket>,
271 sessionsByFingerprint: ReadonlyMap<string, number>,
272): {
273 readonly buckets: readonly UsageBucket[];
274 readonly sessionsByProvider: ReadonlyMap<UsageProviderKind, number>;
275} {
276 const ownedProviders = new Set<UsageProviderKind>();
277 const ownedSources = new Set<string>();
278 const sessionsByProvider = new Map<UsageProviderKind, number>();
279 for (const source of environment.summary.sources) {
280 if (source.status === "missing") continue;
281 const key = fingerprintKey(source.fingerprint);
282 if (ownerByFingerprint.get(key) === environment.environmentId) {
283 const provider = source.fingerprint.provider;
284 ownedProviders.add(provider);
285 ownedSources.add(`${provider}\u0000${source.fingerprint.resolvedHomePath}`);
286 // Distinct within a directory. Summing per-bucket session counts instead
287 // would count a session once per day and model it spans.
288 sessionsByProvider.set(
289 provider,
290 (sessionsByProvider.get(provider) ?? 0) +
291 (sessionsByFingerprint.get(key) ?? source.distinctSessions),
292 );
293 }
294 }
295 return {
296 buckets: environment.summary.buckets.filter(
297 (bucket) =>
298 supplementalBuckets.has(bucket) ||
299 (bucket.sourcePath === undefined
300 ? ownedProviders.has(bucket.provider)
301 : ownedSources.has(`${bucket.provider}\u0000${bucket.sourcePath}`)),
302 ),
303 sessionsByProvider,
304 };
305}
306
307function bucketTokens(bucket: UsageBucket): number {
308 // reasoningTokens is a subset of outputTokens and must not be added again.
309 return (
310 bucket.totals.uncachedInputTokens +
311 bucket.totals.cachedInputTokens +
312 bucket.totals.cacheCreationTokens +
313 bucket.totals.outputTokens
314 );
315}
316
317export function isCompatibleUsageContractVersion(version: number, expected: number): boolean {
318 return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected;
319}
320
321const EMPTY_MERGED: MergedUsage = {
322 costUsd: 0,
323 uncachedInputTokens: 0,
324 cachedInputTokens: 0,
325 cacheCreationTokens: 0,
326 outputTokens: 0,
327 reasoningTokens: 0,
328 totalTokens: 0,
329 records: 0,
330 sessions: 0,
331 providers: [],
332 models: [],
333 daily: [],
334 hourly: [],
335 costQuality: {
336 providerReportedShare: 0,
337 modelPricedShare: 0,
338 unpricedShare: 0,
339 cacheSavingsUsd: 0,
340 },
341 categoryCost: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, unsplit: 0 },
342 speedCost: { standard: 0, fast: 0, ultrafast: 0, premium: 0 },
343 duplicateSources: [],
344 contributingEnvironments: [],
345 contractMismatches: [],
346};
347
348/**
349 * Merges every connected environment's summary.
350 *
351 * `expectedContractVersion` guards against incompatible server code: rather
352 * than blocking the page, its data is excluded and the mismatch direction is
353 * reported so the UI can identify which side needs updating. Versions in
354 * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive
355 * provider expansion does not drop Claude/Codex totals from older servers.
356 */
357export function mergeUsage(
358 environments: readonly EnvironmentUsage[],
359 expectedContractVersion: number,
360): MergedUsage {
361 if (environments.length === 0) return EMPTY_MERGED;
362
363 const current: EnvironmentUsage[] = [];
364 const contractMismatches: UsageContractMismatch[] = [];
365 for (const environment of environments) {
366 if (
367 isCompatibleUsageContractVersion(environment.summary.contractVersion, expectedContractVersion)
368 ) {
369 current.push(environment);
370 } else {
371 contractMismatches.push({
372 environmentId: environment.environmentId,
373 direction:
374 environment.summary.contractVersion < expectedContractVersion
375 ? "serverBehind"
376 : "clientBehind",
377 contractVersion: environment.summary.contractVersion,
378 });
379 }
380 }
381
382 const {
383 ownerByFingerprint,
384 supplementalBucketsByEnvironment,
385 sessionsByFingerprint,
386 duplicates,
387 } = claimSources(current);
388
389 let costUsd = 0;
390 let uncachedInputTokens = 0;
391 let cachedInputTokens = 0;
392 let cacheCreationTokens = 0;
393 let outputTokens = 0;
394 let reasoningTokens = 0;
395 let records = 0;
396 let sessions = 0;
397 let cacheSavingsUsd = 0;
398 let providerReportedRecords = 0;
399 let unpricedRecords = 0;
400 const categoryCost = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
401 const speedCost = { fast: 0, ultrafast: 0, premium: 0 };
402
403 const providerAccumulator = new Map<
404 UsageProviderKind,
405 { costUsd: number; totalTokens: number; records: number; sessions: number }
406 >();
407 const modelAccumulator = new Map<
408 string,
409 {
410 provider: UsageProviderKind;
411 costUsd: number;
412 totalTokens: number;
413 tokens: UsageTokenTotals;
414 records: number;
415 unpricedRecords: number;
416 unpricedTokens: number;
417 }
418 >();
419 const dailyAccumulator = new Map<
420 string,
421 {
422 costUsd: number;
423 totalTokens: number;
424 byProvider: Map<UsageProviderKind, { costUsd: number; totalTokens: number }>;
425 }
426 >();
427 const hourlyAccumulator = new Map<
428 string,
429 {
430 day: string;
431 hourStart: string;
432 costUsd: number;
433 totalTokens: number;
434 byProvider: Map<UsageProviderKind, { costUsd: number; totalTokens: number }>;
435 }
436 >();
437 const contributingEnvironments: EnvironmentId[] = [];
438
439 for (const environment of current) {
440 const { buckets, sessionsByProvider } = ownedContribution(
441 environment,
442 ownerByFingerprint,
443 supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(),
444 sessionsByFingerprint,
445 );
446 if (buckets.length > 0) contributingEnvironments.push(environment.environmentId);
447
448 for (const [providerKind, providerSessions] of sessionsByProvider) {
449 sessions += providerSessions;
450 if (providerSessions === 0) continue;
451 const provider = providerAccumulator.get(providerKind) ?? {
452 costUsd: 0,
453 totalTokens: 0,
454 records: 0,
455 sessions: 0,
456 };
457 provider.sessions += providerSessions;
458 providerAccumulator.set(providerKind, provider);
459 }
460
461 for (const bucket of buckets) {
462 const tokens = bucketTokens(bucket);
463
464 costUsd += bucket.costUsd;
465 cacheSavingsUsd += bucket.cacheSavingsUsd;
466 uncachedInputTokens += bucket.totals.uncachedInputTokens;
467 cachedInputTokens += bucket.totals.cachedInputTokens;
468 cacheCreationTokens += bucket.totals.cacheCreationTokens;
469 outputTokens += bucket.totals.outputTokens;
470 reasoningTokens += bucket.totals.reasoningTokens;
471 records += bucket.records;
472 unpricedRecords += bucket.unpricedRecords;
473 if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records;
474 if (bucket.categoryCostUsd !== undefined) {
475 categoryCost.input += bucket.categoryCostUsd.input;
476 categoryCost.cacheRead += bucket.categoryCostUsd.cacheRead;
477 categoryCost.cacheWrite += bucket.categoryCostUsd.cacheWrite;
478 categoryCost.output += bucket.categoryCostUsd.output;
479 }
480 speedCost.fast += bucket.fastCostUsd ?? 0;
481 speedCost.ultrafast += bucket.ultrafastCostUsd ?? 0;
482 speedCost.premium += bucket.speedPremiumUsd ?? 0;
483
484 const provider = providerAccumulator.get(bucket.provider) ?? {
485 costUsd: 0,
486 totalTokens: 0,
487 records: 0,
488 sessions: 0,
489 };
490 provider.costUsd += bucket.costUsd;
491 provider.totalTokens += tokens;
492 provider.records += bucket.records;
493 providerAccumulator.set(bucket.provider, provider);
494
495 const modelKey = `${bucket.provider} ${bucket.model}`;
496 const model = modelAccumulator.get(modelKey) ?? {
497 provider: bucket.provider,
498 costUsd: 0,
499 totalTokens: 0,
500 tokens: {
501 uncachedInputTokens: 0,
502 cachedInputTokens: 0,
503 cacheCreationTokens: 0,
504 outputTokens: 0,
505 reasoningTokens: 0,
506 },
507 records: 0,
508 unpricedRecords: 0,
509 unpricedTokens: 0,
510 };
511 model.costUsd += bucket.costUsd;
512 model.totalTokens += tokens;
513 model.tokens = {
514 uncachedInputTokens: model.tokens.uncachedInputTokens + bucket.totals.uncachedInputTokens,
515 cachedInputTokens: model.tokens.cachedInputTokens + bucket.totals.cachedInputTokens,
516 cacheCreationTokens: model.tokens.cacheCreationTokens + bucket.totals.cacheCreationTokens,
517 outputTokens: model.tokens.outputTokens + bucket.totals.outputTokens,
518 reasoningTokens: model.tokens.reasoningTokens + bucket.totals.reasoningTokens,
519 };
520 model.records += bucket.records;
521 model.unpricedRecords += bucket.unpricedRecords;
522 if (bucket.records > 0) {
523 model.unpricedTokens += (tokens * bucket.unpricedRecords) / bucket.records;
524 }
525 modelAccumulator.set(modelKey, model);
526
527 const day = dailyAccumulator.get(bucket.day) ?? {
528 costUsd: 0,
529 totalTokens: 0,
530 byProvider: new Map<UsageProviderKind, { costUsd: number; totalTokens: number }>(),
531 };
532 day.costUsd += bucket.costUsd;
533 day.totalTokens += tokens;
534 const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 };
535 dayProvider.costUsd += bucket.costUsd;
536 dayProvider.totalTokens += tokens;
537 day.byProvider.set(bucket.provider, dayProvider);
538 dailyAccumulator.set(bucket.day, day);
539
540 if (bucket.hourStart !== undefined) {
541 const hour = hourlyAccumulator.get(bucket.hourStart) ?? {
542 day: bucket.day,
543 hourStart: bucket.hourStart,
544 costUsd: 0,
545 totalTokens: 0,
546 byProvider: new Map<UsageProviderKind, { costUsd: number; totalTokens: number }>(),
547 };
548 hour.costUsd += bucket.costUsd;
549 hour.totalTokens += tokens;
550 const hourProvider = hour.byProvider.get(bucket.provider) ?? {
551 costUsd: 0,
552 totalTokens: 0,
553 };
554 hourProvider.costUsd += bucket.costUsd;
555 hourProvider.totalTokens += tokens;
556 hour.byProvider.set(bucket.provider, hourProvider);
557 hourlyAccumulator.set(bucket.hourStart, hour);
558 }
559 }
560 }
561
562 const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens;
563
564 const providers: ProviderTotals[] = [...providerAccumulator.entries()]
565 .map(([provider, totals]) => ({
566 provider,
567 costUsd: totals.costUsd,
568 totalTokens: totals.totalTokens,
569 records: totals.records,
570 sessions: totals.sessions,
571 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
572 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
573 }))
574 .sort((a, b) => b.costUsd - a.costUsd);
575
576 const models: ModelTotals[] = [...modelAccumulator.entries()]
577 .map(([key, totals]) => ({
578 model: key.slice(key.indexOf(" ") + 1),
579 provider: totals.provider,
580 costUsd: totals.costUsd,
581 totalTokens: totals.totalTokens,
582 tokens: totals.tokens,
583 records: totals.records,
584 unpricedRecords: totals.unpricedRecords,
585 unpricedTokens: totals.unpricedTokens,
586 costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
587 tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
588 }))
589 .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
590
591 const daily: DailyTotals[] = [...dailyAccumulator.entries()]
592 .map(([day, totals]) => ({
593 day,
594 costUsd: totals.costUsd,
595 totalTokens: totals.totalTokens,
596 byProvider: totals.byProvider,
597 }))
598 .sort((a, b) => a.day.localeCompare(b.day));
599
600 const hourly: HourlyTotals[] = [...hourlyAccumulator.values()].sort((a, b) =>
601 a.hourStart.localeCompare(b.hourStart),
602 );
603
604 return {
605 costUsd,
606 uncachedInputTokens,
607 cachedInputTokens,
608 cacheCreationTokens,
609 outputTokens,
610 reasoningTokens,
611 totalTokens,
612 records,
613 sessions,
614 providers,
615 models,
616 daily,
617 hourly,
618 costQuality: {
619 providerReportedShare: records === 0 ? 0 : providerReportedRecords / records,
620 unpricedShare: records === 0 ? 0 : unpricedRecords / records,
621 modelPricedShare:
622 records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records,
623 cacheSavingsUsd,
624 },
625 // Clamped so float error never shows as a negative remainder.
626 categoryCost: {
627 ...categoryCost,
628 unsplit: Math.max(
629 0,
630 costUsd -
631 categoryCost.input -
632 categoryCost.cacheRead -
633 categoryCost.cacheWrite -
634 categoryCost.output,
635 ),
636 },
637 speedCost: {
638 ...speedCost,
639 standard: Math.max(0, costUsd - speedCost.fast - speedCost.ultrafast),
640 },
641 duplicateSources: duplicates,
642 contributingEnvironments,
643 contractMismatches,
644 };
645}