pi-model-fallback 0.3.2 → 0.3.4

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,8 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Changed
6
+
7
+ - Bump package version to `0.3.4` for the next patch release.
8
+
9
+ - Declare `@earendil-works/pi-agent-core` as a peerDependency to align with pi-extension-template.
5
10
  - Add Buy Me a Coffee sponsor button to README and native GitHub funding link via `.github/FUNDING.yml`.
6
11
 
12
+ ## [0.3.3] - 2026-07-04
13
+
14
+ ### Changed
15
+
16
+ - Rewrite the README with real `pi-model-fallback` install, command, configuration, and state-path documentation instead of template placeholders.
17
+
7
18
  ## [0.3.2] - 2026-07-04
8
19
 
9
20
  ### Fixed
package/README.md CHANGED
@@ -1,164 +1,140 @@
1
- # PACKAGE_DISPLAY_NAME
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)
7
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
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)
10
- <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>
11
-
12
- > One-line pitch for this TypeScript-first Pi package.
13
-
14
- ## What this is
15
-
16
- Briefly explain what this TypeScript-first package adds to Pi and who should use it.
17
-
18
- ## Features
19
-
20
- - Feature 1
21
- - Feature 2
22
- - Feature 3
23
-
24
- ## Install
25
-
26
- Install the published npm package with Pi:
27
-
28
- ```bash
29
- pi install npm:PACKAGE_NAME
30
- ```
31
-
32
- Replace `PACKAGE_NAME` with the exact `name` from `package.json`.
33
- For a scoped npm package, keep the `npm:` prefix:
34
-
35
- ```bash
36
- pi install npm:@your-scope/your-pi-package
37
- ```
38
-
39
- Pin a specific version when you want reproducible installs:
40
-
41
- ```bash
42
- pi install npm:PACKAGE_NAME@0.1.0
43
- ```
44
-
45
- Install into the current project instead of your user Pi settings:
46
-
47
- ```bash
48
- pi install npm:PACKAGE_NAME -l
49
- ```
50
-
51
- Or install from GitHub:
52
-
53
- ```bash
54
- pi install git:github.com/OWNER/REPO
55
- ```
56
-
57
- Try it without permanently installing:
58
-
59
- ```bash
60
- pi -e npm:PACKAGE_NAME
61
- ```
62
-
63
- ## Quick start
64
-
65
- Try this package locally:
66
-
67
- ```bash
68
- pi -e .
69
- ```
70
-
71
- Then run:
72
-
73
- ```txt
74
- /your-command
75
- ```
76
-
77
- ## Package contents
78
-
79
- | Path | Purpose |
80
- |---|---|
81
- | `extensions/` | Pi TypeScript extension entrypoints (`*.ts` and `index.ts`) |
82
- | `lib/` | Shared TypeScript helpers |
83
- | `skills/` | Agent Skills |
84
- | `prompts/` | Prompt templates |
85
- | `themes/` | Pi themes |
86
- | `docs/` | Optional supporting docs (usage, examples, release, ADRs) |
87
-
88
- ## Development
89
-
90
- ```bash
91
- npm install
92
- npm run ci
93
- ```
94
-
95
- ## Development flow
96
-
97
- Use this default flow when building a new Pi extension OSS project from this template:
98
-
99
- 1. Create the Vault project notes under `4_Project/<ProjectName>/`.
100
- 2. Add `CONTEXT.md`, `README.md`, `ROADMAP.md`, `Docs/`, `Issues/`, and `Progress/`.
101
- 3. Write the PRD in `4_Project/<ProjectName>/Docs/`.
102
- 4. Split approved tracer-bullet issues into `4_Project/<ProjectName>/Issues/`.
103
- 5. Implement in the OSS repo.
104
- 6. Run `npm run ci`, `npm test`, and `npm pack --dry-run`.
105
- 7. Release with Trusted Publishing.
106
- 8. Save release notes and follow-up decisions back to the Vault project.
107
-
108
- Short version:
109
-
110
- ```txt
111
- Vault notes -> PRD -> Issues -> implement -> ci/check -> release -> save learnings
112
- ```
113
-
114
- ## Release
115
-
116
- This package is set up for npm Trusted Publishing, so no `NPM_TOKEN` is required.
117
-
118
- ```bash
119
- npm version patch
120
- git push
121
- ```
122
-
123
- See [`docs/release.md`](docs/release.md) for setup details.
124
-
125
- ## Docs
126
-
127
- `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.
128
-
129
- After creating a repository from this template:
130
-
131
- 1. Follow [`docs/template-checklist.md`](docs/template-checklist.md) for setup.
132
- 2. Run the **post-generation docs cleanup** in that checklist: delete or merge template bootstrap docs that no longer add project value.
133
-
134
- Useful docs to keep when they add value:
135
-
136
- - [`docs/examples.md`](docs/examples.md) — examples for extensions, skills, prompts, and themes
137
- - [`docs/release.md`](docs/release.md) — Trusted Publishing details (README Release summarizes the flow)
138
- - `docs/usage.md` — create when usage does not fit in README
139
-
140
- Optional maintainer guidance (not a public-user navigation target in mature repos):
141
-
142
- - [`docs/template-checklist.md`](docs/template-checklist.md)
143
-
144
- Template bootstrap docs to delete or merge after setup unless they still teach something project-specific:
145
-
146
- - `docs/github-template.md`
147
- - `docs/repository-settings.md`
148
- - `docs/typescript.md`
149
-
150
- ## Security
151
-
152
- Pi packages can execute code with your local permissions. Review extensions before installing third-party packages.
153
-
154
- For vulnerability reporting, see [`SECURITY.md`](SECURITY.md).
155
-
156
- ## Links
157
-
158
- - npm: https://www.npmjs.com/package/PACKAGE_NAME
159
- - GitHub: https://github.com/OWNER/REPO
160
- - Issues: https://github.com/OWNER/REPO/issues
161
-
162
- ## License
163
-
164
- MIT
1
+ # pi-model-fallback
2
+
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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![Pi package](https://img.shields.io/badge/pi-package-purple.svg)](https://pi.dev/packages)
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
+
11
+ > Pi extension that switches to a fallback model after provider failures such as 429 rate limits.
12
+
13
+ ## What this does
14
+
15
+ `pi-model-fallback` watches provider failures and automatically moves Pi to a safer fallback model when a matching rule fires.
16
+
17
+ Current default:
18
+
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.
24
+
25
+ ## Install
26
+
27
+ Install from npm:
28
+
29
+ ```bash
30
+ pi install npm:pi-model-fallback
31
+ ```
32
+
33
+ Install into the current project only:
34
+
35
+ ```bash
36
+ pi install npm:pi-model-fallback -l
37
+ ```
38
+
39
+ Or install from GitHub:
40
+
41
+ ```bash
42
+ pi install git:github.com/eiei114/pi-model-fallback
43
+ ```
44
+
45
+ Try it without permanently installing:
46
+
47
+ ```bash
48
+ pi -e npm:pi-model-fallback
49
+ ```
50
+
51
+ ## Commands
52
+
53
+ ```text
54
+ /model-fallback:status
55
+ /model-fallback:reset
56
+ ```
57
+
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
+ }
83
+ ```
84
+
85
+ Rule fields:
86
+
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
92
+
93
+ ### Cooldowns
94
+
95
+ When a rule does not set `cooldownMs`, the extension uses these defaults:
96
+
97
+ - `429` → 72 hours
98
+ - `5xx` → 10 minutes
99
+
100
+ When the provider response includes `Retry-After` or `x-ratelimit-reset*` headers, those values override `cooldownMs` and the defaults for the persisted fallback window.
101
+
102
+ ## State and paths
103
+
104
+ The extension stores:
105
+
106
+ - config: `model-fallback/config.json`
107
+ - state: `model-fallback/state.json`
108
+
109
+ 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.
110
+
111
+ ## Behavior notes
112
+
113
+ - Successful responses do nothing.
114
+ - Matching failures from `after_provider_response` can trigger fallback immediately.
115
+ - 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.
116
+ - The failed request is not automatically replayed.
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ npm install
122
+ npm run ci
123
+ ```
124
+
125
+ Run locally in Pi:
126
+
127
+ ```bash
128
+ pi -e .
129
+ ```
130
+
131
+ ## Links
132
+
133
+ - npm: https://www.npmjs.com/package/pi-model-fallback
134
+ - GitHub: https://github.com/eiei114/pi-model-fallback
135
+ - Issues: https://github.com/eiei114/pi-model-fallback/issues
136
+ - Usage notes: [`docs/usage.md`](docs/usage.md)
137
+
138
+ ## License
139
+
140
+ MIT
package/docs/usage.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- pi install git:github.com/eiei114/pi-model-fallback
6
+ pi install npm:pi-model-fallback
7
7
  ```
8
8
 
9
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.
@@ -15,4 +15,11 @@ Commands:
15
15
  /model-fallback:reset
16
16
  ```
17
17
 
18
- The failed provider response is not automatically replayed in v0.1.0.
18
+ Cooldown defaults when a rule omits `cooldownMs`:
19
+
20
+ - `429` → 72 hours
21
+ - `5xx` → 10 minutes
22
+
23
+ `Retry-After` and `x-ratelimit-reset*` response headers override `cooldownMs` and those defaults.
24
+
25
+ The failed request is not automatically replayed.
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -12,7 +13,8 @@ import {
12
13
  type ModelFallbackConfig,
13
14
  type ModelRef,
14
15
  } from "../lib/config.js";
15
- import { findActiveStateEntry, readState, upsertStateEntry, writeState, type FallbackState } from "../lib/state.js";
16
+ import { parseStatusFromErrorMessage } from "../lib/error-status.js";
17
+ import { emptyState, findActiveStateEntry, pruneExpiredState, readState, upsertStateEntry, validateStateShape, writeState, type FallbackState } from "../lib/state.js";
16
18
  import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
17
19
 
18
20
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -22,6 +24,7 @@ const DEFAULT_5XX_COOLDOWN_MS = 10 * 60 * 1000;
22
24
 
23
25
  export default function modelFallback(pi: ExtensionAPI) {
24
26
  let paths: ModelFallbackPaths = modelFallbackPaths(getAgentDir());
27
+ let pathsResolvedForCwd: string | undefined;
25
28
  let config: ModelFallbackConfig | undefined;
26
29
  let state: FallbackState | undefined;
27
30
  let originalModelKey: string | undefined;
@@ -29,6 +32,8 @@ export default function modelFallback(pi: ExtensionAPI) {
29
32
  let lastFallbackReason: string | undefined;
30
33
 
31
34
  function syncPaths(ctx: ExtensionContext): void {
35
+ if (pathsResolvedForCwd === ctx.cwd) return;
36
+ pathsResolvedForCwd = ctx.cwd;
32
37
  paths = shouldUseProjectLocalState(ctx) ? modelFallbackPaths(join(ctx.cwd, ".pi")) : modelFallbackPaths(getAgentDir());
33
38
  }
34
39
 
@@ -46,8 +51,18 @@ export default function modelFallback(pi: ExtensionAPI) {
46
51
  async function loadState(ctx: ExtensionContext): Promise<FallbackState | undefined> {
47
52
  try {
48
53
  syncPaths(ctx);
49
- state = await readState(paths.state);
50
- await writeState(paths.state, state);
54
+ const statePath = paths.state;
55
+ if (!existsSync(statePath)) {
56
+ state = emptyState();
57
+ return state;
58
+ }
59
+
60
+ const before = validateStateShape(JSON.parse(await readFile(statePath, "utf8")));
61
+ const after = pruneExpiredState(before);
62
+ state = after;
63
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
64
+ await writeState(statePath, after);
65
+ }
51
66
  return state;
52
67
  } catch (error) {
53
68
  ctx.ui.notify(`Model fallback state error: ${errorMessage(error)}`, "warning");
@@ -343,7 +358,7 @@ function parseRetryAfter(value: string | undefined): number | undefined {
343
358
  return undefined;
344
359
  }
345
360
 
346
- function parseResetHeader(value: string | undefined): number | undefined {
361
+ function parseResetHeader(value: string | undefined): number | undefined {
347
362
  if (!value) return undefined;
348
363
  const trimmed = value.trim();
349
364
  const numeric = Number(trimmed);
@@ -354,17 +369,10 @@ function parseResetHeader(value: string | undefined): number | undefined {
354
369
  }
355
370
  const dateMs = Date.parse(trimmed);
356
371
  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 {
372
+ return undefined;
373
+ }
374
+
375
+ function modelToRef(model: { provider: string; id: string }): ModelRef {
368
376
  return { provider: model.provider, model: model.id };
369
377
  }
370
378
 
@@ -0,0 +1,18 @@
1
+ export function parseStatusFromErrorMessage(message: string): number | undefined {
2
+ const patterns: RegExp[] = [
3
+ /\b(?:status|code|http)\s*[:\s]?\s*([1-5][0-9]{2})\b/i,
4
+ /\bHTTP\/\d(?:\.\d)?\s+([1-5][0-9]{2})\b/,
5
+ /\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,
7
+ /\berror\s+([45][0-9]{2})\b/i,
8
+ ];
9
+
10
+ for (const pattern of patterns) {
11
+ const match = message.match(pattern);
12
+ if (!match) continue;
13
+ const status = Number(match[1]);
14
+ if (Number.isInteger(status) && status >= 100 && status <= 599) return status;
15
+ }
16
+
17
+ return undefined;
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-model-fallback",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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",
@@ -43,15 +43,17 @@
43
43
  "access": "public"
44
44
  },
45
45
  "peerDependencies": {
46
+ "@earendil-works/pi-agent-core": "*",
46
47
  "@earendil-works/pi-ai": "*",
47
48
  "@earendil-works/pi-coding-agent": "*",
48
49
  "@earendil-works/pi-tui": "*",
49
50
  "typebox": "*"
50
51
  },
51
52
  "devDependencies": {
52
- "@earendil-works/pi-ai": "latest",
53
- "@earendil-works/pi-coding-agent": "latest",
54
- "@earendil-works/pi-tui": "latest",
53
+ "@earendil-works/pi-agent-core": "^0.80.6",
54
+ "@earendil-works/pi-ai": "^0.80.6",
55
+ "@earendil-works/pi-coding-agent": "^0.80.6",
56
+ "@earendil-works/pi-tui": "^0.80.6",
55
57
  "typebox": "latest",
56
58
  "@types/node": "^26.0.0",
57
59
  "typescript": "^6.0.3"