/** * Merges per-environment usage summaries into the single view the page renders. * * Pure, so the de-duplication and derivation rules can be tested without a * connected environment. * * @module usageMerge */ import type { EnvironmentId, UsageBucket, UsageProviderKind, UsageSourceFingerprint, UsageSummary, } from "@t3tools/contracts"; export interface EnvironmentUsage { readonly environmentId: EnvironmentId; readonly label: string; readonly summary: UsageSummary; } export interface ProviderTotals { readonly provider: UsageProviderKind; readonly costUsd: number; readonly totalTokens: number; readonly records: number; readonly sessions: number; readonly costShare: number; readonly tokenShare: number; } export interface ModelTotals { readonly model: string; readonly provider: UsageProviderKind; readonly costUsd: number; readonly totalTokens: number; readonly records: number; readonly costShare: number; } export interface DailyTotals { readonly day: string; readonly costUsd: number; readonly totalTokens: number; readonly byProvider: ReadonlyMap; } export interface HourlyTotals { readonly day: string; readonly hourStart: string; readonly costUsd: number; readonly totalTokens: number; readonly byProvider: ReadonlyMap; } export interface CostQuality { readonly providerReportedShare: number; readonly modelPricedShare: number; readonly unpricedShare: number; readonly cacheSavingsUsd: number; } export interface MergedUsage { readonly costUsd: number; readonly uncachedInputTokens: number; readonly cachedInputTokens: number; readonly cacheCreationTokens: number; readonly outputTokens: number; readonly reasoningTokens: number; readonly totalTokens: number; readonly records: number; readonly sessions: number; readonly providers: readonly ProviderTotals[]; readonly models: readonly ModelTotals[]; readonly daily: readonly DailyTotals[]; readonly hourly: readonly HourlyTotals[]; readonly costQuality: CostQuality; /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; readonly staleEnvironments: readonly EnvironmentId[]; } /** * Two sources are the same physical transcript directory only when host, * provider, path and filesystem identity all agree. * * `volumeId` is what stops two machines that happen to share a hostname and a * home path, which is every Mac in a fleet, from collapsing into one source and * having one of them silently dropped. */ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { return [ fingerprint.hostId, fingerprint.provider, fingerprint.resolvedHomePath, fingerprint.volumeId, ].join(" "); } /** * Decides which environment owns each physical transcript directory. * * Several environments on one machine (worktree servers, for instance) resolve * the same provider home and would otherwise double count every token. The * first environment in a stable order claims a fingerprint; the rest have that * provider's buckets dropped. Environments are sorted by id so the winner does * not change between renders. */ function claimSources(environments: readonly EnvironmentUsage[]): { readonly ownerByFingerprint: ReadonlyMap; readonly duplicates: readonly string[]; } { const ownerByFingerprint = new Map(); const duplicates: string[] = []; const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); for (const environment of ordered) { for (const source of environment.summary.sources) { if (source.status === "missing") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.has(key)) { duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); continue; } ownerByFingerprint.set(key, environment.environmentId); } } return { ownerByFingerprint, duplicates }; } /** Sources this environment owns after fingerprint claims, plus their buckets. */ function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, ): { readonly buckets: readonly UsageBucket[]; readonly sessionsByProvider: ReadonlyMap; } { const ownedProviders = new Set(); const sessionsByProvider = new Map(); for (const source of environment.summary.sources) { if (source.status === "missing") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.get(key) === environment.environmentId) { const provider = source.fingerprint.provider; ownedProviders.add(provider); // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. sessionsByProvider.set( provider, (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, ); } } return { buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), sessionsByProvider, }; } function bucketTokens(bucket: UsageBucket): number { // reasoningTokens is a subset of outputTokens and must not be added again. return ( bucket.totals.uncachedInputTokens + bucket.totals.cachedInputTokens + bucket.totals.cacheCreationTokens + bucket.totals.outputTokens ); } const EMPTY_MERGED: MergedUsage = { costUsd: 0, uncachedInputTokens: 0, cachedInputTokens: 0, cacheCreationTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0, records: 0, sessions: 0, providers: [], models: [], daily: [], hourly: [], costQuality: { providerReportedShare: 0, modelPricedShare: 0, unpricedShare: 0, cacheSavingsUsd: 0, }, duplicateSources: [], contributingEnvironments: [], staleEnvironments: [], }; /** * Merges every connected environment's summary. * * `expectedContractVersion` guards against an environment running older server * code: rather than blocking the page, its data is excluded and its id is * reported so the UI can say coverage is partial. */ export function mergeUsage( environments: readonly EnvironmentUsage[], expectedContractVersion: number, ): MergedUsage { if (environments.length === 0) return EMPTY_MERGED; const current: EnvironmentUsage[] = []; const staleEnvironments: EnvironmentId[] = []; for (const environment of environments) { if (environment.summary.contractVersion === expectedContractVersion) { current.push(environment); } else { staleEnvironments.push(environment.environmentId); } } const { ownerByFingerprint, duplicates } = claimSources(current); let costUsd = 0; let uncachedInputTokens = 0; let cachedInputTokens = 0; let cacheCreationTokens = 0; let outputTokens = 0; let reasoningTokens = 0; let records = 0; let sessions = 0; let cacheSavingsUsd = 0; let providerReportedRecords = 0; let unpricedRecords = 0; const providerAccumulator = new Map< UsageProviderKind, { costUsd: number; totalTokens: number; records: number; sessions: number } >(); const modelAccumulator = new Map< string, { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } >(); const dailyAccumulator = new Map< string, { costUsd: number; totalTokens: number; byProvider: Map; } >(); const hourlyAccumulator = new Map< string, { day: string; hourStart: string; costUsd: number; totalTokens: number; byProvider: Map; } >(); const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); for (const [providerKind, providerSessions] of sessionsByProvider) { sessions += providerSessions; if (providerSessions === 0) continue; const provider = providerAccumulator.get(providerKind) ?? { costUsd: 0, totalTokens: 0, records: 0, sessions: 0, }; provider.sessions += providerSessions; providerAccumulator.set(providerKind, provider); } for (const bucket of buckets) { const tokens = bucketTokens(bucket); costUsd += bucket.costUsd; cacheSavingsUsd += bucket.cacheSavingsUsd; uncachedInputTokens += bucket.totals.uncachedInputTokens; cachedInputTokens += bucket.totals.cachedInputTokens; cacheCreationTokens += bucket.totals.cacheCreationTokens; outputTokens += bucket.totals.outputTokens; reasoningTokens += bucket.totals.reasoningTokens; records += bucket.records; unpricedRecords += bucket.unpricedRecords; if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records; const provider = providerAccumulator.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0, records: 0, sessions: 0, }; provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; provider.records += bucket.records; providerAccumulator.set(bucket.provider, provider); const modelKey = `${bucket.provider} ${bucket.model}`; const model = modelAccumulator.get(modelKey) ?? { provider: bucket.provider, costUsd: 0, totalTokens: 0, records: 0, }; model.costUsd += bucket.costUsd; model.totalTokens += tokens; model.records += bucket.records; modelAccumulator.set(modelKey, model); const day = dailyAccumulator.get(bucket.day) ?? { costUsd: 0, totalTokens: 0, byProvider: new Map(), }; day.costUsd += bucket.costUsd; day.totalTokens += tokens; const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 }; dayProvider.costUsd += bucket.costUsd; dayProvider.totalTokens += tokens; day.byProvider.set(bucket.provider, dayProvider); dailyAccumulator.set(bucket.day, day); if (bucket.hourStart !== undefined) { const hour = hourlyAccumulator.get(bucket.hourStart) ?? { day: bucket.day, hourStart: bucket.hourStart, costUsd: 0, totalTokens: 0, byProvider: new Map(), }; hour.costUsd += bucket.costUsd; hour.totalTokens += tokens; const hourProvider = hour.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0, }; hourProvider.costUsd += bucket.costUsd; hourProvider.totalTokens += tokens; hour.byProvider.set(bucket.provider, hourProvider); hourlyAccumulator.set(bucket.hourStart, hour); } } } const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens; const providers: ProviderTotals[] = [...providerAccumulator.entries()] .map(([provider, totals]) => ({ provider, costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, sessions: totals.sessions, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, })) .sort((a, b) => b.costUsd - a.costUsd); const models: ModelTotals[] = [...modelAccumulator.entries()] .map(([key, totals]) => ({ model: key.slice(key.indexOf(" ") + 1), provider: totals.provider, costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); const daily: DailyTotals[] = [...dailyAccumulator.entries()] .map(([day, totals]) => ({ day, costUsd: totals.costUsd, totalTokens: totals.totalTokens, byProvider: totals.byProvider, })) .sort((a, b) => a.day.localeCompare(b.day)); const hourly: HourlyTotals[] = [...hourlyAccumulator.values()].sort((a, b) => a.hourStart.localeCompare(b.hourStart), ); return { costUsd, uncachedInputTokens, cachedInputTokens, cacheCreationTokens, outputTokens, reasoningTokens, totalTokens, records, sessions, providers, models, daily, hourly, costQuality: { providerReportedShare: records === 0 ? 0 : providerReportedRecords / records, unpricedShare: records === 0 ? 0 : unpricedRecords / records, modelPricedShare: records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, cacheSavingsUsd, }, duplicateSources: duplicates, contributingEnvironments, staleEnvironments, }; }