@ohgodtamit/pi-usage 0.1.0-alpha.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 +8 -0
- package/LICENSE +22 -0
- package/README.md +403 -0
- package/THIRD_PARTY_NOTICES.md +54 -0
- package/dist/aggregate.d.ts +392 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/cache.d.ts +27 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/format.d.ts +46 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/freshness.d.ts +42 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/mascot.d.ts +35 -0
- package/dist/mascot.d.ts.map +1 -0
- package/dist/prices.d.ts +24 -0
- package/dist/prices.d.ts.map +1 -0
- package/dist/provider.d.ts +147 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/view.d.ts +207 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/zai.d.ts +57 -0
- package/dist/zai.d.ts.map +1 -0
- package/package.json +60 -0
- package/src/aggregate.ts +1838 -0
- package/src/cache.ts +100 -0
- package/src/config.ts +93 -0
- package/src/format.ts +166 -0
- package/src/freshness.ts +55 -0
- package/src/index.ts +642 -0
- package/src/mascot.ts +227 -0
- package/src/prices.ts +63 -0
- package/src/provider.ts +704 -0
- package/src/view.ts +2585 -0
- package/src/zai.ts +101 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi usage extension — a Claude Code-style `/usage` panel for pi.
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* /usage Open the interactive usage panel (5H / day / week / all
|
|
6
|
+
* windows, quota bars, model/skill/plugin/tool/project
|
|
7
|
+
* breakdowns).
|
|
8
|
+
* /usage-config Set your 5-hour and weekly USD budgets.
|
|
9
|
+
* /usage-widget Toggle a compact always-on spend widget above the editor.
|
|
10
|
+
*
|
|
11
|
+
* Config: ~/.pi/agent/usage.json (see config.ts).
|
|
12
|
+
*
|
|
13
|
+
* Budgets are user-defined because pi works with any provider — unlike Claude
|
|
14
|
+
* Code's subscription, pi has no built-in quota. Set limits that match your
|
|
15
|
+
* plan and the panel shows progress against them.
|
|
16
|
+
*/
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import {
|
|
21
|
+
type AttributionMaps,
|
|
22
|
+
buildAttributionMaps,
|
|
23
|
+
type Report,
|
|
24
|
+
scanSessions,
|
|
25
|
+
} from "./aggregate.ts";
|
|
26
|
+
import { loadScanCache, type ScanCache, saveScanCache } from "./cache.ts";
|
|
27
|
+
import { loadConfig, saveConfig, type UsageConfig } from "./config.ts";
|
|
28
|
+
import { formatCost, formatTokens } from "./format.ts";
|
|
29
|
+
import { isReportCacheFresh } from "./freshness.ts";
|
|
30
|
+
import {
|
|
31
|
+
type ActiveProvider,
|
|
32
|
+
detectActiveProvider,
|
|
33
|
+
fetchProviderQuota,
|
|
34
|
+
parseRateLimits,
|
|
35
|
+
type RateLimitWindow,
|
|
36
|
+
} from "./provider.ts";
|
|
37
|
+
import { type UsageAction, UsageView, type ViewKey } from "./view.ts";
|
|
38
|
+
|
|
39
|
+
const HOUR = 60 * 60 * 1000;
|
|
40
|
+
const DAY = 24 * HOUR;
|
|
41
|
+
const CACHE_TTL = 2 * 60 * 1000; // 2 minutes
|
|
42
|
+
// RPC widgets cannot negotiate a terminal viewport, so the dashboard is
|
|
43
|
+
// deliberately bounded to a fixed portable width/height.
|
|
44
|
+
const RPC_RENDER_WIDTH = 80;
|
|
45
|
+
const RPC_PAGE_HEIGHT = 20;
|
|
46
|
+
const RPC_PANEL_KEY = "usage-panel";
|
|
47
|
+
const RPC_STATUS_KEY = "usage-scan";
|
|
48
|
+
|
|
49
|
+
// RPC clients receive plain string[] widgets; strip all theming so no ANSI
|
|
50
|
+
// escape codes ever reach the protocol.
|
|
51
|
+
const PLAIN_THEME = {
|
|
52
|
+
fg: (_color: string, text: string) => text,
|
|
53
|
+
bg: (_color: string, text: string) => text,
|
|
54
|
+
bold: (text: string) => text,
|
|
55
|
+
} as Theme;
|
|
56
|
+
|
|
57
|
+
export default function usageExtension(pi: ExtensionAPI) {
|
|
58
|
+
const home = homedir();
|
|
59
|
+
let config: UsageConfig = loadConfig();
|
|
60
|
+
let maps: AttributionMaps = {
|
|
61
|
+
toolToPlugin: new Map(),
|
|
62
|
+
skillToPlugin: new Map(),
|
|
63
|
+
};
|
|
64
|
+
let cache: { report: Report; at: number } | null = null;
|
|
65
|
+
// Epoch-ms of the most recent assistant turn seen this run. A new turn
|
|
66
|
+
// invalidates the in-memory report cache (see isReportCacheFresh) so the
|
|
67
|
+
// trend graph reflects current usage instead of a stale snapshot.
|
|
68
|
+
let lastTurnAt = 0;
|
|
69
|
+
// Persistent incremental scan cache (loaded lazily on first scan), so only
|
|
70
|
+
// new/changed session files are re-parsed across opens and restarts.
|
|
71
|
+
let scanCache: ScanCache | null = null;
|
|
72
|
+
// Latest context, captured for widget updates (setWidget needs ctx.ui).
|
|
73
|
+
let latestCtx: ExtensionContext | null = null;
|
|
74
|
+
// Active provider + most recent rate-limit headers, captured live from the
|
|
75
|
+
// provider responses so the panel can show the provider's own quota.
|
|
76
|
+
let activeProvider: ActiveProvider | null = null;
|
|
77
|
+
let capturedRateLimits: RateLimitWindow[] = [];
|
|
78
|
+
// Raw headers from the most recent provider response (for Codex x-codex-* parsing).
|
|
79
|
+
let capturedHeaders: Record<string, string> = {};
|
|
80
|
+
|
|
81
|
+
// Track the active provider and capture rate-limit headers from responses.
|
|
82
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
83
|
+
activeProvider = await detectActiveProvider(ctx.modelRegistry, ctx.model);
|
|
84
|
+
});
|
|
85
|
+
pi.on("model_select", async (event, ctx) => {
|
|
86
|
+
activeProvider = await detectActiveProvider(ctx.modelRegistry, event.model);
|
|
87
|
+
refreshWidget();
|
|
88
|
+
});
|
|
89
|
+
pi.on("after_provider_response", async (event) => {
|
|
90
|
+
// Only trust headers from successful responses; 4xx/5xx often omit them.
|
|
91
|
+
if (event.status >= 400) return;
|
|
92
|
+
capturedRateLimits = parseRateLimits(event.headers);
|
|
93
|
+
capturedHeaders = event.headers;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const captureCtx = (ctx: ExtensionContext) => {
|
|
97
|
+
latestCtx = ctx;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Rebuild attribution maps when resources (re)load; refresh the widget.
|
|
101
|
+
pi.on("session_start", async () => {
|
|
102
|
+
maps = buildAttributionMaps(pi);
|
|
103
|
+
refreshWidget();
|
|
104
|
+
});
|
|
105
|
+
pi.on("session_start", async (_e, ctx) => captureCtx(ctx));
|
|
106
|
+
pi.on("turn_end", async (_e, ctx) => {
|
|
107
|
+
captureCtx(ctx);
|
|
108
|
+
// A turn just completed and has been persisted to the session file — mark
|
|
109
|
+
// it so the next /usage open rescans instead of serving a stale cache.
|
|
110
|
+
lastTurnAt = Date.now();
|
|
111
|
+
refreshWidget();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Delegation lifecycle channels are optional — they are only emitted by
|
|
115
|
+
// delegation frameworks (e.g. a subagent roster extension), and subscribing
|
|
116
|
+
// on the shared event bus is safe when nothing emits. A finished subagent
|
|
117
|
+
// run appends turns, so treat it like turn_end for cache freshness. There
|
|
118
|
+
// is no extension-lifetime disposer registry, so these subscriptions live
|
|
119
|
+
// for the extension's lifetime.
|
|
120
|
+
for (const channel of ["subagents:completed", "subagents:failed", "subagents:resumed"]) {
|
|
121
|
+
pi.events.on(channel, () => {
|
|
122
|
+
lastTurnAt = Date.now();
|
|
123
|
+
refreshWidget();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ------------------------------------------------------------------ /usage
|
|
128
|
+
|
|
129
|
+
pi.registerCommand("usage", {
|
|
130
|
+
description:
|
|
131
|
+
"Usage panel — Overview / Models / Delegation / Daily / Stats / Hourly / Providers / Wrapped AI",
|
|
132
|
+
handler: async (_args, ctx) => {
|
|
133
|
+
await openUsagePanel(ctx);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// View shortcuts: open the panel directly on a specific menu.
|
|
138
|
+
pi.registerCommand("usage-models", {
|
|
139
|
+
description: "Open the usage panel on the Models view",
|
|
140
|
+
handler: async (_args, ctx) => {
|
|
141
|
+
await openUsagePanel(ctx, "models");
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
pi.registerCommand("usage-delegation", {
|
|
145
|
+
description: "Open the usage panel on the Delegation view",
|
|
146
|
+
handler: async (_args, ctx) => {
|
|
147
|
+
await openUsagePanel(ctx, "delegation");
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
pi.registerCommand("usage-daily", {
|
|
151
|
+
description: "Open the usage panel on the Daily summary view",
|
|
152
|
+
handler: async (_args, ctx) => {
|
|
153
|
+
await openUsagePanel(ctx, "daily");
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
pi.registerCommand("usage-stats", {
|
|
157
|
+
description: "Open the usage panel on the Stats (contribution graph) view",
|
|
158
|
+
handler: async (_args, ctx) => {
|
|
159
|
+
await openUsagePanel(ctx, "stats");
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
pi.registerCommand("usage-hourly", {
|
|
163
|
+
description: "Open the usage panel on the Hourly (time-of-day) view",
|
|
164
|
+
handler: async (_args, ctx) => {
|
|
165
|
+
await openUsagePanel(ctx, "hourly");
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
pi.registerCommand("usage-providers", {
|
|
169
|
+
description: "Open the usage panel on the Providers view",
|
|
170
|
+
handler: async (_args, ctx) => {
|
|
171
|
+
await openUsagePanel(ctx, "providers");
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
pi.registerCommand("usage-agents", {
|
|
175
|
+
description: "Compatibility alias for the Providers view",
|
|
176
|
+
handler: async (_args, ctx) => {
|
|
177
|
+
await openUsagePanel(ctx, "providers");
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
pi.registerCommand("usage-wrapped", {
|
|
181
|
+
description: "Open the usage panel on the Wrapped AI year-in-review view",
|
|
182
|
+
handler: async (_args, ctx) => {
|
|
183
|
+
await openUsagePanel(ctx, "wrapped");
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
async function openUsagePanel(ctx: ExtensionContext, initialView?: ViewKey): Promise<void> {
|
|
188
|
+
requireUI(ctx, "/usage");
|
|
189
|
+
if (ctx.mode === "rpc") {
|
|
190
|
+
await openRpcUsagePanel(ctx, initialView);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (ctx.mode !== "tui") return;
|
|
194
|
+
|
|
195
|
+
// Constructed with tui undefined; it is bound inside custom() where pi
|
|
196
|
+
// hands us the terminal instance and active theme.
|
|
197
|
+
const view = new UsageView({
|
|
198
|
+
theme: ctx.ui.theme,
|
|
199
|
+
tui: undefined,
|
|
200
|
+
maps,
|
|
201
|
+
home,
|
|
202
|
+
getConfig: () => config,
|
|
203
|
+
onClose: () => undefined,
|
|
204
|
+
onRefresh: () => {
|
|
205
|
+
void runScan(ctx, view, true);
|
|
206
|
+
void runProviderQuota(ctx, view);
|
|
207
|
+
},
|
|
208
|
+
onConfigure: () => {
|
|
209
|
+
void configureLimits(ctx);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
if (initialView) view.setInitialView(initialView);
|
|
213
|
+
|
|
214
|
+
await ctx.ui.custom<undefined>((tui, theme, _kb, done) => {
|
|
215
|
+
view.bind(tui, theme, () => done(undefined));
|
|
216
|
+
void runProviderQuota(ctx, view);
|
|
217
|
+
|
|
218
|
+
const cached = cache;
|
|
219
|
+
const fresh = isReportCacheFresh(cached, Date.now(), lastTurnAt, CACHE_TTL);
|
|
220
|
+
if (fresh && cached) view.setReport(cached.report);
|
|
221
|
+
else void runScan(ctx, view, false);
|
|
222
|
+
|
|
223
|
+
return view;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function openRpcUsagePanel(ctx: ExtensionContext, initialView?: ViewKey): Promise<void> {
|
|
228
|
+
const view = new UsageView({
|
|
229
|
+
theme: PLAIN_THEME,
|
|
230
|
+
tui: undefined,
|
|
231
|
+
maps,
|
|
232
|
+
home,
|
|
233
|
+
getConfig: () => config,
|
|
234
|
+
onClose: () => undefined,
|
|
235
|
+
onRefresh: () => undefined,
|
|
236
|
+
onConfigure: () => undefined,
|
|
237
|
+
});
|
|
238
|
+
if (initialView) view.setInitialView(initialView);
|
|
239
|
+
let page = 0;
|
|
240
|
+
|
|
241
|
+
const updateDashboard = () => {
|
|
242
|
+
const all = view.renderPortable(RPC_RENDER_WIDTH);
|
|
243
|
+
const pageCount = Math.max(1, Math.ceil(all.length / RPC_PAGE_HEIGHT));
|
|
244
|
+
page = Math.min(page, pageCount - 1);
|
|
245
|
+
const start = page * RPC_PAGE_HEIGHT;
|
|
246
|
+
const lines = all.slice(start, start + RPC_PAGE_HEIGHT);
|
|
247
|
+
lines.push(`Page ${page + 1}/${pageCount} · ${all.length} lines · select an action below`);
|
|
248
|
+
ctx.ui.setWidget(RPC_PANEL_KEY, lines);
|
|
249
|
+
return pageCount;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
view.setScanning(0, 0);
|
|
254
|
+
updateDashboard();
|
|
255
|
+
await Promise.all([runScan(ctx, view, false), runProviderQuota(ctx, view)]);
|
|
256
|
+
|
|
257
|
+
for (;;) {
|
|
258
|
+
const pageCount = updateDashboard();
|
|
259
|
+
const actions = rpcActions(view, page, pageCount);
|
|
260
|
+
const selected = await ctx.ui.select(
|
|
261
|
+
`Usage · ${view.activeView} · page ${page + 1}/${pageCount}`,
|
|
262
|
+
actions.map((item) => item.label),
|
|
263
|
+
);
|
|
264
|
+
if (selected === undefined) break;
|
|
265
|
+
const item = actions.find((candidate) => candidate.label === selected);
|
|
266
|
+
if (!item || item.kind === "close") break;
|
|
267
|
+
if (item.kind === "previous") {
|
|
268
|
+
page = Math.max(0, page - 1);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (item.kind === "next") {
|
|
272
|
+
page = Math.min(pageCount - 1, page + 1);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (item.kind !== "action") break;
|
|
276
|
+
page = 0;
|
|
277
|
+
if (item.action.type === "refresh") {
|
|
278
|
+
await Promise.all([runScan(ctx, view, true), runProviderQuota(ctx, view)]);
|
|
279
|
+
} else if (item.action.type === "configure") {
|
|
280
|
+
await configureLimits(ctx);
|
|
281
|
+
} else {
|
|
282
|
+
view.applyAction(item.action);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} finally {
|
|
286
|
+
ctx.ui.setStatus(RPC_STATUS_KEY, undefined);
|
|
287
|
+
ctx.ui.setWidget(RPC_PANEL_KEY, undefined);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function runScan(ctx: ExtensionContext, view: UsageView, force: boolean): Promise<void> {
|
|
292
|
+
if (!force && isReportCacheFresh(cache, Date.now(), lastTurnAt, CACHE_TTL) && cache) {
|
|
293
|
+
view.setReport(cache.report);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
view.setScanning(0, 0);
|
|
297
|
+
let lastStatusAt = 0;
|
|
298
|
+
try {
|
|
299
|
+
if (!scanCache) scanCache = loadScanCache();
|
|
300
|
+
const report = await scanSessions(
|
|
301
|
+
config.maxSessions ?? 1000,
|
|
302
|
+
config.excludeProjects ?? [],
|
|
303
|
+
(loaded, total) => {
|
|
304
|
+
view.setScanning(loaded, total);
|
|
305
|
+
if (ctx.mode !== "rpc") return;
|
|
306
|
+
const now = Date.now();
|
|
307
|
+
if (loaded !== total && now - lastStatusAt < 250) return;
|
|
308
|
+
lastStatusAt = now;
|
|
309
|
+
ctx.ui.setStatus(RPC_STATUS_KEY, `Scanning sessions… ${loaded}/${total}`);
|
|
310
|
+
},
|
|
311
|
+
config.modelPrices ?? {},
|
|
312
|
+
scanCache,
|
|
313
|
+
);
|
|
314
|
+
cache = { report, at: Date.now() };
|
|
315
|
+
saveScanCache(scanCache);
|
|
316
|
+
view.setReport(report);
|
|
317
|
+
refreshWidget();
|
|
318
|
+
} catch (err) {
|
|
319
|
+
const message = `Failed to scan sessions: ${err instanceof Error ? err.message : String(err)}`;
|
|
320
|
+
view.setError(message);
|
|
321
|
+
if (ctx.mode === "rpc") ctx.ui.notify(message, "error");
|
|
322
|
+
} finally {
|
|
323
|
+
if (ctx.mode === "rpc") ctx.ui.setStatus(RPC_STATUS_KEY, undefined);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Fetch the active provider's live quota + merge captured rate-limit headers. */
|
|
328
|
+
async function runProviderQuota(ctx: ExtensionContext, view: UsageView): Promise<void> {
|
|
329
|
+
try {
|
|
330
|
+
// Keep the active provider fresh in case the model changed.
|
|
331
|
+
activeProvider = await detectActiveProvider(ctx.modelRegistry, ctx.model);
|
|
332
|
+
const quota = await fetchProviderQuota(
|
|
333
|
+
ctx.modelRegistry,
|
|
334
|
+
activeProvider,
|
|
335
|
+
capturedRateLimits,
|
|
336
|
+
capturedHeaders,
|
|
337
|
+
ctx.signal,
|
|
338
|
+
);
|
|
339
|
+
view.setProviderQuota(quota);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
view.setProviderQuota({
|
|
342
|
+
active: activeProvider,
|
|
343
|
+
fetchedAt: Date.now(),
|
|
344
|
+
rateLimits: capturedRateLimits,
|
|
345
|
+
source: "none",
|
|
346
|
+
notes: [],
|
|
347
|
+
error: `Failed to fetch provider quota: ${err instanceof Error ? err.message : String(err)}`,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ------------------------------------------------------------ /usage-config
|
|
353
|
+
|
|
354
|
+
pi.registerCommand("usage-config", {
|
|
355
|
+
description: "Set 5-hour and weekly USD usage budgets",
|
|
356
|
+
handler: async (_args, ctx) => {
|
|
357
|
+
requireUI(ctx, "/usage-config");
|
|
358
|
+
await configureLimits(ctx);
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
async function configureLimits(ctx: ExtensionContext): Promise<void> {
|
|
363
|
+
// USD budgets (priced providers).
|
|
364
|
+
const five = await ctx.ui.input(
|
|
365
|
+
"5-hour budget (USD, 0 = no limit)",
|
|
366
|
+
`${config.fiveHourLimit ?? 0}`,
|
|
367
|
+
);
|
|
368
|
+
if (five === undefined) return;
|
|
369
|
+
const weekly = await ctx.ui.input(
|
|
370
|
+
"Weekly budget (USD, 0 = no limit)",
|
|
371
|
+
`${config.weeklyLimit ?? 0}`,
|
|
372
|
+
);
|
|
373
|
+
if (weekly === undefined) return;
|
|
374
|
+
// Token budgets (token-priced providers like zai/GLM).
|
|
375
|
+
const fiveTok = await ctx.ui.input(
|
|
376
|
+
"5-hour token budget (e.g. 2000000, 0 = none)",
|
|
377
|
+
`${config.fiveHourTokenLimit ?? 0}`,
|
|
378
|
+
);
|
|
379
|
+
if (fiveTok === undefined) return;
|
|
380
|
+
const weeklyTok = await ctx.ui.input(
|
|
381
|
+
"Weekly token budget (e.g. 10000000, 0 = none)",
|
|
382
|
+
`${config.weeklyTokenLimit ?? 0}`,
|
|
383
|
+
);
|
|
384
|
+
if (weeklyTok === undefined) return;
|
|
385
|
+
|
|
386
|
+
config = {
|
|
387
|
+
...config,
|
|
388
|
+
fiveHourLimit: parseUsd(five),
|
|
389
|
+
weeklyLimit: parseUsd(weekly),
|
|
390
|
+
fiveHourTokenLimit: parseUsd(fiveTok),
|
|
391
|
+
weeklyTokenLimit: parseUsd(weeklyTok),
|
|
392
|
+
};
|
|
393
|
+
saveConfig(config);
|
|
394
|
+
cache = null; // force re-eval of quota colors next open
|
|
395
|
+
refreshWidget();
|
|
396
|
+
ctx.ui.notify(
|
|
397
|
+
`Budgets set · 5h ${formatCost(config.fiveHourLimit ?? 0)} / ${formatTokens(config.fiveHourTokenLimit ?? 0)} tok`,
|
|
398
|
+
"info",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ----------------------------------------------------------- /usage-pricing
|
|
403
|
+
|
|
404
|
+
pi.registerCommand("usage-pricing", {
|
|
405
|
+
description: "Set a manual per-model price ($/M tokens) for token-priced models",
|
|
406
|
+
handler: async (args, ctx) => {
|
|
407
|
+
requireUI(ctx, "/usage-pricing");
|
|
408
|
+
await configurePricing(ctx, typeof args === "string" ? args : "");
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
async function configurePricing(ctx: ExtensionContext, arg: string): Promise<void> {
|
|
413
|
+
const model =
|
|
414
|
+
arg.trim() ||
|
|
415
|
+
(await ctx.ui.input("Model ID (e.g. glm-5-turbo, or base name claude-opus-4.7)", "")) ||
|
|
416
|
+
"";
|
|
417
|
+
if (!model.trim()) return;
|
|
418
|
+
const key = model.trim();
|
|
419
|
+
const cur = config.modelPrices?.[key] ?? {};
|
|
420
|
+
const inp = await ctx.ui.input(
|
|
421
|
+
`Input price for ${key} (USD per 1M tokens)`,
|
|
422
|
+
`${cur.input ?? 0}`,
|
|
423
|
+
);
|
|
424
|
+
if (inp === undefined) return;
|
|
425
|
+
const out = await ctx.ui.input("Output price (USD per 1M tokens)", `${cur.output ?? 0}`);
|
|
426
|
+
if (out === undefined) return;
|
|
427
|
+
const cr = await ctx.ui.input("Cache-read price (USD per 1M tokens)", `${cur.cacheRead ?? 0}`);
|
|
428
|
+
if (cr === undefined) return;
|
|
429
|
+
const cw = await ctx.ui.input(
|
|
430
|
+
"Cache-write price (USD per 1M tokens)",
|
|
431
|
+
`${cur.cacheWrite ?? 0}`,
|
|
432
|
+
);
|
|
433
|
+
if (cw === undefined) return;
|
|
434
|
+
|
|
435
|
+
const price = {
|
|
436
|
+
input: parseUsd(inp),
|
|
437
|
+
output: parseUsd(out),
|
|
438
|
+
cacheRead: parseUsd(cr),
|
|
439
|
+
cacheWrite: parseUsd(cw),
|
|
440
|
+
};
|
|
441
|
+
config = {
|
|
442
|
+
...config,
|
|
443
|
+
modelPrices: { ...(config.modelPrices ?? {}), [key]: price },
|
|
444
|
+
};
|
|
445
|
+
saveConfig(config);
|
|
446
|
+
cache = null; // force a re-scan so the new price is applied
|
|
447
|
+
ctx.ui.notify(
|
|
448
|
+
`Price set for ${key}: in $${price.input} / out $${price.output} per 1M tokens`,
|
|
449
|
+
"info",
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ------------------------------------------------------------ /usage-widget
|
|
454
|
+
|
|
455
|
+
pi.registerCommand("usage-widget", {
|
|
456
|
+
description: "Toggle the always-on usage summary widget",
|
|
457
|
+
handler: async (_args, ctx) => {
|
|
458
|
+
requireUI(ctx, "/usage-widget");
|
|
459
|
+
latestCtx = ctx;
|
|
460
|
+
config = { ...config, showWidget: !config.showWidget };
|
|
461
|
+
saveConfig(config);
|
|
462
|
+
if (!config.showWidget) ctx.ui.setWidget("usage", undefined);
|
|
463
|
+
refreshWidget();
|
|
464
|
+
ctx.ui.notify(`Usage widget ${config.showWidget ? "on" : "off"}`, "info");
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
function refreshWidget(): void {
|
|
469
|
+
if (!config.showWidget || !latestCtx?.hasUI) return;
|
|
470
|
+
const summary = currentSessionWindows();
|
|
471
|
+
// Use tokens when there's no meaningful $ cost in the current session (token-priced providers).
|
|
472
|
+
const useTokens = summary.fiveHourCost <= 0 && summary.weeklyCost <= 0;
|
|
473
|
+
const fmt = (n: number) => (useTokens ? formatTokens(n) : formatCost(n));
|
|
474
|
+
const f5 = useTokens ? summary.fiveHourTokens : summary.fiveHourCost;
|
|
475
|
+
const w7 = useTokens ? summary.weeklyTokens : summary.weeklyCost;
|
|
476
|
+
const lim5 = useTokens ? config.fiveHourTokenLimit : config.fiveHourLimit;
|
|
477
|
+
const lim7 = useTokens ? config.weeklyTokenLimit : config.weeklyLimit;
|
|
478
|
+
const five = `5H ${fmt(f5)}${lim5 && lim5 > 0 ? ` / ${fmt(lim5)}` : ""}${useTokens ? " tok" : ""}`;
|
|
479
|
+
const week = `week ${fmt(w7)}${lim7 && lim7 > 0 ? ` / ${fmt(lim7)}` : ""}${useTokens ? " tok" : ""}`;
|
|
480
|
+
const line = `usage ${five} ${week}`;
|
|
481
|
+
if (latestCtx.mode === "rpc") {
|
|
482
|
+
latestCtx.ui.setWidget("usage", [line]);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const theme = latestCtx.ui.theme;
|
|
486
|
+
latestCtx.ui.setWidget("usage", [
|
|
487
|
+
`${theme.fg("dim", "usage")} ${theme.fg("text", five)} ${theme.fg("text", week)}`,
|
|
488
|
+
]);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Sum cost + tokens in the current session branch for the 5h and 7d windows. */
|
|
492
|
+
function currentSessionWindows(): {
|
|
493
|
+
fiveHourCost: number;
|
|
494
|
+
weeklyCost: number;
|
|
495
|
+
fiveHourTokens: number;
|
|
496
|
+
weeklyTokens: number;
|
|
497
|
+
} {
|
|
498
|
+
const zero = {
|
|
499
|
+
fiveHourCost: 0,
|
|
500
|
+
weeklyCost: 0,
|
|
501
|
+
fiveHourTokens: 0,
|
|
502
|
+
weeklyTokens: 0,
|
|
503
|
+
};
|
|
504
|
+
const sm = latestCtx?.sessionManager;
|
|
505
|
+
if (!sm) return zero;
|
|
506
|
+
const now = Date.now();
|
|
507
|
+
let fiveHourCost = 0;
|
|
508
|
+
let weeklyCost = 0;
|
|
509
|
+
let fiveHourTokens = 0;
|
|
510
|
+
let weeklyTokens = 0;
|
|
511
|
+
for (const e of sm.getBranch()) {
|
|
512
|
+
if (e.type !== "message") continue;
|
|
513
|
+
const m = e.message as AssistantMessage;
|
|
514
|
+
if (m.role !== "assistant" || !m.usage) continue;
|
|
515
|
+
const u = m.usage;
|
|
516
|
+
const tok = u.input + u.output + u.cacheRead + u.cacheWrite;
|
|
517
|
+
if (m.timestamp >= now - 5 * HOUR) {
|
|
518
|
+
fiveHourCost += u.cost.total;
|
|
519
|
+
fiveHourTokens += tok;
|
|
520
|
+
}
|
|
521
|
+
if (m.timestamp >= now - 7 * DAY) {
|
|
522
|
+
weeklyCost += u.cost.total;
|
|
523
|
+
weeklyTokens += tok;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return { fiveHourCost, weeklyCost, fiveHourTokens, weeklyTokens };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
type RpcMenuItem =
|
|
531
|
+
| { label: string; kind: "action"; action: UsageAction }
|
|
532
|
+
| { label: string; kind: "previous" | "next" | "close" };
|
|
533
|
+
|
|
534
|
+
function rpcActions(view: UsageView, page: number, pageCount: number): RpcMenuItem[] {
|
|
535
|
+
const actions: RpcMenuItem[] = [
|
|
536
|
+
{ label: "View: Overview", kind: "action", action: { type: "view", view: "overview" } },
|
|
537
|
+
{ label: "View: Models", kind: "action", action: { type: "view", view: "models" } },
|
|
538
|
+
{ label: "View: Delegation", kind: "action", action: { type: "view", view: "delegation" } },
|
|
539
|
+
{ label: "View: Daily", kind: "action", action: { type: "view", view: "daily" } },
|
|
540
|
+
{ label: "View: Stats", kind: "action", action: { type: "view", view: "stats" } },
|
|
541
|
+
{ label: "View: Hourly", kind: "action", action: { type: "view", view: "hourly" } },
|
|
542
|
+
{ label: "View: Providers", kind: "action", action: { type: "view", view: "providers" } },
|
|
543
|
+
{ label: "View: Wrapped AI", kind: "action", action: { type: "view", view: "wrapped" } },
|
|
544
|
+
];
|
|
545
|
+
|
|
546
|
+
if (["overview", "models", "delegation"].includes(view.activeView)) {
|
|
547
|
+
for (const [label, window] of [
|
|
548
|
+
["Window: 5 hours", "5h"],
|
|
549
|
+
["Window: 24 hours", "24h"],
|
|
550
|
+
["Window: 7 days", "7d"],
|
|
551
|
+
["Window: All time", "all"],
|
|
552
|
+
] as const) {
|
|
553
|
+
actions.push({ label, kind: "action", action: { type: "window", window } });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (view.activeView === "models") {
|
|
557
|
+
actions.push(
|
|
558
|
+
{ label: "Sort models: Usage", kind: "action", action: { type: "modelSort", sort: "value" } },
|
|
559
|
+
{ label: "Sort models: Name", kind: "action", action: { type: "modelSort", sort: "name" } },
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
if (view.activeView === "daily") {
|
|
563
|
+
actions.push(
|
|
564
|
+
{
|
|
565
|
+
label: "Sort daily: Tokens (toggle direction)",
|
|
566
|
+
kind: "action",
|
|
567
|
+
action: { type: "dailySort", sort: "tokens" },
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
label: "Sort daily: Cost (toggle direction)",
|
|
571
|
+
kind: "action",
|
|
572
|
+
action: { type: "dailySort", sort: "cost" },
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
label: "Sort daily: Date (toggle direction)",
|
|
576
|
+
kind: "action",
|
|
577
|
+
action: { type: "dailySort", sort: "date" },
|
|
578
|
+
},
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (view.activeView === "stats") {
|
|
582
|
+
actions.push(
|
|
583
|
+
{
|
|
584
|
+
label: "Stats range: All time",
|
|
585
|
+
kind: "action",
|
|
586
|
+
action: { type: "statsRange", range: "all" },
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
label: "Stats range: 30 days",
|
|
590
|
+
kind: "action",
|
|
591
|
+
action: { type: "statsRange", range: "30d" },
|
|
592
|
+
},
|
|
593
|
+
{ label: "Stats range: 7 days", kind: "action", action: { type: "statsRange", range: "7d" } },
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (view.activeView === "providers") {
|
|
597
|
+
actions.push(
|
|
598
|
+
{
|
|
599
|
+
label: "Sort providers: Usage",
|
|
600
|
+
kind: "action",
|
|
601
|
+
action: { type: "providerSort", sort: "value" },
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
label: "Sort providers: Name",
|
|
605
|
+
kind: "action",
|
|
606
|
+
action: { type: "providerSort", sort: "name" },
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (view.activeView === "wrapped") {
|
|
611
|
+
for (const year of view.wrappedYears) {
|
|
612
|
+
actions.push({
|
|
613
|
+
label: `Wrapped year: ${year}`,
|
|
614
|
+
kind: "action",
|
|
615
|
+
action: { type: "wrappedYear", year },
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (page > 0) actions.push({ label: "Page: Previous", kind: "previous" });
|
|
620
|
+
if (page + 1 < pageCount) actions.push({ label: "Page: Next", kind: "next" });
|
|
621
|
+
actions.push(
|
|
622
|
+
{ label: "Refresh usage and provider quota", kind: "action", action: { type: "refresh" } },
|
|
623
|
+
{ label: "Configure usage budgets", kind: "action", action: { type: "configure" } },
|
|
624
|
+
{ label: "Close usage dashboard", kind: "close" },
|
|
625
|
+
);
|
|
626
|
+
return actions;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function requireUI(ctx: ExtensionContext, command: string): void {
|
|
630
|
+
if (!ctx.hasUI) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
`${command} requires interactive TUI or RPC mode; rerun Pi without print/JSON mode`,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Parse a user-entered USD string into a number (0 on invalid/empty). */
|
|
638
|
+
function parseUsd(input: string | undefined): number {
|
|
639
|
+
if (!input) return 0;
|
|
640
|
+
const n = Number.parseFloat(input.replace(/[^0-9.]/g, ""));
|
|
641
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
642
|
+
}
|