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.
- package/README.md +150 -136
- package/bin/token-ledger-cache-image.mjs +1150 -0
- package/bin/token-ledger-rates.mjs +4 -1
- package/bin/token-ledger-terminal.mjs +89 -29
- package/bin/token-ledger-trend-image.mjs +1527 -584
- package/bin/token-ledger-trend-terminal.mjs +73 -28
- package/bin/token-ledger-trend.mjs +25 -11
- package/bin/token-ledger-tui.mjs +7 -3
- package/bin/token-ledger.mjs +303 -92
- package/docs/token-ledger-cli-week.png +0 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-importer.mjs +589 -279
- package/lib/token-ledger-snapshot.mjs +267 -0
- package/lib/token-ledger-usage.mjs +524 -0
- package/package.json +10 -5
|
@@ -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
|
-
|
|
75
|
+
const units = [
|
|
71
76
|
[1_000_000_000, "B"],
|
|
72
77
|
[1_000_000, "M"],
|
|
73
78
|
[1_000, "K"],
|
|
74
|
-
]
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
126
|
-
|
|
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
|
-
})
|
|
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(
|
|
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),
|
|
149
|
-
return new Date(first.getTime() - timeZoneOffsetMs(first,
|
|
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(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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(
|
|
190
|
-
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
96
|
-
|
|
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, -
|
|
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:
|
|
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
|
|
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
|
|
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 = (
|
|
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);
|
package/bin/token-ledger-tui.mjs
CHANGED
|
@@ -22,7 +22,11 @@ export function startInteractive(view) {
|
|
|
22
22
|
} = view;
|
|
23
23
|
const stdin = process.stdin;
|
|
24
24
|
const stdout = process.stdout;
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
+
setRawMode(true);
|
|
83
87
|
stdin.setEncoding("utf8");
|
|
84
88
|
stdin.resume();
|
|
85
89
|
stdin.on("data", onData);
|