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.
Files changed (3) hide show
  1. package/README.md +66 -23
  2. package/index.js +79 -64
  3. 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 session before giving up. |
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 and reacts to one event type:
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
- // -> { data: { sessionID, error: { type, message } } }
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 failure it:
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 and is not cooling down;
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
- The `id`/`setup` plugin shape, the `plugins` config key, and the four methods
124
- above are the whole v2 surface it uses.
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 derived from the last
171
- published `latest`, so repeated merges never collide; an intentional
172
- minor/major bump in `package.json` is honored.
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
- before the workflow can publish. After that first version exists, configure the
182
- trusted publisher on npmjs.com (repository `nabheet/opencode-model-failover`,
183
- workflow filename `ci.yml`, allow `npm publish`), and CI owns every release
184
- after it.
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
- // opencode-model-failover — OpenCode v2 plugin.
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 on purpose — the chain is yours to define. Nothing about the
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
- // Failure kinds worth failing over on. A bad request or a validation error will
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
- // Optional file sink — the only reliable way to observe a server-side
62
- // plugin. The env var fallback keeps it usable without touching config.
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 || (row.info && row.info.role);
69
+ const role = row.role || row.info?.role;
102
70
  if (role !== "user") continue;
103
- const parts = row.parts || (row.info && row.info.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
- log(`availability=${set.size} models; looking for next in chain`);
124
- const next = chain.find((ref) => set.has(ref) && (cooldown.get(ref) || 0) <= now);
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 && e.message}`);
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 && e.message}`);
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
- if (!event || event.type !== "session.execution.failed") continue;
164
- const data = event.data || {};
165
- const sessionID = data.sessionID;
166
- const error = data.error || {};
167
- log(`failed event: session=${sessionID} error=${error.type} msg=${String(error.message).slice(0, 120)}`);
168
- if (!sessionID || busy.has(sessionID)) continue;
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(sessionID);
193
+ busy.add(sid);
177
194
  try {
178
- await failover(sessionID, error);
195
+ await failover(sid, error);
179
196
  } finally {
180
- busy.delete(sessionID);
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.2",
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
- "test": "node --check index.js && node --test test/*.test.mjs",
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
- "markdownlint-cli2": "^0.23.0"
51
+ "@biomejs/biome": "^2.6.2",
52
+ "markdownlint-cli2": "^0.24.0"
51
53
  }
52
54
  }