pi-switchback 2026.10.14 → 2026.10.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -44,11 +44,23 @@ import { createModelPicker } from "./src/model-picker.ts";
44
44
  import { groupLocalEndpoints, registerLocalClassifier } from "./src/local-classifier.ts";
45
45
  import { piThinkingSupport } from "./src/thinking-support.ts";
46
46
  import { SWITCHBACK_THINKING_LEVELS } from "switchback-core/thinking.ts";
47
- import { getPinnedModel, isBlocked, readBlockedMap, readPinMap, setPinnedModel } from "switchback-core/state.ts";
47
+ import {
48
+ clearDisabled,
49
+ disableModel,
50
+ disabledFilePath,
51
+ enableModel,
52
+ getPinnedModel,
53
+ isBlocked,
54
+ isDisabled,
55
+ readBlockedMap,
56
+ readDisabledMap,
57
+ readPinMap,
58
+ setPinnedModel,
59
+ } from "switchback-core/state.ts";
48
60
  import { annotateCrash, hashSample, isValidAnnotationClass, readCrashMap, shortHash } from "switchback-core/crashes.ts";
49
61
  import type {
50
62
  ErrorClass,
51
- JevConfig,
63
+ DecisionModelConfig,
52
64
  ModelId,
53
65
  ResolvedSwitchbackConfig,
54
66
  ResolvedSwitchbackFileConfig,
@@ -66,7 +78,7 @@ export default function (pi: ExtensionAPI) {
66
78
  // SWITCHBACK_DEBUG environment variable for quick toggling without a config edit.
67
79
  const debug = debugFlag(process.env["SWITCHBACK_DEBUG"]) ?? initial.config.debug ?? false;
68
80
 
69
- // A classifier that names its own endpoint (jev.baseUrl) is registered here, so
81
+ // A classifier that names its own endpoint (decisionModel.baseUrl) is registered here, so
70
82
  // switchback can classify through a local System One server (e.g. Ollama
71
83
  // v0.35+) without a pi provider for it. No-op for catalog classifiers. Every
72
84
  // direct endpoint is registered **once**, carrying all the classifier models
@@ -75,8 +87,10 @@ export default function (pi: ExtensionAPI) {
75
87
  // `findOfType`. Decision models count even when no virtual model references
76
88
  // them, so an endpoint stays registered while a model list is being edited.
77
89
  for (const endpoint of groupLocalEndpoints([
78
- ...modelConfigs.flatMap((modelConfig) => (modelConfig.jev === undefined ? [] : [modelConfig.jev])),
79
- ...(initial.config.decisionModels ?? []),
90
+ ...modelConfigs.flatMap((modelConfig) =>
91
+ modelConfig.decisionModel === undefined ? [] : [modelConfig.decisionModel],
92
+ ),
93
+ ...Object.values(initial.config.decisionModels ?? {}),
80
94
  ])) {
81
95
  registerLocalClassifier(pi, endpoint);
82
96
  }
@@ -127,6 +141,7 @@ export default function (pi: ExtensionAPI) {
127
141
  const result = await decide(request.reason, request, modelConfig, ctx.modelRegistry, {
128
142
  now,
129
143
  blocked,
144
+ disabled: readDisabledMap(),
130
145
  support: piThinkingSupport(ctx.modelRegistry),
131
146
  notify,
132
147
  debug,
@@ -184,7 +199,7 @@ export default function (pi: ExtensionAPI) {
184
199
  failedId: `${message.provider}/${message.model}`,
185
200
  errorMessage,
186
201
  stopReason: message.stopReason,
187
- jev: modelConfig.jev,
202
+ decisionModel: modelConfig.decisionModel,
188
203
  registry: ctx.modelRegistry,
189
204
  now,
190
205
  blocked: readBlockedMap(now),
@@ -199,14 +214,16 @@ export default function (pi: ExtensionAPI) {
199
214
  }
200
215
 
201
216
  // `/switchback` — single overview: config source, debug, per-model pin +
202
- // fallbacks (effective / greyed + blocks + reset), active blocks.
217
+ // fallbacks (effective / greyed + blocks + manual disables + reset), active
218
+ // blocks, and the models the user disabled by hand.
203
219
  pi.registerCommand("switchback", {
204
- description: "Show switchback config: source, debug, per-model pin, fallbacks (effective/greyed/blocks)",
220
+ description: "Show switchback config: source, debug, pin, fallbacks (effective/greyed/blocks/disabled)",
205
221
  handler: async (_args, ctx) => {
206
222
  const now = Date.now();
207
223
  const reloaded = safeLoadForCommand(pi, secrets);
208
224
  const config = reloaded.config;
209
225
  const blocked = readBlockedMap(now);
226
+ const disabled = readDisabledMap();
210
227
  const pinMap = readPinMap();
211
228
  const lines: string[] = [];
212
229
  lines.push(`switchback config: ${reloaded.source}${debug ? " [debug on]" : ""}`);
@@ -223,10 +240,14 @@ export default function (pi: ExtensionAPI) {
223
240
  for (const entry of resolved.entries) {
224
241
  if (entry.availability === "effective") {
225
242
  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}`);
243
+ const isDisabledNow = isDisabled(entry.id, disabled);
244
+ const notes: string[] = [];
245
+ if (isDisabledNow) notes.push("disabled by hand");
246
+ if (isBlockedNow)
247
+ notes.push(`blocked, resets in ${Math.ceil((blocked[entry.id]! - now) / 60_000)} min`);
248
+ lines.push(
249
+ ` ${isDisabledNow ? "⊘" : "✓"} ${entry.id}${notes.length === 0 ? "" : ` [${notes.join("; ")}]`}`,
250
+ );
230
251
  } else {
231
252
  lines.push(` ✗ ${entry.id} [${entry.availability}: ${entry.reason ?? ""}]`);
232
253
  }
@@ -241,14 +262,121 @@ export default function (pi: ExtensionAPI) {
241
262
  } else {
242
263
  lines.push("active blocks: (none)");
243
264
  }
265
+ const disabledIds = Object.keys(disabled).sort();
266
+ if (disabledIds.length > 0) {
267
+ lines.push(`disabled by hand (${disabledIds.length}) - ${disabledFilePath()}:`);
268
+ for (const id of disabledIds) lines.push(` ${id}`);
269
+ lines.push(" (/switchback-enable <provider/model|all> restores)");
270
+ } else {
271
+ lines.push("disabled by hand: (none)");
272
+ }
244
273
  await ctx.ui.notify(lines.join("\n"), "info");
245
274
  },
246
275
  });
247
276
 
248
- // `/switchback-config` — interactive editor for the switchback.yaml layers:
277
+ // `/switchback-disable` - keep a configured fallback out of every chain until
278
+ // it is enabled again. The list lives in the state dir (`disabled.json`), not
279
+ // in switchback.yaml, so regenerating the config cannot silently re-enable a
280
+ // model the user turned off.
281
+ pi.registerCommand("switchback-disable", {
282
+ description: "Keep a model out of every chain until re-enabled: /switchback-disable <provider/model>",
283
+ getArgumentCompletions: (argumentPrefix) => {
284
+ const known = configuredFallbacks(safeLoadForCommand(pi, secrets).config);
285
+ return known
286
+ .filter((id) => id.toLowerCase().startsWith(argumentPrefix.toLowerCase()))
287
+ .map((id) => ({ value: id, label: id, description: "configured fallback" }));
288
+ },
289
+ handler: async (args, ctx) => {
290
+ const target = args.trim();
291
+ const addresses = configuredFallbacks(safeLoadForCommand(pi, secrets).config);
292
+ if (target.length === 0) {
293
+ await ctx.ui.notify(
294
+ `switchback-disable: name a model address\nconfigured fallbacks:\n${
295
+ addresses.length === 0 ? " (none)" : addresses.map((id) => ` ${id}`).join("\n")
296
+ }`,
297
+ "info",
298
+ );
299
+ return;
300
+ }
301
+ const address = matchAddress(addresses, target);
302
+ if (address === undefined) {
303
+ await ctx.ui.notify(
304
+ `switchback: ${JSON.stringify(target)} is not a configured fallback; known addresses: ${
305
+ addresses.length === 0 ? "(none)" : addresses.join(", ")
306
+ }`,
307
+ "warning",
308
+ );
309
+ return;
310
+ }
311
+ const changed = disableModel(address);
312
+ await ctx.ui.notify(
313
+ changed
314
+ ? `switchback: disabled ${address} (${disabledFilePath()}) - /switchback-enable ${address} restores it`
315
+ : `switchback: ${address} was already disabled`,
316
+ "info",
317
+ );
318
+ },
319
+ });
320
+
321
+ // `/switchback-enable` - undo a manual disable, one model or all of them.
322
+ pi.registerCommand("switchback-enable", {
323
+ description: "Re-enable a manually disabled model: /switchback-enable <provider/model|all>",
324
+ getArgumentCompletions: (argumentPrefix) => {
325
+ const prefix = argumentPrefix.toLowerCase();
326
+ const disabledIds = Object.keys(readDisabledMap()).sort();
327
+ return ["all", ...disabledIds]
328
+ .filter((id) => id.toLowerCase().startsWith(prefix))
329
+ .map((id) => ({
330
+ value: id,
331
+ label: id,
332
+ description: id === "all" ? "re-enable every disabled model" : "disabled by hand",
333
+ }));
334
+ },
335
+ handler: async (args, ctx) => {
336
+ const target = args.trim();
337
+ const disabled = readDisabledMap();
338
+ const disabledIds = Object.keys(disabled).sort();
339
+ if (target.length === 0) {
340
+ await ctx.ui.notify(
341
+ `switchback-enable: name a model address or "all"\ndisabled by hand:\n${
342
+ disabledIds.length === 0 ? " (none)" : disabledIds.map((id) => ` ${id}`).join("\n")
343
+ }`,
344
+ "info",
345
+ );
346
+ return;
347
+ }
348
+ if (target.toLowerCase() === "all") {
349
+ const count = clearDisabled();
350
+ await ctx.ui.notify(
351
+ count === 0
352
+ ? "switchback: no models were disabled"
353
+ : `switchback: re-enabled ${count} disabled model(s)`,
354
+ "info",
355
+ );
356
+ return;
357
+ }
358
+ const address =
359
+ matchAddress(disabledIds, target) ??
360
+ matchAddress(configuredFallbacks(safeLoadForCommand(pi, secrets).config), target);
361
+ if (address === undefined) {
362
+ await ctx.ui.notify(
363
+ `switchback: ${JSON.stringify(target)} is not a configured fallback and was not disabled`,
364
+ "warning",
365
+ );
366
+ return;
367
+ }
368
+ const changed = enableModel(address);
369
+ await ctx.ui.notify(
370
+ changed ? `switchback: re-enabled ${address}` : `switchback: ${address} was not disabled`,
371
+ "info",
372
+ );
373
+ },
374
+ });
375
+
376
+ // `/switchback-config` - interactive editor for the switchback.yaml layers:
249
377
  // virtual models, fallback order, decision models, encrypted API-key secrets
250
378
  // and the debug flag. Each action is validated through the loader's single
251
- // gate (validateFileConfig + resolveJevConfig) and saved immediately, so the
379
+ // gate (`validateFileConfig`) and saved immediately, so the
252
380
  // file on disk always holds the last good config; a failed action reverts.
253
381
  pi.registerCommand("switchback-config", {
254
382
  description: "Interactively configure virtual models, fallbacks, decision models, API keys and debug",
@@ -266,7 +394,7 @@ export default function (pi: ExtensionAPI) {
266
394
  }
267
395
  : {}),
268
396
  classifierProviders: classifierProvidersFrom(ctx.modelRegistry),
269
- probeClassifier: (jev: JevConfig) => probeClassifierConfig(ctx, jev),
397
+ probeClassifier: (decisionModel: DecisionModelConfig) => probeClassifierConfig(ctx, decisionModel),
270
398
  },
271
399
  secrets,
272
400
  );
@@ -527,23 +655,23 @@ function classifierProvidersFrom(registry: ExtensionContext["modelRegistry"]) {
527
655
  * through switchback's own transport (the registered provider only appears after
528
656
  * a restart), a catalog classifier through pi's registry.
529
657
  */
530
- async function probeClassifierConfig(ctx: ExtensionContext, jev: JevConfig): Promise<ProbeResult> {
531
- if (jev.baseUrl !== undefined) {
658
+ async function probeClassifierConfig(ctx: ExtensionContext, decisionModel: DecisionModelConfig): Promise<ProbeResult> {
659
+ if (decisionModel.baseUrl !== undefined) {
532
660
  return probeLocalClassifier({
533
- baseUrl: jev.baseUrl,
534
- apiKey: jev.apiKey ?? jev.provider,
535
- provider: jev.provider,
536
- id: jev.id,
661
+ baseUrl: decisionModel.baseUrl,
662
+ apiKey: decisionModel.apiKey ?? decisionModel.provider,
663
+ provider: decisionModel.provider,
664
+ id: decisionModel.id,
537
665
  });
538
666
  }
539
- const handle = ctx.modelRegistry.findOfType("classifier", jev.provider, jev.id);
667
+ const handle = ctx.modelRegistry.findOfType("classifier", decisionModel.provider, decisionModel.id);
540
668
  if (handle === undefined) {
541
669
  return {
542
670
  ok: false,
543
671
  answers: {},
544
672
  missing: ["choice", "score", "noul"],
545
673
  ms: 0,
546
- error: `pi has no classifier "${jev.provider}/${jev.id}" in its catalog`,
674
+ error: `pi has no classifier "${decisionModel.provider}/${decisionModel.id}" in its catalog`,
547
675
  };
548
676
  }
549
677
  const started = Date.now();
@@ -612,6 +740,22 @@ function parseCount(args: string, fallback: number): number | undefined {
612
740
  return n;
613
741
  }
614
742
 
743
+ // Every configured fallback address across a config file, first-seen order and
744
+ // deduplicated: the vocabulary the disable/enable commands accept.
745
+ function configuredFallbacks(config: ResolvedSwitchbackFileConfig): ModelId[] {
746
+ const seen = new Set<ModelId>();
747
+ for (const model of config.models) for (const id of model.fallbacks) seen.add(id);
748
+ return [...seen];
749
+ }
750
+
751
+ // Commands are typed by hand, so address matching ignores case (the unblock
752
+ // command works the same way). Returns the canonical configured spelling.
753
+ function matchAddress(addresses: readonly ModelId[], argument: string): ModelId | undefined {
754
+ const wanted = argument.trim().toUpperCase();
755
+ if (wanted.length === 0) return undefined;
756
+ return addresses.find((id) => id.toUpperCase() === wanted);
757
+ }
758
+
615
759
  interface ParsedAnnotateArgs {
616
760
  hash: string;
617
761
  class: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-switchback",
3
- "version": "2026.10.14",
3
+ "version": "2026.10.15",
4
4
  "type": "module",
5
5
  "description": "Quota-aware graceful model fallback for pi via the native pi.registerVirtualModel() API.",
6
6
  "license": "MIT",
@@ -1,7 +1,13 @@
1
1
  /**
2
- * The decision-model half of the `/switchback-config` dialogue: choosing a
3
- * classifier for a virtual model, and the wizard that creates, edits, renames,
4
- * tests and deletes reusable `decisionModels` entries.
2
+ * The decision-model half of the `/switchback-config` dialogue: naming the
3
+ * decision model a virtual model uses, and the wizard that creates, edits,
4
+ * renames, tests and deletes `decisionModels` entries.
5
+ *
6
+ * A decision model is addressed as `provider/model-id` - the same shape as a
7
+ * fallback. The address alone is enough when the host can resolve it from its
8
+ * classifier catalog; a `decisionModels` entry at the same address adds the
9
+ * connection details (`baseUrl`, `apiKey`) that make a direct SystemOne endpoint
10
+ * reachable from any host.
5
11
  *
6
12
  * Split out of `src/dialogue.ts` because it is the larger half and grew
7
13
  * independently: it is the only part that needs the provider catalog, the live
@@ -26,20 +32,18 @@ import {
26
32
  type DecisionModelInput,
27
33
  type DecisionModelPatch,
28
34
  } from "switchback-core/config-editor.ts";
29
- import { LOCAL_CLASSIFIER_APIS } from "switchback-core/config.ts";
30
35
  import {
31
36
  classifierBaseUrlNote,
32
- defaultDecisionModelName,
33
37
  directEndpointWouldClobber,
34
38
  type ClassifierProviderOption,
35
39
  } from "switchback-core/classifier-catalog.ts";
36
40
  import { discoverOllamaModels } from "switchback-core/classifier-discovery.ts";
37
41
  import { formatProbeReport } from "switchback-core/classifier-probe.ts";
38
42
  import {
39
- describeJev,
43
+ describeDecisionModel,
40
44
  errorMessage,
41
45
  findModel,
42
- promptNameLast,
46
+ promptAddressLast,
43
47
  promptOptional,
44
48
  promptRequired,
45
49
  readConfigOrReport,
@@ -50,7 +54,7 @@ import {
50
54
  type DialogueContext,
51
55
  type DialogueSession,
52
56
  } from "./dialogue-ui.ts";
53
- import type { DecisionModelEntry, JevConfig } from "switchback-core/types.ts";
57
+ import type { DecisionModelConfig, DecisionModelEntry } from "switchback-core/types.ts";
54
58
 
55
59
  export async function decisionModelPicker(
56
60
  ctx: DialogueContext,
@@ -58,7 +62,6 @@ export async function decisionModelPicker(
58
62
  virtualId: string,
59
63
  ): Promise<void> {
60
64
  const noneOption = "(none - report and cycle)";
61
- const inlineOption = "(inline - configure directly)...";
62
65
  const newOption = "New decision model...";
63
66
  const backOption = "Back";
64
67
  for (;;) {
@@ -66,28 +69,20 @@ export async function decisionModelPicker(
66
69
  if (config === undefined) return;
67
70
  const model = findModel(config, virtualId);
68
71
  if (model === undefined) return;
69
- const dmLabels = (config.decisionModels ?? []).map((d) => `${d.name} - ${d.provider}/${d.id}`);
72
+ const entries = Object.entries(config.decisionModels ?? {});
73
+ const labels = entries.map(([address, entry]) => describeEntry(address, entry));
70
74
  const choice = await ctx.ui.select(
71
- screenTitle(session.loaded, `decision model for ${virtualId}\ncurrently: ${describeJev(model.jev)}`),
72
- [noneOption, ...dmLabels, inlineOption, newOption, backOption],
75
+ screenTitle(
76
+ session.loaded,
77
+ `decision model for ${virtualId}\ncurrently: ${describeDecisionModel(model.decisionModel)}`,
78
+ ),
79
+ [noneOption, ...labels, newOption, backOption],
73
80
  );
74
81
  if (choice === undefined || choice === backOption) return;
75
82
  if (choice === noneOption) {
76
83
  setDecisionModel(session.loaded, virtualId, null);
77
84
  if (await saveOrRevert(ctx, session)) {
78
- ctx.ui.notify(`${virtualId}: classifier cleared (reports and cycles)`, "info");
79
- return;
80
- }
81
- continue;
82
- }
83
- if (choice === inlineOption) {
84
- const existing = model.jev !== undefined && !("decisionModel" in model.jev) ? model.jev : undefined;
85
- const inline = await collectJevFields(ctx, session, existing, virtualId);
86
- if (inline === undefined) continue;
87
- setDecisionModel(session.loaded, virtualId, inline);
88
- if (await saveOrRevert(ctx, session)) {
89
- ctx.ui.notify(`${virtualId}: inline classifier set`, "info");
90
- await offerClassifierTest(ctx, session, jevFromInput(inline));
85
+ ctx.ui.notify(`${virtualId}: decision model cleared (reports and cycles)`, "info");
91
86
  return;
92
87
  }
93
88
  continue;
@@ -95,20 +90,21 @@ export async function decisionModelPicker(
95
90
  if (choice === newOption) {
96
91
  const created = await decisionModelWizard(ctx, session);
97
92
  if (created === undefined) continue;
98
- setDecisionModel(session.loaded, virtualId, { decisionModel: created.name });
93
+ setDecisionModel(session.loaded, virtualId, created.address);
99
94
  if (await saveOrRevert(ctx, session)) {
100
- ctx.ui.notify(`${virtualId}: decision model "${created.name}" created and set`, "info");
95
+ ctx.ui.notify(`${virtualId}: decision model ${created.address} created and set`, "info");
96
+ await offerClassifierTest(ctx, session, entryForInput(created), created.address);
101
97
  return;
102
98
  }
103
99
  continue;
104
100
  }
105
- const name =
106
- dmLabels.indexOf(choice) >= 0 ? (config.decisionModels ?? [])[dmLabels.indexOf(choice)]?.name : undefined;
107
- const entry = name !== undefined ? (config.decisionModels ?? []).find((d) => d.name === name) : undefined;
108
- if (entry === undefined) continue;
109
- setDecisionModel(session.loaded, virtualId, { decisionModel: entry.name });
101
+ const index = labels.indexOf(choice);
102
+ const picked = index >= 0 ? entries[index] : undefined;
103
+ if (picked === undefined) continue;
104
+ setDecisionModel(session.loaded, virtualId, picked[0]);
110
105
  if (await saveOrRevert(ctx, session)) {
111
- ctx.ui.notify(`${virtualId}: decision model "${entry.name}" set`, "info");
106
+ ctx.ui.notify(`${virtualId}: decision model ${picked[0]} set`, "info");
107
+ await offerClassifierTest(ctx, session, picked[1], picked[0]);
112
108
  return;
113
109
  }
114
110
  }
@@ -120,10 +116,10 @@ export async function decisionModelsMenu(ctx: DialogueContext, session: Dialogue
120
116
  for (;;) {
121
117
  const config = readConfigOrReport(ctx, session.loaded);
122
118
  if (config === undefined) return;
123
- const entries = config.decisionModels ?? [];
124
- const labels = entries.map((d) => `${d.name} - ${d.provider}/${d.id}`);
119
+ const entries = Object.entries(config.decisionModels ?? {});
120
+ const labels = entries.map(([address, entry]) => describeEntry(address, entry));
125
121
  const choice = await ctx.ui.select(
126
- screenTitle(session.loaded, "decision models (reusable classifier configs)"),
122
+ screenTitle(session.loaded, "decision models (address-keyed classifier connection details)"),
127
123
  [...labels, addOption, backOption],
128
124
  );
129
125
  if (choice === undefined || choice === backOption) return;
@@ -131,113 +127,107 @@ export async function decisionModelsMenu(ctx: DialogueContext, session: Dialogue
131
127
  await decisionModelWizard(ctx, session);
132
128
  continue;
133
129
  }
134
- const name = labels.indexOf(choice) >= 0 ? entries[labels.indexOf(choice)]?.name : undefined;
135
- const entry = name !== undefined ? entries.find((d) => d.name === name) : undefined;
136
- if (entry !== undefined) await decisionModelEntryMenu(ctx, session, entry);
130
+ const index = labels.indexOf(choice);
131
+ const picked = index >= 0 ? entries[index] : undefined;
132
+ if (picked !== undefined) await decisionModelEntryMenu(ctx, session, picked[0]);
137
133
  }
138
134
  }
139
135
 
140
- async function decisionModelEntryMenu(
141
- ctx: DialogueContext,
142
- session: DialogueSession,
143
- entry: DecisionModelEntry,
144
- ): Promise<void> {
145
- const renameOption = "Rename (updates references)";
146
- const editOption = "Edit fields";
136
+ async function decisionModelEntryMenu(ctx: DialogueContext, session: DialogueSession, address: string): Promise<void> {
137
+ const renameOption = "Rename address (updates references)";
138
+ const editOption = "Edit connection (baseUrl, apiKey)";
147
139
  const testOption = "Test classifier (choice, score, noul)";
148
140
  const deleteOption = "Delete";
149
141
  const backOption = "Back";
150
142
  for (;;) {
151
143
  const config = readConfigOrReport(ctx, session.loaded);
152
144
  if (config === undefined) return;
153
- const current = (config.decisionModels ?? []).find((d) => d.name === entry.name);
145
+ const current = (config.decisionModels ?? {})[address];
154
146
  if (current === undefined) return; // deleted elsewhere in this session
155
- const choice = await ctx.ui.select(
156
- screenTitle(session.loaded, `${current.name} - ${current.provider}/${current.id}`),
157
- [
158
- renameOption,
159
- editOption,
160
- ...(ctx.probeClassifier !== undefined ? [testOption] : []),
161
- deleteOption,
162
- backOption,
163
- ],
164
- );
147
+ const choice = await ctx.ui.select(screenTitle(session.loaded, describeEntry(address, current)), [
148
+ renameOption,
149
+ editOption,
150
+ ...(ctx.probeClassifier !== undefined ? [testOption] : []),
151
+ deleteOption,
152
+ backOption,
153
+ ]);
165
154
  if (choice === undefined || choice === backOption) return;
166
155
  if (choice === testOption) {
167
- await runClassifierTest(ctx, session, {
168
- provider: current.provider,
169
- id: current.id,
170
- ...(current.baseUrl !== undefined ? { baseUrl: current.baseUrl } : {}),
171
- ...(current.api !== undefined ? { api: current.api } : {}),
172
- ...(current.apiKey !== undefined ? { apiKey: current.apiKey } : {}),
173
- });
156
+ await runClassifierTest(ctx, session, current, address);
174
157
  continue;
175
158
  }
176
159
  if (choice === renameOption) {
177
- const newName = await promptRequired(
160
+ const newAddress = await promptRequired(
178
161
  ctx,
179
- screenTitle(session.loaded, `rename decision model "${current.name}"`),
180
- current.name,
162
+ screenTitle(session.loaded, `rename decision model ${address} (provider/model-id)`),
163
+ address,
181
164
  );
182
- if (newName === undefined || newName === current.name) continue;
183
- renameDecisionModel(session.loaded, current.name, newName);
165
+ if (newAddress === undefined || newAddress === address) continue;
166
+ if (!newAddress.includes("/")) {
167
+ ctx.ui.notify(`an address is "provider/model-id" - "${newAddress}" has no provider`, "warning");
168
+ continue;
169
+ }
170
+ if (Object.hasOwn(config.decisionModels ?? {}, newAddress)) {
171
+ ctx.ui.notify(`a decision model at "${newAddress}" already exists`, "warning");
172
+ continue;
173
+ }
174
+ renameDecisionModel(session.loaded, address, newAddress);
184
175
  if (await saveOrRevert(ctx, session)) {
185
- entry.name = newName;
186
- ctx.ui.notify(`renamed to "${newName}"; references updated`, "info");
176
+ address = newAddress;
177
+ ctx.ui.notify(`renamed to ${newAddress}; references updated`, "info");
187
178
  }
188
179
  continue;
189
180
  }
190
181
  if (choice === editOption) {
191
- const patch = await collectJevPatch(ctx, session, current);
182
+ const patch = await collectConnection(ctx, session, address, current);
192
183
  if (patch === undefined) continue;
193
- updateDecisionModel(session.loaded, current.name, patch);
184
+ updateDecisionModel(session.loaded, address, patch);
194
185
  if (await saveOrRevert(ctx, session)) {
195
- ctx.ui.notify(`decision model "${current.name}" updated`, "info");
186
+ ctx.ui.notify(`decision model ${address} updated`, "info");
196
187
  }
197
188
  continue;
198
189
  }
199
190
  if (choice === deleteOption) {
200
- const refs = countDecisionModelReferences(session.loaded, current.name);
191
+ const refs = countDecisionModelReferences(session.loaded, address);
201
192
  if (refs > 0) {
202
193
  ctx.ui.notify(
203
- `"${current.name}" is still referenced by ${refs} model(s) - set their decision model first`,
194
+ `${address} is still named by ${refs} model(s) - set their decision model first`,
204
195
  "warning",
205
196
  );
206
197
  continue;
207
198
  }
208
199
  const confirmed = await ctx.ui.confirm(
209
- screenTitle(session.loaded, `delete decision model "${current.name}"`),
210
- `Delete "${current.name}" (${current.provider}/${current.id}) from ${session.loaded.path}?`,
200
+ screenTitle(session.loaded, `delete decision model ${address}`),
201
+ `Delete ${address} from ${session.loaded.path}? Chains that name it fall back to catalog resolution.`,
211
202
  );
212
203
  if (!confirmed) continue;
213
- removeDecisionModel(session.loaded, current.name);
204
+ removeDecisionModel(session.loaded, address);
214
205
  if (await saveOrRevert(ctx, session)) {
215
- ctx.ui.notify(`deleted "${current.name}"`, "info");
206
+ ctx.ui.notify(`deleted ${address}`, "info");
216
207
  return;
217
208
  }
218
209
  }
219
210
  }
220
211
  }
221
212
 
222
- /** Collect a brand-new decision model entry. The order is provider → baseUrl →
223
- * model id → apiKey → name (last, prefilled). The user can Tab + Enter through
224
- * the whole flow when accepting the defaults; the only prompt that requires a
225
- * real decision is the apiKey action (new secret, keep existing, or none).
213
+ /**
214
+ * Collect a brand-new decision model entry. The order is provider -> baseUrl ->
215
+ * model id -> apiKey -> address (last, prefilled). The address is the map key,
216
+ * so it is asked after the connection details it describes.
226
217
  */
227
218
  async function decisionModelWizard(
228
219
  ctx: DialogueContext,
229
220
  session: DialogueSession,
230
221
  ): Promise<DecisionModelInput | undefined> {
231
- // Tolerant read: on a fresh layer there is no config yet and no name to clash with;
232
- // addDecisionModel + the save gate still own the uniqueness rule.
222
+ // Tolerant read: on a fresh layer there is no config yet and no address to clash with.
233
223
  const config = tryReadConfig(session.loaded);
234
- const knownNames = (config?.decisionModels ?? []).map((d) => d.name);
224
+ const knownAddresses = Object.keys(config?.decisionModels ?? {});
235
225
 
236
226
  // 1. Provider
237
227
  const option = await chooseClassifierProvider(ctx, session, undefined);
238
228
  if (option === undefined) return undefined;
239
229
 
240
- // 2. baseUrl (always prefilled for both catalog and local; "-" clears it)
230
+ // 2. baseUrl ("-" keeps catalog resolution; a URL declares a direct endpoint)
241
231
  const baseUrl = await chooseBaseUrl(ctx, session, option, undefined);
242
232
  if (baseUrl === undefined) return undefined;
243
233
 
@@ -245,61 +235,31 @@ async function decisionModelWizard(
245
235
  const id = await chooseClassifierModel(ctx, session, option, undefined, baseUrl ?? option.baseUrl);
246
236
  if (id === undefined) return undefined;
247
237
 
248
- // apiKey / api / baseUrl are loader-coupled: apiKey and api both require a
249
- // baseUrl, so a cleared baseUrl means none of those fields apply. Skip the
250
- // apiKey step entirely when the user typed "-".
251
- let apiKeyResult: ApiKeyAction = { kind: "none" };
238
+ // 4. apiKey: only a direct endpoint carries one (the loader requires it there).
239
+ let pendingKey: ApiKeyAction = { kind: "local" };
252
240
  if (baseUrl !== null) {
253
- // 4. apiKey action: collect the user's choice (none / pick existing / new
254
- // secret value) but defer the secret-store write until we know the
255
- // final decision-model name - the store entry is derived from the
256
- // name so a single secret can be re-keyed under the new name.
257
- apiKeyResult = await apiKeyAction(ctx, session, undefined, option.provider);
258
- if (apiKeyResult.kind === "cancel") return undefined;
259
- }
260
-
261
- // 5. Name (LAST, prefilled). Only the catalog providers need an apiKey at
262
- // all - a local Ollama / llama-server ignores it; a local server also
263
- // needs one as a non-empty transport requirement, but the wizard sets
264
- // it for them when the user picks "no api key" or skips the step.
265
- const rest: Partial<DecisionModelInput> = {};
266
- if (baseUrl !== null && baseUrl !== undefined) rest.baseUrl = baseUrl;
267
- if (baseUrl !== null && baseUrl !== undefined) {
268
- const api = option.api ?? LOCAL_CLASSIFIER_APIS[0];
269
- if (api !== undefined) rest.api = api;
270
- }
271
- if (apiKeyResult.kind === "secret") {
272
- // Defer the secret-store commit until the name is known so the store
273
- // entry follows the decision-model name (`secret:<name>-key`); we pass
274
- // apiKeyResult.value into the new logic further below.
241
+ pendingKey = await apiKeyAction(ctx, session, undefined, `${option.provider}/${id}`);
242
+ if (pendingKey.kind === "cancel") return undefined;
275
243
  }
276
- // For local endpoints the transport defaults to a non-empty bearer token
277
- // (`local-classifier.ts` falls back to the provider id), so an apiKey in
278
- // the config is optional. For catalog providers the user must either
279
- // supply a secret or accept "no api key" - the wizard does not write a
280
- // literal token to disk in either path.
281
244
 
282
- const suggestedName = defaultDecisionModelName(option.provider, id);
283
- const name = await promptNameLast(ctx, session.loaded, suggestedName, knownNames);
284
- if (name === undefined) return undefined;
245
+ // 5. Address (LAST, prefilled). The secret-store entry is derived from the
246
+ // address, so it is committed only after the address is known.
247
+ const address = await promptAddressLast(ctx, session.loaded, `${option.provider}/${id}`, knownAddresses);
248
+ if (address === undefined) return undefined;
285
249
 
286
- // Commit the apiKey value (if any) under the now-known name, then build the
287
- // DecisionModelInput. Deferring the write keeps the secret name aligned with
288
- // the decision-model name regardless of whether the user kept the suggested
289
- // default or typed their own.
290
- let finalRest: Partial<DecisionModelInput> = rest;
291
- if (apiKeyResult.kind === "secret") {
292
- const secretName = secretNameFor(name);
293
- try {
294
- session.secrets.set(secretName, apiKeyResult.value);
295
- } catch (error) {
296
- ctx.ui.notify(`secret store refused the value: ${errorMessage(error)}`, "error");
297
- return undefined;
298
- }
299
- finalRest = { ...rest, apiKeySecretName: secretName };
250
+ let apiKeySecretName: string | undefined;
251
+ if (pendingKey.kind === "new") {
252
+ apiKeySecretName = commitSecret(ctx, session, address, pendingKey.value);
253
+ if (apiKeySecretName === undefined) return undefined;
254
+ } else if (pendingKey.kind === "existing") {
255
+ apiKeySecretName = pendingKey.name;
300
256
  }
301
257
 
302
- const input: DecisionModelInput = { name, provider: option.provider, id, ...finalRest };
258
+ const input: DecisionModelInput = {
259
+ address,
260
+ ...(baseUrl !== null ? { baseUrl } : {}),
261
+ ...(apiKeySecretName !== undefined ? { apiKeySecretName } : {}),
262
+ };
303
263
  try {
304
264
  addDecisionModel(session.loaded, input);
305
265
  } catch (error) {
@@ -307,23 +267,47 @@ async function decisionModelWizard(
307
267
  return undefined;
308
268
  }
309
269
  if (await saveOrRevert(ctx, session)) {
310
- ctx.ui.notify(`decision model "${name}" added`, "info");
311
- await offerClassifierTest(ctx, session, jevFromInput({ provider: option.provider, id, rest: finalRest }));
270
+ ctx.ui.notify(`decision model ${address} added`, "info");
271
+ await offerClassifierTest(ctx, session, entryForInput(input), address);
312
272
  return input;
313
273
  }
314
274
  return undefined;
315
275
  }
316
276
 
317
- /** The inline classifier config a wizard result describes (used for the capability test). */
318
- function jevFromInput(fields: { provider: string; id: string; rest?: Partial<DecisionModelInput> }): JevConfig {
319
- const rest = fields.rest ?? {};
320
- return {
321
- provider: fields.provider,
322
- id: fields.id,
323
- ...(rest.baseUrl !== undefined ? { baseUrl: rest.baseUrl } : {}),
324
- ...(rest.api !== undefined ? { api: rest.api } : {}),
325
- ...(rest.apiKeySecretName !== undefined ? { apiKey: `secret:${rest.apiKeySecretName}` } : {}),
326
- };
277
+ /**
278
+ * Edit an existing entry's connection details. The address (provider/model id)
279
+ * is the map key and is changed through the rename action instead, so this only
280
+ * collects `baseUrl` and `apiKey`.
281
+ */
282
+ async function collectConnection(
283
+ ctx: DialogueContext,
284
+ session: DialogueSession,
285
+ address: string,
286
+ current: DecisionModelEntry,
287
+ ): Promise<DecisionModelPatch | undefined> {
288
+ const baseUrl = await promptOptional(
289
+ ctx,
290
+ screenTitle(session.loaded, `baseUrl for ${address} - a direct SystemOne endpoint ("-" clears it, Esc keeps)`),
291
+ current.baseUrl,
292
+ );
293
+ if (baseUrl === undefined) return undefined;
294
+ const patch: DecisionModelPatch = {};
295
+ if (baseUrl === null) {
296
+ // updateDecisionModel clears apiKey with baseUrl (the loader rule: it requires baseUrl).
297
+ patch.baseUrl = null;
298
+ return patch;
299
+ }
300
+ if (baseUrl !== current.baseUrl) patch.baseUrl = baseUrl;
301
+ const key = await apiKeyAction(ctx, session, current.apiKey, address);
302
+ if (key.kind === "cancel") return undefined;
303
+ if (key.kind === "local") patch.apiKeySecretName = null;
304
+ else if (key.kind === "existing") patch.apiKeySecretName = key.name;
305
+ else if (key.kind === "new") {
306
+ const committedName = commitSecret(ctx, session, address, key.value);
307
+ if (committedName === undefined) return undefined;
308
+ patch.apiKeySecretName = committedName;
309
+ }
310
+ return patch;
327
311
  }
328
312
 
329
313
  /**
@@ -382,22 +366,13 @@ async function chooseClassifierProvider(
382
366
 
383
367
  /**
384
368
  * An endpoint the user names themselves: pi has no catalog entry for it, so
385
- * switchback registers the classifier from the config (`baseUrl` present). No
386
- * base URL or wire API is invented here - the wizard asks for both.
369
+ * switchback registers the classifier from the config (`baseUrl` present).
387
370
  */
388
371
  function declaredEndpoint(provider: string): ClassifierProviderOption {
389
372
  return { provider, label: provider, displayName: provider, models: [], local: true, chatModels: 0 };
390
373
  }
391
374
 
392
- /** Choose the classifier model id from the provider's known classifiers, or type one.
393
- *
394
- * The picker offers what pi's catalog knows for this provider. When the catalog
395
- * knows nothing and the endpoint is reachable, the wizard asks the server
396
- * itself (Ollama's `/api/tags`, filtered by the `decision` capability) so a
397
- * freshly pointed endpoint does not have to wait for a routing failure to reveal
398
- * a wrong model id. Provider-agnostic on purpose: the trigger is "the catalog
399
- * cannot help here", not a hardcoded provider name.
400
- */
375
+ /** Choose the classifier model id from the provider's known classifiers, or type one. */
401
376
  async function chooseClassifierModel(
402
377
  ctx: DialogueContext,
403
378
  session: DialogueSession,
@@ -492,64 +467,93 @@ async function chooseBaseUrl(
492
467
  /**
493
468
  * Send the capability prompt and report the verdict. See `packages/core/src/classifier-probe.ts`.
494
469
  */
495
- async function runClassifierTest(ctx: DialogueContext, session: DialogueSession, jev: JevConfig): Promise<void> {
470
+ async function runClassifierTest(
471
+ ctx: DialogueContext,
472
+ session: DialogueSession,
473
+ entry: DecisionModelEntry,
474
+ address: string,
475
+ ): Promise<void> {
496
476
  if (ctx.probeClassifier === undefined) return;
497
- const label = `${jev.provider}/${jev.id}${jev.baseUrl !== undefined ? ` at ${jev.baseUrl}` : ""}`;
498
- const result = await ctx.probeClassifier(resolveSecretRef(jev, session));
477
+ const resolved = probeConfig(address, entry, session);
478
+ if (resolved === undefined) {
479
+ ctx.ui.notify(`${address}: the address has no "/" - cannot probe it`, "warning");
480
+ return;
481
+ }
482
+ const label = `${resolved.provider}/${resolved.id}${
483
+ resolved.baseUrl !== undefined ? ` at ${resolved.baseUrl}` : ""
484
+ }`;
485
+ const result = await ctx.probeClassifier(resolved);
499
486
  ctx.ui.notify(formatProbeReport({ label, result }), result.ok ? "info" : "warning");
500
487
  }
501
488
 
502
489
  /**
503
- * Offer the one-shot capability test after a classifier config was written.
490
+ * Offer the one-shot capability test after a decision model was written.
504
491
  *
505
492
  * Most SystemOne endpoints publish no model list, so the model id is guessed;
506
493
  * this is where a wrong guess (or a model without the `decision` capability)
507
494
  * surfaces, instead of at the first real routing failure.
508
495
  */
509
- async function offerClassifierTest(ctx: DialogueContext, session: DialogueSession, jev: JevConfig): Promise<void> {
496
+ async function offerClassifierTest(
497
+ ctx: DialogueContext,
498
+ session: DialogueSession,
499
+ entry: DecisionModelEntry,
500
+ address: string,
501
+ ): Promise<void> {
510
502
  if (ctx.probeClassifier === undefined) return;
511
503
  const confirmed = await ctx.ui.confirm(
512
- screenTitle(session.loaded, `test ${jev.provider}/${jev.id} now?`),
504
+ screenTitle(session.loaded, `test ${address} now?`),
513
505
  "Sends one SystemOne prompt that exercises choice, score and noul, so a wrong model id or endpoint shows up immediately.",
514
506
  );
515
507
  if (!confirmed) return;
516
- await runClassifierTest(ctx, session, jev);
508
+ await runClassifierTest(ctx, session, entry, address);
517
509
  }
518
510
 
519
- /** Classifier fields for a NEW inline config / decision model. Undefined = user backed out. */
520
- async function collectJevFields(
521
- ctx: DialogueContext,
511
+ function describeEntry(address: string, entry: DecisionModelEntry): string {
512
+ return entry.baseUrl === undefined
513
+ ? `${address} - resolved from the classifier catalog`
514
+ : `${address} - direct: ${entry.baseUrl}`;
515
+ }
516
+
517
+ function splitAddress(address: string): { provider: string; id: string } | undefined {
518
+ const slash = address.indexOf("/");
519
+ if (slash <= 0 || slash === address.length - 1) return undefined;
520
+ return { provider: address.slice(0, slash), id: address.slice(slash + 1) };
521
+ }
522
+
523
+ /** The entry a wizard result describes, for the capability test. */
524
+ function entryForInput(input: DecisionModelInput): DecisionModelEntry {
525
+ if (input.baseUrl === undefined) return {};
526
+ return {
527
+ baseUrl: input.baseUrl,
528
+ apiKey: input.apiKeySecretName === undefined ? "local" : `secret:${input.apiKeySecretName}`,
529
+ };
530
+ }
531
+
532
+ /** Resolve an entry into the probe's shape, pulling a `secret:` value from the store. */
533
+ function probeConfig(
534
+ address: string,
535
+ entry: DecisionModelEntry,
522
536
  session: DialogueSession,
523
- existing: JevConfig | undefined,
524
- hint?: string,
525
- ): Promise<{ provider: string; id: string; rest?: Partial<DecisionModelInput> } | undefined> {
526
- const option = await chooseClassifierProvider(ctx, session, existing?.provider);
527
- if (option === undefined) return undefined;
528
- const baseUrl = await chooseBaseUrl(ctx, session, option, existing?.baseUrl);
529
- if (baseUrl === undefined) return undefined;
530
- const id = await chooseClassifierModel(ctx, session, option, existing?.id, baseUrl ?? option.baseUrl);
531
- if (id === undefined) return undefined;
532
- if (baseUrl === null) return { provider: option.provider, id };
533
- // `api` is aligned with the endpoint instead of asked: a direct endpoint speaks
534
- // exactly one wire API (switchback's own SystemOne transport), so the old
535
- // "(none)" choice offered an option that does not exist.
536
- const api = option.api ?? LOCAL_CLASSIFIER_APIS[0];
537
- const rest: Partial<DecisionModelInput> = { baseUrl, ...(api !== undefined ? { api } : {}) };
538
- const key = await apiKeyAction(ctx, session, existing?.apiKey, hint ?? option.provider);
539
- if (key.kind === "cancel") return undefined;
540
- if (key.kind === "secret") {
541
- const committedName = commitSecret(ctx, session, hint ?? option.provider, key.value);
542
- if (committedName === undefined) return undefined;
543
- rest.apiKeySecretName = committedName;
537
+ ): DecisionModelConfig | undefined {
538
+ const parts = splitAddress(address);
539
+ if (parts === undefined) return undefined;
540
+ let apiKey = entry.apiKey;
541
+ if (apiKey !== undefined && apiKey.startsWith("secret:")) {
542
+ const stored = session.secrets.get(apiKey.slice("secret:".length));
543
+ if (stored !== undefined) apiKey = stored;
544
544
  }
545
- return { provider: option.provider, id, rest };
545
+ return {
546
+ provider: parts.provider,
547
+ id: parts.id,
548
+ ...(entry.baseUrl !== undefined ? { baseUrl: entry.baseUrl } : {}),
549
+ ...(apiKey !== undefined ? { apiKey } : {}),
550
+ };
546
551
  }
547
552
 
548
553
  /**
549
- * Commit a secret value to the store under a name derived from `hint`. Returns
550
- * the chosen name on success, undefined on failure (the wizard treats undefined
551
- * as user cancellation). The hint should be the decision-model name when one
552
- * exists, or the provider id for inline jev.
554
+ * Commit a secret value to the store under a name derived from the address.
555
+ * Returns the chosen name on success, undefined on failure (the wizard treats
556
+ * undefined as user cancellation).
553
557
  */
554
558
  function commitSecret(ctx: DialogueContext, session: DialogueSession, hint: string, value: string): string | undefined {
555
559
  const name = secretNameFor(hint);
@@ -562,64 +566,19 @@ function commitSecret(ctx: DialogueContext, session: DialogueSession, hint: stri
562
566
  }
563
567
  }
564
568
 
565
- /** Patch fields for an EXISTING decision model. Undefined = user backed out. */
566
- async function collectJevPatch(
567
- ctx: DialogueContext,
568
- session: DialogueSession,
569
- current: DecisionModelEntry,
570
- ): Promise<DecisionModelPatch | undefined> {
571
- const option = await chooseClassifierProvider(ctx, session, current.provider);
572
- if (option === undefined) return undefined;
573
- const patch: DecisionModelPatch = {};
574
- if (option.provider !== current.provider) patch.provider = option.provider;
575
- const baseUrl = await chooseBaseUrl(ctx, session, option, current.baseUrl);
576
- if (baseUrl === undefined) return undefined;
577
- const id = await chooseClassifierModel(ctx, session, option, current.id, baseUrl ?? option.baseUrl);
578
- if (id === undefined) return undefined;
579
- if (id !== current.id) patch.id = id;
580
- if (baseUrl === null) {
581
- // updateDecisionModel clears api/apiKey with baseUrl (loader rule: they require it).
582
- patch.baseUrl = null;
583
- return patch;
584
- }
585
- patch.baseUrl = baseUrl;
586
- // Keep the entry's own api, or set the endpoint's for a newly added one.
587
- if (current.api === undefined) {
588
- const api = option.api ?? LOCAL_CLASSIFIER_APIS[0];
589
- if (api !== undefined) patch.api = api;
590
- }
591
- const key = await apiKeyAction(ctx, session, current.apiKey, current.name);
592
- if (key.kind === "cancel") return undefined;
593
- if (key.kind === "none") patch.apiKeySecretName = null;
594
- else if (key.kind === "secret") {
595
- const committedName = commitSecret(ctx, session, current.name, key.value);
596
- if (committedName === undefined) return undefined;
597
- patch.apiKeySecretName = committedName;
598
- }
599
- return patch;
600
- }
601
- /** Resolve a `secret:<name>` reference for use (the probe needs the real token). */
602
- function resolveSecretRef(jev: JevConfig, session: DialogueSession): JevConfig {
603
- const apiKey = jev.apiKey;
604
- if (apiKey === undefined || !apiKey.startsWith("secret:")) return jev;
605
- const stored = session.secrets.get(apiKey.slice("secret:".length));
606
- return stored === undefined ? jev : { ...jev, apiKey: stored };
607
- }
608
-
609
569
  /**
610
570
  * What the user decided about the API key.
611
571
  *
612
- * `keep` leaves the field untouched (an existing key stays), `none` means "no
613
- * key" (a new entry gets none, an existing one loses it), `secret` names the
614
- * store entry to reference, and `cancel` abandons the whole step. Collapsing
615
- * these into one nullable string made "no key" indistinguishable from "back
616
- * out", which turned a valid choice into an aborted wizard.
572
+ * `keep` leaves the field untouched (an existing key stays), `local` means "no
573
+ * secret - a local endpoint ignores the bearer token", `new` carries a value to
574
+ * store under a derived name, `existing` names a stored secret to reference,
575
+ * and `cancel` abandons the whole step.
617
576
  */
618
577
  type ApiKeyAction =
619
578
  | { kind: "keep" }
620
- | { kind: "none" }
621
- /** Value picked up; the wizard commits it under a name it derives later. */
622
- | { kind: "secret"; value: string }
579
+ | { kind: "local" }
580
+ | { kind: "new"; value: string }
581
+ | { kind: "existing"; name: string }
623
582
  | { kind: "cancel" };
624
583
 
625
584
  /**
@@ -635,20 +594,20 @@ async function apiKeyAction(
635
594
  current: string | undefined,
636
595
  hint: string,
637
596
  ): Promise<ApiKeyAction> {
638
- const hasCurrent = current !== undefined;
597
+ const hasCurrent = current !== undefined && current !== "local";
639
598
  const keepOption = hasCurrent
640
599
  ? `Keep current (${current.startsWith("secret:") ? current : "value set by hand"})`
641
- : "(no API key)";
600
+ : "(no API key - local endpoint)";
642
601
  const existingOption = "Use existing secret...";
643
602
  const newOption = "New secret...";
644
- const removeOption = "Remove API key";
603
+ const removeOption = "No secret (local endpoint)";
645
604
  const choice = await ctx.ui.select(
646
605
  screenTitle(session.loaded, "API key - stored encrypted; the config only carries secret:<name>"),
647
606
  hasCurrent ? [keepOption, existingOption, newOption, removeOption] : [keepOption, existingOption, newOption],
648
607
  );
649
608
  if (choice === undefined) return { kind: "cancel" };
650
- if (choice === keepOption) return hasCurrent ? { kind: "keep" } : { kind: "none" };
651
- if (choice === removeOption) return { kind: "none" };
609
+ if (choice === keepOption) return hasCurrent ? { kind: "keep" } : { kind: "local" };
610
+ if (choice === removeOption) return { kind: "local" };
652
611
  if (choice === existingOption) {
653
612
  const names = session.secrets.list();
654
613
  if (names.length === 0) {
@@ -656,7 +615,7 @@ async function apiKeyAction(
656
615
  return { kind: "cancel" };
657
616
  }
658
617
  const name = await ctx.ui.select(screenTitle(session.loaded, "reference which stored secret?"), [...names]);
659
- return name === undefined ? { kind: "cancel" } : { kind: "secret", value: name };
618
+ return name === undefined ? { kind: "cancel" } : { kind: "existing", name };
660
619
  }
661
620
  if (choice !== newOption) return { kind: "cancel" }; // an option this flow does not know: back out
662
621
  const value = await promptRequired(
@@ -668,5 +627,5 @@ async function apiKeyAction(
668
627
  "secret value",
669
628
  );
670
629
  if (value === undefined) return { kind: "cancel" };
671
- return { kind: "secret", value };
630
+ return { kind: "new", value };
672
631
  }
@@ -12,8 +12,8 @@
12
12
  * reverts exactly the failed action, and a model lookup,
13
13
  * - every prompt (required, name-last, optional) in one place, so the wording
14
14
  * of "a value is required" or "empty clears, Esc keeps" is defined once,
15
- * - the two pure formatters that render a classifier for a menu
16
- * (`describeJev`) or name its secret entry (`secretNameFor`).
15
+ * - the two pure formatters that render a decision model for a menu
16
+ * (`describeDecisionModel`) or name its secret entry (`secretNameFor`).
17
17
  *
18
18
  * It is a module of its own so the two halves can both depend on it without
19
19
  * importing each other.
@@ -25,7 +25,12 @@ import { loadLayer, readLayerConfig, saveLayer, type LoadedLayer } from "switchb
25
25
  import type { ClassifierProviderOption } from "switchback-core/classifier-catalog.ts";
26
26
  import type { ProbeResult } from "switchback-core/classifier-probe.ts";
27
27
  import type { SecretStore } from "./secrets.ts";
28
- import type { JevConfig, JevRef, SwitchbackConfig, SwitchbackFileConfig } from "switchback-core/types.ts";
28
+ import type {
29
+ DecisionModelConfig,
30
+ DecisionModelId,
31
+ SwitchbackConfig,
32
+ SwitchbackFileConfig,
33
+ } from "switchback-core/types.ts";
29
34
 
30
35
  /** The dialog surface the dialogue needs (a subset of pi's `ctx.ui`). */
31
36
  export interface DialogueUi {
@@ -56,19 +61,17 @@ export interface DialogueContext {
56
61
  * decision model. Supplied by the command handler, which has the model registry
57
62
  * and switchback's transport; absent means the test is not offered.
58
63
  */
59
- probeClassifier?: (jev: JevConfig) => Promise<ProbeResult>;
64
+ probeClassifier?: (decisionModel: DecisionModelConfig) => Promise<ProbeResult>;
60
65
  }
61
66
 
62
67
  export interface DialogueSession {
63
68
  loaded: LoadedLayer;
64
69
  secrets: SecretStore;
65
70
  }
66
- /** Describe a model's classifier for menus: reference, inline, or none. */
67
- export function describeJev(jev: JevRef | undefined): string {
68
- if (jev === undefined) return "(none - report and cycle)";
69
- if ("decisionModel" in jev) return `decision model "${jev.decisionModel}"`;
70
- const direct = jev.baseUrl !== undefined ? ` (direct: ${jev.baseUrl})` : "";
71
- return `${jev.provider}/${jev.id}${direct}`;
71
+ /** Describe a model's decision model for menus: its address, or none. */
72
+ export function describeDecisionModel(decisionModel: DecisionModelId | undefined): string {
73
+ if (decisionModel === undefined) return "(none - report and cycle)";
74
+ return `decision model ${decisionModel}`;
72
75
  }
73
76
  export function errorMessage(error: unknown): string {
74
77
  return error instanceof Error ? error.message : String(error);
@@ -145,29 +148,34 @@ export async function promptRequired(
145
148
  }
146
149
  }
147
150
  /**
148
- * Name-last step of the decision-model wizard: the input arrives prefilled with
149
- * a derived default, empty input keeps the default, Esc aborts. A name already
150
- * in use is rejected with a warning and the prompt loops.
151
+ * Address-last step of the decision-model wizard: the input arrives prefilled
152
+ * with a derived `provider/model-id`, empty input keeps the default, Esc aborts.
153
+ * An address already in use, or one without a `/`, is rejected with a warning
154
+ * and the prompt loops.
151
155
  */
152
- export async function promptNameLast(
156
+ export async function promptAddressLast(
153
157
  ctx: DialogueContext,
154
158
  loaded: LoadedLayer,
155
- suggestedName: string,
156
- knownNames: readonly string[],
159
+ suggestedAddress: string,
160
+ knownAddresses: readonly string[],
157
161
  ): Promise<string | undefined> {
158
162
  for (;;) {
159
163
  const value = await ctx.ui.input(
160
- screenTitle(loaded, `new decision model - name (Enter keeps "${suggestedName}")`),
161
- suggestedName,
164
+ screenTitle(loaded, `new decision model - address (Enter keeps "${suggestedAddress}")`),
165
+ suggestedAddress,
162
166
  );
163
167
  if (value === undefined) return undefined;
164
168
  const trimmed = value.trim();
165
- const name = trimmed.length === 0 ? suggestedName : trimmed;
166
- if (knownNames.includes(name)) {
167
- ctx.ui.notify(`a decision model named "${name}" already exists`, "warning");
169
+ const address = trimmed.length === 0 ? suggestedAddress : trimmed;
170
+ if (!address.includes("/")) {
171
+ ctx.ui.notify(`an address is "provider/model-id" - "${address}" has no provider`, "warning");
168
172
  continue;
169
173
  }
170
- return name;
174
+ if (knownAddresses.includes(address)) {
175
+ ctx.ui.notify(`a decision model at "${address}" already exists`, "warning");
176
+ continue;
177
+ }
178
+ return address;
171
179
  }
172
180
  }
173
181
 
@@ -188,7 +196,7 @@ export function findModel(config: SwitchbackFileConfig, id: string): SwitchbackC
188
196
  }
189
197
  /**
190
198
  * Secret names are an internal detail of the encrypted store, so the dialogue
191
- * derives one instead of asking: `ollama-key`, `local-jev-key`, and so on. A
199
+ * derives one instead of asking: `ollama-key`, `local-decisionModel-key`, and so on. A
192
200
  * second key for the same entry simply replaces the stored value under that name.
193
201
  */
194
202
  export function secretNameFor(hint: string): string {
package/src/dialogue.ts CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  } from "switchback-core/config-editor.ts";
52
52
  import { createSecretStore, type SecretStore } from "./secrets.ts";
53
53
  import {
54
- describeJev,
54
+ describeDecisionModel,
55
55
  errorMessage,
56
56
  findModel,
57
57
  promptRequired,
@@ -186,7 +186,7 @@ async function mainMenu(ctx: DialogueContext, session: DialogueSession): Promise
186
186
  const options: string[] = config.models.map((m) => `${m.id} - ${m.name}`);
187
187
  const addOption = "Add virtual model...";
188
188
  const debugOption = `Toggle debug (currently ${config.debug === true ? "on" : "off"})`;
189
- const dmOption = `Decision models (${config.decisionModels?.length ?? 0})...`;
189
+ const dmOption = `Decision models (${Object.keys(config.decisionModels ?? {}).length})...`;
190
190
  const switchOption = "Switch layer...";
191
191
  const doneOption = "Done";
192
192
  options.push(addOption, debugOption, dmOption, switchOption, doneOption);
@@ -231,7 +231,8 @@ async function virtualModelMenu(
231
231
  const renameIdOption = "Rename model id (pi restart required)";
232
232
  const renameNameOption = "Change display name";
233
233
  const editFallbacks = (n: number): string => `Edit fallbacks (${n})`;
234
- const decisionOption = (config: SwitchbackConfig): string => `Decision model: ${describeJev(config.jev)}`;
234
+ const decisionOption = (config: SwitchbackConfig): string =>
235
+ `Decision model: ${describeDecisionModel(config.decisionModel)}`;
235
236
  const removeOption = "Remove model";
236
237
  const backOption = "Back";
237
238
  for (;;) {
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  export * from "switchback-core";
11
11
  export { buildRoute, ConfigInvalidError } from "./build-route.ts";
12
12
  export { runDialogue, layerOptionLabel } from "./dialogue.ts";
13
- export { describeJev } from "./dialogue-ui.ts";
13
+ export { describeDecisionModel } from "./dialogue-ui.ts";
14
14
  export { createModelPicker, buildModelItems, MODEL_PICKER_MAX_VISIBLE } from "./model-picker.ts";
15
15
  export type { ModelPickerSource, PickerColor, PickerKeybindings, PickerTheme, PickerTui } from "./model-picker.ts";
16
16
  export type { DialogueContext, DialogueUi } from "./dialogue-ui.ts";
@@ -8,8 +8,8 @@
8
8
  * one (its schema has no classifier model type). So switchback declares the
9
9
  * endpoint itself, from its own config.
10
10
  *
11
- * `jev.baseUrl` in `switchback.yaml` is the switch. When present, switchback
12
- * registers `jev.provider` with the classifier model(s) for that endpoint and
11
+ * `decisionModel.baseUrl` in `switchback.yaml` is the switch. When present, switchback
12
+ * registers `decisionModel.provider` with the classifier model(s) for that endpoint and
13
13
  * its own System One transport (packages/core/src/systemone.ts), so the normal
14
14
  * `ctx.modelRegistry.classify()` path - block map, Tier 2b annotation cache,
15
15
  * reset scoring - works unchanged.
@@ -39,7 +39,7 @@
39
39
  import type { ClassifierApi, ClassifierModel } from "@earendil-works/pi-ai";
40
40
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
41
41
  import { SYSTEM_ONE_API, systemOneClassifier } from "switchback-core/systemone.ts";
42
- import type { JevConfig } from "switchback-core/types.ts";
42
+ import type { DecisionModelConfig } from "switchback-core/types.ts";
43
43
 
44
44
  /** The only System One wire protocol switchback drives directly today. */
45
45
  export const LOCAL_CLASSIFIER_API = SYSTEM_ONE_API;
@@ -52,8 +52,8 @@ export const LOCAL_CLASSIFIER_API = SYSTEM_ONE_API;
52
52
  const LOCAL_CLASSIFIER_CONTEXT_WINDOW = 32_768;
53
53
 
54
54
  /** True when the classifier block names its own endpoint instead of a pi catalog entry. */
55
- export function isLocalClassifier(jev: JevConfig | undefined): boolean {
56
- return jev?.baseUrl !== undefined;
55
+ export function isLocalClassifier(decisionModel: DecisionModelConfig | undefined): boolean {
56
+ return decisionModel?.baseUrl !== undefined;
57
57
  }
58
58
 
59
59
  /** One direct endpoint and every classifier model that lives on it. */
@@ -68,7 +68,7 @@ export interface LocalClassifierEndpoint {
68
68
  */
69
69
  apiKey: string;
70
70
  /** Distinct classifier models to register under this provider. */
71
- models: readonly JevConfig[];
71
+ models: readonly DecisionModelConfig[];
72
72
  }
73
73
 
74
74
  /**
@@ -80,14 +80,21 @@ export interface LocalClassifierEndpoint {
80
80
  * model id collapses to one entry - the same classifier is often named by
81
81
  * several virtual models, and by a `decisionModels` entry as well.
82
82
  */
83
- export function groupLocalEndpoints(jevs: readonly JevConfig[]): LocalClassifierEndpoint[] {
84
- const byEndpoint = new Map<string, { provider: string; baseUrl: string; apiKey?: string; models: JevConfig[] }>();
85
- for (const jev of jevs) {
86
- if (jev.baseUrl === undefined) continue;
87
- const key = `${jev.provider}|${jev.baseUrl}`;
88
- const group = byEndpoint.get(key) ?? { provider: jev.provider, baseUrl: jev.baseUrl, models: [] };
89
- if (group.apiKey === undefined && jev.apiKey !== undefined) group.apiKey = jev.apiKey;
90
- if (!group.models.some((model) => model.id === jev.id)) group.models.push(jev);
83
+ export function groupLocalEndpoints(jevs: readonly DecisionModelConfig[]): LocalClassifierEndpoint[] {
84
+ const byEndpoint = new Map<
85
+ string,
86
+ { provider: string; baseUrl: string; apiKey?: string; models: DecisionModelConfig[] }
87
+ >();
88
+ for (const decisionModel of jevs) {
89
+ if (decisionModel.baseUrl === undefined) continue;
90
+ const key = `${decisionModel.provider}|${decisionModel.baseUrl}`;
91
+ const group = byEndpoint.get(key) ?? {
92
+ provider: decisionModel.provider,
93
+ baseUrl: decisionModel.baseUrl,
94
+ models: [],
95
+ };
96
+ if (group.apiKey === undefined && decisionModel.apiKey !== undefined) group.apiKey = decisionModel.apiKey;
97
+ if (!group.models.some((model) => model.id === decisionModel.id)) group.models.push(decisionModel);
91
98
  byEndpoint.set(key, group);
92
99
  }
93
100
  return [...byEndpoint.values()].map((group) => ({
@@ -105,10 +112,10 @@ export function groupLocalEndpoints(jevs: readonly JevConfig[]): LocalClassifier
105
112
  */
106
113
  export function registerLocalClassifier(pi: ExtensionAPI, endpoint: LocalClassifierEndpoint): void {
107
114
  const { provider, baseUrl, apiKey } = endpoint;
108
- const models: ClassifierModel<ClassifierApi>[] = endpoint.models.map((jev) => ({
115
+ const models: ClassifierModel<ClassifierApi>[] = endpoint.models.map((decisionModel) => ({
109
116
  type: "classifier",
110
- id: jev.id,
111
- name: `${jev.id} (switchback local classifier)`,
117
+ id: decisionModel.id,
118
+ name: `${decisionModel.id} (switchback local classifier)`,
112
119
  api: LOCAL_CLASSIFIER_API,
113
120
  provider,
114
121
  baseUrl,