oira666_pi-limits-wait 0.4.5 → 0.4.8

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 +19 -21
  2. package/package.json +1 -1
  3. package/src/index.ts +188 -49
package/README.md CHANGED
@@ -27,38 +27,36 @@ Restart Pi or run `/reload` after installing.
27
27
 
28
28
  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
29
 
30
- To enable automatic model fallback, add `oira666_pi-limits-wait.try_models` to Pi's normal `settings.json`.
30
+ To enable automatic model fallback, add `fallback-models` to a `limits-wait.json` file.
31
31
 
32
- Pi settings can be global or project-local:
32
+ The extension reads these files in order; later files override earlier ones:
33
33
 
34
34
  | Location | Scope |
35
35
  |----------|-------|
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.
36
+ | `~/.config/.pi/limits-wait.json` | Global defaults |
37
+ | `<Pi agent dir>/limits-wait.json` (for example `~/.pi/agent/limits-wait.json`, or `PI_CODING_AGENT_DIR/limits-wait.json`) | Pi agent directory |
38
+ | `.limits-wait.json` | Project root override |
39
+ | `.pi/limits-wait.json` | Pi project override |
40
40
 
41
41
  Example:
42
42
 
43
43
  ```json
44
44
  {
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
- }
45
+ "fallback-models": [
46
+ {
47
+ "provider": "anthropic",
48
+ "modelname": "claude-sonnet-4-5",
49
+ "reasoning effort": "medium"
50
+ },
51
+ {
52
+ "provider": "openai",
53
+ "modelname": "gpt-5.1-codex"
54
+ }
55
+ ]
58
56
  }
59
57
  ```
60
58
 
61
- Each `try_models` entry supports:
59
+ Each `fallback-models` entry supports:
62
60
 
63
61
  - `provider` — required. The Pi provider name, for example `anthropic`, `openai`, `google`, etc.
64
62
  - `modelname` — required. The model id/name as Pi knows it.
@@ -69,7 +67,7 @@ Fallback behavior:
69
67
  1. Pi starts with the normal default or user-selected model.
70
68
  2. If that model is rate-limited, the extension tries models in this order:
71
69
  - the original default/user-selected model;
72
- - then every model from `oira666_pi-limits-wait.try_models`, top to bottom.
70
+ - then every model from `fallback-models`, top to bottom.
73
71
  3. The first model that responds without a rate-limit becomes the active Pi model for the rest of the session/task.
74
72
  4. If that model later becomes rate-limited too, the extension starts again from the same ordered list.
75
73
  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.
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.8",
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,
@@ -29,7 +32,8 @@ const PI_IDENTITY_SENTENCE_PATTERN =
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
34
  const DEFAULT_NON_RETRYABLE_FREEZE_MS = 60 * 60 * 1_000; // 1 hour
32
- const SETTINGS_KEY = "oira666_pi-limits-wait";
35
+ const SETTINGS_FILE_NAME = "limits-wait.json";
36
+ const FALLBACK_MODELS_KEY = "fallback-models";
33
37
 
34
38
  /** Key used with ctx.ui.setStatus() for the countdown line. */
35
39
  const STATUS_KEY = "limits-wait";
@@ -59,6 +63,12 @@ type RateLimitMemory = {
59
63
  deadline: number;
60
64
  };
61
65
 
66
+ type NonRetryableFailureMemory = {
67
+ failedAt: number;
68
+ deadline: number;
69
+ errorMessage: string;
70
+ };
71
+
62
72
  // ─── Shared UI context ────────────────────────────────────────────────────────
63
73
 
64
74
  let sharedCtx: ExtensionContext | undefined;
@@ -67,6 +77,8 @@ let restoreFetch: (() => void) | undefined;
67
77
  let ambientStatusCleanup: (() => void) | undefined;
68
78
  let modelStatusCleanup: (() => void) | undefined;
69
79
  let activeProviderRequests = 0;
80
+ let activeFallbackProviderRequests = 0;
81
+ let lastObservedRateLimitError: { at: number; message: string } | undefined;
70
82
  const wrappedApis = new Set<Api>();
71
83
  const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
72
84
  let fallbackModels: FallbackModel[] = [];
@@ -75,7 +87,7 @@ let primaryThinkingLevel: ThinkingLevel | undefined;
75
87
  let suppressNextModelSelect = false;
76
88
  let settingsSignature: string | undefined;
77
89
  const rateLimitMemory = new Map<string, RateLimitMemory>();
78
- const nonRetryableFailureMemory = new Map<string, { failedAt: number; deadline: number; errorMessage: string }>();
90
+ const nonRetryableFailureMemory = new Map<string, NonRetryableFailureMemory>();
79
91
 
80
92
  // ─── Anthropic subscription prompt sanitisation ──────────────────────────────
81
93
 
@@ -130,8 +142,8 @@ function isThinkingLevel(value: unknown): value is ThinkingLevel {
130
142
  }
131
143
 
132
144
  function parseConfiguredModels(raw: unknown): ConfiguredModel[] {
133
- const source = raw && typeof raw === "object" && Array.isArray((raw as { try_models?: unknown }).try_models)
134
- ? (raw as { try_models: unknown[] }).try_models
145
+ const source = raw && typeof raw === "object" && Array.isArray((raw as { [FALLBACK_MODELS_KEY]?: unknown })[FALLBACK_MODELS_KEY])
146
+ ? (raw as { [FALLBACK_MODELS_KEY]: unknown[] })[FALLBACK_MODELS_KEY]
135
147
  : [];
136
148
 
137
149
  const models: ConfiguredModel[] = [];
@@ -155,37 +167,77 @@ function parseConfiguredModels(raw: unknown): ConfiguredModel[] {
155
167
  return models;
156
168
  }
157
169
 
170
+ function configFileCandidates(cwd: string, agentDir = getAgentDir(), homeDir = homedir()): string[] {
171
+ return [
172
+ join(homeDir, ".config", ".pi", SETTINGS_FILE_NAME),
173
+ join(agentDir, SETTINGS_FILE_NAME),
174
+ resolve(cwd, `.${SETTINGS_FILE_NAME}`),
175
+ resolve(cwd, ".pi", SETTINGS_FILE_NAME),
176
+ ];
177
+ }
178
+
179
+ function mergeConfig(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
180
+ return { ...base, ...override };
181
+ }
182
+
183
+ function readFallbackSettings(cwd: string, agentDir = getAgentDir(), homeDir = homedir()): { config: Record<string, unknown>; loadedPaths: string[]; warnings: string[] } {
184
+ let config: Record<string, unknown> = {};
185
+ const loadedPaths: string[] = [];
186
+ const warnings: string[] = [];
187
+ const seen = new Set<string>();
188
+
189
+ for (const candidate of configFileCandidates(cwd, agentDir, homeDir)) {
190
+ const path = resolve(candidate);
191
+ if (seen.has(path)) continue;
192
+ seen.add(path);
193
+ if (!existsSync(path)) continue;
194
+
195
+ try {
196
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
197
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
198
+ warnings.push(`${path}: expected a JSON object`);
199
+ continue;
200
+ }
201
+ config = mergeConfig(config, parsed as Record<string, unknown>);
202
+ loadedPaths.push(path);
203
+ } catch (err) {
204
+ warnings.push(`${path}: ${err instanceof Error ? err.message : String(err)}`);
205
+ }
206
+ }
207
+
208
+ return { config, loadedPaths, warnings };
209
+ }
210
+
211
+ export function __readFallbackSettingsForTests(cwd: string, agentDir: string, homeDir: string): { config: Record<string, unknown>; loadedPaths: string[]; warnings: string[] } {
212
+ return readFallbackSettings(cwd, agentDir, homeDir);
213
+ }
214
+
158
215
  function loadFallbackSettings(ctx: ExtensionContext): void {
159
216
  fallbackModels = [];
160
217
 
161
218
  try {
162
- const settingsManager = SettingsManager.create(ctx.cwd);
163
- const globalSettings = settingsManager.getGlobalSettings() as Record<string, unknown>;
164
- const projectSettings = settingsManager.getProjectSettings() as Record<string, unknown>;
165
- const globalConfig = globalSettings[SETTINGS_KEY];
166
- const projectConfig = projectSettings[SETTINGS_KEY];
167
- const config = {
168
- ...(globalConfig && typeof globalConfig === "object" && !Array.isArray(globalConfig) ? globalConfig : {}),
169
- ...(projectConfig && typeof projectConfig === "object" && !Array.isArray(projectConfig) ? projectConfig : {}),
170
- };
171
- const content = JSON.stringify(config);
219
+ const { config, loadedPaths, warnings } = readFallbackSettings(ctx.cwd);
220
+ const content = JSON.stringify({ config, loadedPaths, warnings });
172
221
  const shouldNotify = settingsSignature !== content;
173
222
  settingsSignature = content;
174
223
 
175
224
  const configured = parseConfiguredModels(config);
176
- if (configured.length === 0) return;
225
+ if (configured.length === 0) {
226
+ if (shouldNotify && warnings.length > 0) ctx.ui.notify(`Could not fully load ${SETTINGS_FILE_NAME}: ${warnings.join(", ")}`, "warning");
227
+ return;
228
+ }
177
229
 
178
- const warnings: string[] = [];
230
+ const modelWarnings: string[] = [];
179
231
  const seen = new Set<string>();
180
232
 
181
233
  for (const entry of configured) {
182
234
  const model = ctx.modelRegistry.find(entry.provider, entry.modelname);
183
235
  if (!model) {
184
- warnings.push(`missing ${entry.provider}/${entry.modelname}`);
236
+ modelWarnings.push(`missing ${entry.provider}/${entry.modelname}`);
185
237
  continue;
186
238
  }
187
239
  if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
188
- warnings.push(`no auth ${entry.provider}/${entry.modelname}`);
240
+ modelWarnings.push(`no auth ${entry.provider}/${entry.modelname}`);
189
241
  continue;
190
242
  }
191
243
  const key = modelKey(model);
@@ -197,14 +249,16 @@ function loadFallbackSettings(ctx: ExtensionContext): void {
197
249
  const lines = fallbackModels.map((entry, index) =>
198
250
  `${index + 1}. ${formatModel(entry.model)}${entry.reasoningEffort ? ` (${entry.reasoningEffort})` : ""}`,
199
251
  );
252
+ const source = loadedPaths.length > 0 ? ` from ${loadedPaths.join(", ")}` : "";
200
253
  const message = lines.length > 0
201
- ? `Loaded ${SETTINGS_KEY}.try_models:\n${lines.join("\n")}`
202
- : `Loaded ${SETTINGS_KEY}.try_models, but no usable fallback models were found.`;
254
+ ? `Loaded ${SETTINGS_FILE_NAME}.${FALLBACK_MODELS_KEY}${source}:\n${lines.join("\n")}`
255
+ : `Loaded ${SETTINGS_FILE_NAME}.${FALLBACK_MODELS_KEY}${source}, but no usable fallback models were found.`;
256
+ const allWarnings = [...warnings, ...modelWarnings];
203
257
  if (shouldNotify) {
204
- ctx.ui.notify(warnings.length > 0 ? `${message}\nSkipped: ${warnings.join(", ")}` : message, warnings.length > 0 ? "warning" : "info");
258
+ ctx.ui.notify(allWarnings.length > 0 ? `${message}\nSkipped: ${allWarnings.join(", ")}` : message, allWarnings.length > 0 ? "warning" : "info");
205
259
  }
206
260
  } catch (err) {
207
- ctx.ui.notify(`Could not load ${SETTINGS_KEY}.try_models: ${err instanceof Error ? err.message : String(err)}`, "warning");
261
+ ctx.ui.notify(`Could not load ${SETTINGS_FILE_NAME}.${FALLBACK_MODELS_KEY}: ${err instanceof Error ? err.message : String(err)}`, "warning");
208
262
  }
209
263
  }
210
264
 
@@ -250,27 +304,31 @@ function formatDuration(ms: number): string {
250
304
  function updateRateLimitedModelsStatus(): void {
251
305
  const ctx = sharedCtx;
252
306
  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;
307
+ try {
308
+ const now = Date.now();
309
+ const lines: string[] = [];
310
+ for (const [key, entry] of rateLimitMemory) {
311
+ if (now >= entry.deadline) {
312
+ rateLimitMemory.delete(key);
313
+ continue;
314
+ }
315
+ lines.push(`${key}: ${formatDuration(entry.deadline - now)}`);
259
316
  }
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;
317
+ for (const [key, entry] of nonRetryableFailureMemory) {
318
+ if (now >= entry.deadline) {
319
+ nonRetryableFailureMemory.delete(key);
320
+ continue;
321
+ }
322
+ lines.push(`${key}: frozen ${formatDuration(entry.deadline - now)}`);
266
323
  }
267
- lines.push(`${key}: frozen ${formatDuration(entry.deadline - now)}`);
268
- }
269
- if (lines.length === 0) {
324
+ if (lines.length === 0) {
325
+ modelStatusCleanup?.();
326
+ return;
327
+ }
328
+ ctx.ui.setStatus(MODELS_STATUS_KEY, `🚦 Limited: ${lines.join(" | ")}`);
329
+ } catch {
270
330
  modelStatusCleanup?.();
271
- return;
272
331
  }
273
- ctx.ui.setStatus(MODELS_STATUS_KEY, `🚦 Limited: ${lines.join(" | ")}`);
274
332
  }
275
333
 
276
334
  function ensureRateLimitedModelsStatus(): void {
@@ -280,18 +338,23 @@ function ensureRateLimitedModelsStatus(): void {
280
338
  modelStatusCleanup = () => {
281
339
  clearInterval(ticker);
282
340
  modelStatusCleanup = undefined;
283
- sharedCtx?.ui.setStatus(MODELS_STATUS_KEY, undefined);
341
+ try {
342
+ sharedCtx?.ui.setStatus(MODELS_STATUS_KEY, undefined);
343
+ } catch {
344
+ // The extension context can become stale after print-mode session teardown.
345
+ }
284
346
  };
285
347
  }
286
348
 
287
- function rememberRateLimit(model: Model<Api>, retryable: RetryableError): void {
349
+ function rememberRateLimit(model: Model<Api>, retryable: RetryableError, errorMessage?: string): void {
288
350
  const deadline = Date.now() + retryable.waitMs;
289
351
  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");
352
+ const detail = errorMessage ? ` Error: ${formatErrorDetail(errorMessage)}` : "";
353
+ sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.${detail}`, "warning");
291
354
  ensureRateLimitedModelsStatus();
292
355
  }
293
356
 
294
- function activeNonRetryableFailure(model: Model<Api>): { failedAt: number; deadline: number; errorMessage: string } | undefined {
357
+ function activeNonRetryableFailure(model: Model<Api>): NonRetryableFailureMemory | undefined {
295
358
  const entry = nonRetryableFailureMemory.get(modelKey(model));
296
359
  if (!entry) return undefined;
297
360
  if (Date.now() >= entry.deadline) {
@@ -309,13 +372,38 @@ function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined
309
372
  return candidateOrder(current).find((entry) => !activeLimit(entry.model) && !hasNonRetryableFailure(entry.model));
310
373
  }
311
374
 
375
+ function formatErrorDetail(errorMessage: string, maxLength = 500): string {
376
+ const oneLine = errorMessage.replace(/\s+/g, " ").trim();
377
+ if (!oneLine) return "unknown error";
378
+ return oneLine.length > maxLength ? `${oneLine.slice(0, maxLength - 1)}…` : oneLine;
379
+ }
380
+
312
381
  function rememberNonRetryableFailure(model: Model<Api>, errorMessage: string): void {
313
382
  const deadline = Date.now() + DEFAULT_NON_RETRYABLE_FREEZE_MS;
314
383
  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");
384
+ 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
385
  ensureRateLimitedModelsStatus();
317
386
  }
318
387
 
388
+ function errorMessageWithCauses(err: unknown): string {
389
+ const messages: string[] = [];
390
+ let current: unknown = err;
391
+ const seen = new Set<unknown>();
392
+
393
+ while (current && !seen.has(current)) {
394
+ seen.add(current);
395
+ if (current instanceof Error) {
396
+ if (current.message) messages.push(current.message);
397
+ current = (current as Error & { cause?: unknown }).cause;
398
+ continue;
399
+ }
400
+ messages.push(String(current));
401
+ break;
402
+ }
403
+
404
+ return messages.filter(Boolean).join(" Cause: ") || String(err);
405
+ }
406
+
319
407
  function isAbortMessage(msg: string): boolean {
320
408
  const lower = msg.toLowerCase();
321
409
  return (
@@ -335,6 +423,12 @@ function isAbortErrorEvent(event: AssistantMessageEvent, signal?: AbortSignal):
335
423
  return reason === "aborted" || stopReason === "aborted" || isAbortMessage(message);
336
424
  }
337
425
 
426
+ 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;
430
+ }
431
+
338
432
  function earliestCandidateDeadline(current: Model<Api>): number | undefined {
339
433
  const deadlines = candidateOrder(current)
340
434
  .map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
@@ -624,6 +718,19 @@ export function waitForRateLimit(
624
718
 
625
719
  // ─── Early 429 observer ──────────────────────────────────────────────────────
626
720
 
721
+ async function responseErrorMessage(response: Response): Promise<string> {
722
+ const retryAfterMs = retryAfterHeaderMs(response.headers);
723
+ const retryAfter = retryAfterMs !== undefined ? ` retry-after-ms ${retryAfterMs}` : "";
724
+ let body = "";
725
+ try {
726
+ body = await response.clone().text();
727
+ } catch {
728
+ // Ignore body read failures; status and retry headers are enough.
729
+ }
730
+ const detail = body.trim() ? ` ${formatErrorDetail(body)}` : "";
731
+ return `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}${retryAfter}${detail}`;
732
+ }
733
+
627
734
  function installFetchRateLimitObserver(): void {
628
735
  if (restoreFetch || typeof globalThis.fetch !== "function") return;
629
736
 
@@ -634,7 +741,12 @@ function installFetchRateLimitObserver(): void {
634
741
  if (activeProviderRequests > 0) {
635
742
  if (response.status === 429) {
636
743
  const waitMs = retryAfterHeaderMs(response.headers) ?? DEFAULT_RATE_LIMIT_WAIT_MS;
744
+ const message = await responseErrorMessage(response);
745
+ lastObservedRateLimitError = { at: Date.now(), message };
637
746
  showAmbientRetryStatus("rate-limit", waitMs);
747
+ if (activeFallbackProviderRequests > 0) {
748
+ throw new Error(message);
749
+ }
638
750
  } else if (ambientStatusCleanup && response.ok) {
639
751
  clearAmbientRetryStatus();
640
752
  }
@@ -667,6 +779,7 @@ export function __configureFallbackModelsForTests(
667
779
  primaryThinkingLevel = undefined;
668
780
  rateLimitMemory.clear();
669
781
  nonRetryableFailureMemory.clear();
782
+ lastObservedRateLimitError = undefined;
670
783
  ambientStatusCleanup?.();
671
784
  modelStatusCleanup?.();
672
785
  }
@@ -716,9 +829,11 @@ export function streamWithLimitsRetry(
716
829
 
717
830
  while (true) {
718
831
  activeProviderRequests++;
832
+ if (allowFallback) activeFallbackProviderRequests++;
719
833
  installFetchRateLimitObserver();
720
834
  const buffer: AssistantMessageEvent[] = [];
721
835
  let retryable: RetryableError | undefined;
836
+ let retryableErrorMessage = "";
722
837
  let nonRetryableError: { message: string; event?: AssistantMessageEvent } | undefined;
723
838
 
724
839
  try {
@@ -753,14 +868,22 @@ export function streamWithLimitsRetry(
753
868
  }
754
869
 
755
870
  retryable = getRetryableError(errMsg);
756
- if (retryable) break;
871
+ if (retryable) {
872
+ retryableErrorMessage = errMsg;
873
+ break;
874
+ }
875
+ const observedRateLimit = allowFallback ? recentObservedRateLimitError() : undefined;
876
+ retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
877
+ if (retryable && observedRateLimit) {
878
+ retryableErrorMessage = observedRateLimit;
879
+ break;
880
+ }
757
881
 
758
882
  if (allowFallback) {
759
883
  nonRetryableError = { message: errMsg, event };
760
884
  break;
761
885
  }
762
886
 
763
- // Non-retryable error without fallbacks: ensure start came first, then forward error.
764
887
  if (buffer.length > 0) {
765
888
  flush(buffer);
766
889
  } else {
@@ -801,9 +924,15 @@ export function streamWithLimitsRetry(
801
924
  }
802
925
  }
803
926
  } catch (err) {
804
- const errMsg = err instanceof Error ? err.message : String(err);
927
+ const errMsg = errorMessageWithCauses(err);
805
928
  const aborted = Boolean(options?.signal?.aborted) || isAbortMessage(errMsg);
806
929
  retryable = !aborted ? getRetryableError(errMsg) : undefined;
930
+ if (retryable) retryableErrorMessage = errMsg;
931
+ if (!retryable && allowFallback) {
932
+ const observedRateLimit = recentObservedRateLimitError();
933
+ retryable = observedRateLimit ? getRetryableError(observedRateLimit) : undefined;
934
+ if (retryable && observedRateLimit) retryableErrorMessage = observedRateLimit;
935
+ }
807
936
  if (!retryable) {
808
937
  if (allowFallback && !aborted) {
809
938
  nonRetryableError = { message: errMsg };
@@ -828,6 +957,7 @@ export function streamWithLimitsRetry(
828
957
  }
829
958
  } finally {
830
959
  activeProviderRequests--;
960
+ if (allowFallback) activeFallbackProviderRequests--;
831
961
  if (activeProviderRequests === 0) {
832
962
  clearAmbientRetryStatus();
833
963
  restoreFetch?.();
@@ -874,6 +1004,15 @@ export function streamWithLimitsRetry(
874
1004
  }
875
1005
 
876
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
+ }
877
1016
  if (!committed) {
878
1017
  if (buffer.length > 0) {
879
1018
  flush(buffer);
@@ -891,7 +1030,7 @@ export function streamWithLimitsRetry(
891
1030
  }
892
1031
 
893
1032
  if (allowFallback) {
894
- rememberRateLimit(attempt.model, retryable);
1033
+ rememberRateLimit(attempt.model, retryable, retryableErrorMessage);
895
1034
  const next = nextAvailableCandidate(attempt.model);
896
1035
  if (next) {
897
1036
  attempt = next;