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 +59 -30
- package/README.md +75 -107
- package/extensions/index.ts +199 -27
- package/lib/config.ts +144 -136
- package/lib/state.ts +118 -0
- package/lib/storage.ts +37 -36
- package/package.json +59 -59
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,67 @@
|
|
|
1
|
-
# Changelog
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
- Add Buy Me a Coffee sponsor button to README and native GitHub funding link via `.github/FUNDING.yml`.
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## [0.1.3] - 2026-06-19
|
|
7
|
+
## [0.3.3] - 2026-07-04
|
|
8
8
|
|
|
9
9
|
### Changed
|
|
10
10
|
|
|
11
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
37
|
-
-
|
|
38
|
-
|
|
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
|
-
#
|
|
1
|
+
# pi-model-fallback
|
|
2
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
7
|
[](LICENSE)
|
|
8
8
|
[](https://pi.dev/packages)
|
|
9
|
-
|
|
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
|
-
>
|
|
11
|
+
> Pi extension that switches to a fallback model after provider failures such as 429 rate limits.
|
|
12
12
|
|
|
13
|
-
## What this
|
|
13
|
+
## What this does
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
`pi-model-fallback` watches provider failures and automatically moves Pi to a safer fallback model when a matching rule fires.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Current default:
|
|
18
18
|
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
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
|
|
27
|
+
Install from npm:
|
|
26
28
|
|
|
27
29
|
```bash
|
|
28
|
-
pi install npm:
|
|
30
|
+
pi install npm:pi-model-fallback
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
|
|
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
|
|
36
|
+
pi install npm:pi-model-fallback -l
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
Or install from GitHub:
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
|
-
pi install
|
|
42
|
+
pi install git:github.com/eiei114/pi-model-fallback
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
Try it without permanently installing:
|
|
45
46
|
|
|
46
47
|
```bash
|
|
47
|
-
pi
|
|
48
|
+
pi -e npm:pi-model-fallback
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
## Commands
|
|
51
52
|
|
|
52
|
-
```
|
|
53
|
-
|
|
53
|
+
```text
|
|
54
|
+
/model-fallback:status
|
|
55
|
+
/model-fallback:reset
|
|
54
56
|
```
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
85
|
+
Rule fields:
|
|
63
86
|
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
pi -e .
|
|
68
|
-
```
|
|
93
|
+
## State and paths
|
|
69
94
|
|
|
70
|
-
|
|
95
|
+
The extension stores:
|
|
71
96
|
|
|
72
|
-
|
|
73
|
-
/
|
|
74
|
-
```
|
|
97
|
+
- config: `model-fallback/config.json`
|
|
98
|
+
- state: `model-fallback/state.json`
|
|
75
99
|
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
158
|
-
- GitHub: https://github.com/
|
|
159
|
-
- Issues: https://github.com/
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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)}
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
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
|
-
if (
|
|
81
|
-
|
|
82
|
-
if (value.
|
|
83
|
-
if (value.
|
|
84
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
await
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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
|
+
}
|