oira666_pi-limits-wait 0.4.4 → 0.4.7

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 +1 -1
  2. package/src/index.ts +174 -45
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.4.4",
3
+ "version": "0.4.7",
4
4
  "description": "Extension for PI that allows to automatically wait for limits to reset and retry the request",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -59,6 +59,12 @@ type RateLimitMemory = {
59
59
  deadline: number;
60
60
  };
61
61
 
62
+ type NonRetryableFailureMemory = {
63
+ failedAt: number;
64
+ deadline: number;
65
+ errorMessage: string;
66
+ };
67
+
62
68
  // ─── Shared UI context ────────────────────────────────────────────────────────
63
69
 
64
70
  let sharedCtx: ExtensionContext | undefined;
@@ -67,6 +73,8 @@ let restoreFetch: (() => void) | undefined;
67
73
  let ambientStatusCleanup: (() => void) | undefined;
68
74
  let modelStatusCleanup: (() => void) | undefined;
69
75
  let activeProviderRequests = 0;
76
+ let activeFallbackProviderRequests = 0;
77
+ let lastObservedRateLimitError: { at: number; message: string } | undefined;
70
78
  const wrappedApis = new Set<Api>();
71
79
  const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
72
80
  let fallbackModels: FallbackModel[] = [];
@@ -75,7 +83,7 @@ let primaryThinkingLevel: ThinkingLevel | undefined;
75
83
  let suppressNextModelSelect = false;
76
84
  let settingsSignature: string | undefined;
77
85
  const rateLimitMemory = new Map<string, RateLimitMemory>();
78
- const nonRetryableFailureMemory = new Map<string, { failedAt: number; deadline: number; errorMessage: string }>();
86
+ const nonRetryableFailureMemory = new Map<string, NonRetryableFailureMemory>();
79
87
 
80
88
  // ─── Anthropic subscription prompt sanitisation ──────────────────────────────
81
89
 
@@ -250,27 +258,31 @@ function formatDuration(ms: number): string {
250
258
  function updateRateLimitedModelsStatus(): void {
251
259
  const ctx = sharedCtx;
252
260
  if (!ctx) return;
253
- const now = Date.now();
254
- const lines: string[] = [];
255
- for (const [key, entry] of rateLimitMemory) {
256
- if (now >= entry.deadline) {
257
- rateLimitMemory.delete(key);
258
- continue;
261
+ try {
262
+ const now = Date.now();
263
+ const lines: string[] = [];
264
+ for (const [key, entry] of rateLimitMemory) {
265
+ if (now >= entry.deadline) {
266
+ rateLimitMemory.delete(key);
267
+ continue;
268
+ }
269
+ lines.push(`${key}: ${formatDuration(entry.deadline - now)}`);
259
270
  }
260
- lines.push(`${key}: ${formatDuration(entry.deadline - now)}`);
261
- }
262
- for (const [key, entry] of nonRetryableFailureMemory) {
263
- if (now >= entry.deadline) {
264
- nonRetryableFailureMemory.delete(key);
265
- continue;
271
+ for (const [key, entry] of nonRetryableFailureMemory) {
272
+ if (now >= entry.deadline) {
273
+ nonRetryableFailureMemory.delete(key);
274
+ continue;
275
+ }
276
+ lines.push(`${key}: frozen ${formatDuration(entry.deadline - now)}`);
266
277
  }
267
- lines.push(`${key}: frozen ${formatDuration(entry.deadline - now)}`);
268
- }
269
- if (lines.length === 0) {
278
+ if (lines.length === 0) {
279
+ modelStatusCleanup?.();
280
+ return;
281
+ }
282
+ ctx.ui.setStatus(MODELS_STATUS_KEY, `🚦 Limited: ${lines.join(" | ")}`);
283
+ } catch {
270
284
  modelStatusCleanup?.();
271
- return;
272
285
  }
273
- ctx.ui.setStatus(MODELS_STATUS_KEY, `🚦 Limited: ${lines.join(" | ")}`);
274
286
  }
275
287
 
276
288
  function ensureRateLimitedModelsStatus(): void {
@@ -280,18 +292,23 @@ function ensureRateLimitedModelsStatus(): void {
280
292
  modelStatusCleanup = () => {
281
293
  clearInterval(ticker);
282
294
  modelStatusCleanup = undefined;
283
- sharedCtx?.ui.setStatus(MODELS_STATUS_KEY, undefined);
295
+ try {
296
+ sharedCtx?.ui.setStatus(MODELS_STATUS_KEY, undefined);
297
+ } catch {
298
+ // The extension context can become stale after print-mode session teardown.
299
+ }
284
300
  };
285
301
  }
286
302
 
287
- function rememberRateLimit(model: Model<Api>, retryable: RetryableError): void {
303
+ function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
288
304
  const deadline = Date.now() + retryable.waitMs;
289
305
  rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
290
- sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.`, "warning");
306
+ const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage)}` : "";
307
+ sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.${detail}`, "warning");
291
308
  ensureRateLimitedModelsStatus();
292
309
  }
293
310
 
294
- function activeNonRetryableFailure(model: Model<Api>): { failedAt: number; deadline: number; errorMessage: string } | undefined {
311
+ function activeNonRetryableFailure(model: Model<Api>): NonRetryableFailureMemory | undefined {
295
312
  const entry = nonRetryableFailureMemory.get(modelKey(model));
296
313
  if (!entry) return undefined;
297
314
  if (Date.now() >= entry.deadline) {
@@ -309,13 +326,63 @@ function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined
309
326
  return candidateOrder(current).find((entry) => !activeLimit(entry.model) && !hasNonRetryableFailure(entry.model));
310
327
  }
311
328
 
329
+ function formatErrorDetail(errorMessage: string, maxLength = 500): string {
330
+ const oneLine = errorMessage.replace(/\s+/g, " ").trim();
331
+ if (!oneLine) return "unknown error";
332
+ return oneLine.length > maxLength ? `${oneLine.slice(0, maxLength - 1)}…` : oneLine;
333
+ }
334
+
312
335
  function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
313
336
  const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
314
337
  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");
338
+ sharedCtx?.ui.notify(`${formatModel(model)} failed (${formatErrorDetail(errorMessage)}); freezing it for ${formatDuration(DEFAULT_NON_RETRYABLE_FREEZE_MS)} and trying another configured model if available.`, "warning");
316
339
  ensureRateLimitedModelsStatus();
317
340
  }
318
341
 
342
+ function errorMessageWithCauses(err: unknown): string {
343
+ const messages: string[] = [];
344
+ let current: unknown = err;
345
+ const seen = new Set<unknown>();
346
+
347
+ while (current && !seen.has(current)) {
348
+ seen.add(current);
349
+ if (current instanceof Error) {
350
+ if (current.message) messages.push(current.message);
351
+ current = (current as Error & { cause?: unknown }).cause;
352
+ continue;
353
+ }
354
+ messages.push(String(current));
355
+ break;
356
+ }
357
+
358
+ return messages.filter(Boolean).join(" Cause: ") || String(err);
359
+ }
360
+
361
+ function isAbortMessage(msg: string): boolean {
362
+ const lower = msg.toLowerCase();
363
+ return (
364
+ lower.includes("operation aborted") ||
365
+ lower.includes("request aborted") ||
366
+ lower.includes("aborterror") ||
367
+ /\baborted\b/.test(lower)
368
+ );
369
+ }
370
+
371
+ function isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal): boolean {
372
+ if (signal?.aborted) return true;
373
+ if (event.type !== "error") return false;
374
+ const reason = "reason" in event ? String(event.reason) : "";
375
+ const stopReason = event.error.stopReason ? String(event.error.stopReason) : "";
376
+ const message = event.error.errorMessage ?? "";
377
+ return reason === "aborted" || stopReason === "aborted" || isAbortMessage(message);
378
+ }
379
+
380
+ function recentObservedRateLimitError(maxAgeMs = 60_000): string | undefined {
381
+ if (!lastObservedRateLimitError) return undefined;
382
+ if (Date.now() - lastObservedRateLimitError.at > maxAgeMs) return undefined;
383
+ return lastObservedRateLimitError.message;
384
+ }
385
+
319
386
  function earliestCandidateDeadline(current: Model<Api>): number | undefined {
320
387
  const deadlines = candidateOrder(current)
321
388
  .map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
@@ -605,6 +672,19 @@ export function waitForRateLimit(
605
672
 
606
673
  // ─── Early 429 observer ──────────────────────────────────────────────────────
607
674
 
675
+ async function responseErrorMessage(response: Response): Promise<string> {
676
+ const retryAfterMs = retryAfterHeaderMs(response.headers);
677
+ const retryAfter = retryAfterMs !== undefined ? ` retry-after-ms ${retryAfterMs}` : "";
678
+ let body = "";
679
+ try {
680
+ body = await response.clone().text();
681
+ } catch {
682
+ // Ignore body read failures; status and retry headers are enough.
683
+ }
684
+ const detail = body.trim() ? ` ${formatErrorDetail(body)}` : "";
685
+ return `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}${retryAfter}${detail}`;
686
+ }
687
+
608
688
  function installFetchRateLimitObserver(): void {
609
689
  if (restoreFetch || typeof globalThis.fetch !== "function") return;
610
690
 
@@ -615,7 +695,12 @@ function installFetchRateLimitObserver(): void {
615
695
  if (activeProviderRequests > 0) {
616
696
  if (response.status === 429) {
617
697
  const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
698
+ const message = await responseErrorMessage(response);
699
+ lastObservedRateLimitError = { at: Date.now(), message };
618
700
  showAmbientRetryStatus("rate-limit", waitMs);
701
+ if (activeFallbackProviderRequests > 0) {
702
+ throw new Error(message);
703
+ }
619
704
  } else if (ambientStatusCleanup && response.ok) {
620
705
  clearAmbientRetryStatus();
621
706
  }
@@ -648,6 +733,7 @@ export function __configureFallbackModelsForTests(
648
733
  primaryThinkingLevel = undefined;
649
734
  rateLimitMemory.clear();
650
735
  nonRetryableFailureMemory.clear();
736
+ lastObservedRateLimitError = undefined;
651
737
  ambientStatusCleanup?.();
652
738
  modelStatusCleanup?.();
653
739
  }
@@ -697,9 +783,11 @@ export function streamWithLimitsRetry(
697
783
 
698
784
  while (true) {
699
785
  activeProviderRequests++;
786
+ if (allowFallback) activeFallbackProviderRequests++;
700
787
  installFetchRateLimitObserver();
701
788
  const buffer: AssistantMessageEvent[] = [];
702
789
  let retryable: RetryableError | undefined;
790
+ let retryableErrorMessage = "";
703
791
  let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
704
792
 
705
793
  try {
@@ -717,15 +805,39 @@ export function streamWithLimitsRetry(
717
805
  if (!committed) {
718
806
  if (event.type === "error") {
719
807
  const errMsg = event.error.errorMessage ?? "";
720
- retryable = !options?.signal?.aborted ? getRetryableError(errMsg) : undefined;
721
- if (retryable) break;
808
+
809
+ if (isAbortErrorEvent(event, options?.signal)) {
810
+ if (buffer.length > 0) {
811
+ flush(buffer);
812
+ } else {
813
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
814
+ committed = true;
815
+ }
816
+ const error = freshMessage(attempt.model);
817
+ error.stopReason = "aborted";
818
+ error.errorMessage = errMsg || "Operation aborted";
819
+ output.push({ type: "error", reason: "aborted", error });
820
+ output.end();
821
+ return;
822
+ }
823
+
824
+ retryable = getRetryableError(errMsg);
825
+ if (retryable) {
826
+ retryableErrorMessage = errMsg;
827
+ break;
828
+ }
829
+ const observedRateLimit = allowFallback ? recentObservedRateLimitError() : undefined;
830
+ retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
831
+ if (retryable && observedRateLimit) {
832
+ retryableErrorMessage = observedRateLimit;
833
+ break;
834
+ }
722
835
 
723
836
  if (allowFallback) {
724
837
  nonRetryableError = { message: errMsg, event };
725
838
  break;
726
839
  }
727
840
 
728
- // Non-retryable error without fallbacks: ensure start came first, then forward error.
729
841
  if (buffer.length > 0) {
730
842
  flush(buffer);
731
843
  } else {
@@ -766,32 +878,40 @@ export function streamWithLimitsRetry(
766
878
  }
767
879
  }
768
880
  } catch (err) {
769
- const errMsg = err instanceof Error ? err.message : String(err);
770
- retryable = !options?.signal?.aborted ? getRetryableError(errMsg) : undefined;
881
+ const errMsg = errorMessageWithCauses(err);
882
+ const aborted = Boolean(options?.signal?.aborted) || isAbortMessage(errMsg);
883
+ retryable = !aborted ? getRetryableError(errMsg) : undefined;
884
+ if (retryable) retryableErrorMessage = errMsg;
885
+ if (!retryable && allowFallback) {
886
+ const observedRateLimit = recentObservedRateLimitError();
887
+ retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
888
+ if (retryable && observedRateLimit) retryableErrorMessage = observedRateLimit;
889
+ }
771
890
  if (!retryable) {
772
- if (allowFallback && !options?.signal?.aborted) {
891
+ if (allowFallback && !aborted) {
773
892
  nonRetryableError = { message: errMsg };
774
893
  } else {
775
- if (!committed) {
776
- // Synthetic start+error so the stream protocol stays valid.
777
- output.push({ type: "start", partial: freshMessage(attempt.model) });
778
- committed = true;
779
- }
780
- const error = freshMessage(attempt.model);
781
- error.stopReason = options?.signal?.aborted ? "aborted" : "error";
782
- error.errorMessage = errMsg;
783
- output.push({
784
- type: "error",
785
- reason: error.stopReason as "error" | "aborted",
786
- error,
787
- });
788
- output.end();
789
- return;
894
+ if (!committed) {
895
+ // Synthetic start+error so the stream protocol stays valid.
896
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
897
+ committed = true;
898
+ }
899
+ const error = freshMessage(attempt.model);
900
+ error.stopReason = aborted ? "aborted" : "error";
901
+ error.errorMessage = errMsg;
902
+ output.push({
903
+ type: "error",
904
+ reason: error.stopReason as "error" | "aborted",
905
+ error,
906
+ });
907
+ output.end();
908
+ return;
790
909
  }
791
910
  }
792
911
  }
793
912
  } finally {
794
913
  activeProviderRequests--;
914
+ if (allowFallback) activeFallbackProviderRequests--;
795
915
  if (activeProviderRequests === 0) {
796
916
  clearAmbientRetryStatus();
797
917
  restoreFetch?.();
@@ -838,6 +958,15 @@ export function streamWithLimitsRetry(
838
958
  }
839
959
 
840
960
  if (!retryable) {
961
+ if (allowFallback && !committed) {
962
+ const message = "Provider stream ended without a terminal event.";
963
+ rememberNonRetryableFailure(attempt.model, message);
964
+ const next = nextAvailableCandidate(attempt.model);
965
+ if (next) {
966
+ attempt = next;
967
+ continue;
968
+ }
969
+ }
841
970
  if (!committed) {
842
971
  if (buffer.length > 0) {
843
972
  flush(buffer);
@@ -855,7 +984,7 @@ export function streamWithLimitsRetry(
855
984
  }
856
985
 
857
986
  if (allowFallback) {
858
- rememberRateLimit(attempt.model, retryable);
987
+ rememberRateLimit(attempt.model, retryable, retryableErrorMessage);
859
988
  const next = nextAvailableCandidate(attempt.model);
860
989
  if (next) {
861
990
  attempt = next;