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.
Files changed (3) hide show
  1. package/README.md +49 -18
  2. package/index.js +66 -17
  3. 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 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. |
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 and reacts to one event type:
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
- // -> { data: { sessionID, error: { type, message } } }
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 failure it:
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 and is not cooling down;
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
- The `id`/`setup` plugin shape, the `plugins` config key, and the four methods
124
- above are the whole v2 surface it uses.
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 derived from the last
181
- published `latest`, so repeated merges never collide; an intentional
182
- minor/major bump in `package.json` is honored.
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
- 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
+ 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
- if (event?.type !== "session.execution.failed") continue;
130
- const data = event.data || {};
131
- const sessionID = data.sessionID;
132
- const error = data.error || {};
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=${sessionID} error=${error.type} msg=${String(error.message).slice(0, 120)}`,
183
+ `failed event: session=${sid} error=${error.type} msg=${String(error.message).slice(0, 120)}`,
135
184
  );
136
- if (!sessionID || busy.has(sessionID)) continue;
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(sessionID);
193
+ busy.add(sid);
145
194
  try {
146
- await failover(sessionID, error);
195
+ await failover(sid, error);
147
196
  } finally {
148
- busy.delete(sessionID);
197
+ busy.delete(sid);
149
198
  }
150
199
  }
151
200
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-model-failover",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",