@latentminds/pi-quotas 0.2.4 → 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 +34 -23
- 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 +154 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +13 -10
- 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 +47 -19
- 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 +114 -44
- 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 +71 -0
- package/src/providers/fetch.ts +200 -17
- 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.test.ts +22 -2
- package/src/utils/quotas-severity.ts +28 -6
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { aggregateAllSessions } from "../../lib/session-tokens.js";
|
|
6
|
+
import { TokensComponent } from "./tokens-display.js";
|
|
7
|
+
|
|
8
|
+
async function openTokensView(
|
|
9
|
+
ctx: ExtensionCommandContext,
|
|
10
|
+
cwd?: string,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const component = new TokensComponent(
|
|
15
|
+
theme,
|
|
16
|
+
tui,
|
|
17
|
+
() => {
|
|
18
|
+
controller.abort();
|
|
19
|
+
done(null);
|
|
20
|
+
},
|
|
21
|
+
() => {
|
|
22
|
+
component.setState({ type: "loading" });
|
|
23
|
+
tui.requestRender();
|
|
24
|
+
void load();
|
|
25
|
+
},
|
|
26
|
+
cwd,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
async function load(): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
const aggregateResult = await aggregateAllSessions({ cwd });
|
|
32
|
+
if (controller.signal.aborted) return;
|
|
33
|
+
component.setState({ type: "loaded", result: aggregateResult });
|
|
34
|
+
tui.requestRender();
|
|
35
|
+
} catch {
|
|
36
|
+
if (controller.signal.aborted) return;
|
|
37
|
+
component.setState({
|
|
38
|
+
type: "loaded",
|
|
39
|
+
result: {
|
|
40
|
+
totals: {
|
|
41
|
+
input: 0,
|
|
42
|
+
output: 0,
|
|
43
|
+
cacheRead: 0,
|
|
44
|
+
cacheWrite: 0,
|
|
45
|
+
totalTokens: 0,
|
|
46
|
+
costTotal: 0,
|
|
47
|
+
},
|
|
48
|
+
byModel: [],
|
|
49
|
+
byProvider: [],
|
|
50
|
+
bySession: [],
|
|
51
|
+
sessionCount: 0,
|
|
52
|
+
messageCount: 0,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
tui.requestRender();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
void load();
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
render: (width: number) => component.render(width),
|
|
63
|
+
invalidate: () => component.invalidate(),
|
|
64
|
+
handleInput: (data: string) => component.handleInput(data),
|
|
65
|
+
dispose: () => {
|
|
66
|
+
controller.abort();
|
|
67
|
+
component.destroy();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Fallback for non-interactive mode
|
|
73
|
+
if (result === undefined) {
|
|
74
|
+
const aggregateResult = await aggregateAllSessions({ cwd });
|
|
75
|
+
const { totals } = aggregateResult;
|
|
76
|
+
const lines = [
|
|
77
|
+
`Token Usage: ${aggregateResult.sessionCount} sessions, ${aggregateResult.messageCount} messages`,
|
|
78
|
+
` Input: ${totals.input.toLocaleString()} · Output: ${totals.output.toLocaleString()}`,
|
|
79
|
+
` Cache Read: ${totals.cacheRead.toLocaleString()} · Cache Write: ${totals.cacheWrite.toLocaleString()}`,
|
|
80
|
+
` Total: ${totals.totalTokens.toLocaleString()} tokens · $${totals.costTotal.toFixed(2)}`,
|
|
81
|
+
];
|
|
82
|
+
for (const model of aggregateResult.byModel) {
|
|
83
|
+
lines.push(
|
|
84
|
+
` ${model.provider}/${model.model}: $${model.tokens.costTotal.toFixed(2)} (${model.messageCount} msgs)`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function registerTokensCommand(pi: ExtensionAPI): void {
|
|
92
|
+
pi.registerCommand("tokens", {
|
|
93
|
+
description: "Display token usage and cost across all sessions",
|
|
94
|
+
handler: async (_args, ctx) => {
|
|
95
|
+
await openTokensView(ctx, ctx.cwd);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default async function (pi: ExtensionAPI) {
|
|
101
|
+
registerTokensCommand(pi);
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./command.js";
|
|
@@ -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,12 +95,22 @@ 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);
|
|
86
106
|
}
|
|
87
107
|
|
|
108
|
+
function scheduleCheck(ctx: ExtensionContext, onlyNew: boolean): void {
|
|
109
|
+
void check(ctx, onlyNew).catch(() => {
|
|
110
|
+
// Quota warnings are opportunistic; never let a failed quota check block Pi events.
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
88
114
|
pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
89
115
|
enabled = (data as QuotasConfigUpdatedPayload).config.quotaWarnings;
|
|
90
116
|
if (!enabled) {
|
|
@@ -93,21 +119,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
93
119
|
}
|
|
94
120
|
if (currentContext) {
|
|
95
121
|
clearAlertState();
|
|
96
|
-
|
|
122
|
+
scheduleCheck(currentContext, false);
|
|
97
123
|
}
|
|
98
124
|
});
|
|
99
125
|
|
|
100
|
-
pi.on("session_start",
|
|
126
|
+
pi.on("session_start", (_event, ctx) => {
|
|
101
127
|
currentContext = ctx;
|
|
102
128
|
clearAlertState();
|
|
103
129
|
if (!enabled) return;
|
|
104
|
-
|
|
130
|
+
scheduleCheck(ctx, false);
|
|
105
131
|
});
|
|
106
132
|
|
|
107
|
-
pi.on("turn_end",
|
|
133
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
108
134
|
currentContext = ctx;
|
|
109
135
|
if (!enabled) return;
|
|
110
|
-
|
|
136
|
+
scheduleCheck(ctx, true);
|
|
111
137
|
});
|
|
112
138
|
|
|
113
139
|
pi.on("model_select", async (_event, ctx) => {
|
|
@@ -122,7 +148,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
122
148
|
|
|
123
149
|
pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
|
|
124
150
|
if (configLoader.getConfig().quotaWarnings) {
|
|
125
|
-
pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, {
|
|
151
|
+
pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, {
|
|
152
|
+
feature: "quotaWarnings",
|
|
153
|
+
});
|
|
126
154
|
}
|
|
127
155
|
});
|
|
128
156
|
}
|