pi-model-fallback 0.3.3 → 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 +5 -0
- package/README.md +10 -1
- 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,6 +2,11 @@
|
|
|
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
|
|
|
7
12
|
## [0.3.3] - 2026-07-04
|
package/README.md
CHANGED
|
@@ -90,6 +90,15 @@ Rule fields:
|
|
|
90
90
|
- `cooldownMs`: optional persistent fallback window
|
|
91
91
|
- `fallback`: target model Pi should switch to
|
|
92
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
|
+
|
|
93
102
|
## State and paths
|
|
94
103
|
|
|
95
104
|
The extension stores:
|
|
@@ -103,7 +112,7 @@ If the package is installed project-locally and the current project references i
|
|
|
103
112
|
|
|
104
113
|
- Successful responses do nothing.
|
|
105
114
|
- 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.
|
|
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.
|
|
107
116
|
- The failed request is not automatically replayed.
|
|
108
117
|
|
|
109
118
|
## Development
|
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"
|