@aliou/pi-synthetic 0.10.2 → 0.12.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.
@@ -0,0 +1,280 @@
1
+ import { assert, beforeEach, describe, expect, it } from "vitest";
2
+ import type { QuotasResponse } from "../../types/quotas";
3
+ import { assessWindow, type QuotaWindow } from "../../utils/quotas-severity";
4
+ import {
5
+ clearAlertState,
6
+ findHighRiskWindows,
7
+ formatWarningMessage,
8
+ markNotified,
9
+ shouldNotify,
10
+ } from "./notifier";
11
+
12
+ // Access the module-scoped windowAlerts map for test inspection.
13
+ // We import the module and rely on clearAlertState() to reset between tests.
14
+ //
15
+ // Since windowAlerts is not exported, we test shouldNotify/markNotified
16
+ // by observing their behavior (the state machine) rather than reading the map directly.
17
+
18
+ beforeEach(() => {
19
+ clearAlertState();
20
+ });
21
+
22
+ describe("shouldNotify", () => {
23
+ it("notifies on first time seeing a window at risk", () => {
24
+ expect(shouldNotify("Credits / week", "warning")).toBe(true);
25
+ expect(shouldNotify("Requests / 5h", "high")).toBe(true);
26
+ expect(shouldNotify("Search / hour", "critical")).toBe(true);
27
+ });
28
+
29
+ it("notifies on severity escalation", () => {
30
+ markNotified("Credits / week", "warning");
31
+ expect(shouldNotify("Credits / week", "high")).toBe(true);
32
+
33
+ markNotified("Requests / 5h", "high");
34
+ expect(shouldNotify("Requests / 5h", "critical")).toBe(true);
35
+ });
36
+
37
+ it("notifies on skip from none to any risk level", () => {
38
+ // When a window was at "none" (implicitly, by not being in the map)
39
+ // and escalates, it's first-time => true. But also test explicit none->warning.
40
+ markNotified("Test", "none");
41
+ expect(shouldNotify("Test", "warning")).toBe(true);
42
+ });
43
+
44
+ it("does not notify on same severity for warning within cooldown", () => {
45
+ markNotified("Credits / week", "warning");
46
+ expect(shouldNotify("Credits / week", "warning")).toBe(false);
47
+ });
48
+
49
+ it("does not notify on downgrade to warning", () => {
50
+ markNotified("Credits / week", "high");
51
+ expect(shouldNotify("Credits / week", "warning")).toBe(false);
52
+ });
53
+
54
+ it("does notify on downgrade to high (no cooldown)", () => {
55
+ // high always re-notifies regardless of previous severity
56
+ markNotified("Requests / 5h", "critical");
57
+ expect(shouldNotify("Requests / 5h", "high")).toBe(true);
58
+ });
59
+
60
+ it("always notifies for high severity (no cooldown)", () => {
61
+ markNotified("Credits / week", "high");
62
+ // Same severity, but high always re-notifies
63
+ expect(shouldNotify("Credits / week", "high")).toBe(true);
64
+ });
65
+
66
+ it("always notifies for critical severity (no cooldown)", () => {
67
+ markNotified("Credits / week", "critical");
68
+ expect(shouldNotify("Credits / week", "critical")).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("markNotified", () => {
73
+ it("tracks severity per window key", () => {
74
+ markNotified("Credits / week", "warning");
75
+ // After marking as warning, re-checking warning should be blocked
76
+ expect(shouldNotify("Credits / week", "warning")).toBe(false);
77
+
78
+ // But a different key is independent
79
+ expect(shouldNotify("Requests / 5h", "warning")).toBe(true);
80
+ });
81
+
82
+ it("allows re-notification after escalation then downgrade then re-escalation", () => {
83
+ markNotified("Test", "high");
84
+ // Downgrade doesn't notify but updates state
85
+ expect(shouldNotify("Test", "warning")).toBe(false);
86
+ // Re-escalation notifies
87
+ expect(shouldNotify("Test", "high")).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe("clearAlertState", () => {
92
+ it("resets all alert state so windows notify again", () => {
93
+ markNotified("Credits / week", "warning");
94
+ expect(shouldNotify("Credits / week", "warning")).toBe(false);
95
+
96
+ clearAlertState();
97
+
98
+ expect(shouldNotify("Credits / week", "warning")).toBe(true);
99
+ });
100
+ });
101
+
102
+ describe("findHighRiskWindows", () => {
103
+ const baseQuotas: QuotasResponse = {
104
+ weeklyTokenLimit: {
105
+ nextRegenAt: new Date(Date.now() + 6 * 24 * 3600 * 1000).toISOString(),
106
+ percentRemaining: 90, // 10% used
107
+ maxCredits: "$10.00",
108
+ remainingCredits: "$9.00",
109
+ nextRegenCredits: "$0.50",
110
+ },
111
+ rollingFiveHourLimit: {
112
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
113
+ tickPercent: 10,
114
+ remaining: 90,
115
+ max: 100,
116
+ limited: false,
117
+ },
118
+ search: {
119
+ hourly: {
120
+ limit: 100,
121
+ requests: 10, // 10% used
122
+ renewsAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
123
+ },
124
+ },
125
+ freeToolCalls: {
126
+ limit: 100,
127
+ requests: 5, // 5% used
128
+ renewsAt: new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
129
+ },
130
+ };
131
+
132
+ it("returns empty for low-usage quotas", () => {
133
+ const risks = findHighRiskWindows(baseQuotas);
134
+ // All windows have low usage, most should be "none"
135
+ // The 5h window at 10% used with no pace => "none"
136
+ // Weekly at 10% with paceScale 1/7 => very low projected => "none"
137
+ expect(risks).toHaveLength(0);
138
+ });
139
+
140
+ it("finds windows with high usage", () => {
141
+ const quotas: QuotasResponse = {
142
+ ...baseQuotas,
143
+ rollingFiveHourLimit: {
144
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
145
+ tickPercent: 10,
146
+ remaining: 5,
147
+ max: 100,
148
+ limited: false,
149
+ },
150
+ };
151
+ const risks = findHighRiskWindows(quotas);
152
+ // 95% used, no pace => static: >=90 => high
153
+ const fiveHourRisk = risks.find((r) => r.window.label === "Requests / 5h");
154
+ assert(fiveHourRisk, "fiveHourRisk should exist");
155
+ expect(fiveHourRisk.assessment.severity).toBe("high");
156
+ });
157
+
158
+ it("finds limited windows even with low usage", () => {
159
+ const quotas: QuotasResponse = {
160
+ ...baseQuotas,
161
+ rollingFiveHourLimit: {
162
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
163
+ tickPercent: 10,
164
+ remaining: 95,
165
+ max: 100,
166
+ limited: true,
167
+ },
168
+ };
169
+ const risks = findHighRiskWindows(quotas);
170
+ const fiveHourRisk = risks.find((r) => r.window.label === "Requests / 5h");
171
+ assert(fiveHourRisk, "fiveHourRisk should exist");
172
+ expect(fiveHourRisk.assessment.severity).toBe("critical");
173
+ });
174
+
175
+ it("returns empty for quotas with no windows", () => {
176
+ const quotas: QuotasResponse = {};
177
+ expect(findHighRiskWindows(quotas)).toHaveLength(0);
178
+ });
179
+ });
180
+
181
+ describe("formatWarningMessage", () => {
182
+ it("formats single window warning", () => {
183
+ const w: QuotaWindow = {
184
+ label: "Requests / 5h",
185
+ usedPercent: 92,
186
+ resetsAt: new Date(Date.now() + 2 * 3600 * 1000),
187
+ windowSeconds: 5 * 3600,
188
+ usedValue: 92,
189
+ limitValue: 100,
190
+ showPace: false,
191
+ };
192
+ const assessment = assessWindow(w);
193
+ const msg = formatWarningMessage([{ window: w, assessment }]);
194
+ expect(msg).toContain("Synthetic quota warning:");
195
+ expect(msg).toContain("Requests / 5h");
196
+ expect(msg).toContain("92% used");
197
+ expect(msg).toContain("projected");
198
+ });
199
+
200
+ it("formats multiple windows", () => {
201
+ const w1: QuotaWindow = {
202
+ label: "Credits / week",
203
+ usedPercent: 85,
204
+ resetsAt: new Date(Date.now() + 6 * 24 * 3600 * 1000),
205
+ windowSeconds: 7 * 24 * 3600,
206
+ usedValue: 85,
207
+ limitValue: 100,
208
+ showPace: true,
209
+ paceScale: 1 / 7,
210
+ };
211
+ const w2: QuotaWindow = {
212
+ label: "Requests / 5h",
213
+ usedPercent: 92,
214
+ resetsAt: new Date(Date.now() + 2 * 3600 * 1000),
215
+ windowSeconds: 5 * 3600,
216
+ usedValue: 92,
217
+ limitValue: 100,
218
+ showPace: false,
219
+ };
220
+ const msg = formatWarningMessage([
221
+ { window: w1, assessment: assessWindow(w1) },
222
+ { window: w2, assessment: assessWindow(w2) },
223
+ ]);
224
+ expect(msg).toContain("Credits / week");
225
+ expect(msg).toContain("Requests / 5h");
226
+ // Two separate lines
227
+ const lines = msg.split("\n");
228
+ expect(lines).toHaveLength(3); // header + 2 windows
229
+ });
230
+
231
+ it("includes severity label for non-none severities", () => {
232
+ const w: QuotaWindow = {
233
+ label: "Requests / 5h",
234
+ usedPercent: 92,
235
+ resetsAt: new Date(Date.now() + 2 * 3600 * 1000),
236
+ windowSeconds: 5 * 3600,
237
+ usedValue: 92,
238
+ limitValue: 100,
239
+ showPace: false,
240
+ };
241
+ const msg = formatWarningMessage([
242
+ { window: w, assessment: assessWindow(w) },
243
+ ]);
244
+ expect(msg).toMatch(/\(high\)/);
245
+ });
246
+ });
247
+
248
+ describe("notification flow (shouldNotify + markNotified integration)", () => {
249
+ it("notifies once on first warning, blocks repeat, notifies on escalation", () => {
250
+ // 1. First warning
251
+ expect(shouldNotify("Credits / week", "warning")).toBe(true);
252
+ markNotified("Credits / week", "warning");
253
+
254
+ // 2. Same severity within cooldown
255
+ expect(shouldNotify("Credits / week", "warning")).toBe(false);
256
+
257
+ // 3. Escalation to high
258
+ expect(shouldNotify("Credits / week", "high")).toBe(true);
259
+ markNotified("Credits / week", "high");
260
+
261
+ // 4. High always re-notifies (no cooldown)
262
+ expect(shouldNotify("Credits / week", "high")).toBe(true);
263
+ });
264
+
265
+ it("allows re-notification after clear", () => {
266
+ expect(shouldNotify("Credits / week", "warning")).toBe(true);
267
+ markNotified("Credits / week", "warning");
268
+ expect(shouldNotify("Credits / week", "warning")).toBe(false);
269
+
270
+ clearAlertState();
271
+
272
+ expect(shouldNotify("Credits / week", "warning")).toBe(true);
273
+ });
274
+
275
+ it("tracks windows independently", () => {
276
+ markNotified("Credits / week", "warning");
277
+ expect(shouldNotify("Credits / week", "warning")).toBe(false);
278
+ expect(shouldNotify("Search / hour", "warning")).toBe(true);
279
+ });
280
+ });
@@ -0,0 +1,200 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { getSyntheticApiKey } from "../../lib/env";
3
+ import type { QuotasResponse } from "../../types/quotas";
4
+ import { fetchQuotas } from "../../utils/quotas";
5
+ import {
6
+ assessWindow,
7
+ formatTimeRemaining,
8
+ type QuotaWindow,
9
+ type RiskAssessment,
10
+ type RiskSeverity,
11
+ toWindows,
12
+ } from "../../utils/quotas-severity";
13
+
14
+ const COOLDOWN_MS = 60 * 60 * 1000; // 60 minutes
15
+ const MIN_FETCH_INTERVAL_MS = 30_000; // 30 seconds
16
+
17
+ export interface WindowAlertState {
18
+ lastSeverity: RiskSeverity;
19
+ lastNotifiedAt: number; // epoch ms
20
+ }
21
+
22
+ // Key format: "label" (e.g., "Credits / week", "Requests / 5h")
23
+ const windowAlerts = new Map<string, WindowAlertState>();
24
+
25
+ let lastFetchAt = 0;
26
+
27
+ interface WindowRisk {
28
+ window: QuotaWindow;
29
+ assessment: RiskAssessment;
30
+ }
31
+
32
+ /**
33
+ * Finds windows that exceed the risk threshold.
34
+ * Returns windows with their risk assessments.
35
+ */
36
+ export function findHighRiskWindows(quotas: QuotasResponse): WindowRisk[] {
37
+ const windows = toWindows(quotas);
38
+ return windows
39
+ .map((window) => ({ window, assessment: assessWindow(window) }))
40
+ .filter((item) => item.assessment.severity !== "none");
41
+ }
42
+
43
+ /**
44
+ * Determines if we should notify for this window based on cooldown and severity rules.
45
+ * Rules:
46
+ * - First time seeing this window at risk: notify
47
+ * - Severity escalation (warning → high → critical): notify
48
+ * - Cooldown elapsed (60 min) AND severity is "warning": notify
49
+ * - High/Critical severity: always notify (no cooldown)
50
+ */
51
+ export function shouldNotify(
52
+ windowKey: string,
53
+ severity: RiskSeverity,
54
+ ): boolean {
55
+ const state = windowAlerts.get(windowKey);
56
+
57
+ if (!state) {
58
+ // First time seeing this window at risk
59
+ return true;
60
+ }
61
+
62
+ // Severity escalation always notifies
63
+ const severityOrder: RiskSeverity[] = ["none", "warning", "high", "critical"];
64
+ const currentIndex = severityOrder.indexOf(severity);
65
+ const lastIndex = severityOrder.indexOf(state.lastSeverity);
66
+ if (currentIndex > lastIndex) {
67
+ return true;
68
+ }
69
+
70
+ // High and critical: no cooldown, always notify
71
+ if (severity === "high" || severity === "critical") {
72
+ return true;
73
+ }
74
+
75
+ // Warning: only notify if cooldown elapsed
76
+ if (severity === "warning") {
77
+ const elapsed = Date.now() - state.lastNotifiedAt;
78
+ return elapsed >= COOLDOWN_MS;
79
+ }
80
+
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Updates alert state after notifying.
86
+ */
87
+ export function markNotified(windowKey: string, severity: RiskSeverity): void {
88
+ windowAlerts.set(windowKey, {
89
+ lastSeverity: severity,
90
+ lastNotifiedAt: Date.now(),
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Formats the warning message for the notification.
96
+ */
97
+ export function formatWarningMessage(windows: WindowRisk[]): string {
98
+ const lines = windows.map(({ window, assessment }) => {
99
+ const status = assessment.severity;
100
+ const statusLabel = status !== "none" ? ` (${status})` : "";
101
+ const projected = Math.round(assessment.projectedPercent);
102
+ const used = Math.round(window.usedPercent);
103
+ const timeStr = formatTimeRemaining(window.resetsAt);
104
+ const eventStr = window.nextAmount
105
+ ? `${window.nextAmount} in ${timeStr}`
106
+ : `${window.nextLabel ?? "Resets"} in ${timeStr}`;
107
+ return `- ${window.label}: ${used}% used, projected ${projected}%${statusLabel}, ${eventStr}`;
108
+ });
109
+ return `Synthetic quota warning:\n${lines.join("\n")}`;
110
+ }
111
+
112
+ /**
113
+ * Clears the alert state and resets fetch tracking.
114
+ * Call on session start, model change, or shutdown.
115
+ */
116
+ export function clearAlertState(): void {
117
+ windowAlerts.clear();
118
+ lastFetchAt = 0;
119
+ }
120
+
121
+ /**
122
+ * Checks quotas and shows a warning if above threshold.
123
+ * This is fire-and-forget - does not block the caller.
124
+ *
125
+ * @param skipAlreadyWarned - If true, only warn for windows that haven't been warned yet.
126
+ * If false, warn for all high usage windows (used on session start).
127
+ */
128
+ export async function checkAndWarn(
129
+ ctx: ExtensionContext,
130
+ model: { provider: string; id: string } | undefined,
131
+ skipAlreadyWarned: boolean,
132
+ ): Promise<void> {
133
+ if (!ctx.hasUI) return;
134
+ if (model?.provider !== "synthetic") return;
135
+
136
+ const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
137
+ if (!apiKey) return;
138
+
139
+ // Throttle: skip if fetched recently, unless skipAlreadyWarned is false
140
+ // (session start / model change always fetches)
141
+ const now = Date.now();
142
+ if (skipAlreadyWarned && now - lastFetchAt < MIN_FETCH_INTERVAL_MS) {
143
+ return;
144
+ }
145
+
146
+ lastFetchAt = now;
147
+
148
+ try {
149
+ const result = await fetchQuotas(apiKey);
150
+ if (!result.success) return;
151
+
152
+ const highRiskWindows = findHighRiskWindows(result.data.quotas);
153
+ if (highRiskWindows.length === 0) return;
154
+
155
+ // Filter to only windows that should be notified
156
+ const windowsToNotify = skipAlreadyWarned
157
+ ? highRiskWindows.filter(({ window, assessment }) => {
158
+ return shouldNotify(window.label, assessment.severity);
159
+ })
160
+ : highRiskWindows;
161
+
162
+ if (windowsToNotify.length === 0) return;
163
+
164
+ // Mark only the windows that were actually notified
165
+ for (const { window, assessment } of windowsToNotify) {
166
+ markNotified(window.label, assessment.severity);
167
+ }
168
+
169
+ const message = formatWarningMessage(windowsToNotify);
170
+
171
+ // Determine severity based on highest projected usage
172
+ const hasCritical = windowsToNotify.some(
173
+ ({ assessment }) => assessment.severity === "critical",
174
+ );
175
+ const hasHigh = windowsToNotify.some(
176
+ ({ assessment }) => assessment.severity === "high",
177
+ );
178
+ const notifyLevel = hasCritical ? "error" : hasHigh ? "error" : "warning";
179
+
180
+ ctx.ui.notify(message, notifyLevel);
181
+ } catch {
182
+ // Silently ignore errors
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Fire-and-forget wrapper that ensures the check is non-blocking.
188
+ *
189
+ * @param skipAlreadyWarned - If true, only warn for windows that haven't been warned yet.
190
+ */
191
+ export function triggerCheck(
192
+ ctx: ExtensionContext,
193
+ model: { provider: string; id: string } | undefined,
194
+ skipAlreadyWarned: boolean,
195
+ ): void {
196
+ // Do not await - this is intentionally fire-and-forget
197
+ checkAndWarn(ctx, model, skipAlreadyWarned).catch(() => {
198
+ // Ignore errors
199
+ });
200
+ }