oira666_pi-limits-wait 0.4.8 → 0.4.10

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 (3) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/index.ts +232 -92
package/README.md CHANGED
@@ -19,6 +19,8 @@ Restart Pi or run `/reload` after installing.
19
19
  - Uses provider retry timing when available (`retry-after`, `retry-after-ms`, `retry in ...`, reset messages).
20
20
  - If no retry timing is available for a rate limit, waits 30 minutes before retrying.
21
21
  - On `server_is_overloaded`, waits 5 minutes, then retries. If the provider is still overloaded after Pi's normal retries, it waits another 5 minutes and repeats.
22
+ - On transient network/transport failures — including undici idle-timeout aborts (`UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`), `fetch failed`, `terminated`, `ECONNRESET`, `ETIMEDOUT`, etc. — treats the error as retryable with a short backoff (default 15s, or the provider's `retry-after`) instead of giving up. This prevents a stalled streaming request from turning into a silent hang.
23
+ - For any other (unclassified) non-retryable error, retries the same model a few times (default 3) before falling back / freezing it, so a one-off hiccup does not immediately sideline a model.
22
24
  - Shows a countdown in the Pi status/working line.
23
25
  - Press Enter during the countdown to skip the wait and retry immediately.
24
26
  - Optionally falls back to configured models when the current/default model is rate-limited.
@@ -74,6 +76,12 @@ Fallback behavior:
74
76
 
75
77
  When the settings are loaded, the extension shows the full usable fallback model list. When models become rate-limited, it shows a live countdown for each limited model.
76
78
 
79
+ ## Environment variables
80
+
81
+ | Variable | Default | Description |
82
+ |----------|---------|-------------|
83
+ | `PI_LIMITS_WAIT_FREEZING_ENABLED` | `true` | When a model keeps failing with a non-retryable error, it is normally "frozen" for 1 hour and skipped in favour of other configured models. Set this to `false` (also accepts `0`, `no`, `off`) to disable freezing entirely: the extension will instead try each configured candidate once (after the bounded retries) and then surface the error, never blocking on a long "model-frozen" wait. Useful for non-interactive / subagent runs where no one can press Enter to skip. |
84
+
77
85
  ## License
78
86
 
79
87
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
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
@@ -31,15 +31,22 @@ const PI_IDENTITY_SENTENCE_PATTERN =
31
31
 
32
32
  const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
33
33
  const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
34
- const DEFAULT_NON_RETRYABLE_FREEZE_MS = 60 * 60 * 1_000; // 1 hour
34
+ const DEFAULT_NETWORK_WAIT_MS = 15 * 1_000; // 15 seconds
35
+ const DEFAULT_NON_RETRYABLE_FREEZE_MS = 10 * 60 * 1_000; // 1 hour
36
+ /** How many times a non-retryable error is retried before we freeze/fallback. */
37
+ const NON_RETRYABLE_MAX_ATTEMPTS = 3;
38
+ /** Short backoff between non-retryable retries. */
39
+ const NON_RETRYABLE_RETRY_DELAY_MS = 5 * 1_000; // 5 seconds
35
40
  const SETTINGS_FILE_NAME = "limits-wait.json";
36
41
  const FALLBACK_MODELS_KEY = "fallback-models";
42
+ /** Env var to toggle the 1-hour model-freeze behaviour. Default: enabled. */
43
+ const FREEZING_ENV_VAR = "PI_LIMITS_WAIT_FREEZING_ENABLED";
37
44
 
38
45
  /** Key used with ctx.ui.setStatus() for the countdown line. */
39
46
  const STATUS_KEY = "limits-wait";
40
47
  const MODELS_STATUS_KEY = "limits-wait-models";
41
48
 
42
- type RetryReason = "rate-limit" | "overloaded" | "authentication" | "model-frozen";
49
+ type RetryReason = "rate-limit" | "overloaded" | "authentication" | "model-frozen" | "network" | "retry";
43
50
 
44
51
  type RetryableError = {
45
52
  reason: RetryReason;
@@ -78,10 +85,12 @@ let ambientStatusCleanup: (() => void) | undefined;
78
85
  let modelStatusCleanup: (() => void) | undefined;
79
86
  let activeProviderRequests = 0;
80
87
  let activeFallbackProviderRequests = 0;
81
- let lastObservedRateLimitError: { at: number; message: string } | undefined;
88
+ let lastObservedHttpError: { at: number; message: string } | undefined;
82
89
  const wrappedApis = new Set<Api>();
83
90
  const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
84
91
  let fallbackModels: FallbackModel[] = [];
92
+ let nonRetryableMaxAttempts = NON_RETRYABLE_MAX_ATTEMPTS;
93
+ let nonRetryableRetryDelayMs = NON_RETRYABLE_RETRY_DELAY_MS;
85
94
  let primaryModel: Model<Api> | undefined;
86
95
  let primaryThinkingLevel: ThinkingLevel | undefined;
87
96
  let suppressNextModelSelect = false;
@@ -346,11 +355,15 @@ function ensureRateLimitedModelsStatus(): void {
346
355
  };
347
356
  }
348
357
 
358
+ function notifyRetryableError(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
359
+ const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage)}` : "";
360
+ sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.${detail}`, "warning");
361
+ }
362
+
349
363
  function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
350
364
  const deadline = Date.now() + retryable.waitMs;
351
365
  rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
352
- const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage)}` : "";
353
- sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.${detail}`, "warning");
366
+ notifyRetryableError(model, retryable, errorMessage);
354
367
  ensureRateLimitedModelsStatus();
355
368
  }
356
369
 
@@ -423,10 +436,21 @@ function isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal):
423
436
  return reason === "aborted" || stopReason === "aborted" || isAbortMessage(message);
424
437
  }
425
438
 
439
+ function recentObservedHttpError(maxAgeMs = 60_000): string | undefined {
440
+ if (!lastObservedHttpError) return undefined;
441
+ if (Date.now() - lastObservedHttpError.at > maxAgeMs) return undefined;
442
+ return lastObservedHttpError.message;
443
+ }
444
+
426
445
  function recentObservedRateLimitError(maxAgeMs = 60_000): string | undefined {
427
- if (!lastObservedRateLimitError) return undefined;
428
- if (Date.now() - lastObservedRateLimitError.at > maxAgeMs) return undefined;
429
- return lastObservedRateLimitError.message;
446
+ const message = recentObservedHttpError(maxAgeMs);
447
+ return message && isRateLimitError(message) ? message : undefined;
448
+ }
449
+
450
+ function errorMessageWithRecentHttpStatus(errorMessage: string): string {
451
+ const observed = recentObservedHttpError();
452
+ if (!observed || /(?:^|\D)[1-5]\d\d(?:\D|$)/.test(errorMessage) || errorMessage.includes(observed)) return errorMessage;
453
+ return `${observed}; Error: ${errorMessage}`;
430
454
  }
431
455
 
432
456
  function earliestCandidateDeadline(current: Model<Api>): number | undefined {
@@ -481,7 +505,7 @@ async function switchPiModel(entry: FallbackModel): Promise<void> {
481
505
 
482
506
  // ─── Retryable error detection ────────────────────────────────────────────────
483
507
 
484
- export function isRateLimitError(msg: string): boolean {
508
+ function isExplicitRateLimitError(msg: string): boolean {
485
509
  const lower = msg.toLowerCase();
486
510
  return (
487
511
  /(?:^|\D)429(?:\D|$)/.test(msg) ||
@@ -489,7 +513,14 @@ export function isRateLimitError(msg: string): boolean {
489
513
  /rate\s*limit/.test(lower) ||
490
514
  lower.includes("too many requests") ||
491
515
  lower.includes("quota exceeded") ||
492
- lower.includes("quota will reset") ||
516
+ lower.includes("quota will reset")
517
+ );
518
+ }
519
+
520
+ export function isRateLimitError(msg: string): boolean {
521
+ const lower = msg.toLowerCase();
522
+ return (
523
+ isExplicitRateLimitError(msg) ||
493
524
  lower.includes("retry delay") ||
494
525
  lower.includes("retry-after")
495
526
  );
@@ -504,6 +535,40 @@ export function isServerOverloadedError(msg: string): boolean {
504
535
  );
505
536
  }
506
537
 
538
+ /**
539
+ * Transient network / HTTP transport failures. These are NOT API rate-limit or
540
+ * overload responses, but they are still worth retrying with a short backoff
541
+ * instead of treating them as a hard non-retryable failure (which would freeze
542
+ * the model for an hour). Most importantly this covers undici's idle-timeout
543
+ * aborts (`UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`) that fire when a
544
+ * streaming request stalls without a response.
545
+ */
546
+ export function isTransientNetworkError(msg: string): boolean {
547
+ const lower = msg.toLowerCase();
548
+ return (
549
+ lower.includes("und_err_headers_timeout") ||
550
+ lower.includes("und_err_body_timeout") ||
551
+ lower.includes("und_err_connect_timeout") ||
552
+ lower.includes("und_err_socket") ||
553
+ lower.includes("headers timeout") ||
554
+ lower.includes("body timeout") ||
555
+ lower.includes("connect timeout") ||
556
+ lower.includes("fetch failed") ||
557
+ lower.includes("terminated") ||
558
+ lower.includes("socket hang up") ||
559
+ lower.includes("other side closed") ||
560
+ lower.includes("network_error") ||
561
+ lower.includes("network error") ||
562
+ lower.includes("econnreset") ||
563
+ lower.includes("econnrefused") ||
564
+ lower.includes("etimedout") ||
565
+ lower.includes("enetunreach") ||
566
+ lower.includes("ehostunreach") ||
567
+ lower.includes("eai_again") ||
568
+ lower.includes("enotfound")
569
+ );
570
+ }
571
+
507
572
  export function isAuthenticationRefreshError(msg: string): boolean {
508
573
  const lower = msg.toLowerCase();
509
574
  return (
@@ -586,9 +651,15 @@ export function getRetryableError(msg: string): RetryableError | undefined {
586
651
  if (isAuthenticationRefreshError(msg)) {
587
652
  return { reason: "authentication", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_RATE_LIMIT_WAIT_MS };
588
653
  }
654
+ if (isTransientNetworkError(msg) && !isExplicitRateLimitError(msg)) {
655
+ return { reason: "network", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_NETWORK_WAIT_MS };
656
+ }
589
657
  if (isRateLimitError(msg)) {
590
658
  return { reason: "rate-limit", waitMs: rateLimitWaitMs(msg) };
591
659
  }
660
+ if (isTransientNetworkError(msg)) {
661
+ return { reason: "network", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_NETWORK_WAIT_MS };
662
+ }
592
663
  return undefined;
593
664
  }
594
665
 
@@ -598,9 +669,23 @@ function reasonLabel(reason: RetryReason): string {
598
669
  if (reason === "overloaded") return "Server overloaded";
599
670
  if (reason === "authentication") return "Authentication refresh pending";
600
671
  if (reason === "model-frozen") return "Model frozen after error";
672
+ if (reason === "network") return "Network/timeout error";
673
+ if (reason === "retry") return "Retrying after error";
601
674
  return "Rate limited";
602
675
  }
603
676
 
677
+ /**
678
+ * Whether the 1-hour model-freeze behaviour is enabled. Controlled by the
679
+ * `PI_LIMITS_WAIT_FREEZING_ENABLED` env var (default enabled). Set it to
680
+ * `false`/`0`/`no`/`off` to disable freezing entirely.
681
+ */
682
+ export function freezingEnabled(): boolean {
683
+ const raw = process.env[FREEZING_ENV_VAR];
684
+ if (raw === undefined) return true;
685
+ const value = raw.trim().toLowerCase();
686
+ return !(value === "false" || value === "0" || value === "no" || value === "off");
687
+ }
688
+
604
689
  function statusText(reason: RetryReason, deadline: number, allowSkip: boolean): string {
605
690
  const remaining = Math.max(0, deadline - Date.now());
606
691
  const totalSecs = Math.ceil(remaining / 1_000);
@@ -692,14 +777,19 @@ export function waitForRetry(
692
777
  };
693
778
 
694
779
  tick();
780
+ // The interval only drives the visible 1s countdown; the actual wait is
781
+ // resolved precisely at the deadline by `timer` below (so a 200ms backoff
782
+ // resolves in ~200ms instead of being rounded up to the next 1s tick).
695
783
  const ticker = setInterval(() => {
696
784
  if (Date.now() >= deadline) { cleanup(); resolve("waited"); return; }
697
785
  tick();
698
786
  }, 1_000);
787
+ const timer = setTimeout(() => { if (!done) { cleanup(); resolve("waited"); } }, Math.max(0, deadline - Date.now()));
699
788
 
700
789
  function cleanup() {
701
790
  done = true;
702
791
  clearInterval(ticker);
792
+ clearTimeout(timer);
703
793
  signal?.removeEventListener("abort", onAbort);
704
794
  unsubInput?.();
705
795
  ctx?.ui.setStatus(STATUS_KEY, undefined);
@@ -739,13 +829,15 @@ function installFetchRateLimitObserver(): void {
739
829
  const response = await originalFetch(...args);
740
830
 
741
831
  if (activeProviderRequests > 0) {
742
- if (response.status === 429) {
743
- const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
832
+ if (!response.ok) {
744
833
  const message = await responseErrorMessage(response);
745
- lastObservedRateLimitError = { at: Date.now(), message };
746
- showAmbientRetryStatus("rate-limit", waitMs);
747
- if (activeFallbackProviderRequests > 0) {
748
- throw new Error(message);
834
+ lastObservedHttpError = { at: Date.now(), message };
835
+ if (response.status === 429) {
836
+ const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
837
+ showAmbientRetryStatus("rate-limit", waitMs);
838
+ if (activeFallbackProviderRequests > 0) {
839
+ throw new Error(message);
840
+ }
749
841
  }
750
842
  } else if (ambientStatusCleanup && response.ok) {
751
843
  clearAmbientRetryStatus();
@@ -769,6 +861,11 @@ type StreamSimpleFn = (
769
861
  options?: SimpleStreamOptions,
770
862
  ) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
771
863
 
864
+ export function __setNonRetryableTuningForTests(maxAttempts: number, retryDelayMs: number): void {
865
+ nonRetryableMaxAttempts = maxAttempts;
866
+ nonRetryableRetryDelayMs = retryDelayMs;
867
+ }
868
+
772
869
  export function __configureFallbackModelsForTests(
773
870
  models: FallbackModel[],
774
871
  ctx?: ExtensionContext,
@@ -779,7 +876,7 @@ export function __configureFallbackModelsForTests(
779
876
  primaryThinkingLevel = undefined;
780
877
  rateLimitMemory.clear();
781
878
  nonRetryableFailureMemory.clear();
782
- lastObservedRateLimitError = undefined;
879
+ lastObservedHttpError = undefined;
783
880
  ambientStatusCleanup?.();
784
881
  modelStatusCleanup?.();
785
882
  }
@@ -821,6 +918,11 @@ export function streamWithLimitsRetry(
821
918
  let committed = false;
822
919
  const allowFallback = fallbackEnabled() && isFallbackEligibleModel(model);
823
920
  let attempt: FallbackModel = allowFallback ? initialAttempt(model) : { model, reasoningEffort: primaryThinkingLevel };
921
+ // Per-stream bookkeeping: how many times each model has hit a non-retryable
922
+ // error, and which models we've exhausted (used to advance fallbacks when
923
+ // freezing is disabled).
924
+ const nonRetryableAttempts = new Map<string, number>();
925
+ const triedModels = new Set<string>();
824
926
 
825
927
  const flush = (buffer: AssistantMessageEvent[]) => {
826
928
  for (const event of buffer) output.push(event);
@@ -850,7 +952,8 @@ export function streamWithLimitsRetry(
850
952
  for await (const event of inner) {
851
953
  if (!committed) {
852
954
  if (event.type === "error") {
853
- const errMsg = event.error.errorMessage ?? "";
955
+ const rawErrMsg = event.error.errorMessage ?? "";
956
+ const errMsg = errorMessageWithRecentHttpStatus(rawErrMsg);
854
957
 
855
958
  if (isAbortErrorEvent(event, options?.signal)) {
856
959
  if (buffer.length > 0) {
@@ -879,20 +982,11 @@ export function streamWithLimitsRetry(
879
982
  break;
880
983
  }
881
984
 
882
- if (allowFallback) {
883
- nonRetryableError = { message: errMsg, event };
884
- break;
885
- }
886
-
887
- if (buffer.length > 0) {
888
- flush(buffer);
889
- } else {
890
- output.push({ type: "start", partial: freshMessage(attempt.model) });
891
- committed = true;
892
- }
893
- output.push(event);
894
- output.end();
895
- return;
985
+ // Defer handling to the post-loop block, which applies the
986
+ // bounded non-retryable retry / fallback / freeze logic. This
987
+ // runs whether or not fallbacks are configured.
988
+ nonRetryableError = { message: errMsg, event };
989
+ break;
896
990
  }
897
991
 
898
992
  if (event.type === "start") {
@@ -924,7 +1018,7 @@ export function streamWithLimitsRetry(
924
1018
  }
925
1019
  }
926
1020
  } catch (err) {
927
- const errMsg = errorMessageWithCauses(err);
1021
+ const errMsg = errorMessageWithRecentHttpStatus(errorMessageWithCauses(err));
928
1022
  const aborted = Boolean(options?.signal?.aborted) || isAbortMessage(errMsg);
929
1023
  retryable = !aborted ? getRetryableError(errMsg) : undefined;
930
1024
  if (retryable) retryableErrorMessage = errMsg;
@@ -934,25 +1028,21 @@ export function streamWithLimitsRetry(
934
1028
  if (retryable && observedRateLimit) retryableErrorMessage = observedRateLimit;
935
1029
  }
936
1030
  if (!retryable) {
937
- if (allowFallback && !aborted) {
938
- nonRetryableError = { message: errMsg };
939
- } else {
1031
+ if (aborted) {
940
1032
  if (!committed) {
941
1033
  // Synthetic start+error so the stream protocol stays valid.
942
1034
  output.push({ type: "start", partial: freshMessage(attempt.model) });
943
1035
  committed = true;
944
1036
  }
945
1037
  const error = freshMessage(attempt.model);
946
- error.stopReason = aborted ? "aborted" : "error";
1038
+ error.stopReason = "aborted";
947
1039
  error.errorMessage = errMsg;
948
- output.push({
949
- type: "error",
950
- reason: error.stopReason as "error" | "aborted",
951
- error,
952
- });
1040
+ output.push({ type: "error", reason: "aborted", error });
953
1041
  output.end();
954
1042
  return;
955
1043
  }
1044
+ // Defer to the post-loop block for bounded retries / fallback.
1045
+ nonRetryableError = { message: errMsg };
956
1046
  }
957
1047
  }
958
1048
  } finally {
@@ -964,72 +1054,122 @@ export function streamWithLimitsRetry(
964
1054
  }
965
1055
  }
966
1056
 
967
- if (nonRetryableError) {
968
- if (allowFallback && !committed) {
969
- rememberNonRetryableFailure(attempt.model, nonRetryableError.message);
970
- const next = nextAvailableCandidate(attempt.model);
971
- if (next) {
972
- attempt = next;
973
- continue;
974
- }
1057
+ if (!retryable) {
1058
+ // Both explicit non-retryable error events/exceptions and a provider
1059
+ // stream that ended without a terminal event land here.
1060
+ const failureEvent = nonRetryableError?.event;
1061
+ const failureMessage = nonRetryableError?.message ?? "Provider stream ended without a terminal event.";
975
1062
 
976
- const deadline = earliestCandidateDeadline(attempt.model);
977
- if (deadline) {
978
- const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
979
- if (waitResult !== "aborted") {
980
- attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
981
- continue;
1063
+ const emitFailure = () => {
1064
+ if (!committed) {
1065
+ if (buffer.length > 0) {
1066
+ flush(buffer);
1067
+ } else {
1068
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
1069
+ committed = true;
982
1070
  }
983
1071
  }
984
- }
985
-
986
- if (!committed) {
987
- if (buffer.length > 0) {
988
- flush(buffer);
1072
+ if (failureEvent) {
1073
+ output.push(failureEvent);
989
1074
  } else {
1075
+ const error = freshMessage(attempt.model);
1076
+ error.stopReason = "error";
1077
+ error.errorMessage = failureMessage;
1078
+ output.push({ type: "error", reason: "error", error });
1079
+ }
1080
+ output.end();
1081
+ };
1082
+
1083
+ const emitAborted = () => {
1084
+ if (!committed) {
990
1085
  output.push({ type: "start", partial: freshMessage(attempt.model) });
991
1086
  committed = true;
992
1087
  }
993
- }
994
- if (nonRetryableError.event) {
995
- output.push(nonRetryableError.event);
996
- } else {
997
1088
  const error = freshMessage(attempt.model);
998
- error.stopReason = "error";
999
- error.errorMessage = nonRetryableError.message;
1000
- output.push({ type: "error", reason: "error", error });
1001
- }
1002
- output.end();
1003
- return;
1004
- }
1089
+ error.stopReason = "aborted";
1090
+ error.errorMessage = "Request aborted while retrying after error.";
1091
+ output.push({ type: "error", reason: "aborted", error });
1092
+ output.end();
1093
+ };
1005
1094
 
1006
- if (!retryable) {
1007
- if (allowFallback && !committed) {
1008
- const message = "Provider stream ended without a terminal event.";
1009
- rememberNonRetryableFailure(attempt.model, message);
1010
- const next = nextAvailableCandidate(attempt.model);
1011
- if (next) {
1012
- attempt = next;
1013
- continue;
1014
- }
1015
- }
1095
+ // Nothing has been streamed yet: give the same model a few quick retries
1096
+ // before we freeze it or fall back. This rides out transient failures
1097
+ // that aren't recognised as retryable (stalls, odd 5xx, malformed
1098
+ // errors) instead of immediately freezing the model for an hour.
1016
1099
  if (!committed) {
1017
- if (buffer.length > 0) {
1018
- flush(buffer);
1019
- } else {
1020
- output.push({ type: "start", partial: freshMessage(attempt.model) });
1021
- committed = true;
1100
+ const key = modelKey(attempt.model);
1101
+ const attempts = (nonRetryableAttempts.get(key) ?? 0) + 1;
1102
+ nonRetryableAttempts.set(key, attempts);
1103
+
1104
+ if (attempts < nonRetryableMaxAttempts) {
1105
+ const waitResult = await waitForRetry("retry", nonRetryableRetryDelayMs, options?.signal);
1106
+ if (waitResult === "aborted") {
1107
+ emitAborted();
1108
+ return;
1109
+ }
1110
+ continue; // retry the same model
1111
+ }
1112
+
1113
+ // Retries for this model are exhausted.
1114
+ triedModels.add(key);
1115
+
1116
+ if (allowFallback) {
1117
+ if (freezingEnabled()) {
1118
+ rememberNonRetryableFailure(attempt.model, failureMessage);
1119
+ const next = nextAvailableCandidate(attempt.model);
1120
+ if (next) {
1121
+ attempt = next;
1122
+ continue;
1123
+ }
1124
+
1125
+ const deadline = earliestCandidateDeadline(attempt.model);
1126
+ if (deadline) {
1127
+ const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
1128
+ if (waitResult !== "aborted") {
1129
+ attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
1130
+ continue;
1131
+ }
1132
+ }
1133
+ } else {
1134
+ // Freezing disabled: walk through the remaining configured
1135
+ // candidates (without freezing) and only give up once they are
1136
+ // all exhausted. Never block on a long "model-frozen" wait.
1137
+ const next = candidateOrder(attempt.model).find((entry) => !triedModels.has(modelKey(entry.model)));
1138
+ if (next) {
1139
+ attempt = next;
1140
+ continue;
1141
+ }
1142
+ }
1022
1143
  }
1023
1144
  }
1024
- const error = freshMessage(attempt.model);
1025
- error.stopReason = "error";
1026
- error.errorMessage = "Provider stream ended without a terminal event.";
1027
- output.push({ type: "error", reason: "error", error });
1028
- output.end();
1145
+
1146
+ emitFailure();
1029
1147
  return;
1030
1148
  }
1031
1149
 
1032
1150
  if (allowFallback) {
1151
+ // Network/transport failures usually mean the user's connection dropped
1152
+ // or a socket timed out. Do not mark the model as unavailable, switch
1153
+ // to another model, or enter model-freeze handling; keep retrying the
1154
+ // same model with the short network backoff until it works again.
1155
+ if (retryable.reason === "network") {
1156
+ notifyRetryableError(attempt.model, retryable, retryableErrorMessage);
1157
+ const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
1158
+ if (waitResult === "aborted") {
1159
+ if (!committed) {
1160
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
1161
+ committed = true;
1162
+ }
1163
+ const error = freshMessage(attempt.model);
1164
+ error.stopReason = "aborted";
1165
+ error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
1166
+ output.push({ type: "error", reason: "aborted", error });
1167
+ output.end();
1168
+ return;
1169
+ }
1170
+ continue;
1171
+ }
1172
+
1033
1173
  rememberRateLimit(attempt.model, retryable, retryableErrorMessage);
1034
1174
  const next = nextAvailableCandidate(attempt.model);
1035
1175
  if (next) {