@aliou/pi-synthetic 0.14.0 → 0.16.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,112 @@
1
+ import type { QuotaSource, QuotasResponse } from "../types/quotas";
2
+
3
+ export interface QuotaSnapshot {
4
+ quotas: QuotasResponse;
5
+ source: QuotaSource;
6
+ updatedAt: number; // epoch ms
7
+ }
8
+
9
+ type Listener = (snapshot: QuotaSnapshot) => void;
10
+
11
+ /**
12
+ * Pi-agnostic in-memory quota store.
13
+ *
14
+ * Ingests quota data from headers or API, handles throttling of
15
+ * header-sourced updates, and notifies subscribers on change.
16
+ *
17
+ * Usage:
18
+ * const store = new QuotaStore();
19
+ * store.subscribe((snap) => { ... });
20
+ * store.ingest(quotas, "header");
21
+ * store.ingest(quotas, "api");
22
+ */
23
+ export class QuotaStore {
24
+ private snapshot: QuotaSnapshot | undefined;
25
+ private listeners = new Set<Listener>();
26
+ private lastHeaderIngestAt = 0;
27
+ private inFlightRefresh: Promise<QuotaSnapshot | undefined> | undefined;
28
+ private inFlightId = 0;
29
+
30
+ /** Throttle header ingestion: skip if last header ingest was within this window. */
31
+ headerThrottleMs = 5_000;
32
+
33
+ /** Current snapshot (may be undefined if no data has been ingested yet). */
34
+ getSnapshot(): QuotaSnapshot | undefined {
35
+ return this.snapshot;
36
+ }
37
+
38
+ /** Subscribe to snapshot updates. Returns unsubscribe function. */
39
+ subscribe(listener: Listener): () => void {
40
+ this.listeners.add(listener);
41
+ return () => {
42
+ this.listeners.delete(listener);
43
+ };
44
+ }
45
+
46
+ private emit(snapshot: QuotaSnapshot): void {
47
+ for (const l of this.listeners) l(snapshot);
48
+ }
49
+
50
+ /**
51
+ * Ingest quota data. Returns true if the snapshot was updated
52
+ * (i.e. not throttled).
53
+ *
54
+ * Header-sourced data is throttled: if the last header ingest was
55
+ * within `headerThrottleMs`, it is silently dropped.
56
+ * API-sourced data always goes through.
57
+ */
58
+ ingest(quotas: QuotasResponse, source: QuotaSource): boolean {
59
+ const now = Date.now();
60
+
61
+ if (source === "header") {
62
+ if (now - this.lastHeaderIngestAt < this.headerThrottleMs) return false;
63
+ this.lastHeaderIngestAt = now;
64
+ }
65
+
66
+ this.snapshot = { quotas, source, updatedAt: now };
67
+ this.emit(this.snapshot);
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Refresh quotas by calling the provided fetcher.
73
+ * Deduplicates concurrent calls — only one fetch runs at a time.
74
+ */
75
+ async refreshFromApi(
76
+ fetcher: () => Promise<QuotasResponse | undefined>,
77
+ ): Promise<QuotaSnapshot | undefined> {
78
+ if (this.inFlightRefresh) return this.inFlightRefresh;
79
+
80
+ this.inFlightId++;
81
+ const id = this.inFlightId;
82
+
83
+ this.inFlightRefresh = (async () => {
84
+ try {
85
+ const quotas = await fetcher();
86
+ if (quotas && id === this.inFlightId) {
87
+ this.ingest(quotas, "api");
88
+ }
89
+ return this.snapshot;
90
+ } finally {
91
+ if (id === this.inFlightId) {
92
+ this.inFlightRefresh = undefined;
93
+ }
94
+ }
95
+ })();
96
+
97
+ return this.inFlightRefresh;
98
+ }
99
+
100
+ /** Returns true if a refresh is currently in flight. */
101
+ get isRefreshing(): boolean {
102
+ return !!this.inFlightRefresh;
103
+ }
104
+
105
+ /** Clear all state. Call on session shutdown or reset. */
106
+ clear(): void {
107
+ this.inFlightId++; // Invalidates in-flight refresh
108
+ this.snapshot = undefined;
109
+ this.lastHeaderIngestAt = 0;
110
+ this.inFlightRefresh = undefined;
111
+ }
112
+ }
@@ -0,0 +1,393 @@
1
+ import {
2
+ afterEach,
3
+ assert,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ vi,
9
+ } from "vitest";
10
+ import type { QuotasResponse } from "../types/quotas";
11
+ import { assessWindow, type QuotaWindow } from "../utils/quotas-severity";
12
+ import { type NotifyFn, QuotaWarningNotifier } from "./quota-warnings";
13
+
14
+ beforeEach(() => {
15
+ vi.useFakeTimers();
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.useRealTimers();
20
+ });
21
+
22
+ describe("QuotaWarningNotifier", () => {
23
+ const baseQuotas: QuotasResponse = {
24
+ weeklyTokenLimit: {
25
+ nextRegenAt: new Date(Date.now() + 6 * 24 * 3600 * 1000).toISOString(),
26
+ percentRemaining: 90,
27
+ maxCredits: "$10.00",
28
+ remainingCredits: "$9.00",
29
+ nextRegenCredits: "$0.50",
30
+ },
31
+ rollingFiveHourLimit: {
32
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
33
+ tickPercent: 10,
34
+ remaining: 90,
35
+ max: 100,
36
+ limited: false,
37
+ },
38
+ search: {
39
+ hourly: {
40
+ limit: 100,
41
+ requests: 10,
42
+ renewsAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
43
+ },
44
+ },
45
+ freeToolCalls: {
46
+ limit: 100,
47
+ requests: 5,
48
+ renewsAt: new Date(Date.now() + 12 * 3600 * 1000).toISOString(),
49
+ },
50
+ };
51
+
52
+ describe("shouldNotify", () => {
53
+ it("notifies on first time seeing a window at risk", () => {
54
+ const notifier = new QuotaWarningNotifier();
55
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(true);
56
+ expect(notifier.shouldNotify("Requests / 5h", "high")).toBe(true);
57
+ expect(notifier.shouldNotify("Search / hour", "critical")).toBe(true);
58
+ });
59
+
60
+ it("notifies on severity escalation", () => {
61
+ const notifier = new QuotaWarningNotifier();
62
+ notifier.markNotified("Credits / week", "warning");
63
+ expect(notifier.shouldNotify("Credits / week", "high")).toBe(true);
64
+
65
+ notifier.markNotified("Requests / 5h", "high");
66
+ expect(notifier.shouldNotify("Requests / 5h", "critical")).toBe(true);
67
+ });
68
+
69
+ it("notifies on skip from none to any risk level", () => {
70
+ const notifier = new QuotaWarningNotifier();
71
+ notifier.markNotified("Test", "none");
72
+ expect(notifier.shouldNotify("Test", "warning")).toBe(true);
73
+ });
74
+
75
+ it("does not notify on same severity for warning within cooldown", () => {
76
+ const notifier = new QuotaWarningNotifier();
77
+ notifier.markNotified("Credits / week", "warning");
78
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(false);
79
+ });
80
+
81
+ it("does notify on warning after cooldown elapsed", () => {
82
+ const notifier = new QuotaWarningNotifier();
83
+ notifier.markNotified("Credits / week", "warning");
84
+
85
+ vi.advanceTimersByTime(60 * 60 * 1000 + 1);
86
+
87
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(true);
88
+ });
89
+
90
+ it("does not notify on downgrade to warning", () => {
91
+ const notifier = new QuotaWarningNotifier();
92
+ notifier.markNotified("Credits / week", "high");
93
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(false);
94
+ });
95
+
96
+ it("does notify on downgrade to high (no cooldown)", () => {
97
+ const notifier = new QuotaWarningNotifier();
98
+ notifier.markNotified("Requests / 5h", "critical");
99
+ expect(notifier.shouldNotify("Requests / 5h", "high")).toBe(true);
100
+ });
101
+
102
+ it("always notifies for high severity (no cooldown)", () => {
103
+ const notifier = new QuotaWarningNotifier();
104
+ notifier.markNotified("Credits / week", "high");
105
+ expect(notifier.shouldNotify("Credits / week", "high")).toBe(true);
106
+ });
107
+
108
+ it("always notifies for critical severity (no cooldown)", () => {
109
+ const notifier = new QuotaWarningNotifier();
110
+ notifier.markNotified("Credits / week", "critical");
111
+ expect(notifier.shouldNotify("Credits / week", "critical")).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe("markNotified", () => {
116
+ it("tracks severity per window key", () => {
117
+ const notifier = new QuotaWarningNotifier();
118
+ notifier.markNotified("Credits / week", "warning");
119
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(false);
120
+
121
+ // Different key is independent
122
+ expect(notifier.shouldNotify("Requests / 5h", "warning")).toBe(true);
123
+ });
124
+
125
+ it("allows re-notification after escalation then downgrade then re-escalation", () => {
126
+ const notifier = new QuotaWarningNotifier();
127
+ notifier.markNotified("Test", "high");
128
+ expect(notifier.shouldNotify("Test", "warning")).toBe(false);
129
+ expect(notifier.shouldNotify("Test", "high")).toBe(true);
130
+ });
131
+ });
132
+
133
+ describe("clearAlertState", () => {
134
+ it("resets all alert state so windows notify again", () => {
135
+ const notifier = new QuotaWarningNotifier();
136
+ notifier.markNotified("Credits / week", "warning");
137
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(false);
138
+
139
+ notifier.clearAlertState();
140
+
141
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(true);
142
+ });
143
+ });
144
+
145
+ describe("findHighRiskWindows", () => {
146
+ it("returns empty for low-usage quotas", () => {
147
+ const notifier = new QuotaWarningNotifier();
148
+ const risks = notifier.findHighRiskWindows(baseQuotas);
149
+ expect(risks).toHaveLength(0);
150
+ });
151
+
152
+ it("finds windows with high usage", () => {
153
+ const notifier = new QuotaWarningNotifier();
154
+ const quotas: QuotasResponse = {
155
+ ...baseQuotas,
156
+ rollingFiveHourLimit: {
157
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
158
+ tickPercent: 10,
159
+ remaining: 5,
160
+ max: 100,
161
+ limited: false,
162
+ },
163
+ };
164
+ const risks = notifier.findHighRiskWindows(quotas);
165
+ const fiveHourRisk = risks.find(
166
+ (r) => r.window.label === "Requests / 5h",
167
+ );
168
+ assert(fiveHourRisk, "fiveHourRisk should exist");
169
+ expect(fiveHourRisk.assessment.severity).toBe("high");
170
+ });
171
+
172
+ it("finds limited windows even with low usage", () => {
173
+ const notifier = new QuotaWarningNotifier();
174
+ const quotas: QuotasResponse = {
175
+ ...baseQuotas,
176
+ rollingFiveHourLimit: {
177
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
178
+ tickPercent: 10,
179
+ remaining: 95,
180
+ max: 100,
181
+ limited: true,
182
+ },
183
+ };
184
+ const risks = notifier.findHighRiskWindows(quotas);
185
+ const fiveHourRisk = risks.find(
186
+ (r) => r.window.label === "Requests / 5h",
187
+ );
188
+ assert(fiveHourRisk, "fiveHourRisk should exist");
189
+ expect(fiveHourRisk.assessment.severity).toBe("critical");
190
+ });
191
+
192
+ it("returns empty for quotas with no windows", () => {
193
+ const notifier = new QuotaWarningNotifier();
194
+ expect(notifier.findHighRiskWindows({})).toHaveLength(0);
195
+ });
196
+ });
197
+
198
+ describe("formatWarningMessage", () => {
199
+ it("formats single window warning", () => {
200
+ const notifier = new QuotaWarningNotifier();
201
+ const w: QuotaWindow = {
202
+ label: "Requests / 5h",
203
+ usedPercent: 92,
204
+ resetsAt: new Date(Date.now() + 2 * 3600 * 1000),
205
+ windowSeconds: 5 * 3600,
206
+ usedValue: 92,
207
+ limitValue: 100,
208
+ showPace: false,
209
+ };
210
+ const assessment = assessWindow(w);
211
+ const msg = notifier.formatWarningMessage([{ window: w, assessment }]);
212
+ expect(msg).toContain("Synthetic quota warning:");
213
+ expect(msg).toContain("Requests / 5h");
214
+ expect(msg).toContain("92% used");
215
+ expect(msg).toContain("projected");
216
+ });
217
+
218
+ it("formats multiple windows", () => {
219
+ const notifier = new QuotaWarningNotifier();
220
+ const w1: QuotaWindow = {
221
+ label: "Credits / week",
222
+ usedPercent: 85,
223
+ resetsAt: new Date(Date.now() + 6 * 24 * 3600 * 1000),
224
+ windowSeconds: 7 * 24 * 3600,
225
+ usedValue: 85,
226
+ limitValue: 100,
227
+ showPace: true,
228
+ paceScale: 1 / 7,
229
+ };
230
+ const w2: QuotaWindow = {
231
+ label: "Requests / 5h",
232
+ usedPercent: 92,
233
+ resetsAt: new Date(Date.now() + 2 * 3600 * 1000),
234
+ windowSeconds: 5 * 3600,
235
+ usedValue: 92,
236
+ limitValue: 100,
237
+ showPace: false,
238
+ };
239
+ const msg = notifier.formatWarningMessage([
240
+ { window: w1, assessment: assessWindow(w1) },
241
+ { window: w2, assessment: assessWindow(w2) },
242
+ ]);
243
+ expect(msg).toContain("Credits / week");
244
+ expect(msg).toContain("Requests / 5h");
245
+ const lines = msg.split("\n");
246
+ expect(lines).toHaveLength(3); // header + 2 windows
247
+ });
248
+
249
+ it("includes severity label for non-none severities", () => {
250
+ const notifier = new QuotaWarningNotifier();
251
+ const w: QuotaWindow = {
252
+ label: "Requests / 5h",
253
+ usedPercent: 92,
254
+ resetsAt: new Date(Date.now() + 2 * 3600 * 1000),
255
+ windowSeconds: 5 * 3600,
256
+ usedValue: 92,
257
+ limitValue: 100,
258
+ showPace: false,
259
+ };
260
+ const msg = notifier.formatWarningMessage([
261
+ { window: w, assessment: assessWindow(w) },
262
+ ]);
263
+ expect(msg).toMatch(/\(high\)/);
264
+ });
265
+ });
266
+
267
+ describe("evaluate", () => {
268
+ it("does not notify for low-usage quotas", () => {
269
+ const notifier = new QuotaWarningNotifier();
270
+ const calls: Array<[string, string]> = [];
271
+ const notify: NotifyFn = (msg, lvl) => calls.push([msg, lvl]);
272
+
273
+ notifier.evaluate(baseQuotas, false, notify);
274
+ expect(calls).toHaveLength(0);
275
+ });
276
+
277
+ it("notifies for high-usage quotas with skipAlreadyWarned=false", () => {
278
+ const notifier = new QuotaWarningNotifier();
279
+ const calls: Array<[string, string]> = [];
280
+ const notify: NotifyFn = (msg, lvl) => calls.push([msg, lvl]);
281
+
282
+ const highUsageQuotas: QuotasResponse = {
283
+ ...baseQuotas,
284
+ rollingFiveHourLimit: {
285
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
286
+ tickPercent: 10,
287
+ remaining: 5,
288
+ max: 100,
289
+ limited: false,
290
+ },
291
+ };
292
+
293
+ notifier.evaluate(highUsageQuotas, false, notify);
294
+ expect(calls).toHaveLength(1);
295
+ expect(calls[0][0]).toContain("Synthetic quota warning");
296
+ });
297
+
298
+ it("does not re-notify on same severity with skipAlreadyWarned=true", () => {
299
+ const notifier = new QuotaWarningNotifier();
300
+ const calls: Array<[string, string]> = [];
301
+ const notify: NotifyFn = (msg, lvl) => calls.push([msg, lvl]);
302
+
303
+ // 85% used (no pace) → warning severity, which has cooldown
304
+ const warningQuotas: QuotasResponse = {
305
+ ...baseQuotas,
306
+ rollingFiveHourLimit: {
307
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
308
+ tickPercent: 10,
309
+ remaining: 15,
310
+ max: 100,
311
+ limited: false,
312
+ },
313
+ };
314
+
315
+ notifier.evaluate(warningQuotas, true, notify);
316
+ expect(calls).toHaveLength(1);
317
+
318
+ // Same severity, same data — should not re-notify (warning has cooldown)
319
+ notifier.evaluate(warningQuotas, true, notify);
320
+ expect(calls).toHaveLength(1);
321
+ });
322
+
323
+ it("notifies on severity escalation even with skipAlreadyWarned=true", () => {
324
+ const notifier = new QuotaWarningNotifier();
325
+ const calls: Array<[string, string]> = [];
326
+ const notify: NotifyFn = (msg, lvl) => calls.push([msg, lvl]);
327
+
328
+ // 92% used (no pace) → high severity
329
+ const highQuotas: QuotasResponse = {
330
+ ...baseQuotas,
331
+ rollingFiveHourLimit: {
332
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
333
+ tickPercent: 10,
334
+ remaining: 8,
335
+ max: 100,
336
+ limited: false,
337
+ },
338
+ };
339
+
340
+ notifier.evaluate(highQuotas, true, notify);
341
+ expect(calls).toHaveLength(1);
342
+
343
+ // Escalate to critical (limited)
344
+ const criticalQuotas: QuotasResponse = {
345
+ ...baseQuotas,
346
+ rollingFiveHourLimit: {
347
+ nextTickAt: new Date(Date.now() + 2.5 * 3600 * 1000).toISOString(),
348
+ tickPercent: 10,
349
+ remaining: 2,
350
+ max: 100,
351
+ limited: true,
352
+ },
353
+ };
354
+
355
+ notifier.evaluate(criticalQuotas, true, notify);
356
+ expect(calls).toHaveLength(2);
357
+ expect(calls[1][1]).toBe("error");
358
+ });
359
+ });
360
+
361
+ describe("notification flow (shouldNotify + markNotified integration)", () => {
362
+ it("notifies once on first warning, blocks repeat, notifies on escalation", () => {
363
+ const notifier = new QuotaWarningNotifier();
364
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(true);
365
+ notifier.markNotified("Credits / week", "warning");
366
+
367
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(false);
368
+
369
+ expect(notifier.shouldNotify("Credits / week", "high")).toBe(true);
370
+ notifier.markNotified("Credits / week", "high");
371
+
372
+ expect(notifier.shouldNotify("Credits / week", "high")).toBe(true);
373
+ });
374
+
375
+ it("allows re-notification after clear", () => {
376
+ const notifier = new QuotaWarningNotifier();
377
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(true);
378
+ notifier.markNotified("Credits / week", "warning");
379
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(false);
380
+
381
+ notifier.clearAlertState();
382
+
383
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(true);
384
+ });
385
+
386
+ it("tracks windows independently", () => {
387
+ const notifier = new QuotaWarningNotifier();
388
+ notifier.markNotified("Credits / week", "warning");
389
+ expect(notifier.shouldNotify("Credits / week", "warning")).toBe(false);
390
+ expect(notifier.shouldNotify("Search / hour", "warning")).toBe(true);
391
+ });
392
+ });
393
+ });
@@ -0,0 +1,149 @@
1
+ import type { QuotasResponse } from "../types/quotas";
2
+ import {
3
+ assessWindow,
4
+ formatTimeRemaining,
5
+ type QuotaWindow,
6
+ type RiskAssessment,
7
+ type RiskSeverity,
8
+ toWindows,
9
+ } from "../utils/quotas-severity";
10
+
11
+ const COOLDOWN_MS = 60 * 60 * 1000; // 60 minutes
12
+
13
+ export interface WindowAlertState {
14
+ lastSeverity: RiskSeverity;
15
+ lastNotifiedAt: number; // epoch ms
16
+ }
17
+
18
+ interface WindowRisk {
19
+ window: QuotaWindow;
20
+ assessment: RiskAssessment;
21
+ }
22
+
23
+ export type NotifyFn = (message: string, level: "warning" | "error") => void;
24
+
25
+ /**
26
+ * Pi-agnostic quota warning evaluator.
27
+ *
28
+ * Call `evaluate()` with a QuotasResponse and it decides whether
29
+ * to fire a notification based on severity, escalation, and cooldown rules.
30
+ *
31
+ * Usage:
32
+ * const notifier = new QuotaWarningNotifier();
33
+ * notifier.evaluate(quotas, true, (msg, lvl) => ctx.ui.notify(msg, lvl));
34
+ */
35
+ export class QuotaWarningNotifier {
36
+ private windowAlerts = new Map<string, WindowAlertState>();
37
+
38
+ /** Finds windows that exceed the risk threshold. */
39
+ findHighRiskWindows(quotas: QuotasResponse): WindowRisk[] {
40
+ const windows = toWindows(quotas);
41
+ return windows
42
+ .map((window) => ({ window, assessment: assessWindow(window) }))
43
+ .filter((item) => item.assessment.severity !== "none");
44
+ }
45
+
46
+ /**
47
+ * Determines if we should notify for this window based on cooldown
48
+ * and severity rules.
49
+ *
50
+ * Rules:
51
+ * - First time seeing this window at risk: notify
52
+ * - Severity escalation (warning → high → critical): notify
53
+ * - Cooldown elapsed (60 min) AND severity is "warning": notify
54
+ * - High/Critical severity: always notify (no cooldown)
55
+ */
56
+ shouldNotify(windowKey: string, severity: RiskSeverity): boolean {
57
+ const state = this.windowAlerts.get(windowKey);
58
+
59
+ if (!state) return true;
60
+
61
+ const severityOrder: RiskSeverity[] = [
62
+ "none",
63
+ "warning",
64
+ "high",
65
+ "critical",
66
+ ];
67
+ const currentIndex = severityOrder.indexOf(severity);
68
+ const lastIndex = severityOrder.indexOf(state.lastSeverity);
69
+ if (currentIndex > lastIndex) return true;
70
+
71
+ if (severity === "high" || severity === "critical") return true;
72
+
73
+ if (severity === "warning") {
74
+ return Date.now() - state.lastNotifiedAt >= COOLDOWN_MS;
75
+ }
76
+
77
+ return false;
78
+ }
79
+
80
+ /** Updates alert state after notifying. */
81
+ markNotified(windowKey: string, severity: RiskSeverity): void {
82
+ this.windowAlerts.set(windowKey, {
83
+ lastSeverity: severity,
84
+ lastNotifiedAt: Date.now(),
85
+ });
86
+ }
87
+
88
+ /** Formats the warning message for the notification. */
89
+ formatWarningMessage(windows: WindowRisk[]): string {
90
+ const lines = windows.map(({ window, assessment }) => {
91
+ const status = assessment.severity;
92
+ const statusLabel = status !== "none" ? ` (${status})` : "";
93
+ const projected = Math.round(assessment.projectedPercent);
94
+ const used = Math.round(window.usedPercent);
95
+ const timeStr = formatTimeRemaining(window.resetsAt);
96
+ const eventStr = window.nextAmount
97
+ ? `${window.nextAmount} in ${timeStr}`
98
+ : `${window.nextLabel ?? "Resets"} in ${timeStr}`;
99
+ return `- ${window.label}: ${used}% used, projected ${projected}%${statusLabel}, ${eventStr}`;
100
+ });
101
+ return `Synthetic quota warning:\n${lines.join("\n")}`;
102
+ }
103
+
104
+ /** Clear all alert state. Call on session start, model change, or shutdown. */
105
+ clearAlertState(): void {
106
+ this.windowAlerts.clear();
107
+ }
108
+
109
+ /**
110
+ * Evaluate a QuotasResponse and notify if thresholds are exceeded.
111
+ *
112
+ * @param quotas - The quota data to evaluate
113
+ * @param skipAlreadyWarned - If true, only warn for windows not yet warned.
114
+ * If false, warn for all high-usage windows.
115
+ * @param notify - Callback to display the notification
116
+ */
117
+ evaluate(
118
+ quotas: QuotasResponse,
119
+ skipAlreadyWarned: boolean,
120
+ notify: NotifyFn,
121
+ ): void {
122
+ const highRiskWindows = this.findHighRiskWindows(quotas);
123
+ if (highRiskWindows.length === 0) return;
124
+
125
+ const windowsToNotify = skipAlreadyWarned
126
+ ? highRiskWindows.filter(({ window, assessment }) =>
127
+ this.shouldNotify(window.label, assessment.severity),
128
+ )
129
+ : highRiskWindows;
130
+
131
+ if (windowsToNotify.length === 0) return;
132
+
133
+ for (const { window, assessment } of windowsToNotify) {
134
+ this.markNotified(window.label, assessment.severity);
135
+ }
136
+
137
+ const message = this.formatWarningMessage(windowsToNotify);
138
+
139
+ const hasCritical = windowsToNotify.some(
140
+ ({ assessment }) => assessment.severity === "critical",
141
+ );
142
+ const hasHigh = windowsToNotify.some(
143
+ ({ assessment }) => assessment.severity === "high",
144
+ );
145
+ const notifyLevel = hasCritical || hasHigh ? "error" : "warning";
146
+
147
+ notify(message, notifyLevel);
148
+ }
149
+ }
@@ -1,3 +1,30 @@
1
+ export type QuotaSource = "header" | "api";
2
+
3
+ export const SYNTHETIC_QUOTAS_UPDATED_EVENT =
4
+ "synthetic:quotas:updated" as const;
5
+
6
+ export const SYNTHETIC_QUOTAS_REQUEST_EVENT =
7
+ "synthetic:quotas:request" as const;
8
+
9
+ export const SYNTHETIC_QUOTAS_READ_EVENT = "synthetic:quotas:read" as const;
10
+
11
+ export interface SyntheticQuotasSnapshotPayload {
12
+ quotas: QuotasResponse;
13
+ source: QuotaSource;
14
+ updatedAt: number;
15
+ }
16
+
17
+ export interface SyntheticQuotasUpdatedPayload
18
+ extends SyntheticQuotasSnapshotPayload {}
19
+
20
+ export interface SyntheticQuotasReadPayload {
21
+ respond: (snapshot: SyntheticQuotasSnapshotPayload | undefined) => void;
22
+ }
23
+
24
+ export interface SyntheticQuotasRequestPayload {
25
+ respond?: (snapshot: SyntheticQuotasSnapshotPayload | undefined) => void;
26
+ }
27
+
1
28
  export type QuotasErrorKind =
2
29
  | "cancelled"
3
30
  | "timeout"
@@ -42,3 +69,23 @@ export interface QuotasResponse {
42
69
  limited: boolean;
43
70
  };
44
71
  }
72
+
73
+ /** Parse the `x-synthetic-quotas` header value into a QuotasResponse.
74
+ * Returns undefined if the header is missing or invalid. */
75
+ export function parseQuotaHeader(
76
+ headers: Record<string, string>,
77
+ ): QuotasResponse | undefined {
78
+ const entry = Object.entries(headers).find(
79
+ ([key]) => key.toLowerCase() === "x-synthetic-quotas",
80
+ );
81
+ if (!entry?.[1]) return undefined;
82
+ try {
83
+ const parsed = JSON.parse(entry[1]);
84
+ // Basic structural check: must be a non-null object
85
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
86
+ return undefined;
87
+ return parsed as QuotasResponse;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }