pi-model-fallback 0.5.2 → 0.5.6
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 +35 -1
- package/README.md +11 -4
- package/docs/pi-sdk-compatibility.md +25 -0
- package/docs/usage.md +7 -3
- package/docs/verification/pi-sdk-smoke.json +103 -0
- package/extensions/index.ts +126 -90
- package/lib/config.ts +1 -1
- package/lib/error-status.ts +22 -3
- package/lib/state.ts +76 -4
- package/package.json +12 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## [0.5.6] - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Restore automatic releases on a `package.json` version bump pushed to `main`; the manual **Auto Release** dispatch stays available with `release_authorized` checked.
|
|
8
|
+
|
|
9
|
+
## [0.5.5] - 2026-10-19
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Serialize cooldown read-modify-write transactions across sessions and processes, publish state atomically, and coordinate reset and expiry pruning so cached snapshots cannot discard or resurrect other sessions' entries. Retry transient Windows sharing violations during atomic replacement.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Run CI on Windows as well as Ubuntu, and accept CRLF markdown when checking release docs.
|
|
18
|
+
|
|
19
|
+
## [0.5.4] - 2026-10-16
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Pin Pi SDK development dependencies to 1.2.0 and verify the extension against the actual Pi host runtime.
|
|
24
|
+
|
|
25
|
+
## [0.5.3] - 2026-10-12
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Require Pi 1.1 for the continuation boundary API and align Pi development dependencies with that release line.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Continue failed requests after Pi's own retries without replaying the user prompt or dropping image inputs and completed tool results.
|
|
34
|
+
- Keep fallback-chain visitation across continuation loops, respect interruption and aborted settlement, and persist cooldowns only after successful target activation.
|
|
35
|
+
- Retain observed HTTP status and retry headers for the matching request when provider error text omits a status.
|
|
36
|
+
- Keep fallback active for the rest of the run after a config save mid-run; superseding pending fallback work no longer disables fallback until the next user prompt.
|
|
37
|
+
- Recognize Anthropic SDK status-plus-JSON and streaming errors so configured rate-limit and outage rules are evaluated. Treat interrupted Anthropic streams as `502` and supported connection failures as `503`, without changing rule defaults or matching cancellation.
|
|
4
38
|
|
|
5
39
|
## [0.5.2] - 2026-10-07
|
|
6
40
|
|
package/README.md
CHANGED
|
@@ -26,6 +26,8 @@ When a failure matches, the extension also stores persistent fallback state so f
|
|
|
26
26
|
|
|
27
27
|
## Install
|
|
28
28
|
|
|
29
|
+
Requires Pi 1.1 or later for the continuation boundary API.
|
|
30
|
+
|
|
29
31
|
Install from npm:
|
|
30
32
|
|
|
31
33
|
```bash
|
|
@@ -97,7 +99,7 @@ Rule fields:
|
|
|
97
99
|
Top-level fields:
|
|
98
100
|
|
|
99
101
|
- `enabled`: toggle the extension without deleting rules
|
|
100
|
-
- `autoRetry`:
|
|
102
|
+
- `autoRetry`: after Pi exhausts its own retries, continue the existing conversation on the fallback model without re-queueing the user prompt (defaults to `true`; set to `false` to only switch models). Completed tool results and image inputs remain in context; only the failed assistant entry is excluded.
|
|
101
103
|
|
|
102
104
|
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.
|
|
103
105
|
|
|
@@ -119,12 +121,17 @@ The extension stores:
|
|
|
119
121
|
|
|
120
122
|
If the package is installed project-locally and the current project references it from `.pi/settings.json`, those files live under the project `.pi/` directory. Otherwise they live under the user agent directory.
|
|
121
123
|
|
|
124
|
+
Cooldown updates re-read and merge the current state under a shared `proper-lockfile` lock, including across Pi processes. Reset uses the same lock. State is published by atomic replacement, so readers never see a partially written document; expired entries are ignored on read and removed during the next mutation. Lock acquisition is bounded (about two seconds of retries); a lock or write error is reported rather than overwriting state without the lock. A reset clears entries committed before it; a new failure committed afterward can create a new cooldown.
|
|
125
|
+
|
|
122
126
|
## Behavior notes
|
|
123
127
|
|
|
124
128
|
- Successful responses do nothing.
|
|
125
|
-
-
|
|
126
|
-
- Assistant error messages parsed at `turn_end`
|
|
127
|
-
-
|
|
129
|
+
- Failed HTTP responses are recorded by `after_provider_response`; model switching waits until Pi's retry/recovery paths finish at `agent_before_settle`.
|
|
130
|
+
- Assistant error messages parsed at `turn_end` identify SDK/provider failures that do not emit the normal response hook. When friendly error text omits a status, the extension uses the observed HTTP status and headers from the same model request. 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. A status in parentheses is only recognized when an error wrapper keyword precedes it, as in `omni API error (503): ...`; a parenthesized number with no such wrapper, such as `(503) items processed`, stays unmatched.
|
|
131
|
+
- Anthropic SDK errors with a bare status followed by JSON (for example `429 {"type":"error",...}`) are recognized. Status-free Anthropic streaming errors map `rate_limit_error` to `429`, `overloaded_error` to `529`, and `api_error` to `500`; an interrupted stream maps to `502`. Add `529` to a rule's `statuses` if it should cover Anthropic overloads. Supported connection failures without an HTTP response map to `503`; cancellation, refusals, and ordinary numeric prose stay unmatched, and explicit HTTP statuses take precedence.
|
|
132
|
+
- With the default `autoRetry: true`, the existing run continues on the fallback model without a second user request. Set `autoRetry: false` to switch models without continuing.
|
|
133
|
+
- Interrupting a pending fallback prevents continuation. An in-flight model change is not atomic with cancellation: the selected model may change, but the extension will not request another inference after the interrupt.
|
|
134
|
+
- Switching providers sends the existing model context to the target provider. Preserving completed tool results prevents extension-level replay, not a model independently choosing to repeat an action.
|
|
128
135
|
|
|
129
136
|
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
137
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Pi SDK 1.2.0 compatibility
|
|
2
|
+
|
|
3
|
+
This package is verified with the frozen development/test versions:
|
|
4
|
+
|
|
5
|
+
- `@earendil-works/pi-agent-core`: `1.2.0`
|
|
6
|
+
- `@earendil-works/pi-ai`: `1.2.0`
|
|
7
|
+
- `@earendil-works/pi-coding-agent`: `1.2.0`
|
|
8
|
+
- `@earendil-works/pi-tui`: `1.2.0`
|
|
9
|
+
|
|
10
|
+
These exact versions are dev/test pins. Public peer dependency ranges are
|
|
11
|
+
unchanged; the package continues to require Pi 1.1 or later.
|
|
12
|
+
|
|
13
|
+
The upstream [Pi SDK compatibility notes](https://github.com/eiei114/pi-extension-template/blob/main/docs/pi-sdk-compatibility.md)
|
|
14
|
+
record the 1.2.0 `before_agent_start` message-type union and startup busy-state
|
|
15
|
+
changes. This extension does not read user-only event fields, so no production
|
|
16
|
+
API migration was needed. Tests now construct the current `messageType: "user"`
|
|
17
|
+
event and account for sessions being busy while startup handlers run.
|
|
18
|
+
|
|
19
|
+
From a repository checkout, run `npm run smoke:pi` to exercise the installed
|
|
20
|
+
local Pi CLI and RPC host with this extension. The deterministic offline provider
|
|
21
|
+
returns a source 429 and a successful fallback response; the harness also checks
|
|
22
|
+
a handled invalid config, a second-process reload and feature response, clean
|
|
23
|
+
shutdown, and the native host shell. It uses an isolated agent directory and
|
|
24
|
+
makes no paid inference. `npm run ci` runs the smoke as well as typecheck, tests,
|
|
25
|
+
and pack dry-run.
|
package/docs/usage.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Usage
|
|
2
2
|
|
|
3
|
-
Install:
|
|
3
|
+
Requires Pi 1.1 or later. Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pi install npm:pi-model-fallback
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Default behavior: when a `zai/*` model
|
|
9
|
+
Default behavior: when a `zai/*` model fails with `429`, `500`, `502`, `503`, or `504`, Pi finishes its own retries, switches to `deepseek/deepseek-v4-flash`, and continues the existing conversation without replaying the user prompt. Completed tool results and images remain in context. Set `"autoRetry": false` in `model-fallback/config.json` to switch models without continuing.
|
|
10
|
+
|
|
11
|
+
The selected session model changes; subsequent requests use that model directly. Interrupting a pending fallback prevents continuation, although a model change already in progress may remain. A chain never revisits a model within the same user run.
|
|
10
12
|
|
|
11
13
|
Commands:
|
|
12
14
|
|
|
@@ -26,4 +28,6 @@ Cooldown defaults when a rule omits `cooldownMs`:
|
|
|
26
28
|
|
|
27
29
|
`Retry-After` and `x-ratelimit-reset*` response headers override `cooldownMs` and those defaults.
|
|
28
30
|
|
|
29
|
-
If `autoRetry` is disabled, the
|
|
31
|
+
If `autoRetry` is disabled, the existing run is not automatically continued.
|
|
32
|
+
|
|
33
|
+
Shared cooldown state uses a cross-process lock and atomic file replacement. Concurrent failures merge against the latest file, not a session's cached snapshot. Reset takes the same lock and can repair a malformed state file; it clears earlier commits, not failures committed afterward. Expired entries are ignored when selecting a model and cleaned up during the next state mutation. Lock/write failures are reported instead of performing an unlocked overwrite.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"verification_contract": "pi-sdk-behavior-v2",
|
|
4
|
+
"sdk_versions": {
|
|
5
|
+
"@earendil-works/pi-agent-core": "1.2.0",
|
|
6
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
7
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
8
|
+
"@earendil-works/pi-tui": "1.2.0"
|
|
9
|
+
},
|
|
10
|
+
"platform": "win32",
|
|
11
|
+
"model_inference": "No paid inference; actual pinned Pi CLI/RPC, extension loader, lifecycle and fallback model runtime use a deterministic offline faux provider.",
|
|
12
|
+
"unhandled_errors": 0,
|
|
13
|
+
"extension_load_errors": 0,
|
|
14
|
+
"cases": [
|
|
15
|
+
{
|
|
16
|
+
"name": "extension_load",
|
|
17
|
+
"command": "npm run smoke:pi",
|
|
18
|
+
"exit_code": 0,
|
|
19
|
+
"passed": true,
|
|
20
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":1013,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"150821d3-944b-44d0-99e2-92a72920ec36\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},{\"id\":\"extension-load\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}],\"stderr\":\"\",\"error\":null}",
|
|
21
|
+
"assertions": [
|
|
22
|
+
{
|
|
23
|
+
"id": "extension_registered",
|
|
24
|
+
"expected": "The actual Pi host handles the package's registered status command",
|
|
25
|
+
"observed": "{\"response\":{\"id\":\"extension-load\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},\"notification\":{\"type\":\"extension_ui_request\",\"id\":\"150821d3-944b-44d0-99e2-92a72920ec36\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},\"exit_code\":0}",
|
|
26
|
+
"passed": true
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "happy_path",
|
|
32
|
+
"command": "npm run smoke:pi",
|
|
33
|
+
"exit_code": 0,
|
|
34
|
+
"passed": true,
|
|
35
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":1014,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"f2cfe8b5-18e7-4054-8649-707c1224832e\",\"method\":\"setStatus\",\"statusKey\":\"model-fallback\"},{\"id\":\"happy-path\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}},{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Exercise the deterministic fallback response.\"}],\"timestamp\":1792131439674}},{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-source\",\"model\":\"source\",\"usage\":{\"input\":563,\"output\":0,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1126,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"Error: 429 deterministic smoke failure\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"}},{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-source\",\"model\":\"source\",\"usage\":{\"input\":563,\"output\":0,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1126,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"Error: 429 deterministic smoke failure\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"},\"toolResults\":[]},{\"type\":\"extension_ui_request\",\"id\":\"8a3bc896-7b4d-4b0a-bcc2-83193ca06029\",\"method\":\"setStatus\",\"statusKey\":\"model-fallback\",\"statusText\":\"fallback:pi-sdk-smoke-target/target\"},{\"type\":\"extension_ui_request\",\"id\":\"919dd5a4-0993-4101-83c8-d29ecc31f3d5\",\"method\":\"notify\",\"message\":\"Model fallback: pi-sdk-smoke-source/source → pi-sdk-smoke-target/target (429). Future sessions preselect fallback until 2026-10-16T06:18:19.680Z.\",\"notifyType\":\"warning\"},{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"pi-model-fallback-smoke-ok\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-target\",\"model\":\"target\",\"usage\":{\"input\":563,\"output\":7,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1133,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"}},{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"pi-model-fallback-smoke-ok\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-target\",\"model\":\"target\",\"usage\":{\"input\":563,\"output\":7,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1133,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"},\"toolResults\":[]},{\"type\":\"agent_settled\",\"aborted\":false}],\"stderr\":\"\",\"error\":null}",
|
|
36
|
+
"assertions": [
|
|
37
|
+
{
|
|
38
|
+
"id": "feature_response",
|
|
39
|
+
"expected": "The real Pi run handles a 429, persists the configured fallback, and returns the target response",
|
|
40
|
+
"observed": "{\"response\":{\"id\":\"happy-path\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}},\"source_error\":{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-source\",\"model\":\"source\",\"usage\":{\"input\":563,\"output\":0,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1126,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"Error: 429 deterministic smoke failure\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"}},\"fallback_response\":{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"pi-model-fallback-smoke-ok\"}],\"api\":\"pi-sdk-smoke-faux\",\"provider\":\"pi-sdk-smoke-target\",\"model\":\"target\",\"usage\":{\"input\":563,\"output\":7,\"cacheRead\":0,\"cacheWrite\":563,\"totalTokens\":1133,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1792131439650,\"thinkingLevel\":\"off\"}},\"fallback_notification\":{\"type\":\"extension_ui_request\",\"id\":\"919dd5a4-0993-4101-83c8-d29ecc31f3d5\",\"method\":\"notify\",\"message\":\"Model fallback: pi-sdk-smoke-source/source → pi-sdk-smoke-target/target (429). Future sessions preselect fallback until 2026-10-16T06:18:19.680Z.\",\"notifyType\":\"warning\"},\"agent_settled\":{\"type\":\"agent_settled\",\"aborted\":false},\"user_message_count\":1,\"persisted_state\":{\"source\":{\"provider\":\"pi-sdk-smoke-source\",\"model\":\"source\"},\"fallback\":{\"provider\":\"pi-sdk-smoke-target\",\"model\":\"target\"},\"status\":429,\"until\":\"2026-10-16T06:18:19.680Z\",\"createdAt\":\"2026-10-16T06:17:19.680Z\"},\"exit_code\":0}",
|
|
41
|
+
"passed": true
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "error_path",
|
|
47
|
+
"command": "npm run smoke:pi",
|
|
48
|
+
"exit_code": 0,
|
|
49
|
+
"passed": true,
|
|
50
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":963,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"8ed2cb69-da08-4c3b-b9aa-3b2e042617c8\",\"method\":\"notify\",\"message\":\"Model fallback config error: Unsupported config version: 2.\",\"notifyType\":\"warning\"},{\"type\":\"extension_ui_request\",\"id\":\"739c37cd-366e-46fa-b25a-49c957448732\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},{\"id\":\"error-path\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}],\"stderr\":\"\",\"error\":null}",
|
|
51
|
+
"assertions": [
|
|
52
|
+
{
|
|
53
|
+
"id": "handled_error",
|
|
54
|
+
"expected": "The actual extension reports a malformed config as a handled warning without crashing Pi",
|
|
55
|
+
"observed": "{\"response\":{\"id\":\"error-path\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},\"warning\":{\"type\":\"extension_ui_request\",\"id\":\"8ed2cb69-da08-4c3b-b9aa-3b2e042617c8\",\"method\":\"notify\",\"message\":\"Model fallback config error: Unsupported config version: 2.\",\"notifyType\":\"warning\"},\"exit_code\":0}",
|
|
56
|
+
"passed": true
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "reload_cleanup",
|
|
62
|
+
"command": "npm run smoke:pi",
|
|
63
|
+
"exit_code": 0,
|
|
64
|
+
"passed": true,
|
|
65
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":977,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"7eebdc06-9361-463d-8702-59b1fcf47520\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},{\"id\":\"reload-first\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}],\"stderr\":\"\",\"error\":null}\n--- next Pi process ---\n{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"duration_ms\":943,\"rpc_records\":[{\"type\":\"extension_ui_request\",\"id\":\"c14e9202-6c27-45c0-ba9e-08befeff728a\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},{\"id\":\"reload-status\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},{\"type\":\"extension_ui_request\",\"id\":\"f68c1ead-b99e-4ddd-a43b-e0eb798cbb35\",\"method\":\"notify\",\"message\":\"Model fallback: persistent state cleared; no remembered original model.\",\"notifyType\":\"info\"},{\"type\":\"extension_ui_request\",\"id\":\"f6e7f463-ffb9-4ac8-8cba-68a94653e32b\",\"method\":\"setStatus\",\"statusKey\":\"model-fallback\"},{\"id\":\"reload-feature\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}],\"stderr\":\"\",\"error\":null}",
|
|
66
|
+
"assertions": [
|
|
67
|
+
{
|
|
68
|
+
"id": "reloaded_extension_registered",
|
|
69
|
+
"expected": "A second real Pi process reloads the extension and handles its registered status command",
|
|
70
|
+
"observed": "{\"response\":{\"id\":\"reload-status\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},\"notification\":{\"type\":\"extension_ui_request\",\"id\":\"c14e9202-6c27-45c0-ba9e-08befeff728a\",\"method\":\"notify\",\"message\":\"Model fallback: enabled\\nAuto retry: enabled\\nConfig: <isolated-agent-dir>\\\\model-fallback\\\\config.json\\nState: <isolated-agent-dir>\\\\model-fallback\\\\state.json\\nRules: 1\\nConfig warnings: none\\nPersistent entries: 0\\nActive fallback: none\\nOriginal model: none\\nReason: none\",\"notifyType\":\"info\"},\"second_exit_code\":0}",
|
|
71
|
+
"passed": true
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "reloaded_feature_response",
|
|
75
|
+
"expected": "The reloaded second process executes reset and returns its package-specific handled response",
|
|
76
|
+
"observed": "{\"response\":{\"id\":\"reload-feature\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}},\"notification\":{\"type\":\"extension_ui_request\",\"id\":\"f68c1ead-b99e-4ddd-a43b-e0eb798cbb35\",\"method\":\"notify\",\"message\":\"Model fallback: persistent state cleared; no remembered original model.\",\"notifyType\":\"info\"}}",
|
|
77
|
+
"passed": true
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "clean_shutdown",
|
|
81
|
+
"expected": "Both actual Pi processes exit cleanly without unhandled, extension-load, or shutdown errors",
|
|
82
|
+
"observed": "{\"first_exit_code\":0,\"second_exit_code\":0,\"first_timed_out\":false,\"second_timed_out\":false,\"first_unhandled_error\":false,\"second_unhandled_error\":false,\"first_extension_load_error\":false,\"second_extension_load_error\":false}",
|
|
83
|
+
"passed": true
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "host_shell",
|
|
89
|
+
"command": "npm run smoke:pi",
|
|
90
|
+
"exit_code": 0,
|
|
91
|
+
"passed": true,
|
|
92
|
+
"evidence": "{\"exit_code\":0,\"signal\":null,\"timed_out\":false,\"rpc_records\":[],\"stderr\":\"\",\"error\":null}",
|
|
93
|
+
"assertions": [
|
|
94
|
+
{
|
|
95
|
+
"id": "native_shell_boundary",
|
|
96
|
+
"expected": "The native host shell launches the locally pinned Pi CLI and returns exactly 1.2.0",
|
|
97
|
+
"observed": "{\"exit_code\":0,\"output\":\"1.2.0\",\"expected_version\":\"1.2.0\",\"stderr\":\"\",\"error\":null}",
|
|
98
|
+
"passed": true
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
2
|
import { dirname, join, resolve } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
5
4
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { getKeybindings } from "@earendil-works/pi-tui";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
import {
|
|
8
8
|
analyzeRuleWarnings,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type RuleWarning,
|
|
17
17
|
} from "../lib/config.js";
|
|
18
18
|
import { parseReasonFromErrorMessage, parseStatusFromErrorMessage } from "../lib/error-status.js";
|
|
19
|
-
import {
|
|
19
|
+
import { findActiveStateEntry, readState, updateState, upsertStateEntry, 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";
|
|
22
22
|
|
|
@@ -37,6 +37,45 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
37
37
|
// target a model never visited, which makes chains (free → kimi → glm) safe while
|
|
38
38
|
// preventing A→B→A ping-pong with circular rules.
|
|
39
39
|
let visitedFallbackKeys = new Set<string>();
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
let generation = 0;
|
|
42
|
+
let switching = false;
|
|
43
|
+
let uiPromptOpen = false;
|
|
44
|
+
let removeTerminalListener: (() => void) | undefined;
|
|
45
|
+
let responseFailure: { modelKey: string; status: number; headers: Record<string, string> } | undefined;
|
|
46
|
+
let pendingFailure: { source: ModelRef; status: number; reason?: string; entryId: string; headers: Record<string, string> } | undefined;
|
|
47
|
+
|
|
48
|
+
function cancelFallback(): void {
|
|
49
|
+
cancelled = true;
|
|
50
|
+
invalidatePending();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Drop fallback work computed from superseded state without disabling fallback
|
|
54
|
+
// for the rest of the run (unlike cancelFallback).
|
|
55
|
+
function invalidatePending(): void {
|
|
56
|
+
generation += 1;
|
|
57
|
+
pendingFailure = undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pi.on("session_start", (_event, ctx) => {
|
|
61
|
+
removeTerminalListener?.();
|
|
62
|
+
if (ctx.mode === "tui") {
|
|
63
|
+
removeTerminalListener = ctx.ui.onTerminalInput((data) => {
|
|
64
|
+
if ((pendingFailure || switching) && !uiPromptOpen && getKeybindings().matches(data, "app.interrupt")) cancelFallback();
|
|
65
|
+
// Observe cancellation without consuming autocomplete, overlay, or editor input.
|
|
66
|
+
return undefined;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
pi.on("ui_prompt_start", () => { uiPromptOpen = true; });
|
|
71
|
+
pi.on("ui_prompt_end", () => { uiPromptOpen = false; });
|
|
72
|
+
pi.on("session_shutdown", () => {
|
|
73
|
+
cancelFallback();
|
|
74
|
+
removeTerminalListener?.();
|
|
75
|
+
removeTerminalListener = undefined;
|
|
76
|
+
});
|
|
77
|
+
pi.on("session_before_switch", cancelFallback);
|
|
78
|
+
pi.on("session_before_tree", cancelFallback);
|
|
40
79
|
|
|
41
80
|
function syncPaths(ctx: ExtensionContext): void {
|
|
42
81
|
if (pathsResolvedForCwd === ctx.cwd) return;
|
|
@@ -58,18 +97,9 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
58
97
|
async function loadState(ctx: ExtensionContext): Promise<FallbackState | undefined> {
|
|
59
98
|
try {
|
|
60
99
|
syncPaths(ctx);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return state;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const before = validateStateShape(JSON.parse(await readFile(statePath, "utf8")));
|
|
68
|
-
const after = pruneExpiredState(before);
|
|
69
|
-
state = after;
|
|
70
|
-
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
|
71
|
-
await writeState(statePath, after);
|
|
72
|
-
}
|
|
100
|
+
// Prune the snapshot for selection, but never write a stale read back over another session.
|
|
101
|
+
// Mutating transactions prune the current on-disk state while holding the state lock.
|
|
102
|
+
state = await readState(paths.state);
|
|
73
103
|
return state;
|
|
74
104
|
} catch (error) {
|
|
75
105
|
ctx.ui.notify(`Model fallback state error: ${errorMessage(error)}`, "warning");
|
|
@@ -103,7 +133,13 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
103
133
|
visitedFallbackKeys = new Set([originalModelKey, activeFallbackKey]);
|
|
104
134
|
lastFallbackReason = `persistent ${active.status} from ${originalModelKey} until ${active.until}`;
|
|
105
135
|
updateStatus(ctx);
|
|
106
|
-
|
|
136
|
+
let ok = false;
|
|
137
|
+
switching = true;
|
|
138
|
+
try {
|
|
139
|
+
ok = await pi.setModel(fallbackModel);
|
|
140
|
+
} finally {
|
|
141
|
+
switching = false;
|
|
142
|
+
}
|
|
107
143
|
if (!ok) {
|
|
108
144
|
ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(active.fallback)}`, "warning");
|
|
109
145
|
originalModelKey = undefined;
|
|
@@ -117,7 +153,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
117
153
|
ctx.ui.notify(`Model fallback preselected: ${originalModelKey} → ${activeFallbackKey} until ${active.until}.`, "warning");
|
|
118
154
|
}
|
|
119
155
|
|
|
120
|
-
async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext, reason?: string): Promise<boolean> {
|
|
156
|
+
async function persistFailure(source: ModelRef, status: number, headers: Record<string, string>, ctx: ExtensionContext, isCancelled: () => boolean, reason?: string): Promise<boolean> {
|
|
121
157
|
const loaded = config ?? (await loadConfig(ctx));
|
|
122
158
|
if (!loaded) return false;
|
|
123
159
|
const match = findFallback(loaded, { provider: source.provider, id: source.model }, status, reason);
|
|
@@ -129,30 +165,12 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
129
165
|
return false;
|
|
130
166
|
}
|
|
131
167
|
|
|
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
168
|
const fallbackKey = modelRefKey(match.fallback);
|
|
140
169
|
const sourceKey = modelRefKey(source);
|
|
141
|
-
|
|
142
|
-
if (visitedFallbackKeys.has(fallbackKey) && !idempotentReapply) return false;
|
|
170
|
+
if (visitedFallbackKeys.has(fallbackKey) || isCancelled()) return false;
|
|
143
171
|
|
|
144
172
|
const now = new Date();
|
|
145
173
|
const until = new Date(now.getTime() + cooldownMsFor(match.rule.cooldownMs, status, headers)).toISOString();
|
|
146
|
-
const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
|
|
147
|
-
state = upsertStateEntry(loadedState, {
|
|
148
|
-
source,
|
|
149
|
-
fallback: match.fallback,
|
|
150
|
-
status,
|
|
151
|
-
until,
|
|
152
|
-
createdAt: now.toISOString(),
|
|
153
|
-
ruleName: match.rule.name,
|
|
154
|
-
});
|
|
155
|
-
await writeState(paths.state, state);
|
|
156
174
|
|
|
157
175
|
const prevOriginal = originalModelKey;
|
|
158
176
|
const prevActive = activeFallbackKey;
|
|
@@ -167,30 +185,50 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
167
185
|
lastFallbackReason = `${status} from ${sourceKey}${reason ? ` (${reason})` : ""}; persistent until ${until}`;
|
|
168
186
|
updateStatus(ctx);
|
|
169
187
|
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
let ok = false;
|
|
189
|
+
try {
|
|
190
|
+
ok = await pi.setModel(fallbackModel);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
ctx.ui.notify(`Model fallback switch failed: ${errorMessage(error)}`, "warning");
|
|
193
|
+
}
|
|
194
|
+
if (!ok || isCancelled()) {
|
|
172
195
|
originalModelKey = prevOriginal;
|
|
173
196
|
activeFallbackKey = prevActive;
|
|
174
197
|
lastFallbackReason = prevReason;
|
|
175
198
|
visitedFallbackKeys = prevVisited;
|
|
176
199
|
updateStatus(ctx);
|
|
177
|
-
ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
|
|
200
|
+
if (!ok) ctx.ui.notify(`Model fallback auth unavailable: ${modelRefKey(match.fallback)}`, "warning");
|
|
178
201
|
return false;
|
|
179
202
|
}
|
|
180
|
-
|
|
203
|
+
if (isCancelled()) return false;
|
|
204
|
+
state = await updateState(paths.state, (current) => isCancelled() ? current : upsertStateEntry(current, {
|
|
205
|
+
source, fallback: match.fallback, status, until, createdAt: now.toISOString(), ruleName: match.rule.name,
|
|
206
|
+
}));
|
|
207
|
+
if (isCancelled()) return false;
|
|
208
|
+
ctx.ui.notify(`Model fallback: ${sourceKey} → ${activeFallbackKey} (${status}). Future sessions preselect fallback until ${until}.`, "warning");
|
|
181
209
|
return true;
|
|
182
210
|
}
|
|
183
211
|
|
|
184
|
-
pi.on("
|
|
212
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
213
|
+
cancelled = false;
|
|
214
|
+
generation += 1;
|
|
215
|
+
pendingFailure = undefined;
|
|
216
|
+
responseFailure = undefined;
|
|
185
217
|
await loadConfig(ctx);
|
|
186
218
|
await loadState(ctx);
|
|
187
|
-
visitedFallbackKeys = new Set();
|
|
219
|
+
visitedFallbackKeys = new Set(ctx.model ? [modelKey(ctx.model)] : []);
|
|
188
220
|
await applyPersistentFallback(ctx);
|
|
189
221
|
updateStatus(ctx);
|
|
190
222
|
});
|
|
191
223
|
|
|
224
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
225
|
+
// Preselection happens before a run exists; abort once its controller is active.
|
|
226
|
+
if (cancelled) ctx.abort();
|
|
227
|
+
});
|
|
228
|
+
|
|
192
229
|
pi.on("model_select", async (event, ctx) => {
|
|
193
|
-
if (
|
|
230
|
+
if (modelKey(event.model) !== activeFallbackKey) {
|
|
231
|
+
cancelFallback();
|
|
194
232
|
activeFallbackKey = undefined;
|
|
195
233
|
originalModelKey = undefined;
|
|
196
234
|
lastFallbackReason = undefined;
|
|
@@ -199,34 +237,53 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
199
237
|
}
|
|
200
238
|
});
|
|
201
239
|
|
|
202
|
-
pi.on("
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
240
|
+
pi.on("before_provider_request", () => { responseFailure = undefined; });
|
|
241
|
+
pi.on("after_provider_response", (event, ctx) => {
|
|
242
|
+
responseFailure = event.status >= 400 && ctx.model
|
|
243
|
+
? { modelKey: modelKey(ctx.model), status: event.status, headers: event.headers }
|
|
244
|
+
: undefined;
|
|
245
|
+
});
|
|
207
246
|
|
|
208
|
-
|
|
247
|
+
pi.on("turn_end", (event) => {
|
|
248
|
+
const response = responseFailure;
|
|
249
|
+
responseFailure = undefined;
|
|
250
|
+
pendingFailure = undefined;
|
|
251
|
+
const message = event.message;
|
|
252
|
+
if (message.role !== "assistant" || message.stopReason !== "error") return;
|
|
253
|
+
const source = { provider: message.provider, model: message.model };
|
|
254
|
+
const observed = response?.modelKey === modelRefKey(source) ? response : undefined;
|
|
255
|
+
const errorText = message.errorMessage ?? "";
|
|
256
|
+
const status = parseStatusFromErrorMessage(errorText) ?? observed?.status;
|
|
257
|
+
if (status !== undefined) {
|
|
258
|
+
pendingFailure = { source, status, reason: parseReasonFromErrorMessage(errorText),
|
|
259
|
+
entryId: event.messageEntryId, headers: observed?.headers ?? {} };
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
pi.on("agent_before_settle", async (event, ctx) => {
|
|
264
|
+
const failure = pendingFailure;
|
|
265
|
+
if (event.outcome !== "error" || cancelled || !failure || !ctx.model || modelKey(ctx.model) !== modelRefKey(failure.source)) return;
|
|
266
|
+
const visibleFailure = event.context.contextEntries.some((entry) => entry.sourceEntry.id === failure.entryId && entry.messages.some((message) => message.role === "assistant" && message.stopReason === "error"));
|
|
267
|
+
if (!visibleFailure && !event.context.canContinue) return;
|
|
268
|
+
pendingFailure = undefined;
|
|
269
|
+
const currentGeneration = generation;
|
|
270
|
+
const isCancelled = () => cancelled || generation !== currentGeneration;
|
|
271
|
+
switching = true;
|
|
272
|
+
try {
|
|
273
|
+
const switched = await persistFailure(failure.source, failure.status, failure.headers, ctx, isCancelled, failure.reason);
|
|
274
|
+
if (!switched || isCancelled() || config?.autoRetry === false) return;
|
|
275
|
+
return {
|
|
276
|
+
entries: visibleFailure ? [...event.entries, { type: "context_edit" as const, targetId: failure.entryId, replacement: null }] : event.entries,
|
|
277
|
+
continue: true,
|
|
278
|
+
};
|
|
279
|
+
} finally {
|
|
280
|
+
switching = false;
|
|
281
|
+
}
|
|
209
282
|
});
|
|
210
283
|
|
|
211
|
-
pi.on("
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage : undefined;
|
|
215
|
-
if (!errorMessage) return;
|
|
216
|
-
const status = parseStatusFromErrorMessage(errorMessage);
|
|
217
|
-
if (status === undefined) return;
|
|
218
|
-
const reason = parseReasonFromErrorMessage(errorMessage);
|
|
219
|
-
const provider = typeof message.provider === "string" ? message.provider : ctx.model?.provider;
|
|
220
|
-
const model = typeof message.model === "string" ? message.model : ctx.model?.id;
|
|
221
|
-
if (!provider || !model) return;
|
|
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" });
|
|
284
|
+
pi.on("agent_settled", () => {
|
|
285
|
+
pendingFailure = undefined;
|
|
286
|
+
responseFailure = undefined;
|
|
230
287
|
});
|
|
231
288
|
|
|
232
289
|
pi.registerCommand("model-fallback:status", {
|
|
@@ -241,6 +298,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
241
298
|
pi.registerCommand("model-fallback:reset", {
|
|
242
299
|
description: "Return from fallback model to the pre-fallback model when remembered and clear persistent state",
|
|
243
300
|
handler: async (_args, ctx) => {
|
|
301
|
+
cancelFallback();
|
|
244
302
|
syncPaths(ctx);
|
|
245
303
|
state = { version: 1, entries: [] };
|
|
246
304
|
await writeState(paths.state, state);
|
|
@@ -305,6 +363,7 @@ export default function modelFallback(pi: ExtensionAPI) {
|
|
|
305
363
|
if (!ctx.hasUI) return textResult("Config not saved: confirmation UI is unavailable.", { configPath: paths.config, ...warningDetails(warnings) });
|
|
306
364
|
const ok = await ctx.ui.confirm("Save model fallback config?", summarizeConfig(nextConfig, warnings));
|
|
307
365
|
if (!ok) return textResult("Config not saved.", { configPath: paths.config, ...warningDetails(warnings) });
|
|
366
|
+
invalidatePending();
|
|
308
367
|
await writeConfig(paths.config, nextConfig);
|
|
309
368
|
config = nextConfig;
|
|
310
369
|
return textResult(configResultText("Config saved.", warnings), { configPath: paths.config, ...warningDetails(warnings) });
|
|
@@ -316,29 +375,6 @@ function autoRetryEnabled(config: ModelFallbackConfig): boolean {
|
|
|
316
375
|
return config.autoRetry !== false;
|
|
317
376
|
}
|
|
318
377
|
|
|
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
|
-
|
|
342
378
|
function shouldUseProjectLocalState(ctx: ExtensionContext): boolean {
|
|
343
379
|
const projectSettingsPath = join(ctx.cwd, ".pi", "settings.json");
|
|
344
380
|
return existsSync(projectSettingsPath) && projectSettingsIncludesThisPackage(projectSettingsPath);
|
package/lib/config.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface FallbackRule {
|
|
|
24
24
|
export interface ModelFallbackConfig {
|
|
25
25
|
version: 1;
|
|
26
26
|
enabled: boolean;
|
|
27
|
-
/**
|
|
27
|
+
/** Continue the existing conversation after switching without replaying the user prompt. Defaults to true. */
|
|
28
28
|
autoRetry?: boolean;
|
|
29
29
|
rules: FallbackRule[];
|
|
30
30
|
}
|
package/lib/error-status.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isRecord } from "./internal.ts";
|
|
2
|
+
|
|
1
3
|
const REASON_TEXT_PATTERNS: Array<[RegExp, string]> = [
|
|
2
4
|
[/exceeds\s+(?:this|the)\s+model'?s?\s+context\s+length/i, "context_length_exceeded"],
|
|
3
5
|
[/context[\s_-]*length[\s_-]*exceeded/i, "context_length_exceeded"],
|
|
@@ -27,9 +29,24 @@ const EMPTY_PROVIDER_RESPONSE_PATTERNS: RegExp[] = [
|
|
|
27
29
|
/response (has|with) no body/i,
|
|
28
30
|
/stream ended without a terminal event/i,
|
|
29
31
|
/stream ended without finish_reason/i,
|
|
32
|
+
/^Anthropic stream ended before message_stop$/,
|
|
30
33
|
];
|
|
31
34
|
|
|
32
35
|
export function parseStatusFromErrorMessage(message: string): number | undefined {
|
|
36
|
+
if (message.trimStart().startsWith("{")) {
|
|
37
|
+
try {
|
|
38
|
+
const payload: unknown = JSON.parse(message);
|
|
39
|
+
if (isRecord(payload) && payload.type === "error" && isRecord(payload.error)) {
|
|
40
|
+
switch (payload.error.type) {
|
|
41
|
+
case "overloaded_error": return 529;
|
|
42
|
+
case "rate_limit_error": return 429;
|
|
43
|
+
case "api_error": return 500;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Non-JSON error text still uses the status patterns below.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
33
50
|
for (const pattern of EMPTY_PROVIDER_RESPONSE_PATTERNS) {
|
|
34
51
|
if (pattern.test(message)) {
|
|
35
52
|
// Synthesize 502 so status-based fallback rules fire instead of the
|
|
@@ -40,9 +57,8 @@ export function parseStatusFromErrorMessage(message: string): number | undefined
|
|
|
40
57
|
}
|
|
41
58
|
}
|
|
42
59
|
const patterns: RegExp[] = [
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
/^\s*(?:error\s*:?\s*)?([1-5][0-9]{2})\s*:/i,
|
|
60
|
+
// SDKs use either "429: ..." or "429 {JSON}"; keep ordinary numeric prose unmatched.
|
|
61
|
+
/^\s*(?:error\s*:?\s*)?([1-5][0-9]{2})(?:\s*:|\s+(?=\{))/i,
|
|
46
62
|
// Anchored "Error: <status> <prose>", e.g. "Error: 429 quota exceeded"
|
|
47
63
|
// (GitHub Copilot). The pattern above needs a colon AFTER the status and the
|
|
48
64
|
// "error <status>" pattern below needs whitespace directly after "error", so
|
|
@@ -71,5 +87,8 @@ export function parseStatusFromErrorMessage(message: string): number | undefined
|
|
|
71
87
|
if (Number.isInteger(status) && status >= 100 && status <= 599) return status;
|
|
72
88
|
}
|
|
73
89
|
|
|
90
|
+
// Connection failures have no HTTP response; use the existing unavailable-service rules.
|
|
91
|
+
if (/^\s*(?:error:\s*)?(?:connection error|request timed out|fetch failed)[.!]?\s*$/i.test(message) ||
|
|
92
|
+
/\b(?:ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT)\b/.test(message)) return 503;
|
|
74
93
|
return undefined;
|
|
75
94
|
}
|
package/lib/state.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import lockfile from "proper-lockfile";
|
|
3
6
|
import { modelRefKey, type ModelRef } from "./config.ts";
|
|
4
|
-
import { isRecord, readJsonIfExists, readModelRef, readNonEmptyString } from "./internal.ts";
|
|
7
|
+
import { isNodeError, isRecord, readJsonIfExists, readModelRef, readNonEmptyString } from "./internal.ts";
|
|
5
8
|
|
|
6
9
|
export const STATE_VERSION = 1;
|
|
10
|
+
const WINDOWS_RENAME_RETRY_MS = 1000;
|
|
11
|
+
const WINDOWS_RENAME_RETRY_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
|
7
12
|
|
|
8
13
|
export interface FallbackStateEntry {
|
|
9
14
|
source: ModelRef;
|
|
@@ -30,8 +35,75 @@ export async function readState(path: string, now = new Date()): Promise<Fallbac
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export async function writeState(path: string, state: FallbackState): Promise<void> {
|
|
38
|
+
const next = validateStateShape(state);
|
|
39
|
+
// Explicit replacement (notably reset) must also be able to repair a corrupt old file.
|
|
40
|
+
await withStateLock(path, (target, checkLock) => publishState(target, next, checkLock));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Serialize the entire read/modify/publish transaction, including across Pi processes. */
|
|
44
|
+
export async function updateState(path: string, update: (current: FallbackState) => FallbackState): Promise<FallbackState> {
|
|
45
|
+
return withStateLock(path, async (target, checkLock) => {
|
|
46
|
+
const next = validateStateShape(update(await readState(target)));
|
|
47
|
+
checkLock();
|
|
48
|
+
await publishState(target, next, checkLock);
|
|
49
|
+
return next;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function withStateLock<T>(path: string, operation: (target: string, checkLock: () => void) => Promise<T>): Promise<T> {
|
|
33
54
|
await mkdir(dirname(path), { recursive: true });
|
|
34
|
-
|
|
55
|
+
// Aliases of the same agent directory must share a lock (the state file may not exist yet).
|
|
56
|
+
const target = join(await realpath(dirname(path)), basename(path));
|
|
57
|
+
let compromised: Error | undefined;
|
|
58
|
+
const release = await lockfile.lock(target, {
|
|
59
|
+
realpath: false,
|
|
60
|
+
retries: { retries: 100, factor: 1, minTimeout: 20, maxTimeout: 20, randomize: false },
|
|
61
|
+
onCompromised: (error) => { compromised = error; },
|
|
62
|
+
});
|
|
63
|
+
const checkLock = () => { if (compromised) throw compromised; };
|
|
64
|
+
try {
|
|
65
|
+
checkLock();
|
|
66
|
+
return await operation(target, checkLock);
|
|
67
|
+
} finally {
|
|
68
|
+
await release();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function publishState(path: string, state: FallbackState, checkLock: () => void): Promise<void> {
|
|
73
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
74
|
+
let created = false;
|
|
75
|
+
try {
|
|
76
|
+
const file = await open(temporary, "wx", 0o600);
|
|
77
|
+
created = true;
|
|
78
|
+
try {
|
|
79
|
+
await file.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
80
|
+
await file.sync();
|
|
81
|
+
} finally {
|
|
82
|
+
await file.close();
|
|
83
|
+
}
|
|
84
|
+
// Readers see either the old complete document or the new one, never a partial write.
|
|
85
|
+
checkLock();
|
|
86
|
+
await replaceStateFile(temporary, path, checkLock);
|
|
87
|
+
} finally {
|
|
88
|
+
if (created) await rm(temporary, { force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function replaceStateFile(source: string, target: string, checkLock: () => void): Promise<void> {
|
|
93
|
+
// Windows can reject replacement while an unlocked reader briefly has the target open.
|
|
94
|
+
const deadline = Date.now() + WINDOWS_RENAME_RETRY_MS;
|
|
95
|
+
let retryDelayMs = 10;
|
|
96
|
+
for (;;) {
|
|
97
|
+
try {
|
|
98
|
+
await rename(source, target);
|
|
99
|
+
return;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (process.platform !== "win32" || !isNodeError(error) || !WINDOWS_RENAME_RETRY_CODES.has(error.code ?? "") || Date.now() >= deadline) throw error;
|
|
102
|
+
await delay(retryDelayMs);
|
|
103
|
+
checkLock();
|
|
104
|
+
retryDelayMs = Math.min(retryDelayMs + 10, 100);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
35
107
|
}
|
|
36
108
|
|
|
37
109
|
export function upsertStateEntry(state: FallbackState, entry: FallbackStateEntry): FallbackState {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-model-fallback",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
33
|
"test": "node --test tests/*.test.mjs",
|
|
34
|
-
"
|
|
34
|
+
"smoke:pi": "node scripts/pi-sdk-smoke.mjs",
|
|
35
|
+
"ci": "npm run typecheck && npm test && npm run smoke:pi && npm run pack:check",
|
|
35
36
|
"pack:check": "npm pack --dry-run"
|
|
36
37
|
},
|
|
37
38
|
"pi": {
|
|
@@ -45,18 +46,22 @@
|
|
|
45
46
|
"peerDependencies": {
|
|
46
47
|
"@earendil-works/pi-agent-core": "*",
|
|
47
48
|
"@earendil-works/pi-ai": "*",
|
|
48
|
-
"@earendil-works/pi-coding-agent": "
|
|
49
|
+
"@earendil-works/pi-coding-agent": ">=1.1.0",
|
|
49
50
|
"@earendil-works/pi-tui": "*",
|
|
50
51
|
"typebox": "*"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-agent-core": "
|
|
54
|
-
"@earendil-works/pi-ai": "
|
|
55
|
-
"@earendil-works/pi-coding-agent": "
|
|
56
|
-
"@earendil-works/pi-tui": "
|
|
54
|
+
"@earendil-works/pi-agent-core": "1.2.0",
|
|
55
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
57
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
57
58
|
"@types/node": "^26.6.3",
|
|
59
|
+
"@types/proper-lockfile": "4.1.4",
|
|
58
60
|
"jiti": "^2.7.0",
|
|
59
61
|
"typebox": "latest",
|
|
60
62
|
"typescript": "^7.0.2"
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"proper-lockfile": "4.1.2"
|
|
61
66
|
}
|
|
62
67
|
}
|