tledger 0.1.3 → 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.
- package/README.md +121 -67
- package/bin/token-ledger-rates.mjs +62 -0
- package/bin/token-ledger-terminal.mjs +97 -154
- package/bin/token-ledger-trend-image.mjs +945 -0
- package/bin/token-ledger-trend-terminal.mjs +609 -0
- package/bin/token-ledger-trend.mjs +745 -0
- package/bin/token-ledger-tui.mjs +15 -21
- package/bin/token-ledger.mjs +390 -102
- package/lib/{token-ledger-collector.mjs → token-ledger-importer.mjs} +244 -407
- package/package.json +17 -10
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { buildBurnDayBins, buildUsageTrend, trendModelLabel } from "./token-ledger-trend.mjs";
|
|
2
|
+
|
|
3
|
+
const RESET = "\u001b[0m";
|
|
4
|
+
const PRIMARY_STYLE = [38, 2, 255, 255, 255];
|
|
5
|
+
const SECONDARY_STYLE = [38, 2, 155, 155, 155];
|
|
6
|
+
const BORDER_STYLE = [38, 2, 88, 88, 88];
|
|
7
|
+
const GRID_STYLE = [38, 2, 72, 72, 72];
|
|
8
|
+
const LINE_STYLE = [1, 38, 2, 255, 236, 168];
|
|
9
|
+
const RESET_LINE_STYLE = [1, 38, 2, 255, 255, 255];
|
|
10
|
+
|
|
11
|
+
// Mirrors the SVG renderer's validated categorical palette.
|
|
12
|
+
export const TREND_MODEL_COLORS = {
|
|
13
|
+
Luna: [38, 2, 42, 120, 214],
|
|
14
|
+
Sol: [38, 2, 235, 104, 52],
|
|
15
|
+
Terra: [38, 2, 27, 175, 122],
|
|
16
|
+
"GPT-5.5": [38, 2, 237, 161, 0],
|
|
17
|
+
"GPT-5.4": [38, 2, 232, 123, 164],
|
|
18
|
+
Daybreak: [38, 2, 0, 131, 0],
|
|
19
|
+
"Auto review": [38, 2, 74, 58, 167],
|
|
20
|
+
Other: [38, 2, 137, 135, 129],
|
|
21
|
+
Unknown: [38, 2, 137, 135, 129],
|
|
22
|
+
Unattributed: [38, 2, 195, 194, 183],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const MODEL_ORDER = [
|
|
26
|
+
"Luna",
|
|
27
|
+
"Sol",
|
|
28
|
+
"Terra",
|
|
29
|
+
"GPT-5.5",
|
|
30
|
+
"GPT-5.4",
|
|
31
|
+
"Daybreak",
|
|
32
|
+
"Auto review",
|
|
33
|
+
"Other",
|
|
34
|
+
"Unknown",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function colorsEnabled(options = {}) {
|
|
38
|
+
return options.forceColor ??
|
|
39
|
+
(!options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function colorize(value, style, enabled) {
|
|
43
|
+
return enabled ? `\u001b[${style.join(";")}m${value}${RESET}` : value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stripAnsi(value) {
|
|
47
|
+
return String(value).replace(/\u001b\[[0-9;]*m/g, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function visibleLength(value) {
|
|
51
|
+
return stripAnsi(value).length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fit(value, width, alignment = "left") {
|
|
55
|
+
const text = String(value);
|
|
56
|
+
const length = visibleLength(text);
|
|
57
|
+
if (length > width) return stripAnsi(text).slice(0, Math.max(0, width - 1)) + (width > 0 ? "…" : "");
|
|
58
|
+
const padding = " ".repeat(Math.max(0, width - length));
|
|
59
|
+
if (alignment === "right") return `${padding}${text}`;
|
|
60
|
+
if (alignment === "center") {
|
|
61
|
+
const left = Math.floor(padding.length / 2);
|
|
62
|
+
return `${" ".repeat(left)}${text}${" ".repeat(padding.length - left)}`;
|
|
63
|
+
}
|
|
64
|
+
return `${text}${padding}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function compact(value) {
|
|
68
|
+
if (!Number.isFinite(value)) return "—";
|
|
69
|
+
const absolute = Math.abs(value);
|
|
70
|
+
for (const [divisor, suffix] of [
|
|
71
|
+
[1_000_000_000, "B"],
|
|
72
|
+
[1_000_000, "M"],
|
|
73
|
+
[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
|
+
}
|
|
81
|
+
return Math.round(value).toLocaleString("en-US");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function percent(value) {
|
|
85
|
+
if (!Number.isFinite(value)) return "—";
|
|
86
|
+
return `${value.toFixed(value >= 10 ? 1 : 2)}%`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function styleForModel(model) {
|
|
90
|
+
return TREND_MODEL_COLORS[model] ?? TREND_MODEL_COLORS.Other;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function modelSort(left, right) {
|
|
94
|
+
const leftIndex = MODEL_ORDER.indexOf(left);
|
|
95
|
+
const rightIndex = MODEL_ORDER.indexOf(right);
|
|
96
|
+
return (
|
|
97
|
+
(leftIndex < 0 ? MODEL_ORDER.length : leftIndex) -
|
|
98
|
+
(rightIndex < 0 ? MODEL_ORDER.length : rightIndex) ||
|
|
99
|
+
left.localeCompare(right)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function dateParts(dateString) {
|
|
104
|
+
return dateString.split("-").map(Number);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dateStringFromParts(year, month, day) {
|
|
108
|
+
return [year, month, day]
|
|
109
|
+
.map((value, index) =>
|
|
110
|
+
index === 0 ? String(value) : String(value).padStart(2, "0"),
|
|
111
|
+
)
|
|
112
|
+
.join("-");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function shiftCalendarDate(dateString, amount) {
|
|
116
|
+
const [year, month, day] = dateParts(dateString);
|
|
117
|
+
const date = new Date(Date.UTC(year, month - 1, day + amount));
|
|
118
|
+
return dateStringFromParts(
|
|
119
|
+
date.getUTCFullYear(),
|
|
120
|
+
date.getUTCMonth() + 1,
|
|
121
|
+
date.getUTCDate(),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function timeZoneOffsetMs(instant, timeZone) {
|
|
126
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
127
|
+
timeZone,
|
|
128
|
+
timeZoneName: "longOffset",
|
|
129
|
+
year: "numeric",
|
|
130
|
+
month: "2-digit",
|
|
131
|
+
day: "2-digit",
|
|
132
|
+
hour: "2-digit",
|
|
133
|
+
minute: "2-digit",
|
|
134
|
+
second: "2-digit",
|
|
135
|
+
hourCycle: "h23",
|
|
136
|
+
}).formatToParts(instant);
|
|
137
|
+
const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
|
|
138
|
+
if (value === "GMT") return 0;
|
|
139
|
+
const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
|
|
140
|
+
if (!match) return 0;
|
|
141
|
+
const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
|
|
142
|
+
return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function zonedMidnight(dateString, timeZone) {
|
|
146
|
+
const [year, month, day] = dateParts(dateString);
|
|
147
|
+
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));
|
|
150
|
+
}
|
|
151
|
+
|
|
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));
|
|
159
|
+
const values = Object.fromEntries(
|
|
160
|
+
parts
|
|
161
|
+
.filter((part) => part.type !== "literal")
|
|
162
|
+
.map((part) => [part.type, part.value]),
|
|
163
|
+
);
|
|
164
|
+
return `${values.year}-${values.month}-${values.day}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function localDateLabel(dateString, timeZone) {
|
|
168
|
+
const date = zonedMidnight(dateString, timeZone);
|
|
169
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
170
|
+
timeZone,
|
|
171
|
+
month: "short",
|
|
172
|
+
day: "2-digit",
|
|
173
|
+
})
|
|
174
|
+
.format(date)
|
|
175
|
+
.toUpperCase();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function chooseBinSize(days, width) {
|
|
179
|
+
if (days <= 14) return 1;
|
|
180
|
+
return width >= 120 ? 2 : 3;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sortedModelEntries(values) {
|
|
184
|
+
return [...values.entries()]
|
|
185
|
+
.filter(([, value]) => value > 0)
|
|
186
|
+
.sort(([left], [right]) => modelSort(left, right));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function buildActualTokenBins(snapshot, bounds, days, width) {
|
|
190
|
+
const binSize = chooseBinSize(days, width);
|
|
191
|
+
const binCount = Math.ceil(days / binSize);
|
|
192
|
+
const bins = Array.from({ length: binCount }, (_, index) => ({
|
|
193
|
+
startDateString: shiftCalendarDate(bounds.startDateString, index * binSize),
|
|
194
|
+
endDateString: shiftCalendarDate(
|
|
195
|
+
bounds.startDateString,
|
|
196
|
+
Math.min(days, (index + 1) * binSize),
|
|
197
|
+
),
|
|
198
|
+
values: new Map(),
|
|
199
|
+
fastValues: new Map(),
|
|
200
|
+
totalTokens: 0,
|
|
201
|
+
calls: 0,
|
|
202
|
+
}));
|
|
203
|
+
const startDate = bounds.startDateString;
|
|
204
|
+
const dateIndexByString = new Map(
|
|
205
|
+
Array.from({ length: days }, (_, index) => [
|
|
206
|
+
shiftCalendarDate(startDate, index),
|
|
207
|
+
index,
|
|
208
|
+
]),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
for (const event of snapshot.events ?? []) {
|
|
212
|
+
const timestamp = new Date(event.timestamp).getTime();
|
|
213
|
+
if (!Number.isFinite(timestamp)) continue;
|
|
214
|
+
const dateString = localDateString(timestamp, bounds.timeZone);
|
|
215
|
+
const dayIndex = dateIndexByString.get(dateString);
|
|
216
|
+
if (dayIndex === undefined || dayIndex >= days) continue;
|
|
217
|
+
const bin = bins[Math.floor(dayIndex / binSize)];
|
|
218
|
+
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
219
|
+
const model = trendModelLabel(event.model);
|
|
220
|
+
bin.totalTokens += tokens;
|
|
221
|
+
bin.calls += 1;
|
|
222
|
+
bin.values.set(model, (bin.values.get(model) ?? 0) + tokens);
|
|
223
|
+
if (event.serviceTier === "priority") {
|
|
224
|
+
bin.fastValues.set(model, (bin.fastValues.get(model) ?? 0) + tokens);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const totals = new Map();
|
|
229
|
+
const fastTotals = new Map();
|
|
230
|
+
for (const bin of bins) {
|
|
231
|
+
for (const [model, value] of bin.values) {
|
|
232
|
+
totals.set(model, (totals.get(model) ?? 0) + value);
|
|
233
|
+
}
|
|
234
|
+
for (const [model, value] of bin.fastValues) {
|
|
235
|
+
fastTotals.set(model, (fastTotals.get(model) ?? 0) + value);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { bins, totals, fastTotals, binSize, binCount };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function niceCeiling(value) {
|
|
242
|
+
if (!(value > 0)) return 1;
|
|
243
|
+
const magnitude = 10 ** Math.floor(Math.log10(value));
|
|
244
|
+
const normalized = value / magnitude;
|
|
245
|
+
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
|
246
|
+
return step * magnitude;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function allocateSegmentHeights(entries, total, maxValue, plotHeight) {
|
|
250
|
+
const barHeight = total > 0
|
|
251
|
+
? Math.max(1, Math.round((total / maxValue) * (plotHeight - 1)))
|
|
252
|
+
: 0;
|
|
253
|
+
if (!barHeight) return [];
|
|
254
|
+
const ideal = entries.map(([, value]) => (value / total) * barHeight);
|
|
255
|
+
const heights = ideal.map(Math.floor);
|
|
256
|
+
let remainder = barHeight - heights.reduce((sum, value) => sum + value, 0);
|
|
257
|
+
const order = ideal
|
|
258
|
+
.map((value, index) => ({ index, fraction: value - Math.floor(value), value }))
|
|
259
|
+
.sort((left, right) => right.fraction - left.fraction || right.value - left.value);
|
|
260
|
+
for (let index = 0; index < remainder; index += 1) {
|
|
261
|
+
heights[order[index % order.length].index] += 1;
|
|
262
|
+
}
|
|
263
|
+
// Preserve a one-cell sliver for a non-zero model whenever the bar has
|
|
264
|
+
// enough vertical resolution to show it.
|
|
265
|
+
if (barHeight >= entries.length) {
|
|
266
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
267
|
+
if (heights[index] > 0) continue;
|
|
268
|
+
const donor = heights.findIndex((height) => height > 1);
|
|
269
|
+
if (donor < 0) break;
|
|
270
|
+
heights[donor] -= 1;
|
|
271
|
+
heights[index] = 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return heights;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sampleQuota(trend, bounds, width) {
|
|
278
|
+
const startMs = bounds.start.getTime();
|
|
279
|
+
const endMs = bounds.end.getTime();
|
|
280
|
+
const points = trend.points ?? [];
|
|
281
|
+
const resets = trend.resets ?? [];
|
|
282
|
+
const samples = [];
|
|
283
|
+
let pointIndex = 0;
|
|
284
|
+
let resetIndex = 0;
|
|
285
|
+
let activePoint = null;
|
|
286
|
+
|
|
287
|
+
for (let column = 0; column < width; column += 1) {
|
|
288
|
+
const ratio = width <= 1 ? 0 : column / (width - 1);
|
|
289
|
+
const timestampMs = startMs + (endMs - startMs) * ratio;
|
|
290
|
+
while (
|
|
291
|
+
pointIndex < points.length &&
|
|
292
|
+
points[pointIndex].timestampMs <= timestampMs
|
|
293
|
+
) {
|
|
294
|
+
activePoint = points[pointIndex];
|
|
295
|
+
pointIndex += 1;
|
|
296
|
+
}
|
|
297
|
+
const previousTimestampMs =
|
|
298
|
+
column === 0 ? startMs - 1 : samples[column - 1].timestampMs;
|
|
299
|
+
let reset = false;
|
|
300
|
+
while (
|
|
301
|
+
resetIndex < resets.length &&
|
|
302
|
+
resets[resetIndex].timestampMs <= previousTimestampMs
|
|
303
|
+
) {
|
|
304
|
+
resetIndex += 1;
|
|
305
|
+
}
|
|
306
|
+
while (
|
|
307
|
+
resetIndex < resets.length &&
|
|
308
|
+
resets[resetIndex].timestampMs <= timestampMs
|
|
309
|
+
) {
|
|
310
|
+
reset = true;
|
|
311
|
+
resetIndex += 1;
|
|
312
|
+
}
|
|
313
|
+
samples.push({
|
|
314
|
+
timestampMs,
|
|
315
|
+
point: activePoint,
|
|
316
|
+
reset,
|
|
317
|
+
remainingPercent: reset ? 100 : activePoint?.remainingPercent ?? null,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return samples;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function lineRow(remainingPercent, plotHeight) {
|
|
324
|
+
if (!Number.isFinite(remainingPercent)) return null;
|
|
325
|
+
return Math.max(
|
|
326
|
+
0,
|
|
327
|
+
Math.min(
|
|
328
|
+
plotHeight - 1,
|
|
329
|
+
Math.round(((100 - remainingPercent) / 100) * (plotHeight - 1)),
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function xLabelLine(bins, plotWidth, leftWidth, rightWidth, timeZone) {
|
|
335
|
+
const labels = Array.from({ length: plotWidth }, () => " ");
|
|
336
|
+
const write = (label, offset) => {
|
|
337
|
+
for (let index = 0; index < label.length; index += 1) {
|
|
338
|
+
const target = offset + index;
|
|
339
|
+
if (target >= 0 && target < labels.length) labels[target] = label[index];
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
bins.forEach((bin, index) => {
|
|
343
|
+
const start = Math.round((index * plotWidth) / bins.length);
|
|
344
|
+
const end = Math.round(((index + 1) * plotWidth) / bins.length);
|
|
345
|
+
const label = localDateLabel(bin.startDateString, timeZone);
|
|
346
|
+
if (end - start >= label.length) {
|
|
347
|
+
write(label, start + Math.floor((end - start - label.length) / 2));
|
|
348
|
+
} else if (index === 0 || index === bins.length - 1 || end - start >= 4) {
|
|
349
|
+
write(label.slice(-2), start + Math.max(0, Math.floor((end - start - 2) / 2)));
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
return `${" ".repeat(leftWidth + 1)}${labels.join("")}${" ".repeat(rightWidth + 1)}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function rowAxisLine(leftLabel, plot, rightLabel, leftWidth, rightWidth, enabled) {
|
|
356
|
+
const axis = colorize("│", BORDER_STYLE, enabled);
|
|
357
|
+
return `${fit(leftLabel, leftWidth, "right")}${axis}${plot}${axis}${fit(rightLabel, rightWidth)}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function frameLine(content, width) {
|
|
361
|
+
return `│${fit(content, width - 2)}│`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function formatAttribution(trend, enabled, width, percentMode) {
|
|
365
|
+
const rows = new Map((trend.models ?? []).map((row) => [row.model, row]));
|
|
366
|
+
const entries = ["Luna", "Sol"]
|
|
367
|
+
.map((model) => {
|
|
368
|
+
const row = rows.get(model);
|
|
369
|
+
if (!row || !(row.tokensPerBurnPoint > 0)) return null;
|
|
370
|
+
return percentMode
|
|
371
|
+
? `${model} ${compact(row.tokensPerBurnPoint)} tok/1%`
|
|
372
|
+
: `${model} ${compact(row.tokensPerBurnPoint)} T/p · ${row.burnPoints.toFixed(1)} pts`;
|
|
373
|
+
})
|
|
374
|
+
.filter(Boolean);
|
|
375
|
+
if (!entries.length) return [];
|
|
376
|
+
if (percentMode) {
|
|
377
|
+
const valueLine = colorize(
|
|
378
|
+
`Observed burn rate · ${entries.join(" ")}`,
|
|
379
|
+
SECONDARY_STYLE,
|
|
380
|
+
enabled,
|
|
381
|
+
);
|
|
382
|
+
const method = colorize(
|
|
383
|
+
`Columns sum to observed meter drops; model split via rate-card credit weights (card ${trend.rateCardAsOf}).`,
|
|
384
|
+
SECONDARY_STYLE,
|
|
385
|
+
enabled,
|
|
386
|
+
);
|
|
387
|
+
return [fit(valueLine, width - 2), fit(method, width - 2)];
|
|
388
|
+
}
|
|
389
|
+
const valueLine = colorize(
|
|
390
|
+
`ESTIMATE ONLY · quota attribution lens · ${entries.join(" ")}`,
|
|
391
|
+
SECONDARY_STYLE,
|
|
392
|
+
enabled,
|
|
393
|
+
);
|
|
394
|
+
const method = colorize(
|
|
395
|
+
`Rate-card/token weights, ${trend.rateCardAsOf}; separate from actual-token bars and not official quota math.`,
|
|
396
|
+
SECONDARY_STYLE,
|
|
397
|
+
enabled,
|
|
398
|
+
);
|
|
399
|
+
return [fit(valueLine, width - 2), fit(method, width - 2)];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function drainLabelLine(burnBins, plotWidth, leftWidth, rightWidth, enabled) {
|
|
403
|
+
const labels = Array.from({ length: plotWidth }, () => " ");
|
|
404
|
+
const write = (label, at) => {
|
|
405
|
+
for (let index = 0; index < label.length; index += 1) {
|
|
406
|
+
const target = at + index;
|
|
407
|
+
if (target >= 0 && target < labels.length) labels[target] = label[index];
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
burnBins.forEach((bin, index) => {
|
|
411
|
+
if (Math.round(bin.totalPercent) < 1) return;
|
|
412
|
+
const start = Math.round((index * plotWidth) / burnBins.length);
|
|
413
|
+
const end = Math.round(((index + 1) * plotWidth) / burnBins.length);
|
|
414
|
+
const label = `-${Math.round(bin.totalPercent)}%${bin.approximate ? "~" : ""}`;
|
|
415
|
+
if (end - start >= label.length) {
|
|
416
|
+
write(label, start + Math.floor((end - start - label.length) / 2));
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
return `${" ".repeat(leftWidth + 1)}${colorize(labels.join(""), LINE_STYLE, enabled)}${" ".repeat(rightWidth + 1)}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function renderTrendCombo({
|
|
423
|
+
snapshot,
|
|
424
|
+
bounds,
|
|
425
|
+
trend = buildUsageTrend(snapshot, bounds),
|
|
426
|
+
days = bounds.rangeDays ?? 7,
|
|
427
|
+
options = {},
|
|
428
|
+
}) {
|
|
429
|
+
const enabled = colorsEnabled(options);
|
|
430
|
+
const frameWidth = Math.max(82, Math.min(158, Number(options.width) || 120));
|
|
431
|
+
const innerWidth = frameWidth - 2;
|
|
432
|
+
const leftWidth = 8;
|
|
433
|
+
const rightWidth = 7;
|
|
434
|
+
const plotWidth = Math.max(36, innerWidth - leftWidth - rightWidth - 2);
|
|
435
|
+
const plotHeight = 11;
|
|
436
|
+
const actual = buildActualTokenBins(snapshot, bounds, days, plotWidth);
|
|
437
|
+
const burn = buildBurnDayBins(trend, bounds, {
|
|
438
|
+
days,
|
|
439
|
+
binSize: actual.binSize,
|
|
440
|
+
});
|
|
441
|
+
// Drain mode (--drain) draws the meter's own observed drops as columns in
|
|
442
|
+
// the same unit as the quota line. The default volume mode keeps token bars
|
|
443
|
+
// and shows the observed drop per column in a label row instead.
|
|
444
|
+
const meterUsable = Boolean(trend.available && burn.totalPercent > 0);
|
|
445
|
+
const percentMode = Boolean(options.drain) && meterUsable;
|
|
446
|
+
const barBins = percentMode ? burn.bins : actual.bins;
|
|
447
|
+
const binTotal = (bin) => (percentMode ? bin.totalPercent : bin.totalTokens);
|
|
448
|
+
const maxLeft = niceCeiling(
|
|
449
|
+
barBins.reduce((maximum, bin) => Math.max(maximum, binTotal(bin)), 0),
|
|
450
|
+
);
|
|
451
|
+
const chart = Array.from({ length: plotHeight }, () =>
|
|
452
|
+
Array.from({ length: plotWidth }, () => ({ char: "·", style: GRID_STYLE })),
|
|
453
|
+
);
|
|
454
|
+
const baseline = plotHeight - 1;
|
|
455
|
+
const majorRows = new Set([0, Math.floor(baseline / 2), baseline]);
|
|
456
|
+
for (const row of majorRows) {
|
|
457
|
+
for (let column = 0; column < plotWidth; column += 1) {
|
|
458
|
+
chart[row][column] = { char: "┄", style: GRID_STYLE };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for (const [binIndex, bin] of barBins.entries()) {
|
|
463
|
+
const entries = sortedModelEntries(bin.values);
|
|
464
|
+
const heights = allocateSegmentHeights(
|
|
465
|
+
entries,
|
|
466
|
+
binTotal(bin),
|
|
467
|
+
maxLeft,
|
|
468
|
+
plotHeight,
|
|
469
|
+
);
|
|
470
|
+
const start = Math.round((binIndex * plotWidth) / actual.binCount);
|
|
471
|
+
const end = Math.round(((binIndex + 1) * plotWidth) / actual.binCount);
|
|
472
|
+
const fillStart = end - start > 2 ? start + 1 : start;
|
|
473
|
+
const fillEnd = end - start > 2 ? end - 1 : end;
|
|
474
|
+
let cumulative = 0;
|
|
475
|
+
for (let entryIndex = 0; entryIndex < entries.length; entryIndex += 1) {
|
|
476
|
+
const [model] = entries[entryIndex];
|
|
477
|
+
const height = heights[entryIndex];
|
|
478
|
+
for (let row = baseline - cumulative - height; row < baseline - cumulative; row += 1) {
|
|
479
|
+
if (row < 0 || row >= plotHeight) continue;
|
|
480
|
+
for (let column = fillStart; column < fillEnd; column += 1) {
|
|
481
|
+
chart[row][column] = { char: "█", style: styleForModel(model) };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
cumulative += height;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const quotaSamples = sampleQuota(trend, bounds, plotWidth);
|
|
489
|
+
const quotaRows = quotaSamples.map((sample) =>
|
|
490
|
+
lineRow(sample.remainingPercent, plotHeight),
|
|
491
|
+
);
|
|
492
|
+
for (let column = 0; column < quotaSamples.length; column += 1) {
|
|
493
|
+
const current = quotaRows[column];
|
|
494
|
+
if (current === null) continue;
|
|
495
|
+
const previous = column > 0 ? quotaRows[column - 1] : null;
|
|
496
|
+
const sample = quotaSamples[column];
|
|
497
|
+
if (sample.reset && previous !== null) {
|
|
498
|
+
const top = Math.min(current, previous);
|
|
499
|
+
const bottom = Math.max(current, previous);
|
|
500
|
+
for (let row = top; row <= bottom; row += 1) {
|
|
501
|
+
chart[row][column] = {
|
|
502
|
+
char: row === current ? "↟" : "│",
|
|
503
|
+
style: RESET_LINE_STYLE,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const character = previous === null
|
|
509
|
+
? "◆"
|
|
510
|
+
: current === previous
|
|
511
|
+
? "─"
|
|
512
|
+
: current < previous
|
|
513
|
+
? "╱"
|
|
514
|
+
: "╲";
|
|
515
|
+
chart[current][column] = { char: character, style: LINE_STYLE };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const axisRows = [0, Math.floor(baseline / 2), baseline];
|
|
519
|
+
const lines = [
|
|
520
|
+
`┌${"─".repeat(frameWidth - 2)}┐`,
|
|
521
|
+
frameLine(
|
|
522
|
+
colorize(
|
|
523
|
+
`TOKEN LEDGER · ${percentMode ? "OBSERVED LIMIT DRAIN + WEEKLY METER" : "ACTUAL TOKENS + WEEKLY QUOTA"} · ${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)} · ${days}D`,
|
|
524
|
+
PRIMARY_STYLE,
|
|
525
|
+
enabled,
|
|
526
|
+
),
|
|
527
|
+
frameWidth,
|
|
528
|
+
),
|
|
529
|
+
frameLine(
|
|
530
|
+
colorize(
|
|
531
|
+
percentMode
|
|
532
|
+
? "BARS = observed limit % consumed per day by model · LINE = meter remaining · one percent scale"
|
|
533
|
+
: meterUsable
|
|
534
|
+
? "BARS = actual token quantity by model · LINE = meter remaining · -% row = observed drain per column"
|
|
535
|
+
: "BARS = actual token quantity by model · LINE = observed remaining quota · separate scales",
|
|
536
|
+
SECONDARY_STYLE,
|
|
537
|
+
enabled,
|
|
538
|
+
),
|
|
539
|
+
frameWidth,
|
|
540
|
+
),
|
|
541
|
+
`├${"─".repeat(frameWidth - 2)}┤`,
|
|
542
|
+
];
|
|
543
|
+
for (let row = 0; row < plotHeight; row += 1) {
|
|
544
|
+
const leftValue = maxLeft * (1 - row / baseline);
|
|
545
|
+
const rightValue = 100 * (1 - row / baseline);
|
|
546
|
+
const leftLabel = axisRows.includes(row)
|
|
547
|
+
? percentMode
|
|
548
|
+
? percent(leftValue)
|
|
549
|
+
: compact(leftValue)
|
|
550
|
+
: "";
|
|
551
|
+
const rightLabel = axisRows.includes(row) ? `${Math.round(rightValue)}%` : "";
|
|
552
|
+
const content = chart[row]
|
|
553
|
+
.map(({ char, style }) => colorize(char, style, enabled))
|
|
554
|
+
.join("");
|
|
555
|
+
lines.push(frameLine(rowAxisLine(leftLabel, content, rightLabel, leftWidth, rightWidth, enabled), frameWidth));
|
|
556
|
+
}
|
|
557
|
+
const axis = `${" ".repeat(leftWidth)}${colorize(`└${"─".repeat(plotWidth)}┘`, BORDER_STYLE, enabled)}${" ".repeat(rightWidth)}`;
|
|
558
|
+
lines.push(frameLine(axis, frameWidth));
|
|
559
|
+
lines.push(frameLine(xLabelLine(barBins, plotWidth, leftWidth, rightWidth, bounds.timeZone), frameWidth));
|
|
560
|
+
if (!percentMode && meterUsable) {
|
|
561
|
+
lines.push(frameLine(drainLabelLine(burn.bins, plotWidth, leftWidth, rightWidth, enabled), frameWidth));
|
|
562
|
+
lines.push(frameLine(fit("CALENDAR DAY · -% = OBSERVED METER DROP", innerWidth, "center"), frameWidth));
|
|
563
|
+
} else {
|
|
564
|
+
lines.push(frameLine(fit("CALENDAR DAY", innerWidth, "center"), frameWidth));
|
|
565
|
+
}
|
|
566
|
+
lines.push(`├${"─".repeat(frameWidth - 2)}┤`);
|
|
567
|
+
|
|
568
|
+
const totalTokens = [...actual.totals.values()].reduce((sum, value) => sum + value, 0);
|
|
569
|
+
const legendModels = percentMode
|
|
570
|
+
? [...burn.totals.keys()].sort(modelSort)
|
|
571
|
+
: [...actual.totals.keys()].sort(modelSort);
|
|
572
|
+
const legend = legendModels.map((model) => {
|
|
573
|
+
if (percentMode) {
|
|
574
|
+
const tokens = actual.totals.get(model);
|
|
575
|
+
const tokenPart = tokens > 0 ? ` · ${compact(tokens)} tok` : "";
|
|
576
|
+
return colorize(
|
|
577
|
+
`■ ${model} ${percent(burn.totals.get(model))} of limit${tokenPart}`,
|
|
578
|
+
styleForModel(model),
|
|
579
|
+
enabled,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
const fastTokens = actual.fastTotals?.get(model) ?? 0;
|
|
583
|
+
const fastPart = fastTokens > 0
|
|
584
|
+
? ` · ${percent((fastTokens / actual.totals.get(model)) * 100)} fast`
|
|
585
|
+
: "";
|
|
586
|
+
return colorize(
|
|
587
|
+
`■ ${model} ${compact(actual.totals.get(model))} (${percent((actual.totals.get(model) / totalTokens) * 100)})${fastPart}`,
|
|
588
|
+
styleForModel(model),
|
|
589
|
+
enabled,
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
lines.push(frameLine(colorize(percentMode ? "OBSERVED LIMIT DRAIN BY MODEL · LEFT AXIS" : "ACTUAL TOKEN VOLUME · LEFT AXIS", PRIMARY_STYLE, enabled), frameWidth));
|
|
593
|
+
for (let index = 0; index < legend.length; index += 2) {
|
|
594
|
+
const leftLegendWidth = Math.floor((innerWidth - 2) / 2);
|
|
595
|
+
const rightLegendWidth = innerWidth - 2 - leftLegendWidth;
|
|
596
|
+
lines.push(frameLine(`${fit(legend[index], leftLegendWidth)} ${fit(legend[index + 1] ?? "", rightLegendWidth)}`, frameWidth));
|
|
597
|
+
}
|
|
598
|
+
lines.push(frameLine(colorize("LINE · OBSERVED WEEKLY QUOTA REMAINING · RIGHT AXIS", LINE_STYLE, enabled), frameWidth));
|
|
599
|
+
lines.push(frameLine(colorize("↟ reset marker returns the line to 100%; it never rises within a cycle", SECONDARY_STYLE, enabled), frameWidth));
|
|
600
|
+
for (const line of formatAttribution(trend, enabled, frameWidth, percentMode)) {
|
|
601
|
+
lines.push(frameLine(line, frameWidth));
|
|
602
|
+
}
|
|
603
|
+
lines.push(`└${"─".repeat(frameWidth - 2)}┘`);
|
|
604
|
+
return lines.join("\n");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function renderTrendPlain(args) {
|
|
608
|
+
return renderTrendCombo({ ...args, options: { ...args.options, plain: true } });
|
|
609
|
+
}
|