pi-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.
- package/index.ts +633 -0
- package/package.json +54 -5
- package/src/build-route.ts +74 -0
- package/src/dialogue-classifier.ts +672 -0
- package/src/dialogue-ui.ts +201 -0
- package/src/dialogue.ts +425 -0
- package/src/index.ts +18 -0
- package/src/local-classifier.ts +125 -0
- package/src/model-picker.ts +228 -0
- package/src/secrets.ts +328 -0
- package/src/thinking-support.ts +35 -0
- package/README.md +0 -3
package/index.ts
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* switchback - quota-aware graceful model fallback for pi.
|
|
3
|
+
*
|
|
4
|
+
* Registers one virtual model per entry in the user config (so `switchback/auto`
|
|
5
|
+
* and `switchback/auto-flash` both appear in `/model`). Each request is routed
|
|
6
|
+
* through the configured SystemOne classifier (Jev / von / whatever
|
|
7
|
+
* `ctx.modelRegistry.findOfType("classifier", ...)` returns). When the classifier is
|
|
8
|
+
* missing, unresolvable, or its call fails, the router reports the condition
|
|
9
|
+
* visibly (`ctx.ui.notify` when `ctx.hasUI`) and cycles to the next model in the
|
|
10
|
+
* config list without writing to `.pi/switchback.json`. Cross-session blocked-until
|
|
11
|
+
* state is only ever written from a classifier-given class + reset, never from the
|
|
12
|
+
* cycle path.
|
|
13
|
+
*
|
|
14
|
+
* Usage: pi -e ./packages/pi/index.ts --model switchback/auto
|
|
15
|
+
*
|
|
16
|
+
* Configuration: the user config is aggregated from two YAML layers
|
|
17
|
+
* (`<cwd>/.pi/switchback.yaml` on top of `~/.pi/agent/switchback.yaml`), each entry
|
|
18
|
+
* a virtual model. Bare ids (`auto`) are normalized to the provider prefix
|
|
19
|
+
* (`switchback/auto`). Edits to the file are picked up on the next request: the
|
|
20
|
+
* router reloads the config per route, surfaces a warning when a reload fails, and
|
|
21
|
+
* falls back to the last good config so a broken edit never kills the session.
|
|
22
|
+
*
|
|
23
|
+
* The full design lives in the design doc (see the project's task notes for the
|
|
24
|
+
* canonical reference).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { resolveFallbacks } from "switchback-core/availability.ts";
|
|
29
|
+
import type { AvailabilityPort, NotifyPort } from "switchback-core/ports.ts";
|
|
30
|
+
import { SWITCHBACK_PROVIDER, findModelConfig, loadConfig } from "switchback-core/config.ts";
|
|
31
|
+
import { runDialogue } from "./src/dialogue.ts";
|
|
32
|
+
import { createSecretStore } from "./src/secrets.ts";
|
|
33
|
+
import { buildRoute, ConfigInvalidError } from "./src/build-route.ts";
|
|
34
|
+
import { observeUnretriedFailure } from "switchback-core/failure.ts";
|
|
35
|
+
import { decide } from "switchback-core/routing.ts";
|
|
36
|
+
import { buildClassifierProviders } from "switchback-core/classifier-catalog.ts";
|
|
37
|
+
import {
|
|
38
|
+
buildProbeContext,
|
|
39
|
+
probeLocalClassifier,
|
|
40
|
+
summarizeProbe,
|
|
41
|
+
type ProbeResult,
|
|
42
|
+
} from "switchback-core/classifier-probe.ts";
|
|
43
|
+
import { createModelPicker } from "./src/model-picker.ts";
|
|
44
|
+
import { groupLocalEndpoints, registerLocalClassifier } from "./src/local-classifier.ts";
|
|
45
|
+
import { piThinkingSupport } from "./src/thinking-support.ts";
|
|
46
|
+
import { SWITCHBACK_THINKING_LEVELS } from "switchback-core/thinking.ts";
|
|
47
|
+
import { getPinnedModel, isBlocked, readBlockedMap, readPinMap, setPinnedModel } from "switchback-core/state.ts";
|
|
48
|
+
import { annotateCrash, hashSample, isValidAnnotationClass, readCrashMap, shortHash } from "switchback-core/crashes.ts";
|
|
49
|
+
import type {
|
|
50
|
+
ErrorClass,
|
|
51
|
+
JevConfig,
|
|
52
|
+
ModelId,
|
|
53
|
+
ResolvedSwitchbackConfig,
|
|
54
|
+
ResolvedSwitchbackFileConfig,
|
|
55
|
+
SwitchbackState,
|
|
56
|
+
} from "switchback-core/types.ts";
|
|
57
|
+
|
|
58
|
+
export default function (pi: ExtensionAPI) {
|
|
59
|
+
// Secrets: `apiKey: secret:<name>` references in the config are resolved to
|
|
60
|
+
// plaintext here (never written back to the YAML). One store instance for the
|
|
61
|
+
// whole extension lifetime - a DPAPI round-trip is not free.
|
|
62
|
+
const secrets = createSecretStore();
|
|
63
|
+
const initial = safeLoad(pi, secrets);
|
|
64
|
+
const modelConfigs = initial.config.models;
|
|
65
|
+
// Diagnostic switch notifications: the config's `debug: true`, overridden by the
|
|
66
|
+
// SWITCHBACK_DEBUG environment variable for quick toggling without a config edit.
|
|
67
|
+
const debug = debugFlag(process.env["SWITCHBACK_DEBUG"]) ?? initial.config.debug ?? false;
|
|
68
|
+
|
|
69
|
+
// A classifier that names its own endpoint (jev.baseUrl) is registered here, so
|
|
70
|
+
// switchback can classify through a local System One server (e.g. Ollama
|
|
71
|
+
// v0.35+) without a pi provider for it. No-op for catalog classifiers. Every
|
|
72
|
+
// direct endpoint is registered **once**, carrying all the classifier models
|
|
73
|
+
// that share it - pi replaces a provider's model list wholesale when an
|
|
74
|
+
// extension supplies one, and a model left out of that list is invisible to
|
|
75
|
+
// `findOfType`. Decision models count even when no virtual model references
|
|
76
|
+
// them, so an endpoint stays registered while a model list is being edited.
|
|
77
|
+
for (const endpoint of groupLocalEndpoints([
|
|
78
|
+
...modelConfigs.flatMap((modelConfig) => (modelConfig.jev === undefined ? [] : [modelConfig.jev])),
|
|
79
|
+
...(initial.config.decisionModels ?? []),
|
|
80
|
+
])) {
|
|
81
|
+
registerLocalClassifier(pi, endpoint);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Last successfully loaded config; reloaded on every route so edits to
|
|
85
|
+
// switchback.yaml are picked up live. A broken edit fails loudly on the first
|
|
86
|
+
// affected route but does not kill the session.
|
|
87
|
+
let lastGoodConfig: ResolvedSwitchbackFileConfig = initial.config;
|
|
88
|
+
|
|
89
|
+
for (const modelConfig of modelConfigs) {
|
|
90
|
+
const fullVirtualId = modelConfig.id;
|
|
91
|
+
pi.registerVirtualModel<SwitchbackState>({
|
|
92
|
+
provider: virtualProvider(fullVirtualId),
|
|
93
|
+
id: virtualId(fullVirtualId),
|
|
94
|
+
name: modelConfig.name,
|
|
95
|
+
// One fixed category scale for every switchback model. The concrete model's
|
|
96
|
+
// own level map is resolved at activation time by the classifier, so the
|
|
97
|
+
// offered set does not have to be the intersection of the fallbacks' maps.
|
|
98
|
+
thinkingLevels: SWITCHBACK_THINKING_LEVELS,
|
|
99
|
+
async route(request, ctx) {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const blocked = readBlockedMap(now);
|
|
102
|
+
const pinned = getPinnedModel(fullVirtualId);
|
|
103
|
+
// Wire the no-classifier report to ctx.ui.notify when pi is running
|
|
104
|
+
// in a UI-capable mode (TUI / RPC). In non-UI modes (print, RPC-no-ui)
|
|
105
|
+
// the call is a no-op so simulate/replay and headless runs are silent.
|
|
106
|
+
const notify: NotifyPort = ctx.hasUI ? (message, type) => ctx.ui.notify(message, type) : () => {};
|
|
107
|
+
// Live-reload the config so edits to switchback.yaml are picked up without
|
|
108
|
+
// a restart. On parse/validation failure, keep the last good config and
|
|
109
|
+
// warn once; the broken edit does not break the session.
|
|
110
|
+
let activeConfig: ResolvedSwitchbackFileConfig;
|
|
111
|
+
try {
|
|
112
|
+
const reloaded = loadConfig(secrets);
|
|
113
|
+
lastGoodConfig = reloaded.config;
|
|
114
|
+
activeConfig = reloaded.config;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
notify(
|
|
117
|
+
`switchback config reload failed (using last good): ${error instanceof Error ? error.message : String(error)}`,
|
|
118
|
+
"warning",
|
|
119
|
+
);
|
|
120
|
+
activeConfig = lastGoodConfig;
|
|
121
|
+
}
|
|
122
|
+
const modelConfig: ResolvedSwitchbackConfig = findModelConfig(activeConfig, fullVirtualId);
|
|
123
|
+
// The context size pi reports for the *routed* model. Only used to prefer a
|
|
124
|
+
// switch target that can hold the conversation (see packages/core/src/context-fit.ts).
|
|
125
|
+
// pi reports null when no assistant has answered since the last compaction.
|
|
126
|
+
const contextTokens = ctx.getContextUsage()?.tokens ?? undefined;
|
|
127
|
+
const result = await decide(request.reason, request, modelConfig, ctx.modelRegistry, {
|
|
128
|
+
now,
|
|
129
|
+
blocked,
|
|
130
|
+
support: piThinkingSupport(ctx.modelRegistry),
|
|
131
|
+
notify,
|
|
132
|
+
debug,
|
|
133
|
+
...(pinned !== null && pinned !== undefined ? { pinned } : {}),
|
|
134
|
+
...(contextTokens !== undefined ? { contextTokens } : {}),
|
|
135
|
+
});
|
|
136
|
+
return buildRoute(ctx.modelRegistry, result.decision, result.thinkingLevel, result.nextState);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Failures pi will not retry.
|
|
141
|
+
//
|
|
142
|
+
// pi only shows a router an error when `isRetryableAssistantError` accepts
|
|
143
|
+
// its text - a string match against two fixed lists. A provider-limit error
|
|
144
|
+
// that trips the non-retryable list (a quota message whose text happens to
|
|
145
|
+
// mention `billing`, for instance) or neither list never reaches `route()`,
|
|
146
|
+
// so `request.failed` is absent, nothing is classified, the model is never
|
|
147
|
+
// blocked, and the next turn walks back into it. The retry path above is
|
|
148
|
+
// untouched: that one *is* pi's retry, re-routed rather than second-guessed.
|
|
149
|
+
//
|
|
150
|
+
// This hook is the fallback for the cases pi gave up on. It produces the
|
|
151
|
+
// verdict and the block with the same code (`observeUnretriedFailure` ->
|
|
152
|
+
// `assessFailure`), never touches pi's retry machinery, and cannot re-route a
|
|
153
|
+
// run that has already ended - it only ensures the next routing decision
|
|
154
|
+
// leaves the dead model behind, which the `user` path already does for a
|
|
155
|
+
// blocked current model.
|
|
156
|
+
const handledFailures = new Set<string>();
|
|
157
|
+
const rememberHandled = (raw: string): void => {
|
|
158
|
+
handledFailures.add(hashSample(raw));
|
|
159
|
+
if (handledFailures.size > 64) {
|
|
160
|
+
// Small, bounded, session-scoped: only needs to cover the retries of
|
|
161
|
+
// one run, not the whole transcript.
|
|
162
|
+
const oldest = handledFailures.values().next();
|
|
163
|
+
if (!oldest.done) handledFailures.delete(oldest.value);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
pi.on("agent_end", (event, ctx) => {
|
|
167
|
+
void (async () => {
|
|
168
|
+
for (const message of event.messages) {
|
|
169
|
+
if (message.role !== "assistant" || message.stopReason !== "error") continue;
|
|
170
|
+
const errorMessage = message.errorMessage;
|
|
171
|
+
if (errorMessage === undefined || errorMessage.trim().length === 0) continue;
|
|
172
|
+
if (handledFailures.has(hashSample(errorMessage))) continue;
|
|
173
|
+
let config: ResolvedSwitchbackFileConfig;
|
|
174
|
+
try {
|
|
175
|
+
config = loadConfig(secrets).config;
|
|
176
|
+
} catch {
|
|
177
|
+
// A broken config must not turn a provider error into an
|
|
178
|
+
// extension throw; the router will report it on the next route.
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const modelConfig = findModelConfig(config, fullVirtualId);
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const acted = await observeUnretriedFailure({
|
|
184
|
+
failedId: `${message.provider}/${message.model}`,
|
|
185
|
+
errorMessage,
|
|
186
|
+
stopReason: message.stopReason,
|
|
187
|
+
jev: modelConfig.jev,
|
|
188
|
+
registry: ctx.modelRegistry,
|
|
189
|
+
now,
|
|
190
|
+
blocked: readBlockedMap(now),
|
|
191
|
+
...(debug && ctx.hasUI
|
|
192
|
+
? { notify: (m: string, t: "info" | "warning" | "error") => ctx.ui.notify(m, t) }
|
|
193
|
+
: {}),
|
|
194
|
+
});
|
|
195
|
+
if (acted || handledFailures.size === 0) rememberHandled(errorMessage);
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `/switchback` — single overview: config source, debug, per-model pin +
|
|
202
|
+
// fallbacks (effective / greyed + blocks + reset), active blocks.
|
|
203
|
+
pi.registerCommand("switchback", {
|
|
204
|
+
description: "Show switchback config: source, debug, per-model pin, fallbacks (effective/greyed/blocks)",
|
|
205
|
+
handler: async (_args, ctx) => {
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
const reloaded = safeLoadForCommand(pi, secrets);
|
|
208
|
+
const config = reloaded.config;
|
|
209
|
+
const blocked = readBlockedMap(now);
|
|
210
|
+
const pinMap = readPinMap();
|
|
211
|
+
const lines: string[] = [];
|
|
212
|
+
lines.push(`switchback config: ${reloaded.source}${debug ? " [debug on]" : ""}`);
|
|
213
|
+
if (config.debug === true) lines.push("debug: true");
|
|
214
|
+
if (Object.keys(pinMap).length > 0) {
|
|
215
|
+
const pinLines = Object.entries(pinMap)
|
|
216
|
+
.filter(([, p]) => p !== null)
|
|
217
|
+
.map(([v, p]) => ` ${v} -> ${p}`);
|
|
218
|
+
if (pinLines.length > 0) lines.push(`pin:\n${pinLines.join("\n")}`);
|
|
219
|
+
}
|
|
220
|
+
for (const modelConfig of config.models) {
|
|
221
|
+
const resolved = resolveFallbacks(modelConfig.fallbacks, ctx.modelRegistry as AvailabilityPort);
|
|
222
|
+
lines.push(`${modelConfig.id} (${resolved.effectiveCount} effective, ${resolved.greyedCount} greyed):`);
|
|
223
|
+
for (const entry of resolved.entries) {
|
|
224
|
+
if (entry.availability === "effective") {
|
|
225
|
+
const isBlockedNow = isBlocked(entry.id, now, blocked);
|
|
226
|
+
const blockNote = isBlockedNow
|
|
227
|
+
? ` [blocked, resets in ${Math.ceil((blocked[entry.id]! - now) / 60_000)} min]`
|
|
228
|
+
: "";
|
|
229
|
+
lines.push(` ✓ ${entry.id}${blockNote}`);
|
|
230
|
+
} else {
|
|
231
|
+
lines.push(` ✗ ${entry.id} [${entry.availability}: ${entry.reason ?? ""}]`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const activeBlocks = Object.entries(blocked).filter(([, ts]) => ts > now);
|
|
236
|
+
if (activeBlocks.length > 0) {
|
|
237
|
+
lines.push("active blocks:");
|
|
238
|
+
for (const [id, ts] of activeBlocks) {
|
|
239
|
+
lines.push(` ${id} (resets in ${Math.ceil((ts - now) / 60_000)} min)`);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
lines.push("active blocks: (none)");
|
|
243
|
+
}
|
|
244
|
+
await ctx.ui.notify(lines.join("\n"), "info");
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// `/switchback-config` — interactive editor for the switchback.yaml layers:
|
|
249
|
+
// virtual models, fallback order, decision models, encrypted API-key secrets
|
|
250
|
+
// and the debug flag. Each action is validated through the loader's single
|
|
251
|
+
// gate (validateFileConfig + resolveJevConfig) and saved immediately, so the
|
|
252
|
+
// file on disk always holds the last good config; a failed action reverts.
|
|
253
|
+
pi.registerCommand("switchback-config", {
|
|
254
|
+
description: "Interactively configure virtual models, fallbacks, decision models, API keys and debug",
|
|
255
|
+
handler: async (_args, ctx) => {
|
|
256
|
+
await runDialogue(
|
|
257
|
+
{
|
|
258
|
+
hasUI: ctx.hasUI,
|
|
259
|
+
ui: ctx.ui,
|
|
260
|
+
// The searchable catalog picker is TUI-only; other modes fall back to
|
|
261
|
+
// the dialogue's text prompt for `provider/id`.
|
|
262
|
+
...(ctx.mode === "tui"
|
|
263
|
+
? {
|
|
264
|
+
pickModel: (title: string, current: readonly string[]) =>
|
|
265
|
+
pickModelInteractive(ctx, title, current),
|
|
266
|
+
}
|
|
267
|
+
: {}),
|
|
268
|
+
classifierProviders: classifierProvidersFrom(ctx.modelRegistry),
|
|
269
|
+
probeClassifier: (jev: JevConfig) => probeClassifierConfig(ctx, jev),
|
|
270
|
+
},
|
|
271
|
+
secrets,
|
|
272
|
+
);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// `/switchback-next` — cycle the session's virtual-model pin through its fallback
|
|
277
|
+
// list (including "off" between the last fallback and the first). The pin is the
|
|
278
|
+
// user's explicit "route here" override: it wins over both stickiness and the
|
|
279
|
+
// preference list for as long as the pinned model is usable. A blocked or greyed
|
|
280
|
+
// pin is ignored, and the status command shows why.
|
|
281
|
+
pi.registerCommand("switchback-next", {
|
|
282
|
+
description: "Cycle the pin to the next fallback of the current switchback model (pin off to start)",
|
|
283
|
+
handler: async (_args, ctx) => {
|
|
284
|
+
if (!ctx.hasUI) {
|
|
285
|
+
await ctx.ui.notify("/switchback-next needs a UI-capable context (TUI or RPC)", "warning");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const current = ctx.model;
|
|
289
|
+
if (current === undefined) {
|
|
290
|
+
await ctx.ui.notify("no active model in this session", "warning");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (current.provider !== SWITCHBACK_PROVIDER) {
|
|
294
|
+
await ctx.ui.notify(
|
|
295
|
+
`switchback-next only cycles switchback models; current is "${current.provider}/${current.id}"`,
|
|
296
|
+
"warning",
|
|
297
|
+
);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const virtualId = `${current.provider}/${current.id}`;
|
|
301
|
+
const reloaded = safeLoadForCommand(pi, secrets);
|
|
302
|
+
const modelConfig = findModelConfig(reloaded.config, virtualId);
|
|
303
|
+
const choices: ReadonlyArray<ModelId | null> = [null, ...modelConfig.fallbacks];
|
|
304
|
+
const currentPin = getPinnedModel(virtualId);
|
|
305
|
+
const currentIdx = currentPin === undefined ? -1 : choices.indexOf(currentPin);
|
|
306
|
+
const nextIdx = currentIdx < 0 ? 0 : (currentIdx + 1) % choices.length;
|
|
307
|
+
const next = choices[nextIdx] ?? null;
|
|
308
|
+
setPinnedModel(virtualId, next);
|
|
309
|
+
await ctx.ui.notify(
|
|
310
|
+
next === null
|
|
311
|
+
? `${virtualId}: pin cleared (router falls through to stickiness + preference)`
|
|
312
|
+
: `${virtualId}: pinned to ${next}${nextIdx === 0 ? " (cycled)" : ""}`,
|
|
313
|
+
"info",
|
|
314
|
+
);
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// `/switchback-model` sets the routed model directly: pick one of the current
|
|
319
|
+
// virtual model's fallbacks from a list (or clear the pin). Same pin as
|
|
320
|
+
// `/switchback-next`, without cycling through the list.
|
|
321
|
+
pi.registerCommand("switchback-model", {
|
|
322
|
+
description: "Choose the model the current switchback model routes to (or clear the pin)",
|
|
323
|
+
handler: async (_args, ctx) => {
|
|
324
|
+
if (!ctx.hasUI) {
|
|
325
|
+
await ctx.ui.notify("/switchback-model needs a UI-capable context (TUI or RPC)", "warning");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const current = ctx.model;
|
|
329
|
+
if (current === undefined || current.provider !== SWITCHBACK_PROVIDER) {
|
|
330
|
+
await ctx.ui.notify(
|
|
331
|
+
`switchback-model only works on a switchback model; current is "${current?.provider ?? "(none)"}/${current?.id ?? ""}"`,
|
|
332
|
+
"warning",
|
|
333
|
+
);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const virtualId = `${current.provider}/${current.id}`;
|
|
337
|
+
const reloaded = safeLoadForCommand(pi, secrets);
|
|
338
|
+
const modelConfig = findModelConfig(reloaded.config, virtualId);
|
|
339
|
+
const autoLabel = "(auto - clear the pin, route by stickiness and preference)";
|
|
340
|
+
const currentPin = getPinnedModel(virtualId);
|
|
341
|
+
const labels = modelConfig.fallbacks.map((id) => (id === currentPin ? `${id} (current pin)` : id));
|
|
342
|
+
const choice = await ctx.ui.select(`${virtualId} routes to`, [autoLabel, ...labels]);
|
|
343
|
+
if (choice === undefined) return;
|
|
344
|
+
if (choice === autoLabel) {
|
|
345
|
+
setPinnedModel(virtualId, null);
|
|
346
|
+
await ctx.ui.notify(`${virtualId}: pin cleared`, "info");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const picked = modelConfig.fallbacks[labels.indexOf(choice)];
|
|
350
|
+
if (picked === undefined) return;
|
|
351
|
+
setPinnedModel(virtualId, picked);
|
|
352
|
+
await ctx.ui.notify(`${virtualId}: routes to ${picked}`, "info");
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// `/switchback-blocked` lists currently blocked models and their reset times.
|
|
357
|
+
pi.registerCommand("switchback-blocked", {
|
|
358
|
+
description: "List models currently blocked by the switchback router",
|
|
359
|
+
handler: async (_args, ctx) => {
|
|
360
|
+
const now = Date.now();
|
|
361
|
+
const blocked = readBlockedMap(now);
|
|
362
|
+
const entries = Object.entries(blocked)
|
|
363
|
+
.filter(([, ts]) => ts > now)
|
|
364
|
+
.sort(([, a], [, b]) => a - b);
|
|
365
|
+
if (entries.length === 0) {
|
|
366
|
+
await ctx.ui.notify("(no models currently blocked)", "info");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const lines = entries.map(([modelId, ts]) => {
|
|
370
|
+
const mins = Math.ceil((ts - now) / 60_000);
|
|
371
|
+
return ` ${modelId} (resets in ${mins} min)`;
|
|
372
|
+
});
|
|
373
|
+
await ctx.ui.notify(`blocked models:\n${lines.join("\n")}`, "info");
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// `/switchback-crashes [n]` lists the most-recent N entries in crashes.json
|
|
378
|
+
// (default 10). Each row shows the short-hash, provider/model, class-or-reason,
|
|
379
|
+
// count, last-seen, and an [annot] marker when the entry is annotated.
|
|
380
|
+
pi.registerCommand("switchback-crashes", {
|
|
381
|
+
description: "List the most-recent N crash entries (default 10)",
|
|
382
|
+
handler: async (args, ctx) => {
|
|
383
|
+
const n = parseCount(args, 10);
|
|
384
|
+
if (n === undefined) {
|
|
385
|
+
await ctx.ui.notify("usage: /switchback-crashes [n]", "warning");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const map = readCrashMap();
|
|
389
|
+
const entries = Object.entries(map)
|
|
390
|
+
.sort(([, a], [, b]) => b.last - a.last)
|
|
391
|
+
.slice(0, n);
|
|
392
|
+
if (entries.length === 0) {
|
|
393
|
+
await ctx.ui.notify("(no crashes recorded yet)", "info");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const lines: string[] = [`crashes (${entries.length} of ${Object.keys(map).length} total):`];
|
|
397
|
+
for (const [hash, e] of entries) {
|
|
398
|
+
const clsOrReason = e.verdict ? `${e.verdict.class}` : `no-classifier (${e.noClassifierReason ?? "?"})`;
|
|
399
|
+
const annotated = e.annotated ? " [annot]" : "";
|
|
400
|
+
const lastMin = Math.max(0, Math.floor((Date.now() - e.last) / 60_000));
|
|
401
|
+
lines.push(
|
|
402
|
+
` ${shortHash(hash)} ${e.provider}/${e.model} ${clsOrReason} count=${e.count} last=${lastMin}m ago action=${e.action}${annotated}`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
await ctx.ui.notify(lines.join("\n"), "info");
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// `/switchback-annotate <hash> <class> [note...]` writes a user-confirmed class
|
|
410
|
+
// onto a stored crash. After annotation, the Tier 2b cache in classifyError
|
|
411
|
+
// short-circuits the classifier call for the exact same raw bytes.
|
|
412
|
+
pi.registerCommand("switchback-annotate", {
|
|
413
|
+
description: "Annotate a crash with a confirmed class (Tier 2b cache)",
|
|
414
|
+
handler: async (args, ctx) => {
|
|
415
|
+
const parsed = parseAnnotateArgs(args);
|
|
416
|
+
if (parsed === undefined) {
|
|
417
|
+
await ctx.ui.notify("usage: /switchback-annotate <hash-prefix> <class> [note...]", "warning");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const map = readCrashMap();
|
|
421
|
+
const matches = Object.entries(map).filter(([hash]) => hash.startsWith(parsed.hash));
|
|
422
|
+
if (matches.length === 0) {
|
|
423
|
+
await ctx.ui.notify(`no crash matches hash prefix "${parsed.hash}"`, "warning");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (matches.length > 1 && !parsed.hash.match(/^[0-9a-f]{8}$/)) {
|
|
427
|
+
const list = matches.map(([h]) => ` ${shortHash(h)} ${map[h]?.provider}/${map[h]?.model}`).join("\n");
|
|
428
|
+
await ctx.ui.notify(`ambiguous hash prefix; matches:\n${list}\nuse a longer prefix`, "warning");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!isValidAnnotationClass(parsed.class)) {
|
|
432
|
+
await ctx.ui.notify(
|
|
433
|
+
`class must be one of: ${["quota", "auth", "transient", "overflow", "unknown"].join(", ")}`,
|
|
434
|
+
"warning",
|
|
435
|
+
);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const first = matches[0];
|
|
439
|
+
if (first === undefined) return;
|
|
440
|
+
const hash = first[0];
|
|
441
|
+
const crash = first[1];
|
|
442
|
+
const updated = annotateCrash(hash, parsed.class as ErrorClass, parsed.note ?? "");
|
|
443
|
+
if (!updated) {
|
|
444
|
+
await ctx.ui.notify(`annotation failed for ${shortHash(hash)}`, "error");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
await ctx.ui.notify(
|
|
448
|
+
`annotated ${shortHash(hash)} (${crash.provider}/${crash.model}) as ${parsed.class}${parsed.note !== undefined ? ` — note: ${parsed.note}` : ""}`,
|
|
449
|
+
"info",
|
|
450
|
+
);
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Provider part of a configured virtual model id (`switchback/auto` -> `switchback`).
|
|
457
|
+
*/
|
|
458
|
+
function virtualProvider(fullId: string): string {
|
|
459
|
+
const slash = fullId.indexOf("/");
|
|
460
|
+
return slash > 0 ? fullId.slice(0, slash) : SWITCHBACK_PROVIDER;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Id part of a configured virtual model id (`switchback/auto` -> `auto`).
|
|
465
|
+
*/
|
|
466
|
+
function virtualId(fullId: string): string {
|
|
467
|
+
const slash = fullId.indexOf("/");
|
|
468
|
+
return slash >= 0 ? fullId.slice(slash + 1) : fullId;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Parse an on/off environment variable; undefined when it does not carry a flag. */
|
|
472
|
+
/**
|
|
473
|
+
* The dialogue's searchable model picker: every chat model in pi's catalog
|
|
474
|
+
* (switchback's own virtual models excluded), marked ready / no-credentials and
|
|
475
|
+
* with the ones already in the edited list flagged. Built from pi's TUI toolkit
|
|
476
|
+
* - the same components pi's own pickers use (see `src/model-picker.ts`).
|
|
477
|
+
*/
|
|
478
|
+
function pickModelInteractive(
|
|
479
|
+
ctx: ExtensionContext,
|
|
480
|
+
title: string,
|
|
481
|
+
current: readonly string[],
|
|
482
|
+
): Promise<string | undefined> {
|
|
483
|
+
const models = ctx.modelRegistry.getModelsOfType("chat").filter((model) => model.provider !== SWITCHBACK_PROVIDER);
|
|
484
|
+
return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
|
|
485
|
+
createModelPicker({
|
|
486
|
+
tui,
|
|
487
|
+
theme,
|
|
488
|
+
keybindings,
|
|
489
|
+
done,
|
|
490
|
+
title,
|
|
491
|
+
source: {
|
|
492
|
+
models,
|
|
493
|
+
current,
|
|
494
|
+
ready: (provider) => ctx.modelRegistry.getProviderAuthStatus(provider).configured,
|
|
495
|
+
},
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Classifier endpoints for the decision-model wizard: every provider pi reports
|
|
502
|
+
* a classifier model for (with its own display name and base URL), plus the local
|
|
503
|
+
* SystemOne servers with their environment-derived defaults. switchback keeps no
|
|
504
|
+
* provider table of its own.
|
|
505
|
+
*/
|
|
506
|
+
function classifierProvidersFrom(registry: ExtensionContext["modelRegistry"]) {
|
|
507
|
+
return buildClassifierProviders(
|
|
508
|
+
registry.getRegisteredProviderIds().map((id) => {
|
|
509
|
+
const provider = registry.getProvider(id);
|
|
510
|
+
return {
|
|
511
|
+
id,
|
|
512
|
+
name: registry.getProviderDisplayName(id) || provider?.name || id,
|
|
513
|
+
...(provider?.baseUrl !== undefined ? { baseUrl: provider.baseUrl } : {}),
|
|
514
|
+
classifiers: registry
|
|
515
|
+
.getModelsOfType("classifier", id)
|
|
516
|
+
.map((model) => ({ id: model.id, api: model.api })),
|
|
517
|
+
// Chat models pi already owns under this id: registering a direct
|
|
518
|
+
// endpoint here would replace them (pi's `applyExtension`).
|
|
519
|
+
chatModels: registry.getModelsOfType("chat", id).length,
|
|
520
|
+
};
|
|
521
|
+
}),
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Probe a classifier the dialogue just configured: a local `baseUrl` goes
|
|
527
|
+
* through switchback's own transport (the registered provider only appears after
|
|
528
|
+
* a restart), a catalog classifier through pi's registry.
|
|
529
|
+
*/
|
|
530
|
+
async function probeClassifierConfig(ctx: ExtensionContext, jev: JevConfig): Promise<ProbeResult> {
|
|
531
|
+
if (jev.baseUrl !== undefined) {
|
|
532
|
+
return probeLocalClassifier({
|
|
533
|
+
baseUrl: jev.baseUrl,
|
|
534
|
+
apiKey: jev.apiKey ?? jev.provider,
|
|
535
|
+
provider: jev.provider,
|
|
536
|
+
id: jev.id,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const handle = ctx.modelRegistry.findOfType("classifier", jev.provider, jev.id);
|
|
540
|
+
if (handle === undefined) {
|
|
541
|
+
return {
|
|
542
|
+
ok: false,
|
|
543
|
+
answers: {},
|
|
544
|
+
missing: ["choice", "score", "noul"],
|
|
545
|
+
ms: 0,
|
|
546
|
+
error: `pi has no classifier "${jev.provider}/${jev.id}" in its catalog`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
const started = Date.now();
|
|
550
|
+
const result = await ctx.modelRegistry.classify(handle, buildProbeContext());
|
|
551
|
+
return summarizeProbe(result, Date.now() - started);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function debugFlag(value: string | undefined): boolean | undefined {
|
|
555
|
+
const v = value?.trim().toLowerCase();
|
|
556
|
+
if (v === undefined || v === "") return undefined;
|
|
557
|
+
return !(v === "0" || v === "false" || v === "off" || v === "no");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Load the config; surfaces a no-config warning when no file is found rather than
|
|
562
|
+
* throwing (commands run at any time, not just during routing).
|
|
563
|
+
*/
|
|
564
|
+
function safeLoad(
|
|
565
|
+
pi: ExtensionAPI,
|
|
566
|
+
secrets?: { get(name: string): string | undefined },
|
|
567
|
+
): {
|
|
568
|
+
config: ResolvedSwitchbackFileConfig;
|
|
569
|
+
source: string;
|
|
570
|
+
} {
|
|
571
|
+
try {
|
|
572
|
+
return loadConfig(secrets);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
const message =
|
|
575
|
+
error instanceof ConfigInvalidError
|
|
576
|
+
? error.message
|
|
577
|
+
: error instanceof Error
|
|
578
|
+
? error.message
|
|
579
|
+
: String(error);
|
|
580
|
+
// Best-effort notify: commands only exist after this factory completes, so
|
|
581
|
+
// this path is only hit on a truly broken config. Fall back to stderr so the
|
|
582
|
+
// extension is at least visible in the host log.
|
|
583
|
+
console.warn(`switchback: failed to load config (using defaults): ${message}`);
|
|
584
|
+
return {
|
|
585
|
+
config: {
|
|
586
|
+
models: [{ id: `${SWITCHBACK_PROVIDER}/auto`, name: "Auto (Switchback) - defaults", fallbacks: [] }],
|
|
587
|
+
},
|
|
588
|
+
source: "<default - load failed>",
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function safeLoadForCommand(
|
|
594
|
+
pi: ExtensionAPI,
|
|
595
|
+
secrets?: { get(name: string): string | undefined },
|
|
596
|
+
): {
|
|
597
|
+
config: ResolvedSwitchbackFileConfig;
|
|
598
|
+
source: string;
|
|
599
|
+
} {
|
|
600
|
+
return safeLoad(pi, secrets);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export { ConfigInvalidError };
|
|
604
|
+
|
|
605
|
+
// The following parse helpers are tiny, so they live here rather than in a
|
|
606
|
+
// dedicated utility module; they are tested via tests/commands.test.ts.
|
|
607
|
+
function parseCount(args: string, fallback: number): number | undefined {
|
|
608
|
+
const trimmed = args.trim();
|
|
609
|
+
if (trimmed.length === 0) return fallback;
|
|
610
|
+
const n = Number(trimmed);
|
|
611
|
+
if (!Number.isInteger(n) || n < 1) return undefined;
|
|
612
|
+
return n;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
interface ParsedAnnotateArgs {
|
|
616
|
+
hash: string;
|
|
617
|
+
class: string;
|
|
618
|
+
note?: string;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function parseAnnotateArgs(args: string): ParsedAnnotateArgs | undefined {
|
|
622
|
+
const parts = args
|
|
623
|
+
.trim()
|
|
624
|
+
.split(/\s+/u)
|
|
625
|
+
.filter((p) => p.length > 0);
|
|
626
|
+
if (parts.length < 2) return undefined;
|
|
627
|
+
const [maybeHash, maybeClass, ...rest] = parts;
|
|
628
|
+
if (maybeHash === undefined || maybeClass === undefined) return undefined;
|
|
629
|
+
const hashOk = /^[0-9a-f]{4,}$/u.test(maybeHash);
|
|
630
|
+
if (!hashOk) return undefined;
|
|
631
|
+
const note = rest.length > 0 ? rest.join(" ") : undefined;
|
|
632
|
+
return note === undefined ? { hash: maybeHash, class: maybeClass } : { hash: maybeHash, class: maybeClass, note };
|
|
633
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "pi-switchback",
|
|
3
|
+
"version": "2026.10.14",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Quota-aware graceful model fallback for pi via the native pi.registerVirtualModel() API.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jr2804/switchback.git",
|
|
10
|
+
"directory": "packages/pi"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/jr2804/switchback#readme",
|
|
13
|
+
"bugs": "https://github.com/jr2804/switchback/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"pi",
|
|
16
|
+
"extension",
|
|
17
|
+
"switchback",
|
|
18
|
+
"fallback",
|
|
19
|
+
"quota"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/index.ts",
|
|
27
|
+
"./*": "./src/*"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"clean": "echo 'nothing to clean'",
|
|
34
|
+
"build": "echo 'nothing to build'",
|
|
35
|
+
"check": "echo 'nothing to check'"
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./index.ts"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"switchback-core": "^2026.10.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@earendil-works/pi-ai": "^1.1.0",
|
|
52
|
+
"@earendil-works/pi-coding-agent": "^1.1.0",
|
|
53
|
+
"@earendil-works/pi-tui": "^1.1.0"
|
|
54
|
+
}
|
|
55
|
+
}
|