@renjfk/opencode-model-fallback 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/index.js +7 -0
- package/lib/errors.js +36 -0
- package/lib/models.js +10 -0
- package/lib/options.js +25 -0
- package/lib/router.js +254 -0
- package/lib/session.js +25 -0
- package/lib/store.js +51 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Soner Koksal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
[](https://github.com/renjfk/opencode-model-fallback/actions/workflows/ci.yml)
|
|
2
|
+
[](LICENSE)
|
|
3
|
+
[](https://www.npmjs.com/package/@renjfk/opencode-model-fallback)
|
|
4
|
+
[](https://www.npmjs.com/package/@renjfk/opencode-model-fallback)
|
|
5
|
+
|
|
6
|
+
# opencode-model-fallback
|
|
7
|
+
|
|
8
|
+
Mapped model fallback router for [OpenCode](https://opencode.ai/).
|
|
9
|
+
|
|
10
|
+
There are situations where you may want to use the quota that comes with a
|
|
11
|
+
subscription first, then fall back to an API pay-as-you-go model only when that
|
|
12
|
+
subscription-backed model is rate-limited or usage-limited. You can solve that
|
|
13
|
+
with a local proxy, but maintaining a proxy server is often not worth it if all
|
|
14
|
+
you need is a simple one-to-one fallback inside OpenCode. This plugin handles
|
|
15
|
+
that routing directly in OpenCode.
|
|
16
|
+
|
|
17
|
+
When a configured model hits a retryable provider failure, this plugin aborts
|
|
18
|
+
the in-flight request, replays the latest user message on the mapped fallback
|
|
19
|
+
model, persists a global cooldown for the failed model, and routes back to the
|
|
20
|
+
original model after the cooldown expires.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Add to your OpenCode config at `~/.config/opencode/config.json`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"plugin": ["@renjfk/opencode-model-fallback"]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
If you want to set plugin options, use the tuple form:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"plugin": [
|
|
39
|
+
[
|
|
40
|
+
"@renjfk/opencode-model-fallback",
|
|
41
|
+
{
|
|
42
|
+
"mappings": {
|
|
43
|
+
"openai/gpt-5.4": "azure-ai-foundry/gpt-5.4",
|
|
44
|
+
"openai/gpt-5.5": "azure-ai-foundry/gpt-5.5"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Options
|
|
53
|
+
|
|
54
|
+
- `mappings`: map original model IDs to fallback model IDs.
|
|
55
|
+
- `retry_on_errors`: retryable HTTP status codes. Defaults to `429`.
|
|
56
|
+
- `retryable_error_patterns`: retryable error message patterns. Defaults to `["rate.?limit"]`.
|
|
57
|
+
- `cooldown_seconds`: how long a failed original model remains on fallback. Defaults to `3600`.
|
|
58
|
+
- `timeout_seconds`: abort and retry if a response is inactive for this long. Defaults to `30`.
|
|
59
|
+
- `notify_on_fallback`: show fallback/recovery toasts. Defaults to `true`.
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
63
|
+
The plugin watches OpenCode chat and session events. When a request uses a model
|
|
64
|
+
listed in `mappings`, that model is preferred unless it has an active global
|
|
65
|
+
cooldown. If OpenCode reports a retryable provider failure, the plugin switches
|
|
66
|
+
to the mapped fallback model and stores a global cooldown for the failed model.
|
|
67
|
+
|
|
68
|
+
Global model cooldowns are persisted at:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
~/.local/share/opencode/mapped-fallback-router.json
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Persisted cooldowns let all sessions avoid immediately retrying a model that has
|
|
75
|
+
just failed. When the cooldown expires, mapped requests are routed back to the
|
|
76
|
+
original model.
|
|
77
|
+
|
|
78
|
+
The plugin does not load balance, race models, or retry through a chain. Each
|
|
79
|
+
mapping is one original model to one fallback model.
|
|
80
|
+
|
|
81
|
+
## Scenarios
|
|
82
|
+
|
|
83
|
+
### Normal request
|
|
84
|
+
|
|
85
|
+
If you send a message with `openai/gpt-5.5` and the model has a mapping, the
|
|
86
|
+
request goes to `openai/gpt-5.5` normally unless it has an active cooldown.
|
|
87
|
+
If you select the mapped fallback model directly, the plugin still routes back
|
|
88
|
+
to the original model unless the original has an active cooldown.
|
|
89
|
+
|
|
90
|
+
### Retryable failure while streaming
|
|
91
|
+
|
|
92
|
+
If OpenCode reports a retryable provider failure such as a rate limit or a
|
|
93
|
+
configured retryable status code, the plugin aborts the current request and
|
|
94
|
+
replays the latest user message on the mapped fallback model.
|
|
95
|
+
|
|
96
|
+
For example:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"mappings": {
|
|
101
|
+
"openai/gpt-5.5": "azure-ai-foundry/gpt-5.5"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If `openai/gpt-5.5` fails with a retryable error, the session continues on
|
|
107
|
+
`azure-ai-foundry/gpt-5.5`.
|
|
108
|
+
|
|
109
|
+
### Active cooldown
|
|
110
|
+
|
|
111
|
+
After fallback is triggered, the original model is considered cooling down for
|
|
112
|
+
`cooldown_seconds`. During that cooldown, mapped requests use the fallback model
|
|
113
|
+
instead of switching back and immediately hitting the same provider
|
|
114
|
+
failure again.
|
|
115
|
+
|
|
116
|
+
All sessions are routed straight to the fallback while the original model is
|
|
117
|
+
cooling down.
|
|
118
|
+
|
|
119
|
+
### Recovery
|
|
120
|
+
|
|
121
|
+
When the cooldown expires, mapped requests switch back to the original model.
|
|
122
|
+
|
|
123
|
+
### Exhausted fallback
|
|
124
|
+
|
|
125
|
+
Mappings are one-to-one. If the fallback model also hits a retryable failure,
|
|
126
|
+
there is no next fallback to try. The plugin shows a fallback exhausted toast
|
|
127
|
+
when notifications are enabled.
|
|
128
|
+
|
|
129
|
+
## Troubleshooting Retry Matching
|
|
130
|
+
|
|
131
|
+
Use OpenCode's provider logs to find the exact status code, headers, and error
|
|
132
|
+
body returned by a provider. This is the most reliable way to tune
|
|
133
|
+
`retry_on_errors` and `retryable_error_patterns`.
|
|
134
|
+
|
|
135
|
+
For a short headless reproduction, capture logs and stop the run after a few
|
|
136
|
+
seconds to avoid long retry loops:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
log="/tmp/opencode-provider.log"
|
|
140
|
+
: > "$log"
|
|
141
|
+
opencode run --print-logs --log-level DEBUG --model openai/gpt-5.3-codex --format json "Reply with OK only." 2> "$log" &
|
|
142
|
+
pid=$!
|
|
143
|
+
sleep 3
|
|
144
|
+
kill "$pid" 2>/dev/null || true
|
|
145
|
+
wait "$pid" 2>/dev/null || true
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Then inspect the captured provider errors:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
rg 'service=llm|AI_APICallError|statusCode|responseBody|x-codex|reset|usage_limit|rate.?limit' /tmp/opencode-provider.log
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Look for an OpenCode log line like:
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
ERROR ... service=llm providerID=openai modelID=gpt-5.3-codex ... error={...}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Inside `error`, check fields such as `statusCode`, `responseHeaders`,
|
|
161
|
+
`responseBody`, `isRetryable`, and `data.error.message`. For example, OpenAI
|
|
162
|
+
usage limits can appear as `statusCode: 429` with a response body containing
|
|
163
|
+
`usage_limit_reached` and `The usage limit has been reached`. OpenAI Codex
|
|
164
|
+
responses can also include reset headers such as `x-codex-primary-reset-at`,
|
|
165
|
+
`x-codex-primary-reset-after-seconds`, `x-codex-secondary-reset-at`, and
|
|
166
|
+
`x-codex-secondary-reset-after-seconds`.
|
|
167
|
+
|
|
168
|
+
For TUI sessions, start OpenCode the same way and reproduce manually:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
opencode --print-logs --log-level DEBUG 2> /tmp/opencode-provider.log
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Use the provider `statusCode` and response body text to tune the retry rules:
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"plugin": [
|
|
179
|
+
[
|
|
180
|
+
"@renjfk/opencode-model-fallback",
|
|
181
|
+
{
|
|
182
|
+
"retry_on_errors": [429, 403],
|
|
183
|
+
"retryable_error_patterns": ["rate.?limit", "usage.?limit"],
|
|
184
|
+
"mappings": {
|
|
185
|
+
"openai/gpt-5.5": "azure-ai-foundry/gpt-5.5"
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
If the status code is not in `retry_on_errors`, add it. If the response body has
|
|
194
|
+
stable text or an error type, add a small regex matching it to
|
|
195
|
+
`retryable_error_patterns`. If there is no `service=llm` error line, OpenCode did
|
|
196
|
+
not reach the provider or the run was stopped before the provider returned.
|
|
197
|
+
|
|
198
|
+
## Contributing
|
|
199
|
+
|
|
200
|
+
opencode-model-fallback is open to contributions and ideas!
|
|
201
|
+
|
|
202
|
+
### Issue conventions
|
|
203
|
+
|
|
204
|
+
**Format:** `type: brief description`
|
|
205
|
+
|
|
206
|
+
- `feat:` new features or functionality
|
|
207
|
+
- `fix:` bug fixes
|
|
208
|
+
- `enhance:` improvements to existing features
|
|
209
|
+
- `chore:` maintenance tasks, dependencies, cleanup
|
|
210
|
+
- `docs:` documentation updates
|
|
211
|
+
- `build:` build system, CI/CD changes
|
|
212
|
+
|
|
213
|
+
### Development
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
npm run test # node test suite
|
|
217
|
+
npm run check # test + lint + fmt
|
|
218
|
+
npm run lint # oxlint
|
|
219
|
+
npm run fmt # oxfmt --check
|
|
220
|
+
npm run fmt:fix # oxfmt --write
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Test local plugin in OpenCode
|
|
224
|
+
|
|
225
|
+
To test unpublished changes in the OpenCode TUI, point `~/.config/opencode/config.json`
|
|
226
|
+
at the local repo path, not the npm package name:
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{
|
|
230
|
+
"plugin": ["/Users/your-user/opencode-model-fallback"]
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Release process
|
|
235
|
+
|
|
236
|
+
Manual releases via opencode; see [RELEASE_PROCESS.md](RELEASE_PROCESS.md).
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
This project is licensed under the [MIT License](LICENSE).
|
package/index.js
ADDED
package/lib/errors.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function isRetryable(error, options) {
|
|
2
|
+
const status = extractStatus(error);
|
|
3
|
+
if (status && options.retry_on_errors.includes(status)) return true;
|
|
4
|
+
const text = errorText(error).toLowerCase();
|
|
5
|
+
return options.retryable_error_patterns.some((pattern) => {
|
|
6
|
+
try {
|
|
7
|
+
return new RegExp(pattern, "i").test(text);
|
|
8
|
+
} catch {
|
|
9
|
+
return text.includes(pattern.toLowerCase());
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function extractStatus(error) {
|
|
15
|
+
if (!error || typeof error !== "object") return undefined;
|
|
16
|
+
const value = error.statusCode ?? error.status ?? error.code;
|
|
17
|
+
if (typeof value === "number") return value;
|
|
18
|
+
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function extractErrorName(error) {
|
|
23
|
+
if (!error || typeof error !== "object") return undefined;
|
|
24
|
+
return typeof error.name === "string" ? error.name : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function errorText(error) {
|
|
28
|
+
if (!error) return "";
|
|
29
|
+
if (typeof error === "string") return error;
|
|
30
|
+
if (error instanceof Error) return `${error.name} ${error.message}`;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.stringify(error);
|
|
33
|
+
} catch {
|
|
34
|
+
return String(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function modelObject(model) {
|
|
2
|
+
const [providerID, ...modelParts] = model.split("/");
|
|
3
|
+
if (!providerID || modelParts.length === 0) return undefined;
|
|
4
|
+
return { providerID, modelID: modelParts.join("/") };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function modelString(model) {
|
|
8
|
+
if (!model?.providerID || !model?.modelID) return undefined;
|
|
9
|
+
return `${model.providerID}/${model.modelID}`;
|
|
10
|
+
}
|
package/lib/options.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const DEFAULT_OPTIONS = {
|
|
2
|
+
mappings: {},
|
|
3
|
+
retry_on_errors: [429],
|
|
4
|
+
retryable_error_patterns: ["rate.?limit"],
|
|
5
|
+
cooldown_seconds: 3600,
|
|
6
|
+
timeout_seconds: 30,
|
|
7
|
+
notify_on_fallback: true,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function normalizeOptions(rawOptions) {
|
|
11
|
+
return {
|
|
12
|
+
...DEFAULT_OPTIONS,
|
|
13
|
+
...rawOptions,
|
|
14
|
+
mappings: normalizeMappings(rawOptions?.mappings ?? {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeMappings(mappings) {
|
|
19
|
+
const normalized = {};
|
|
20
|
+
for (const [from, to] of Object.entries(mappings)) {
|
|
21
|
+
if (!from || typeof to !== "string" || !to.includes("/")) continue;
|
|
22
|
+
normalized[from] = to;
|
|
23
|
+
}
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
package/lib/router.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { isRetryable, extractErrorName } from "./errors.js";
|
|
2
|
+
import { modelObject, modelString } from "./models.js";
|
|
3
|
+
import { normalizeOptions } from "./options.js";
|
|
4
|
+
import { abortSession, getReplayParts } from "./session.js";
|
|
5
|
+
import { createStateStore } from "./store.js";
|
|
6
|
+
|
|
7
|
+
const POST_ABORT_DELAY_MS = 150;
|
|
8
|
+
|
|
9
|
+
export function createMappedFallbackRouter(ctx, rawOptions) {
|
|
10
|
+
const options = normalizeOptions(rawOptions);
|
|
11
|
+
const fallbackToOriginal = Object.fromEntries(
|
|
12
|
+
Object.entries(options.mappings).map(([original, fallback]) => [fallback, original]),
|
|
13
|
+
);
|
|
14
|
+
const store = createStateStore();
|
|
15
|
+
const retrying = new Set();
|
|
16
|
+
const timers = new Map();
|
|
17
|
+
const selfAbortAt = new Map();
|
|
18
|
+
const activeOriginals = new Map();
|
|
19
|
+
let agentConfigs;
|
|
20
|
+
|
|
21
|
+
function hasMapping(model) {
|
|
22
|
+
return !!model && !!options.mappings[model];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mappedOriginal(model) {
|
|
26
|
+
if (hasMapping(model)) return model;
|
|
27
|
+
return fallbackToOriginal[model];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function modelFromAgent(agent) {
|
|
31
|
+
const agentConfig = agent && agentConfigs?.[agent];
|
|
32
|
+
return typeof agentConfig === "object" && agentConfig ? agentConfig.model : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function selectedModel(requested) {
|
|
36
|
+
const original = mappedOriginal(requested);
|
|
37
|
+
if (!original) return requested;
|
|
38
|
+
const fallback = options.mappings[original];
|
|
39
|
+
if (!fallback) return requested;
|
|
40
|
+
const cooldown = store.getModelCooldown(original);
|
|
41
|
+
return cooldown ? fallback : original;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function timedOriginal(requested) {
|
|
45
|
+
if (!hasMapping(requested)) return undefined;
|
|
46
|
+
const cooldown = store.getModelCooldown(requested);
|
|
47
|
+
if (cooldown) return undefined;
|
|
48
|
+
return requested;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function shouldRoute(requested) {
|
|
52
|
+
return hasMapping(requested) || !!fallbackToOriginal[requested];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveErrorModels(model, agent) {
|
|
56
|
+
const failed = model ?? modelFromAgent(agent);
|
|
57
|
+
const original = mappedOriginal(failed);
|
|
58
|
+
return { failed, original };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function shouldFallbackFromError(failed, original) {
|
|
62
|
+
if (!original) return false;
|
|
63
|
+
if (failed !== original) return false;
|
|
64
|
+
const cooldown = store.getModelCooldown(original);
|
|
65
|
+
return !cooldown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clearTimer(sessionID) {
|
|
69
|
+
const timer = timers.get(sessionID);
|
|
70
|
+
if (timer) clearTimeout(timer);
|
|
71
|
+
timers.delete(sessionID);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scheduleTimeout(sessionID, original, agent) {
|
|
75
|
+
clearTimer(sessionID);
|
|
76
|
+
if (options.timeout_seconds <= 0 || !hasMapping(original)) return;
|
|
77
|
+
timers.set(
|
|
78
|
+
sessionID,
|
|
79
|
+
setTimeout(async () => {
|
|
80
|
+
timers.delete(sessionID);
|
|
81
|
+
if (retrying.has(sessionID) || !timedOriginal(original)) return;
|
|
82
|
+
retrying.add(sessionID);
|
|
83
|
+
try {
|
|
84
|
+
await abortCurrentSession(sessionID);
|
|
85
|
+
await retryWithFallback(sessionID, original, agent, "timeout");
|
|
86
|
+
} finally {
|
|
87
|
+
retrying.delete(sessionID);
|
|
88
|
+
}
|
|
89
|
+
}, options.timeout_seconds * 1000),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function abortCurrentSession(sessionID) {
|
|
94
|
+
const aborted = await abortSession(ctx.client, sessionID);
|
|
95
|
+
if (aborted) selfAbortAt.set(sessionID, Date.now());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function retryWithFallback(sessionID, original, agent, reason) {
|
|
99
|
+
const fallback = options.mappings[original];
|
|
100
|
+
const target = fallback ? modelObject(fallback) : undefined;
|
|
101
|
+
if (!target) return;
|
|
102
|
+
const failedAt = Date.now();
|
|
103
|
+
const cooldownUntil = failedAt + options.cooldown_seconds * 1000;
|
|
104
|
+
store.setModelCooldown(original, reason, failedAt, cooldownUntil);
|
|
105
|
+
const parts = await getReplayParts(ctx.client, ctx.directory, sessionID);
|
|
106
|
+
if (parts.length === 0) return;
|
|
107
|
+
clearTimer(sessionID);
|
|
108
|
+
try {
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, POST_ABORT_DELAY_MS));
|
|
110
|
+
await ctx.client.session.promptAsync({
|
|
111
|
+
path: { id: sessionID },
|
|
112
|
+
body: { ...(agent ? { agent } : {}), model: target, parts },
|
|
113
|
+
query: { directory: ctx.directory },
|
|
114
|
+
});
|
|
115
|
+
await toast("Model Fallback", `${original} -> ${fallback} (${reason})`, "warning");
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function toast(title, message, variant) {
|
|
120
|
+
if (!options.notify_on_fallback) return;
|
|
121
|
+
await ctx.client.tui
|
|
122
|
+
.showToast({ body: { title, message, variant, duration: 5000 } })
|
|
123
|
+
.catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function handleError(sessionID, error, model, agent, source) {
|
|
127
|
+
if (!sessionID || retrying.has(sessionID)) return;
|
|
128
|
+
const name = extractErrorName(error);
|
|
129
|
+
const selfAbort = selfAbortAt.get(sessionID);
|
|
130
|
+
if (name === "MessageAbortedError" && selfAbort && Date.now() - selfAbort < 2000) return;
|
|
131
|
+
const { failed, original } = resolveErrorModels(model, agent);
|
|
132
|
+
if (!original) return;
|
|
133
|
+
const retryable = isRetryable(error, options);
|
|
134
|
+
if (!retryable) return;
|
|
135
|
+
if (failed !== original) {
|
|
136
|
+
await toast("Model Fallback Exhausted", `No mapped fallback left for ${original}`, "error");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!shouldFallbackFromError(failed, original)) return;
|
|
140
|
+
retrying.add(sessionID);
|
|
141
|
+
clearTimer(sessionID);
|
|
142
|
+
try {
|
|
143
|
+
await retryWithFallback(sessionID, original, agent, source);
|
|
144
|
+
} finally {
|
|
145
|
+
retrying.delete(sessionID);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function handleProviderRetryStatus(props) {
|
|
150
|
+
const sessionID = props?.sessionID;
|
|
151
|
+
const status = props?.status;
|
|
152
|
+
if (!sessionID || !status || retrying.has(sessionID)) return;
|
|
153
|
+
const type = String(status.type ?? "").toLowerCase();
|
|
154
|
+
if (type !== "retry") return;
|
|
155
|
+
const agent = props?.agent;
|
|
156
|
+
const model =
|
|
157
|
+
props?.model ??
|
|
158
|
+
(typeof props?.providerID === "string" && typeof props?.modelID === "string"
|
|
159
|
+
? `${props.providerID}/${props.modelID}`
|
|
160
|
+
: (activeOriginals.get(sessionID) ?? modelFromAgent(agent)));
|
|
161
|
+
const { failed, original } = resolveErrorModels(model, agent);
|
|
162
|
+
if (!original) return;
|
|
163
|
+
if (failed !== original) {
|
|
164
|
+
await toast("Model Fallback Exhausted", `No mapped fallback left for ${original}`, "error");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!shouldFallbackFromError(failed, original)) return;
|
|
168
|
+
retrying.add(sessionID);
|
|
169
|
+
clearTimer(sessionID);
|
|
170
|
+
try {
|
|
171
|
+
await abortCurrentSession(sessionID);
|
|
172
|
+
await retryWithFallback(sessionID, original, agent, "session.status");
|
|
173
|
+
} finally {
|
|
174
|
+
retrying.delete(sessionID);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
name: "mapped-fallback-router",
|
|
180
|
+
|
|
181
|
+
config: (config) => {
|
|
182
|
+
const agentValue = config.agent;
|
|
183
|
+
agentConfigs =
|
|
184
|
+
agentValue && typeof agentValue === "object" && !Array.isArray(agentValue)
|
|
185
|
+
? agentValue
|
|
186
|
+
: undefined;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
"chat.message": async (input, output) => {
|
|
190
|
+
const sessionID = input.sessionID;
|
|
191
|
+
const requested = modelString(input.model) ?? modelFromAgent(input.agent);
|
|
192
|
+
if (!sessionID || !requested) return;
|
|
193
|
+
if (!shouldRoute(requested)) return;
|
|
194
|
+
const target = selectedModel(requested);
|
|
195
|
+
if (!target) return;
|
|
196
|
+
const original = mappedOriginal(requested);
|
|
197
|
+
if (original) activeOriginals.set(sessionID, original);
|
|
198
|
+
const model = modelObject(target);
|
|
199
|
+
if (model && output.message) output.message.model = model;
|
|
200
|
+
if (requested !== target) {
|
|
201
|
+
const isFallback = hasMapping(requested);
|
|
202
|
+
await toast(
|
|
203
|
+
isFallback ? "Model Fallback" : "Model Recovered",
|
|
204
|
+
`Using ${target} instead of ${requested}`,
|
|
205
|
+
isFallback ? "warning" : "info",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (hasMapping(target)) scheduleTimeout(sessionID, target, input.agent);
|
|
209
|
+
else clearTimer(sessionID);
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
event: async ({ event }) => {
|
|
213
|
+
const props = event.properties;
|
|
214
|
+
if (event.type === "session.deleted") {
|
|
215
|
+
const id = props?.info?.id;
|
|
216
|
+
if (id) {
|
|
217
|
+
retrying.delete(id);
|
|
218
|
+
activeOriginals.delete(id);
|
|
219
|
+
clearTimer(id);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (event.type === "session.status") {
|
|
224
|
+
await handleProviderRetryStatus(props);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (event.type === "session.error") {
|
|
228
|
+
await handleError(
|
|
229
|
+
props?.sessionID,
|
|
230
|
+
props?.error,
|
|
231
|
+
props?.model,
|
|
232
|
+
props?.agent,
|
|
233
|
+
"session.error",
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (event.type === "message.updated") {
|
|
238
|
+
const info = props?.info;
|
|
239
|
+
if (info?.role !== "assistant") return;
|
|
240
|
+
const sessionID = info?.sessionID;
|
|
241
|
+
if (!info?.error) {
|
|
242
|
+
if (sessionID) clearTimer(sessionID);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const model =
|
|
246
|
+
info?.model ??
|
|
247
|
+
(typeof info?.providerID === "string" && typeof info?.modelID === "string"
|
|
248
|
+
? `${info.providerID}/${info.modelID}`
|
|
249
|
+
: undefined);
|
|
250
|
+
await handleError(sessionID, info.error, model, info?.agent, "message.updated");
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function abortSession(client, sessionID) {
|
|
2
|
+
try {
|
|
3
|
+
await client.session.abort({ path: { id: sessionID } });
|
|
4
|
+
return true;
|
|
5
|
+
} catch {
|
|
6
|
+
// Best effort. The session may already be idle after an error.
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function getReplayParts(client, directory, sessionID) {
|
|
12
|
+
const response = await client.session.messages({
|
|
13
|
+
path: { id: sessionID },
|
|
14
|
+
query: { directory },
|
|
15
|
+
});
|
|
16
|
+
const messages = response.data ?? [];
|
|
17
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
18
|
+
const message = messages[index];
|
|
19
|
+
const role = String(message.info?.role ?? "").toLowerCase();
|
|
20
|
+
const parts = message.parts ?? message.info?.parts ?? [];
|
|
21
|
+
if (role !== "user" || parts.length === 0) continue;
|
|
22
|
+
return parts.filter((part) => typeof part.type === "string" && part.type !== "compaction");
|
|
23
|
+
}
|
|
24
|
+
return [];
|
|
25
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const STORE_PATH = join(
|
|
5
|
+
process.env.XDG_DATA_HOME ?? join(process.env.HOME ?? "", ".local", "share"),
|
|
6
|
+
"opencode",
|
|
7
|
+
"mapped-fallback-router.json",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export function createStateStore(storePath = STORE_PATH) {
|
|
11
|
+
function read() {
|
|
12
|
+
try {
|
|
13
|
+
if (!existsSync(storePath)) return {};
|
|
14
|
+
const parsed = JSON.parse(readFileSync(storePath, "utf8"));
|
|
15
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
16
|
+
return parsed;
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function write(store) {
|
|
23
|
+
try {
|
|
24
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
25
|
+
const tempPath = `${storePath}.${process.pid}.tmp`;
|
|
26
|
+
writeFileSync(tempPath, `${JSON.stringify(store, null, 2)}\n`);
|
|
27
|
+
renameSync(tempPath, storePath);
|
|
28
|
+
} catch {
|
|
29
|
+
// Persisted cooldown state is best effort. In-memory fallback still works.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
getModelCooldown(model) {
|
|
35
|
+
if (!model) return undefined;
|
|
36
|
+
const store = read();
|
|
37
|
+
const record = store[model];
|
|
38
|
+
if (!record) return undefined;
|
|
39
|
+
if (Date.now() < record.cooldownUntil) return record;
|
|
40
|
+
delete store[model];
|
|
41
|
+
write(store);
|
|
42
|
+
return undefined;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
setModelCooldown(model, reason, failedAt, cooldownUntil) {
|
|
46
|
+
const store = read();
|
|
47
|
+
store[model] = { failedAt, cooldownUntil, reason };
|
|
48
|
+
write(store);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@renjfk/opencode-model-fallback",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mapped model fallback router for OpenCode. Routes retryable model failures to configured fallback models and recovers after cooldown.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"fallback",
|
|
7
|
+
"model",
|
|
8
|
+
"opencode",
|
|
9
|
+
"plugin",
|
|
10
|
+
"router"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/renjfk/opencode-model-fallback.git"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"index.js",
|
|
19
|
+
"lib"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "index.js",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": "./index.js"
|
|
26
|
+
},
|
|
27
|
+
"./tui": {
|
|
28
|
+
"import": "./index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"lint": "npx oxlint .",
|
|
34
|
+
"fmt": "npx oxfmt --check .",
|
|
35
|
+
"fmt:fix": "npx oxfmt --write .",
|
|
36
|
+
"check": "npm run test && npm run lint && npm run fmt",
|
|
37
|
+
"prepack": "npm run check"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"oxfmt": "0.50.0",
|
|
41
|
+
"oxlint": "1.65.0",
|
|
42
|
+
"vitest": "latest"
|
|
43
|
+
}
|
|
44
|
+
}
|