pi-tokenburn 0.5.8 → 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/README.md +59 -4
- package/extensions/command/completions.ts +80 -0
- package/extensions/command/handlers.ts +22 -0
- package/extensions/command/host.ts +25 -0
- package/extensions/command/parse.ts +173 -0
- package/extensions/core/aggregate.ts +155 -0
- package/extensions/core/config.ts +157 -0
- package/extensions/core/format.ts +12 -0
- package/extensions/core/paint.ts +35 -0
- package/extensions/core/parser.ts +261 -0
- package/extensions/core/select.ts +105 -0
- package/extensions/core/time.ts +25 -0
- package/extensions/core/types.ts +43 -0
- package/extensions/features/breakdown/commands.ts +32 -0
- package/extensions/features/breakdown/query.ts +138 -0
- package/extensions/features/breakdown/table.ts +100 -0
- package/extensions/features/budgets/budgets.ts +105 -0
- package/extensions/features/budgets/commands.ts +20 -0
- package/extensions/features/export/commands.ts +19 -0
- package/extensions/features/export/export.ts +61 -0
- package/extensions/features/filter/commands.ts +18 -0
- package/extensions/features/live/coalescer.ts +71 -0
- package/extensions/features/pricing/commands.ts +30 -0
- package/extensions/features/pricing/report.ts +59 -0
- package/extensions/features/report/chart.ts +96 -0
- package/extensions/features/report/commands.ts +33 -0
- package/extensions/features/report/report.ts +47 -0
- package/extensions/features/status-line/commands.ts +78 -0
- package/extensions/features/status-line/render.ts +43 -0
- package/extensions/features/status-line/status.ts +35 -0
- package/extensions/tokenburn.ts +78 -1030
- package/package.json +5 -3
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Grouped usage queries: per model, provider, project and session, over a period. */
|
|
2
|
+
import { HOUR_MS, loadSelected, turnTokens } from "../../core/aggregate.ts";
|
|
3
|
+
import type { UsageOptions } from "../../core/select.ts";
|
|
4
|
+
import { bucketStart } from "../../core/time.ts";
|
|
5
|
+
|
|
6
|
+
export const PERIODS = ["hour", "day", "week", "month", "year", "all"] as const;
|
|
7
|
+
export type Period = (typeof PERIODS)[number];
|
|
8
|
+
/** Words accepted for a period, mapped to it (`today` = day, `total` = all). */
|
|
9
|
+
export const PERIOD_ALIASES: Record<string, Period> = {
|
|
10
|
+
hour: "hour",
|
|
11
|
+
day: "day",
|
|
12
|
+
today: "day",
|
|
13
|
+
week: "week",
|
|
14
|
+
month: "month",
|
|
15
|
+
year: "year",
|
|
16
|
+
all: "all",
|
|
17
|
+
total: "all",
|
|
18
|
+
};
|
|
19
|
+
export const PERIOD_TITLE: Record<Period, string> = {
|
|
20
|
+
hour: "last 60 minutes",
|
|
21
|
+
day: "today",
|
|
22
|
+
week: "this week",
|
|
23
|
+
month: "this month",
|
|
24
|
+
year: "this year",
|
|
25
|
+
all: "all time",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Start of a period; `hour` is the rolling last 60 minutes, `all` is 0. */
|
|
29
|
+
export function periodStart(now: number, p: Period): number {
|
|
30
|
+
if (p === "hour") return now - HOUR_MS;
|
|
31
|
+
if (p === "all") return 0;
|
|
32
|
+
return bucketStart(now, p);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const GROUPS = ["model", "provider", "project", "session"] as const;
|
|
36
|
+
export type GroupBy = (typeof GROUPS)[number];
|
|
37
|
+
|
|
38
|
+
export interface Row {
|
|
39
|
+
key: string;
|
|
40
|
+
provider: string;
|
|
41
|
+
model: string;
|
|
42
|
+
project: string;
|
|
43
|
+
session: string;
|
|
44
|
+
tokens: number;
|
|
45
|
+
input: number;
|
|
46
|
+
output: number;
|
|
47
|
+
cacheRead: number;
|
|
48
|
+
cacheWrite: number;
|
|
49
|
+
cost: number;
|
|
50
|
+
turns: number;
|
|
51
|
+
sessions: number;
|
|
52
|
+
first: number;
|
|
53
|
+
last: number;
|
|
54
|
+
/** What the cached tokens would have cost as plain input, and what they did cost (turns with a known input price). */
|
|
55
|
+
cacheBase: number;
|
|
56
|
+
cacheActual: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const emptyRow = (key: string): Row => ({
|
|
60
|
+
key, provider: "", model: "", project: "", session: "",
|
|
61
|
+
tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0,
|
|
62
|
+
turns: 0, sessions: 0, first: Infinity, last: 0, cacheBase: 0, cacheActual: 0,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Share of prompt tokens served from cache, 0..1. */
|
|
66
|
+
export function cacheHitRatio(r: { input: number; cacheRead: number; cacheWrite: number }): number {
|
|
67
|
+
const prompt = r.input + r.cacheRead + r.cacheWrite;
|
|
68
|
+
return prompt > 0 ? r.cacheRead / prompt : 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Estimated dollars saved by cache reads versus paying the plain input price for them. */
|
|
72
|
+
export const cacheSavings = (r: Row): number => Math.max(0, r.cacheBase - r.cacheActual);
|
|
73
|
+
|
|
74
|
+
/** Rows for one grouping over a period, biggest cost first (ties: most tokens). */
|
|
75
|
+
export async function collectGroups(
|
|
76
|
+
root: string,
|
|
77
|
+
by: GroupBy,
|
|
78
|
+
period: Period,
|
|
79
|
+
now: Date = new Date(),
|
|
80
|
+
includeCache = true,
|
|
81
|
+
opts?: UsageOptions,
|
|
82
|
+
): Promise<Row[]> {
|
|
83
|
+
const since = periodStart(now.getTime(), period);
|
|
84
|
+
const rows = new Map<string, Row>();
|
|
85
|
+
for (const turns of await loadSelected(root, opts)) {
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
for (const t of turns) {
|
|
88
|
+
if (t.ts < since) continue;
|
|
89
|
+
const key =
|
|
90
|
+
by === "model" ? `${t.provider}/${t.model}` : by === "provider" ? t.provider : by === "project" ? t.project : t.session;
|
|
91
|
+
let r = rows.get(key);
|
|
92
|
+
if (!r) {
|
|
93
|
+
r = emptyRow(key);
|
|
94
|
+
r.provider = t.provider;
|
|
95
|
+
r.model = t.model;
|
|
96
|
+
r.project = t.project;
|
|
97
|
+
r.session = t.session;
|
|
98
|
+
rows.set(key, r);
|
|
99
|
+
}
|
|
100
|
+
r.tokens += turnTokens(t, includeCache);
|
|
101
|
+
r.input += t.input;
|
|
102
|
+
r.output += t.output;
|
|
103
|
+
r.cacheRead += t.cacheRead;
|
|
104
|
+
r.cacheWrite += t.cacheWrite;
|
|
105
|
+
r.cost += t.cost;
|
|
106
|
+
r.turns++;
|
|
107
|
+
if (t.ts < r.first) r.first = t.ts;
|
|
108
|
+
if (t.ts > r.last) r.last = t.ts;
|
|
109
|
+
if (t.input > 0 && t.costInput > 0) {
|
|
110
|
+
r.cacheBase += (t.cacheRead * t.costInput) / t.input;
|
|
111
|
+
r.cacheActual += t.costCacheRead;
|
|
112
|
+
}
|
|
113
|
+
if (!seen.has(key)) {
|
|
114
|
+
seen.add(key);
|
|
115
|
+
r.sessions++;
|
|
116
|
+
}
|
|
117
|
+
// Mixed groups (e.g. a provider with many models) keep the label only when unambiguous.
|
|
118
|
+
if (by === "provider") r.model = "";
|
|
119
|
+
if (by === "provider" || by === "model") r.project = "";
|
|
120
|
+
if (by !== "session") r.session = "";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [...rows.values()].sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Sum of rows (for a totals line). */
|
|
127
|
+
export function sumRows(rows: Row[]): Row {
|
|
128
|
+
const t = emptyRow("total");
|
|
129
|
+
for (const r of rows) {
|
|
130
|
+
t.tokens += r.tokens; t.input += r.input; t.output += r.output;
|
|
131
|
+
t.cacheRead += r.cacheRead; t.cacheWrite += r.cacheWrite; t.cost += r.cost;
|
|
132
|
+
t.turns += r.turns; t.sessions += r.sessions;
|
|
133
|
+
t.cacheBase += r.cacheBase; t.cacheActual += r.cacheActual;
|
|
134
|
+
if (r.first < t.first) t.first = r.first;
|
|
135
|
+
if (r.last > t.last) t.last = r.last;
|
|
136
|
+
}
|
|
137
|
+
return t;
|
|
138
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Usage tables (models / providers / projects / sessions) and the current-session summary. */
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { bar, type Paint } from "../../core/paint.ts";
|
|
5
|
+
import { formatCost, formatNum } from "../../core/format.ts";
|
|
6
|
+
import { PERIOD_TITLE, cacheHitRatio, cacheSavings, sumRows, type GroupBy, type Period, type Row } from "./query.ts";
|
|
7
|
+
import { pad2 } from "../../core/time.ts";
|
|
8
|
+
|
|
9
|
+
interface Col {
|
|
10
|
+
title: string;
|
|
11
|
+
align: "left" | "right";
|
|
12
|
+
cell: (r: Row, max: Row) => string;
|
|
13
|
+
foot?: (t: Row) => string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pct = (n: number) => `${Math.round(n * 100)}%`;
|
|
17
|
+
const clip = (s: string, n: number) => (s.length <= n ? s : "…" + s.slice(s.length - n + 1));
|
|
18
|
+
|
|
19
|
+
/** `~/x/y` for paths under home. */
|
|
20
|
+
export function shortPath(p: string, home = homedir()): string {
|
|
21
|
+
return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function stamp(ts: number): string {
|
|
25
|
+
const d = new Date(ts);
|
|
26
|
+
return Number.isFinite(ts) ? `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` : "-";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tail = (): Col[] => [
|
|
30
|
+
{ title: "tokens", align: "right", cell: (r) => formatNum(r.tokens), foot: (t) => formatNum(t.tokens) },
|
|
31
|
+
{ title: "in", align: "right", cell: (r) => formatNum(r.input), foot: (t) => formatNum(t.input) },
|
|
32
|
+
{ title: "out", align: "right", cell: (r) => formatNum(r.output), foot: (t) => formatNum(t.output) },
|
|
33
|
+
{ title: "cache hit", align: "right", cell: (r) => (r.cacheRead + r.cacheWrite + r.input > 0 ? pct(cacheHitRatio(r)) : "-"), foot: (t) => pct(cacheHitRatio(t)) },
|
|
34
|
+
{ title: "cache saved", align: "right", cell: (r) => (cacheSavings(r) > 0 ? "~" + formatCost(cacheSavings(r)) : "-"), foot: (t) => (cacheSavings(t) > 0 ? "~" + formatCost(cacheSavings(t)) : "-") },
|
|
35
|
+
{ title: "cost", align: "right", cell: (r) => formatCost(r.cost), foot: (t) => formatCost(t.cost) },
|
|
36
|
+
{ title: "share", align: "left", cell: (r, max) => bar(r.cost > 0 ? r.cost : r.tokens, max.cost > 0 ? max.cost : max.tokens, 10) },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const COLUMNS: Record<GroupBy, () => Col[]> = {
|
|
40
|
+
model: () => [
|
|
41
|
+
{ title: "model", align: "left", cell: (r) => clip(r.model, 32), foot: () => "total" },
|
|
42
|
+
{ title: "provider", align: "left", cell: (r) => r.provider },
|
|
43
|
+
{ title: "turns", align: "right", cell: (r) => String(r.turns), foot: (t) => String(t.turns) },
|
|
44
|
+
...tail(),
|
|
45
|
+
],
|
|
46
|
+
provider: () => [
|
|
47
|
+
{ title: "provider", align: "left", cell: (r) => r.provider, foot: () => "total" },
|
|
48
|
+
{ title: "turns", align: "right", cell: (r) => String(r.turns), foot: (t) => String(t.turns) },
|
|
49
|
+
...tail(),
|
|
50
|
+
],
|
|
51
|
+
project: () => [
|
|
52
|
+
{ title: "project", align: "left", cell: (r) => clip(shortPath(r.project), 38), foot: () => "total" },
|
|
53
|
+
{ title: "sessions", align: "right", cell: (r) => String(r.sessions), foot: (t) => String(t.sessions) },
|
|
54
|
+
{ title: "turns", align: "right", cell: (r) => String(r.turns), foot: (t) => String(t.turns) },
|
|
55
|
+
...tail(),
|
|
56
|
+
],
|
|
57
|
+
session: () => [
|
|
58
|
+
{ title: "started", align: "left", cell: (r) => stamp(r.first), foot: () => "total" },
|
|
59
|
+
{ title: "project", align: "left", cell: (r) => clip(shortPath(r.project), 28) },
|
|
60
|
+
{ title: "turns", align: "right", cell: (r) => String(r.turns), foot: (t) => String(t.turns) },
|
|
61
|
+
...tail(),
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const NOUN: Record<GroupBy, string> = { model: "Models", provider: "Providers", project: "Projects", session: "Top sessions" };
|
|
66
|
+
|
|
67
|
+
/** A bordered table with a totals line. `limit` caps the rows (sessions leaderboard). */
|
|
68
|
+
export function renderTable(
|
|
69
|
+
by: GroupBy,
|
|
70
|
+
rows: Row[],
|
|
71
|
+
period: Period,
|
|
72
|
+
opts: { limit?: number; filterText?: string; paint?: Paint } = {},
|
|
73
|
+
): string[] {
|
|
74
|
+
const paint = opts.paint ?? ((_k, t) => t);
|
|
75
|
+
const shown = opts.limit ? rows.slice(0, opts.limit) : rows;
|
|
76
|
+
const title = `${NOUN[by]} · ${PERIOD_TITLE[period]}${opts.filterText ? ` · ${opts.filterText}` : ""}`;
|
|
77
|
+
if (rows.length === 0) return [paint("title", title), paint("dim", "no usage in this period")];
|
|
78
|
+
|
|
79
|
+
const cols = COLUMNS[by]();
|
|
80
|
+
const total = sumRows(rows);
|
|
81
|
+
const max = shown.reduce((m, r) => (r.cost > m.cost || (m.cost === 0 && r.tokens > m.tokens) ? r : m), shown[0]);
|
|
82
|
+
const body = shown.map((r) => cols.map((c) => c.cell(r, max)));
|
|
83
|
+
const foot = cols.map((c) => c.foot?.(total) ?? "");
|
|
84
|
+
const widths = cols.map((c, i) => Math.max(c.title.length, foot[i].length, ...body.map((b) => b[i].length)));
|
|
85
|
+
const fmt = (cells: string[]) =>
|
|
86
|
+
cells.map((v, i) => (cols[i].align === "right" ? v.padStart(widths[i]) : v.padEnd(widths[i]))).join(" ");
|
|
87
|
+
const rule = "─".repeat(widths.reduce((a, w) => a + w, 0) + 2 * (cols.length - 1));
|
|
88
|
+
|
|
89
|
+
const lines = [paint("title", title), paint("axis", rule), paint("dim", fmt(cols.map((c) => c.title))), paint("axis", rule), ...body.map(fmt)];
|
|
90
|
+
if (shown.length < rows.length) lines.push(paint("dim", `… ${rows.length - shown.length} more (total row covers all ${rows.length})`));
|
|
91
|
+
lines.push(paint("axis", rule), fmt(foot));
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Spend of one session file (the current one), against the period totals. */
|
|
96
|
+
export function sessionSummary(file: string, row: Row | undefined, todayCost: number): string {
|
|
97
|
+
if (!row) return `This session (${basename(file)}): no usage yet`;
|
|
98
|
+
const share = todayCost > 0 ? `, ${pct(row.cost / todayCost)} of today` : "";
|
|
99
|
+
return `This session: ${formatNum(row.tokens)} tokens · ${formatCost(row.cost)} · ${row.turns} turns · cache hit ${pct(cacheHitRatio(row))}${share}`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Budgets: projection ("runs out Thu 14:05"), burn rate and threshold warnings (pure). */
|
|
2
|
+
import { budgetsFor, type TokenBurnConfig } from "../../core/config.ts";
|
|
3
|
+
import { formatCost, formatNum } from "../../core/format.ts";
|
|
4
|
+
import { bucketStart, type Unit } from "../../core/time.ts";
|
|
5
|
+
import type { PeriodStats, Rate, TokenBurnReport, Window } from "../../core/types.ts";
|
|
6
|
+
|
|
7
|
+
/** Burn rate from the rolling last-60-minutes stats. */
|
|
8
|
+
export const burnRate = (hour: PeriodStats): Rate => ({ tokensPerHour: hour.tokens, costPerHour: hour.cost });
|
|
9
|
+
|
|
10
|
+
/** Start of the bucket after the one starting at `start`. */
|
|
11
|
+
export function nextBucket(start: number, unit: Exclude<Unit, "year">): number {
|
|
12
|
+
const d = new Date(start);
|
|
13
|
+
if (unit === "day") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
|
|
14
|
+
if (unit === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7).getTime();
|
|
15
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Projection {
|
|
19
|
+
/** used / budget, 0..n */
|
|
20
|
+
ratio: number;
|
|
21
|
+
/** When the budget runs out at the pace so far, if that is before the period ends. */
|
|
22
|
+
exhaustAt?: number;
|
|
23
|
+
/** Projected usage at the end of the period. */
|
|
24
|
+
projected: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Linear projection: pace = used / elapsed. Undefined when nothing has been used yet. */
|
|
28
|
+
export function project(used: number, budget: number, start: number, end: number, now: number): Projection | undefined {
|
|
29
|
+
const elapsed = now - start;
|
|
30
|
+
if (!(used > 0) || !(elapsed > 0) || !(budget > 0)) return undefined;
|
|
31
|
+
const pace = used / elapsed;
|
|
32
|
+
const proj: Projection = { ratio: used / budget, projected: pace * (end - start) };
|
|
33
|
+
if (used < budget) {
|
|
34
|
+
const at = now + (budget - used) / pace;
|
|
35
|
+
if (at < end) proj.exhaustAt = at;
|
|
36
|
+
}
|
|
37
|
+
return proj;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
41
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
42
|
+
|
|
43
|
+
/** `today 14:05`, `tomorrow 09:00`, or `Thu 14:05`. */
|
|
44
|
+
export function formatWhen(ts: number, now: number): string {
|
|
45
|
+
const d = new Date(ts);
|
|
46
|
+
const days = Math.round((bucketStart(ts, "day") - bucketStart(now, "day")) / 86_400_000);
|
|
47
|
+
const day = days === 0 ? "today" : days === 1 ? "tomorrow" : DAYS[d.getDay()];
|
|
48
|
+
return `${day} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const BUDGET_WINDOWS: { w: Window; unit: Exclude<Unit, "year">; name: string }[] = [
|
|
52
|
+
{ w: "today", unit: "day", name: "daily" },
|
|
53
|
+
{ w: "week", unit: "week", name: "weekly" },
|
|
54
|
+
{ w: "month", unit: "month", name: "monthly" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** Projection lines for each configured budget, e.g. "at this pace the weekly token budget runs out Thu 14:05". */
|
|
58
|
+
export function forecastLines(report: TokenBurnReport, cfg: TokenBurnConfig, now: number): string[] {
|
|
59
|
+
const lines: string[] = [];
|
|
60
|
+
for (const { w, unit, name } of BUDGET_WINDOWS) {
|
|
61
|
+
const b = budgetsFor(cfg, w);
|
|
62
|
+
const start = bucketStart(now, unit);
|
|
63
|
+
const end = nextBucket(start, unit);
|
|
64
|
+
const stat = report[w];
|
|
65
|
+
for (const kind of ["tokens", "cost"] as const) {
|
|
66
|
+
const budget = b[kind];
|
|
67
|
+
if (budget === undefined) continue;
|
|
68
|
+
const fmt = kind === "tokens" ? (n: number) => `${formatNum(n)} tokens` : formatCost;
|
|
69
|
+
const p = project(stat[kind], budget, start, end, now);
|
|
70
|
+
if (!p) continue;
|
|
71
|
+
const used = `${Math.round(p.ratio * 100)}% of ${fmt(budget)} used`;
|
|
72
|
+
if (p.ratio >= 1) lines.push(`${name} ${kind} budget: ${used} (over)`);
|
|
73
|
+
else if (p.exhaustAt !== undefined) lines.push(`${name} ${kind} budget: ${used}; at this pace it runs out ${formatWhen(p.exhaustAt, now)}`);
|
|
74
|
+
else lines.push(`${name} ${kind} budget: ${used}; on track (${fmt(p.projected)} projected)`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Warning {
|
|
81
|
+
/** Stable id: one notification per id (budget + period bucket + level). */
|
|
82
|
+
id: string;
|
|
83
|
+
level: "warn" | "over";
|
|
84
|
+
message: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Budgets at or above `warnPct` percent (and those over 100%), each with a once-per-bucket id. */
|
|
88
|
+
export function budgetWarnings(report: TokenBurnReport, cfg: TokenBurnConfig, now: number): Warning[] {
|
|
89
|
+
const out: Warning[] = [];
|
|
90
|
+
if (cfg.warnPct <= 0) return out;
|
|
91
|
+
for (const { w, unit, name } of BUDGET_WINDOWS) {
|
|
92
|
+
const b = budgetsFor(cfg, w);
|
|
93
|
+
const bucket = bucketStart(now, unit);
|
|
94
|
+
for (const kind of ["tokens", "cost"] as const) {
|
|
95
|
+
const budget = b[kind];
|
|
96
|
+
if (budget === undefined) continue;
|
|
97
|
+
const ratio = report[w][kind] / budget;
|
|
98
|
+
const fmt = kind === "tokens" ? (n: number) => `${formatNum(n)} tokens` : formatCost;
|
|
99
|
+
const text = `${Math.round(ratio * 100)}% of the ${name} ${kind} budget (${fmt(report[w][kind])} / ${fmt(budget)})`;
|
|
100
|
+
if (ratio > 1) out.push({ id: `${w}:${kind}:${bucket}:over`, level: "over", message: `tokenburn: over budget, ${text}` });
|
|
101
|
+
else if (ratio * 100 >= cfg.warnPct) out.push({ id: `${w}:${kind}:${bucket}:warn`, level: "warn", message: `tokenburn: ${text}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Commands: set a budget, set the warning threshold. */
|
|
2
|
+
import { formatCost, formatNum } from "../../core/format.ts";
|
|
3
|
+
import type { TokenBurnConfig } from "../../core/config.ts";
|
|
4
|
+
import type { HandlersFor } from "../../command/host.ts";
|
|
5
|
+
|
|
6
|
+
export const budgetCommands: HandlersFor<"budget" | "warn"> = {
|
|
7
|
+
async budget(cmd, { config, persist, note }) {
|
|
8
|
+
const key = `${cmd.period}${cmd.isCost ? "Cost" : "Tokens"}` as keyof TokenBurnConfig["budgets"];
|
|
9
|
+
config.budgets[key] = cmd.amount;
|
|
10
|
+
await persist();
|
|
11
|
+
note(`tokenburn ${cmd.period} budget: ${cmd.isCost ? formatCost(cmd.amount) : formatNum(cmd.amount) + " tokens"}`);
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
async warn(cmd, { config, persist, note, resetWarnings }) {
|
|
15
|
+
config.warnPct = cmd.pct;
|
|
16
|
+
resetWarnings();
|
|
17
|
+
await persist();
|
|
18
|
+
note(cmd.pct === 0 ? "tokenburn budget warnings: off" : `tokenburn: warning when a budget reaches ${cmd.pct}%`);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Command: write usage to a csv / json file. */
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { getSessionsDir } from "../../core/config.ts";
|
|
5
|
+
import type { HandlersFor } from "../../command/host.ts";
|
|
6
|
+
import { collectExport, formatExport } from "./export.ts";
|
|
7
|
+
|
|
8
|
+
export const exportCommands: HandlersFor<"export"> = {
|
|
9
|
+
async export(cmd, { usageOpts, ctx }) {
|
|
10
|
+
const rows = await collectExport(getSessionsDir(), cmd.period, new Date(), usageOpts());
|
|
11
|
+
const path = resolve(ctx.cwd ?? process.cwd(), cmd.path ?? `tokenburn-${cmd.period}.${cmd.format}`);
|
|
12
|
+
try {
|
|
13
|
+
await writeFile(path, formatExport(rows, cmd.format), "utf8");
|
|
14
|
+
ctx.ui.notify(`tokenburn: exported ${rows.length} rows (${cmd.period}) to ${path}`, "info");
|
|
15
|
+
} catch (e) {
|
|
16
|
+
ctx.ui.notify(`tokenburn: export failed: ${(e as Error).message}`, "warning");
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Export usage as CSV or JSON: one row per day x provider x model x project. */
|
|
2
|
+
import { loadSelected } from "../../core/aggregate.ts";
|
|
3
|
+
import type { UsageOptions } from "../../core/select.ts";
|
|
4
|
+
import { periodStart, type Period } from "../breakdown/query.ts";
|
|
5
|
+
import { bucketStart, pad2 } from "../../core/time.ts";
|
|
6
|
+
|
|
7
|
+
export interface ExportRow {
|
|
8
|
+
date: string;
|
|
9
|
+
provider: string;
|
|
10
|
+
model: string;
|
|
11
|
+
project: string;
|
|
12
|
+
turns: number;
|
|
13
|
+
input: number;
|
|
14
|
+
output: number;
|
|
15
|
+
cacheRead: number;
|
|
16
|
+
cacheWrite: number;
|
|
17
|
+
cost: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const EXPORT_FORMATS = ["csv", "json"] as const;
|
|
21
|
+
export type ExportFormat = (typeof EXPORT_FORMATS)[number];
|
|
22
|
+
|
|
23
|
+
const day = (ts: number) => {
|
|
24
|
+
const d = new Date(bucketStart(ts, "day"));
|
|
25
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function collectExport(root: string, period: Period, now: Date = new Date(), opts?: UsageOptions): Promise<ExportRow[]> {
|
|
29
|
+
const since = periodStart(now.getTime(), period);
|
|
30
|
+
const rows = new Map<string, ExportRow>();
|
|
31
|
+
for (const turns of await loadSelected(root, opts)) {
|
|
32
|
+
for (const t of turns) {
|
|
33
|
+
if (t.ts < since) continue;
|
|
34
|
+
const date = day(t.ts);
|
|
35
|
+
const key = `${date}\u0000${t.provider}\u0000${t.model}\u0000${t.project}`;
|
|
36
|
+
let r = rows.get(key);
|
|
37
|
+
if (!r) rows.set(key, (r = { date, provider: t.provider, model: t.model, project: t.project, turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }));
|
|
38
|
+
r.turns++;
|
|
39
|
+
r.input += t.input;
|
|
40
|
+
r.output += t.output;
|
|
41
|
+
r.cacheRead += t.cacheRead;
|
|
42
|
+
r.cacheWrite += t.cacheWrite;
|
|
43
|
+
r.cost += t.cost;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...rows.values()].sort((a, b) => a.date.localeCompare(b.date) || b.cost - a.cost);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FIELDS: (keyof ExportRow)[] = ["date", "provider", "model", "project", "turns", "input", "output", "cacheRead", "cacheWrite", "cost"];
|
|
50
|
+
|
|
51
|
+
/** RFC 4180 quoting; also neutralises spreadsheet formula injection (=, +, -, @ at the start). */
|
|
52
|
+
export function csvCell(v: string | number): string {
|
|
53
|
+
let s = typeof v === "number" ? (Number.isInteger(v) ? String(v) : v.toFixed(6)) : v;
|
|
54
|
+
if (typeof v === "string" && /^[=+\-@\t\r]/.test(s)) s = "'" + s;
|
|
55
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatExport(rows: ExportRow[], format: ExportFormat): string {
|
|
59
|
+
if (format === "json") return JSON.stringify(rows, null, 2) + "\n";
|
|
60
|
+
return [FIELDS.join(","), ...rows.map((r) => FIELDS.map((f) => csvCell(r[f])).join(","))].join("\n") + "\n";
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Command: limit everything to a provider / model / project. */
|
|
2
|
+
import { describeFilter, hasFilter } from "../../core/select.ts";
|
|
3
|
+
import type { HandlersFor } from "../../command/host.ts";
|
|
4
|
+
|
|
5
|
+
export const filterCommands: HandlersFor<"filter"> = {
|
|
6
|
+
async filter(cmd, { config, persist, note, ctx }) {
|
|
7
|
+
if (cmd.clear) config.filter = {};
|
|
8
|
+
else if (cmd.key) {
|
|
9
|
+
if (cmd.value === undefined) delete config.filter[cmd.key];
|
|
10
|
+
else config.filter[cmd.key] = cmd.value;
|
|
11
|
+
} else {
|
|
12
|
+
ctx.ui.notify(hasFilter(config.filter) ? `tokenburn filter: ${describeFilter(config.filter)}` : "tokenburn filter: none (all providers, models and projects)", "info");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
await persist();
|
|
16
|
+
note(hasFilter(config.filter) ? `tokenburn filter: ${describeFilter(config.filter)}` : "tokenburn filter cleared: showing everything");
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Live refresh scheduling. */
|
|
2
|
+
|
|
3
|
+
export interface Timers {
|
|
4
|
+
set: (fn: () => void, ms: number) => unknown;
|
|
5
|
+
clear: (handle: unknown) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const realTimers: Timers = {
|
|
9
|
+
set: (fn, ms) => setTimeout(fn, ms),
|
|
10
|
+
clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs `run` at most once per `delayMs` window however often `schedule()` is called
|
|
15
|
+
* (trailing edge), and never two runs at once: a request that arrives mid-run triggers one
|
|
16
|
+
* more run afterwards. A burst of 50 messages costs one refresh. Failures are swallowed;
|
|
17
|
+
* the timer never keeps the process alive.
|
|
18
|
+
*/
|
|
19
|
+
export function createCoalescer(
|
|
20
|
+
run: () => Promise<void>,
|
|
21
|
+
delayMs: number | (() => number),
|
|
22
|
+
timers: Timers = realTimers,
|
|
23
|
+
) {
|
|
24
|
+
let timer: unknown;
|
|
25
|
+
let running = false;
|
|
26
|
+
let dirty = false;
|
|
27
|
+
|
|
28
|
+
const execute = async () => {
|
|
29
|
+
if (running) {
|
|
30
|
+
dirty = true;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
running = true;
|
|
34
|
+
try {
|
|
35
|
+
await run();
|
|
36
|
+
} catch {
|
|
37
|
+
/* a failed refresh must never surface */
|
|
38
|
+
} finally {
|
|
39
|
+
running = false;
|
|
40
|
+
}
|
|
41
|
+
if (dirty) {
|
|
42
|
+
dirty = false;
|
|
43
|
+
schedule();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const schedule = () => {
|
|
48
|
+
if (timer !== undefined) return; // one is already pending: coalesce
|
|
49
|
+
const ms = typeof delayMs === "function" ? delayMs() : delayMs;
|
|
50
|
+
timer = timers.set(() => {
|
|
51
|
+
timer = undefined;
|
|
52
|
+
void execute();
|
|
53
|
+
}, ms);
|
|
54
|
+
(timer as { unref?: () => void } | undefined)?.unref?.();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const cancel = () => {
|
|
58
|
+
if (timer !== undefined) timers.clear(timer);
|
|
59
|
+
timer = undefined;
|
|
60
|
+
dirty = false;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Run now (dropping any pending delayed run). */
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (timer !== undefined) timers.clear(timer);
|
|
66
|
+
timer = undefined;
|
|
67
|
+
return execute();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return { schedule, cancel, flush, isPending: () => timer !== undefined };
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Commands: where costs come from, and the price table. */
|
|
2
|
+
import { getSessionsDir } from "../../core/config.ts";
|
|
3
|
+
import { sanitizePrices } from "../../core/select.ts";
|
|
4
|
+
import type { HandlersFor } from "../../command/host.ts";
|
|
5
|
+
import { collectPricing, renderPricing } from "./report.ts";
|
|
6
|
+
|
|
7
|
+
export const pricingCommands: HandlersFor<"pricing" | "price"> = {
|
|
8
|
+
async pricing(cmd, { config, persist, ctx }) {
|
|
9
|
+
if (cmd.mode) {
|
|
10
|
+
config.priceMode = cmd.mode;
|
|
11
|
+
await persist();
|
|
12
|
+
}
|
|
13
|
+
const rep = await collectPricing(getSessionsDir(), config.prices, config.priceMode, config.filter);
|
|
14
|
+
ctx.ui.notify(renderPricing(rep, config.prices, config.priceMode).join("\n"), "info");
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async price(cmd, { config, persist, note }) {
|
|
18
|
+
if (cmd.remove) {
|
|
19
|
+
const had = cmd.model in config.prices;
|
|
20
|
+
delete config.prices[cmd.model];
|
|
21
|
+
await persist();
|
|
22
|
+
note(had ? `tokenburn: removed price for ${cmd.model}` : `tokenburn: no price for ${cmd.model}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const next = sanitizePrices({ [cmd.model]: { input: cmd.input, output: cmd.output, cacheRead: cmd.cacheRead, cacheWrite: cmd.cacheWrite } });
|
|
26
|
+
Object.assign(config.prices, next);
|
|
27
|
+
await persist();
|
|
28
|
+
note(`tokenburn: price for ${cmd.model} set ($/M in ${cmd.input}, out ${cmd.output}); mode ${config.priceMode}`);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Where costs come from: the session logs or the configured price table. */
|
|
2
|
+
import { loadAll } from "../../core/parser.ts";
|
|
3
|
+
import { applyPricing, matchesFilter, type PriceMode, type PriceTable, type UsageFilter } from "../../core/select.ts";
|
|
4
|
+
import { formatCost, formatNum } from "../../core/format.ts";
|
|
5
|
+
|
|
6
|
+
export interface PricingReport {
|
|
7
|
+
logCost: number;
|
|
8
|
+
logTurns: number;
|
|
9
|
+
tableCost: number;
|
|
10
|
+
tableTurns: number;
|
|
11
|
+
/** Models with token usage but no cost and no price entry: their cost is counted as $0. */
|
|
12
|
+
unpriced: { model: string; tokens: number; turns: number }[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function collectPricing(root: string, prices: PriceTable, mode: PriceMode, filter?: UsageFilter): Promise<PricingReport> {
|
|
16
|
+
const rep: PricingReport = { logCost: 0, logTurns: 0, tableCost: 0, tableTurns: 0, unpriced: [] };
|
|
17
|
+
const un = new Map<string, { model: string; tokens: number; turns: number }>();
|
|
18
|
+
for (const turns of await loadAll(root)) {
|
|
19
|
+
for (const raw of turns) {
|
|
20
|
+
if (!matchesFilter(raw, filter)) continue;
|
|
21
|
+
const { turn, source } = applyPricing(raw, prices, mode);
|
|
22
|
+
if (source === "table") {
|
|
23
|
+
rep.tableCost += turn.cost;
|
|
24
|
+
rep.tableTurns++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
rep.logCost += turn.cost;
|
|
28
|
+
rep.logTurns++;
|
|
29
|
+
const tokens = turn.input + turn.output + turn.cacheRead + turn.cacheWrite;
|
|
30
|
+
if (turn.cost === 0 && tokens > 0) {
|
|
31
|
+
const key = `${turn.provider}/${turn.model}`;
|
|
32
|
+
const u = un.get(key) ?? { model: key, tokens: 0, turns: 0 };
|
|
33
|
+
u.tokens += tokens;
|
|
34
|
+
u.turns++;
|
|
35
|
+
un.set(key, u);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
rep.unpriced = [...un.values()].sort((a, b) => b.tokens - a.tokens);
|
|
40
|
+
return rep;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderPricing(rep: PricingReport, prices: PriceTable, mode: PriceMode): string[] {
|
|
44
|
+
const entries = Object.entries(prices);
|
|
45
|
+
const lines = [
|
|
46
|
+
`Pricing · mode ${mode} (${mode === "fill" ? "price table only fills turns the log has no cost for" : "price table overrides the log cost"})`,
|
|
47
|
+
`cost from session logs: ${formatCost(rep.logCost)} (${rep.logTurns} turns)`,
|
|
48
|
+
`cost from price table: ${formatCost(rep.tableCost)} (${rep.tableTurns} turns)`,
|
|
49
|
+
];
|
|
50
|
+
if (entries.length > 0) {
|
|
51
|
+
lines.push("price table ($ per million tokens: in / out / cache read / cache write):");
|
|
52
|
+
for (const [name, p] of entries) lines.push(` ${name}: ${p.input} / ${p.output} / ${p.cacheRead} / ${p.cacheWrite}`);
|
|
53
|
+
} else lines.push("price table: empty. /tokenburn price <model> <in> <out> [cacheRead] [cacheWrite]");
|
|
54
|
+
if (rep.unpriced.length > 0) {
|
|
55
|
+
lines.push("unpriced (tokens but $0, counted as free; add a price):");
|
|
56
|
+
for (const u of rep.unpriced.slice(0, 8)) lines.push(` ${u.model}: ${formatNum(u.tokens)} tokens in ${u.turns} turns`);
|
|
57
|
+
}
|
|
58
|
+
return lines;
|
|
59
|
+
}
|