opencode-switchback 2026.10.14 → 2026.10.15

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +146 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-switchback",
3
- "version": "2026.10.14",
3
+ "version": "2026.10.15",
4
4
  "type": "module",
5
5
  "description": "OpenCode V2 plugin for switchback: quota-aware model fallback routing.",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * 1. OpenCode classifies a failure and proposes its own retry policy; this
9
9
  * hook runs after that decision, before any retry is scheduled.
10
10
  * 2. The plugin classifies the error with the shared config's classifier
11
- * (switchback.yaml's `jev`) and, per the verdict, updates the shared
11
+ * (switchback.yaml's `decisionModel`) and, per the verdict, updates the shared
12
12
  * account-scoped block state.
13
13
  * 3. When the failing model belongs to a configured fallback chain, the
14
14
  * session is switched to the next effective fallback via
@@ -29,9 +29,15 @@
29
29
  import { Plugin } from "@opencode/plugin";
30
30
  import {
31
31
  assessFailure,
32
+ clearDisabled,
33
+ disableModel,
34
+ disabledFilePath,
35
+ enableModel,
36
+ isDisabled,
32
37
  loadConfig,
33
38
  pickNextEffective,
34
39
  readBlockedMap,
40
+ readDisabledMap,
35
41
  recordDecideCrash,
36
42
  resolveFallbacks,
37
43
  unblockModel,
@@ -39,6 +45,7 @@ import {
39
45
  } from "switchback-core";
40
46
  import type {
41
47
  CrashAction,
48
+ DisabledMap,
42
49
  HostModel,
43
50
  ModelId,
44
51
  ResolvedSwitchbackFileConfig,
@@ -70,6 +77,22 @@ export default Plugin.define({
70
77
  },
71
78
  });
72
79
  });
80
+ await ctx.command.transform((editor) => {
81
+ editor.add({
82
+ name: "switchback-disable",
83
+ description: "Keep a model out of every chain until re-enabled: <provider/model>",
84
+ execute: async ({ sessionID, prompt }) => {
85
+ await runDisable(ctx, sessionID, prompt.text);
86
+ },
87
+ });
88
+ editor.add({
89
+ name: "switchback-enable",
90
+ description: "Re-enable a manually disabled model: <provider/model>, or ALL",
91
+ execute: async ({ sessionID, prompt }) => {
92
+ await runEnable(ctx, sessionID, prompt.text);
93
+ },
94
+ });
95
+ });
73
96
  },
74
97
  });
75
98
 
@@ -114,20 +137,20 @@ async function handleRetry(ctx: Plugin.Context, input: RetryPayload): Promise<vo
114
137
  if (chain === undefined) return; // This model answers to no configured chain; OpenCode's policy stands.
115
138
 
116
139
  const availability = catalogPort(catalog);
117
- const jev = chain.jev;
140
+ const decisionModel = chain.decisionModel;
118
141
  const registry: RouterRegistryPort = {
119
142
  ...availability,
120
143
  ...systemOneClassifierPort({
121
- baseUrl: jev?.baseUrl,
122
- ...(jev?.apiKey !== undefined ? { apiKey: jev.apiKey } : {}),
123
- identity: { provider: jev?.provider ?? "", id: jev?.id ?? "" },
144
+ baseUrl: decisionModel?.baseUrl,
145
+ ...(decisionModel?.apiKey !== undefined ? { apiKey: decisionModel.apiKey } : {}),
146
+ identity: { provider: decisionModel?.provider ?? "", id: decisionModel?.id ?? "" },
124
147
  }),
125
148
  };
126
149
  const assessed = await assessFailure({
127
150
  failedId: failing,
128
151
  errorMessage: rawMessage,
129
152
  stopReason: undefined,
130
- jev,
153
+ decisionModel,
131
154
  registry,
132
155
  now,
133
156
  blocked: readBlockedMap(now),
@@ -162,17 +185,20 @@ async function handleRetry(ctx: Plugin.Context, input: RetryPayload): Promise<vo
162
185
  recordDecideCrash(assessed.failedId, registry, rawMessage, now, classified, undefined, action);
163
186
  if (!assessed.touchedBlocks) return; // Transient within OpenCode's own budget: leave its policy standing.
164
187
 
188
+ const disabled = readDisabledMap();
165
189
  const resolved = resolveFallbacks(chain.fallbacks, availability);
166
- const next = pickNextEffective(resolved, failing, readBlockedMap(now), now, failing);
190
+ const next = pickNextEffective(resolved, failing, readBlockedMap(now), now, failing, undefined, disabled);
167
191
  const until = classified.resetAtMs === undefined ? "" : ` until ${new Date(classified.resetAtMs).toISOString()}`;
168
192
  if (next === undefined || next.degraded) {
169
193
  // Nothing effective left (or only the failing model itself): surface the
170
194
  // failure instead of scheduling another attempt into a blocked model -
171
195
  // pi's stuck behavior when the advance finds nothing left.
196
+ const disabledInChain = chain.fallbacks.filter((id) => isDisabled(id, disabled)).length;
197
+ const disabledNote = disabledInChain === 0 ? "" : `; ${disabledInChain} disabled by hand`;
172
198
  notify(
173
199
  ctx,
174
200
  input.sessionID,
175
- `switchback: ${failing} blocked${until}; every further fallback in ${chain.id} is unavailable - surfacing the failure`,
201
+ `switchback: ${failing} blocked${until}; every further fallback in ${chain.id} is unavailable${disabledNote} - surfacing the failure`,
176
202
  );
177
203
  input.decision = { retry: false };
178
204
  return;
@@ -245,12 +271,14 @@ async function describeStatus(ctx: Plugin.Context, sessionID: string): Promise<v
245
271
  const catalog = (await ctx.model.list()).data;
246
272
  const availability = catalogPort(catalog);
247
273
  const blocked = readBlockedMap(Date.now());
274
+ const disabled = readDisabledMap();
248
275
  const sections = config.models.map((model) => {
249
276
  const resolved = resolveFallbacks(model.fallbacks, availability);
250
277
  const rows = resolved.entries.map((entry) => {
251
278
  const until = blocked[entry.id];
252
- const state =
253
- entry.availability === "effective"
279
+ const state = isDisabled(entry.id, disabled)
280
+ ? "disabled by hand (enable with /switchback-enable)"
281
+ : entry.availability === "effective"
254
282
  ? until !== undefined
255
283
  ? `blocked until ${new Date(until).toISOString()}`
256
284
  : "available"
@@ -259,10 +287,117 @@ async function describeStatus(ctx: Plugin.Context, sessionID: string): Promise<v
259
287
  });
260
288
  return `${model.id} (${model.name}): \n${rows.join("\n")}`;
261
289
  });
290
+ const disabledLine = describeDisabled(disabled);
291
+ await ctx.session
292
+ .synthetic({
293
+ sessionID,
294
+ text: `switchback: ${config.models.length} configured chain(s)\n\n${sections.join("\n\n")}${disabledLine}`,
295
+ })
296
+ .catch(() => {
297
+ // Same best-effort rule as notify.
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Every fallback address the config lists, in chain order and deduplicated.
303
+ * The disable/enable commands accept exactly these addresses.
304
+ */
305
+ function configuredAddresses(config: ResolvedSwitchbackFileConfig): ModelId[] {
306
+ const seen = new Set<ModelId>();
307
+ for (const model of config.models) for (const id of model.fallbacks) seen.add(id);
308
+ return [...seen];
309
+ }
310
+
311
+ /** Match an argument to a configured address, case-insensitively. */
312
+ function resolveAddress(config: ResolvedSwitchbackFileConfig, argument: string): ModelId | undefined {
313
+ const upper = argument.toUpperCase();
314
+ return configuredAddresses(config).find((id) => id.toUpperCase() === upper);
315
+ }
316
+
317
+ /** The trailing "disabled by hand" block of `/switchback`; empty when nothing is disabled. */
318
+ function describeDisabled(disabled: DisabledMap): string {
319
+ const ids = Object.keys(disabled).sort();
320
+ return ids.length === 0 ? "" : `\n\ndisabled by hand (${ids.length}): ${ids.join(", ")}`;
321
+ }
322
+
323
+ /**
324
+ * `/switchback-disable <provider/model>`: keep a configured fallback out of
325
+ * every chain until the user enables it again. Written to the state dir rather
326
+ * than switchback.yaml so a regenerated config cannot silently undo it.
327
+ */
328
+ async function runDisable(ctx: Plugin.Context, sessionID: string, argument: string | undefined): Promise<void> {
329
+ const target = argument?.trim() ?? "";
330
+ const config = readConfig(ctx, sessionID);
331
+ if (config === undefined) return;
332
+ if (target.length === 0) {
333
+ await describeStatus(ctx, sessionID);
334
+ return;
335
+ }
336
+ const address = resolveAddress(config, target);
337
+ if (address === undefined) {
338
+ const known = configuredAddresses(config);
339
+ notify(
340
+ ctx,
341
+ sessionID,
342
+ `switchback: ${JSON.stringify(target)} is not a configured fallback; known addresses: ${
343
+ known.length === 0 ? "(none)" : known.join(", ")
344
+ }`,
345
+ );
346
+ return;
347
+ }
348
+ const changed = disableModel(address);
349
+ await ctx.session
350
+ .synthetic({
351
+ sessionID,
352
+ text: changed
353
+ ? `switchback: disabled ${address} (${disabledFilePath()}) - it stays out of every chain until /switchback-enable ${address}`
354
+ : `switchback: ${address} was already disabled`,
355
+ })
356
+ .catch(() => {
357
+ // Same best-effort rule as notify.
358
+ });
359
+ }
360
+
361
+ /** `/switchback-enable <provider/model|ALL>`: undo a manual disable. */
362
+ async function runEnable(ctx: Plugin.Context, sessionID: string, argument: string | undefined): Promise<void> {
363
+ const target = argument?.trim() ?? "";
364
+ const config = readConfig(ctx, sessionID);
365
+ if (config === undefined) return;
366
+ if (target.length === 0) {
367
+ await describeStatus(ctx, sessionID);
368
+ return;
369
+ }
370
+ if (target.toUpperCase() === "ALL") {
371
+ const count = clearDisabled();
372
+ await ctx.session
373
+ .synthetic({
374
+ sessionID,
375
+ text:
376
+ count === 0
377
+ ? "switchback: no models were disabled"
378
+ : `switchback: re-enabled ${count} disabled model(s)`,
379
+ })
380
+ .catch(() => {
381
+ // Same best-effort rule as notify.
382
+ });
383
+ return;
384
+ }
385
+ const upper = target.toUpperCase();
386
+ const address =
387
+ resolveAddress(config, target) ?? Object.keys(readDisabledMap()).find((id) => id.toUpperCase() === upper);
388
+ if (address === undefined) {
389
+ notify(
390
+ ctx,
391
+ sessionID,
392
+ `switchback: ${JSON.stringify(target)} is not a configured fallback and was not disabled`,
393
+ );
394
+ return;
395
+ }
396
+ const changed = enableModel(address);
262
397
  await ctx.session
263
398
  .synthetic({
264
399
  sessionID,
265
- text: `switchback: ${config.models.length} configured chain(s)\n\n${sections.join("\n\n")}`,
400
+ text: changed ? `switchback: re-enabled ${address}` : `switchback: ${address} was not disabled`,
266
401
  })
267
402
  .catch(() => {
268
403
  // Same best-effort rule as notify.