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.
- package/package.json +1 -1
- package/src/index.ts +146 -11
package/package.json
CHANGED
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 `
|
|
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
|
|
140
|
+
const decisionModel = chain.decisionModel;
|
|
118
141
|
const registry: RouterRegistryPort = {
|
|
119
142
|
...availability,
|
|
120
143
|
...systemOneClassifierPort({
|
|
121
|
-
baseUrl:
|
|
122
|
-
...(
|
|
123
|
-
identity: { provider:
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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.
|