opencode-switchback 0.0.0-stage → 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 +40 -5
- package/src/index.ts +503 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "opencode-switchback",
|
|
3
|
+
"version": "2026.10.15",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OpenCode V2 plugin for switchback: quota-aware model fallback routing.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jr2804/switchback.git",
|
|
10
|
+
"directory": "packages/opencode"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/jr2804/switchback#readme",
|
|
13
|
+
"bugs": "https://github.com/jr2804/switchback/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"opencode",
|
|
16
|
+
"plugin",
|
|
17
|
+
"switchback",
|
|
18
|
+
"fallback",
|
|
19
|
+
"quota"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.ts",
|
|
26
|
+
"./*": "./src/*"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"switchback-core": "^2026.10.1"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@opencode/plugin": "*"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@earendil-works/pi-ai": "^1.1.0",
|
|
39
|
+
"@opencode/plugin": "^2.0.32"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode V2 plugin for switchback - quota-aware model fallback routing.
|
|
3
|
+
*
|
|
4
|
+
* pi resolves the router's virtual model per request and re-routes provider
|
|
5
|
+
* failures in flight. OpenCode has no virtual-model dispatch, so the same job
|
|
6
|
+
* moves to a turn boundary around the `retry` session hook:
|
|
7
|
+
*
|
|
8
|
+
* 1. OpenCode classifies a failure and proposes its own retry policy; this
|
|
9
|
+
* hook runs after that decision, before any retry is scheduled.
|
|
10
|
+
* 2. The plugin classifies the error with the shared config's classifier
|
|
11
|
+
* (switchback.yaml's `decisionModel`) and, per the verdict, updates the shared
|
|
12
|
+
* account-scoped block state.
|
|
13
|
+
* 3. When the failing model belongs to a configured fallback chain, the
|
|
14
|
+
* session is switched to the next effective fallback via
|
|
15
|
+
* `ctx.session.switchModel`, so the retry OpenCode already scheduled runs
|
|
16
|
+
* on the switched model - the in-flight re-route step wide.
|
|
17
|
+
* 4. When no alternative remains, the hook's `decision` is flipped to
|
|
18
|
+
* `retry: false` so OpenCode surfaces the failure instead of looping into
|
|
19
|
+
* blocked models. Every other case keeps OpenCode's own policy untouched.
|
|
20
|
+
* 5. Without a classifier decision nothing is blocked and no verdict is
|
|
21
|
+
* fabricated (root AGENTS.md rule 1): the sighting lands in the shared
|
|
22
|
+
* crash corpus and OpenCode's policy runs undetoured.
|
|
23
|
+
*
|
|
24
|
+
* Config, block state and crash corpus are shared with the pi extension
|
|
25
|
+
* unchanged: the shared `switchback.yaml` layers plus the neutral state dir
|
|
26
|
+
* (see `switchback-core/config.ts`; override with SWITCHBACK_DIR).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Plugin } from "@opencode/plugin";
|
|
30
|
+
import {
|
|
31
|
+
assessFailure,
|
|
32
|
+
clearDisabled,
|
|
33
|
+
disableModel,
|
|
34
|
+
disabledFilePath,
|
|
35
|
+
enableModel,
|
|
36
|
+
isDisabled,
|
|
37
|
+
loadConfig,
|
|
38
|
+
pickNextEffective,
|
|
39
|
+
readBlockedMap,
|
|
40
|
+
readDisabledMap,
|
|
41
|
+
recordDecideCrash,
|
|
42
|
+
resolveFallbacks,
|
|
43
|
+
unblockModel,
|
|
44
|
+
systemOneClassifierPort,
|
|
45
|
+
} from "switchback-core";
|
|
46
|
+
import type {
|
|
47
|
+
CrashAction,
|
|
48
|
+
DisabledMap,
|
|
49
|
+
HostModel,
|
|
50
|
+
ModelId,
|
|
51
|
+
ResolvedSwitchbackFileConfig,
|
|
52
|
+
RouterRegistryPort,
|
|
53
|
+
AvailabilityPort,
|
|
54
|
+
SecretResolver,
|
|
55
|
+
} from "switchback-core";
|
|
56
|
+
|
|
57
|
+
export default Plugin.define({
|
|
58
|
+
id: "switchback",
|
|
59
|
+
async setup(ctx) {
|
|
60
|
+
// Failure handling first: routing must be live even if a dialog fails to register.
|
|
61
|
+
await ctx.session.hook("retry", (input) => handleRetry(ctx, input));
|
|
62
|
+
await ctx.command.transform((editor) => {
|
|
63
|
+
editor.add({
|
|
64
|
+
name: "switchback",
|
|
65
|
+
description: "Show the fallback chains, their resolved availability and the current block state",
|
|
66
|
+
execute: async ({ sessionID }) => {
|
|
67
|
+
await describeStatus(ctx, sessionID);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
await ctx.command.transform((editor) => {
|
|
72
|
+
editor.add({
|
|
73
|
+
name: "switchback-unblock",
|
|
74
|
+
description: "Clear switchback blocks: ALL, or a model address shown by /switchback",
|
|
75
|
+
execute: async ({ sessionID, prompt }) => {
|
|
76
|
+
await runUnblock(ctx, sessionID, prompt.text);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
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
|
+
});
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The retry hook's payload, narrowed to what switchback reads.
|
|
101
|
+
*
|
|
102
|
+
* OpenCode's own record is `SessionRetry` plus `Model.Ref` plus
|
|
103
|
+
* `SessionError.Error` (see @opencode/plugin/dist/promise/session.d.ts) - the
|
|
104
|
+
* plugin never imports the host's types, only structurally compatible shapes.
|
|
105
|
+
* `decision` stays mutable on purpose: it is OpenCode's pending retry policy
|
|
106
|
+
* and the exhausted path flips it to `retry: false`.
|
|
107
|
+
*/
|
|
108
|
+
interface RetryPayload {
|
|
109
|
+
readonly sessionID: string;
|
|
110
|
+
readonly model: { readonly providerID: string; readonly id: string };
|
|
111
|
+
readonly error: { readonly message: string; readonly response?: { readonly body?: string } | undefined };
|
|
112
|
+
decision: { retry: false } | { retry: true; delay: number };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Classification + block + advance for one failure OpenCode chose to retry.
|
|
117
|
+
*
|
|
118
|
+
* The failing model must be listed in a configured chain's fallbacks; anything
|
|
119
|
+
* else is untouched (pi behaves the same way: its route only ever runs for the
|
|
120
|
+
* switchback virtual model). Without a chain match OpenCode's own policy is
|
|
121
|
+
* kept as-is, which is also what keeps unrelated failures cheap.
|
|
122
|
+
*/
|
|
123
|
+
async function handleRetry(ctx: Plugin.Context, input: RetryPayload): Promise<void> {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const rawMessage = [input.error.message, input.error.response?.body]
|
|
126
|
+
.filter((part) => typeof part === "string" && part.trim().length > 0)
|
|
127
|
+
.join("\n");
|
|
128
|
+
if (rawMessage.length === 0) return; // Provider text is the classifier's only input; nothing to classify.
|
|
129
|
+
const config = readConfig(ctx, input.sessionID);
|
|
130
|
+
if (config === undefined) return;
|
|
131
|
+
|
|
132
|
+
const catalog = (await ctx.model.list()).data;
|
|
133
|
+
const info = findInCatalog(catalog, input.model.providerID, input.model.id);
|
|
134
|
+
const failing: ModelId =
|
|
135
|
+
info !== undefined ? `${info.providerID}/${info.modelID}` : `${input.model.providerID}/${input.model.id}`;
|
|
136
|
+
const chain = config.models.find((model) => model.fallbacks.includes(failing));
|
|
137
|
+
if (chain === undefined) return; // This model answers to no configured chain; OpenCode's policy stands.
|
|
138
|
+
|
|
139
|
+
const availability = catalogPort(catalog);
|
|
140
|
+
const decisionModel = chain.decisionModel;
|
|
141
|
+
const registry: RouterRegistryPort = {
|
|
142
|
+
...availability,
|
|
143
|
+
...systemOneClassifierPort({
|
|
144
|
+
baseUrl: decisionModel?.baseUrl,
|
|
145
|
+
...(decisionModel?.apiKey !== undefined ? { apiKey: decisionModel.apiKey } : {}),
|
|
146
|
+
identity: { provider: decisionModel?.provider ?? "", id: decisionModel?.id ?? "" },
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
const assessed = await assessFailure({
|
|
150
|
+
failedId: failing,
|
|
151
|
+
errorMessage: rawMessage,
|
|
152
|
+
stopReason: undefined,
|
|
153
|
+
decisionModel,
|
|
154
|
+
registry,
|
|
155
|
+
now,
|
|
156
|
+
blocked: readBlockedMap(now),
|
|
157
|
+
// Every hook sighting is fresh: OpenCode owns the retry schedule, and a
|
|
158
|
+
// cycle pi counts in-flight is a cycle OpenCode's own policy already
|
|
159
|
+
// counted. Transient state does not cross the host boundary.
|
|
160
|
+
transientRetries: 0,
|
|
161
|
+
});
|
|
162
|
+
if (assessed.noClassifierReason !== undefined) {
|
|
163
|
+
// No verdict, no block, no switch (root AGENTS.md rule 1). Recording the
|
|
164
|
+
// sighting keeps the gap visible in the shared crash corpus.
|
|
165
|
+
recordDecideCrash(
|
|
166
|
+
assessed.failedId,
|
|
167
|
+
registry,
|
|
168
|
+
rawMessage,
|
|
169
|
+
now,
|
|
170
|
+
null,
|
|
171
|
+
assessed.noClassifierReason,
|
|
172
|
+
"blind-cycle",
|
|
173
|
+
);
|
|
174
|
+
notify(
|
|
175
|
+
ctx,
|
|
176
|
+
input.sessionID,
|
|
177
|
+
`switchback: no classifier decision (${assessed.noClassifierReason}) for ${failing}; no block, no switch`,
|
|
178
|
+
);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const classified = assessed.verdict;
|
|
182
|
+
if (classified === undefined) return; // Unreachable; satisfies the type without a cast.
|
|
183
|
+
|
|
184
|
+
const action: CrashAction = assessed.touchedBlocks ? "blocked+advanced" : "stuck-stayed";
|
|
185
|
+
recordDecideCrash(assessed.failedId, registry, rawMessage, now, classified, undefined, action);
|
|
186
|
+
if (!assessed.touchedBlocks) return; // Transient within OpenCode's own budget: leave its policy standing.
|
|
187
|
+
|
|
188
|
+
const disabled = readDisabledMap();
|
|
189
|
+
const resolved = resolveFallbacks(chain.fallbacks, availability);
|
|
190
|
+
const next = pickNextEffective(resolved, failing, readBlockedMap(now), now, failing, undefined, disabled);
|
|
191
|
+
const until = classified.resetAtMs === undefined ? "" : ` until ${new Date(classified.resetAtMs).toISOString()}`;
|
|
192
|
+
if (next === undefined || next.degraded) {
|
|
193
|
+
// Nothing effective left (or only the failing model itself): surface the
|
|
194
|
+
// failure instead of scheduling another attempt into a blocked model -
|
|
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`;
|
|
198
|
+
notify(
|
|
199
|
+
ctx,
|
|
200
|
+
input.sessionID,
|
|
201
|
+
`switchback: ${failing} blocked${until}; every further fallback in ${chain.id} is unavailable${disabledNote} - surfacing the failure`,
|
|
202
|
+
);
|
|
203
|
+
input.decision = { retry: false };
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const address = splitModelAddress(next.modelId);
|
|
207
|
+
const nextInfo = address === undefined ? undefined : findInCatalog(catalog, address.provider, address.modelId);
|
|
208
|
+
if (nextInfo === undefined) return; // Unreachable: pickNextEffective only offers catalog-backed entries.
|
|
209
|
+
await ctx.session.switchModel({
|
|
210
|
+
sessionID: input.sessionID,
|
|
211
|
+
model: { providerID: nextInfo.providerID, id: nextInfo.modelID },
|
|
212
|
+
});
|
|
213
|
+
notify(
|
|
214
|
+
ctx,
|
|
215
|
+
input.sessionID,
|
|
216
|
+
`switchback: ${classified.class} on ${failing} (blocked${until}) - advancing to ${next.modelId}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* OpenCode has no readable secret vault (its credential API activates stored
|
|
222
|
+
* profiles; it never returns key material), so `secret:<name>` refs resolve
|
|
223
|
+
* against the environment. Unresolvable refs surface through `loadConfig` as
|
|
224
|
+
* a visible config problem - same failure mode as pi, different store.
|
|
225
|
+
*/
|
|
226
|
+
const envSecrets: SecretResolver = {
|
|
227
|
+
get: (name) => process.env[name],
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Read the shared config once per decision; on a config failure the notice is fan-out-safe. */
|
|
231
|
+
function readConfig(ctx: Plugin.Context, sessionID: string): ResolvedSwitchbackFileConfig | undefined {
|
|
232
|
+
try {
|
|
233
|
+
return loadConfig(envSecrets).config;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
notify(
|
|
236
|
+
ctx,
|
|
237
|
+
sessionID,
|
|
238
|
+
`switchback disabled for this attempt (config problem): ${error instanceof Error ? error.message : String(error)}`,
|
|
239
|
+
);
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const seenNotifications = new Map<string, Set<string>>();
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Chat-side notice, one per session-per message (a process-lifetime best
|
|
248
|
+
* effort) so a long-lived session does not spam the conversation. Never
|
|
249
|
+
* blocks: a failed write stays a failed write.
|
|
250
|
+
*/
|
|
251
|
+
function notify(ctx: Plugin.Context, sessionID: string, message: string): void {
|
|
252
|
+
const seen = seenNotifications.get(sessionID);
|
|
253
|
+
if (seen?.has(message) === true) return;
|
|
254
|
+
seenNotifications.set(sessionID, new Set([...(seen ?? []), message]));
|
|
255
|
+
void ctx.session.synthetic({ sessionID, text: message }).catch(() => {
|
|
256
|
+
// Nothing the routing decision can use; swallow.
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The `/switchback` dialog: every chain, every fallback, resolved against the
|
|
262
|
+
* current catalog and live block state.
|
|
263
|
+
*/
|
|
264
|
+
async function describeStatus(ctx: Plugin.Context, sessionID: string): Promise<void> {
|
|
265
|
+
const config = readConfig(ctx, sessionID);
|
|
266
|
+
if (config === undefined) return;
|
|
267
|
+
if (config.models.length === 0) {
|
|
268
|
+
await ctx.session.synthetic({ sessionID, text: "switchback: no virtual models configured" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const catalog = (await ctx.model.list()).data;
|
|
272
|
+
const availability = catalogPort(catalog);
|
|
273
|
+
const blocked = readBlockedMap(Date.now());
|
|
274
|
+
const disabled = readDisabledMap();
|
|
275
|
+
const sections = config.models.map((model) => {
|
|
276
|
+
const resolved = resolveFallbacks(model.fallbacks, availability);
|
|
277
|
+
const rows = resolved.entries.map((entry) => {
|
|
278
|
+
const until = blocked[entry.id];
|
|
279
|
+
const state = isDisabled(entry.id, disabled)
|
|
280
|
+
? "disabled by hand (enable with /switchback-enable)"
|
|
281
|
+
: entry.availability === "effective"
|
|
282
|
+
? until !== undefined
|
|
283
|
+
? `blocked until ${new Date(until).toISOString()}`
|
|
284
|
+
: "available"
|
|
285
|
+
: `unavailable (${entry.reason ?? entry.availability})`;
|
|
286
|
+
return ` - ${entry.id}: ${state}`;
|
|
287
|
+
});
|
|
288
|
+
return `${model.id} (${model.name}): \n${rows.join("\n")}`;
|
|
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);
|
|
397
|
+
await ctx.session
|
|
398
|
+
.synthetic({
|
|
399
|
+
sessionID,
|
|
400
|
+
text: changed ? `switchback: re-enabled ${address}` : `switchback: ${address} was not disabled`,
|
|
401
|
+
})
|
|
402
|
+
.catch(() => {
|
|
403
|
+
// Same best-effort rule as notify.
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Clear ALL blocks, or the one matching a model address / id substring. */
|
|
408
|
+
async function runUnblock(ctx: Plugin.Context, sessionID: string, argument: string | undefined): Promise<void> {
|
|
409
|
+
const target = argument?.trim() ?? "";
|
|
410
|
+
const config = readConfig(ctx, sessionID);
|
|
411
|
+
if (config === undefined) return;
|
|
412
|
+
if (target.length === 0) {
|
|
413
|
+
await describeStatus(ctx, sessionID);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const blocked = readBlockedMap(Date.now());
|
|
417
|
+
const upper = target.toUpperCase();
|
|
418
|
+
const ids =
|
|
419
|
+
upper === "ALL" ? Object.keys(blocked) : Object.keys(blocked).filter((id) => id.toUpperCase() === upper);
|
|
420
|
+
if (ids.length === 0) {
|
|
421
|
+
notify(ctx, sessionID, `switchback: no blocked model matches ${JSON.stringify(target)}; see /switchback`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
for (const id of ids) {
|
|
425
|
+
unblockModel(id);
|
|
426
|
+
}
|
|
427
|
+
const remaining = Object.keys(readBlockedMap(Date.now()));
|
|
428
|
+
await ctx.session
|
|
429
|
+
.synthetic({
|
|
430
|
+
sessionID,
|
|
431
|
+
text: `switchback: unblocked ${ids.length} block(s): ${ids.join(", ")}${
|
|
432
|
+
remaining.length === 0 ? "; no blocks left" : `; blocks left: ${remaining.join(", ")}`
|
|
433
|
+
}`,
|
|
434
|
+
})
|
|
435
|
+
.catch(() => {
|
|
436
|
+
// Same best-effort rule as notify.
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** What the adapter reads off a model catalog entry, narrowed to consumed fields. */
|
|
441
|
+
interface CatalogModel {
|
|
442
|
+
readonly id: string;
|
|
443
|
+
readonly modelID: string;
|
|
444
|
+
readonly providerID: string;
|
|
445
|
+
readonly name: string;
|
|
446
|
+
readonly enabled: boolean | undefined;
|
|
447
|
+
readonly limit: { readonly context: number } | undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The availability port over OpenCode's model catalog. The catalog is re-read
|
|
452
|
+
* per decision (one `ctx.model.list()` call) rather than snapshotted: OpenCode
|
|
453
|
+
* edits its own catalog live and the list never lives longer than one
|
|
454
|
+
* classification.
|
|
455
|
+
*/
|
|
456
|
+
function catalogPort(catalog: readonly CatalogModel[]): AvailabilityPort {
|
|
457
|
+
return {
|
|
458
|
+
find: (provider, modelId) => {
|
|
459
|
+
const info = findInCatalog(catalog, provider, modelId);
|
|
460
|
+
return info === undefined || info.enabled === false ? undefined : hostModel(info);
|
|
461
|
+
},
|
|
462
|
+
hasConfiguredAuth: (model) => findInCatalog(catalog, model.provider, model.id) !== undefined,
|
|
463
|
+
getProviderAuthStatus: (provider) =>
|
|
464
|
+
catalog.some((info) => info.providerID === provider)
|
|
465
|
+
? { configured: true }
|
|
466
|
+
: { configured: false, label: `no enabled models for provider "${provider}" in the OpenCode catalog` },
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** A catalog entry gets a second look when it carries OpenCode's own record type. */
|
|
471
|
+
function hostModel(info: CatalogModel): HostModel {
|
|
472
|
+
return {
|
|
473
|
+
provider: info.providerID,
|
|
474
|
+
id: info.modelID,
|
|
475
|
+
name: info.name,
|
|
476
|
+
// OpenCode's catalog carries no wire-API name; the core port keeps the
|
|
477
|
+
// field for pi-shaped records and no core switchback step reads it here.
|
|
478
|
+
api: "opencode",
|
|
479
|
+
contextWindow: info.limit?.context ?? 0,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** "provider/id" to its halves; undefined for anything else config could hold. */
|
|
484
|
+
function splitModelAddress(serialized: string): { provider: string; modelId: string } | undefined {
|
|
485
|
+
const slash = serialized.indexOf("/");
|
|
486
|
+
if (slash <= 0 || slash === serialized.length - 1) return undefined;
|
|
487
|
+
return { provider: serialized.slice(0, slash), modelId: serialized.slice(slash + 1) };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Resolve a catalog entry by provider plus model id. OpenCode's catalog
|
|
492
|
+
* entries carry both a bare (`modelID`) and a provider-qualified (`id`)
|
|
493
|
+
* address; switchback's config speaks "provider/modelID", and the openCode
|
|
494
|
+
* surfaces so far (the Model.Ref the retry hook gets) have historically been
|
|
495
|
+
* bare - so both forms resolve.
|
|
496
|
+
*/
|
|
497
|
+
function findInCatalog(catalog: readonly CatalogModel[], provider: string, modelId: string): CatalogModel | undefined {
|
|
498
|
+
const bare = splitModelAddress(modelId)?.modelId ?? modelId;
|
|
499
|
+
return catalog.find(
|
|
500
|
+
(info) =>
|
|
501
|
+
info.providerID === provider && (info.modelID === modelId || info.modelID === bare || info.id === modelId),
|
|
502
|
+
);
|
|
503
|
+
}
|
package/README.md
DELETED