tledger 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1159 @@
1
+ // Pure view model for the PNG trend report. Builds validated,
2
+ // reconciliation-safe report data from a snapshot and range bounds so the SVG
3
+ // renderer consumes one bounded event set instead of recalculating totals in
4
+ // every layout section. Contains no SVG, positions, colors, or formatted
5
+ // strings.
6
+
7
+ import {
8
+ normalizeQuotaTimeline,
9
+ priorPeriodBounds,
10
+ trendModelLabel,
11
+ weeklyQuotaObservations,
12
+ } from "./token-ledger-trend.mjs";
13
+ import { buildRangeAnalysis } from "../lib/token-ledger-range-analysis.mjs";
14
+ import { historyScopeLabel } from "../lib/token-ledger-collection.mjs";
15
+ import {
16
+ CODEX_CREDIT_RATE_CARD_AS_OF,
17
+ isFastServiceTier,
18
+ } from "../lib/token-ledger-rates.mjs";
19
+ import {
20
+ MAX_SAFE_TOKEN_COUNT,
21
+ splitUsageBucketsAtBoundaries,
22
+ usageCallCount,
23
+ usageDetailedCallCount,
24
+ } from "../lib/token-ledger-usage.mjs";
25
+ import {
26
+ localDateBoundary,
27
+ localDateString,
28
+ shiftCalendarDate,
29
+ } from "../lib/token-ledger-calendar.mjs";
30
+ import { SOURCE_STATUSES } from "./token-ledger-source-status.mjs";
31
+
32
+ export { shiftCalendarDate, SOURCE_STATUSES };
33
+
34
+ const DAY_MS = 86_400_000;
35
+ const HOUR_MS = 3_600_000;
36
+ // Two readings this close in percent confirm a flat reported interval.
37
+ const METER_EQUAL_TOLERANCE = 0.05;
38
+ // Remaining percent at or below this reads as an exhausted meter.
39
+ const METER_EXHAUSTED_TOLERANCE = 0.05;
40
+ const RECONCILE_RELATIVE_TOLERANCE = 1e-6;
41
+ const RECONCILE_ABSOLUTE_TOLERANCE = 1.5;
42
+
43
+ // Fast mode is an overlapping usage property, not a separate model. Both
44
+ // recognized service-tier labels count. Keep normalTokens limited to the
45
+ // explicit standard/default tiers; a missing or unfamiliar tier is unknown.
46
+ const KNOWN_NORMAL_SERVICE_TIERS = new Set(["default", "standard"]);
47
+
48
+ function serviceTierClass(serviceTier) {
49
+ const tier = String(serviceTier ?? "")
50
+ .trim()
51
+ .toLowerCase()
52
+ .replace(/[\s_]+/g, "-");
53
+ if (isFastServiceTier(tier)) return "fast";
54
+ if (KNOWN_NORMAL_SERVICE_TIERS.has(tier)) return "normal";
55
+ return "unknown";
56
+ }
57
+
58
+ export function isFastMode(serviceTier) {
59
+ return serviceTierClass(serviceTier) === "fast";
60
+ }
61
+
62
+ function finiteTimestamp(value) {
63
+ // Snapshot timestamps are serialized as ISO strings. Keep finite numeric
64
+ // epoch milliseconds for existing callers, but do not let Date coerce
65
+ // null, booleans, objects, or other non-timestamp values.
66
+ if (Number.isFinite(value)) {
67
+ const timestamp = new Date(value).getTime();
68
+ return Number.isFinite(timestamp) ? timestamp : null;
69
+ }
70
+ const text = primitiveString(value);
71
+ if (text === null) return null;
72
+ const timestamp = Date.parse(text);
73
+ return Number.isFinite(timestamp) ? timestamp : null;
74
+ }
75
+
76
+ function primitiveString(value) {
77
+ try {
78
+ const text = String.prototype.valueOf.call(value);
79
+ return text === value ? text : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function dateStringFromParts(year, month, day) {
86
+ return [year, month, day]
87
+ .map((value, index) => String(value).padStart(index === 0 ? 4 : 2, "0"))
88
+ .join("-");
89
+ }
90
+
91
+ function offsetAt(instant, timeZone) {
92
+ const parts = new Intl.DateTimeFormat("en-US", {
93
+ timeZone,
94
+ timeZoneName: "longOffset",
95
+ }).formatToParts(instant);
96
+ const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
97
+ if (value === "GMT") return 0;
98
+ const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
99
+ if (!match) return 0;
100
+ const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
101
+ return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
102
+ }
103
+
104
+ function localDateTimeParts(timestampMs, timeZone) {
105
+ const parts = new Intl.DateTimeFormat("en-US", {
106
+ timeZone,
107
+ year: "numeric",
108
+ month: "2-digit",
109
+ day: "2-digit",
110
+ hour: "2-digit",
111
+ minute: "2-digit",
112
+ second: "2-digit",
113
+ hourCycle: "h23",
114
+ }).formatToParts(new Date(timestampMs));
115
+ const values = Object.fromEntries(
116
+ parts
117
+ .filter((part) => part.type !== "literal")
118
+ .map((part) => [part.type, Number(part.value)]),
119
+ );
120
+ return {
121
+ dateString: dateStringFromParts(values.year, values.month, values.day),
122
+ hour: values.hour,
123
+ minute: values.minute,
124
+ second: values.second,
125
+ millisecond: new Date(timestampMs).getUTCMilliseconds(),
126
+ };
127
+ }
128
+
129
+ function zonedDateTime(dateString, timeZone, time = {}) {
130
+ const [year, month, day] = dateString.split("-").map(Number);
131
+ const utcGuess = Date.UTC(
132
+ year,
133
+ month - 1,
134
+ day,
135
+ time.hour ?? 0,
136
+ time.minute ?? 0,
137
+ time.second ?? 0,
138
+ time.millisecond ?? 0,
139
+ );
140
+ let instant = new Date(utcGuess - offsetAt(new Date(utcGuess), timeZone));
141
+ instant = new Date(utcGuess - offsetAt(instant, timeZone));
142
+ return instant;
143
+ }
144
+
145
+ export function zonedMidnight(dateString, timeZone) {
146
+ return localDateBoundary(dateString, timeZone);
147
+ }
148
+
149
+ function elapsedHourIntervals(startMs, endMs) {
150
+ const intervals = [];
151
+ for (let cursor = startMs; cursor < endMs; cursor += HOUR_MS) {
152
+ intervals.push({
153
+ startMs: cursor,
154
+ endMs: Math.min(endMs, cursor + HOUR_MS),
155
+ });
156
+ }
157
+ return intervals;
158
+ }
159
+
160
+ function intervalIndexAt(intervals, timestampMs) {
161
+ let low = 0;
162
+ let high = intervals.length;
163
+ while (low < high) {
164
+ const middle = Math.floor((low + high) / 2);
165
+ if (intervals[middle].startMs <= timestampMs) low = middle + 1;
166
+ else high = middle;
167
+ }
168
+ const index = low - 1;
169
+ return index >= 0 && timestampMs < intervals[index].endMs ? index : -1;
170
+ }
171
+
172
+ // Whether the stored input/output components of an event can be trusted.
173
+ // Mirrors the importer's breakdownAvailable rule for snapshots that predate
174
+ // the stored flag.
175
+ function usableComponents(event) {
176
+ if (event.breakdownAvailable === true) return true;
177
+ if (event.breakdownAvailable === false) return false;
178
+ const total = Math.max(0, Number(event.totalTokens) || 0);
179
+ const input = Math.max(0, Number(event.inputTokens) || 0);
180
+ const output = Math.max(0, Number(event.outputTokens) || 0);
181
+ if (total === 0) return input > 0 || output > 0;
182
+ return input + output === total && (input > 0 || output > 0);
183
+ }
184
+
185
+ // The moment the report's event window actually ends. A verified-current
186
+ // source may report through the wall clock; every other source status must
187
+ // stop at the snapshot's own capture time so the report never claims data it
188
+ // could not have seen.
189
+ export function resolveEffectiveEnd({
190
+ snapshot = {},
191
+ bounds,
192
+ reportTimeMs = null,
193
+ sourceStatus = "unchecked-cache",
194
+ }) {
195
+ const startMs = bounds.start.getTime();
196
+ const endMs = bounds.end.getTime();
197
+ const generatedAtMs = finiteTimestamp(snapshot.generatedAt);
198
+ const sourceCutoffAtMs = finiteTimestamp(
199
+ snapshot.provenance?.sourceCutoffAt,
200
+ );
201
+ const wallClockMs = Number.isFinite(reportTimeMs) ? reportTimeMs : endMs;
202
+ // The capture-time cutoff is inclusive: an event stamped exactly at
203
+ // generatedAt was part of the capture.
204
+ const cutoff = sourceStatus === "verified-current"
205
+ ? wallClockMs
206
+ : (sourceCutoffAtMs ?? generatedAtMs) === null
207
+ ? wallClockMs
208
+ : (sourceCutoffAtMs ?? generatedAtMs) + 1;
209
+ return Math.max(startMs, Math.min(endMs, cutoff));
210
+ }
211
+
212
+ function approxEqual(left, right) {
213
+ return (
214
+ Math.abs(left - right) <=
215
+ Math.max(
216
+ RECONCILE_ABSOLUTE_TOLERANCE,
217
+ RECONCILE_RELATIVE_TOLERANCE * Math.max(Math.abs(left), Math.abs(right)),
218
+ )
219
+ );
220
+ }
221
+
222
+ function assertReconciles(label, left, right) {
223
+ if (!approxEqual(left, right)) {
224
+ throw new Error(
225
+ `Report reconciliation failed: ${label} (${left} vs ${right})`,
226
+ );
227
+ }
228
+ }
229
+
230
+ function modelRowFor(map, model) {
231
+ const row = map.get(model) ?? {
232
+ model,
233
+ totalTokens: 0,
234
+ normalTokens: 0,
235
+ fastTokens: 0,
236
+ unknownTokens: 0,
237
+ cacheInputTokens: 0,
238
+ cachedInputTokens: 0,
239
+ uncachedInputTokens: 0,
240
+ estimated: false,
241
+ };
242
+ map.set(model, row);
243
+ return row;
244
+ }
245
+
246
+ function meterRemainingPercent(usedPercent) {
247
+ return Math.max(0, Math.min(100, 100 - usedPercent));
248
+ }
249
+
250
+ // A compacted quota row represents the same provider reading from its first
251
+ // occurrence through lastSeenAt. Keep the distinction between the first
252
+ // timestamp (where a change may have happened) and the confirmed observation
253
+ // tail (where that unchanged value was still reported).
254
+ function boundedObservationThroughMs(observation, effectiveEndMs) {
255
+ const timestampMs = observation.timestampMs;
256
+ const observedThroughMs = Number.isFinite(observation.observedThroughMs)
257
+ ? observation.observedThroughMs
258
+ : timestampMs;
259
+ return Math.min(
260
+ effectiveEndMs,
261
+ Math.max(timestampMs, observedThroughMs),
262
+ );
263
+ }
264
+
265
+ function buildMeter({ snapshot, bounds, effectiveEndMs, sourceStatus, events }) {
266
+ const startMs = bounds.start.getTime();
267
+ const stale = sourceStatus === "stale-fallback";
268
+ // weeklyQuotaObservations owns quota identity and account-scope selection;
269
+ // this layer only applies the report-range cutoff to its selected meter.
270
+ const selected = weeklyQuotaObservations(snapshot);
271
+ const observationsAll = normalizeQuotaTimeline(selected).filter(
272
+ (observation) => observation.timestampMs < effectiveEndMs,
273
+ );
274
+
275
+ const empty = {
276
+ status: "unavailable",
277
+ stale,
278
+ remainingPercent: null,
279
+ lastObservedAtMs: null,
280
+ observedThroughMs: null,
281
+ firstExhaustedObservedAtMs: null,
282
+ resetsAtMs: null,
283
+ resetInMs: null,
284
+ cycleStartMs: null,
285
+ cycleBurnPercent: null,
286
+ burnPerDay: null,
287
+ runwayDays: null,
288
+ tokensPerMeterPoint: null,
289
+ observations: [],
290
+ segments: [],
291
+ resets: [],
292
+ };
293
+ if (!observationsAll.length) return empty;
294
+
295
+ const latest = observationsAll.at(-1);
296
+ const latestObservedThroughMs = boundedObservationThroughMs(
297
+ latest,
298
+ effectiveEndMs,
299
+ );
300
+ const remainingPercent = meterRemainingPercent(latest.normalizedUsedPercent);
301
+
302
+ // Points for the sampled meter line: real observations inside the range
303
+ // plus one carried anchor at the range start when an earlier reading
304
+ // exists. The anchor is not drawn as a dot.
305
+ const inRange = observationsAll.filter(
306
+ (observation) => observation.timestampMs >= startMs,
307
+ );
308
+ const before = observationsAll.filter(
309
+ (observation) => observation.timestampMs < startMs,
310
+ );
311
+ const pointRows = [];
312
+ const addPoint = (observation, timestampMs, observed) => {
313
+ if (timestampMs < startMs || timestampMs > effectiveEndMs) return;
314
+ pointRows.push({
315
+ timestampMs,
316
+ remainingPercent: meterRemainingPercent(observation.normalizedUsedPercent),
317
+ cycle: observation.cycle,
318
+ observed,
319
+ });
320
+ };
321
+ const anchor = before.at(-1);
322
+ if (anchor && (!inRange.length || inRange[0].cycle === anchor.cycle)) {
323
+ addPoint(anchor, startMs, false);
324
+ const anchorThroughMs = boundedObservationThroughMs(
325
+ anchor,
326
+ effectiveEndMs,
327
+ );
328
+ if (anchorThroughMs > startMs) addPoint(anchor, anchorThroughMs, true);
329
+ }
330
+ for (const observation of inRange) {
331
+ addPoint(observation, observation.timestampMs, true);
332
+ const observationThroughMs = boundedObservationThroughMs(
333
+ observation,
334
+ effectiveEndMs,
335
+ );
336
+ if (observationThroughMs > observation.timestampMs) {
337
+ addPoint(observation, observationThroughMs, true);
338
+ }
339
+ }
340
+ const points = pointRows.sort(
341
+ (left, right) =>
342
+ left.timestampMs - right.timestampMs ||
343
+ Number(left.observed) - Number(right.observed),
344
+ );
345
+
346
+ // Straight segments between adjacent readings of one cycle. Repeated equal
347
+ // readings confirm a flat reported interval; a changed value means the
348
+ // movement happened somewhere unobserved, so the connector is a gap.
349
+ const segments = [];
350
+ for (let index = 0; index < points.length - 1; index += 1) {
351
+ const from = points[index];
352
+ const to = points[index + 1];
353
+ if (from.cycle !== to.cycle) continue;
354
+ if (to.timestampMs - from.timestampMs <= 0) continue;
355
+ segments.push({
356
+ fromMs: from.timestampMs,
357
+ toMs: to.timestampMs,
358
+ fromPercent: from.remainingPercent,
359
+ toPercent: to.remainingPercent,
360
+ cycle: from.cycle,
361
+ kind:
362
+ Math.abs(to.remainingPercent - from.remainingPercent) <=
363
+ METER_EQUAL_TOLERANCE
364
+ ? "confirmed"
365
+ : "gap",
366
+ });
367
+ }
368
+
369
+ // Cycle resets that land inside the display window. The reset moment is
370
+ // derived from the window schedule, not directly observed.
371
+ const resets = [];
372
+ const seenCycles = new Set();
373
+ for (const observation of observationsAll) {
374
+ if (seenCycles.has(observation.cycle)) continue;
375
+ seenCycles.add(observation.cycle);
376
+ if (!observation.reset) continue;
377
+ const timestampMs = Math.min(
378
+ observation.cycleStartMs,
379
+ observation.timestampMs,
380
+ );
381
+ if (timestampMs < startMs || timestampMs >= effectiveEndMs) continue;
382
+ resets.push({
383
+ timestampMs,
384
+ observedAtMs: observation.timestampMs,
385
+ kind: observation.resetKind ?? "restart",
386
+ inferred: true,
387
+ });
388
+ }
389
+
390
+ // Pace uses only the active weekly cycle: observed burn between its first
391
+ // and last readings, never the whole report range.
392
+ const cycleObservations = observationsAll.filter(
393
+ (observation) => observation.cycle === latest.cycle,
394
+ );
395
+ const firstCycleObservation = cycleObservations[0];
396
+ const cycleBurnPercent =
397
+ latest.normalizedUsedPercent - firstCycleObservation.normalizedUsedPercent;
398
+ const observedCycleMs =
399
+ latestObservedThroughMs - firstCycleObservation.timestampMs;
400
+ const burnPerDay =
401
+ cycleBurnPercent > 0 && observedCycleMs > 0
402
+ ? cycleBurnPercent / (observedCycleMs / DAY_MS)
403
+ : null;
404
+ const runwayDays =
405
+ burnPerDay !== null && burnPerDay > 0 ? remainingPercent / burnPerDay : null;
406
+ let cycleTokens = 0;
407
+ if (cycleBurnPercent > 0) {
408
+ for (const event of events) {
409
+ if (
410
+ event.timestampMs > firstCycleObservation.timestampMs &&
411
+ event.timestampMs <= latestObservedThroughMs
412
+ ) {
413
+ cycleTokens += event.tokens;
414
+ }
415
+ }
416
+ }
417
+ const tokensPerMeterPoint =
418
+ cycleBurnPercent > 0 && cycleTokens > 0
419
+ ? cycleTokens / cycleBurnPercent
420
+ : null;
421
+
422
+ const firstExhausted = cycleObservations.find(
423
+ (observation) =>
424
+ 100 - observation.normalizedUsedPercent <= METER_EXHAUSTED_TOLERANCE,
425
+ );
426
+ const resetsAtMs = Number.isFinite(latest.resetsAt)
427
+ ? latest.resetsAt * 1_000
428
+ : null;
429
+ const resetInMs =
430
+ resetsAtMs !== null && resetsAtMs > effectiveEndMs
431
+ ? resetsAtMs - effectiveEndMs
432
+ : null;
433
+
434
+ let status = "active";
435
+ if (remainingPercent <= METER_EXHAUSTED_TOLERANCE) {
436
+ status = "exhausted";
437
+ } else if (
438
+ runwayDays !== null &&
439
+ resetInMs !== null &&
440
+ runwayDays * DAY_MS < resetInMs
441
+ ) {
442
+ status = "at-risk";
443
+ }
444
+
445
+ return {
446
+ status,
447
+ stale,
448
+ remainingPercent,
449
+ lastObservedAtMs: latestObservedThroughMs,
450
+ observedThroughMs: latestObservedThroughMs,
451
+ firstExhaustedObservedAtMs: firstExhausted?.timestampMs ?? null,
452
+ resetsAtMs,
453
+ resetInMs,
454
+ cycleStartMs: latest.cycleStartMs ?? null,
455
+ cycleBurnPercent,
456
+ burnPerDay,
457
+ runwayDays,
458
+ tokensPerMeterPoint,
459
+ observations: points,
460
+ segments,
461
+ resets,
462
+ };
463
+ }
464
+
465
+ export function buildTrendReportViewModel({
466
+ snapshot = {},
467
+ bounds,
468
+ days = null,
469
+ reportTimeMs = null,
470
+ sourceStatus = "unchecked-cache",
471
+ projectRows = null,
472
+ events = null,
473
+ priorEvents = null,
474
+ }) {
475
+ if (!SOURCE_STATUSES.includes(sourceStatus)) {
476
+ throw new Error(`Unknown report source status: ${sourceStatus}`);
477
+ }
478
+ const timeZone = bounds.timeZone;
479
+ const startMs = bounds.start.getTime();
480
+ const requestedEndMs = bounds.end.getTime();
481
+ const rangeDays = Number(days) || bounds.rangeDays || 7;
482
+ const effectiveEndMs = resolveEffectiveEnd({
483
+ snapshot,
484
+ bounds,
485
+ reportTimeMs,
486
+ sourceStatus,
487
+ });
488
+ const partialFinalDay = effectiveEndMs < requestedEndMs;
489
+
490
+ const defaultRangeAnalysis =
491
+ (!Array.isArray(events) || !Array.isArray(priorEvents)) &&
492
+ bounds.startDateString &&
493
+ bounds.endDateString &&
494
+ bounds.timeZone
495
+ ? buildRangeAnalysis(snapshot, bounds, {
496
+ priorBounds: priorPeriodBounds(bounds, rangeDays),
497
+ })
498
+ : null;
499
+
500
+ const dayStrings = Array.from({ length: rangeDays }, (_, index) =>
501
+ shiftCalendarDate(bounds.startDateString, index),
502
+ );
503
+ const dayIndexByString = new Map(
504
+ dayStrings.map((dateString, index) => [dateString, index]),
505
+ );
506
+ const lastObservedMs = Math.max(startMs, effectiveEndMs - 1);
507
+ const lastObservedDateString = localDateString(lastObservedMs, timeZone);
508
+ const lastObservedDayIndex = dayIndexByString.get(lastObservedDateString);
509
+ const lastObservedDayEndMs =
510
+ lastObservedDayIndex === undefined
511
+ ? null
512
+ : zonedMidnight(
513
+ shiftCalendarDate(lastObservedDateString, 1),
514
+ timeZone,
515
+ ).getTime();
516
+ const partialDayIndex =
517
+ partialFinalDay &&
518
+ effectiveEndMs > startMs &&
519
+ lastObservedDayIndex !== undefined &&
520
+ effectiveEndMs !== lastObservedDayEndMs
521
+ ? lastObservedDayIndex
522
+ : null;
523
+ const daily = dayStrings.map((dateString, index) => ({
524
+ dateString,
525
+ totalTokens: 0,
526
+ inputTokens: 0,
527
+ outputTokens: 0,
528
+ cachedInputTokens: 0,
529
+ uncachedInputTokens: 0,
530
+ cacheRatePercent: null,
531
+ modelCalls: 0,
532
+ estimated: false,
533
+ observed: !partialFinalDay ||
534
+ (lastObservedDayIndex !== undefined && index <= lastObservedDayIndex),
535
+ partial: partialDayIndex === index,
536
+ models: new Map(),
537
+ }));
538
+ const hourlyMode = rangeDays === 1;
539
+ // A 1d report is a selected local calendar day, but the chart follows
540
+ // elapsed one-hour intervals inside the captured portion of that day. This
541
+ // naturally yields 23/24/25 rows across DST transitions and avoids adding
542
+ // future zero-valued rows after a partial cutoff.
543
+ const hourlyIntervals = hourlyMode
544
+ ? elapsedHourIntervals(startMs, requestedEndMs).filter(
545
+ (interval) => interval.startMs < effectiveEndMs,
546
+ )
547
+ : [];
548
+ const hourly = hourlyMode
549
+ ? hourlyIntervals.map((interval) => ({
550
+ startMs: interval.startMs,
551
+ endMs: interval.endMs,
552
+ observedEndMs: Math.min(interval.endMs, effectiveEndMs),
553
+ dateString: localDateString(interval.startMs, timeZone),
554
+ totalTokens: 0,
555
+ inputTokens: 0,
556
+ outputTokens: 0,
557
+ cachedInputTokens: 0,
558
+ uncachedInputTokens: 0,
559
+ cacheRatePercent: null,
560
+ modelCalls: 0,
561
+ estimated: false,
562
+ observed: true,
563
+ unobserved: false,
564
+ partial: effectiveEndMs > interval.startMs && effectiveEndMs < interval.endMs,
565
+ models: new Map(),
566
+ }))
567
+ : null;
568
+
569
+ // One pass over the selected range classifies every event once; the bounded
570
+ // set feeds every panel so subtotals reconcile by construction. Callers that
571
+ // already built a shared range analysis may provide its split current and
572
+ // prior fragments so the image report uses the exact same allocations as
573
+ // the terminal and cache renderers.
574
+ const boundedEvents = [];
575
+ const priorStartMs = zonedMidnight(
576
+ shiftCalendarDate(bounds.startDateString, -rangeDays),
577
+ timeZone,
578
+ ).getTime();
579
+ const effectiveLocal = localDateTimeParts(effectiveEndMs, timeZone);
580
+ const priorEndMs = zonedDateTime(
581
+ shiftCalendarDate(effectiveLocal.dateString, -rangeDays),
582
+ timeZone,
583
+ effectiveLocal,
584
+ ).getTime();
585
+ let priorEquivalentTokens = 0;
586
+ let priorHasEvents = false;
587
+ let priorEquivalentEstimated = false;
588
+
589
+ const models = new Map();
590
+ let totalTokens = 0;
591
+ let inputTokens = 0;
592
+ let outputTokens = 0;
593
+ let cachedInputTokens = 0;
594
+ let fastTokens = 0;
595
+ let normalTokens = 0;
596
+ let unknownTokens = 0;
597
+ let detailedTokens = 0;
598
+ let detailedCalls = 0;
599
+ let modelCalls = 0;
600
+
601
+ const currentEventSource = Array.isArray(events)
602
+ ? events
603
+ : defaultRangeAnalysis?.currentEvents ?? snapshot.events ?? [];
604
+ const comparisonEventSource = Array.isArray(priorEvents)
605
+ ? priorEvents
606
+ : defaultRangeAnalysis?.priorEvents ?? snapshot.events ?? [];
607
+ const currentEvents = splitUsageBucketsAtBoundaries(
608
+ currentEventSource,
609
+ hourlyMode
610
+ ? [
611
+ startMs,
612
+ effectiveEndMs,
613
+ ...hourlyIntervals.flatMap(({ startMs: hourStartMs, endMs: hourEndMs }) => [
614
+ hourStartMs,
615
+ hourEndMs,
616
+ ]),
617
+ ]
618
+ : [startMs, effectiveEndMs],
619
+ );
620
+ const comparisonEvents = splitUsageBucketsAtBoundaries(
621
+ comparisonEventSource,
622
+ [priorStartMs, priorEndMs],
623
+ );
624
+ const rawTokenTotal = currentEvents.reduce((sum, event) => {
625
+ const tokens = Number(event?.totalTokens);
626
+ return Number.isFinite(tokens) && tokens > 0 ? sum + tokens : sum;
627
+ }, 0);
628
+ const tokenScale = Number.isFinite(rawTokenTotal) &&
629
+ rawTokenTotal > MAX_SAFE_TOKEN_COUNT
630
+ ? rawTokenTotal / MAX_SAFE_TOKEN_COUNT
631
+ : 1;
632
+ const scaledTokens = (value) => {
633
+ const tokens = Number(value);
634
+ return Number.isFinite(tokens) && tokens > 0 ? tokens / tokenScale : 0;
635
+ };
636
+ const addUsageToRow = (
637
+ row,
638
+ {
639
+ tokens,
640
+ input,
641
+ output,
642
+ cached,
643
+ callCount,
644
+ model,
645
+ serviceTierClass: tierClass,
646
+ estimated,
647
+ },
648
+ ) => {
649
+ if (!row) return;
650
+ row.totalTokens += tokens;
651
+ row.inputTokens += input;
652
+ row.outputTokens += output;
653
+ row.cachedInputTokens += cached;
654
+ row.modelCalls += callCount;
655
+ row.estimated ||= estimated;
656
+ const rowModel = row.models.get(model) ?? {
657
+ model,
658
+ totalTokens: 0,
659
+ normalTokens: 0,
660
+ fastTokens: 0,
661
+ unknownTokens: 0,
662
+ estimated: false,
663
+ };
664
+ rowModel.totalTokens += tokens;
665
+ if (tierClass === "fast") rowModel.fastTokens += tokens;
666
+ else if (tierClass === "normal") rowModel.normalTokens += tokens;
667
+ else rowModel.unknownTokens += tokens;
668
+ rowModel.estimated ||= estimated;
669
+ row.models.set(model, rowModel);
670
+ };
671
+ for (const event of comparisonEvents) {
672
+ const timestampMs = finiteTimestamp(event.timestamp);
673
+ if (timestampMs === null) continue;
674
+ const tokens = scaledTokens(event.totalTokens);
675
+ if (timestampMs >= priorStartMs && timestampMs < priorEndMs) {
676
+ priorEquivalentTokens += tokens;
677
+ priorHasEvents = true;
678
+ priorEquivalentEstimated ||=
679
+ tokens > 0 && event.rangeAllocationEstimated === true;
680
+ }
681
+ }
682
+ for (const event of currentEvents) {
683
+ const timestampMs = finiteTimestamp(event.timestamp);
684
+ if (timestampMs === null) continue;
685
+ if (timestampMs < startMs || timestampMs >= effectiveEndMs) continue;
686
+
687
+ const tokens = scaledTokens(event.totalTokens);
688
+ const model = trendModelLabel(event.model);
689
+ const tierClass = serviceTierClass(event.serviceTier);
690
+ const fast = tierClass === "fast";
691
+ const usable = usableComponents(event);
692
+ const input = usable ? scaledTokens(event.inputTokens) : 0;
693
+ const output = usable ? scaledTokens(event.outputTokens) : 0;
694
+ const cached = usable
695
+ ? Math.min(input, scaledTokens(event.cachedInputTokens))
696
+ : 0;
697
+ const estimated = tokens > 0 && event.rangeAllocationEstimated === true;
698
+ const callCount = usageCallCount(event);
699
+ const detailedCallCount = usable
700
+ ? usageDetailedCallCount(event)
701
+ : 0;
702
+
703
+ boundedEvents.push({ timestampMs, tokens, model, fast, event });
704
+ totalTokens += tokens;
705
+ modelCalls += callCount;
706
+ if (usable) {
707
+ detailedTokens += tokens;
708
+ detailedCalls += detailedCallCount;
709
+ }
710
+ inputTokens += input;
711
+ outputTokens += output;
712
+ cachedInputTokens += cached;
713
+ if (tierClass === "fast") fastTokens += tokens;
714
+ else if (tierClass === "normal") normalTokens += tokens;
715
+ else unknownTokens += tokens;
716
+
717
+ const modelRow = modelRowFor(models, model);
718
+ modelRow.totalTokens += tokens;
719
+ if (tierClass === "fast") modelRow.fastTokens += tokens;
720
+ else if (tierClass === "normal") modelRow.normalTokens += tokens;
721
+ else modelRow.unknownTokens += tokens;
722
+ modelRow.cacheInputTokens += input;
723
+ modelRow.cachedInputTokens += cached;
724
+ modelRow.estimated ||= estimated;
725
+
726
+ const rowValues = {
727
+ tokens,
728
+ input,
729
+ output,
730
+ cached,
731
+ callCount,
732
+ model,
733
+ serviceTierClass: tierClass,
734
+ estimated,
735
+ };
736
+ const dayRow = daily[dayIndexByString.get(localDateString(timestampMs, timeZone)) ?? -1];
737
+ addUsageToRow(dayRow, rowValues);
738
+ if (hourlyMode) {
739
+ addUsageToRow(
740
+ hourly[intervalIndexAt(hourlyIntervals, timestampMs)],
741
+ rowValues,
742
+ );
743
+ }
744
+ }
745
+
746
+ for (const row of daily) {
747
+ row.uncachedInputTokens = Math.max(0, row.inputTokens - row.cachedInputTokens);
748
+ row.cacheRatePercent =
749
+ row.inputTokens > 0
750
+ ? (row.cachedInputTokens / row.inputTokens) * 100
751
+ : null;
752
+ row.models = [...row.models.values()].sort(
753
+ (left, right) => right.totalTokens - left.totalTokens,
754
+ );
755
+ }
756
+ for (const row of hourly ?? []) {
757
+ row.uncachedInputTokens = Math.max(0, row.inputTokens - row.cachedInputTokens);
758
+ row.cacheRatePercent =
759
+ row.inputTokens > 0
760
+ ? (row.cachedInputTokens / row.inputTokens) * 100
761
+ : null;
762
+ row.models = [...row.models.values()].sort(
763
+ (left, right) => right.totalTokens - left.totalTokens,
764
+ );
765
+ }
766
+
767
+ const uncachedInputTokens = Math.max(0, inputTokens - cachedInputTokens);
768
+ const modelRows = [...models.values()]
769
+ .map((row) => ({
770
+ ...row,
771
+ sharePercent: totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0,
772
+ uncachedInputTokens: Math.max(
773
+ 0,
774
+ row.cacheInputTokens - row.cachedInputTokens,
775
+ ),
776
+ cacheRatePercent:
777
+ row.cacheInputTokens > 0
778
+ ? (row.cachedInputTokens / row.cacheInputTokens) * 100
779
+ : null,
780
+ }))
781
+ .sort(
782
+ (left, right) =>
783
+ right.totalTokens - left.totalTokens ||
784
+ left.model.localeCompare(right.model),
785
+ );
786
+
787
+ // Projects: prefer sanitized rows aggregated by the caller from the same
788
+ // bounded event set; otherwise group locally by raw project label.
789
+ let allProjectRows;
790
+ if (projectRows) {
791
+ allProjectRows = projectRows.map((row) => ({
792
+ project: row.project,
793
+ displayProject: row.displayProject ?? row.project,
794
+ totalTokens: Math.max(0, Number(row.totalTokens) || 0),
795
+ estimated: row.estimated === true,
796
+ }));
797
+ } else {
798
+ const projectTotals = new Map();
799
+ for (const { tokens, event } of boundedEvents) {
800
+ if (!(tokens > 0)) continue;
801
+ const project =
802
+ String(event.project ?? "")
803
+ .replace(/[\t\r\n]+/g, " ")
804
+ .replace(/\s+/g, " ")
805
+ .trim() || "Unlabelled activity";
806
+ const row = projectTotals.get(project) ?? {
807
+ totalTokens: 0,
808
+ estimated: false,
809
+ };
810
+ row.totalTokens += tokens;
811
+ row.estimated ||= event.rangeAllocationEstimated === true;
812
+ projectTotals.set(project, row);
813
+ }
814
+ allProjectRows = [...projectTotals.entries()].map(
815
+ ([project, row]) => ({
816
+ project,
817
+ displayProject: project,
818
+ totalTokens: row.totalTokens,
819
+ estimated: row.estimated,
820
+ }),
821
+ );
822
+ }
823
+ allProjectRows.sort(
824
+ (left, right) =>
825
+ right.totalTokens - left.totalTokens ||
826
+ left.project.localeCompare(right.project),
827
+ );
828
+ const topProjects = allProjectRows.slice(0, 4).map((row) => ({
829
+ ...row,
830
+ sharePercent: totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0,
831
+ }));
832
+ const remainderRows = allProjectRows.slice(4);
833
+ const remainderTokens = remainderRows.reduce(
834
+ (sum, row) => sum + row.totalTokens,
835
+ 0,
836
+ );
837
+ const projectRemainder = {
838
+ count: remainderRows.length,
839
+ totalTokens: remainderTokens,
840
+ sharePercent: totalTokens > 0 ? (remainderTokens / totalTokens) * 100 : 0,
841
+ estimated: remainderRows.some((row) => row.estimated),
842
+ };
843
+ const topFourProjectTokens = topProjects.reduce(
844
+ (sum, row) => sum + row.totalTokens,
845
+ 0,
846
+ );
847
+
848
+ const meter = buildMeter({
849
+ snapshot,
850
+ bounds,
851
+ effectiveEndMs,
852
+ sourceStatus,
853
+ events: boundedEvents,
854
+ });
855
+
856
+ const durationMs = effectiveEndMs - startMs;
857
+ const totalDeltaPercent =
858
+ priorHasEvents && priorEquivalentTokens > 0 && durationMs > 0
859
+ ? (totalTokens / priorEquivalentTokens - 1) * 100
860
+ : null;
861
+ const estimated = daily.some((row) => row.estimated);
862
+ const rawLegacySnapshotStatus = snapshot.coverage?.legacySnapshotStatus ??
863
+ snapshot.metadata?.durableLedger?.legacySnapshotStatus;
864
+ const legacySnapshotStatus = primitiveString(rawLegacySnapshotStatus);
865
+ const maximumEstimatedResolutionSeconds = boundedEvents.reduce(
866
+ (maximum, { tokens, event }) => {
867
+ if (!(tokens > 0) || event.rangeAllocationEstimated !== true) {
868
+ return maximum;
869
+ }
870
+ const resolutionSeconds = Number(event.resolutionSeconds);
871
+ return Number.isFinite(resolutionSeconds) && resolutionSeconds > 0
872
+ ? Math.max(maximum, resolutionSeconds)
873
+ : maximum;
874
+ },
875
+ 0,
876
+ );
877
+
878
+ const snapshotGeneratedAtMs = finiteTimestamp(snapshot.generatedAt);
879
+ const viewModel = {
880
+ meta: {
881
+ startMs,
882
+ requestedEndMs,
883
+ effectiveEndMs,
884
+ timeZone,
885
+ rangeDays,
886
+ granularity: hourlyMode ? "hour" : "day",
887
+ startDateString: bounds.startDateString,
888
+ endDateString: bounds.endDateString,
889
+ partialFinalDay,
890
+ observedThroughDateString:
891
+ lastObservedDayIndex === undefined ? null : lastObservedDateString,
892
+ reportThroughMs: effectiveEndMs,
893
+ meterObservedThroughMs: meter.observedThroughMs,
894
+ sourceStatus,
895
+ },
896
+ summary: {
897
+ totalTokens,
898
+ estimated,
899
+ priorEquivalentTokens: priorHasEvents ? priorEquivalentTokens : null,
900
+ priorEquivalentEstimated:
901
+ priorHasEvents && priorEquivalentEstimated,
902
+ totalDeltaPercent,
903
+ totalDeltaEstimated:
904
+ totalDeltaPercent !== null && (estimated || priorEquivalentEstimated),
905
+ inputTokens,
906
+ outputTokens,
907
+ cachedInputTokens,
908
+ uncachedInputTokens,
909
+ cacheRatePercent:
910
+ inputTokens > 0 ? (cachedInputTokens / inputTokens) * 100 : null,
911
+ fastTokens,
912
+ normalTokens,
913
+ unknownTokens,
914
+ fastEstimated: boundedEvents.some(
915
+ ({ fast, tokens, event }) =>
916
+ fast && tokens > 0 && event.rangeAllocationEstimated === true,
917
+ ),
918
+ fastSharePercent: totalTokens > 0 ? (fastTokens / totalTokens) * 100 : null,
919
+ unknownSharePercent:
920
+ totalTokens > 0 ? (unknownTokens / totalTokens) * 100 : null,
921
+ activeProjects: allProjectRows.length,
922
+ topFourProjectTokens,
923
+ topFourProjectSharePercent:
924
+ totalTokens > 0 ? (topFourProjectTokens / totalTokens) * 100 : null,
925
+ },
926
+ models: modelRows,
927
+ daily,
928
+ hourly,
929
+ meter,
930
+ projects: topProjects,
931
+ projectRemainder,
932
+ coverage: {
933
+ modelCalls,
934
+ detailedCalls,
935
+ detailedTokens,
936
+ componentCoveragePercent:
937
+ totalTokens > 0 ? (detailedTokens / totalTokens) * 100 : 100,
938
+ parseErrors: Math.max(0, Number(snapshot.coverage?.parseErrors) || 0),
939
+ invalidTokenRecords: Math.max(
940
+ 0,
941
+ Number(snapshot.coverage?.invalidTokenRecords) || 0,
942
+ ),
943
+ invalidQuotaRecords: Math.max(
944
+ 0,
945
+ Number(snapshot.coverage?.invalidQuotaRecords) || 0,
946
+ ),
947
+ sourceIncomplete: snapshot.coverage?.sourceIncomplete === true,
948
+ estimated,
949
+ estimatedBucketCount: daily.filter((row) => row.estimated).length,
950
+ maximumResolutionSeconds: maximumEstimatedResolutionSeconds || null,
951
+ legacySnapshotStatus,
952
+ },
953
+ provenance: {
954
+ localOnly: (snapshot.provenance?.kind ?? "codex-local-metadata") ===
955
+ "codex-local-metadata",
956
+ historyScope: historyScopeLabel(snapshot),
957
+ snapshotGeneratedAtMs,
958
+ rateCardAsOf: CODEX_CREDIT_RATE_CARD_AS_OF,
959
+ snapshotRateCardAsOf: snapshot.provenance?.rateCardAsOf ?? null,
960
+ },
961
+ };
962
+
963
+ validateReportViewModel(viewModel);
964
+ return viewModel;
965
+ }
966
+
967
+ function modelRowsFor(row) {
968
+ if (row?.models instanceof Map) return [...row.models.values()];
969
+ return Array.isArray(row?.models) ? row.models : [];
970
+ }
971
+
972
+ function assertServiceTierBuckets(label, row) {
973
+ for (const field of ["normalTokens", "fastTokens", "unknownTokens"]) {
974
+ if (!Number.isFinite(row[field]) || row[field] < 0) {
975
+ throw new Error(
976
+ `Report reconciliation failed: ${label} ${field} is invalid`,
977
+ );
978
+ }
979
+ }
980
+ assertReconciles(
981
+ `${label} service-tier buckets must sum to total usage`,
982
+ row.normalTokens + row.fastTokens + row.unknownTokens,
983
+ row.totalTokens,
984
+ );
985
+ }
986
+
987
+ // Core reconciliation invariants from the report specification. A failure is
988
+ // a calculation bug, never something to render around, so it throws with a
989
+ // descriptive message.
990
+ export function validateReportViewModel(viewModel) {
991
+ const { summary, daily, hourly, models, projects, projectRemainder } = viewModel;
992
+
993
+ assertReconciles(
994
+ "daily totals must sum to total usage",
995
+ daily.reduce((sum, row) => sum + row.totalTokens, 0),
996
+ summary.totalTokens,
997
+ );
998
+ assertReconciles(
999
+ "model totals must sum to total usage",
1000
+ models.reduce((sum, row) => sum + row.totalTokens, 0),
1001
+ summary.totalTokens,
1002
+ );
1003
+ assertReconciles(
1004
+ "model normal tokens must sum to overall normal tokens",
1005
+ models.reduce((sum, row) => sum + row.normalTokens, 0),
1006
+ summary.normalTokens,
1007
+ );
1008
+ assertReconciles(
1009
+ "model fast tokens must sum to overall fast tokens",
1010
+ models.reduce((sum, row) => sum + row.fastTokens, 0),
1011
+ summary.fastTokens,
1012
+ );
1013
+ assertReconciles(
1014
+ "model unknown tokens must sum to overall unknown tokens",
1015
+ models.reduce((sum, row) => sum + row.unknownTokens, 0),
1016
+ summary.unknownTokens,
1017
+ );
1018
+ assertServiceTierBuckets("summary", summary);
1019
+ for (const row of models) {
1020
+ assertServiceTierBuckets(`model ${row.model}`, row);
1021
+ }
1022
+ assertReconciles(
1023
+ "project totals plus remainder must sum to total usage",
1024
+ projects.reduce((sum, row) => sum + row.totalTokens, 0) +
1025
+ projectRemainder.totalTokens,
1026
+ summary.totalTokens,
1027
+ );
1028
+ assertReconciles(
1029
+ "daily input must sum to overall input",
1030
+ daily.reduce((sum, row) => sum + row.inputTokens, 0),
1031
+ summary.inputTokens,
1032
+ );
1033
+ if (Array.isArray(hourly)) {
1034
+ assertReconciles(
1035
+ "hourly totals must sum to total usage",
1036
+ hourly.reduce((sum, row) => sum + row.totalTokens, 0),
1037
+ summary.totalTokens,
1038
+ );
1039
+ assertReconciles(
1040
+ "hourly model totals must sum to total usage",
1041
+ hourly.reduce(
1042
+ (sum, row) => sum + [...row.models.values()]
1043
+ .reduce((rowTotal, model) => rowTotal + model.totalTokens, 0),
1044
+ 0,
1045
+ ),
1046
+ summary.totalTokens,
1047
+ );
1048
+ assertReconciles(
1049
+ "hourly input must sum to overall input",
1050
+ hourly.reduce((sum, row) => sum + row.inputTokens, 0),
1051
+ summary.inputTokens,
1052
+ );
1053
+ assertReconciles(
1054
+ "hourly cached input must sum to overall cached input",
1055
+ hourly.reduce((sum, row) => sum + row.cachedInputTokens, 0),
1056
+ summary.cachedInputTokens,
1057
+ );
1058
+ assertReconciles(
1059
+ "hourly fast tokens must sum to overall fast tokens",
1060
+ hourly.reduce(
1061
+ (sum, row) => sum + [...row.models.values()]
1062
+ .reduce((rowTotal, model) => rowTotal + model.fastTokens, 0),
1063
+ 0,
1064
+ ),
1065
+ summary.fastTokens,
1066
+ );
1067
+ assertReconciles(
1068
+ "hourly normal tokens must sum to overall normal tokens",
1069
+ hourly.reduce(
1070
+ (sum, row) => sum + modelRowsFor(row)
1071
+ .reduce((rowTotal, model) => rowTotal + model.normalTokens, 0),
1072
+ 0,
1073
+ ),
1074
+ summary.normalTokens,
1075
+ );
1076
+ assertReconciles(
1077
+ "hourly unknown tokens must sum to overall unknown tokens",
1078
+ hourly.reduce(
1079
+ (sum, row) => sum + modelRowsFor(row)
1080
+ .reduce((rowTotal, model) => rowTotal + model.unknownTokens, 0),
1081
+ 0,
1082
+ ),
1083
+ summary.unknownTokens,
1084
+ );
1085
+ for (const row of hourly) {
1086
+ for (const model of modelRowsFor(row)) {
1087
+ assertServiceTierBuckets(`hour ${row.startMs} model ${model.model}`, model);
1088
+ }
1089
+ }
1090
+ }
1091
+ assertReconciles(
1092
+ "model cache input must sum to overall input",
1093
+ models.reduce((sum, row) => sum + row.cacheInputTokens, 0),
1094
+ summary.inputTokens,
1095
+ );
1096
+ if (summary.cachedInputTokens > summary.inputTokens) {
1097
+ throw new Error(
1098
+ "Report reconciliation failed: cached input exceeds input",
1099
+ );
1100
+ }
1101
+ assertReconciles(
1102
+ "uncached input must equal input minus cached input",
1103
+ summary.uncachedInputTokens,
1104
+ summary.inputTokens - summary.cachedInputTokens,
1105
+ );
1106
+ if (summary.fastTokens > summary.totalTokens) {
1107
+ throw new Error(
1108
+ "Report reconciliation failed: fast-mode tokens exceed total usage",
1109
+ );
1110
+ }
1111
+ if (summary.normalTokens > summary.totalTokens) {
1112
+ throw new Error(
1113
+ "Report reconciliation failed: known normal tokens exceed total usage",
1114
+ );
1115
+ }
1116
+ if (summary.unknownTokens > summary.totalTokens) {
1117
+ throw new Error(
1118
+ "Report reconciliation failed: unknown-tier tokens exceed total usage",
1119
+ );
1120
+ }
1121
+ for (const row of daily) {
1122
+ if (row.inputTokens > row.totalTokens) {
1123
+ throw new Error(
1124
+ `Report reconciliation failed: ${row.dateString} input exceeds its total`,
1125
+ );
1126
+ }
1127
+ if (row.cachedInputTokens > row.inputTokens) {
1128
+ throw new Error(
1129
+ `Report reconciliation failed: ${row.dateString} cached input exceeds its input`,
1130
+ );
1131
+ }
1132
+ for (const model of modelRowsFor(row)) {
1133
+ assertServiceTierBuckets(`day ${row.dateString} model ${model.model}`, model);
1134
+ }
1135
+ }
1136
+ for (const row of models) {
1137
+ if (row.fastTokens > row.totalTokens) {
1138
+ throw new Error(
1139
+ `Report reconciliation failed: ${row.model} fast tokens exceed its total`,
1140
+ );
1141
+ }
1142
+ if (row.uncachedInputTokens < 0) {
1143
+ throw new Error(
1144
+ `Report reconciliation failed: ${row.model} uncached input is negative`,
1145
+ );
1146
+ }
1147
+ }
1148
+ if (
1149
+ summary.inputTokens > 0 &&
1150
+ !approxEqual(
1151
+ summary.cacheRatePercent,
1152
+ (summary.cachedInputTokens / summary.inputTokens) * 100,
1153
+ )
1154
+ ) {
1155
+ throw new Error(
1156
+ "Report reconciliation failed: cache rate must be input-token weighted",
1157
+ );
1158
+ }
1159
+ }