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,96 @@
|
|
|
1
|
+
/** Terminal chart rendering. */
|
|
2
|
+
import { SPARK, type Paint } from "../../core/paint.ts";
|
|
3
|
+
import { isOverBudget } from "../../core/config.ts";
|
|
4
|
+
import { formatCost, formatNum } from "../../core/format.ts";
|
|
5
|
+
import { bucketLabel, pad2, type Unit } from "../../core/time.ts";
|
|
6
|
+
import type { SeriesPoint } from "../../core/aggregate.ts";
|
|
7
|
+
import type { PeriodStats } from "../../core/types.ts";
|
|
8
|
+
|
|
9
|
+
const UNIT_TITLE: Record<Unit, string> = { day: "Daily", week: "Weekly", month: "Monthly", year: "Yearly" };
|
|
10
|
+
const UNIT_NOUN: Record<Unit, string> = { day: "day", week: "week", month: "month", year: "year" };
|
|
11
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
12
|
+
|
|
13
|
+
const plain: Paint = (_k, t) => t;
|
|
14
|
+
|
|
15
|
+
/** Two header rows for a column: [top, bottom]. */
|
|
16
|
+
function columnLabels(p: SeriesPoint, unit: Unit): [string, string] {
|
|
17
|
+
const d = new Date(p.start);
|
|
18
|
+
// `?? ""`: an invalid date (NaN) has no weekday or month name.
|
|
19
|
+
if (unit === "day") return [pad2(d.getDate()), ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][d.getDay()] ?? ""];
|
|
20
|
+
if (unit === "week") return [`${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, "wk"];
|
|
21
|
+
if (unit === "year") return [String(d.getFullYear()), ""];
|
|
22
|
+
return [MONTHS[d.getMonth()] ?? "", String(d.getFullYear())];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Vertical column chart: y-axis, 1/8-row precision, optional budget line,
|
|
27
|
+
* highlighted current bucket and over-budget columns, summary footer.
|
|
28
|
+
*/
|
|
29
|
+
export function renderChart(
|
|
30
|
+
series: SeriesPoint[],
|
|
31
|
+
unit: Unit,
|
|
32
|
+
opts: { height?: number; budget?: { tokens?: number; cost?: number }; paint?: Paint; title?: string } = {},
|
|
33
|
+
): string[] {
|
|
34
|
+
const paint = opts.paint ?? plain;
|
|
35
|
+
const height = opts.height ?? 8;
|
|
36
|
+
const colW = unit === "day" ? 3 : 6; // bar cells + 1 gap
|
|
37
|
+
const barW = colW - 1;
|
|
38
|
+
|
|
39
|
+
const peakPoint = series.reduce((m, p) => (p.tokens > m.tokens ? p : m), series[0]);
|
|
40
|
+
const dataMax = peakPoint?.tokens ?? 0;
|
|
41
|
+
const budgetTokens = opts.budget?.tokens;
|
|
42
|
+
const scaleMax = Math.max(dataMax, budgetTokens ?? 0);
|
|
43
|
+
const total = series.reduce((a, p) => a + p.tokens, 0);
|
|
44
|
+
const totalCost = series.reduce((a, p) => a + p.cost, 0);
|
|
45
|
+
|
|
46
|
+
const yLabels = [formatNum(scaleMax), formatNum(scaleMax / 2), "0"];
|
|
47
|
+
const yW = Math.max(...yLabels.map((l) => l.length)) + 1;
|
|
48
|
+
const labelFor = (row: number) => (row === height ? yLabels[0] : row === Math.ceil(height / 2) ? yLabels[1] : "");
|
|
49
|
+
const budgetRow = budgetTokens && scaleMax > 0 ? Math.max(1, Math.ceil((budgetTokens / scaleMax) * height)) : 0;
|
|
50
|
+
|
|
51
|
+
const isOver = (p: SeriesPoint) =>
|
|
52
|
+
opts.budget ? isOverBudget({ tokens: p.tokens, cost: p.cost } as PeriodStats, opts.budget) : false;
|
|
53
|
+
const kindOf = (i: number): "bar" | "now" | "over" =>
|
|
54
|
+
isOver(series[i]) ? "over" : i === series.length - 1 ? "now" : "bar";
|
|
55
|
+
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
lines.push(
|
|
58
|
+
paint("title", opts.title ?? `${UNIT_TITLE[unit]} tokens`) +
|
|
59
|
+
paint("dim", ` · ${opts.title ? "" : "last "}${series.length} ${UNIT_NOUN[unit]}${series.length === 1 ? "" : "s"}`) +
|
|
60
|
+
(budgetTokens ? paint("budget", ` ┄ budget ${formatNum(budgetTokens)}`) : ""),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
for (let row = height; row >= 1; row--) {
|
|
64
|
+
let line = paint("axis", `${labelFor(row).padStart(yW)} │`);
|
|
65
|
+
for (let i = 0; i < series.length; i++) {
|
|
66
|
+
const h = scaleMax > 0 ? (series[i].tokens / scaleMax) * height : 0;
|
|
67
|
+
let cell: string;
|
|
68
|
+
if (h >= row) cell = "█";
|
|
69
|
+
else if (h > row - 1 && series[i].tokens > 0) cell = SPARK[Math.min(7, Math.max(0, Math.floor((h - (row - 1)) * 8) - 1))];
|
|
70
|
+
else if (row === 1 && series[i].tokens > 0) cell = "▁";
|
|
71
|
+
else cell = row === budgetRow ? "┄" : " ";
|
|
72
|
+
const filled = cell !== " " && cell !== "┄";
|
|
73
|
+
const body = cell.repeat(barW);
|
|
74
|
+
line += filled ? paint(kindOf(i), body) : cell === "┄" ? paint("budget", body) : body;
|
|
75
|
+
line += row === budgetRow ? paint("budget", "┄") : " ";
|
|
76
|
+
}
|
|
77
|
+
lines.push(line);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
lines.push(paint("axis", `${"".padStart(yW)} └${"─".repeat(series.length * colW)}`));
|
|
81
|
+
const pad = " ".repeat(yW + 2);
|
|
82
|
+
const cols = series.map((p, i) => ({ l: columnLabels(p, unit), now: i === series.length - 1 }));
|
|
83
|
+
lines.push(pad + cols.map((c) => c.l[0].padEnd(colW)).join(""));
|
|
84
|
+
lines.push(paint("dim", pad + cols.map((c) => c.l[1].padEnd(colW)).join("")));
|
|
85
|
+
lines.push(pad + cols.map((c) => (c.now ? paint("now", "▲".padEnd(colW)) : " ".repeat(colW))).join(""));
|
|
86
|
+
|
|
87
|
+
const avg = series.length ? total / series.length : 0;
|
|
88
|
+
lines.push(
|
|
89
|
+
paint(
|
|
90
|
+
"dim",
|
|
91
|
+
`Σ ${formatNum(total)} (${formatCost(totalCost)}) · avg ${formatNum(avg)}/${UNIT_NOUN[unit]}` +
|
|
92
|
+
(dataMax > 0 ? ` · peak ${formatNum(dataMax)} (${bucketLabel(peakPoint.start, unit)})` : ""),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Command: the summary report with a chart. */
|
|
2
|
+
import { collectAllSeries, collectSeries, collectUsage, DEFAULT_COUNT } from "../../core/aggregate.ts";
|
|
3
|
+
import { budgetsFor, getSessionsDir } from "../../core/config.ts";
|
|
4
|
+
import { themePaint } from "../../core/paint.ts";
|
|
5
|
+
import { describeFilter } from "../../core/select.ts";
|
|
6
|
+
import type { Window } from "../../core/types.ts";
|
|
7
|
+
import type { HandlersFor } from "../../command/host.ts";
|
|
8
|
+
import { collectGroups, sumRows } from "../breakdown/query.ts";
|
|
9
|
+
import { renderChart } from "./chart.ts";
|
|
10
|
+
import { renderReport } from "./report.ts";
|
|
11
|
+
|
|
12
|
+
export const reportCommands: HandlersFor<"report"> = {
|
|
13
|
+
async report(cmd, { config, usageOpts, ctx }) {
|
|
14
|
+
const root = getSessionsDir();
|
|
15
|
+
const now = new Date();
|
|
16
|
+
const opts = usageOpts();
|
|
17
|
+
const report = await collectUsage(root, now, config.includeCache, opts);
|
|
18
|
+
const paint = config.chartColor ? themePaint(ctx.ui.theme) : undefined;
|
|
19
|
+
|
|
20
|
+
let chart: string[];
|
|
21
|
+
if (cmd.view === "all") {
|
|
22
|
+
const all = await collectAllSeries(root, now, config.includeCache, opts);
|
|
23
|
+
chart = renderChart(all.series, all.unit, { paint, title: "All time" });
|
|
24
|
+
} else {
|
|
25
|
+
const series = await collectSeries(root, cmd.view, DEFAULT_COUNT[cmd.view], now, config.includeCache, opts);
|
|
26
|
+
const budgetWindow: Window | undefined = cmd.view === "day" ? "today" : cmd.view === "year" ? undefined : cmd.view;
|
|
27
|
+
chart = renderChart(series, cmd.view, { paint, budget: budgetWindow ? budgetsFor(config, budgetWindow) : undefined });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const models = sumRows(await collectGroups(root, "model", "all", now, config.includeCache, opts));
|
|
31
|
+
ctx.ui.notify(renderReport({ report, chart, models, config, now: now.getTime(), filterText: describeFilter(opts.filter) }).join("\n"), "info");
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** The text of the `/tokenburn` summary report (pure). */
|
|
2
|
+
import type { TokenBurnConfig } from "../../core/config.ts";
|
|
3
|
+
import { formatCost, formatNum } from "../../core/format.ts";
|
|
4
|
+
import type { PeriodStats, TokenBurnReport } from "../../core/types.ts";
|
|
5
|
+
import { burnRate, forecastLines } from "../budgets/budgets.ts";
|
|
6
|
+
import { cacheHitRatio, cacheSavings, type Row } from "../breakdown/query.ts";
|
|
7
|
+
|
|
8
|
+
const row = (name: string, p: PeriodStats) =>
|
|
9
|
+
`${name.padEnd(10)} ${formatNum(p.tokens).padStart(8)} tokens ${formatCost(p.cost).padStart(9)} · in:${formatNum(p.input)} out:${formatNum(p.output)} cache:${formatNum(p.cacheRead + p.cacheWrite)} · ${p.sessions} sess`;
|
|
10
|
+
|
|
11
|
+
const FOOTER = "models · providers · projects · sessions · session · filter · export · pricing · chart <view> · <view> sets the status bar · cycle · window · budget · cache · live · on|off";
|
|
12
|
+
|
|
13
|
+
export function renderReport(input: {
|
|
14
|
+
report: TokenBurnReport;
|
|
15
|
+
chart: string[];
|
|
16
|
+
/** All-time totals over every model (cache efficiency). */
|
|
17
|
+
models: Row;
|
|
18
|
+
config: TokenBurnConfig;
|
|
19
|
+
now: number;
|
|
20
|
+
filterText: string;
|
|
21
|
+
}): string[] {
|
|
22
|
+
const { report, chart, models, config, now, filterText } = input;
|
|
23
|
+
const rule = "─".repeat(60);
|
|
24
|
+
const rate = burnRate(report.hour);
|
|
25
|
+
const extra = [
|
|
26
|
+
`burn rate: ${formatNum(rate.tokensPerHour)} tokens/h · ${formatCost(rate.costPerHour)}/h (last 60 min)`,
|
|
27
|
+
...(models.cacheRead + models.cacheWrite > 0
|
|
28
|
+
? [`cache: ${Math.round(cacheHitRatio(models) * 100)}% of prompt tokens read from cache${cacheSavings(models) > 0 ? ` · saved ~${formatCost(cacheSavings(models))}` : ""}`]
|
|
29
|
+
: []),
|
|
30
|
+
...forecastLines(report, config, now),
|
|
31
|
+
];
|
|
32
|
+
return [
|
|
33
|
+
`🔥 TokenBurn Report${filterText ? ` · ${filterText}` : ""}`,
|
|
34
|
+
rule,
|
|
35
|
+
row("Today", report.today),
|
|
36
|
+
row("This Week", report.week),
|
|
37
|
+
row("This Month", report.month),
|
|
38
|
+
row("This Year", report.year),
|
|
39
|
+
row("All Time", report.total),
|
|
40
|
+
rule,
|
|
41
|
+
...chart,
|
|
42
|
+
rule,
|
|
43
|
+
...extra,
|
|
44
|
+
rule,
|
|
45
|
+
FOOTER,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Commands that change what the status line shows and how tokenburn runs. */
|
|
2
|
+
import { STATUS_WINDOWS } from "../../core/types.ts";
|
|
3
|
+
import type { HandlersFor } from "../../command/host.ts";
|
|
4
|
+
|
|
5
|
+
const onOff = (v: boolean) => (v ? "on" : "off");
|
|
6
|
+
|
|
7
|
+
export const statusLineCommands: HandlersFor<"window" | "status" | "cycle" | "cache" | "live" | "enable" | "disable" | "rate" | "hit" | "saved"> = {
|
|
8
|
+
async window(_cmd, { config, persist, note }) {
|
|
9
|
+
config.showWidget = !config.showWidget;
|
|
10
|
+
await persist();
|
|
11
|
+
note(`tokenburn widget: ${config.showWidget ? "enabled" : "hidden"}`);
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
async status(cmd, { config, persist, note }) {
|
|
15
|
+
config.statusWindow = cmd.window;
|
|
16
|
+
await persist();
|
|
17
|
+
note(`tokenburn status window: ${cmd.window}`);
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
async cycle(_cmd, { config, persist, note }) {
|
|
21
|
+
const next = STATUS_WINDOWS[(STATUS_WINDOWS.indexOf(config.statusWindow) + 1) % STATUS_WINDOWS.length];
|
|
22
|
+
config.statusWindow = next;
|
|
23
|
+
await persist();
|
|
24
|
+
note(`tokenburn status window: ${next}`);
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async cache(_cmd, { config, persist, note }) {
|
|
28
|
+
config.includeCache = !config.includeCache;
|
|
29
|
+
await persist();
|
|
30
|
+
note(`tokenburn: cache tokens ${config.includeCache ? "included" : "excluded"}`);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async live(_cmd, { config, persist, note, cancelRefresh }) {
|
|
34
|
+
config.liveRefresh = !config.liveRefresh;
|
|
35
|
+
if (!config.liveRefresh) cancelRefresh();
|
|
36
|
+
await persist();
|
|
37
|
+
note(`tokenburn live refresh ${config.liveRefresh ? "on (after each assistant message)" : "off (only when a turn ends)"}`);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async enable(_cmd, { config, persist, ctx }) {
|
|
41
|
+
if (config.enabled) {
|
|
42
|
+
ctx.ui.notify("tokenburn is already enabled", "info");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
config.enabled = true;
|
|
46
|
+
await persist();
|
|
47
|
+
ctx.ui.notify("tokenburn enabled", "info");
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async disable(_cmd, { config, persist, ctx, cancelRefresh }) {
|
|
51
|
+
if (!config.enabled) {
|
|
52
|
+
ctx.ui.notify("tokenburn is already disabled", "info");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
config.enabled = false;
|
|
56
|
+
cancelRefresh();
|
|
57
|
+
await persist(); // clears the status line and the panel
|
|
58
|
+
ctx.ui.notify("tokenburn disabled: nothing runs in the background. /tokenburn enable turns it back on (charts still work on demand)", "info");
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async rate(_cmd, { config, persist, note }) {
|
|
62
|
+
config.showRate = !config.showRate;
|
|
63
|
+
await persist();
|
|
64
|
+
note(`tokenburn burn rate in the status line: ${onOff(config.showRate)}`);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async hit(_cmd, { config, persist, note }) {
|
|
68
|
+
config.showHit = !config.showHit;
|
|
69
|
+
await persist();
|
|
70
|
+
note(`tokenburn cache hit ratio in the status line: ${onOff(config.showHit)}`);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async saved(_cmd, { config, persist, note }) {
|
|
74
|
+
config.showSaved = !config.showSaved;
|
|
75
|
+
await persist();
|
|
76
|
+
note(`tokenburn estimated cache savings in the status line: ${onOff(config.showSaved)}`);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** What the status line and the optional panel look like. */
|
|
2
|
+
import { budgetsFor, isOverBudget, type TokenBurnConfig } from "../../core/config.ts";
|
|
3
|
+
import { formatCost, formatNum } from "../../core/format.ts";
|
|
4
|
+
import { describeFilter } from "../../core/select.ts";
|
|
5
|
+
import type { PeriodStats, TokenBurnReport } from "../../core/types.ts";
|
|
6
|
+
import { statusExtras } from "./status.ts";
|
|
7
|
+
|
|
8
|
+
type Theme = { fg: (color: any, text: string) => string };
|
|
9
|
+
|
|
10
|
+
/** `🔥 1.2M ($5.10) [today] · 52k/h · 95% hit · ~$12.40 saved` (red `OVER BUDGET` past a budget). */
|
|
11
|
+
export function renderStatusLine(report: TokenBurnReport, config: TokenBurnConfig, theme: Theme, now: number): string {
|
|
12
|
+
const stat = report[config.statusWindow];
|
|
13
|
+
const isOver = isOverBudget(stat, budgetsFor(config, config.statusWindow));
|
|
14
|
+
const fire = isOver ? theme.fg("error", "🔥 OVER BUDGET") : theme.fg("warning", "🔥");
|
|
15
|
+
const tokensText = theme.fg(isOver ? "error" : "accent", formatNum(stat.tokens));
|
|
16
|
+
const costText = theme.fg("dim", `(${formatCost(stat.cost)})`);
|
|
17
|
+
const filterText = describeFilter(config.filter);
|
|
18
|
+
const label = theme.fg("dim", `[${config.statusWindow}${filterText ? ` · ${filterText}` : ""}]`);
|
|
19
|
+
const extras = statusExtras(report, config, now);
|
|
20
|
+
const tail = extras.length > 0 ? theme.fg("dim", ` · ${extras.join(" · ")}`) : "";
|
|
21
|
+
return `${fire} ${tokensText} ${costText} ${label}${tail}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The Today / Week / Month / Year / Total panel below the editor. */
|
|
25
|
+
export function renderWidget(report: TokenBurnReport, config: TokenBurnConfig, theme: Theme): string[] {
|
|
26
|
+
const header = theme.fg("accent", " 🔥 TokenBurn Dashboard ");
|
|
27
|
+
const line = (label: string, p: PeriodStats, b: { tokens?: number; cost?: number } = {}) => {
|
|
28
|
+
const over = isOverBudget(p, b);
|
|
29
|
+
const tokStr = (over ? theme.fg("error", "!") : " ") + formatNum(p.tokens).padStart(8);
|
|
30
|
+
const inStr = `(in:${formatNum(p.input)} out:${formatNum(p.output)})`.padEnd(20);
|
|
31
|
+
const costStr = formatCost(p.cost).padStart(8);
|
|
32
|
+
return ` ${theme.fg("muted", label.padEnd(8))} ${tokStr} ${theme.fg("dim", inStr)} ${costStr}`;
|
|
33
|
+
};
|
|
34
|
+
return [
|
|
35
|
+
`┌${header}${"─".repeat(21)}┐`,
|
|
36
|
+
line("Today", report.today, budgetsFor(config, "today")),
|
|
37
|
+
line("Week", report.week, budgetsFor(config, "week")),
|
|
38
|
+
line("Month", report.month, budgetsFor(config, "month")),
|
|
39
|
+
line("Year", report.year),
|
|
40
|
+
line("Total", report.total),
|
|
41
|
+
`└${"─".repeat(46)}┘`,
|
|
42
|
+
];
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Status line: window start, burn rate / cache hit / savings extras for the status window (pure). */
|
|
2
|
+
import type { TokenBurnConfig } from "../../core/config.ts";
|
|
3
|
+
import { formatCost, formatNum } from "../../core/format.ts";
|
|
4
|
+
import { bucketStart } from "../../core/time.ts";
|
|
5
|
+
import type { PeriodStats, Rate, TokenBurnReport, Window } from "../../core/types.ts";
|
|
6
|
+
import { cacheHitRatio } from "../breakdown/query.ts";
|
|
7
|
+
|
|
8
|
+
const HOUR = 3_600_000;
|
|
9
|
+
|
|
10
|
+
/** Start of a status window (`hour` = rolling last 60 minutes, `total` = 0). */
|
|
11
|
+
export function windowStart(w: Window, now: number): number {
|
|
12
|
+
if (w === "hour") return now - HOUR;
|
|
13
|
+
if (w === "total") return 0;
|
|
14
|
+
return bucketStart(now, w === "today" ? "day" : w);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Tokens/hour and $/hour averaged over the window (counted from the first turn when the window started earlier; at least one hour). */
|
|
18
|
+
export function windowRate(stat: PeriodStats, w: Window, firstTs: number | undefined, now: number): Rate {
|
|
19
|
+
const from = Math.max(windowStart(w, now), firstTs ?? now);
|
|
20
|
+
const hours = Math.max((now - from) / HOUR, 1);
|
|
21
|
+
return { tokensPerHour: stat.tokens / hours, costPerHour: stat.cost / hours };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Estimated dollars saved by cache reads in a window. */
|
|
25
|
+
export const statSavings = (s: PeriodStats): number => Math.max(0, s.cacheBase - s.cacheActual);
|
|
26
|
+
|
|
27
|
+
/** Status-line extras for the window, in order: burn rate, cache hit ratio, cache savings (each only when enabled and meaningful). */
|
|
28
|
+
export function statusExtras(report: TokenBurnReport, cfg: TokenBurnConfig, now: number): string[] {
|
|
29
|
+
const stat = report[cfg.statusWindow];
|
|
30
|
+
const out: string[] = [];
|
|
31
|
+
if (cfg.showRate && stat.tokens > 0) out.push(`${formatNum(windowRate(stat, cfg.statusWindow, report.firstTs, now).tokensPerHour)}/h`);
|
|
32
|
+
if (cfg.showHit && stat.input + stat.cacheRead + stat.cacheWrite > 0) out.push(`${Math.round(cacheHitRatio(stat) * 100)}% hit`);
|
|
33
|
+
if (cfg.showSaved && statSavings(stat) >= 0.005) out.push(`~${formatCost(statSavings(stat))} saved`);
|
|
34
|
+
return out;
|
|
35
|
+
}
|