@maheidem/model-discovery 0.7.1 → 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,
@@ -52,205 +51,40 @@ import {
52
51
  repairRequestToolSchemas,
53
52
  type ToolSchemaRepairReport,
54
53
  } from "./schema-repair.ts";
55
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
56
- import { join } from "node:path";
57
- import os from "node:os";
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";
58
71
 
59
72
  // ---------------------------------------------------------------------------
60
73
  // Types
61
74
  // ---------------------------------------------------------------------------
62
75
 
63
- interface ModelOverride {
64
- contextWindow?: number;
65
- maxTokens?: number;
66
- reasoning?: boolean;
67
- input?: string[];
68
- }
69
- interface DiscoveredProvider {
70
- name: string;
71
- baseUrl: string;
72
- apiKey?: string;
73
- serverType?: string;
74
- defaultContextWindow?: number;
75
- defaultMaxTokens?: number;
76
- modelOverrides?: Record<string, ModelOverride>;
77
- modelProfiles?: Record<string, ModelProfile[]>;
78
- modelProfileRouting?: Record<string, ModelProfileRouting>;
79
- profileSchemaVersion?: number;
80
- cachedModels?: Record<string, unknown>[];
81
- compat?: Record<string, unknown>;
82
- /**
83
- * Inline $defs/$ref in outgoing tool schemas for this endpoint (default: true for
84
- * local/self-hosted endpoints, where llama.cpp-style grammar converters reject any
85
- * $ref that is not resolvable at the document root). Set false to send verbatim.
86
- */
87
- repairToolSchemas?: boolean;
88
- /** Last successful live catalogue refresh (legacy name retained in storage). */
89
- lastScanned?: number;
90
- lastScanAttempt?: number;
91
- lastScanError?: string;
92
- }
93
-
94
- // ---------------------------------------------------------------------------
95
- // Storage
96
- // ---------------------------------------------------------------------------
97
-
98
- const STORAGE_PATH = join(os.homedir(), ".pi", "agent", "model-discovery.json");
99
-
100
- function writeProvidersAtomic(providers: DiscoveredProvider[]): void {
101
- const tempPath = `${STORAGE_PATH}.${process.pid}.${Date.now()}.tmp`;
102
- try {
103
- writeFileSync(tempPath, JSON.stringify(providers, null, 2), { encoding: "utf-8", mode: 0o600 });
104
- renameSync(tempPath, STORAGE_PATH);
105
- } catch (error) {
106
- try {
107
- if (existsSync(tempPath)) unlinkSync(tempPath);
108
- } catch {
109
- /* best-effort cleanup */
110
- }
111
- 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.");
112
81
  }
113
- }
114
-
115
- function loadProviders(): DiscoveredProvider[] {
116
- try {
117
- if (existsSync(STORAGE_PATH)) {
118
- const providers = JSON.parse(readFileSync(STORAGE_PATH, "utf-8")) as DiscoveredProvider[];
119
- let migrated = false;
120
- for (const provider of providers) {
121
- if ((provider.profileSchemaVersion ?? 0) >= 2) continue;
122
- for (const [modelId, rawProfiles] of Object.entries(provider.modelProfiles ?? {})) {
123
- if (!Array.isArray(rawProfiles)) continue;
124
- const result = migrateLegacyProfileRouting(rawProfiles, provider.modelProfileRouting?.[modelId]);
125
- if (!result.changed || !result.routing) continue;
126
- provider.modelProfiles = { ...provider.modelProfiles, [modelId]: result.profiles };
127
- provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: result.routing };
128
- migrated = true;
129
- }
130
- provider.profileSchemaVersion = 2;
131
- migrated = true;
132
- }
133
- if (migrated) writeProvidersAtomic(providers);
134
- return providers;
135
- }
136
- } catch {
137
- /* 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.");
138
86
  }
139
- return [];
140
- }
141
-
142
- function saveProviders(providers: DiscoveredProvider[]): void {
143
- writeProvidersAtomic(providers);
144
- }
145
-
146
- function upsertProvider(provider: DiscoveredProvider): void {
147
- const all = loadProviders();
148
- const idx = all.findIndex((p) => p.name === provider.name);
149
- if (idx >= 0) all[idx] = provider;
150
- else all.push(provider);
151
- saveProviders(all);
152
- }
153
-
154
- function deleteProvider(name: string): void {
155
- saveProviders(loadProviders().filter((p) => p.name !== name));
156
- }
157
-
158
- function errorMessage(error: unknown): string {
159
- return error instanceof Error ? error.message : String(error);
160
- }
161
-
162
- function persistProviderScanState(provider: DiscoveredProvider): void {
163
- try {
164
- const providers = loadProviders();
165
- const stored = providers.find((candidate) => candidate.name === provider.name && candidate.baseUrl === provider.baseUrl);
166
- if (!stored) return;
167
- stored.serverType = provider.serverType;
168
- stored.cachedModels = provider.cachedModels;
169
- stored.lastScanned = provider.lastScanned;
170
- stored.lastScanAttempt = provider.lastScanAttempt;
171
- stored.lastScanError = provider.lastScanError;
172
- saveProviders(providers);
173
- } catch (error) {
174
- // Runtime registration must not fail merely because scan metadata could not be persisted.
175
- console.error(`[model-discovery] ${provider.name}: could not persist catalogue state (${errorMessage(error)}).`);
176
- }
177
- }
178
-
179
- function recordSuccessfulScan(
180
- provider: DiscoveredProvider,
181
- models: Record<string, unknown>[],
182
- serverType: string,
183
- persist = true,
184
- ): void {
185
- const now = Date.now();
186
- provider.serverType = serverType;
187
- provider.cachedModels = models;
188
- provider.lastScanned = now;
189
- provider.lastScanAttempt = now;
190
- provider.lastScanError = undefined;
191
- if (persist) persistProviderScanState(provider);
192
- }
193
-
194
- function recordFailedScan(provider: DiscoveredProvider, error: unknown, persist = true): void {
195
- provider.lastScanAttempt = Date.now();
196
- provider.lastScanError = redactSecret(errorMessage(error), provider.apiKey);
197
- if (persist) persistProviderScanState(provider);
198
- }
199
-
200
- function renameProvider(oldName: string, newName: string): boolean {
201
- const all = loadProviders();
202
- const idx = all.findIndex((p) => p.name === oldName);
203
- if (idx < 0) return false;
204
- if (all.some((p) => p.name === newName)) return false; // name already taken
205
- all[idx].name = newName;
206
- saveProviders(all);
207
- return true;
208
- }
209
-
210
- function getModelProfiles(provider: DiscoveredProvider, modelId: string): ModelProfile[] {
211
- const profiles: unknown = provider.modelProfiles?.[modelId];
212
- if (!Array.isArray(profiles)) return [];
213
- return profiles.filter((profile): profile is ModelProfile => validateModelProfile(profile) === null);
214
- }
215
-
216
- function saveModelProfile(
217
- provider: DiscoveredProvider,
218
- modelId: string,
219
- profile: ModelProfile,
220
- previousSlug?: string,
221
- ): void {
222
- const profiles = getModelProfiles(provider, modelId);
223
- const index = previousSlug === undefined ? -1 : profiles.findIndex((item) => item.slug === previousSlug);
224
- const next = [...profiles];
225
- if (index >= 0) next[index] = profile;
226
- else next.push(profile);
227
- provider.modelProfiles = { ...provider.modelProfiles, [modelId]: next };
228
- }
229
-
230
- function deleteModelProfile(provider: DiscoveredProvider, modelId: string, slug: string): void {
231
- const nextProfiles = getModelProfiles(provider, modelId).filter((profile) => profile.slug !== slug);
232
- const modelProfiles = { ...provider.modelProfiles };
233
- if (nextProfiles.length > 0) modelProfiles[modelId] = nextProfiles;
234
- else delete modelProfiles[modelId];
235
- provider.modelProfiles = Object.keys(modelProfiles).length > 0 ? modelProfiles : undefined;
236
- }
237
-
238
- function getModelProfileRouting(provider: DiscoveredProvider, modelId: string): ModelProfileRouting | undefined {
239
- return provider.modelProfileRouting?.[modelId];
240
- }
241
-
242
- function saveModelProfileRouting(
243
- provider: DiscoveredProvider,
244
- modelId: string,
245
- routing: ModelProfileRouting,
246
- ): void {
247
- provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: routing };
248
- }
249
-
250
- function deleteModelProfileRouting(provider: DiscoveredProvider, modelId: string): void {
251
- const routing = { ...provider.modelProfileRouting };
252
- delete routing[modelId];
253
- provider.modelProfileRouting = Object.keys(routing).length > 0 ? routing : undefined;
87
+ return url.replace(/\/+$/, "");
254
88
  }
255
89
 
256
90
  function generateProviderName(url: string): string {
@@ -273,6 +107,8 @@ function fmt(n: number | null | undefined): string {
273
107
  // ---------------------------------------------------------------------------
274
108
 
275
109
  export default async function (pi: ExtensionAPI) {
110
+ const app = createDiscoveryApplication();
111
+
276
112
  type RuntimeThinkingRoutes = {
277
113
  routes: ThinkingProfileRoutes;
278
114
  repetitionPenaltyKey: ReturnType<typeof repetitionPenaltyKeyForServer>;
@@ -316,8 +152,9 @@ export default async function (pi: ExtensionAPI) {
316
152
  async function registerProvider(
317
153
  provider: DiscoveredProvider,
318
154
  prefetched?: { models: Record<string, unknown>[]; serverType: string },
155
+ signal: AbortSignal = AbortSignal.timeout(2_000),
319
156
  ): Promise<{ models: ModelConfig[]; rawModels: Record<string, unknown>[]; serverType: string; profileCount: number }> {
320
- 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));
321
158
  if (models.length === 0) throw new Error("No models found at this endpoint.");
322
159
 
323
160
  const routePrefix = `${provider.name}/`;
@@ -383,7 +220,7 @@ export default async function (pi: ExtensionAPI) {
383
220
  thinkingRoutes.set(routeKey(provider.name, modelId), { routes, repetitionPenaltyKey });
384
221
  }
385
222
  for (const base of baseModels) {
386
- for (const profile of getModelProfiles(provider, base.id)) {
223
+ for (const profile of app.profiles(provider, base.id)) {
387
224
  if (profile.exposeAsModel !== false) {
388
225
  fixedProfileLabels.set(routeKey(provider.name, profileModelId(base.id, profile.slug)), profile.slug);
389
226
  }
@@ -410,8 +247,40 @@ export default async function (pi: ExtensionAPI) {
410
247
  };
411
248
  }
412
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
+
413
282
  // Register saved providers at startup (concurrent — one dead endpoint can't block the others)
414
- const providers = loadProviders();
283
+ const providers = app.listSources();
415
284
  if (providers.length > 0) {
416
285
  const results = await Promise.allSettled(
417
286
  providers.map(async (provider) => {
@@ -501,42 +370,67 @@ export default async function (pi: ExtensionAPI) {
501
370
  items: SelectItem[],
502
371
  headerLines: string[] = [],
503
372
  ): Promise<string | null> {
504
- return await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
505
- const container = new Container();
506
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
507
- container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
508
- for (const line of headerLines) {
509
- container.addChild(new Text(theme.fg("muted", line), 1, 0));
510
- }
511
-
512
- 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,
513
378
  items,
514
- Math.min(items.length, 12),
515
- {
516
- selectedPrefix: (t: string) => theme.fg("accent", t),
517
- selectedText: (t: string) => theme.fg("accent", t),
518
- description: (t: string) => theme.fg("muted", t),
519
- scrollInfo: (t: string) => theme.fg("dim", t),
520
- noMatch: (t: string) => theme.fg("warning", t),
521
- },
522
- { minPrimaryColumnWidth: 18, maxPrimaryColumnWidth: 48 },
523
- );
524
- selectList.onSelect = (item) => done(item.value);
525
- selectList.onCancel = () => done(null);
526
- 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
+ }
527
389
 
528
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc back • type to filter"), 1, 0));
529
- 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
+ }
530
410
 
531
- return {
532
- render: (w: number) => container.render(w),
533
- invalidate: () => container.invalidate(),
534
- handleInput: (data: string) => {
535
- selectList.handleInput(data);
536
- tui.requestRender();
537
- },
538
- };
539
- });
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
+ );
540
434
  }
541
435
 
542
436
  async function runLoader<T>(
@@ -545,18 +439,31 @@ export default async function (pi: ExtensionAPI) {
545
439
  work: (signal: AbortSignal) => Promise<T>,
546
440
  onError?: (error: unknown) => void,
547
441
  ): Promise<T | null> {
548
- return await ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
549
- const loader = new BorderedLoader(tui, theme, message);
550
- loader.onAbort = () => done(null);
551
- work(loader.signal)
552
- .then((result) => done(result))
553
- .catch((err) => {
554
- onError?.(err);
555
- 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;
556
448
  done(null);
557
- });
558
- return loader;
559
- });
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
+ );
560
467
  }
561
468
 
562
469
  async function askSecret(
@@ -564,54 +471,41 @@ export default async function (pi: ExtensionAPI) {
564
471
  title: string,
565
472
  description: string,
566
473
  ): Promise<string | undefined> {
567
- return await ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
568
- const input = new Input();
569
- input.onSubmit = (value) => done(value);
570
- input.onEscape = () => done(undefined);
571
-
572
- const container = new Container();
573
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
574
- container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
575
- container.addChild(new Text(theme.fg("muted", description), 1, 0));
576
- container.addChild({
577
- render: (width: number) => {
578
- const count = [...input.getValue()].length;
579
- const available = Math.max(1, width - 4);
580
- const masked = count > available ? `…${"•".repeat(Math.max(0, available - 1))}` : "•".repeat(count);
581
- const marker = input.focused ? CURSOR_MARKER : "";
582
- return [truncateToWidth(`> ${masked}${marker}\x1b[7m \x1b[27m`, width, "")];
583
- },
584
- invalidate: () => {},
585
- });
586
- container.addChild(new Text(theme.fg("dim", "enter submit • esc cancel • value is masked"), 1, 0));
587
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
588
-
589
- return {
590
- get focused() {
591
- return input.focused;
592
- },
593
- set focused(value: boolean) {
594
- input.focused = value;
595
- },
596
- render: (width: number) => container.render(width),
597
- invalidate: () => container.invalidate(),
598
- handleInput: (data: string) => {
599
- input.handleInput(data);
600
- tui.requestRender();
601
- },
602
- };
603
- });
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
+ );
604
488
  }
605
489
 
606
490
  async function askNumber(
607
491
  ctx: ExtensionCommandContext,
608
492
  title: string,
609
- placeholder: string,
493
+ initialValue: string,
610
494
  ): Promise<number | undefined> {
611
- const raw = (await ctx.ui.input(title, placeholder))?.trim();
612
- if (!raw) return undefined;
613
- const n = parseInt(raw.replace(/[,._\s]/g, ""), 10);
614
- 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;
615
509
  }
616
510
 
617
511
  function modelFlags(c: ModelConfig, ov?: ModelOverride): string {
@@ -721,19 +615,18 @@ export default async function (pi: ExtensionAPI) {
721
615
  if (action === null) return null;
722
616
  if (action === "omit") return undefined;
723
617
 
724
- for (;;) {
725
- const raw = await ctx.ui.input(`Value for ${field.label}`, String(current ?? field.example));
726
- if (raw === undefined) return null;
727
- const trimmed = raw.trim();
728
- if (!trimmed) {
729
- ctx.ui.notify("Enter a numeric value, or choose Omit from the previous screen.", "error");
730
- continue;
731
- }
732
- const value = Number(trimmed);
733
- const error = validateProfileSampling({ [field.key]: value });
734
- if (!error) return value;
735
- ctx.ui.notify(error, "error");
736
- }
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());
737
630
  }
738
631
 
739
632
  function profileDescription(
@@ -782,7 +675,7 @@ export default async function (pi: ExtensionAPI) {
782
675
  candidate: ModelProfile,
783
676
  previousSlug?: string,
784
677
  ): ModelProfile[] {
785
- const profiles = getModelProfiles(provider, modelId);
678
+ const profiles = app.profiles(provider, modelId);
786
679
  const index = previousSlug === undefined ? -1 : profiles.findIndex((profile) => profile.slug === previousSlug);
787
680
  if (index < 0) return [...profiles, candidate];
788
681
  return profiles.map((profile, profileIndex) => (profileIndex === index ? candidate : profile));
@@ -792,14 +685,14 @@ export default async function (pi: ExtensionAPI) {
792
685
  ctx: ExtensionCommandContext,
793
686
  initial: string,
794
687
  ): Promise<string | null> {
795
- for (;;) {
796
- const answer = await ctx.ui.input("Preset name", initial || "thinking-medium");
797
- if (answer === undefined) return null;
798
- const slug = answer.trim();
799
- const error = validateProfileSlug(slug);
800
- if (!error) return slug;
801
- ctx.ui.notify(error, "error");
802
- }
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();
803
696
  }
804
697
 
805
698
  function validateProfileForProvider(
@@ -813,7 +706,7 @@ export default async function (pi: ExtensionAPI) {
813
706
  if (profileError) return profileError;
814
707
 
815
708
  const aliasId = profileModelId(config.id, profile.slug);
816
- const routing = getModelProfileRouting(provider, config.id);
709
+ const routing = app.profileRouting(provider, config.id);
817
710
  if (routing?.aliasSlug === profile.slug && profile.slug !== previousSlug) {
818
711
  return `Preset name "${profile.slug}" collides with the adaptive model alias.`;
819
712
  }
@@ -865,7 +758,7 @@ export default async function (pi: ExtensionAPI) {
865
758
  const sampling = profile.sampling ?? {};
866
759
  const samplingFields = profileSamplingFields(serverType);
867
760
  const repetitionPenaltyKey = repetitionPenaltyKeyForServer(serverType);
868
- const currentRouting = getModelProfileRouting(provider, config.id);
761
+ const currentRouting = app.profileRouting(provider, config.id);
869
762
  const previewRouting = currentRouting
870
763
  ? { ...currentRouting, levels: { ...currentRouting.levels } }
871
764
  : undefined;
@@ -904,10 +797,10 @@ export default async function (pi: ExtensionAPI) {
904
797
  label: field.label,
905
798
  description: `${sampling[field.key] ?? "omitted (server/model default)"} · ${field.description}`,
906
799
  })),
907
- { value: "save", label: "Save preset", description: profileModelId(config.id, profile.slug) },
800
+ { value: "save", label: "Save preset", description: profileModelId(config.id, profile.slug) },
908
801
  ];
909
- if (existing) items.push({ value: "delete", label: "Delete preset" });
910
- 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" });
911
804
 
912
805
  const action = await runSelect(ctx, `Preset: ${profile.slug}`, items, [
913
806
  `model id: ${profileModelId(config.id, profile.slug)} → ${config.id}`,
@@ -966,7 +859,7 @@ export default async function (pi: ExtensionAPI) {
966
859
  }
967
860
  return { action: "save", profile };
968
861
  } else if (action === "delete" && existing) {
969
- const routing = getModelProfileRouting(provider, config.id);
862
+ const routing = app.profileRouting(provider, config.id);
970
863
  const routed = routing && Object.values(routing.levels).includes(existing.slug);
971
864
  const confirmed = await ctx.ui.confirm(
972
865
  "Delete preset",
@@ -982,10 +875,10 @@ export default async function (pi: ExtensionAPI) {
982
875
  provider: DiscoveredProvider,
983
876
  prefetched: { models: Record<string, unknown>[]; serverType: string },
984
877
  ): Promise<boolean> {
985
- upsertProvider(provider);
878
+ app.saveSource(provider);
986
879
  try {
987
880
  await registerProvider(provider, prefetched);
988
- upsertProvider(provider);
881
+ app.saveSource(provider);
989
882
  return true;
990
883
  } catch (err) {
991
884
  ctx.ui.notify(
@@ -1012,12 +905,12 @@ export default async function (pi: ExtensionAPI) {
1012
905
  provider: DiscoveredProvider,
1013
906
  modelId: string,
1014
907
  ): Promise<void> {
1015
- const routing = getModelProfileRouting(provider, modelId);
908
+ const routing = app.profileRouting(provider, modelId);
1016
909
  if (!routing || ctx.model?.provider !== provider.name) return;
1017
910
  const adaptiveId = profileModelId(modelId, routing.aliasSlug);
1018
911
  if (ctx.model.id !== adaptiveId) return;
1019
912
  const valid =
1020
- routing.enabled && analyzeExplicitProfileRouting(routing, getModelProfiles(provider, modelId)).errors.length === 0;
913
+ routing.enabled && analyzeExplicitProfileRouting(routing, app.profiles(provider, modelId)).errors.length === 0;
1021
914
  const refreshed = ctx.modelRegistry.find(provider.name, valid ? adaptiveId : modelId);
1022
915
  if (refreshed) await pi.setModel(refreshed);
1023
916
  }
@@ -1088,7 +981,10 @@ export default async function (pi: ExtensionAPI) {
1088
981
  profile,
1089
982
  repetitionPenaltyKeyForServer(serverType),
1090
983
  );
1091
- 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
+ "",
1092
988
  ...JSON.stringify(payload, null, 2).split("\n"),
1093
989
  ]);
1094
990
  }
@@ -1107,7 +1003,7 @@ export default async function (pi: ExtensionAPI) {
1107
1003
  ctx.ui.notify("Create at least one preset before configuring adaptive routing.", "warning");
1108
1004
  return null;
1109
1005
  }
1110
- const existing = getModelProfileRouting(provider, config.id);
1006
+ const existing = app.profileRouting(provider, config.id);
1111
1007
  const routing: ModelProfileRouting = existing
1112
1008
  ? { ...existing, levels: { ...existing.levels } }
1113
1009
  : defaultProfileRouting(profiles);
@@ -1135,11 +1031,11 @@ export default async function (pi: ExtensionAPI) {
1135
1031
  label: `Pi ${level}`,
1136
1032
  description: `→ ${routing.levels[level] || "not selected"}`,
1137
1033
  })),
1138
- { value: "preview", label: "Preview exact requests", description: "inspect the payload preset for each Pi level" },
1139
- { 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" },
1140
1036
  ];
1141
- if (existing) items.push({ value: "remove", label: "Remove adaptive routing", description: "fixed presets remain unchanged" });
1142
- 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" });
1143
1039
 
1144
1040
  const action = await runSelect(ctx, "Adaptive Shift-Tab routing", items, [
1145
1041
  "Explicit router: only this alias changes complete presets when Shift-Tab is pressed.",
@@ -1212,13 +1108,13 @@ export default async function (pi: ExtensionAPI) {
1212
1108
  prefetched: { models: Record<string, unknown>[]; serverType: string },
1213
1109
  ): Promise<void> {
1214
1110
  for (;;) {
1215
- const profiles = getModelProfiles(provider, config.id);
1216
- const routing = getModelProfileRouting(provider, config.id);
1111
+ const profiles = app.profiles(provider, config.id);
1112
+ const routing = app.profileRouting(provider, config.id);
1217
1113
  const routeAnalysis = routing ? analyzeExplicitProfileRouting(routing, profiles) : undefined;
1218
1114
  const items: SelectItem[] = [
1219
1115
  {
1220
1116
  value: "routing",
1221
- label: routing ? "Configure adaptive routing" : "+ Configure adaptive routing",
1117
+ label: "Configure adaptive routing",
1222
1118
  description: !routing
1223
1119
  ? "explicitly map all seven Pi levels to complete presets"
1224
1120
  : routeAnalysis?.errors.length
@@ -1236,8 +1132,8 @@ export default async function (pi: ExtensionAPI) {
1236
1132
  });
1237
1133
  }
1238
1134
  items.push(
1239
- { value: "add", label: "+ Create preset", description: "create a complete thinking/sampling parameter bundle" },
1240
- { 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" },
1241
1137
  );
1242
1138
  for (const profile of profiles) {
1243
1139
  items.push({
@@ -1246,7 +1142,7 @@ export default async function (pi: ExtensionAPI) {
1246
1142
  description: profileDescription(profile, prefetched.serverType, routing),
1247
1143
  });
1248
1144
  }
1249
- items.push({ value: "back", label: "Back" });
1145
+ items.push({ value: "back", label: "Back" });
1250
1146
 
1251
1147
  const action = await runSelect(ctx, `Thinking & presets: ${config.id}`, items, [
1252
1148
  `${profiles.length} preset(s) · base model behavior is never changed by presets`,
@@ -1265,8 +1161,8 @@ export default async function (pi: ExtensionAPI) {
1265
1161
  prefetched.serverType,
1266
1162
  );
1267
1163
  if (!result) continue;
1268
- if (result.action === "save") saveModelProfileRouting(provider, config.id, result.routing);
1269
- else deleteModelProfileRouting(provider, config.id);
1164
+ if (result.action === "save") app.saveRouting(provider, config.id, result.routing);
1165
+ else app.removeRouting(provider, config.id);
1270
1166
  const registered = await persistProfileChange(ctx, provider, prefetched);
1271
1167
  if (registered) {
1272
1168
  const nextAlias = result.action === "save" && result.routing.enabled ? result.routing.aliasSlug : undefined;
@@ -1318,7 +1214,7 @@ export default async function (pi: ExtensionAPI) {
1318
1214
  );
1319
1215
  if (!result) continue;
1320
1216
  if (result.action === "save") {
1321
- const currentRouting = getModelProfileRouting(provider, config.id);
1217
+ const currentRouting = app.profileRouting(provider, config.id);
1322
1218
  const nextRouting = currentRouting
1323
1219
  ? { ...currentRouting, levels: { ...currentRouting.levels } }
1324
1220
  : undefined;
@@ -1344,8 +1240,8 @@ export default async function (pi: ExtensionAPI) {
1344
1240
  );
1345
1241
  if (!confirmed) continue;
1346
1242
  }
1347
- saveModelProfile(provider, config.id, result.profile, existing?.slug);
1348
- 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);
1349
1245
  const registered = await persistProfileChange(ctx, provider, prefetched);
1350
1246
  if (registered) {
1351
1247
  if (existing && existing.exposeAsModel !== false) {
@@ -1363,7 +1259,7 @@ export default async function (pi: ExtensionAPI) {
1363
1259
  ctx.ui.notify(`${existing ? "Updated" : "Created"} preset "${result.profile.slug}".`, "info");
1364
1260
  }
1365
1261
  } else if (existing) {
1366
- deleteModelProfile(provider, config.id, existing.slug);
1262
+ app.removeProfile(provider, config.id, existing.slug);
1367
1263
  const registered = await persistProfileChange(ctx, provider, prefetched);
1368
1264
  if (registered) {
1369
1265
  await refreshSelectedProfile(
@@ -1407,8 +1303,8 @@ export default async function (pi: ExtensionAPI) {
1407
1303
  } · input ${effInput.join("+")}`,
1408
1304
  ];
1409
1305
 
1410
- const configuredProfiles = getModelProfiles(provider, config.id);
1411
- const configuredRouting = getModelProfileRouting(provider, config.id);
1306
+ const configuredProfiles = app.profiles(provider, config.id);
1307
+ const configuredRouting = app.profileRouting(provider, config.id);
1412
1308
  const routingAnalysis = configuredRouting
1413
1309
  ? analyzeExplicitProfileRouting(configuredRouting, configuredProfiles)
1414
1310
  : undefined;
@@ -1441,7 +1337,7 @@ export default async function (pi: ExtensionAPI) {
1441
1337
  if (Object.keys(ov).length > 0) {
1442
1338
  items.push({ value: "clear", label: "Clear overrides", description: "revert to server-reported values" });
1443
1339
  }
1444
- items.push({ value: "back", label: "Back" });
1340
+ items.push({ value: "back", label: "Back" });
1445
1341
 
1446
1342
  const action = await runSelect(ctx, `Model: ${config.id}${modelFlags(config, ov)}`, items, header);
1447
1343
  if (!action || action === "back") return;
@@ -1451,21 +1347,24 @@ export default async function (pi: ExtensionAPI) {
1451
1347
  continue;
1452
1348
  }
1453
1349
 
1350
+ let feedback: string | undefined;
1454
1351
  if (action === "ctx") {
1455
1352
  const n = await askNumber(ctx, `Context window for ${config.id}`, String(effCtx ?? 128000));
1456
- if (n !== undefined) {
1457
- provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, contextWindow: n } };
1458
- }
1353
+ if (n === undefined) continue;
1354
+ provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, contextWindow: n } };
1355
+ feedback = `Context window saved as ${fmt(n)}.`;
1459
1356
  } else if (action === "max") {
1460
1357
  const n = await askNumber(ctx, `Max output tokens for ${config.id}`, String(effMax ?? 16384));
1461
- if (n !== undefined) {
1462
- provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, maxTokens: n } };
1463
- }
1358
+ if (n === undefined) continue;
1359
+ provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, maxTokens: n } };
1360
+ feedback = `Max output tokens saved as ${fmt(n)}.`;
1464
1361
  } else if (action === "reasoning") {
1362
+ const reasoning = !(effReasoning ?? false);
1465
1363
  provider.modelOverrides = {
1466
1364
  ...provider.modelOverrides,
1467
- [config.id]: { ...ov, reasoning: !(effReasoning ?? false) },
1365
+ [config.id]: { ...ov, reasoning },
1468
1366
  };
1367
+ feedback = `Reasoning ${reasoning ? "enabled" : "disabled"}.`;
1469
1368
  } else if (action === "input") {
1470
1369
  // Toggle vision: add/remove "image" from input modalities
1471
1370
  const hasVision = effInput.includes("image");
@@ -1476,19 +1375,23 @@ export default async function (pi: ExtensionAPI) {
1476
1375
  ...provider.modelOverrides,
1477
1376
  [config.id]: { ...ov, input: newInput },
1478
1377
  };
1378
+ feedback = `Vision input ${hasVision ? "disabled" : "enabled"}.`;
1479
1379
  } else if (action === "clear") {
1480
1380
  if (provider.modelOverrides) {
1481
1381
  delete provider.modelOverrides[config.id];
1482
1382
  if (Object.keys(provider.modelOverrides).length === 0) provider.modelOverrides = undefined;
1483
1383
  }
1384
+ feedback = "Model overrides cleared.";
1484
1385
  }
1386
+ if (!feedback) continue;
1485
1387
 
1486
- // Persist + re-register with new values
1487
- upsertProvider(provider);
1388
+ // Persist + re-register with new values.
1389
+ app.saveSource(provider);
1488
1390
  try {
1489
1391
  await registerProvider(provider, prefetched);
1490
- } catch {
1491
- /* 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");
1492
1395
  }
1493
1396
  }
1494
1397
  }
@@ -1532,7 +1435,7 @@ export default async function (pi: ExtensionAPI) {
1532
1435
  header.push(`last successful scan: ${new Date(provider.lastScanned).toLocaleString()}`);
1533
1436
  }
1534
1437
  if (!live && provider.lastScanError) {
1535
- header.push(`latest live scan failed: ${provider.lastScanError}`);
1438
+ header.push(`latest live scan failed: ${redactSecret(provider.lastScanError, provider.apiKey)}`);
1536
1439
  header.push("Last known-good models and all saved presets remain available.");
1537
1440
  }
1538
1441
 
@@ -1541,26 +1444,26 @@ export default async function (pi: ExtensionAPI) {
1541
1444
  label: `${c.id}${modelFlags(c, provider.modelOverrides?.[c.id])}`,
1542
1445
  description: modelDescription(c, provider),
1543
1446
  }));
1544
- 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" });
1545
1448
  items.push({
1546
1449
  value: "rename",
1547
- label: "Rename source",
1548
- description: `current: ${provider.name}`,
1450
+ label: "Rename source",
1451
+ description: `Current: ${provider.name}`,
1549
1452
  });
1550
1453
  items.push({
1551
1454
  value: "auth",
1552
- label: "🔑 Authentication",
1553
- 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",
1554
1457
  });
1555
1458
  items.push({
1556
1459
  value: "defaults",
1557
- label: " Edit fallback defaults",
1558
- 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)}`,
1559
1462
  });
1560
- items.push({ value: "remove", label: "Remove endpoint", description: "unregister provider and delete saved config" });
1561
- 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" });
1562
1465
 
1563
- const action = await runSelect(ctx, `Endpoint: ${provider.name}`, items, header);
1466
+ const action = await runSelect(ctx, `Source: ${provider.name}`, items, header);
1564
1467
  if (!action || action === "back") return;
1565
1468
 
1566
1469
  if (action.startsWith("model:")) {
@@ -1582,7 +1485,7 @@ export default async function (pi: ExtensionAPI) {
1582
1485
  try {
1583
1486
  const registered = await registerProvider(provider, live);
1584
1487
  recordSuccessfulScan(provider, live.models, live.serverType, false);
1585
- upsertProvider(provider);
1488
+ app.saveSource(provider);
1586
1489
  ctx.ui.notify(
1587
1490
  `Re-registered ${live.models.length} base model(s)${
1588
1491
  registered.profileCount ? ` + ${registered.profileCount} profile(s)` : ""
@@ -1596,13 +1499,26 @@ export default async function (pi: ExtensionAPI) {
1596
1499
  }
1597
1500
  }
1598
1501
  } else if (action === "rename") {
1599
- 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();
1600
1517
  if (newName && newName !== provider.name) {
1601
- const oldName = provider.name;
1602
- if (!renameProvider(oldName, newName)) {
1603
- 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");
1604
1521
  } else {
1605
- provider.name = newName;
1606
1522
  const catalog = live ??
1607
1523
  (provider.cachedModels?.length
1608
1524
  ? {
@@ -1612,12 +1528,11 @@ export default async function (pi: ExtensionAPI) {
1612
1528
  : undefined);
1613
1529
  try {
1614
1530
  await registerProvider(provider, catalog);
1615
- pi.unregisterProvider(oldName);
1616
- upsertProvider(provider);
1531
+ pi.unregisterProvider(renamed.oldName);
1532
+ app.saveSource(provider);
1617
1533
  ctx.ui.notify(`Renamed to "${newName}"${live ? "" : " using the cached catalogue"}.`, "info");
1618
1534
  } catch (error) {
1619
- renameProvider(newName, oldName);
1620
- provider.name = oldName;
1535
+ app.renameSource(provider, renamed.oldName);
1621
1536
  ctx.ui.notify(`Rename rolled back: ${errorMessage(error)}`, "error");
1622
1537
  }
1623
1538
  }
@@ -1635,9 +1550,9 @@ export default async function (pi: ExtensionAPI) {
1635
1550
  ...(provider.apiKey
1636
1551
  ? [{ value: "clear", label: "Clear API key", description: "remove the saved bearer credential" }]
1637
1552
  : []),
1638
- { value: "back", label: "Back" },
1553
+ { value: "back", label: "Back" },
1639
1554
  ],
1640
- [provider.baseUrl, `current: ${provider.apiKey ? "API key configured" : "anonymous"}`],
1555
+ [provider.baseUrl, `Current: ${provider.apiKey ? "API key configured" : "anonymous"}`],
1641
1556
  );
1642
1557
  if (!authAction || authAction === "back") continue;
1643
1558
 
@@ -1658,8 +1573,7 @@ export default async function (pi: ExtensionAPI) {
1658
1573
  if (!confirmed) continue;
1659
1574
  }
1660
1575
 
1661
- provider.apiKey = nextApiKey;
1662
- upsertProvider(provider);
1576
+ app.setCredential(provider, nextApiKey);
1663
1577
  const checked = await runLoader(
1664
1578
  ctx,
1665
1579
  `Validating ${provider.name} authentication...`,
@@ -1670,7 +1584,7 @@ export default async function (pi: ExtensionAPI) {
1670
1584
  try {
1671
1585
  await registerProvider(provider, checked);
1672
1586
  recordSuccessfulScan(provider, checked.models, checked.serverType, false);
1673
- upsertProvider(provider);
1587
+ app.saveSource(provider);
1674
1588
  live = checked;
1675
1589
  ctx.ui.notify(`Authentication saved and validated for ${provider.name}.`, "info");
1676
1590
  } catch (error) {
@@ -1698,11 +1612,19 @@ export default async function (pi: ExtensionAPI) {
1698
1612
  ctx.ui.notify("Authentication saved but could not be validated; the last known-good catalogue was retained.", "warning");
1699
1613
  }
1700
1614
  } else if (action === "defaults") {
1615
+ const changed: string[] = [];
1701
1616
  const cw = await askNumber(ctx, "Default context window (blank = keep)", String(provider.defaultContextWindow ?? 128000));
1702
- if (cw !== undefined) provider.defaultContextWindow = cw;
1617
+ if (cw !== undefined) {
1618
+ provider.defaultContextWindow = cw;
1619
+ changed.push(`context ${fmt(cw)}`);
1620
+ }
1703
1621
  const mt = await askNumber(ctx, "Default max output tokens (blank = keep)", String(provider.defaultMaxTokens ?? 16384));
1704
- if (mt !== undefined) provider.defaultMaxTokens = mt;
1705
- 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);
1706
1628
  const catalog = live ??
1707
1629
  (provider.cachedModels?.length
1708
1630
  ? {
@@ -1712,14 +1634,18 @@ export default async function (pi: ExtensionAPI) {
1712
1634
  : undefined);
1713
1635
  try {
1714
1636
  await registerProvider(provider, catalog);
1637
+ ctx.ui.notify(`Fallback defaults saved: ${changed.join(" · ")}.`, "info");
1715
1638
  } catch (error) {
1716
1639
  ctx.ui.notify(`Defaults saved; provider remains on its last registered catalogue: ${errorMessage(error)}`, "warning");
1717
1640
  }
1718
1641
  } else if (action === "remove") {
1719
- 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
+ );
1720
1646
  if (sure) {
1721
1647
  pi.unregisterProvider(provider.name);
1722
- deleteProvider(provider.name);
1648
+ app.removeSource(provider.name);
1723
1649
  ctx.ui.notify(`Removed "${provider.name}".`, "info");
1724
1650
  return;
1725
1651
  }
@@ -1731,23 +1657,45 @@ export default async function (pi: ExtensionAPI) {
1731
1657
  // Screen: add endpoint
1732
1658
  // -----------------------------------------------------------------------
1733
1659
 
1734
- async function showAddScreen(ctx: ExtensionCommandContext, presetUrl?: string): Promise<void> {
1735
- let baseUrl = presetUrl ?? (await ctx.ui.input("Endpoint URL", "http://192.168.1.100:8080"))?.trim();
1736
- if (!baseUrl) return;
1737
- if (!baseUrl.startsWith("http")) baseUrl = `http://${baseUrl}`;
1738
- 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
+ }
1739
1687
 
1740
1688
  const authMode = await runSelect(
1741
1689
  ctx,
1742
1690
  "Endpoint authentication",
1743
1691
  [
1744
- { 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" },
1745
1693
  {
1746
1694
  value: "api-key",
1747
1695
  label: "Enter API key",
1748
- 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",
1749
1697
  },
1750
- { value: "cancel", label: "Cancel" },
1698
+ { value: "cancel", label: "Cancel" },
1751
1699
  ],
1752
1700
  [baseUrl, "The key is sent as an Authorization: Bearer header for discovery and inference."],
1753
1701
  );
@@ -1789,7 +1737,21 @@ export default async function (pi: ExtensionAPI) {
1789
1737
  return;
1790
1738
  }
1791
1739
 
1792
- 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();
1793
1755
 
1794
1756
  const provider: DiscoveredProvider = { name, baseUrl, apiKey };
1795
1757
  const configs = live.models.map(extractModelConfig);
@@ -1810,8 +1772,8 @@ export default async function (pi: ExtensionAPI) {
1810
1772
  label: `${c.id}${modelFlags(c, provider.modelOverrides?.[c.id])}`,
1811
1773
  description: modelDescription(c, provider),
1812
1774
  }));
1813
- items.push({ value: "register", label: "Register endpoint", description: `save as "${name}" and make models available in /model` });
1814
- 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" });
1815
1777
 
1816
1778
  const action = await runSelect(ctx, `Review: ${name}`, items, header);
1817
1779
  if (!action || action === "cancel") {
@@ -1854,7 +1816,7 @@ export default async function (pi: ExtensionAPI) {
1854
1816
  try {
1855
1817
  await registerProvider(provider, live);
1856
1818
  recordSuccessfulScan(provider, live.models, live.serverType, false);
1857
- upsertProvider(provider);
1819
+ app.saveSource(provider);
1858
1820
  ctx.ui.notify(
1859
1821
  `Registered ${configs.length} model(s) from ${live.serverType} as "${name}". Use /model to select.`,
1860
1822
  "info",
@@ -1873,41 +1835,33 @@ export default async function (pi: ExtensionAPI) {
1873
1835
 
1874
1836
  async function showMainScreen(ctx: ExtensionCommandContext): Promise<void> {
1875
1837
  for (;;) {
1876
- const providers = loadProviders();
1877
- const items: SelectItem[] = providers.map((p) => ({
1878
- value: `provider:${p.name}`,
1879
- label: p.name,
1880
- description: `${p.serverType ?? "?"} · ${p.baseUrl} · ${
1881
- p.lastScanError
1882
- ? `${p.cachedModels?.length ? "cached" : "unavailable"} after failed live scan`
1883
- : `live scan ${p.lastScanned ? new Date(p.lastScanned).toLocaleString() : "never completed"}`
1884
- }`,
1885
- }));
1886
- items.push({ value: "add", label: "+ Add endpoint", description: "discover models from an OpenAI-compatible server" });
1887
- if (providers.length > 0) {
1888
- items.push({ value: "rescan-all", label: "⟳ Re-scan all", description: "refresh model lists from every endpoint" });
1889
- }
1890
- items.push({ value: "quit", label: "✗ Close" });
1891
-
1892
- const action = await runSelect(ctx, "Model Discovery", items, [
1893
- providers.length === 0 ? "No endpoints yet — add your first one." : `${providers.length} endpoint(s) registered`,
1894
- ]);
1838
+ const providers = app.listSources();
1839
+ const action = await runSelect(
1840
+ ctx,
1841
+ "Model Discovery",
1842
+ buildHomeItems(providers),
1843
+ buildHomeSummary(providers),
1844
+ );
1895
1845
  if (!action || action === "quit") return;
1896
1846
 
1897
1847
  if (action === "add") {
1898
1848
  await showAddScreen(ctx);
1849
+ } else if (action === "diagnostics") {
1850
+ await runTextView(ctx, "Model Discovery diagnostics", buildDiagnosticsLines(app.listSources()));
1899
1851
  } else if (action === "rescan-all") {
1900
- const results = await runLoader(ctx, "Re-scanning all endpoints...", async () => {
1852
+ const results = await runLoader(ctx, "Re-scanning all sources...", async (signal) => {
1901
1853
  let live = 0;
1902
1854
  let cached = 0;
1903
1855
  let failed = 0;
1904
- for (const provider of loadProviders()) {
1856
+ for (const provider of app.listSources()) {
1857
+ if (signal.aborted) break;
1905
1858
  try {
1906
- const registered = await registerProvider(provider);
1859
+ const registered = await registerProvider(provider, undefined, signal);
1907
1860
  recordSuccessfulScan(provider, registered.rawModels, registered.serverType, false);
1908
- upsertProvider(provider);
1861
+ app.saveSource(provider);
1909
1862
  live++;
1910
1863
  } catch (error) {
1864
+ if (signal.aborted) break;
1911
1865
  recordFailedScan(provider, error, false);
1912
1866
  if (provider.cachedModels?.length) {
1913
1867
  try {
@@ -1915,14 +1869,14 @@ export default async function (pi: ExtensionAPI) {
1915
1869
  models: provider.cachedModels,
1916
1870
  serverType: provider.serverType ?? "OpenAI-compatible",
1917
1871
  });
1918
- upsertProvider(provider);
1872
+ app.saveSource(provider);
1919
1873
  cached++;
1920
1874
  continue;
1921
1875
  } catch {
1922
1876
  /* report below without removing the previously registered provider */
1923
1877
  }
1924
1878
  }
1925
- upsertProvider(provider);
1879
+ app.saveSource(provider);
1926
1880
  failed++;
1927
1881
  }
1928
1882
  }
@@ -1937,7 +1891,7 @@ export default async function (pi: ExtensionAPI) {
1937
1891
  }
1938
1892
  } else if (action.startsWith("provider:")) {
1939
1893
  const name = action.slice("provider:".length);
1940
- const provider = loadProviders().find((p) => p.name === name);
1894
+ const provider = app.findSource(name);
1941
1895
  if (provider) await showEndpointScreen(ctx, provider);
1942
1896
  }
1943
1897
  }
@@ -1947,18 +1901,100 @@ export default async function (pi: ExtensionAPI) {
1947
1901
  // Command: /discover — single entry point
1948
1902
  // -----------------------------------------------------------------------
1949
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
+
1950
1931
  pi.registerCommand("discover", {
1951
- 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
+ ),
1952
1937
  handler: async (args, ctx) => {
1953
- if (ctx.mode !== "tui") {
1954
- ctx.ui.notify("/discover requires interactive mode", "error");
1955
- return;
1956
- }
1957
- const url = args?.trim();
1958
- if (url) {
1959
- await showAddScreen(ctx, url.startsWith("http") ? url : `http://${url}`);
1960
- } else {
1961
- 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");
1962
1998
  }
1963
1999
  },
1964
2000
  });
@@ -1986,36 +2022,23 @@ export default async function (pi: ExtensionAPI) {
1986
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.",
1987
2023
  parameters: discoverModelsParameters,
1988
2024
  async execute(_toolCallId, params) {
1989
- let { url, providerName, apiKey } = params;
1990
- if (!url.startsWith("http")) url = `http://${url}`;
1991
- url = url.replace(/\/+$/, "");
1992
- providerName = providerName || generateProviderName(url);
1993
-
1994
- let live: { models: Record<string, unknown>[]; serverType: string };
2025
+ let result: Awaited<ReturnType<typeof discoverAndRegisterSource>>;
1995
2026
  try {
1996
- live = await fetchModels(url, apiKey);
1997
- } 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
+ }
1998
2033
  return {
1999
- content: [
2000
- { type: "text", text: `Endpoint unavailable: ${err instanceof Error ? err.message : String(err)}` },
2001
- ],
2034
+ content: [{ type: "text", text: `Endpoint unavailable or registration failed: ${message}` }],
2002
2035
  details: {},
2003
2036
  isError: true,
2004
2037
  };
2005
2038
  }
2006
- if (live.models.length === 0) {
2007
- return { content: [{ type: "text", text: "Endpoint online but reports no models." }], details: {} };
2008
- }
2009
2039
 
2010
- const existing = loadProviders().find((p) => p.name === providerName);
2011
- const provider: DiscoveredProvider = existing
2012
- ? { ...existing, baseUrl: url, apiKey: apiKey ?? existing.apiKey }
2013
- : { name: providerName, baseUrl: url, apiKey };
2040
+ const { provider, models: configs, serverType, profileCount } = result;
2014
2041
  try {
2015
- const { models: configs, profileCount } = await registerProvider(provider, live);
2016
- recordSuccessfulScan(provider, live.models, live.serverType, false);
2017
- upsertProvider(provider);
2018
-
2019
2042
  const lines = configs.map(
2020
2043
  (c) =>
2021
2044
  `- ${c.id}${modelFlags(c, provider.modelOverrides?.[c.id])}: ${modelDescription(c, provider)}`,
@@ -2030,12 +2053,12 @@ export default async function (pi: ExtensionAPI) {
2030
2053
  content: [
2031
2054
  {
2032
2055
  type: "text",
2033
- text: `Endpoint online (${live.serverType}). Registered ${configs.length} base model(s)${
2056
+ text: `Endpoint online (${serverType}). Registered ${configs.length} base model(s)${
2034
2057
  profileCount ? ` + ${profileCount} profile(s)` : ""
2035
- } 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.`,
2036
2059
  },
2037
2060
  ],
2038
- details: { providerName, serverType: live.serverType, modelCount: configs.length, profileCount },
2061
+ details: { providerName: provider.name, serverType, modelCount: configs.length, profileCount },
2039
2062
  };
2040
2063
  } catch (err) {
2041
2064
  return {