pi-switchback 2026.10.14 → 2026.10.16
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 +167 -23
- package/package.json +1 -1
- package/src/dialogue-classifier.ts +214 -255
- package/src/dialogue-ui.ts +31 -23
- package/src/dialogue.ts +4 -3
- package/src/index.ts +1 -1
- package/src/local-classifier.ts +24 -17
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 {
|
|
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
|
-
|
|
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 (
|
|
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) =>
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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-
|
|
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
|
|
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: (
|
|
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,
|
|
531
|
-
if (
|
|
658
|
+
async function probeClassifierConfig(ctx: ExtensionContext, decisionModel: DecisionModelConfig): Promise<ProbeResult> {
|
|
659
|
+
if (decisionModel.baseUrl !== undefined) {
|
|
532
660
|
return probeLocalClassifier({
|
|
533
|
-
baseUrl:
|
|
534
|
-
apiKey:
|
|
535
|
-
provider:
|
|
536
|
-
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",
|
|
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 "${
|
|
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,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The decision-model half of the `/switchback-config` dialogue:
|
|
3
|
-
*
|
|
4
|
-
* tests and deletes
|
|
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
|
-
|
|
43
|
+
describeDecisionModel,
|
|
40
44
|
errorMessage,
|
|
41
45
|
findModel,
|
|
42
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
72
|
-
|
|
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}:
|
|
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,
|
|
93
|
+
setDecisionModel(session.loaded, virtualId, created.address);
|
|
99
94
|
if (await saveOrRevert(ctx, session)) {
|
|
100
|
-
ctx.ui.notify(`${virtualId}: decision model
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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((
|
|
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 (
|
|
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
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
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
|
-
|
|
142
|
-
|
|
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 ?? []
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
160
|
+
const newAddress = await promptRequired(
|
|
178
161
|
ctx,
|
|
179
|
-
screenTitle(session.loaded, `rename decision model
|
|
180
|
-
|
|
162
|
+
screenTitle(session.loaded, `rename decision model ${address} (provider/model-id)`),
|
|
163
|
+
address,
|
|
181
164
|
);
|
|
182
|
-
if (
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
ctx.ui.notify(`renamed to
|
|
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
|
|
182
|
+
const patch = await collectConnection(ctx, session, address, current);
|
|
192
183
|
if (patch === undefined) continue;
|
|
193
|
-
updateDecisionModel(session.loaded,
|
|
184
|
+
updateDecisionModel(session.loaded, address, patch);
|
|
194
185
|
if (await saveOrRevert(ctx, session)) {
|
|
195
|
-
ctx.ui.notify(`decision model
|
|
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,
|
|
191
|
+
const refs = countDecisionModelReferences(session.loaded, address);
|
|
201
192
|
if (refs > 0) {
|
|
202
193
|
ctx.ui.notify(
|
|
203
|
-
|
|
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
|
|
210
|
-
`Delete
|
|
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,
|
|
204
|
+
removeDecisionModel(session.loaded, address);
|
|
214
205
|
if (await saveOrRevert(ctx, session)) {
|
|
215
|
-
ctx.ui.notify(`deleted
|
|
206
|
+
ctx.ui.notify(`deleted ${address}`, "info");
|
|
216
207
|
return;
|
|
217
208
|
}
|
|
218
209
|
}
|
|
219
210
|
}
|
|
220
211
|
}
|
|
221
212
|
|
|
222
|
-
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
249
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 = {
|
|
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
|
|
311
|
-
await offerClassifierTest(ctx, session,
|
|
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
|
-
/**
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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).
|
|
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(
|
|
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
|
|
498
|
-
|
|
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
|
|
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(
|
|
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 ${
|
|
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,
|
|
508
|
+
await runClassifierTest(ctx, session, entry, address);
|
|
517
509
|
}
|
|
518
510
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if (
|
|
528
|
-
|
|
529
|
-
|
|
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 {
|
|
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
|
|
550
|
-
* the chosen name on success, undefined on failure (the wizard treats
|
|
551
|
-
* as user cancellation).
|
|
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), `
|
|
613
|
-
*
|
|
614
|
-
* store
|
|
615
|
-
*
|
|
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: "
|
|
621
|
-
|
|
622
|
-
| { kind: "
|
|
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 = "
|
|
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: "
|
|
651
|
-
if (choice === removeOption) return { kind: "
|
|
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: "
|
|
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: "
|
|
630
|
+
return { kind: "new", value };
|
|
672
631
|
}
|
package/src/dialogue-ui.ts
CHANGED
|
@@ -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
|
|
16
|
-
* (`
|
|
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 {
|
|
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?: (
|
|
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
|
|
67
|
-
export function
|
|
68
|
-
if (
|
|
69
|
-
|
|
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
|
-
*
|
|
149
|
-
* a derived
|
|
150
|
-
* in use is rejected with a warning
|
|
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
|
|
156
|
+
export async function promptAddressLast(
|
|
153
157
|
ctx: DialogueContext,
|
|
154
158
|
loaded: LoadedLayer,
|
|
155
|
-
|
|
156
|
-
|
|
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 -
|
|
161
|
-
|
|
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
|
|
166
|
-
if (
|
|
167
|
-
ctx.ui.notify(`
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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 =>
|
|
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 {
|
|
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";
|
package/src/local-classifier.ts
CHANGED
|
@@ -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
|
-
* `
|
|
12
|
-
* registers `
|
|
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 {
|
|
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(
|
|
56
|
-
return
|
|
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
|
|
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
|
|
84
|
-
const byEndpoint = new Map<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
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((
|
|
115
|
+
const models: ClassifierModel<ClassifierApi>[] = endpoint.models.map((decisionModel) => ({
|
|
109
116
|
type: "classifier",
|
|
110
|
-
id:
|
|
111
|
-
name: `${
|
|
117
|
+
id: decisionModel.id,
|
|
118
|
+
name: `${decisionModel.id} (switchback local classifier)`,
|
|
112
119
|
api: LOCAL_CLASSIFIER_API,
|
|
113
120
|
provider,
|
|
114
121
|
baseUrl,
|