opencode-switchback 0.0.0-stage → 2026.10.14

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 +368 -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.14",
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,368 @@
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 `jev`) 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
+ loadConfig,
33
+ pickNextEffective,
34
+ readBlockedMap,
35
+ recordDecideCrash,
36
+ resolveFallbacks,
37
+ unblockModel,
38
+ systemOneClassifierPort,
39
+ } from "switchback-core";
40
+ import type {
41
+ CrashAction,
42
+ HostModel,
43
+ ModelId,
44
+ ResolvedSwitchbackFileConfig,
45
+ RouterRegistryPort,
46
+ AvailabilityPort,
47
+ SecretResolver,
48
+ } from "switchback-core";
49
+
50
+ export default Plugin.define({
51
+ id: "switchback",
52
+ async setup(ctx) {
53
+ // Failure handling first: routing must be live even if a dialog fails to register.
54
+ await ctx.session.hook("retry", (input) => handleRetry(ctx, input));
55
+ await ctx.command.transform((editor) => {
56
+ editor.add({
57
+ name: "switchback",
58
+ description: "Show the fallback chains, their resolved availability and the current block state",
59
+ execute: async ({ sessionID }) => {
60
+ await describeStatus(ctx, sessionID);
61
+ },
62
+ });
63
+ });
64
+ await ctx.command.transform((editor) => {
65
+ editor.add({
66
+ name: "switchback-unblock",
67
+ description: "Clear switchback blocks: ALL, or a model address shown by /switchback",
68
+ execute: async ({ sessionID, prompt }) => {
69
+ await runUnblock(ctx, sessionID, prompt.text);
70
+ },
71
+ });
72
+ });
73
+ },
74
+ });
75
+
76
+ /**
77
+ * The retry hook's payload, narrowed to what switchback reads.
78
+ *
79
+ * OpenCode's own record is `SessionRetry` plus `Model.Ref` plus
80
+ * `SessionError.Error` (see @opencode/plugin/dist/promise/session.d.ts) - the
81
+ * plugin never imports the host's types, only structurally compatible shapes.
82
+ * `decision` stays mutable on purpose: it is OpenCode's pending retry policy
83
+ * and the exhausted path flips it to `retry: false`.
84
+ */
85
+ interface RetryPayload {
86
+ readonly sessionID: string;
87
+ readonly model: { readonly providerID: string; readonly id: string };
88
+ readonly error: { readonly message: string; readonly response?: { readonly body?: string } | undefined };
89
+ decision: { retry: false } | { retry: true; delay: number };
90
+ }
91
+
92
+ /**
93
+ * Classification + block + advance for one failure OpenCode chose to retry.
94
+ *
95
+ * The failing model must be listed in a configured chain's fallbacks; anything
96
+ * else is untouched (pi behaves the same way: its route only ever runs for the
97
+ * switchback virtual model). Without a chain match OpenCode's own policy is
98
+ * kept as-is, which is also what keeps unrelated failures cheap.
99
+ */
100
+ async function handleRetry(ctx: Plugin.Context, input: RetryPayload): Promise<void> {
101
+ const now = Date.now();
102
+ const rawMessage = [input.error.message, input.error.response?.body]
103
+ .filter((part) => typeof part === "string" && part.trim().length > 0)
104
+ .join("\n");
105
+ if (rawMessage.length === 0) return; // Provider text is the classifier's only input; nothing to classify.
106
+ const config = readConfig(ctx, input.sessionID);
107
+ if (config === undefined) return;
108
+
109
+ const catalog = (await ctx.model.list()).data;
110
+ const info = findInCatalog(catalog, input.model.providerID, input.model.id);
111
+ const failing: ModelId =
112
+ info !== undefined ? `${info.providerID}/${info.modelID}` : `${input.model.providerID}/${input.model.id}`;
113
+ const chain = config.models.find((model) => model.fallbacks.includes(failing));
114
+ if (chain === undefined) return; // This model answers to no configured chain; OpenCode's policy stands.
115
+
116
+ const availability = catalogPort(catalog);
117
+ const jev = chain.jev;
118
+ const registry: RouterRegistryPort = {
119
+ ...availability,
120
+ ...systemOneClassifierPort({
121
+ baseUrl: jev?.baseUrl,
122
+ ...(jev?.apiKey !== undefined ? { apiKey: jev.apiKey } : {}),
123
+ identity: { provider: jev?.provider ?? "", id: jev?.id ?? "" },
124
+ }),
125
+ };
126
+ const assessed = await assessFailure({
127
+ failedId: failing,
128
+ errorMessage: rawMessage,
129
+ stopReason: undefined,
130
+ jev,
131
+ registry,
132
+ now,
133
+ blocked: readBlockedMap(now),
134
+ // Every hook sighting is fresh: OpenCode owns the retry schedule, and a
135
+ // cycle pi counts in-flight is a cycle OpenCode's own policy already
136
+ // counted. Transient state does not cross the host boundary.
137
+ transientRetries: 0,
138
+ });
139
+ if (assessed.noClassifierReason !== undefined) {
140
+ // No verdict, no block, no switch (root AGENTS.md rule 1). Recording the
141
+ // sighting keeps the gap visible in the shared crash corpus.
142
+ recordDecideCrash(
143
+ assessed.failedId,
144
+ registry,
145
+ rawMessage,
146
+ now,
147
+ null,
148
+ assessed.noClassifierReason,
149
+ "blind-cycle",
150
+ );
151
+ notify(
152
+ ctx,
153
+ input.sessionID,
154
+ `switchback: no classifier decision (${assessed.noClassifierReason}) for ${failing}; no block, no switch`,
155
+ );
156
+ return;
157
+ }
158
+ const classified = assessed.verdict;
159
+ if (classified === undefined) return; // Unreachable; satisfies the type without a cast.
160
+
161
+ const action: CrashAction = assessed.touchedBlocks ? "blocked+advanced" : "stuck-stayed";
162
+ recordDecideCrash(assessed.failedId, registry, rawMessage, now, classified, undefined, action);
163
+ if (!assessed.touchedBlocks) return; // Transient within OpenCode's own budget: leave its policy standing.
164
+
165
+ const resolved = resolveFallbacks(chain.fallbacks, availability);
166
+ const next = pickNextEffective(resolved, failing, readBlockedMap(now), now, failing);
167
+ const until = classified.resetAtMs === undefined ? "" : ` until ${new Date(classified.resetAtMs).toISOString()}`;
168
+ if (next === undefined || next.degraded) {
169
+ // Nothing effective left (or only the failing model itself): surface the
170
+ // failure instead of scheduling another attempt into a blocked model -
171
+ // pi's stuck behavior when the advance finds nothing left.
172
+ notify(
173
+ ctx,
174
+ input.sessionID,
175
+ `switchback: ${failing} blocked${until}; every further fallback in ${chain.id} is unavailable - surfacing the failure`,
176
+ );
177
+ input.decision = { retry: false };
178
+ return;
179
+ }
180
+ const address = splitModelAddress(next.modelId);
181
+ const nextInfo = address === undefined ? undefined : findInCatalog(catalog, address.provider, address.modelId);
182
+ if (nextInfo === undefined) return; // Unreachable: pickNextEffective only offers catalog-backed entries.
183
+ await ctx.session.switchModel({
184
+ sessionID: input.sessionID,
185
+ model: { providerID: nextInfo.providerID, id: nextInfo.modelID },
186
+ });
187
+ notify(
188
+ ctx,
189
+ input.sessionID,
190
+ `switchback: ${classified.class} on ${failing} (blocked${until}) - advancing to ${next.modelId}`,
191
+ );
192
+ }
193
+
194
+ /**
195
+ * OpenCode has no readable secret vault (its credential API activates stored
196
+ * profiles; it never returns key material), so `secret:<name>` refs resolve
197
+ * against the environment. Unresolvable refs surface through `loadConfig` as
198
+ * a visible config problem - same failure mode as pi, different store.
199
+ */
200
+ const envSecrets: SecretResolver = {
201
+ get: (name) => process.env[name],
202
+ };
203
+
204
+ /** Read the shared config once per decision; on a config failure the notice is fan-out-safe. */
205
+ function readConfig(ctx: Plugin.Context, sessionID: string): ResolvedSwitchbackFileConfig | undefined {
206
+ try {
207
+ return loadConfig(envSecrets).config;
208
+ } catch (error) {
209
+ notify(
210
+ ctx,
211
+ sessionID,
212
+ `switchback disabled for this attempt (config problem): ${error instanceof Error ? error.message : String(error)}`,
213
+ );
214
+ return undefined;
215
+ }
216
+ }
217
+
218
+ const seenNotifications = new Map<string, Set<string>>();
219
+
220
+ /**
221
+ * Chat-side notice, one per session-per message (a process-lifetime best
222
+ * effort) so a long-lived session does not spam the conversation. Never
223
+ * blocks: a failed write stays a failed write.
224
+ */
225
+ function notify(ctx: Plugin.Context, sessionID: string, message: string): void {
226
+ const seen = seenNotifications.get(sessionID);
227
+ if (seen?.has(message) === true) return;
228
+ seenNotifications.set(sessionID, new Set([...(seen ?? []), message]));
229
+ void ctx.session.synthetic({ sessionID, text: message }).catch(() => {
230
+ // Nothing the routing decision can use; swallow.
231
+ });
232
+ }
233
+
234
+ /**
235
+ * The `/switchback` dialog: every chain, every fallback, resolved against the
236
+ * current catalog and live block state.
237
+ */
238
+ async function describeStatus(ctx: Plugin.Context, sessionID: string): Promise<void> {
239
+ const config = readConfig(ctx, sessionID);
240
+ if (config === undefined) return;
241
+ if (config.models.length === 0) {
242
+ await ctx.session.synthetic({ sessionID, text: "switchback: no virtual models configured" });
243
+ return;
244
+ }
245
+ const catalog = (await ctx.model.list()).data;
246
+ const availability = catalogPort(catalog);
247
+ const blocked = readBlockedMap(Date.now());
248
+ const sections = config.models.map((model) => {
249
+ const resolved = resolveFallbacks(model.fallbacks, availability);
250
+ const rows = resolved.entries.map((entry) => {
251
+ const until = blocked[entry.id];
252
+ const state =
253
+ entry.availability === "effective"
254
+ ? until !== undefined
255
+ ? `blocked until ${new Date(until).toISOString()}`
256
+ : "available"
257
+ : `unavailable (${entry.reason ?? entry.availability})`;
258
+ return ` - ${entry.id}: ${state}`;
259
+ });
260
+ return `${model.id} (${model.name}): \n${rows.join("\n")}`;
261
+ });
262
+ await ctx.session
263
+ .synthetic({
264
+ sessionID,
265
+ text: `switchback: ${config.models.length} configured chain(s)\n\n${sections.join("\n\n")}`,
266
+ })
267
+ .catch(() => {
268
+ // Same best-effort rule as notify.
269
+ });
270
+ }
271
+
272
+ /** Clear ALL blocks, or the one matching a model address / id substring. */
273
+ async function runUnblock(ctx: Plugin.Context, sessionID: string, argument: string | undefined): Promise<void> {
274
+ const target = argument?.trim() ?? "";
275
+ const config = readConfig(ctx, sessionID);
276
+ if (config === undefined) return;
277
+ if (target.length === 0) {
278
+ await describeStatus(ctx, sessionID);
279
+ return;
280
+ }
281
+ const blocked = readBlockedMap(Date.now());
282
+ const upper = target.toUpperCase();
283
+ const ids =
284
+ upper === "ALL" ? Object.keys(blocked) : Object.keys(blocked).filter((id) => id.toUpperCase() === upper);
285
+ if (ids.length === 0) {
286
+ notify(ctx, sessionID, `switchback: no blocked model matches ${JSON.stringify(target)}; see /switchback`);
287
+ return;
288
+ }
289
+ for (const id of ids) {
290
+ unblockModel(id);
291
+ }
292
+ const remaining = Object.keys(readBlockedMap(Date.now()));
293
+ await ctx.session
294
+ .synthetic({
295
+ sessionID,
296
+ text: `switchback: unblocked ${ids.length} block(s): ${ids.join(", ")}${
297
+ remaining.length === 0 ? "; no blocks left" : `; blocks left: ${remaining.join(", ")}`
298
+ }`,
299
+ })
300
+ .catch(() => {
301
+ // Same best-effort rule as notify.
302
+ });
303
+ }
304
+
305
+ /** What the adapter reads off a model catalog entry, narrowed to consumed fields. */
306
+ interface CatalogModel {
307
+ readonly id: string;
308
+ readonly modelID: string;
309
+ readonly providerID: string;
310
+ readonly name: string;
311
+ readonly enabled: boolean | undefined;
312
+ readonly limit: { readonly context: number } | undefined;
313
+ }
314
+
315
+ /**
316
+ * The availability port over OpenCode's model catalog. The catalog is re-read
317
+ * per decision (one `ctx.model.list()` call) rather than snapshotted: OpenCode
318
+ * edits its own catalog live and the list never lives longer than one
319
+ * classification.
320
+ */
321
+ function catalogPort(catalog: readonly CatalogModel[]): AvailabilityPort {
322
+ return {
323
+ find: (provider, modelId) => {
324
+ const info = findInCatalog(catalog, provider, modelId);
325
+ return info === undefined || info.enabled === false ? undefined : hostModel(info);
326
+ },
327
+ hasConfiguredAuth: (model) => findInCatalog(catalog, model.provider, model.id) !== undefined,
328
+ getProviderAuthStatus: (provider) =>
329
+ catalog.some((info) => info.providerID === provider)
330
+ ? { configured: true }
331
+ : { configured: false, label: `no enabled models for provider "${provider}" in the OpenCode catalog` },
332
+ };
333
+ }
334
+
335
+ /** A catalog entry gets a second look when it carries OpenCode's own record type. */
336
+ function hostModel(info: CatalogModel): HostModel {
337
+ return {
338
+ provider: info.providerID,
339
+ id: info.modelID,
340
+ name: info.name,
341
+ // OpenCode's catalog carries no wire-API name; the core port keeps the
342
+ // field for pi-shaped records and no core switchback step reads it here.
343
+ api: "opencode",
344
+ contextWindow: info.limit?.context ?? 0,
345
+ };
346
+ }
347
+
348
+ /** "provider/id" to its halves; undefined for anything else config could hold. */
349
+ function splitModelAddress(serialized: string): { provider: string; modelId: string } | undefined {
350
+ const slash = serialized.indexOf("/");
351
+ if (slash <= 0 || slash === serialized.length - 1) return undefined;
352
+ return { provider: serialized.slice(0, slash), modelId: serialized.slice(slash + 1) };
353
+ }
354
+
355
+ /**
356
+ * Resolve a catalog entry by provider plus model id. OpenCode's catalog
357
+ * entries carry both a bare (`modelID`) and a provider-qualified (`id`)
358
+ * address; switchback's config speaks "provider/modelID", and the openCode
359
+ * surfaces so far (the Model.Ref the retry hook gets) have historically been
360
+ * bare - so both forms resolve.
361
+ */
362
+ function findInCatalog(catalog: readonly CatalogModel[], provider: string, modelId: string): CatalogModel | undefined {
363
+ const bare = splitModelAddress(modelId)?.modelId ?? modelId;
364
+ return catalog.find(
365
+ (info) =>
366
+ info.providerID === provider && (info.modelID === modelId || info.modelID === bare || info.id === modelId),
367
+ );
368
+ }
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.