@tmustier/pi-usage-extension 0.3.2 → 0.6.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/CHANGELOG.md +30 -0
- package/README.md +32 -5
- package/data.ts +964 -0
- package/graph.ts +386 -0
- package/index.ts +184 -561
- package/package.json +3 -3
package/graph.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph explorer model + braille chart rendering for /usage.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is pure and theme-free: series building works off the hourly
|
|
5
|
+
* buckets produced by data.ts, and the renderer emits plain text plus a
|
|
6
|
+
* colorize callback so the UI layer owns all styling. This keeps the module
|
|
7
|
+
* fully unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Explicit .ts extension so plain `node --test` (type stripping) can resolve
|
|
11
|
+
// this module too; pi's extension loader accepts it as well.
|
|
12
|
+
import type { HourlyCell, HourlyKey, PeriodBounds, TabName } from "./data.ts";
|
|
13
|
+
import { splitHourlyKey } from "./data.ts";
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Options and model types
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export type GraphMetric = "cost" | "tokens" | "messages" | "reasoning";
|
|
20
|
+
export type GraphGroupBy = "provider" | "model" | "thinking" | "total";
|
|
21
|
+
|
|
22
|
+
export const METRIC_ORDER: GraphMetric[] = ["cost", "tokens", "messages", "reasoning"];
|
|
23
|
+
export const GROUP_ORDER: GraphGroupBy[] = ["provider", "model", "thinking", "total"];
|
|
24
|
+
|
|
25
|
+
export const METRIC_LABELS: Record<GraphMetric, string> = {
|
|
26
|
+
cost: "cost",
|
|
27
|
+
tokens: "tokens",
|
|
28
|
+
messages: "messages",
|
|
29
|
+
reasoning: "reasoning tokens",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const GROUP_LABELS: Record<GraphGroupBy, string> = {
|
|
33
|
+
provider: "by provider",
|
|
34
|
+
model: "by model",
|
|
35
|
+
thinking: "by thinking level",
|
|
36
|
+
total: "total only",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Series beyond this cap are merged into a single "other" series. */
|
|
40
|
+
export const MAX_GROUP_SERIES = 6;
|
|
41
|
+
|
|
42
|
+
export const TOTAL_SERIES_KEY = "\u0000total";
|
|
43
|
+
export const OTHER_SERIES_KEY = "\u0000other";
|
|
44
|
+
|
|
45
|
+
export interface GraphOptions {
|
|
46
|
+
period: TabName;
|
|
47
|
+
metric: GraphMetric;
|
|
48
|
+
groupBy: GraphGroupBy;
|
|
49
|
+
cumulative: boolean;
|
|
50
|
+
/** Series keys hidden via the legend. */
|
|
51
|
+
hidden?: ReadonlySet<string>;
|
|
52
|
+
bounds: PeriodBounds;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GraphSeries {
|
|
56
|
+
key: string;
|
|
57
|
+
label: string;
|
|
58
|
+
/** One value per bucket. Cumulative when options.cumulative. */
|
|
59
|
+
points: number[];
|
|
60
|
+
/** Period total for this series (not affected by cumulative). */
|
|
61
|
+
total: number;
|
|
62
|
+
hidden: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GraphModel {
|
|
66
|
+
series: GraphSeries[];
|
|
67
|
+
/** Bucket start timestamps (ms), ascending. */
|
|
68
|
+
bucketStarts: number[];
|
|
69
|
+
bucketMs: number;
|
|
70
|
+
domainStartMs: number;
|
|
71
|
+
domainEndMs: number;
|
|
72
|
+
/** Max point value across visible series (y-axis scale). */
|
|
73
|
+
yMax: number;
|
|
74
|
+
/** Sum of totals across grouped (non-total) series, for legend percentages. */
|
|
75
|
+
groupedTotal: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Series building
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
const HOUR_MS = 3_600_000;
|
|
83
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
84
|
+
/** Periods spanning at most this many hours use hourly buckets; otherwise daily. */
|
|
85
|
+
const MAX_HOURLY_BUCKETS = 8 * 24;
|
|
86
|
+
|
|
87
|
+
function metricOf(cell: HourlyCell, metric: GraphMetric): number {
|
|
88
|
+
switch (metric) {
|
|
89
|
+
case "cost":
|
|
90
|
+
return cell.cost;
|
|
91
|
+
case "tokens":
|
|
92
|
+
// Matches the dashboard formula: fresh tokens = input + output + cacheWrite.
|
|
93
|
+
return cell.input + cell.output + cell.cacheWrite;
|
|
94
|
+
case "messages":
|
|
95
|
+
return cell.messages;
|
|
96
|
+
case "reasoning":
|
|
97
|
+
return cell.reasoning;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function groupKeyOf(key: HourlyKey, groupBy: GraphGroupBy): string {
|
|
102
|
+
if (groupBy === "total") return TOTAL_SERIES_KEY;
|
|
103
|
+
const { provider, model, thinkingLevel } = splitHourlyKey(key);
|
|
104
|
+
if (groupBy === "provider") return provider;
|
|
105
|
+
if (groupBy === "model") return model;
|
|
106
|
+
return thinkingLevel === "" ? "unknown" : thinkingLevel;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function domainFor(period: TabName, bounds: PeriodBounds, hourly: Map<number, Map<HourlyKey, HourlyCell>>): { startMs: number; endMs: number } {
|
|
110
|
+
switch (period) {
|
|
111
|
+
case "today":
|
|
112
|
+
return { startMs: bounds.todayMs, endMs: bounds.nowMs };
|
|
113
|
+
case "thisWeek":
|
|
114
|
+
return { startMs: bounds.weekStartMs, endMs: bounds.nowMs };
|
|
115
|
+
case "lastWeek":
|
|
116
|
+
return { startMs: bounds.lastWeekStartMs, endMs: bounds.weekStartMs };
|
|
117
|
+
case "last30Days":
|
|
118
|
+
return { startMs: bounds.last30DaysStartMs, endMs: bounds.nowMs };
|
|
119
|
+
case "allTime": {
|
|
120
|
+
let first = Number.POSITIVE_INFINITY;
|
|
121
|
+
for (const hour of hourly.keys()) if (hour < first) first = hour;
|
|
122
|
+
if (!Number.isFinite(first)) first = bounds.todayMs;
|
|
123
|
+
return { startMs: Math.min(first, bounds.nowMs), endMs: bounds.nowMs };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build the graph model for one (period, metric, groupBy) view.
|
|
130
|
+
*
|
|
131
|
+
* Buckets are hourly for short periods and daily for long ones. Group series
|
|
132
|
+
* are capped at MAX_GROUP_SERIES by period total; the rest merge into "other".
|
|
133
|
+
* A Total series is always present (first). Hidden series keep their points
|
|
134
|
+
* but are excluded from the y-axis scale.
|
|
135
|
+
*/
|
|
136
|
+
export function buildGraphModel(
|
|
137
|
+
hourly: Map<number, Map<HourlyKey, HourlyCell>>,
|
|
138
|
+
options: GraphOptions
|
|
139
|
+
): GraphModel {
|
|
140
|
+
const { startMs, endMs } = domainFor(options.period, options.bounds, hourly);
|
|
141
|
+
const spanMs = Math.max(endMs - startMs, 1);
|
|
142
|
+
const bucketMs = spanMs / HOUR_MS <= MAX_HOURLY_BUCKETS ? HOUR_MS : DAY_MS;
|
|
143
|
+
|
|
144
|
+
// Bucket starts aligned to the domain start so "day" buckets follow the
|
|
145
|
+
// local-midnight period boundaries computed by data.ts (DST shifts move a
|
|
146
|
+
// boundary by an hour, which is invisible at graph resolution).
|
|
147
|
+
const bucketCount = Math.max(1, Math.ceil(spanMs / bucketMs));
|
|
148
|
+
const bucketStarts: number[] = [];
|
|
149
|
+
for (let i = 0; i < bucketCount; i++) bucketStarts.push(startMs + i * bucketMs);
|
|
150
|
+
|
|
151
|
+
// Accumulate per-group bucket values.
|
|
152
|
+
const groupValues = new Map<string, number[]>();
|
|
153
|
+
const groupTotals = new Map<string, number>();
|
|
154
|
+
const totalPoints = new Array<number>(bucketCount).fill(0);
|
|
155
|
+
let totalSum = 0;
|
|
156
|
+
|
|
157
|
+
for (const [hour, bucket] of hourly) {
|
|
158
|
+
if (hour < startMs || hour >= endMs) continue;
|
|
159
|
+
const idx = Math.min(bucketCount - 1, Math.floor((hour - startMs) / bucketMs));
|
|
160
|
+
for (const [key, cell] of bucket) {
|
|
161
|
+
const value = metricOf(cell, options.metric);
|
|
162
|
+
if (value === 0) continue;
|
|
163
|
+
totalPoints[idx] += value;
|
|
164
|
+
totalSum += value;
|
|
165
|
+
if (options.groupBy !== "total") {
|
|
166
|
+
const groupKey = groupKeyOf(key, options.groupBy);
|
|
167
|
+
let points = groupValues.get(groupKey);
|
|
168
|
+
if (!points) {
|
|
169
|
+
points = new Array<number>(bucketCount).fill(0);
|
|
170
|
+
groupValues.set(groupKey, points);
|
|
171
|
+
}
|
|
172
|
+
points[idx] += value;
|
|
173
|
+
groupTotals.set(groupKey, (groupTotals.get(groupKey) ?? 0) + value);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Rank groups and cap at MAX_GROUP_SERIES; merge the tail into "other".
|
|
179
|
+
const ranked = Array.from(groupTotals.entries()).sort((a, b) => b[1] - a[1]);
|
|
180
|
+
const kept = ranked.slice(0, MAX_GROUP_SERIES);
|
|
181
|
+
const merged = ranked.slice(MAX_GROUP_SERIES);
|
|
182
|
+
|
|
183
|
+
const hidden = options.hidden ?? new Set<string>();
|
|
184
|
+
const series: GraphSeries[] = [];
|
|
185
|
+
|
|
186
|
+
series.push({
|
|
187
|
+
key: TOTAL_SERIES_KEY,
|
|
188
|
+
label: "Total",
|
|
189
|
+
points: totalPoints.slice(),
|
|
190
|
+
total: totalSum,
|
|
191
|
+
hidden: hidden.has(TOTAL_SERIES_KEY),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
for (const [groupKey, total] of kept) {
|
|
195
|
+
series.push({
|
|
196
|
+
key: groupKey,
|
|
197
|
+
label: groupKey,
|
|
198
|
+
points: groupValues.get(groupKey)!,
|
|
199
|
+
total,
|
|
200
|
+
hidden: hidden.has(groupKey),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (merged.length > 0) {
|
|
205
|
+
const points = new Array<number>(bucketCount).fill(0);
|
|
206
|
+
let total = 0;
|
|
207
|
+
for (const [groupKey, groupTotal] of merged) {
|
|
208
|
+
const groupPoints = groupValues.get(groupKey)!;
|
|
209
|
+
for (let i = 0; i < bucketCount; i++) points[i] += groupPoints[i]!;
|
|
210
|
+
total += groupTotal;
|
|
211
|
+
}
|
|
212
|
+
series.push({
|
|
213
|
+
key: OTHER_SERIES_KEY,
|
|
214
|
+
label: `other (${merged.length})`,
|
|
215
|
+
points,
|
|
216
|
+
total,
|
|
217
|
+
hidden: hidden.has(OTHER_SERIES_KEY),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (options.cumulative) {
|
|
222
|
+
for (const s of series) {
|
|
223
|
+
let running = 0;
|
|
224
|
+
s.points = s.points.map((v) => (running += v));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let yMax = 0;
|
|
229
|
+
for (const s of series) {
|
|
230
|
+
if (s.hidden) continue;
|
|
231
|
+
for (const v of s.points) if (v > yMax) yMax = v;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
series,
|
|
236
|
+
bucketStarts,
|
|
237
|
+
bucketMs,
|
|
238
|
+
domainStartMs: startMs,
|
|
239
|
+
domainEndMs: endMs,
|
|
240
|
+
yMax,
|
|
241
|
+
groupedTotal: totalSum,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// =============================================================================
|
|
246
|
+
// Braille chart rendering
|
|
247
|
+
// =============================================================================
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Style callback: seriesIndex is the index into model.series, or -1 for
|
|
251
|
+
* chart furniture (axes). Return the text styled for the terminal.
|
|
252
|
+
*/
|
|
253
|
+
export type ChartColorize = (seriesIndex: number, text: string) => string;
|
|
254
|
+
|
|
255
|
+
export interface ChartRenderOptions {
|
|
256
|
+
width: number;
|
|
257
|
+
/** Text rows for the plot area (each row is 4 braille dots tall). */
|
|
258
|
+
height: number;
|
|
259
|
+
formatValue: (value: number) => string;
|
|
260
|
+
formatTime: (ms: number) => string;
|
|
261
|
+
colorize?: ChartColorize;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const BRAILLE_BASE = 0x2800;
|
|
265
|
+
// Dot bit masks by (x: 0=left, 1=right) and (y: 0=top .. 3=bottom).
|
|
266
|
+
const DOT_BITS = [
|
|
267
|
+
[0x01, 0x02, 0x04, 0x40],
|
|
268
|
+
[0x08, 0x10, 0x20, 0x80],
|
|
269
|
+
] as const;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Render the model as a braille line chart with a y-axis and an x-axis line.
|
|
273
|
+
* Returns exactly height + 1 lines (plot rows + x-axis labels).
|
|
274
|
+
*/
|
|
275
|
+
export function renderChart(model: GraphModel, options: ChartRenderOptions): string[] {
|
|
276
|
+
const colorize: ChartColorize = options.colorize ?? ((_i, text) => text);
|
|
277
|
+
const plotHeightForLabels = Math.max(options.height, 4);
|
|
278
|
+
const midRowForLabels = Math.floor((plotHeightForLabels - 1) / 2);
|
|
279
|
+
const midValue = (model.yMax * (plotHeightForLabels - 1 - midRowForLabels)) / (plotHeightForLabels - 1);
|
|
280
|
+
const yLabelWidth = Math.max(
|
|
281
|
+
options.formatValue(model.yMax).length,
|
|
282
|
+
options.formatValue(midValue).length,
|
|
283
|
+
options.formatValue(0).length
|
|
284
|
+
);
|
|
285
|
+
const axisWidth = yLabelWidth + 2; // label + " ┤" / " │"
|
|
286
|
+
const plotWidth = Math.max(options.width - axisWidth, 10);
|
|
287
|
+
const plotHeight = Math.max(options.height, 4);
|
|
288
|
+
const dotW = plotWidth * 2;
|
|
289
|
+
const dotH = plotHeight * 4;
|
|
290
|
+
|
|
291
|
+
// masks[row][col] per series index — draw order decides which color wins a cell.
|
|
292
|
+
const cellMasks: number[][] = Array.from({ length: plotHeight }, () => new Array<number>(plotWidth).fill(0));
|
|
293
|
+
const cellOwner: number[][] = Array.from({ length: plotHeight }, () => new Array<number>(plotWidth).fill(-1));
|
|
294
|
+
|
|
295
|
+
const yMax = model.yMax > 0 ? model.yMax : 1;
|
|
296
|
+
const bucketCount = model.bucketStarts.length;
|
|
297
|
+
|
|
298
|
+
const plot = (seriesIndex: number, points: number[]) => {
|
|
299
|
+
let prevX = -1;
|
|
300
|
+
let prevY = -1;
|
|
301
|
+
for (let i = 0; i < bucketCount; i++) {
|
|
302
|
+
const x = bucketCount === 1 ? dotW - 1 : Math.round((i / (bucketCount - 1)) * (dotW - 1));
|
|
303
|
+
const y = Math.round((1 - (points[i]! / yMax)) * (dotH - 1));
|
|
304
|
+
if (prevX >= 0) {
|
|
305
|
+
// Connect with a vertical-stepped segment for continuity.
|
|
306
|
+
const steps = Math.max(Math.abs(x - prevX), Math.abs(y - prevY), 1);
|
|
307
|
+
for (let s = 1; s <= steps; s++) {
|
|
308
|
+
const ix = Math.round(prevX + ((x - prevX) * s) / steps);
|
|
309
|
+
const iy = Math.round(prevY + ((y - prevY) * s) / steps);
|
|
310
|
+
setDot(ix, iy, seriesIndex);
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
setDot(x, y, seriesIndex);
|
|
314
|
+
}
|
|
315
|
+
prevX = x;
|
|
316
|
+
prevY = y;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const setDot = (x: number, y: number, seriesIndex: number) => {
|
|
321
|
+
if (x < 0 || y < 0 || x >= dotW || y >= dotH) return;
|
|
322
|
+
const col = Math.floor(x / 2);
|
|
323
|
+
const row = Math.floor(y / 4);
|
|
324
|
+
cellMasks[row]![col]! |= DOT_BITS[x % 2]![y % 4]!;
|
|
325
|
+
cellOwner[row]![col] = seriesIndex;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// Draw least-important first so important series own contested cells:
|
|
329
|
+
// other → smallest groups → largest group → total.
|
|
330
|
+
const drawOrder = model.series
|
|
331
|
+
.map((s, i) => ({ s, i }))
|
|
332
|
+
.filter(({ s }) => !s.hidden)
|
|
333
|
+
.sort((a, b) => {
|
|
334
|
+
const rank = (entry: { s: GraphSeries; i: number }) =>
|
|
335
|
+
entry.s.key === TOTAL_SERIES_KEY ? Number.POSITIVE_INFINITY : entry.s.key === OTHER_SERIES_KEY ? -1 : entry.s.total;
|
|
336
|
+
return rank(a) - rank(b);
|
|
337
|
+
});
|
|
338
|
+
for (const { s, i } of drawOrder) plot(i, s.points);
|
|
339
|
+
|
|
340
|
+
// Compose text rows.
|
|
341
|
+
const lines: string[] = [];
|
|
342
|
+
const midRow = Math.floor((plotHeight - 1) / 2);
|
|
343
|
+
for (let row = 0; row < plotHeight; row++) {
|
|
344
|
+
let label = "";
|
|
345
|
+
if (row === 0) label = options.formatValue(model.yMax);
|
|
346
|
+
else if (row === midRow && plotHeight > 2) label = options.formatValue(midValue);
|
|
347
|
+
else if (row === plotHeight - 1) label = options.formatValue(0);
|
|
348
|
+
const axisChar = label ? "┤" : "│";
|
|
349
|
+
let line = colorize(-1, label.padStart(yLabelWidth) + " " + axisChar);
|
|
350
|
+
// Batch consecutive cells with the same owning series into one colorize
|
|
351
|
+
// call to keep ANSI overhead proportional to color changes, not cells.
|
|
352
|
+
let runOwner = -2;
|
|
353
|
+
let runText = "";
|
|
354
|
+
const flush = () => {
|
|
355
|
+
if (!runText) return;
|
|
356
|
+
line += runOwner === -2 ? runText : colorize(runOwner, runText);
|
|
357
|
+
runText = "";
|
|
358
|
+
};
|
|
359
|
+
for (let col = 0; col < plotWidth; col++) {
|
|
360
|
+
const mask = cellMasks[row]![col]!;
|
|
361
|
+
const owner = mask === 0 ? -2 : cellOwner[row]![col]!;
|
|
362
|
+
if (owner !== runOwner) {
|
|
363
|
+
flush();
|
|
364
|
+
runOwner = owner;
|
|
365
|
+
}
|
|
366
|
+
runText += mask === 0 ? " " : String.fromCharCode(BRAILLE_BASE + mask);
|
|
367
|
+
}
|
|
368
|
+
flush();
|
|
369
|
+
lines.push(line);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// X-axis labels: start, optional middle, end.
|
|
373
|
+
const startLabel = options.formatTime(model.domainStartMs);
|
|
374
|
+
const endLabel = options.formatTime(model.domainEndMs);
|
|
375
|
+
const midLabel = plotWidth >= startLabel.length + endLabel.length + 14 ? options.formatTime(model.domainStartMs + (model.domainEndMs - model.domainStartMs) / 2) : "";
|
|
376
|
+
let axis = " ".repeat(yLabelWidth + 2) + startLabel;
|
|
377
|
+
if (midLabel) {
|
|
378
|
+
const midPos = yLabelWidth + 2 + Math.floor(plotWidth / 2 - midLabel.length / 2);
|
|
379
|
+
axis = axis.padEnd(midPos) + midLabel;
|
|
380
|
+
}
|
|
381
|
+
const endPos = yLabelWidth + 2 + plotWidth - endLabel.length;
|
|
382
|
+
axis = axis.padEnd(Math.max(endPos, axis.length + 1)) + endLabel;
|
|
383
|
+
lines.push(colorize(-1, axis));
|
|
384
|
+
|
|
385
|
+
return lines;
|
|
386
|
+
}
|