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.
Files changed (2) hide show
  1. package/package.json +49 -49
  2. 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.1",
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
- return reason === "overloaded" ? "Server overloaded" : "Rate limited";
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
- // Non-retryable error: ensure start came first, then forward error.
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() && retryable.reason === "rate-limit") {
846
+ if (fallbackEnabled()) {
736
847
  rememberRateLimit(attempt.model, retryable);
737
848
  const next = nextAvailableCandidate(attempt.model);
738
849
  if (next) {