pi-model-fallback 0.4.0 → 0.5.1
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 +24 -0
- package/README.md +7 -2
- package/docs/usage.md +4 -2
- package/extensions/index.ts +53 -19
- package/lib/config.ts +34 -4
- package/lib/error-status.ts +48 -1
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## [0.5.1] - 2026-09-30
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Update `@earendil-works/pi-*` dependencies to `0.99.1`.
|
|
10
|
+
|
|
11
|
+
## [0.5.0] - 2026-09-22
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `reasons` rule option: match optional failure reason codes (for example `context_length_exceeded`) parsed from provider error messages, so status codes like `400` can trigger fallback only for specific causes such as context-length overflow. Reason-aware rules participate in shadowing analysis, and unrestricted earlier rules still shadow later reason-scoped rules.
|
|
16
|
+
- Never fall back to the failing model itself: rules whose fallback target equals the failing model are skipped so the next matching rule applies.
|
|
17
|
+
- Cascading fallback chains: when the active fallback model fails, other matching rules can move the session onward (for example `free → kimi-k2.6 → glm-5.3-flash`). Cascades never revisit a model already used in the current run, so circular rules cannot loop.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Parse `Error: <status>` messages that carry no colon after the status, such as GitHub Copilot's `Error: 429 quota exceeded`. These fell between the leading-bare-status pattern, which requires a colon after the status, and the `error <status>` pattern, which requires whitespace directly after `error`, so Copilot quota exhaustion never triggered a fallback.
|
|
22
|
+
- Recognise a bare status followed by a quota noun, such as `429 quota exceeded`.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Bump Pi dev dependencies to `@earendil-works/*` 0.86.1 and `@types/node` 26.5.1.
|
|
27
|
+
- Refresh the fallback retry example in the README and usage docs.
|
|
28
|
+
|
|
5
29
|
## [0.4.0] - 2026-09-11
|
|
6
30
|
|
|
7
31
|
### Added
|
package/README.md
CHANGED
|
@@ -90,13 +90,14 @@ Rule fields:
|
|
|
90
90
|
- `matchProviders`: match all models from a provider
|
|
91
91
|
- `matchModels`: match specific `provider` + `model` pairs
|
|
92
92
|
- `statuses`: optional; defaults to `429, 500, 502, 503, 504`
|
|
93
|
+
- `reasons`: optional list of failure reason codes (for example `context_length_exceeded`); when set, the rule only fires if the parsed failure reason is listed. Reasons come from quoted machine codes in the error message (for example `"provider_error_code":"context_length_exceeded"`) or prose such as `exceeds this model's context length`. Useful for status codes like `400` that should only trigger a fallback for specific causes, such as context-length overflow.
|
|
93
94
|
- `cooldownMs`: optional persistent fallback window
|
|
94
95
|
- `fallback`: target model Pi should switch to
|
|
95
96
|
|
|
96
97
|
Top-level fields:
|
|
97
98
|
|
|
98
99
|
- `enabled`: toggle the extension without deleting rules
|
|
99
|
-
- `autoRetry`: when a fallback fires from a failed turn, automatically re-queue the failed user prompt so the turn retries on the fallback model (defaults to `true`; set to `false` to only switch models)
|
|
100
|
+
- `autoRetry`: when a fallback fires from a failed turn, automatically re-queue the failed user prompt so the turn retries on the fallback model (defaults to `true`; set to `false` to only switch models). The failed prompt is queued once per fallback transition, so a fallback chain may replay it more than once.
|
|
100
101
|
|
|
101
102
|
Rules use first-match order: the first rule whose provider/model and status match wins. Put specific `matchModels` rules before broad `matchProviders` rules when they should take priority. `model_fallback_config validate`, `save`, `read`, and `status` report warning-only diagnostics when a later rule or model entry is completely shadowed by an earlier rule; `/model-fallback:status` also includes a concise warning summary for the current config. Warning-bearing config remains valid and can still be saved.
|
|
102
103
|
|
|
@@ -123,7 +124,11 @@ If the package is installed project-locally and the current project references i
|
|
|
123
124
|
- Successful responses do nothing.
|
|
124
125
|
- Matching failures from `after_provider_response` can trigger fallback immediately.
|
|
125
126
|
- 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.
|
|
126
|
-
-
|
|
127
|
+
- With the default `autoRetry: true`, the failed prompt is automatically queued once per fallback transition on the fallback model. A fallback chain may therefore replay the prompt more than once. Set `autoRetry: false` to switch models without replaying it.
|
|
128
|
+
|
|
129
|
+
Rules never fall back to the failing model itself; a rule whose fallback target equals the failing model is skipped so the next matching rule (or no rule) applies.
|
|
130
|
+
|
|
131
|
+
Fallback chains cascade: when the active fallback model itself fails, another matching rule may move the session further (for example `free model → kimi-k2.6 → glm-5.3-flash`). A cascade never revisits a model already used in the current run (the original or an earlier fallback), so circular rule configurations cannot ping-pong; when no unvisited target remains, the failure surfaces normally.
|
|
127
132
|
|
|
128
133
|
## Development
|
|
129
134
|
|
package/docs/usage.md
CHANGED
|
@@ -6,7 +6,7 @@ Install:
|
|
|
6
6
|
pi install npm:pi-model-fallback
|
|
7
7
|
```
|
|
8
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.
|
|
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. The failed prompt is automatically queued once per fallback transition on the fallback model, so a fallback chain may replay it more than once; set `"autoRetry": false` in `model-fallback/config.json` to switch models without retrying it.
|
|
10
10
|
|
|
11
11
|
Commands:
|
|
12
12
|
|
|
@@ -17,6 +17,8 @@ Commands:
|
|
|
17
17
|
|
|
18
18
|
Rule order is first-match: the first rule whose provider/model and status match wins. Place specific `matchModels` rules before broad `matchProviders` rules when the model-specific fallback should take priority. `model_fallback_config validate`, `save`, `read`, and `status` expose warning-only details for completely shadowed later rules or model entries, and `/model-fallback:status` includes a concise current-config warning summary; these warnings do not make the config invalid.
|
|
19
19
|
|
|
20
|
+
Rules can also match on failure reasons with `reasons`, for example `"reasons": ["context_length_exceeded"]` on a `400` rule so only context-overflow failures switch models while other `400`s stay errors. Reasons are parsed from quoted provider error codes (for example `"provider_error_code":"context_length_exceeded"`) or prose such as `exceeds this model's context length`. A rule never falls back to the failing model itself.
|
|
21
|
+
|
|
20
22
|
Cooldown defaults when a rule omits `cooldownMs`:
|
|
21
23
|
|
|
22
24
|
- `429` → 72 hours
|
|
@@ -24,4 +26,4 @@ Cooldown defaults when a rule omits `cooldownMs`:
|
|
|
24
26
|
|
|
25
27
|
`Retry-After` and `x-ratelimit-reset*` response headers override `cooldownMs` and those defaults.
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
If `autoRetry` is disabled, the failed request is not automatically replayed.
|
package/extensions/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type ModelRef,
|
|
16
16
|
type RuleWarning,
|
|
17
17
|
} from "../lib/config.js";
|
|
18
|
-
import { parseStatusFromErrorMessage } from "../lib/error-status.js";
|
|
18
|
+
import { parseReasonFromErrorMessage, parseStatusFromErrorMessage } from "../lib/error-status.js";
|
|
19
19
|
import { emptyState, findActiveStateEntry, pruneExpiredState, readState, upsertStateEntry, validateStateShape, writeState, type FallbackState } from "../lib/state.js";
|
|
20
20
|
import { isRecord } from "../lib/internal.ts";
|
|
21
21
|
import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
|
|
@@ -33,6 +33,10 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
33
33
|
let originalModelKey: string | undefined;
|
|
34
34
|
let activeFallbackKey: string | undefined;
|
|
35
35
|
let lastFallbackReason: string | undefined;
|
|
36
|
+
// Models already fallen back to during this run (plus the original). A cascade may only
|
|
37
|
+
// target a model never visited, which makes chains (free → kimi → glm) safe while
|
|
38
|
+
// preventing A→B→A ping-pong with circular rules.
|
|
39
|
+
let visitedFallbackKeys = new Set<string>();
|
|
36
40
|
|
|
37
41
|
function syncPaths(ctx: ExtensionContext): void {
|
|
38
42
|
if (pathsResolvedForCwd === ctx.cwd) return;
|
|
@@ -94,28 +98,30 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
94
98
|
return;
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
originalModelKey = modelKey(current);
|
|
102
|
+
activeFallbackKey = modelRefKey(active.fallback);
|
|
103
|
+
visitedFallbackKeys = new Set([originalModelKey, activeFallbackKey]);
|
|
104
|
+
lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
|
|
105
|
+
updateStatus(ctx);
|
|
97
106
|
const ok = await pi.setModel(fallbackModel);
|
|
98
107
|
if (!ok) {
|
|
99
108
|
ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(active.fallback)}`, "warning");
|
|
109
|
+
originalModelKey = undefined;
|
|
110
|
+
activeFallbackKey = undefined;
|
|
111
|
+
lastFallbackReason = undefined;
|
|
112
|
+
visitedFallbackKeys = new Set();
|
|
113
|
+
updateStatus(ctx);
|
|
100
114
|
return;
|
|
101
115
|
}
|
|
102
|
-
|
|
103
|
-
originalModelKey = modelKey(current);
|
|
104
|
-
activeFallbackKey = modelRefKey(active.fallback);
|
|
105
|
-
lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
|
|
106
116
|
updateStatus(ctx);
|
|
107
117
|
ctx.ui.notify(`Model fallback preselected: ${originalModelKey} → ${activeFallbackKey} until ${active.until}.`, "warning");
|
|
108
118
|
}
|
|
109
119
|
|
|
110
|
-
async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext): Promise<boolean> {
|
|
120
|
+
async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext, reason?: string): Promise<boolean> {
|
|
111
121
|
const loaded = config ?? (await loadConfig(ctx));
|
|
112
122
|
if (!loaded) return false;
|
|
113
|
-
const match = findFallback(loaded, { provider: source.provider, id: source.model }, status);
|
|
123
|
+
const match = findFallback(loaded, { provider: source.provider, id: source.model }, status, reason);
|
|
114
124
|
if (!match) return false;
|
|
115
|
-
// Only skip when the failing model IS the active fallback (its own failure should not
|
|
116
|
-
// re-trigger). A failure on the original model while the fallback is merely preselected
|
|
117
|
-
// must still proceed so auto-retry can run on the fallback.
|
|
118
|
-
if (activeFallbackKey && modelRefKey(source) === activeFallbackKey) return false;
|
|
119
125
|
|
|
120
126
|
const fallbackModel = ctx.modelRegistry.find(match.fallback.provider, match.fallback.model);
|
|
121
127
|
if (!fallbackModel) {
|
|
@@ -123,6 +129,18 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
123
129
|
return false;
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
// Loop guard: never fall back to a model already visited this run (the original or an
|
|
133
|
+
// earlier fallback). This both blocks circular rules from ping-ponging and stops the
|
|
134
|
+
// current fallback from re-selecting itself, while still allowing onward cascades
|
|
135
|
+
// (free → kimi → glm) when a different rule matches the fallback's own failure.
|
|
136
|
+
// Exception: when the failing model is the remembered original and the candidate is the
|
|
137
|
+
// already-preselected fallback, re-applying the same hop is idempotent and stays allowed
|
|
138
|
+
// (turn attribution can lag behind the preselection).
|
|
139
|
+
const fallbackKey = modelRefKey(match.fallback);
|
|
140
|
+
const sourceKey = modelRefKey(source);
|
|
141
|
+
const idempotentReapply = fallbackKey === activeFallbackKey && sourceKey === originalModelKey;
|
|
142
|
+
if (visitedFallbackKeys.has(fallbackKey) && !idempotentReapply) return false;
|
|
143
|
+
|
|
126
144
|
const now = new Date();
|
|
127
145
|
const until = new Date(now.getTime() + cooldownMsFor(match.rule.cooldownMs, status, headers)).toISOString();
|
|
128
146
|
const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
|
|
@@ -136,16 +154,29 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
136
154
|
});
|
|
137
155
|
await writeState(paths.state, state);
|
|
138
156
|
|
|
157
|
+
const prevOriginal = originalModelKey;
|
|
158
|
+
const prevActive = activeFallbackKey;
|
|
159
|
+
const prevReason = lastFallbackReason;
|
|
160
|
+
const prevVisited = visitedFallbackKeys;
|
|
161
|
+
// Mark before setModel so the model_select handler keeps fallback bookkeeping.
|
|
162
|
+
if (!originalModelKey) originalModelKey = sourceKey;
|
|
163
|
+
activeFallbackKey = fallbackKey;
|
|
164
|
+
visitedFallbackKeys = new Set(prevVisited);
|
|
165
|
+
visitedFallbackKeys.add(sourceKey);
|
|
166
|
+
visitedFallbackKeys.add(fallbackKey);
|
|
167
|
+
lastFallbackReason = `${status} from ${sourceKey}${reason ? ` (${reason})` : ""}; persistent until ${until}`;
|
|
168
|
+
updateStatus(ctx);
|
|
169
|
+
|
|
139
170
|
const ok = await pi.setModel(fallbackModel);
|
|
140
171
|
if (!ok) {
|
|
172
|
+
originalModelKey = prevOriginal;
|
|
173
|
+
activeFallbackKey = prevActive;
|
|
174
|
+
lastFallbackReason = prevReason;
|
|
175
|
+
visitedFallbackKeys = prevVisited;
|
|
176
|
+
updateStatus(ctx);
|
|
141
177
|
ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
|
|
142
178
|
return false;
|
|
143
179
|
}
|
|
144
|
-
|
|
145
|
-
originalModelKey = modelRefKey(source);
|
|
146
|
-
activeFallbackKey = modelRefKey(match.fallback);
|
|
147
|
-
lastFallbackReason = `${status} from ${originalModelKey}; persistent until ${until}`;
|
|
148
|
-
updateStatus(ctx);
|
|
149
180
|
ctx.ui.notify(`Model fallback: ${originalModelKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
|
|
150
181
|
return true;
|
|
151
182
|
}
|
|
@@ -153,6 +184,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
153
184
|
pi.on("agent_start", async (_event, ctx) => {
|
|
154
185
|
await loadConfig(ctx);
|
|
155
186
|
await loadState(ctx);
|
|
187
|
+
visitedFallbackKeys = new Set();
|
|
156
188
|
await applyPersistentFallback(ctx);
|
|
157
189
|
updateStatus(ctx);
|
|
158
190
|
});
|
|
@@ -162,6 +194,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
162
194
|
activeFallbackKey = undefined;
|
|
163
195
|
originalModelKey = undefined;
|
|
164
196
|
lastFallbackReason = undefined;
|
|
197
|
+
visitedFallbackKeys = new Set();
|
|
165
198
|
updateStatus(ctx);
|
|
166
199
|
}
|
|
167
200
|
});
|
|
@@ -171,7 +204,6 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
171
204
|
const loaded = config ?? (await loadConfig(ctx));
|
|
172
205
|
if (!current || !loaded) return;
|
|
173
206
|
if (event.status >= 200 && event.status < 300) return;
|
|
174
|
-
if (activeFallbackKey && modelKey(current) === activeFallbackKey) return;
|
|
175
207
|
|
|
176
208
|
await persistFailure(modelToRef(current), event.status, event.headers, ctx);
|
|
177
209
|
});
|
|
@@ -183,11 +215,11 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
183
215
|
if (!errorMessage) return;
|
|
184
216
|
const status = parseStatusFromErrorMessage(errorMessage);
|
|
185
217
|
if (status === undefined) return;
|
|
218
|
+
const reason = parseReasonFromErrorMessage(errorMessage);
|
|
186
219
|
const provider = typeof message.provider === "string" ? message.provider : ctx.model?.provider;
|
|
187
220
|
const model = typeof message.model === "string" ? message.model : ctx.model?.id;
|
|
188
221
|
if (!provider || !model) return;
|
|
189
|
-
|
|
190
|
-
const switched = await persistFailure({ provider, model }, status, {}, ctx);
|
|
222
|
+
const switched = await persistFailure({ provider, model }, status, {}, ctx, reason);
|
|
191
223
|
if (!switched) return;
|
|
192
224
|
const loaded = config;
|
|
193
225
|
if (!loaded || loaded.autoRetry === false) return;
|
|
@@ -216,6 +248,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
216
248
|
ctx.ui.notify("Model fallback: persistent state cleared; no remembered original model.", "info");
|
|
217
249
|
activeFallbackKey = undefined;
|
|
218
250
|
lastFallbackReason = undefined;
|
|
251
|
+
visitedFallbackKeys = new Set();
|
|
219
252
|
updateStatus(ctx);
|
|
220
253
|
return;
|
|
221
254
|
}
|
|
@@ -233,6 +266,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
233
266
|
activeFallbackKey = undefined;
|
|
234
267
|
originalModelKey = undefined;
|
|
235
268
|
lastFallbackReason = undefined;
|
|
269
|
+
visitedFallbackKeys = new Set();
|
|
236
270
|
updateStatus(ctx);
|
|
237
271
|
ctx.ui.notify(`Model fallback reset: ${modelKey(model)}; persistent state cleared.`, "info");
|
|
238
272
|
},
|
package/lib/config.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface FallbackRule {
|
|
|
14
14
|
matchProviders?: string[];
|
|
15
15
|
matchModels?: ModelRef[];
|
|
16
16
|
statuses?: number[];
|
|
17
|
+
/** Failure reason codes (for example `context_length_exceeded`); when set, the rule only fires if the parsed reason is listed. */
|
|
18
|
+
reasons?: string[];
|
|
17
19
|
/** Persistent failover cooldown in milliseconds after a matching failure. Defaults: 429 => 72h, 5xx => 10m. */
|
|
18
20
|
cooldownMs?: number;
|
|
19
21
|
fallback: ModelRef;
|
|
@@ -52,6 +54,8 @@ interface NormalizedRuleForWarnings {
|
|
|
52
54
|
modelKeys: Set<string>;
|
|
53
55
|
models: ModelRef[];
|
|
54
56
|
statuses: Set<number>;
|
|
57
|
+
/** undefined = the rule matches any failure reason. */
|
|
58
|
+
reasons?: Set<string>;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export function defaultConfig(): ModelFallbackConfig {
|
|
@@ -85,11 +89,19 @@ export function validateConfigShape(value: unknown): ModelFallbackConfig {
|
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
export function findFallback(
|
|
92
|
+
export function findFallback(
|
|
93
|
+
config: ModelFallbackConfig,
|
|
94
|
+
model: Pick<Model<any>, "provider" | "id">,
|
|
95
|
+
status: number,
|
|
96
|
+
reason?: string,
|
|
97
|
+
): MatchedFallback | undefined {
|
|
89
98
|
if (!config.enabled) return undefined;
|
|
90
99
|
for (const rule of config.rules) {
|
|
91
100
|
if (!statusesFor(rule).has(status)) continue;
|
|
92
101
|
if (!modelMatches(rule, model)) continue;
|
|
102
|
+
if (!reasonMatches(rule, reason)) continue;
|
|
103
|
+
// Never "fall back" to the failing model itself; that would only re-trigger the same failure.
|
|
104
|
+
if (modelKey(model) === modelRefKey(rule.fallback)) continue;
|
|
93
105
|
return { rule, fallback: rule.fallback };
|
|
94
106
|
}
|
|
95
107
|
return undefined;
|
|
@@ -109,17 +121,17 @@ export function analyzeRuleWarnings(config: ModelFallbackConfig): RuleWarning[]
|
|
|
109
121
|
const earlierRule = config.rules[earlierIndex];
|
|
110
122
|
const earlier = normalizedRules[earlierIndex];
|
|
111
123
|
|
|
112
|
-
if (setsEqual(earlier.statuses, later.statuses) && ruleScopesEqual(earlier, later)) {
|
|
124
|
+
if (setsEqual(earlier.statuses, later.statuses) && ruleScopesEqual(earlier, later) && reasonsEqual(earlier, later)) {
|
|
113
125
|
warnings.push(duplicateRuleWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex));
|
|
114
126
|
continue rules;
|
|
115
127
|
}
|
|
116
128
|
|
|
117
|
-
if (statusSetCovers(earlier.statuses, later.statuses) && ruleScopeCovers(earlier, later)) {
|
|
129
|
+
if (statusSetCovers(earlier.statuses, later.statuses) && ruleScopeCovers(earlier, later) && reasonsCover(earlier, later)) {
|
|
118
130
|
warnings.push(scopeShadowWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex, earlier));
|
|
119
131
|
continue rules;
|
|
120
132
|
}
|
|
121
133
|
|
|
122
|
-
if (earlier.providers.size === 0 || later.models.length === 0 || !statusSetCovers(earlier.statuses, later.statuses)) continue;
|
|
134
|
+
if (earlier.providers.size === 0 || later.models.length === 0 || !statusSetCovers(earlier.statuses, later.statuses) || !reasonsCover(earlier, later)) continue;
|
|
123
135
|
|
|
124
136
|
const shadowedModels = later.models.filter((entry) => earlier.providers.has(entry.provider) && !reportedModelShadowKeys.has(modelRefKey(entry)));
|
|
125
137
|
if (shadowedModels.length === 0) continue;
|
|
@@ -147,6 +159,7 @@ function validateRule(value: unknown, index: number): FallbackRule {
|
|
|
147
159
|
if (value.matchProviders !== undefined) rule.matchProviders = readStringArray(value.matchProviders, `rules[${index}].matchProviders`);
|
|
148
160
|
if (value.matchModels !== undefined) rule.matchModels = readModelRefArray(value.matchModels, `rules[${index}].matchModels`);
|
|
149
161
|
if (value.statuses !== undefined) rule.statuses = readStatuses(value.statuses, `rules[${index}].statuses`);
|
|
162
|
+
if (value.reasons !== undefined) rule.reasons = readStringArray(value.reasons, `rules[${index}].reasons`);
|
|
150
163
|
if (value.cooldownMs !== undefined) rule.cooldownMs = readPositiveInteger(value.cooldownMs, `rules[${index}].cooldownMs`);
|
|
151
164
|
if ((!rule.matchProviders || rule.matchProviders.length === 0) && (!rule.matchModels || rule.matchModels.length === 0)) {
|
|
152
165
|
throw new Error(`rules[${index}] must define matchProviders or matchModels.`);
|
|
@@ -160,6 +173,11 @@ function modelMatches(rule: FallbackRule, model: Pick<Model<any>, "provider" | "
|
|
|
160
173
|
return false;
|
|
161
174
|
}
|
|
162
175
|
|
|
176
|
+
function reasonMatches(rule: FallbackRule, reason: string | undefined): boolean {
|
|
177
|
+
if (!rule.reasons || rule.reasons.length === 0) return true;
|
|
178
|
+
return reason !== undefined && rule.reasons.includes(reason);
|
|
179
|
+
}
|
|
180
|
+
|
|
163
181
|
function statusesFor(rule: FallbackRule): Set<number> {
|
|
164
182
|
return new Set(rule.statuses && rule.statuses.length > 0 ? rule.statuses : DEFAULT_FALLBACK_STATUSES);
|
|
165
183
|
}
|
|
@@ -171,6 +189,7 @@ function normalizeRuleForWarnings(rule: FallbackRule): NormalizedRuleForWarnings
|
|
|
171
189
|
modelKeys: new Set(modelsByKey.keys()),
|
|
172
190
|
models: [...modelsByKey.values()],
|
|
173
191
|
statuses: statusesFor(rule),
|
|
192
|
+
reasons: rule.reasons && rule.reasons.length > 0 ? new Set(rule.reasons) : undefined,
|
|
174
193
|
};
|
|
175
194
|
}
|
|
176
195
|
|
|
@@ -249,6 +268,17 @@ function statusSetCovers(earlier: Set<number>, later: Set<number>): boolean {
|
|
|
249
268
|
return setCovers(earlier, later);
|
|
250
269
|
}
|
|
251
270
|
|
|
271
|
+
function reasonsCover(earlier: NormalizedRuleForWarnings, later: NormalizedRuleForWarnings): boolean {
|
|
272
|
+
if (earlier.reasons === undefined) return true;
|
|
273
|
+
if (later.reasons === undefined) return false;
|
|
274
|
+
return setCovers(earlier.reasons, later.reasons);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function reasonsEqual(earlier: NormalizedRuleForWarnings, later: NormalizedRuleForWarnings): boolean {
|
|
278
|
+
if (earlier.reasons === undefined || later.reasons === undefined) return earlier.reasons === later.reasons;
|
|
279
|
+
return setsEqual(earlier.reasons, later.reasons);
|
|
280
|
+
}
|
|
281
|
+
|
|
252
282
|
function setsEqual<T>(left: Set<T>, right: Set<T>): boolean {
|
|
253
283
|
return left.size === right.size && setCovers(left, right);
|
|
254
284
|
}
|
package/lib/error-status.ts
CHANGED
|
@@ -1,12 +1,59 @@
|
|
|
1
|
+
const REASON_TEXT_PATTERNS: Array<[RegExp, string]> = [
|
|
2
|
+
[/exceeds\s+(?:this|the)\s+model'?s?\s+context\s+length/i, "context_length_exceeded"],
|
|
3
|
+
[/context[\s_-]*length[\s_-]*exceeded/i, "context_length_exceeded"],
|
|
4
|
+
];
|
|
5
|
+
|
|
6
|
+
export function parseReasonFromErrorMessage(message: string): string | undefined {
|
|
7
|
+
// Quoted machine-readable codes relayed by routers, for example
|
|
8
|
+
// "code":"context_length_exceeded" or "provider_error_code":"context_length_exceeded".
|
|
9
|
+
// Numeric "code":400 values are ignored; parseStatusFromErrorMessage handles the status.
|
|
10
|
+
const quoted = message.match(/"(?:[a-z0-9_]+_)?code"\s*:\s*"([a-z0-9_.:-]+)"/i);
|
|
11
|
+
if (quoted) return quoted[1];
|
|
12
|
+
for (const [pattern, reason] of REASON_TEXT_PATTERNS) {
|
|
13
|
+
if (pattern.test(message)) return reason;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Failure modes where the transport reports HTTP success but the provider
|
|
19
|
+
// yields nothing usable — no numeric status is present in any of them, so the
|
|
20
|
+
// status parser must synthesize one for the fallback rules to fire:
|
|
21
|
+
// - "Provider returned an empty response" (OpenRouter stealth/pool models)
|
|
22
|
+
// - "Stream ended without finish_reason" (chunk stream truncated mid-flight)
|
|
23
|
+
// - "<provider> response has no body" (Anthropic/Mistral/generic, 200 empty body)
|
|
24
|
+
// - "<provider> stream ended without a terminal event" (SSE stream dies silently)
|
|
25
|
+
const EMPTY_PROVIDER_RESPONSE_PATTERNS: RegExp[] = [
|
|
26
|
+
/provider returned an empty response/i,
|
|
27
|
+
/response (has|with) no body/i,
|
|
28
|
+
/stream ended without a terminal event/i,
|
|
29
|
+
/stream ended without finish_reason/i,
|
|
30
|
+
];
|
|
31
|
+
|
|
1
32
|
export function parseStatusFromErrorMessage(message: string): number | undefined {
|
|
33
|
+
for (const pattern of EMPTY_PROVIDER_RESPONSE_PATTERNS) {
|
|
34
|
+
if (pattern.test(message)) {
|
|
35
|
+
// Synthesize 502 so status-based fallback rules fire instead of the
|
|
36
|
+
// agent silently retrying the same model. 502 is deliberately chosen
|
|
37
|
+
// over a novel code: it lands in the 5xx status lists users already
|
|
38
|
+
// configure and inherits the standard 10-minute cooldown.
|
|
39
|
+
return 502;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
2
42
|
const patterns: RegExp[] = [
|
|
3
43
|
// Leading bare status code, optionally prefixed with "Error:", e.g. "401: {\"type\":\"CreditsError\"...}"
|
|
4
44
|
// or "Error: 401: {\"type\":\"CreditsError\"...}" (OpenCode Go, OpenRouter relays)
|
|
5
45
|
/^\s*(?:error\s*:?\s*)?([1-5][0-9]{2})\s*:/i,
|
|
46
|
+
// Anchored "Error: <status> <prose>", e.g. "Error: 429 quota exceeded"
|
|
47
|
+
// (GitHub Copilot). The pattern above needs a colon AFTER the status and the
|
|
48
|
+
// "error <status>" pattern below needs whitespace directly after "error", so
|
|
49
|
+
// a message carrying both the prefix and a colon fell between them. The
|
|
50
|
+
// "error" prefix is required: without it this would also match prose such as
|
|
51
|
+
// "429 tokens remaining in context window", which must stay unmatched.
|
|
52
|
+
/^\s*error\s*:?\s*([1-5][0-9]{2})\b/i,
|
|
6
53
|
/\b(?:status|code|http)\s*[:\s]?\s*([1-5][0-9]{2})\b/i,
|
|
7
54
|
/\bHTTP\/\d(?:\.\d)?\s+([1-5][0-9]{2})\b/,
|
|
8
55
|
/\b(?:rate[\s-]?limit(?:ed)?|too many requests)[^0-9]{0,40}([45][0-9]{2})\b/i,
|
|
9
|
-
/\b([45][0-9]{2})\s+(?:error|too many requests|service unavailable|bad gateway|gateway timeout|internal server error)\b/i,
|
|
56
|
+
/\b([45][0-9]{2})\s+(?:error|quota|too many requests|service unavailable|bad gateway|gateway timeout|internal server error)\b/i,
|
|
10
57
|
/\berror\s+([45][0-9]{2})\b/i,
|
|
11
58
|
];
|
|
12
59
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-model-fallback",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
"typebox": "*"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
54
|
-
"@earendil-works/pi-ai": "^0.
|
|
55
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
56
|
-
"@earendil-works/pi-tui": "^0.
|
|
57
|
-
"@types/node": "^26.
|
|
53
|
+
"@earendil-works/pi-agent-core": "^0.99.1",
|
|
54
|
+
"@earendil-works/pi-ai": "^0.99.1",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^0.99.1",
|
|
56
|
+
"@earendil-works/pi-tui": "^0.99.1",
|
|
57
|
+
"@types/node": "^26.6.3",
|
|
58
58
|
"jiti": "^2.7.0",
|
|
59
59
|
"typebox": "latest",
|
|
60
60
|
"typescript": "^7.0.2"
|