oira666_pi-limits-wait 0.2.4 → 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 +368 -11
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,
|
|
@@ -13,11 +14,25 @@ import {
|
|
|
13
14
|
|
|
14
15
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
15
16
|
|
|
17
|
+
const CLAUDE_CODE_IDENTITY =
|
|
18
|
+
"You are Claude Code, Anthropic's official CLI for Claude.";
|
|
19
|
+
|
|
20
|
+
const PI_REMOVAL_ANCHORS = [
|
|
21
|
+
"pi-coding-agent",
|
|
22
|
+
"@mariozechner/pi-coding-agent",
|
|
23
|
+
"badlogic/pi-mono",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
const PI_IDENTITY_SENTENCE_PATTERN =
|
|
27
|
+
/(?:^|\n)\s*You are pi\b[^.!?\n]*(?:[.!?](?=\s|$)|(?=\n|$))/gi;
|
|
28
|
+
|
|
16
29
|
const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
|
|
17
30
|
const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
|
|
31
|
+
const SETTINGS_KEY = "oira666_pi-subagents";
|
|
18
32
|
|
|
19
33
|
/** Key used with ctx.ui.setStatus() for the countdown line. */
|
|
20
34
|
const STATUS_KEY = "limits-wait";
|
|
35
|
+
const MODELS_STATUS_KEY = "limits-wait-models";
|
|
21
36
|
|
|
22
37
|
type RetryReason = "rate-limit" | "overloaded";
|
|
23
38
|
|
|
@@ -26,13 +41,290 @@ type RetryableError = {
|
|
|
26
41
|
waitMs: number;
|
|
27
42
|
};
|
|
28
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
|
+
|
|
29
61
|
// ─── Shared UI context ────────────────────────────────────────────────────────
|
|
30
62
|
|
|
31
63
|
let sharedCtx: ExtensionContext | undefined;
|
|
64
|
+
let extensionApi: ExtensionAPI | undefined;
|
|
32
65
|
let restoreFetch: (() => void) | undefined;
|
|
33
66
|
let ambientStatusCleanup: (() => void) | undefined;
|
|
67
|
+
let modelStatusCleanup: (() => void) | undefined;
|
|
34
68
|
let activeProviderRequests = 0;
|
|
35
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>();
|
|
77
|
+
|
|
78
|
+
// ─── Anthropic subscription prompt sanitisation ──────────────────────────────
|
|
79
|
+
|
|
80
|
+
export function sanitiseSystemPrompt(raw: string): string {
|
|
81
|
+
const paragraphs = raw.split(/\n\n+/);
|
|
82
|
+
const filtered = paragraphs.filter((p) =>
|
|
83
|
+
!PI_REMOVAL_ANCHORS.some((anchor) => p.includes(anchor)),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return filtered
|
|
87
|
+
.join("\n\n")
|
|
88
|
+
.replace(PI_IDENTITY_SENTENCE_PATTERN, "")
|
|
89
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
90
|
+
.trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns true if Anthropic OAuth is configured for this session, regardless
|
|
95
|
+
* of which model is currently active. This handles cases where a synthetic or
|
|
96
|
+
* temporary model (e.g. a subagent/resume provider) is active at
|
|
97
|
+
* before_agent_start time, even though the session will ultimately use an
|
|
98
|
+
* Anthropic subscription model.
|
|
99
|
+
*/
|
|
100
|
+
function isAnthropicOAuthSession(ctx: ExtensionContext): boolean {
|
|
101
|
+
return ctx.modelRegistry.isUsingOAuth(
|
|
102
|
+
{ provider: "anthropic" } as Parameters<typeof ctx.modelRegistry.isUsingOAuth>[0],
|
|
103
|
+
);
|
|
104
|
+
}
|
|
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
|
+
}
|
|
36
328
|
|
|
37
329
|
// ─── Retryable error detection ────────────────────────────────────────────────
|
|
38
330
|
|
|
@@ -323,6 +615,7 @@ export function streamWithLimitsRetry(
|
|
|
323
615
|
|
|
324
616
|
void (async () => {
|
|
325
617
|
let committed = false;
|
|
618
|
+
let attempt: FallbackModel = initialAttempt(model);
|
|
326
619
|
|
|
327
620
|
const flush = (buffer: AssistantMessageEvent[]) => {
|
|
328
621
|
for (const event of buffer) output.push(event);
|
|
@@ -337,7 +630,15 @@ export function streamWithLimitsRetry(
|
|
|
337
630
|
|
|
338
631
|
try {
|
|
339
632
|
try {
|
|
340
|
-
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);
|
|
341
642
|
for await (const event of inner) {
|
|
342
643
|
if (!committed) {
|
|
343
644
|
if (event.type === "error") {
|
|
@@ -349,7 +650,7 @@ export function streamWithLimitsRetry(
|
|
|
349
650
|
if (buffer.length > 0) {
|
|
350
651
|
flush(buffer);
|
|
351
652
|
} else {
|
|
352
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
653
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
353
654
|
committed = true;
|
|
354
655
|
}
|
|
355
656
|
output.push(event);
|
|
@@ -363,10 +664,11 @@ export function streamWithLimitsRetry(
|
|
|
363
664
|
}
|
|
364
665
|
|
|
365
666
|
// First non-start, non-error event means this attempt is real.
|
|
667
|
+
if (fallbackEnabled()) await switchPiModel(attempt);
|
|
366
668
|
if (buffer.length > 0) {
|
|
367
669
|
flush(buffer);
|
|
368
670
|
} else {
|
|
369
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
671
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
370
672
|
committed = true;
|
|
371
673
|
}
|
|
372
674
|
output.push(event);
|
|
@@ -390,10 +692,10 @@ export function streamWithLimitsRetry(
|
|
|
390
692
|
if (!retryable) {
|
|
391
693
|
if (!committed) {
|
|
392
694
|
// Synthetic start+error so the stream protocol stays valid.
|
|
393
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
695
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
394
696
|
committed = true;
|
|
395
697
|
}
|
|
396
|
-
const error = freshMessage(model);
|
|
698
|
+
const error = freshMessage(attempt.model);
|
|
397
699
|
error.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
398
700
|
error.errorMessage = errMsg;
|
|
399
701
|
output.push({
|
|
@@ -418,11 +720,11 @@ export function streamWithLimitsRetry(
|
|
|
418
720
|
if (buffer.length > 0) {
|
|
419
721
|
flush(buffer);
|
|
420
722
|
} else {
|
|
421
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
723
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
422
724
|
committed = true;
|
|
423
725
|
}
|
|
424
726
|
}
|
|
425
|
-
const error = freshMessage(model);
|
|
727
|
+
const error = freshMessage(attempt.model);
|
|
426
728
|
error.stopReason = "error";
|
|
427
729
|
error.errorMessage = "Provider stream ended without a terminal event.";
|
|
428
730
|
output.push({ type: "error", reason: "error", error });
|
|
@@ -430,13 +732,40 @@ export function streamWithLimitsRetry(
|
|
|
430
732
|
return;
|
|
431
733
|
}
|
|
432
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
|
+
|
|
433
762
|
const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
|
|
434
763
|
if (waitResult === "aborted") {
|
|
435
764
|
if (!committed) {
|
|
436
|
-
output.push({ type: "start", partial: freshMessage(model) });
|
|
765
|
+
output.push({ type: "start", partial: freshMessage(attempt.model) });
|
|
437
766
|
committed = true;
|
|
438
767
|
}
|
|
439
|
-
const error = freshMessage(model);
|
|
768
|
+
const error = freshMessage(attempt.model);
|
|
440
769
|
error.stopReason = "aborted";
|
|
441
770
|
error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
|
|
442
771
|
output.push({ type: "error", reason: "aborted", error });
|
|
@@ -459,6 +788,7 @@ function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
|
|
|
459
788
|
const builtinStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple;
|
|
460
789
|
if (!builtinStreamSimple) return;
|
|
461
790
|
|
|
791
|
+
builtinStreamSimpleByApi.set(api, builtinStreamSimple);
|
|
462
792
|
wrappedApis.add(api);
|
|
463
793
|
pi.registerProvider(`limits-wait-${api}`, {
|
|
464
794
|
api,
|
|
@@ -470,8 +800,22 @@ function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
|
|
|
470
800
|
// ─── Extension entry point ────────────────────────────────────────────────────
|
|
471
801
|
|
|
472
802
|
export default function (pi: ExtensionAPI) {
|
|
473
|
-
|
|
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
|
+
|
|
814
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
474
815
|
sharedCtx = ctx;
|
|
816
|
+
primaryModel ??= ctx.model;
|
|
817
|
+
primaryThinkingLevel ??= pi.getThinkingLevel();
|
|
818
|
+
loadFallbackSettings(ctx);
|
|
475
819
|
|
|
476
820
|
// Some extensions may register providers after this extension loads. Wrap
|
|
477
821
|
// any APIs that exist by the time an agent starts too.
|
|
@@ -479,5 +823,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
479
823
|
registerWrappedApi(pi, model.api);
|
|
480
824
|
}
|
|
481
825
|
if (ctx.model) registerWrappedApi(pi, ctx.model.api);
|
|
826
|
+
|
|
827
|
+
// Anthropic subscription/OAuth requests identify as Claude Code, not Pi.
|
|
828
|
+
// Check the session-wide Anthropic OAuth configuration rather than only the
|
|
829
|
+
// current model: resumed/subagent sessions can temporarily expose a
|
|
830
|
+
// synthetic active model during before_agent_start.
|
|
831
|
+
if (!isAnthropicOAuthSession(ctx)) return;
|
|
832
|
+
|
|
833
|
+
const sanitised = sanitiseSystemPrompt(event.systemPrompt);
|
|
834
|
+
return {
|
|
835
|
+
systemPrompt: sanitised
|
|
836
|
+
? `${CLAUDE_CODE_IDENTITY}\n\n${sanitised}`
|
|
837
|
+
: CLAUDE_CODE_IDENTITY,
|
|
838
|
+
};
|
|
482
839
|
});
|
|
483
840
|
}
|