oira666_pi-limits-wait 0.4.1 → 0.4.3

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 +138 -15
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.3",
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
 
@@ -113,6 +115,16 @@ function formatModel(model: Pick<Model<Api>, "provider" | "id">): string {
113
115
  return `${model.provider}/${model.id}`;
114
116
  }
115
117
 
118
+ function isInternalSyntheticModel(model: Pick<Model<Api>, "provider" | "id">): boolean {
119
+ return model.provider.startsWith("pi-") || model.id.startsWith("synthetic-");
120
+ }
121
+
122
+ function isFallbackEligibleModel(model: Model<Api>): boolean {
123
+ if (isInternalSyntheticModel(model)) return false;
124
+ const key = modelKey(model);
125
+ return primaryModel ? key === modelKey(primaryModel) || fallbackModels.some((entry) => modelKey(entry.model) === key) : true;
126
+ }
127
+
116
128
  function isThinkingLevel(value: unknown): value is ThinkingLevel {
117
129
  return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(String(value));
118
130
  }
@@ -247,6 +259,13 @@ function updateRateLimitedModelsStatus(): void {
247
259
  }
248
260
  lines.push(`${key}: ${formatDuration(entry.deadline - now)}`);
249
261
  }
262
+ for (const [key, entry] of nonRetryableFailureMemory) {
263
+ if (now >= entry.deadline) {
264
+ nonRetryableFailureMemory.delete(key);
265
+ continue;
266
+ }
267
+ lines.push(`${key}: frozen ${formatDuration(entry.deadline - now)}`);
268
+ }
250
269
  if (lines.length === 0) {
251
270
  modelStatusCleanup?.();
252
271
  return;
@@ -272,13 +291,34 @@ function rememberRateLimit(model: Model<Api>, retryable: RetryableError): void {
272
291
  ensureRateLimitedModelsStatus();
273
292
  }
274
293
 
294
+ function activeNonRetryableFailure(model: Model<Api>): { failedAt: number; deadline: number; errorMessage: string } | undefined {
295
+ const entry = nonRetryableFailureMemory.get(modelKey(model));
296
+ if (!entry) return undefined;
297
+ if (Date.now() >= entry.deadline) {
298
+ nonRetryableFailureMemory.delete(modelKey(model));
299
+ return undefined;
300
+ }
301
+ return entry;
302
+ }
303
+
304
+ function hasNonRetryableFailure(model: Model<Api>): boolean {
305
+ return Boolean(activeNonRetryableFailure(model));
306
+ }
307
+
275
308
  function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined {
276
- return candidateOrder(current).find((entry) => !activeLimit(entry.model));
309
+ return candidateOrder(current).find((entry) => !activeLimit(entry.model) && !hasNonRetryableFailure(entry.model));
310
+ }
311
+
312
+ function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
313
+ const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
314
+ nonRetryableFailureMemory.set(modelKey(model), { failedAt: Date.now(), deadline, errorMessage });
315
+ sharedCtx?.ui.notify(`${formatModel(model)} failed; freezing it for ${formatDuration(DEFAULT_NON_RETRYABLE_FREEZE_MS)} and trying another configured model if available.`, "warning");
316
+ ensureRateLimitedModelsStatus();
277
317
  }
278
318
 
279
319
  function earliestCandidateDeadline(current: Model<Api>): number | undefined {
280
320
  const deadlines = candidateOrder(current)
281
- .map((entry) => activeLimit(entry.model)?.deadline)
321
+ .map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
282
322
  .filter((deadline): deadline is number => typeof deadline === "number");
283
323
  return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
284
324
  }
@@ -286,7 +326,7 @@ function earliestCandidateDeadline(current: Model<Api>): number | undefined {
286
326
  function initialAttempt(model: Model<Api>): FallbackModel {
287
327
  const configured = fallbackModels.find((entry) => modelKey(entry.model) === modelKey(model));
288
328
  const current = configured ?? { model, reasoningEffort: primaryThinkingLevel };
289
- if (!fallbackEnabled() || !activeLimit(model)) return current;
329
+ if (!fallbackEnabled() || (!activeLimit(model) && !hasNonRetryableFailure(model))) return current;
290
330
  return nextAvailableCandidate(model) ?? current;
291
331
  }
292
332
 
@@ -351,6 +391,18 @@ export function isServerOverloadedError(msg: string): boolean {
351
391
  );
352
392
  }
353
393
 
394
+ export function isAuthenticationRefreshError(msg: string): boolean {
395
+ const lower = msg.toLowerCase();
396
+ return (
397
+ /(?:^|\D)401(?:\D|$)/.test(msg) &&
398
+ (
399
+ lower.includes("authentication_error") ||
400
+ lower.includes("invalid authentication credentials") ||
401
+ lower.includes("invalid authentication")
402
+ )
403
+ );
404
+ }
405
+
354
406
  export function parseRetryDelayMs(msg: string): number | undefined {
355
407
  const retryAfter = msg.match(/retry-after(?:-ms)?[^0-9]*(\d+(?:\.\d+)?)/i);
356
408
  if (retryAfter?.[1]) {
@@ -416,7 +468,10 @@ export function retryAfterHeaderMs(headers: Headers): number | undefined {
416
468
 
417
469
  export function getRetryableError(msg: string): RetryableError | undefined {
418
470
  if (isServerOverloadedError(msg)) {
419
- return { reason: "overloaded", waitMs: DEFAULT_OVERLOADED_WAIT_MS };
471
+ return { reason: "overloaded", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_OVERLOADED_WAIT_MS };
472
+ }
473
+ if (isAuthenticationRefreshError(msg)) {
474
+ return { reason: "authentication", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_RATE_LIMIT_WAIT_MS };
420
475
  }
421
476
  if (isRateLimitError(msg)) {
422
477
  return { reason: "rate-limit", waitMs: rateLimitWaitMs(msg) };
@@ -427,7 +482,10 @@ export function getRetryableError(msg: string): RetryableError | undefined {
427
482
  // ─── Countdown UI ─────────────────────────────────────────────────────────────
428
483
 
429
484
  function reasonLabel(reason: RetryReason): string {
430
- return reason === "overloaded" ? "Server overloaded" : "Rate limited";
485
+ if (reason === "overloaded") return "Server overloaded";
486
+ if (reason === "authentication") return "Authentication refresh pending";
487
+ if (reason === "model-frozen") return "Model frozen after error";
488
+ return "Rate limited";
431
489
  }
432
490
 
433
491
  function statusText(reason: RetryReason, deadline: number, allowSkip: boolean): string {
@@ -580,6 +638,20 @@ type StreamSimpleFn = (
580
638
  options?: SimpleStreamOptions,
581
639
  ) => AssistantMessageEventStream;
582
640
 
641
+ export function __configureFallbackModelsForTests(
642
+ models: FallbackModel[],
643
+ ctx?: ExtensionContext,
644
+ ): void {
645
+ fallbackModels = models;
646
+ sharedCtx = ctx;
647
+ primaryModel = undefined;
648
+ primaryThinkingLevel = undefined;
649
+ rateLimitMemory.clear();
650
+ nonRetryableFailureMemory.clear();
651
+ ambientStatusCleanup?.();
652
+ modelStatusCleanup?.();
653
+ }
654
+
583
655
  function freshMessage(model: Model<Api>): AssistantMessage {
584
656
  return {
585
657
  role: "assistant",
@@ -615,7 +687,8 @@ export function streamWithLimitsRetry(
615
687
 
616
688
  void (async () => {
617
689
  let committed = false;
618
- let attempt: FallbackModel = initialAttempt(model);
690
+ const allowFallback = fallbackEnabled() && isFallbackEligibleModel(model);
691
+ let attempt: FallbackModel = allowFallback ? initialAttempt(model) : { model, reasoningEffort: primaryThinkingLevel };
619
692
 
620
693
  const flush = (buffer: AssistantMessageEvent[]) => {
621
694
  for (const event of buffer) output.push(event);
@@ -627,10 +700,11 @@ export function streamWithLimitsRetry(
627
700
  installFetchRateLimitObserver();
628
701
  const buffer: AssistantMessageEvent[] = [];
629
702
  let retryable: RetryableError | undefined;
703
+ let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
630
704
 
631
705
  try {
632
706
  try {
633
- const attemptOptions = fallbackEnabled()
707
+ const attemptOptions = allowFallback
634
708
  ? await optionsForModel(model, attempt, options)
635
709
  : options;
636
710
  const attemptDelegate = attempt.model.api === model.api
@@ -646,7 +720,12 @@ export function streamWithLimitsRetry(
646
720
  retryable = !options?.signal?.aborted ? getRetryableError(errMsg) : undefined;
647
721
  if (retryable) break;
648
722
 
649
- // Non-retryable error: ensure start came first, then forward error.
723
+ if (allowFallback) {
724
+ nonRetryableError = { message: errMsg, event };
725
+ break;
726
+ }
727
+
728
+ // Non-retryable error without fallbacks: ensure start came first, then forward error.
650
729
  if (buffer.length > 0) {
651
730
  flush(buffer);
652
731
  } else {
@@ -664,7 +743,7 @@ export function streamWithLimitsRetry(
664
743
  }
665
744
 
666
745
  // First non-start, non-error event means this attempt is real.
667
- if (fallbackEnabled()) await switchPiModel(attempt);
746
+ if (allowFallback) await switchPiModel(attempt);
668
747
  if (buffer.length > 0) {
669
748
  flush(buffer);
670
749
  } else {
@@ -690,6 +769,9 @@ export function streamWithLimitsRetry(
690
769
  const errMsg = err instanceof Error ? err.message : String(err);
691
770
  retryable = !options?.signal?.aborted ? getRetryableError(errMsg) : undefined;
692
771
  if (!retryable) {
772
+ if (allowFallback && !options?.signal?.aborted) {
773
+ nonRetryableError = { message: errMsg };
774
+ } else {
693
775
  if (!committed) {
694
776
  // Synthetic start+error so the stream protocol stays valid.
695
777
  output.push({ type: "start", partial: freshMessage(attempt.model) });
@@ -705,6 +787,7 @@ export function streamWithLimitsRetry(
705
787
  });
706
788
  output.end();
707
789
  return;
790
+ }
708
791
  }
709
792
  }
710
793
  } finally {
@@ -715,6 +798,45 @@ export function streamWithLimitsRetry(
715
798
  }
716
799
  }
717
800
 
801
+ if (nonRetryableError) {
802
+ if (allowFallback && !committed) {
803
+ rememberNonRetryableFailure(attempt.model, nonRetryableError.message);
804
+ const next = nextAvailableCandidate(attempt.model);
805
+ if (next) {
806
+ attempt = next;
807
+ continue;
808
+ }
809
+
810
+ const deadline = earliestCandidateDeadline(attempt.model);
811
+ if (deadline) {
812
+ const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
813
+ if (waitResult !== "aborted") {
814
+ attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
815
+ continue;
816
+ }
817
+ }
818
+ }
819
+
820
+ if (!committed) {
821
+ if (buffer.length > 0) {
822
+ flush(buffer);
823
+ } else {
824
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
825
+ committed = true;
826
+ }
827
+ }
828
+ if (nonRetryableError.event) {
829
+ output.push(nonRetryableError.event);
830
+ } else {
831
+ const error = freshMessage(attempt.model);
832
+ error.stopReason = "error";
833
+ error.errorMessage = nonRetryableError.message;
834
+ output.push({ type: "error", reason: "error", error });
835
+ }
836
+ output.end();
837
+ return;
838
+ }
839
+
718
840
  if (!retryable) {
719
841
  if (!committed) {
720
842
  if (buffer.length > 0) {
@@ -732,7 +854,7 @@ export function streamWithLimitsRetry(
732
854
  return;
733
855
  }
734
856
 
735
- if (fallbackEnabled() && retryable.reason === "rate-limit") {
857
+ if (allowFallback) {
736
858
  rememberRateLimit(attempt.model, retryable);
737
859
  const next = nextAvailableCandidate(attempt.model);
738
860
  if (next) {
@@ -807,22 +929,23 @@ export default function (pi: ExtensionAPI) {
807
929
  suppressNextModelSelect = false;
808
930
  return;
809
931
  }
932
+ if (isInternalSyntheticModel(event.model)) return;
810
933
  primaryModel = event.model;
811
934
  primaryThinkingLevel = pi.getThinkingLevel();
812
935
  });
813
936
 
814
937
  pi.on("before_agent_start", (event, ctx) => {
815
938
  sharedCtx = ctx;
816
- primaryModel ??= ctx.model;
939
+ if (!primaryModel && ctx.model && !isInternalSyntheticModel(ctx.model)) primaryModel = ctx.model;
817
940
  primaryThinkingLevel ??= pi.getThinkingLevel();
818
941
  loadFallbackSettings(ctx);
819
942
 
820
943
  // Some extensions may register providers after this extension loads. Wrap
821
944
  // any APIs that exist by the time an agent starts too.
822
945
  for (const model of ctx.modelRegistry.getAll()) {
823
- registerWrappedApi(pi, model.api);
946
+ if (!isInternalSyntheticModel(model)) registerWrappedApi(pi, model.api);
824
947
  }
825
- if (ctx.model) registerWrappedApi(pi, ctx.model.api);
948
+ if (ctx.model && !isInternalSyntheticModel(ctx.model)) registerWrappedApi(pi, ctx.model.api);
826
949
 
827
950
  // Anthropic subscription/OAuth requests identify as Claude Code, not Pi.
828
951
  // Check the session-wide Anthropic OAuth configuration rather than only the