@maheidem/model-discovery 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -15,8 +15,8 @@
15
15
  */
16
16
 
17
17
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
- import { BorderedLoader, DynamicBorder } from "@earendil-works/pi-coding-agent";
19
- import { CURSOR_MARKER, Container, Input, type SelectItem, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
18
+ import { BorderedLoader } from "@earendil-works/pi-coding-agent";
19
+ import type { SelectItem } from "@earendil-works/pi-tui";
20
20
  import { Type } from "typebox";
21
21
  import {
22
22
  analyzeExplicitProfileRouting,
@@ -26,7 +26,6 @@ import {
26
26
  describeProfileSampling,
27
27
  expandAdaptiveProfileRouters,
28
28
  expandModelProfiles,
29
- migrateLegacyProfileRouting,
30
29
  profileModelId,
31
30
  REASONING_EFFORTS,
32
31
  repetitionPenaltyKeyForServer,
@@ -46,199 +45,46 @@ import {
46
45
  redactSecret,
47
46
  type ModelConfig,
48
47
  } from "./providers.ts";
49
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
50
- import { join } from "node:path";
51
- import os from "node:os";
48
+ import {
49
+ describeToolSchemaRepair,
50
+ isLocalEndpointUrl,
51
+ repairRequestToolSchemas,
52
+ type ToolSchemaRepairReport,
53
+ } from "./schema-repair.ts";
54
+ import { createDiscoveryApplication } from "./application.ts";
55
+ import { completeDiscoverArgs, DISCOVER_USAGE, parseDiscoverArgs } from "./commands.ts";
56
+ import {
57
+ errorMessage,
58
+ recordFailedScan,
59
+ recordSuccessfulScan,
60
+ STORAGE_PATH,
61
+ type DiscoveredProvider,
62
+ type ModelOverride,
63
+ } from "./storage.ts";
64
+ import {
65
+ buildDiagnosticsLines,
66
+ buildHomeItems,
67
+ buildHomeSummary,
68
+ formatDiscoveryStatus,
69
+ } from "./ui-model.ts";
70
+ import { WizardInput, WizardSecretInput, WizardSelect, WizardTextView } from "./ui/wizard-shell.ts";
52
71
 
53
72
  // ---------------------------------------------------------------------------
54
73
  // Types
55
74
  // ---------------------------------------------------------------------------
56
75
 
57
- interface ModelOverride {
58
- contextWindow?: number;
59
- maxTokens?: number;
60
- reasoning?: boolean;
61
- input?: string[];
62
- }
63
- interface DiscoveredProvider {
64
- name: string;
65
- baseUrl: string;
66
- apiKey?: string;
67
- serverType?: string;
68
- defaultContextWindow?: number;
69
- defaultMaxTokens?: number;
70
- modelOverrides?: Record<string, ModelOverride>;
71
- modelProfiles?: Record<string, ModelProfile[]>;
72
- modelProfileRouting?: Record<string, ModelProfileRouting>;
73
- profileSchemaVersion?: number;
74
- cachedModels?: Record<string, unknown>[];
75
- compat?: Record<string, unknown>;
76
- /** Last successful live catalogue refresh (legacy name retained in storage). */
77
- lastScanned?: number;
78
- lastScanAttempt?: number;
79
- lastScanError?: string;
80
- }
81
-
82
- // ---------------------------------------------------------------------------
83
- // Storage
84
- // ---------------------------------------------------------------------------
85
-
86
- const STORAGE_PATH = join(os.homedir(), ".pi", "agent", "model-discovery.json");
87
-
88
- function writeProvidersAtomic(providers: DiscoveredProvider[]): void {
89
- const tempPath = `${STORAGE_PATH}.${process.pid}.${Date.now()}.tmp`;
90
- try {
91
- writeFileSync(tempPath, JSON.stringify(providers, null, 2), { encoding: "utf-8", mode: 0o600 });
92
- renameSync(tempPath, STORAGE_PATH);
93
- } catch (error) {
94
- try {
95
- if (existsSync(tempPath)) unlinkSync(tempPath);
96
- } catch {
97
- /* best-effort cleanup */
98
- }
99
- throw error;
76
+ function normalizeEndpointUrl(rawUrl: string): string {
77
+ let url = rawUrl.trim();
78
+ if (!url) throw new Error("Endpoint URL cannot be blank.");
79
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) && !/^https?:\/\//i.test(url)) {
80
+ throw new Error("Endpoint URL must use HTTP or HTTPS.");
100
81
  }
101
- }
102
-
103
- function loadProviders(): DiscoveredProvider[] {
104
- try {
105
- if (existsSync(STORAGE_PATH)) {
106
- const providers = JSON.parse(readFileSync(STORAGE_PATH, "utf-8")) as DiscoveredProvider[];
107
- let migrated = false;
108
- for (const provider of providers) {
109
- if ((provider.profileSchemaVersion ?? 0) >= 2) continue;
110
- for (const [modelId, rawProfiles] of Object.entries(provider.modelProfiles ?? {})) {
111
- if (!Array.isArray(rawProfiles)) continue;
112
- const result = migrateLegacyProfileRouting(rawProfiles, provider.modelProfileRouting?.[modelId]);
113
- if (!result.changed || !result.routing) continue;
114
- provider.modelProfiles = { ...provider.modelProfiles, [modelId]: result.profiles };
115
- provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: result.routing };
116
- migrated = true;
117
- }
118
- provider.profileSchemaVersion = 2;
119
- migrated = true;
120
- }
121
- if (migrated) writeProvidersAtomic(providers);
122
- return providers;
123
- }
124
- } catch {
125
- /* ignore */
82
+ if (!/^https?:\/\//i.test(url)) url = `http://${url}`;
83
+ const parsed = new URL(url);
84
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
85
+ throw new Error("Endpoint URL must use HTTP or HTTPS.");
126
86
  }
127
- return [];
128
- }
129
-
130
- function saveProviders(providers: DiscoveredProvider[]): void {
131
- writeProvidersAtomic(providers);
132
- }
133
-
134
- function upsertProvider(provider: DiscoveredProvider): void {
135
- const all = loadProviders();
136
- const idx = all.findIndex((p) => p.name === provider.name);
137
- if (idx >= 0) all[idx] = provider;
138
- else all.push(provider);
139
- saveProviders(all);
140
- }
141
-
142
- function deleteProvider(name: string): void {
143
- saveProviders(loadProviders().filter((p) => p.name !== name));
144
- }
145
-
146
- function errorMessage(error: unknown): string {
147
- return error instanceof Error ? error.message : String(error);
148
- }
149
-
150
- function persistProviderScanState(provider: DiscoveredProvider): void {
151
- try {
152
- const providers = loadProviders();
153
- const stored = providers.find((candidate) => candidate.name === provider.name && candidate.baseUrl === provider.baseUrl);
154
- if (!stored) return;
155
- stored.serverType = provider.serverType;
156
- stored.cachedModels = provider.cachedModels;
157
- stored.lastScanned = provider.lastScanned;
158
- stored.lastScanAttempt = provider.lastScanAttempt;
159
- stored.lastScanError = provider.lastScanError;
160
- saveProviders(providers);
161
- } catch (error) {
162
- // Runtime registration must not fail merely because scan metadata could not be persisted.
163
- console.error(`[model-discovery] ${provider.name}: could not persist catalogue state (${errorMessage(error)}).`);
164
- }
165
- }
166
-
167
- function recordSuccessfulScan(
168
- provider: DiscoveredProvider,
169
- models: Record<string, unknown>[],
170
- serverType: string,
171
- persist = true,
172
- ): void {
173
- const now = Date.now();
174
- provider.serverType = serverType;
175
- provider.cachedModels = models;
176
- provider.lastScanned = now;
177
- provider.lastScanAttempt = now;
178
- provider.lastScanError = undefined;
179
- if (persist) persistProviderScanState(provider);
180
- }
181
-
182
- function recordFailedScan(provider: DiscoveredProvider, error: unknown, persist = true): void {
183
- provider.lastScanAttempt = Date.now();
184
- provider.lastScanError = redactSecret(errorMessage(error), provider.apiKey);
185
- if (persist) persistProviderScanState(provider);
186
- }
187
-
188
- function renameProvider(oldName: string, newName: string): boolean {
189
- const all = loadProviders();
190
- const idx = all.findIndex((p) => p.name === oldName);
191
- if (idx < 0) return false;
192
- if (all.some((p) => p.name === newName)) return false; // name already taken
193
- all[idx].name = newName;
194
- saveProviders(all);
195
- return true;
196
- }
197
-
198
- function getModelProfiles(provider: DiscoveredProvider, modelId: string): ModelProfile[] {
199
- const profiles: unknown = provider.modelProfiles?.[modelId];
200
- if (!Array.isArray(profiles)) return [];
201
- return profiles.filter((profile): profile is ModelProfile => validateModelProfile(profile) === null);
202
- }
203
-
204
- function saveModelProfile(
205
- provider: DiscoveredProvider,
206
- modelId: string,
207
- profile: ModelProfile,
208
- previousSlug?: string,
209
- ): void {
210
- const profiles = getModelProfiles(provider, modelId);
211
- const index = previousSlug === undefined ? -1 : profiles.findIndex((item) => item.slug === previousSlug);
212
- const next = [...profiles];
213
- if (index >= 0) next[index] = profile;
214
- else next.push(profile);
215
- provider.modelProfiles = { ...provider.modelProfiles, [modelId]: next };
216
- }
217
-
218
- function deleteModelProfile(provider: DiscoveredProvider, modelId: string, slug: string): void {
219
- const nextProfiles = getModelProfiles(provider, modelId).filter((profile) => profile.slug !== slug);
220
- const modelProfiles = { ...provider.modelProfiles };
221
- if (nextProfiles.length > 0) modelProfiles[modelId] = nextProfiles;
222
- else delete modelProfiles[modelId];
223
- provider.modelProfiles = Object.keys(modelProfiles).length > 0 ? modelProfiles : undefined;
224
- }
225
-
226
- function getModelProfileRouting(provider: DiscoveredProvider, modelId: string): ModelProfileRouting | undefined {
227
- return provider.modelProfileRouting?.[modelId];
228
- }
229
-
230
- function saveModelProfileRouting(
231
- provider: DiscoveredProvider,
232
- modelId: string,
233
- routing: ModelProfileRouting,
234
- ): void {
235
- provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: routing };
236
- }
237
-
238
- function deleteModelProfileRouting(provider: DiscoveredProvider, modelId: string): void {
239
- const routing = { ...provider.modelProfileRouting };
240
- delete routing[modelId];
241
- provider.modelProfileRouting = Object.keys(routing).length > 0 ? routing : undefined;
87
+ return url.replace(/\/+$/, "");
242
88
  }
243
89
 
244
90
  function generateProviderName(url: string): string {
@@ -261,14 +107,44 @@ function fmt(n: number | null | undefined): string {
261
107
  // ---------------------------------------------------------------------------
262
108
 
263
109
  export default async function (pi: ExtensionAPI) {
110
+ const app = createDiscoveryApplication();
111
+
264
112
  type RuntimeThinkingRoutes = {
265
113
  routes: ThinkingProfileRoutes;
266
114
  repetitionPenaltyKey: ReturnType<typeof repetitionPenaltyKeyForServer>;
267
115
  };
268
116
  const thinkingRoutes = new Map<string, RuntimeThinkingRoutes>();
269
117
  const fixedProfileLabels = new Map<string, string>();
118
+ /** Providers whose outgoing tool schemas get local grammar compatibility repair. */
119
+ const schemaRepairProviders = new Set<string>();
120
+ /** Repair notices already surfaced, so a per-request hook never spams the log. */
121
+ const schemaRepairNotices = new Set<string>();
270
122
  const routeKey = (providerName: string, modelId: string): string => `${providerName}/${modelId}`;
271
123
 
124
+ /**
125
+ * llama.cpp (and llama-swap / LM Studio / LiteLLM routes that forward to it) has
126
+ * strict JSON-schema→grammar compatibility limits: root-scoped $ref resolution and,
127
+ * in b10612, one exact nested maxLength parser failure. A single incompatible MCP
128
+ * tool makes *every* message 400. Local endpoints get their schemas normalised;
129
+ * cloud APIs stay byte-identical. See schema-repair.ts.
130
+ */
131
+ function shouldRepairToolSchemas(provider: DiscoveredProvider, serverType: string): boolean {
132
+ if (provider.repairToolSchemas === false) return false;
133
+ if (process.env.PI_MODEL_DISCOVERY_NO_SCHEMA_REPAIR) return false;
134
+ if (provider.repairToolSchemas === true) return true;
135
+ const LOCAL_ENGINES = ["llama.cpp", "oMLX", "Ollama", "vLLM", "SGLang", "LM Studio", "llama-swap"];
136
+ return LOCAL_ENGINES.some((needle) => serverType.toLowerCase().includes(needle.toLowerCase())) || isLocalEndpointUrl(provider.baseUrl);
137
+ }
138
+
139
+ function noteToolSchemaRepair(providerName: string, report: ToolSchemaRepairReport): void {
140
+ if (!report.changed) return;
141
+ const summary = describeToolSchemaRepair(report);
142
+ const signature = `${providerName}::${summary}`;
143
+ if (schemaRepairNotices.has(signature)) return;
144
+ schemaRepairNotices.add(signature);
145
+ console.error(`[model-discovery] ${providerName}: ${summary}`);
146
+ }
147
+
272
148
  // -----------------------------------------------------------------------
273
149
  // Provider registration with Pi's model registry
274
150
  // -----------------------------------------------------------------------
@@ -276,8 +152,9 @@ export default async function (pi: ExtensionAPI) {
276
152
  async function registerProvider(
277
153
  provider: DiscoveredProvider,
278
154
  prefetched?: { models: Record<string, unknown>[]; serverType: string },
155
+ signal: AbortSignal = AbortSignal.timeout(2_000),
279
156
  ): Promise<{ models: ModelConfig[]; rawModels: Record<string, unknown>[]; serverType: string; profileCount: number }> {
280
- const { models, serverType } = prefetched ?? (await fetchModels(provider.baseUrl, provider.apiKey, AbortSignal.timeout(2_000)));
157
+ const { models, serverType } = prefetched ?? (await fetchModels(provider.baseUrl, provider.apiKey, signal));
281
158
  if (models.length === 0) throw new Error("No models found at this endpoint.");
282
159
 
283
160
  const routePrefix = `${provider.name}/`;
@@ -291,6 +168,8 @@ export default async function (pi: ExtensionAPI) {
291
168
  if (serverType === "llama.cpp" || serverType === "oMLX" || serverType === "Ollama") {
292
169
  if (compat.supportsDeveloperRole === undefined) compat.supportsDeveloperRole = false;
293
170
  }
171
+ if (shouldRepairToolSchemas(provider, serverType)) schemaRepairProviders.add(provider.name);
172
+ else schemaRepairProviders.delete(provider.name);
294
173
  if (serverType === "oMLX") {
295
174
  // Preserve the pre-profile base-model behavior. Fixed and adaptive profile
296
175
  // aliases supply their own complete chat-template kwargs independently.
@@ -341,7 +220,7 @@ export default async function (pi: ExtensionAPI) {
341
220
  thinkingRoutes.set(routeKey(provider.name, modelId), { routes, repetitionPenaltyKey });
342
221
  }
343
222
  for (const base of baseModels) {
344
- for (const profile of getModelProfiles(provider, base.id)) {
223
+ for (const profile of app.profiles(provider, base.id)) {
345
224
  if (profile.exposeAsModel !== false) {
346
225
  fixedProfileLabels.set(routeKey(provider.name, profileModelId(base.id, profile.slug)), profile.slug);
347
226
  }
@@ -368,8 +247,40 @@ export default async function (pi: ExtensionAPI) {
368
247
  };
369
248
  }
370
249
 
250
+ async function discoverAndRegisterSource(options: {
251
+ url: string;
252
+ providerName?: string;
253
+ apiKey?: string;
254
+ signal?: AbortSignal;
255
+ }): Promise<{
256
+ provider: DiscoveredProvider;
257
+ models: ModelConfig[];
258
+ rawModels: Record<string, unknown>[];
259
+ serverType: string;
260
+ profileCount: number;
261
+ }> {
262
+ const url = normalizeEndpointUrl(options.url);
263
+ const providerName = options.providerName?.trim() || generateProviderName(url);
264
+ const live = await fetchModels(url, options.apiKey, options.signal);
265
+ if (live.models.length === 0) throw new Error("Endpoint is online but reports no models.");
266
+ const existing = app.findSource(providerName);
267
+ const provider: DiscoveredProvider = existing
268
+ ? { ...existing, baseUrl: url, apiKey: options.apiKey ?? existing.apiKey }
269
+ : { name: providerName, baseUrl: url, apiKey: options.apiKey };
270
+ const registered = await registerProvider(provider, live);
271
+ recordSuccessfulScan(provider, live.models, live.serverType, false);
272
+ app.saveSource(provider);
273
+ return {
274
+ provider,
275
+ models: registered.models,
276
+ rawModels: live.models,
277
+ serverType: live.serverType,
278
+ profileCount: registered.profileCount,
279
+ };
280
+ }
281
+
371
282
  // Register saved providers at startup (concurrent — one dead endpoint can't block the others)
372
- const providers = loadProviders();
283
+ const providers = app.listSources();
373
284
  if (providers.length > 0) {
374
285
  const results = await Promise.allSettled(
375
286
  providers.map(async (provider) => {
@@ -414,9 +325,27 @@ export default async function (pi: ExtensionAPI) {
414
325
  }
415
326
 
416
327
  pi.on("before_provider_request", (event, ctx) => {
328
+ let payload: unknown = event.payload;
329
+ let touched = false;
330
+
417
331
  const active = activeThinkingRoute(ctx);
418
- if (!active) return undefined;
419
- return applyThinkingProfileRoute(event.payload, active.profile, active.runtime.repetitionPenaltyKey);
332
+ if (active) {
333
+ payload = applyThinkingProfileRoute(payload, active.profile, active.runtime.repetitionPenaltyKey);
334
+ touched = true;
335
+ }
336
+
337
+ // Repair local tool schemas so llama.cpp-style grammar converters accept them.
338
+ const providerName = ctx.model?.provider;
339
+ if (providerName && schemaRepairProviders.has(providerName)) {
340
+ const repaired = repairRequestToolSchemas(payload);
341
+ if (repaired.report.changed) {
342
+ noteToolSchemaRepair(providerName, repaired.report);
343
+ payload = repaired.payload;
344
+ touched = true;
345
+ }
346
+ }
347
+
348
+ return touched ? payload : undefined;
420
349
  });
421
350
 
422
351
  const updateThinkingProfileStatus = (ctx: ExtensionContext): void => {
@@ -441,42 +370,67 @@ export default async function (pi: ExtensionAPI) {
441
370
  items: SelectItem[],
442
371
  headerLines: string[] = [],
443
372
  ): Promise<string | null> {
444
- return await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
445
- const container = new Container();
446
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
447
- container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
448
- for (const line of headerLines) {
449
- container.addChild(new Text(theme.fg("muted", line), 1, 0));
450
- }
451
-
452
- const selectList = new SelectList(
373
+ return await ctx.ui.custom<string | null>(
374
+ (tui, theme, keybindings, done) => new WizardSelect({
375
+ theme,
376
+ keybindings,
377
+ title,
453
378
  items,
454
- Math.min(items.length, 12),
455
- {
456
- selectedPrefix: (t: string) => theme.fg("accent", t),
457
- selectedText: (t: string) => theme.fg("accent", t),
458
- description: (t: string) => theme.fg("muted", t),
459
- scrollInfo: (t: string) => theme.fg("dim", t),
460
- noMatch: (t: string) => theme.fg("warning", t),
461
- },
462
- { minPrimaryColumnWidth: 18, maxPrimaryColumnWidth: 48 },
463
- );
464
- selectList.onSelect = (item) => done(item.value);
465
- selectList.onCancel = () => done(null);
466
- container.addChild(selectList);
379
+ headerLines,
380
+ requestRender: () => tui.requestRender(),
381
+ done,
382
+ }),
383
+ {
384
+ overlay: true,
385
+ overlayOptions: { anchor: "center", width: 88, minWidth: 36, maxHeight: "90%", margin: 1 },
386
+ },
387
+ );
388
+ }
467
389
 
468
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc back • type to filter"), 1, 0));
469
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
390
+ async function runTextView(
391
+ ctx: ExtensionCommandContext,
392
+ title: string,
393
+ lines: string[],
394
+ ): Promise<void> {
395
+ await ctx.ui.custom<void>(
396
+ (tui, theme, keybindings, done) => new WizardTextView({
397
+ theme,
398
+ keybindings,
399
+ title,
400
+ lines,
401
+ requestRender: () => tui.requestRender(),
402
+ done: () => done(),
403
+ }),
404
+ {
405
+ overlay: true,
406
+ overlayOptions: { anchor: "center", width: 88, minWidth: 36, maxHeight: "90%", margin: 1 },
407
+ },
408
+ );
409
+ }
470
410
 
471
- return {
472
- render: (w: number) => container.render(w),
473
- invalidate: () => container.invalidate(),
474
- handleInput: (data: string) => {
475
- selectList.handleInput(data);
476
- tui.requestRender();
477
- },
478
- };
479
- });
411
+ async function runInput(
412
+ ctx: ExtensionCommandContext,
413
+ title: string,
414
+ initialValue = "",
415
+ description?: string,
416
+ validate?: (value: string) => string | null,
417
+ ): Promise<string | undefined> {
418
+ return await ctx.ui.custom<string | undefined>(
419
+ (tui, theme, keybindings, done) => new WizardInput({
420
+ theme,
421
+ keybindings,
422
+ title,
423
+ description,
424
+ initialValue,
425
+ validate,
426
+ requestRender: () => tui.requestRender(),
427
+ done,
428
+ }),
429
+ {
430
+ overlay: true,
431
+ overlayOptions: { anchor: "center", width: 72, minWidth: 36, maxHeight: "90%", margin: 1 },
432
+ },
433
+ );
480
434
  }
481
435
 
482
436
  async function runLoader<T>(
@@ -485,18 +439,31 @@ export default async function (pi: ExtensionAPI) {
485
439
  work: (signal: AbortSignal) => Promise<T>,
486
440
  onError?: (error: unknown) => void,
487
441
  ): Promise<T | null> {
488
- return await ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
489
- const loader = new BorderedLoader(tui, theme, message);
490
- loader.onAbort = () => done(null);
491
- work(loader.signal)
492
- .then((result) => done(result))
493
- .catch((err) => {
494
- onError?.(err);
495
- ctx.ui.notify(errorMessage(err), "error");
442
+ return await ctx.ui.custom<T | null>(
443
+ (tui, theme, _keybindings, done) => {
444
+ const loader = new BorderedLoader(tui, theme, message);
445
+ let settled = false;
446
+ loader.onAbort = () => {
447
+ settled = true;
496
448
  done(null);
497
- });
498
- return loader;
499
- });
449
+ };
450
+ work(loader.signal)
451
+ .then((result) => {
452
+ if (!settled) done(result);
453
+ })
454
+ .catch((err) => {
455
+ if (settled) return;
456
+ onError?.(err);
457
+ ctx.ui.notify(errorMessage(err), "error");
458
+ done(null);
459
+ });
460
+ return loader;
461
+ },
462
+ {
463
+ overlay: true,
464
+ overlayOptions: { anchor: "center", width: 72, minWidth: 36, maxHeight: "90%", margin: 1 },
465
+ },
466
+ );
500
467
  }
501
468
 
502
469
  async function askSecret(
@@ -504,54 +471,41 @@ export default async function (pi: ExtensionAPI) {
504
471
  title: string,
505
472
  description: string,
506
473
  ): Promise<string | undefined> {
507
- return await ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
508
- const input = new Input();
509
- input.onSubmit = (value) => done(value);
510
- input.onEscape = () => done(undefined);
511
-
512
- const container = new Container();
513
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
514
- container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
515
- container.addChild(new Text(theme.fg("muted", description), 1, 0));
516
- container.addChild({
517
- render: (width: number) => {
518
- const count = [...input.getValue()].length;
519
- const available = Math.max(1, width - 4);
520
- const masked = count > available ? `…${"•".repeat(Math.max(0, available - 1))}` : "•".repeat(count);
521
- const marker = input.focused ? CURSOR_MARKER : "";
522
- return [truncateToWidth(`> ${masked}${marker}\x1b[7m \x1b[27m`, width, "")];
523
- },
524
- invalidate: () => {},
525
- });
526
- container.addChild(new Text(theme.fg("dim", "enter submit • esc cancel • value is masked"), 1, 0));
527
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
528
-
529
- return {
530
- get focused() {
531
- return input.focused;
532
- },
533
- set focused(value: boolean) {
534
- input.focused = value;
535
- },
536
- render: (width: number) => container.render(width),
537
- invalidate: () => container.invalidate(),
538
- handleInput: (data: string) => {
539
- input.handleInput(data);
540
- tui.requestRender();
541
- },
542
- };
543
- });
474
+ return await ctx.ui.custom<string | undefined>(
475
+ (tui, theme, keybindings, done) => new WizardSecretInput({
476
+ theme,
477
+ keybindings,
478
+ title,
479
+ description,
480
+ requestRender: () => tui.requestRender(),
481
+ done,
482
+ }),
483
+ {
484
+ overlay: true,
485
+ overlayOptions: { anchor: "center", width: 72, minWidth: 36, maxHeight: "90%", margin: 1 },
486
+ },
487
+ );
544
488
  }
545
489
 
546
490
  async function askNumber(
547
491
  ctx: ExtensionCommandContext,
548
492
  title: string,
549
- placeholder: string,
493
+ initialValue: string,
550
494
  ): Promise<number | undefined> {
551
- const raw = (await ctx.ui.input(title, placeholder))?.trim();
552
- if (!raw) return undefined;
553
- const n = parseInt(raw.replace(/[,._\s]/g, ""), 10);
554
- return isNaN(n) ? undefined : n;
495
+ const raw = await runInput(
496
+ ctx,
497
+ title,
498
+ initialValue,
499
+ "Enter a positive whole number. Clear the field and submit to keep the current value.",
500
+ (value) => {
501
+ const normalized = value.trim().replace(/[,._\s]/g, "");
502
+ if (!normalized) return null;
503
+ if (!/^\d+$/.test(normalized) || Number(normalized) <= 0) return "Enter a positive whole number greater than zero.";
504
+ return null;
505
+ },
506
+ );
507
+ const normalized = raw?.trim().replace(/[,._\s]/g, "");
508
+ return normalized ? Number(normalized) : undefined;
555
509
  }
556
510
 
557
511
  function modelFlags(c: ModelConfig, ov?: ModelOverride): string {
@@ -661,19 +615,18 @@ export default async function (pi: ExtensionAPI) {
661
615
  if (action === null) return null;
662
616
  if (action === "omit") return undefined;
663
617
 
664
- for (;;) {
665
- const raw = await ctx.ui.input(`Value for ${field.label}`, String(current ?? field.example));
666
- if (raw === undefined) return null;
667
- const trimmed = raw.trim();
668
- if (!trimmed) {
669
- ctx.ui.notify("Enter a numeric value, or choose Omit from the previous screen.", "error");
670
- continue;
671
- }
672
- const value = Number(trimmed);
673
- const error = validateProfileSampling({ [field.key]: value });
674
- if (!error) return value;
675
- ctx.ui.notify(error, "error");
676
- }
618
+ const raw = await runInput(
619
+ ctx,
620
+ `Value for ${field.label}`,
621
+ String(current ?? field.example),
622
+ field.description,
623
+ (value) => {
624
+ const trimmed = value.trim();
625
+ if (!trimmed) return "Enter a numeric value, or return and choose Omit.";
626
+ return validateProfileSampling({ [field.key]: Number(trimmed) });
627
+ },
628
+ );
629
+ return raw === undefined ? null : Number(raw.trim());
677
630
  }
678
631
 
679
632
  function profileDescription(
@@ -722,7 +675,7 @@ export default async function (pi: ExtensionAPI) {
722
675
  candidate: ModelProfile,
723
676
  previousSlug?: string,
724
677
  ): ModelProfile[] {
725
- const profiles = getModelProfiles(provider, modelId);
678
+ const profiles = app.profiles(provider, modelId);
726
679
  const index = previousSlug === undefined ? -1 : profiles.findIndex((profile) => profile.slug === previousSlug);
727
680
  if (index < 0) return [...profiles, candidate];
728
681
  return profiles.map((profile, profileIndex) => (profileIndex === index ? candidate : profile));
@@ -732,14 +685,14 @@ export default async function (pi: ExtensionAPI) {
732
685
  ctx: ExtensionCommandContext,
733
686
  initial: string,
734
687
  ): Promise<string | null> {
735
- for (;;) {
736
- const answer = await ctx.ui.input("Preset name", initial || "thinking-medium");
737
- if (answer === undefined) return null;
738
- const slug = answer.trim();
739
- const error = validateProfileSlug(slug);
740
- if (!error) return slug;
741
- ctx.ui.notify(error, "error");
742
- }
688
+ const answer = await runInput(
689
+ ctx,
690
+ "Preset name",
691
+ initial || "thinking-medium",
692
+ "Use a short slug for the fixed model alias and routing map.",
693
+ (value) => validateProfileSlug(value.trim()),
694
+ );
695
+ return answer === undefined ? null : answer.trim();
743
696
  }
744
697
 
745
698
  function validateProfileForProvider(
@@ -753,7 +706,7 @@ export default async function (pi: ExtensionAPI) {
753
706
  if (profileError) return profileError;
754
707
 
755
708
  const aliasId = profileModelId(config.id, profile.slug);
756
- const routing = getModelProfileRouting(provider, config.id);
709
+ const routing = app.profileRouting(provider, config.id);
757
710
  if (routing?.aliasSlug === profile.slug && profile.slug !== previousSlug) {
758
711
  return `Preset name "${profile.slug}" collides with the adaptive model alias.`;
759
712
  }
@@ -805,7 +758,7 @@ export default async function (pi: ExtensionAPI) {
805
758
  const sampling = profile.sampling ?? {};
806
759
  const samplingFields = profileSamplingFields(serverType);
807
760
  const repetitionPenaltyKey = repetitionPenaltyKeyForServer(serverType);
808
- const currentRouting = getModelProfileRouting(provider, config.id);
761
+ const currentRouting = app.profileRouting(provider, config.id);
809
762
  const previewRouting = currentRouting
810
763
  ? { ...currentRouting, levels: { ...currentRouting.levels } }
811
764
  : undefined;
@@ -844,10 +797,10 @@ export default async function (pi: ExtensionAPI) {
844
797
  label: field.label,
845
798
  description: `${sampling[field.key] ?? "omitted (server/model default)"} · ${field.description}`,
846
799
  })),
847
- { value: "save", label: "Save preset", description: profileModelId(config.id, profile.slug) },
800
+ { value: "save", label: "Save preset", description: profileModelId(config.id, profile.slug) },
848
801
  ];
849
- if (existing) items.push({ value: "delete", label: "Delete preset" });
850
- items.push({ value: "cancel", label: "Cancel" });
802
+ if (existing) items.push({ value: "delete", label: "Delete preset", description: "Requires confirmation" });
803
+ items.push({ value: "cancel", label: "Cancel" });
851
804
 
852
805
  const action = await runSelect(ctx, `Preset: ${profile.slug}`, items, [
853
806
  `model id: ${profileModelId(config.id, profile.slug)} → ${config.id}`,
@@ -906,7 +859,7 @@ export default async function (pi: ExtensionAPI) {
906
859
  }
907
860
  return { action: "save", profile };
908
861
  } else if (action === "delete" && existing) {
909
- const routing = getModelProfileRouting(provider, config.id);
862
+ const routing = app.profileRouting(provider, config.id);
910
863
  const routed = routing && Object.values(routing.levels).includes(existing.slug);
911
864
  const confirmed = await ctx.ui.confirm(
912
865
  "Delete preset",
@@ -922,10 +875,10 @@ export default async function (pi: ExtensionAPI) {
922
875
  provider: DiscoveredProvider,
923
876
  prefetched: { models: Record<string, unknown>[]; serverType: string },
924
877
  ): Promise<boolean> {
925
- upsertProvider(provider);
878
+ app.saveSource(provider);
926
879
  try {
927
880
  await registerProvider(provider, prefetched);
928
- upsertProvider(provider);
881
+ app.saveSource(provider);
929
882
  return true;
930
883
  } catch (err) {
931
884
  ctx.ui.notify(
@@ -952,12 +905,12 @@ export default async function (pi: ExtensionAPI) {
952
905
  provider: DiscoveredProvider,
953
906
  modelId: string,
954
907
  ): Promise<void> {
955
- const routing = getModelProfileRouting(provider, modelId);
908
+ const routing = app.profileRouting(provider, modelId);
956
909
  if (!routing || ctx.model?.provider !== provider.name) return;
957
910
  const adaptiveId = profileModelId(modelId, routing.aliasSlug);
958
911
  if (ctx.model.id !== adaptiveId) return;
959
912
  const valid =
960
- routing.enabled && analyzeExplicitProfileRouting(routing, getModelProfiles(provider, modelId)).errors.length === 0;
913
+ routing.enabled && analyzeExplicitProfileRouting(routing, app.profiles(provider, modelId)).errors.length === 0;
961
914
  const refreshed = ctx.modelRegistry.find(provider.name, valid ? adaptiveId : modelId);
962
915
  if (refreshed) await pi.setModel(refreshed);
963
916
  }
@@ -1028,7 +981,10 @@ export default async function (pi: ExtensionAPI) {
1028
981
  profile,
1029
982
  repetitionPenaltyKeyForServer(serverType),
1030
983
  );
1031
- await runSelect(ctx, `${level} → ${profile.slug}`, [{ value: "back", label: "← Back" }], [
984
+ await runTextView(ctx, `${level} → ${profile.slug}`, [
985
+ `Adaptive model: ${profileModelId(config.id, routing.aliasSlug)}`,
986
+ `Base model: ${config.id}`,
987
+ "",
1032
988
  ...JSON.stringify(payload, null, 2).split("\n"),
1033
989
  ]);
1034
990
  }
@@ -1047,7 +1003,7 @@ export default async function (pi: ExtensionAPI) {
1047
1003
  ctx.ui.notify("Create at least one preset before configuring adaptive routing.", "warning");
1048
1004
  return null;
1049
1005
  }
1050
- const existing = getModelProfileRouting(provider, config.id);
1006
+ const existing = app.profileRouting(provider, config.id);
1051
1007
  const routing: ModelProfileRouting = existing
1052
1008
  ? { ...existing, levels: { ...existing.levels } }
1053
1009
  : defaultProfileRouting(profiles);
@@ -1075,11 +1031,11 @@ export default async function (pi: ExtensionAPI) {
1075
1031
  label: `Pi ${level}`,
1076
1032
  description: `→ ${routing.levels[level] || "not selected"}`,
1077
1033
  })),
1078
- { value: "preview", label: "Preview exact requests", description: "inspect the payload preset for each Pi level" },
1079
- { value: "save", label: "Review and save", description: analysis.errors.length ? `${analysis.errors.length} issue(s)` : "valid mapping" },
1034
+ { value: "preview", label: "Preview exact requests", description: "Inspect the payload preset for each Pi level" },
1035
+ { value: "save", label: "Review and save", description: analysis.errors.length ? `${analysis.errors.length} issue(s)` : "Valid mapping" },
1080
1036
  ];
1081
- if (existing) items.push({ value: "remove", label: "Remove adaptive routing", description: "fixed presets remain unchanged" });
1082
- items.push({ value: "cancel", label: "Cancel" });
1037
+ if (existing) items.push({ value: "remove", label: "Remove adaptive routing", description: "Fixed presets remain unchanged · requires confirmation" });
1038
+ items.push({ value: "cancel", label: "Cancel" });
1083
1039
 
1084
1040
  const action = await runSelect(ctx, "Adaptive Shift-Tab routing", items, [
1085
1041
  "Explicit router: only this alias changes complete presets when Shift-Tab is pressed.",
@@ -1152,13 +1108,13 @@ export default async function (pi: ExtensionAPI) {
1152
1108
  prefetched: { models: Record<string, unknown>[]; serverType: string },
1153
1109
  ): Promise<void> {
1154
1110
  for (;;) {
1155
- const profiles = getModelProfiles(provider, config.id);
1156
- const routing = getModelProfileRouting(provider, config.id);
1111
+ const profiles = app.profiles(provider, config.id);
1112
+ const routing = app.profileRouting(provider, config.id);
1157
1113
  const routeAnalysis = routing ? analyzeExplicitProfileRouting(routing, profiles) : undefined;
1158
1114
  const items: SelectItem[] = [
1159
1115
  {
1160
1116
  value: "routing",
1161
- label: routing ? "Configure adaptive routing" : "+ Configure adaptive routing",
1117
+ label: "Configure adaptive routing",
1162
1118
  description: !routing
1163
1119
  ? "explicitly map all seven Pi levels to complete presets"
1164
1120
  : routeAnalysis?.errors.length
@@ -1176,8 +1132,8 @@ export default async function (pi: ExtensionAPI) {
1176
1132
  });
1177
1133
  }
1178
1134
  items.push(
1179
- { value: "add", label: "+ Create preset", description: "create a complete thinking/sampling parameter bundle" },
1180
- { value: "clone", label: "+ Clone preset", description: "copy an existing preset, then edit only what differs" },
1135
+ { value: "add", label: "Create preset", description: "Create a complete thinking/sampling parameter bundle" },
1136
+ { value: "clone", label: "Clone preset", description: "Copy an existing preset, then edit only what differs" },
1181
1137
  );
1182
1138
  for (const profile of profiles) {
1183
1139
  items.push({
@@ -1186,7 +1142,7 @@ export default async function (pi: ExtensionAPI) {
1186
1142
  description: profileDescription(profile, prefetched.serverType, routing),
1187
1143
  });
1188
1144
  }
1189
- items.push({ value: "back", label: "Back" });
1145
+ items.push({ value: "back", label: "Back" });
1190
1146
 
1191
1147
  const action = await runSelect(ctx, `Thinking & presets: ${config.id}`, items, [
1192
1148
  `${profiles.length} preset(s) · base model behavior is never changed by presets`,
@@ -1205,8 +1161,8 @@ export default async function (pi: ExtensionAPI) {
1205
1161
  prefetched.serverType,
1206
1162
  );
1207
1163
  if (!result) continue;
1208
- if (result.action === "save") saveModelProfileRouting(provider, config.id, result.routing);
1209
- else deleteModelProfileRouting(provider, config.id);
1164
+ if (result.action === "save") app.saveRouting(provider, config.id, result.routing);
1165
+ else app.removeRouting(provider, config.id);
1210
1166
  const registered = await persistProfileChange(ctx, provider, prefetched);
1211
1167
  if (registered) {
1212
1168
  const nextAlias = result.action === "save" && result.routing.enabled ? result.routing.aliasSlug : undefined;
@@ -1258,7 +1214,7 @@ export default async function (pi: ExtensionAPI) {
1258
1214
  );
1259
1215
  if (!result) continue;
1260
1216
  if (result.action === "save") {
1261
- const currentRouting = getModelProfileRouting(provider, config.id);
1217
+ const currentRouting = app.profileRouting(provider, config.id);
1262
1218
  const nextRouting = currentRouting
1263
1219
  ? { ...currentRouting, levels: { ...currentRouting.levels } }
1264
1220
  : undefined;
@@ -1284,8 +1240,8 @@ export default async function (pi: ExtensionAPI) {
1284
1240
  );
1285
1241
  if (!confirmed) continue;
1286
1242
  }
1287
- saveModelProfile(provider, config.id, result.profile, existing?.slug);
1288
- if (nextRouting) saveModelProfileRouting(provider, config.id, nextRouting);
1243
+ app.saveProfile(provider, config.id, result.profile, existing?.slug);
1244
+ if (nextRouting) app.saveRouting(provider, config.id, nextRouting);
1289
1245
  const registered = await persistProfileChange(ctx, provider, prefetched);
1290
1246
  if (registered) {
1291
1247
  if (existing && existing.exposeAsModel !== false) {
@@ -1303,7 +1259,7 @@ export default async function (pi: ExtensionAPI) {
1303
1259
  ctx.ui.notify(`${existing ? "Updated" : "Created"} preset "${result.profile.slug}".`, "info");
1304
1260
  }
1305
1261
  } else if (existing) {
1306
- deleteModelProfile(provider, config.id, existing.slug);
1262
+ app.removeProfile(provider, config.id, existing.slug);
1307
1263
  const registered = await persistProfileChange(ctx, provider, prefetched);
1308
1264
  if (registered) {
1309
1265
  await refreshSelectedProfile(
@@ -1347,8 +1303,8 @@ export default async function (pi: ExtensionAPI) {
1347
1303
  } · input ${effInput.join("+")}`,
1348
1304
  ];
1349
1305
 
1350
- const configuredProfiles = getModelProfiles(provider, config.id);
1351
- const configuredRouting = getModelProfileRouting(provider, config.id);
1306
+ const configuredProfiles = app.profiles(provider, config.id);
1307
+ const configuredRouting = app.profileRouting(provider, config.id);
1352
1308
  const routingAnalysis = configuredRouting
1353
1309
  ? analyzeExplicitProfileRouting(configuredRouting, configuredProfiles)
1354
1310
  : undefined;
@@ -1381,7 +1337,7 @@ export default async function (pi: ExtensionAPI) {
1381
1337
  if (Object.keys(ov).length > 0) {
1382
1338
  items.push({ value: "clear", label: "Clear overrides", description: "revert to server-reported values" });
1383
1339
  }
1384
- items.push({ value: "back", label: "Back" });
1340
+ items.push({ value: "back", label: "Back" });
1385
1341
 
1386
1342
  const action = await runSelect(ctx, `Model: ${config.id}${modelFlags(config, ov)}`, items, header);
1387
1343
  if (!action || action === "back") return;
@@ -1391,21 +1347,24 @@ export default async function (pi: ExtensionAPI) {
1391
1347
  continue;
1392
1348
  }
1393
1349
 
1350
+ let feedback: string | undefined;
1394
1351
  if (action === "ctx") {
1395
1352
  const n = await askNumber(ctx, `Context window for ${config.id}`, String(effCtx ?? 128000));
1396
- if (n !== undefined) {
1397
- provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, contextWindow: n } };
1398
- }
1353
+ if (n === undefined) continue;
1354
+ provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, contextWindow: n } };
1355
+ feedback = `Context window saved as ${fmt(n)}.`;
1399
1356
  } else if (action === "max") {
1400
1357
  const n = await askNumber(ctx, `Max output tokens for ${config.id}`, String(effMax ?? 16384));
1401
- if (n !== undefined) {
1402
- provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, maxTokens: n } };
1403
- }
1358
+ if (n === undefined) continue;
1359
+ provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, maxTokens: n } };
1360
+ feedback = `Max output tokens saved as ${fmt(n)}.`;
1404
1361
  } else if (action === "reasoning") {
1362
+ const reasoning = !(effReasoning ?? false);
1405
1363
  provider.modelOverrides = {
1406
1364
  ...provider.modelOverrides,
1407
- [config.id]: { ...ov, reasoning: !(effReasoning ?? false) },
1365
+ [config.id]: { ...ov, reasoning },
1408
1366
  };
1367
+ feedback = `Reasoning ${reasoning ? "enabled" : "disabled"}.`;
1409
1368
  } else if (action === "input") {
1410
1369
  // Toggle vision: add/remove "image" from input modalities
1411
1370
  const hasVision = effInput.includes("image");
@@ -1416,19 +1375,23 @@ export default async function (pi: ExtensionAPI) {
1416
1375
  ...provider.modelOverrides,
1417
1376
  [config.id]: { ...ov, input: newInput },
1418
1377
  };
1378
+ feedback = `Vision input ${hasVision ? "disabled" : "enabled"}.`;
1419
1379
  } else if (action === "clear") {
1420
1380
  if (provider.modelOverrides) {
1421
1381
  delete provider.modelOverrides[config.id];
1422
1382
  if (Object.keys(provider.modelOverrides).length === 0) provider.modelOverrides = undefined;
1423
1383
  }
1384
+ feedback = "Model overrides cleared.";
1424
1385
  }
1386
+ if (!feedback) continue;
1425
1387
 
1426
- // Persist + re-register with new values
1427
- upsertProvider(provider);
1388
+ // Persist + re-register with new values.
1389
+ app.saveSource(provider);
1428
1390
  try {
1429
1391
  await registerProvider(provider, prefetched);
1430
- } catch {
1431
- /* endpoint may be down; overrides still saved */
1392
+ ctx.ui.notify(feedback, "info");
1393
+ } catch (error) {
1394
+ ctx.ui.notify(`${feedback} Provider registration remains on its previous state: ${errorMessage(error)}`, "warning");
1432
1395
  }
1433
1396
  }
1434
1397
  }
@@ -1472,7 +1435,7 @@ export default async function (pi: ExtensionAPI) {
1472
1435
  header.push(`last successful scan: ${new Date(provider.lastScanned).toLocaleString()}`);
1473
1436
  }
1474
1437
  if (!live && provider.lastScanError) {
1475
- header.push(`latest live scan failed: ${provider.lastScanError}`);
1438
+ header.push(`latest live scan failed: ${redactSecret(provider.lastScanError, provider.apiKey)}`);
1476
1439
  header.push("Last known-good models and all saved presets remain available.");
1477
1440
  }
1478
1441
 
@@ -1481,26 +1444,26 @@ export default async function (pi: ExtensionAPI) {
1481
1444
  label: `${c.id}${modelFlags(c, provider.modelOverrides?.[c.id])}`,
1482
1445
  description: modelDescription(c, provider),
1483
1446
  }));
1484
- items.push({ value: "rescan", label: "Re-scan endpoint", description: "fetch fresh model list and re-register" });
1447
+ items.push({ value: "rescan", label: "Re-scan source", description: "Fetch a fresh model list and re-register" });
1485
1448
  items.push({
1486
1449
  value: "rename",
1487
- label: "Rename source",
1488
- description: `current: ${provider.name}`,
1450
+ label: "Rename source",
1451
+ description: `Current: ${provider.name}`,
1489
1452
  });
1490
1453
  items.push({
1491
1454
  value: "auth",
1492
- label: "🔑 Authentication",
1493
- description: provider.apiKey ? "API key configured · replace or clear" : "anonymous · add an API key",
1455
+ label: "Authentication",
1456
+ description: provider.apiKey ? "API key configured · replace or clear" : "Anonymous · add an API key",
1494
1457
  });
1495
1458
  items.push({
1496
1459
  value: "defaults",
1497
- label: " Edit fallback defaults",
1498
- description: `used when server reports nothing ctx ${fmt(provider.defaultContextWindow ?? null)} · max ${fmt(provider.defaultMaxTokens ?? null)}`,
1460
+ label: "Fallback defaults",
1461
+ description: `Used when the server reports nothing · ctx ${fmt(provider.defaultContextWindow ?? null)} · max ${fmt(provider.defaultMaxTokens ?? null)}`,
1499
1462
  });
1500
- items.push({ value: "remove", label: "Remove endpoint", description: "unregister provider and delete saved config" });
1501
- items.push({ value: "back", label: "Back" });
1463
+ items.push({ value: "remove", label: "Remove source", description: "Unregister the provider and delete its saved configuration" });
1464
+ items.push({ value: "back", label: "Back" });
1502
1465
 
1503
- const action = await runSelect(ctx, `Endpoint: ${provider.name}`, items, header);
1466
+ const action = await runSelect(ctx, `Source: ${provider.name}`, items, header);
1504
1467
  if (!action || action === "back") return;
1505
1468
 
1506
1469
  if (action.startsWith("model:")) {
@@ -1522,7 +1485,7 @@ export default async function (pi: ExtensionAPI) {
1522
1485
  try {
1523
1486
  const registered = await registerProvider(provider, live);
1524
1487
  recordSuccessfulScan(provider, live.models, live.serverType, false);
1525
- upsertProvider(provider);
1488
+ app.saveSource(provider);
1526
1489
  ctx.ui.notify(
1527
1490
  `Re-registered ${live.models.length} base model(s)${
1528
1491
  registered.profileCount ? ` + ${registered.profileCount} profile(s)` : ""
@@ -1536,13 +1499,26 @@ export default async function (pi: ExtensionAPI) {
1536
1499
  }
1537
1500
  }
1538
1501
  } else if (action === "rename") {
1539
- const newName = (await ctx.ui.input("New source name", provider.name))?.trim();
1502
+ const enteredName = await runInput(
1503
+ ctx,
1504
+ "New source name",
1505
+ provider.name,
1506
+ "This name identifies the provider and its models in /model.",
1507
+ (value) => {
1508
+ const name = value.trim();
1509
+ if (!name) return "Source name cannot be blank.";
1510
+ if (app.listSources().some((candidate) => candidate.name === name && candidate.name !== provider.name)) {
1511
+ return `Source "${name}" already exists.`;
1512
+ }
1513
+ return null;
1514
+ },
1515
+ );
1516
+ const newName = enteredName?.trim();
1540
1517
  if (newName && newName !== provider.name) {
1541
- const oldName = provider.name;
1542
- if (!renameProvider(oldName, newName)) {
1543
- ctx.ui.notify(`Cannot rename — "${newName}" already exists or "${oldName}" not found.`, "error");
1518
+ const renamed = app.renameSource(provider, newName);
1519
+ if (!renamed.ok) {
1520
+ ctx.ui.notify(`Cannot rename — "${newName}" already exists or "${renamed.oldName}" was not found.`, "error");
1544
1521
  } else {
1545
- provider.name = newName;
1546
1522
  const catalog = live ??
1547
1523
  (provider.cachedModels?.length
1548
1524
  ? {
@@ -1552,12 +1528,11 @@ export default async function (pi: ExtensionAPI) {
1552
1528
  : undefined);
1553
1529
  try {
1554
1530
  await registerProvider(provider, catalog);
1555
- pi.unregisterProvider(oldName);
1556
- upsertProvider(provider);
1531
+ pi.unregisterProvider(renamed.oldName);
1532
+ app.saveSource(provider);
1557
1533
  ctx.ui.notify(`Renamed to "${newName}"${live ? "" : " using the cached catalogue"}.`, "info");
1558
1534
  } catch (error) {
1559
- renameProvider(newName, oldName);
1560
- provider.name = oldName;
1535
+ app.renameSource(provider, renamed.oldName);
1561
1536
  ctx.ui.notify(`Rename rolled back: ${errorMessage(error)}`, "error");
1562
1537
  }
1563
1538
  }
@@ -1575,9 +1550,9 @@ export default async function (pi: ExtensionAPI) {
1575
1550
  ...(provider.apiKey
1576
1551
  ? [{ value: "clear", label: "Clear API key", description: "remove the saved bearer credential" }]
1577
1552
  : []),
1578
- { value: "back", label: "Back" },
1553
+ { value: "back", label: "Back" },
1579
1554
  ],
1580
- [provider.baseUrl, `current: ${provider.apiKey ? "API key configured" : "anonymous"}`],
1555
+ [provider.baseUrl, `Current: ${provider.apiKey ? "API key configured" : "anonymous"}`],
1581
1556
  );
1582
1557
  if (!authAction || authAction === "back") continue;
1583
1558
 
@@ -1598,8 +1573,7 @@ export default async function (pi: ExtensionAPI) {
1598
1573
  if (!confirmed) continue;
1599
1574
  }
1600
1575
 
1601
- provider.apiKey = nextApiKey;
1602
- upsertProvider(provider);
1576
+ app.setCredential(provider, nextApiKey);
1603
1577
  const checked = await runLoader(
1604
1578
  ctx,
1605
1579
  `Validating ${provider.name} authentication...`,
@@ -1610,7 +1584,7 @@ export default async function (pi: ExtensionAPI) {
1610
1584
  try {
1611
1585
  await registerProvider(provider, checked);
1612
1586
  recordSuccessfulScan(provider, checked.models, checked.serverType, false);
1613
- upsertProvider(provider);
1587
+ app.saveSource(provider);
1614
1588
  live = checked;
1615
1589
  ctx.ui.notify(`Authentication saved and validated for ${provider.name}.`, "info");
1616
1590
  } catch (error) {
@@ -1638,11 +1612,19 @@ export default async function (pi: ExtensionAPI) {
1638
1612
  ctx.ui.notify("Authentication saved but could not be validated; the last known-good catalogue was retained.", "warning");
1639
1613
  }
1640
1614
  } else if (action === "defaults") {
1615
+ const changed: string[] = [];
1641
1616
  const cw = await askNumber(ctx, "Default context window (blank = keep)", String(provider.defaultContextWindow ?? 128000));
1642
- if (cw !== undefined) provider.defaultContextWindow = cw;
1617
+ if (cw !== undefined) {
1618
+ provider.defaultContextWindow = cw;
1619
+ changed.push(`context ${fmt(cw)}`);
1620
+ }
1643
1621
  const mt = await askNumber(ctx, "Default max output tokens (blank = keep)", String(provider.defaultMaxTokens ?? 16384));
1644
- if (mt !== undefined) provider.defaultMaxTokens = mt;
1645
- upsertProvider(provider);
1622
+ if (mt !== undefined) {
1623
+ provider.defaultMaxTokens = mt;
1624
+ changed.push(`max output ${fmt(mt)}`);
1625
+ }
1626
+ if (!changed.length) continue;
1627
+ app.saveSource(provider);
1646
1628
  const catalog = live ??
1647
1629
  (provider.cachedModels?.length
1648
1630
  ? {
@@ -1652,14 +1634,18 @@ export default async function (pi: ExtensionAPI) {
1652
1634
  : undefined);
1653
1635
  try {
1654
1636
  await registerProvider(provider, catalog);
1637
+ ctx.ui.notify(`Fallback defaults saved: ${changed.join(" · ")}.`, "info");
1655
1638
  } catch (error) {
1656
1639
  ctx.ui.notify(`Defaults saved; provider remains on its last registered catalogue: ${errorMessage(error)}`, "warning");
1657
1640
  }
1658
1641
  } else if (action === "remove") {
1659
- const sure = await ctx.ui.confirm("Remove endpoint", `Remove "${provider.name}" (${provider.baseUrl})?`);
1642
+ const sure = await ctx.ui.confirm(
1643
+ "Remove source",
1644
+ `Unregister "${provider.name}" and delete its saved configuration, cached catalogue, presets, and routing?`,
1645
+ );
1660
1646
  if (sure) {
1661
1647
  pi.unregisterProvider(provider.name);
1662
- deleteProvider(provider.name);
1648
+ app.removeSource(provider.name);
1663
1649
  ctx.ui.notify(`Removed "${provider.name}".`, "info");
1664
1650
  return;
1665
1651
  }
@@ -1671,23 +1657,45 @@ export default async function (pi: ExtensionAPI) {
1671
1657
  // Screen: add endpoint
1672
1658
  // -----------------------------------------------------------------------
1673
1659
 
1674
- async function showAddScreen(ctx: ExtensionCommandContext, presetUrl?: string): Promise<void> {
1675
- let baseUrl = presetUrl ?? (await ctx.ui.input("Endpoint URL", "http://192.168.1.100:8080"))?.trim();
1676
- if (!baseUrl) return;
1677
- if (!baseUrl.startsWith("http")) baseUrl = `http://${baseUrl}`;
1678
- baseUrl = baseUrl.replace(/\/+$/, "");
1660
+ async function showAddScreen(ctx: ExtensionCommandContext, presetUrl?: string, presetName?: string): Promise<void> {
1661
+ const enteredUrl = presetUrl ?? await runInput(
1662
+ ctx,
1663
+ "Endpoint URL",
1664
+ "http://192.168.1.100:8080",
1665
+ "Enter the base URL of an OpenAI-compatible model server.",
1666
+ (value) => {
1667
+ if (!value.trim()) return "Endpoint URL cannot be blank.";
1668
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value.trim()) && !/^https?:\/\//i.test(value.trim())) {
1669
+ return "Endpoint URL must use HTTP or HTTPS.";
1670
+ }
1671
+ try {
1672
+ new URL(normalizeEndpointUrl(value));
1673
+ return null;
1674
+ } catch {
1675
+ return "Enter a valid HTTP or HTTPS endpoint URL.";
1676
+ }
1677
+ },
1678
+ );
1679
+ if (!enteredUrl) return;
1680
+ let baseUrl: string;
1681
+ try {
1682
+ baseUrl = normalizeEndpointUrl(enteredUrl);
1683
+ } catch (error) {
1684
+ ctx.ui.notify(errorMessage(error), "error");
1685
+ return;
1686
+ }
1679
1687
 
1680
1688
  const authMode = await runSelect(
1681
1689
  ctx,
1682
1690
  "Endpoint authentication",
1683
1691
  [
1684
- { value: "none", label: "No API key", description: "connect without a configured bearer credential" },
1692
+ { value: "none", label: "No API key", description: "Connect without a configured bearer credential" },
1685
1693
  {
1686
1694
  value: "api-key",
1687
1695
  label: "Enter API key",
1688
- description: "masked while typing · saved only in the private model-discovery config",
1696
+ description: "Masked while typing · saved only in the private model-discovery configuration",
1689
1697
  },
1690
- { value: "cancel", label: "Cancel" },
1698
+ { value: "cancel", label: "Cancel" },
1691
1699
  ],
1692
1700
  [baseUrl, "The key is sent as an Authorization: Bearer header for discovery and inference."],
1693
1701
  );
@@ -1729,7 +1737,21 @@ export default async function (pi: ExtensionAPI) {
1729
1737
  return;
1730
1738
  }
1731
1739
 
1732
- const name = (await ctx.ui.input("Provider name", generateProviderName(baseUrl)))?.trim() || generateProviderName(baseUrl);
1740
+ const suggestedName = presetName?.trim() || generateProviderName(baseUrl);
1741
+ const enteredName = await runInput(
1742
+ ctx,
1743
+ "Source name",
1744
+ suggestedName,
1745
+ "This name identifies the provider and its models in /model.",
1746
+ (value) => {
1747
+ const name = value.trim();
1748
+ if (!name) return "Source name cannot be blank.";
1749
+ if (app.findSource(name)) return `Source "${name}" already exists. Open it from the home screen instead.`;
1750
+ return null;
1751
+ },
1752
+ );
1753
+ if (enteredName === undefined) return;
1754
+ const name = enteredName.trim();
1733
1755
 
1734
1756
  const provider: DiscoveredProvider = { name, baseUrl, apiKey };
1735
1757
  const configs = live.models.map(extractModelConfig);
@@ -1750,8 +1772,8 @@ export default async function (pi: ExtensionAPI) {
1750
1772
  label: `${c.id}${modelFlags(c, provider.modelOverrides?.[c.id])}`,
1751
1773
  description: modelDescription(c, provider),
1752
1774
  }));
1753
- items.push({ value: "register", label: "Register endpoint", description: `save as "${name}" and make models available in /model` });
1754
- items.push({ value: "cancel", label: "Cancel" });
1775
+ items.push({ value: "register", label: "Register source", description: `Save as "${name}" and make models available in /model` });
1776
+ items.push({ value: "cancel", label: "Cancel" });
1755
1777
 
1756
1778
  const action = await runSelect(ctx, `Review: ${name}`, items, header);
1757
1779
  if (!action || action === "cancel") {
@@ -1794,7 +1816,7 @@ export default async function (pi: ExtensionAPI) {
1794
1816
  try {
1795
1817
  await registerProvider(provider, live);
1796
1818
  recordSuccessfulScan(provider, live.models, live.serverType, false);
1797
- upsertProvider(provider);
1819
+ app.saveSource(provider);
1798
1820
  ctx.ui.notify(
1799
1821
  `Registered ${configs.length} model(s) from ${live.serverType} as "${name}". Use /model to select.`,
1800
1822
  "info",
@@ -1813,41 +1835,33 @@ export default async function (pi: ExtensionAPI) {
1813
1835
 
1814
1836
  async function showMainScreen(ctx: ExtensionCommandContext): Promise<void> {
1815
1837
  for (;;) {
1816
- const providers = loadProviders();
1817
- const items: SelectItem[] = providers.map((p) => ({
1818
- value: `provider:${p.name}`,
1819
- label: p.name,
1820
- description: `${p.serverType ?? "?"} · ${p.baseUrl} · ${
1821
- p.lastScanError
1822
- ? `${p.cachedModels?.length ? "cached" : "unavailable"} after failed live scan`
1823
- : `live scan ${p.lastScanned ? new Date(p.lastScanned).toLocaleString() : "never completed"}`
1824
- }`,
1825
- }));
1826
- items.push({ value: "add", label: "+ Add endpoint", description: "discover models from an OpenAI-compatible server" });
1827
- if (providers.length > 0) {
1828
- items.push({ value: "rescan-all", label: "⟳ Re-scan all", description: "refresh model lists from every endpoint" });
1829
- }
1830
- items.push({ value: "quit", label: "✗ Close" });
1831
-
1832
- const action = await runSelect(ctx, "Model Discovery", items, [
1833
- providers.length === 0 ? "No endpoints yet — add your first one." : `${providers.length} endpoint(s) registered`,
1834
- ]);
1838
+ const providers = app.listSources();
1839
+ const action = await runSelect(
1840
+ ctx,
1841
+ "Model Discovery",
1842
+ buildHomeItems(providers),
1843
+ buildHomeSummary(providers),
1844
+ );
1835
1845
  if (!action || action === "quit") return;
1836
1846
 
1837
1847
  if (action === "add") {
1838
1848
  await showAddScreen(ctx);
1849
+ } else if (action === "diagnostics") {
1850
+ await runTextView(ctx, "Model Discovery diagnostics", buildDiagnosticsLines(app.listSources()));
1839
1851
  } else if (action === "rescan-all") {
1840
- const results = await runLoader(ctx, "Re-scanning all endpoints...", async () => {
1852
+ const results = await runLoader(ctx, "Re-scanning all sources...", async (signal) => {
1841
1853
  let live = 0;
1842
1854
  let cached = 0;
1843
1855
  let failed = 0;
1844
- for (const provider of loadProviders()) {
1856
+ for (const provider of app.listSources()) {
1857
+ if (signal.aborted) break;
1845
1858
  try {
1846
- const registered = await registerProvider(provider);
1859
+ const registered = await registerProvider(provider, undefined, signal);
1847
1860
  recordSuccessfulScan(provider, registered.rawModels, registered.serverType, false);
1848
- upsertProvider(provider);
1861
+ app.saveSource(provider);
1849
1862
  live++;
1850
1863
  } catch (error) {
1864
+ if (signal.aborted) break;
1851
1865
  recordFailedScan(provider, error, false);
1852
1866
  if (provider.cachedModels?.length) {
1853
1867
  try {
@@ -1855,14 +1869,14 @@ export default async function (pi: ExtensionAPI) {
1855
1869
  models: provider.cachedModels,
1856
1870
  serverType: provider.serverType ?? "OpenAI-compatible",
1857
1871
  });
1858
- upsertProvider(provider);
1872
+ app.saveSource(provider);
1859
1873
  cached++;
1860
1874
  continue;
1861
1875
  } catch {
1862
1876
  /* report below without removing the previously registered provider */
1863
1877
  }
1864
1878
  }
1865
- upsertProvider(provider);
1879
+ app.saveSource(provider);
1866
1880
  failed++;
1867
1881
  }
1868
1882
  }
@@ -1877,7 +1891,7 @@ export default async function (pi: ExtensionAPI) {
1877
1891
  }
1878
1892
  } else if (action.startsWith("provider:")) {
1879
1893
  const name = action.slice("provider:".length);
1880
- const provider = loadProviders().find((p) => p.name === name);
1894
+ const provider = app.findSource(name);
1881
1895
  if (provider) await showEndpointScreen(ctx, provider);
1882
1896
  }
1883
1897
  }
@@ -1887,18 +1901,100 @@ export default async function (pi: ExtensionAPI) {
1887
1901
  // Command: /discover — single entry point
1888
1902
  // -----------------------------------------------------------------------
1889
1903
 
1904
+ type ReportLevel = "info" | "warning" | "error";
1905
+ function emitText(ctx: ExtensionCommandContext, text: string, level: ReportLevel = "info"): void {
1906
+ if (ctx.hasUI) ctx.ui.notify(text, level);
1907
+ else console.log(text);
1908
+ }
1909
+
1910
+ async function showReport(ctx: ExtensionCommandContext, title: string, text: string): Promise<void> {
1911
+ if (ctx.mode === "tui") await runTextView(ctx, title, text.split("\n"));
1912
+ else emitText(ctx, text);
1913
+ }
1914
+
1915
+ async function addSourceHeadlessly(
1916
+ ctx: ExtensionCommandContext,
1917
+ url: string,
1918
+ providerName?: string,
1919
+ ): Promise<void> {
1920
+ try {
1921
+ const result = await discoverAndRegisterSource({ url, providerName });
1922
+ emitText(
1923
+ ctx,
1924
+ `Registered source "${result.provider.name}" (${result.serverType}): ${result.models.length} base model(s)${result.profileCount ? ` + ${result.profileCount} preset model(s)` : ""}.`,
1925
+ );
1926
+ } catch (error) {
1927
+ emitText(ctx, `Could not add source: ${errorMessage(error)}`, "error");
1928
+ }
1929
+ }
1930
+
1890
1931
  pi.registerCommand("discover", {
1891
- description: "Manage local model endpoints (llama.cpp, oMLX, Ollama, vLLM, ...)",
1932
+ description: "Open model-source discovery or inspect it with /discover status",
1933
+ getArgumentCompletions: (prefix) => completeDiscoverArgs(
1934
+ prefix,
1935
+ app.listSources().map((provider) => provider.name),
1936
+ ),
1892
1937
  handler: async (args, ctx) => {
1893
- if (ctx.mode !== "tui") {
1894
- ctx.ui.notify("/discover requires interactive mode", "error");
1895
- return;
1896
- }
1897
- const url = args?.trim();
1898
- if (url) {
1899
- await showAddScreen(ctx, url.startsWith("http") ? url : `http://${url}`);
1900
- } else {
1901
- await showMainScreen(ctx);
1938
+ const intent = parseDiscoverArgs(args);
1939
+ switch (intent.kind) {
1940
+ case "open":
1941
+ if (ctx.mode === "tui") await showMainScreen(ctx);
1942
+ else await showReport(ctx, "Model Discovery status", formatDiscoveryStatus(app.listSources()));
1943
+ return;
1944
+ case "status":
1945
+ await showReport(ctx, "Model Discovery status", formatDiscoveryStatus(app.listSources()));
1946
+ return;
1947
+ case "doctor": {
1948
+ const lines = buildDiagnosticsLines(app.listSources());
1949
+ lines.push(
1950
+ "",
1951
+ "Actions",
1952
+ "- Re-scan from the wizard to refresh live catalogues.",
1953
+ "- Authentication secrets are configured only through the masked TUI.",
1954
+ );
1955
+ await showReport(ctx, "Model Discovery diagnostics", lines.join("\n"));
1956
+ return;
1957
+ }
1958
+ case "paths":
1959
+ await showReport(ctx, "Model Discovery paths", `Configuration: ${STORAGE_PATH}`);
1960
+ return;
1961
+ case "help":
1962
+ await showReport(ctx, "Model Discovery help", DISCOVER_USAGE);
1963
+ return;
1964
+ case "add":
1965
+ if (!intent.url) {
1966
+ if (ctx.mode === "tui") await showAddScreen(ctx);
1967
+ else emitText(ctx, `Missing source URL.\n${DISCOVER_USAGE}`, "error");
1968
+ return;
1969
+ }
1970
+ if (ctx.mode === "tui") {
1971
+ await showAddScreen(ctx, intent.url, intent.providerName);
1972
+ } else {
1973
+ await addSourceHeadlessly(ctx, intent.url, intent.providerName);
1974
+ }
1975
+ return;
1976
+ case "remove": {
1977
+ const provider = app.findSource(intent.name);
1978
+ if (!provider) {
1979
+ emitText(ctx, `Unknown source: ${intent.name}`, "error");
1980
+ return;
1981
+ }
1982
+ if (!intent.confirmed && ctx.mode !== "tui") {
1983
+ emitText(ctx, `Refusing to remove "${provider.name}" without --yes.`, "error");
1984
+ return;
1985
+ }
1986
+ const confirmed = intent.confirmed || await ctx.ui.confirm(
1987
+ "Remove source",
1988
+ `Unregister "${provider.name}" and delete its saved configuration, cached catalogue, presets, and routing?`,
1989
+ );
1990
+ if (!confirmed) return;
1991
+ pi.unregisterProvider(provider.name);
1992
+ app.removeSource(provider.name);
1993
+ emitText(ctx, `Removed source "${provider.name}".`);
1994
+ return;
1995
+ }
1996
+ case "invalid":
1997
+ emitText(ctx, `${intent.message}\n${DISCOVER_USAGE}`, "error");
1902
1998
  }
1903
1999
  },
1904
2000
  });
@@ -1926,36 +2022,23 @@ export default async function (pi: ExtensionAPI) {
1926
2022
  "Discover and register models from an OpenAI-compatible endpoint (llama.cpp, oMLX, Ollama, vLLM). Reads actual server config. Use when the user asks to add a local model server.",
1927
2023
  parameters: discoverModelsParameters,
1928
2024
  async execute(_toolCallId, params) {
1929
- let { url, providerName, apiKey } = params;
1930
- if (!url.startsWith("http")) url = `http://${url}`;
1931
- url = url.replace(/\/+$/, "");
1932
- providerName = providerName || generateProviderName(url);
1933
-
1934
- let live: { models: Record<string, unknown>[]; serverType: string };
2025
+ let result: Awaited<ReturnType<typeof discoverAndRegisterSource>>;
1935
2026
  try {
1936
- live = await fetchModels(url, apiKey);
1937
- } catch (err) {
2027
+ result = await discoverAndRegisterSource(params);
2028
+ } catch (error) {
2029
+ const message = errorMessage(error);
2030
+ if (message === "Endpoint is online but reports no models.") {
2031
+ return { content: [{ type: "text", text: message }], details: {} };
2032
+ }
1938
2033
  return {
1939
- content: [
1940
- { type: "text", text: `Endpoint unavailable: ${err instanceof Error ? err.message : String(err)}` },
1941
- ],
2034
+ content: [{ type: "text", text: `Endpoint unavailable or registration failed: ${message}` }],
1942
2035
  details: {},
1943
2036
  isError: true,
1944
2037
  };
1945
2038
  }
1946
- if (live.models.length === 0) {
1947
- return { content: [{ type: "text", text: "Endpoint online but reports no models." }], details: {} };
1948
- }
1949
2039
 
1950
- const existing = loadProviders().find((p) => p.name === providerName);
1951
- const provider: DiscoveredProvider = existing
1952
- ? { ...existing, baseUrl: url, apiKey: apiKey ?? existing.apiKey }
1953
- : { name: providerName, baseUrl: url, apiKey };
2040
+ const { provider, models: configs, serverType, profileCount } = result;
1954
2041
  try {
1955
- const { models: configs, profileCount } = await registerProvider(provider, live);
1956
- recordSuccessfulScan(provider, live.models, live.serverType, false);
1957
- upsertProvider(provider);
1958
-
1959
2042
  const lines = configs.map(
1960
2043
  (c) =>
1961
2044
  `- ${c.id}${modelFlags(c, provider.modelOverrides?.[c.id])}: ${modelDescription(c, provider)}`,
@@ -1970,12 +2053,12 @@ export default async function (pi: ExtensionAPI) {
1970
2053
  content: [
1971
2054
  {
1972
2055
  type: "text",
1973
- text: `Endpoint online (${live.serverType}). Registered ${configs.length} base model(s)${
2056
+ text: `Endpoint online (${serverType}). Registered ${configs.length} base model(s)${
1974
2057
  profileCount ? ` + ${profileCount} profile(s)` : ""
1975
- } as "${providerName}":\n${lines.join("\n")}${note}\n\nModels are now selectable via /model.`,
2058
+ } as "${provider.name}":\n${lines.join("\n")}${note}\n\nModels are now selectable via /model.`,
1976
2059
  },
1977
2060
  ],
1978
- details: { providerName, serverType: live.serverType, modelCount: configs.length, profileCount },
2061
+ details: { providerName: provider.name, serverType, modelCount: configs.length, profileCount },
1979
2062
  };
1980
2063
  } catch (err) {
1981
2064
  return {