pi-model-fallback 0.3.7 → 0.5.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 +37 -7
- package/README.md +12 -1
- package/docs/usage.md +4 -2
- package/extensions/index.ts +95 -23
- package/lib/config.ts +39 -20
- package/lib/error-status.ts +28 -1
- package/lib/internal.ts +31 -0
- package/lib/state.ts +3 -35
- package/lib/storage.ts +3 -14
- package/package.json +7 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,25 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
5
|
+
## [0.5.0] - 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `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.
|
|
10
|
+
- Never fall back to the failing model itself: rules whose fallback target equals the failing model are skipped so the next matching rule applies.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
- Recognise a bare status followed by a quota noun, such as `429 quota exceeded`.
|
|
6
17
|
|
|
7
18
|
### Changed
|
|
8
19
|
|
|
9
|
-
-
|
|
20
|
+
- Bump Pi dev dependencies to `@earendil-works/*` 0.86.1 and `@types/node` 26.5.1.
|
|
21
|
+
- Refresh the fallback retry example in the README and usage docs.
|
|
22
|
+
|
|
23
|
+
## [0.4.0] - 2026-09-11
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `autoRetry` config option (default `true`): when a fallback switch happens after a failed turn, the failed user prompt is automatically re-queued so the turn retries on the fallback model.
|
|
10
28
|
|
|
11
29
|
### Fixed
|
|
12
30
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
31
|
+
- Parse leading bare status codes in provider error messages (for example `401: {...}` from OpenCode Go and OpenRouter) so fallback rules fire on real-world failure shapes.
|
|
32
|
+
- Auto-retry when a fallback model is merely preselected instead of skipping the switch path.
|
|
33
|
+
- Parse optional `Error:` prefixes before leading bare status codes.
|
|
16
34
|
|
|
17
35
|
### Changed
|
|
18
36
|
|
|
19
|
-
- Bump
|
|
37
|
+
- Bump Pi dev dependencies to `@earendil-works/*` 0.84.4 and `@types/node` 26.4.0.
|
|
38
|
+
- Add `jiti` as a devDependency so auto-retry integration tests run in CI.
|
|
39
|
+
|
|
40
|
+
## [0.3.7] - 2026-08-22
|
|
20
41
|
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- Merge the 2026-08-22 managed OSS dependency and maintenance PR batch.
|
|
21
45
|
- Declare `@earendil-works/pi-agent-core` as a peerDependency to align with pi-extension-template.
|
|
22
46
|
- Add Buy Me a Coffee sponsor button to README and native GitHub funding link via `.github/FUNDING.yml`.
|
|
23
47
|
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- Warn on completely shadowed fallback rules in config validation, save confirmations, status output, and config details without rejecting warning-bearing configs.
|
|
51
|
+
- Document the missing `git add`/`git commit` steps in `CONTRIBUTING.md` release instructions so maintainers can trigger auto-release after `npm version patch --no-git-tag-version`.
|
|
52
|
+
- Clarify that maintainers should update `CHANGELOG.md` before staging release files.
|
|
53
|
+
|
|
24
54
|
## [0.3.3] - 2026-07-04
|
|
25
55
|
|
|
26
56
|
### Changed
|
|
@@ -74,7 +104,7 @@ This project follows semantic versioning.
|
|
|
74
104
|
- Publish workflow now installs a current npm CLI so npm Trusted Publishing OIDC is supported.
|
|
75
105
|
- CI and publish workflow commands no longer include literal trailing `\\n` text.
|
|
76
106
|
|
|
77
|
-
## [0.1.0] -
|
|
107
|
+
## [0.1.0] - 2026-06-30
|
|
78
108
|
|
|
79
109
|
### Added
|
|
80
110
|
|
package/README.md
CHANGED
|
@@ -70,6 +70,7 @@ Default config shape:
|
|
|
70
70
|
{
|
|
71
71
|
"version": 1,
|
|
72
72
|
"enabled": true,
|
|
73
|
+
"autoRetry": true,
|
|
73
74
|
"rules": [
|
|
74
75
|
{
|
|
75
76
|
"name": "zai-to-deepseek-flash",
|
|
@@ -89,9 +90,15 @@ Rule fields:
|
|
|
89
90
|
- `matchProviders`: match all models from a provider
|
|
90
91
|
- `matchModels`: match specific `provider` + `model` pairs
|
|
91
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.
|
|
92
94
|
- `cooldownMs`: optional persistent fallback window
|
|
93
95
|
- `fallback`: target model Pi should switch to
|
|
94
96
|
|
|
97
|
+
Top-level fields:
|
|
98
|
+
|
|
99
|
+
- `enabled`: toggle the extension without deleting rules
|
|
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, not retried indefinitely.
|
|
101
|
+
|
|
95
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.
|
|
96
103
|
|
|
97
104
|
### Cooldowns
|
|
@@ -117,7 +124,11 @@ If the package is installed project-locally and the current project references i
|
|
|
117
124
|
- Successful responses do nothing.
|
|
118
125
|
- Matching failures from `after_provider_response` can trigger fallback immediately.
|
|
119
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.
|
|
120
|
-
-
|
|
127
|
+
- With the default `autoRetry: true`, the failed prompt is automatically queued once on the fallback model. 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.
|
|
121
132
|
|
|
122
133
|
## Development
|
|
123
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 on the fallback model; 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,8 +15,9 @@ 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
|
+
import { isRecord } from "../lib/internal.ts";
|
|
20
21
|
import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
|
|
21
22
|
|
|
22
23
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -32,6 +33,10 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
32
33
|
let originalModelKey: string | undefined;
|
|
33
34
|
let activeFallbackKey: string | undefined;
|
|
34
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>();
|
|
35
40
|
|
|
36
41
|
function syncPaths(ctx: ExtensionContext): void {
|
|
37
42
|
if (pathsResolvedForCwd === ctx.cwd) return;
|
|
@@ -93,32 +98,49 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
93
98
|
return;
|
|
94
99
|
}
|
|
95
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);
|
|
96
106
|
const ok = await pi.setModel(fallbackModel);
|
|
97
107
|
if (!ok) {
|
|
98
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);
|
|
99
114
|
return;
|
|
100
115
|
}
|
|
101
|
-
|
|
102
|
-
originalModelKey = modelKey(current);
|
|
103
|
-
activeFallbackKey = modelRefKey(active.fallback);
|
|
104
|
-
lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
|
|
105
116
|
updateStatus(ctx);
|
|
106
117
|
ctx.ui.notify(`Model fallback preselected: ${originalModelKey} → ${activeFallbackKey} until ${active.until}.`, "warning");
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext): Promise<
|
|
120
|
+
async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext, reason?: string): Promise<boolean> {
|
|
110
121
|
const loaded = config ?? (await loadConfig(ctx));
|
|
111
|
-
if (!loaded) return;
|
|
112
|
-
const match = findFallback(loaded, { provider: source.provider, id: source.model }, status);
|
|
113
|
-
if (!match) return;
|
|
114
|
-
if (activeFallbackKey && activeFallbackKey === modelRefKey(match.fallback)) return;
|
|
122
|
+
if (!loaded) return false;
|
|
123
|
+
const match = findFallback(loaded, { provider: source.provider, id: source.model }, status, reason);
|
|
124
|
+
if (!match) return false;
|
|
115
125
|
|
|
116
126
|
const fallbackModel = ctx.modelRegistry.find(match.fallback.provider, match.fallback.model);
|
|
117
127
|
if (!fallbackModel) {
|
|
118
128
|
ctx.ui.notify(`Model fallback missing: ${modelRefKey(match.fallback)}`, "warning");
|
|
119
|
-
return;
|
|
129
|
+
return false;
|
|
120
130
|
}
|
|
121
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
|
+
|
|
122
144
|
const now = new Date();
|
|
123
145
|
const until = new Date(now.getTime() + cooldownMsFor(match.rule.cooldownMs, status, headers)).toISOString();
|
|
124
146
|
const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
|
|
@@ -132,22 +154,37 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
132
154
|
});
|
|
133
155
|
await writeState(paths.state, state);
|
|
134
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
|
+
|
|
135
170
|
const ok = await pi.setModel(fallbackModel);
|
|
136
171
|
if (!ok) {
|
|
172
|
+
originalModelKey = prevOriginal;
|
|
173
|
+
activeFallbackKey = prevActive;
|
|
174
|
+
lastFallbackReason = prevReason;
|
|
175
|
+
visitedFallbackKeys = prevVisited;
|
|
176
|
+
updateStatus(ctx);
|
|
137
177
|
ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
|
|
138
|
-
return;
|
|
178
|
+
return false;
|
|
139
179
|
}
|
|
140
|
-
|
|
141
|
-
originalModelKey = modelRefKey(source);
|
|
142
|
-
activeFallbackKey = modelRefKey(match.fallback);
|
|
143
|
-
lastFallbackReason = `${status} from ${originalModelKey}; persistent until ${until}`;
|
|
144
|
-
updateStatus(ctx);
|
|
145
180
|
ctx.ui.notify(`Model fallback: ${originalModelKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
|
|
181
|
+
return true;
|
|
146
182
|
}
|
|
147
183
|
|
|
148
184
|
pi.on("agent_start", async (_event, ctx) => {
|
|
149
185
|
await loadConfig(ctx);
|
|
150
186
|
await loadState(ctx);
|
|
187
|
+
visitedFallbackKeys = new Set();
|
|
151
188
|
await applyPersistentFallback(ctx);
|
|
152
189
|
updateStatus(ctx);
|
|
153
190
|
});
|
|
@@ -157,6 +194,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
157
194
|
activeFallbackKey = undefined;
|
|
158
195
|
originalModelKey = undefined;
|
|
159
196
|
lastFallbackReason = undefined;
|
|
197
|
+
visitedFallbackKeys = new Set();
|
|
160
198
|
updateStatus(ctx);
|
|
161
199
|
}
|
|
162
200
|
});
|
|
@@ -166,7 +204,6 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
166
204
|
const loaded = config ?? (await loadConfig(ctx));
|
|
167
205
|
if (!current || !loaded) return;
|
|
168
206
|
if (event.status >= 200 && event.status < 300) return;
|
|
169
|
-
if (activeFallbackKey && modelKey(current) === activeFallbackKey) return;
|
|
170
207
|
|
|
171
208
|
await persistFailure(modelToRef(current), event.status, event.headers, ctx);
|
|
172
209
|
});
|
|
@@ -178,11 +215,18 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
178
215
|
if (!errorMessage) return;
|
|
179
216
|
const status = parseStatusFromErrorMessage(errorMessage);
|
|
180
217
|
if (status === undefined) return;
|
|
218
|
+
const reason = parseReasonFromErrorMessage(errorMessage);
|
|
181
219
|
const provider = typeof message.provider === "string" ? message.provider : ctx.model?.provider;
|
|
182
220
|
const model = typeof message.model === "string" ? message.model : ctx.model?.id;
|
|
183
221
|
if (!provider || !model) return;
|
|
184
|
-
|
|
185
|
-
|
|
222
|
+
const switched = await persistFailure({ provider, model }, status, {}, ctx, reason);
|
|
223
|
+
if (!switched) return;
|
|
224
|
+
const loaded = config;
|
|
225
|
+
if (!loaded || loaded.autoRetry === false) return;
|
|
226
|
+
const prompt = lastUserPromptText(ctx);
|
|
227
|
+
if (!prompt) return;
|
|
228
|
+
ctx.ui.notify(`Model fallback: retrying failed prompt on ${activeFallbackKey}.`, "info");
|
|
229
|
+
await pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
186
230
|
});
|
|
187
231
|
|
|
188
232
|
pi.registerCommand("model-fallback:status", {
|
|
@@ -204,6 +248,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
204
248
|
ctx.ui.notify("Model fallback: persistent state cleared; no remembered original model.", "info");
|
|
205
249
|
activeFallbackKey = undefined;
|
|
206
250
|
lastFallbackReason = undefined;
|
|
251
|
+
visitedFallbackKeys = new Set();
|
|
207
252
|
updateStatus(ctx);
|
|
208
253
|
return;
|
|
209
254
|
}
|
|
@@ -221,6 +266,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
221
266
|
activeFallbackKey = undefined;
|
|
222
267
|
originalModelKey = undefined;
|
|
223
268
|
lastFallbackReason = undefined;
|
|
269
|
+
visitedFallbackKeys = new Set();
|
|
224
270
|
updateStatus(ctx);
|
|
225
271
|
ctx.ui.notify(`Model fallback reset: ${modelKey(model)}; persistent state cleared.`, "info");
|
|
226
272
|
},
|
|
@@ -266,6 +312,33 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
266
312
|
});
|
|
267
313
|
}
|
|
268
314
|
|
|
315
|
+
function autoRetryEnabled(config: ModelFallbackConfig): boolean {
|
|
316
|
+
return config.autoRetry !== false;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function lastUserPromptText(ctx: ExtensionContext): string | undefined {
|
|
320
|
+
const entries = ctx.sessionManager.getEntries() as unknown[];
|
|
321
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
322
|
+
const entry = entries[index];
|
|
323
|
+
if (!isRecord(entry) || !isRecord(entry.message)) continue;
|
|
324
|
+
const message = entry.message;
|
|
325
|
+
if (message.role !== "user") continue;
|
|
326
|
+
return userMessageText(message.content);
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function userMessageText(content: unknown): string | undefined {
|
|
332
|
+
if (typeof content === "string") return content.trim() || undefined;
|
|
333
|
+
if (!Array.isArray(content)) return undefined;
|
|
334
|
+
const parts: string[] = [];
|
|
335
|
+
for (const block of content) {
|
|
336
|
+
if (isRecord(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
337
|
+
}
|
|
338
|
+
const text = parts.join("\n").trim();
|
|
339
|
+
return text || undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
269
342
|
function shouldUseProjectLocalState(ctx: ExtensionContext): boolean {
|
|
270
343
|
const projectSettingsPath = join(ctx.cwd, ".pi", "settings.json");
|
|
271
344
|
return existsSync(projectSettingsPath) && projectSettingsIncludesThisPackage(projectSettingsPath);
|
|
@@ -307,6 +380,7 @@ function formatStatus(
|
|
|
307
380
|
): string {
|
|
308
381
|
return [
|
|
309
382
|
`Model fallback: ${config.enabled ? "enabled" : "disabled"}`,
|
|
383
|
+
`Auto retry: ${autoRetryEnabled(config) ? "enabled" : "disabled"}`,
|
|
310
384
|
`Config: ${configPath}`,
|
|
311
385
|
`State: ${statePath}`,
|
|
312
386
|
`Rules: ${config.rules.length}`,
|
|
@@ -322,6 +396,7 @@ function formatStatus(
|
|
|
322
396
|
function summarizeConfig(config: ModelFallbackConfig, warnings: RuleWarning[]): string {
|
|
323
397
|
return [
|
|
324
398
|
`enabled: ${config.enabled}`,
|
|
399
|
+
`autoRetry: ${autoRetryEnabled(config)}`,
|
|
325
400
|
...config.rules.map((rule) => `${rule.name ?? "rule"}: ${rule.matchProviders?.join(",") ?? "models"} -> ${modelRefKey(rule.fallback)}`),
|
|
326
401
|
`warnings: ${warningSummary(warnings)}`,
|
|
327
402
|
].join("\n");
|
|
@@ -423,6 +498,3 @@ function errorMessage(error: unknown): string {
|
|
|
423
498
|
return error instanceof Error ? error.message : String(error);
|
|
424
499
|
}
|
|
425
500
|
|
|
426
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
427
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
428
|
-
}
|
package/lib/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import { isRecord, readModelRef, readNonEmptyString } from "./internal.ts";
|
|
2
3
|
|
|
3
4
|
export const CONFIG_VERSION = 1;
|
|
4
5
|
export const DEFAULT_FALLBACK_STATUSES = [429, 500, 502, 503, 504] as const;
|
|
@@ -13,6 +14,8 @@ export interface FallbackRule {
|
|
|
13
14
|
matchProviders?: string[];
|
|
14
15
|
matchModels?: ModelRef[];
|
|
15
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[];
|
|
16
19
|
/** Persistent failover cooldown in milliseconds after a matching failure. Defaults: 429 => 72h, 5xx => 10m. */
|
|
17
20
|
cooldownMs?: number;
|
|
18
21
|
fallback: ModelRef;
|
|
@@ -21,6 +24,8 @@ export interface FallbackRule {
|
|
|
21
24
|
export interface ModelFallbackConfig {
|
|
22
25
|
version: 1;
|
|
23
26
|
enabled: boolean;
|
|
27
|
+
/** Re-queue the failed user prompt after switching to the fallback model so the turn retries automatically. Defaults to true. */
|
|
28
|
+
autoRetry?: boolean;
|
|
24
29
|
rules: FallbackRule[];
|
|
25
30
|
}
|
|
26
31
|
|
|
@@ -49,12 +54,15 @@ interface NormalizedRuleForWarnings {
|
|
|
49
54
|
modelKeys: Set<string>;
|
|
50
55
|
models: ModelRef[];
|
|
51
56
|
statuses: Set<number>;
|
|
57
|
+
/** undefined = the rule matches any failure reason. */
|
|
58
|
+
reasons?: Set<string>;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
export function defaultConfig(): ModelFallbackConfig {
|
|
55
62
|
return {
|
|
56
63
|
version: CONFIG_VERSION,
|
|
57
64
|
enabled: true,
|
|
65
|
+
autoRetry: true,
|
|
58
66
|
rules: [
|
|
59
67
|
{
|
|
60
68
|
name: "zai-to-deepseek-flash",
|
|
@@ -76,15 +84,24 @@ export function validateConfigShape(value: unknown): ModelFallbackConfig {
|
|
|
76
84
|
return {
|
|
77
85
|
version: CONFIG_VERSION,
|
|
78
86
|
enabled,
|
|
87
|
+
autoRetry: typeof value.autoRetry === "boolean" ? value.autoRetry : true,
|
|
79
88
|
rules: value.rules.map((ruleValue, index) => validateRule(ruleValue, index)),
|
|
80
89
|
};
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
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 {
|
|
84
98
|
if (!config.enabled) return undefined;
|
|
85
99
|
for (const rule of config.rules) {
|
|
86
100
|
if (!statusesFor(rule).has(status)) continue;
|
|
87
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;
|
|
88
105
|
return { rule, fallback: rule.fallback };
|
|
89
106
|
}
|
|
90
107
|
return undefined;
|
|
@@ -104,17 +121,17 @@ export function analyzeRuleWarnings(config: ModelFallbackConfig): RuleWarning[]
|
|
|
104
121
|
const earlierRule = config.rules[earlierIndex];
|
|
105
122
|
const earlier = normalizedRules[earlierIndex];
|
|
106
123
|
|
|
107
|
-
if (setsEqual(earlier.statuses, later.statuses) && ruleScopesEqual(earlier, later)) {
|
|
124
|
+
if (setsEqual(earlier.statuses, later.statuses) && ruleScopesEqual(earlier, later) && reasonsEqual(earlier, later)) {
|
|
108
125
|
warnings.push(duplicateRuleWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex));
|
|
109
126
|
continue rules;
|
|
110
127
|
}
|
|
111
128
|
|
|
112
|
-
if (statusSetCovers(earlier.statuses, later.statuses) && ruleScopeCovers(earlier, later)) {
|
|
129
|
+
if (statusSetCovers(earlier.statuses, later.statuses) && ruleScopeCovers(earlier, later) && reasonsCover(earlier, later)) {
|
|
113
130
|
warnings.push(scopeShadowWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex, earlier));
|
|
114
131
|
continue rules;
|
|
115
132
|
}
|
|
116
133
|
|
|
117
|
-
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;
|
|
118
135
|
|
|
119
136
|
const shadowedModels = later.models.filter((entry) => earlier.providers.has(entry.provider) && !reportedModelShadowKeys.has(modelRefKey(entry)));
|
|
120
137
|
if (shadowedModels.length === 0) continue;
|
|
@@ -142,6 +159,7 @@ function validateRule(value: unknown, index: number): FallbackRule {
|
|
|
142
159
|
if (value.matchProviders !== undefined) rule.matchProviders = readStringArray(value.matchProviders, `rules[${index}].matchProviders`);
|
|
143
160
|
if (value.matchModels !== undefined) rule.matchModels = readModelRefArray(value.matchModels, `rules[${index}].matchModels`);
|
|
144
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`);
|
|
145
163
|
if (value.cooldownMs !== undefined) rule.cooldownMs = readPositiveInteger(value.cooldownMs, `rules[${index}].cooldownMs`);
|
|
146
164
|
if ((!rule.matchProviders || rule.matchProviders.length === 0) && (!rule.matchModels || rule.matchModels.length === 0)) {
|
|
147
165
|
throw new Error(`rules[${index}] must define matchProviders or matchModels.`);
|
|
@@ -155,6 +173,11 @@ function modelMatches(rule: FallbackRule, model: Pick<Model<any>, "provider" | "
|
|
|
155
173
|
return false;
|
|
156
174
|
}
|
|
157
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
|
+
|
|
158
181
|
function statusesFor(rule: FallbackRule): Set<number> {
|
|
159
182
|
return new Set(rule.statuses && rule.statuses.length > 0 ? rule.statuses : DEFAULT_FALLBACK_STATUSES);
|
|
160
183
|
}
|
|
@@ -166,6 +189,7 @@ function normalizeRuleForWarnings(rule: FallbackRule): NormalizedRuleForWarnings
|
|
|
166
189
|
modelKeys: new Set(modelsByKey.keys()),
|
|
167
190
|
models: [...modelsByKey.values()],
|
|
168
191
|
statuses: statusesFor(rule),
|
|
192
|
+
reasons: rule.reasons && rule.reasons.length > 0 ? new Set(rule.reasons) : undefined,
|
|
169
193
|
};
|
|
170
194
|
}
|
|
171
195
|
|
|
@@ -244,6 +268,17 @@ function statusSetCovers(earlier: Set<number>, later: Set<number>): boolean {
|
|
|
244
268
|
return setCovers(earlier, later);
|
|
245
269
|
}
|
|
246
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
|
+
|
|
247
282
|
function setsEqual<T>(left: Set<T>, right: Set<T>): boolean {
|
|
248
283
|
return left.size === right.size && setCovers(left, right);
|
|
249
284
|
}
|
|
@@ -281,14 +316,6 @@ function formatModelList(models: ModelRef[]): string {
|
|
|
281
316
|
return models.map(modelRefKey).sort().join(", ");
|
|
282
317
|
}
|
|
283
318
|
|
|
284
|
-
function readModelRef(value: unknown, path: string): ModelRef {
|
|
285
|
-
if (!isRecord(value)) throw new Error(`${path} must be an object.`);
|
|
286
|
-
return {
|
|
287
|
-
provider: readNonEmptyString(value.provider, `${path}.provider`),
|
|
288
|
-
model: readNonEmptyString(value.model, `${path}.model`),
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
319
|
function readModelRefArray(value: unknown, path: string): ModelRef[] {
|
|
293
320
|
if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
|
|
294
321
|
if (value.length === 0) throw new Error(`${path} must not be empty.`);
|
|
@@ -315,11 +342,3 @@ function readPositiveInteger(value: unknown, path: string): number {
|
|
|
315
342
|
return value;
|
|
316
343
|
}
|
|
317
344
|
|
|
318
|
-
function readNonEmptyString(value: unknown, path: string): string {
|
|
319
|
-
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
320
|
-
return value.trim();
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
324
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
325
|
-
}
|
package/lib/error-status.ts
CHANGED
|
@@ -1,9 +1,36 @@
|
|
|
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
|
+
|
|
1
18
|
export function parseStatusFromErrorMessage(message: string): number | undefined {
|
|
2
19
|
const patterns: RegExp[] = [
|
|
20
|
+
// Leading bare status code, optionally prefixed with "Error:", e.g. "401: {\"type\":\"CreditsError\"...}"
|
|
21
|
+
// or "Error: 401: {\"type\":\"CreditsError\"...}" (OpenCode Go, OpenRouter relays)
|
|
22
|
+
/^\s*(?:error\s*:?\s*)?([1-5][0-9]{2})\s*:/i,
|
|
23
|
+
// Anchored "Error: <status> <prose>", e.g. "Error: 429 quota exceeded"
|
|
24
|
+
// (GitHub Copilot). The pattern above needs a colon AFTER the status and the
|
|
25
|
+
// "error <status>" pattern below needs whitespace directly after "error", so
|
|
26
|
+
// a message carrying both the prefix and a colon fell between them. The
|
|
27
|
+
// "error" prefix is required: without it this would also match prose such as
|
|
28
|
+
// "429 tokens remaining in context window", which must stay unmatched.
|
|
29
|
+
/^\s*error\s*:?\s*([1-5][0-9]{2})\b/i,
|
|
3
30
|
/\b(?:status|code|http)\s*[:\s]?\s*([1-5][0-9]{2})\b/i,
|
|
4
31
|
/\bHTTP\/\d(?:\.\d)?\s+([1-5][0-9]{2})\b/,
|
|
5
32
|
/\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,
|
|
33
|
+
/\b([45][0-9]{2})\s+(?:error|quota|too many requests|service unavailable|bad gateway|gateway timeout|internal server error)\b/i,
|
|
7
34
|
/\berror\s+([45][0-9]{2})\b/i,
|
|
8
35
|
];
|
|
9
36
|
|
package/lib/internal.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function readNonEmptyString(value: unknown, path: string): string {
|
|
8
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
9
|
+
return value.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readModelRef(value: unknown, path: string): { provider: string; model: string } {
|
|
13
|
+
if (!isRecord(value)) throw new Error(`${path} must be an object.`);
|
|
14
|
+
return {
|
|
15
|
+
provider: readNonEmptyString(value.provider, `${path}.provider`),
|
|
16
|
+
model: readNonEmptyString(value.model, `${path}.model`),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readJsonIfExists(path: string): Promise<unknown | undefined> {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
30
|
+
return error instanceof Error && "code" in error;
|
|
31
|
+
}
|
package/lib/state.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import type
|
|
3
|
+
import { modelRefKey, type ModelRef } from "./config.ts";
|
|
4
|
+
import { isRecord, readJsonIfExists, readModelRef, readNonEmptyString } from "./internal.ts";
|
|
4
5
|
|
|
5
6
|
export const STATE_VERSION = 1;
|
|
6
7
|
|
|
@@ -41,10 +42,6 @@ export function upsertStateEntry(state: FallbackState, entry: FallbackStateEntry
|
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
function modelRefKey(ref: ModelRef): string {
|
|
45
|
-
return `${ref.provider}/${ref.model}`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
45
|
export function findActiveStateEntry(state: FallbackState, source: ModelRef, now = new Date()): FallbackStateEntry | undefined {
|
|
49
46
|
const sourceKey = modelRefKey(source);
|
|
50
47
|
return state.entries.find((entry) => modelRefKey(entry.source) === sourceKey && Date.parse(entry.until) > now.getTime());
|
|
@@ -87,32 +84,3 @@ function validateEntry(value: unknown, index: number): FallbackStateEntry {
|
|
|
87
84
|
return entry;
|
|
88
85
|
}
|
|
89
86
|
|
|
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,6 +1,7 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { defaultConfig, validateConfigShape, type ModelFallbackConfig } from "./config.
|
|
3
|
+
import { defaultConfig, validateConfigShape, type ModelFallbackConfig } from "./config.ts";
|
|
4
|
+
import { readJsonIfExists } from "./internal.ts";
|
|
4
5
|
|
|
5
6
|
export interface ModelFallbackPaths {
|
|
6
7
|
dir: string;
|
|
@@ -23,15 +24,3 @@ export async function writeConfig(path: string, config: ModelFallbackConfig): Pr
|
|
|
23
24
|
await writeFile(path, `${JSON.stringify(validateConfigShape(config), null, 2)}\n`, "utf8");
|
|
24
25
|
}
|
|
25
26
|
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-model-fallback",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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,12 +50,13 @@
|
|
|
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
|
-
"typebox": "latest",
|
|
53
|
+
"@earendil-works/pi-agent-core": "^0.86.1",
|
|
54
|
+
"@earendil-works/pi-ai": "^0.86.1",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^0.86.1",
|
|
56
|
+
"@earendil-works/pi-tui": "^0.86.1",
|
|
58
57
|
"@types/node": "^26.0.0",
|
|
58
|
+
"jiti": "^2.7.0",
|
|
59
|
+
"typebox": "latest",
|
|
59
60
|
"typescript": "^7.0.2"
|
|
60
61
|
}
|
|
61
62
|
}
|