opencode-model-failover 0.1.0-beta.2 → 0.1.0-beta.22
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/README.md +66 -23
- package/index.js +79 -64
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ v2-native.
|
|
|
22
22
|
a prompt is a state-changing action.
|
|
23
23
|
- **Cooldown.** A model whose switch fails is skipped for `cooldownMs`, so a
|
|
24
24
|
broken target is not retried on every failure.
|
|
25
|
+
- **Forward progress.** Each failure advances along your chain instead of
|
|
26
|
+
re-selecting the entry that just failed, so later providers are reachable.
|
|
25
27
|
|
|
26
28
|
## Requirements
|
|
27
29
|
|
|
@@ -88,40 +90,67 @@ plugin entry in your OpenCode config:
|
|
|
88
90
|
}
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
| Option | Type | Default | Description
|
|
92
|
-
| ---------------- | ---------- | ------- |
|
|
93
|
-
| `fallbackModels` | `string[]` | `[]` | Ordered `provider/model` chain, tried in order.
|
|
94
|
-
| `maxAttempts` | `number` | `2` | Failovers per
|
|
95
|
-
| `cooldownMs` | `number` | `60000` | Skip a model this long after a failed switch.
|
|
96
|
-
| `autoRetry` | `boolean` | `false` | Replay the last user message after switching.
|
|
97
|
-
| `notify` | `boolean` | `true` | Log each failover to stderr.
|
|
98
|
-
| `logFile` | `string` | `""` | Also append log lines to this file.
|
|
93
|
+
| Option | Type | Default | Description |
|
|
94
|
+
| ---------------- | ---------- | ------- | ------------------------------------------------- |
|
|
95
|
+
| `fallbackModels` | `string[]` | `[]` | Ordered `provider/model` chain, tried in order. |
|
|
96
|
+
| `maxAttempts` | `number` | `2` | Failovers per failure sequence before giving up. |
|
|
97
|
+
| `cooldownMs` | `number` | `60000` | Skip a model this long after a failed switch. |
|
|
98
|
+
| `autoRetry` | `boolean` | `false` | Replay the last user message after switching. |
|
|
99
|
+
| `notify` | `boolean` | `true` | Log each failover to stderr. |
|
|
100
|
+
| `logFile` | `string` | `""` | Also append log lines to this file. |
|
|
99
101
|
|
|
100
102
|
A `provider/model` string is split on the first `/`. Entries without a `/` are
|
|
101
103
|
dropped from the chain.
|
|
102
104
|
|
|
103
105
|
## How it works
|
|
104
106
|
|
|
105
|
-
The plugin subscribes to the v2 event stream
|
|
107
|
+
The plugin subscribes to the v2 event stream:
|
|
106
108
|
|
|
107
109
|
```js
|
|
108
110
|
ctx.event.subscribe({ signal })
|
|
109
|
-
// session.execution.failed
|
|
110
|
-
//
|
|
111
|
+
// session.execution.failed -> { data: { sessionID, error: { type, message } } }
|
|
112
|
+
// session.execution.succeeded -> { data: { sessionID } }
|
|
113
|
+
// session.execution.interrupted -> { data: { sessionID } }
|
|
114
|
+
// session.model.selected -> { data: { sessionID, model: { providerID, id } } }
|
|
115
|
+
// session.step.started -> { data: { sessionID, model: { providerID, id } } }
|
|
116
|
+
// session.deleted -> { data: { sessionID } }
|
|
111
117
|
```
|
|
112
118
|
|
|
113
|
-
On a retryable
|
|
119
|
+
On a retryable `session.execution.failed` it:
|
|
114
120
|
|
|
115
121
|
1. reads the models that actually resolve with `ctx.model.list()`;
|
|
116
|
-
2. picks the first chain entry that resolves
|
|
122
|
+
2. picks the first chain entry that resolves, is not cooling down, is not the
|
|
123
|
+
model the session is already on, and has not been tried in this failure
|
|
124
|
+
sequence;
|
|
117
125
|
3. moves the session with `ctx.session.switchModel({ sessionID, model })`.
|
|
118
126
|
|
|
119
127
|
If `autoRetry` is on, it then reads the transcript with
|
|
120
128
|
`ctx.session.context({ sessionID })` and replays the last user message with
|
|
121
129
|
`ctx.session.prompt({ sessionID, text })`.
|
|
122
130
|
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
### Progression and reset
|
|
132
|
+
|
|
133
|
+
`maxAttempts` counts failovers within one failure sequence, not per session
|
|
134
|
+
lifetime, and each completed switch is remembered so the next failure moves to
|
|
135
|
+
the entry after it. That is what lets a session reach a later provider: without
|
|
136
|
+
it every failure re-selected the first available entry.
|
|
137
|
+
|
|
138
|
+
- A failed `switchModel` is a provider problem, not a failed inference: the
|
|
139
|
+
model is cooled down for `cooldownMs`, but no attempt is used and the chain
|
|
140
|
+
position does not move.
|
|
141
|
+
- A failed *inference* does not cool the model down. It advances this session's
|
|
142
|
+
position instead, so a sibling session can still land on that model: the
|
|
143
|
+
failure is the model's problem for this request, and a process-wide cooldown
|
|
144
|
+
would take a working model away from everyone.
|
|
145
|
+
- `session.execution.succeeded` and `session.execution.interrupted` end the
|
|
146
|
+
sequence, so the next failure starts at the top of the chain again.
|
|
147
|
+
- `session.model.selected` and `session.step.started` record the model the
|
|
148
|
+
session is on, so a failure never selects that model again in the same
|
|
149
|
+
sequence.
|
|
150
|
+
- `session.deleted` drops the session's state.
|
|
151
|
+
|
|
152
|
+
The `id`/`setup` plugin shape, the `plugins` config key, and the methods above
|
|
153
|
+
are the whole v2 surface it uses.
|
|
125
154
|
|
|
126
155
|
## Observing it
|
|
127
156
|
|
|
@@ -157,6 +186,16 @@ Every push to a PR publishes an installable beta to the npm `beta` dist-tag:
|
|
|
157
186
|
opencode plugin add opencode-model-failover@beta
|
|
158
187
|
```
|
|
159
188
|
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npm install
|
|
193
|
+
npm run lint # biome check + markdownlint-cli2
|
|
194
|
+
npm run lint:fix # apply safe fixes
|
|
195
|
+
npm test # node:test
|
|
196
|
+
npm run check # lint + test
|
|
197
|
+
```
|
|
198
|
+
|
|
160
199
|
## Releases (maintainers)
|
|
161
200
|
|
|
162
201
|
All publishing happens in GitHub Actions via npm **trusted publishing** (OIDC
|
|
@@ -167,9 +206,11 @@ publisher per package; the workflow filename must stay `ci.yml`):
|
|
|
167
206
|
|
|
168
207
|
- `test` — lint + plugin tests on `main` and PRs (Node 24).
|
|
169
208
|
- push to `main` → `publish` job publishes the next patch to `latest`, pushes a
|
|
170
|
-
`vX.Y.Z` tag, and opens a GitHub release. The version is
|
|
171
|
-
|
|
172
|
-
minor/major bump in
|
|
209
|
+
`vX.Y.Z` tag, and opens a GitHub release. The version is the highest of the
|
|
210
|
+
newest stable `vX.Y.Z` tag and the registry's `latest`, plus a patch, so
|
|
211
|
+
repeated merges never collide; an intentional minor/major bump in
|
|
212
|
+
`package.json` is honored. A publish that hits `E409` bumps a patch and
|
|
213
|
+
retries (bounded), because the registry read can lag a fresh publish.
|
|
173
214
|
- tag `v*-beta*` → `publish` job publishes `beta` plus a prerelease GitHub
|
|
174
215
|
release.
|
|
175
216
|
- every PR push → `prerelease` job publishes `<version>-beta.<run_number>` to
|
|
@@ -177,11 +218,13 @@ publisher per package; the workflow filename must stay `ci.yml`):
|
|
|
177
218
|
- manual dispatch → next `-beta.X` staging publish from `main`, no git change.
|
|
178
219
|
|
|
179
220
|
One-time bootstrap: npm cannot attach a trusted publisher to a package that does
|
|
180
|
-
not exist yet, so `opencode-model-failover` must be published once by hand
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
221
|
+
not exist yet, so `opencode-model-failover` must be published once by hand before
|
|
222
|
+
the workflow can publish. Then configure the trusted publisher on npmjs.com:
|
|
223
|
+
repository `nabheet/opencode-model-failover`, workflow filename `ci.yml`, and
|
|
224
|
+
tick **Allow `npm publish`**. A trusted publisher created after Sep 03 2026 is
|
|
225
|
+
stage-only by default, and stage-only sends CI publishes to the staging area to
|
|
226
|
+
wait for a 2FA approval instead of going live. A new config must also complete
|
|
227
|
+
its first successful publish within 2 days or it expires.
|
|
185
228
|
|
|
186
229
|
## License
|
|
187
230
|
|
package/index.js
CHANGED
|
@@ -1,42 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Native model failover built entirely on the OpenCode v2 plugin API:
|
|
4
|
-
//
|
|
5
|
-
// ctx.event.subscribe() -> session.execution.failed carries
|
|
6
|
-
// { data: { sessionID, error: { type, message } } }
|
|
7
|
-
// ctx.model.list() -> which provider/model pairs actually resolve
|
|
8
|
-
// ctx.session.switchModel() -> move the session onto a healthy model
|
|
9
|
-
// ctx.session.context() -> read the transcript (only for autoRetry)
|
|
10
|
-
// ctx.session.prompt() -> replay the last user message (only for autoRetry)
|
|
11
|
-
//
|
|
12
|
-
// Discovery: a plugin directory exposing index.js is loaded automatically from
|
|
13
|
-
// .opencode/plugins/. Installing the package is enough; a config entry is only
|
|
14
|
-
// needed to pass options:
|
|
15
|
-
//
|
|
16
|
-
// { "plugins": [
|
|
17
|
-
// { "package": "opencode-model-failover",
|
|
18
|
-
// "options": { "fallbackModels": ["provider/model"], "autoRetry": false } } ] }
|
|
19
|
-
//
|
|
20
|
-
// Options (all optional):
|
|
21
|
-
// fallbackModels string[] provider/model chain, tried in order (default: empty)
|
|
22
|
-
// maxAttempts number fallbacks per session (default 2)
|
|
23
|
-
// cooldownMs number skip a failed model this long, in ms (default 60000)
|
|
24
|
-
// autoRetry boolean replay the last user message (default false)
|
|
25
|
-
// notify boolean log each fallback to stderr (default true)
|
|
26
|
-
// logFile string append log lines to this file
|
|
27
|
-
//
|
|
28
|
-
// No chain ships by default: set fallbackModels to your own provider/model
|
|
29
|
-
// pairs. autoRetry is off by default, because replaying a prompt is a
|
|
30
|
-
// state-changing action and is deliberately opt-in.
|
|
1
|
+
// OpenCode v2 plugin. Options and behaviour: see README.md.
|
|
31
2
|
|
|
32
3
|
import { appendFileSync } from "node:fs";
|
|
33
4
|
|
|
34
|
-
// Ships empty
|
|
35
|
-
// author's provider setup is baked in.
|
|
5
|
+
// Ships empty: the chain is yours to define.
|
|
36
6
|
const DEFAULT_CHAIN = [];
|
|
37
7
|
|
|
38
|
-
//
|
|
39
|
-
// fail identically on every model, so retrying those just burns attempts.
|
|
8
|
+
// Only retryable failures are worth failing over on; a validation error fails on every model.
|
|
40
9
|
const RETRYABLE =
|
|
41
10
|
/no-route|provider\.|rate|quota|overload|unavailable|server|timeout|econn|socket|network|502|503|504|429/i;
|
|
42
11
|
|
|
@@ -58,11 +27,12 @@ export default {
|
|
|
58
27
|
const cooldownMs = Number.isFinite(opt.cooldownMs) ? opt.cooldownMs : 60_000;
|
|
59
28
|
const autoRetry = opt.autoRetry === true;
|
|
60
29
|
const notify = opt.notify !== false;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const logFile = typeof opt.logFile === "string" ? opt.logFile : process.env.MODEL_FALLBACK_LOG || "";
|
|
30
|
+
const logFile =
|
|
31
|
+
typeof opt.logFile === "string" ? opt.logFile : process.env.MODEL_FALLBACK_LOG || "";
|
|
64
32
|
|
|
65
|
-
const attempts = new Map(); // sessionID -> failovers used
|
|
33
|
+
const attempts = new Map(); // sessionID -> failovers used in this failure sequence
|
|
34
|
+
const attempted = new Map(); // sessionID -> Set of refs already tried in this sequence
|
|
35
|
+
const current = new Map(); // sessionID -> "provider/model" selected for the session
|
|
66
36
|
const busy = new Set(); // sessionID -> failover in flight
|
|
67
37
|
const cooldown = new Map(); // "provider/model" -> epoch ms it is usable again
|
|
68
38
|
|
|
@@ -72,9 +42,7 @@ export default {
|
|
|
72
42
|
if (logFile) {
|
|
73
43
|
try {
|
|
74
44
|
appendFileSync(logFile, `${line}\n`);
|
|
75
|
-
} catch {
|
|
76
|
-
/* best effort */
|
|
77
|
-
}
|
|
45
|
+
} catch {}
|
|
78
46
|
}
|
|
79
47
|
if (notify) console.error(line);
|
|
80
48
|
};
|
|
@@ -98,9 +66,9 @@ export default {
|
|
|
98
66
|
const rows = Array.isArray(messages) ? messages : [];
|
|
99
67
|
for (let i = rows.length - 1; i >= 0; i--) {
|
|
100
68
|
const row = rows[i] || {};
|
|
101
|
-
const role = row.role ||
|
|
69
|
+
const role = row.role || row.info?.role;
|
|
102
70
|
if (role !== "user") continue;
|
|
103
|
-
const parts = row.parts ||
|
|
71
|
+
const parts = row.parts || row.info?.parts || [];
|
|
104
72
|
const text = parts
|
|
105
73
|
.filter((p) => p && p.type === "text" && typeof p.text === "string")
|
|
106
74
|
.map((p) => p.text)
|
|
@@ -120,8 +88,22 @@ export default {
|
|
|
120
88
|
|
|
121
89
|
const set = await availableRefs();
|
|
122
90
|
const now = Date.now();
|
|
123
|
-
|
|
124
|
-
const
|
|
91
|
+
const tried = attempted.get(sessionID) || new Set();
|
|
92
|
+
const selected = current.get(sessionID);
|
|
93
|
+
// The model that just failed must not be picked again even if this
|
|
94
|
+
// sequence never switched to it (for example the first failure of a
|
|
95
|
+
// session that started on chain[0]).
|
|
96
|
+
if (selected) tried.add(selected);
|
|
97
|
+
log(
|
|
98
|
+
`availability=${set.size} models; already tried=${tried.size}; looking for next in chain`,
|
|
99
|
+
);
|
|
100
|
+
// Skip what the session is already on and what this failure sequence
|
|
101
|
+
// has already moved to, otherwise the first available entry is picked
|
|
102
|
+
// forever and later providers are never reached.
|
|
103
|
+
const next = chain.find(
|
|
104
|
+
(ref) =>
|
|
105
|
+
set.has(ref) && (cooldown.get(ref) || 0) <= now && !tried.has(ref) && ref !== selected,
|
|
106
|
+
);
|
|
125
107
|
if (!next) {
|
|
126
108
|
log("no available model left in chain");
|
|
127
109
|
return;
|
|
@@ -130,29 +112,38 @@ export default {
|
|
|
130
112
|
const ref = splitRef(next);
|
|
131
113
|
if (!ref) return;
|
|
132
114
|
|
|
133
|
-
attempts.set(sessionID, used + 1);
|
|
134
115
|
try {
|
|
135
116
|
await ctx.session.switchModel({
|
|
136
117
|
sessionID,
|
|
137
118
|
model: { providerID: ref.providerID, id: ref.modelID },
|
|
138
119
|
});
|
|
139
|
-
log(
|
|
140
|
-
`session ${sessionID} moved to ${next} (attempt ${used + 1}/${maxAttempts}) after ` +
|
|
141
|
-
`${(error && error.type) || "error"}`,
|
|
142
|
-
);
|
|
143
120
|
} catch (e) {
|
|
121
|
+
// A failed switch is a provider problem, not a failed inference: cool
|
|
122
|
+
// the model down and leave the attempt budget and progression alone,
|
|
123
|
+
// so the next failure can pick the entry after it.
|
|
144
124
|
cooldown.set(next, Date.now() + cooldownMs);
|
|
145
|
-
log(`switchModel to ${next} failed: ${e
|
|
125
|
+
log(`switchModel to ${next} failed: ${e?.message}`);
|
|
146
126
|
return;
|
|
147
127
|
}
|
|
148
128
|
|
|
129
|
+
tried.add(next);
|
|
130
|
+
attempted.set(sessionID, tried);
|
|
131
|
+
attempts.set(sessionID, used + 1);
|
|
132
|
+
// Record the new model immediately: the session.model.selected event
|
|
133
|
+
// that follows may arrive after the next failure.
|
|
134
|
+
current.set(sessionID, next);
|
|
135
|
+
log(
|
|
136
|
+
`session ${sessionID} moved to ${next} (attempt ${used + 1}/${maxAttempts}) after ` +
|
|
137
|
+
`${error?.type || "error"}`,
|
|
138
|
+
);
|
|
139
|
+
|
|
149
140
|
if (!autoRetry) return;
|
|
150
141
|
try {
|
|
151
142
|
const transcript = await ctx.session.context({ sessionID });
|
|
152
143
|
const text = lastUserText(transcript && (transcript.data || transcript));
|
|
153
144
|
if (text) await ctx.session.prompt({ sessionID, text });
|
|
154
145
|
} catch (e) {
|
|
155
|
-
log(`autoRetry failed: ${e
|
|
146
|
+
log(`autoRetry failed: ${e?.message}`);
|
|
156
147
|
}
|
|
157
148
|
}
|
|
158
149
|
|
|
@@ -160,12 +151,38 @@ export default {
|
|
|
160
151
|
void (async () => {
|
|
161
152
|
try {
|
|
162
153
|
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
154
|
+
const type = event?.type;
|
|
155
|
+
const sid = event?.data?.sessionID;
|
|
156
|
+
|
|
157
|
+
// A completed or user-interrupted run ends the failure sequence, so
|
|
158
|
+
// the next failure starts at the top of the chain again instead of
|
|
159
|
+
// inheriting another sequence's progress.
|
|
160
|
+
if (type === "session.execution.succeeded" || type === "session.execution.interrupted") {
|
|
161
|
+
if (!sid) continue;
|
|
162
|
+
attempted.delete(sid);
|
|
163
|
+
attempts.delete(sid);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (type === "session.model.selected" || type === "session.step.started") {
|
|
167
|
+
const model = event.data?.model || {};
|
|
168
|
+
const id = model.id || model.modelID;
|
|
169
|
+
if (sid && model.providerID && id) current.set(sid, `${model.providerID}/${id}`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (type === "session.deleted") {
|
|
173
|
+
if (!sid) continue;
|
|
174
|
+
attempted.delete(sid);
|
|
175
|
+
attempts.delete(sid);
|
|
176
|
+
current.delete(sid);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (type !== "session.execution.failed") continue;
|
|
180
|
+
|
|
181
|
+
const error = event.data?.error || {};
|
|
182
|
+
log(
|
|
183
|
+
`failed event: session=${sid} error=${error.type} msg=${String(error.message).slice(0, 120)}`,
|
|
184
|
+
);
|
|
185
|
+
if (!sid || busy.has(sid)) continue;
|
|
169
186
|
|
|
170
187
|
const haystack = `${error.type || ""} ${error.message || ""}`;
|
|
171
188
|
if (!RETRYABLE.test(haystack)) {
|
|
@@ -173,16 +190,14 @@ export default {
|
|
|
173
190
|
continue;
|
|
174
191
|
}
|
|
175
192
|
|
|
176
|
-
busy.add(
|
|
193
|
+
busy.add(sid);
|
|
177
194
|
try {
|
|
178
|
-
await failover(
|
|
195
|
+
await failover(sid, error);
|
|
179
196
|
} finally {
|
|
180
|
-
busy.delete(
|
|
197
|
+
busy.delete(sid);
|
|
181
198
|
}
|
|
182
199
|
}
|
|
183
|
-
} catch {
|
|
184
|
-
/* stream aborted on unload */
|
|
185
|
-
}
|
|
200
|
+
} catch {}
|
|
186
201
|
})();
|
|
187
202
|
|
|
188
203
|
return () => controller.abort();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-model-failover",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.22",
|
|
4
4
|
"description": "Native model failover for OpenCode v2: move a failed session onto the next healthy model in a chain you define.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -42,11 +42,13 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
|
-
"lint": "markdownlint-cli2",
|
|
46
|
-
"
|
|
47
|
-
"check": "npm run lint && npm test"
|
|
45
|
+
"lint": "biome check . && markdownlint-cli2 \"**/*.md\" \"#node_modules/**\" \"#dist/**\" \"#.git/**\"",
|
|
46
|
+
"lint:fix": "biome check --write . && markdownlint-cli2 --fix \"**/*.md\" \"#node_modules/**\" \"#dist/**\" \"#.git/**\"",
|
|
47
|
+
"check": "npm run lint && npm test",
|
|
48
|
+
"test": "node --test test/*.test.mjs"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
|
-
"
|
|
51
|
+
"@biomejs/biome": "^2.6.2",
|
|
52
|
+
"markdownlint-cli2": "^0.24.0"
|
|
51
53
|
}
|
|
52
54
|
}
|