pi-model-fallback 0.4.0 → 0.5.1

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
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.5.1] - 2026-09-30
6
+
7
+ ### Changed
8
+
9
+ - Update `@earendil-works/pi-*` dependencies to `0.99.1`.
10
+
11
+ ## [0.5.0] - 2026-09-22
12
+
13
+ ### Added
14
+
15
+ - `reasons` rule option: match optional failure reason codes (for example `context_length_exceeded`) parsed from provider error messages, so status codes like `400` can trigger fallback only for specific causes such as context-length overflow. Reason-aware rules participate in shadowing analysis, and unrestricted earlier rules still shadow later reason-scoped rules.
16
+ - Never fall back to the failing model itself: rules whose fallback target equals the failing model are skipped so the next matching rule applies.
17
+ - Cascading fallback chains: when the active fallback model fails, other matching rules can move the session onward (for example `free → kimi-k2.6 → glm-5.3-flash`). Cascades never revisit a model already used in the current run, so circular rules cannot loop.
18
+
19
+ ### Fixed
20
+
21
+ - Parse `Error: <status>` messages that carry no colon after the status, such as GitHub Copilot's `Error: 429 quota exceeded`. These fell between the leading-bare-status pattern, which requires a colon after the status, and the `error <status>` pattern, which requires whitespace directly after `error`, so Copilot quota exhaustion never triggered a fallback.
22
+ - Recognise a bare status followed by a quota noun, such as `429 quota exceeded`.
23
+
24
+ ### Changed
25
+
26
+ - Bump Pi dev dependencies to `@earendil-works/*` 0.86.1 and `@types/node` 26.5.1.
27
+ - Refresh the fallback retry example in the README and usage docs.
28
+
5
29
  ## [0.4.0] - 2026-09-11
6
30
 
7
31
  ### Added
package/README.md CHANGED
@@ -90,13 +90,14 @@ Rule fields:
90
90
  - `matchProviders`: match all models from a provider
91
91
  - `matchModels`: match specific `provider` + `model` pairs
92
92
  - `statuses`: optional; defaults to `429, 500, 502, 503, 504`
93
+ - `reasons`: optional list of failure reason codes (for example `context_length_exceeded`); when set, the rule only fires if the parsed failure reason is listed. Reasons come from quoted machine codes in the error message (for example `"provider_error_code":"context_length_exceeded"`) or prose such as `exceeds this model's context length`. Useful for status codes like `400` that should only trigger a fallback for specific causes, such as context-length overflow.
93
94
  - `cooldownMs`: optional persistent fallback window
94
95
  - `fallback`: target model Pi should switch to
95
96
 
96
97
  Top-level fields:
97
98
 
98
99
  - `enabled`: toggle the extension without deleting rules
99
- - `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)
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.
100
101
 
101
102
  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.
102
103
 
@@ -123,7 +124,11 @@ If the package is installed project-locally and the current project references i
123
124
  - Successful responses do nothing.
124
125
  - Matching failures from `after_provider_response` can trigger fallback immediately.
125
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.
126
- - The failed request is not automatically replayed.
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.
128
+
129
+ 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
+
131
+ Fallback chains cascade: when the active fallback model itself fails, another matching rule may move the session further (for example `free model → kimi-k2.6 → glm-5.3-flash`). A cascade never revisits a model already used in the current run (the original or an earlier fallback), so circular rule configurations cannot ping-pong; when no unvisited target remains, the failure surfaces normally.
127
132
 
128
133
  ## Development
129
134
 
package/docs/usage.md CHANGED
@@ -6,7 +6,7 @@ Install:
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.
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.
10
10
 
11
11
  Commands:
12
12
 
@@ -17,6 +17,8 @@ Commands:
17
17
 
18
18
  Rule order is first-match: the first rule whose provider/model and status match wins. Place specific `matchModels` rules before broad `matchProviders` rules when the model-specific fallback should take priority. `model_fallback_config validate`, `save`, `read`, and `status` expose warning-only details for completely shadowed later rules or model entries, and `/model-fallback:status` includes a concise current-config warning summary; these warnings do not make the config invalid.
19
19
 
20
+ Rules can also match on failure reasons with `reasons`, for example `"reasons": ["context_length_exceeded"]` on a `400` rule so only context-overflow failures switch models while other `400`s stay errors. Reasons are parsed from quoted provider error codes (for example `"provider_error_code":"context_length_exceeded"`) or prose such as `exceeds this model's context length`. A rule never falls back to the failing model itself.
21
+
20
22
  Cooldown defaults when a rule omits `cooldownMs`:
21
23
 
22
24
  - `429` → 72 hours
@@ -24,4 +26,4 @@ Cooldown defaults when a rule omits `cooldownMs`:
24
26
 
25
27
  `Retry-After` and `x-ratelimit-reset*` response headers override `cooldownMs` and those defaults.
26
28
 
27
- The failed request is not automatically replayed.
29
+ If `autoRetry` is disabled, the failed request is not automatically replayed.
@@ -15,7 +15,7 @@ import {
15
15
  type ModelRef,
16
16
  type RuleWarning,
17
17
  } from "../lib/config.js";
18
- import { parseStatusFromErrorMessage } from "../lib/error-status.js";
18
+ import { parseReasonFromErrorMessage, parseStatusFromErrorMessage } from "../lib/error-status.js";
19
19
  import { emptyState, findActiveStateEntry, pruneExpiredState, readState, upsertStateEntry, validateStateShape, writeState, type FallbackState } from "../lib/state.js";
20
20
  import { isRecord } from "../lib/internal.ts";
21
21
  import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
@@ -33,6 +33,10 @@ export default function modelFallback(pi: ExtensionAPI) {
33
33
  let originalModelKey: string | undefined;
34
34
  let activeFallbackKey: string | undefined;
35
35
  let lastFallbackReason: string | undefined;
36
+ // Models already fallen back to during this run (plus the original). A cascade may only
37
+ // target a model never visited, which makes chains (free → kimi → glm) safe while
38
+ // preventing A→B→A ping-pong with circular rules.
39
+ let visitedFallbackKeys = new Set<string>();
36
40
 
37
41
  function syncPaths(ctx: ExtensionContext): void {
38
42
  if (pathsResolvedForCwd === ctx.cwd) return;
@@ -94,28 +98,30 @@ export default function modelFallback(pi: ExtensionAPI) {
94
98
  return;
95
99
  }
96
100
 
101
+ originalModelKey = modelKey(current);
102
+ activeFallbackKey = modelRefKey(active.fallback);
103
+ visitedFallbackKeys = new Set([originalModelKey, activeFallbackKey]);
104
+ lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
105
+ updateStatus(ctx);
97
106
  const ok = await pi.setModel(fallbackModel);
98
107
  if (!ok) {
99
108
  ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(active.fallback)}`, "warning");
109
+ originalModelKey = undefined;
110
+ activeFallbackKey = undefined;
111
+ lastFallbackReason = undefined;
112
+ visitedFallbackKeys = new Set();
113
+ updateStatus(ctx);
100
114
  return;
101
115
  }
102
-
103
- originalModelKey = modelKey(current);
104
- activeFallbackKey = modelRefKey(active.fallback);
105
- lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
106
116
  updateStatus(ctx);
107
117
  ctx.ui.notify(`Model fallback preselected: ${originalModelKey} → ${activeFallbackKey} until ${active.until}.`, "warning");
108
118
  }
109
119
 
110
- async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext): Promise<boolean> {
120
+ async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext, reason?: string): Promise<boolean> {
111
121
  const loaded = config ?? (await loadConfig(ctx));
112
122
  if (!loaded) return false;
113
- const match = findFallback(loaded, { provider: source.provider, id: source.model }, status);
123
+ const match = findFallback(loaded, { provider: source.provider, id: source.model }, status, reason);
114
124
  if (!match) return false;
115
- // Only skip when the failing model IS the active fallback (its own failure should not
116
- // re-trigger). A failure on the original model while the fallback is merely preselected
117
- // must still proceed so auto-retry can run on the fallback.
118
- if (activeFallbackKey && modelRefKey(source) === activeFallbackKey) return false;
119
125
 
120
126
  const fallbackModel = ctx.modelRegistry.find(match.fallback.provider, match.fallback.model);
121
127
  if (!fallbackModel) {
@@ -123,6 +129,18 @@ export default function modelFallback(pi: ExtensionAPI) {
123
129
  return false;
124
130
  }
125
131
 
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
+ const fallbackKey = modelRefKey(match.fallback);
140
+ const sourceKey = modelRefKey(source);
141
+ const idempotentReapply = fallbackKey === activeFallbackKey && sourceKey === originalModelKey;
142
+ if (visitedFallbackKeys.has(fallbackKey) && !idempotentReapply) return false;
143
+
126
144
  const now = new Date();
127
145
  const until = new Date(now.getTime() + cooldownMsFor(match.rule.cooldownMs, status, headers)).toISOString();
128
146
  const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
@@ -136,16 +154,29 @@ export default function modelFallback(pi: ExtensionAPI) {
136
154
  });
137
155
  await writeState(paths.state, state);
138
156
 
157
+ const prevOriginal = originalModelKey;
158
+ const prevActive = activeFallbackKey;
159
+ const prevReason = lastFallbackReason;
160
+ const prevVisited = visitedFallbackKeys;
161
+ // Mark before setModel so the model_select handler keeps fallback bookkeeping.
162
+ if (!originalModelKey) originalModelKey = sourceKey;
163
+ activeFallbackKey = fallbackKey;
164
+ visitedFallbackKeys = new Set(prevVisited);
165
+ visitedFallbackKeys.add(sourceKey);
166
+ visitedFallbackKeys.add(fallbackKey);
167
+ lastFallbackReason = `${status} from ${sourceKey}${reason ? ` (${reason})` : ""}; persistent until ${until}`;
168
+ updateStatus(ctx);
169
+
139
170
  const ok = await pi.setModel(fallbackModel);
140
171
  if (!ok) {
172
+ originalModelKey = prevOriginal;
173
+ activeFallbackKey = prevActive;
174
+ lastFallbackReason = prevReason;
175
+ visitedFallbackKeys = prevVisited;
176
+ updateStatus(ctx);
141
177
  ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
142
178
  return false;
143
179
  }
144
-
145
- originalModelKey = modelRefKey(source);
146
- activeFallbackKey = modelRefKey(match.fallback);
147
- lastFallbackReason = `${status} from ${originalModelKey}; persistent until ${until}`;
148
- updateStatus(ctx);
149
180
  ctx.ui.notify(`Model fallback: ${originalModelKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
150
181
  return true;
151
182
  }
@@ -153,6 +184,7 @@ export default function modelFallback(pi: ExtensionAPI) {
153
184
  pi.on("agent_start", async (_event, ctx) => {
154
185
  await loadConfig(ctx);
155
186
  await loadState(ctx);
187
+ visitedFallbackKeys = new Set();
156
188
  await applyPersistentFallback(ctx);
157
189
  updateStatus(ctx);
158
190
  });
@@ -162,6 +194,7 @@ export default function modelFallback(pi: ExtensionAPI) {
162
194
  activeFallbackKey = undefined;
163
195
  originalModelKey = undefined;
164
196
  lastFallbackReason = undefined;
197
+ visitedFallbackKeys = new Set();
165
198
  updateStatus(ctx);
166
199
  }
167
200
  });
@@ -171,7 +204,6 @@ export default function modelFallback(pi: ExtensionAPI) {
171
204
  const loaded = config ?? (await loadConfig(ctx));
172
205
  if (!current || !loaded) return;
173
206
  if (event.status >= 200 && event.status < 300) return;
174
- if (activeFallbackKey && modelKey(current) === activeFallbackKey) return;
175
207
 
176
208
  await persistFailure(modelToRef(current), event.status, event.headers, ctx);
177
209
  });
@@ -183,11 +215,11 @@ export default function modelFallback(pi: ExtensionAPI) {
183
215
  if (!errorMessage) return;
184
216
  const status = parseStatusFromErrorMessage(errorMessage);
185
217
  if (status === undefined) return;
218
+ const reason = parseReasonFromErrorMessage(errorMessage);
186
219
  const provider = typeof message.provider === "string" ? message.provider : ctx.model?.provider;
187
220
  const model = typeof message.model === "string" ? message.model : ctx.model?.id;
188
221
  if (!provider || !model) return;
189
- if (activeFallbackKey && `${provider}/${model}` === activeFallbackKey) return;
190
- const switched = await persistFailure({ provider, model }, status, {}, ctx);
222
+ const switched = await persistFailure({ provider, model }, status, {}, ctx, reason);
191
223
  if (!switched) return;
192
224
  const loaded = config;
193
225
  if (!loaded || loaded.autoRetry === false) return;
@@ -216,6 +248,7 @@ export default function modelFallback(pi: ExtensionAPI) {
216
248
  ctx.ui.notify("Model fallback: persistent state cleared; no remembered original model.", "info");
217
249
  activeFallbackKey = undefined;
218
250
  lastFallbackReason = undefined;
251
+ visitedFallbackKeys = new Set();
219
252
  updateStatus(ctx);
220
253
  return;
221
254
  }
@@ -233,6 +266,7 @@ export default function modelFallback(pi: ExtensionAPI) {
233
266
  activeFallbackKey = undefined;
234
267
  originalModelKey = undefined;
235
268
  lastFallbackReason = undefined;
269
+ visitedFallbackKeys = new Set();
236
270
  updateStatus(ctx);
237
271
  ctx.ui.notify(`Model fallback reset: ${modelKey(model)}; persistent state cleared.`, "info");
238
272
  },
package/lib/config.ts CHANGED
@@ -14,6 +14,8 @@ export interface FallbackRule {
14
14
  matchProviders?: string[];
15
15
  matchModels?: ModelRef[];
16
16
  statuses?: number[];
17
+ /** Failure reason codes (for example `context_length_exceeded`); when set, the rule only fires if the parsed reason is listed. */
18
+ reasons?: string[];
17
19
  /** Persistent failover cooldown in milliseconds after a matching failure. Defaults: 429 => 72h, 5xx => 10m. */
18
20
  cooldownMs?: number;
19
21
  fallback: ModelRef;
@@ -52,6 +54,8 @@ interface NormalizedRuleForWarnings {
52
54
  modelKeys: Set<string>;
53
55
  models: ModelRef[];
54
56
  statuses: Set<number>;
57
+ /** undefined = the rule matches any failure reason. */
58
+ reasons?: Set<string>;
55
59
  }
56
60
 
57
61
  export function defaultConfig(): ModelFallbackConfig {
@@ -85,11 +89,19 @@ export function validateConfigShape(value: unknown): ModelFallbackConfig {
85
89
  };
86
90
  }
87
91
 
88
- export function findFallback(config: ModelFallbackConfig, model: Pick<Model<any>, "provider" | "id">, status: number): MatchedFallback | undefined {
92
+ export function findFallback(
93
+ config: ModelFallbackConfig,
94
+ model: Pick<Model<any>, "provider" | "id">,
95
+ status: number,
96
+ reason?: string,
97
+ ): MatchedFallback | undefined {
89
98
  if (!config.enabled) return undefined;
90
99
  for (const rule of config.rules) {
91
100
  if (!statusesFor(rule).has(status)) continue;
92
101
  if (!modelMatches(rule, model)) continue;
102
+ if (!reasonMatches(rule, reason)) continue;
103
+ // Never "fall back" to the failing model itself; that would only re-trigger the same failure.
104
+ if (modelKey(model) === modelRefKey(rule.fallback)) continue;
93
105
  return { rule, fallback: rule.fallback };
94
106
  }
95
107
  return undefined;
@@ -109,17 +121,17 @@ export function analyzeRuleWarnings(config: ModelFallbackConfig): RuleWarning[]
109
121
  const earlierRule = config.rules[earlierIndex];
110
122
  const earlier = normalizedRules[earlierIndex];
111
123
 
112
- if (setsEqual(earlier.statuses, later.statuses) && ruleScopesEqual(earlier, later)) {
124
+ if (setsEqual(earlier.statuses, later.statuses) && ruleScopesEqual(earlier, later) && reasonsEqual(earlier, later)) {
113
125
  warnings.push(duplicateRuleWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex));
114
126
  continue rules;
115
127
  }
116
128
 
117
- if (statusSetCovers(earlier.statuses, later.statuses) && ruleScopeCovers(earlier, later)) {
129
+ if (statusSetCovers(earlier.statuses, later.statuses) && ruleScopeCovers(earlier, later) && reasonsCover(earlier, later)) {
118
130
  warnings.push(scopeShadowWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex, earlier));
119
131
  continue rules;
120
132
  }
121
133
 
122
- if (earlier.providers.size === 0 || later.models.length === 0 || !statusSetCovers(earlier.statuses, later.statuses)) continue;
134
+ if (earlier.providers.size === 0 || later.models.length === 0 || !statusSetCovers(earlier.statuses, later.statuses) || !reasonsCover(earlier, later)) continue;
123
135
 
124
136
  const shadowedModels = later.models.filter((entry) => earlier.providers.has(entry.provider) && !reportedModelShadowKeys.has(modelRefKey(entry)));
125
137
  if (shadowedModels.length === 0) continue;
@@ -147,6 +159,7 @@ function validateRule(value: unknown, index: number): FallbackRule {
147
159
  if (value.matchProviders !== undefined) rule.matchProviders = readStringArray(value.matchProviders, `rules[${index}].matchProviders`);
148
160
  if (value.matchModels !== undefined) rule.matchModels = readModelRefArray(value.matchModels, `rules[${index}].matchModels`);
149
161
  if (value.statuses !== undefined) rule.statuses = readStatuses(value.statuses, `rules[${index}].statuses`);
162
+ if (value.reasons !== undefined) rule.reasons = readStringArray(value.reasons, `rules[${index}].reasons`);
150
163
  if (value.cooldownMs !== undefined) rule.cooldownMs = readPositiveInteger(value.cooldownMs, `rules[${index}].cooldownMs`);
151
164
  if ((!rule.matchProviders || rule.matchProviders.length === 0) && (!rule.matchModels || rule.matchModels.length === 0)) {
152
165
  throw new Error(`rules[${index}] must define matchProviders or matchModels.`);
@@ -160,6 +173,11 @@ function modelMatches(rule: FallbackRule, model: Pick<Model<any>, "provider" | "
160
173
  return false;
161
174
  }
162
175
 
176
+ function reasonMatches(rule: FallbackRule, reason: string | undefined): boolean {
177
+ if (!rule.reasons || rule.reasons.length === 0) return true;
178
+ return reason !== undefined && rule.reasons.includes(reason);
179
+ }
180
+
163
181
  function statusesFor(rule: FallbackRule): Set<number> {
164
182
  return new Set(rule.statuses && rule.statuses.length > 0 ? rule.statuses : DEFAULT_FALLBACK_STATUSES);
165
183
  }
@@ -171,6 +189,7 @@ function normalizeRuleForWarnings(rule: FallbackRule): NormalizedRuleForWarnings
171
189
  modelKeys: new Set(modelsByKey.keys()),
172
190
  models: [...modelsByKey.values()],
173
191
  statuses: statusesFor(rule),
192
+ reasons: rule.reasons && rule.reasons.length > 0 ? new Set(rule.reasons) : undefined,
174
193
  };
175
194
  }
176
195
 
@@ -249,6 +268,17 @@ function statusSetCovers(earlier: Set<number>, later: Set<number>): boolean {
249
268
  return setCovers(earlier, later);
250
269
  }
251
270
 
271
+ function reasonsCover(earlier: NormalizedRuleForWarnings, later: NormalizedRuleForWarnings): boolean {
272
+ if (earlier.reasons === undefined) return true;
273
+ if (later.reasons === undefined) return false;
274
+ return setCovers(earlier.reasons, later.reasons);
275
+ }
276
+
277
+ function reasonsEqual(earlier: NormalizedRuleForWarnings, later: NormalizedRuleForWarnings): boolean {
278
+ if (earlier.reasons === undefined || later.reasons === undefined) return earlier.reasons === later.reasons;
279
+ return setsEqual(earlier.reasons, later.reasons);
280
+ }
281
+
252
282
  function setsEqual<T>(left: Set<T>, right: Set<T>): boolean {
253
283
  return left.size === right.size && setCovers(left, right);
254
284
  }
@@ -1,12 +1,59 @@
1
+ const REASON_TEXT_PATTERNS: Array<[RegExp, string]> = [
2
+ [/exceeds\s+(?:this|the)\s+model'?s?\s+context\s+length/i, "context_length_exceeded"],
3
+ [/context[\s_-]*length[\s_-]*exceeded/i, "context_length_exceeded"],
4
+ ];
5
+
6
+ export function parseReasonFromErrorMessage(message: string): string | undefined {
7
+ // Quoted machine-readable codes relayed by routers, for example
8
+ // "code":"context_length_exceeded" or "provider_error_code":"context_length_exceeded".
9
+ // Numeric "code":400 values are ignored; parseStatusFromErrorMessage handles the status.
10
+ const quoted = message.match(/"(?:[a-z0-9_]+_)?code"\s*:\s*"([a-z0-9_.:-]+)"/i);
11
+ if (quoted) return quoted[1];
12
+ for (const [pattern, reason] of REASON_TEXT_PATTERNS) {
13
+ if (pattern.test(message)) return reason;
14
+ }
15
+ return undefined;
16
+ }
17
+
18
+ // Failure modes where the transport reports HTTP success but the provider
19
+ // yields nothing usable — no numeric status is present in any of them, so the
20
+ // status parser must synthesize one for the fallback rules to fire:
21
+ // - "Provider returned an empty response" (OpenRouter stealth/pool models)
22
+ // - "Stream ended without finish_reason" (chunk stream truncated mid-flight)
23
+ // - "<provider> response has no body" (Anthropic/Mistral/generic, 200 empty body)
24
+ // - "<provider> stream ended without a terminal event" (SSE stream dies silently)
25
+ const EMPTY_PROVIDER_RESPONSE_PATTERNS: RegExp[] = [
26
+ /provider returned an empty response/i,
27
+ /response (has|with) no body/i,
28
+ /stream ended without a terminal event/i,
29
+ /stream ended without finish_reason/i,
30
+ ];
31
+
1
32
  export function parseStatusFromErrorMessage(message: string): number | undefined {
33
+ for (const pattern of EMPTY_PROVIDER_RESPONSE_PATTERNS) {
34
+ if (pattern.test(message)) {
35
+ // Synthesize 502 so status-based fallback rules fire instead of the
36
+ // agent silently retrying the same model. 502 is deliberately chosen
37
+ // over a novel code: it lands in the 5xx status lists users already
38
+ // configure and inherits the standard 10-minute cooldown.
39
+ return 502;
40
+ }
41
+ }
2
42
  const patterns: RegExp[] = [
3
43
  // Leading bare status code, optionally prefixed with "Error:", e.g. "401: {\"type\":\"CreditsError\"...}"
4
44
  // or "Error: 401: {\"type\":\"CreditsError\"...}" (OpenCode Go, OpenRouter relays)
5
45
  /^\s*(?:error\s*:?\s*)?([1-5][0-9]{2})\s*:/i,
46
+ // Anchored "Error: <status> <prose>", e.g. "Error: 429 quota exceeded"
47
+ // (GitHub Copilot). The pattern above needs a colon AFTER the status and the
48
+ // "error <status>" pattern below needs whitespace directly after "error", so
49
+ // a message carrying both the prefix and a colon fell between them. The
50
+ // "error" prefix is required: without it this would also match prose such as
51
+ // "429 tokens remaining in context window", which must stay unmatched.
52
+ /^\s*error\s*:?\s*([1-5][0-9]{2})\b/i,
6
53
  /\b(?:status|code|http)\s*[:\s]?\s*([1-5][0-9]{2})\b/i,
7
54
  /\bHTTP\/\d(?:\.\d)?\s+([1-5][0-9]{2})\b/,
8
55
  /\b(?:rate[\s-]?limit(?:ed)?|too many requests)[^0-9]{0,40}([45][0-9]{2})\b/i,
9
- /\b([45][0-9]{2})\s+(?:error|too many requests|service unavailable|bad gateway|gateway timeout|internal server error)\b/i,
56
+ /\b([45][0-9]{2})\s+(?:error|quota|too many requests|service unavailable|bad gateway|gateway timeout|internal server error)\b/i,
10
57
  /\berror\s+([45][0-9]{2})\b/i,
11
58
  ];
12
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-model-fallback",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",
@@ -50,11 +50,11 @@
50
50
  "typebox": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-agent-core": "^0.84.1",
54
- "@earendil-works/pi-ai": "^0.84.1",
55
- "@earendil-works/pi-coding-agent": "^0.84.1",
56
- "@earendil-works/pi-tui": "^0.84.1",
57
- "@types/node": "^26.0.0",
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",
57
+ "@types/node": "^26.6.3",
58
58
  "jiti": "^2.7.0",
59
59
  "typebox": "latest",
60
60
  "typescript": "^7.0.2"