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.
- package/README.md +19 -21
- package/package.json +1 -1
- 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 `
|
|
30
|
+
To enable automatic model fallback, add `fallback-models` to a `limits-wait.json` file.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
The extension reads these files in order; later files override earlier ones:
|
|
33
33
|
|
|
34
34
|
| Location | Scope |
|
|
35
35
|
|----------|-------|
|
|
36
|
-
| `~/.pi/
|
|
37
|
-
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 `
|
|
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 `
|
|
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
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,
|
|
@@ -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
|
|
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,
|
|
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 {
|
|
134
|
-
? (raw as {
|
|
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
|
|
163
|
-
const
|
|
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)
|
|
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
|
|
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
|
-
|
|
236
|
+
modelWarnings.push(`missing ${entry.provider}/${entry.modelname}`);
|
|
185
237
|
continue;
|
|
186
238
|
}
|
|
187
239
|
if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
188
|
-
|
|
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 ${
|
|
202
|
-
: `Loaded ${
|
|
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(
|
|
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 ${
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>):
|
|
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)
|
|
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 =
|
|
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;
|