opencode-model-failover 0.1.0 → 0.1.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 (3) hide show
  1. package/README.md +17 -5
  2. package/index.js +16 -50
  3. package/package.json +7 -5
package/README.md CHANGED
@@ -157,6 +157,16 @@ Every push to a PR publishes an installable beta to the npm `beta` dist-tag:
157
157
  opencode plugin add opencode-model-failover@beta
158
158
  ```
159
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
+
160
170
  ## Releases (maintainers)
161
171
 
162
172
  All publishing happens in GitHub Actions via npm **trusted publishing** (OIDC
@@ -177,11 +187,13 @@ publisher per package; the workflow filename must stay `ci.yml`):
177
187
  - manual dispatch → next `-beta.X` staging publish from `main`, no git change.
178
188
 
179
189
  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.
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.
185
197
 
186
198
  ## License
187
199
 
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,9 +27,8 @@ 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
33
  const attempts = new Map(); // sessionID -> failovers used
66
34
  const busy = new Set(); // sessionID -> failover in flight
@@ -72,9 +40,7 @@ export default {
72
40
  if (logFile) {
73
41
  try {
74
42
  appendFileSync(logFile, `${line}\n`);
75
- } catch {
76
- /* best effort */
77
- }
43
+ } catch {}
78
44
  }
79
45
  if (notify) console.error(line);
80
46
  };
@@ -98,9 +64,9 @@ export default {
98
64
  const rows = Array.isArray(messages) ? messages : [];
99
65
  for (let i = rows.length - 1; i >= 0; i--) {
100
66
  const row = rows[i] || {};
101
- const role = row.role || (row.info && row.info.role);
67
+ const role = row.role || row.info?.role;
102
68
  if (role !== "user") continue;
103
- const parts = row.parts || (row.info && row.info.parts) || [];
69
+ const parts = row.parts || row.info?.parts || [];
104
70
  const text = parts
105
71
  .filter((p) => p && p.type === "text" && typeof p.text === "string")
106
72
  .map((p) => p.text)
@@ -138,11 +104,11 @@ export default {
138
104
  });
139
105
  log(
140
106
  `session ${sessionID} moved to ${next} (attempt ${used + 1}/${maxAttempts}) after ` +
141
- `${(error && error.type) || "error"}`,
107
+ `${error?.type || "error"}`,
142
108
  );
143
109
  } catch (e) {
144
110
  cooldown.set(next, Date.now() + cooldownMs);
145
- log(`switchModel to ${next} failed: ${e && e.message}`);
111
+ log(`switchModel to ${next} failed: ${e?.message}`);
146
112
  return;
147
113
  }
148
114
 
@@ -152,7 +118,7 @@ export default {
152
118
  const text = lastUserText(transcript && (transcript.data || transcript));
153
119
  if (text) await ctx.session.prompt({ sessionID, text });
154
120
  } catch (e) {
155
- log(`autoRetry failed: ${e && e.message}`);
121
+ log(`autoRetry failed: ${e?.message}`);
156
122
  }
157
123
  }
158
124
 
@@ -160,11 +126,13 @@ export default {
160
126
  void (async () => {
161
127
  try {
162
128
  for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
163
- if (!event || event.type !== "session.execution.failed") continue;
129
+ if (event?.type !== "session.execution.failed") continue;
164
130
  const data = event.data || {};
165
131
  const sessionID = data.sessionID;
166
132
  const error = data.error || {};
167
- log(`failed event: session=${sessionID} error=${error.type} msg=${String(error.message).slice(0, 120)}`);
133
+ log(
134
+ `failed event: session=${sessionID} error=${error.type} msg=${String(error.message).slice(0, 120)}`,
135
+ );
168
136
  if (!sessionID || busy.has(sessionID)) continue;
169
137
 
170
138
  const haystack = `${error.type || ""} ${error.message || ""}`;
@@ -180,9 +148,7 @@ export default {
180
148
  busy.delete(sessionID);
181
149
  }
182
150
  }
183
- } catch {
184
- /* stream aborted on unload */
185
- }
151
+ } catch {}
186
152
  })();
187
153
 
188
154
  return () => controller.abort();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-model-failover",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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.5.14",
52
+ "markdownlint-cli2": "^0.23.3"
51
53
  }
52
54
  }