tledger 0.2.1 → 0.3.1

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.
@@ -1,4 +1,9 @@
1
1
  import { buildBurnDayBins, buildUsageTrend, trendModelLabel } from "./token-ledger-trend.mjs";
2
+ import {
3
+ splitUsageBucketsAtBoundaries,
4
+ usageBuckets,
5
+ usageCallCount,
6
+ } from "../lib/token-ledger-usage.mjs";
2
7
 
3
8
  const RESET = "\u001b[0m";
4
9
  const PRIMARY_STYLE = [38, 2, 255, 255, 255];
@@ -67,16 +72,23 @@ function fit(value, width, alignment = "left") {
67
72
  function compact(value) {
68
73
  if (!Number.isFinite(value)) return "—";
69
74
  const absolute = Math.abs(value);
70
- for (const [divisor, suffix] of [
75
+ const units = [
71
76
  [1_000_000_000, "B"],
72
77
  [1_000_000, "M"],
73
78
  [1_000, "K"],
74
- ]) {
75
- if (absolute >= divisor) {
76
- const scaled = value / divisor;
77
- const precision = scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2;
78
- return `${scaled.toFixed(precision)}${suffix}`;
79
+ ];
80
+ for (let index = 0; index < units.length; index += 1) {
81
+ const [divisor, suffix] = units[index];
82
+ if (absolute < divisor) continue;
83
+ const scaled = value / divisor;
84
+ const magnitude = Math.abs(scaled);
85
+ const precision = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : 2;
86
+ // Values that round to 1000 of a unit belong to the next unit up
87
+ // (999,999 → 1.00M, not 1000K).
88
+ if (index > 0 && Number(magnitude.toFixed(precision)) >= 1_000) {
89
+ return compact(Math.sign(value) * divisor * 1_000);
79
90
  }
91
+ return `${scaled.toFixed(precision)}${suffix}`;
80
92
  }
81
93
  return Math.round(value).toLocaleString("en-US");
82
94
  }
@@ -122,8 +134,8 @@ function shiftCalendarDate(dateString, amount) {
122
134
  );
123
135
  }
124
136
 
125
- function timeZoneOffsetMs(instant, timeZone) {
126
- const parts = new Intl.DateTimeFormat("en-US", {
137
+ function timeZoneFormatter(timeZone) {
138
+ return new Intl.DateTimeFormat("en-US", {
127
139
  timeZone,
128
140
  timeZoneName: "longOffset",
129
141
  year: "numeric",
@@ -133,7 +145,11 @@ function timeZoneOffsetMs(instant, timeZone) {
133
145
  minute: "2-digit",
134
146
  second: "2-digit",
135
147
  hourCycle: "h23",
136
- }).formatToParts(instant);
148
+ });
149
+ }
150
+
151
+ function timeZoneOffsetMs(instant, formatter) {
152
+ const parts = formatter.formatToParts(instant);
137
153
  const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
138
154
  if (value === "GMT") return 0;
139
155
  const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
@@ -142,20 +158,23 @@ function timeZoneOffsetMs(instant, timeZone) {
142
158
  return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
143
159
  }
144
160
 
145
- function zonedMidnight(dateString, timeZone) {
161
+ function zonedMidnight(
162
+ dateString,
163
+ timeZone,
164
+ formatter = timeZoneFormatter(timeZone),
165
+ ) {
146
166
  const [year, month, day] = dateParts(dateString);
147
167
  const utcGuess = Date.UTC(year, month - 1, day);
148
- const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), timeZone));
149
- return new Date(first.getTime() - timeZoneOffsetMs(first, timeZone));
168
+ const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), formatter));
169
+ return new Date(utcGuess - timeZoneOffsetMs(first, formatter));
150
170
  }
151
171
 
152
- function localDateString(timestamp, timeZone) {
153
- const parts = new Intl.DateTimeFormat("en-US", {
154
- timeZone,
155
- year: "numeric",
156
- month: "2-digit",
157
- day: "2-digit",
158
- }).formatToParts(new Date(timestamp));
172
+ function localDateString(
173
+ timestamp,
174
+ timeZone,
175
+ formatter = timeZoneFormatter(timeZone),
176
+ ) {
177
+ const parts = formatter.formatToParts(new Date(timestamp));
159
178
  const values = Object.fromEntries(
160
179
  parts
161
180
  .filter((part) => part.type !== "literal")
@@ -175,9 +194,19 @@ function localDateLabel(dateString, timeZone) {
175
194
  .toUpperCase();
176
195
  }
177
196
 
178
- function chooseBinSize(days, width) {
179
- if (days <= 14) return 1;
180
- return width >= 120 ? 2 : 3;
197
+ export function chooseBinSize(days, width, { minBinWidth = 1, preferDaily = false } = {}) {
198
+ const rangeDays = Number(days);
199
+ const plotWidth = Math.max(1, Number(width) || 1);
200
+ const minimumBinWidth = Math.max(1, Number(minBinWidth) || 1);
201
+ const maxBinCount = Math.max(1, Math.floor(plotWidth / minimumBinWidth));
202
+ const preferredBinSize = preferDaily
203
+ ? 1
204
+ : rangeDays <= 14
205
+ ? 1
206
+ : plotWidth >= 120
207
+ ? 2
208
+ : 3;
209
+ return Math.max(preferredBinSize, Math.ceil(rangeDays / maxBinCount));
181
210
  }
182
211
 
183
212
  function sortedModelEntries(values) {
@@ -186,8 +215,14 @@ function sortedModelEntries(values) {
186
215
  .sort(([left], [right]) => modelSort(left, right));
187
216
  }
188
217
 
189
- export function buildActualTokenBins(snapshot, bounds, days, width) {
190
- const binSize = chooseBinSize(days, width);
218
+ export function buildActualTokenBins(
219
+ snapshot,
220
+ bounds,
221
+ days,
222
+ width,
223
+ { binSize: forcedBinSize, minBinWidth, preferDaily } = {},
224
+ ) {
225
+ const binSize = forcedBinSize ?? chooseBinSize(days, width, { minBinWidth, preferDaily });
191
226
  const binCount = Math.ceil(days / binSize);
192
227
  const bins = Array.from({ length: binCount }, (_, index) => ({
193
228
  startDateString: shiftCalendarDate(bounds.startDateString, index * binSize),
@@ -207,18 +242,28 @@ export function buildActualTokenBins(snapshot, bounds, days, width) {
207
242
  index,
208
243
  ]),
209
244
  );
210
-
211
- for (const event of snapshot.events ?? []) {
245
+ const dateFormatter = timeZoneFormatter(bounds.timeZone);
246
+ const binBoundaries = [
247
+ bins[0]?.startDateString,
248
+ ...bins.map((bin) => bin.endDateString),
249
+ ]
250
+ .filter(Boolean)
251
+ .map((dateString) =>
252
+ zonedMidnight(dateString, bounds.timeZone, dateFormatter).getTime());
253
+ for (const event of splitUsageBucketsAtBoundaries(
254
+ usageBuckets(snapshot),
255
+ binBoundaries,
256
+ )) {
212
257
  const timestamp = new Date(event.timestamp).getTime();
213
258
  if (!Number.isFinite(timestamp)) continue;
214
- const dateString = localDateString(timestamp, bounds.timeZone);
259
+ const dateString = localDateString(timestamp, bounds.timeZone, dateFormatter);
215
260
  const dayIndex = dateIndexByString.get(dateString);
216
261
  if (dayIndex === undefined || dayIndex >= days) continue;
217
262
  const bin = bins[Math.floor(dayIndex / binSize)];
218
263
  const tokens = Math.max(0, Number(event.totalTokens) || 0);
219
264
  const model = trendModelLabel(event.model);
220
265
  bin.totalTokens += tokens;
221
- bin.calls += 1;
266
+ bin.calls += usageCallCount(event);
222
267
  bin.values.set(model, (bin.values.get(model) ?? 0) + tokens);
223
268
  if (event.serviceTier === "priority") {
224
269
  bin.fastValues.set(model, (bin.fastValues.get(model) ?? 0) + tokens);
@@ -3,9 +3,15 @@ import {
3
3
  FAST_MODE_MULTIPLIER,
4
4
  RATE_CARD_AS_OF,
5
5
  } from "./token-ledger-rates.mjs";
6
+ import {
7
+ splitUsageBucketsAtBoundaries,
8
+ usageBuckets,
9
+ usageBucketsInRange,
10
+ } from "../lib/token-ledger-usage.mjs";
6
11
 
7
12
  const WEEK_MINUTES = 10_080;
8
13
  const RESET_JITTER_SECONDS = 5 * 60;
14
+ const MAX_TREND_DAYS = 3_650;
9
15
  // Meter observations more than this far apart get their burn spread across
10
16
  // calendar days as an estimate rather than pinned to the observation day.
11
17
  const LONG_GAP_MS = 36 * 60 * 60 * 1_000;
@@ -92,8 +98,9 @@ function todayInTimeZone(timeZone) {
92
98
  }
93
99
 
94
100
  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.");
101
+ const days = Number(rangeDays);
102
+ if (!Number.isSafeInteger(days) || days < 1 || days > MAX_TREND_DAYS) {
103
+ throw new Error(`Trend range must be between 1 and ${MAX_TREND_DAYS} days.`);
97
104
  }
98
105
  let endDateString = value;
99
106
  if (!endDateString || endDateString === "today") {
@@ -113,7 +120,7 @@ export function multiDayBounds(value, timeZone, rangeDays) {
113
120
  ) {
114
121
  throw new Error(`Invalid calendar day: ${endDateString}`);
115
122
  }
116
- const startDateString = shiftCalendarDate(endDateString, -Number(rangeDays) + 1);
123
+ const startDateString = shiftCalendarDate(endDateString, -days + 1);
117
124
  return {
118
125
  dateString: endDateString,
119
126
  startDateString,
@@ -121,7 +128,7 @@ export function multiDayBounds(value, timeZone, rangeDays) {
121
128
  start: zonedMidnight(startDateString, timeZone),
122
129
  end: zonedMidnight(shiftCalendarDate(endDateString, 1), timeZone),
123
130
  timeZone,
124
- rangeDays: Number(rangeDays),
131
+ rangeDays: days,
125
132
  };
126
133
  }
127
134
 
@@ -144,12 +151,20 @@ export function trendModelLabel(value) {
144
151
 
145
152
  export function weeklyQuotaObservations(snapshot = {}) {
146
153
  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
- }))
154
+ .map((observation) => {
155
+ const timestampMs = finiteTimestamp(observation.timestamp);
156
+ const lastSeenAtMs = finiteTimestamp(observation.lastSeenAt);
157
+ return {
158
+ ...observation,
159
+ timestampMs,
160
+ observedThroughMs:
161
+ timestampMs === null
162
+ ? lastSeenAtMs
163
+ : Math.max(timestampMs, lastSeenAtMs ?? timestampMs),
164
+ resetsAt: Number(observation.resetsAt),
165
+ usedPercent: Number(observation.usedPercent),
166
+ };
167
+ })
153
168
  .filter(
154
169
  (observation) =>
155
170
  Number(observation.windowMinutes) === WEEK_MINUTES &&
@@ -270,6 +285,12 @@ export function normalizeQuotaTimeline(observations) {
270
285
  : Math.max(usedPercent, observation.usedPercent);
271
286
  normalized.push({
272
287
  ...observation,
288
+ observedThroughMs: Math.min(
289
+ Number.isFinite(observation.observedThroughMs)
290
+ ? observation.observedThroughMs
291
+ : observation.timestampMs,
292
+ nextFirstMs,
293
+ ),
273
294
  cycle,
274
295
  reset: !emitted && previousEpoch !== null,
275
296
  resetKind,
@@ -283,7 +304,7 @@ export function normalizeQuotaTimeline(observations) {
283
304
  return normalized;
284
305
  }
285
306
 
286
- function eventCredits(event) {
307
+ export function eventCredits(event) {
287
308
  // Recompute from token components first so the current rate card applies;
288
309
  // snapshots can carry credits stored under an outdated card. Fast-mode
289
310
  // turns (service tier "priority") debit the limit at a higher rate.
@@ -416,10 +437,7 @@ function cloneAllocations(allocations) {
416
437
  function eventsInBounds(events, bounds) {
417
438
  const startMs = bounds.start.getTime();
418
439
  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
- });
440
+ return usageBucketsInRange({ events }, startMs, endMs);
423
441
  }
424
442
 
425
443
  function buildModelStats(displayedEvents, intervals, bounds) {
@@ -495,7 +513,7 @@ function buildModelStats(displayedEvents, intervals, bounds) {
495
513
  export function buildUsageTrend(snapshot = {}, bounds) {
496
514
  const startMs = bounds.start.getTime();
497
515
  const endMs = bounds.end.getTime();
498
- const displayedEvents = eventsInBounds(snapshot.events ?? [], bounds);
516
+ const displayedEvents = eventsInBounds(usageBuckets(snapshot), bounds);
499
517
  const observations = normalizeQuotaTimeline(
500
518
  weeklyQuotaObservations(snapshot),
501
519
  ).filter((observation) => observation.timestampMs < endMs);
@@ -514,7 +532,17 @@ export function buildUsageTrend(snapshot = {}, bounds) {
514
532
  };
515
533
  }
516
534
 
517
- const sortedEvents = (snapshot.events ?? [])
535
+ const sortedEvents = splitUsageBucketsAtBoundaries(
536
+ usageBuckets(snapshot),
537
+ [
538
+ startMs,
539
+ endMs,
540
+ ...observations.flatMap((observation) => [
541
+ observation.cycleStartMs,
542
+ observation.timestampMs,
543
+ ]),
544
+ ],
545
+ )
518
546
  .map((event) => ({ ...event, timestampMs: finiteTimestamp(event.timestamp) }))
519
547
  .filter((event) => event.timestampMs !== null && event.timestampMs < endMs)
520
548
  .sort((left, right) => left.timestampMs - right.timestampMs);
@@ -603,6 +631,7 @@ export function buildUsageTrend(snapshot = {}, bounds) {
603
631
  }
604
632
  points.push({
605
633
  timestampMs: observation.timestampMs,
634
+ observedThroughMs: observation.observedThroughMs,
606
635
  cycle: observation.cycle,
607
636
  usedPercent: observation.normalizedUsedPercent,
608
637
  remainingPercent: 100 - observation.normalizedUsedPercent,
@@ -631,15 +660,46 @@ export function buildUsageTrend(snapshot = {}, bounds) {
631
660
  (point) => point.timestampMs > startMs && point.timestampMs < endMs,
632
661
  ),
633
662
  );
634
- const lastPoint = displayPoints.at(-1);
635
- if (lastPoint) {
636
- displayPoints.push({
637
- ...lastPoint,
638
- timestampMs: endMs,
639
- observed: false,
640
- carried: true,
641
- });
663
+
664
+ // Repeated equal meter readings are compacted into an observed span. Extend
665
+ // each displayed cycle only through its last real sample; never synthesize a
666
+ // flat line through the unobserved remainder of the report range.
667
+ const sourcePointsByCycle = new Map();
668
+ for (const point of points) {
669
+ const cyclePoints = sourcePointsByCycle.get(point.cycle) ?? [];
670
+ cyclePoints.push(point);
671
+ sourcePointsByCycle.set(point.cycle, cyclePoints);
642
672
  }
673
+ const displayedCycles = new Set(displayPoints.map((point) => point.cycle));
674
+ for (const cycle of displayedCycles) {
675
+ const cyclePoints = sourcePointsByCycle.get(cycle) ?? [];
676
+ const displayedCyclePoints = displayPoints.filter(
677
+ (point) => point.cycle === cycle,
678
+ );
679
+ const lastPoint = displayedCyclePoints.at(-1);
680
+ if (!lastPoint || !cyclePoints.length) continue;
681
+ const observedThroughMs = Math.max(
682
+ ...cyclePoints.map((point) => point.observedThroughMs),
683
+ );
684
+ const nextResetMs = resets.find((reset) => reset.cycle === cycle + 1)
685
+ ?.timestampMs;
686
+ const crossesNextReset = Number.isFinite(nextResetMs) &&
687
+ observedThroughMs >= nextResetMs;
688
+ const endpointMs = Math.min(observedThroughMs, endMs);
689
+ if (!crossesNextReset && endpointMs > lastPoint.timestampMs) {
690
+ displayPoints.push({
691
+ ...lastPoint,
692
+ timestampMs: endpointMs,
693
+ observedThroughMs: endpointMs,
694
+ observed: true,
695
+ carried: false,
696
+ confirmation: true,
697
+ });
698
+ }
699
+ }
700
+ displayPoints.sort(
701
+ (left, right) => left.timestampMs - right.timestampMs || left.cycle - right.cycle,
702
+ );
643
703
 
644
704
  const hasUnattributed = methods.has("unattributed");
645
705
  let allocationMethod = "unavailable";
@@ -685,7 +745,7 @@ export function buildUsageTrend(snapshot = {}, bounds) {
685
745
  ).length,
686
746
  allocationMethod,
687
747
  observedThroughMs:
688
- [...points].reverse().find((point) => point.timestampMs < endMs)
748
+ [...displayPoints].reverse().find((point) => point.observed)
689
749
  ?.timestampMs ?? null,
690
750
  rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
691
751
  };
@@ -22,7 +22,11 @@ export function startInteractive(view) {
22
22
  } = view;
23
23
  const stdin = process.stdin;
24
24
  const stdout = process.stdout;
25
- if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
25
+ // Interactive mode needs a raw-mode-capable terminal on both ends. Capture
26
+ // the capability once here; the handlers below rely on it unconditionally.
27
+ const setRawMode =
28
+ stdin.isTTY && stdout.isTTY ? stdin.setRawMode?.bind(stdin) : null;
29
+ if (!setRawMode) {
26
30
  throw new Error("Interactive mode requires a terminal. Use --static when redirecting output.");
27
31
  }
28
32
 
@@ -53,7 +57,7 @@ export function startInteractive(view) {
53
57
  stdin.off("data", onData);
54
58
  stdout.off("resize", draw);
55
59
  process.off("SIGINT", onSignal);
56
- if (stdin.isTTY) stdin.setRawMode(false);
60
+ setRawMode(false);
57
61
  stdin.pause();
58
62
  stdout.write(`${RESET}${SHOW_CURSOR}${EXIT_ALT_SCREEN}`);
59
63
  if (error) reject(error);
@@ -79,7 +83,7 @@ export function startInteractive(view) {
79
83
  }
80
84
  };
81
85
 
82
- stdin.setRawMode(true);
86
+ setRawMode(true);
83
87
  stdin.setEncoding("utf8");
84
88
  stdin.resume();
85
89
  stdin.on("data", onData);