@aliou/pi-synthetic 0.11.0 → 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.
- package/package.json +3 -2
- package/src/extensions/command-quotas/components/quotas-display.ts +12 -167
- package/src/extensions/provider/models.ts +4 -4
- package/src/extensions/quota-warnings/index.ts +22 -0
- package/src/extensions/quota-warnings/notifier.test.ts +280 -0
- package/src/extensions/quota-warnings/notifier.ts +200 -0
- package/src/utils/quotas-severity.test.ts +278 -0
- package/src/utils/quotas-severity.ts +272 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-synthetic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"extensions": [
|
|
18
18
|
"./src/extensions/provider/index.ts",
|
|
19
19
|
"./src/extensions/web-search/index.ts",
|
|
20
|
-
"./src/extensions/command-quotas/index.ts"
|
|
20
|
+
"./src/extensions/command-quotas/index.ts",
|
|
21
|
+
"./src/extensions/quota-warnings/index.ts"
|
|
21
22
|
],
|
|
22
23
|
"video": "https://assets.aliou.me/pi-extensions/demos/pi-synthetic.mp4"
|
|
23
24
|
},
|
|
@@ -3,167 +3,19 @@ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
|
3
3
|
import type { Component, TUI } from "@mariozechner/pi-tui";
|
|
4
4
|
import { Loader, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
5
5
|
import type { QuotasResponse } from "../../../types/quotas";
|
|
6
|
+
import {
|
|
7
|
+
assessWindow,
|
|
8
|
+
formatTimeRemaining,
|
|
9
|
+
getSeverityColor,
|
|
10
|
+
type QuotaWindow,
|
|
11
|
+
toWindows,
|
|
12
|
+
} from "../../../utils/quotas-severity";
|
|
6
13
|
|
|
7
14
|
type QuotasState =
|
|
8
15
|
| { type: "loading" }
|
|
9
16
|
| { type: "error"; message: string }
|
|
10
17
|
| { type: "loaded"; quotas: QuotasResponse };
|
|
11
18
|
|
|
12
|
-
interface QuotaWindow {
|
|
13
|
-
label: string;
|
|
14
|
-
usedPercent: number;
|
|
15
|
-
resetsAt: Date;
|
|
16
|
-
windowSeconds: number;
|
|
17
|
-
usedValue: number;
|
|
18
|
-
limitValue: number;
|
|
19
|
-
isCurrency?: boolean;
|
|
20
|
-
showPace?: boolean;
|
|
21
|
-
paceScale?: number;
|
|
22
|
-
limited?: boolean;
|
|
23
|
-
nextAmount?: string;
|
|
24
|
-
nextLabel?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Safely compute percentage, guarding against division by zero */
|
|
28
|
-
function safePercent(used: number, limit: number): number {
|
|
29
|
-
if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) return 0;
|
|
30
|
-
return Math.max(0, Math.min(100, (used / limit) * 100));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Parse currency string like "$1,234.56" to number */
|
|
34
|
-
function parseCurrency(value: string): number {
|
|
35
|
-
const n = Number(value.replace(/[^0-9.-]/g, ""));
|
|
36
|
-
return Number.isFinite(n) ? n : 0;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function toWindows(quotas: QuotasResponse): QuotaWindow[] {
|
|
40
|
-
const windows: QuotaWindow[] = [];
|
|
41
|
-
|
|
42
|
-
if (quotas.weeklyTokenLimit) {
|
|
43
|
-
const { weeklyTokenLimit } = quotas;
|
|
44
|
-
const limitValue = parseCurrency(weeklyTokenLimit.maxCredits);
|
|
45
|
-
const remainingValue = parseCurrency(weeklyTokenLimit.remainingCredits);
|
|
46
|
-
windows.push({
|
|
47
|
-
label: "Credits / week",
|
|
48
|
-
usedPercent: Math.max(
|
|
49
|
-
0,
|
|
50
|
-
Math.min(100, 100 - weeklyTokenLimit.percentRemaining),
|
|
51
|
-
),
|
|
52
|
-
resetsAt: new Date(weeklyTokenLimit.nextRegenAt),
|
|
53
|
-
windowSeconds: 24 * 60 * 60,
|
|
54
|
-
usedValue: limitValue - remainingValue,
|
|
55
|
-
limitValue,
|
|
56
|
-
isCurrency: true,
|
|
57
|
-
showPace: true,
|
|
58
|
-
paceScale: 1 / 7,
|
|
59
|
-
nextAmount: `+${weeklyTokenLimit.nextRegenCredits}`,
|
|
60
|
-
nextLabel: "Next regen",
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (quotas.rollingFiveHourLimit && quotas.rollingFiveHourLimit.max > 0) {
|
|
65
|
-
const { rollingFiveHourLimit } = quotas;
|
|
66
|
-
const used = rollingFiveHourLimit.max - rollingFiveHourLimit.remaining;
|
|
67
|
-
const tickAmount =
|
|
68
|
-
rollingFiveHourLimit.tickPercent * rollingFiveHourLimit.max;
|
|
69
|
-
windows.push({
|
|
70
|
-
label: "Requests / 5h",
|
|
71
|
-
usedPercent: safePercent(used, rollingFiveHourLimit.max),
|
|
72
|
-
resetsAt: new Date(rollingFiveHourLimit.nextTickAt),
|
|
73
|
-
windowSeconds: 5 * 60 * 60,
|
|
74
|
-
usedValue: Math.round(used),
|
|
75
|
-
limitValue: rollingFiveHourLimit.max,
|
|
76
|
-
showPace: false,
|
|
77
|
-
limited: rollingFiveHourLimit.limited,
|
|
78
|
-
nextAmount: `+${tickAmount.toFixed(1)}`,
|
|
79
|
-
nextLabel: "Next tick",
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (quotas.search?.hourly?.limit && quotas.search.hourly.limit > 0) {
|
|
84
|
-
const { hourly } = quotas.search;
|
|
85
|
-
windows.push({
|
|
86
|
-
label: "Search / hour",
|
|
87
|
-
usedPercent: safePercent(hourly.requests, hourly.limit),
|
|
88
|
-
resetsAt: new Date(hourly.renewsAt),
|
|
89
|
-
windowSeconds: 60 * 60,
|
|
90
|
-
usedValue: hourly.requests,
|
|
91
|
-
limitValue: hourly.limit,
|
|
92
|
-
showPace: true,
|
|
93
|
-
paceScale: 1,
|
|
94
|
-
nextLabel: "Resets",
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (quotas.freeToolCalls?.limit && quotas.freeToolCalls.limit > 0) {
|
|
99
|
-
windows.push({
|
|
100
|
-
label: "Free Tool Calls / day",
|
|
101
|
-
usedPercent: safePercent(
|
|
102
|
-
quotas.freeToolCalls.requests,
|
|
103
|
-
quotas.freeToolCalls.limit,
|
|
104
|
-
),
|
|
105
|
-
resetsAt: new Date(quotas.freeToolCalls.renewsAt),
|
|
106
|
-
windowSeconds: 24 * 60 * 60,
|
|
107
|
-
usedValue: quotas.freeToolCalls.requests,
|
|
108
|
-
limitValue: quotas.freeToolCalls.limit,
|
|
109
|
-
showPace: true,
|
|
110
|
-
paceScale: 1,
|
|
111
|
-
nextLabel: "Resets",
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return windows;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function getPacePercent(window: QuotaWindow): number | null {
|
|
119
|
-
const totalMs = window.windowSeconds * 1000;
|
|
120
|
-
if (totalMs <= 0) return null;
|
|
121
|
-
const remainingMs = window.resetsAt.getTime() - Date.now();
|
|
122
|
-
const elapsedMs = totalMs - remainingMs;
|
|
123
|
-
return Math.max(0, Math.min(100, (elapsedMs / totalMs) * 100));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function getProjectedPercent(
|
|
127
|
-
usedPercent: number,
|
|
128
|
-
pacePercent: number | null,
|
|
129
|
-
): number {
|
|
130
|
-
if (pacePercent === null) return usedPercent;
|
|
131
|
-
const effectivePace = Math.max(5, pacePercent);
|
|
132
|
-
return Math.max(0, (usedPercent / effectivePace) * 100);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function getSeverity(
|
|
136
|
-
projectedPercent: number,
|
|
137
|
-
pacePercent: number | null,
|
|
138
|
-
): "success" | "warning" | "error" {
|
|
139
|
-
if (pacePercent === null) {
|
|
140
|
-
if (projectedPercent >= 100) return "error";
|
|
141
|
-
if (projectedPercent >= 90) return "warning";
|
|
142
|
-
return "success";
|
|
143
|
-
}
|
|
144
|
-
// Dynamic thresholds based on window progress
|
|
145
|
-
const progress = pacePercent / 100;
|
|
146
|
-
const warnThreshold = 260 - (260 - 120) * progress;
|
|
147
|
-
const highThreshold = 320 - (320 - 145) * progress;
|
|
148
|
-
const criticalThreshold = 400 - (400 - 170) * progress;
|
|
149
|
-
|
|
150
|
-
if (projectedPercent >= criticalThreshold) return "error";
|
|
151
|
-
if (projectedPercent >= highThreshold) return "error";
|
|
152
|
-
if (projectedPercent >= warnThreshold) return "warning";
|
|
153
|
-
return "success";
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function formatTimeRemaining(date: Date): string {
|
|
157
|
-
const ms = date.getTime() - Date.now();
|
|
158
|
-
if (ms <= 0) return "now";
|
|
159
|
-
const totalMins = Math.ceil(ms / (1000 * 60));
|
|
160
|
-
const hours = Math.floor(totalMins / 60);
|
|
161
|
-
const mins = totalMins % 60;
|
|
162
|
-
if (hours >= 1) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
|
163
|
-
const totalSecs = Math.ceil(ms / 1000);
|
|
164
|
-
return totalMins >= 1 ? `${totalMins}m` : `${totalSecs}s`;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
19
|
/**
|
|
168
20
|
* Convert a foreground ANSI escape to its background equivalent.
|
|
169
21
|
* Handles truecolor (38;2), 256-color (38;5), and basic (3X) escapes.
|
|
@@ -355,15 +207,8 @@ export class QuotasComponent implements Component {
|
|
|
355
207
|
const lines: string[] = [];
|
|
356
208
|
const theme = this.theme;
|
|
357
209
|
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
rawPace !== null ? rawPace * (window.paceScale ?? 1) : null;
|
|
361
|
-
const projectedPercent = getProjectedPercent(
|
|
362
|
-
window.usedPercent,
|
|
363
|
-
pacePercent,
|
|
364
|
-
);
|
|
365
|
-
let severity = getSeverity(projectedPercent, pacePercent);
|
|
366
|
-
if (window.limited) severity = "error";
|
|
210
|
+
const assessment = assessWindow(window);
|
|
211
|
+
const color = getSeverityColor(assessment.severity);
|
|
367
212
|
|
|
368
213
|
// Label
|
|
369
214
|
lines.push(
|
|
@@ -375,8 +220,8 @@ export class QuotasComponent implements Component {
|
|
|
375
220
|
window.usedPercent,
|
|
376
221
|
barWidth,
|
|
377
222
|
theme,
|
|
378
|
-
|
|
379
|
-
pacePercent,
|
|
223
|
+
color,
|
|
224
|
+
assessment.pacePercent,
|
|
380
225
|
);
|
|
381
226
|
const usedStr = window.isCurrency
|
|
382
227
|
? `${Math.round(window.usedPercent)}%/$${window.limitValue.toFixed(2)}`
|
|
@@ -384,7 +229,7 @@ export class QuotasComponent implements Component {
|
|
|
384
229
|
const limitedBadge = window.limited ? theme.fg("error", " LIMITED") : "";
|
|
385
230
|
lines.push(
|
|
386
231
|
truncateToWidth(
|
|
387
|
-
` ${bar} ${theme.fg(
|
|
232
|
+
` ${bar} ${theme.fg(color, usedStr)}${limitedBadge}`,
|
|
388
233
|
maxWidth,
|
|
389
234
|
),
|
|
390
235
|
);
|
|
@@ -36,7 +36,7 @@ const SYNTHETIC_REASONING_EFFORT_MAP = {
|
|
|
36
36
|
} as const;
|
|
37
37
|
|
|
38
38
|
export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
|
|
39
|
-
// API: hf:zai-org/GLM-4.7 → ctx=202752
|
|
39
|
+
// API: hf:zai-org/GLM-4.7 → ctx=202752
|
|
40
40
|
{
|
|
41
41
|
id: "hf:zai-org/GLM-4.7",
|
|
42
42
|
name: "zai-org/GLM-4.7",
|
|
@@ -45,11 +45,11 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
|
|
|
45
45
|
supportsReasoningEffort: true,
|
|
46
46
|
reasoningEffortMap: SYNTHETIC_REASONING_EFFORT_MAP,
|
|
47
47
|
},
|
|
48
|
-
input: ["text"
|
|
48
|
+
input: ["text"],
|
|
49
49
|
cost: {
|
|
50
|
-
input:
|
|
50
|
+
input: 0.45,
|
|
51
51
|
output: 2.19,
|
|
52
|
-
cacheRead:
|
|
52
|
+
cacheRead: 0.45,
|
|
53
53
|
cacheWrite: 0,
|
|
54
54
|
},
|
|
55
55
|
contextWindow: 202752,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { clearAlertState, triggerCheck } from "./notifier";
|
|
3
|
+
|
|
4
|
+
export default async function (pi: ExtensionAPI) {
|
|
5
|
+
// Session start: reset local warning state and run an immediate check
|
|
6
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
7
|
+
if (ctx.model?.provider !== "synthetic") return;
|
|
8
|
+
clearAlertState();
|
|
9
|
+
triggerCheck(ctx, ctx.model, false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Check after agent turn - only warn for newly crossed thresholds
|
|
13
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
14
|
+
if (ctx.model?.provider !== "synthetic") return;
|
|
15
|
+
triggerCheck(ctx, ctx.model, true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Clear state on shutdown
|
|
19
|
+
pi.on("session_shutdown", async () => {
|
|
20
|
+
clearAlertState();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { assert, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
assessWindow,
|
|
4
|
+
getPacePercent,
|
|
5
|
+
getProjectedPercent,
|
|
6
|
+
getSeverityColor,
|
|
7
|
+
parseCurrency,
|
|
8
|
+
type QuotaWindow,
|
|
9
|
+
safePercent,
|
|
10
|
+
} from "./quotas-severity";
|
|
11
|
+
|
|
12
|
+
// Helper to create a QuotaWindow with sensible defaults
|
|
13
|
+
function makeWindow(
|
|
14
|
+
overrides: Partial<QuotaWindow> & Pick<QuotaWindow, "usedPercent">,
|
|
15
|
+
): QuotaWindow {
|
|
16
|
+
const windowSeconds = overrides.windowSeconds ?? 3600;
|
|
17
|
+
// resetsAt defaults to 30 minutes from now (50% through a 1h window)
|
|
18
|
+
const resetsAt =
|
|
19
|
+
overrides.resetsAt ?? new Date(Date.now() + windowSeconds * 500);
|
|
20
|
+
return {
|
|
21
|
+
label: "Test Window",
|
|
22
|
+
resetsAt,
|
|
23
|
+
windowSeconds,
|
|
24
|
+
usedValue: 0,
|
|
25
|
+
limitValue: 100,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("safePercent", () => {
|
|
31
|
+
it("returns 0 for zero/invalid limit", () => {
|
|
32
|
+
expect(safePercent(50, 0)).toBe(0);
|
|
33
|
+
expect(safePercent(50, -1)).toBe(0);
|
|
34
|
+
expect(safePercent(50, NaN)).toBe(0);
|
|
35
|
+
expect(safePercent(NaN, 100)).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("computes correct percentage", () => {
|
|
39
|
+
expect(safePercent(50, 100)).toBe(50);
|
|
40
|
+
expect(safePercent(75, 100)).toBe(75);
|
|
41
|
+
expect(safePercent(1, 3)).toBeCloseTo(33.33);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("clamps to 0-100", () => {
|
|
45
|
+
expect(safePercent(150, 100)).toBe(100);
|
|
46
|
+
expect(safePercent(-10, 100)).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("parseCurrency", () => {
|
|
51
|
+
it("parses dollar amounts", () => {
|
|
52
|
+
expect(parseCurrency("$1,234.56")).toBe(1234.56);
|
|
53
|
+
expect(parseCurrency("$10.00")).toBe(10);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns 0 for invalid input", () => {
|
|
57
|
+
expect(parseCurrency("")).toBe(0);
|
|
58
|
+
expect(parseCurrency("abc")).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("getPacePercent", () => {
|
|
63
|
+
it("returns null for zero window", () => {
|
|
64
|
+
const w = makeWindow({ usedPercent: 50, windowSeconds: 0 });
|
|
65
|
+
expect(getPacePercent(w)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns ~50 for a window 50% elapsed", () => {
|
|
69
|
+
const w = makeWindow({
|
|
70
|
+
usedPercent: 50,
|
|
71
|
+
windowSeconds: 3600,
|
|
72
|
+
resetsAt: new Date(Date.now() + 1800 * 1000), // 30 min remaining
|
|
73
|
+
});
|
|
74
|
+
const pace = getPacePercent(w);
|
|
75
|
+
assert(pace, "pace should not be null");
|
|
76
|
+
expect(pace).toBeCloseTo(50, 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("clamps to 0-100", () => {
|
|
80
|
+
const w = makeWindow({
|
|
81
|
+
usedPercent: 50,
|
|
82
|
+
windowSeconds: 3600,
|
|
83
|
+
resetsAt: new Date(Date.now() + 7200 * 1000), // way past
|
|
84
|
+
});
|
|
85
|
+
expect(getPacePercent(w)).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("getProjectedPercent", () => {
|
|
90
|
+
it("returns usedPercent when no pace", () => {
|
|
91
|
+
expect(getProjectedPercent(42, null)).toBe(42);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("projects based on pace", () => {
|
|
95
|
+
// 50% used, 25% through window => projected 200%
|
|
96
|
+
expect(getProjectedPercent(50, 25)).toBe(200);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("uses minimum pace of 5", () => {
|
|
100
|
+
// Very low pace should not blow up projection
|
|
101
|
+
expect(getProjectedPercent(1, 0)).toBe(20); // 1 / 5 * 100
|
|
102
|
+
expect(getProjectedPercent(1, 1)).toBe(20); // clamped to 5
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("assessWindow", () => {
|
|
107
|
+
describe("no pace (showPace: false)", () => {
|
|
108
|
+
it("returns none for low usage", () => {
|
|
109
|
+
const w = makeWindow({ usedPercent: 10, showPace: false });
|
|
110
|
+
expect(assessWindow(w).severity).toBe("none");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns warning at 80% projected", () => {
|
|
114
|
+
const w = makeWindow({ usedPercent: 85, showPace: false });
|
|
115
|
+
expect(assessWindow(w).severity).toBe("warning");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns high at 90% projected", () => {
|
|
119
|
+
const w = makeWindow({ usedPercent: 92, showPace: false });
|
|
120
|
+
expect(assessWindow(w).severity).toBe("high");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns critical at 100% projected", () => {
|
|
124
|
+
const w = makeWindow({ usedPercent: 100, showPace: false });
|
|
125
|
+
expect(assessWindow(w).severity).toBe("critical");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns critical for limited window regardless of usage", () => {
|
|
129
|
+
const w = makeWindow({ usedPercent: 5, showPace: false, limited: true });
|
|
130
|
+
expect(assessWindow(w).severity).toBe("critical");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("with pace (showPace: true)", () => {
|
|
135
|
+
it("returns none when usage is low and pace is normal", () => {
|
|
136
|
+
const w = makeWindow({
|
|
137
|
+
usedPercent: 20,
|
|
138
|
+
showPace: true,
|
|
139
|
+
paceScale: 1,
|
|
140
|
+
windowSeconds: 3600,
|
|
141
|
+
resetsAt: new Date(Date.now() + 1800 * 1000), // 50% through
|
|
142
|
+
});
|
|
143
|
+
expect(assessWindow(w).severity).toBe("none");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns warning when projected exceeds warn threshold", () => {
|
|
147
|
+
// 50% used, 50% through => projected 100%, well above warn at 50% progress (190)
|
|
148
|
+
// But usedFloor at 50% progress is 20.5, so 50% > 20.5 => passes floor check
|
|
149
|
+
const w = makeWindow({
|
|
150
|
+
usedPercent: 50,
|
|
151
|
+
showPace: true,
|
|
152
|
+
paceScale: 1,
|
|
153
|
+
windowSeconds: 3600,
|
|
154
|
+
resetsAt: new Date(Date.now() + 1800 * 1000),
|
|
155
|
+
});
|
|
156
|
+
const result = assessWindow(w);
|
|
157
|
+
// projected = 50 / 50 * 100 = 100
|
|
158
|
+
// At 50% progress: warn = 260 - (260-120)*0.5 = 190, high = 232.5, critical = 285
|
|
159
|
+
// 100 < 190 => none actually. Let me pick better numbers.
|
|
160
|
+
expect(result.severity).toBe("none");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns warning when projected exceeds dynamic warn threshold", () => {
|
|
164
|
+
// 95% used, 50% through => projected 190%
|
|
165
|
+
// At 50% progress: warn = 190, so 190 >= 190 => warning
|
|
166
|
+
// usedFloor at 50% = 20.5, 95 >= 20.5 => passes
|
|
167
|
+
const w = makeWindow({
|
|
168
|
+
usedPercent: 95,
|
|
169
|
+
showPace: true,
|
|
170
|
+
paceScale: 1,
|
|
171
|
+
windowSeconds: 3600,
|
|
172
|
+
resetsAt: new Date(Date.now() + 1800 * 1000),
|
|
173
|
+
});
|
|
174
|
+
const result = assessWindow(w);
|
|
175
|
+
expect(result.severity).toBe("warning");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("uses paceScale to normalize pace", () => {
|
|
179
|
+
// Weekly window with daily pace: paceScale = 1/7
|
|
180
|
+
// At 50% through the day (12h), raw pace = 50%, scaled = 50/7 ≈ 7.14%
|
|
181
|
+
// So progress ≈ 0.0714, projected = 95 / max(5, 7.14) * 100 ≈ 1330%
|
|
182
|
+
const w = makeWindow({
|
|
183
|
+
usedPercent: 95,
|
|
184
|
+
showPace: true,
|
|
185
|
+
paceScale: 1 / 7,
|
|
186
|
+
windowSeconds: 7 * 24 * 3600, // 1 week
|
|
187
|
+
resetsAt: new Date(Date.now() + 6 * 24 * 3600 * 1000), // 6 days remaining
|
|
188
|
+
});
|
|
189
|
+
const result = assessWindow(w);
|
|
190
|
+
// With paceScale applied, projected should be much higher
|
|
191
|
+
assert(result.pacePercent, "pacePercent should not be null");
|
|
192
|
+
expect(result.pacePercent).toBeLessThan(15); // scaled down
|
|
193
|
+
expect(result.projectedPercent).toBeGreaterThan(500);
|
|
194
|
+
expect(result.severity).toBe("critical");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does not use pace when showPace is false", () => {
|
|
198
|
+
// Same timestamps but showPace: false
|
|
199
|
+
const w = makeWindow({
|
|
200
|
+
usedPercent: 50,
|
|
201
|
+
showPace: false,
|
|
202
|
+
windowSeconds: 5 * 3600,
|
|
203
|
+
resetsAt: new Date(Date.now() + 2.5 * 3600 * 1000),
|
|
204
|
+
});
|
|
205
|
+
const result = assessWindow(w);
|
|
206
|
+
expect(result.pacePercent).toBeNull();
|
|
207
|
+
expect(result.progress).toBeNull();
|
|
208
|
+
// Static thresholds: 50% < 80 => none
|
|
209
|
+
expect(result.severity).toBe("none");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("suppresses warning when usage is below usedFloor", () => {
|
|
213
|
+
// Early window: raw pace ~10%, with paceScale=1 => progress=0.1
|
|
214
|
+
// usedFloor at 10% progress = 33 - (33-8)*0.1 = 33 - 2.5 = 30.5
|
|
215
|
+
// If used = 15% (< 30.5), projected might exceed warn but floor blocks it
|
|
216
|
+
const w = makeWindow({
|
|
217
|
+
usedPercent: 15,
|
|
218
|
+
showPace: true,
|
|
219
|
+
paceScale: 1,
|
|
220
|
+
windowSeconds: 3600,
|
|
221
|
+
// 10% through: 54 min remaining
|
|
222
|
+
resetsAt: new Date(Date.now() + 54 * 60 * 1000),
|
|
223
|
+
});
|
|
224
|
+
const result = assessWindow(w);
|
|
225
|
+
// projected = 15 / 10 * 100 = 150, which exceeds warn at 10% progress (246)
|
|
226
|
+
// But usedFloor = 30.5, and 15 < 30.5 => suppressed
|
|
227
|
+
expect(result.severity).toBe("none");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("allows warning when usage exceeds usedFloor", () => {
|
|
231
|
+
// Same timing but higher usage
|
|
232
|
+
const w = makeWindow({
|
|
233
|
+
usedPercent: 50,
|
|
234
|
+
showPace: true,
|
|
235
|
+
paceScale: 1,
|
|
236
|
+
windowSeconds: 3600,
|
|
237
|
+
resetsAt: new Date(Date.now() + 54 * 60 * 1000),
|
|
238
|
+
});
|
|
239
|
+
const result = assessWindow(w);
|
|
240
|
+
// projected = 50 / 10 * 100 = 500
|
|
241
|
+
// warn at 10% progress = 246, high = 282.5, critical = 357
|
|
242
|
+
// 500 >= 357 => critical, usedFloor = 30.5, 50 >= 30.5 => passes
|
|
243
|
+
expect(result.severity).toBe("critical");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("limited flag", () => {
|
|
248
|
+
it("overrides severity to critical even with low usage", () => {
|
|
249
|
+
const w = makeWindow({
|
|
250
|
+
usedPercent: 5,
|
|
251
|
+
showPace: false,
|
|
252
|
+
limited: true,
|
|
253
|
+
});
|
|
254
|
+
expect(assessWindow(w).severity).toBe("critical");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("overrides severity to critical even with pace showing none", () => {
|
|
258
|
+
const w = makeWindow({
|
|
259
|
+
usedPercent: 5,
|
|
260
|
+
showPace: true,
|
|
261
|
+
paceScale: 1,
|
|
262
|
+
limited: true,
|
|
263
|
+
windowSeconds: 3600,
|
|
264
|
+
resetsAt: new Date(Date.now() + 54 * 60 * 1000),
|
|
265
|
+
});
|
|
266
|
+
expect(assessWindow(w).severity).toBe("critical");
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("getSeverityColor", () => {
|
|
272
|
+
it("maps severity levels to display colors", () => {
|
|
273
|
+
expect(getSeverityColor("none")).toBe("success");
|
|
274
|
+
expect(getSeverityColor("warning")).toBe("warning");
|
|
275
|
+
expect(getSeverityColor("high")).toBe("error");
|
|
276
|
+
expect(getSeverityColor("critical")).toBe("error");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { QuotasResponse } from "../types/quotas";
|
|
2
|
+
|
|
3
|
+
export type RiskSeverity = "none" | "warning" | "high" | "critical";
|
|
4
|
+
|
|
5
|
+
export interface QuotaWindow {
|
|
6
|
+
label: string;
|
|
7
|
+
usedPercent: number;
|
|
8
|
+
resetsAt: Date;
|
|
9
|
+
windowSeconds: number;
|
|
10
|
+
usedValue: number;
|
|
11
|
+
limitValue: number;
|
|
12
|
+
isCurrency?: boolean;
|
|
13
|
+
showPace?: boolean;
|
|
14
|
+
paceScale?: number;
|
|
15
|
+
limited?: boolean;
|
|
16
|
+
nextAmount?: string;
|
|
17
|
+
nextLabel?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WindowProjection {
|
|
21
|
+
pacePercent: number | null;
|
|
22
|
+
progress: number | null; // 0..1
|
|
23
|
+
projectedPercent: number; // 0..+
|
|
24
|
+
usedPercent: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RiskAssessment extends WindowProjection {
|
|
28
|
+
usedFloorPercent: number | null;
|
|
29
|
+
warnProjectedPercent: number | null;
|
|
30
|
+
highProjectedPercent: number | null;
|
|
31
|
+
criticalProjectedPercent: number | null;
|
|
32
|
+
severity: RiskSeverity;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MIN_PACE_PERCENT = 5;
|
|
36
|
+
|
|
37
|
+
// Threshold interpolation points
|
|
38
|
+
// Early window (0% progress) -> Late window (100% progress)
|
|
39
|
+
const THRESHOLDS = {
|
|
40
|
+
usedFloor: { start: 33, end: 8 },
|
|
41
|
+
warnProjected: { start: 260, end: 120 },
|
|
42
|
+
highProjected: { start: 320, end: 145 },
|
|
43
|
+
criticalProjected: { start: 400, end: 170 },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function interpolate(start: number, end: number, progress: number): number {
|
|
47
|
+
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
48
|
+
return start + (end - start) * clampedProgress;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Safely compute percentage, guarding against division by zero */
|
|
52
|
+
export function safePercent(used: number, limit: number): number {
|
|
53
|
+
if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) return 0;
|
|
54
|
+
return Math.max(0, Math.min(100, (used / limit) * 100));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse currency string like "$1,234.56" to number */
|
|
58
|
+
export function parseCurrency(value: string): number {
|
|
59
|
+
const n = Number(value.replace(/[^0-9.-]/g, ""));
|
|
60
|
+
return Number.isFinite(n) ? n : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function toWindows(quotas: QuotasResponse): QuotaWindow[] {
|
|
64
|
+
const windows: QuotaWindow[] = [];
|
|
65
|
+
|
|
66
|
+
if (quotas.weeklyTokenLimit) {
|
|
67
|
+
const { weeklyTokenLimit } = quotas;
|
|
68
|
+
const limitValue = parseCurrency(weeklyTokenLimit.maxCredits);
|
|
69
|
+
const remainingValue = parseCurrency(weeklyTokenLimit.remainingCredits);
|
|
70
|
+
windows.push({
|
|
71
|
+
label: "Credits / week",
|
|
72
|
+
usedPercent: Math.max(
|
|
73
|
+
0,
|
|
74
|
+
Math.min(100, 100 - weeklyTokenLimit.percentRemaining),
|
|
75
|
+
),
|
|
76
|
+
resetsAt: new Date(weeklyTokenLimit.nextRegenAt),
|
|
77
|
+
windowSeconds: 24 * 60 * 60,
|
|
78
|
+
usedValue: limitValue - remainingValue,
|
|
79
|
+
limitValue,
|
|
80
|
+
isCurrency: true,
|
|
81
|
+
showPace: true,
|
|
82
|
+
paceScale: 1 / 7,
|
|
83
|
+
nextAmount: `+${weeklyTokenLimit.nextRegenCredits}`,
|
|
84
|
+
nextLabel: "Next regen",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (quotas.rollingFiveHourLimit && quotas.rollingFiveHourLimit.max > 0) {
|
|
89
|
+
const { rollingFiveHourLimit } = quotas;
|
|
90
|
+
const used = rollingFiveHourLimit.max - rollingFiveHourLimit.remaining;
|
|
91
|
+
const tickAmount =
|
|
92
|
+
rollingFiveHourLimit.tickPercent * rollingFiveHourLimit.max;
|
|
93
|
+
windows.push({
|
|
94
|
+
label: "Requests / 5h",
|
|
95
|
+
usedPercent: safePercent(used, rollingFiveHourLimit.max),
|
|
96
|
+
resetsAt: new Date(rollingFiveHourLimit.nextTickAt),
|
|
97
|
+
windowSeconds: 5 * 60 * 60,
|
|
98
|
+
usedValue: Math.round(used),
|
|
99
|
+
limitValue: rollingFiveHourLimit.max,
|
|
100
|
+
showPace: false,
|
|
101
|
+
limited: rollingFiveHourLimit.limited,
|
|
102
|
+
nextAmount: `+${tickAmount.toFixed(1)}`,
|
|
103
|
+
nextLabel: "Next tick",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (quotas.search?.hourly?.limit && quotas.search.hourly.limit > 0) {
|
|
108
|
+
const { hourly } = quotas.search;
|
|
109
|
+
windows.push({
|
|
110
|
+
label: "Search / hour",
|
|
111
|
+
usedPercent: safePercent(hourly.requests, hourly.limit),
|
|
112
|
+
resetsAt: new Date(hourly.renewsAt),
|
|
113
|
+
windowSeconds: 60 * 60,
|
|
114
|
+
usedValue: hourly.requests,
|
|
115
|
+
limitValue: hourly.limit,
|
|
116
|
+
showPace: true,
|
|
117
|
+
paceScale: 1,
|
|
118
|
+
nextLabel: "Resets",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (quotas.freeToolCalls?.limit && quotas.freeToolCalls.limit > 0) {
|
|
123
|
+
windows.push({
|
|
124
|
+
label: "Free Tool Calls / day",
|
|
125
|
+
usedPercent: safePercent(
|
|
126
|
+
quotas.freeToolCalls.requests,
|
|
127
|
+
quotas.freeToolCalls.limit,
|
|
128
|
+
),
|
|
129
|
+
resetsAt: new Date(quotas.freeToolCalls.renewsAt),
|
|
130
|
+
windowSeconds: 24 * 60 * 60,
|
|
131
|
+
usedValue: quotas.freeToolCalls.requests,
|
|
132
|
+
limitValue: quotas.freeToolCalls.limit,
|
|
133
|
+
showPace: true,
|
|
134
|
+
paceScale: 1,
|
|
135
|
+
nextLabel: "Resets",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return windows;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function getPacePercent(window: QuotaWindow): number | null {
|
|
143
|
+
const totalMs = window.windowSeconds * 1000;
|
|
144
|
+
if (totalMs <= 0) return null;
|
|
145
|
+
const remainingMs = window.resetsAt.getTime() - Date.now();
|
|
146
|
+
const elapsedMs = totalMs - remainingMs;
|
|
147
|
+
return Math.max(0, Math.min(100, (elapsedMs / totalMs) * 100));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getProjectedPercent(
|
|
151
|
+
usedPercent: number,
|
|
152
|
+
pacePercent: number | null,
|
|
153
|
+
): number {
|
|
154
|
+
if (pacePercent === null) return usedPercent;
|
|
155
|
+
const effectivePace = Math.max(MIN_PACE_PERCENT, pacePercent);
|
|
156
|
+
return Math.max(0, (usedPercent / effectivePace) * 100);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function assessWindow(window: QuotaWindow): RiskAssessment {
|
|
160
|
+
// Respect showPace/paceScale: only compute pace when the window opts in,
|
|
161
|
+
// and apply paceScale to normalize (e.g. weekly windows scale daily pace by 1/7).
|
|
162
|
+
const rawPace = window.showPace ? getPacePercent(window) : null;
|
|
163
|
+
const pacePercent =
|
|
164
|
+
rawPace !== null ? rawPace * (window.paceScale ?? 1) : null;
|
|
165
|
+
const projectedPercent = getProjectedPercent(window.usedPercent, pacePercent);
|
|
166
|
+
|
|
167
|
+
// Calculate progress (0 to 1) through the window
|
|
168
|
+
let progress: number | null = null;
|
|
169
|
+
if (pacePercent !== null) {
|
|
170
|
+
progress = pacePercent / 100;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const base: WindowProjection = {
|
|
174
|
+
pacePercent,
|
|
175
|
+
progress,
|
|
176
|
+
projectedPercent,
|
|
177
|
+
usedPercent: window.usedPercent,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Fallback when pace/progress unavailable: use static thresholds on projected only
|
|
181
|
+
if (progress === null) {
|
|
182
|
+
let severity: RiskSeverity = "none";
|
|
183
|
+
if (window.limited) {
|
|
184
|
+
severity = "critical";
|
|
185
|
+
} else if (projectedPercent >= 100) {
|
|
186
|
+
severity = "critical";
|
|
187
|
+
} else if (projectedPercent >= 90) {
|
|
188
|
+
severity = "high";
|
|
189
|
+
} else if (projectedPercent >= 80) {
|
|
190
|
+
severity = "warning";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
...base,
|
|
195
|
+
usedFloorPercent: null,
|
|
196
|
+
warnProjectedPercent: 80,
|
|
197
|
+
highProjectedPercent: 90,
|
|
198
|
+
criticalProjectedPercent: 100,
|
|
199
|
+
severity,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Dynamic thresholds based on window progress
|
|
204
|
+
const usedFloorPercent = interpolate(
|
|
205
|
+
THRESHOLDS.usedFloor.start,
|
|
206
|
+
THRESHOLDS.usedFloor.end,
|
|
207
|
+
progress,
|
|
208
|
+
);
|
|
209
|
+
const warnProjectedPercent = interpolate(
|
|
210
|
+
THRESHOLDS.warnProjected.start,
|
|
211
|
+
THRESHOLDS.warnProjected.end,
|
|
212
|
+
progress,
|
|
213
|
+
);
|
|
214
|
+
const highProjectedPercent = interpolate(
|
|
215
|
+
THRESHOLDS.highProjected.start,
|
|
216
|
+
THRESHOLDS.highProjected.end,
|
|
217
|
+
progress,
|
|
218
|
+
);
|
|
219
|
+
const criticalProjectedPercent = interpolate(
|
|
220
|
+
THRESHOLDS.criticalProjected.start,
|
|
221
|
+
THRESHOLDS.criticalProjected.end,
|
|
222
|
+
progress,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Determine severity (hard-limited windows are always critical)
|
|
226
|
+
let severity: RiskSeverity = "none";
|
|
227
|
+
if (window.limited) {
|
|
228
|
+
severity = "critical";
|
|
229
|
+
} else if (window.usedPercent >= usedFloorPercent) {
|
|
230
|
+
if (projectedPercent >= criticalProjectedPercent) {
|
|
231
|
+
severity = "critical";
|
|
232
|
+
} else if (projectedPercent >= highProjectedPercent) {
|
|
233
|
+
severity = "high";
|
|
234
|
+
} else if (projectedPercent >= warnProjectedPercent) {
|
|
235
|
+
severity = "warning";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
...base,
|
|
241
|
+
usedFloorPercent,
|
|
242
|
+
warnProjectedPercent,
|
|
243
|
+
highProjectedPercent,
|
|
244
|
+
criticalProjectedPercent,
|
|
245
|
+
severity,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function formatTimeRemaining(date: Date): string {
|
|
250
|
+
const ms = date.getTime() - Date.now();
|
|
251
|
+
if (ms <= 0) return "now";
|
|
252
|
+
const totalMins = Math.ceil(ms / (1000 * 60));
|
|
253
|
+
const hours = Math.floor(totalMins / 60);
|
|
254
|
+
const mins = totalMins % 60;
|
|
255
|
+
if (hours >= 1) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
|
256
|
+
const totalSecs = Math.ceil(ms / 1000);
|
|
257
|
+
return totalMins >= 1 ? `${totalMins}m` : `${totalSecs}s`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function getSeverityColor(
|
|
261
|
+
severity: RiskSeverity,
|
|
262
|
+
): "success" | "warning" | "error" {
|
|
263
|
+
switch (severity) {
|
|
264
|
+
case "critical":
|
|
265
|
+
case "high":
|
|
266
|
+
return "error";
|
|
267
|
+
case "warning":
|
|
268
|
+
return "warning";
|
|
269
|
+
default:
|
|
270
|
+
return "success";
|
|
271
|
+
}
|
|
272
|
+
}
|