oira666_pi-limits-wait 0.3.0 → 0.4.0
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 +54 -0
- package/package.json +1 -1
- package/src/index.ts +314 -10
package/README.md
CHANGED
|
@@ -21,6 +21,60 @@ Restart Pi or run `/reload` after installing.
|
|
|
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
22
|
- Shows a countdown in the Pi status/working line.
|
|
23
23
|
- Press Enter during the countdown to skip the wait and retry immediately.
|
|
24
|
+
- Optionally falls back to configured models when the current/default model is rate-limited.
|
|
25
|
+
|
|
26
|
+
## Optional fallback models
|
|
27
|
+
|
|
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
|
+
|
|
30
|
+
To enable automatic model fallback, add `oira666_pi-subagents.try_models` to Pi's normal `settings.json`.
|
|
31
|
+
|
|
32
|
+
Pi settings can be global or project-local:
|
|
33
|
+
|
|
34
|
+
| Location | Scope |
|
|
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.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"oira666_pi-subagents": {
|
|
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
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each `try_models` entry supports:
|
|
62
|
+
|
|
63
|
+
- `provider` — required. The Pi provider name, for example `anthropic`, `openai`, `google`, etc.
|
|
64
|
+
- `modelname` — required. The model id/name as Pi knows it.
|
|
65
|
+
- `reasoning effort` — optional. One of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. If omitted, Pi's current/default reasoning level is used.
|
|
66
|
+
|
|
67
|
+
Fallback behavior:
|
|
68
|
+
|
|
69
|
+
1. Pi starts with the normal default or user-selected model.
|
|
70
|
+
2. If that model is rate-limited, the extension tries models in this order:
|
|
71
|
+
- the original default/user-selected model;
|
|
72
|
+
- then every model from `oira666_pi-subagents.try_models`, top to bottom.
|
|
73
|
+
3. The first model that responds without a rate-limit becomes the active Pi model for the rest of the session/task.
|
|
74
|
+
4. If that model later becomes rate-limited too, the extension starts again from the same ordered list.
|
|
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
|
+
|
|
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.
|
|
24
78
|
|
|
25
79
|
## License
|
|
26
80
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { SettingsManager, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
2
3
|
import {
|
|
3
4
|
createAssistantMessageEventStream,
|
|
4
5
|
getApiProviders,
|
|
@@ -27,9 +28,11 @@ const PI_IDENTITY_SENTENCE_PATTERN =
|
|
|
27
28
|
|
|
28
29
|
const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
|
|
29
30
|
const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
|
|
31
|
+
const SETTINGS_KEY = "oira666_pi-subagents";
|
|
30
32
|
|
|
31
33
|
/** Key used with ctx.ui.setStatus() for the countdown line. */
|
|
32
34
|
const STATUS_KEY = "limits-wait";
|
|
35
|
+
const MODELS_STATUS_KEY = "limits-wait-models";
|
|
33
36
|
|
|
34
37
|
type RetryReason = "rate-limit" | "overloaded";
|
|
35
38
|
|
|
@@ -38,13 +41,39 @@ type RetryableError = {
|
|
|
38
41
|
waitMs: number;
|
|
39
42
|
};
|
|
40
43
|
|
|
44
|
+
type ConfiguredModel = {
|
|
45
|
+
provider: string;
|
|
46
|
+
modelname: string;
|
|
47
|
+
reasoningEffort?: ThinkingLevel;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type FallbackModel = {
|
|
51
|
+
model: Model<Api>;
|
|
52
|
+
reasoningEffort?: ThinkingLevel;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type RateLimitMemory = {
|
|
56
|
+
reason: RetryReason;
|
|
57
|
+
limitedAt: number;
|
|
58
|
+
deadline: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
41
61
|
// ─── Shared UI context ────────────────────────────────────────────────────────
|
|
42
62
|
|
|
43
63
|
let sharedCtx: ExtensionContext | undefined;
|
|
64
|
+
let extensionApi: ExtensionAPI | undefined;
|
|
44
65
|
let restoreFetch: (() => void) | undefined;
|
|
45
66
|
let ambientStatusCleanup: (() => void) | undefined;
|
|
67
|
+
let modelStatusCleanup: (() => void) | undefined;
|
|
46
68
|
let activeProviderRequests = 0;
|
|
47
69
|
const wrappedApis = new Set<Api>();
|
|
70
|
+
const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
|
|
71
|
+
let fallbackModels: FallbackModel[] = [];
|
|
72
|
+
let primaryModel: Model<Api> | undefined;
|
|
73
|
+
let primaryThinkingLevel: ThinkingLevel | undefined;
|
|
74
|
+
let suppressNextModelSelect = false;
|
|
75
|
+
let settingsSignature: string | undefined;
|
|
76
|
+
const rateLimitMemory = new Map<string, RateLimitMemory>();
|
|
48
77
|
|
|
49
78
|
// ─── Anthropic subscription prompt sanitisation ──────────────────────────────
|
|
50
79
|
|
|
@@ -74,6 +103,229 @@ function isAnthropicOAuthSession(ctx: ExtensionContext): boolean {
|
|
|
74
103
|
);
|
|
75
104
|
}
|
|
76
105
|
|
|
106
|
+
// ─── Optional fallback model settings ────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function modelKey(model: Pick<Model<Api>, "provider" | "id">): string {
|
|
109
|
+
return `${model.provider}/${model.id}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatModel(model: Pick<Model<Api>, "provider" | "id">): string {
|
|
113
|
+
return `${model.provider}/${model.id}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
|
117
|
+
return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(String(value));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseConfiguredModels(raw: unknown): ConfiguredModel[] {
|
|
121
|
+
const source = raw && typeof raw === "object" && Array.isArray((raw as { try_models?: unknown }).try_models)
|
|
122
|
+
? (raw as { try_models: unknown[] }).try_models
|
|
123
|
+
: [];
|
|
124
|
+
|
|
125
|
+
const models: ConfiguredModel[] = [];
|
|
126
|
+
for (const item of source) {
|
|
127
|
+
if (!item || typeof item !== "object") continue;
|
|
128
|
+
const record = item as Record<string, unknown>;
|
|
129
|
+
const provider = typeof record.provider === "string" ? record.provider.trim() : "";
|
|
130
|
+
const modelname =
|
|
131
|
+
typeof record.modelname === "string" ? record.modelname.trim()
|
|
132
|
+
: typeof record.modelName === "string" ? record.modelName.trim()
|
|
133
|
+
: typeof record.model === "string" ? record.model.trim()
|
|
134
|
+
: "";
|
|
135
|
+
const reasoning = record["reasoning effort"] ?? record.reasoningEffort ?? record.reasoning_effort;
|
|
136
|
+
if (!provider || !modelname) continue;
|
|
137
|
+
models.push({
|
|
138
|
+
provider,
|
|
139
|
+
modelname,
|
|
140
|
+
reasoningEffort: isThinkingLevel(reasoning) ? reasoning : undefined,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return models;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function loadFallbackSettings(ctx: ExtensionContext): void {
|
|
147
|
+
fallbackModels = [];
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const settingsManager = SettingsManager.create(ctx.cwd);
|
|
151
|
+
const globalSettings = settingsManager.getGlobalSettings() as Record<string, unknown>;
|
|
152
|
+
const projectSettings = settingsManager.getProjectSettings() as Record<string, unknown>;
|
|
153
|
+
const globalConfig = globalSettings[SETTINGS_KEY];
|
|
154
|
+
const projectConfig = projectSettings[SETTINGS_KEY];
|
|
155
|
+
const config = {
|
|
156
|
+
...(globalConfig && typeof globalConfig === "object" && !Array.isArray(globalConfig) ? globalConfig : {}),
|
|
157
|
+
...(projectConfig && typeof projectConfig === "object" && !Array.isArray(projectConfig) ? projectConfig : {}),
|
|
158
|
+
};
|
|
159
|
+
const content = JSON.stringify(config);
|
|
160
|
+
const shouldNotify = settingsSignature !== content;
|
|
161
|
+
settingsSignature = content;
|
|
162
|
+
|
|
163
|
+
const configured = parseConfiguredModels(config);
|
|
164
|
+
if (configured.length === 0) return;
|
|
165
|
+
|
|
166
|
+
const warnings: string[] = [];
|
|
167
|
+
const seen = new Set<string>();
|
|
168
|
+
|
|
169
|
+
for (const entry of configured) {
|
|
170
|
+
const model = ctx.modelRegistry.find(entry.provider, entry.modelname);
|
|
171
|
+
if (!model) {
|
|
172
|
+
warnings.push(`missing ${entry.provider}/${entry.modelname}`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
176
|
+
warnings.push(`no auth ${entry.provider}/${entry.modelname}`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const key = modelKey(model);
|
|
180
|
+
if (seen.has(key)) continue;
|
|
181
|
+
seen.add(key);
|
|
182
|
+
fallbackModels.push({ model, reasoningEffort: entry.reasoningEffort });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const lines = fallbackModels.map((entry, index) =>
|
|
186
|
+
`${index + 1}. ${formatModel(entry.model)}${entry.reasoningEffort ? ` (${entry.reasoningEffort})` : ""}`,
|
|
187
|
+
);
|
|
188
|
+
const message = lines.length > 0
|
|
189
|
+
? `Loaded ${SETTINGS_KEY}.try_models:\n${lines.join("\n")}`
|
|
190
|
+
: `Loaded ${SETTINGS_KEY}.try_models, but no usable fallback models were found.`;
|
|
191
|
+
if (shouldNotify) {
|
|
192
|
+
ctx.ui.notify(warnings.length > 0 ? `${message}\nSkipped: ${warnings.join(", ")}` : message, warnings.length > 0 ? "warning" : "info");
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
ctx.ui.notify(`Could not load ${SETTINGS_KEY}.try_models: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function fallbackEnabled(): boolean {
|
|
200
|
+
return fallbackModels.length > 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getPrimaryModel(current: Model<Api>): Model<Api> {
|
|
204
|
+
return primaryModel ?? current;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function candidateOrder(current: Model<Api>): FallbackModel[] {
|
|
208
|
+
const primary = getPrimaryModel(current);
|
|
209
|
+
const order: FallbackModel[] = [{ model: primary, reasoningEffort: primaryThinkingLevel }];
|
|
210
|
+
const seen = new Set([modelKey(primary)]);
|
|
211
|
+
for (const entry of fallbackModels) {
|
|
212
|
+
const key = modelKey(entry.model);
|
|
213
|
+
if (seen.has(key)) continue;
|
|
214
|
+
seen.add(key);
|
|
215
|
+
order.push(entry);
|
|
216
|
+
}
|
|
217
|
+
return order;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function activeLimit(model: Model<Api>): RateLimitMemory | undefined {
|
|
221
|
+
const entry = rateLimitMemory.get(modelKey(model));
|
|
222
|
+
if (!entry) return undefined;
|
|
223
|
+
if (Date.now() >= entry.deadline) {
|
|
224
|
+
rateLimitMemory.delete(modelKey(model));
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
return entry;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function formatDuration(ms: number): string {
|
|
231
|
+
const totalSecs = Math.max(0, Math.ceil(ms / 1_000));
|
|
232
|
+
const hours = Math.floor(totalSecs / 3_600);
|
|
233
|
+
const mins = Math.floor((totalSecs % 3_600) / 60);
|
|
234
|
+
const secs = (totalSecs % 60).toString().padStart(2, "0");
|
|
235
|
+
return hours > 0 ? `${hours}h ${mins}m ${secs}s` : `${mins}m ${secs}s`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function updateRateLimitedModelsStatus(): void {
|
|
239
|
+
const ctx = sharedCtx;
|
|
240
|
+
if (!ctx) return;
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
const lines: string[] = [];
|
|
243
|
+
for (const [key, entry] of rateLimitMemory) {
|
|
244
|
+
if (now >= entry.deadline) {
|
|
245
|
+
rateLimitMemory.delete(key);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
lines.push(`${key}: ${formatDuration(entry.deadline - now)}`);
|
|
249
|
+
}
|
|
250
|
+
if (lines.length === 0) {
|
|
251
|
+
modelStatusCleanup?.();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
ctx.ui.setStatus(MODELS_STATUS_KEY, `🚦 Limited: ${lines.join(" | ")}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ensureRateLimitedModelsStatus(): void {
|
|
258
|
+
if (modelStatusCleanup) return;
|
|
259
|
+
updateRateLimitedModelsStatus();
|
|
260
|
+
const ticker = setInterval(updateRateLimitedModelsStatus, 1_000);
|
|
261
|
+
modelStatusCleanup = () => {
|
|
262
|
+
clearInterval(ticker);
|
|
263
|
+
modelStatusCleanup = undefined;
|
|
264
|
+
sharedCtx?.ui.setStatus(MODELS_STATUS_KEY, undefined);
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function rememberRateLimit(model: Model<Api>, retryable: RetryableError): void {
|
|
269
|
+
const deadline = Date.now() + retryable.waitMs;
|
|
270
|
+
rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
|
|
271
|
+
sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.`, "warning");
|
|
272
|
+
ensureRateLimitedModelsStatus();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined {
|
|
276
|
+
return candidateOrder(current).find((entry) => !activeLimit(entry.model));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function earliestCandidateDeadline(current: Model<Api>): number | undefined {
|
|
280
|
+
const deadlines = candidateOrder(current)
|
|
281
|
+
.map((entry) => activeLimit(entry.model)?.deadline)
|
|
282
|
+
.filter((deadline): deadline is number => typeof deadline === "number");
|
|
283
|
+
return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function initialAttempt(model: Model<Api>): FallbackModel {
|
|
287
|
+
const configured = fallbackModels.find((entry) => modelKey(entry.model) === modelKey(model));
|
|
288
|
+
const current = configured ?? { model, reasoningEffort: primaryThinkingLevel };
|
|
289
|
+
if (!fallbackEnabled() || !activeLimit(model)) return current;
|
|
290
|
+
return nextAvailableCandidate(model) ?? current;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function optionsForModel(
|
|
294
|
+
originalModel: Model<Api>,
|
|
295
|
+
target: FallbackModel,
|
|
296
|
+
options?: SimpleStreamOptions,
|
|
297
|
+
): Promise<SimpleStreamOptions | undefined> {
|
|
298
|
+
const level = target.reasoningEffort ?? primaryThinkingLevel;
|
|
299
|
+
const reasoning = level && level !== "off" ? level : undefined;
|
|
300
|
+
if (modelKey(originalModel) === modelKey(target.model)) {
|
|
301
|
+
return reasoning ? { ...options, reasoning } : options;
|
|
302
|
+
}
|
|
303
|
+
const auth = await sharedCtx?.modelRegistry.getApiKeyAndHeaders(target.model);
|
|
304
|
+
if (!auth?.ok) throw new Error(auth ? auth.error : "Model registry is unavailable.");
|
|
305
|
+
return {
|
|
306
|
+
...options,
|
|
307
|
+
...(reasoning ? { reasoning } : {}),
|
|
308
|
+
apiKey: auth.apiKey,
|
|
309
|
+
headers: auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined,
|
|
310
|
+
} as SimpleStreamOptions;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function switchPiModel(entry: FallbackModel): Promise<void> {
|
|
314
|
+
const pi = extensionApi;
|
|
315
|
+
if (!pi) return;
|
|
316
|
+
const current = sharedCtx?.model;
|
|
317
|
+
if (current && modelKey(current) === modelKey(entry.model)) return;
|
|
318
|
+
suppressNextModelSelect = true;
|
|
319
|
+
const ok = await pi.setModel(entry.model);
|
|
320
|
+
if (!ok) {
|
|
321
|
+
suppressNextModelSelect = false;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const level = entry.reasoningEffort ?? primaryThinkingLevel;
|
|
325
|
+
if (level) pi.setThinkingLevel(level);
|
|
326
|
+
sharedCtx?.ui.notify(`Switched to ${formatModel(entry.model)}${level ? ` (${level})` : ""}.`, "info");
|
|
327
|
+
}
|
|
328
|
+
|
|
77
329
|
// ─── Retryable error detection ────────────────────────────────────────────────
|
|
78
330
|
|
|
79
331
|
export function isRateLimitError(msg: string): boolean {
|
|
@@ -363,6 +615,7 @@ export function streamWithLimitsRetry(
|
|
|
363
615
|
|
|
364
616
|
void (async () => {
|
|
365
617
|
let committed = false;
|
|
618
|
+
let attempt: FallbackModel = initialAttempt(model);
|
|
366
619
|
|
|
367
620
|
const flush = (buffer: AssistantMessageEvent[]) => {
|
|
368
621
|
for (const event of buffer) output.push(event);
|
|
@@ -377,7 +630,15 @@ export function streamWithLimitsRetry(
|
|
|
377
630
|
|
|
378
631
|
try {
|
|
379
632
|
try {
|
|
380
|
-
const
|
|
633
|
+
const attemptOptions = fallbackEnabled()
|
|
634
|
+
? await optionsForModel(model, attempt, options)
|
|
635
|
+
: options;
|
|
636
|
+
const attemptDelegate = attempt.model.api === model.api
|
|
637
|
+
? delegate
|
|
638
|
+
: builtinStreamSimpleByApi.get(attempt.model.api)
|
|
639
|
+
?? getApiProviders().find((provider) => provider.api === attempt.model.api)?.streamSimple;
|
|
640
|
+
if (!attemptDelegate) throw new Error(`No stream handler registered for API ${attempt.model.api}.`);
|
|
641
|
+
const inner = attemptDelegate(attempt.model, context, attemptOptions);
|
|
381
642
|
for await (const event of inner) {
|
|
382
643
|
if (!committed) {
|
|
383
644
|
if (event.type === "error") {
|
|
@@ -389,7 +650,7 @@ export function streamWithLimitsRetry(
|
|
|
389
650
|
if (buffer.length > 0) {
|
|
390
651
|
flush(buffer);
|
|
391
652
|
} else {
|
|
392
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
653
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
393
654
|
committed = true;
|
|
394
655
|
}
|
|
395
656
|
output.push(event);
|
|
@@ -403,10 +664,11 @@ export function streamWithLimitsRetry(
|
|
|
403
664
|
}
|
|
404
665
|
|
|
405
666
|
// First non-start, non-error event means this attempt is real.
|
|
667
|
+
if (fallbackEnabled()) await switchPiModel(attempt);
|
|
406
668
|
if (buffer.length > 0) {
|
|
407
669
|
flush(buffer);
|
|
408
670
|
} else {
|
|
409
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
671
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
410
672
|
committed = true;
|
|
411
673
|
}
|
|
412
674
|
output.push(event);
|
|
@@ -430,10 +692,10 @@ export function streamWithLimitsRetry(
|
|
|
430
692
|
if (!retryable) {
|
|
431
693
|
if (!committed) {
|
|
432
694
|
// Synthetic start+error so the stream protocol stays valid.
|
|
433
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
695
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
434
696
|
committed = true;
|
|
435
697
|
}
|
|
436
|
-
const error = freshMessage(model);
|
|
698
|
+
const error = freshMessage(attempt.model);
|
|
437
699
|
error.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
438
700
|
error.errorMessage = errMsg;
|
|
439
701
|
output.push({
|
|
@@ -458,11 +720,11 @@ export function streamWithLimitsRetry(
|
|
|
458
720
|
if (buffer.length > 0) {
|
|
459
721
|
flush(buffer);
|
|
460
722
|
} else {
|
|
461
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
723
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
462
724
|
committed = true;
|
|
463
725
|
}
|
|
464
726
|
}
|
|
465
|
-
const error = freshMessage(model);
|
|
727
|
+
const error = freshMessage(attempt.model);
|
|
466
728
|
error.stopReason = "error";
|
|
467
729
|
error.errorMessage = "Provider stream ended without a terminal event.";
|
|
468
730
|
output.push({ type: "error", reason: "error", error });
|
|
@@ -470,13 +732,40 @@ export function streamWithLimitsRetry(
|
|
|
470
732
|
return;
|
|
471
733
|
}
|
|
472
734
|
|
|
735
|
+
if (fallbackEnabled() && retryable.reason === "rate-limit") {
|
|
736
|
+
rememberRateLimit(attempt.model, retryable);
|
|
737
|
+
const next = nextAvailableCandidate(attempt.model);
|
|
738
|
+
if (next) {
|
|
739
|
+
attempt = next;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const deadline = earliestCandidateDeadline(attempt.model);
|
|
744
|
+
const waitMs = deadline ? Math.max(0, deadline - Date.now()) : retryable.waitMs;
|
|
745
|
+
const waitResult = await waitForRetry(retryable.reason, waitMs, options?.signal);
|
|
746
|
+
if (waitResult === "aborted") {
|
|
747
|
+
if (!committed) {
|
|
748
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
749
|
+
committed = true;
|
|
750
|
+
}
|
|
751
|
+
const error = freshMessage(attempt.model);
|
|
752
|
+
error.stopReason = "aborted";
|
|
753
|
+
error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
|
|
754
|
+
output.push({ type: "error", reason: "aborted", error });
|
|
755
|
+
output.end();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
|
|
473
762
|
const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
|
|
474
763
|
if (waitResult === "aborted") {
|
|
475
764
|
if (!committed) {
|
|
476
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
765
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
477
766
|
committed = true;
|
|
478
767
|
}
|
|
479
|
-
const error = freshMessage(model);
|
|
768
|
+
const error = freshMessage(attempt.model);
|
|
480
769
|
error.stopReason = "aborted";
|
|
481
770
|
error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
|
|
482
771
|
output.push({ type: "error", reason: "aborted", error });
|
|
@@ -499,6 +788,7 @@ function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
|
|
|
499
788
|
const builtinStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple;
|
|
500
789
|
if (!builtinStreamSimple) return;
|
|
501
790
|
|
|
791
|
+
builtinStreamSimpleByApi.set(api, builtinStreamSimple);
|
|
502
792
|
wrappedApis.add(api);
|
|
503
793
|
pi.registerProvider(`limits-wait-${api}`, {
|
|
504
794
|
api,
|
|
@@ -510,8 +800,22 @@ function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
|
|
|
510
800
|
// ─── Extension entry point ────────────────────────────────────────────────────
|
|
511
801
|
|
|
512
802
|
export default function (pi: ExtensionAPI) {
|
|
803
|
+
extensionApi = pi;
|
|
804
|
+
|
|
805
|
+
pi.on("model_select", (event) => {
|
|
806
|
+
if (suppressNextModelSelect) {
|
|
807
|
+
suppressNextModelSelect = false;
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
primaryModel = event.model;
|
|
811
|
+
primaryThinkingLevel = pi.getThinkingLevel();
|
|
812
|
+
});
|
|
813
|
+
|
|
513
814
|
pi.on("before_agent_start", (event, ctx) => {
|
|
514
815
|
sharedCtx = ctx;
|
|
816
|
+
primaryModel ??= ctx.model;
|
|
817
|
+
primaryThinkingLevel ??= pi.getThinkingLevel();
|
|
818
|
+
loadFallbackSettings(ctx);
|
|
515
819
|
|
|
516
820
|
// Some extensions may register providers after this extension loads. Wrap
|
|
517
821
|
// any APIs that exist by the time an agent starts too.
|