@latentminds/pi-quotas 0.1.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/LICENSE +21 -0
- package/README.md +94 -0
- package/package.json +44 -0
- package/src/config.ts +216 -0
- package/src/extensions/command-quotas/command.ts +122 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +209 -0
- package/src/extensions/command-quotas/index.ts +1 -0
- package/src/extensions/command-quotas/provider-commands.test.ts +34 -0
- package/src/extensions/command-quotas/provider-commands.ts +32 -0
- package/src/extensions/core/index.ts +95 -0
- package/src/extensions/quota-warnings/index.ts +128 -0
- package/src/extensions/usage-status/format-status.test.ts +104 -0
- package/src/extensions/usage-status/format-status.ts +80 -0
- package/src/extensions/usage-status/index.ts +163 -0
- package/src/index.ts +4 -0
- package/src/lib/quotas.ts +96 -0
- package/src/providers/fetch.test.ts +137 -0
- package/src/providers/fetch.ts +245 -0
- package/src/providers/parse.test.ts +256 -0
- package/src/providers/providers.ts +245 -0
- package/src/types/quotas.ts +31 -0
- package/src/utils/quotas-severity.test.ts +111 -0
- package/src/utils/quotas-severity.ts +151 -0
|
@@ -0,0 +1,209 @@
|
|
|
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 { PROVIDER_LABELS } from "../../../lib/quotas.js";
|
|
6
|
+
import type { QuotasResult, SupportedQuotaProvider } from "../../../types/quotas.js";
|
|
7
|
+
import {
|
|
8
|
+
assessWindow,
|
|
9
|
+
formatTimeRemaining,
|
|
10
|
+
getSeverityColor,
|
|
11
|
+
} from "../../../utils/quotas-severity.js";
|
|
12
|
+
|
|
13
|
+
type Snapshot = {
|
|
14
|
+
provider: SupportedQuotaProvider;
|
|
15
|
+
result: QuotasResult;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type QuotasState =
|
|
19
|
+
| { type: "loading" }
|
|
20
|
+
| { type: "loaded"; snapshots: Snapshot[] };
|
|
21
|
+
|
|
22
|
+
function fgAnsiToBg(fgAnsi: string): string {
|
|
23
|
+
return fgAnsi.split("[38;").join("[48;").replace(/\[3([0-9])m/g, "[4$1m");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderProgressBar(
|
|
27
|
+
percent: number,
|
|
28
|
+
width: number,
|
|
29
|
+
theme: Theme,
|
|
30
|
+
fillColor: "success" | "warning" | "error",
|
|
31
|
+
pacePercent?: number | null,
|
|
32
|
+
): string {
|
|
33
|
+
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
|
34
|
+
const filled = Math.round((clamped / 100) * width);
|
|
35
|
+
const showPace =
|
|
36
|
+
pacePercent !== null &&
|
|
37
|
+
pacePercent !== undefined &&
|
|
38
|
+
pacePercent >= 5 &&
|
|
39
|
+
Math.abs(pacePercent - percent) >= 5;
|
|
40
|
+
const paceIndex = showPace
|
|
41
|
+
? Math.min(width - 1, Math.round((Math.max(0, Math.min(100, pacePercent ?? 0)) / 100) * width))
|
|
42
|
+
: null;
|
|
43
|
+
const reset = "\x1b[0m";
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
for (let idx = 0; idx < width; idx++) {
|
|
46
|
+
if (paceIndex !== null && idx === paceIndex) {
|
|
47
|
+
const markerColor = idx < filled ? "accent" : fillColor;
|
|
48
|
+
if (idx < filled) {
|
|
49
|
+
parts.push(`${fgAnsiToBg(theme.getFgAnsi(fillColor))}${theme.getFgAnsi(markerColor)}|${reset}`);
|
|
50
|
+
} else {
|
|
51
|
+
parts.push(theme.fg(markerColor, "|"));
|
|
52
|
+
}
|
|
53
|
+
} else if (idx < filled) {
|
|
54
|
+
parts.push(theme.fg(fillColor, "█"));
|
|
55
|
+
} else {
|
|
56
|
+
parts.push(theme.fg("dim", "░"));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return parts.join("");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class QuotasComponent implements Component {
|
|
63
|
+
private state: QuotasState = { type: "loading" };
|
|
64
|
+
private loader: Loader | null = null;
|
|
65
|
+
|
|
66
|
+
constructor(
|
|
67
|
+
private theme: Theme,
|
|
68
|
+
private tui: any,
|
|
69
|
+
private title: string,
|
|
70
|
+
private onClose: () => void,
|
|
71
|
+
private onRefetch: () => void,
|
|
72
|
+
) {
|
|
73
|
+
this.startLoader();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private startLoader(): void {
|
|
77
|
+
this.loader = new Loader(
|
|
78
|
+
this.tui,
|
|
79
|
+
(s: string) => this.theme.fg("accent", s),
|
|
80
|
+
(s: string) => this.theme.fg("muted", s),
|
|
81
|
+
"Fetching quotas...",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
destroy(): void {
|
|
86
|
+
this.loader?.stop();
|
|
87
|
+
this.loader = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setState(state: QuotasState): void {
|
|
91
|
+
if (state.type === "loading") {
|
|
92
|
+
this.loader?.stop();
|
|
93
|
+
this.startLoader();
|
|
94
|
+
} else if (this.state.type === "loading") {
|
|
95
|
+
this.loader?.stop();
|
|
96
|
+
this.loader = null;
|
|
97
|
+
}
|
|
98
|
+
this.state = state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
handleInput(data: string): boolean {
|
|
102
|
+
if (matchesKey(data, "escape") || data === "q") {
|
|
103
|
+
this.onClose();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (data === "r") {
|
|
107
|
+
this.onRefetch();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
render(width: number): string[] {
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
const border = new DynamicBorder((s: string) => this.theme.fg("border", s));
|
|
116
|
+
lines.push(...border.render(width));
|
|
117
|
+
lines.push(truncateToWidth(` ${this.theme.fg("accent", this.theme.bold(this.title))}`, width));
|
|
118
|
+
|
|
119
|
+
if (this.state.type === "loading") {
|
|
120
|
+
lines.push(...(this.loader ? this.loader.render(width) : [this.theme.fg("muted", " Fetching quotas...")]));
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(...this.renderLoaded(this.state.snapshots, width));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push(this.theme.fg("dim", " r to refresh q/Esc to close"));
|
|
127
|
+
lines.push(...border.render(width));
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private renderLoaded(snapshots: Snapshot[], maxWidth: number): string[] {
|
|
132
|
+
const lines: string[] = [""];
|
|
133
|
+
for (const snapshot of snapshots) {
|
|
134
|
+
lines.push(...this.renderProvider(snapshot, maxWidth));
|
|
135
|
+
lines.push("");
|
|
136
|
+
}
|
|
137
|
+
if (lines.at(-1) === "") lines.pop();
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private renderProvider(snapshot: Snapshot, maxWidth: number): string[] {
|
|
142
|
+
const lines: string[] = [];
|
|
143
|
+
const title = PROVIDER_LABELS[snapshot.provider];
|
|
144
|
+
lines.push(truncateToWidth(` ${this.theme.fg("accent", title)}`, maxWidth));
|
|
145
|
+
|
|
146
|
+
if (!snapshot.result.success) {
|
|
147
|
+
lines.push(truncateToWidth(` ${this.theme.fg("warning", snapshot.result.error.message)}`, maxWidth));
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const windows = snapshot.result.data.windows;
|
|
152
|
+
if (windows.length === 0) {
|
|
153
|
+
lines.push(truncateToWidth(` ${this.theme.fg("dim", "No quota windows available")}`, maxWidth));
|
|
154
|
+
return lines;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const barWidth = Math.min(42, Math.max(18, maxWidth - 28));
|
|
158
|
+
for (const window of windows) {
|
|
159
|
+
const assessment = assessWindow(window);
|
|
160
|
+
const color = getSeverityColor(assessment.severity);
|
|
161
|
+
|
|
162
|
+
// Format the usage string depending on window type
|
|
163
|
+
let usedStr: string;
|
|
164
|
+
if (window.isCurrency) {
|
|
165
|
+
usedStr = `$${window.usedValue.toFixed(2)} / $${window.limitValue.toFixed(2)}`;
|
|
166
|
+
} else if (window.limitValue <= 1 && window.label === "Spend cap") {
|
|
167
|
+
usedStr = window.limited ? "REACHED" : "OK";
|
|
168
|
+
} else if (window.limitValue > 0 && window.limitValue !== 100) {
|
|
169
|
+
// Real counts: show remaining/total (e.g. "293/300")
|
|
170
|
+
const remaining = Math.max(0, Math.round(window.limitValue - window.usedValue));
|
|
171
|
+
usedStr = `${remaining}/${window.limitValue} left`;
|
|
172
|
+
} else {
|
|
173
|
+
const remaining = Math.max(0, Math.min(100, Math.round(100 - window.usedPercent)));
|
|
174
|
+
usedStr = `${remaining}% left`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const bar = renderProgressBar(window.usedPercent, barWidth, this.theme, color, assessment.pacePercent);
|
|
178
|
+
const limitedBadge = window.limited ? this.theme.fg("error", " LIMITED") : "";
|
|
179
|
+
// Color the label based on severity: dim when safe, colored when at risk
|
|
180
|
+
const isAtRisk = assessment.severity !== "none";
|
|
181
|
+
const labelColor = isAtRisk ? color : "dim";
|
|
182
|
+
lines.push(truncateToWidth(` ${this.theme.fg(labelColor, `${window.label}:`)}`, maxWidth));
|
|
183
|
+
lines.push(truncateToWidth(` ${bar} ${this.theme.fg(color, usedStr)}${limitedBadge}`, maxWidth));
|
|
184
|
+
|
|
185
|
+
// Subtitle: next event info + overage
|
|
186
|
+
const subtitleParts: string[] = [];
|
|
187
|
+
if (window.resetsAt.getTime() > 0) {
|
|
188
|
+
subtitleParts.push(`${window.nextLabel ?? "Resets"} in ${formatTimeRemaining(window.resetsAt)}`);
|
|
189
|
+
} else if (window.nextLabel) {
|
|
190
|
+
subtitleParts.push(window.nextLabel);
|
|
191
|
+
}
|
|
192
|
+
if (window.nextAmount) {
|
|
193
|
+
subtitleParts.push(window.nextAmount);
|
|
194
|
+
}
|
|
195
|
+
if (subtitleParts.length > 0) {
|
|
196
|
+
lines.push(
|
|
197
|
+
truncateToWidth(
|
|
198
|
+
` ${this.theme.fg("dim", subtitleParts.join(" · "))}`,
|
|
199
|
+
maxWidth,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return lines;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
invalidate(): void {}
|
|
209
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./command.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getProviderCommandInfo,
|
|
4
|
+
type ProviderCommandInfo,
|
|
5
|
+
} from "./provider-commands.js";
|
|
6
|
+
|
|
7
|
+
describe("getProviderCommandInfo", () => {
|
|
8
|
+
it("maps anthropic to anthropic:quotas", () => {
|
|
9
|
+
const info = getProviderCommandInfo("anthropic");
|
|
10
|
+
expect(info).toMatchObject<Partial<ProviderCommandInfo>>({
|
|
11
|
+
provider: "anthropic",
|
|
12
|
+
commandName: "anthropic:quotas",
|
|
13
|
+
title: "Anthropic Quotas",
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("maps openai-codex to codex:quotas", () => {
|
|
18
|
+
const info = getProviderCommandInfo("openai-codex");
|
|
19
|
+
expect(info).toMatchObject<Partial<ProviderCommandInfo>>({
|
|
20
|
+
provider: "openai-codex",
|
|
21
|
+
commandName: "codex:quotas",
|
|
22
|
+
title: "OpenAI Codex Quotas",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("maps github-copilot to github:quotas", () => {
|
|
27
|
+
const info = getProviderCommandInfo("github-copilot");
|
|
28
|
+
expect(info).toMatchObject<Partial<ProviderCommandInfo>>({
|
|
29
|
+
provider: "github-copilot",
|
|
30
|
+
commandName: "github:quotas",
|
|
31
|
+
title: "GitHub Copilot Quotas",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SupportedQuotaProvider } from "../../types/quotas.js";
|
|
2
|
+
|
|
3
|
+
export interface ProviderCommandInfo {
|
|
4
|
+
provider: SupportedQuotaProvider;
|
|
5
|
+
commandName: string;
|
|
6
|
+
title: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getProviderCommandInfo(
|
|
10
|
+
provider: SupportedQuotaProvider,
|
|
11
|
+
): ProviderCommandInfo {
|
|
12
|
+
switch (provider) {
|
|
13
|
+
case "anthropic":
|
|
14
|
+
return {
|
|
15
|
+
provider,
|
|
16
|
+
commandName: "anthropic:quotas",
|
|
17
|
+
title: "Anthropic Quotas",
|
|
18
|
+
};
|
|
19
|
+
case "openai-codex":
|
|
20
|
+
return {
|
|
21
|
+
provider,
|
|
22
|
+
commandName: "codex:quotas",
|
|
23
|
+
title: "OpenAI Codex Quotas",
|
|
24
|
+
};
|
|
25
|
+
case "github-copilot":
|
|
26
|
+
return {
|
|
27
|
+
provider,
|
|
28
|
+
commandName: "github:quotas",
|
|
29
|
+
title: "GitHub Copilot Quotas",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
clearPendingMigrationNotice,
|
|
5
|
+
emitQuotasConfigUpdated,
|
|
6
|
+
hasPendingMigrationNotice,
|
|
7
|
+
QUOTAS_EXTENSIONS_REGISTER_EVENT,
|
|
8
|
+
QUOTAS_EXTENSIONS_REQUEST_EVENT,
|
|
9
|
+
registerQuotasSettings,
|
|
10
|
+
type QuotasExtensionsRegisterPayload,
|
|
11
|
+
type QuotasFeatureId,
|
|
12
|
+
configLoader,
|
|
13
|
+
seedQuotasConfigIfMissing,
|
|
14
|
+
} from "../../config.js";
|
|
15
|
+
|
|
16
|
+
const NOTICE_TYPE = "quotas:migration-notice";
|
|
17
|
+
const NOTICE_TITLE = "pi-quotas";
|
|
18
|
+
const NOTICE_CONTENT = [
|
|
19
|
+
"Optional features available in `pi-quotas`:",
|
|
20
|
+
"- Combined quotas command",
|
|
21
|
+
"- Provider-specific quota commands",
|
|
22
|
+
"- Usage footer status",
|
|
23
|
+
"- Quota warnings",
|
|
24
|
+
"",
|
|
25
|
+
"Use `/quotas:settings` to enable or disable them.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
function wrapInRoundedBorder(
|
|
29
|
+
lines: string[],
|
|
30
|
+
width: number,
|
|
31
|
+
colorFn: (text: string) => string,
|
|
32
|
+
): string[] {
|
|
33
|
+
const innerWidth = Math.max(1, width - 2);
|
|
34
|
+
const hBar = "─".repeat(innerWidth);
|
|
35
|
+
const top = colorFn(`╭${hBar}╮`);
|
|
36
|
+
const bottom = colorFn(`╰${hBar}╯`);
|
|
37
|
+
const left = colorFn("│");
|
|
38
|
+
const right = colorFn("│");
|
|
39
|
+
return [
|
|
40
|
+
top,
|
|
41
|
+
...lines.map((line) => {
|
|
42
|
+
const fill = Math.max(0, innerWidth - visibleWidth(line));
|
|
43
|
+
return `${left}${line}${" ".repeat(fill)}${right}`;
|
|
44
|
+
}),
|
|
45
|
+
bottom,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function highlightInlineCode(text: string, colorFn: (text: string) => string): string {
|
|
50
|
+
return text.replace(/`([^`]+)`/g, (_, code) => colorFn(code));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default async function (pi: ExtensionAPI) {
|
|
54
|
+
await configLoader.load();
|
|
55
|
+
await seedQuotasConfigIfMissing();
|
|
56
|
+
|
|
57
|
+
pi.registerMessageRenderer(NOTICE_TYPE, (message, _options, theme) => {
|
|
58
|
+
const rawContent = typeof message.content === "string" ? message.content : NOTICE_CONTENT;
|
|
59
|
+
const accent = (t: string) => theme.fg("accent", t);
|
|
60
|
+
const title = theme.bold(accent(NOTICE_TITLE));
|
|
61
|
+
const body = highlightInlineCode(rawContent, accent);
|
|
62
|
+
return {
|
|
63
|
+
render(width: number) {
|
|
64
|
+
const contentWidth = Math.max(1, width - 4);
|
|
65
|
+
const bodyLines = wrapTextWithAnsi(body, contentWidth);
|
|
66
|
+
return wrapInRoundedBorder([` ${title} `, " ", ...bodyLines.map((line) => ` ${line} `)], width, accent);
|
|
67
|
+
},
|
|
68
|
+
handleInput() {
|
|
69
|
+
return false;
|
|
70
|
+
},
|
|
71
|
+
invalidate() {},
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const loadedFeatures = new Set<QuotasFeatureId>();
|
|
76
|
+
pi.events.on(QUOTAS_EXTENSIONS_REGISTER_EVENT, (data: unknown) => {
|
|
77
|
+
const { feature } = data as QuotasExtensionsRegisterPayload;
|
|
78
|
+
loadedFeatures.add(feature);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
registerQuotasSettings(pi, () => loadedFeatures);
|
|
82
|
+
|
|
83
|
+
pi.on("session_start", async () => {
|
|
84
|
+
loadedFeatures.clear();
|
|
85
|
+
pi.events.emit(QUOTAS_EXTENSIONS_REQUEST_EVENT, undefined);
|
|
86
|
+
emitQuotasConfigUpdated(pi);
|
|
87
|
+
if (hasPendingMigrationNotice()) {
|
|
88
|
+
clearPendingMigrationNotice();
|
|
89
|
+
pi.sendMessage(
|
|
90
|
+
{ customType: NOTICE_TYPE, content: NOTICE_CONTENT, display: true },
|
|
91
|
+
{ triggerTurn: false },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
QUOTAS_CONFIG_UPDATED_EVENT,
|
|
4
|
+
QUOTAS_EXTENSIONS_REGISTER_EVENT,
|
|
5
|
+
QUOTAS_EXTENSIONS_REQUEST_EVENT,
|
|
6
|
+
type QuotasConfigUpdatedPayload,
|
|
7
|
+
configLoader,
|
|
8
|
+
} from "../../config.js";
|
|
9
|
+
import { fetchProviderQuotas, isSupportedProvider } from "../../lib/quotas.js";
|
|
10
|
+
import {
|
|
11
|
+
assessWindow,
|
|
12
|
+
formatTimeRemaining,
|
|
13
|
+
type RiskSeverity,
|
|
14
|
+
} from "../../utils/quotas-severity.js";
|
|
15
|
+
|
|
16
|
+
const COOLDOWN_MS = 60 * 60 * 1000;
|
|
17
|
+
const MIN_FETCH_INTERVAL_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
type AlertState = { lastSeverity: RiskSeverity; lastNotifiedAt: number };
|
|
20
|
+
const alertState = new Map<string, AlertState>();
|
|
21
|
+
let lastFetchAt = 0;
|
|
22
|
+
|
|
23
|
+
function shouldNotify(key: string, severity: RiskSeverity): boolean {
|
|
24
|
+
const current = alertState.get(key);
|
|
25
|
+
if (!current) return true;
|
|
26
|
+
const order: RiskSeverity[] = ["none", "warning", "high", "critical"];
|
|
27
|
+
if (order.indexOf(severity) > order.indexOf(current.lastSeverity)) return true;
|
|
28
|
+
if (severity === "high" || severity === "critical") return true;
|
|
29
|
+
if (severity === "warning") return Date.now() - current.lastNotifiedAt >= COOLDOWN_MS;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function markNotified(key: string, severity: RiskSeverity): void {
|
|
34
|
+
alertState.set(key, { lastSeverity: severity, lastNotifiedAt: Date.now() });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clearAlertState(): void {
|
|
38
|
+
alertState.clear();
|
|
39
|
+
lastFetchAt = 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default async function (pi: ExtensionAPI) {
|
|
43
|
+
await configLoader.load();
|
|
44
|
+
let enabled = configLoader.getConfig().quotaWarnings;
|
|
45
|
+
let currentContext: ExtensionContext | undefined;
|
|
46
|
+
async function check(ctx: ExtensionContext, onlyNew: boolean): Promise<void> {
|
|
47
|
+
const provider = ctx.model?.provider;
|
|
48
|
+
if (!ctx.hasUI || !provider || !isSupportedProvider(provider)) return;
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
if (onlyNew && now - lastFetchAt < MIN_FETCH_INTERVAL_MS) return;
|
|
51
|
+
lastFetchAt = now;
|
|
52
|
+
|
|
53
|
+
const result = await fetchProviderQuotas(ctx.modelRegistry.authStorage, provider);
|
|
54
|
+
if (!result.success) return;
|
|
55
|
+
|
|
56
|
+
const risky = result.data.windows
|
|
57
|
+
.map((window) => ({ window, assessment: assessWindow(window) }))
|
|
58
|
+
.filter((entry) => entry.assessment.severity !== "none");
|
|
59
|
+
if (risky.length === 0) return;
|
|
60
|
+
|
|
61
|
+
const toNotify = onlyNew
|
|
62
|
+
? risky.filter((entry) => shouldNotify(`${provider}:${entry.window.label}`, entry.assessment.severity))
|
|
63
|
+
: risky;
|
|
64
|
+
if (toNotify.length === 0) return;
|
|
65
|
+
|
|
66
|
+
for (const entry of toNotify) {
|
|
67
|
+
markNotified(`${provider}:${entry.window.label}`, entry.assessment.severity);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const providerName = provider === "openai-codex"
|
|
71
|
+
? "Codex"
|
|
72
|
+
: provider === "github-copilot"
|
|
73
|
+
? "GitHub Copilot"
|
|
74
|
+
: "Anthropic";
|
|
75
|
+
|
|
76
|
+
const lines = toNotify.map(({ window, assessment }) => {
|
|
77
|
+
const projected = Math.round(assessment.projectedPercent);
|
|
78
|
+
const used = Math.round(window.usedPercent);
|
|
79
|
+
return `- ${window.label}: ${used}% used, projected ${projected}% (${assessment.severity}), resets in ${formatTimeRemaining(window.resetsAt)}`;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const level = toNotify.some((entry) => entry.assessment.severity === "critical" || entry.assessment.severity === "high")
|
|
83
|
+
? "error"
|
|
84
|
+
: "warning";
|
|
85
|
+
ctx.ui.notify(`${providerName} quota warning:\n${lines.join("\n")}`, level);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
89
|
+
enabled = (data as QuotasConfigUpdatedPayload).config.quotaWarnings;
|
|
90
|
+
if (!enabled) {
|
|
91
|
+
clearAlertState();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (currentContext) {
|
|
95
|
+
clearAlertState();
|
|
96
|
+
void check(currentContext, false);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
101
|
+
currentContext = ctx;
|
|
102
|
+
clearAlertState();
|
|
103
|
+
if (!enabled) return;
|
|
104
|
+
await check(ctx, false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
108
|
+
currentContext = ctx;
|
|
109
|
+
if (!enabled) return;
|
|
110
|
+
await check(ctx, true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
114
|
+
currentContext = ctx;
|
|
115
|
+
clearAlertState();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("session_shutdown", async () => {
|
|
119
|
+
currentContext = undefined;
|
|
120
|
+
clearAlertState();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
pi.events.on(QUOTAS_EXTENSIONS_REQUEST_EVENT, () => {
|
|
124
|
+
if (configLoader.getConfig().quotaWarnings) {
|
|
125
|
+
pi.events.emit(QUOTAS_EXTENSIONS_REGISTER_EVENT, { feature: "quotaWarnings" });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatWindowStatus, type WindowStatus } from "./format-status.js";
|
|
3
|
+
|
|
4
|
+
// Minimal fake theme that just returns text with markers for color assertions
|
|
5
|
+
function fakeTheme() {
|
|
6
|
+
return {
|
|
7
|
+
fg: (color: string, text: string) => `[${color}]${text}[/${color}]`,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("formatWindowStatus", () => {
|
|
12
|
+
const theme = fakeTheme() as any;
|
|
13
|
+
|
|
14
|
+
it("shows remaining/limit for windows with known limits (GitHub premium)", () => {
|
|
15
|
+
const w: WindowStatus = {
|
|
16
|
+
label: "Premium / month",
|
|
17
|
+
usedPercent: 2.3,
|
|
18
|
+
severity: "none",
|
|
19
|
+
resetsAt: "2026-05-01T00:00:00Z",
|
|
20
|
+
limited: false,
|
|
21
|
+
usedValue: 7,
|
|
22
|
+
limitValue: 300,
|
|
23
|
+
};
|
|
24
|
+
const result = formatWindowStatus(theme, w);
|
|
25
|
+
expect(result).toContain("293/300");
|
|
26
|
+
expect(result).toContain("[success]");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("shows remaining % for percentage-only windows (Anthropic 5h)", () => {
|
|
30
|
+
const w: WindowStatus = {
|
|
31
|
+
label: "5h",
|
|
32
|
+
usedPercent: 9,
|
|
33
|
+
severity: "none",
|
|
34
|
+
resetsAt: "2026-04-22T18:00:00Z",
|
|
35
|
+
limited: false,
|
|
36
|
+
usedValue: 9,
|
|
37
|
+
limitValue: 100,
|
|
38
|
+
};
|
|
39
|
+
const result = formatWindowStatus(theme, w);
|
|
40
|
+
expect(result).toContain("91% left");
|
|
41
|
+
expect(result).toContain("[success]");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("shows currency for isCurrency windows (Anthropic extra)", () => {
|
|
45
|
+
const w: WindowStatus = {
|
|
46
|
+
label: "Extra (AUD)",
|
|
47
|
+
usedPercent: 71.8,
|
|
48
|
+
severity: "warning",
|
|
49
|
+
resetsAt: "2026-05-01T00:00:00Z",
|
|
50
|
+
limited: false,
|
|
51
|
+
isCurrency: true,
|
|
52
|
+
usedValue: 215,
|
|
53
|
+
limitValue: 300,
|
|
54
|
+
};
|
|
55
|
+
const result = formatWindowStatus(theme, w);
|
|
56
|
+
expect(result).toContain("$215/$300");
|
|
57
|
+
expect(result).toContain("[warning]");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("shows REACHED for spend cap", () => {
|
|
61
|
+
const w: WindowStatus = {
|
|
62
|
+
label: "Spend cap",
|
|
63
|
+
usedPercent: 100,
|
|
64
|
+
severity: "critical",
|
|
65
|
+
resetsAt: null,
|
|
66
|
+
limited: true,
|
|
67
|
+
usedValue: 1,
|
|
68
|
+
limitValue: 1,
|
|
69
|
+
};
|
|
70
|
+
const result = formatWindowStatus(theme, w);
|
|
71
|
+
expect(result).toContain("REACHED");
|
|
72
|
+
expect(result).toContain("[error]");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("colors label when severity is warning or worse", () => {
|
|
76
|
+
const w: WindowStatus = {
|
|
77
|
+
label: "7d",
|
|
78
|
+
usedPercent: 85,
|
|
79
|
+
severity: "high",
|
|
80
|
+
resetsAt: "2026-04-23T23:00:00Z",
|
|
81
|
+
limited: false,
|
|
82
|
+
usedValue: 85,
|
|
83
|
+
limitValue: 100,
|
|
84
|
+
};
|
|
85
|
+
const result = formatWindowStatus(theme, w);
|
|
86
|
+
// label should be colored with error (high maps to error)
|
|
87
|
+
expect(result).toContain("[error]7d:");
|
|
88
|
+
expect(result).toContain("15% left");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps label dim when severity is none", () => {
|
|
92
|
+
const w: WindowStatus = {
|
|
93
|
+
label: "5h",
|
|
94
|
+
usedPercent: 10,
|
|
95
|
+
severity: "none",
|
|
96
|
+
resetsAt: "2026-04-22T18:00:00Z",
|
|
97
|
+
limited: false,
|
|
98
|
+
usedValue: 10,
|
|
99
|
+
limitValue: 100,
|
|
100
|
+
};
|
|
101
|
+
const result = formatWindowStatus(theme, w);
|
|
102
|
+
expect(result).toContain("[dim]5h:");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { RiskSeverity } from "../../utils/quotas-severity.js";
|
|
2
|
+
import { getSeverityColor } from "../../utils/quotas-severity.js";
|
|
3
|
+
|
|
4
|
+
export type WindowStatus = {
|
|
5
|
+
label: string;
|
|
6
|
+
usedPercent: number;
|
|
7
|
+
severity: RiskSeverity;
|
|
8
|
+
resetsAt: string | null;
|
|
9
|
+
limited: boolean;
|
|
10
|
+
isCurrency?: boolean;
|
|
11
|
+
usedValue?: number;
|
|
12
|
+
limitValue?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface ThemeLike {
|
|
16
|
+
fg(color: string, text: string): string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SHORT_LABELS: Record<string, string> = {
|
|
20
|
+
"5h": "5h",
|
|
21
|
+
"7d": "7d",
|
|
22
|
+
"7d Sonnet": "7d-son",
|
|
23
|
+
"7d Opus": "7d-opus",
|
|
24
|
+
"7d Opus (legacy)": "7d-opus",
|
|
25
|
+
"Premium / month": "premium",
|
|
26
|
+
"Chat / month": "chat",
|
|
27
|
+
"Completions / month": "comp",
|
|
28
|
+
"Spend cap": "cap",
|
|
29
|
+
"Credits": "credits",
|
|
30
|
+
"Extra (AUD)": "extra",
|
|
31
|
+
"Extra (USD)": "extra",
|
|
32
|
+
"Extra (EUR)": "extra",
|
|
33
|
+
"Extra (GBP)": "extra",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns true when a window has a real used/limit pair
|
|
38
|
+
* (e.g. 293/300 premium requests) rather than just a percentage.
|
|
39
|
+
*/
|
|
40
|
+
function hasRealCounts(w: WindowStatus): boolean {
|
|
41
|
+
if (w.limitValue == null || w.usedValue == null) return false;
|
|
42
|
+
// Percentage-only windows store limitValue=100 and usedValue=usedPercent
|
|
43
|
+
if (w.limitValue === 100 && Math.abs(w.usedValue - w.usedPercent) < 0.01) return false;
|
|
44
|
+
return w.limitValue > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Format a single window for the footer status bar.
|
|
49
|
+
*
|
|
50
|
+
* - Colors both the label and value based on severity
|
|
51
|
+
* - Uses used/limit for real counts (e.g. "7/300")
|
|
52
|
+
* - Uses "$X/$Y" for currency windows
|
|
53
|
+
* - Uses "N% left" for percentage-only windows
|
|
54
|
+
* - Uses "REACHED" / "OK" for spend cap
|
|
55
|
+
*/
|
|
56
|
+
export function formatWindowStatus(theme: ThemeLike, w: WindowStatus): string {
|
|
57
|
+
const short = SHORT_LABELS[w.label] ?? w.label;
|
|
58
|
+
const color = getSeverityColor(w.severity);
|
|
59
|
+
|
|
60
|
+
// Color the label based on severity: dim when safe, colored when at risk
|
|
61
|
+
const isAtRisk = w.severity !== "none";
|
|
62
|
+
const labelColor = isAtRisk ? color : "dim";
|
|
63
|
+
const labelText = theme.fg(labelColor, `${short}:`);
|
|
64
|
+
|
|
65
|
+
let valueText: string;
|
|
66
|
+
if (w.label === "Spend cap") {
|
|
67
|
+
valueText = theme.fg(color, w.limited ? "REACHED" : "OK");
|
|
68
|
+
} else if (w.isCurrency && w.usedValue != null && w.limitValue != null) {
|
|
69
|
+
valueText = theme.fg(color, `$${w.usedValue.toFixed(0)}/$${w.limitValue.toFixed(0)}`);
|
|
70
|
+
} else if (hasRealCounts(w)) {
|
|
71
|
+
const remaining = Math.max(0, Math.round(w.limitValue! - w.usedValue!));
|
|
72
|
+
valueText = theme.fg(color, `${remaining}/${w.limitValue}`);
|
|
73
|
+
} else {
|
|
74
|
+
const remaining = Math.max(0, Math.min(100, Math.round(100 - w.usedPercent)));
|
|
75
|
+
valueText = theme.fg(color, `${remaining}% left`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const limitTag = w.limited ? theme.fg("error", " !") : "";
|
|
79
|
+
return `${labelText}${valueText}${limitTag}`;
|
|
80
|
+
}
|