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.
- package/README.md +27 -21
- package/package.json +1 -1
- 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 `
|
|
32
|
+
To enable automatic model fallback, add `fallback-models` to a `limits-wait.json` file.
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
The extension reads these files in order; later files override earlier ones:
|
|
33
35
|
|
|
34
36
|
| Location | Scope |
|
|
35
37
|
|----------|-------|
|
|
36
|
-
| `~/.pi/
|
|
37
|
-
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 `
|
|
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 `
|
|
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
package/src/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
|
32
|
-
const
|
|
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 {
|
|
142
|
-
? (raw as {
|
|
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
|
|
171
|
-
const
|
|
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)
|
|
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
|
|
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
|
-
|
|
245
|
+
modelWarnings.push(`missing ${entry.provider}/${entry.modelname}`);
|
|
193
246
|
continue;
|
|
194
247
|
}
|
|
195
248
|
if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
196
|
-
|
|
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 ${
|
|
210
|
-
: `Loaded ${
|
|
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(
|
|
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 ${
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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 (
|
|
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 =
|
|
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 (
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
931
|
-
if (
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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 = "
|
|
953
|
-
error.errorMessage =
|
|
954
|
-
output.push({ type: "error", reason: "
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
979
|
-
|
|
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
|
|