pi-model-fallback 0.3.7 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,25 +2,55 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- ## [0.3.7] - 2026-08-22
5
+ ## [0.5.0] - 2026-09-22
6
+
7
+ ### Added
8
+
9
+ - `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.
10
+ - Never fall back to the failing model itself: rules whose fallback target equals the failing model are skipped so the next matching rule applies.
11
+ - 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.
12
+
13
+ ### Fixed
14
+
15
+ - 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.
16
+ - Recognise a bare status followed by a quota noun, such as `429 quota exceeded`.
6
17
 
7
18
  ### Changed
8
19
 
9
- - Merge the 2026-08-22 managed OSS dependency and maintenance PR batch.
20
+ - Bump Pi dev dependencies to `@earendil-works/*` 0.86.1 and `@types/node` 26.5.1.
21
+ - Refresh the fallback retry example in the README and usage docs.
22
+
23
+ ## [0.4.0] - 2026-09-11
24
+
25
+ ### Added
26
+
27
+ - `autoRetry` config option (default `true`): when a fallback switch happens after a failed turn, the failed user prompt is automatically re-queued so the turn retries on the fallback model.
10
28
 
11
29
  ### Fixed
12
30
 
13
- - Warn on completely shadowed fallback rules in config validation, save confirmations, status output, and config details without rejecting warning-bearing configs.
14
- - Document the missing `git add`/`git commit` steps in `CONTRIBUTING.md` release instructions so maintainers can trigger auto-release after `npm version patch --no-git-tag-version`.
15
- - Clarify that maintainers should update `CHANGELOG.md` before staging release files.
31
+ - Parse leading bare status codes in provider error messages (for example `401: {...}` from OpenCode Go and OpenRouter) so fallback rules fire on real-world failure shapes.
32
+ - Auto-retry when a fallback model is merely preselected instead of skipping the switch path.
33
+ - Parse optional `Error:` prefixes before leading bare status codes.
16
34
 
17
35
  ### Changed
18
36
 
19
- - Bump package version to `0.3.5` for the next patch release.
37
+ - Bump Pi dev dependencies to `@earendil-works/*` 0.84.4 and `@types/node` 26.4.0.
38
+ - Add `jiti` as a devDependency so auto-retry integration tests run in CI.
39
+
40
+ ## [0.3.7] - 2026-08-22
20
41
 
42
+ ### Changed
43
+
44
+ - Merge the 2026-08-22 managed OSS dependency and maintenance PR batch.
21
45
  - Declare `@earendil-works/pi-agent-core` as a peerDependency to align with pi-extension-template.
22
46
  - Add Buy Me a Coffee sponsor button to README and native GitHub funding link via `.github/FUNDING.yml`.
23
47
 
48
+ ### Fixed
49
+
50
+ - Warn on completely shadowed fallback rules in config validation, save confirmations, status output, and config details without rejecting warning-bearing configs.
51
+ - Document the missing `git add`/`git commit` steps in `CONTRIBUTING.md` release instructions so maintainers can trigger auto-release after `npm version patch --no-git-tag-version`.
52
+ - Clarify that maintainers should update `CHANGELOG.md` before staging release files.
53
+
24
54
  ## [0.3.3] - 2026-07-04
25
55
 
26
56
  ### Changed
@@ -74,7 +104,7 @@ This project follows semantic versioning.
74
104
  - Publish workflow now installs a current npm CLI so npm Trusted Publishing OIDC is supported.
75
105
  - CI and publish workflow commands no longer include literal trailing `\\n` text.
76
106
 
77
- ## [0.1.0] - YYYY-MM-DD
107
+ ## [0.1.0] - 2026-06-30
78
108
 
79
109
  ### Added
80
110
 
package/README.md CHANGED
@@ -70,6 +70,7 @@ Default config shape:
70
70
  {
71
71
  "version": 1,
72
72
  "enabled": true,
73
+ "autoRetry": true,
73
74
  "rules": [
74
75
  {
75
76
  "name": "zai-to-deepseek-flash",
@@ -89,9 +90,15 @@ Rule fields:
89
90
  - `matchProviders`: match all models from a provider
90
91
  - `matchModels`: match specific `provider` + `model` pairs
91
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.
92
94
  - `cooldownMs`: optional persistent fallback window
93
95
  - `fallback`: target model Pi should switch to
94
96
 
97
+ Top-level fields:
98
+
99
+ - `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, not retried indefinitely.
101
+
95
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.
96
103
 
97
104
  ### Cooldowns
@@ -117,7 +124,11 @@ If the package is installed project-locally and the current project references i
117
124
  - Successful responses do nothing.
118
125
  - Matching failures from `after_provider_response` can trigger fallback immediately.
119
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.
120
- - The failed request is not automatically replayed.
127
+ - With the default `autoRetry: true`, the failed prompt is automatically queued once on the fallback model. 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.
121
132
 
122
133
  ## Development
123
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 on the fallback model; 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,8 +15,9 @@ 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
+ import { isRecord } from "../lib/internal.ts";
20
21
  import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
21
22
 
22
23
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -32,6 +33,10 @@ export default function modelFallback(pi: ExtensionAPI) {
32
33
  let originalModelKey: string | undefined;
33
34
  let activeFallbackKey: string | undefined;
34
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>();
35
40
 
36
41
  function syncPaths(ctx: ExtensionContext): void {
37
42
  if (pathsResolvedForCwd === ctx.cwd) return;
@@ -93,32 +98,49 @@ export default function modelFallback(pi: ExtensionAPI) {
93
98
  return;
94
99
  }
95
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);
96
106
  const ok = await pi.setModel(fallbackModel);
97
107
  if (!ok) {
98
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);
99
114
  return;
100
115
  }
101
-
102
- originalModelKey = modelKey(current);
103
- activeFallbackKey = modelRefKey(active.fallback);
104
- lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
105
116
  updateStatus(ctx);
106
117
  ctx.ui.notify(`Model fallback preselected: ${originalModelKey} → ${activeFallbackKey} until ${active.until}.`, "warning");
107
118
  }
108
119
 
109
- async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext): Promise<void> {
120
+ async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext, reason?: string): Promise<boolean> {
110
121
  const loaded = config ?? (await loadConfig(ctx));
111
- if (!loaded) return;
112
- const match = findFallback(loaded, { provider: source.provider, id: source.model }, status);
113
- if (!match) return;
114
- if (activeFallbackKey && activeFallbackKey === modelRefKey(match.fallback)) return;
122
+ if (!loaded) return false;
123
+ const match = findFallback(loaded, { provider: source.provider, id: source.model }, status, reason);
124
+ if (!match) return false;
115
125
 
116
126
  const fallbackModel = ctx.modelRegistry.find(match.fallback.provider, match.fallback.model);
117
127
  if (!fallbackModel) {
118
128
  ctx.ui.notify(`Model fallback missing: ${modelRefKey(match.fallback)}`, "warning");
119
- return;
129
+ return false;
120
130
  }
121
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
+
122
144
  const now = new Date();
123
145
  const until = new Date(now.getTime() + cooldownMsFor(match.rule.cooldownMs, status, headers)).toISOString();
124
146
  const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
@@ -132,22 +154,37 @@ export default function modelFallback(pi: ExtensionAPI) {
132
154
  });
133
155
  await writeState(paths.state, state);
134
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
+
135
170
  const ok = await pi.setModel(fallbackModel);
136
171
  if (!ok) {
172
+ originalModelKey = prevOriginal;
173
+ activeFallbackKey = prevActive;
174
+ lastFallbackReason = prevReason;
175
+ visitedFallbackKeys = prevVisited;
176
+ updateStatus(ctx);
137
177
  ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
138
- return;
178
+ return false;
139
179
  }
140
-
141
- originalModelKey = modelRefKey(source);
142
- activeFallbackKey = modelRefKey(match.fallback);
143
- lastFallbackReason = `${status} from ${originalModelKey}; persistent until ${until}`;
144
- updateStatus(ctx);
145
180
  ctx.ui.notify(`Model fallback: ${originalModelKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
181
+ return true;
146
182
  }
147
183
 
148
184
  pi.on("agent_start", async (_event, ctx) => {
149
185
  await loadConfig(ctx);
150
186
  await loadState(ctx);
187
+ visitedFallbackKeys = new Set();
151
188
  await applyPersistentFallback(ctx);
152
189
  updateStatus(ctx);
153
190
  });
@@ -157,6 +194,7 @@ export default function modelFallback(pi: ExtensionAPI) {
157
194
  activeFallbackKey = undefined;
158
195
  originalModelKey = undefined;
159
196
  lastFallbackReason = undefined;
197
+ visitedFallbackKeys = new Set();
160
198
  updateStatus(ctx);
161
199
  }
162
200
  });
@@ -166,7 +204,6 @@ export default function modelFallback(pi: ExtensionAPI) {
166
204
  const loaded = config ?? (await loadConfig(ctx));
167
205
  if (!current || !loaded) return;
168
206
  if (event.status >= 200 && event.status < 300) return;
169
- if (activeFallbackKey && modelKey(current) === activeFallbackKey) return;
170
207
 
171
208
  await persistFailure(modelToRef(current), event.status, event.headers, ctx);
172
209
  });
@@ -178,11 +215,18 @@ export default function modelFallback(pi: ExtensionAPI) {
178
215
  if (!errorMessage) return;
179
216
  const status = parseStatusFromErrorMessage(errorMessage);
180
217
  if (status === undefined) return;
218
+ const reason = parseReasonFromErrorMessage(errorMessage);
181
219
  const provider = typeof message.provider === "string" ? message.provider : ctx.model?.provider;
182
220
  const model = typeof message.model === "string" ? message.model : ctx.model?.id;
183
221
  if (!provider || !model) return;
184
- if (activeFallbackKey && `${provider}/${model}` === activeFallbackKey) return;
185
- await persistFailure({ provider, model }, status, {}, ctx);
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" });
186
230
  });
187
231
 
188
232
  pi.registerCommand("model-fallback:status", {
@@ -204,6 +248,7 @@ export default function modelFallback(pi: ExtensionAPI) {
204
248
  ctx.ui.notify("Model fallback: persistent state cleared; no remembered original model.", "info");
205
249
  activeFallbackKey = undefined;
206
250
  lastFallbackReason = undefined;
251
+ visitedFallbackKeys = new Set();
207
252
  updateStatus(ctx);
208
253
  return;
209
254
  }
@@ -221,6 +266,7 @@ export default function modelFallback(pi: ExtensionAPI) {
221
266
  activeFallbackKey = undefined;
222
267
  originalModelKey = undefined;
223
268
  lastFallbackReason = undefined;
269
+ visitedFallbackKeys = new Set();
224
270
  updateStatus(ctx);
225
271
  ctx.ui.notify(`Model fallback reset: ${modelKey(model)}; persistent state cleared.`, "info");
226
272
  },
@@ -266,6 +312,33 @@ export default function modelFallback(pi: ExtensionAPI) {
266
312
  });
267
313
  }
268
314
 
315
+ function autoRetryEnabled(config: ModelFallbackConfig): boolean {
316
+ return config.autoRetry !== false;
317
+ }
318
+
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
+
269
342
  function shouldUseProjectLocalState(ctx: ExtensionContext): boolean {
270
343
  const projectSettingsPath = join(ctx.cwd, ".pi", "settings.json");
271
344
  return existsSync(projectSettingsPath) && projectSettingsIncludesThisPackage(projectSettingsPath);
@@ -307,6 +380,7 @@ function formatStatus(
307
380
  ): string {
308
381
  return [
309
382
  `Model fallback: ${config.enabled ? "enabled" : "disabled"}`,
383
+ `Auto retry: ${autoRetryEnabled(config) ? "enabled" : "disabled"}`,
310
384
  `Config: ${configPath}`,
311
385
  `State: ${statePath}`,
312
386
  `Rules: ${config.rules.length}`,
@@ -322,6 +396,7 @@ function formatStatus(
322
396
  function summarizeConfig(config: ModelFallbackConfig, warnings: RuleWarning[]): string {
323
397
  return [
324
398
  `enabled: ${config.enabled}`,
399
+ `autoRetry: ${autoRetryEnabled(config)}`,
325
400
  ...config.rules.map((rule) => `${rule.name ?? "rule"}: ${rule.matchProviders?.join(",") ?? "models"} -> ${modelRefKey(rule.fallback)}`),
326
401
  `warnings: ${warningSummary(warnings)}`,
327
402
  ].join("\n");
@@ -423,6 +498,3 @@ function errorMessage(error: unknown): string {
423
498
  return error instanceof Error ? error.message : String(error);
424
499
  }
425
500
 
426
- function isRecord(value: unknown): value is Record<string, unknown> {
427
- return typeof value === "object" && value !== null && !Array.isArray(value);
428
- }
package/lib/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Model } from "@earendil-works/pi-ai";
2
+ import { isRecord, readModelRef, readNonEmptyString } from "./internal.ts";
2
3
 
3
4
  export const CONFIG_VERSION = 1;
4
5
  export const DEFAULT_FALLBACK_STATUSES = [429, 500, 502, 503, 504] as const;
@@ -13,6 +14,8 @@ export interface FallbackRule {
13
14
  matchProviders?: string[];
14
15
  matchModels?: ModelRef[];
15
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[];
16
19
  /** Persistent failover cooldown in milliseconds after a matching failure. Defaults: 429 => 72h, 5xx => 10m. */
17
20
  cooldownMs?: number;
18
21
  fallback: ModelRef;
@@ -21,6 +24,8 @@ export interface FallbackRule {
21
24
  export interface ModelFallbackConfig {
22
25
  version: 1;
23
26
  enabled: boolean;
27
+ /** Re-queue the failed user prompt after switching to the fallback model so the turn retries automatically. Defaults to true. */
28
+ autoRetry?: boolean;
24
29
  rules: FallbackRule[];
25
30
  }
26
31
 
@@ -49,12 +54,15 @@ interface NormalizedRuleForWarnings {
49
54
  modelKeys: Set<string>;
50
55
  models: ModelRef[];
51
56
  statuses: Set<number>;
57
+ /** undefined = the rule matches any failure reason. */
58
+ reasons?: Set<string>;
52
59
  }
53
60
 
54
61
  export function defaultConfig(): ModelFallbackConfig {
55
62
  return {
56
63
  version: CONFIG_VERSION,
57
64
  enabled: true,
65
+ autoRetry: true,
58
66
  rules: [
59
67
  {
60
68
  name: "zai-to-deepseek-flash",
@@ -76,15 +84,24 @@ export function validateConfigShape(value: unknown): ModelFallbackConfig {
76
84
  return {
77
85
  version: CONFIG_VERSION,
78
86
  enabled,
87
+ autoRetry: typeof value.autoRetry === "boolean" ? value.autoRetry : true,
79
88
  rules: value.rules.map((ruleValue, index) => validateRule(ruleValue, index)),
80
89
  };
81
90
  }
82
91
 
83
- 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 {
84
98
  if (!config.enabled) return undefined;
85
99
  for (const rule of config.rules) {
86
100
  if (!statusesFor(rule).has(status)) continue;
87
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;
88
105
  return { rule, fallback: rule.fallback };
89
106
  }
90
107
  return undefined;
@@ -104,17 +121,17 @@ export function analyzeRuleWarnings(config: ModelFallbackConfig): RuleWarning[]
104
121
  const earlierRule = config.rules[earlierIndex];
105
122
  const earlier = normalizedRules[earlierIndex];
106
123
 
107
- if (setsEqual(earlier.statuses, later.statuses) && ruleScopesEqual(earlier, later)) {
124
+ if (setsEqual(earlier.statuses, later.statuses) && ruleScopesEqual(earlier, later) && reasonsEqual(earlier, later)) {
108
125
  warnings.push(duplicateRuleWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex));
109
126
  continue rules;
110
127
  }
111
128
 
112
- if (statusSetCovers(earlier.statuses, later.statuses) && ruleScopeCovers(earlier, later)) {
129
+ if (statusSetCovers(earlier.statuses, later.statuses) && ruleScopeCovers(earlier, later) && reasonsCover(earlier, later)) {
113
130
  warnings.push(scopeShadowWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex, earlier));
114
131
  continue rules;
115
132
  }
116
133
 
117
- 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;
118
135
 
119
136
  const shadowedModels = later.models.filter((entry) => earlier.providers.has(entry.provider) && !reportedModelShadowKeys.has(modelRefKey(entry)));
120
137
  if (shadowedModels.length === 0) continue;
@@ -142,6 +159,7 @@ function validateRule(value: unknown, index: number): FallbackRule {
142
159
  if (value.matchProviders !== undefined) rule.matchProviders = readStringArray(value.matchProviders, `rules[${index}].matchProviders`);
143
160
  if (value.matchModels !== undefined) rule.matchModels = readModelRefArray(value.matchModels, `rules[${index}].matchModels`);
144
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`);
145
163
  if (value.cooldownMs !== undefined) rule.cooldownMs = readPositiveInteger(value.cooldownMs, `rules[${index}].cooldownMs`);
146
164
  if ((!rule.matchProviders || rule.matchProviders.length === 0) && (!rule.matchModels || rule.matchModels.length === 0)) {
147
165
  throw new Error(`rules[${index}] must define matchProviders or matchModels.`);
@@ -155,6 +173,11 @@ function modelMatches(rule: FallbackRule, model: Pick<Model<any>, "provider" | "
155
173
  return false;
156
174
  }
157
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
+
158
181
  function statusesFor(rule: FallbackRule): Set<number> {
159
182
  return new Set(rule.statuses && rule.statuses.length > 0 ? rule.statuses : DEFAULT_FALLBACK_STATUSES);
160
183
  }
@@ -166,6 +189,7 @@ function normalizeRuleForWarnings(rule: FallbackRule): NormalizedRuleForWarnings
166
189
  modelKeys: new Set(modelsByKey.keys()),
167
190
  models: [...modelsByKey.values()],
168
191
  statuses: statusesFor(rule),
192
+ reasons: rule.reasons && rule.reasons.length > 0 ? new Set(rule.reasons) : undefined,
169
193
  };
170
194
  }
171
195
 
@@ -244,6 +268,17 @@ function statusSetCovers(earlier: Set<number>, later: Set<number>): boolean {
244
268
  return setCovers(earlier, later);
245
269
  }
246
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
+
247
282
  function setsEqual<T>(left: Set<T>, right: Set<T>): boolean {
248
283
  return left.size === right.size && setCovers(left, right);
249
284
  }
@@ -281,14 +316,6 @@ function formatModelList(models: ModelRef[]): string {
281
316
  return models.map(modelRefKey).sort().join(", ");
282
317
  }
283
318
 
284
- function readModelRef(value: unknown, path: string): ModelRef {
285
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
286
- return {
287
- provider: readNonEmptyString(value.provider, `${path}.provider`),
288
- model: readNonEmptyString(value.model, `${path}.model`),
289
- };
290
- }
291
-
292
319
  function readModelRefArray(value: unknown, path: string): ModelRef[] {
293
320
  if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
294
321
  if (value.length === 0) throw new Error(`${path} must not be empty.`);
@@ -315,11 +342,3 @@ function readPositiveInteger(value: unknown, path: string): number {
315
342
  return value;
316
343
  }
317
344
 
318
- function readNonEmptyString(value: unknown, path: string): string {
319
- if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
320
- return value.trim();
321
- }
322
-
323
- function isRecord(value: unknown): value is Record<string, unknown> {
324
- return typeof value === "object" && value !== null && !Array.isArray(value);
325
- }
@@ -1,9 +1,36 @@
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
+
1
18
  export function parseStatusFromErrorMessage(message: string): number | undefined {
2
19
  const patterns: RegExp[] = [
20
+ // Leading bare status code, optionally prefixed with "Error:", e.g. "401: {\"type\":\"CreditsError\"...}"
21
+ // or "Error: 401: {\"type\":\"CreditsError\"...}" (OpenCode Go, OpenRouter relays)
22
+ /^\s*(?:error\s*:?\s*)?([1-5][0-9]{2})\s*:/i,
23
+ // Anchored "Error: <status> <prose>", e.g. "Error: 429 quota exceeded"
24
+ // (GitHub Copilot). The pattern above needs a colon AFTER the status and the
25
+ // "error <status>" pattern below needs whitespace directly after "error", so
26
+ // a message carrying both the prefix and a colon fell between them. The
27
+ // "error" prefix is required: without it this would also match prose such as
28
+ // "429 tokens remaining in context window", which must stay unmatched.
29
+ /^\s*error\s*:?\s*([1-5][0-9]{2})\b/i,
3
30
  /\b(?:status|code|http)\s*[:\s]?\s*([1-5][0-9]{2})\b/i,
4
31
  /\bHTTP\/\d(?:\.\d)?\s+([1-5][0-9]{2})\b/,
5
32
  /\b(?:rate[\s-]?limit(?:ed)?|too many requests)[^0-9]{0,40}([45][0-9]{2})\b/i,
6
- /\b([45][0-9]{2})\s+(?:error|too many requests|service unavailable|bad gateway|gateway timeout|internal server error)\b/i,
33
+ /\b([45][0-9]{2})\s+(?:error|quota|too many requests|service unavailable|bad gateway|gateway timeout|internal server error)\b/i,
7
34
  /\berror\s+([45][0-9]{2})\b/i,
8
35
  ];
9
36
 
@@ -0,0 +1,31 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ export function isRecord(value: unknown): value is Record<string, unknown> {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+
7
+ export function readNonEmptyString(value: unknown, path: string): string {
8
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
9
+ return value.trim();
10
+ }
11
+
12
+ export function readModelRef(value: unknown, path: string): { provider: string; model: string } {
13
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
14
+ return {
15
+ provider: readNonEmptyString(value.provider, `${path}.provider`),
16
+ model: readNonEmptyString(value.model, `${path}.model`),
17
+ };
18
+ }
19
+
20
+ export async function readJsonIfExists(path: string): Promise<unknown | undefined> {
21
+ try {
22
+ return JSON.parse(await readFile(path, "utf8"));
23
+ } catch (error) {
24
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
30
+ return error instanceof Error && "code" in error;
31
+ }
package/lib/state.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
- import type { ModelRef } from "./config.js";
3
+ import { modelRefKey, type ModelRef } from "./config.ts";
4
+ import { isRecord, readJsonIfExists, readModelRef, readNonEmptyString } from "./internal.ts";
4
5
 
5
6
  export const STATE_VERSION = 1;
6
7
 
@@ -41,10 +42,6 @@ export function upsertStateEntry(state: FallbackState, entry: FallbackStateEntry
41
42
  };
42
43
  }
43
44
 
44
- function modelRefKey(ref: ModelRef): string {
45
- return `${ref.provider}/${ref.model}`;
46
- }
47
-
48
45
  export function findActiveStateEntry(state: FallbackState, source: ModelRef, now = new Date()): FallbackStateEntry | undefined {
49
46
  const sourceKey = modelRefKey(source);
50
47
  return state.entries.find((entry) => modelRefKey(entry.source) === sourceKey && Date.parse(entry.until) > now.getTime());
@@ -87,32 +84,3 @@ function validateEntry(value: unknown, index: number): FallbackStateEntry {
87
84
  return entry;
88
85
  }
89
86
 
90
- function readModelRef(value: unknown, path: string): ModelRef {
91
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
92
- return {
93
- provider: readNonEmptyString(value.provider, `${path}.provider`),
94
- model: readNonEmptyString(value.model, `${path}.model`),
95
- };
96
- }
97
-
98
- function readNonEmptyString(value: unknown, path: string): string {
99
- if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
100
- return value.trim();
101
- }
102
-
103
- async function readJsonIfExists(path: string): Promise<unknown | undefined> {
104
- try {
105
- return JSON.parse(await readFile(path, "utf8"));
106
- } catch (error) {
107
- if (isNodeError(error) && error.code === "ENOENT") return undefined;
108
- throw error;
109
- }
110
- }
111
-
112
- function isNodeError(error: unknown): error is NodeJS.ErrnoException {
113
- return error instanceof Error && "code" in error;
114
- }
115
-
116
- function isRecord(value: unknown): value is Record<string, unknown> {
117
- return typeof value === "object" && value !== null && !Array.isArray(value);
118
- }
package/lib/storage.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
- import { defaultConfig, validateConfigShape, type ModelFallbackConfig } from "./config.js";
3
+ import { defaultConfig, validateConfigShape, type ModelFallbackConfig } from "./config.ts";
4
+ import { readJsonIfExists } from "./internal.ts";
4
5
 
5
6
  export interface ModelFallbackPaths {
6
7
  dir: string;
@@ -23,15 +24,3 @@ export async function writeConfig(path: string, config: ModelFallbackConfig): Pr
23
24
  await writeFile(path, `${JSON.stringify(validateConfigShape(config), null, 2)}\n`, "utf8");
24
25
  }
25
26
 
26
- async function readJsonIfExists(path: string): Promise<unknown | undefined> {
27
- try {
28
- return JSON.parse(await readFile(path, "utf8"));
29
- } catch (error) {
30
- if (isNodeError(error) && error.code === "ENOENT") return undefined;
31
- throw error;
32
- }
33
- }
34
-
35
- function isNodeError(error: unknown): error is NodeJS.ErrnoException {
36
- return error instanceof Error && "code" in error;
37
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-model-fallback",
3
- "version": "0.3.7",
3
+ "version": "0.5.0",
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,12 +50,13 @@
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
- "typebox": "latest",
53
+ "@earendil-works/pi-agent-core": "^0.86.1",
54
+ "@earendil-works/pi-ai": "^0.86.1",
55
+ "@earendil-works/pi-coding-agent": "^0.86.1",
56
+ "@earendil-works/pi-tui": "^0.86.1",
58
57
  "@types/node": "^26.0.0",
58
+ "jiti": "^2.7.0",
59
+ "typebox": "latest",
59
60
  "typescript": "^7.0.2"
60
61
  }
61
62
  }