pi-switchback 0.0.0-stage → 2026.10.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +633 -0
- package/package.json +54 -5
- package/src/build-route.ts +74 -0
- package/src/dialogue-classifier.ts +672 -0
- package/src/dialogue-ui.ts +201 -0
- package/src/dialogue.ts +425 -0
- package/src/index.ts +18 -0
- package/src/local-classifier.ts +125 -0
- package/src/model-picker.ts +228 -0
- package/src/secrets.ts +328 -0
- package/src/thinking-support.ts +35 -0
- package/README.md +0 -3
|
@@ -0,0 +1,672 @@
|
|
|
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.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `src/dialogue.ts` because it is the larger half and grew
|
|
7
|
+
* independently: it is the only part that needs the provider catalog, the live
|
|
8
|
+
* endpoint model discovery and the probe. The shell imports the two entry
|
|
9
|
+
* points it needs (`decisionModelPicker`, `decisionModelsMenu`); everything
|
|
10
|
+
* else here is private to this module, and the shared prompts and session
|
|
11
|
+
* primitives come from `src/dialogue-ui.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Secrets stay unrepresentable in the file: the editor only ever receives a
|
|
14
|
+
* derived secret NAME, and the value goes straight to the encrypted store.
|
|
15
|
+
*
|
|
16
|
+
* See `src/dialogue.ts` for the design of the dialogue as a whole.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
addDecisionModel,
|
|
21
|
+
countDecisionModelReferences,
|
|
22
|
+
removeDecisionModel,
|
|
23
|
+
renameDecisionModel,
|
|
24
|
+
setDecisionModel,
|
|
25
|
+
updateDecisionModel,
|
|
26
|
+
type DecisionModelInput,
|
|
27
|
+
type DecisionModelPatch,
|
|
28
|
+
} from "switchback-core/config-editor.ts";
|
|
29
|
+
import { LOCAL_CLASSIFIER_APIS } from "switchback-core/config.ts";
|
|
30
|
+
import {
|
|
31
|
+
classifierBaseUrlNote,
|
|
32
|
+
defaultDecisionModelName,
|
|
33
|
+
directEndpointWouldClobber,
|
|
34
|
+
type ClassifierProviderOption,
|
|
35
|
+
} from "switchback-core/classifier-catalog.ts";
|
|
36
|
+
import { discoverOllamaModels } from "switchback-core/classifier-discovery.ts";
|
|
37
|
+
import { formatProbeReport } from "switchback-core/classifier-probe.ts";
|
|
38
|
+
import {
|
|
39
|
+
describeJev,
|
|
40
|
+
errorMessage,
|
|
41
|
+
findModel,
|
|
42
|
+
promptNameLast,
|
|
43
|
+
promptOptional,
|
|
44
|
+
promptRequired,
|
|
45
|
+
readConfigOrReport,
|
|
46
|
+
saveOrRevert,
|
|
47
|
+
screenTitle,
|
|
48
|
+
secretNameFor,
|
|
49
|
+
tryReadConfig,
|
|
50
|
+
type DialogueContext,
|
|
51
|
+
type DialogueSession,
|
|
52
|
+
} from "./dialogue-ui.ts";
|
|
53
|
+
import type { DecisionModelEntry, JevConfig } from "switchback-core/types.ts";
|
|
54
|
+
|
|
55
|
+
export async function decisionModelPicker(
|
|
56
|
+
ctx: DialogueContext,
|
|
57
|
+
session: DialogueSession,
|
|
58
|
+
virtualId: string,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
const noneOption = "(none - report and cycle)";
|
|
61
|
+
const inlineOption = "(inline - configure directly)...";
|
|
62
|
+
const newOption = "New decision model...";
|
|
63
|
+
const backOption = "Back";
|
|
64
|
+
for (;;) {
|
|
65
|
+
const config = readConfigOrReport(ctx, session.loaded);
|
|
66
|
+
if (config === undefined) return;
|
|
67
|
+
const model = findModel(config, virtualId);
|
|
68
|
+
if (model === undefined) return;
|
|
69
|
+
const dmLabels = (config.decisionModels ?? []).map((d) => `${d.name} - ${d.provider}/${d.id}`);
|
|
70
|
+
const choice = await ctx.ui.select(
|
|
71
|
+
screenTitle(session.loaded, `decision model for ${virtualId}\ncurrently: ${describeJev(model.jev)}`),
|
|
72
|
+
[noneOption, ...dmLabels, inlineOption, newOption, backOption],
|
|
73
|
+
);
|
|
74
|
+
if (choice === undefined || choice === backOption) return;
|
|
75
|
+
if (choice === noneOption) {
|
|
76
|
+
setDecisionModel(session.loaded, virtualId, null);
|
|
77
|
+
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));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (choice === newOption) {
|
|
96
|
+
const created = await decisionModelWizard(ctx, session);
|
|
97
|
+
if (created === undefined) continue;
|
|
98
|
+
setDecisionModel(session.loaded, virtualId, { decisionModel: created.name });
|
|
99
|
+
if (await saveOrRevert(ctx, session)) {
|
|
100
|
+
ctx.ui.notify(`${virtualId}: decision model "${created.name}" created and set`, "info");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
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 });
|
|
110
|
+
if (await saveOrRevert(ctx, session)) {
|
|
111
|
+
ctx.ui.notify(`${virtualId}: decision model "${entry.name}" set`, "info");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function decisionModelsMenu(ctx: DialogueContext, session: DialogueSession): Promise<void> {
|
|
118
|
+
const addOption = "Add decision model...";
|
|
119
|
+
const backOption = "Back";
|
|
120
|
+
for (;;) {
|
|
121
|
+
const config = readConfigOrReport(ctx, session.loaded);
|
|
122
|
+
if (config === undefined) return;
|
|
123
|
+
const entries = config.decisionModels ?? [];
|
|
124
|
+
const labels = entries.map((d) => `${d.name} - ${d.provider}/${d.id}`);
|
|
125
|
+
const choice = await ctx.ui.select(
|
|
126
|
+
screenTitle(session.loaded, "decision models (reusable classifier configs)"),
|
|
127
|
+
[...labels, addOption, backOption],
|
|
128
|
+
);
|
|
129
|
+
if (choice === undefined || choice === backOption) return;
|
|
130
|
+
if (choice === addOption) {
|
|
131
|
+
await decisionModelWizard(ctx, session);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
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);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
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";
|
|
147
|
+
const testOption = "Test classifier (choice, score, noul)";
|
|
148
|
+
const deleteOption = "Delete";
|
|
149
|
+
const backOption = "Back";
|
|
150
|
+
for (;;) {
|
|
151
|
+
const config = readConfigOrReport(ctx, session.loaded);
|
|
152
|
+
if (config === undefined) return;
|
|
153
|
+
const current = (config.decisionModels ?? []).find((d) => d.name === entry.name);
|
|
154
|
+
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
|
+
);
|
|
165
|
+
if (choice === undefined || choice === backOption) return;
|
|
166
|
+
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
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (choice === renameOption) {
|
|
177
|
+
const newName = await promptRequired(
|
|
178
|
+
ctx,
|
|
179
|
+
screenTitle(session.loaded, `rename decision model "${current.name}"`),
|
|
180
|
+
current.name,
|
|
181
|
+
);
|
|
182
|
+
if (newName === undefined || newName === current.name) continue;
|
|
183
|
+
renameDecisionModel(session.loaded, current.name, newName);
|
|
184
|
+
if (await saveOrRevert(ctx, session)) {
|
|
185
|
+
entry.name = newName;
|
|
186
|
+
ctx.ui.notify(`renamed to "${newName}"; references updated`, "info");
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (choice === editOption) {
|
|
191
|
+
const patch = await collectJevPatch(ctx, session, current);
|
|
192
|
+
if (patch === undefined) continue;
|
|
193
|
+
updateDecisionModel(session.loaded, current.name, patch);
|
|
194
|
+
if (await saveOrRevert(ctx, session)) {
|
|
195
|
+
ctx.ui.notify(`decision model "${current.name}" updated`, "info");
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (choice === deleteOption) {
|
|
200
|
+
const refs = countDecisionModelReferences(session.loaded, current.name);
|
|
201
|
+
if (refs > 0) {
|
|
202
|
+
ctx.ui.notify(
|
|
203
|
+
`"${current.name}" is still referenced by ${refs} model(s) - set their decision model first`,
|
|
204
|
+
"warning",
|
|
205
|
+
);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
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}?`,
|
|
211
|
+
);
|
|
212
|
+
if (!confirmed) continue;
|
|
213
|
+
removeDecisionModel(session.loaded, current.name);
|
|
214
|
+
if (await saveOrRevert(ctx, session)) {
|
|
215
|
+
ctx.ui.notify(`deleted "${current.name}"`, "info");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
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).
|
|
226
|
+
*/
|
|
227
|
+
async function decisionModelWizard(
|
|
228
|
+
ctx: DialogueContext,
|
|
229
|
+
session: DialogueSession,
|
|
230
|
+
): 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.
|
|
233
|
+
const config = tryReadConfig(session.loaded);
|
|
234
|
+
const knownNames = (config?.decisionModels ?? []).map((d) => d.name);
|
|
235
|
+
|
|
236
|
+
// 1. Provider
|
|
237
|
+
const option = await chooseClassifierProvider(ctx, session, undefined);
|
|
238
|
+
if (option === undefined) return undefined;
|
|
239
|
+
|
|
240
|
+
// 2. baseUrl (always prefilled for both catalog and local; "-" clears it)
|
|
241
|
+
const baseUrl = await chooseBaseUrl(ctx, session, option, undefined);
|
|
242
|
+
if (baseUrl === undefined) return undefined;
|
|
243
|
+
|
|
244
|
+
// 3. Model id (from the provider's catalog classifiers, or asked of the endpoint itself)
|
|
245
|
+
const id = await chooseClassifierModel(ctx, session, option, undefined, baseUrl ?? option.baseUrl);
|
|
246
|
+
if (id === undefined) return undefined;
|
|
247
|
+
|
|
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" };
|
|
252
|
+
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.
|
|
275
|
+
}
|
|
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
|
+
|
|
282
|
+
const suggestedName = defaultDecisionModelName(option.provider, id);
|
|
283
|
+
const name = await promptNameLast(ctx, session.loaded, suggestedName, knownNames);
|
|
284
|
+
if (name === undefined) return undefined;
|
|
285
|
+
|
|
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 };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const input: DecisionModelInput = { name, provider: option.provider, id, ...finalRest };
|
|
303
|
+
try {
|
|
304
|
+
addDecisionModel(session.loaded, input);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
ctx.ui.notify(errorMessage(error), "warning");
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
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 }));
|
|
312
|
+
return input;
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
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
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Choose the classifier provider.
|
|
331
|
+
*
|
|
332
|
+
* The picker shows two sections, separated by header lines that explain what
|
|
333
|
+
* each side means:
|
|
334
|
+
*
|
|
335
|
+
* - Catalog providers (resolved through pi) - TypeSafe, OpenRouter, ...
|
|
336
|
+
* These speak `typesafe-system-one` over HTTPS; the wizard sets the
|
|
337
|
+
* provider's default baseUrl automatically.
|
|
338
|
+
* - Local SystemOne endpoints (you run the server) - Ollama, llama.cpp.
|
|
339
|
+
* The wizard sets the `/v1/systemone` baseUrl from OLLAMA_HOST /
|
|
340
|
+
* LLAMA_SERVER_URL or a well-known default; switchback registers the
|
|
341
|
+
* classifier itself.
|
|
342
|
+
*
|
|
343
|
+
* "Other (type a provider id)..." is the escape hatch for a custom provider
|
|
344
|
+
* not in either section. The provider already in use, if any, is offered first
|
|
345
|
+
* so Enter keeps it.
|
|
346
|
+
*
|
|
347
|
+
* Falls back to a text prompt when no catalog was supplied (non-TUI contexts,
|
|
348
|
+
* tests).
|
|
349
|
+
*/
|
|
350
|
+
async function chooseClassifierProvider(
|
|
351
|
+
ctx: DialogueContext,
|
|
352
|
+
session: DialogueSession,
|
|
353
|
+
current: string | undefined,
|
|
354
|
+
): Promise<ClassifierProviderOption | undefined> {
|
|
355
|
+
const options = ctx.classifierProviders ?? [];
|
|
356
|
+
if (options.length === 0) {
|
|
357
|
+
const provider = await promptRequired(ctx, screenTitle(session.loaded, "classifier provider"), current ?? "");
|
|
358
|
+
if (provider === undefined) return undefined;
|
|
359
|
+
return declaredEndpoint(provider);
|
|
360
|
+
}
|
|
361
|
+
// The provider already in use is offered first so Enter keeps it.
|
|
362
|
+
const ordered = [...options].sort((a, b) => (a.provider === current ? -1 : b.provider === current ? 1 : 0));
|
|
363
|
+
const otherOption = "Other (type a provider id)...";
|
|
364
|
+
const backOption = "Back";
|
|
365
|
+
const choice = await ctx.ui.select(
|
|
366
|
+
screenTitle(session.loaded, `classifier provider${current === undefined ? "" : ` (current: ${current})`}`),
|
|
367
|
+
[...ordered.map((option) => option.label), otherOption, backOption],
|
|
368
|
+
);
|
|
369
|
+
if (choice === undefined || choice === backOption) return undefined;
|
|
370
|
+
if (choice === otherOption) {
|
|
371
|
+
const provider = await promptRequired(
|
|
372
|
+
ctx,
|
|
373
|
+
screenTitle(session.loaded, "classifier provider id"),
|
|
374
|
+
current ?? "",
|
|
375
|
+
);
|
|
376
|
+
if (provider === undefined) return undefined;
|
|
377
|
+
return declaredEndpoint(provider);
|
|
378
|
+
}
|
|
379
|
+
const index = ordered.findIndex((o) => o.label === choice);
|
|
380
|
+
return index >= 0 ? ordered[index] : undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 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.
|
|
387
|
+
*/
|
|
388
|
+
function declaredEndpoint(provider: string): ClassifierProviderOption {
|
|
389
|
+
return { provider, label: provider, displayName: provider, models: [], local: true, chatModels: 0 };
|
|
390
|
+
}
|
|
391
|
+
|
|
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
|
+
*/
|
|
401
|
+
async function chooseClassifierModel(
|
|
402
|
+
ctx: DialogueContext,
|
|
403
|
+
session: DialogueSession,
|
|
404
|
+
option: ClassifierProviderOption,
|
|
405
|
+
current: string | undefined,
|
|
406
|
+
baseUrl: string | undefined,
|
|
407
|
+
): Promise<string | undefined> {
|
|
408
|
+
const prompt = (): Promise<string | undefined> =>
|
|
409
|
+
promptRequired(ctx, screenTitle(session.loaded, `classifier model id for ${option.provider}`), current ?? "");
|
|
410
|
+
|
|
411
|
+
let liveModels: readonly string[] = option.models;
|
|
412
|
+
if (liveModels.length === 0 && baseUrl !== undefined) {
|
|
413
|
+
const { models, error } = await discoverOllamaModels({ baseUrl });
|
|
414
|
+
if (error !== undefined) {
|
|
415
|
+
ctx.ui.notify(`no model list from ${baseUrl} (${error}); type the model id`, "warning");
|
|
416
|
+
}
|
|
417
|
+
const decisionOnly = models.filter((m) => m.decisionCapable).map((m) => m.id);
|
|
418
|
+
if (decisionOnly.length > 0) liveModels = decisionOnly;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (liveModels.length === 0) return prompt();
|
|
422
|
+
const otherOption = "Other (type a model id)...";
|
|
423
|
+
const backOption = "Back";
|
|
424
|
+
const choice = await ctx.ui.select(screenTitle(session.loaded, `classifier model for ${option.provider}`), [
|
|
425
|
+
...liveModels,
|
|
426
|
+
otherOption,
|
|
427
|
+
backOption,
|
|
428
|
+
]);
|
|
429
|
+
if (choice === undefined || choice === backOption) return undefined;
|
|
430
|
+
if (choice === otherOption) return prompt();
|
|
431
|
+
return choice;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The base URL for the chosen endpoint.
|
|
436
|
+
*
|
|
437
|
+
* Two shapes, because the config means different things by the field:
|
|
438
|
+
*
|
|
439
|
+
* - An endpoint the user declared (pi has no catalog entry): the base URL is
|
|
440
|
+
* required, since switchback registers the classifier from it.
|
|
441
|
+
* - A provider from pi's catalog: pi already resolves the endpoint, so the
|
|
442
|
+
* config normally carries **no** `baseUrl` at all and `Enter` keeps that.
|
|
443
|
+
* Typing one is an explicit override - and when pi owns chat models under
|
|
444
|
+
* that provider id, writing a direct endpoint would make pi replace them
|
|
445
|
+
* (its `applyExtension`), so the wizard refuses and says so.
|
|
446
|
+
*
|
|
447
|
+
* `"-"` always means "no direct endpoint".
|
|
448
|
+
*/
|
|
449
|
+
async function chooseBaseUrl(
|
|
450
|
+
ctx: DialogueContext,
|
|
451
|
+
session: DialogueSession,
|
|
452
|
+
option: ClassifierProviderOption,
|
|
453
|
+
current: string | undefined,
|
|
454
|
+
): Promise<string | null | undefined> {
|
|
455
|
+
if (option.local) {
|
|
456
|
+
const value = await promptOptional(
|
|
457
|
+
ctx,
|
|
458
|
+
screenTitle(
|
|
459
|
+
session.loaded,
|
|
460
|
+
`baseUrl for ${option.provider} — the SystemOne endpoint, e.g. http://host:port/v1 (required)`,
|
|
461
|
+
),
|
|
462
|
+
current,
|
|
463
|
+
);
|
|
464
|
+
if (value === undefined || value === null) return undefined;
|
|
465
|
+
const trimmed = value.trim();
|
|
466
|
+
// Required: an empty answer for a declared endpoint leaves nothing to register.
|
|
467
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
468
|
+
}
|
|
469
|
+
const note = classifierBaseUrlNote(option);
|
|
470
|
+
const value = await ctx.ui.input(
|
|
471
|
+
screenTitle(
|
|
472
|
+
session.loaded,
|
|
473
|
+
`baseUrl for ${option.provider} — Enter keeps pi's catalog (${note}), "-" clears, any URL overrides`,
|
|
474
|
+
),
|
|
475
|
+
current ?? "",
|
|
476
|
+
);
|
|
477
|
+
if (value === undefined) return undefined;
|
|
478
|
+
const trimmed = value.trim();
|
|
479
|
+
if (trimmed === "-" || trimmed.length === 0) return null;
|
|
480
|
+
if (directEndpointWouldClobber(option)) {
|
|
481
|
+
ctx.ui.notify(
|
|
482
|
+
`pi already serves ${option.chatModels} chat model(s) under "${option.provider}". ` +
|
|
483
|
+
"A direct endpoint here would replace them with the classifier alone - " +
|
|
484
|
+
`pick a distinct provider id ("${option.provider}-<suffix>") instead.`,
|
|
485
|
+
"warning",
|
|
486
|
+
);
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
return trimmed;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Send the capability prompt and report the verdict. See `packages/core/src/classifier-probe.ts`.
|
|
494
|
+
*/
|
|
495
|
+
async function runClassifierTest(ctx: DialogueContext, session: DialogueSession, jev: JevConfig): Promise<void> {
|
|
496
|
+
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));
|
|
499
|
+
ctx.ui.notify(formatProbeReport({ label, result }), result.ok ? "info" : "warning");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Offer the one-shot capability test after a classifier config was written.
|
|
504
|
+
*
|
|
505
|
+
* Most SystemOne endpoints publish no model list, so the model id is guessed;
|
|
506
|
+
* this is where a wrong guess (or a model without the `decision` capability)
|
|
507
|
+
* surfaces, instead of at the first real routing failure.
|
|
508
|
+
*/
|
|
509
|
+
async function offerClassifierTest(ctx: DialogueContext, session: DialogueSession, jev: JevConfig): Promise<void> {
|
|
510
|
+
if (ctx.probeClassifier === undefined) return;
|
|
511
|
+
const confirmed = await ctx.ui.confirm(
|
|
512
|
+
screenTitle(session.loaded, `test ${jev.provider}/${jev.id} now?`),
|
|
513
|
+
"Sends one SystemOne prompt that exercises choice, score and noul, so a wrong model id or endpoint shows up immediately.",
|
|
514
|
+
);
|
|
515
|
+
if (!confirmed) return;
|
|
516
|
+
await runClassifierTest(ctx, session, jev);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Classifier fields for a NEW inline config / decision model. Undefined = user backed out. */
|
|
520
|
+
async function collectJevFields(
|
|
521
|
+
ctx: DialogueContext,
|
|
522
|
+
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;
|
|
544
|
+
}
|
|
545
|
+
return { provider: option.provider, id, rest };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
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.
|
|
553
|
+
*/
|
|
554
|
+
function commitSecret(ctx: DialogueContext, session: DialogueSession, hint: string, value: string): string | undefined {
|
|
555
|
+
const name = secretNameFor(hint);
|
|
556
|
+
try {
|
|
557
|
+
session.secrets.set(name, value);
|
|
558
|
+
return name;
|
|
559
|
+
} catch (error) {
|
|
560
|
+
ctx.ui.notify(`secret store refused the value: ${errorMessage(error)}`, "error");
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
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
|
+
/**
|
|
610
|
+
* What the user decided about the API key.
|
|
611
|
+
*
|
|
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.
|
|
617
|
+
*/
|
|
618
|
+
type ApiKeyAction =
|
|
619
|
+
| { kind: "keep" }
|
|
620
|
+
| { kind: "none" }
|
|
621
|
+
/** Value picked up; the wizard commits it under a name it derives later. */
|
|
622
|
+
| { kind: "secret"; value: string }
|
|
623
|
+
| { kind: "cancel" };
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Ask what to do about the API key.
|
|
627
|
+
*
|
|
628
|
+
* The value is the only thing asked for: the store name is derived
|
|
629
|
+
* (`secretNameFor`) because it is an internal detail the user has no reason to
|
|
630
|
+
* invent.
|
|
631
|
+
*/
|
|
632
|
+
async function apiKeyAction(
|
|
633
|
+
ctx: DialogueContext,
|
|
634
|
+
session: DialogueSession,
|
|
635
|
+
current: string | undefined,
|
|
636
|
+
hint: string,
|
|
637
|
+
): Promise<ApiKeyAction> {
|
|
638
|
+
const hasCurrent = current !== undefined;
|
|
639
|
+
const keepOption = hasCurrent
|
|
640
|
+
? `Keep current (${current.startsWith("secret:") ? current : "value set by hand"})`
|
|
641
|
+
: "(no API key)";
|
|
642
|
+
const existingOption = "Use existing secret...";
|
|
643
|
+
const newOption = "New secret...";
|
|
644
|
+
const removeOption = "Remove API key";
|
|
645
|
+
const choice = await ctx.ui.select(
|
|
646
|
+
screenTitle(session.loaded, "API key - stored encrypted; the config only carries secret:<name>"),
|
|
647
|
+
hasCurrent ? [keepOption, existingOption, newOption, removeOption] : [keepOption, existingOption, newOption],
|
|
648
|
+
);
|
|
649
|
+
if (choice === undefined) return { kind: "cancel" };
|
|
650
|
+
if (choice === keepOption) return hasCurrent ? { kind: "keep" } : { kind: "none" };
|
|
651
|
+
if (choice === removeOption) return { kind: "none" };
|
|
652
|
+
if (choice === existingOption) {
|
|
653
|
+
const names = session.secrets.list();
|
|
654
|
+
if (names.length === 0) {
|
|
655
|
+
ctx.ui.notify("no secrets stored yet - pick 'New secret...'", "warning");
|
|
656
|
+
return { kind: "cancel" };
|
|
657
|
+
}
|
|
658
|
+
const name = await ctx.ui.select(screenTitle(session.loaded, "reference which stored secret?"), [...names]);
|
|
659
|
+
return name === undefined ? { kind: "cancel" } : { kind: "secret", value: name };
|
|
660
|
+
}
|
|
661
|
+
if (choice !== newOption) return { kind: "cancel" }; // an option this flow does not know: back out
|
|
662
|
+
const value = await promptRequired(
|
|
663
|
+
ctx,
|
|
664
|
+
screenTitle(
|
|
665
|
+
session.loaded,
|
|
666
|
+
`API key for ${hint} - the store name is derived from the decision model, not shown again`,
|
|
667
|
+
),
|
|
668
|
+
"secret value",
|
|
669
|
+
);
|
|
670
|
+
if (value === undefined) return { kind: "cancel" };
|
|
671
|
+
return { kind: "secret", value };
|
|
672
|
+
}
|