pi-model-fallback 0.1.0 → 0.3.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,38 +1,67 @@
1
- # Changelog
1
+ # Changelog
2
+
3
+ ## Unreleased
2
4
 
3
- All notable changes to this project will be documented in this file.
5
+ - Add Buy Me a Coffee sponsor button to README and native GitHub funding link via `.github/FUNDING.yml`.
4
6
 
5
- This project follows semantic versioning.
6
-
7
- ## [0.1.3] - 2026-06-19
7
+ ## [0.3.3] - 2026-07-04
8
8
 
9
9
  ### Changed
10
10
 
11
- - CONTRIBUTING now reminds maintainers to run `npm pack --dry-run` after changing which `docs/` files ship in the package.
12
-
13
- ### Fixed
14
-
15
- - README License section no longer ends with a literal `\n` placeholder.
16
-
17
- ## [0.1.2] - 2026-06-04
18
-
19
- ### Changed
11
+ - Rewrite the README with real `pi-model-fallback` install, command, configuration, and state-path documentation instead of template placeholders.
20
12
 
21
- - README and `docs/template-checklist.md` now follow the Pi OSS minimal-docs policy: `docs/` is optional, with explicit post-generation cleanup for template bootstrap docs.
22
- - Template bootstrap docs (`github-template.md`, `repository-settings.md`, `typescript.md`) are labeled for delete-or-merge after setup.
13
+ ## [0.3.2] - 2026-07-04
23
14
 
24
- ## [0.1.1] - 2026-06-01
25
-
26
- ### Changed
27
-
28
- - Publish workflow now supports npm publishing on merged package version bumps in addition to tags, releases, and manual dispatch.
29
- - Publish workflow now installs a current npm CLI so npm Trusted Publishing OIDC is supported.
30
- - CI and publish workflow commands no longer include literal trailing `\\n` text.
31
-
32
- ## [0.1.0] - YYYY-MM-DD
33
-
34
- ### Added
15
+ ### Fixed
35
16
 
36
- - Initial Pi package template.
37
- - Example extension, Agent Skill, prompt, and theme.
38
- - CI and npm Trusted Publishing workflow.
17
+ - Publish only from release refs, print Node/npm versions, and publish with `--provenance --access public` so npm Trusted Publishing uses the same known-good path as `pi-spotify-widget`.
18
+ - Release instructions now use `npm version patch --no-git-tag-version` so local tags do not race the auto-release workflow.
19
+
20
+ ## 0.3.0
21
+
22
+ - Use `agent_start` for persistent preselect; `session_start` was not a Pi extension event.
23
+ - Persist fallback state from assistant error messages on `turn_end`, covering provider SDK errors that do not emit `after_provider_response`.
24
+
25
+ ## 0.2.0
26
+
27
+ - Add persistent failover state. A matching provider failure now writes `model-fallback/state.json`, and future sessions preselect the fallback model before the first provider call until the cooldown expires.
28
+ - Parse retry/reset headers when available; otherwise default 429 cooldown to 72h and 5xx cooldown to 10m.
29
+ - `/model-fallback:reset` now clears persistent state.
30
+
31
+ All notable changes to this project will be documented in this file.
32
+
33
+ This project follows semantic versioning.
34
+
35
+ ## [0.1.3] - 2026-06-19
36
+
37
+ ### Changed
38
+
39
+ - CONTRIBUTING now reminds maintainers to run `npm pack --dry-run` after changing which `docs/` files ship in the package.
40
+
41
+ ### Fixed
42
+
43
+ - README License section no longer ends with a literal `\n` placeholder.
44
+
45
+ ## [0.1.2] - 2026-06-04
46
+
47
+ ### Changed
48
+
49
+ - README and `docs/template-checklist.md` now follow the Pi OSS minimal-docs policy: `docs/` is optional, with explicit post-generation cleanup for template bootstrap docs.
50
+ - Template bootstrap docs (`github-template.md`, `repository-settings.md`, `typescript.md`) are labeled for delete-or-merge after setup.
51
+
52
+ ## [0.1.1] - 2026-06-01
53
+
54
+ ### Changed
55
+
56
+ - Publish workflow now supports npm publishing on merged package version bumps in addition to tags, releases, and manual dispatch.
57
+ - Publish workflow now installs a current npm CLI so npm Trusted Publishing OIDC is supported.
58
+ - CI and publish workflow commands no longer include literal trailing `\\n` text.
59
+
60
+ ## [0.1.0] - YYYY-MM-DD
61
+
62
+ ### Added
63
+
64
+ - Initial Pi package template.
65
+ - Example extension, Agent Skill, prompt, and theme.
66
+ - CI and npm Trusted Publishing workflow.
67
+
package/README.md CHANGED
@@ -1,88 +1,110 @@
1
- # PACKAGE_DISPLAY_NAME
1
+ # pi-model-fallback
2
2
 
3
- [![CI](https://github.com/OWNER/REPO/actions/workflows/ci.yml/badge.svg)](https://github.com/OWNER/REPO/actions/workflows/ci.yml)
4
- [![Publish](https://github.com/OWNER/REPO/actions/workflows/publish.yml/badge.svg)](https://github.com/OWNER/REPO/actions/workflows/publish.yml)
5
- [![npm version](https://img.shields.io/npm/v/PACKAGE_NAME.svg)](https://www.npmjs.com/package/PACKAGE_NAME)
6
- [![npm downloads](https://img.shields.io/npm/dm/PACKAGE_NAME.svg)](https://www.npmjs.com/package/PACKAGE_NAME)
3
+ [![CI](https://github.com/eiei114/pi-model-fallback/actions/workflows/ci.yml/badge.svg)](https://github.com/eiei114/pi-model-fallback/actions/workflows/ci.yml)
4
+ [![Publish](https://github.com/eiei114/pi-model-fallback/actions/workflows/publish.yml/badge.svg)](https://github.com/eiei114/pi-model-fallback/actions/workflows/publish.yml)
5
+ [![npm version](https://img.shields.io/npm/v/pi-model-fallback.svg)](https://www.npmjs.com/package/pi-model-fallback)
6
+ [![npm downloads](https://img.shields.io/npm/dm/pi-model-fallback.svg)](https://www.npmjs.com/package/pi-model-fallback)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
8
  [![Pi package](https://img.shields.io/badge/pi-package-purple.svg)](https://pi.dev/packages)
9
- [![Trusted Publishing](https://img.shields.io/badge/npm-Trusted%20Publishing-blue.svg)](docs/release.md)
9
+ <a href="https://buymeacoffee.com/ekawano114m"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="217" height="60"></a>
10
10
 
11
- > One-line pitch for this TypeScript-first Pi package.
11
+ > Pi extension that switches to a fallback model after provider failures such as 429 rate limits.
12
12
 
13
- ## What this is
13
+ ## What this does
14
14
 
15
- Briefly explain what this TypeScript-first package adds to Pi and who should use it.
15
+ `pi-model-fallback` watches provider failures and automatically moves Pi to a safer fallback model when a matching rule fires.
16
16
 
17
- ## Features
17
+ Current default:
18
18
 
19
- - Feature 1
20
- - Feature 2
21
- - Feature 3
19
+ - source provider: `zai`
20
+ - matching statuses: `429`, `500`, `502`, `503`, `504`
21
+ - fallback model: `deepseek/deepseek-v4-flash`
22
+
23
+ When a failure matches, the extension also stores persistent fallback state so future sessions can preselect the fallback model until the cooldown expires.
22
24
 
23
25
  ## Install
24
26
 
25
- Install the published npm package with Pi:
27
+ Install from npm:
26
28
 
27
29
  ```bash
28
- pi install npm:PACKAGE_NAME
30
+ pi install npm:pi-model-fallback
29
31
  ```
30
32
 
31
- Replace `PACKAGE_NAME` with the exact `name` from `package.json`.
32
- For a scoped npm package, keep the `npm:` prefix:
33
+ Install into the current project only:
33
34
 
34
35
  ```bash
35
- pi install npm:@your-scope/your-pi-package
36
+ pi install npm:pi-model-fallback -l
36
37
  ```
37
38
 
38
- Pin a specific version when you want reproducible installs:
39
+ Or install from GitHub:
39
40
 
40
41
  ```bash
41
- pi install npm:PACKAGE_NAME@0.1.0
42
+ pi install git:github.com/eiei114/pi-model-fallback
42
43
  ```
43
44
 
44
- Install into the current project instead of your user Pi settings:
45
+ Try it without permanently installing:
45
46
 
46
47
  ```bash
47
- pi install npm:PACKAGE_NAME -l
48
+ pi -e npm:pi-model-fallback
48
49
  ```
49
50
 
50
- Or install from GitHub:
51
+ ## Commands
51
52
 
52
- ```bash
53
- pi install git:github.com/OWNER/REPO
53
+ ```text
54
+ /model-fallback:status
55
+ /model-fallback:reset
54
56
  ```
55
57
 
56
- Try it without permanently installing:
57
-
58
- ```bash
59
- pi -e npm:PACKAGE_NAME
58
+ - `status`: shows whether fallback is enabled, active persistent entries, and current paths
59
+ - `reset`: clears persistent fallback state and switches back to the remembered original model when possible
60
+
61
+ ## Configuration
62
+
63
+ The extension exposes the `model_fallback_config` tool for reading, validating, and saving config JSON.
64
+
65
+ Default config shape:
66
+
67
+ ```json
68
+ {
69
+ "version": 1,
70
+ "enabled": true,
71
+ "rules": [
72
+ {
73
+ "name": "zai-to-deepseek-flash",
74
+ "matchProviders": ["zai"],
75
+ "statuses": [429, 500, 502, 503, 504],
76
+ "fallback": {
77
+ "provider": "deepseek",
78
+ "model": "deepseek-v4-flash"
79
+ }
80
+ }
81
+ ]
82
+ }
60
83
  ```
61
84
 
62
- ## Quick start
85
+ Rule fields:
63
86
 
64
- Try this package locally:
87
+ - `matchProviders`: match all models from a provider
88
+ - `matchModels`: match specific `provider` + `model` pairs
89
+ - `statuses`: optional; defaults to `429, 500, 502, 503, 504`
90
+ - `cooldownMs`: optional persistent fallback window
91
+ - `fallback`: target model Pi should switch to
65
92
 
66
- ```bash
67
- pi -e .
68
- ```
93
+ ## State and paths
69
94
 
70
- Then run:
95
+ The extension stores:
71
96
 
72
- ```txt
73
- /your-command
74
- ```
97
+ - config: `model-fallback/config.json`
98
+ - state: `model-fallback/state.json`
75
99
 
76
- ## Package contents
100
+ If the package is installed project-locally and the current project references it from `.pi/settings.json`, those files live under the project `.pi/` directory. Otherwise they live under the user agent directory.
77
101
 
78
- | Path | Purpose |
79
- |---|---|
80
- | `extensions/` | Pi TypeScript extension entrypoints (`*.ts` and `index.ts`) |
81
- | `lib/` | Shared TypeScript helpers |
82
- | `skills/` | Agent Skills |
83
- | `prompts/` | Prompt templates |
84
- | `themes/` | Pi themes |
85
- | `docs/` | Optional supporting docs (usage, examples, release, ADRs) |
102
+ ## Behavior notes
103
+
104
+ - Successful responses do nothing.
105
+ - Matching failures from `after_provider_response` can trigger fallback immediately.
106
+ - Assistant error messages parsed at `turn_end` can also persist fallback state for SDK/provider failures that do not emit the normal response hook.
107
+ - The failed request is not automatically replayed.
86
108
 
87
109
  ## Development
88
110
 
@@ -91,72 +113,18 @@ npm install
91
113
  npm run ci
92
114
  ```
93
115
 
94
- ## Development flow
95
-
96
- Use this default flow when building a new Pi extension OSS project from this template:
97
-
98
- 1. Create the Vault project notes under `4_Project/<ProjectName>/`.
99
- 2. Add `CONTEXT.md`, `README.md`, `ROADMAP.md`, `Docs/`, `Issues/`, and `Progress/`.
100
- 3. Write the PRD in `4_Project/<ProjectName>/Docs/`.
101
- 4. Split approved tracer-bullet issues into `4_Project/<ProjectName>/Issues/`.
102
- 5. Implement in the OSS repo.
103
- 6. Run `npm run ci`, `npm test`, and `npm pack --dry-run`.
104
- 7. Release with Trusted Publishing.
105
- 8. Save release notes and follow-up decisions back to the Vault project.
106
-
107
- Short version:
108
-
109
- ```txt
110
- Vault notes -> PRD -> Issues -> implement -> ci/check -> release -> save learnings
111
- ```
112
-
113
- ## Release
114
-
115
- This package is set up for npm Trusted Publishing, so no `NPM_TOKEN` is required.
116
+ Run locally in Pi:
116
117
 
117
118
  ```bash
118
- npm version patch
119
- git push
119
+ pi -e .
120
120
  ```
121
121
 
122
- See [`docs/release.md`](docs/release.md) for setup details.
123
-
124
- ## Docs
125
-
126
- `docs/` is optional supporting documentation, not a fixed six-file set. README stays the GitHub/npm entrypoint; add `docs/*.md` only when they help users or maintainers.
127
-
128
- After creating a repository from this template:
129
-
130
- 1. Follow [`docs/template-checklist.md`](docs/template-checklist.md) for setup.
131
- 2. Run the **post-generation docs cleanup** in that checklist: delete or merge template bootstrap docs that no longer add project value.
132
-
133
- Useful docs to keep when they add value:
134
-
135
- - [`docs/examples.md`](docs/examples.md) — examples for extensions, skills, prompts, and themes
136
- - [`docs/release.md`](docs/release.md) — Trusted Publishing details (README Release summarizes the flow)
137
- - `docs/usage.md` — create when usage does not fit in README
138
-
139
- Optional maintainer guidance (not a public-user navigation target in mature repos):
140
-
141
- - [`docs/template-checklist.md`](docs/template-checklist.md)
142
-
143
- Template bootstrap docs to delete or merge after setup unless they still teach something project-specific:
144
-
145
- - `docs/github-template.md`
146
- - `docs/repository-settings.md`
147
- - `docs/typescript.md`
148
-
149
- ## Security
150
-
151
- Pi packages can execute code with your local permissions. Review extensions before installing third-party packages.
152
-
153
- For vulnerability reporting, see [`SECURITY.md`](SECURITY.md).
154
-
155
122
  ## Links
156
123
 
157
- - npm: https://www.npmjs.com/package/PACKAGE_NAME
158
- - GitHub: https://github.com/OWNER/REPO
159
- - Issues: https://github.com/OWNER/REPO/issues
124
+ - npm: https://www.npmjs.com/package/pi-model-fallback
125
+ - GitHub: https://github.com/eiei114/pi-model-fallback
126
+ - Issues: https://github.com/eiei114/pi-model-fallback/issues
127
+ - Usage notes: [`docs/usage.md`](docs/usage.md)
160
128
 
161
129
  ## License
162
130
 
@@ -3,15 +3,27 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
- import { defaultConfig, findFallback, modelKey, modelRefKey, validateConfigShape, type ModelFallbackConfig } from "../lib/config.js";
6
+ import {
7
+ defaultConfig,
8
+ findFallback,
9
+ modelKey,
10
+ modelRefKey,
11
+ validateConfigShape,
12
+ type ModelFallbackConfig,
13
+ type ModelRef,
14
+ } from "../lib/config.js";
15
+ import { findActiveStateEntry, readState, upsertStateEntry, writeState, type FallbackState } from "../lib/state.js";
7
16
  import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
8
17
 
9
18
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
10
19
  const STATUS_KEY = "model-fallback";
20
+ const DEFAULT_429_COOLDOWN_MS = 72 * 60 * 60 * 1000;
21
+ const DEFAULT_5XX_COOLDOWN_MS = 10 * 60 * 1000;
11
22
 
12
23
  export default function modelFallback(pi: ExtensionAPI) {
13
24
  let paths: ModelFallbackPaths = modelFallbackPaths(getAgentDir());
14
25
  let config: ModelFallbackConfig | undefined;
26
+ let state: FallbackState | undefined;
15
27
  let originalModelKey: string | undefined;
16
28
  let activeFallbackKey: string | undefined;
17
29
  let lastFallbackReason: string | undefined;
@@ -31,12 +43,95 @@ export default function modelFallback(pi: ExtensionAPI) {
31
43
  }
32
44
  }
33
45
 
46
+ async function loadState(ctx: ExtensionContext): Promise<FallbackState | undefined> {
47
+ try {
48
+ syncPaths(ctx);
49
+ state = await readState(paths.state);
50
+ await writeState(paths.state, state);
51
+ return state;
52
+ } catch (error) {
53
+ ctx.ui.notify(`Model fallback state error: ${errorMessage(error)}`, "warning");
54
+ return undefined;
55
+ }
56
+ }
57
+
34
58
  function updateStatus(ctx: ExtensionContext): void {
35
59
  ctx.ui.setStatus(STATUS_KEY, activeFallbackKey ? `fallback:${activeFallbackKey}` : undefined);
36
60
  }
37
61
 
38
- pi.on("session_start", async (_event, ctx) => {
62
+ async function applyPersistentFallback(ctx: ExtensionContext): Promise<void> {
63
+ const current = ctx.model;
64
+ if (!current) return;
65
+ const loadedConfig = config ?? (await loadConfig(ctx));
66
+ if (!loadedConfig?.enabled) return;
67
+ const loadedState = state ?? (await loadState(ctx));
68
+ if (!loadedState) return;
69
+
70
+ const active = findActiveStateEntry(loadedState, modelToRef(current));
71
+ if (!active) return;
72
+
73
+ const fallbackModel = ctx.modelRegistry.find(active.fallback.provider, active.fallback.model);
74
+ if (!fallbackModel) {
75
+ ctx.ui.notify(`Model fallback state target missing: ${modelRefKey(active.fallback)}`, "warning");
76
+ return;
77
+ }
78
+
79
+ const ok = await pi.setModel(fallbackModel);
80
+ if (!ok) {
81
+ ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(active.fallback)}`, "warning");
82
+ return;
83
+ }
84
+
85
+ originalModelKey = modelKey(current);
86
+ activeFallbackKey = modelRefKey(active.fallback);
87
+ lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
88
+ updateStatus(ctx);
89
+ ctx.ui.notify(`Model fallback preselected: ${originalModelKey} → ${activeFallbackKey} until ${active.until}.`, "warning");
90
+ }
91
+
92
+ async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext): Promise<void> {
93
+ const loaded = config ?? (await loadConfig(ctx));
94
+ if (!loaded) return;
95
+ const match = findFallback(loaded, { provider: source.provider, id: source.model }, status);
96
+ if (!match) return;
97
+ if (activeFallbackKey && activeFallbackKey === modelRefKey(match.fallback)) return;
98
+
99
+ const fallbackModel = ctx.modelRegistry.find(match.fallback.provider, match.fallback.model);
100
+ if (!fallbackModel) {
101
+ ctx.ui.notify(`Model fallback missing: ${modelRefKey(match.fallback)}`, "warning");
102
+ return;
103
+ }
104
+
105
+ const now = new Date();
106
+ const until = new Date(now.getTime() + cooldownMsFor(match.rule.cooldownMs, status, headers)).toISOString();
107
+ const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
108
+ state = upsertStateEntry(loadedState, {
109
+ source,
110
+ fallback: match.fallback,
111
+ status,
112
+ until,
113
+ createdAt: now.toISOString(),
114
+ ruleName: match.rule.name,
115
+ });
116
+ await writeState(paths.state, state);
117
+
118
+ const ok = await pi.setModel(fallbackModel);
119
+ if (!ok) {
120
+ ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
121
+ return;
122
+ }
123
+
124
+ originalModelKey = modelRefKey(source);
125
+ activeFallbackKey = modelRefKey(match.fallback);
126
+ lastFallbackReason = `${status} from ${originalModelKey}; persistent until ${until}`;
127
+ updateStatus(ctx);
128
+ ctx.ui.notify(`Model fallback: ${originalModelKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
129
+ }
130
+
131
+ pi.on("agent_start", async (_event, ctx) => {
39
132
  await loadConfig(ctx);
133
+ await loadState(ctx);
134
+ await applyPersistentFallback(ctx);
40
135
  updateStatus(ctx);
41
136
  });
42
137
 
@@ -56,41 +151,43 @@ export default function modelFallback(pi: ExtensionAPI) {
56
151
  if (event.status >= 200 && event.status < 300) return;
57
152
  if (activeFallbackKey && modelKey(current) === activeFallbackKey) return;
58
153
 
59
- const match = findFallback(loaded, current, event.status);
60
- if (!match) return;
61
-
62
- const fallbackModel = ctx.modelRegistry.find(match.fallback.provider, match.fallback.model);
63
- if (!fallbackModel) {
64
- ctx.ui.notify(`Model fallback missing: ${modelRefKey(match.fallback)}`, "warning");
65
- return;
66
- }
67
-
68
- const ok = await pi.setModel(fallbackModel);
69
- if (!ok) {
70
- ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
71
- return;
72
- }
154
+ await persistFailure(modelToRef(current), event.status, event.headers, ctx);
155
+ });
73
156
 
74
- originalModelKey = modelKey(current);
75
- activeFallbackKey = modelRefKey(match.fallback);
76
- lastFallbackReason = `${event.status} from ${originalModelKey}`;
77
- updateStatus(ctx);
78
- ctx.ui.notify(`Model fallback: ${originalModelKey} → ${activeFallbackKey} (${event.status}). Re-run the prompt to use fallback.`, "warning");
157
+ pi.on("turn_end", async (event, ctx) => {
158
+ const message = event.message as unknown;
159
+ if (!isRecord(message) || message.role !== "assistant") return;
160
+ const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage : undefined;
161
+ if (!errorMessage) return;
162
+ const status = parseStatusFromErrorMessage(errorMessage);
163
+ if (status === undefined) return;
164
+ const provider = typeof message.provider === "string" ? message.provider : ctx.model?.provider;
165
+ const model = typeof message.model === "string" ? message.model : ctx.model?.id;
166
+ if (!provider || !model) return;
167
+ if (activeFallbackKey && `${provider}/${model}` === activeFallbackKey) return;
168
+ await persistFailure({ provider, model }, status, {}, ctx);
79
169
  });
80
170
 
81
171
  pi.registerCommand("model-fallback:status", {
82
172
  description: "Show model fallback status",
83
173
  handler: async (_args, ctx) => {
84
174
  const loaded = config ?? (await loadConfig(ctx)) ?? defaultConfig();
85
- ctx.ui.notify(formatStatus(loaded, paths.config, activeFallbackKey, originalModelKey, lastFallbackReason), "info");
175
+ const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
176
+ ctx.ui.notify(formatStatus(loaded, loadedState, paths.config, paths.state, activeFallbackKey, originalModelKey, lastFallbackReason), "info");
86
177
  },
87
178
  });
88
179
 
89
180
  pi.registerCommand("model-fallback:reset", {
90
- description: "Return from fallback model to the pre-fallback model when remembered",
181
+ description: "Return from fallback model to the pre-fallback model when remembered and clear persistent state",
91
182
  handler: async (_args, ctx) => {
183
+ syncPaths(ctx);
184
+ state = { version: 1, entries: [] };
185
+ await writeState(paths.state, state);
92
186
  if (!originalModelKey) {
93
- ctx.ui.notify("Model fallback: no remembered original model.", "info");
187
+ ctx.ui.notify("Model fallback: persistent state cleared; no remembered original model.", "info");
188
+ activeFallbackKey = undefined;
189
+ lastFallbackReason = undefined;
190
+ updateStatus(ctx);
94
191
  return;
95
192
  }
96
193
  const [provider, ...modelParts] = originalModelKey.split("/");
@@ -108,7 +205,7 @@ export default function modelFallback(pi: ExtensionAPI) {
108
205
  originalModelKey = undefined;
109
206
  lastFallbackReason = undefined;
110
207
  updateStatus(ctx);
111
- ctx.ui.notify(`Model fallback reset: ${modelKey(model)}`, "info");
208
+ ctx.ui.notify(`Model fallback reset: ${modelKey(model)}; persistent state cleared.`, "info");
112
209
  },
113
210
  });
114
211
 
@@ -129,7 +226,15 @@ export default function modelFallback(pi: ExtensionAPI) {
129
226
  syncPaths(ctx);
130
227
  if (params.action === "read" || params.action === "status") {
131
228
  const current = await readConfig(paths.config);
132
- return textResult(JSON.stringify(current, null, 2), { configPath: paths.config, activeFallbackKey, originalModelKey, lastFallbackReason });
229
+ const currentState = await readState(paths.state);
230
+ return textResult(JSON.stringify(current, null, 2), {
231
+ configPath: paths.config,
232
+ statePath: paths.state,
233
+ state: currentState,
234
+ activeFallbackKey,
235
+ originalModelKey,
236
+ lastFallbackReason,
237
+ });
133
238
  }
134
239
  if (!params.configJson) throw new Error("configJson is required for validate/save.");
135
240
  const nextConfig = validateConfigShape(JSON.parse(params.configJson));
@@ -174,11 +279,22 @@ function validateRegisteredModels(ctx: ExtensionContext, nextConfig: ModelFallba
174
279
  }
175
280
  }
176
281
 
177
- function formatStatus(config: ModelFallbackConfig, configPath: string, activeFallbackKey?: string, originalModelKey?: string, reason?: string): string {
282
+ function formatStatus(
283
+ config: ModelFallbackConfig,
284
+ state: FallbackState,
285
+ configPath: string,
286
+ statePath: string,
287
+ activeFallbackKey?: string,
288
+ originalModelKey?: string,
289
+ reason?: string,
290
+ ): string {
178
291
  return [
179
292
  `Model fallback: ${config.enabled ? "enabled" : "disabled"}`,
180
293
  `Config: ${configPath}`,
294
+ `State: ${statePath}`,
181
295
  `Rules: ${config.rules.length}`,
296
+ `Persistent entries: ${state.entries.length}`,
297
+ ...state.entries.map((entry) => `- ${modelRefKey(entry.source)} -> ${modelRefKey(entry.fallback)} until ${entry.until} (${entry.status})`),
182
298
  `Active fallback: ${activeFallbackKey ?? "none"}`,
183
299
  `Original model: ${originalModelKey ?? "none"}`,
184
300
  `Reason: ${reason ?? "none"}`,
@@ -196,6 +312,62 @@ function textResult(text: string, details?: Record<string, unknown>) {
196
312
  return { content: [{ type: "text" as const, text }], details: details ?? {} };
197
313
  }
198
314
 
315
+ function cooldownMsFor(ruleCooldownMs: number | undefined, status: number, headers: Record<string, string>): number {
316
+ const headerMs = cooldownMsFromHeaders(headers);
317
+ if (headerMs !== undefined) return headerMs;
318
+ if (ruleCooldownMs !== undefined) return ruleCooldownMs;
319
+ if (status === 429) return DEFAULT_429_COOLDOWN_MS;
320
+ if (status >= 500 && status <= 599) return DEFAULT_5XX_COOLDOWN_MS;
321
+ return DEFAULT_5XX_COOLDOWN_MS;
322
+ }
323
+
324
+ function cooldownMsFromHeaders(headers: Record<string, string>): number | undefined {
325
+ const normalized = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
326
+ const retryAfter = parseRetryAfter(normalized["retry-after"]);
327
+ if (retryAfter !== undefined) return retryAfter;
328
+
329
+ for (const name of ["x-ratelimit-reset", "x-ratelimit-reset-requests", "x-ratelimit-reset-tokens", "ratelimit-reset", "x-rate-limit-reset"]) {
330
+ const parsed = parseResetHeader(normalized[name]);
331
+ if (parsed !== undefined) return parsed;
332
+ }
333
+ return undefined;
334
+ }
335
+
336
+ function parseRetryAfter(value: string | undefined): number | undefined {
337
+ if (!value) return undefined;
338
+ const trimmed = value.trim();
339
+ const seconds = Number(trimmed);
340
+ if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds * 1000);
341
+ const dateMs = Date.parse(trimmed);
342
+ if (!Number.isNaN(dateMs) && dateMs > Date.now()) return dateMs - Date.now();
343
+ return undefined;
344
+ }
345
+
346
+ function parseResetHeader(value: string | undefined): number | undefined {
347
+ if (!value) return undefined;
348
+ const trimmed = value.trim();
349
+ const numeric = Number(trimmed);
350
+ if (Number.isFinite(numeric) && numeric > 0) {
351
+ if (numeric > 1_000_000_000_000) return Math.max(0, numeric - Date.now());
352
+ if (numeric > 1_000_000_000) return Math.max(0, numeric * 1000 - Date.now());
353
+ return Math.ceil(numeric * 1000);
354
+ }
355
+ const dateMs = Date.parse(trimmed);
356
+ if (!Number.isNaN(dateMs) && dateMs > Date.now()) return dateMs - Date.now();
357
+ return undefined;
358
+ }
359
+
360
+ function parseStatusFromErrorMessage(message: string): number | undefined {
361
+ const match = message.match(/\b([1-5][0-9]{2})\b/);
362
+ if (!match) return undefined;
363
+ const status = Number(match[1]);
364
+ return Number.isInteger(status) ? status : undefined;
365
+ }
366
+
367
+ function modelToRef(model: { provider: string; id: string }): ModelRef {
368
+ return { provider: model.provider, model: model.id };
369
+ }
370
+
199
371
  function errorMessage(error: unknown): string {
200
372
  return error instanceof Error ? error.message : String(error);
201
373
  }
package/lib/config.ts CHANGED
@@ -1,136 +1,144 @@
1
- import type { Model } from "@earendil-works/pi-ai";
2
-
3
- export const CONFIG_VERSION = 1;
4
- export const DEFAULT_FALLBACK_STATUSES = [429, 500, 502, 503, 504] as const;
5
-
6
- export interface ModelRef {
7
- provider: string;
8
- model: string;
9
- }
10
-
11
- export interface FallbackRule {
12
- name?: string;
13
- matchProviders?: string[];
14
- matchModels?: ModelRef[];
15
- statuses?: number[];
16
- fallback: ModelRef;
17
- }
18
-
19
- export interface ModelFallbackConfig {
20
- version: 1;
21
- enabled: boolean;
22
- rules: FallbackRule[];
23
- }
24
-
25
- export interface MatchedFallback {
26
- rule: FallbackRule;
27
- fallback: ModelRef;
28
- }
29
-
30
- export function defaultConfig(): ModelFallbackConfig {
31
- return {
32
- version: CONFIG_VERSION,
33
- enabled: true,
34
- rules: [
35
- {
36
- name: "zai-to-deepseek-flash",
37
- matchProviders: ["zai"],
38
- statuses: [...DEFAULT_FALLBACK_STATUSES],
39
- fallback: { provider: "deepseek", model: "deepseek-v4-flash" },
40
- },
41
- ],
42
- };
43
- }
44
-
45
- export function validateConfigShape(value: unknown): ModelFallbackConfig {
46
- if (!isRecord(value)) throw new Error("Config must be an object.");
47
- if (value.version !== CONFIG_VERSION) throw new Error(`Unsupported config version: ${String(value.version)}.`);
48
- const enabled = typeof value.enabled === "boolean" ? value.enabled : true;
49
- if (!Array.isArray(value.rules)) throw new Error("rules must be an array.");
50
- if (value.rules.length === 0) throw new Error("rules must include at least one fallback rule.");
51
-
52
- return {
53
- version: CONFIG_VERSION,
54
- enabled,
55
- rules: value.rules.map((ruleValue, index) => validateRule(ruleValue, index)),
56
- };
57
- }
58
-
59
- export function findFallback(config: ModelFallbackConfig, model: Pick<Model<any>, "provider" | "id">, status: number): MatchedFallback | undefined {
60
- if (!config.enabled) return undefined;
61
- for (const rule of config.rules) {
62
- if (!statusesFor(rule).has(status)) continue;
63
- if (!modelMatches(rule, model)) continue;
64
- return { rule, fallback: rule.fallback };
65
- }
66
- return undefined;
67
- }
68
-
69
- export function modelRefKey(ref: ModelRef): string {
70
- return `${ref.provider}/${ref.model}`;
71
- }
72
-
73
- export function modelKey(model: Pick<Model<any>, "provider" | "id">): string {
74
- return `${model.provider}/${model.id}`;
75
- }
76
-
77
- function validateRule(value: unknown, index: number): FallbackRule {
78
- if (!isRecord(value)) throw new Error(`rules[${index}] must be an object.`);
79
- const rule: FallbackRule = { fallback: readModelRef(value.fallback, `rules[${index}].fallback`) };
80
- if (typeof value.name === "string" && value.name.trim() !== "") rule.name = value.name.trim();
81
- if (value.matchProviders !== undefined) rule.matchProviders = readStringArray(value.matchProviders, `rules[${index}].matchProviders`);
82
- if (value.matchModels !== undefined) rule.matchModels = readModelRefArray(value.matchModels, `rules[${index}].matchModels`);
83
- if (value.statuses !== undefined) rule.statuses = readStatuses(value.statuses, `rules[${index}].statuses`);
84
- if ((!rule.matchProviders || rule.matchProviders.length === 0) && (!rule.matchModels || rule.matchModels.length === 0)) {
85
- throw new Error(`rules[${index}] must define matchProviders or matchModels.`);
86
- }
87
- return rule;
88
- }
89
-
90
- function modelMatches(rule: FallbackRule, model: Pick<Model<any>, "provider" | "id">): boolean {
91
- if (rule.matchModels?.some((entry) => entry.provider === model.provider && entry.model === model.id)) return true;
92
- if (rule.matchProviders?.includes(model.provider)) return true;
93
- return false;
94
- }
95
-
96
- function statusesFor(rule: FallbackRule): Set<number> {
97
- return new Set(rule.statuses && rule.statuses.length > 0 ? rule.statuses : DEFAULT_FALLBACK_STATUSES);
98
- }
99
-
100
- function readModelRef(value: unknown, path: string): ModelRef {
101
- if (!isRecord(value)) throw new Error(`${path} must be an object.`);
102
- return {
103
- provider: readNonEmptyString(value.provider, `${path}.provider`),
104
- model: readNonEmptyString(value.model, `${path}.model`),
105
- };
106
- }
107
-
108
- function readModelRefArray(value: unknown, path: string): ModelRef[] {
109
- if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
110
- if (value.length === 0) throw new Error(`${path} must not be empty.`);
111
- return value.map((entry, index) => readModelRef(entry, `${path}[${index}]`));
112
- }
113
-
114
- function readStringArray(value: unknown, path: string): string[] {
115
- if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
116
- if (value.length === 0) throw new Error(`${path} must not be empty.`);
117
- return value.map((entry, index) => readNonEmptyString(entry, `${path}[${index}]`));
118
- }
119
-
120
- function readStatuses(value: unknown, path: string): number[] {
121
- if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
122
- if (value.length === 0) throw new Error(`${path} must not be empty.`);
123
- return value.map((entry, index) => {
124
- if (!Number.isInteger(entry) || entry < 100 || entry > 599) throw new Error(`${path}[${index}] must be an HTTP status code.`);
125
- return entry;
126
- });
127
- }
128
-
129
- function readNonEmptyString(value: unknown, path: string): string {
130
- if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
131
- return value.trim();
132
- }
133
-
134
- function isRecord(value: unknown): value is Record<string, unknown> {
135
- return typeof value === "object" && value !== null && !Array.isArray(value);
136
- }
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+
3
+ export const CONFIG_VERSION = 1;
4
+ export const DEFAULT_FALLBACK_STATUSES = [429, 500, 502, 503, 504] as const;
5
+
6
+ export interface ModelRef {
7
+ provider: string;
8
+ model: string;
9
+ }
10
+
11
+ export interface FallbackRule {
12
+ name?: string;
13
+ matchProviders?: string[];
14
+ matchModels?: ModelRef[];
15
+ statuses?: number[];
16
+ /** Persistent failover cooldown in milliseconds after a matching failure. Defaults: 429 => 72h, 5xx => 10m. */
17
+ cooldownMs?: number;
18
+ fallback: ModelRef;
19
+ }
20
+
21
+ export interface ModelFallbackConfig {
22
+ version: 1;
23
+ enabled: boolean;
24
+ rules: FallbackRule[];
25
+ }
26
+
27
+ export interface MatchedFallback {
28
+ rule: FallbackRule;
29
+ fallback: ModelRef;
30
+ }
31
+
32
+ export function defaultConfig(): ModelFallbackConfig {
33
+ return {
34
+ version: CONFIG_VERSION,
35
+ enabled: true,
36
+ rules: [
37
+ {
38
+ name: "zai-to-deepseek-flash",
39
+ matchProviders: ["zai"],
40
+ statuses: [...DEFAULT_FALLBACK_STATUSES],
41
+ fallback: { provider: "deepseek", model: "deepseek-v4-flash" },
42
+ },
43
+ ],
44
+ };
45
+ }
46
+
47
+ export function validateConfigShape(value: unknown): ModelFallbackConfig {
48
+ if (!isRecord(value)) throw new Error("Config must be an object.");
49
+ if (value.version !== CONFIG_VERSION) throw new Error(`Unsupported config version: ${String(value.version)}.`);
50
+ const enabled = typeof value.enabled === "boolean" ? value.enabled : true;
51
+ if (!Array.isArray(value.rules)) throw new Error("rules must be an array.");
52
+ if (value.rules.length === 0) throw new Error("rules must include at least one fallback rule.");
53
+
54
+ return {
55
+ version: CONFIG_VERSION,
56
+ enabled,
57
+ rules: value.rules.map((ruleValue, index) => validateRule(ruleValue, index)),
58
+ };
59
+ }
60
+
61
+ export function findFallback(config: ModelFallbackConfig, model: Pick<Model<any>, "provider" | "id">, status: number): MatchedFallback | undefined {
62
+ if (!config.enabled) return undefined;
63
+ for (const rule of config.rules) {
64
+ if (!statusesFor(rule).has(status)) continue;
65
+ if (!modelMatches(rule, model)) continue;
66
+ return { rule, fallback: rule.fallback };
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ export function modelRefKey(ref: ModelRef): string {
72
+ return `${ref.provider}/${ref.model}`;
73
+ }
74
+
75
+ export function modelKey(model: Pick<Model<any>, "provider" | "id">): string {
76
+ return `${model.provider}/${model.id}`;
77
+ }
78
+
79
+ function validateRule(value: unknown, index: number): FallbackRule {
80
+ if (!isRecord(value)) throw new Error(`rules[${index}] must be an object.`);
81
+ const rule: FallbackRule = { fallback: readModelRef(value.fallback, `rules[${index}].fallback`) };
82
+ if (typeof value.name === "string" && value.name.trim() !== "") rule.name = value.name.trim();
83
+ if (value.matchProviders !== undefined) rule.matchProviders = readStringArray(value.matchProviders, `rules[${index}].matchProviders`);
84
+ if (value.matchModels !== undefined) rule.matchModels = readModelRefArray(value.matchModels, `rules[${index}].matchModels`);
85
+ if (value.statuses !== undefined) rule.statuses = readStatuses(value.statuses, `rules[${index}].statuses`);
86
+ if (value.cooldownMs !== undefined) rule.cooldownMs = readPositiveInteger(value.cooldownMs, `rules[${index}].cooldownMs`);
87
+ if ((!rule.matchProviders || rule.matchProviders.length === 0) && (!rule.matchModels || rule.matchModels.length === 0)) {
88
+ throw new Error(`rules[${index}] must define matchProviders or matchModels.`);
89
+ }
90
+ return rule;
91
+ }
92
+
93
+ function modelMatches(rule: FallbackRule, model: Pick<Model<any>, "provider" | "id">): boolean {
94
+ if (rule.matchModels?.some((entry) => entry.provider === model.provider && entry.model === model.id)) return true;
95
+ if (rule.matchProviders?.includes(model.provider)) return true;
96
+ return false;
97
+ }
98
+
99
+ function statusesFor(rule: FallbackRule): Set<number> {
100
+ return new Set(rule.statuses && rule.statuses.length > 0 ? rule.statuses : DEFAULT_FALLBACK_STATUSES);
101
+ }
102
+
103
+ function readModelRef(value: unknown, path: string): ModelRef {
104
+ if (!isRecord(value)) throw new Error(`${path} must be an object.`);
105
+ return {
106
+ provider: readNonEmptyString(value.provider, `${path}.provider`),
107
+ model: readNonEmptyString(value.model, `${path}.model`),
108
+ };
109
+ }
110
+
111
+ function readModelRefArray(value: unknown, path: string): ModelRef[] {
112
+ if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
113
+ if (value.length === 0) throw new Error(`${path} must not be empty.`);
114
+ return value.map((entry, index) => readModelRef(entry, `${path}[${index}]`));
115
+ }
116
+
117
+ function readStringArray(value: unknown, path: string): string[] {
118
+ if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
119
+ if (value.length === 0) throw new Error(`${path} must not be empty.`);
120
+ return value.map((entry, index) => readNonEmptyString(entry, `${path}[${index}]`));
121
+ }
122
+
123
+ function readStatuses(value: unknown, path: string): number[] {
124
+ if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
125
+ if (value.length === 0) throw new Error(`${path} must not be empty.`);
126
+ return value.map((entry, index) => {
127
+ if (!Number.isInteger(entry) || entry < 100 || entry > 599) throw new Error(`${path}[${index}] must be an HTTP status code.`);
128
+ return entry;
129
+ });
130
+ }
131
+
132
+ function readPositiveInteger(value: unknown, path: string): number {
133
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path} must be a positive integer.`);
134
+ return value;
135
+ }
136
+
137
+ function readNonEmptyString(value: unknown, path: string): string {
138
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
139
+ return value.trim();
140
+ }
141
+
142
+ function isRecord(value: unknown): value is Record<string, unknown> {
143
+ return typeof value === "object" && value !== null && !Array.isArray(value);
144
+ }
package/lib/state.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import type { ModelRef } from "./config.js";
4
+
5
+ export const STATE_VERSION = 1;
6
+
7
+ export interface FallbackStateEntry {
8
+ source: ModelRef;
9
+ fallback: ModelRef;
10
+ status: number;
11
+ until: string;
12
+ createdAt: string;
13
+ ruleName?: string;
14
+ }
15
+
16
+ export interface FallbackState {
17
+ version: 1;
18
+ entries: FallbackStateEntry[];
19
+ }
20
+
21
+ export function emptyState(): FallbackState {
22
+ return { version: STATE_VERSION, entries: [] };
23
+ }
24
+
25
+ export async function readState(path: string, now = new Date()): Promise<FallbackState> {
26
+ const json = await readJsonIfExists(path);
27
+ const state = json === undefined ? emptyState() : validateStateShape(json);
28
+ return pruneExpiredState(state, now);
29
+ }
30
+
31
+ export async function writeState(path: string, state: FallbackState): Promise<void> {
32
+ await mkdir(dirname(path), { recursive: true });
33
+ await writeFile(path, `${JSON.stringify(validateStateShape(state), null, 2)}\n`, "utf8");
34
+ }
35
+
36
+ export function upsertStateEntry(state: FallbackState, entry: FallbackStateEntry): FallbackState {
37
+ const sourceKey = modelRefKey(entry.source);
38
+ return {
39
+ version: STATE_VERSION,
40
+ entries: [entry, ...state.entries.filter((item) => modelRefKey(item.source) !== sourceKey)],
41
+ };
42
+ }
43
+
44
+ function modelRefKey(ref: ModelRef): string {
45
+ return `${ref.provider}/${ref.model}`;
46
+ }
47
+
48
+ export function findActiveStateEntry(state: FallbackState, source: ModelRef, now = new Date()): FallbackStateEntry | undefined {
49
+ const sourceKey = modelRefKey(source);
50
+ return state.entries.find((entry) => modelRefKey(entry.source) === sourceKey && Date.parse(entry.until) > now.getTime());
51
+ }
52
+
53
+ export function pruneExpiredState(state: FallbackState, now = new Date()): FallbackState {
54
+ const nowMs = now.getTime();
55
+ return {
56
+ version: STATE_VERSION,
57
+ entries: state.entries.filter((entry) => Date.parse(entry.until) > nowMs),
58
+ };
59
+ }
60
+
61
+ export function validateStateShape(value: unknown): FallbackState {
62
+ if (!isRecord(value)) throw new Error("State must be an object.");
63
+ if (value.version !== STATE_VERSION) throw new Error(`Unsupported state version: ${String(value.version)}.`);
64
+ if (!Array.isArray(value.entries)) throw new Error("entries must be an array.");
65
+ return {
66
+ version: STATE_VERSION,
67
+ entries: value.entries.map((entry, index) => validateEntry(entry, index)),
68
+ };
69
+ }
70
+
71
+ function validateEntry(value: unknown, index: number): FallbackStateEntry {
72
+ if (!isRecord(value)) throw new Error(`entries[${index}] must be an object.`);
73
+ const until = readNonEmptyString(value.until, `entries[${index}].until`);
74
+ const createdAt = readNonEmptyString(value.createdAt, `entries[${index}].createdAt`);
75
+ if (Number.isNaN(Date.parse(until))) throw new Error(`entries[${index}].until must be an ISO date.`);
76
+ if (Number.isNaN(Date.parse(createdAt))) throw new Error(`entries[${index}].createdAt must be an ISO date.`);
77
+ const status = value.status;
78
+ if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599) throw new Error(`entries[${index}].status must be an HTTP status code.`);
79
+ const entry: FallbackStateEntry = {
80
+ source: readModelRef(value.source, `entries[${index}].source`),
81
+ fallback: readModelRef(value.fallback, `entries[${index}].fallback`),
82
+ status,
83
+ until,
84
+ createdAt,
85
+ };
86
+ if (typeof value.ruleName === "string" && value.ruleName.trim() !== "") entry.ruleName = value.ruleName.trim();
87
+ return entry;
88
+ }
89
+
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,36 +1,37 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
- import { defaultConfig, validateConfigShape, type ModelFallbackConfig } from "./config.js";
4
-
5
- export interface ModelFallbackPaths {
6
- dir: string;
7
- config: string;
8
- }
9
-
10
- export function modelFallbackPaths(agentDir: string): ModelFallbackPaths {
11
- const dir = join(agentDir, "model-fallback");
12
- return { dir, config: join(dir, "config.json") };
13
- }
14
-
15
- export async function readConfig(path: string): Promise<ModelFallbackConfig> {
16
- const json = await readJsonIfExists(path);
17
- return json === undefined ? defaultConfig() : validateConfigShape(json);
18
- }
19
-
20
- export async function writeConfig(path: string, config: ModelFallbackConfig): Promise<void> {
21
- await mkdir(dirname(path), { recursive: true });
22
- await writeFile(path, `${JSON.stringify(validateConfigShape(config), null, 2)}\n`, "utf8");
23
- }
24
-
25
- async function readJsonIfExists(path: string): Promise<unknown | undefined> {
26
- try {
27
- return JSON.parse(await readFile(path, "utf8"));
28
- } catch (error) {
29
- if (isNodeError(error) && error.code === "ENOENT") return undefined;
30
- throw error;
31
- }
32
- }
33
-
34
- function isNodeError(error: unknown): error is NodeJS.ErrnoException {
35
- return error instanceof Error && "code" in error;
36
- }
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { defaultConfig, validateConfigShape, type ModelFallbackConfig } from "./config.js";
4
+
5
+ export interface ModelFallbackPaths {
6
+ dir: string;
7
+ config: string;
8
+ state: string;
9
+ }
10
+
11
+ export function modelFallbackPaths(agentDir: string): ModelFallbackPaths {
12
+ const dir = join(agentDir, "model-fallback");
13
+ return { dir, config: join(dir, "config.json"), state: join(dir, "state.json") };
14
+ }
15
+
16
+ export async function readConfig(path: string): Promise<ModelFallbackConfig> {
17
+ const json = await readJsonIfExists(path);
18
+ return json === undefined ? defaultConfig() : validateConfigShape(json);
19
+ }
20
+
21
+ export async function writeConfig(path: string, config: ModelFallbackConfig): Promise<void> {
22
+ await mkdir(dirname(path), { recursive: true });
23
+ await writeFile(path, `${JSON.stringify(validateConfigShape(config), null, 2)}\n`, "utf8");
24
+ }
25
+
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,59 +1,59 @@
1
- {
2
- "name": "pi-model-fallback",
3
- "version": "0.1.0",
4
- "description": "Pi extension that switches to a fallback model after provider failures such as 429 rate limits.",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "Keisu",
8
- "keywords": [
9
- "pi-package",
10
- "pi",
11
- "model-fallback",
12
- "fallback",
13
- "typescript"
14
- ],
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/eiei114/pi-model-fallback.git"
18
- },
19
- "bugs": {
20
- "url": "https://github.com/eiei114/pi-model-fallback/issues"
21
- },
22
- "homepage": "https://github.com/eiei114/pi-model-fallback#readme",
23
- "files": [
24
- "extensions/",
25
- "lib/",
26
- "docs/",
27
- "README.md",
28
- "LICENSE",
29
- "CHANGELOG.md"
30
- ],
31
- "scripts": {
32
- "typecheck": "tsc --noEmit",
33
- "test": "node --test tests/*.test.mjs",
34
- "ci": "npm run typecheck && npm test && npm run pack:check",
35
- "pack:check": "npm pack --dry-run"
36
- },
37
- "pi": {
38
- "extensions": [
39
- "./extensions"
40
- ]
41
- },
42
- "publishConfig": {
43
- "access": "public"
44
- },
45
- "peerDependencies": {
46
- "@earendil-works/pi-ai": "*",
47
- "@earendil-works/pi-coding-agent": "*",
48
- "@earendil-works/pi-tui": "*",
49
- "typebox": "*"
50
- },
51
- "devDependencies": {
52
- "@earendil-works/pi-ai": "latest",
53
- "@earendil-works/pi-coding-agent": "latest",
54
- "@earendil-works/pi-tui": "latest",
55
- "typebox": "latest",
56
- "@types/node": "^26.0.0",
57
- "typescript": "^6.0.3"
58
- }
59
- }
1
+ {
2
+ "name": "pi-model-fallback",
3
+ "version": "0.3.3",
4
+ "description": "Pi extension that switches to a fallback model after provider failures such as 429 rate limits.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Keisu",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "model-fallback",
12
+ "fallback",
13
+ "typescript"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/eiei114/pi-model-fallback.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/eiei114/pi-model-fallback/issues"
21
+ },
22
+ "homepage": "https://github.com/eiei114/pi-model-fallback#readme",
23
+ "files": [
24
+ "extensions/",
25
+ "lib/",
26
+ "docs/",
27
+ "README.md",
28
+ "LICENSE",
29
+ "CHANGELOG.md"
30
+ ],
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "node --test tests/*.test.mjs",
34
+ "ci": "npm run typecheck && npm test && npm run pack:check",
35
+ "pack:check": "npm pack --dry-run"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./extensions"
40
+ ]
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-ai": "*",
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*",
49
+ "typebox": "*"
50
+ },
51
+ "devDependencies": {
52
+ "@earendil-works/pi-ai": "latest",
53
+ "@earendil-works/pi-coding-agent": "latest",
54
+ "@earendil-works/pi-tui": "latest",
55
+ "typebox": "latest",
56
+ "@types/node": "^26.0.0",
57
+ "typescript": "^6.0.3"
58
+ }
59
+ }