@aliou/pi-synthetic 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }