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

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 +199 -2
  3. package/index.js +156 -0
  4. package/package.json +52 -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,200 @@
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
+ ## Development
161
+
162
+ ```bash
163
+ npm install
164
+ npm run lint # biome check + markdownlint-cli2
165
+ npm run lint:fix # apply safe fixes
166
+ npm test # node:test
167
+ npm run check # lint + test
168
+ ```
169
+
170
+ ## Releases (maintainers)
171
+
172
+ All publishing happens in GitHub Actions via npm **trusted publishing** (OIDC
173
+ provenance). Never publish from a local shell.
174
+
175
+ `.github/workflows/ci.yml` is the single workflow file (npm allows one trusted
176
+ publisher per package; the workflow filename must stay `ci.yml`):
177
+
178
+ - `test` — lint + plugin tests on `main` and PRs (Node 24).
179
+ - push to `main` → `publish` job publishes the next patch to `latest`, pushes a
180
+ `vX.Y.Z` tag, and opens a GitHub release. The version is derived from the last
181
+ published `latest`, so repeated merges never collide; an intentional
182
+ minor/major bump in `package.json` is honored.
183
+ - tag `v*-beta*` → `publish` job publishes `beta` plus a prerelease GitHub
184
+ release.
185
+ - every PR push → `prerelease` job publishes `<version>-beta.<run_number>` to
186
+ `beta` (same-repo, non-draft, non-dependabot PRs; re-runs deduped).
187
+ - manual dispatch → next `-beta.X` staging publish from `main`, no git change.
188
+
189
+ One-time bootstrap: npm cannot attach a trusted publisher to a package that does
190
+ not exist yet, so `opencode-model-failover` must be published once by hand before
191
+ the workflow can publish. Then configure the trusted publisher on npmjs.com:
192
+ repository `nabheet/opencode-model-failover`, workflow filename `ci.yml`, and
193
+ tick **Allow `npm publish`**. A trusted publisher created after Sep 03 2026 is
194
+ stage-only by default, and stage-only sends CI publishes to the staging area to
195
+ wait for a 2FA approval instead of going live. A new config must also complete
196
+ its first successful publish within 2 days or it expires.
197
+
198
+ ## License
199
+
200
+ MIT — see [LICENSE](./LICENSE).
package/index.js ADDED
@@ -0,0 +1,156 @@
1
+ // OpenCode v2 plugin. Options and behaviour: see README.md.
2
+
3
+ import { appendFileSync } from "node:fs";
4
+
5
+ // Ships empty: the chain is yours to define.
6
+ const DEFAULT_CHAIN = [];
7
+
8
+ // Only retryable failures are worth failing over on; a validation error fails on every model.
9
+ const RETRYABLE =
10
+ /no-route|provider\.|rate|quota|overload|unavailable|server|timeout|econn|socket|network|502|503|504|429/i;
11
+
12
+ function splitRef(ref) {
13
+ const at = String(ref).indexOf("/");
14
+ if (at <= 0) return null;
15
+ return { providerID: ref.slice(0, at), modelID: ref.slice(at + 1) };
16
+ }
17
+
18
+ export default {
19
+ id: "model-failover",
20
+ async setup(ctx) {
21
+ const opt = ctx.options || {};
22
+ const chain =
23
+ Array.isArray(opt.fallbackModels) && opt.fallbackModels.length > 0
24
+ ? opt.fallbackModels.filter((m) => splitRef(m))
25
+ : DEFAULT_CHAIN;
26
+ const maxAttempts = Number.isFinite(opt.maxAttempts) ? opt.maxAttempts : 2;
27
+ const cooldownMs = Number.isFinite(opt.cooldownMs) ? opt.cooldownMs : 60_000;
28
+ const autoRetry = opt.autoRetry === true;
29
+ const notify = opt.notify !== false;
30
+ const logFile =
31
+ typeof opt.logFile === "string" ? opt.logFile : process.env.MODEL_FALLBACK_LOG || "";
32
+
33
+ const attempts = new Map(); // sessionID -> failovers used
34
+ const busy = new Set(); // sessionID -> failover in flight
35
+ const cooldown = new Map(); // "provider/model" -> epoch ms it is usable again
36
+
37
+ const log = (msg) => {
38
+ if (!logFile && !notify) return;
39
+ const line = `[model-failover] ${new Date().toISOString()} ${msg}`;
40
+ if (logFile) {
41
+ try {
42
+ appendFileSync(logFile, `${line}\n`);
43
+ } catch {}
44
+ }
45
+ if (notify) console.error(line);
46
+ };
47
+
48
+ log(`setup: chain=[${chain.join(", ")}] maxAttempts=${maxAttempts} autoRetry=${autoRetry}`);
49
+ if (chain.length === 0) {
50
+ log("no fallbackModels configured; set fallbackModels to enable failover");
51
+ }
52
+
53
+ async function availableRefs() {
54
+ try {
55
+ const listed = await ctx.model.list();
56
+ const rows = listed && Array.isArray(listed.data) ? listed.data : [];
57
+ return new Set(rows.map((m) => `${m.providerID}/${m.modelID}`));
58
+ } catch {
59
+ return new Set();
60
+ }
61
+ }
62
+
63
+ function lastUserText(messages) {
64
+ const rows = Array.isArray(messages) ? messages : [];
65
+ for (let i = rows.length - 1; i >= 0; i--) {
66
+ const row = rows[i] || {};
67
+ const role = row.role || row.info?.role;
68
+ if (role !== "user") continue;
69
+ const parts = row.parts || row.info?.parts || [];
70
+ const text = parts
71
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
72
+ .map((p) => p.text)
73
+ .join("\n")
74
+ .trim();
75
+ if (text) return text;
76
+ }
77
+ return "";
78
+ }
79
+
80
+ async function failover(sessionID, error) {
81
+ const used = attempts.get(sessionID) || 0;
82
+ if (used >= maxAttempts) {
83
+ log(`max attempts reached for session ${sessionID} (${used}/${maxAttempts})`);
84
+ return;
85
+ }
86
+
87
+ const set = await availableRefs();
88
+ const now = Date.now();
89
+ log(`availability=${set.size} models; looking for next in chain`);
90
+ const next = chain.find((ref) => set.has(ref) && (cooldown.get(ref) || 0) <= now);
91
+ if (!next) {
92
+ log("no available model left in chain");
93
+ return;
94
+ }
95
+
96
+ const ref = splitRef(next);
97
+ if (!ref) return;
98
+
99
+ attempts.set(sessionID, used + 1);
100
+ try {
101
+ await ctx.session.switchModel({
102
+ sessionID,
103
+ model: { providerID: ref.providerID, id: ref.modelID },
104
+ });
105
+ log(
106
+ `session ${sessionID} moved to ${next} (attempt ${used + 1}/${maxAttempts}) after ` +
107
+ `${error?.type || "error"}`,
108
+ );
109
+ } catch (e) {
110
+ cooldown.set(next, Date.now() + cooldownMs);
111
+ log(`switchModel to ${next} failed: ${e?.message}`);
112
+ return;
113
+ }
114
+
115
+ if (!autoRetry) return;
116
+ try {
117
+ const transcript = await ctx.session.context({ sessionID });
118
+ const text = lastUserText(transcript && (transcript.data || transcript));
119
+ if (text) await ctx.session.prompt({ sessionID, text });
120
+ } catch (e) {
121
+ log(`autoRetry failed: ${e?.message}`);
122
+ }
123
+ }
124
+
125
+ const controller = new AbortController();
126
+ void (async () => {
127
+ try {
128
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
129
+ if (event?.type !== "session.execution.failed") continue;
130
+ const data = event.data || {};
131
+ const sessionID = data.sessionID;
132
+ const error = data.error || {};
133
+ log(
134
+ `failed event: session=${sessionID} error=${error.type} msg=${String(error.message).slice(0, 120)}`,
135
+ );
136
+ if (!sessionID || busy.has(sessionID)) continue;
137
+
138
+ const haystack = `${error.type || ""} ${error.message || ""}`;
139
+ if (!RETRYABLE.test(haystack)) {
140
+ log(`not retryable, ignoring: ${haystack.slice(0, 120)}`);
141
+ continue;
142
+ }
143
+
144
+ busy.add(sessionID);
145
+ try {
146
+ await failover(sessionID, error);
147
+ } finally {
148
+ busy.delete(sessionID);
149
+ }
150
+ }
151
+ } catch {}
152
+ })();
153
+
154
+ return () => controller.abort();
155
+ },
156
+ };
package/package.json CHANGED
@@ -1,6 +1,54 @@
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.12",
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": "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"
49
+ },
50
+ "devDependencies": {
51
+ "@biomejs/biome": "^2.6.2",
52
+ "markdownlint-cli2": "^0.23.3"
53
+ }
54
+ }