pi-model-fallback 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/docs/usage.md +18 -0
- package/extensions/index.ts +205 -0
- package/lib/config.ts +136 -0
- package/lib/storage.ts +36 -0
- package/package.json +59 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
This project follows semantic versioning.
|
|
6
|
+
|
|
7
|
+
## [0.1.3] - 2026-06-19
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
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
|
|
20
|
+
|
|
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.
|
|
23
|
+
|
|
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
|
|
35
|
+
|
|
36
|
+
- Initial Pi package template.
|
|
37
|
+
- Example extension, Agent Skill, prompt, and theme.
|
|
38
|
+
- CI and npm Trusted Publishing workflow.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 YOUR_NAME
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# PACKAGE_DISPLAY_NAME
|
|
2
|
+
|
|
3
|
+
[](https://github.com/OWNER/REPO/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/OWNER/REPO/actions/workflows/publish.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/PACKAGE_NAME)
|
|
6
|
+
[](https://www.npmjs.com/package/PACKAGE_NAME)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://pi.dev/packages)
|
|
9
|
+
[](docs/release.md)
|
|
10
|
+
|
|
11
|
+
> One-line pitch for this TypeScript-first Pi package.
|
|
12
|
+
|
|
13
|
+
## What this is
|
|
14
|
+
|
|
15
|
+
Briefly explain what this TypeScript-first package adds to Pi and who should use it.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- Feature 1
|
|
20
|
+
- Feature 2
|
|
21
|
+
- Feature 3
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Install the published npm package with Pi:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi install npm:PACKAGE_NAME
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Replace `PACKAGE_NAME` with the exact `name` from `package.json`.
|
|
32
|
+
For a scoped npm package, keep the `npm:` prefix:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install npm:@your-scope/your-pi-package
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Pin a specific version when you want reproducible installs:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pi install npm:PACKAGE_NAME@0.1.0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Install into the current project instead of your user Pi settings:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pi install npm:PACKAGE_NAME -l
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or install from GitHub:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pi install git:github.com/OWNER/REPO
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Try it without permanently installing:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pi -e npm:PACKAGE_NAME
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
Try this package locally:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pi -e .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Then run:
|
|
71
|
+
|
|
72
|
+
```txt
|
|
73
|
+
/your-command
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Package contents
|
|
77
|
+
|
|
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) |
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm install
|
|
91
|
+
npm run ci
|
|
92
|
+
```
|
|
93
|
+
|
|
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
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npm version patch
|
|
119
|
+
git push
|
|
120
|
+
```
|
|
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
|
+
## Links
|
|
156
|
+
|
|
157
|
+
- npm: https://www.npmjs.com/package/PACKAGE_NAME
|
|
158
|
+
- GitHub: https://github.com/OWNER/REPO
|
|
159
|
+
- Issues: https://github.com/OWNER/REPO/issues
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
package/docs/usage.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Usage
|
|
2
|
+
|
|
3
|
+
Install:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pi install git:github.com/eiei114/pi-model-fallback
|
|
7
|
+
```
|
|
8
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
Commands:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
/model-fallback:status
|
|
15
|
+
/model-fallback:reset
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The failed provider response is not automatically replayed in v0.1.0.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { defaultConfig, findFallback, modelKey, modelRefKey, validateConfigShape, type ModelFallbackConfig } from "../lib/config.js";
|
|
7
|
+
import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
|
|
8
|
+
|
|
9
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
|
+
const STATUS_KEY = "model-fallback";
|
|
11
|
+
|
|
12
|
+
export default function modelFallback(pi: ExtensionAPI) {
|
|
13
|
+
let paths: ModelFallbackPaths = modelFallbackPaths(getAgentDir());
|
|
14
|
+
let config: ModelFallbackConfig | undefined;
|
|
15
|
+
let originalModelKey: string | undefined;
|
|
16
|
+
let activeFallbackKey: string | undefined;
|
|
17
|
+
let lastFallbackReason: string | undefined;
|
|
18
|
+
|
|
19
|
+
function syncPaths(ctx: ExtensionContext): void {
|
|
20
|
+
paths = shouldUseProjectLocalState(ctx) ? modelFallbackPaths(join(ctx.cwd, ".pi")) : modelFallbackPaths(getAgentDir());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function loadConfig(ctx: ExtensionContext): Promise<ModelFallbackConfig | undefined> {
|
|
24
|
+
try {
|
|
25
|
+
syncPaths(ctx);
|
|
26
|
+
config = await readConfig(paths.config);
|
|
27
|
+
return config;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
ctx.ui.notify(`Model fallback config error: ${errorMessage(error)}`, "warning");
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
35
|
+
ctx.ui.setStatus(STATUS_KEY, activeFallbackKey ? `fallback:${activeFallbackKey}` : undefined);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
39
|
+
await loadConfig(ctx);
|
|
40
|
+
updateStatus(ctx);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pi.on("model_select", async (event, ctx) => {
|
|
44
|
+
if (activeFallbackKey && modelKey(event.model) !== activeFallbackKey) {
|
|
45
|
+
activeFallbackKey = undefined;
|
|
46
|
+
originalModelKey = undefined;
|
|
47
|
+
lastFallbackReason = undefined;
|
|
48
|
+
updateStatus(ctx);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.on("after_provider_response", async (event, ctx) => {
|
|
53
|
+
const current = ctx.model;
|
|
54
|
+
const loaded = config ?? (await loadConfig(ctx));
|
|
55
|
+
if (!current || !loaded) return;
|
|
56
|
+
if (event.status >= 200 && event.status < 300) return;
|
|
57
|
+
if (activeFallbackKey && modelKey(current) === activeFallbackKey) return;
|
|
58
|
+
|
|
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
|
+
}
|
|
73
|
+
|
|
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");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
pi.registerCommand("model-fallback:status", {
|
|
82
|
+
description: "Show model fallback status",
|
|
83
|
+
handler: async (_args, ctx) => {
|
|
84
|
+
const loaded = config ?? (await loadConfig(ctx)) ?? defaultConfig();
|
|
85
|
+
ctx.ui.notify(formatStatus(loaded, paths.config, activeFallbackKey, originalModelKey, lastFallbackReason), "info");
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
pi.registerCommand("model-fallback:reset", {
|
|
90
|
+
description: "Return from fallback model to the pre-fallback model when remembered",
|
|
91
|
+
handler: async (_args, ctx) => {
|
|
92
|
+
if (!originalModelKey) {
|
|
93
|
+
ctx.ui.notify("Model fallback: no remembered original model.", "info");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const [provider, ...modelParts] = originalModelKey.split("/");
|
|
97
|
+
const model = ctx.modelRegistry.find(provider, modelParts.join("/"));
|
|
98
|
+
if (!model) {
|
|
99
|
+
ctx.ui.notify(`Model fallback original missing: ${originalModelKey}`, "warning");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const ok = await pi.setModel(model);
|
|
103
|
+
if (!ok) {
|
|
104
|
+
ctx.ui.notify(`Model fallback original auth unavailable: ${originalModelKey}`, "warning");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
activeFallbackKey = undefined;
|
|
108
|
+
originalModelKey = undefined;
|
|
109
|
+
lastFallbackReason = undefined;
|
|
110
|
+
updateStatus(ctx);
|
|
111
|
+
ctx.ui.notify(`Model fallback reset: ${modelKey(model)}`, "info");
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
pi.registerTool({
|
|
116
|
+
name: "model_fallback_config",
|
|
117
|
+
label: "Model Fallback Config",
|
|
118
|
+
description: "Read, validate, or save pi-model-fallback configuration.",
|
|
119
|
+
promptSnippet: "Read, validate, or save pi-model-fallback configuration after user asks.",
|
|
120
|
+
promptGuidelines: [
|
|
121
|
+
"Use model_fallback_config when the user asks to inspect or configure model fallback rules.",
|
|
122
|
+
"Use action=save only after preparing the full JSON config; the tool asks for confirmation before writing.",
|
|
123
|
+
],
|
|
124
|
+
parameters: Type.Object({
|
|
125
|
+
action: Type.Union([Type.Literal("read"), Type.Literal("status"), Type.Literal("validate"), Type.Literal("save")]),
|
|
126
|
+
configJson: Type.Optional(Type.String({ description: "Full ModelFallbackConfig JSON for validate or save." })),
|
|
127
|
+
}),
|
|
128
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
129
|
+
syncPaths(ctx);
|
|
130
|
+
if (params.action === "read" || params.action === "status") {
|
|
131
|
+
const current = await readConfig(paths.config);
|
|
132
|
+
return textResult(JSON.stringify(current, null, 2), { configPath: paths.config, activeFallbackKey, originalModelKey, lastFallbackReason });
|
|
133
|
+
}
|
|
134
|
+
if (!params.configJson) throw new Error("configJson is required for validate/save.");
|
|
135
|
+
const nextConfig = validateConfigShape(JSON.parse(params.configJson));
|
|
136
|
+
validateRegisteredModels(ctx, nextConfig);
|
|
137
|
+
if (params.action === "validate") return textResult("Config is valid.", { config: nextConfig });
|
|
138
|
+
if (!ctx.hasUI) return textResult("Config not saved: confirmation UI is unavailable.", { configPath: paths.config });
|
|
139
|
+
const ok = await ctx.ui.confirm("Save model fallback config?", summarizeConfig(nextConfig));
|
|
140
|
+
if (!ok) return textResult("Config not saved.", { configPath: paths.config });
|
|
141
|
+
await writeConfig(paths.config, nextConfig);
|
|
142
|
+
config = nextConfig;
|
|
143
|
+
return textResult("Config saved.", { configPath: paths.config });
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function shouldUseProjectLocalState(ctx: ExtensionContext): boolean {
|
|
149
|
+
const projectSettingsPath = join(ctx.cwd, ".pi", "settings.json");
|
|
150
|
+
return existsSync(projectSettingsPath) && projectSettingsIncludesThisPackage(projectSettingsPath);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function projectSettingsIncludesThisPackage(projectSettingsPath: string): boolean {
|
|
154
|
+
try {
|
|
155
|
+
const settings = JSON.parse(readFileSync(projectSettingsPath, "utf8")) as unknown;
|
|
156
|
+
if (!isRecord(settings) || !Array.isArray(settings.packages)) return false;
|
|
157
|
+
return settings.packages.some((entry) => packageEntryMatchesThisPackage(entry, dirname(projectSettingsPath)));
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function packageEntryMatchesThisPackage(entry: unknown, settingsDir: string): boolean {
|
|
164
|
+
const source = typeof entry === "string" ? entry : isRecord(entry) && typeof entry.source === "string" ? entry.source : undefined;
|
|
165
|
+
if (!source) return false;
|
|
166
|
+
if (source.includes("pi-model-fallback")) return true;
|
|
167
|
+
return resolve(settingsDir, source) === resolve(PACKAGE_ROOT);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateRegisteredModels(ctx: ExtensionContext, nextConfig: ModelFallbackConfig): void {
|
|
171
|
+
for (const rule of nextConfig.rules) {
|
|
172
|
+
const fallback = ctx.modelRegistry.find(rule.fallback.provider, rule.fallback.model);
|
|
173
|
+
if (!fallback) throw new Error(`Unknown fallback model: ${modelRefKey(rule.fallback)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function formatStatus(config: ModelFallbackConfig, configPath: string, activeFallbackKey?: string, originalModelKey?: string, reason?: string): string {
|
|
178
|
+
return [
|
|
179
|
+
`Model fallback: ${config.enabled ? "enabled" : "disabled"}`,
|
|
180
|
+
`Config: ${configPath}`,
|
|
181
|
+
`Rules: ${config.rules.length}`,
|
|
182
|
+
`Active fallback: ${activeFallbackKey ?? "none"}`,
|
|
183
|
+
`Original model: ${originalModelKey ?? "none"}`,
|
|
184
|
+
`Reason: ${reason ?? "none"}`,
|
|
185
|
+
].join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function summarizeConfig(config: ModelFallbackConfig): string {
|
|
189
|
+
return [
|
|
190
|
+
`enabled: ${config.enabled}`,
|
|
191
|
+
...config.rules.map((rule) => `${rule.name ?? "rule"}: ${rule.matchProviders?.join(",") ?? "models"} -> ${modelRefKey(rule.fallback)}`),
|
|
192
|
+
].join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function textResult(text: string, details?: Record<string, unknown>) {
|
|
196
|
+
return { content: [{ type: "text" as const, text }], details: details ?? {} };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function errorMessage(error: unknown): string {
|
|
200
|
+
return error instanceof Error ? error.message : String(error);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
204
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
205
|
+
}
|
package/lib/config.ts
ADDED
|
@@ -0,0 +1,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
|
+
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
|
+
}
|
package/lib/storage.ts
ADDED
|
@@ -0,0 +1,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
|
+
}
|
|
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
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +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
|
+
}
|