opencode-model-failover 0.0.0-stage → 0.1.0-beta.2

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +187 -2
  3. package/index.js +190 -0
  4. package/package.json +50 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nabheet S. Sandhu
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 CHANGED
@@ -1,3 +1,188 @@
1
- # Temporary Holding Version
1
+ # opencode-model-failover
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Native model failover for [OpenCode](https://opencode.ai) **v2**.
4
+
5
+ When a session fails because a model is rate-limited, overloaded, or otherwise
6
+ unreachable, this plugin moves the session onto the next healthy model in a
7
+ chain you define. It runs entirely on the OpenCode v2 plugin API: no HTTP
8
+ client, no vendor SDK, no runtime dependencies.
9
+
10
+ ## Why
11
+
12
+ OpenCode v2 has a different plugin API from v1, and the fallback plugins already
13
+ on npm are written against the v1 API, so they do not load in v2. This plugin is
14
+ v2-native.
15
+
16
+ - **No chain ships with it.** You name the models. Nothing about the author's
17
+ provider setup is baked in, and the default chain is empty.
18
+ - **Failures are filtered.** Only retryable errors (rate limits, timeouts,
19
+ provider and network errors, 429/502/503/504) trigger a failover. A validation
20
+ error would fail on every model, so it is ignored instead of burning attempts.
21
+ - **Prompt replay is opt-in.** `autoRetry` is off by default, because re-sending
22
+ a prompt is a state-changing action.
23
+ - **Cooldown.** A model whose switch fails is skipped for `cooldownMs`, so a
24
+ broken target is not retried on every failure.
25
+
26
+ ## Requirements
27
+
28
+ - OpenCode **v2** (`opencode --version` reports 2.x).
29
+ - Node **>= 22**.
30
+
31
+ ## Install
32
+
33
+ ### From npm
34
+
35
+ ```bash
36
+ opencode plugin add opencode-model-failover
37
+ ```
38
+
39
+ ### From GitHub
40
+
41
+ ```bash
42
+ opencode plugin add github:nabheet/opencode-model-failover
43
+ ```
44
+
45
+ Or add it to `opencode.json(c)` directly:
46
+
47
+ ```jsonc
48
+ {
49
+ "$schema": "https://opencode.ai/config.json",
50
+ "plugins": ["opencode-model-failover"]
51
+ }
52
+ ```
53
+
54
+ ### Manual
55
+
56
+ Copy this directory to `.opencode/plugins/opencode-model-failover/` (project) or
57
+ `~/.config/opencode/plugins/opencode-model-failover/` (global), then list it:
58
+
59
+ ```jsonc
60
+ {
61
+ "plugins": [{ "package": "./.opencode/plugins/opencode-model-failover" }]
62
+ }
63
+ ```
64
+
65
+ The v2 loader resolves `index.js` inside the directory; the `package.json`
66
+ `main` field is not consulted.
67
+
68
+ ## Options
69
+
70
+ The plugin does nothing until you supply a chain. Pass options through the
71
+ plugin entry in your OpenCode config:
72
+
73
+ ```jsonc
74
+ {
75
+ "plugins": [
76
+ {
77
+ "package": "opencode-model-failover",
78
+ "options": {
79
+ "fallbackModels": ["provider-a/model-x", "provider-b/model-y"],
80
+ "maxAttempts": 2,
81
+ "cooldownMs": 60000,
82
+ "autoRetry": false,
83
+ "notify": true,
84
+ "logFile": "/tmp/model-failover.log"
85
+ }
86
+ }
87
+ ]
88
+ }
89
+ ```
90
+
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. |
99
+
100
+ A `provider/model` string is split on the first `/`. Entries without a `/` are
101
+ dropped from the chain.
102
+
103
+ ## How it works
104
+
105
+ The plugin subscribes to the v2 event stream and reacts to one event type:
106
+
107
+ ```js
108
+ ctx.event.subscribe({ signal })
109
+ // session.execution.failed
110
+ // -> { data: { sessionID, error: { type, message } } }
111
+ ```
112
+
113
+ On a retryable failure it:
114
+
115
+ 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;
117
+ 3. moves the session with `ctx.session.switchModel({ sessionID, model })`.
118
+
119
+ If `autoRetry` is on, it then reads the transcript with
120
+ `ctx.session.context({ sessionID })` and replays the last user message with
121
+ `ctx.session.prompt({ sessionID, text })`.
122
+
123
+ The `id`/`setup` plugin shape, the `plugins` config key, and the four methods
124
+ above are the whole v2 surface it uses.
125
+
126
+ ## Observing it
127
+
128
+ A server-side plugin is easy to miss, so every decision is logged with a
129
+ `[model-failover]` prefix. Set `notify: false` to silence stderr, and
130
+ `logFile` (or the `MODEL_FALLBACK_LOG` environment variable) to append to a
131
+ file:
132
+
133
+ ```bash
134
+ MODEL_FALLBACK_LOG=/tmp/model-failover.log opencode
135
+ ```
136
+
137
+ Typical lines:
138
+
139
+ ```text
140
+ [model-failover] 2026-01-01T00:00:00.000Z setup: chain=[provider-a/model-x] maxAttempts=2 autoRetry=false
141
+ [model-failover] 2026-01-01T00:00:01.000Z failed event: session=ses_123 error=provider.rateLimit msg=429
142
+ [model-failover] 2026-01-01T00:00:01.010Z availability=12 models; looking for next in chain
143
+ [model-failover] 2026-01-01T00:00:01.020Z session ses_123 moved to provider-a/model-x (attempt 1/2)
144
+ ```
145
+
146
+ ## Compatibility
147
+
148
+ - Targets **OpenCode v2** (the `plugins` config key and the `id`/`setup` plugin
149
+ definition). OpenCode v1 used a different plugin API and is not supported.
150
+ - No runtime dependencies. Only `node:` builtins are imported.
151
+
152
+ ## Prereleases
153
+
154
+ Every push to a PR publishes an installable beta to the npm `beta` dist-tag:
155
+
156
+ ```bash
157
+ opencode plugin add opencode-model-failover@beta
158
+ ```
159
+
160
+ ## Releases (maintainers)
161
+
162
+ All publishing happens in GitHub Actions via npm **trusted publishing** (OIDC
163
+ provenance). Never publish from a local shell.
164
+
165
+ `.github/workflows/ci.yml` is the single workflow file (npm allows one trusted
166
+ publisher per package; the workflow filename must stay `ci.yml`):
167
+
168
+ - `test` — lint + plugin tests on `main` and PRs (Node 24).
169
+ - 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.
173
+ - tag `v*-beta*` → `publish` job publishes `beta` plus a prerelease GitHub
174
+ release.
175
+ - every PR push → `prerelease` job publishes `<version>-beta.<run_number>` to
176
+ `beta` (same-repo, non-draft, non-dependabot PRs; re-runs deduped).
177
+ - manual dispatch → next `-beta.X` staging publish from `main`, no git change.
178
+
179
+ 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.
185
+
186
+ ## License
187
+
188
+ MIT — see [LICENSE](./LICENSE).
package/index.js ADDED
@@ -0,0 +1,190 @@
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.
31
+
32
+ import { appendFileSync } from "node:fs";
33
+
34
+ // Ships empty on purpose — the chain is yours to define. Nothing about the
35
+ // author's provider setup is baked in.
36
+ const DEFAULT_CHAIN = [];
37
+
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.
40
+ const RETRYABLE =
41
+ /no-route|provider\.|rate|quota|overload|unavailable|server|timeout|econn|socket|network|502|503|504|429/i;
42
+
43
+ function splitRef(ref) {
44
+ const at = String(ref).indexOf("/");
45
+ if (at <= 0) return null;
46
+ return { providerID: ref.slice(0, at), modelID: ref.slice(at + 1) };
47
+ }
48
+
49
+ export default {
50
+ id: "model-failover",
51
+ async setup(ctx) {
52
+ const opt = ctx.options || {};
53
+ const chain =
54
+ Array.isArray(opt.fallbackModels) && opt.fallbackModels.length > 0
55
+ ? opt.fallbackModels.filter((m) => splitRef(m))
56
+ : DEFAULT_CHAIN;
57
+ const maxAttempts = Number.isFinite(opt.maxAttempts) ? opt.maxAttempts : 2;
58
+ const cooldownMs = Number.isFinite(opt.cooldownMs) ? opt.cooldownMs : 60_000;
59
+ const autoRetry = opt.autoRetry === true;
60
+ 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 || "";
64
+
65
+ const attempts = new Map(); // sessionID -> failovers used
66
+ const busy = new Set(); // sessionID -> failover in flight
67
+ const cooldown = new Map(); // "provider/model" -> epoch ms it is usable again
68
+
69
+ const log = (msg) => {
70
+ if (!logFile && !notify) return;
71
+ const line = `[model-failover] ${new Date().toISOString()} ${msg}`;
72
+ if (logFile) {
73
+ try {
74
+ appendFileSync(logFile, `${line}\n`);
75
+ } catch {
76
+ /* best effort */
77
+ }
78
+ }
79
+ if (notify) console.error(line);
80
+ };
81
+
82
+ log(`setup: chain=[${chain.join(", ")}] maxAttempts=${maxAttempts} autoRetry=${autoRetry}`);
83
+ if (chain.length === 0) {
84
+ log("no fallbackModels configured; set fallbackModels to enable failover");
85
+ }
86
+
87
+ async function availableRefs() {
88
+ try {
89
+ const listed = await ctx.model.list();
90
+ const rows = listed && Array.isArray(listed.data) ? listed.data : [];
91
+ return new Set(rows.map((m) => `${m.providerID}/${m.modelID}`));
92
+ } catch {
93
+ return new Set();
94
+ }
95
+ }
96
+
97
+ function lastUserText(messages) {
98
+ const rows = Array.isArray(messages) ? messages : [];
99
+ for (let i = rows.length - 1; i >= 0; i--) {
100
+ const row = rows[i] || {};
101
+ const role = row.role || (row.info && row.info.role);
102
+ if (role !== "user") continue;
103
+ const parts = row.parts || (row.info && row.info.parts) || [];
104
+ const text = parts
105
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
106
+ .map((p) => p.text)
107
+ .join("\n")
108
+ .trim();
109
+ if (text) return text;
110
+ }
111
+ return "";
112
+ }
113
+
114
+ async function failover(sessionID, error) {
115
+ const used = attempts.get(sessionID) || 0;
116
+ if (used >= maxAttempts) {
117
+ log(`max attempts reached for session ${sessionID} (${used}/${maxAttempts})`);
118
+ return;
119
+ }
120
+
121
+ const set = await availableRefs();
122
+ 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);
125
+ if (!next) {
126
+ log("no available model left in chain");
127
+ return;
128
+ }
129
+
130
+ const ref = splitRef(next);
131
+ if (!ref) return;
132
+
133
+ attempts.set(sessionID, used + 1);
134
+ try {
135
+ await ctx.session.switchModel({
136
+ sessionID,
137
+ model: { providerID: ref.providerID, id: ref.modelID },
138
+ });
139
+ log(
140
+ `session ${sessionID} moved to ${next} (attempt ${used + 1}/${maxAttempts}) after ` +
141
+ `${(error && error.type) || "error"}`,
142
+ );
143
+ } catch (e) {
144
+ cooldown.set(next, Date.now() + cooldownMs);
145
+ log(`switchModel to ${next} failed: ${e && e.message}`);
146
+ return;
147
+ }
148
+
149
+ if (!autoRetry) return;
150
+ try {
151
+ const transcript = await ctx.session.context({ sessionID });
152
+ const text = lastUserText(transcript && (transcript.data || transcript));
153
+ if (text) await ctx.session.prompt({ sessionID, text });
154
+ } catch (e) {
155
+ log(`autoRetry failed: ${e && e.message}`);
156
+ }
157
+ }
158
+
159
+ const controller = new AbortController();
160
+ void (async () => {
161
+ try {
162
+ 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;
169
+
170
+ const haystack = `${error.type || ""} ${error.message || ""}`;
171
+ if (!RETRYABLE.test(haystack)) {
172
+ log(`not retryable, ignoring: ${haystack.slice(0, 120)}`);
173
+ continue;
174
+ }
175
+
176
+ busy.add(sessionID);
177
+ try {
178
+ await failover(sessionID, error);
179
+ } finally {
180
+ busy.delete(sessionID);
181
+ }
182
+ }
183
+ } catch {
184
+ /* stream aborted on unload */
185
+ }
186
+ })();
187
+
188
+ return () => controller.abort();
189
+ },
190
+ };
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "opencode-model-failover",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.2",
4
+ "description": "Native model failover for OpenCode v2: move a failed session onto the next healthy model in a chain you define.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "opencode",
17
+ "opencode-plugin",
18
+ "opencode-v2",
19
+ "model-failover",
20
+ "model-fallback",
21
+ "failover",
22
+ "rate-limit",
23
+ "quota",
24
+ "resilience",
25
+ "llm",
26
+ "ai-coding-agent"
27
+ ],
28
+ "license": "MIT",
29
+ "author": "Nabheet S. Sandhu",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/nabheet/opencode-model-failover.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/nabheet/opencode-model-failover/issues"
36
+ },
37
+ "homepage": "https://github.com/nabheet/opencode-model-failover#readme",
38
+ "engines": {
39
+ "node": ">=22"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "lint": "markdownlint-cli2",
46
+ "test": "node --check index.js && node --test test/*.test.mjs",
47
+ "check": "npm run lint && npm test"
48
+ },
49
+ "devDependencies": {
50
+ "markdownlint-cli2": "^0.23.0"
51
+ }
52
+ }