@aliou/pi-synthetic 0.11.0 → 0.13.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/README.md +22 -0
- package/package.json +6 -2
- package/src/config.ts +277 -0
- package/src/extensions/command-quotas/command.ts +9 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +14 -168
- package/src/extensions/command-quotas/index.ts +18 -3
- package/src/extensions/provider/index.ts +113 -0
- package/src/extensions/provider/models.ts +1 -1
- package/src/extensions/quota-warnings/index.ts +58 -0
- package/src/extensions/quota-warnings/notifier.test.ts +280 -0
- package/src/extensions/quota-warnings/notifier.ts +200 -0
- package/src/extensions/{command-quotas/sub-integration.ts → sub-bar-integration/index.ts} +34 -9
- package/src/extensions/usage-status/index.ts +245 -0
- package/src/extensions/web-search/index.ts +45 -1
- package/src/extensions/web-search/tool.ts +7 -0
- package/src/utils/quotas-severity.test.ts +278 -0
- package/src/utils/quotas-severity.ts +272 -0
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { AuthStorage, ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
configLoader,
|
|
4
|
+
SYNTHETIC_CONFIG_UPDATED_EVENT,
|
|
5
|
+
SYNTHETIC_EXTENSIONS_REGISTER_EVENT,
|
|
6
|
+
SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
|
|
7
|
+
type SyntheticConfigUpdatedPayload,
|
|
8
|
+
} from "../../config";
|
|
2
9
|
import { getSyntheticApiKey } from "../../lib/env";
|
|
3
10
|
import type { QuotasResponse } from "../../types/quotas";
|
|
4
11
|
import { fetchQuotas, formatResetTime } from "../../utils/quotas";
|
|
@@ -28,7 +35,6 @@ interface SubCoreSettingsPayload {
|
|
|
28
35
|
function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
|
|
29
36
|
const windows: RateWindow[] = [];
|
|
30
37
|
|
|
31
|
-
// Weekly token limit (credits-based)
|
|
32
38
|
if (quotas.weeklyTokenLimit) {
|
|
33
39
|
const { weeklyTokenLimit } = quotas;
|
|
34
40
|
windows.push({
|
|
@@ -41,7 +47,6 @@ function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
|
|
|
41
47
|
});
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
// Rolling 5-hour limit (request-based)
|
|
45
50
|
if (quotas.rollingFiveHourLimit && quotas.rollingFiveHourLimit.max > 0) {
|
|
46
51
|
const { rollingFiveHourLimit } = quotas;
|
|
47
52
|
const used = rollingFiveHourLimit.max - rollingFiveHourLimit.remaining;
|
|
@@ -55,7 +60,6 @@ function toUsageSnapshot(quotas: QuotasResponse): UsageSnapshot {
|
|
|
55
60
|
});
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
// Legacy subscription (fallback if rollingFiveHourLimit not available)
|
|
59
63
|
if (
|
|
60
64
|
!quotas.rollingFiveHourLimit &&
|
|
61
65
|
quotas.subscription?.limit &&
|
|
@@ -117,15 +121,16 @@ async function emitCurrentUsage(
|
|
|
117
121
|
});
|
|
118
122
|
}
|
|
119
123
|
|
|
120
|
-
export function
|
|
124
|
+
export function registerSubBarIntegration(pi: ExtensionAPI): void {
|
|
121
125
|
let interval: NodeJS.Timeout | undefined;
|
|
122
126
|
let refreshMs = 60000;
|
|
123
127
|
let subCoreReady = false;
|
|
124
128
|
let currentProvider: string | undefined;
|
|
125
129
|
let currentAuthStorage: AuthStorage | undefined;
|
|
130
|
+
let enabled = configLoader.getConfig().subBarIntegration;
|
|
126
131
|
|
|
127
132
|
function isSynthetic(): boolean {
|
|
128
|
-
return currentProvider === "synthetic";
|
|
133
|
+
return enabled && currentProvider === "synthetic";
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
function stop(): void {
|
|
@@ -148,24 +153,33 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
|
|
|
148
153
|
interval.unref?.();
|
|
149
154
|
}
|
|
150
155
|
|
|
151
|
-
|
|
156
|
+
pi.events.on(SYNTHETIC_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
157
|
+
enabled = (data as SyntheticConfigUpdatedPayload).config.subBarIntegration;
|
|
158
|
+
|
|
159
|
+
if (!enabled) {
|
|
160
|
+
stop();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (subCoreReady && currentAuthStorage && currentProvider === "synthetic") {
|
|
165
|
+
startPolling(currentAuthStorage);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
152
169
|
pi.events.on("sub-core:ready", () => {
|
|
153
170
|
subCoreReady = true;
|
|
154
|
-
// Polling starts in session_start/model_select when provider is synthetic
|
|
155
171
|
});
|
|
156
172
|
|
|
157
173
|
pi.events.on("sub-core:settings:updated", (data: unknown) => {
|
|
158
174
|
const payload = data as SubCoreSettingsPayload;
|
|
159
175
|
if (payload.settings?.behavior?.refreshInterval) {
|
|
160
176
|
refreshMs = payload.settings.behavior.refreshInterval * 1000;
|
|
161
|
-
// Restart with new interval if currently running
|
|
162
177
|
if (interval && isSynthetic() && currentAuthStorage) {
|
|
163
178
|
startPolling(currentAuthStorage);
|
|
164
179
|
}
|
|
165
180
|
}
|
|
166
181
|
});
|
|
167
182
|
|
|
168
|
-
// Lifecycle events
|
|
169
183
|
pi.on("session_start", async (_event, ctx) => {
|
|
170
184
|
currentProvider = ctx.model?.provider;
|
|
171
185
|
currentAuthStorage = ctx.modelRegistry.authStorage;
|
|
@@ -200,3 +214,14 @@ export function registerSubIntegration(pi: ExtensionAPI): void {
|
|
|
200
214
|
stop();
|
|
201
215
|
});
|
|
202
216
|
}
|
|
217
|
+
|
|
218
|
+
export default async function (pi: ExtensionAPI) {
|
|
219
|
+
await configLoader.load();
|
|
220
|
+
registerSubBarIntegration(pi);
|
|
221
|
+
|
|
222
|
+
pi.events.on(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, () => {
|
|
223
|
+
pi.events.emit(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, {
|
|
224
|
+
feature: "subBarIntegration",
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
configLoader,
|
|
7
|
+
SYNTHETIC_CONFIG_UPDATED_EVENT,
|
|
8
|
+
SYNTHETIC_EXTENSIONS_REGISTER_EVENT,
|
|
9
|
+
SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
|
|
10
|
+
type SyntheticConfigUpdatedPayload,
|
|
11
|
+
} from "../../config";
|
|
12
|
+
import { getSyntheticApiKey } from "../../lib/env";
|
|
13
|
+
import type { QuotasResponse } from "../../types/quotas";
|
|
14
|
+
import { fetchQuotas, formatResetTime } from "../../utils/quotas";
|
|
15
|
+
import {
|
|
16
|
+
assessWindow,
|
|
17
|
+
getSeverityColor,
|
|
18
|
+
type RiskSeverity,
|
|
19
|
+
toWindows,
|
|
20
|
+
} from "../../utils/quotas-severity";
|
|
21
|
+
|
|
22
|
+
const EXTENSION_ID = "synthetic-usage";
|
|
23
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
type WindowStatus = {
|
|
26
|
+
label: string;
|
|
27
|
+
usedPercent: number;
|
|
28
|
+
severity: RiskSeverity;
|
|
29
|
+
resetsAt: string | null;
|
|
30
|
+
limited: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function parseSnapshot(quotas: QuotasResponse): WindowStatus[] {
|
|
34
|
+
const windows = toWindows(quotas);
|
|
35
|
+
return windows.map((w) => {
|
|
36
|
+
const assessment = assessWindow(w);
|
|
37
|
+
return {
|
|
38
|
+
label: w.label,
|
|
39
|
+
usedPercent: w.usedPercent,
|
|
40
|
+
severity: assessment.severity,
|
|
41
|
+
resetsAt: w.resetsAt.toISOString(),
|
|
42
|
+
limited: w.limited ?? false,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SHORT_LABELS: Record<string, string> = {
|
|
48
|
+
"Credits / week": "week",
|
|
49
|
+
"Requests / 5h": "5h",
|
|
50
|
+
"Search / hour": "search",
|
|
51
|
+
"Free Tool Calls / day": "tools",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function formatStatus(ctx: ExtensionContext, windows: WindowStatus[]): string {
|
|
55
|
+
const theme = ctx.ui.theme;
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
|
|
58
|
+
for (const w of windows) {
|
|
59
|
+
const short = SHORT_LABELS[w.label] ?? w.label;
|
|
60
|
+
const remaining = Math.max(
|
|
61
|
+
0,
|
|
62
|
+
Math.min(100, Math.round(100 - w.usedPercent)),
|
|
63
|
+
);
|
|
64
|
+
const color = getSeverityColor(w.severity);
|
|
65
|
+
const pctText = theme.fg(color, `${remaining}%`);
|
|
66
|
+
const reset = w.resetsAt
|
|
67
|
+
? theme.fg("dim", ` (\u21ba${formatResetTime(w.resetsAt)})`)
|
|
68
|
+
: "";
|
|
69
|
+
const limitTag = w.limited ? theme.fg("error", " [limited]") : "";
|
|
70
|
+
parts.push(`${theme.fg("dim", `${short}:`)}${pctText}${reset}${limitTag}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return parts.join(" ");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createStatusRefresher() {
|
|
77
|
+
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
78
|
+
let activeContext: ExtensionContext | undefined;
|
|
79
|
+
let isRefreshInFlight = false;
|
|
80
|
+
let queuedRefresh = false;
|
|
81
|
+
let lastSnapshot: WindowStatus[] | undefined;
|
|
82
|
+
|
|
83
|
+
async function updateFooterStatus(ctx: ExtensionContext): Promise<void> {
|
|
84
|
+
if (!ctx.hasUI) return;
|
|
85
|
+
if (isRefreshInFlight) {
|
|
86
|
+
queuedRefresh = true;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
isRefreshInFlight = true;
|
|
90
|
+
try {
|
|
91
|
+
const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
|
|
92
|
+
if (!apiKey) {
|
|
93
|
+
lastSnapshot = undefined;
|
|
94
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const result = await fetchQuotas(apiKey);
|
|
98
|
+
if (!result.success) {
|
|
99
|
+
ctx.ui.setStatus(
|
|
100
|
+
EXTENSION_ID,
|
|
101
|
+
ctx.ui.theme.fg("warning", "usage unavailable"),
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const windows = parseSnapshot(result.data.quotas);
|
|
106
|
+
lastSnapshot = windows;
|
|
107
|
+
if (windows.length === 0) {
|
|
108
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
ctx.ui.setStatus(EXTENSION_ID, formatStatus(ctx, windows));
|
|
112
|
+
} catch {
|
|
113
|
+
ctx.ui.setStatus(
|
|
114
|
+
EXTENSION_ID,
|
|
115
|
+
ctx.ui.theme.fg("warning", "usage unavailable"),
|
|
116
|
+
);
|
|
117
|
+
} finally {
|
|
118
|
+
isRefreshInFlight = false;
|
|
119
|
+
if (queuedRefresh) {
|
|
120
|
+
queuedRefresh = false;
|
|
121
|
+
void updateFooterStatus(ctx);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function refreshFor(ctx: ExtensionContext): Promise<void> {
|
|
127
|
+
activeContext = ctx;
|
|
128
|
+
return updateFooterStatus(ctx);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function startAutoRefresh(): void {
|
|
132
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
133
|
+
refreshTimer = setInterval(() => {
|
|
134
|
+
if (!activeContext) return;
|
|
135
|
+
void updateFooterStatus(activeContext);
|
|
136
|
+
}, REFRESH_INTERVAL_MS);
|
|
137
|
+
refreshTimer.unref?.();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stopAutoRefresh(ctx?: ExtensionContext): void {
|
|
141
|
+
if (refreshTimer) {
|
|
142
|
+
clearInterval(refreshTimer);
|
|
143
|
+
refreshTimer = undefined;
|
|
144
|
+
}
|
|
145
|
+
ctx?.ui.setStatus(EXTENSION_ID, undefined);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function setLoadingStatus(ctx: ExtensionContext): Promise<void> {
|
|
149
|
+
if (!ctx.hasUI) return;
|
|
150
|
+
const apiKey = await getSyntheticApiKey(
|
|
151
|
+
ctx.modelRegistry.authStorage,
|
|
152
|
+
).catch(() => undefined);
|
|
153
|
+
if (!apiKey) {
|
|
154
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("dim", "loading usage..."));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderFromLastSnapshot(ctx: ExtensionContext): boolean {
|
|
161
|
+
if (!ctx.hasUI || !lastSnapshot) return false;
|
|
162
|
+
ctx.ui.setStatus(EXTENSION_ID, formatStatus(ctx, lastSnapshot));
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
refreshFor,
|
|
168
|
+
startAutoRefresh,
|
|
169
|
+
stopAutoRefresh,
|
|
170
|
+
setLoadingStatus,
|
|
171
|
+
renderFromLastSnapshot,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default async function (pi: ExtensionAPI) {
|
|
176
|
+
await configLoader.load();
|
|
177
|
+
|
|
178
|
+
const refresher = createStatusRefresher();
|
|
179
|
+
let enabled = configLoader.getConfig().usageStatus;
|
|
180
|
+
let currentContext: ExtensionContext | undefined;
|
|
181
|
+
let currentProvider: string | undefined;
|
|
182
|
+
|
|
183
|
+
pi.events.on(SYNTHETIC_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
184
|
+
enabled = (data as SyntheticConfigUpdatedPayload).config.usageStatus;
|
|
185
|
+
|
|
186
|
+
if (!enabled) {
|
|
187
|
+
refresher.stopAutoRefresh(currentContext);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (currentContext && currentProvider === "synthetic") {
|
|
192
|
+
refresher.startAutoRefresh();
|
|
193
|
+
void refresher.refreshFor(currentContext);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
198
|
+
currentContext = ctx;
|
|
199
|
+
currentProvider = ctx.model?.provider;
|
|
200
|
+
if (!enabled || ctx.model?.provider !== "synthetic") return;
|
|
201
|
+
refresher.startAutoRefresh();
|
|
202
|
+
await refresher.setLoadingStatus(ctx);
|
|
203
|
+
await refresher.refreshFor(ctx);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
207
|
+
currentContext = ctx;
|
|
208
|
+
currentProvider = ctx.model?.provider;
|
|
209
|
+
if (!enabled || ctx.model?.provider !== "synthetic") return;
|
|
210
|
+
void refresher.refreshFor(ctx);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
pi.on("session_switch", (_event, ctx) => {
|
|
214
|
+
currentContext = ctx;
|
|
215
|
+
currentProvider = ctx.model?.provider;
|
|
216
|
+
if (enabled && ctx.model?.provider === "synthetic") {
|
|
217
|
+
void refresher.refreshFor(ctx);
|
|
218
|
+
} else {
|
|
219
|
+
refresher.stopAutoRefresh(ctx);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
pi.on("model_select", (_event, ctx) => {
|
|
224
|
+
currentContext = ctx;
|
|
225
|
+
currentProvider = ctx.model?.provider;
|
|
226
|
+
if (enabled && ctx.model?.provider === "synthetic") {
|
|
227
|
+
refresher.startAutoRefresh();
|
|
228
|
+
void refresher.refreshFor(ctx);
|
|
229
|
+
} else {
|
|
230
|
+
refresher.stopAutoRefresh(ctx);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
235
|
+
currentContext = undefined;
|
|
236
|
+
currentProvider = undefined;
|
|
237
|
+
refresher.stopAutoRefresh(ctx);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
pi.events.on(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, () => {
|
|
241
|
+
pi.events.emit(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, {
|
|
242
|
+
feature: "usageStatus",
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
configLoader,
|
|
4
|
+
SYNTHETIC_CONFIG_UPDATED_EVENT,
|
|
5
|
+
SYNTHETIC_EXTENSIONS_REGISTER_EVENT,
|
|
6
|
+
SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
|
|
7
|
+
type SyntheticConfigUpdatedPayload,
|
|
8
|
+
} from "../../config";
|
|
9
|
+
import {
|
|
10
|
+
registerSyntheticWebSearchTool,
|
|
11
|
+
SYNTHETIC_WEB_SEARCH_TOOL,
|
|
12
|
+
} from "./tool";
|
|
13
|
+
|
|
14
|
+
function syncToolActivation(pi: ExtensionAPI, enabled: boolean): void {
|
|
15
|
+
const allToolNames = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
16
|
+
const activeTools = new Set(pi.getActiveTools());
|
|
17
|
+
|
|
18
|
+
if (!allToolNames.has(SYNTHETIC_WEB_SEARCH_TOOL)) return;
|
|
19
|
+
|
|
20
|
+
if (enabled) {
|
|
21
|
+
activeTools.add(SYNTHETIC_WEB_SEARCH_TOOL);
|
|
22
|
+
} else {
|
|
23
|
+
activeTools.delete(SYNTHETIC_WEB_SEARCH_TOOL);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pi.setActiveTools([...activeTools].filter((name) => allToolNames.has(name)));
|
|
27
|
+
}
|
|
3
28
|
|
|
4
29
|
export default async function (pi: ExtensionAPI) {
|
|
30
|
+
await configLoader.load();
|
|
31
|
+
|
|
32
|
+
let webSearchEnabled = configLoader.getConfig().webSearch;
|
|
33
|
+
|
|
5
34
|
registerSyntheticWebSearchTool(pi);
|
|
35
|
+
|
|
36
|
+
pi.on("session_start", async () => {
|
|
37
|
+
syncToolActivation(pi, webSearchEnabled);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
pi.events.on(SYNTHETIC_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
41
|
+
webSearchEnabled = (data as SyntheticConfigUpdatedPayload).config.webSearch;
|
|
42
|
+
syncToolActivation(pi, webSearchEnabled);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
pi.events.on(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, () => {
|
|
46
|
+
pi.events.emit(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, {
|
|
47
|
+
feature: "webSearch",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
6
50
|
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
10
10
|
import { Container, Markdown, Text } from "@mariozechner/pi-tui";
|
|
11
11
|
import { type Static, Type } from "@sinclair/typebox";
|
|
12
|
+
import { configLoader } from "../../config";
|
|
12
13
|
import { getSyntheticApiKey } from "../../lib/env";
|
|
13
14
|
|
|
14
15
|
export const SYNTHETIC_WEB_SEARCH_TOOL = "synthetic_web_search" as const;
|
|
@@ -59,6 +60,12 @@ export function registerSyntheticWebSearchTool(pi: ExtensionAPI): void {
|
|
|
59
60
|
details: { query: params.query },
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
if (!configLoader.getConfig().webSearch) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Synthetic web search is disabled. Re-enable it with synthetic:settings or pi config.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
const apiKey = await getSyntheticApiKey(ctx.modelRegistry.authStorage);
|
|
63
70
|
if (!apiKey) {
|
|
64
71
|
throw new Error(
|
|
@@ -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
|
+
});
|