oira666_pi-limits-wait 0.4.5 → 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 +120 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.4.5",
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,38 @@ 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
+
319
361
  function isAbortMessage(msg: string): boolean {
320
362
  const lower = msg.toLowerCase();
321
363
  return (
@@ -335,6 +377,12 @@ function isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal):
335
377
  return reason === "aborted" || stopReason === "aborted" || isAbortMessage(message);
336
378
  }
337
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
+
338
386
  function earliestCandidateDeadline(current: Model<Api>): number | undefined {
339
387
  const deadlines = candidateOrder(current)
340
388
  .map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
@@ -624,6 +672,19 @@ export function waitForRateLimit(
624
672
 
625
673
  // ─── Early 429 observer ──────────────────────────────────────────────────────
626
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
+
627
688
  function installFetchRateLimitObserver(): void {
628
689
  if (restoreFetch || typeof globalThis.fetch !== "function") return;
629
690
 
@@ -634,7 +695,12 @@ function installFetchRateLimitObserver(): void {
634
695
  if (activeProviderRequests > 0) {
635
696
  if (response.status === 429) {
636
697
  const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
698
+ const message = await responseErrorMessage(response);
699
+ lastObservedRateLimitError = { at: Date.now(), message };
637
700
  showAmbientRetryStatus("rate-limit", waitMs);
701
+ if (activeFallbackProviderRequests > 0) {
702
+ throw new Error(message);
703
+ }
638
704
  } else if (ambientStatusCleanup && response.ok) {
639
705
  clearAmbientRetryStatus();
640
706
  }
@@ -667,6 +733,7 @@ export function __configureFallbackModelsForTests(
667
733
  primaryThinkingLevel = undefined;
668
734
  rateLimitMemory.clear();
669
735
  nonRetryableFailureMemory.clear();
736
+ lastObservedRateLimitError = undefined;
670
737
  ambientStatusCleanup?.();
671
738
  modelStatusCleanup?.();
672
739
  }
@@ -716,9 +783,11 @@ export function streamWithLimitsRetry(
716
783
 
717
784
  while (true) {
718
785
  activeProviderRequests++;
786
+ if (allowFallback) activeFallbackProviderRequests++;
719
787
  installFetchRateLimitObserver();
720
788
  const buffer: AssistantMessageEvent[] = [];
721
789
  let retryable: RetryableError | undefined;
790
+ let retryableErrorMessage = "";
722
791
  let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
723
792
 
724
793
  try {
@@ -753,14 +822,22 @@ export function streamWithLimitsRetry(
753
822
  }
754
823
 
755
824
  retryable = getRetryableError(errMsg);
756
- if (retryable) break;
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
+ }
757
835
 
758
836
  if (allowFallback) {
759
837
  nonRetryableError = { message: errMsg, event };
760
838
  break;
761
839
  }
762
840
 
763
- // Non-retryable error without fallbacks: ensure start came first, then forward error.
764
841
  if (buffer.length > 0) {
765
842
  flush(buffer);
766
843
  } else {
@@ -801,9 +878,15 @@ export function streamWithLimitsRetry(
801
878
  }
802
879
  }
803
880
  } catch (err) {
804
- const errMsg = err instanceof Error ? err.message : String(err);
881
+ const errMsg = errorMessageWithCauses(err);
805
882
  const aborted = Boolean(options?.signal?.aborted) || isAbortMessage(errMsg);
806
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
+ }
807
890
  if (!retryable) {
808
891
  if (allowFallback && !aborted) {
809
892
  nonRetryableError = { message: errMsg };
@@ -828,6 +911,7 @@ export function streamWithLimitsRetry(
828
911
  }
829
912
  } finally {
830
913
  activeProviderRequests--;
914
+ if (allowFallback) activeFallbackProviderRequests--;
831
915
  if (activeProviderRequests === 0) {
832
916
  clearAmbientRetryStatus();
833
917
  restoreFetch?.();
@@ -874,6 +958,15 @@ export function streamWithLimitsRetry(
874
958
  }
875
959
 
876
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
+ }
877
970
  if (!committed) {
878
971
  if (buffer.length > 0) {
879
972
  flush(buffer);
@@ -891,7 +984,7 @@ export function streamWithLimitsRetry(
891
984
  }
892
985
 
893
986
  if (allowFallback) {
894
- rememberRateLimit(attempt.model, retryable);
987
+ rememberRateLimit(attempt.model, retryable, retryableErrorMessage);
895
988
  const next = nextAvailableCandidate(attempt.model);
896
989
  if (next) {
897
990
  attempt = next;