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.
Files changed (3) hide show
  1. package/package.json +40 -5
  2. package/src/index.ts +503 -0
  3. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
- "name": "opencode-switchback",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.