oira666_pi-limits-wait 0.4.7 → 0.4.9

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 +27 -21
  2. package/package.json +1 -1
  3. package/src/index.ts +233 -97
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.
@@ -27,38 +29,36 @@ Restart Pi or run `/reload` after installing.
27
29
 
28
30
  By default, if you do not configure fallback models, the extension behaves exactly as before: it waits for the current model's limit to reset and retries.
29
31
 
30
- To enable automatic model fallback, add `oira666_pi-limits-wait.try_models` to Pi's normal `settings.json`.
32
+ To enable automatic model fallback, add `fallback-models` to a `limits-wait.json` file.
31
33
 
32
- Pi settings can be global or project-local:
34
+ The extension reads these files in order; later files override earlier ones:
33
35
 
34
36
  | Location | Scope |
35
37
  |----------|-------|
36
- | `~/.pi/agent/settings.json` | Global, all projects |
37
- | `.pi/settings.json` | Project-local |
38
-
39
- Project settings override global settings. The extension reads these through Pi's `SettingsManager`, so normal Pi settings paths and `PI_CODING_AGENT_DIR` are respected.
38
+ | `~/.config/.pi/limits-wait.json` | Global defaults |
39
+ | `<Pi agent dir>/limits-wait.json` (for example `~/.pi/agent/limits-wait.json`, or `PI_CODING_AGENT_DIR/limits-wait.json`) | Pi agent directory |
40
+ | `.limits-wait.json` | Project root override |
41
+ | `.pi/limits-wait.json` | Pi project override |
40
42
 
41
43
  Example:
42
44
 
43
45
  ```json
44
46
  {
45
- "oira666_pi-limits-wait": {
46
- "try_models": [
47
- {
48
- "provider": "anthropic",
49
- "modelname": "claude-sonnet-4-5",
50
- "reasoning effort": "medium"
51
- },
52
- {
53
- "provider": "openai",
54
- "modelname": "gpt-5.1-codex"
55
- }
56
- ]
57
- }
47
+ "fallback-models": [
48
+ {
49
+ "provider": "anthropic",
50
+ "modelname": "claude-sonnet-4-5",
51
+ "reasoning effort": "medium"
52
+ },
53
+ {
54
+ "provider": "openai",
55
+ "modelname": "gpt-5.1-codex"
56
+ }
57
+ ]
58
58
  }
59
59
  ```
60
60
 
61
- Each `try_models` entry supports:
61
+ Each `fallback-models` entry supports:
62
62
 
63
63
  - `provider` — required. The Pi provider name, for example `anthropic`, `openai`, `google`, etc.
64
64
  - `modelname` — required. The model id/name as Pi knows it.
@@ -69,13 +69,19 @@ Fallback behavior:
69
69
  1. Pi starts with the normal default or user-selected model.
70
70
  2. If that model is rate-limited, the extension tries models in this order:
71
71
  - the original default/user-selected model;
72
- - then every model from `oira666_pi-limits-wait.try_models`, top to bottom.
72
+ - then every model from `fallback-models`, top to bottom.
73
73
  3. The first model that responds without a rate-limit becomes the active Pi model for the rest of the session/task.
74
74
  4. If that model later becomes rate-limited too, the extension starts again from the same ordered list.
75
75
  5. Rate-limit reset times are remembered only in memory, so known-limited models are skipped until their countdown expires. This memory is cleared when Pi restarts.
76
76
 
77
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.
78
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
+
79
85
  ## License
80
86
 
81
87
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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
@@ -1,4 +1,7 @@
1
- import { SettingsManager, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join, resolve } from "path";
4
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
2
5
  import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
3
6
  import {
4
7
  createAssistantMessageEventStream,
@@ -28,14 +31,22 @@ const PI_IDENTITY_SENTENCE_PATTERN =
28
31
 
29
32
  const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
30
33
  const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
31
- const DEFAULT_NON_RETRYABLE_FREEZE_MS = 60 * 60 * 1_000; // 1 hour
32
- const SETTINGS_KEY = "oira666_pi-limits-wait";
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
40
+ const SETTINGS_FILE_NAME = "limits-wait.json";
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";
33
44
 
34
45
  /** Key used with ctx.ui.setStatus() for the countdown line. */
35
46
  const STATUS_KEY = "limits-wait";
36
47
  const MODELS_STATUS_KEY = "limits-wait-models";
37
48
 
38
- type RetryReason = "rate-limit" | "overloaded" | "authentication" | "model-frozen";
49
+ type RetryReason = "rate-limit" | "overloaded" | "authentication" | "model-frozen" | "network" | "retry";
39
50
 
40
51
  type RetryableError = {
41
52
  reason: RetryReason;
@@ -78,6 +89,8 @@ let lastObservedRateLimitError: { at: number; message: string } | undefined;
78
89
  const wrappedApis = new Set<Api>();
79
90
  const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
80
91
  let fallbackModels: FallbackModel[] = [];
92
+ let nonRetryableMaxAttempts = NON_RETRYABLE_MAX_ATTEMPTS;
93
+ let nonRetryableRetryDelayMs = NON_RETRYABLE_RETRY_DELAY_MS;
81
94
  let primaryModel: Model<Api> | undefined;
82
95
  let primaryThinkingLevel: ThinkingLevel | undefined;
83
96
  let suppressNextModelSelect = false;
@@ -138,8 +151,8 @@ function isThinkingLevel(value: unknown): value is ThinkingLevel {
138
151
  }
139
152
 
140
153
  function parseConfiguredModels(raw: unknown): ConfiguredModel[] {
141
- const source = raw && typeof raw === "object" && Array.isArray((raw as { try_models?: unknown }).try_models)
142
- ? (raw as { try_models: unknown[] }).try_models
154
+ const source = raw && typeof raw === "object" && Array.isArray((raw as { [FALLBACK_MODELS_KEY]?: unknown })[FALLBACK_MODELS_KEY])
155
+ ? (raw as { [FALLBACK_MODELS_KEY]: unknown[] })[FALLBACK_MODELS_KEY]
143
156
  : [];
144
157
 
145
158
  const models: ConfiguredModel[] = [];
@@ -163,37 +176,77 @@ function parseConfiguredModels(raw: unknown): ConfiguredModel[] {
163
176
  return models;
164
177
  }
165
178
 
179
+ function configFileCandidates(cwd: string, agentDir = getAgentDir(), homeDir = homedir()): string[] {
180
+ return [
181
+ join(homeDir, ".config", ".pi", SETTINGS_FILE_NAME),
182
+ join(agentDir, SETTINGS_FILE_NAME),
183
+ resolve(cwd, `.${SETTINGS_FILE_NAME}`),
184
+ resolve(cwd, ".pi", SETTINGS_FILE_NAME),
185
+ ];
186
+ }
187
+
188
+ function mergeConfig(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
189
+ return { ...base, ...override };
190
+ }
191
+
192
+ function readFallbackSettings(cwd: string, agentDir = getAgentDir(), homeDir = homedir()): { config: Record<string, unknown>; loadedPaths: string[]; warnings: string[] } {
193
+ let config: Record<string, unknown> = {};
194
+ const loadedPaths: string[] = [];
195
+ const warnings: string[] = [];
196
+ const seen = new Set<string>();
197
+
198
+ for (const candidate of configFileCandidates(cwd, agentDir, homeDir)) {
199
+ const path = resolve(candidate);
200
+ if (seen.has(path)) continue;
201
+ seen.add(path);
202
+ if (!existsSync(path)) continue;
203
+
204
+ try {
205
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
206
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
207
+ warnings.push(`${path}: expected a JSON object`);
208
+ continue;
209
+ }
210
+ config = mergeConfig(config, parsed as Record<string, unknown>);
211
+ loadedPaths.push(path);
212
+ } catch (err) {
213
+ warnings.push(`${path}: ${err instanceof Error ? err.message : String(err)}`);
214
+ }
215
+ }
216
+
217
+ return { config, loadedPaths, warnings };
218
+ }
219
+
220
+ export function __readFallbackSettingsForTests(cwd: string, agentDir: string, homeDir: string): { config: Record<string, unknown>; loadedPaths: string[]; warnings: string[] } {
221
+ return readFallbackSettings(cwd, agentDir, homeDir);
222
+ }
223
+
166
224
  function loadFallbackSettings(ctx: ExtensionContext): void {
167
225
  fallbackModels = [];
168
226
 
169
227
  try {
170
- const settingsManager = SettingsManager.create(ctx.cwd);
171
- const globalSettings = settingsManager.getGlobalSettings() as Record<string, unknown>;
172
- const projectSettings = settingsManager.getProjectSettings() as Record<string, unknown>;
173
- const globalConfig = globalSettings[SETTINGS_KEY];
174
- const projectConfig = projectSettings[SETTINGS_KEY];
175
- const config = {
176
- ...(globalConfig && typeof globalConfig === "object" && !Array.isArray(globalConfig) ? globalConfig : {}),
177
- ...(projectConfig && typeof projectConfig === "object" && !Array.isArray(projectConfig) ? projectConfig : {}),
178
- };
179
- const content = JSON.stringify(config);
228
+ const { config, loadedPaths, warnings } = readFallbackSettings(ctx.cwd);
229
+ const content = JSON.stringify({ config, loadedPaths, warnings });
180
230
  const shouldNotify = settingsSignature !== content;
181
231
  settingsSignature = content;
182
232
 
183
233
  const configured = parseConfiguredModels(config);
184
- if (configured.length === 0) return;
234
+ if (configured.length === 0) {
235
+ if (shouldNotify && warnings.length > 0) ctx.ui.notify(`Could not fully load ${SETTINGS_FILE_NAME}: ${warnings.join(", ")}`, "warning");
236
+ return;
237
+ }
185
238
 
186
- const warnings: string[] = [];
239
+ const modelWarnings: string[] = [];
187
240
  const seen = new Set<string>();
188
241
 
189
242
  for (const entry of configured) {
190
243
  const model = ctx.modelRegistry.find(entry.provider, entry.modelname);
191
244
  if (!model) {
192
- warnings.push(`missing ${entry.provider}/${entry.modelname}`);
245
+ modelWarnings.push(`missing ${entry.provider}/${entry.modelname}`);
193
246
  continue;
194
247
  }
195
248
  if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
196
- warnings.push(`no auth ${entry.provider}/${entry.modelname}`);
249
+ modelWarnings.push(`no auth ${entry.provider}/${entry.modelname}`);
197
250
  continue;
198
251
  }
199
252
  const key = modelKey(model);
@@ -205,14 +258,16 @@ function loadFallbackSettings(ctx: ExtensionContext): void {
205
258
  const lines = fallbackModels.map((entry, index) =>
206
259
  `${index + 1}. ${formatModel(entry.model)}${entry.reasoningEffort ? ` (${entry.reasoningEffort})` : ""}`,
207
260
  );
261
+ const source = loadedPaths.length > 0 ? ` from ${loadedPaths.join(", ")}` : "";
208
262
  const message = lines.length > 0
209
- ? `Loaded ${SETTINGS_KEY}.try_models:\n${lines.join("\n")}`
210
- : `Loaded ${SETTINGS_KEY}.try_models, but no usable fallback models were found.`;
263
+ ? `Loaded ${SETTINGS_FILE_NAME}.${FALLBACK_MODELS_KEY}${source}:\n${lines.join("\n")}`
264
+ : `Loaded ${SETTINGS_FILE_NAME}.${FALLBACK_MODELS_KEY}${source}, but no usable fallback models were found.`;
265
+ const allWarnings = [...warnings, ...modelWarnings];
211
266
  if (shouldNotify) {
212
- ctx.ui.notify(warnings.length > 0 ? `${message}\nSkipped: ${warnings.join(", ")}` : message, warnings.length > 0 ? "warning" : "info");
267
+ ctx.ui.notify(allWarnings.length > 0 ? `${message}\nSkipped: ${allWarnings.join(", ")}` : message, allWarnings.length > 0 ? "warning" : "info");
213
268
  }
214
269
  } catch (err) {
215
- ctx.ui.notify(`Could not load ${SETTINGS_KEY}.try_models: ${err instanceof Error ? err.message : String(err)}`, "warning");
270
+ ctx.ui.notify(`Could not load ${SETTINGS_FILE_NAME}.${FALLBACK_MODELS_KEY}: ${err instanceof Error ? err.message : String(err)}`, "warning");
216
271
  }
217
272
  }
218
273
 
@@ -458,6 +513,40 @@ export function isServerOverloadedError(msg: string): boolean {
458
513
  );
459
514
  }
460
515
 
516
+ /**
517
+ * Transient network / HTTP transport failures. These are NOT API rate-limit or
518
+ * overload responses, but they are still worth retrying with a short backoff
519
+ * instead of treating them as a hard non-retryable failure (which would freeze
520
+ * the model for an hour). Most importantly this covers undici's idle-timeout
521
+ * aborts (`UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`) that fire when a
522
+ * streaming request stalls without a response.
523
+ */
524
+ export function isTransientNetworkError(msg: string): boolean {
525
+ const lower = msg.toLowerCase();
526
+ return (
527
+ lower.includes("und_err_headers_timeout") ||
528
+ lower.includes("und_err_body_timeout") ||
529
+ lower.includes("und_err_connect_timeout") ||
530
+ lower.includes("und_err_socket") ||
531
+ lower.includes("headers timeout") ||
532
+ lower.includes("body timeout") ||
533
+ lower.includes("connect timeout") ||
534
+ lower.includes("fetch failed") ||
535
+ lower.includes("terminated") ||
536
+ lower.includes("socket hang up") ||
537
+ lower.includes("other side closed") ||
538
+ lower.includes("network_error") ||
539
+ lower.includes("network error") ||
540
+ lower.includes("econnreset") ||
541
+ lower.includes("econnrefused") ||
542
+ lower.includes("etimedout") ||
543
+ lower.includes("enetunreach") ||
544
+ lower.includes("ehostunreach") ||
545
+ lower.includes("eai_again") ||
546
+ lower.includes("enotfound")
547
+ );
548
+ }
549
+
461
550
  export function isAuthenticationRefreshError(msg: string): boolean {
462
551
  const lower = msg.toLowerCase();
463
552
  return (
@@ -543,6 +632,9 @@ export function getRetryableError(msg: string): RetryableError | undefined {
543
632
  if (isRateLimitError(msg)) {
544
633
  return { reason: "rate-limit", waitMs: rateLimitWaitMs(msg) };
545
634
  }
635
+ if (isTransientNetworkError(msg)) {
636
+ return { reason: "network", waitMs: parseRetryDelayMs(msg) ?? DEFAULT_NETWORK_WAIT_MS };
637
+ }
546
638
  return undefined;
547
639
  }
548
640
 
@@ -552,9 +644,23 @@ function reasonLabel(reason: RetryReason): string {
552
644
  if (reason === "overloaded") return "Server overloaded";
553
645
  if (reason === "authentication") return "Authentication refresh pending";
554
646
  if (reason === "model-frozen") return "Model frozen after error";
647
+ if (reason === "network") return "Network/timeout error";
648
+ if (reason === "retry") return "Retrying after error";
555
649
  return "Rate limited";
556
650
  }
557
651
 
652
+ /**
653
+ * Whether the 1-hour model-freeze behaviour is enabled. Controlled by the
654
+ * `PI_LIMITS_WAIT_FREEZING_ENABLED` env var (default enabled). Set it to
655
+ * `false`/`0`/`no`/`off` to disable freezing entirely.
656
+ */
657
+ export function freezingEnabled(): boolean {
658
+ const raw = process.env[FREEZING_ENV_VAR];
659
+ if (raw === undefined) return true;
660
+ const value = raw.trim().toLowerCase();
661
+ return !(value === "false" || value === "0" || value === "no" || value === "off");
662
+ }
663
+
558
664
  function statusText(reason: RetryReason, deadline: number, allowSkip: boolean): string {
559
665
  const remaining = Math.max(0, deadline - Date.now());
560
666
  const totalSecs = Math.ceil(remaining / 1_000);
@@ -646,14 +752,19 @@ export function waitForRetry(
646
752
  };
647
753
 
648
754
  tick();
755
+ // The interval only drives the visible 1s countdown; the actual wait is
756
+ // resolved precisely at the deadline by `timer` below (so a 200ms backoff
757
+ // resolves in ~200ms instead of being rounded up to the next 1s tick).
649
758
  const ticker = setInterval(() => {
650
759
  if (Date.now() >= deadline) { cleanup(); resolve("waited"); return; }
651
760
  tick();
652
761
  }, 1_000);
762
+ const timer = setTimeout(() => { if (!done) { cleanup(); resolve("waited"); } }, Math.max(0, deadline - Date.now()));
653
763
 
654
764
  function cleanup() {
655
765
  done = true;
656
766
  clearInterval(ticker);
767
+ clearTimeout(timer);
657
768
  signal?.removeEventListener("abort", onAbort);
658
769
  unsubInput?.();
659
770
  ctx?.ui.setStatus(STATUS_KEY, undefined);
@@ -723,6 +834,11 @@ type StreamSimpleFn = (
723
834
  options?: SimpleStreamOptions,
724
835
  ) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
725
836
 
837
+ export function __setNonRetryableTuningForTests(maxAttempts: number, retryDelayMs: number): void {
838
+ nonRetryableMaxAttempts = maxAttempts;
839
+ nonRetryableRetryDelayMs = retryDelayMs;
840
+ }
841
+
726
842
  export function __configureFallbackModelsForTests(
727
843
  models: FallbackModel[],
728
844
  ctx?: ExtensionContext,
@@ -775,6 +891,11 @@ export function streamWithLimitsRetry(
775
891
  let committed = false;
776
892
  const allowFallback = fallbackEnabled() && isFallbackEligibleModel(model);
777
893
  let attempt: FallbackModel = allowFallback ? initialAttempt(model) : { model, reasoningEffort: primaryThinkingLevel };
894
+ // Per-stream bookkeeping: how many times each model has hit a non-retryable
895
+ // error, and which models we've exhausted (used to advance fallbacks when
896
+ // freezing is disabled).
897
+ const nonRetryableAttempts = new Map<string, number>();
898
+ const triedModels = new Set<string>();
778
899
 
779
900
  const flush = (buffer: AssistantMessageEvent[]) => {
780
901
  for (const event of buffer) output.push(event);
@@ -833,20 +954,11 @@ export function streamWithLimitsRetry(
833
954
  break;
834
955
  }
835
956
 
836
- if (allowFallback) {
837
- nonRetryableError = { message: errMsg, event };
838
- break;
839
- }
840
-
841
- if (buffer.length > 0) {
842
- flush(buffer);
843
- } else {
844
- output.push({ type: "start", partial: freshMessage(attempt.model) });
845
- committed = true;
846
- }
847
- output.push(event);
848
- output.end();
849
- return;
957
+ // Defer handling to the post-loop block, which applies the
958
+ // bounded non-retryable retry / fallback / freeze logic. This
959
+ // runs whether or not fallbacks are configured.
960
+ nonRetryableError = { message: errMsg, event };
961
+ break;
850
962
  }
851
963
 
852
964
  if (event.type === "start") {
@@ -888,25 +1000,21 @@ export function streamWithLimitsRetry(
888
1000
  if (retryable && observedRateLimit) retryableErrorMessage = observedRateLimit;
889
1001
  }
890
1002
  if (!retryable) {
891
- if (allowFallback && !aborted) {
892
- nonRetryableError = { message: errMsg };
893
- } else {
1003
+ if (aborted) {
894
1004
  if (!committed) {
895
1005
  // Synthetic start+error so the stream protocol stays valid.
896
1006
  output.push({ type: "start", partial: freshMessage(attempt.model) });
897
1007
  committed = true;
898
1008
  }
899
1009
  const error = freshMessage(attempt.model);
900
- error.stopReason = aborted ? "aborted" : "error";
1010
+ error.stopReason = "aborted";
901
1011
  error.errorMessage = errMsg;
902
- output.push({
903
- type: "error",
904
- reason: error.stopReason as "error" | "aborted",
905
- error,
906
- });
1012
+ output.push({ type: "error", reason: "aborted", error });
907
1013
  output.end();
908
1014
  return;
909
1015
  }
1016
+ // Defer to the post-loop block for bounded retries / fallback.
1017
+ nonRetryableError = { message: errMsg };
910
1018
  }
911
1019
  }
912
1020
  } finally {
@@ -918,68 +1026,96 @@ export function streamWithLimitsRetry(
918
1026
  }
919
1027
  }
920
1028
 
921
- if (nonRetryableError) {
922
- if (allowFallback && !committed) {
923
- rememberNonRetryableFailure(attempt.model, nonRetryableError.message);
924
- const next = nextAvailableCandidate(attempt.model);
925
- if (next) {
926
- attempt = next;
927
- continue;
928
- }
1029
+ if (!retryable) {
1030
+ // Both explicit non-retryable error events/exceptions and a provider
1031
+ // stream that ended without a terminal event land here.
1032
+ const failureEvent = nonRetryableError?.event;
1033
+ const failureMessage = nonRetryableError?.message ?? "Provider stream ended without a terminal event.";
929
1034
 
930
- const deadline = earliestCandidateDeadline(attempt.model);
931
- if (deadline) {
932
- const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
933
- if (waitResult !== "aborted") {
934
- attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
935
- continue;
1035
+ const emitFailure = () => {
1036
+ if (!committed) {
1037
+ if (buffer.length > 0) {
1038
+ flush(buffer);
1039
+ } else {
1040
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
1041
+ committed = true;
936
1042
  }
937
1043
  }
938
- }
939
-
940
- if (!committed) {
941
- if (buffer.length > 0) {
942
- flush(buffer);
1044
+ if (failureEvent) {
1045
+ output.push(failureEvent);
943
1046
  } else {
1047
+ const error = freshMessage(attempt.model);
1048
+ error.stopReason = "error";
1049
+ error.errorMessage = failureMessage;
1050
+ output.push({ type: "error", reason: "error", error });
1051
+ }
1052
+ output.end();
1053
+ };
1054
+
1055
+ const emitAborted = () => {
1056
+ if (!committed) {
944
1057
  output.push({ type: "start", partial: freshMessage(attempt.model) });
945
1058
  committed = true;
946
1059
  }
947
- }
948
- if (nonRetryableError.event) {
949
- output.push(nonRetryableError.event);
950
- } else {
951
1060
  const error = freshMessage(attempt.model);
952
- error.stopReason = "error";
953
- error.errorMessage = nonRetryableError.message;
954
- output.push({ type: "error", reason: "error", error });
955
- }
956
- output.end();
957
- return;
958
- }
1061
+ error.stopReason = "aborted";
1062
+ error.errorMessage = "Request aborted while retrying after error.";
1063
+ output.push({ type: "error", reason: "aborted", error });
1064
+ output.end();
1065
+ };
959
1066
 
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
- }
1067
+ // Nothing has been streamed yet: give the same model a few quick retries
1068
+ // before we freeze it or fall back. This rides out transient failures
1069
+ // that aren't recognised as retryable (stalls, odd 5xx, malformed
1070
+ // errors) instead of immediately freezing the model for an hour.
970
1071
  if (!committed) {
971
- if (buffer.length > 0) {
972
- flush(buffer);
973
- } else {
974
- output.push({ type: "start", partial: freshMessage(attempt.model) });
975
- committed = true;
1072
+ const key = modelKey(attempt.model);
1073
+ const attempts = (nonRetryableAttempts.get(key) ?? 0) + 1;
1074
+ nonRetryableAttempts.set(key, attempts);
1075
+
1076
+ if (attempts < nonRetryableMaxAttempts) {
1077
+ const waitResult = await waitForRetry("retry", nonRetryableRetryDelayMs, options?.signal);
1078
+ if (waitResult === "aborted") {
1079
+ emitAborted();
1080
+ return;
1081
+ }
1082
+ continue; // retry the same model
1083
+ }
1084
+
1085
+ // Retries for this model are exhausted.
1086
+ triedModels.add(key);
1087
+
1088
+ if (allowFallback) {
1089
+ if (freezingEnabled()) {
1090
+ rememberNonRetryableFailure(attempt.model, failureMessage);
1091
+ const next = nextAvailableCandidate(attempt.model);
1092
+ if (next) {
1093
+ attempt = next;
1094
+ continue;
1095
+ }
1096
+
1097
+ const deadline = earliestCandidateDeadline(attempt.model);
1098
+ if (deadline) {
1099
+ const waitResult = await waitForRetry("model-frozen", Math.max(0, deadline - Date.now()), options?.signal);
1100
+ if (waitResult !== "aborted") {
1101
+ attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
1102
+ continue;
1103
+ }
1104
+ }
1105
+ } else {
1106
+ // Freezing disabled: walk through the remaining configured
1107
+ // candidates (without freezing) and only give up once they are
1108
+ // all exhausted. Never block on a long "model-frozen" wait.
1109
+ const next = candidateOrder(attempt.model).find((entry) => !triedModels.has(modelKey(entry.model)));
1110
+ if (next) {
1111
+ attempt = next;
1112
+ continue;
1113
+ }
1114
+ }
976
1115
  }
977
1116
  }
978
- const error = freshMessage(attempt.model);
979
- error.stopReason = "error";
980
- error.errorMessage = "Provider stream ended without a terminal event.";
981
- output.push({ type: "error", reason: "error", error });
982
- output.end();
1117
+
1118
+ emitFailure();
983
1119
  return;
984
1120
  }
985
1121