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 +11 -0
- package/README.md +140 -164
- package/docs/usage.md +9 -2
- package/extensions/index.ts +23 -15
- package/lib/error-status.ts +18 -0
- package/package.json +6 -4
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
|
-
#
|
|
2
|
-
|
|
3
|
-
[](LICENSE)
|
|
8
|
-
[](https://pi.dev/packages)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
pi install npm
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
pi install
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
pi
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
- [`docs/
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
[](https://github.com/eiei114/pi-model-fallback/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/eiei114/pi-model-fallback/actions/workflows/publish.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/pi-model-fallback)
|
|
6
|
+
[](https://www.npmjs.com/package/pi-model-fallback)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](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
|
|
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
|
-
|
|
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.
|
package/extensions/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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.
|
|
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-
|
|
53
|
-
"@earendil-works/pi-
|
|
54
|
-
"@earendil-works/pi-
|
|
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"
|