@latentminds/pi-quotas 0.2.6 → 0.3.1
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 +9 -2
- package/package.json +25 -5
- package/src/config.ts +43 -13
- package/src/extensions/command-quotas/command.ts +23 -1
- package/src/extensions/command-quotas/components/quotas-display.test.ts +29 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +8 -2
- package/src/extensions/command-quotas/provider-commands.test.ts +9 -0
- package/src/extensions/command-quotas/provider-commands.ts +12 -0
- package/src/extensions/command-tokens/command.ts +102 -0
- package/src/extensions/command-tokens/index.ts +1 -0
- package/src/extensions/command-tokens/tokens-display.ts +357 -0
- package/src/extensions/quota-warnings/index.ts +36 -14
- package/src/extensions/token-status/index.ts +241 -0
- package/src/extensions/token-status/provider-detection.test.ts +30 -0
- package/src/extensions/usage-status/index.test.ts +182 -0
- package/src/extensions/usage-status/index.ts +104 -40
- package/src/lib/quotas.ts +12 -1
- package/src/lib/session-tokens.test.ts +137 -0
- package/src/lib/session-tokens.ts +399 -0
- package/src/providers/fetch.test.ts +31 -0
- package/src/providers/fetch.ts +167 -22
- package/src/providers/opencode-go-config.ts +127 -0
- package/src/providers/opencode-go.ts +183 -0
- package/src/providers/parse.test.ts +185 -7
- package/src/providers/providers.ts +229 -21
- package/src/types/quotas.ts +12 -3
- package/src/utils/quotas-severity.ts +14 -2
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
4
|
+
import { Loader, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
5
|
+
import pkg from "../../../package.json" with { type: "json" };
|
|
6
|
+
import type {
|
|
7
|
+
AggregateResult,
|
|
8
|
+
} from "../../lib/session-tokens.js";
|
|
9
|
+
import {
|
|
10
|
+
formatCost,
|
|
11
|
+
formatNumber,
|
|
12
|
+
formatTokenSummary,
|
|
13
|
+
} from "../../lib/session-tokens.js";
|
|
14
|
+
|
|
15
|
+
type TokensState =
|
|
16
|
+
| { type: "loading" }
|
|
17
|
+
| { type: "loaded"; result: AggregateResult };
|
|
18
|
+
|
|
19
|
+
type TabId = "overview" | "models" | "sessions";
|
|
20
|
+
|
|
21
|
+
const TABS: Array<{ id: TabId; label: string }> = [
|
|
22
|
+
{ id: "overview", label: "Overview" },
|
|
23
|
+
{ id: "models", label: "Models" },
|
|
24
|
+
{ id: "sessions", label: "Sessions" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function renderProgressBar(
|
|
28
|
+
value: number,
|
|
29
|
+
max: number,
|
|
30
|
+
width: number,
|
|
31
|
+
theme: Theme,
|
|
32
|
+
): string {
|
|
33
|
+
if (max <= 0 || value <= 0) return theme.fg("dim", "░".repeat(width));
|
|
34
|
+
const ratio = Math.min(1, value / max);
|
|
35
|
+
const filled = Math.round(ratio * width);
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
for (let i = 0; i < width; i++) {
|
|
38
|
+
parts.push(i < filled ? theme.fg("accent", "█") : theme.fg("dim", "░"));
|
|
39
|
+
}
|
|
40
|
+
return parts.join("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class TokensComponent implements Component {
|
|
44
|
+
private state: TokensState = { type: "loading" };
|
|
45
|
+
private loader: Loader | null = null;
|
|
46
|
+
private activeTab: TabId = "overview";
|
|
47
|
+
private scrollOffset = 0;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private theme: Theme,
|
|
51
|
+
private tui: any,
|
|
52
|
+
private onClose: () => void,
|
|
53
|
+
private onRefetch: () => void,
|
|
54
|
+
private cwd?: string,
|
|
55
|
+
) {
|
|
56
|
+
this.startLoader();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private startLoader(): void {
|
|
60
|
+
this.loader = new Loader(
|
|
61
|
+
this.tui,
|
|
62
|
+
(s: string) => this.theme.fg("accent", s),
|
|
63
|
+
(s: string) => this.theme.fg("muted", s),
|
|
64
|
+
"Scanning sessions...",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
destroy(): void {
|
|
69
|
+
this.loader?.stop();
|
|
70
|
+
this.loader = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setState(state: TokensState): void {
|
|
74
|
+
if (state.type === "loading") {
|
|
75
|
+
this.loader?.stop();
|
|
76
|
+
this.startLoader();
|
|
77
|
+
this.scrollOffset = 0;
|
|
78
|
+
} else if (this.state.type === "loading") {
|
|
79
|
+
this.loader?.stop();
|
|
80
|
+
this.loader = null;
|
|
81
|
+
}
|
|
82
|
+
this.state = state;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
handleInput(data: string): boolean {
|
|
86
|
+
if (matchesKey(data, "escape") || data === "q") {
|
|
87
|
+
this.onClose();
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (data === "r") {
|
|
91
|
+
this.onRefetch();
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (data === "\t" || data === "right") {
|
|
95
|
+
const idx = TABS.findIndex((t) => t.id === this.activeTab);
|
|
96
|
+
this.activeTab = TABS[(idx + 1) % TABS.length].id;
|
|
97
|
+
this.scrollOffset = 0;
|
|
98
|
+
this.tui.requestRender();
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (data === "left") {
|
|
102
|
+
const idx = TABS.findIndex((t) => t.id === this.activeTab);
|
|
103
|
+
this.activeTab = TABS[(idx - 1 + TABS.length) % TABS.length].id;
|
|
104
|
+
this.scrollOffset = 0;
|
|
105
|
+
this.tui.requestRender();
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (data === "1") {
|
|
109
|
+
this.activeTab = "overview";
|
|
110
|
+
this.scrollOffset = 0;
|
|
111
|
+
this.tui.requestRender();
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
if (data === "2") {
|
|
115
|
+
this.activeTab = "models";
|
|
116
|
+
this.scrollOffset = 0;
|
|
117
|
+
this.tui.requestRender();
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (data === "3") {
|
|
121
|
+
this.activeTab = "sessions";
|
|
122
|
+
this.scrollOffset = 0;
|
|
123
|
+
this.tui.requestRender();
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (matchesKey(data, "down") || data === "j") {
|
|
127
|
+
this.scrollOffset++;
|
|
128
|
+
this.tui.requestRender();
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
132
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
133
|
+
this.tui.requestRender();
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
render(width: number): string[] {
|
|
140
|
+
const lines: string[] = [];
|
|
141
|
+
const border = new DynamicBorder((s: string) => this.theme.fg("border", s));
|
|
142
|
+
lines.push(...border.render(width));
|
|
143
|
+
lines.push(
|
|
144
|
+
truncateToWidth(
|
|
145
|
+
` ${this.theme.fg("accent", this.theme.bold("Token Usage"))}`,
|
|
146
|
+
width,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Tab bar
|
|
151
|
+
const tabParts = TABS.map((tab) => {
|
|
152
|
+
if (tab.id === this.activeTab) {
|
|
153
|
+
return this.theme.fg("accent", this.theme.bold(`[${tab.label}]`));
|
|
154
|
+
}
|
|
155
|
+
return this.theme.fg("dim", ` ${tab.label} `);
|
|
156
|
+
});
|
|
157
|
+
lines.push(truncateToWidth(` ${tabParts.join(" ")}`, width));
|
|
158
|
+
lines.push("");
|
|
159
|
+
|
|
160
|
+
if (this.state.type === "loading") {
|
|
161
|
+
lines.push(
|
|
162
|
+
...(this.loader
|
|
163
|
+
? this.loader.render(width)
|
|
164
|
+
: [this.theme.fg("muted", " Scanning sessions...")]),
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
const contentLines = this.renderTab(width);
|
|
168
|
+
// Apply scroll
|
|
169
|
+
const maxVisible = Math.max(1, 20); // approximate visible lines
|
|
170
|
+
const visible = contentLines.slice(
|
|
171
|
+
this.scrollOffset,
|
|
172
|
+
this.scrollOffset + maxVisible,
|
|
173
|
+
);
|
|
174
|
+
lines.push(...visible);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
lines.push("");
|
|
178
|
+
lines.push(
|
|
179
|
+
this.theme.fg(
|
|
180
|
+
"dim",
|
|
181
|
+
` pi-quotas v${pkg.version} · 1/2/3 switch tab r refresh q/Esc close`,
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
lines.push(...border.render(width));
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private renderTab(width: number): string[] {
|
|
189
|
+
if (this.state.type !== "loaded") return [];
|
|
190
|
+
const { result } = this.state;
|
|
191
|
+
|
|
192
|
+
switch (this.activeTab) {
|
|
193
|
+
case "overview":
|
|
194
|
+
return this.renderOverview(result, width);
|
|
195
|
+
case "models":
|
|
196
|
+
return this.renderModels(result, width);
|
|
197
|
+
case "sessions":
|
|
198
|
+
return this.renderSessions(result, width);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private renderOverview(result: AggregateResult, width: number): string[] {
|
|
203
|
+
const lines: string[] = [];
|
|
204
|
+
const { totals } = result;
|
|
205
|
+
|
|
206
|
+
lines.push(
|
|
207
|
+
truncateToWidth(
|
|
208
|
+
` ${this.theme.fg("accent", this.theme.bold("Summary"))}`,
|
|
209
|
+
width,
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
lines.push("");
|
|
213
|
+
|
|
214
|
+
const statRows = [
|
|
215
|
+
["Sessions", String(result.sessionCount)],
|
|
216
|
+
["Messages", String(result.messageCount)],
|
|
217
|
+
["Input tokens", formatNumber(totals.input)],
|
|
218
|
+
["Output tokens", formatNumber(totals.output)],
|
|
219
|
+
["Cache read", formatNumber(totals.cacheRead)],
|
|
220
|
+
["Cache write", formatNumber(totals.cacheWrite)],
|
|
221
|
+
["Total tokens", formatNumber(totals.totalTokens)],
|
|
222
|
+
["Total cost", formatCost(totals.costTotal)],
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
const labelWidth = Math.max(...statRows.map(([l]) => l.length));
|
|
226
|
+
for (const [label, value] of statRows) {
|
|
227
|
+
lines.push(
|
|
228
|
+
truncateToWidth(
|
|
229
|
+
` ${this.theme.fg("dim", label.padEnd(labelWidth))} ${this.theme.fg("accent", value)}`,
|
|
230
|
+
width,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Provider breakdown
|
|
236
|
+
if (result.byProvider.length > 0) {
|
|
237
|
+
lines.push("");
|
|
238
|
+
lines.push(
|
|
239
|
+
truncateToWidth(
|
|
240
|
+
` ${this.theme.fg("accent", this.theme.bold("By Provider"))}`,
|
|
241
|
+
width,
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
lines.push("");
|
|
245
|
+
const maxCost = Math.max(
|
|
246
|
+
...result.byProvider.map((p) => p.tokens.costTotal),
|
|
247
|
+
);
|
|
248
|
+
for (const provider of result.byProvider) {
|
|
249
|
+
const barWidth = Math.min(20, Math.max(8, width - 40));
|
|
250
|
+
const bar = renderProgressBar(
|
|
251
|
+
provider.tokens.costTotal,
|
|
252
|
+
maxCost,
|
|
253
|
+
barWidth,
|
|
254
|
+
this.theme,
|
|
255
|
+
);
|
|
256
|
+
const cost = formatCost(provider.tokens.costTotal);
|
|
257
|
+
const msgs = `${provider.messageCount} msgs`;
|
|
258
|
+
lines.push(
|
|
259
|
+
truncateToWidth(
|
|
260
|
+
` ${this.theme.fg("accent", provider.provider.padEnd(20))} ${bar} ${this.theme.fg("accent", cost)} ${this.theme.fg("dim", msgs)}`,
|
|
261
|
+
width,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return lines;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private renderModels(result: AggregateResult, width: number): string[] {
|
|
271
|
+
const lines: string[] = [];
|
|
272
|
+
|
|
273
|
+
if (result.byModel.length === 0) {
|
|
274
|
+
lines.push(this.theme.fg("dim", " No assistant messages found"));
|
|
275
|
+
return lines;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const maxCost = Math.max(...result.byModel.map((m) => m.tokens.costTotal));
|
|
279
|
+
|
|
280
|
+
for (const model of result.byModel) {
|
|
281
|
+
const barWidth = Math.min(16, Math.max(8, width - 50));
|
|
282
|
+
const bar = renderProgressBar(
|
|
283
|
+
model.tokens.costTotal,
|
|
284
|
+
maxCost,
|
|
285
|
+
barWidth,
|
|
286
|
+
this.theme,
|
|
287
|
+
);
|
|
288
|
+
const cost = formatCost(model.tokens.costTotal);
|
|
289
|
+
const label =
|
|
290
|
+
model.provider === model.model
|
|
291
|
+
? model.model
|
|
292
|
+
: `${model.provider}/${model.model}`;
|
|
293
|
+
const shortLabel = label.length > 30 ? label.slice(0, 27) + "..." : label;
|
|
294
|
+
|
|
295
|
+
lines.push(
|
|
296
|
+
truncateToWidth(` ${this.theme.fg("accent", shortLabel)}`, width),
|
|
297
|
+
);
|
|
298
|
+
lines.push(
|
|
299
|
+
truncateToWidth(
|
|
300
|
+
` ${bar} ${this.theme.fg("accent", cost)} ${this.theme.fg("dim", formatTokenSummary(model.tokens))}`,
|
|
301
|
+
width,
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return lines;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private renderSessions(result: AggregateResult, width: number): string[] {
|
|
310
|
+
const lines: string[] = [];
|
|
311
|
+
|
|
312
|
+
if (result.bySession.length === 0) {
|
|
313
|
+
lines.push(this.theme.fg("dim", " No sessions found"));
|
|
314
|
+
return lines;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const maxCost = Math.max(
|
|
318
|
+
...result.bySession.map((s) => s.tokens.costTotal),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
for (const session of result.bySession) {
|
|
322
|
+
const dateStr = session.created.toISOString().slice(0, 10);
|
|
323
|
+
const name = session.name ?? session.sessionId.slice(0, 8);
|
|
324
|
+
const cost = formatCost(session.tokens.costTotal);
|
|
325
|
+
const msgs = `${session.messageCount} msgs`;
|
|
326
|
+
|
|
327
|
+
const barWidth = Math.min(12, Math.max(6, width - 50));
|
|
328
|
+
const bar = renderProgressBar(
|
|
329
|
+
session.tokens.costTotal,
|
|
330
|
+
maxCost,
|
|
331
|
+
barWidth,
|
|
332
|
+
this.theme,
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
lines.push(
|
|
336
|
+
truncateToWidth(
|
|
337
|
+
` ${this.theme.fg("dim", dateStr)} ${this.theme.fg("accent", name)} ${bar} ${this.theme.fg("accent", cost)} ${this.theme.fg("dim", msgs)}`,
|
|
338
|
+
width,
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (result.sessionCount > result.bySession.length) {
|
|
344
|
+
lines.push("");
|
|
345
|
+
lines.push(
|
|
346
|
+
this.theme.fg(
|
|
347
|
+
"dim",
|
|
348
|
+
` Showing ${result.bySession.length} of ${result.sessionCount} sessions`,
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return lines;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
invalidate(): void {}
|
|
357
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
2
5
|
import {
|
|
3
6
|
QUOTAS_CONFIG_UPDATED_EVENT,
|
|
4
7
|
QUOTAS_EXTENSIONS_REGISTER_EVENT,
|
|
@@ -6,7 +9,11 @@ import {
|
|
|
6
9
|
type QuotasConfigUpdatedPayload,
|
|
7
10
|
configLoader,
|
|
8
11
|
} from "../../config.js";
|
|
9
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
fetchProviderQuotas,
|
|
14
|
+
isSupportedProvider,
|
|
15
|
+
PROVIDER_LABELS,
|
|
16
|
+
} from "../../lib/quotas.js";
|
|
10
17
|
import {
|
|
11
18
|
assessWindow,
|
|
12
19
|
formatTimeRemaining,
|
|
@@ -24,9 +31,11 @@ function shouldNotify(key: string, severity: RiskSeverity): boolean {
|
|
|
24
31
|
const current = alertState.get(key);
|
|
25
32
|
if (!current) return true;
|
|
26
33
|
const order: RiskSeverity[] = ["none", "warning", "high", "critical"];
|
|
27
|
-
if (order.indexOf(severity) > order.indexOf(current.lastSeverity))
|
|
34
|
+
if (order.indexOf(severity) > order.indexOf(current.lastSeverity))
|
|
35
|
+
return true;
|
|
28
36
|
if (severity === "high" || severity === "critical") return true;
|
|
29
|
-
if (severity === "warning")
|
|
37
|
+
if (severity === "warning")
|
|
38
|
+
return Date.now() - current.lastNotifiedAt >= COOLDOWN_MS;
|
|
30
39
|
return false;
|
|
31
40
|
}
|
|
32
41
|
|
|
@@ -50,7 +59,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
50
59
|
if (onlyNew && now - lastFetchAt < MIN_FETCH_INTERVAL_MS) return;
|
|
51
60
|
lastFetchAt = now;
|
|
52
61
|
|
|
53
|
-
const result = await fetchProviderQuotas(
|
|
62
|
+
const result = await fetchProviderQuotas(
|
|
63
|
+
ctx.modelRegistry.authStorage,
|
|
64
|
+
provider,
|
|
65
|
+
);
|
|
54
66
|
if (!result.success) return;
|
|
55
67
|
|
|
56
68
|
const risky = result.data.windows
|
|
@@ -59,19 +71,23 @@ export default async function (pi: ExtensionAPI) {
|
|
|
59
71
|
if (risky.length === 0) return;
|
|
60
72
|
|
|
61
73
|
const toNotify = onlyNew
|
|
62
|
-
? risky.filter((entry) =>
|
|
74
|
+
? risky.filter((entry) =>
|
|
75
|
+
shouldNotify(
|
|
76
|
+
`${provider}:${entry.window.label}`,
|
|
77
|
+
entry.assessment.severity,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
63
80
|
: risky;
|
|
64
81
|
if (toNotify.length === 0) return;
|
|
65
82
|
|
|
66
83
|
for (const entry of toNotify) {
|
|
67
|
-
markNotified(
|
|
84
|
+
markNotified(
|
|
85
|
+
`${provider}:${entry.window.label}`,
|
|
86
|
+
entry.assessment.severity,
|
|
87
|
+
);
|
|
68
88
|
}
|
|
69
89
|
|
|
70
|
-
const providerName = provider
|
|
71
|
-
? "Codex"
|
|
72
|
-
: provider === "github-copilot"
|
|
73
|
-
? "GitHub Copilot"
|
|
74
|
-
: "Anthropic";
|
|
90
|
+
const providerName = PROVIDER_LABELS[provider];
|
|
75
91
|
|
|
76
92
|
const lines = toNotify.map(({ window, assessment }) => {
|
|
77
93
|
const projected = Math.round(assessment.projectedPercent);
|
|
@@ -79,7 +95,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
79
95
|
return `- ${window.label}: ${used}% used, projected ${projected}% (${assessment.severity}), resets in ${formatTimeRemaining(window.resetsAt)}`;
|
|
80
96
|
});
|
|
81
97
|
|
|
82
|
-
const level = toNotify.some(
|
|
98
|
+
const level = toNotify.some(
|
|
99
|
+
(entry) =>
|
|
100
|
+
entry.assessment.severity === "critical" ||
|
|
101
|
+
entry.assessment.severity === "high",
|
|
102
|
+
)
|
|
83
103
|
? "error"
|
|
84
104
|
: "warning";
|
|
85
105
|
ctx.ui.notify(`${providerName} quota warning:\n${lines.join("\n")}`, level);
|
|
@@ -128,7 +148,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
128
148
|
|
|
129
149
|
pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
|
|
130
150
|
if (configLoader.getConfig().quotaWarnings) {
|
|
131
|
-
pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, {
|
|
151
|
+
pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, {
|
|
152
|
+
feature: "quotaWarnings",
|
|
153
|
+
});
|
|
132
154
|
}
|
|
133
155
|
});
|
|
134
156
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ThemeColor,
|
|
5
|
+
} from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
QUOTAS_CONFIG_UPDATED_EVENT,
|
|
8
|
+
QUOTAS_EXTENSIONS_REGISTER_EVENT,
|
|
9
|
+
QUOTAS_EXTENSIONS_REQUEST_EVENT,
|
|
10
|
+
type QuotasConfigUpdatedPayload,
|
|
11
|
+
configLoader,
|
|
12
|
+
} from "../../config.js";
|
|
13
|
+
import { aggregateAllSessions, formatCost } from "../../lib/session-tokens.js";
|
|
14
|
+
|
|
15
|
+
const EXTENSION_ID = "pi-quotas-token-status";
|
|
16
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
/** Go tier limits (approximate, from docs) */
|
|
19
|
+
const GO_LIMITS = {
|
|
20
|
+
rolling5h: 12, // $12 per 5 hours
|
|
21
|
+
weekly: 30, // $30 per week
|
|
22
|
+
monthly: 60, // $60 per month
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
interface RollingWindowCosts {
|
|
26
|
+
rolling5h: number;
|
|
27
|
+
weekly: number;
|
|
28
|
+
monthly: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function computeWindowTimestamps(now: Date): {
|
|
32
|
+
since5h: Date;
|
|
33
|
+
sinceWeekly: Date;
|
|
34
|
+
sinceMonthly: Date;
|
|
35
|
+
} {
|
|
36
|
+
return {
|
|
37
|
+
since5h: new Date(now.getTime() - 5 * 60 * 60 * 1000),
|
|
38
|
+
sinceWeekly: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
|
39
|
+
sinceMonthly: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function computeRollingCosts(cwd?: string): Promise<RollingWindowCosts> {
|
|
44
|
+
const now = new Date();
|
|
45
|
+
const { since5h, sinceWeekly, sinceMonthly } = computeWindowTimestamps(now);
|
|
46
|
+
|
|
47
|
+
// Run all three aggregations in parallel
|
|
48
|
+
const [result5h, resultWeekly, resultMonthly] = await Promise.all([
|
|
49
|
+
aggregateAllSessions({ cwd, since: since5h }),
|
|
50
|
+
aggregateAllSessions({ cwd, since: sinceWeekly }),
|
|
51
|
+
aggregateAllSessions({ cwd, since: sinceMonthly }),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
rolling5h: result5h.totals.costTotal,
|
|
56
|
+
weekly: resultWeekly.totals.costTotal,
|
|
57
|
+
monthly: resultMonthly.totals.costTotal,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatWindowForStatus(
|
|
62
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
63
|
+
label: string,
|
|
64
|
+
cost: number,
|
|
65
|
+
limit: number,
|
|
66
|
+
): string {
|
|
67
|
+
const percent = limit > 0 ? Math.round((cost / limit) * 100) : 0;
|
|
68
|
+
const costStr = formatCost(cost);
|
|
69
|
+
|
|
70
|
+
// Color by usage level
|
|
71
|
+
let color: ThemeColor;
|
|
72
|
+
if (percent >= 90) {
|
|
73
|
+
color = "error";
|
|
74
|
+
} else if (percent >= 70) {
|
|
75
|
+
color = "warning";
|
|
76
|
+
} else {
|
|
77
|
+
color = "dim";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `${theme.fg(color, `${label}:`)}${theme.fg("accent", costStr)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatTokenStatus(
|
|
84
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
85
|
+
costs: RollingWindowCosts,
|
|
86
|
+
): string {
|
|
87
|
+
const parts = [
|
|
88
|
+
formatWindowForStatus(theme, "5h", costs.rolling5h, GO_LIMITS.rolling5h),
|
|
89
|
+
formatWindowForStatus(theme, "wk", costs.weekly, GO_LIMITS.weekly),
|
|
90
|
+
formatWindowForStatus(theme, "mo", costs.monthly, GO_LIMITS.monthly),
|
|
91
|
+
];
|
|
92
|
+
return parts.join(" · ");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isGoProvider(provider: string | undefined): boolean {
|
|
96
|
+
if (!provider) return false;
|
|
97
|
+
return provider === "opencode-go" || provider.startsWith("opencode-go/");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function createTokenStatusRefresher() {
|
|
101
|
+
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
102
|
+
let activeContext: ExtensionContext | undefined;
|
|
103
|
+
let lastCosts: RollingWindowCosts | undefined;
|
|
104
|
+
let inFlight = false;
|
|
105
|
+
let queued = false;
|
|
106
|
+
|
|
107
|
+
async function update(ctx: ExtensionContext): Promise<void> {
|
|
108
|
+
if (!ctx.hasUI) return;
|
|
109
|
+
if (inFlight) {
|
|
110
|
+
queued = true;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
inFlight = true;
|
|
114
|
+
try {
|
|
115
|
+
const costs = await computeRollingCosts(ctx.cwd);
|
|
116
|
+
if (!ctx.hasUI) return;
|
|
117
|
+
lastCosts = costs;
|
|
118
|
+
const status = formatTokenStatus(ctx.ui.theme, costs);
|
|
119
|
+
ctx.ui.setStatus(EXTENSION_ID, status);
|
|
120
|
+
} catch {
|
|
121
|
+
ctx.ui.setStatus(
|
|
122
|
+
EXTENSION_ID,
|
|
123
|
+
ctx.ui.theme.fg("warning", "token tracking unavailable"),
|
|
124
|
+
);
|
|
125
|
+
} finally {
|
|
126
|
+
inFlight = false;
|
|
127
|
+
if (queued) {
|
|
128
|
+
queued = false;
|
|
129
|
+
void update(ctx);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
async refreshFor(ctx: ExtensionContext): Promise<void> {
|
|
136
|
+
activeContext = ctx;
|
|
137
|
+
if (!isGoProvider(ctx.model?.provider)) {
|
|
138
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await update(ctx);
|
|
142
|
+
},
|
|
143
|
+
start(): void {
|
|
144
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
145
|
+
refreshTimer = setInterval(() => {
|
|
146
|
+
if (activeContext) void update(activeContext);
|
|
147
|
+
}, REFRESH_INTERVAL_MS);
|
|
148
|
+
refreshTimer.unref?.();
|
|
149
|
+
},
|
|
150
|
+
stop(ctx?: ExtensionContext): void {
|
|
151
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
152
|
+
refreshTimer = undefined;
|
|
153
|
+
activeContext = undefined;
|
|
154
|
+
lastCosts = undefined;
|
|
155
|
+
ctx?.ui.setStatus(EXTENSION_ID, undefined);
|
|
156
|
+
},
|
|
157
|
+
renderLast(ctx: ExtensionContext): boolean {
|
|
158
|
+
if (!lastCosts || !ctx.hasUI) return false;
|
|
159
|
+
ctx.ui.setStatus(
|
|
160
|
+
EXTENSION_ID,
|
|
161
|
+
formatTokenStatus(ctx.ui.theme, lastCosts),
|
|
162
|
+
);
|
|
163
|
+
return true;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export default async function (pi: ExtensionAPI) {
|
|
169
|
+
await configLoader.load();
|
|
170
|
+
const refresher = createTokenStatusRefresher();
|
|
171
|
+
let enabled = configLoader.getConfig().tokenStatus;
|
|
172
|
+
let currentContext: ExtensionContext | undefined;
|
|
173
|
+
|
|
174
|
+
function scheduleRefresh(ctx: ExtensionContext): void {
|
|
175
|
+
void refresher.refreshFor(ctx).catch(() => {
|
|
176
|
+
if (ctx.hasUI)
|
|
177
|
+
ctx.ui.setStatus(
|
|
178
|
+
EXTENSION_ID,
|
|
179
|
+
ctx.ui.theme.fg("warning", "token tracking unavailable"),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
185
|
+
const config = (data as QuotasConfigUpdatedPayload).config;
|
|
186
|
+
enabled = config.tokenStatus;
|
|
187
|
+
if (!enabled) {
|
|
188
|
+
refresher.stop(currentContext);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (currentContext) {
|
|
192
|
+
refresher.start();
|
|
193
|
+
scheduleRefresh(currentContext);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
pi.on("session_start", (_event, ctx) => {
|
|
198
|
+
currentContext = ctx;
|
|
199
|
+
if (!enabled) return;
|
|
200
|
+
if (!isGoProvider(ctx.model?.provider)) {
|
|
201
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
refresher.start();
|
|
205
|
+
scheduleRefresh(ctx);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
209
|
+
currentContext = ctx;
|
|
210
|
+
if (!enabled) return;
|
|
211
|
+
if (!isGoProvider(ctx.model?.provider)) return;
|
|
212
|
+
scheduleRefresh(ctx);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
pi.on("model_select", (_event, ctx) => {
|
|
216
|
+
currentContext = ctx;
|
|
217
|
+
if (!enabled) {
|
|
218
|
+
refresher.stop(ctx);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!isGoProvider(ctx.model?.provider)) {
|
|
222
|
+
refresher.stop(ctx);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
refresher.start();
|
|
226
|
+
scheduleRefresh(ctx);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
230
|
+
currentContext = undefined;
|
|
231
|
+
refresher.stop(ctx);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
|
|
235
|
+
if (configLoader.getConfig().tokenStatus) {
|
|
236
|
+
pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, {
|
|
237
|
+
feature: "tokenStatus",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|