opencode-model-failover 0.1.0 → 0.1.1
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 +17 -5
- package/index.js +16 -50
- 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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
//
|
|
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,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
|
-
|
|
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
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 ||
|
|
67
|
+
const role = row.role || row.info?.role;
|
|
102
68
|
if (role !== "user") continue;
|
|
103
|
-
const parts = row.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
|
-
`${
|
|
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
|
|
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
|
|
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 (
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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.5.14",
|
|
52
|
+
"markdownlint-cli2": "^0.23.3"
|
|
51
53
|
}
|
|
52
54
|
}
|