@aliou/pi-synthetic 0.15.0 → 0.17.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 +8 -8
- package/src/config.ts +2 -2
- package/src/extensions/command-quotas/command.ts +1 -1
- package/src/extensions/command-quotas/components/quotas-display.ts +4 -4
- package/src/extensions/command-quotas/index.ts +1 -1
- package/src/extensions/provider/index.ts +70 -1
- package/src/extensions/provider/models.ts +1 -1
- package/src/extensions/quota-warnings/index.ts +79 -24
- package/src/extensions/sub-bar-integration/index.ts +33 -83
- package/src/extensions/usage-status/index.ts +69 -144
- package/src/extensions/web-search/index.ts +1 -1
- package/src/extensions/web-search/tool.ts +3 -3
- package/src/lib/env.ts +1 -1
- package/src/services/quota-store.test.ts +211 -0
- package/src/services/quota-store.ts +112 -0
- package/src/services/quota-warnings.test.ts +393 -0
- package/src/services/quota-warnings.ts +149 -0
- package/src/types/quotas.ts +47 -0
- package/src/extensions/quota-warnings/notifier.test.ts +0 -280
- package/src/extensions/quota-warnings/notifier.ts +0 -200
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
|
-
} from "@
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import {
|
|
6
6
|
configLoader,
|
|
7
7
|
SYNTHETIC_CONFIG_UPDATED_EVENT,
|
|
@@ -9,9 +9,15 @@ import {
|
|
|
9
9
|
SYNTHETIC_EXTENSIONS_REQUEST_EVENT,
|
|
10
10
|
type SyntheticConfigUpdatedPayload,
|
|
11
11
|
} from "../../config";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
import {
|
|
13
|
+
type QuotasResponse,
|
|
14
|
+
SYNTHETIC_QUOTAS_READ_EVENT,
|
|
15
|
+
SYNTHETIC_QUOTAS_REQUEST_EVENT,
|
|
16
|
+
type SyntheticQuotasReadPayload,
|
|
17
|
+
type SyntheticQuotasRequestPayload,
|
|
18
|
+
type SyntheticQuotasSnapshotPayload,
|
|
19
|
+
} from "../../types/quotas";
|
|
20
|
+
import { formatResetTime } from "../../utils/quotas";
|
|
15
21
|
import {
|
|
16
22
|
assessWindow,
|
|
17
23
|
getSeverityColor,
|
|
@@ -20,7 +26,6 @@ import {
|
|
|
20
26
|
} from "../../utils/quotas-severity";
|
|
21
27
|
|
|
22
28
|
const EXTENSION_ID = "synthetic-usage";
|
|
23
|
-
const REFRESH_INTERVAL_MS = 60_000;
|
|
24
29
|
|
|
25
30
|
type WindowStatus = {
|
|
26
31
|
label: string;
|
|
@@ -73,175 +78,95 @@ function formatStatus(ctx: ExtensionContext, windows: WindowStatus[]): string {
|
|
|
73
78
|
return parts.join(" ");
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
let activeContext: ExtensionContext | undefined;
|
|
79
|
-
let isRefreshInFlight = false;
|
|
80
|
-
let queuedRefresh = false;
|
|
81
|
-
let lastSnapshot: WindowStatus[] | undefined;
|
|
81
|
+
export default async function (pi: ExtensionAPI) {
|
|
82
|
+
await configLoader.load();
|
|
82
83
|
|
|
83
|
-
|
|
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
|
-
}
|
|
84
|
+
let enabled = configLoader.getConfig().usageStatus;
|
|
125
85
|
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
86
|
+
function requestQuotas(
|
|
87
|
+
respond: (snapshot: SyntheticQuotasSnapshotPayload | undefined) => void,
|
|
88
|
+
): void {
|
|
89
|
+
pi.events.emit(SYNTHETIC_QUOTAS_REQUEST_EVENT, {
|
|
90
|
+
respond,
|
|
91
|
+
} satisfies SyntheticQuotasRequestPayload);
|
|
129
92
|
}
|
|
130
93
|
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
refreshTimer.unref?.();
|
|
94
|
+
function readQuotas(
|
|
95
|
+
respond: (snapshot: SyntheticQuotasSnapshotPayload | undefined) => void,
|
|
96
|
+
): void {
|
|
97
|
+
pi.events.emit(SYNTHETIC_QUOTAS_READ_EVENT, {
|
|
98
|
+
respond,
|
|
99
|
+
} satisfies SyntheticQuotasReadPayload);
|
|
138
100
|
}
|
|
139
101
|
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
102
|
+
function renderSnapshot(
|
|
103
|
+
ctx: ExtensionContext,
|
|
104
|
+
snapshot: SyntheticQuotasSnapshotPayload | undefined,
|
|
105
|
+
): void {
|
|
106
|
+
if (!ctx.hasUI) return;
|
|
107
|
+
if (!snapshot) {
|
|
108
|
+
ctx.ui.setStatus(
|
|
109
|
+
EXTENSION_ID,
|
|
110
|
+
ctx.ui.theme.fg("dim", "loading usage..."),
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
144
113
|
}
|
|
145
|
-
ctx?.ui.setStatus(EXTENSION_ID, undefined);
|
|
146
|
-
}
|
|
147
114
|
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
const apiKey = await getSyntheticApiKey(
|
|
151
|
-
ctx.modelRegistry.authStorage,
|
|
152
|
-
).catch(() => undefined);
|
|
153
|
-
if (!apiKey) {
|
|
115
|
+
const windows = parseSnapshot(snapshot.quotas);
|
|
116
|
+
if (windows.length === 0) {
|
|
154
117
|
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
155
118
|
return;
|
|
156
119
|
}
|
|
157
|
-
ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("dim", "loading usage..."));
|
|
158
|
-
}
|
|
159
120
|
|
|
160
|
-
|
|
161
|
-
if (!ctx.hasUI || !lastSnapshot) return false;
|
|
162
|
-
ctx.ui.setStatus(EXTENSION_ID, formatStatus(ctx, lastSnapshot));
|
|
163
|
-
return true;
|
|
121
|
+
ctx.ui.setStatus(EXTENSION_ID, formatStatus(ctx, windows));
|
|
164
122
|
}
|
|
165
123
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
setLoadingStatus,
|
|
171
|
-
renderFromLastSnapshot,
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export default async function (pi: ExtensionAPI) {
|
|
176
|
-
await configLoader.load();
|
|
124
|
+
function clearStatus(ctx: ExtensionContext): void {
|
|
125
|
+
if (!ctx.hasUI) return;
|
|
126
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
127
|
+
}
|
|
177
128
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
129
|
+
function renderFromStoreOrRefresh(ctx: ExtensionContext): void {
|
|
130
|
+
if (!enabled || ctx.model?.provider !== "synthetic") {
|
|
131
|
+
clearStatus(ctx);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
readQuotas((snapshot) => {
|
|
135
|
+
if (snapshot) {
|
|
136
|
+
renderSnapshot(ctx, snapshot);
|
|
137
|
+
} else {
|
|
138
|
+
renderSnapshot(ctx, undefined); // show loading
|
|
139
|
+
requestQuotas((refreshed) => renderSnapshot(ctx, refreshed));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
182
143
|
|
|
183
144
|
pi.events.on(SYNTHETIC_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
184
145
|
enabled = (data as SyntheticConfigUpdatedPayload).config.usageStatus;
|
|
146
|
+
});
|
|
185
147
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (currentContext && currentProvider === "synthetic") {
|
|
192
|
-
refresher.startAutoRefresh();
|
|
193
|
-
void refresher.refreshFor(currentContext);
|
|
194
|
-
}
|
|
148
|
+
pi.on("session_start", (_event, ctx) => {
|
|
149
|
+
renderFromStoreOrRefresh(ctx);
|
|
195
150
|
});
|
|
196
151
|
|
|
197
|
-
pi.on("
|
|
198
|
-
|
|
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);
|
|
152
|
+
pi.on("model_select", (_event, ctx) => {
|
|
153
|
+
renderFromStoreOrRefresh(ctx);
|
|
204
154
|
});
|
|
205
155
|
|
|
206
|
-
pi.on("
|
|
207
|
-
|
|
208
|
-
currentProvider = ctx.model?.provider;
|
|
209
|
-
if (!enabled || ctx.model?.provider !== "synthetic") return;
|
|
210
|
-
void refresher.refreshFor(ctx);
|
|
156
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
157
|
+
renderFromStoreOrRefresh(ctx);
|
|
211
158
|
});
|
|
212
159
|
|
|
213
|
-
pi.on("
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
event.reason === "new" ||
|
|
217
|
-
event.reason === "resume" ||
|
|
218
|
-
event.reason === "fork"
|
|
219
|
-
) {
|
|
220
|
-
currentContext = ctx;
|
|
221
|
-
currentProvider = ctx.model?.provider;
|
|
222
|
-
if (enabled && ctx.model?.provider === "synthetic") {
|
|
223
|
-
void refresher.refreshFor(ctx);
|
|
224
|
-
} else {
|
|
225
|
-
refresher.stopAutoRefresh(ctx);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
160
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
161
|
+
renderFromStoreOrRefresh(ctx);
|
|
228
162
|
});
|
|
229
163
|
|
|
230
|
-
pi.on("
|
|
231
|
-
|
|
232
|
-
currentProvider = ctx.model?.provider;
|
|
233
|
-
if (enabled && ctx.model?.provider === "synthetic") {
|
|
234
|
-
refresher.startAutoRefresh();
|
|
235
|
-
void refresher.refreshFor(ctx);
|
|
236
|
-
} else {
|
|
237
|
-
refresher.stopAutoRefresh(ctx);
|
|
238
|
-
}
|
|
164
|
+
pi.on("session_before_switch", (_event, ctx) => {
|
|
165
|
+
clearStatus(ctx);
|
|
239
166
|
});
|
|
240
167
|
|
|
241
168
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
242
|
-
|
|
243
|
-
currentProvider = undefined;
|
|
244
|
-
refresher.stopAutoRefresh(ctx);
|
|
169
|
+
clearStatus(ctx);
|
|
245
170
|
});
|
|
246
171
|
|
|
247
172
|
pi.events.on(SYNTHETIC_EXTENSIONS_REQUEST_EVENT, () => {
|
|
@@ -5,9 +5,9 @@ import type {
|
|
|
5
5
|
ExtensionContext,
|
|
6
6
|
Theme,
|
|
7
7
|
ToolRenderResultOptions,
|
|
8
|
-
} from "@
|
|
9
|
-
import { getMarkdownTheme, keyHint } from "@
|
|
10
|
-
import { Container, Markdown, Text } from "@
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Container, Markdown, Text } from "@earendil-works/pi-tui";
|
|
11
11
|
import { type Static, Type } from "typebox";
|
|
12
12
|
import { configLoader } from "../../config";
|
|
13
13
|
import { getSyntheticApiKey } from "../../lib/env";
|
package/src/lib/env.ts
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { QuotaSource, QuotasResponse } from "../types/quotas";
|
|
2
|
+
|
|
3
|
+
export interface QuotaSnapshot {
|
|
4
|
+
quotas: QuotasResponse;
|
|
5
|
+
source: QuotaSource;
|
|
6
|
+
updatedAt: number; // epoch ms
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type Listener = (snapshot: QuotaSnapshot) => void;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pi-agnostic in-memory quota store.
|
|
13
|
+
*
|
|
14
|
+
* Ingests quota data from headers or API, handles throttling of
|
|
15
|
+
* header-sourced updates, and notifies subscribers on change.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* const store = new QuotaStore();
|
|
19
|
+
* store.subscribe((snap) => { ... });
|
|
20
|
+
* store.ingest(quotas, "header");
|
|
21
|
+
* store.ingest(quotas, "api");
|
|
22
|
+
*/
|
|
23
|
+
export class QuotaStore {
|
|
24
|
+
private snapshot: QuotaSnapshot | undefined;
|
|
25
|
+
private listeners = new Set<Listener>();
|
|
26
|
+
private lastHeaderIngestAt = 0;
|
|
27
|
+
private inFlightRefresh: Promise<QuotaSnapshot | undefined> | undefined;
|
|
28
|
+
private inFlightId = 0;
|
|
29
|
+
|
|
30
|
+
/** Throttle header ingestion: skip if last header ingest was within this window. */
|
|
31
|
+
headerThrottleMs = 5_000;
|
|
32
|
+
|
|
33
|
+
/** Current snapshot (may be undefined if no data has been ingested yet). */
|
|
34
|
+
getSnapshot(): QuotaSnapshot | undefined {
|
|
35
|
+
return this.snapshot;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Subscribe to snapshot updates. Returns unsubscribe function. */
|
|
39
|
+
subscribe(listener: Listener): () => void {
|
|
40
|
+
this.listeners.add(listener);
|
|
41
|
+
return () => {
|
|
42
|
+
this.listeners.delete(listener);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private emit(snapshot: QuotaSnapshot): void {
|
|
47
|
+
for (const l of this.listeners) l(snapshot);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ingest quota data. Returns true if the snapshot was updated
|
|
52
|
+
* (i.e. not throttled).
|
|
53
|
+
*
|
|
54
|
+
* Header-sourced data is throttled: if the last header ingest was
|
|
55
|
+
* within `headerThrottleMs`, it is silently dropped.
|
|
56
|
+
* API-sourced data always goes through.
|
|
57
|
+
*/
|
|
58
|
+
ingest(quotas: QuotasResponse, source: QuotaSource): boolean {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
|
|
61
|
+
if (source === "header") {
|
|
62
|
+
if (now - this.lastHeaderIngestAt < this.headerThrottleMs) return false;
|
|
63
|
+
this.lastHeaderIngestAt = now;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.snapshot = { quotas, source, updatedAt: now };
|
|
67
|
+
this.emit(this.snapshot);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Refresh quotas by calling the provided fetcher.
|
|
73
|
+
* Deduplicates concurrent calls — only one fetch runs at a time.
|
|
74
|
+
*/
|
|
75
|
+
async refreshFromApi(
|
|
76
|
+
fetcher: () => Promise<QuotasResponse | undefined>,
|
|
77
|
+
): Promise<QuotaSnapshot | undefined> {
|
|
78
|
+
if (this.inFlightRefresh) return this.inFlightRefresh;
|
|
79
|
+
|
|
80
|
+
this.inFlightId++;
|
|
81
|
+
const id = this.inFlightId;
|
|
82
|
+
|
|
83
|
+
this.inFlightRefresh = (async () => {
|
|
84
|
+
try {
|
|
85
|
+
const quotas = await fetcher();
|
|
86
|
+
if (quotas && id === this.inFlightId) {
|
|
87
|
+
this.ingest(quotas, "api");
|
|
88
|
+
}
|
|
89
|
+
return this.snapshot;
|
|
90
|
+
} finally {
|
|
91
|
+
if (id === this.inFlightId) {
|
|
92
|
+
this.inFlightRefresh = undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
|
|
97
|
+
return this.inFlightRefresh;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Returns true if a refresh is currently in flight. */
|
|
101
|
+
get isRefreshing(): boolean {
|
|
102
|
+
return !!this.inFlightRefresh;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Clear all state. Call on session shutdown or reset. */
|
|
106
|
+
clear(): void {
|
|
107
|
+
this.inFlightId++; // Invalidates in-flight refresh
|
|
108
|
+
this.snapshot = undefined;
|
|
109
|
+
this.lastHeaderIngestAt = 0;
|
|
110
|
+
this.inFlightRefresh = undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|