Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222/** * Usage aggregate — pure functions over a {@link UsageRecord} list (saga vr7kgx.20). * * Mirrors the compactor's split of I/O from logic (ARCH §11): `UsageLog` owns the append-only * JSONL; these functions own the time-bucketing + per-model grouping + totals. Pure, * deterministic, unit-testable — no I/O, no side effects. The bucketing/grouping logic can * change across versions and old logs still aggregate correctly ("data outlives behavior", * §1.4). * * Pipeline (analogous to `normalize → buildSections → formatSummary`): * 1. `bucketize(records, granularity)` → group by `(bucket_start, provider, model)`, accumulate. * 2. `summarize(series)` → per-model grand totals + a grand total across the range. * * The output shape mirrors the sibling tool-usage stats contract * (`src/contracts/tool-usage-stats.ts`): a per-model `totals` rollup (the summary table) + * a time `series` of buckets (the chart) + a `grand_totals` row. Bucket flooring is in the * daemon's *local* timezone (so "today" means the user's today); `bucket_start` is an * absolute epoch ms (TZ-independent in storage), formatted by the client. */
import type { UsageRecord } from '@/daemon/usage-log';
/** Time-bucket granularity for the aggregate. `total` collapses the whole range into one bucket. */export type UsageBucket = 'hour' | 'day' | 'week' | 'month' | 'total';
/** The closed set of bucket granularities (for validation). */export const USAGE_BUCKETS: readonly UsageBucket[] = ['hour', 'day', 'week', 'month', 'total'];
/** Coerce an unknown value to a valid {@link UsageBucket}, or `undefined`. */export function coerceBucket(value: unknown): UsageBucket | undefined { return typeof value === 'string' && (USAGE_BUCKETS as readonly string[]).includes(value) ? (value as UsageBucket) : undefined;}
/** Per-model accumulated counts (the unit of both the per-bucket breakdown and the grand rollup). */export interface ModelBreakdown { provider: string; model: string; input: number; cache_read: number; cache_write: number; output: number; cost: number; calls: number;}
/** Totals across all models in a bucket (or the whole range). */export interface UsageTotals { input: number; cache_read: number; cache_write: number; output: number; cost: number; calls: number;}
/** One time bucket of the series. */export interface UsageBucketSummary { /** Epoch ms of the bucket floor (0 when `granularity === 'total'`). */ bucket_start: number; /** Per-model breakdown within this bucket, sorted by total tokens (input + output) desc. */ by_model: ModelBreakdown[];}
/** The full aggregate: time series + a per-model grand rollup + a grand total. */export interface UsageAggregate { /** Time-ordered buckets (ascending `bucket_start`). */ series: UsageBucketSummary[]; /** Per-model totals across the whole queried range, sorted by total tokens desc. */ totals: ModelBreakdown[]; /** Summed totals across all models (the "Totals:" row). */ grand_totals: UsageTotals;}
const EMPTY_TOTALS: UsageTotals = { input: 0, cache_read: 0, cache_write: 0, output: 0, cost: 0, calls: 0,};
/** * Group usage records into time buckets keyed by `(bucket_start, provider, model)`, * accumulating input/cache_read/cache_write/output/cost/calls per group. * * Records missing `provider`/`model` aggregate under the sentinel `'(unknown)'` so they are * still counted (never dropped) and stay distinguishable from attributed models. * * Pure: deterministic given the records + granularity. Bucket flooring is in local time. */export function bucketize(records: readonly UsageRecord[], granularity: UsageBucket): UsageBucketSummary[] { if (records.length === 0) return []; // group key → accumulator const groups = new Map<string, { bucket_start: number; provider: string; model: string; totals: UsageTotals }>();
for (const rec of records) { const bucketStart = floorBucket(rec.ts, granularity); const provider = rec.provider ?? '(unknown)'; const model = rec.model ?? '(unknown)'; const key = `${bucketStart}\u{1f}\u{1f}${provider}\u{1f}${model}`; let group = groups.get(key); if (group === undefined) { group = { bucket_start: bucketStart, provider, model, totals: { ...EMPTY_TOTALS } }; groups.set(key, group); } group.totals.input += rec.input; group.totals.cache_read += rec.cache_read ?? 0; group.totals.cache_write += rec.cache_write ?? 0; group.totals.output += rec.output; group.totals.cost += rec.cost ?? 0; group.totals.calls += 1; }
// Collect into per-bucket summaries. const byBucket = new Map<number, Map<string, ModelBreakdown>>(); for (const group of groups.values()) { let bucket = byBucket.get(group.bucket_start); if (bucket === undefined) { bucket = new Map(); byBucket.set(group.bucket_start, bucket); } bucket.set(`${group.provider}\u{1f}${group.model}`, { provider: group.provider, model: group.model, input: group.totals.input, cache_read: group.totals.cache_read, cache_write: group.totals.cache_write, output: group.totals.output, cost: group.totals.cost, calls: group.totals.calls, }); }
const summaries: UsageBucketSummary[] = []; for (const [bucketStart, modelMap] of byBucket) { const byModel = [...modelMap.values()].sort(byTotalTokensDesc); summaries.push({ bucket_start: bucketStart, by_model: byModel }); } // Sort buckets chronologically (ascending by bucket_start). summaries.sort((a, b) => a.bucket_start - b.bucket_start); return summaries;}
/** * Compute the per-model grand rollup + grand total across the whole queried range. Pure. * The `series` is passed through unchanged; this adds the cross-bucket rollup the summary * table renders. */export function summarize(series: readonly UsageBucketSummary[]): UsageAggregate { // Roll up per-model totals across all buckets. const rollup = new Map<string, ModelBreakdown>(); const grandTotals: UsageTotals = { ...EMPTY_TOTALS }; for (const bucket of series) { for (const m of bucket.by_model) { const key = `${m.provider}\u{1f}${m.model}`; let acc = rollup.get(key); if (acc === undefined) { acc = { provider: m.provider, model: m.model, input: 0, cache_read: 0, cache_write: 0, output: 0, cost: 0, calls: 0 }; rollup.set(key, acc); } acc.input += m.input; acc.cache_read += m.cache_read; acc.cache_write += m.cache_write; acc.output += m.output; acc.cost += m.cost; acc.calls += m.calls; grandTotals.input += m.input; grandTotals.cache_read += m.cache_read; grandTotals.cache_write += m.cache_write; grandTotals.output += m.output; grandTotals.cost += m.cost; grandTotals.calls += m.calls; } } const totals = [...rollup.values()].sort(byTotalTokensDesc); return { series: [...series], totals, grand_totals: grandTotals };}
/** Convenience: bucketize + summarize in one step (the common query path). */export function aggregate(records: readonly UsageRecord[], granularity: UsageBucket): UsageAggregate { return summarize(bucketize(records, granularity));}
/** Sort comparator: total tokens (input + output) descending, ties broken by provider/model. */function byTotalTokensDesc(a: ModelBreakdown, b: ModelBreakdown): number { const aTotal = a.input + a.output; const bTotal = b.input + b.output; if (aTotal !== bTotal) return bTotal - aTotal; if (a.provider !== b.provider) return a.provider < b.provider ? -1 : 1; return a.model < b.model ? -1 : a.model > b.model ? 1 : 0;}
/** * Floor an epoch-ms timestamp to the start of its bucket in the *local* timezone. * * `total` → 0 (a single collapsed bucket). For the time granularities, the floor is computed * by mutating a `Date` in local wall-clock components — robust to DST because it works in * wall-clock fields, not fixed offsets. The returned epoch ms is absolute (TZ-independent). */function floorBucket(ts: number, granularity: UsageBucket): number { if (granularity === 'total') return 0; const d = new Date(ts); if (granularity === 'hour') { d.setMinutes(0, 0, 0); return d.getTime(); } if (granularity === 'day') { d.setHours(0, 0, 0, 0); return d.getTime(); } if (granularity === 'week') { // ISO week: Monday is the first day. Local Sunday (0) → back up 6 days; Monday (1) → 0. d.setHours(0, 0, 0, 0); const day = d.getDay(); const diff = (day === 0 ? 6 : day - 1); d.setDate(d.getDate() - diff); return d.getTime(); } // month: floor to the first of the local month. d.setHours(0, 0, 0, 0); d.setDate(1); return d.getTime();}