oira666_pi-limits-wait 0.2.4

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -0
  3. package/package.json +49 -0
  4. package/src/index.ts +483 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leo Henon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # Pi Limits Wait
2
+
3
+ [![npm](https://img.shields.io/npm/v/oira666_pi-limits-wait?style=flat-square&logo=npm&logoColor=white&label=npm&color=7c3aed)](https://www.npmjs.com/package/oira666_pi-limits-wait) [![node](https://img.shields.io/badge/node-%3E%3D18-7c3aed?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org)
4
+
5
+ Pi extension that keeps requests alive when a provider rate-limits you or returns `server_is_overloaded`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:oira666_pi-limits-wait
11
+ ```
12
+
13
+ Restart Pi or run `/reload` after installing.
14
+
15
+ ## What it does
16
+
17
+ - Works with all Pi model providers/APIs that use `streamSimple`.
18
+ - On rate-limit errors (`429`, `rate_limit`, `too many requests`, quota/reset messages), waits and retries in a loop.
19
+ - Uses provider retry timing when available (`retry-after`, `retry-after-ms`, `retry in ...`, reset messages).
20
+ - If no retry timing is available for a rate limit, waits 30 minutes before retrying.
21
+ - On `server_is_overloaded`, waits 5 minutes, then retries. If the provider is still overloaded after Pi's normal retries, it waits another 5 minutes and repeats.
22
+ - Shows a countdown in the Pi status/working line.
23
+ - Press Enter during the countdown to skip the wait and retry immediately.
24
+
25
+ ## License
26
+
27
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "oira666_pi-limits-wait",
3
+ "version": "0.2.4",
4
+ "description": "Extension for PI that allows to automatically wait for limits to reset and retry the request",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "files": [
8
+ "src",
9
+ "!src/__tests__",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/gee666/pi-limits-wait.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/gee666/pi-limits-wait/issues"
22
+ },
23
+ "homepage": "https://github.com/gee666/pi-limits-wait#readme",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "check": "tsc -p tsconfig.json --noEmit",
29
+ "test": "tsx src/__tests__/test-extension.ts",
30
+ "pack:check": "npm pack --dry-run",
31
+ "prepublishOnly": "npm run check && npm test && npm run pack:check"
32
+ },
33
+ "pi": {
34
+ "extensions": [
35
+ "./src/index.ts"
36
+ ]
37
+ },
38
+ "dependencies": {},
39
+ "peerDependencies": {
40
+ "@mariozechner/pi-ai": "*",
41
+ "@mariozechner/pi-coding-agent": "*"
42
+ },
43
+ "devDependencies": {
44
+ "@mariozechner/pi-ai": "^0.66.1",
45
+ "@mariozechner/pi-coding-agent": "^0.66.1",
46
+ "tsx": "^4.20.6",
47
+ "typescript": "^5.9.3"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,483 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import {
3
+ createAssistantMessageEventStream,
4
+ getApiProviders,
5
+ type Api,
6
+ type AssistantMessage,
7
+ type AssistantMessageEvent,
8
+ type AssistantMessageEventStream,
9
+ type Context,
10
+ type Model,
11
+ type SimpleStreamOptions,
12
+ } from "@mariozechner/pi-ai";
13
+
14
+ // ─── Constants ────────────────────────────────────────────────────────────────
15
+
16
+ const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
17
+ const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
18
+
19
+ /** Key used with ctx.ui.setStatus() for the countdown line. */
20
+ const STATUS_KEY = "limits-wait";
21
+
22
+ type RetryReason = "rate-limit" | "overloaded";
23
+
24
+ type RetryableError = {
25
+ reason: RetryReason;
26
+ waitMs: number;
27
+ };
28
+
29
+ // ─── Shared UI context ────────────────────────────────────────────────────────
30
+
31
+ let sharedCtx: ExtensionContext | undefined;
32
+ let restoreFetch: (() => void) | undefined;
33
+ let ambientStatusCleanup: (() => void) | undefined;
34
+ let activeProviderRequests = 0;
35
+ const wrappedApis = new Set<Api>();
36
+
37
+ // ─── Retryable error detection ────────────────────────────────────────────────
38
+
39
+ export function isRateLimitError(msg: string): boolean {
40
+ const lower = msg.toLowerCase();
41
+ return (
42
+ /(?:^|\D)429(?:\D|$)/.test(msg) ||
43
+ lower.includes("rate_limit") ||
44
+ /rate\s*limit/.test(lower) ||
45
+ lower.includes("too many requests") ||
46
+ lower.includes("quota exceeded") ||
47
+ lower.includes("quota will reset") ||
48
+ lower.includes("retry delay") ||
49
+ lower.includes("retry-after")
50
+ );
51
+ }
52
+
53
+ export function isServerOverloadedError(msg: string): boolean {
54
+ const lower = msg.toLowerCase();
55
+ return (
56
+ lower.includes("server_is_overloaded") ||
57
+ lower.includes("server is overloaded") ||
58
+ lower.includes("overloaded_error")
59
+ );
60
+ }
61
+
62
+ export function parseRetryDelayMs(msg: string): number | undefined {
63
+ const retryAfter = msg.match(/retry-after(?:-ms)?[^0-9]*(\d+(?:\.\d+)?)/i);
64
+ if (retryAfter?.[1]) {
65
+ const value = Number(retryAfter[1]);
66
+ if (Number.isFinite(value) && value > 0) {
67
+ return msg.toLowerCase().includes("retry-after-ms") ? value : value * 1_000;
68
+ }
69
+ }
70
+
71
+ const requested = msg.match(/requested\s+(\d+(?:\.\d+)?)s\s+retry delay/i);
72
+ if (requested?.[1]) {
73
+ const value = Number(requested[1]);
74
+ if (Number.isFinite(value) && value > 0) return value * 1_000;
75
+ }
76
+
77
+ const retryIn = msg.match(/retry\s+in\s+(\d+(?:\.\d+)?)(ms|s|m|h)/i);
78
+ if (retryIn?.[1] && retryIn[2]) {
79
+ const value = Number(retryIn[1]);
80
+ if (Number.isFinite(value) && value > 0) {
81
+ const unit = retryIn[2].toLowerCase();
82
+ if (unit === "ms") return value;
83
+ if (unit === "s") return value * 1_000;
84
+ if (unit === "m") return value * 60_000;
85
+ if (unit === "h") return value * 3_600_000;
86
+ }
87
+ }
88
+
89
+ const resetAfter = msg.match(/reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i);
90
+ if (resetAfter) {
91
+ const hours = resetAfter[1] ? Number(resetAfter[1]) : 0;
92
+ const mins = resetAfter[2] ? Number(resetAfter[2]) : 0;
93
+ const secs = Number(resetAfter[3]);
94
+ if ([hours, mins, secs].every(Number.isFinite)) {
95
+ return ((hours * 60 + mins) * 60 + secs) * 1_000;
96
+ }
97
+ }
98
+
99
+ return undefined;
100
+ }
101
+
102
+ export function rateLimitWaitMs(msg: string): number {
103
+ return parseRetryDelayMs(msg) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
104
+ }
105
+
106
+ export function retryAfterHeaderMs(headers: Headers): number | undefined {
107
+ const retryAfterMs = headers.get("retry-after-ms");
108
+ if (retryAfterMs) {
109
+ const value = Number(retryAfterMs);
110
+ if (Number.isFinite(value) && value > 0) return value;
111
+ }
112
+
113
+ const retryAfter = headers.get("retry-after");
114
+ if (!retryAfter) return undefined;
115
+
116
+ const seconds = Number(retryAfter);
117
+ if (Number.isFinite(seconds) && seconds > 0) return seconds * 1_000;
118
+
119
+ const dateMs = Date.parse(retryAfter);
120
+ if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
121
+
122
+ return undefined;
123
+ }
124
+
125
+ export function getRetryableError(msg: string): RetryableError | undefined {
126
+ if (isServerOverloadedError(msg)) {
127
+ return { reason: "overloaded", waitMs: DEFAULT_OVERLOADED_WAIT_MS };
128
+ }
129
+ if (isRateLimitError(msg)) {
130
+ return { reason: "rate-limit", waitMs: rateLimitWaitMs(msg) };
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ // ─── Countdown UI ─────────────────────────────────────────────────────────────
136
+
137
+ function reasonLabel(reason: RetryReason): string {
138
+ return reason === "overloaded" ? "Server overloaded" : "Rate limited";
139
+ }
140
+
141
+ function statusText(reason: RetryReason, deadline: number, allowSkip: boolean): string {
142
+ const remaining = Math.max(0, deadline - Date.now());
143
+ const totalSecs = Math.ceil(remaining / 1_000);
144
+ const mins = Math.floor(totalSecs / 60);
145
+ const secs = (totalSecs % 60).toString().padStart(2, "0");
146
+ return (
147
+ `⏳ ${reasonLabel(reason)} — next retry in ${mins}m ${secs}s` +
148
+ (allowSkip ? " (Enter to retry now)" : "")
149
+ );
150
+ }
151
+
152
+ function showAmbientRetryStatus(reason: RetryReason, waitMs: number): void {
153
+ const ctx = sharedCtx;
154
+ if (!ctx) return;
155
+
156
+ ambientStatusCleanup?.();
157
+
158
+ if (waitMs <= 0) return;
159
+
160
+ const deadline = Date.now() + waitMs;
161
+ const tick = () => {
162
+ if (Date.now() >= deadline) {
163
+ ambientStatusCleanup?.();
164
+ return;
165
+ }
166
+ const text = statusText(reason, deadline, false);
167
+ ctx.ui.setStatus(STATUS_KEY, text);
168
+ ctx.ui.setWorkingMessage(text);
169
+ };
170
+
171
+ tick();
172
+ const ticker = setInterval(tick, 1_000);
173
+ ambientStatusCleanup = () => {
174
+ clearInterval(ticker);
175
+ ambientStatusCleanup = undefined;
176
+ ctx.ui.setStatus(STATUS_KEY, undefined);
177
+ ctx.ui.setWorkingMessage();
178
+ };
179
+ }
180
+
181
+ function clearAmbientRetryStatus(): void {
182
+ ambientStatusCleanup?.();
183
+ }
184
+
185
+ export function waitForRetry(
186
+ reason: RetryReason,
187
+ waitMs: number,
188
+ signal?: AbortSignal,
189
+ ): Promise<"waited" | "skipped" | "aborted"> {
190
+ if (waitMs <= 0) return Promise.resolve(signal?.aborted ? "aborted" : "waited");
191
+
192
+ return new Promise((resolve) => {
193
+ if (signal?.aborted) { resolve("aborted"); return; }
194
+
195
+ const ctx = sharedCtx;
196
+ const deadline = Date.now() + waitMs;
197
+ let done = false;
198
+
199
+ // Intercept Enter via Pi's terminal input hook — consumes the keypress
200
+ // so Pi's TUI never sees it and doesn't submit an empty message.
201
+ let unsubInput: (() => void) | undefined;
202
+ try {
203
+ unsubInput = ctx?.ui.onTerminalInput((data) => {
204
+ if (done) return undefined;
205
+
206
+ if (data === "\r" || data === "\n") {
207
+ cleanup();
208
+ resolve("skipped");
209
+ return { consume: true };
210
+ }
211
+
212
+ if (data === "\x1b") {
213
+ cleanup();
214
+ resolve("aborted");
215
+ return { consume: true };
216
+ }
217
+
218
+ return undefined;
219
+ });
220
+ } catch { /* UI unavailable */ }
221
+
222
+ const onAbort = () => { if (!done) { cleanup(); resolve("aborted"); } };
223
+ signal?.addEventListener("abort", onAbort);
224
+
225
+ const tick = () => {
226
+ const text = statusText(reason, deadline, Boolean(unsubInput));
227
+ ctx?.ui.setStatus(STATUS_KEY, text);
228
+ ctx?.ui.setWorkingMessage(text);
229
+ };
230
+
231
+ tick();
232
+ const ticker = setInterval(() => {
233
+ if (Date.now() >= deadline) { cleanup(); resolve("waited"); return; }
234
+ tick();
235
+ }, 1_000);
236
+
237
+ function cleanup() {
238
+ done = true;
239
+ clearInterval(ticker);
240
+ signal?.removeEventListener("abort", onAbort);
241
+ unsubInput?.();
242
+ ctx?.ui.setStatus(STATUS_KEY, undefined);
243
+ ctx?.ui.setWorkingMessage(); // restore default "Working..."
244
+ clearAmbientRetryStatus();
245
+ }
246
+ });
247
+ }
248
+
249
+ export function waitForRateLimit(
250
+ waitMs: number,
251
+ signal?: AbortSignal,
252
+ ): Promise<"waited" | "skipped" | "aborted"> {
253
+ return waitForRetry("rate-limit", waitMs, signal);
254
+ }
255
+
256
+ // ─── Early 429 observer ──────────────────────────────────────────────────────
257
+
258
+ function installFetchRateLimitObserver(): void {
259
+ if (restoreFetch || typeof globalThis.fetch !== "function") return;
260
+
261
+ const originalFetch = globalThis.fetch.bind(globalThis);
262
+ globalThis.fetch = (async (...args: Parameters<typeof fetch>) => {
263
+ const response = await originalFetch(...args);
264
+
265
+ if (activeProviderRequests > 0) {
266
+ if (response.status === 429) {
267
+ const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
268
+ showAmbientRetryStatus("rate-limit", waitMs);
269
+ } else if (ambientStatusCleanup && response.ok) {
270
+ clearAmbientRetryStatus();
271
+ }
272
+ }
273
+
274
+ return response;
275
+ }) as typeof fetch;
276
+
277
+ restoreFetch = () => {
278
+ globalThis.fetch = originalFetch as typeof fetch;
279
+ restoreFetch = undefined;
280
+ };
281
+ }
282
+
283
+ // ─── Retrying streamSimple wrapper ───────────────────────────────────────────
284
+
285
+ type StreamSimpleFn = (
286
+ model: Model<Api>,
287
+ context: Context,
288
+ options?: SimpleStreamOptions,
289
+ ) => AssistantMessageEventStream;
290
+
291
+ function freshMessage(model: Model<Api>): AssistantMessage {
292
+ return {
293
+ role: "assistant",
294
+ content: [],
295
+ api: model.api,
296
+ provider: model.provider,
297
+ model: model.id,
298
+ usage: {
299
+ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
300
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
301
+ },
302
+ stopReason: "stop",
303
+ timestamp: Date.now(),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Wrap a streamSimple with an indefinite retry loop for rate limits and
309
+ * server_is_overloaded errors.
310
+ *
311
+ * Important: we forward the built-in provider's events unchanged once an
312
+ * attempt is known to be non-retryable. That preserves tool-call streaming
313
+ * exactly. Potential retryable attempts are buffered and discarded, so Pi
314
+ * never sees them.
315
+ */
316
+ export function streamWithLimitsRetry(
317
+ delegate: StreamSimpleFn,
318
+ model: Model<Api>,
319
+ context: Context,
320
+ options?: SimpleStreamOptions,
321
+ ): AssistantMessageEventStream {
322
+ const output = createAssistantMessageEventStream();
323
+
324
+ void (async () => {
325
+ let committed = false;
326
+
327
+ const flush = (buffer: AssistantMessageEvent[]) => {
328
+ for (const event of buffer) output.push(event);
329
+ committed = true;
330
+ };
331
+
332
+ while (true) {
333
+ activeProviderRequests++;
334
+ installFetchRateLimitObserver();
335
+ const buffer: AssistantMessageEvent[] = [];
336
+ let retryable: RetryableError | undefined;
337
+
338
+ try {
339
+ try {
340
+ const inner = delegate(model, context, options);
341
+ for await (const event of inner) {
342
+ if (!committed) {
343
+ if (event.type === "error") {
344
+ const errMsg = event.error.errorMessage ?? "";
345
+ retryable = !options?.signal?.aborted ? getRetryableError(errMsg) : undefined;
346
+ if (retryable) break;
347
+
348
+ // Non-retryable error: ensure start came first, then forward error.
349
+ if (buffer.length > 0) {
350
+ flush(buffer);
351
+ } else {
352
+ output.push({ type: "start", partial: freshMessage(model) });
353
+ committed = true;
354
+ }
355
+ output.push(event);
356
+ output.end();
357
+ return;
358
+ }
359
+
360
+ if (event.type === "start") {
361
+ buffer.push(event);
362
+ continue;
363
+ }
364
+
365
+ // First non-start, non-error event means this attempt is real.
366
+ if (buffer.length > 0) {
367
+ flush(buffer);
368
+ } else {
369
+ output.push({ type: "start", partial: freshMessage(model) });
370
+ committed = true;
371
+ }
372
+ output.push(event);
373
+ if (event.type === "done") {
374
+ output.end();
375
+ return;
376
+ }
377
+ continue;
378
+ }
379
+
380
+ // Once committed, forward everything unchanged.
381
+ output.push(event);
382
+ if (event.type === "done" || event.type === "error") {
383
+ output.end();
384
+ return;
385
+ }
386
+ }
387
+ } catch (err) {
388
+ const errMsg = err instanceof Error ? err.message : String(err);
389
+ retryable = !options?.signal?.aborted ? getRetryableError(errMsg) : undefined;
390
+ if (!retryable) {
391
+ if (!committed) {
392
+ // Synthetic start+error so the stream protocol stays valid.
393
+ output.push({ type: "start", partial: freshMessage(model) });
394
+ committed = true;
395
+ }
396
+ const error = freshMessage(model);
397
+ error.stopReason = options?.signal?.aborted ? "aborted" : "error";
398
+ error.errorMessage = errMsg;
399
+ output.push({
400
+ type: "error",
401
+ reason: error.stopReason as "error" | "aborted",
402
+ error,
403
+ });
404
+ output.end();
405
+ return;
406
+ }
407
+ }
408
+ } finally {
409
+ activeProviderRequests--;
410
+ if (activeProviderRequests === 0) {
411
+ clearAmbientRetryStatus();
412
+ restoreFetch?.();
413
+ }
414
+ }
415
+
416
+ if (!retryable) {
417
+ if (!committed) {
418
+ if (buffer.length > 0) {
419
+ flush(buffer);
420
+ } else {
421
+ output.push({ type: "start", partial: freshMessage(model) });
422
+ committed = true;
423
+ }
424
+ }
425
+ const error = freshMessage(model);
426
+ error.stopReason = "error";
427
+ error.errorMessage = "Provider stream ended without a terminal event.";
428
+ output.push({ type: "error", reason: "error", error });
429
+ output.end();
430
+ return;
431
+ }
432
+
433
+ const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
434
+ if (waitResult === "aborted") {
435
+ if (!committed) {
436
+ output.push({ type: "start", partial: freshMessage(model) });
437
+ committed = true;
438
+ }
439
+ const error = freshMessage(model);
440
+ error.stopReason = "aborted";
441
+ error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
442
+ output.push({ type: "error", reason: "aborted", error });
443
+ output.end();
444
+ return;
445
+ }
446
+ // waited/skipped -> retry. For server_is_overloaded this means Pi gets
447
+ // another full provider attempt (including the provider's normal retries).
448
+ }
449
+ })();
450
+
451
+ return output;
452
+ }
453
+
454
+ export const streamWithRateLimitRetry = streamWithLimitsRetry;
455
+
456
+ function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
457
+ if (wrappedApis.has(api)) return;
458
+
459
+ const builtinStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple;
460
+ if (!builtinStreamSimple) return;
461
+
462
+ wrappedApis.add(api);
463
+ pi.registerProvider(`limits-wait-${api}`, {
464
+ api,
465
+ streamSimple: (model, context, options) =>
466
+ streamWithLimitsRetry(builtinStreamSimple, model, context, options),
467
+ });
468
+ }
469
+
470
+ // ─── Extension entry point ────────────────────────────────────────────────────
471
+
472
+ export default function (pi: ExtensionAPI) {
473
+ pi.on("before_agent_start", (_event, ctx) => {
474
+ sharedCtx = ctx;
475
+
476
+ // Some extensions may register providers after this extension loads. Wrap
477
+ // any APIs that exist by the time an agent starts too.
478
+ for (const model of ctx.modelRegistry.getAll()) {
479
+ registerWrappedApi(pi, model.api);
480
+ }
481
+ if (ctx.model) registerWrappedApi(pi, ctx.model.api);
482
+ });
483
+ }