@aliou/pi-synthetic 0.17.4 → 0.18.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-synthetic",
3
- "version": "0.17.4",
3
+ "version": "0.18.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -29,7 +29,8 @@
29
29
  },
30
30
  "files": [
31
31
  "src",
32
- "README.md"
32
+ "README.md",
33
+ "!src/**/*.test.ts"
33
34
  ],
34
35
  "peerDependencies": {
35
36
  "@earendil-works/pi-coding-agent": "0.74.0",
@@ -40,8 +41,8 @@
40
41
  "@aliou/pi-utils-ui": "^0.4.0"
41
42
  },
42
43
  "devDependencies": {
43
- "@aliou/biome-plugins": "^0.7.0",
44
- "@biomejs/biome": "^2.4.2",
44
+ "@aliou/biome-plugins": "^0.8.1",
45
+ "@biomejs/biome": "^2.4.15",
45
46
  "@changesets/cli": "^2.27.11",
46
47
  "@earendil-works/pi-coding-agent": "0.74.0",
47
48
  "typebox": "^1.1.37",
@@ -216,4 +216,85 @@ export const SYNTHETIC_MODELS: SyntheticModelConfig[] = [
216
216
  contextWindow: 262144,
217
217
  maxTokens: 65536,
218
218
  },
219
+ // API: syn:large:text → alias for hf:zai-org/GLM-5.1 → ctx=196608, out=65536
220
+ {
221
+ id: "syn:large:text",
222
+ name: "syn:large:text",
223
+ provider: "synthetic",
224
+ reasoning: true,
225
+ thinkingLevelMap: { minimal: null, xhigh: null },
226
+ compat: {
227
+ supportsReasoningEffort: true,
228
+ supportsDeveloperRole: false,
229
+ },
230
+ input: ["text"],
231
+ cost: {
232
+ input: 1,
233
+ output: 3,
234
+ cacheRead: 1,
235
+ cacheWrite: 0,
236
+ },
237
+ contextWindow: 196608,
238
+ maxTokens: 65536,
239
+ },
240
+ // API: syn:small:text → alias for hf:zai-org/GLM-4.7-Flash → ctx=196608, out=65536
241
+ {
242
+ id: "syn:small:text",
243
+ name: "syn:small:text",
244
+ provider: "synthetic",
245
+ reasoning: true,
246
+ thinkingLevelMap: { minimal: null, xhigh: null },
247
+ compat: {
248
+ supportsReasoningEffort: true,
249
+ },
250
+ input: ["text"],
251
+ cost: {
252
+ input: 0.1,
253
+ output: 0.5,
254
+ cacheRead: 0.1,
255
+ cacheWrite: 0,
256
+ },
257
+ contextWindow: 196608,
258
+ maxTokens: 65536,
259
+ },
260
+ // API: syn:large:vision → alias for hf:moonshotai/Kimi-K2.6 → ctx=262144, out=65536
261
+ {
262
+ id: "syn:large:vision",
263
+ name: "syn:large:vision",
264
+ provider: "synthetic",
265
+ reasoning: true,
266
+ thinkingLevelMap: { minimal: null, low: null, xhigh: null },
267
+ compat: {
268
+ supportsReasoningEffort: true,
269
+ },
270
+ input: ["text", "image"],
271
+ cost: {
272
+ input: 0.95,
273
+ output: 4,
274
+ cacheRead: 0.95,
275
+ cacheWrite: 0,
276
+ },
277
+ contextWindow: 262144,
278
+ maxTokens: 65536,
279
+ },
280
+ // API: syn:small:vision → alias for hf:moonshotai/Kimi-K2.6 → ctx=262144, out=65536
281
+ {
282
+ id: "syn:small:vision",
283
+ name: "syn:small:vision",
284
+ provider: "synthetic",
285
+ reasoning: true,
286
+ thinkingLevelMap: { minimal: null, low: null, xhigh: null },
287
+ compat: {
288
+ supportsReasoningEffort: true,
289
+ },
290
+ input: ["text", "image"],
291
+ cost: {
292
+ input: 0.95,
293
+ output: 4,
294
+ cacheRead: 0.95,
295
+ cacheWrite: 0,
296
+ },
297
+ contextWindow: 262144,
298
+ maxTokens: 65536,
299
+ },
219
300
  ];
@@ -1,46 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { buildSyntheticProviderModels } from "./index";
3
- import { SYNTHETIC_MODELS } from "./models";
4
-
5
- describe("buildSyntheticProviderModels", () => {
6
- it("excludes proxied models when includeProxiedModels is false", () => {
7
- const models = buildSyntheticProviderModels(false);
8
- for (const model of models) {
9
- const source = SYNTHETIC_MODELS.find((m) => m.id === model.id);
10
- expect(source).toBeDefined();
11
- expect(source?.provider).toBe("synthetic");
12
- }
13
- });
14
-
15
- it("includes all models when includeProxiedModels is true", () => {
16
- const models = buildSyntheticProviderModels(true);
17
- expect(models).toHaveLength(SYNTHETIC_MODELS.length);
18
- });
19
-
20
- it("does not expose the internal provider field", () => {
21
- const models = buildSyntheticProviderModels(true);
22
- for (const model of models) {
23
- expect(model).not.toHaveProperty("provider");
24
- }
25
- });
26
-
27
- it("sets default compat fields on every model", () => {
28
- const models = buildSyntheticProviderModels(true);
29
- for (const model of models) {
30
- expect(model.compat).toMatchObject({
31
- supportsDeveloperRole: false,
32
- });
33
- expect(model.compat).toHaveProperty("maxTokensField");
34
- }
35
- });
36
-
37
- it("preserves model-specific compat overrides", () => {
38
- const models = buildSyntheticProviderModels(true);
39
- const miniMax = models.find((m) => m.id === "hf:MiniMaxAI/MiniMax-M2.5");
40
- expect(miniMax).toBeDefined();
41
- expect(miniMax?.compat).toMatchObject({
42
- supportsDeveloperRole: false,
43
- maxTokensField: "max_completion_tokens",
44
- });
45
- });
46
- });
@@ -1,217 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { SYNTHETIC_MODELS } from "./models";
3
-
4
- interface ApiModel {
5
- id: string;
6
- name: string;
7
- provider: string | null;
8
- input_modalities: string[];
9
- output_modalities: string[];
10
- context_length: number;
11
- max_output_length: number;
12
- pricing: {
13
- prompt: string;
14
- completion: string;
15
- input_cache_reads: string;
16
- input_cache_writes: string;
17
- };
18
- supported_features?: string[];
19
- }
20
-
21
- interface ApiResponse {
22
- data: ApiModel[];
23
- }
24
-
25
- interface Discrepancy {
26
- model: string;
27
- field: string;
28
- hardcoded: unknown;
29
- api: unknown;
30
- }
31
-
32
- async function fetchApiModels(): Promise<ApiModel[]> {
33
- // Making ourselves known
34
- const response = await fetch("https://api.synthetic.new/openai/v1/models", {
35
- headers: {
36
- Referer: "https://github.com/aliou/pi-synthetic",
37
- },
38
- });
39
-
40
- if (!response.ok) {
41
- throw new Error(
42
- `API request failed: ${response.status} ${response.statusText}`,
43
- );
44
- }
45
-
46
- const data: ApiResponse = await response.json();
47
- return data.data;
48
- }
49
-
50
- function parsePrice(priceStr: string): number {
51
- // Convert "$0.0000006" to 0.6 (dollars per million tokens)
52
- const match = priceStr.match(/\$?(\d+\.?\d*)/);
53
- if (!match) return 0;
54
- const pricePerToken = Number.parseFloat(match[1]);
55
- // API prices are per token, hardcoded prices are per million tokens
56
- return pricePerToken * 1_000_000;
57
- }
58
-
59
- function compareModels(
60
- apiModels: ApiModel[],
61
- hardcodedModels: typeof SYNTHETIC_MODELS,
62
- ): Discrepancy[] {
63
- const discrepancies: Discrepancy[] = [];
64
-
65
- for (const hardcoded of hardcodedModels) {
66
- const apiModel = apiModels.find((m) => m.id === hardcoded.id);
67
-
68
- if (!apiModel) {
69
- discrepancies.push({
70
- model: hardcoded.id,
71
- field: "exists",
72
- hardcoded: true,
73
- api: false,
74
- });
75
- continue;
76
- }
77
-
78
- // Check input modalities (text vs image support)
79
- const apiInputs = apiModel.input_modalities.sort();
80
- const hardcodedInputs = [...hardcoded.input].sort();
81
- if (JSON.stringify(apiInputs) !== JSON.stringify(hardcodedInputs)) {
82
- discrepancies.push({
83
- model: hardcoded.id,
84
- field: "input",
85
- hardcoded: hardcodedInputs,
86
- api: apiInputs,
87
- });
88
- }
89
-
90
- // Check context window
91
- if (apiModel.context_length !== hardcoded.contextWindow) {
92
- discrepancies.push({
93
- model: hardcoded.id,
94
- field: "contextWindow",
95
- hardcoded: hardcoded.contextWindow,
96
- api: apiModel.context_length,
97
- });
98
- }
99
-
100
- // Check max output tokens (skip if API doesn't provide it)
101
- if (
102
- apiModel.max_output_length !== undefined &&
103
- apiModel.max_output_length !== hardcoded.maxTokens
104
- ) {
105
- discrepancies.push({
106
- model: hardcoded.id,
107
- field: "maxTokens",
108
- hardcoded: hardcoded.maxTokens,
109
- api: apiModel.max_output_length,
110
- });
111
- }
112
-
113
- // Check input cost (convert API price to per-million rate)
114
- const apiInputCost = parsePrice(apiModel.pricing.prompt);
115
- const epsilon = 0.001; // Small tolerance for floating point
116
- if (Math.abs(apiInputCost - hardcoded.cost.input) > epsilon) {
117
- discrepancies.push({
118
- model: hardcoded.id,
119
- field: "cost.input",
120
- hardcoded: hardcoded.cost.input,
121
- api: apiInputCost,
122
- });
123
- }
124
-
125
- // Check output cost
126
- const apiOutputCost = parsePrice(apiModel.pricing.completion);
127
- if (Math.abs(apiOutputCost - hardcoded.cost.output) > epsilon) {
128
- discrepancies.push({
129
- model: hardcoded.id,
130
- field: "cost.output",
131
- hardcoded: hardcoded.cost.output,
132
- api: apiOutputCost,
133
- });
134
- }
135
-
136
- // Check cache read cost
137
- const apiCacheReadCost = parsePrice(apiModel.pricing.input_cache_reads);
138
- if (Math.abs(apiCacheReadCost - hardcoded.cost.cacheRead) > epsilon) {
139
- discrepancies.push({
140
- model: hardcoded.id,
141
- field: "cost.cacheRead",
142
- hardcoded: hardcoded.cost.cacheRead,
143
- api: apiCacheReadCost,
144
- });
145
- }
146
-
147
- // Check reasoning capability from supported_features (skip if API doesn't provide it)
148
- if (apiModel.supported_features !== undefined) {
149
- const apiSupportsReasoning =
150
- apiModel.supported_features.includes("reasoning");
151
- if (apiSupportsReasoning !== hardcoded.reasoning) {
152
- discrepancies.push({
153
- model: hardcoded.id,
154
- field: "reasoning",
155
- hardcoded: hardcoded.reasoning,
156
- api: apiSupportsReasoning,
157
- });
158
- }
159
- }
160
-
161
- // Check provider
162
- if (
163
- apiModel.provider !== null &&
164
- apiModel.provider !== hardcoded.provider
165
- ) {
166
- discrepancies.push({
167
- model: hardcoded.id,
168
- field: "provider",
169
- hardcoded: hardcoded.provider,
170
- api: apiModel.provider,
171
- });
172
- }
173
- }
174
-
175
- // Check for API models not in hardcoded list
176
- for (const apiModel of apiModels) {
177
- const hardcoded = hardcodedModels.find((m) => m.id === apiModel.id);
178
- if (!hardcoded) {
179
- discrepancies.push({
180
- model: apiModel.id,
181
- field: "exists",
182
- hardcoded: false,
183
- api: true,
184
- });
185
- }
186
- }
187
-
188
- return discrepancies;
189
- }
190
-
191
- describe("Synthetic models", () => {
192
- it("should match API model definitions", { timeout: 30000 }, async () => {
193
- const apiModels = await fetchApiModels();
194
- const discrepancies = compareModels(apiModels, SYNTHETIC_MODELS);
195
-
196
- if (discrepancies.length > 0) {
197
- console.error("\nModel discrepancies found:");
198
- console.error("==========================");
199
- for (const d of discrepancies) {
200
- if (d.field === "exists") {
201
- if (d.hardcoded) {
202
- console.error(` ${d.model}: Missing from API`);
203
- } else {
204
- console.error(` ${d.model}: Missing from hardcoded models (NEW)`);
205
- }
206
- } else {
207
- console.error(` ${d.model}.${d.field}:`);
208
- console.error(` hardcoded: ${JSON.stringify(d.hardcoded)}`);
209
- console.error(` api: ${JSON.stringify(d.api)}`);
210
- }
211
- }
212
- console.error("==========================\n");
213
- }
214
-
215
- expect(discrepancies).toHaveLength(0);
216
- });
217
- });
@@ -1,211 +0,0 @@
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 { QuotaStore } from "./quota-store";
12
-
13
- beforeEach(() => {
14
- vi.useFakeTimers();
15
- });
16
-
17
- afterEach(() => {
18
- vi.useRealTimers();
19
- });
20
-
21
- describe("QuotaStore", () => {
22
- const sampleQuotas: QuotasResponse = {
23
- subscription: { limit: 100, requests: 5, renewsAt: "2026-01-01T00:00:00Z" },
24
- };
25
-
26
- describe("ingest", () => {
27
- it("stores and emits API-sourced data", () => {
28
- const store = new QuotaStore();
29
- const received: QuotasResponse[] = [];
30
- store.subscribe((snap) => received.push(snap.quotas));
31
-
32
- const result = store.ingest(sampleQuotas, "api");
33
-
34
- expect(result).toBe(true);
35
- expect(store.getSnapshot()?.quotas).toBe(sampleQuotas);
36
- expect(store.getSnapshot()?.source).toBe("api");
37
- expect(received).toHaveLength(1);
38
- });
39
-
40
- it("stores and emits header-sourced data", () => {
41
- const store = new QuotaStore();
42
- const result = store.ingest(sampleQuotas, "header");
43
-
44
- expect(result).toBe(true);
45
- expect(store.getSnapshot()?.source).toBe("header");
46
- });
47
-
48
- it("throttles header ingestion within throttle window", () => {
49
- const store = new QuotaStore();
50
- store.ingest(sampleQuotas, "header");
51
-
52
- // Within throttle window — should be dropped
53
- const result = store.ingest(sampleQuotas, "header");
54
- expect(result).toBe(false);
55
-
56
- // Advance past throttle
57
- vi.advanceTimersByTime(store.headerThrottleMs + 1);
58
- const result2 = store.ingest(sampleQuotas, "header");
59
- expect(result2).toBe(true);
60
- });
61
-
62
- it("does NOT throttle API ingestion", () => {
63
- const store = new QuotaStore();
64
- store.ingest(sampleQuotas, "api");
65
-
66
- // API is never throttled
67
- const result = store.ingest(sampleQuotas, "api");
68
- expect(result).toBe(true);
69
- });
70
-
71
- it("header after API emit always goes through", () => {
72
- const store = new QuotaStore();
73
- store.ingest(sampleQuotas, "api");
74
-
75
- // Header 1ms after API should not be blocked
76
- vi.advanceTimersByTime(1);
77
- const result = store.ingest(sampleQuotas, "header");
78
- expect(result).toBe(true);
79
- });
80
-
81
- it("updates timestamp on each successful ingest", () => {
82
- const store = new QuotaStore();
83
- store.ingest(sampleQuotas, "api");
84
- const snap1 = store.getSnapshot();
85
- assert(snap1);
86
- const t1 = snap1.updatedAt;
87
-
88
- vi.advanceTimersByTime(10_000);
89
- store.ingest(sampleQuotas, "api");
90
- const snap2 = store.getSnapshot();
91
- assert(snap2);
92
- const t2 = snap2.updatedAt;
93
-
94
- expect(t2).toBeGreaterThan(t1);
95
- });
96
- });
97
-
98
- describe("subscribe", () => {
99
- it("notifies subscribers on ingest", () => {
100
- const store = new QuotaStore();
101
- const calls: QuotasResponse[] = [];
102
- store.subscribe((snap) => calls.push(snap.quotas));
103
-
104
- store.ingest(sampleQuotas, "api");
105
- expect(calls).toHaveLength(1);
106
- expect(calls[0]).toBe(sampleQuotas);
107
- });
108
-
109
- it("does not notify on throttled ingest", () => {
110
- const store = new QuotaStore();
111
- const calls: QuotasResponse[] = [];
112
- store.subscribe((snap) => calls.push(snap.quotas));
113
-
114
- store.ingest(sampleQuotas, "header");
115
- store.ingest(sampleQuotas, "header"); // throttled
116
-
117
- expect(calls).toHaveLength(1);
118
- });
119
-
120
- it("unsubscribes when unsubscribe function is called", () => {
121
- const store = new QuotaStore();
122
- const calls: QuotasResponse[] = [];
123
- const unsub = store.subscribe((snap) => calls.push(snap.quotas));
124
-
125
- unsub();
126
- store.ingest(sampleQuotas, "api");
127
-
128
- expect(calls).toHaveLength(0);
129
- });
130
-
131
- it("supports multiple subscribers", () => {
132
- const store = new QuotaStore();
133
- const calls1: QuotasResponse[] = [];
134
- const calls2: QuotasResponse[] = [];
135
- store.subscribe((snap) => calls1.push(snap.quotas));
136
- store.subscribe((snap) => calls2.push(snap.quotas));
137
-
138
- store.ingest(sampleQuotas, "api");
139
-
140
- expect(calls1).toHaveLength(1);
141
- expect(calls2).toHaveLength(1);
142
- });
143
- });
144
-
145
- describe("refreshFromApi", () => {
146
- it("calls the fetcher and ingests the result", async () => {
147
- const store = new QuotaStore();
148
- const fetcher = vi.fn().mockResolvedValue(sampleQuotas);
149
-
150
- const result = await store.refreshFromApi(fetcher);
151
-
152
- assert(result);
153
- expect(result.quotas).toBe(sampleQuotas);
154
- expect(result.source).toBe("api");
155
- expect(fetcher).toHaveBeenCalledOnce();
156
- });
157
-
158
- it("deduplicates concurrent calls", async () => {
159
- const store = new QuotaStore();
160
- let resolveFirst!: (v: QuotasResponse) => void;
161
- const first = new Promise<QuotasResponse>((r) => (resolveFirst = r));
162
- const fetcher = vi.fn().mockImplementation(() => first);
163
-
164
- // Start two concurrent refreshes
165
- const p1 = store.refreshFromApi(fetcher);
166
- const p2 = store.refreshFromApi(fetcher);
167
-
168
- // Only one fetcher call
169
- expect(fetcher).toHaveBeenCalledOnce();
170
- expect(store.isRefreshing).toBe(true);
171
-
172
- // Resolve the fetch
173
- resolveFirst(sampleQuotas);
174
- await p1;
175
- await p2;
176
-
177
- expect(store.isRefreshing).toBe(false);
178
- });
179
-
180
- it("handles fetcher returning undefined", async () => {
181
- const store = new QuotaStore();
182
- const fetcher = vi.fn().mockResolvedValue(undefined);
183
-
184
- const result = await store.refreshFromApi(fetcher);
185
-
186
- expect(result).toBeUndefined();
187
- expect(store.getSnapshot()).toBeUndefined();
188
- });
189
- });
190
-
191
- describe("clear", () => {
192
- it("resets all state", () => {
193
- const store = new QuotaStore();
194
- store.ingest(sampleQuotas, "api");
195
-
196
- store.clear();
197
-
198
- expect(store.getSnapshot()).toBeUndefined();
199
- });
200
-
201
- it("resets header throttle after clear", () => {
202
- const store = new QuotaStore();
203
- store.ingest(sampleQuotas, "header");
204
- expect(store.ingest(sampleQuotas, "header")).toBe(false);
205
-
206
- store.clear();
207
-
208
- expect(store.ingest(sampleQuotas, "header")).toBe(true);
209
- });
210
- });
211
- });
@@ -1,393 +0,0 @@
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
- });
@@ -1,295 +0,0 @@
1
- import {
2
- afterEach,
3
- assert,
4
- beforeEach,
5
- describe,
6
- expect,
7
- it,
8
- vi,
9
- } from "vitest";
10
- import {
11
- assessWindow,
12
- getPacePercent,
13
- getProjectedPercent,
14
- getSeverityColor,
15
- parseCurrency,
16
- type QuotaWindow,
17
- safePercent,
18
- } from "./quotas-severity";
19
-
20
- // Helper to create a QuotaWindow with sensible defaults
21
- function makeWindow(
22
- overrides: Partial<QuotaWindow> & Pick<QuotaWindow, "usedPercent">,
23
- ): QuotaWindow {
24
- const windowSeconds = overrides.windowSeconds ?? 3600;
25
- // resetsAt defaults to 30 minutes from now (50% through a 1h window)
26
- const resetsAt =
27
- overrides.resetsAt ?? new Date(Date.now() + windowSeconds * 500);
28
- return {
29
- label: "Test Window",
30
- resetsAt,
31
- windowSeconds,
32
- usedValue: 0,
33
- limitValue: 100,
34
- ...overrides,
35
- };
36
- }
37
-
38
- describe("safePercent", () => {
39
- it("returns 0 for zero/invalid limit", () => {
40
- expect(safePercent(50, 0)).toBe(0);
41
- expect(safePercent(50, -1)).toBe(0);
42
- expect(safePercent(50, NaN)).toBe(0);
43
- expect(safePercent(NaN, 100)).toBe(0);
44
- });
45
-
46
- it("computes correct percentage", () => {
47
- expect(safePercent(50, 100)).toBe(50);
48
- expect(safePercent(75, 100)).toBe(75);
49
- expect(safePercent(1, 3)).toBeCloseTo(33.33);
50
- });
51
-
52
- it("clamps to 0-100", () => {
53
- expect(safePercent(150, 100)).toBe(100);
54
- expect(safePercent(-10, 100)).toBe(0);
55
- });
56
- });
57
-
58
- describe("parseCurrency", () => {
59
- it("parses dollar amounts", () => {
60
- expect(parseCurrency("$1,234.56")).toBe(1234.56);
61
- expect(parseCurrency("$10.00")).toBe(10);
62
- });
63
-
64
- it("returns 0 for invalid input", () => {
65
- expect(parseCurrency("")).toBe(0);
66
- expect(parseCurrency("abc")).toBe(0);
67
- });
68
- });
69
-
70
- describe("getPacePercent", () => {
71
- it("returns null for zero window", () => {
72
- const w = makeWindow({ usedPercent: 50, windowSeconds: 0 });
73
- expect(getPacePercent(w)).toBeNull();
74
- });
75
-
76
- it("returns ~50 for a window 50% elapsed", () => {
77
- const w = makeWindow({
78
- usedPercent: 50,
79
- windowSeconds: 3600,
80
- resetsAt: new Date(Date.now() + 1800 * 1000), // 30 min remaining
81
- });
82
- const pace = getPacePercent(w);
83
- assert(pace, "pace should not be null");
84
- expect(pace).toBeCloseTo(50, 0);
85
- });
86
-
87
- it("clamps to 0-100", () => {
88
- const w = makeWindow({
89
- usedPercent: 50,
90
- windowSeconds: 3600,
91
- resetsAt: new Date(Date.now() + 7200 * 1000), // way past
92
- });
93
- expect(getPacePercent(w)).toBe(0);
94
- });
95
- });
96
-
97
- describe("getProjectedPercent", () => {
98
- it("returns usedPercent when no pace", () => {
99
- expect(getProjectedPercent(42, null)).toBe(42);
100
- });
101
-
102
- it("projects based on pace", () => {
103
- // 50% used, 25% through window => projected 200%
104
- expect(getProjectedPercent(50, 25)).toBe(200);
105
- });
106
-
107
- it("uses minimum pace of 5", () => {
108
- // Very low pace should not blow up projection
109
- expect(getProjectedPercent(1, 0)).toBe(20); // 1 / 5 * 100
110
- expect(getProjectedPercent(1, 1)).toBe(20); // clamped to 5
111
- });
112
- });
113
-
114
- describe("assessWindow", () => {
115
- describe("no pace (showPace: false)", () => {
116
- it("returns none for low usage", () => {
117
- const w = makeWindow({ usedPercent: 10, showPace: false });
118
- expect(assessWindow(w).severity).toBe("none");
119
- });
120
-
121
- it("returns warning at 80% projected", () => {
122
- const w = makeWindow({ usedPercent: 85, showPace: false });
123
- expect(assessWindow(w).severity).toBe("warning");
124
- });
125
-
126
- it("returns high at 90% projected", () => {
127
- const w = makeWindow({ usedPercent: 92, showPace: false });
128
- expect(assessWindow(w).severity).toBe("high");
129
- });
130
-
131
- it("returns critical at 100% projected", () => {
132
- const w = makeWindow({ usedPercent: 100, showPace: false });
133
- expect(assessWindow(w).severity).toBe("critical");
134
- });
135
-
136
- it("returns critical for limited window regardless of usage", () => {
137
- const w = makeWindow({ usedPercent: 5, showPace: false, limited: true });
138
- expect(assessWindow(w).severity).toBe("critical");
139
- });
140
- });
141
-
142
- describe("with pace (showPace: true)", () => {
143
- beforeEach(() => {
144
- vi.useFakeTimers();
145
- vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
146
- });
147
-
148
- afterEach(() => {
149
- vi.useRealTimers();
150
- });
151
-
152
- it("returns none when usage is low and pace is normal", () => {
153
- const w = makeWindow({
154
- usedPercent: 20,
155
- showPace: true,
156
- paceScale: 1,
157
- windowSeconds: 3600,
158
- resetsAt: new Date(Date.now() + 1800 * 1000), // 50% through
159
- });
160
- expect(assessWindow(w).severity).toBe("none");
161
- });
162
-
163
- it("returns warning when projected exceeds warn threshold", () => {
164
- // 50% used, 50% through => projected 100%, well above warn at 50% progress (190)
165
- // But usedFloor at 50% progress is 20.5, so 50% > 20.5 => passes floor check
166
- const w = makeWindow({
167
- usedPercent: 50,
168
- showPace: true,
169
- paceScale: 1,
170
- windowSeconds: 3600,
171
- resetsAt: new Date(Date.now() + 1800 * 1000),
172
- });
173
- const result = assessWindow(w);
174
- // projected = 50 / 50 * 100 = 100
175
- // At 50% progress: warn = 260 - (260-120)*0.5 = 190, high = 232.5, critical = 285
176
- // 100 < 190 => none actually. Let me pick better numbers.
177
- expect(result.severity).toBe("none");
178
- });
179
-
180
- it("returns warning when projected exceeds dynamic warn threshold", () => {
181
- // 95% used, 50% through => projected 190%
182
- // At 50% progress: warn = 190, so 190 >= 190 => warning
183
- // usedFloor at 50% = 20.5, 95 >= 20.5 => passes
184
- const w = makeWindow({
185
- usedPercent: 95,
186
- showPace: true,
187
- paceScale: 1,
188
- windowSeconds: 3600,
189
- resetsAt: new Date(Date.now() + 1800 * 1000),
190
- });
191
- const result = assessWindow(w);
192
- expect(result.severity).toBe("warning");
193
- });
194
-
195
- it("uses paceScale to normalize pace", () => {
196
- // Weekly window with daily pace: paceScale = 1/7
197
- // At 50% through the day (12h), raw pace = 50%, scaled = 50/7 ≈ 7.14%
198
- // So progress ≈ 0.0714, projected = 95 / max(5, 7.14) * 100 ≈ 1330%
199
- const w = makeWindow({
200
- usedPercent: 95,
201
- showPace: true,
202
- paceScale: 1 / 7,
203
- windowSeconds: 7 * 24 * 3600, // 1 week
204
- resetsAt: new Date(Date.now() + 6 * 24 * 3600 * 1000), // 6 days remaining
205
- });
206
- const result = assessWindow(w);
207
- // With paceScale applied, projected should be much higher
208
- assert(result.pacePercent, "pacePercent should not be null");
209
- expect(result.pacePercent).toBeLessThan(15); // scaled down
210
- expect(result.projectedPercent).toBeGreaterThan(500);
211
- expect(result.severity).toBe("critical");
212
- });
213
-
214
- it("does not use pace when showPace is false", () => {
215
- // Same timestamps but showPace: false
216
- const w = makeWindow({
217
- usedPercent: 50,
218
- showPace: false,
219
- windowSeconds: 5 * 3600,
220
- resetsAt: new Date(Date.now() + 2.5 * 3600 * 1000),
221
- });
222
- const result = assessWindow(w);
223
- expect(result.pacePercent).toBeNull();
224
- expect(result.progress).toBeNull();
225
- // Static thresholds: 50% < 80 => none
226
- expect(result.severity).toBe("none");
227
- });
228
-
229
- it("suppresses warning when usage is below usedFloor", () => {
230
- // Early window: raw pace ~10%, with paceScale=1 => progress=0.1
231
- // usedFloor at 10% progress = 33 - (33-8)*0.1 = 33 - 2.5 = 30.5
232
- // If used = 15% (< 30.5), projected might exceed warn but floor blocks it
233
- const w = makeWindow({
234
- usedPercent: 15,
235
- showPace: true,
236
- paceScale: 1,
237
- windowSeconds: 3600,
238
- // 10% through: 54 min remaining
239
- resetsAt: new Date(Date.now() + 54 * 60 * 1000),
240
- });
241
- const result = assessWindow(w);
242
- // projected = 15 / 10 * 100 = 150, which exceeds warn at 10% progress (246)
243
- // But usedFloor = 30.5, and 15 < 30.5 => suppressed
244
- expect(result.severity).toBe("none");
245
- });
246
-
247
- it("allows warning when usage exceeds usedFloor", () => {
248
- // Same timing but higher usage
249
- const w = makeWindow({
250
- usedPercent: 50,
251
- showPace: true,
252
- paceScale: 1,
253
- windowSeconds: 3600,
254
- resetsAt: new Date(Date.now() + 54 * 60 * 1000),
255
- });
256
- const result = assessWindow(w);
257
- // projected = 50 / 10 * 100 = 500
258
- // warn at 10% progress = 246, high = 282.5, critical = 357
259
- // 500 >= 357 => critical, usedFloor = 30.5, 50 >= 30.5 => passes
260
- expect(result.severity).toBe("critical");
261
- });
262
- });
263
-
264
- describe("limited flag", () => {
265
- it("overrides severity to critical even with low usage", () => {
266
- const w = makeWindow({
267
- usedPercent: 5,
268
- showPace: false,
269
- limited: true,
270
- });
271
- expect(assessWindow(w).severity).toBe("critical");
272
- });
273
-
274
- it("overrides severity to critical even with pace showing none", () => {
275
- const w = makeWindow({
276
- usedPercent: 5,
277
- showPace: true,
278
- paceScale: 1,
279
- limited: true,
280
- windowSeconds: 3600,
281
- resetsAt: new Date(Date.now() + 54 * 60 * 1000),
282
- });
283
- expect(assessWindow(w).severity).toBe("critical");
284
- });
285
- });
286
- });
287
-
288
- describe("getSeverityColor", () => {
289
- it("maps severity levels to display colors", () => {
290
- expect(getSeverityColor("none")).toBe("success");
291
- expect(getSeverityColor("warning")).toBe("warning");
292
- expect(getSeverityColor("high")).toBe("error");
293
- expect(getSeverityColor("critical")).toBe("error");
294
- });
295
- });