tledger 0.1.4 → 0.2.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,745 @@
1
+ import {
2
+ creditsForUsage,
3
+ FAST_MODE_MULTIPLIER,
4
+ RATE_CARD_AS_OF,
5
+ } from "./token-ledger-rates.mjs";
6
+
7
+ const WEEK_MINUTES = 10_080;
8
+ const RESET_JITTER_SECONDS = 5 * 60;
9
+ // Meter observations more than this far apart get their burn spread across
10
+ // calendar days as an estimate rather than pinned to the observation day.
11
+ const LONG_GAP_MS = 36 * 60 * 60 * 1_000;
12
+
13
+ const MODEL_SORT_ORDER = new Map([
14
+ ["Luna", 0],
15
+ ["Sol", 1],
16
+ ["Terra", 2],
17
+ ["GPT-5.5", 3],
18
+ ["GPT-5.4", 4],
19
+ ["Daybreak", 5],
20
+ ["Auto review", 6],
21
+ ["Other", 7],
22
+ ["Unknown", 8],
23
+ ["Unattributed", 9],
24
+ ]);
25
+
26
+ function finiteTimestamp(value) {
27
+ const timestamp = new Date(value).getTime();
28
+ return Number.isFinite(timestamp) ? timestamp : null;
29
+ }
30
+
31
+ function dateStringFromParts(parts) {
32
+ return [parts.year, parts.month, parts.day]
33
+ .map((value, index) =>
34
+ index === 0 ? String(value) : String(value).padStart(2, "0"),
35
+ )
36
+ .join("-");
37
+ }
38
+
39
+ function shiftCalendarDate(dateString, amount) {
40
+ const [year, month, day] = dateString.split("-").map(Number);
41
+ const date = new Date(Date.UTC(year, month - 1, day + amount));
42
+ return dateStringFromParts({
43
+ year: date.getUTCFullYear(),
44
+ month: date.getUTCMonth() + 1,
45
+ day: date.getUTCDate(),
46
+ });
47
+ }
48
+
49
+ function offsetAt(instant, timeZone) {
50
+ const parts = new Intl.DateTimeFormat("en-US", {
51
+ timeZone,
52
+ timeZoneName: "longOffset",
53
+ }).formatToParts(instant);
54
+ const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
55
+ if (value === "GMT") return 0;
56
+ const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
57
+ if (!match) return 0;
58
+ const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
59
+ return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
60
+ }
61
+
62
+ function zonedMidnight(dateString, timeZone) {
63
+ const [year, month, day] = dateString.split("-").map(Number);
64
+ const utcGuess = Date.UTC(year, month - 1, day);
65
+ let instant = new Date(utcGuess - offsetAt(new Date(utcGuess), timeZone));
66
+ instant = new Date(utcGuess - offsetAt(instant, timeZone));
67
+ return instant;
68
+ }
69
+
70
+ function localDateString(timestampMs, timeZone) {
71
+ return new Intl.DateTimeFormat("en-CA", {
72
+ timeZone,
73
+ year: "numeric",
74
+ month: "2-digit",
75
+ day: "2-digit",
76
+ }).format(new Date(timestampMs));
77
+ }
78
+
79
+ function todayInTimeZone(timeZone) {
80
+ const parts = new Intl.DateTimeFormat("en-US", {
81
+ timeZone,
82
+ year: "numeric",
83
+ month: "2-digit",
84
+ day: "2-digit",
85
+ }).formatToParts(new Date());
86
+ const values = Object.fromEntries(
87
+ parts
88
+ .filter((part) => part.type !== "literal")
89
+ .map((part) => [part.type, Number(part.value)]),
90
+ );
91
+ return dateStringFromParts(values);
92
+ }
93
+
94
+ export function multiDayBounds(value, timeZone, rangeDays) {
95
+ if (![7, 14, 30].includes(Number(rangeDays))) {
96
+ throw new Error("Trend range must be 7, 14, or 30 days.");
97
+ }
98
+ let endDateString = value;
99
+ if (!endDateString || endDateString === "today") {
100
+ endDateString = todayInTimeZone(timeZone);
101
+ } else if (endDateString === "yesterday") {
102
+ endDateString = shiftCalendarDate(todayInTimeZone(timeZone), -1);
103
+ }
104
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(endDateString)) {
105
+ throw new Error("Trend end date must be YYYY-MM-DD, today, or yesterday.");
106
+ }
107
+ const [year, month, day] = endDateString.split("-").map(Number);
108
+ const check = new Date(Date.UTC(year, month - 1, day));
109
+ if (
110
+ check.getUTCFullYear() !== year ||
111
+ check.getUTCMonth() + 1 !== month ||
112
+ check.getUTCDate() !== day
113
+ ) {
114
+ throw new Error(`Invalid calendar day: ${endDateString}`);
115
+ }
116
+ const startDateString = shiftCalendarDate(endDateString, -Number(rangeDays) + 1);
117
+ return {
118
+ dateString: endDateString,
119
+ startDateString,
120
+ endDateString,
121
+ start: zonedMidnight(startDateString, timeZone),
122
+ end: zonedMidnight(shiftCalendarDate(endDateString, 1), timeZone),
123
+ timeZone,
124
+ rangeDays: Number(rangeDays),
125
+ };
126
+ }
127
+
128
+ function clampPercent(value) {
129
+ return Math.min(100, Math.max(0, Number(value) || 0));
130
+ }
131
+
132
+ export function trendModelLabel(value) {
133
+ const model = String(value || "unknown").trim().toLowerCase();
134
+ if (model.includes("luna")) return "Luna";
135
+ if (model.includes("sol")) return "Sol";
136
+ if (model.includes("terra")) return "Terra";
137
+ if (model.includes("daybreak")) return "Daybreak";
138
+ if (model.includes("auto-review")) return "Auto review";
139
+ if (model === "gpt-5.5" || model.startsWith("gpt-5.5-")) return "GPT-5.5";
140
+ if (model === "gpt-5.4" || model.startsWith("gpt-5.4-")) return "GPT-5.4";
141
+ if (model === "unknown" || !model) return "Unknown";
142
+ return "Other";
143
+ }
144
+
145
+ export function weeklyQuotaObservations(snapshot = {}) {
146
+ let observations = (snapshot.quotaObservations ?? [])
147
+ .map((observation) => ({
148
+ ...observation,
149
+ timestampMs: finiteTimestamp(observation.timestamp),
150
+ resetsAt: Number(observation.resetsAt),
151
+ usedPercent: Number(observation.usedPercent),
152
+ }))
153
+ .filter(
154
+ (observation) =>
155
+ Number(observation.windowMinutes) === WEEK_MINUTES &&
156
+ observation.timestampMs !== null &&
157
+ Number.isFinite(observation.resetsAt) &&
158
+ observation.resetsAt > 0 &&
159
+ Number.isFinite(observation.usedPercent),
160
+ )
161
+ .map((observation) => ({
162
+ ...observation,
163
+ usedPercent: clampPercent(observation.usedPercent),
164
+ }));
165
+
166
+ // Keep exactly one meter: the account-wide weekly limit. Legacy snapshots
167
+ // tag it with scope: "account"; current snapshots carry a limitKey per
168
+ // limit bucket, where the account-wide bucket has no limitName. Named
169
+ // buckets (per-model limit pools) are separate meters and must not be
170
+ // stitched into this line.
171
+ const accountScoped = observations.filter(
172
+ (observation) => observation.scope === "account",
173
+ );
174
+ if (accountScoped.length) {
175
+ observations = accountScoped;
176
+ } else if (observations.some((observation) => observation.limitKey)) {
177
+ const groups = new Map();
178
+ for (const observation of observations) {
179
+ const key = observation.limitKey ?? "anonymous";
180
+ const group = groups.get(key) ?? [];
181
+ group.push(observation);
182
+ groups.set(key, group);
183
+ }
184
+ const accountWide = [...groups.values()].filter((group) =>
185
+ group.every((observation) => !observation.limitName),
186
+ );
187
+ const pool = accountWide.length ? accountWide : [...groups.values()];
188
+ observations = pool.sort((left, right) => right.length - left.length)[0];
189
+ } else {
190
+ const accountWide = observations.filter(
191
+ (observation) => !observation.limitName,
192
+ );
193
+ if (accountWide.length) observations = accountWide;
194
+ }
195
+
196
+ observations.sort(
197
+ (left, right) =>
198
+ left.timestampMs - right.timestampMs || left.resetsAt - right.resetsAt,
199
+ );
200
+ return observations;
201
+ }
202
+
203
+ // The provider freezes resets_at for the lifetime of a limit window, so the
204
+ // reset timestamp is the window's identity. Cycles are therefore keyed by
205
+ // resets_at clusters instead of inferred from usage drops: refill events that
206
+ // start a fresh window days before the old one expires (limit restarts) and
207
+ // stale readings from sessions still reporting a superseded window would
208
+ // otherwise be fused into one line, producing meter drain that never happened.
209
+ export function normalizeQuotaTimeline(observations) {
210
+ if (!observations.length) return [];
211
+
212
+ const epochs = [];
213
+ for (const observation of observations) {
214
+ let epoch = epochs.find(
215
+ (candidate) =>
216
+ Math.abs(candidate.resetsAt - observation.resetsAt) <=
217
+ RESET_JITTER_SECONDS,
218
+ );
219
+ if (!epoch) {
220
+ epoch = { resetsAt: observation.resetsAt, observations: [] };
221
+ epochs.push(epoch);
222
+ }
223
+ epoch.resetsAt = Math.max(epoch.resetsAt, observation.resetsAt);
224
+ epoch.observations.push(observation);
225
+ }
226
+ for (const epoch of epochs) {
227
+ epoch.firstMs = epoch.observations[0].timestampMs;
228
+ epoch.lastMs = epoch.observations.at(-1).timestampMs;
229
+ }
230
+ epochs.sort(
231
+ (left, right) =>
232
+ left.firstMs - right.firstMs || left.resetsAt - right.resetsAt,
233
+ );
234
+
235
+ // A window with at most two readings sandwiched inside a longer-lived
236
+ // window's span is a transient branch (for example a single stale refresh),
237
+ // not a real refill.
238
+ const kept = epochs.filter((epoch) => {
239
+ if (epoch.observations.length > 2) return true;
240
+ return !epochs.some(
241
+ (other) =>
242
+ other !== epoch &&
243
+ other.observations.length > epoch.observations.length &&
244
+ other.firstMs < epoch.firstMs &&
245
+ other.lastMs > epoch.lastMs,
246
+ );
247
+ });
248
+
249
+ const normalized = [];
250
+ let previousEpoch = null;
251
+ for (const [cycle, epoch] of kept.entries()) {
252
+ const nextFirstMs = kept[cycle + 1]?.firstMs ?? Infinity;
253
+ const resetKind =
254
+ previousEpoch === null
255
+ ? "start"
256
+ : previousEpoch.resetsAt * 1_000 <=
257
+ epoch.firstMs + RESET_JITTER_SECONDS * 1_000
258
+ ? "weekly-expiry"
259
+ : "restart";
260
+ let usedPercent = null;
261
+ let emitted = false;
262
+ for (const observation of epoch.observations) {
263
+ // Once a newer window starts reporting, remaining readings of this
264
+ // window are stale echoes from long-lived sessions.
265
+ if (observation.timestampMs >= nextFirstMs) continue;
266
+ // Usage is cumulative inside a window; clamp display-rounding dips.
267
+ usedPercent =
268
+ usedPercent === null
269
+ ? observation.usedPercent
270
+ : Math.max(usedPercent, observation.usedPercent);
271
+ normalized.push({
272
+ ...observation,
273
+ cycle,
274
+ reset: !emitted && previousEpoch !== null,
275
+ resetKind,
276
+ cycleStartMs: (epoch.resetsAt - WEEK_MINUTES * 60) * 1_000,
277
+ normalizedUsedPercent: usedPercent,
278
+ });
279
+ emitted = true;
280
+ }
281
+ if (emitted) previousEpoch = epoch;
282
+ }
283
+ return normalized;
284
+ }
285
+
286
+ function eventCredits(event) {
287
+ // Recompute from token components first so the current rate card applies;
288
+ // snapshots can carry credits stored under an outdated card. Fast-mode
289
+ // turns (service tier "priority") debit the limit at a higher rate.
290
+ const multiplier =
291
+ event.serviceTier === "priority" ? FAST_MODE_MULTIPLIER : 1;
292
+ const computed = creditsForUsage(event.model, event);
293
+ if (Number.isFinite(computed) && computed >= 0) return computed * multiplier;
294
+ const stored = Number(event.rateCardCredits);
295
+ if (event.rateCardCredits !== null && event.rateCardCredits !== undefined) {
296
+ // Stored credits from current snapshots already include the fast-mode
297
+ // multiplier.
298
+ if (Number.isFinite(stored) && stored >= 0) return stored;
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function eventWeight(event, fallbackCreditsPerToken) {
304
+ const credits = eventCredits(event);
305
+ if (Number.isFinite(credits) && credits > 0) return credits;
306
+ const tokens = Math.max(0, Number(event.totalTokens) || 0);
307
+ return fallbackCreditsPerToken > 0 ? tokens * fallbackCreditsPerToken : tokens;
308
+ }
309
+
310
+ function allocateBurn(delta, events, timeZone) {
311
+ if (!(delta > 0)) return { contributions: new Map(), method: "none" };
312
+
313
+ let ratedCredits = 0;
314
+ let ratedTokens = 0;
315
+ let hasUnrated = false;
316
+ for (const event of events) {
317
+ const credits = eventCredits(event);
318
+ const tokens = Math.max(0, Number(event.totalTokens) || 0);
319
+ if (Number.isFinite(credits) && credits > 0) {
320
+ ratedCredits += credits;
321
+ ratedTokens += tokens;
322
+ } else if (tokens > 0) {
323
+ hasUnrated = true;
324
+ }
325
+ }
326
+
327
+ const fallbackCreditsPerToken =
328
+ ratedCredits > 0 && ratedTokens > 0 ? ratedCredits / ratedTokens : 0;
329
+ const weights = new Map();
330
+ const dayWeights = new Map();
331
+ let totalWeight = 0;
332
+ for (const event of events) {
333
+ const weight = eventWeight(event, fallbackCreditsPerToken);
334
+ if (!(weight > 0)) continue;
335
+ const model = trendModelLabel(event.model);
336
+ weights.set(model, (weights.get(model) ?? 0) + weight);
337
+ if (timeZone) {
338
+ const day = localDateString(event.timestampMs, timeZone);
339
+ dayWeights.set(day, (dayWeights.get(day) ?? 0) + weight);
340
+ }
341
+ totalWeight += weight;
342
+ }
343
+
344
+ if (!(totalWeight > 0)) {
345
+ return {
346
+ contributions: new Map([["Unattributed", delta]]),
347
+ method: "unattributed",
348
+ dayShares: new Map(),
349
+ };
350
+ }
351
+
352
+ const contributions = new Map();
353
+ for (const [model, weight] of weights) {
354
+ contributions.set(model, (delta * weight) / totalWeight);
355
+ }
356
+ const dayShares = new Map();
357
+ for (const [day, weight] of dayWeights) {
358
+ dayShares.set(day, weight / totalWeight);
359
+ }
360
+ return {
361
+ contributions,
362
+ method:
363
+ ratedCredits > 0
364
+ ? hasUnrated
365
+ ? "mixed"
366
+ : "rate-card"
367
+ : "tokens",
368
+ dayShares,
369
+ };
370
+ }
371
+
372
+ // Fractions of the span [startMs, endMs) falling on each local calendar day.
373
+ function durationDayShares(startMs, endMs, timeZone) {
374
+ if (!(endMs > startMs)) {
375
+ return new Map([[localDateString(endMs, timeZone), 1]]);
376
+ }
377
+ const shares = new Map();
378
+ let cursor = startMs;
379
+ while (cursor < endMs) {
380
+ const day = localDateString(cursor, timeZone);
381
+ const nextMidnightMs = zonedMidnight(
382
+ shiftCalendarDate(day, 1),
383
+ timeZone,
384
+ ).getTime();
385
+ const sliceEnd = Math.min(endMs, Math.max(nextMidnightMs, cursor + 1));
386
+ shares.set(day, (shares.get(day) ?? 0) + (sliceEnd - cursor));
387
+ cursor = sliceEnd;
388
+ }
389
+ const total = [...shares.values()].reduce((sum, value) => sum + value, 0);
390
+ return new Map([...shares].map(([day, value]) => [day, value / total]));
391
+ }
392
+
393
+ function tokenTotalsByModel(events) {
394
+ const totals = new Map();
395
+ for (const event of events) {
396
+ const tokens = Math.max(0, Number(event.totalTokens) || 0);
397
+ if (!(tokens > 0)) continue;
398
+ const model = trendModelLabel(event.model);
399
+ totals.set(model, (totals.get(model) ?? 0) + tokens);
400
+ }
401
+ return totals;
402
+ }
403
+
404
+ function modelSort(left, right) {
405
+ const leftOrder = MODEL_SORT_ORDER.get(left) ?? MODEL_SORT_ORDER.size;
406
+ const rightOrder = MODEL_SORT_ORDER.get(right) ?? MODEL_SORT_ORDER.size;
407
+ return leftOrder - rightOrder || left.localeCompare(right);
408
+ }
409
+
410
+ function cloneAllocations(allocations) {
411
+ return Object.fromEntries(
412
+ [...allocations.entries()].sort(([left], [right]) => modelSort(left, right)),
413
+ );
414
+ }
415
+
416
+ function eventsInBounds(events, bounds) {
417
+ const startMs = bounds.start.getTime();
418
+ const endMs = bounds.end.getTime();
419
+ return events.filter((event) => {
420
+ const timestampMs = finiteTimestamp(event.timestamp);
421
+ return timestampMs !== null && timestampMs >= startMs && timestampMs < endMs;
422
+ });
423
+ }
424
+
425
+ function buildModelStats(displayedEvents, intervals, bounds) {
426
+ const rows = new Map();
427
+ const rowFor = (model) => {
428
+ const row = rows.get(model) ?? {
429
+ model,
430
+ tokens: 0,
431
+ credits: 0,
432
+ ratedTokens: 0,
433
+ attributedTokens: 0,
434
+ burnPoints: 0,
435
+ efforts: new Map(),
436
+ };
437
+ rows.set(model, row);
438
+ return row;
439
+ };
440
+
441
+ for (const event of displayedEvents) {
442
+ const model = trendModelLabel(event.model);
443
+ const row = rowFor(model);
444
+ const tokens = Math.max(0, Number(event.totalTokens) || 0);
445
+ const credits = eventCredits(event);
446
+ row.tokens += tokens;
447
+ if (Number.isFinite(credits) && credits >= 0) {
448
+ row.credits += credits;
449
+ row.ratedTokens += tokens;
450
+ }
451
+ const effort = String(event.effort || "unknown").toLowerCase();
452
+ row.efforts.set(effort, (row.efforts.get(effort) ?? 0) + tokens);
453
+ }
454
+
455
+ const startMs = bounds.start.getTime();
456
+ const endMs = bounds.end.getTime();
457
+ for (const interval of intervals) {
458
+ if (interval.endMs < startMs || interval.endMs >= endMs) continue;
459
+ for (const [model, burnPoints] of interval.contributions) {
460
+ const row = rowFor(model);
461
+ row.burnPoints += burnPoints;
462
+ row.attributedTokens += interval.modelTokens.get(model) ?? 0;
463
+ }
464
+ }
465
+
466
+ return [...rows.values()]
467
+ .map((row) => {
468
+ const effortEntries = [...row.efforts.entries()].sort(
469
+ (left, right) => right[1] - left[1],
470
+ );
471
+ const dominantEffort = effortEntries[0]?.[0] ?? "unknown";
472
+ const dominantEffortShare =
473
+ row.tokens > 0 ? (effortEntries[0]?.[1] ?? 0) / row.tokens : 0;
474
+ return {
475
+ ...row,
476
+ efforts: Object.fromEntries(effortEntries),
477
+ dominantEffort,
478
+ dominantEffortShare,
479
+ tokensPerBurnPoint:
480
+ row.burnPoints > 0 && row.attributedTokens > 0
481
+ ? row.attributedTokens / row.burnPoints
482
+ : null,
483
+ ratedPercent:
484
+ row.tokens > 0 ? (row.ratedTokens / row.tokens) * 100 : null,
485
+ };
486
+ })
487
+ .sort(
488
+ (left, right) =>
489
+ right.burnPoints - left.burnPoints ||
490
+ right.tokens - left.tokens ||
491
+ modelSort(left.model, right.model),
492
+ );
493
+ }
494
+
495
+ export function buildUsageTrend(snapshot = {}, bounds) {
496
+ const startMs = bounds.start.getTime();
497
+ const endMs = bounds.end.getTime();
498
+ const displayedEvents = eventsInBounds(snapshot.events ?? [], bounds);
499
+ const observations = normalizeQuotaTimeline(
500
+ weeklyQuotaObservations(snapshot),
501
+ ).filter((observation) => observation.timestampMs < endMs);
502
+
503
+ if (!observations.length) {
504
+ return {
505
+ available: false,
506
+ points: [],
507
+ resets: [],
508
+ burnIntervals: [],
509
+ models: buildModelStats(displayedEvents, [], bounds),
510
+ sampleCount: 0,
511
+ allocationMethod: "unavailable",
512
+ observedThroughMs: null,
513
+ rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
514
+ };
515
+ }
516
+
517
+ const sortedEvents = (snapshot.events ?? [])
518
+ .map((event) => ({ ...event, timestampMs: finiteTimestamp(event.timestamp) }))
519
+ .filter((event) => event.timestampMs !== null && event.timestampMs < endMs)
520
+ .sort((left, right) => left.timestampMs - right.timestampMs);
521
+ const points = [];
522
+ const resets = [];
523
+ const intervals = [];
524
+ const methods = new Set();
525
+ let eventIndex = 0;
526
+ let activeCycle = null;
527
+ let previousObservationMs = null;
528
+ let previousUsedPercent = 0;
529
+ let intervalStartMs = null;
530
+ let pendingIntervalStartMs = null;
531
+ let pendingEvents = [];
532
+ let allocations = new Map();
533
+
534
+ for (const observation of observations) {
535
+ if (observation.cycle !== activeCycle) {
536
+ activeCycle = observation.cycle;
537
+ allocations = new Map();
538
+ previousUsedPercent = 0;
539
+ const inferredStartMs = observation.cycleStartMs;
540
+ intervalStartMs = Math.min(
541
+ observation.timestampMs,
542
+ Math.max(inferredStartMs, previousObservationMs ?? inferredStartMs),
543
+ );
544
+ pendingIntervalStartMs = intervalStartMs;
545
+ pendingEvents = [];
546
+ if (previousObservationMs !== null) {
547
+ resets.push({
548
+ timestampMs: inferredStartMs,
549
+ observedAtMs: observation.timestampMs,
550
+ cycle: observation.cycle,
551
+ kind: observation.resetKind ?? "restart",
552
+ });
553
+ }
554
+ }
555
+
556
+ while (
557
+ eventIndex < sortedEvents.length &&
558
+ sortedEvents[eventIndex].timestampMs <= intervalStartMs
559
+ ) {
560
+ eventIndex += 1;
561
+ }
562
+ const intervalEvents = [];
563
+ while (
564
+ eventIndex < sortedEvents.length &&
565
+ sortedEvents[eventIndex].timestampMs <= observation.timestampMs
566
+ ) {
567
+ intervalEvents.push(sortedEvents[eventIndex]);
568
+ eventIndex += 1;
569
+ }
570
+ pendingEvents.push(...intervalEvents);
571
+
572
+ const delta = Math.max(
573
+ 0,
574
+ observation.normalizedUsedPercent - previousUsedPercent,
575
+ );
576
+ const allocation = allocateBurn(delta, pendingEvents, bounds.timeZone);
577
+ if (allocation.method !== "none" && observation.timestampMs >= startMs) {
578
+ methods.add(allocation.method);
579
+ }
580
+ for (const [model, burnPoints] of allocation.contributions) {
581
+ allocations.set(model, (allocations.get(model) ?? 0) + burnPoints);
582
+ }
583
+ if (delta > 0) {
584
+ intervals.push({
585
+ startMs: pendingIntervalStartMs,
586
+ endMs: observation.timestampMs,
587
+ cycle: observation.cycle,
588
+ contributions: allocation.contributions,
589
+ modelTokens: tokenTotalsByModel(pendingEvents),
590
+ method: allocation.method,
591
+ dayShares: allocation.dayShares?.size
592
+ ? allocation.dayShares
593
+ : durationDayShares(
594
+ pendingIntervalStartMs,
595
+ observation.timestampMs,
596
+ bounds.timeZone,
597
+ ),
598
+ spansLongGap:
599
+ observation.timestampMs - pendingIntervalStartMs > LONG_GAP_MS,
600
+ });
601
+ pendingEvents = [];
602
+ pendingIntervalStartMs = observation.timestampMs;
603
+ }
604
+ points.push({
605
+ timestampMs: observation.timestampMs,
606
+ cycle: observation.cycle,
607
+ usedPercent: observation.normalizedUsedPercent,
608
+ remainingPercent: 100 - observation.normalizedUsedPercent,
609
+ allocations: cloneAllocations(allocations),
610
+ observed: true,
611
+ });
612
+ previousUsedPercent = observation.normalizedUsedPercent;
613
+ previousObservationMs = observation.timestampMs;
614
+ intervalStartMs = observation.timestampMs;
615
+ }
616
+
617
+ const displayPoints = [];
618
+ const beforeStart = [...points]
619
+ .reverse()
620
+ .find((point) => point.timestampMs <= startMs);
621
+ if (beforeStart) {
622
+ displayPoints.push({
623
+ ...beforeStart,
624
+ timestampMs: startMs,
625
+ observed: beforeStart.timestampMs === startMs,
626
+ carried: beforeStart.timestampMs !== startMs,
627
+ });
628
+ }
629
+ displayPoints.push(
630
+ ...points.filter(
631
+ (point) => point.timestampMs > startMs && point.timestampMs < endMs,
632
+ ),
633
+ );
634
+ const lastPoint = displayPoints.at(-1);
635
+ if (lastPoint) {
636
+ displayPoints.push({
637
+ ...lastPoint,
638
+ timestampMs: endMs,
639
+ observed: false,
640
+ carried: true,
641
+ });
642
+ }
643
+
644
+ const hasUnattributed = methods.has("unattributed");
645
+ let allocationMethod = "unavailable";
646
+ if (
647
+ methods.has("mixed") ||
648
+ (methods.has("rate-card") && methods.has("tokens"))
649
+ ) {
650
+ allocationMethod = "mixed rate-card and token weights";
651
+ } else if (methods.has("rate-card")) {
652
+ allocationMethod = "rate-card weights";
653
+ } else if (methods.has("tokens")) {
654
+ allocationMethod = "token weights";
655
+ } else if (hasUnattributed) {
656
+ allocationMethod = "unattributed burn";
657
+ }
658
+ if (hasUnattributed && allocationMethod !== "unattributed burn") {
659
+ allocationMethod += " with unattributed gaps";
660
+ }
661
+
662
+ const burnIntervals = intervals
663
+ .filter((interval) => interval.endMs >= startMs && interval.endMs < endMs)
664
+ .map((interval) => ({
665
+ startMs: Math.max(startMs, interval.startMs),
666
+ endMs: interval.endMs,
667
+ cycle: interval.cycle,
668
+ contributions: cloneAllocations(interval.contributions),
669
+ modelTokens: Object.fromEntries(interval.modelTokens),
670
+ method: interval.method,
671
+ dayShares: Object.fromEntries(interval.dayShares),
672
+ spansLongGap: interval.spansLongGap,
673
+ }));
674
+
675
+ return {
676
+ available: displayPoints.length > 0,
677
+ points: displayPoints,
678
+ resets: resets.filter(
679
+ (reset) => reset.timestampMs >= startMs && reset.timestampMs < endMs,
680
+ ),
681
+ burnIntervals,
682
+ models: buildModelStats(displayedEvents, intervals, bounds),
683
+ sampleCount: points.filter(
684
+ (point) => point.timestampMs >= startMs && point.timestampMs < endMs,
685
+ ).length,
686
+ allocationMethod,
687
+ observedThroughMs:
688
+ [...points].reverse().find((point) => point.timestampMs < endMs)
689
+ ?.timestampMs ?? null,
690
+ rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
691
+ };
692
+ }
693
+
694
+ // Bin observed meter drain into calendar-day (or multi-day) columns in the
695
+ // same percent unit as the meter line. Daily totals are the meter's own
696
+ // observed drops; only the per-model split within a drop and the day
697
+ // placement across long observation gaps are estimated.
698
+ export function buildBurnDayBins(trend, bounds, { days, binSize = 1 } = {}) {
699
+ const rangeDays = Number(days) || bounds.rangeDays || 7;
700
+ const binCount = Math.ceil(rangeDays / binSize);
701
+ const bins = Array.from({ length: binCount }, (_, index) => ({
702
+ startDateString: shiftCalendarDate(bounds.startDateString, index * binSize),
703
+ endDateString: shiftCalendarDate(
704
+ bounds.startDateString,
705
+ Math.min(rangeDays, (index + 1) * binSize),
706
+ ),
707
+ values: new Map(),
708
+ totalPercent: 0,
709
+ approximate: false,
710
+ }));
711
+ const dayIndexByString = new Map(
712
+ Array.from({ length: rangeDays }, (_, index) => [
713
+ shiftCalendarDate(bounds.startDateString, index),
714
+ index,
715
+ ]),
716
+ );
717
+
718
+ for (const interval of trend?.burnIntervals ?? []) {
719
+ for (const [day, fraction] of Object.entries(interval.dayShares ?? {})) {
720
+ const dayIndex = dayIndexByString.get(day);
721
+ if (dayIndex === undefined) continue;
722
+ const bin = bins[Math.floor(dayIndex / binSize)];
723
+ if (!bin) continue;
724
+ if (interval.spansLongGap) bin.approximate = true;
725
+ for (const [model, burnPoints] of Object.entries(
726
+ interval.contributions ?? {},
727
+ )) {
728
+ const share = burnPoints * fraction;
729
+ if (!(share > 0)) continue;
730
+ bin.values.set(model, (bin.values.get(model) ?? 0) + share);
731
+ bin.totalPercent += share;
732
+ }
733
+ }
734
+ }
735
+
736
+ const totals = new Map();
737
+ let totalPercent = 0;
738
+ for (const bin of bins) {
739
+ for (const [model, value] of bin.values) {
740
+ totals.set(model, (totals.get(model) ?? 0) + value);
741
+ totalPercent += value;
742
+ }
743
+ }
744
+ return { bins, totals, totalPercent, binSize, binCount };
745
+ }