pi-model-fallback 0.5.1 → 0.5.3

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/CHANGELOG.md CHANGED
@@ -1,6 +1,28 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## [0.5.3] - 2026-10-12
4
+
5
+ ### Changed
6
+
7
+ - Require Pi 1.1 for the continuation boundary API and align Pi development dependencies with that release line.
8
+
9
+ ### Fixed
10
+
11
+ - Continue failed requests after Pi's own retries without replaying the user prompt or dropping image inputs and completed tool results.
12
+ - Keep fallback-chain visitation across continuation loops, respect interruption and aborted settlement, and persist cooldowns only after successful target activation.
13
+ - Retain observed HTTP status and retry headers for the matching request when provider error text omits a status.
14
+ - Keep fallback active for the rest of the run after a config save mid-run; superseding pending fallback work no longer disables fallback until the next user prompt.
15
+ - Recognize Anthropic SDK status-plus-JSON and streaming errors so configured rate-limit and outage rules are evaluated. Treat interrupted Anthropic streams as `502` and supported connection failures as `503`, without changing rule defaults or matching cancellation.
16
+
17
+ ## [0.5.2] - 2026-10-07
18
+
19
+ ### Changed
20
+
21
+ - Update `@earendil-works/pi-*` development dependencies to `1.0.4`.
22
+
23
+ ### Fixed
24
+
25
+ - Recognize a status in parentheses when a provider error wrapper precedes it, for example `omni API error (503): ...`. Previously such messages yielded no status, so rules listing `503` were never evaluated even when configured.
4
26
 
5
27
  ## [0.5.1] - 2026-09-30
6
28
 
package/README.md CHANGED
@@ -26,6 +26,8 @@ When a failure matches, the extension also stores persistent fallback state so f
26
26
 
27
27
  ## Install
28
28
 
29
+ Requires Pi 1.1 or later for the continuation boundary API.
30
+
29
31
  Install from npm:
30
32
 
31
33
  ```bash
@@ -97,7 +99,7 @@ Rule fields:
97
99
  Top-level fields:
98
100
 
99
101
  - `enabled`: toggle the extension without deleting rules
100
- - `autoRetry`: when a fallback fires from a failed turn, automatically re-queue the failed user prompt so the turn retries on the fallback model (defaults to `true`; set to `false` to only switch models). The failed prompt is queued once per fallback transition, so a fallback chain may replay it more than once.
102
+ - `autoRetry`: after Pi exhausts its own retries, continue the existing conversation on the fallback model without re-queueing the user prompt (defaults to `true`; set to `false` to only switch models). Completed tool results and image inputs remain in context; only the failed assistant entry is excluded.
101
103
 
102
104
  Rules use first-match order: the first rule whose provider/model and status match wins. Put specific `matchModels` rules before broad `matchProviders` rules when they should take priority. `model_fallback_config validate`, `save`, `read`, and `status` report warning-only diagnostics when a later rule or model entry is completely shadowed by an earlier rule; `/model-fallback:status` also includes a concise warning summary for the current config. Warning-bearing config remains valid and can still be saved.
103
105
 
@@ -122,9 +124,12 @@ If the package is installed project-locally and the current project references i
122
124
  ## Behavior notes
123
125
 
124
126
  - Successful responses do nothing.
125
- - Matching failures from `after_provider_response` can trigger fallback immediately.
126
- - Assistant error messages parsed at `turn_end` can also persist fallback state for SDK/provider failures that do not emit the normal response hook. Status extraction looks for HTTP-style tokens (for example `status 429`, `HTTP 503`, or `rate limit`) rather than any bare 3-digit number in the message.
127
- - With the default `autoRetry: true`, the failed prompt is automatically queued once per fallback transition on the fallback model. A fallback chain may therefore replay the prompt more than once. Set `autoRetry: false` to switch models without replaying it.
127
+ - Failed HTTP responses are recorded by `after_provider_response`; model switching waits until Pi's retry/recovery paths finish at `agent_before_settle`.
128
+ - Assistant error messages parsed at `turn_end` identify SDK/provider failures that do not emit the normal response hook. When friendly error text omits a status, the extension uses the observed HTTP status and headers from the same model request. Status extraction looks for HTTP-style tokens (for example `status 429`, `HTTP 503`, or `rate limit`) rather than any bare 3-digit number in the message. A status in parentheses is only recognized when an error wrapper keyword precedes it, as in `omni API error (503): ...`; a parenthesized number with no such wrapper, such as `(503) items processed`, stays unmatched.
129
+ - Anthropic SDK errors with a bare status followed by JSON (for example `429 {"type":"error",...}`) are recognized. Status-free Anthropic streaming errors map `rate_limit_error` to `429`, `overloaded_error` to `529`, and `api_error` to `500`; an interrupted stream maps to `502`. Add `529` to a rule's `statuses` if it should cover Anthropic overloads. Supported connection failures without an HTTP response map to `503`; cancellation, refusals, and ordinary numeric prose stay unmatched, and explicit HTTP statuses take precedence.
130
+ - With the default `autoRetry: true`, the existing run continues on the fallback model without a second user request. Set `autoRetry: false` to switch models without continuing.
131
+ - Interrupting a pending fallback prevents continuation. An in-flight model change is not atomic with cancellation: the selected model may change, but the extension will not request another inference after the interrupt.
132
+ - Switching providers sends the existing model context to the target provider. Preserving completed tool results prevents extension-level replay, not a model independently choosing to repeat an action.
128
133
 
129
134
  Rules never fall back to the failing model itself; a rule whose fallback target equals the failing model is skipped so the next matching rule (or no rule) applies.
130
135
 
package/docs/usage.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Usage
2
2
 
3
- Install:
3
+ Requires Pi 1.1 or later. Install:
4
4
 
5
5
  ```bash
6
6
  pi install npm:pi-model-fallback
7
7
  ```
8
8
 
9
- Default behavior: when a `zai/*` model receives `429`, `500`, `502`, `503`, or `504`, Pi switches to `deepseek/deepseek-v4-flash` for the next prompt. The failed prompt is automatically queued once per fallback transition on the fallback model, so a fallback chain may replay it more than once; set `"autoRetry": false` in `model-fallback/config.json` to switch models without retrying it.
9
+ Default behavior: when a `zai/*` model fails with `429`, `500`, `502`, `503`, or `504`, Pi finishes its own retries, switches to `deepseek/deepseek-v4-flash`, and continues the existing conversation without replaying the user prompt. Completed tool results and images remain in context. Set `"autoRetry": false` in `model-fallback/config.json` to switch models without continuing.
10
+
11
+ The selected session model changes; subsequent requests use that model directly. Interrupting a pending fallback prevents continuation, although a model change already in progress may remain. A chain never revisits a model within the same user run.
10
12
 
11
13
  Commands:
12
14
 
@@ -26,4 +28,4 @@ Cooldown defaults when a rule omits `cooldownMs`:
26
28
 
27
29
  `Retry-After` and `x-ratelimit-reset*` response headers override `cooldownMs` and those defaults.
28
30
 
29
- If `autoRetry` is disabled, the failed request is not automatically replayed.
31
+ If `autoRetry` is disabled, the existing run is not automatically continued.
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { getKeybindings } from "@earendil-works/pi-tui";
6
7
  import { Type } from "typebox";
7
8
  import {
8
9
  analyzeRuleWarnings,
@@ -37,6 +38,45 @@ export default function modelFallback(pi: ExtensionAPI) {
37
38
  // target a model never visited, which makes chains (free → kimi → glm) safe while
38
39
  // preventing A→B→A ping-pong with circular rules.
39
40
  let visitedFallbackKeys = new Set<string>();
41
+ let cancelled = false;
42
+ let generation = 0;
43
+ let switching = false;
44
+ let uiPromptOpen = false;
45
+ let removeTerminalListener: (() => void) | undefined;
46
+ let responseFailure: { modelKey: string; status: number; headers: Record<string, string> } | undefined;
47
+ let pendingFailure: { source: ModelRef; status: number; reason?: string; entryId: string; headers: Record<string, string> } | undefined;
48
+
49
+ function cancelFallback(): void {
50
+ cancelled = true;
51
+ invalidatePending();
52
+ }
53
+
54
+ // Drop fallback work computed from superseded state without disabling fallback
55
+ // for the rest of the run (unlike cancelFallback).
56
+ function invalidatePending(): void {
57
+ generation += 1;
58
+ pendingFailure = undefined;
59
+ }
60
+
61
+ pi.on("session_start", (_event, ctx) => {
62
+ removeTerminalListener?.();
63
+ if (ctx.mode === "tui") {
64
+ removeTerminalListener = ctx.ui.onTerminalInput((data) => {
65
+ if ((pendingFailure || switching) && !uiPromptOpen && getKeybindings().matches(data, "app.interrupt")) cancelFallback();
66
+ // Observe cancellation without consuming autocomplete, overlay, or editor input.
67
+ return undefined;
68
+ });
69
+ }
70
+ });
71
+ pi.on("ui_prompt_start", () => { uiPromptOpen = true; });
72
+ pi.on("ui_prompt_end", () => { uiPromptOpen = false; });
73
+ pi.on("session_shutdown", () => {
74
+ cancelFallback();
75
+ removeTerminalListener?.();
76
+ removeTerminalListener = undefined;
77
+ });
78
+ pi.on("session_before_switch", cancelFallback);
79
+ pi.on("session_before_tree", cancelFallback);
40
80
 
41
81
  function syncPaths(ctx: ExtensionContext): void {
42
82
  if (pathsResolvedForCwd === ctx.cwd) return;
@@ -103,7 +143,13 @@ export default function modelFallback(pi: ExtensionAPI) {
103
143
  visitedFallbackKeys = new Set([originalModelKey, activeFallbackKey]);
104
144
  lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
105
145
  updateStatus(ctx);
106
- const ok = await pi.setModel(fallbackModel);
146
+ let ok = false;
147
+ switching = true;
148
+ try {
149
+ ok = await pi.setModel(fallbackModel);
150
+ } finally {
151
+ switching = false;
152
+ }
107
153
  if (!ok) {
108
154
  ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(active.fallback)}`, "warning");
109
155
  originalModelKey = undefined;
@@ -117,7 +163,7 @@ export default function modelFallback(pi: ExtensionAPI) {
117
163
  ctx.ui.notify(`Model fallback preselected: ${originalModelKey} → ${activeFallbackKey} until ${active.until}.`, "warning");
118
164
  }
119
165
 
120
- async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext, reason?: string): Promise<boolean> {
166
+ async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext, isCancelled: () => boolean, reason?: string): Promise<boolean> {
121
167
  const loaded = config ?? (await loadConfig(ctx));
122
168
  if (!loaded) return false;
123
169
  const match = findFallback(loaded, { provider: source.provider, id: source.model }, status, reason);
@@ -129,30 +175,12 @@ export default function modelFallback(pi: ExtensionAPI) {
129
175
  return false;
130
176
  }
131
177
 
132
- // Loop guard: never fall back to a model already visited this run (the original or an
133
- // earlier fallback). This both blocks circular rules from ping-ponging and stops the
134
- // current fallback from re-selecting itself, while still allowing onward cascades
135
- // (free → kimi → glm) when a different rule matches the fallback's own failure.
136
- // Exception: when the failing model is the remembered original and the candidate is the
137
- // already-preselected fallback, re-applying the same hop is idempotent and stays allowed
138
- // (turn attribution can lag behind the preselection).
139
178
  const fallbackKey = modelRefKey(match.fallback);
140
179
  const sourceKey = modelRefKey(source);
141
- const idempotentReapply = fallbackKey === activeFallbackKey && sourceKey === originalModelKey;
142
- if (visitedFallbackKeys.has(fallbackKey) && !idempotentReapply) return false;
180
+ if (visitedFallbackKeys.has(fallbackKey) || isCancelled()) return false;
143
181
 
144
182
  const now = new Date();
145
183
  const until = new Date(now.getTime() + cooldownMsFor(match.rule.cooldownMs, status, headers)).toISOString();
146
- const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
147
- state = upsertStateEntry(loadedState, {
148
- source,
149
- fallback: match.fallback,
150
- status,
151
- until,
152
- createdAt: now.toISOString(),
153
- ruleName: match.rule.name,
154
- });
155
- await writeState(paths.state, state);
156
184
 
157
185
  const prevOriginal = originalModelKey;
158
186
  const prevActive = activeFallbackKey;
@@ -167,30 +195,49 @@ export default function modelFallback(pi: ExtensionAPI) {
167
195
  lastFallbackReason = `${status} from ${sourceKey}${reason ? ` (${reason})` : ""}; persistent until ${until}`;
168
196
  updateStatus(ctx);
169
197
 
170
- const ok = await pi.setModel(fallbackModel);
171
- if (!ok) {
198
+ let ok = false;
199
+ try {
200
+ ok = await pi.setModel(fallbackModel);
201
+ } catch (error) {
202
+ ctx.ui.notify(`Model fallback switch failed: ${errorMessage(error)}`, "warning");
203
+ }
204
+ if (!ok || isCancelled()) {
172
205
  originalModelKey = prevOriginal;
173
206
  activeFallbackKey = prevActive;
174
207
  lastFallbackReason = prevReason;
175
208
  visitedFallbackKeys = prevVisited;
176
209
  updateStatus(ctx);
177
- ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
210
+ if (!ok) ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
178
211
  return false;
179
212
  }
180
- ctx.ui.notify(`Model fallback: ${originalModelKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
213
+ const loadedState = state ?? (await loadState(ctx)) ?? emptyState();
214
+ if (isCancelled()) return false;
215
+ state = upsertStateEntry(loadedState, { source, fallback: match.fallback, status, until, createdAt: now.toISOString(), ruleName: match.rule.name });
216
+ await writeState(paths.state, state);
217
+ ctx.ui.notify(`Model fallback: ${sourceKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
181
218
  return true;
182
219
  }
183
220
 
184
- pi.on("agent_start", async (_event, ctx) => {
221
+ pi.on("before_agent_start", async (_event, ctx) => {
222
+ cancelled = false;
223
+ generation += 1;
224
+ pendingFailure = undefined;
225
+ responseFailure = undefined;
185
226
  await loadConfig(ctx);
186
227
  await loadState(ctx);
187
- visitedFallbackKeys = new Set();
228
+ visitedFallbackKeys = new Set(ctx.model ? [modelKey(ctx.model)] : []);
188
229
  await applyPersistentFallback(ctx);
189
230
  updateStatus(ctx);
190
231
  });
191
232
 
233
+ pi.on("agent_start", (_event, ctx) => {
234
+ // Preselection happens before a run exists; abort once its controller is active.
235
+ if (cancelled) ctx.abort();
236
+ });
237
+
192
238
  pi.on("model_select", async (event, ctx) => {
193
- if (activeFallbackKey && modelKey(event.model) !== activeFallbackKey) {
239
+ if (modelKey(event.model) !== activeFallbackKey) {
240
+ cancelFallback();
194
241
  activeFallbackKey = undefined;
195
242
  originalModelKey = undefined;
196
243
  lastFallbackReason = undefined;
@@ -199,34 +246,53 @@ export default function modelFallback(pi: ExtensionAPI) {
199
246
  }
200
247
  });
201
248
 
202
- pi.on("after_provider_response", async (event, ctx) => {
203
- const current = ctx.model;
204
- const loaded = config ?? (await loadConfig(ctx));
205
- if (!current || !loaded) return;
206
- if (event.status >= 200 && event.status < 300) return;
249
+ pi.on("before_provider_request", () => { responseFailure = undefined; });
250
+ pi.on("after_provider_response", (event, ctx) => {
251
+ responseFailure = event.status >= 400 && ctx.model
252
+ ? { modelKey: modelKey(ctx.model), status: event.status, headers: event.headers }
253
+ : undefined;
254
+ });
207
255
 
208
- await persistFailure(modelToRef(current), event.status, event.headers, ctx);
256
+ pi.on("turn_end", (event) => {
257
+ const response = responseFailure;
258
+ responseFailure = undefined;
259
+ pendingFailure = undefined;
260
+ const message = event.message;
261
+ if (message.role !== "assistant" || message.stopReason !== "error") return;
262
+ const source = { provider: message.provider, model: message.model };
263
+ const observed = response?.modelKey === modelRefKey(source) ? response : undefined;
264
+ const errorText = message.errorMessage ?? "";
265
+ const status = parseStatusFromErrorMessage(errorText) ?? observed?.status;
266
+ if (status !== undefined) {
267
+ pendingFailure = { source, status, reason: parseReasonFromErrorMessage(errorText),
268
+ entryId: event.messageEntryId, headers: observed?.headers ?? {} };
269
+ }
270
+ });
271
+
272
+ pi.on("agent_before_settle", async (event, ctx) => {
273
+ const failure = pendingFailure;
274
+ if (event.outcome !== "error" || cancelled || !failure || !ctx.model || modelKey(ctx.model) !== modelRefKey(failure.source)) return;
275
+ const visibleFailure = event.context.contextEntries.some((entry) => entry.sourceEntry.id === failure.entryId && entry.messages.some((message) => message.role === "assistant" && message.stopReason === "error"));
276
+ if (!visibleFailure && !event.context.canContinue) return;
277
+ pendingFailure = undefined;
278
+ const currentGeneration = generation;
279
+ const isCancelled = () => cancelled || generation !== currentGeneration;
280
+ switching = true;
281
+ try {
282
+ const switched = await persistFailure(failure.source, failure.status, failure.headers, ctx, isCancelled, failure.reason);
283
+ if (!switched || isCancelled() || config?.autoRetry === false) return;
284
+ return {
285
+ entries: visibleFailure ? [...event.entries, { type: "context_edit" as const, targetId: failure.entryId, replacement: null }] : event.entries,
286
+ continue: true,
287
+ };
288
+ } finally {
289
+ switching = false;
290
+ }
209
291
  });
210
292
 
211
- pi.on("turn_end", async (event, ctx) => {
212
- const message = event.message as unknown;
213
- if (!isRecord(message) || message.role !== "assistant") return;
214
- const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage : undefined;
215
- if (!errorMessage) return;
216
- const status = parseStatusFromErrorMessage(errorMessage);
217
- if (status === undefined) return;
218
- const reason = parseReasonFromErrorMessage(errorMessage);
219
- const provider = typeof message.provider === "string" ? message.provider : ctx.model?.provider;
220
- const model = typeof message.model === "string" ? message.model : ctx.model?.id;
221
- if (!provider || !model) return;
222
- const switched = await persistFailure({ provider, model }, status, {}, ctx, reason);
223
- if (!switched) return;
224
- const loaded = config;
225
- if (!loaded || loaded.autoRetry === false) return;
226
- const prompt = lastUserPromptText(ctx);
227
- if (!prompt) return;
228
- ctx.ui.notify(`Model fallback: retrying failed prompt on ${activeFallbackKey}.`, "info");
229
- await pi.sendUserMessage(prompt, { deliverAs: "followUp" });
293
+ pi.on("agent_settled", () => {
294
+ pendingFailure = undefined;
295
+ responseFailure = undefined;
230
296
  });
231
297
 
232
298
  pi.registerCommand("model-fallback:status", {
@@ -241,6 +307,7 @@ export default function modelFallback(pi: ExtensionAPI) {
241
307
  pi.registerCommand("model-fallback:reset", {
242
308
  description: "Return from fallback model to the pre-fallback model when remembered and clear persistent state",
243
309
  handler: async (_args, ctx) => {
310
+ cancelFallback();
244
311
  syncPaths(ctx);
245
312
  state = { version: 1, entries: [] };
246
313
  await writeState(paths.state, state);
@@ -305,6 +372,7 @@ export default function modelFallback(pi: ExtensionAPI) {
305
372
  if (!ctx.hasUI) return textResult("Config not saved: confirmation UI is unavailable.", { configPath: paths.config, ...warningDetails(warnings) });
306
373
  const ok = await ctx.ui.confirm("Save model fallback config?", summarizeConfig(nextConfig, warnings));
307
374
  if (!ok) return textResult("Config not saved.", { configPath: paths.config, ...warningDetails(warnings) });
375
+ invalidatePending();
308
376
  await writeConfig(paths.config, nextConfig);
309
377
  config = nextConfig;
310
378
  return textResult(configResultText("Config saved.", warnings), { configPath: paths.config, ...warningDetails(warnings) });
@@ -316,29 +384,6 @@ function autoRetryEnabled(config: ModelFallbackConfig): boolean {
316
384
  return config.autoRetry !== false;
317
385
  }
318
386
 
319
- function lastUserPromptText(ctx: ExtensionContext): string | undefined {
320
- const entries = ctx.sessionManager.getEntries() as unknown[];
321
- for (let index = entries.length - 1; index >= 0; index -= 1) {
322
- const entry = entries[index];
323
- if (!isRecord(entry) || !isRecord(entry.message)) continue;
324
- const message = entry.message;
325
- if (message.role !== "user") continue;
326
- return userMessageText(message.content);
327
- }
328
- return undefined;
329
- }
330
-
331
- function userMessageText(content: unknown): string | undefined {
332
- if (typeof content === "string") return content.trim() || undefined;
333
- if (!Array.isArray(content)) return undefined;
334
- const parts: string[] = [];
335
- for (const block of content) {
336
- if (isRecord(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
337
- }
338
- const text = parts.join("\n").trim();
339
- return text || undefined;
340
- }
341
-
342
387
  function shouldUseProjectLocalState(ctx: ExtensionContext): boolean {
343
388
  const projectSettingsPath = join(ctx.cwd, ".pi", "settings.json");
344
389
  return existsSync(projectSettingsPath) && projectSettingsIncludesThisPackage(projectSettingsPath);
package/lib/config.ts CHANGED
@@ -24,7 +24,7 @@ export interface FallbackRule {
24
24
  export interface ModelFallbackConfig {
25
25
  version: 1;
26
26
  enabled: boolean;
27
- /** Re-queue the failed user prompt after switching to the fallback model so the turn retries automatically. Defaults to true. */
27
+ /** Continue the existing conversation after switching without replaying the user prompt. Defaults to true. */
28
28
  autoRetry?: boolean;
29
29
  rules: FallbackRule[];
30
30
  }
@@ -1,3 +1,5 @@
1
+ import { isRecord } from "./internal.ts";
2
+
1
3
  const REASON_TEXT_PATTERNS: Array<[RegExp, string]> = [
2
4
  [/exceeds\s+(?:this|the)\s+model'?s?\s+context\s+length/i, "context_length_exceeded"],
3
5
  [/context[\s_-]*length[\s_-]*exceeded/i, "context_length_exceeded"],
@@ -27,9 +29,24 @@ const EMPTY_PROVIDER_RESPONSE_PATTERNS: RegExp[] = [
27
29
  /response (has|with) no body/i,
28
30
  /stream ended without a terminal event/i,
29
31
  /stream ended without finish_reason/i,
32
+ /^Anthropic stream ended before message_stop$/,
30
33
  ];
31
34
 
32
35
  export function parseStatusFromErrorMessage(message: string): number | undefined {
36
+ if (message.trimStart().startsWith("{")) {
37
+ try {
38
+ const payload: unknown = JSON.parse(message);
39
+ if (isRecord(payload) && payload.type === "error" && isRecord(payload.error)) {
40
+ switch (payload.error.type) {
41
+ case "overloaded_error": return 529;
42
+ case "rate_limit_error": return 429;
43
+ case "api_error": return 500;
44
+ }
45
+ }
46
+ } catch {
47
+ // Non-JSON error text still uses the status patterns below.
48
+ }
49
+ }
33
50
  for (const pattern of EMPTY_PROVIDER_RESPONSE_PATTERNS) {
34
51
  if (pattern.test(message)) {
35
52
  // Synthesize 502 so status-based fallback rules fire instead of the
@@ -40,9 +57,8 @@ export function parseStatusFromErrorMessage(message: string): number | undefined
40
57
  }
41
58
  }
42
59
  const patterns: RegExp[] = [
43
- // Leading bare status code, optionally prefixed with "Error:", e.g. "401: {\"type\":\"CreditsError\"...}"
44
- // or "Error: 401: {\"type\":\"CreditsError\"...}" (OpenCode Go, OpenRouter relays)
45
- /^\s*(?:error\s*:?\s*)?([1-5][0-9]{2})\s*:/i,
60
+ // SDKs use either "429: ..." or "429 {JSON}"; keep ordinary numeric prose unmatched.
61
+ /^\s*(?:error\s*:?\s*)?([1-5][0-9]{2})(?:\s*:|\s+(?=\{))/i,
46
62
  // Anchored "Error: <status> <prose>", e.g. "Error: 429 quota exceeded"
47
63
  // (GitHub Copilot). The pattern above needs a colon AFTER the status and the
48
64
  // "error <status>" pattern below needs whitespace directly after "error", so
@@ -51,6 +67,13 @@ export function parseStatusFromErrorMessage(message: string): number | undefined
51
67
  // "429 tokens remaining in context window", which must stay unmatched.
52
68
  /^\s*error\s*:?\s*([1-5][0-9]{2})\b/i,
53
69
  /\b(?:status|code|http)\s*[:\s]?\s*([1-5][0-9]{2})\b/i,
70
+ // Parenthesized status inside a provider error wrapper, e.g.
71
+ // "omni API error (503): overloaded" or "chat_completion_error (429): ...".
72
+ // Without this the wrapper text sits between "error" and the number, so the
73
+ // anchored "error <status>" pattern cannot see it and the rule set never
74
+ // sees a status at all. The wrapper keyword and the parentheses are both
75
+ // required so prose such as "(503) items processed" stays unmatched.
76
+ /\b\w*(?:error|failure|failed)\s*\(\s*([1-5][0-9]{2})\s*\)/i,
54
77
  /\bHTTP\/\d(?:\.\d)?\s+([1-5][0-9]{2})\b/,
55
78
  /\b(?:rate[\s-]?limit(?:ed)?|too many requests)[^0-9]{0,40}([45][0-9]{2})\b/i,
56
79
  /\b([45][0-9]{2})\s+(?:error|quota|too many requests|service unavailable|bad gateway|gateway timeout|internal server error)\b/i,
@@ -64,5 +87,8 @@ export function parseStatusFromErrorMessage(message: string): number | undefined
64
87
  if (Number.isInteger(status) && status >= 100 && status <= 599) return status;
65
88
  }
66
89
 
90
+ // Connection failures have no HTTP response; use the existing unavailable-service rules.
91
+ if (/^\s*(?:error:\s*)?(?:connection error|request timed out|fetch failed)[.!]?\s*$/i.test(message) ||
92
+ /\b(?:ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT)\b/.test(message)) return 503;
67
93
  return undefined;
68
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-model-fallback",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Pi extension that switches to a fallback model after provider failures such as 429 rate limits.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,15 +45,15 @@
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-agent-core": "*",
47
47
  "@earendil-works/pi-ai": "*",
48
- "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-coding-agent": ">=1.1.0",
49
49
  "@earendil-works/pi-tui": "*",
50
50
  "typebox": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-agent-core": "^0.99.1",
54
- "@earendil-works/pi-ai": "^0.99.1",
55
- "@earendil-works/pi-coding-agent": "^0.99.1",
56
- "@earendil-works/pi-tui": "^0.99.1",
53
+ "@earendil-works/pi-agent-core": "^1.1.0",
54
+ "@earendil-works/pi-ai": "^1.1.0",
55
+ "@earendil-works/pi-coding-agent": "^1.1.0",
56
+ "@earendil-works/pi-tui": "^1.1.0",
57
57
  "@types/node": "^26.6.3",
58
58
  "jiti": "^2.7.0",
59
59
  "typebox": "latest",