oira666_pi-limits-wait 0.4.1 → 0.4.2
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 +49 -49
- package/src/index.ts +120 -9
package/package.json
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "oira666_pi-limits-wait",
|
|
3
|
-
"version": "0.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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "oira666_pi-limits-wait",
|
|
3
|
+
"version": "0.4.2",
|
|
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
CHANGED
|
@@ -28,13 +28,14 @@ const PI_IDENTITY_SENTENCE_PATTERN =
|
|
|
28
28
|
|
|
29
29
|
const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
|
|
30
30
|
const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
|
|
31
|
+
const DEFAULT_NON_RETRYABLE_FREEZE_MS = 60 * 60 * 1_000; // 1 hour
|
|
31
32
|
const SETTINGS_KEY = "oira666_pi-limits-wait";
|
|
32
33
|
|
|
33
34
|
/** Key used with ctx.ui.setStatus() for the countdown line. */
|
|
34
35
|
const STATUS_KEY = "limits-wait";
|
|
35
36
|
const MODELS_STATUS_KEY = "limits-wait-models";
|
|
36
37
|
|
|
37
|
-
type RetryReason = "rate-limit" | "overloaded";
|
|
38
|
+
type RetryReason = "rate-limit" | "overloaded" | "authentication" | "model-frozen";
|
|
38
39
|
|
|
39
40
|
type RetryableError = {
|
|
40
41
|
reason: RetryReason;
|
|
@@ -47,7 +48,7 @@ type ConfiguredModel = {
|
|
|
47
48
|
reasoningEffort?: ThinkingLevel;
|
|
48
49
|
};
|
|
49
50
|
|
|
50
|
-
type FallbackModel = {
|
|
51
|
+
export type FallbackModel = {
|
|
51
52
|
model: Model<Api>;
|
|
52
53
|
reasoningEffort?: ThinkingLevel;
|
|
53
54
|
};
|
|
@@ -74,6 +75,7 @@ let primaryThinkingLevel: ThinkingLevel | undefined;
|
|
|
74
75
|
let suppressNextModelSelect = false;
|
|
75
76
|
let settingsSignature: string | undefined;
|
|
76
77
|
const rateLimitMemory = new Map<string, RateLimitMemory>();
|
|
78
|
+
const nonRetryableFailureMemory = new Map<string, { failedAt: number; deadline: number; errorMessage: string }>();
|
|
77
79
|
|
|
78
80
|
// ─── Anthropic subscription prompt sanitisation ──────────────────────────────
|
|
79
81
|
|
|
@@ -247,6 +249,13 @@ function updateRateLimitedModelsStatus(): void {
|
|
|
247
249
|
}
|
|
248
250
|
lines.push(`${key}: ${formatDuration(entry.deadline - now)}`);
|
|
249
251
|
}
|
|
252
|
+
for (const [key, entry] of nonRetryableFailureMemory) {
|
|
253
|
+
if (now >= entry.deadline) {
|
|
254
|
+
nonRetryableFailureMemory.delete(key);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
lines.push(`${key}: frozen ${formatDuration(entry.deadline - now)}`);
|
|
258
|
+
}
|
|
250
259
|
if (lines.length === 0) {
|
|
251
260
|
modelStatusCleanup?.();
|
|
252
261
|
return;
|
|
@@ -272,13 +281,34 @@ function rememberRateLimit(model: Model<Api>, retryable: RetryableError): void {
|
|
|
272
281
|
ensureRateLimitedModelsStatus();
|
|
273
282
|
}
|
|
274
283
|
|
|
284
|
+
function activeNonRetryableFailure(model: Model<Api>): { failedAt: number; deadline: number; errorMessage: string } | undefined {
|
|
285
|
+
const entry = nonRetryableFailureMemory.get(modelKey(model));
|
|
286
|
+
if (!entry) return undefined;
|
|
287
|
+
if (Date.now() >= entry.deadline) {
|
|
288
|
+
nonRetryableFailureMemory.delete(modelKey(model));
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
return entry;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function hasNonRetryableFailure(model: Model<Api>): boolean {
|
|
295
|
+
return Boolean(activeNonRetryableFailure(model));
|
|
296
|
+
}
|
|
297
|
+
|
|
275
298
|
function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined {
|
|
276
|
-
return candidateOrder(current).find((entry) => !activeLimit(entry.model));
|
|
299
|
+
return candidateOrder(current).find((entry) => !activeLimit(entry.model) && !hasNonRetryableFailure(entry.model));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
|
|
303
|
+
const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
|
|
304
|
+
nonRetryableFailureMemory.set(modelKey(model), { failedAt: Date.now(), deadline, errorMessage });
|
|
305
|
+
sharedCtx?.ui.notify(`${formatModel(model)} failed; freezing it for ${formatDuration(DEFAULT_NON_RETRYABLE_FREEZE_MS)} and trying another configured model if available.`, "warning");
|
|
306
|
+
ensureRateLimitedModelsStatus();
|
|
277
307
|
}
|
|
278
308
|
|
|
279
309
|
function earliestCandidateDeadline(current: Model<Api>): number | undefined {
|
|
280
310
|
const deadlines = candidateOrder(current)
|
|
281
|
-
.map((entry) => activeLimit(entry.model)?.deadline)
|
|
311
|
+
.map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
|
|
282
312
|
.filter((deadline): deadline is number => typeof deadline === "number");
|
|
283
313
|
return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
|
|
284
314
|
}
|
|
@@ -286,7 +316,7 @@ function earliestCandidateDeadline(current: Model<Api>): number | undefined {
|
|
|
286
316
|
function initialAttempt(model: Model<Api>): FallbackModel {
|
|
287
317
|
const configured = fallbackModels.find((entry) => modelKey(entry.model) === modelKey(model));
|
|
288
318
|
const current = configured ?? { model, reasoningEffort: primaryThinkingLevel };
|
|
289
|
-
if (!fallbackEnabled() || !activeLimit(model)) return current;
|
|
319
|
+
if (!fallbackEnabled() || (!activeLimit(model) && !hasNonRetryableFailure(model))) return current;
|
|
290
320
|
return nextAvailableCandidate(model) ?? current;
|
|
291
321
|
}
|
|
292
322
|
|
|
@@ -351,6 +381,18 @@ export function isServerOverloadedError(msg: string): boolean {
|
|
|
351
381
|
);
|
|
352
382
|
}
|
|
353
383
|
|
|
384
|
+
export function isAuthenticationRefreshError(msg: string): boolean {
|
|
385
|
+
const lower = msg.toLowerCase();
|
|
386
|
+
return (
|
|
387
|
+
/(?:^|\D)401(?:\D|$)/.test(msg) &&
|
|
388
|
+
(
|
|
389
|
+
lower.includes("authentication_error") ||
|
|
390
|
+
lower.includes("invalid authentication credentials") ||
|
|
391
|
+
lower.includes("invalid authentication")
|
|
392
|
+
)
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
354
396
|
export function parseRetryDelayMs(msg: string): number | undefined {
|
|
355
397
|
const retryAfter = msg.match(/retry-after(?:-ms)?[^0-9]*(\d+(?:\.\d+)?)/i);
|
|
356
398
|
if (retryAfter?.[1]) {
|
|
@@ -416,7 +458,10 @@ export function retryAfterHeaderMs(headers: Headers): number | undefined {
|
|
|
416
458
|
|
|
417
459
|
export function getRetryableError(msg: string): RetryableError | undefined {
|
|
418
460
|
if (isServerOverloadedError(msg)) {
|
|
419
|
-
return { reason: "overloaded", waitMs: DEFAULT_OVERLOADED_WAIT_MS };
|
|
461
|
+
return { reason: "overloaded", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_OVERLOADED_WAIT_MS };
|
|
462
|
+
}
|
|
463
|
+
if (isAuthenticationRefreshError(msg)) {
|
|
464
|
+
return { reason: "authentication", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_RATE_LIMIT_WAIT_MS };
|
|
420
465
|
}
|
|
421
466
|
if (isRateLimitError(msg)) {
|
|
422
467
|
return { reason: "rate-limit", waitMs: rateLimitWaitMs(msg) };
|
|
@@ -427,7 +472,10 @@ export function getRetryableError(msg: string): RetryableError | undefined {
|
|
|
427
472
|
// ─── Countdown UI ─────────────────────────────────────────────────────────────
|
|
428
473
|
|
|
429
474
|
function reasonLabel(reason: RetryReason): string {
|
|
430
|
-
|
|
475
|
+
if (reason === "overloaded") return "Server overloaded";
|
|
476
|
+
if (reason === "authentication") return "Authentication refresh pending";
|
|
477
|
+
if (reason === "model-frozen") return "Model frozen after error";
|
|
478
|
+
return "Rate limited";
|
|
431
479
|
}
|
|
432
480
|
|
|
433
481
|
function statusText(reason: RetryReason, deadline: number, allowSkip: boolean): string {
|
|
@@ -580,6 +628,20 @@ type StreamSimpleFn = (
|
|
|
580
628
|
options?: SimpleStreamOptions,
|
|
581
629
|
) => AssistantMessageEventStream;
|
|
582
630
|
|
|
631
|
+
export function __configureFallbackModelsForTests(
|
|
632
|
+
models: FallbackModel[],
|
|
633
|
+
ctx?: ExtensionContext,
|
|
634
|
+
): void {
|
|
635
|
+
fallbackModels = models;
|
|
636
|
+
sharedCtx = ctx;
|
|
637
|
+
primaryModel = undefined;
|
|
638
|
+
primaryThinkingLevel = undefined;
|
|
639
|
+
rateLimitMemory.clear();
|
|
640
|
+
nonRetryableFailureMemory.clear();
|
|
641
|
+
ambientStatusCleanup?.();
|
|
642
|
+
modelStatusCleanup?.();
|
|
643
|
+
}
|
|
644
|
+
|
|
583
645
|
function freshMessage(model: Model<Api>): AssistantMessage {
|
|
584
646
|
return {
|
|
585
647
|
role: "assistant",
|
|
@@ -627,6 +689,7 @@ export function streamWithLimitsRetry(
|
|
|
627
689
|
installFetchRateLimitObserver();
|
|
628
690
|
const buffer: AssistantMessageEvent[] = [];
|
|
629
691
|
let retryable: RetryableError | undefined;
|
|
692
|
+
let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
|
|
630
693
|
|
|
631
694
|
try {
|
|
632
695
|
try {
|
|
@@ -646,7 +709,12 @@ export function streamWithLimitsRetry(
|
|
|
646
709
|
retryable = !options?.signal?.aborted ? getRetryableError(errMsg) : undefined;
|
|
647
710
|
if (retryable) break;
|
|
648
711
|
|
|
649
|
-
|
|
712
|
+
if (fallbackEnabled()) {
|
|
713
|
+
nonRetryableError = { message: errMsg, event };
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Non-retryable error without fallbacks: ensure start came first, then forward error.
|
|
650
718
|
if (buffer.length > 0) {
|
|
651
719
|
flush(buffer);
|
|
652
720
|
} else {
|
|
@@ -690,6 +758,9 @@ export function streamWithLimitsRetry(
|
|
|
690
758
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
691
759
|
retryable = !options?.signal?.aborted ? getRetryableError(errMsg) : undefined;
|
|
692
760
|
if (!retryable) {
|
|
761
|
+
if (fallbackEnabled() && !options?.signal?.aborted) {
|
|
762
|
+
nonRetryableError = { message: errMsg };
|
|
763
|
+
} else {
|
|
693
764
|
if (!committed) {
|
|
694
765
|
// Synthetic start+error so the stream protocol stays valid.
|
|
695
766
|
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
@@ -705,6 +776,7 @@ export function streamWithLimitsRetry(
|
|
|
705
776
|
});
|
|
706
777
|
output.end();
|
|
707
778
|
return;
|
|
779
|
+
}
|
|
708
780
|
}
|
|
709
781
|
}
|
|
710
782
|
} finally {
|
|
@@ -715,6 +787,45 @@ export function streamWithLimitsRetry(
|
|
|
715
787
|
}
|
|
716
788
|
}
|
|
717
789
|
|
|
790
|
+
if (nonRetryableError) {
|
|
791
|
+
if (fallbackEnabled() && !committed) {
|
|
792
|
+
rememberNonRetryableFailure(attempt.model, nonRetryableError.message);
|
|
793
|
+
const next = nextAvailableCandidate(attempt.model);
|
|
794
|
+
if (next) {
|
|
795
|
+
attempt = next;
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const deadline = earliestCandidateDeadline(attempt.model);
|
|
800
|
+
if (deadline) {
|
|
801
|
+
const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
|
|
802
|
+
if (waitResult !== "aborted") {
|
|
803
|
+
attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (!committed) {
|
|
810
|
+
if (buffer.length > 0) {
|
|
811
|
+
flush(buffer);
|
|
812
|
+
} else {
|
|
813
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
814
|
+
committed = true;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (nonRetryableError.event) {
|
|
818
|
+
output.push(nonRetryableError.event);
|
|
819
|
+
} else {
|
|
820
|
+
const error = freshMessage(attempt.model);
|
|
821
|
+
error.stopReason = "error";
|
|
822
|
+
error.errorMessage = nonRetryableError.message;
|
|
823
|
+
output.push({ type: "error", reason: "error", error });
|
|
824
|
+
}
|
|
825
|
+
output.end();
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
718
829
|
if (!retryable) {
|
|
719
830
|
if (!committed) {
|
|
720
831
|
if (buffer.length > 0) {
|
|
@@ -732,7 +843,7 @@ export function streamWithLimitsRetry(
|
|
|
732
843
|
return;
|
|
733
844
|
}
|
|
734
845
|
|
|
735
|
-
if (fallbackEnabled()
|
|
846
|
+
if (fallbackEnabled()) {
|
|
736
847
|
rememberRateLimit(attempt.model, retryable);
|
|
737
848
|
const next = nextAvailableCandidate(attempt.model);
|
|
738
849
|
if (next) {
|