pi-tokenburn 0.7.4 → 0.8.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 +2 -2
- package/extensions/command/completions.ts +1 -1
- package/extensions/core/aggregate.ts +41 -1
- package/extensions/core/config.ts +1 -1
- package/extensions/core/plugin-logs.ts +57 -0
- package/extensions/core/types.ts +10 -0
- package/extensions/features/report/report.ts +6 -0
- package/extensions/features/status-line/commands.ts +1 -1
- package/extensions/features/status-line/status.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Totals match the `tokenburn` CLI exactly.
|
|
|
17
17
|
|
|
18
18
|
## Features
|
|
19
19
|
|
|
20
|
-
- **Status bar:** `🔥 1.2M ($5.10) [today] · 52k/h · 95% hit · ~$12.40 saved`: tokens, cost, burn rate, cache hit ratio and estimated cache
|
|
20
|
+
- **Status bar:** `🔥 1.2M ($5.10) [today] · 52k/h · 95% hit · ~$12.40 saved`: tokens, cost, burn rate, cache hit ratio and estimated savings (prompt cache, plus [pi-context-diet](https://github.com/sorinirimies/pi-context-diet) and [pi-squeeze](https://github.com/sorinirimies/pi-squeeze) when installed) for the chosen period (each extra toggleable). Turns red with `OVER BUDGET` past your limit.
|
|
21
21
|
- **Tables:** usage per model, provider, project and session, for any period, with cache hit %, savings, cost and share.
|
|
22
22
|
- **Filter and export:** limit everything to a provider, model or project; write csv or json.
|
|
23
23
|
- **Pricing:** a price table fills in (or overrides) costs the logs don't have.
|
|
@@ -61,7 +61,7 @@ pi install git:github.com/sorinirimies/pi-tokenburn
|
|
|
61
61
|
| `/tokenburn warn <pct>` | Notify once when a budget reaches `pct` percent (default 80) and once when it is exceeded |
|
|
62
62
|
| `/tokenburn rate` | Toggle the burn rate in the status line (`42k/h`: tokens per hour averaged over the status period) |
|
|
63
63
|
| `/tokenburn hit` | Toggle the cache hit ratio (`95% hit`) in the status line |
|
|
64
|
-
| `/tokenburn saved` | Toggle the estimated
|
|
64
|
+
| `/tokenburn saved` | Toggle the estimated savings (`~$12.40 saved`: prompt cache, pi-context-diet, pi-squeeze) in the status line |
|
|
65
65
|
| `/tokenburn pricing` | Where costs come from (session logs vs your price table) and which models are unpriced |
|
|
66
66
|
| `/tokenburn price <model> <in> <out>` | Set a price in $ per million tokens (key `model` or `provider/model`); `price remove <model>` deletes it. `pricing mode <fill\|override>`: use the table only where the log has no cost (default), or always |
|
|
67
67
|
| `/tokenburn window` | Toggle the panel below the editor |
|
|
@@ -41,7 +41,7 @@ const TOP_LEVEL: CompletionItem[] = [
|
|
|
41
41
|
{ value: "price ", label: "price", description: "Set a $/M-token price for a model" },
|
|
42
42
|
{ value: "rate", label: "rate", description: "Toggle the tokens/hour burn rate in the status line" },
|
|
43
43
|
{ value: "hit", label: "hit", description: "Toggle the cache hit ratio in the status line" },
|
|
44
|
-
{ value: "saved", label: "saved", description: "Toggle the estimated cache
|
|
44
|
+
{ value: "saved", label: "saved", description: "Toggle the estimated savings (cache, pi context-diet, pi squeeze) in the status line" },
|
|
45
45
|
{ value: "cycle", label: "cycle", description: "Rotate the status-bar period" },
|
|
46
46
|
{ value: "window", label: "window", description: "Toggle the panel below the editor" },
|
|
47
47
|
{ value: "budget ", label: "budget", description: "Set a day/week/month budget" },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Aggregation of parsed turns into period totals and time series. */
|
|
2
|
+
import { DIET_LOG, loadPluginLog, pluginLogPath, SQUEEZE_LOG } from "./plugin-logs.ts";
|
|
2
3
|
import { loadAll, type Turn } from "./parser.ts";
|
|
3
|
-
import { cacheValue, selectTurns, type UsageOptions } from "./select.ts";
|
|
4
|
+
import { cacheValue, hasFilter, selectTurns, type UsageOptions } from "./select.ts";
|
|
4
5
|
import { bucketLabel, bucketStart, type Unit } from "./time.ts";
|
|
5
6
|
import type { PeriodStats, TokenBurnReport } from "./types.ts";
|
|
6
7
|
|
|
@@ -14,6 +15,13 @@ const emptyStats = (): PeriodStats => ({
|
|
|
14
15
|
sessions: 0,
|
|
15
16
|
cacheBase: 0,
|
|
16
17
|
cacheActual: 0,
|
|
18
|
+
cacheReadPriced: 0,
|
|
19
|
+
dietTokens: 0,
|
|
20
|
+
dietRequests: 0,
|
|
21
|
+
dietUsd: 0,
|
|
22
|
+
squeezeTokens: 0,
|
|
23
|
+
squeezeOutputs: 0,
|
|
24
|
+
squeezeUsd: 0,
|
|
17
25
|
});
|
|
18
26
|
|
|
19
27
|
export const HOUR_MS = 3_600_000;
|
|
@@ -33,6 +41,8 @@ export async function collectUsage(
|
|
|
33
41
|
now: Date = new Date(),
|
|
34
42
|
includeCache = true,
|
|
35
43
|
opts?: UsageOptions,
|
|
44
|
+
/** Where the pi plugins' savings logs are (default: pi's agent dir). */
|
|
45
|
+
logDir?: string,
|
|
36
46
|
): Promise<TokenBurnReport> {
|
|
37
47
|
const startOfHour = now.getTime() - HOUR_MS;
|
|
38
48
|
const startOfDay = bucketStart(now.getTime(), "day");
|
|
@@ -59,6 +69,7 @@ export async function collectUsage(
|
|
|
59
69
|
const cv = cacheValue(t);
|
|
60
70
|
p.cacheBase += cv.base;
|
|
61
71
|
p.cacheActual += cv.actual;
|
|
72
|
+
if (cv.base > 0) p.cacheReadPriced += t.cacheRead;
|
|
62
73
|
};
|
|
63
74
|
|
|
64
75
|
for (const turns of await loadSelected(sessionsRoot, opts)) {
|
|
@@ -81,6 +92,35 @@ export async function collectUsage(
|
|
|
81
92
|
if (maxTs >= startOfHour) report.hour.sessions++;
|
|
82
93
|
}
|
|
83
94
|
|
|
95
|
+
// The pi plugins' logs are not attributed to providers, models or projects: only unfiltered views count them.
|
|
96
|
+
if (!hasFilter(opts?.filter)) {
|
|
97
|
+
const windows: [PeriodStats, number][] = [
|
|
98
|
+
[report.total, -Infinity],
|
|
99
|
+
[report.year, startOfYear],
|
|
100
|
+
[report.month, startOfMonth],
|
|
101
|
+
[report.week, startOfWeek],
|
|
102
|
+
[report.today, startOfDay],
|
|
103
|
+
[report.hour, startOfHour],
|
|
104
|
+
];
|
|
105
|
+
const [diet, squeeze] = await Promise.all([loadPluginLog(pluginLogPath(DIET_LOG, logDir)), loadPluginLog(pluginLogPath(SQUEEZE_LOG, logDir))]);
|
|
106
|
+
for (const [p, start] of windows) {
|
|
107
|
+
for (const r of diet) {
|
|
108
|
+
if (r.ts < start) continue;
|
|
109
|
+
p.dietTokens += r.tokens;
|
|
110
|
+
p.dietRequests++;
|
|
111
|
+
}
|
|
112
|
+
for (const r of squeeze) {
|
|
113
|
+
if (r.ts < start) continue;
|
|
114
|
+
p.squeezeTokens += r.tokens;
|
|
115
|
+
p.squeezeOutputs++;
|
|
116
|
+
}
|
|
117
|
+
// Priced at the window's own prices: elided context would mostly have been cache reads,
|
|
118
|
+
// squeezed bash output would have been fresh input.
|
|
119
|
+
p.dietUsd = p.cacheReadPriced > 0 ? (p.dietTokens * p.cacheActual) / p.cacheReadPriced : 0;
|
|
120
|
+
p.squeezeUsd = p.cacheReadPriced > 0 ? (p.squeezeTokens * p.cacheBase) / p.cacheReadPriced : 0;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
84
124
|
return report;
|
|
85
125
|
}
|
|
86
126
|
|
|
@@ -23,7 +23,7 @@ export interface TokenBurnConfig {
|
|
|
23
23
|
showRate: boolean;
|
|
24
24
|
/** Show the cache hit ratio of the status window in the status line. */
|
|
25
25
|
showHit: boolean;
|
|
26
|
-
/** Show the estimated cache
|
|
26
|
+
/** Show the estimated savings (prompt cache, pi-context-diet, pi-squeeze) of the status window in the status line. */
|
|
27
27
|
showSaved: boolean;
|
|
28
28
|
/** Notify once when a budget reaches this percentage (1 to 100); 0 turns warnings off. */
|
|
29
29
|
warnPct: number;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The savings logs of pi plugins, in pi's agent dir: one JSON line per saving
|
|
3
|
+
* (`{"ts":"2026-01-02T09:15:00.000Z","tokens":18400,...}`). Only `ts` and `tokens` are read.
|
|
4
|
+
*
|
|
5
|
+
* - pi-context-diet: `context-diet-savings.jsonl`, one line per request it trimmed;
|
|
6
|
+
* - pi-squeeze: `squeeze-savings.jsonl`, one line per bash output it shortened.
|
|
7
|
+
*
|
|
8
|
+
* A missing or unreadable file means "no savings"; lines that do not parse are skipped.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile, stat } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { getConfigDir } from "./config.ts";
|
|
13
|
+
|
|
14
|
+
export const DIET_LOG = "context-diet-savings.jsonl";
|
|
15
|
+
export const SQUEEZE_LOG = "squeeze-savings.jsonl";
|
|
16
|
+
|
|
17
|
+
export interface LogRecord {
|
|
18
|
+
ts: number;
|
|
19
|
+
tokens: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Where a plugin's log is: `file` in pi's agent dir. */
|
|
23
|
+
export const pluginLogPath = (file: string, dir: string = getConfigDir()) => join(dir, file);
|
|
24
|
+
|
|
25
|
+
export function parsePluginLog(text: string): LogRecord[] {
|
|
26
|
+
const out: LogRecord[] = [];
|
|
27
|
+
for (const line of text.split("\n")) {
|
|
28
|
+
if (!line.trim()) continue;
|
|
29
|
+
try {
|
|
30
|
+
const r = JSON.parse(line) as { ts?: unknown; tokens?: unknown };
|
|
31
|
+
const ts = typeof r.ts === "string" ? Date.parse(r.ts) : NaN;
|
|
32
|
+
const tokens = r.tokens;
|
|
33
|
+
if (Number.isFinite(ts) && typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) out.push({ ts, tokens });
|
|
34
|
+
} catch {
|
|
35
|
+
/* a torn or foreign line */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The status line refreshes after every message: re-read a log only when it changed.
|
|
42
|
+
const cache = new Map<string, { mtimeMs: number; size: number; records: LogRecord[] }>();
|
|
43
|
+
|
|
44
|
+
export async function loadPluginLog(path: string): Promise<LogRecord[]> {
|
|
45
|
+
try {
|
|
46
|
+
const s = await stat(path);
|
|
47
|
+
const hit = cache.get(path);
|
|
48
|
+
if (hit && hit.mtimeMs === s.mtimeMs && hit.size === s.size) return hit.records;
|
|
49
|
+
const records = parsePluginLog(await readFile(path, "utf8"));
|
|
50
|
+
cache.set(path, { mtimeMs: s.mtimeMs, size: s.size, records });
|
|
51
|
+
return records;
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const clearPluginLogCache = () => cache.clear();
|
package/extensions/core/types.ts
CHANGED
|
@@ -20,6 +20,16 @@ export interface PeriodStats {
|
|
|
20
20
|
/** What cache reads would have cost as plain input vs what they cost (turns with a known input price). */
|
|
21
21
|
cacheBase: number;
|
|
22
22
|
cacheActual: number;
|
|
23
|
+
/** Cache-read tokens of the turns with a known input price (`cacheActual` / this = the cache-read price). */
|
|
24
|
+
cacheReadPriced: number;
|
|
25
|
+
/** pi-context-diet: tokens kept out of requests, the requests, and what they would have cost as cache reads. */
|
|
26
|
+
dietTokens: number;
|
|
27
|
+
dietRequests: number;
|
|
28
|
+
dietUsd: number;
|
|
29
|
+
/** pi-squeeze: tokens cut from bash output, the outputs, and what they would have cost as input. */
|
|
30
|
+
squeezeTokens: number;
|
|
31
|
+
squeezeOutputs: number;
|
|
32
|
+
squeezeUsd: number;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export interface TokenBurnReport {
|
|
@@ -27,6 +27,12 @@ export function renderReport(input: {
|
|
|
27
27
|
...(models.cacheRead + models.cacheWrite > 0
|
|
28
28
|
? [`cache: ${Math.round(cacheHitRatio(models) * 100)}% of prompt tokens read from cache${cacheSavings(models) > 0 ? ` · saved ~${formatCost(cacheSavings(models))}` : ""}`]
|
|
29
29
|
: []),
|
|
30
|
+
...(report.total.dietTokens > 0
|
|
31
|
+
? [`pi context-diet: ~${formatNum(report.total.dietTokens)} tokens kept out of ${report.total.dietRequests} requests${report.total.dietUsd > 0 ? ` · saved ~${formatCost(report.total.dietUsd)}` : ""}`]
|
|
32
|
+
: []),
|
|
33
|
+
...(report.total.squeezeTokens > 0
|
|
34
|
+
? [`pi squeeze: ~${formatNum(report.total.squeezeTokens)} tokens cut from ${report.total.squeezeOutputs} bash outputs${report.total.squeezeUsd > 0 ? ` · saved ~${formatCost(report.total.squeezeUsd)}` : ""}`]
|
|
35
|
+
: []),
|
|
30
36
|
...forecastLines(report, config, now),
|
|
31
37
|
];
|
|
32
38
|
return [
|
|
@@ -73,6 +73,6 @@ export const statusLineCommands: HandlersFor<"window" | "status" | "cycle" | "ca
|
|
|
73
73
|
async saved(_cmd, { config, persist, note }) {
|
|
74
74
|
config.showSaved = !config.showSaved;
|
|
75
75
|
await persist();
|
|
76
|
-
note(`tokenburn estimated cache
|
|
76
|
+
note(`tokenburn estimated savings (cache, pi context-diet, pi squeeze) in the status line: ${onOff(config.showSaved)}`);
|
|
77
77
|
},
|
|
78
78
|
};
|
|
@@ -20,8 +20,8 @@ export function windowRate(stat: PeriodStats, w: Window, firstTs: number | undef
|
|
|
20
20
|
return { tokensPerHour: stat.tokens / hours, costPerHour: stat.cost / hours };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/** Estimated dollars saved
|
|
24
|
-
export const statSavings = (s: PeriodStats): number => Math.max(0, s.cacheBase - s.cacheActual);
|
|
23
|
+
/** Estimated dollars saved in a window: cache reads, plus what pi-context-diet and pi-squeeze kept out. */
|
|
24
|
+
export const statSavings = (s: PeriodStats): number => Math.max(0, s.cacheBase - s.cacheActual) + s.dietUsd + s.squeezeUsd;
|
|
25
25
|
|
|
26
26
|
/** Status-line extras for the window, in order: burn rate, cache hit ratio, cache savings (each only when enabled and meaningful). */
|
|
27
27
|
export function statusExtras(report: TokenBurnReport, cfg: TokenBurnConfig, now: number): string[] {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tokenburn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Pi extension for live token usage monitoring and budget tracking (hour / day / week / month / year / all). 0 prompt tokens.",
|
|
5
5
|
"keywords": ["pi-package", "pi-extension", "tokenburn", "tokens", "analytics", "budget", "stats"],
|
|
6
6
|
"license": "MIT",
|