tledger 0.2.1 → 0.3.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.
@@ -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(first.getTime() - 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
 
@@ -283,7 +290,7 @@ export function normalizeQuotaTimeline(observations) {
283
290
  return normalized;
284
291
  }
285
292
 
286
- function eventCredits(event) {
293
+ export function eventCredits(event) {
287
294
  // Recompute from token components first so the current rate card applies;
288
295
  // snapshots can carry credits stored under an outdated card. Fast-mode
289
296
  // turns (service tier "priority") debit the limit at a higher rate.
@@ -416,10 +423,7 @@ function cloneAllocations(allocations) {
416
423
  function eventsInBounds(events, bounds) {
417
424
  const startMs = bounds.start.getTime();
418
425
  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
- });
426
+ return usageBucketsInRange({ events }, startMs, endMs);
423
427
  }
424
428
 
425
429
  function buildModelStats(displayedEvents, intervals, bounds) {
@@ -495,7 +499,7 @@ function buildModelStats(displayedEvents, intervals, bounds) {
495
499
  export function buildUsageTrend(snapshot = {}, bounds) {
496
500
  const startMs = bounds.start.getTime();
497
501
  const endMs = bounds.end.getTime();
498
- const displayedEvents = eventsInBounds(snapshot.events ?? [], bounds);
502
+ const displayedEvents = eventsInBounds(usageBuckets(snapshot), bounds);
499
503
  const observations = normalizeQuotaTimeline(
500
504
  weeklyQuotaObservations(snapshot),
501
505
  ).filter((observation) => observation.timestampMs < endMs);
@@ -514,7 +518,17 @@ export function buildUsageTrend(snapshot = {}, bounds) {
514
518
  };
515
519
  }
516
520
 
517
- const sortedEvents = (snapshot.events ?? [])
521
+ const sortedEvents = splitUsageBucketsAtBoundaries(
522
+ usageBuckets(snapshot),
523
+ [
524
+ startMs,
525
+ endMs,
526
+ ...observations.flatMap((observation) => [
527
+ observation.cycleStartMs,
528
+ observation.timestampMs,
529
+ ]),
530
+ ],
531
+ )
518
532
  .map((event) => ({ ...event, timestampMs: finiteTimestamp(event.timestamp) }))
519
533
  .filter((event) => event.timestampMs !== null && event.timestampMs < endMs)
520
534
  .sort((left, right) => left.timestampMs - right.timestampMs);
@@ -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);