opencode-model-failover 0.1.4 → 0.1.6
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 +49 -18
- package/index.js +66 -17
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
|
@@ -177,9 +206,11 @@ publisher per package; the workflow filename must stay `ci.yml`):
|
|
|
177
206
|
|
|
178
207
|
- `test` — lint + plugin tests on `main` and PRs (Node 24).
|
|
179
208
|
- 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
|
|
181
|
-
|
|
182
|
-
minor/major bump in
|
|
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.
|
|
183
214
|
- tag `v*-beta*` → `publish` job publishes `beta` plus a prerelease GitHub
|
|
184
215
|
release.
|
|
185
216
|
- every PR push → `prerelease` job publishes `<version>-beta.<run_number>` to
|
package/index.js
CHANGED
|
@@ -30,7 +30,9 @@ export default {
|
|
|
30
30
|
const logFile =
|
|
31
31
|
typeof opt.logFile === "string" ? opt.logFile : process.env.MODEL_FALLBACK_LOG || "";
|
|
32
32
|
|
|
33
|
-
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
|
|
34
36
|
const busy = new Set(); // sessionID -> failover in flight
|
|
35
37
|
const cooldown = new Map(); // "provider/model" -> epoch ms it is usable again
|
|
36
38
|
|
|
@@ -86,8 +88,22 @@ export default {
|
|
|
86
88
|
|
|
87
89
|
const set = await availableRefs();
|
|
88
90
|
const now = Date.now();
|
|
89
|
-
|
|
90
|
-
const
|
|
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
|
+
);
|
|
91
107
|
if (!next) {
|
|
92
108
|
log("no available model left in chain");
|
|
93
109
|
return;
|
|
@@ -96,22 +112,31 @@ export default {
|
|
|
96
112
|
const ref = splitRef(next);
|
|
97
113
|
if (!ref) return;
|
|
98
114
|
|
|
99
|
-
attempts.set(sessionID, used + 1);
|
|
100
115
|
try {
|
|
101
116
|
await ctx.session.switchModel({
|
|
102
117
|
sessionID,
|
|
103
118
|
model: { providerID: ref.providerID, id: ref.modelID },
|
|
104
119
|
});
|
|
105
|
-
log(
|
|
106
|
-
`session ${sessionID} moved to ${next} (attempt ${used + 1}/${maxAttempts}) after ` +
|
|
107
|
-
`${error?.type || "error"}`,
|
|
108
|
-
);
|
|
109
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.
|
|
110
124
|
cooldown.set(next, Date.now() + cooldownMs);
|
|
111
125
|
log(`switchModel to ${next} failed: ${e?.message}`);
|
|
112
126
|
return;
|
|
113
127
|
}
|
|
114
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
|
+
|
|
115
140
|
if (!autoRetry) return;
|
|
116
141
|
try {
|
|
117
142
|
const transcript = await ctx.session.context({ sessionID });
|
|
@@ -126,14 +151,38 @@ export default {
|
|
|
126
151
|
void (async () => {
|
|
127
152
|
try {
|
|
128
153
|
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
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 || {};
|
|
133
182
|
log(
|
|
134
|
-
`failed event: session=${
|
|
183
|
+
`failed event: session=${sid} error=${error.type} msg=${String(error.message).slice(0, 120)}`,
|
|
135
184
|
);
|
|
136
|
-
if (!
|
|
185
|
+
if (!sid || busy.has(sid)) continue;
|
|
137
186
|
|
|
138
187
|
const haystack = `${error.type || ""} ${error.message || ""}`;
|
|
139
188
|
if (!RETRYABLE.test(haystack)) {
|
|
@@ -141,11 +190,11 @@ export default {
|
|
|
141
190
|
continue;
|
|
142
191
|
}
|
|
143
192
|
|
|
144
|
-
busy.add(
|
|
193
|
+
busy.add(sid);
|
|
145
194
|
try {
|
|
146
|
-
await failover(
|
|
195
|
+
await failover(sid, error);
|
|
147
196
|
} finally {
|
|
148
|
-
busy.delete(
|
|
197
|
+
busy.delete(sid);
|
|
149
198
|
}
|
|
150
199
|
}
|
|
151
200
|
} catch {}
|
package/package.json
CHANGED