pi-model-fallback 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,25 +2,37 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- ## [0.3.7] - 2026-08-22
5
+ ## [0.4.0] - 2026-09-11
6
6
 
7
- ### Changed
7
+ ### Added
8
8
 
9
- - Merge the 2026-08-22 managed OSS dependency and maintenance PR batch.
9
+ - `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
10
 
11
11
  ### Fixed
12
12
 
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.
13
+ - 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.
14
+ - Auto-retry when a fallback model is merely preselected instead of skipping the switch path.
15
+ - Parse optional `Error:` prefixes before leading bare status codes.
16
16
 
17
17
  ### Changed
18
18
 
19
- - Bump package version to `0.3.5` for the next patch release.
19
+ - Bump Pi dev dependencies to `@earendil-works/*` 0.84.4 and `@types/node` 26.4.0.
20
+ - Add `jiti` as a devDependency so auto-retry integration tests run in CI.
21
+
22
+ ## [0.3.7] - 2026-08-22
23
+
24
+ ### Changed
20
25
 
26
+ - Merge the 2026-08-22 managed OSS dependency and maintenance PR batch.
21
27
  - Declare `@earendil-works/pi-agent-core` as a peerDependency to align with pi-extension-template.
22
28
  - Add Buy Me a Coffee sponsor button to README and native GitHub funding link via `.github/FUNDING.yml`.
23
29
 
30
+ ### Fixed
31
+
32
+ - Warn on completely shadowed fallback rules in config validation, save confirmations, status output, and config details without rejecting warning-bearing configs.
33
+ - 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`.
34
+ - Clarify that maintainers should update `CHANGELOG.md` before staging release files.
35
+
24
36
  ## [0.3.3] - 2026-07-04
25
37
 
26
38
  ### Changed
@@ -74,7 +86,7 @@ This project follows semantic versioning.
74
86
  - Publish workflow now installs a current npm CLI so npm Trusted Publishing OIDC is supported.
75
87
  - CI and publish workflow commands no longer include literal trailing `\\n` text.
76
88
 
77
- ## [0.1.0] - YYYY-MM-DD
89
+ ## [0.1.0] - 2026-06-30
78
90
 
79
91
  ### Added
80
92
 
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",
@@ -92,6 +93,11 @@ Rule fields:
92
93
  - `cooldownMs`: optional persistent fallback window
93
94
  - `fallback`: target model Pi should switch to
94
95
 
96
+ Top-level fields:
97
+
98
+ - `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
+
95
101
  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
102
 
97
103
  ### Cooldowns
@@ -17,6 +17,7 @@ import {
17
17
  } from "../lib/config.js";
18
18
  import { 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)));
@@ -106,17 +107,20 @@ export default function modelFallback(pi: ExtensionAPI) {
106
107
  ctx.ui.notify(`Model fallback preselected: ${originalModelKey} → ${activeFallbackKey} until ${active.until}.`, "warning");
107
108
  }
108
109
 
109
- async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext): Promise<void> {
110
+ async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext): Promise<boolean> {
110
111
  const loaded = config ?? (await loadConfig(ctx));
111
- if (!loaded) return;
112
+ if (!loaded) return false;
112
113
  const match = findFallback(loaded, { provider: source.provider, id: source.model }, status);
113
- if (!match) return;
114
- if (activeFallbackKey && activeFallbackKey === modelRefKey(match.fallback)) return;
114
+ 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;
115
119
 
116
120
  const fallbackModel = ctx.modelRegistry.find(match.fallback.provider, match.fallback.model);
117
121
  if (!fallbackModel) {
118
122
  ctx.ui.notify(`Model fallback missing: ${modelRefKey(match.fallback)}`, "warning");
119
- return;
123
+ return false;
120
124
  }
121
125
 
122
126
  const now = new Date();
@@ -135,7 +139,7 @@ export default function modelFallback(pi: ExtensionAPI) {
135
139
  const ok = await pi.setModel(fallbackModel);
136
140
  if (!ok) {
137
141
  ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
138
- return;
142
+ return false;
139
143
  }
140
144
 
141
145
  originalModelKey = modelRefKey(source);
@@ -143,6 +147,7 @@ export default function modelFallback(pi: ExtensionAPI) {
143
147
  lastFallbackReason = `${status} from ${originalModelKey}; persistent until ${until}`;
144
148
  updateStatus(ctx);
145
149
  ctx.ui.notify(`Model fallback: ${originalModelKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
150
+ return true;
146
151
  }
147
152
 
148
153
  pi.on("agent_start", async (_event, ctx) => {
@@ -182,7 +187,14 @@ export default function modelFallback(pi: ExtensionAPI) {
182
187
  const model = typeof message.model === "string" ? message.model : ctx.model?.id;
183
188
  if (!provider || !model) return;
184
189
  if (activeFallbackKey && `${provider}/${model}` === activeFallbackKey) return;
185
- await persistFailure({ provider, model }, status, {}, ctx);
190
+ const switched = await persistFailure({ provider, model }, status, {}, ctx);
191
+ if (!switched) return;
192
+ const loaded = config;
193
+ if (!loaded || loaded.autoRetry === false) return;
194
+ const prompt = lastUserPromptText(ctx);
195
+ if (!prompt) return;
196
+ ctx.ui.notify(`Model fallback: retrying failed prompt on ${activeFallbackKey}.`, "info");
197
+ await pi.sendUserMessage(prompt, { deliverAs: "followUp" });
186
198
  });
187
199
 
188
200
  pi.registerCommand("model-fallback:status", {
@@ -266,6 +278,33 @@ export default function modelFallback(pi: ExtensionAPI) {
266
278
  });
267
279
  }
268
280
 
281
+ function autoRetryEnabled(config: ModelFallbackConfig): boolean {
282
+ return config.autoRetry !== false;
283
+ }
284
+
285
+ function lastUserPromptText(ctx: ExtensionContext): string | undefined {
286
+ const entries = ctx.sessionManager.getEntries() as unknown[];
287
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
288
+ const entry = entries[index];
289
+ if (!isRecord(entry) || !isRecord(entry.message)) continue;
290
+ const message = entry.message;
291
+ if (message.role !== "user") continue;
292
+ return userMessageText(message.content);
293
+ }
294
+ return undefined;
295
+ }
296
+
297
+ function userMessageText(content: unknown): string | undefined {
298
+ if (typeof content === "string") return content.trim() || undefined;
299
+ if (!Array.isArray(content)) return undefined;
300
+ const parts: string[] = [];
301
+ for (const block of content) {
302
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
303
+ }
304
+ const text = parts.join("\n").trim();
305
+ return text || undefined;
306
+ }
307
+
269
308
  function shouldUseProjectLocalState(ctx: ExtensionContext): boolean {
270
309
  const projectSettingsPath = join(ctx.cwd, ".pi", "settings.json");
271
310
  return existsSync(projectSettingsPath) && projectSettingsIncludesThisPackage(projectSettingsPath);
@@ -307,6 +346,7 @@ function formatStatus(
307
346
  ): string {
308
347
  return [
309
348
  `Model fallback: ${config.enabled ? "enabled" : "disabled"}`,
349
+ `Auto retry: ${autoRetryEnabled(config) ? "enabled" : "disabled"}`,
310
350
  `Config: ${configPath}`,
311
351
  `State: ${statePath}`,
312
352
  `Rules: ${config.rules.length}`,
@@ -322,6 +362,7 @@ function formatStatus(
322
362
  function summarizeConfig(config: ModelFallbackConfig, warnings: RuleWarning[]): string {
323
363
  return [
324
364
  `enabled: ${config.enabled}`,
365
+ `autoRetry: ${autoRetryEnabled(config)}`,
325
366
  ...config.rules.map((rule) => `${rule.name ?? "rule"}: ${rule.matchProviders?.join(",") ?? "models"} -> ${modelRefKey(rule.fallback)}`),
326
367
  `warnings: ${warningSummary(warnings)}`,
327
368
  ].join("\n");
@@ -423,6 +464,3 @@ function errorMessage(error: unknown): string {
423
464
  return error instanceof Error ? error.message : String(error);
424
465
  }
425
466
 
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;
@@ -21,6 +22,8 @@ export interface FallbackRule {
21
22
  export interface ModelFallbackConfig {
22
23
  version: 1;
23
24
  enabled: boolean;
25
+ /** Re-queue the failed user prompt after switching to the fallback model so the turn retries automatically. Defaults to true. */
26
+ autoRetry?: boolean;
24
27
  rules: FallbackRule[];
25
28
  }
26
29
 
@@ -55,6 +58,7 @@ export function defaultConfig(): ModelFallbackConfig {
55
58
  return {
56
59
  version: CONFIG_VERSION,
57
60
  enabled: true,
61
+ autoRetry: true,
58
62
  rules: [
59
63
  {
60
64
  name: "zai-to-deepseek-flash",
@@ -76,6 +80,7 @@ export function validateConfigShape(value: unknown): ModelFallbackConfig {
76
80
  return {
77
81
  version: CONFIG_VERSION,
78
82
  enabled,
83
+ autoRetry: typeof value.autoRetry === "boolean" ? value.autoRetry : true,
79
84
  rules: value.rules.map((ruleValue, index) => validateRule(ruleValue, index)),
80
85
  };
81
86
  }
@@ -281,14 +286,6 @@ function formatModelList(models: ModelRef[]): string {
281
286
  return models.map(modelRefKey).sort().join(", ");
282
287
  }
283
288
 
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
289
  function readModelRefArray(value: unknown, path: string): ModelRef[] {
293
290
  if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
294
291
  if (value.length === 0) throw new Error(`${path} must not be empty.`);
@@ -315,11 +312,3 @@ function readPositiveInteger(value: unknown, path: string): number {
315
312
  return value;
316
313
  }
317
314
 
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,5 +1,8 @@
1
1
  export function parseStatusFromErrorMessage(message: string): number | undefined {
2
2
  const patterns: RegExp[] = [
3
+ // Leading bare status code, optionally prefixed with "Error:", e.g. "401: {\"type\":\"CreditsError\"...}"
4
+ // or "Error: 401: {\"type\":\"CreditsError\"...}" (OpenCode Go, OpenRouter relays)
5
+ /^\s*(?:error\s*:?\s*)?([1-5][0-9]{2})\s*:/i,
3
6
  /\b(?:status|code|http)\s*[:\s]?\s*([1-5][0-9]{2})\b/i,
4
7
  /\bHTTP\/\d(?:\.\d)?\s+([1-5][0-9]{2})\b/,
5
8
  /\b(?:rate[\s-]?limit(?:ed)?|too many requests)[^0-9]{0,40}([45][0-9]{2})\b/i,
@@ -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.4.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",
@@ -54,8 +54,9 @@
54
54
  "@earendil-works/pi-ai": "^0.84.1",
55
55
  "@earendil-works/pi-coding-agent": "^0.84.1",
56
56
  "@earendil-works/pi-tui": "^0.84.1",
57
- "typebox": "latest",
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
  }