@maheidem/model-discovery 0.1.0 → 0.6.1

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.
Files changed (4) hide show
  1. package/README.md +197 -22
  2. package/index.ts +1465 -86
  3. package/package.json +9 -3
  4. package/profiles.ts +619 -0
package/index.ts CHANGED
@@ -14,11 +14,33 @@
14
14
  * Discovered providers persist across sessions in ~/.pi/agent/model-discovery.json
15
15
  */
16
16
 
17
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
17
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { BorderedLoader, DynamicBorder } from "@earendil-works/pi-coding-agent";
19
- import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
19
+ import { CURSOR_MARKER, Container, Input, type SelectItem, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
20
20
  import { Type } from "typebox";
21
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import {
22
+ analyzeExplicitProfileRouting,
23
+ applyThinkingProfileRoute,
24
+ buildProfileSamplingParams,
25
+ describeChatTemplateKwargs,
26
+ describeProfileSampling,
27
+ expandAdaptiveProfileRouters,
28
+ expandModelProfiles,
29
+ migrateLegacyProfileRouting,
30
+ profileModelId,
31
+ REASONING_EFFORTS,
32
+ repetitionPenaltyKeyForServer,
33
+ THINKING_LEVELS,
34
+ type ModelProfile,
35
+ type ModelProfileRouting,
36
+ type ProfileSampling,
37
+ type ThinkingLevel,
38
+ type ThinkingProfileRoutes,
39
+ validateModelProfile,
40
+ validateProfileSampling,
41
+ validateProfileSlug,
42
+ } from "./profiles.ts";
43
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
22
44
  import { join } from "node:path";
23
45
  import os from "node:os";
24
46
 
@@ -41,8 +63,15 @@ interface DiscoveredProvider {
41
63
  defaultContextWindow?: number;
42
64
  defaultMaxTokens?: number;
43
65
  modelOverrides?: Record<string, ModelOverride>;
66
+ modelProfiles?: Record<string, ModelProfile[]>;
67
+ modelProfileRouting?: Record<string, ModelProfileRouting>;
68
+ profileSchemaVersion?: number;
69
+ cachedModels?: Record<string, unknown>[];
44
70
  compat?: Record<string, unknown>;
71
+ /** Last successful live catalogue refresh (legacy name retained in storage). */
45
72
  lastScanned?: number;
73
+ lastScanAttempt?: number;
74
+ lastScanError?: string;
46
75
  }
47
76
 
48
77
  interface ModelConfig {
@@ -62,10 +91,41 @@ interface ModelConfig {
62
91
 
63
92
  const STORAGE_PATH = join(os.homedir(), ".pi", "agent", "model-discovery.json");
64
93
 
94
+ function writeProvidersAtomic(providers: DiscoveredProvider[]): void {
95
+ const tempPath = `${STORAGE_PATH}.${process.pid}.${Date.now()}.tmp`;
96
+ try {
97
+ writeFileSync(tempPath, JSON.stringify(providers, null, 2), { encoding: "utf-8", mode: 0o600 });
98
+ renameSync(tempPath, STORAGE_PATH);
99
+ } catch (error) {
100
+ try {
101
+ if (existsSync(tempPath)) unlinkSync(tempPath);
102
+ } catch {
103
+ /* best-effort cleanup */
104
+ }
105
+ throw error;
106
+ }
107
+ }
108
+
65
109
  function loadProviders(): DiscoveredProvider[] {
66
110
  try {
67
111
  if (existsSync(STORAGE_PATH)) {
68
- return JSON.parse(readFileSync(STORAGE_PATH, "utf-8")) as DiscoveredProvider[];
112
+ const providers = JSON.parse(readFileSync(STORAGE_PATH, "utf-8")) as DiscoveredProvider[];
113
+ let migrated = false;
114
+ for (const provider of providers) {
115
+ if ((provider.profileSchemaVersion ?? 0) >= 2) continue;
116
+ for (const [modelId, rawProfiles] of Object.entries(provider.modelProfiles ?? {})) {
117
+ if (!Array.isArray(rawProfiles)) continue;
118
+ const result = migrateLegacyProfileRouting(rawProfiles, provider.modelProfileRouting?.[modelId]);
119
+ if (!result.changed || !result.routing) continue;
120
+ provider.modelProfiles = { ...provider.modelProfiles, [modelId]: result.profiles };
121
+ provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: result.routing };
122
+ migrated = true;
123
+ }
124
+ provider.profileSchemaVersion = 2;
125
+ migrated = true;
126
+ }
127
+ if (migrated) writeProvidersAtomic(providers);
128
+ return providers;
69
129
  }
70
130
  } catch {
71
131
  /* ignore */
@@ -74,7 +134,7 @@ function loadProviders(): DiscoveredProvider[] {
74
134
  }
75
135
 
76
136
  function saveProviders(providers: DiscoveredProvider[]): void {
77
- writeFileSync(STORAGE_PATH, JSON.stringify(providers, null, 2), "utf-8");
137
+ writeProvidersAtomic(providers);
78
138
  }
79
139
 
80
140
  function upsertProvider(provider: DiscoveredProvider): void {
@@ -89,6 +149,108 @@ function deleteProvider(name: string): void {
89
149
  saveProviders(loadProviders().filter((p) => p.name !== name));
90
150
  }
91
151
 
152
+ function errorMessage(error: unknown): string {
153
+ return error instanceof Error ? error.message : String(error);
154
+ }
155
+
156
+ function redactSecret(value: string, secret?: string): string {
157
+ return secret ? value.replaceAll(secret, "[redacted]") : value;
158
+ }
159
+
160
+ function persistProviderScanState(provider: DiscoveredProvider): void {
161
+ try {
162
+ const providers = loadProviders();
163
+ const stored = providers.find((candidate) => candidate.name === provider.name && candidate.baseUrl === provider.baseUrl);
164
+ if (!stored) return;
165
+ stored.serverType = provider.serverType;
166
+ stored.cachedModels = provider.cachedModels;
167
+ stored.lastScanned = provider.lastScanned;
168
+ stored.lastScanAttempt = provider.lastScanAttempt;
169
+ stored.lastScanError = provider.lastScanError;
170
+ saveProviders(providers);
171
+ } catch (error) {
172
+ // Runtime registration must not fail merely because scan metadata could not be persisted.
173
+ console.error(`[model-discovery] ${provider.name}: could not persist catalogue state (${errorMessage(error)}).`);
174
+ }
175
+ }
176
+
177
+ function recordSuccessfulScan(
178
+ provider: DiscoveredProvider,
179
+ models: Record<string, unknown>[],
180
+ serverType: string,
181
+ persist = true,
182
+ ): void {
183
+ const now = Date.now();
184
+ provider.serverType = serverType;
185
+ provider.cachedModels = models;
186
+ provider.lastScanned = now;
187
+ provider.lastScanAttempt = now;
188
+ provider.lastScanError = undefined;
189
+ if (persist) persistProviderScanState(provider);
190
+ }
191
+
192
+ function recordFailedScan(provider: DiscoveredProvider, error: unknown, persist = true): void {
193
+ provider.lastScanAttempt = Date.now();
194
+ provider.lastScanError = redactSecret(errorMessage(error), provider.apiKey);
195
+ if (persist) persistProviderScanState(provider);
196
+ }
197
+
198
+ function renameProvider(oldName: string, newName: string): boolean {
199
+ const all = loadProviders();
200
+ const idx = all.findIndex((p) => p.name === oldName);
201
+ if (idx < 0) return false;
202
+ if (all.some((p) => p.name === newName)) return false; // name already taken
203
+ all[idx].name = newName;
204
+ saveProviders(all);
205
+ return true;
206
+ }
207
+
208
+ function getModelProfiles(provider: DiscoveredProvider, modelId: string): ModelProfile[] {
209
+ const profiles: unknown = provider.modelProfiles?.[modelId];
210
+ if (!Array.isArray(profiles)) return [];
211
+ return profiles.filter((profile): profile is ModelProfile => validateModelProfile(profile) === null);
212
+ }
213
+
214
+ function saveModelProfile(
215
+ provider: DiscoveredProvider,
216
+ modelId: string,
217
+ profile: ModelProfile,
218
+ previousSlug?: string,
219
+ ): void {
220
+ const profiles = getModelProfiles(provider, modelId);
221
+ const index = previousSlug === undefined ? -1 : profiles.findIndex((item) => item.slug === previousSlug);
222
+ const next = [...profiles];
223
+ if (index >= 0) next[index] = profile;
224
+ else next.push(profile);
225
+ provider.modelProfiles = { ...provider.modelProfiles, [modelId]: next };
226
+ }
227
+
228
+ function deleteModelProfile(provider: DiscoveredProvider, modelId: string, slug: string): void {
229
+ const nextProfiles = getModelProfiles(provider, modelId).filter((profile) => profile.slug !== slug);
230
+ const modelProfiles = { ...provider.modelProfiles };
231
+ if (nextProfiles.length > 0) modelProfiles[modelId] = nextProfiles;
232
+ else delete modelProfiles[modelId];
233
+ provider.modelProfiles = Object.keys(modelProfiles).length > 0 ? modelProfiles : undefined;
234
+ }
235
+
236
+ function getModelProfileRouting(provider: DiscoveredProvider, modelId: string): ModelProfileRouting | undefined {
237
+ return provider.modelProfileRouting?.[modelId];
238
+ }
239
+
240
+ function saveModelProfileRouting(
241
+ provider: DiscoveredProvider,
242
+ modelId: string,
243
+ routing: ModelProfileRouting,
244
+ ): void {
245
+ provider.modelProfileRouting = { ...provider.modelProfileRouting, [modelId]: routing };
246
+ }
247
+
248
+ function deleteModelProfileRouting(provider: DiscoveredProvider, modelId: string): void {
249
+ const routing = { ...provider.modelProfileRouting };
250
+ delete routing[modelId];
251
+ provider.modelProfileRouting = Object.keys(routing).length > 0 ? routing : undefined;
252
+ }
253
+
92
254
  // ---------------------------------------------------------------------------
93
255
  // Server detection & model config extraction (reads real server data)
94
256
  // ---------------------------------------------------------------------------
@@ -101,7 +263,7 @@ function detectServerType(headers: Headers, models: Record<string, unknown>[]):
101
263
  if (server.includes("ollama")) return "Ollama";
102
264
  if (server.includes("vllm")) return "vLLM";
103
265
  if (server.includes("sglang")) return "SGLang";
104
- if (server.includes("lm-studio")) return "LM Studio";
266
+ if (server.includes("lm-studio") || server.includes("lm studio") || server.includes("lmstudio")) return "LM Studio";
105
267
  if (server.includes("omlx") || poweredBy.includes("omlx")) return "oMLX";
106
268
 
107
269
  for (const m of models) {
@@ -191,20 +353,65 @@ function extractModelConfig(raw: Record<string, unknown>): ModelConfig {
191
353
 
192
354
  // Input modalities
193
355
  let input: string[] | null = null;
356
+ let hasVision = false;
357
+
358
+ // 1. Standard architecture.input_modalities (vLLM, SGLang, etc.)
194
359
  if (raw.architecture && typeof raw.architecture === "object") {
195
- const modalities = (raw.architecture as Record<string, unknown>).input_modalities as string[] | undefined;
360
+ const arch = raw.architecture as Record<string, unknown>;
361
+ const modalities = arch.input_modalities as string[] | undefined;
196
362
  if (Array.isArray(modalities) && modalities.length > 0) {
197
363
  input = [];
198
364
  for (const m of modalities) {
199
365
  const l = m.toLowerCase();
200
366
  if (l.includes("text") && !input.includes("text")) input.push("text");
201
- if ((l.includes("image") || l.includes("vision")) && !input.includes("image")) input.push("image");
367
+ if ((l.includes("image") || l.includes("vision")) && !input.includes("image")) {
368
+ input.push("image");
369
+ hasVision = true;
370
+ }
371
+ }
372
+ }
373
+ // Also check for vision-specific architecture keys
374
+ if (!hasVision && (arch.vision_config || arch.vision_model || arch.mm_proj || arch.multi_modal_projector)) {
375
+ hasVision = true;
376
+ }
377
+ }
378
+
379
+ // 2. Direct input array on the model object
380
+ if (!input && Array.isArray(raw.input)) {
381
+ input = raw.input as string[];
382
+ if (input.includes("image")) hasVision = true;
383
+ }
384
+
385
+ // 3. llama.cpp: --mmproj flag in args or preset (multimodal projector file)
386
+ if (!hasVision && args) {
387
+ for (const a of args) {
388
+ if (a.startsWith("--mmproj") || a.startsWith("--vision")) {
389
+ hasVision = true;
390
+ break;
202
391
  }
203
392
  }
204
393
  }
205
- if (!input && Array.isArray(raw.input)) input = raw.input as string[];
206
- if (input && !input.includes("image") && args?.some((a) => a.startsWith("--mmproj"))) {
207
- input.push("image");
394
+ if (!hasVision && preset) {
395
+ if (/mmproj|vision/i.test(preset)) {
396
+ hasVision = true;
397
+ }
398
+ }
399
+
400
+ // 4. oMLX: check for vision-specific capabilities or model tags
401
+ if (!hasVision && Array.isArray(raw.capabilities)) {
402
+ const caps = (raw.capabilities as string[]).map((c: string) => c.toLowerCase());
403
+ if (caps.some((c: string) => c.includes("vision") || c.includes("image") || c.includes("multimodal"))) {
404
+ hasVision = true;
405
+ }
406
+ }
407
+
408
+ // 5. Build final input array — always include "text", add "image" if vision detected
409
+ if (hasVision) {
410
+ input = input && input.includes("image") ? input : ["text", "image"];
411
+ } else if (!input) {
412
+ input = ["text"];
413
+ } else if (!input.includes("text")) {
414
+ input.unshift("text");
208
415
  }
209
416
 
210
417
  const loaded = status?.value === "loaded" ? true : status?.value === "unloaded" ? false : undefined;
@@ -225,10 +432,20 @@ async function fetchModels(
225
432
  const response = await fetch(url, { headers, signal });
226
433
  if (!response.ok) {
227
434
  const body = await response.text().catch(() => "");
228
- throw new Error(`HTTP ${response.status}: ${body.slice(0, 200)}`);
435
+ throw new Error(`HTTP ${response.status}: ${redactSecret(body.slice(0, 200), apiKey)}`);
229
436
  }
230
437
  const data = (await response.json()) as Record<string, unknown>;
231
- const models = (data.data as Record<string, unknown>[]) ?? [];
438
+ if (!data || typeof data !== "object" || !Array.isArray(data.data)) {
439
+ throw new Error("Invalid /v1/models response: expected a data array.");
440
+ }
441
+ const models = data.data.filter(
442
+ (model): model is Record<string, unknown> =>
443
+ !!model && typeof model === "object" && typeof (model as Record<string, unknown>).id === "string" &&
444
+ (model as Record<string, unknown>).id !== "",
445
+ );
446
+ if (models.length !== data.data.length) {
447
+ throw new Error("Invalid /v1/models response: every model must have a non-empty string id.");
448
+ }
232
449
  return { models, serverType: detectServerType(response.headers, models) };
233
450
  }
234
451
 
@@ -252,6 +469,14 @@ function fmt(n: number | null | undefined): string {
252
469
  // ---------------------------------------------------------------------------
253
470
 
254
471
  export default async function (pi: ExtensionAPI) {
472
+ type RuntimeThinkingRoutes = {
473
+ routes: ThinkingProfileRoutes;
474
+ repetitionPenaltyKey: ReturnType<typeof repetitionPenaltyKeyForServer>;
475
+ };
476
+ const thinkingRoutes = new Map<string, RuntimeThinkingRoutes>();
477
+ const fixedProfileLabels = new Map<string, string>();
478
+ const routeKey = (providerName: string, modelId: string): string => `${providerName}/${modelId}`;
479
+
255
480
  // -----------------------------------------------------------------------
256
481
  // Provider registration with Pi's model registry
257
482
  // -----------------------------------------------------------------------
@@ -259,45 +484,96 @@ export default async function (pi: ExtensionAPI) {
259
484
  async function registerProvider(
260
485
  provider: DiscoveredProvider,
261
486
  prefetched?: { models: Record<string, unknown>[]; serverType: string },
262
- ): Promise<{ models: ModelConfig[]; serverType: string }> {
487
+ ): Promise<{ models: ModelConfig[]; rawModels: Record<string, unknown>[]; serverType: string; profileCount: number }> {
263
488
  const { models, serverType } = prefetched ?? (await fetchModels(provider.baseUrl, provider.apiKey, AbortSignal.timeout(2_000)));
264
489
  if (models.length === 0) throw new Error("No models found at this endpoint.");
265
490
 
491
+ const routePrefix = `${provider.name}/`;
492
+ for (const key of thinkingRoutes.keys()) {
493
+ if (key.startsWith(routePrefix)) thinkingRoutes.delete(key);
494
+ }
495
+ for (const key of fixedProfileLabels.keys()) {
496
+ if (key.startsWith(routePrefix)) fixedProfileLabels.delete(key);
497
+ }
266
498
  const compat: Record<string, unknown> = { ...provider.compat };
267
499
  if (serverType === "llama.cpp" || serverType === "oMLX" || serverType === "Ollama") {
268
500
  if (compat.supportsDeveloperRole === undefined) compat.supportsDeveloperRole = false;
269
- if (compat.supportsReasoningEffort === undefined) compat.supportsReasoningEffort = false;
501
+ }
502
+ if (serverType === "oMLX") {
503
+ // Preserve the pre-profile base-model behavior. Fixed and adaptive profile
504
+ // aliases supply their own complete chat-template kwargs independently.
505
+ if (compat.thinkingFormat === undefined) compat.thinkingFormat = "qwen-chat-template";
506
+ if (compat.supportsReasoningEffort === undefined) compat.supportsReasoningEffort = true;
270
507
  }
271
508
 
509
+ // NOTE: Pi's applyExtension() spreads model definitions but does NOT merge
510
+ // provider-level compat into individual models. So we must attach compat
511
+ // to each model directly — otherwise getCompat(model) returns no thinkingFormat.
512
+
272
513
  const configs = models.map(extractModelConfig);
273
- const piModels = configs.map((c) => {
514
+ const baseModels = configs.map((c) => {
274
515
  const ov = provider.modelOverrides?.[c.id];
516
+ // For oMLX, auto-detect reasoning capability on Qwen models
517
+ const serverReasoning =
518
+ serverType === "oMLX" && !c.reasoning
519
+ ? /^qwen/i.test(c.id) || /^qwen/i.test(c.name)
520
+ : false;
521
+ const reasoning = ov?.reasoning ?? c.reasoning ?? serverReasoning;
275
522
  return {
276
523
  id: c.id,
277
524
  name: c.name,
278
- reasoning: ov?.reasoning ?? c.reasoning ?? false,
525
+ reasoning,
279
526
  input: ov?.input ?? c.input ?? ["text"],
280
527
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
281
528
  contextWindow: ov?.contextWindow ?? c.contextWindow ?? provider.defaultContextWindow ?? 128_000,
282
529
  maxTokens: ov?.maxTokens ?? c.maxTokens ?? provider.defaultMaxTokens ?? 16_384,
530
+ compat: compat, // attach compat to each model (Pi's applyExtension doesn't merge provider-level compat)
283
531
  };
284
532
  });
533
+ const expandedProfiles = expandModelProfiles(
534
+ baseModels.map((model) => ({ ...model, input: model.input as ("text" | "image")[] })),
535
+ provider.modelProfiles,
536
+ { repetitionPenaltyKey: repetitionPenaltyKeyForServer(serverType) },
537
+ );
538
+ const expandedRouters = expandAdaptiveProfileRouters(
539
+ expandedProfiles.models,
540
+ provider.modelProfiles,
541
+ provider.modelProfileRouting,
542
+ );
543
+ for (const warning of [...expandedProfiles.warnings, ...expandedRouters.warnings]) {
544
+ console.error(`[model-discovery] ${provider.name}: ${warning}`);
545
+ }
546
+
547
+ const repetitionPenaltyKey = repetitionPenaltyKeyForServer(serverType);
548
+ for (const [modelId, routes] of expandedRouters.runtimeRoutes) {
549
+ thinkingRoutes.set(routeKey(provider.name, modelId), { routes, repetitionPenaltyKey });
550
+ }
551
+ for (const base of baseModels) {
552
+ for (const profile of getModelProfiles(provider, base.id)) {
553
+ if (profile.exposeAsModel !== false) {
554
+ fixedProfileLabels.set(routeKey(provider.name, profileModelId(base.id, profile.slug)), profile.slug);
555
+ }
556
+ }
557
+ }
285
558
 
286
- // OpenAI SDK appends /chat/completions to baseUrl.
559
+ // OpenAI SDK appends /chat/completions to baseUrl.
287
560
  // Ensure baseUrl ends with /v1 so the full URL is .../v1/chat/completions.
288
- const sdkBaseUrl = provider.baseUrl.replace(/\/v1\/?$/, '') + '/v1';
561
+ const sdkBaseUrl = provider.baseUrl.replace(/\/v1\/?$/, "") + "/v1";
289
562
  pi.registerProvider(provider.name, {
290
563
  name: `${serverType} (${provider.name})`,
291
564
  baseUrl: sdkBaseUrl,
292
565
  apiKey: provider.apiKey || "local",
293
566
  api: "openai-completions",
294
- compat,
295
- models: piModels,
567
+ models: expandedRouters.models,
296
568
  });
297
569
 
298
570
  provider.serverType = serverType;
299
- provider.lastScanned = Date.now();
300
- return { models: configs, serverType };
571
+ return {
572
+ models: configs,
573
+ rawModels: models,
574
+ serverType,
575
+ profileCount: expandedProfiles.profileCount + expandedRouters.routerCount,
576
+ };
301
577
  }
302
578
 
303
579
  // Register saved providers at startup (concurrent — one dead endpoint can't block the others)
@@ -305,17 +581,64 @@ export default async function (pi: ExtensionAPI) {
305
581
  if (providers.length > 0) {
306
582
  const results = await Promise.allSettled(
307
583
  providers.map(async (provider) => {
308
- await registerProvider(provider);
584
+ try {
585
+ const registered = await registerProvider(provider);
586
+ recordSuccessfulScan(provider, registered.rawModels, registered.serverType);
587
+ } catch (error) {
588
+ recordFailedScan(provider, error);
589
+ if (!provider.cachedModels?.length) throw error;
590
+ try {
591
+ await registerProvider(provider, {
592
+ models: provider.cachedModels,
593
+ serverType: provider.serverType ?? "OpenAI-compatible",
594
+ });
595
+ } catch (cacheError) {
596
+ throw new Error(
597
+ `Live scan failed (${errorMessage(error)}); cached catalogue also failed (${errorMessage(cacheError)}).`,
598
+ );
599
+ }
600
+ console.error(
601
+ `[model-discovery] ${provider.name}: ${errorMessage(error)}; registered last known-good cached catalogue.`,
602
+ );
603
+ }
309
604
  return provider.name;
310
605
  }),
311
606
  );
312
- for (const result of results) {
607
+ results.forEach((result, index) => {
313
608
  if (result.status === "rejected") {
314
- console.error(`[model-discovery] Failed to register a provider:`, result.reason);
609
+ console.error(
610
+ `[model-discovery] ${providers[index].name}: unavailable with no usable cache (${errorMessage(result.reason)}); other sources remain available.`,
611
+ );
315
612
  }
316
- }
613
+ });
317
614
  }
318
615
 
616
+ function activeThinkingRoute(ctx: { model?: { provider: string; id: string }; thinkingLevel?: string }) {
617
+ if (!ctx.model || !ctx.thinkingLevel) return undefined;
618
+ const runtime = thinkingRoutes.get(routeKey(ctx.model.provider, ctx.model.id));
619
+ if (!runtime) return undefined;
620
+ const profile = runtime.routes[ctx.thinkingLevel as keyof ThinkingProfileRoutes];
621
+ return profile ? { runtime, profile } : undefined;
622
+ }
623
+
624
+ pi.on("before_provider_request", (event, ctx) => {
625
+ const active = activeThinkingRoute(ctx);
626
+ if (!active) return undefined;
627
+ return applyThinkingProfileRoute(event.payload, active.profile, active.runtime.repetitionPenaltyKey);
628
+ });
629
+
630
+ const updateThinkingProfileStatus = (ctx: ExtensionContext): void => {
631
+ const active = activeThinkingRoute(ctx);
632
+ const fixed = ctx.model ? fixedProfileLabels.get(routeKey(ctx.model.provider, ctx.model.id)) : undefined;
633
+ ctx.ui.setStatus(
634
+ "model-discovery-thinking-profile",
635
+ active ? `preset: ${active.profile.slug}` : fixed ? `fixed preset: ${fixed}` : undefined,
636
+ );
637
+ };
638
+ pi.on("session_start", (_event, ctx) => updateThinkingProfileStatus(ctx));
639
+ pi.on("model_select", (_event, ctx) => updateThinkingProfileStatus(ctx));
640
+ pi.on("thinking_level_select", (_event, ctx) => updateThinkingProfileStatus(ctx));
641
+
319
642
  // -----------------------------------------------------------------------
320
643
  // UI helpers (Pi-standard SelectList dialog)
321
644
  // -----------------------------------------------------------------------
@@ -334,13 +657,18 @@ export default async function (pi: ExtensionAPI) {
334
657
  container.addChild(new Text(theme.fg("muted", line), 1, 0));
335
658
  }
336
659
 
337
- const selectList = new SelectList(items, Math.min(items.length, 12), {
338
- selectedPrefix: (t: string) => theme.fg("accent", t),
339
- selectedText: (t: string) => theme.fg("accent", t),
340
- description: (t: string) => theme.fg("muted", t),
341
- scrollInfo: (t: string) => theme.fg("dim", t),
342
- noMatch: (t: string) => theme.fg("warning", t),
343
- });
660
+ const selectList = new SelectList(
661
+ items,
662
+ Math.min(items.length, 12),
663
+ {
664
+ selectedPrefix: (t: string) => theme.fg("accent", t),
665
+ selectedText: (t: string) => theme.fg("accent", t),
666
+ description: (t: string) => theme.fg("muted", t),
667
+ scrollInfo: (t: string) => theme.fg("dim", t),
668
+ noMatch: (t: string) => theme.fg("warning", t),
669
+ },
670
+ { minPrimaryColumnWidth: 18, maxPrimaryColumnWidth: 48 },
671
+ );
344
672
  selectList.onSelect = (item) => done(item.value);
345
673
  selectList.onCancel = () => done(null);
346
674
  container.addChild(selectList);
@@ -363,6 +691,7 @@ export default async function (pi: ExtensionAPI) {
363
691
  ctx: ExtensionCommandContext,
364
692
  message: string,
365
693
  work: (signal: AbortSignal) => Promise<T>,
694
+ onError?: (error: unknown) => void,
366
695
  ): Promise<T | null> {
367
696
  return await ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
368
697
  const loader = new BorderedLoader(tui, theme, message);
@@ -370,13 +699,58 @@ export default async function (pi: ExtensionAPI) {
370
699
  work(loader.signal)
371
700
  .then((result) => done(result))
372
701
  .catch((err) => {
373
- ctx.ui.notify(`${err instanceof Error ? err.message : String(err)}`, "error");
702
+ onError?.(err);
703
+ ctx.ui.notify(errorMessage(err), "error");
374
704
  done(null);
375
705
  });
376
706
  return loader;
377
707
  });
378
708
  }
379
709
 
710
+ async function askSecret(
711
+ ctx: ExtensionCommandContext,
712
+ title: string,
713
+ description: string,
714
+ ): Promise<string | undefined> {
715
+ return await ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
716
+ const input = new Input();
717
+ input.onSubmit = (value) => done(value);
718
+ input.onEscape = () => done(undefined);
719
+
720
+ const container = new Container();
721
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
722
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
723
+ container.addChild(new Text(theme.fg("muted", description), 1, 0));
724
+ container.addChild({
725
+ render: (width: number) => {
726
+ const count = [...input.getValue()].length;
727
+ const available = Math.max(1, width - 4);
728
+ const masked = count > available ? `…${"•".repeat(Math.max(0, available - 1))}` : "•".repeat(count);
729
+ const marker = input.focused ? CURSOR_MARKER : "";
730
+ return [truncateToWidth(`> ${masked}${marker}\x1b[7m \x1b[27m`, width, "")];
731
+ },
732
+ invalidate: () => {},
733
+ });
734
+ container.addChild(new Text(theme.fg("dim", "enter submit • esc cancel • value is masked"), 1, 0));
735
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
736
+
737
+ return {
738
+ get focused() {
739
+ return input.focused;
740
+ },
741
+ set focused(value: boolean) {
742
+ input.focused = value;
743
+ },
744
+ render: (width: number) => container.render(width),
745
+ invalidate: () => container.invalidate(),
746
+ handleInput: (data: string) => {
747
+ input.handleInput(data);
748
+ tui.requestRender();
749
+ },
750
+ };
751
+ });
752
+ }
753
+
380
754
  async function askNumber(
381
755
  ctx: ExtensionCommandContext,
382
756
  title: string,
@@ -407,6 +781,753 @@ export default async function (pi: ExtensionAPI) {
407
781
  return `ctx ${fmt(ctxVal)} · max ${fmt(maxVal)} · ${c.source}${ovMark}`;
408
782
  }
409
783
 
784
+ function optionalBooleanDescription(value: boolean | undefined): string {
785
+ return value === undefined ? "omitted (inherits base behavior)" : value ? "true" : "false";
786
+ }
787
+
788
+ async function chooseOptionalBoolean(
789
+ ctx: ExtensionCommandContext,
790
+ title: string,
791
+ current: boolean | undefined,
792
+ ): Promise<boolean | undefined | null> {
793
+ const currentValue = current === undefined ? "omit" : String(current);
794
+ const choices: SelectItem[] = [
795
+ { value: "omit", label: "Omit", description: "do not fix this key in the profile" },
796
+ { value: "true", label: "true" },
797
+ { value: "false", label: "false" },
798
+ ];
799
+ const selected = await runSelect(
800
+ ctx,
801
+ title,
802
+ [...choices.filter((item) => item.value === currentValue), ...choices.filter((item) => item.value !== currentValue)],
803
+ [`current: ${optionalBooleanDescription(current)}`],
804
+ );
805
+ if (selected === null) return null;
806
+ if (selected === "omit") return undefined;
807
+ return selected === "true";
808
+ }
809
+
810
+ async function chooseReasoningEffort(
811
+ ctx: ExtensionCommandContext,
812
+ current: string | undefined,
813
+ ): Promise<(typeof REASONING_EFFORTS)[number] | undefined | null> {
814
+ const currentValue = current ?? "omit";
815
+ const choices: SelectItem[] = [
816
+ { value: "omit", label: "Omit", description: "do not fix this key in the profile" },
817
+ ...REASONING_EFFORTS.map((effort) => ({ value: effort, label: effort })),
818
+ ];
819
+ const selected = await runSelect(
820
+ ctx,
821
+ "reasoning_effort",
822
+ [...choices.filter((item) => item.value === currentValue), ...choices.filter((item) => item.value !== currentValue)],
823
+ [`current: ${current ?? "omitted (inherits base behavior)"}`],
824
+ );
825
+ if (selected === null) return null;
826
+ if (selected === "omit") return undefined;
827
+ return selected as (typeof REASONING_EFFORTS)[number];
828
+ }
829
+
830
+ type ProfileSamplingField = {
831
+ key: keyof ProfileSampling;
832
+ label: string;
833
+ description: string;
834
+ example: string;
835
+ };
836
+
837
+ function profileSamplingFields(serverType: string): ProfileSamplingField[] {
838
+ const repetitionPenaltyKey = repetitionPenaltyKeyForServer(serverType);
839
+ return [
840
+ { key: "temperature", label: "temperature", description: "0–2; 0 is greedy", example: "0.7" },
841
+ { key: "topP", label: "top_p", description: "0–1; 1 disables top-p filtering", example: "0.9" },
842
+ { key: "topK", label: "top_k", description: "integer ≥ 0; omit to keep the backend default", example: "20" },
843
+ { key: "minP", label: "min_p", description: "0–1; 0 disables min-p filtering", example: "0.05" },
844
+ {
845
+ key: "repetitionPenalty",
846
+ label: repetitionPenaltyKey,
847
+ description: "> 0; 1 disables the multiplicative penalty",
848
+ example: "1.05",
849
+ },
850
+ { key: "presencePenalty", label: "presence_penalty", description: "-2–2; 0 disables it", example: "0" },
851
+ { key: "frequencyPenalty", label: "frequency_penalty", description: "-2–2; 0 disables it", example: "0" },
852
+ ];
853
+ }
854
+
855
+ async function chooseOptionalSamplingValue(
856
+ ctx: ExtensionCommandContext,
857
+ field: ProfileSamplingField,
858
+ current: number | undefined,
859
+ ): Promise<number | undefined | null> {
860
+ const action = await runSelect(
861
+ ctx,
862
+ field.label,
863
+ [
864
+ { value: "set", label: "Set value", description: field.description },
865
+ { value: "omit", label: "Omit", description: "do not send this key; use the server/model default" },
866
+ ],
867
+ [`current: ${current ?? "omitted (server/model default)"}`],
868
+ );
869
+ if (action === null) return null;
870
+ if (action === "omit") return undefined;
871
+
872
+ for (;;) {
873
+ const raw = await ctx.ui.input(`Value for ${field.label}`, String(current ?? field.example));
874
+ if (raw === undefined) return null;
875
+ const trimmed = raw.trim();
876
+ if (!trimmed) {
877
+ ctx.ui.notify("Enter a numeric value, or choose Omit from the previous screen.", "error");
878
+ continue;
879
+ }
880
+ const value = Number(trimmed);
881
+ const error = validateProfileSampling({ [field.key]: value });
882
+ if (!error) return value;
883
+ ctx.ui.notify(error, "error");
884
+ }
885
+ }
886
+
887
+ function profileDescription(
888
+ profile: ModelProfile,
889
+ serverType: string,
890
+ routing: ModelProfileRouting | undefined,
891
+ ): string {
892
+ const repetitionPenaltyKey = repetitionPenaltyKeyForServer(serverType);
893
+ const routedLevels = routing
894
+ ? (Object.entries(routing.levels) as Array<[ThinkingLevel, string]>)
895
+ .filter(([, slug]) => slug === profile.slug)
896
+ .map(([level]) => level)
897
+ : [];
898
+ return [
899
+ routedLevels.length > 0 ? `routed: ${routedLevels.join(",")}` : "preset",
900
+ profile.exposeAsModel === false ? "fixed alias hidden" : "fixed alias visible",
901
+ describeChatTemplateKwargs(profile.chatTemplateKwargs),
902
+ describeProfileSampling(profile.sampling, repetitionPenaltyKey),
903
+ ]
904
+ .filter(Boolean)
905
+ .join(" · ");
906
+ }
907
+
908
+ function profileRoutingHeader(
909
+ routing: ModelProfileRouting | undefined,
910
+ profiles: readonly ModelProfile[],
911
+ ): string[] {
912
+ if (!routing) return ["Adaptive Shift-Tab routing: not configured (base and fixed aliases are unchanged)"];
913
+ const analysis = analyzeExplicitProfileRouting(routing, profiles);
914
+ if (analysis.errors.length > 0) {
915
+ return [
916
+ `Adaptive Shift-Tab routing: invalid${routing.enabled ? "" : " (disabled)"}`,
917
+ ...analysis.errors.map((error) => `⚠ ${error}`),
918
+ ];
919
+ }
920
+ return [
921
+ `Adaptive Shift-Tab routing: ${routing.enabled ? `enabled as @${routing.aliasSlug}` : "disabled"}`,
922
+ `off → ${routing.levels.off} · minimal → ${routing.levels.minimal} · low → ${routing.levels.low}`,
923
+ `medium → ${routing.levels.medium} · high → ${routing.levels.high} · xhigh → ${routing.levels.xhigh} · max → ${routing.levels.max}`,
924
+ ];
925
+ }
926
+
927
+ function profilesWithCandidate(
928
+ provider: DiscoveredProvider,
929
+ modelId: string,
930
+ candidate: ModelProfile,
931
+ previousSlug?: string,
932
+ ): ModelProfile[] {
933
+ const profiles = getModelProfiles(provider, modelId);
934
+ const index = previousSlug === undefined ? -1 : profiles.findIndex((profile) => profile.slug === previousSlug);
935
+ if (index < 0) return [...profiles, candidate];
936
+ return profiles.map((profile, profileIndex) => (profileIndex === index ? candidate : profile));
937
+ }
938
+
939
+ async function promptProfileSlug(
940
+ ctx: ExtensionCommandContext,
941
+ initial: string,
942
+ ): Promise<string | null> {
943
+ for (;;) {
944
+ const answer = await ctx.ui.input("Preset name", initial || "thinking-medium");
945
+ if (answer === undefined) return null;
946
+ const slug = answer.trim();
947
+ const error = validateProfileSlug(slug);
948
+ if (!error) return slug;
949
+ ctx.ui.notify(error, "error");
950
+ }
951
+ }
952
+
953
+ function validateProfileForProvider(
954
+ provider: DiscoveredProvider,
955
+ config: ModelConfig,
956
+ allConfigs: ModelConfig[],
957
+ profile: ModelProfile,
958
+ previousSlug?: string,
959
+ ): string | null {
960
+ const profileError = validateModelProfile(profile);
961
+ if (profileError) return profileError;
962
+
963
+ const aliasId = profileModelId(config.id, profile.slug);
964
+ const routing = getModelProfileRouting(provider, config.id);
965
+ if (routing?.aliasSlug === profile.slug && profile.slug !== previousSlug) {
966
+ return `Preset name "${profile.slug}" collides with the adaptive model alias.`;
967
+ }
968
+ if (allConfigs.some((item) => item.id === aliasId)) {
969
+ return `Profile id "${aliasId}" collides with a server model.`;
970
+ }
971
+
972
+ for (const [modelId, profiles] of Object.entries(provider.modelProfiles ?? {})) {
973
+ if (!Array.isArray(profiles)) continue;
974
+ for (const other of profiles) {
975
+ if (validateModelProfile(other) !== null) continue;
976
+ if (modelId === config.id && other.slug === previousSlug) continue;
977
+ if (profileModelId(modelId, other.slug) === aliasId) {
978
+ return `Profile id "${aliasId}" is already in use.`;
979
+ }
980
+ }
981
+ }
982
+ return null;
983
+ }
984
+
985
+ type ProfileEditorResult =
986
+ | { action: "save"; profile: ModelProfile }
987
+ | { action: "delete" }
988
+ | null;
989
+
990
+ async function showProfileEditor(
991
+ ctx: ExtensionCommandContext,
992
+ provider: DiscoveredProvider,
993
+ config: ModelConfig,
994
+ allConfigs: ModelConfig[],
995
+ serverType: string,
996
+ existing?: ModelProfile,
997
+ template?: ModelProfile,
998
+ ): Promise<ProfileEditorResult> {
999
+ const source = existing ?? template;
1000
+ const initialSlug = source?.slug ?? (await promptProfileSlug(ctx, ""));
1001
+ if (!initialSlug) return null;
1002
+ const profile: ModelProfile = {
1003
+ slug: initialSlug,
1004
+ ...(source?.chatTemplateKwargs
1005
+ ? { chatTemplateKwargs: { ...source.chatTemplateKwargs } }
1006
+ : {}),
1007
+ ...(source?.sampling ? { sampling: { ...source.sampling } } : {}),
1008
+ ...(source?.exposeAsModel !== undefined ? { exposeAsModel: source.exposeAsModel } : {}),
1009
+ };
1010
+
1011
+ for (;;) {
1012
+ const kwargs = profile.chatTemplateKwargs ?? {};
1013
+ const sampling = profile.sampling ?? {};
1014
+ const samplingFields = profileSamplingFields(serverType);
1015
+ const repetitionPenaltyKey = repetitionPenaltyKeyForServer(serverType);
1016
+ const currentRouting = getModelProfileRouting(provider, config.id);
1017
+ const previewRouting = currentRouting
1018
+ ? { ...currentRouting, levels: { ...currentRouting.levels } }
1019
+ : undefined;
1020
+ if (previewRouting && existing && existing.slug !== profile.slug) {
1021
+ previewRouting.levels = Object.fromEntries(
1022
+ Object.entries(previewRouting.levels).map(([level, slug]) => [
1023
+ level,
1024
+ slug === existing.slug ? profile.slug : slug,
1025
+ ]),
1026
+ ) as Record<ThinkingLevel, string>;
1027
+ }
1028
+ const items: SelectItem[] = [
1029
+ { value: "rename", label: "Preset name", description: profile.slug },
1030
+ {
1031
+ value: "expose",
1032
+ label: "Show as fixed model in /model",
1033
+ description: profile.exposeAsModel === false ? "hidden (still available to adaptive routing)" : "visible",
1034
+ },
1035
+ {
1036
+ value: "enable",
1037
+ label: "enable_thinking",
1038
+ description: optionalBooleanDescription(kwargs.enable_thinking),
1039
+ },
1040
+ {
1041
+ value: "effort",
1042
+ label: "reasoning_effort",
1043
+ description: kwargs.reasoning_effort ?? "omitted (inherits base behavior)",
1044
+ },
1045
+ {
1046
+ value: "preserve",
1047
+ label: "preserve_thinking",
1048
+ description: optionalBooleanDescription(kwargs.preserve_thinking),
1049
+ },
1050
+ ...samplingFields.map((field) => ({
1051
+ value: `sampling:${field.key}`,
1052
+ label: field.label,
1053
+ description: `${sampling[field.key] ?? "omitted (server/model default)"} · ${field.description}`,
1054
+ })),
1055
+ { value: "save", label: "✓ Save preset", description: profileModelId(config.id, profile.slug) },
1056
+ ];
1057
+ if (existing) items.push({ value: "delete", label: "✗ Delete preset" });
1058
+ items.push({ value: "cancel", label: "← Cancel" });
1059
+
1060
+ const action = await runSelect(ctx, `Preset: ${profile.slug}`, items, [
1061
+ `model id: ${profileModelId(config.id, profile.slug)} → ${config.id}`,
1062
+ `thinking kwargs: ${JSON.stringify(kwargs)}`,
1063
+ `sampling params: ${JSON.stringify(buildProfileSamplingParams(profile.sampling, repetitionPenaltyKey))}`,
1064
+ ...profileRoutingHeader(
1065
+ previewRouting,
1066
+ profilesWithCandidate(provider, config.id, profile, existing?.slug),
1067
+ ),
1068
+ "This preset changes adaptive routing only when explicitly mapped to a Pi level.",
1069
+ "Omitted values use the server/model default.",
1070
+ ]);
1071
+ if (!action || action === "cancel") return null;
1072
+
1073
+ if (action === "rename") {
1074
+ const slug = await promptProfileSlug(ctx, profile.slug);
1075
+ if (slug) profile.slug = slug;
1076
+ } else if (action === "expose") {
1077
+ profile.exposeAsModel = profile.exposeAsModel === false;
1078
+ } else if (action === "enable") {
1079
+ const value = await chooseOptionalBoolean(ctx, "enable_thinking", kwargs.enable_thinking);
1080
+ if (value === null) continue;
1081
+ if (value === undefined) delete kwargs.enable_thinking;
1082
+ else kwargs.enable_thinking = value;
1083
+ if (Object.keys(kwargs).length > 0) profile.chatTemplateKwargs = kwargs;
1084
+ else delete profile.chatTemplateKwargs;
1085
+ } else if (action === "effort") {
1086
+ const value = await chooseReasoningEffort(ctx, kwargs.reasoning_effort);
1087
+ if (value === null) continue;
1088
+ if (value === undefined) delete kwargs.reasoning_effort;
1089
+ else kwargs.reasoning_effort = value;
1090
+ if (Object.keys(kwargs).length > 0) profile.chatTemplateKwargs = kwargs;
1091
+ else delete profile.chatTemplateKwargs;
1092
+ } else if (action === "preserve") {
1093
+ const value = await chooseOptionalBoolean(ctx, "preserve_thinking", kwargs.preserve_thinking);
1094
+ if (value === null) continue;
1095
+ if (value === undefined) delete kwargs.preserve_thinking;
1096
+ else kwargs.preserve_thinking = value;
1097
+ if (Object.keys(kwargs).length > 0) profile.chatTemplateKwargs = kwargs;
1098
+ else delete profile.chatTemplateKwargs;
1099
+ } else if (action.startsWith("sampling:")) {
1100
+ const key = action.slice("sampling:".length) as keyof ProfileSampling;
1101
+ const field = samplingFields.find((candidate) => candidate.key === key);
1102
+ if (!field) continue;
1103
+ const value = await chooseOptionalSamplingValue(ctx, field, sampling[key]);
1104
+ if (value === null) continue;
1105
+ if (value === undefined) delete sampling[key];
1106
+ else sampling[key] = value;
1107
+ if (Object.keys(sampling).length > 0) profile.sampling = sampling;
1108
+ else delete profile.sampling;
1109
+ } else if (action === "save") {
1110
+ const error = validateProfileForProvider(provider, config, allConfigs, profile, existing?.slug);
1111
+ if (error) {
1112
+ ctx.ui.notify(error, "error");
1113
+ continue;
1114
+ }
1115
+ return { action: "save", profile };
1116
+ } else if (action === "delete" && existing) {
1117
+ const routing = getModelProfileRouting(provider, config.id);
1118
+ const routed = routing && Object.values(routing.levels).includes(existing.slug);
1119
+ const confirmed = await ctx.ui.confirm(
1120
+ "Delete preset",
1121
+ `Delete "${existing.slug}"?${routed ? " The adaptive route will become invalid until those levels are remapped." : ""}`,
1122
+ );
1123
+ if (confirmed) return { action: "delete" };
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ async function persistProfileChange(
1129
+ ctx: ExtensionCommandContext,
1130
+ provider: DiscoveredProvider,
1131
+ prefetched: { models: Record<string, unknown>[]; serverType: string },
1132
+ ): Promise<boolean> {
1133
+ upsertProvider(provider);
1134
+ try {
1135
+ await registerProvider(provider, prefetched);
1136
+ upsertProvider(provider);
1137
+ return true;
1138
+ } catch (err) {
1139
+ ctx.ui.notify(
1140
+ `Profile saved, but provider registration failed: ${err instanceof Error ? err.message : String(err)}`,
1141
+ "warning",
1142
+ );
1143
+ return false;
1144
+ }
1145
+ }
1146
+
1147
+ async function refreshSelectedProfile(
1148
+ ctx: ExtensionCommandContext,
1149
+ providerName: string,
1150
+ previousModelId: string,
1151
+ nextModelId: string,
1152
+ ): Promise<void> {
1153
+ if (ctx.model?.provider !== providerName || ctx.model.id !== previousModelId) return;
1154
+ const refreshed = ctx.modelRegistry.find(providerName, nextModelId);
1155
+ if (refreshed) await pi.setModel(refreshed);
1156
+ }
1157
+
1158
+ async function refreshAdaptiveSelection(
1159
+ ctx: ExtensionCommandContext,
1160
+ provider: DiscoveredProvider,
1161
+ modelId: string,
1162
+ ): Promise<void> {
1163
+ const routing = getModelProfileRouting(provider, modelId);
1164
+ if (!routing || ctx.model?.provider !== provider.name) return;
1165
+ const adaptiveId = profileModelId(modelId, routing.aliasSlug);
1166
+ if (ctx.model.id !== adaptiveId) return;
1167
+ const valid =
1168
+ routing.enabled && analyzeExplicitProfileRouting(routing, getModelProfiles(provider, modelId)).errors.length === 0;
1169
+ const refreshed = ctx.modelRegistry.find(provider.name, valid ? adaptiveId : modelId);
1170
+ if (refreshed) await pi.setModel(refreshed);
1171
+ }
1172
+
1173
+ async function choosePreset(
1174
+ ctx: ExtensionCommandContext,
1175
+ title: string,
1176
+ profiles: readonly ModelProfile[],
1177
+ serverType: string,
1178
+ current?: string,
1179
+ ): Promise<string | null> {
1180
+ const items: SelectItem[] = profiles.map((profile) => ({
1181
+ value: profile.slug,
1182
+ label: profile.slug,
1183
+ description: profileDescription(profile, serverType, undefined),
1184
+ }));
1185
+ items.sort((a, b) => (a.value === current ? -1 : b.value === current ? 1 : a.label.localeCompare(b.label)));
1186
+ return runSelect(ctx, title, items, [current ? `current: ${current}` : "No preset selected"]);
1187
+ }
1188
+
1189
+ function conventionalPreset(profiles: readonly ModelProfile[], kind: "off" | "low" | "medium" | "xhigh") {
1190
+ return profiles.find((profile) => {
1191
+ const kwargs = profile.chatTemplateKwargs;
1192
+ return kind === "off"
1193
+ ? kwargs?.enable_thinking === false
1194
+ : kwargs?.enable_thinking !== false && kwargs?.reasoning_effort === kind;
1195
+ });
1196
+ }
1197
+
1198
+ function defaultProfileRouting(profiles: readonly ModelProfile[]): ModelProfileRouting {
1199
+ const off = conventionalPreset(profiles, "off")?.slug ?? "";
1200
+ const low = conventionalPreset(profiles, "low")?.slug ?? "";
1201
+ const medium = conventionalPreset(profiles, "medium")?.slug ?? "";
1202
+ const xhigh = conventionalPreset(profiles, "xhigh")?.slug ?? "";
1203
+ return {
1204
+ enabled: true,
1205
+ aliasSlug: "adaptive",
1206
+ levels: { off, minimal: low, low, medium, high: xhigh, xhigh, max: xhigh },
1207
+ };
1208
+ }
1209
+
1210
+ async function previewProfileRouting(
1211
+ ctx: ExtensionCommandContext,
1212
+ config: ModelConfig,
1213
+ routing: ModelProfileRouting,
1214
+ profiles: readonly ModelProfile[],
1215
+ serverType: string,
1216
+ ): Promise<void> {
1217
+ const analysis = analyzeExplicitProfileRouting(routing, profiles);
1218
+ if (!analysis.routes) {
1219
+ ctx.ui.notify(analysis.errors.join(" "), "error");
1220
+ return;
1221
+ }
1222
+ const level = await runSelect(
1223
+ ctx,
1224
+ "Preview exact routed request",
1225
+ (Object.entries(analysis.routes) as Array<[ThinkingLevel, ModelProfile]>).map(([thinkingLevel, profile]) => ({
1226
+ value: thinkingLevel,
1227
+ label: thinkingLevel,
1228
+ description: `${profile.slug} · ${profileDescription(profile, serverType, routing)}`,
1229
+ })),
1230
+ [`adaptive model: ${profileModelId(config.id, routing.aliasSlug)} → ${config.id}`],
1231
+ );
1232
+ if (!level) return;
1233
+ const profile = analysis.routes[level as ThinkingLevel];
1234
+ const payload = applyThinkingProfileRoute(
1235
+ { model: config.id },
1236
+ profile,
1237
+ repetitionPenaltyKeyForServer(serverType),
1238
+ );
1239
+ await runSelect(ctx, `${level} → ${profile.slug}`, [{ value: "back", label: "← Back" }], [
1240
+ ...JSON.stringify(payload, null, 2).split("\n"),
1241
+ ]);
1242
+ }
1243
+
1244
+ type RoutingEditorResult = { action: "save"; routing: ModelProfileRouting } | { action: "remove" } | null;
1245
+
1246
+ async function showProfileRoutingEditor(
1247
+ ctx: ExtensionCommandContext,
1248
+ provider: DiscoveredProvider,
1249
+ config: ModelConfig,
1250
+ allConfigs: ModelConfig[],
1251
+ profiles: readonly ModelProfile[],
1252
+ serverType: string,
1253
+ ): Promise<RoutingEditorResult> {
1254
+ if (profiles.length === 0) {
1255
+ ctx.ui.notify("Create at least one preset before configuring adaptive routing.", "warning");
1256
+ return null;
1257
+ }
1258
+ const existing = getModelProfileRouting(provider, config.id);
1259
+ const routing: ModelProfileRouting = existing
1260
+ ? { ...existing, levels: { ...existing.levels } }
1261
+ : defaultProfileRouting(profiles);
1262
+
1263
+ for (;;) {
1264
+ const analysis = analyzeExplicitProfileRouting(routing, profiles);
1265
+ const items: SelectItem[] = [
1266
+ {
1267
+ value: "enabled",
1268
+ label: "Adaptive routing",
1269
+ description: routing.enabled ? "enabled" : "disabled (mapping retained)",
1270
+ },
1271
+ {
1272
+ value: "alias",
1273
+ label: "Adaptive model alias",
1274
+ description: profileModelId(config.id, routing.aliasSlug),
1275
+ },
1276
+ {
1277
+ value: "conventional",
1278
+ label: "Map four-preset layout",
1279
+ description: "choose off, low, medium, and xhigh once; expand to all seven Pi levels",
1280
+ },
1281
+ ...THINKING_LEVELS.map((level) => ({
1282
+ value: `level:${level}`,
1283
+ label: `Pi ${level}`,
1284
+ description: `→ ${routing.levels[level] || "not selected"}`,
1285
+ })),
1286
+ { value: "preview", label: "Preview exact requests", description: "inspect the payload preset for each Pi level" },
1287
+ { value: "save", label: "✓ Review and save", description: analysis.errors.length ? `${analysis.errors.length} issue(s)` : "valid mapping" },
1288
+ ];
1289
+ if (existing) items.push({ value: "remove", label: "✗ Remove adaptive routing", description: "fixed presets remain unchanged" });
1290
+ items.push({ value: "cancel", label: "← Cancel" });
1291
+
1292
+ const action = await runSelect(ctx, "Adaptive Shift-Tab routing", items, [
1293
+ "Explicit router: only this alias changes complete presets when Shift-Tab is pressed.",
1294
+ ...(analysis.errors.length ? analysis.errors.map((error) => `⚠ ${error}`) : profileRoutingHeader(routing, profiles)),
1295
+ ]);
1296
+ if (!action || action === "cancel") return null;
1297
+ if (action === "enabled") {
1298
+ routing.enabled = !routing.enabled;
1299
+ } else if (action === "alias") {
1300
+ const alias = await promptProfileSlug(ctx, routing.aliasSlug);
1301
+ if (alias) routing.aliasSlug = alias;
1302
+ } else if (action === "conventional") {
1303
+ const selected: Partial<Record<"off" | "low" | "medium" | "xhigh", string>> = {};
1304
+ let cancelled = false;
1305
+ for (const kind of ["off", "low", "medium", "xhigh"] as const) {
1306
+ const slug = await choosePreset(ctx, `${kind} preset`, profiles, serverType, routing.levels[kind]);
1307
+ if (!slug) {
1308
+ cancelled = true;
1309
+ break;
1310
+ }
1311
+ selected[kind] = slug;
1312
+ }
1313
+ if (!cancelled && selected.off && selected.low && selected.medium && selected.xhigh) {
1314
+ routing.levels = {
1315
+ off: selected.off,
1316
+ minimal: selected.low,
1317
+ low: selected.low,
1318
+ medium: selected.medium,
1319
+ high: selected.xhigh,
1320
+ xhigh: selected.xhigh,
1321
+ max: selected.xhigh,
1322
+ };
1323
+ }
1324
+ } else if (action.startsWith("level:")) {
1325
+ const level = action.slice("level:".length) as ThinkingLevel;
1326
+ const slug = await choosePreset(ctx, `Preset for Pi ${level}`, profiles, serverType, routing.levels[level]);
1327
+ if (slug) routing.levels[level] = slug;
1328
+ } else if (action === "preview") {
1329
+ await previewProfileRouting(ctx, config, routing, profiles, serverType);
1330
+ } else if (action === "save") {
1331
+ if (analysis.errors.length > 0) {
1332
+ ctx.ui.notify(analysis.errors.join(" "), "error");
1333
+ continue;
1334
+ }
1335
+ const aliasId = profileModelId(config.id, routing.aliasSlug);
1336
+ if (allConfigs.some((model) => model.id === aliasId)) {
1337
+ ctx.ui.notify(`Adaptive alias "${aliasId}" collides with a server model.`, "error");
1338
+ continue;
1339
+ }
1340
+ const confirmed = await ctx.ui.confirm(
1341
+ "Save adaptive routing",
1342
+ `${routing.enabled ? "Enable" : "Save disabled"} "${aliasId}" with all seven Pi levels mapped? The base model and fixed aliases will not change.`,
1343
+ );
1344
+ if (confirmed) return { action: "save", routing };
1345
+ } else if (action === "remove") {
1346
+ const confirmed = await ctx.ui.confirm(
1347
+ "Remove adaptive routing",
1348
+ `Remove "${profileModelId(config.id, routing.aliasSlug)}"? Presets and fixed aliases remain.`,
1349
+ );
1350
+ if (confirmed) return { action: "remove" };
1351
+ }
1352
+ }
1353
+ }
1354
+
1355
+ async function showProfilesScreen(
1356
+ ctx: ExtensionCommandContext,
1357
+ provider: DiscoveredProvider,
1358
+ config: ModelConfig,
1359
+ allConfigs: ModelConfig[],
1360
+ prefetched: { models: Record<string, unknown>[]; serverType: string },
1361
+ ): Promise<void> {
1362
+ for (;;) {
1363
+ const profiles = getModelProfiles(provider, config.id);
1364
+ const routing = getModelProfileRouting(provider, config.id);
1365
+ const routeAnalysis = routing ? analyzeExplicitProfileRouting(routing, profiles) : undefined;
1366
+ const items: SelectItem[] = [
1367
+ {
1368
+ value: "routing",
1369
+ label: routing ? "Configure adaptive routing" : "+ Configure adaptive routing",
1370
+ description: !routing
1371
+ ? "explicitly map all seven Pi levels to complete presets"
1372
+ : routeAnalysis?.errors.length
1373
+ ? `invalid · ${routeAnalysis.errors.length} issue(s)`
1374
+ : routing.enabled
1375
+ ? `enabled as ${profileModelId(config.id, routing.aliasSlug)}`
1376
+ : "disabled; mapping retained",
1377
+ },
1378
+ ];
1379
+ if (routing && routeAnalysis?.routes) {
1380
+ items.push({
1381
+ value: "preview-routing",
1382
+ label: "Preview routed requests",
1383
+ description: "inspect exact thinking and sampling fields for every Pi level",
1384
+ });
1385
+ }
1386
+ items.push(
1387
+ { value: "add", label: "+ Create preset", description: "create a complete thinking/sampling parameter bundle" },
1388
+ { value: "clone", label: "+ Clone preset", description: "copy an existing preset, then edit only what differs" },
1389
+ );
1390
+ for (const profile of profiles) {
1391
+ items.push({
1392
+ value: `profile:${profile.slug}`,
1393
+ label: profile.slug,
1394
+ description: profileDescription(profile, prefetched.serverType, routing),
1395
+ });
1396
+ }
1397
+ items.push({ value: "back", label: "← Back" });
1398
+
1399
+ const action = await runSelect(ctx, `Thinking & presets: ${config.id}`, items, [
1400
+ `${profiles.length} preset(s) · base model behavior is never changed by presets`,
1401
+ ...profileRoutingHeader(routing, profiles),
1402
+ ]);
1403
+ if (!action || action === "back") return;
1404
+
1405
+ if (action === "routing") {
1406
+ const previousAlias = routing?.aliasSlug;
1407
+ const result = await showProfileRoutingEditor(
1408
+ ctx,
1409
+ provider,
1410
+ config,
1411
+ allConfigs,
1412
+ profiles,
1413
+ prefetched.serverType,
1414
+ );
1415
+ if (!result) continue;
1416
+ if (result.action === "save") saveModelProfileRouting(provider, config.id, result.routing);
1417
+ else deleteModelProfileRouting(provider, config.id);
1418
+ const registered = await persistProfileChange(ctx, provider, prefetched);
1419
+ if (registered) {
1420
+ const nextAlias = result.action === "save" && result.routing.enabled ? result.routing.aliasSlug : undefined;
1421
+ if (previousAlias) {
1422
+ await refreshSelectedProfile(
1423
+ ctx,
1424
+ provider.name,
1425
+ profileModelId(config.id, previousAlias),
1426
+ nextAlias ? profileModelId(config.id, nextAlias) : config.id,
1427
+ );
1428
+ }
1429
+ updateThinkingProfileStatus(ctx);
1430
+ ctx.ui.notify(result.action === "save" ? "Adaptive routing saved." : "Adaptive routing removed.", "info");
1431
+ }
1432
+ continue;
1433
+ }
1434
+ if (action === "preview-routing" && routing) {
1435
+ await previewProfileRouting(ctx, config, routing, profiles, prefetched.serverType);
1436
+ continue;
1437
+ }
1438
+
1439
+ let existing = action.startsWith("profile:")
1440
+ ? profiles.find((profile) => profile.slug === action.slice("profile:".length))
1441
+ : undefined;
1442
+ let template: ModelProfile | undefined;
1443
+ if (action === "clone") {
1444
+ const sourceSlug = await choosePreset(ctx, "Clone which preset?", profiles, prefetched.serverType);
1445
+ const source = profiles.find((profile) => profile.slug === sourceSlug);
1446
+ if (!source) continue;
1447
+ const slug = await promptProfileSlug(ctx, `${source.slug}-copy`);
1448
+ if (!slug) continue;
1449
+ template = {
1450
+ ...source,
1451
+ slug,
1452
+ ...(source.chatTemplateKwargs ? { chatTemplateKwargs: { ...source.chatTemplateKwargs } } : {}),
1453
+ ...(source.sampling ? { sampling: { ...source.sampling } } : {}),
1454
+ };
1455
+ }
1456
+ if (action !== "add" && action !== "clone" && !existing) continue;
1457
+
1458
+ const result = await showProfileEditor(
1459
+ ctx,
1460
+ provider,
1461
+ config,
1462
+ allConfigs,
1463
+ prefetched.serverType,
1464
+ existing,
1465
+ template,
1466
+ );
1467
+ if (!result) continue;
1468
+ if (result.action === "save") {
1469
+ const currentRouting = getModelProfileRouting(provider, config.id);
1470
+ const nextRouting = currentRouting
1471
+ ? { ...currentRouting, levels: { ...currentRouting.levels } }
1472
+ : undefined;
1473
+ const prospectiveProfiles = profilesWithCandidate(provider, config.id, result.profile, existing?.slug);
1474
+ const wasValid = currentRouting
1475
+ ? analyzeExplicitProfileRouting(currentRouting, profiles).errors.length === 0
1476
+ : false;
1477
+ if (nextRouting && existing && existing.slug !== result.profile.slug) {
1478
+ nextRouting.levels = Object.fromEntries(
1479
+ Object.entries(nextRouting.levels).map(([level, slug]) => [
1480
+ level,
1481
+ slug === existing.slug ? result.profile.slug : slug,
1482
+ ]),
1483
+ ) as Record<ThinkingLevel, string>;
1484
+ }
1485
+ const willBeValid = nextRouting
1486
+ ? analyzeExplicitProfileRouting(nextRouting, prospectiveProfiles).errors.length === 0
1487
+ : false;
1488
+ if (nextRouting?.enabled && wasValid && !willBeValid) {
1489
+ const confirmed = await ctx.ui.confirm(
1490
+ "Routing will become invalid",
1491
+ "Save this preset anyway? The adaptive alias will not be registered until the mapping is repaired.",
1492
+ );
1493
+ if (!confirmed) continue;
1494
+ }
1495
+ saveModelProfile(provider, config.id, result.profile, existing?.slug);
1496
+ if (nextRouting) saveModelProfileRouting(provider, config.id, nextRouting);
1497
+ const registered = await persistProfileChange(ctx, provider, prefetched);
1498
+ if (registered) {
1499
+ if (existing && existing.exposeAsModel !== false) {
1500
+ await refreshSelectedProfile(
1501
+ ctx,
1502
+ provider.name,
1503
+ profileModelId(config.id, existing.slug),
1504
+ result.profile.exposeAsModel === false
1505
+ ? config.id
1506
+ : profileModelId(config.id, result.profile.slug),
1507
+ );
1508
+ }
1509
+ await refreshAdaptiveSelection(ctx, provider, config.id);
1510
+ updateThinkingProfileStatus(ctx);
1511
+ ctx.ui.notify(`${existing ? "Updated" : "Created"} preset "${result.profile.slug}".`, "info");
1512
+ }
1513
+ } else if (existing) {
1514
+ deleteModelProfile(provider, config.id, existing.slug);
1515
+ const registered = await persistProfileChange(ctx, provider, prefetched);
1516
+ if (registered) {
1517
+ await refreshSelectedProfile(
1518
+ ctx,
1519
+ provider.name,
1520
+ profileModelId(config.id, existing.slug),
1521
+ config.id,
1522
+ );
1523
+ await refreshAdaptiveSelection(ctx, provider, config.id);
1524
+ updateThinkingProfileStatus(ctx);
1525
+ ctx.ui.notify(`Deleted preset "${existing.slug}".`, "info");
1526
+ }
1527
+ }
1528
+ }
1529
+ }
1530
+
410
1531
  // -----------------------------------------------------------------------
411
1532
  // Screen: model detail / edit
412
1533
  // -----------------------------------------------------------------------
@@ -415,6 +1536,8 @@ export default async function (pi: ExtensionAPI) {
415
1536
  ctx: ExtensionCommandContext,
416
1537
  provider: DiscoveredProvider,
417
1538
  config: ModelConfig,
1539
+ allConfigs: ModelConfig[],
1540
+ prefetched: { models: Record<string, unknown>[]; serverType: string },
418
1541
  ): Promise<void> {
419
1542
  for (;;) {
420
1543
  const ov = provider.modelOverrides?.[config.id] ?? {};
@@ -432,6 +1555,18 @@ export default async function (pi: ExtensionAPI) {
432
1555
  } · input ${effInput.join("+")}`,
433
1556
  ];
434
1557
 
1558
+ const configuredProfiles = getModelProfiles(provider, config.id);
1559
+ const configuredRouting = getModelProfileRouting(provider, config.id);
1560
+ const routingAnalysis = configuredRouting
1561
+ ? analyzeExplicitProfileRouting(configuredRouting, configuredProfiles)
1562
+ : undefined;
1563
+ const routingStatus = !configuredRouting
1564
+ ? "not configured"
1565
+ : routingAnalysis?.errors.length
1566
+ ? `invalid (${routingAnalysis.errors.length} issue(s))`
1567
+ : configuredRouting.enabled
1568
+ ? `enabled as @${configuredRouting.aliasSlug}`
1569
+ : "disabled";
435
1570
  const items: SelectItem[] = [
436
1571
  { value: "ctx", label: "Set context window", description: `current: ${fmt(effCtx)}` },
437
1572
  { value: "max", label: "Set max output tokens", description: `current: ${fmt(effMax)}` },
@@ -440,6 +1575,16 @@ export default async function (pi: ExtensionAPI) {
440
1575
  label: "Toggle reasoning",
441
1576
  description: `current: ${effReasoning === null ? "unknown" : effReasoning ? "on" : "off"}`,
442
1577
  },
1578
+ {
1579
+ value: "input",
1580
+ label: "Toggle vision (image input)",
1581
+ description: `current: ${effInput.includes("image") ? "vision on" : "text only"}`,
1582
+ },
1583
+ {
1584
+ value: "profiles",
1585
+ label: "Thinking & presets",
1586
+ description: `${configuredProfiles.length} preset(s) · adaptive routing ${routingStatus}`,
1587
+ },
443
1588
  ];
444
1589
  if (Object.keys(ov).length > 0) {
445
1590
  items.push({ value: "clear", label: "Clear overrides", description: "revert to server-reported values" });
@@ -449,6 +1594,11 @@ export default async function (pi: ExtensionAPI) {
449
1594
  const action = await runSelect(ctx, `Model: ${config.id}${modelFlags(config, ov)}`, items, header);
450
1595
  if (!action || action === "back") return;
451
1596
 
1597
+ if (action === "profiles") {
1598
+ await showProfilesScreen(ctx, provider, config, allConfigs, prefetched);
1599
+ continue;
1600
+ }
1601
+
452
1602
  if (action === "ctx") {
453
1603
  const n = await askNumber(ctx, `Context window for ${config.id}`, String(effCtx ?? 128000));
454
1604
  if (n !== undefined) {
@@ -464,6 +1614,16 @@ export default async function (pi: ExtensionAPI) {
464
1614
  ...provider.modelOverrides,
465
1615
  [config.id]: { ...ov, reasoning: !(effReasoning ?? false) },
466
1616
  };
1617
+ } else if (action === "input") {
1618
+ // Toggle vision: add/remove "image" from input modalities
1619
+ const hasVision = effInput.includes("image");
1620
+ const newInput = hasVision
1621
+ ? ["text"]
1622
+ : [...new Set([...effInput, "image"])]; // ensure both text and image
1623
+ provider.modelOverrides = {
1624
+ ...provider.modelOverrides,
1625
+ [config.id]: { ...ov, input: newInput },
1626
+ };
467
1627
  } else if (action === "clear") {
468
1628
  if (provider.modelOverrides) {
469
1629
  delete provider.modelOverrides[config.id];
@@ -474,7 +1634,7 @@ export default async function (pi: ExtensionAPI) {
474
1634
  // Persist + re-register with new values
475
1635
  upsertProvider(provider);
476
1636
  try {
477
- await registerProvider(provider);
1637
+ await registerProvider(provider, prefetched);
478
1638
  } catch {
479
1639
  /* endpoint may be down; overrides still saved */
480
1640
  }
@@ -487,21 +1647,41 @@ export default async function (pi: ExtensionAPI) {
487
1647
 
488
1648
  async function showEndpointScreen(ctx: ExtensionCommandContext, provider: DiscoveredProvider): Promise<void> {
489
1649
  // Fetch live data
490
- let live = await runLoader(ctx, `Scanning ${provider.baseUrl}...`, (signal) =>
491
- fetchModels(provider.baseUrl, provider.apiKey, signal),
1650
+ let live = await runLoader(
1651
+ ctx,
1652
+ `Scanning ${provider.baseUrl}...`,
1653
+ (signal) => fetchModels(provider.baseUrl, provider.apiKey, signal),
1654
+ (error) => recordFailedScan(provider, error),
492
1655
  );
1656
+ if (live && live.models.length === 0) {
1657
+ const error = new Error("Endpoint reported no models; retaining the last known-good catalogue.");
1658
+ recordFailedScan(provider, error);
1659
+ ctx.ui.notify(error.message, "warning");
1660
+ live = null;
1661
+ } else if (live) {
1662
+ recordSuccessfulScan(provider, live.models, live.serverType);
1663
+ }
493
1664
 
494
1665
  for (;;) {
495
1666
  const header: string[] = [];
1667
+ const cachedModels = provider.cachedModels ?? [];
496
1668
  let configs: ModelConfig[] = [];
497
1669
  if (live) {
498
1670
  configs = live.models.map(extractModelConfig);
499
1671
  header.push(`${live.serverType} · ${provider.baseUrl} · online · ${configs.length} model(s)`);
500
1672
  } else {
501
- header.push(`${provider.serverType ?? "?"} · ${provider.baseUrl} · OFFLINE (showing saved config)`);
1673
+ configs = cachedModels.map(extractModelConfig);
1674
+ header.push(
1675
+ `${provider.serverType ?? "?"} · ${provider.baseUrl} · OFFLINE · ${configs.length} cached model(s)`,
1676
+ );
502
1677
  }
1678
+ header.push(`authentication: ${provider.apiKey ? "API key configured" : "anonymous"}`);
503
1679
  if (provider.lastScanned) {
504
- header.push(`last scan: ${new Date(provider.lastScanned).toLocaleString()}`);
1680
+ header.push(`last successful scan: ${new Date(provider.lastScanned).toLocaleString()}`);
1681
+ }
1682
+ if (!live && provider.lastScanError) {
1683
+ header.push(`latest live scan failed: ${provider.lastScanError}`);
1684
+ header.push("Last known-good models and all saved presets remain available.");
505
1685
  }
506
1686
 
507
1687
  const items: SelectItem[] = configs.map((c) => ({
@@ -510,6 +1690,16 @@ export default async function (pi: ExtensionAPI) {
510
1690
  description: modelDescription(c, provider),
511
1691
  }));
512
1692
  items.push({ value: "rescan", label: "⟳ Re-scan endpoint", description: "fetch fresh model list and re-register" });
1693
+ items.push({
1694
+ value: "rename",
1695
+ label: "✎ Rename source",
1696
+ description: `current: ${provider.name}`,
1697
+ });
1698
+ items.push({
1699
+ value: "auth",
1700
+ label: "🔑 Authentication",
1701
+ description: provider.apiKey ? "API key configured · replace or clear" : "anonymous · add an API key",
1702
+ });
513
1703
  items.push({
514
1704
  value: "defaults",
515
1705
  label: "✎ Edit fallback defaults",
@@ -524,37 +1714,161 @@ export default async function (pi: ExtensionAPI) {
524
1714
  if (action.startsWith("model:")) {
525
1715
  const id = action.slice("model:".length);
526
1716
  const config = configs.find((c) => c.id === id);
527
- if (config) await showModelScreen(ctx, provider, config);
1717
+ const catalog = live ?? {
1718
+ models: cachedModels,
1719
+ serverType: provider.serverType ?? "OpenAI-compatible",
1720
+ };
1721
+ if (config) await showModelScreen(ctx, provider, config, configs, catalog);
528
1722
  } else if (action === "rescan") {
529
- live = await runLoader(ctx, `Scanning ${provider.baseUrl}...`, (signal) =>
530
- fetchModels(provider.baseUrl, provider.apiKey, signal),
1723
+ live = await runLoader(
1724
+ ctx,
1725
+ `Scanning ${provider.baseUrl}...`,
1726
+ (signal) => fetchModels(provider.baseUrl, provider.apiKey, signal),
1727
+ (error) => recordFailedScan(provider, error),
531
1728
  );
532
1729
  if (live) {
533
1730
  try {
534
- await registerProvider(provider, live);
1731
+ const registered = await registerProvider(provider, live);
1732
+ recordSuccessfulScan(provider, live.models, live.serverType, false);
535
1733
  upsertProvider(provider);
536
- ctx.ui.notify(`Re-registered ${live.models.length} model(s) from ${live.serverType}.`, "success");
1734
+ ctx.ui.notify(
1735
+ `Re-registered ${live.models.length} base model(s)${
1736
+ registered.profileCount ? ` + ${registered.profileCount} profile(s)` : ""
1737
+ } from ${live.serverType}.`,
1738
+ "info",
1739
+ );
537
1740
  } catch (err) {
538
- ctx.ui.notify(`${err instanceof Error ? err.message : String(err)}`, "error");
1741
+ recordFailedScan(provider, err);
1742
+ live = null;
1743
+ ctx.ui.notify(errorMessage(err), "error");
1744
+ }
1745
+ }
1746
+ } else if (action === "rename") {
1747
+ const newName = (await ctx.ui.input("New source name", provider.name))?.trim();
1748
+ if (newName && newName !== provider.name) {
1749
+ const oldName = provider.name;
1750
+ if (!renameProvider(oldName, newName)) {
1751
+ ctx.ui.notify(`Cannot rename — "${newName}" already exists or "${oldName}" not found.`, "error");
1752
+ } else {
1753
+ provider.name = newName;
1754
+ const catalog = live ??
1755
+ (provider.cachedModels?.length
1756
+ ? {
1757
+ models: provider.cachedModels,
1758
+ serverType: provider.serverType ?? "OpenAI-compatible",
1759
+ }
1760
+ : undefined);
1761
+ try {
1762
+ await registerProvider(provider, catalog);
1763
+ pi.unregisterProvider(oldName);
1764
+ upsertProvider(provider);
1765
+ ctx.ui.notify(`Renamed to "${newName}"${live ? "" : " using the cached catalogue"}.`, "info");
1766
+ } catch (error) {
1767
+ renameProvider(newName, oldName);
1768
+ provider.name = oldName;
1769
+ ctx.ui.notify(`Rename rolled back: ${errorMessage(error)}`, "error");
1770
+ }
539
1771
  }
540
1772
  }
1773
+ } else if (action === "auth") {
1774
+ const authAction = await runSelect(
1775
+ ctx,
1776
+ "Provider authentication",
1777
+ [
1778
+ {
1779
+ value: "set",
1780
+ label: provider.apiKey ? "Replace API key" : "Set API key",
1781
+ description: "masked while typing · used for discovery and inference",
1782
+ },
1783
+ ...(provider.apiKey
1784
+ ? [{ value: "clear", label: "Clear API key", description: "remove the saved bearer credential" }]
1785
+ : []),
1786
+ { value: "back", label: "← Back" },
1787
+ ],
1788
+ [provider.baseUrl, `current: ${provider.apiKey ? "API key configured" : "anonymous"}`],
1789
+ );
1790
+ if (!authAction || authAction === "back") continue;
1791
+
1792
+ let nextApiKey: string | undefined;
1793
+ if (authAction === "set") {
1794
+ const entered = await askSecret(ctx, "API key", "Paste or type the replacement key. It will not be displayed.");
1795
+ if (entered === undefined) continue;
1796
+ nextApiKey = entered.trim();
1797
+ if (!nextApiKey) {
1798
+ ctx.ui.notify("API key cannot be blank. Use Clear API key for anonymous access.", "warning");
1799
+ continue;
1800
+ }
1801
+ } else {
1802
+ const confirmed = await ctx.ui.confirm(
1803
+ "Clear API key",
1804
+ "Remove the saved bearer credential from this provider?",
1805
+ );
1806
+ if (!confirmed) continue;
1807
+ }
1808
+
1809
+ provider.apiKey = nextApiKey;
1810
+ upsertProvider(provider);
1811
+ const checked = await runLoader(
1812
+ ctx,
1813
+ `Validating ${provider.name} authentication...`,
1814
+ (signal) => fetchModels(provider.baseUrl, provider.apiKey, signal),
1815
+ (error) => recordFailedScan(provider, error),
1816
+ );
1817
+ if (checked?.models.length) {
1818
+ try {
1819
+ await registerProvider(provider, checked);
1820
+ recordSuccessfulScan(provider, checked.models, checked.serverType, false);
1821
+ upsertProvider(provider);
1822
+ live = checked;
1823
+ ctx.ui.notify(`Authentication saved and validated for ${provider.name}.`, "info");
1824
+ } catch (error) {
1825
+ recordFailedScan(provider, error);
1826
+ live = null;
1827
+ ctx.ui.notify(`Authentication saved, but registration failed: ${errorMessage(error)}`, "warning");
1828
+ }
1829
+ } else {
1830
+ if (checked) {
1831
+ const error = new Error("Endpoint reported no models while validating authentication.");
1832
+ recordFailedScan(provider, error);
1833
+ ctx.ui.notify(error.message, "warning");
1834
+ }
1835
+ live = null;
1836
+ if (provider.cachedModels?.length) {
1837
+ try {
1838
+ await registerProvider(provider, {
1839
+ models: provider.cachedModels,
1840
+ serverType: provider.serverType ?? "OpenAI-compatible",
1841
+ });
1842
+ } catch (error) {
1843
+ ctx.ui.notify(`Authentication was saved, but cached registration failed: ${errorMessage(error)}`, "warning");
1844
+ }
1845
+ }
1846
+ ctx.ui.notify("Authentication saved but could not be validated; the last known-good catalogue was retained.", "warning");
1847
+ }
541
1848
  } else if (action === "defaults") {
542
1849
  const cw = await askNumber(ctx, "Default context window (blank = keep)", String(provider.defaultContextWindow ?? 128000));
543
1850
  if (cw !== undefined) provider.defaultContextWindow = cw;
544
1851
  const mt = await askNumber(ctx, "Default max output tokens (blank = keep)", String(provider.defaultMaxTokens ?? 16384));
545
1852
  if (mt !== undefined) provider.defaultMaxTokens = mt;
546
1853
  upsertProvider(provider);
1854
+ const catalog = live ??
1855
+ (provider.cachedModels?.length
1856
+ ? {
1857
+ models: provider.cachedModels,
1858
+ serverType: provider.serverType ?? "OpenAI-compatible",
1859
+ }
1860
+ : undefined);
547
1861
  try {
548
- await registerProvider(provider, live ?? undefined);
549
- } catch {
550
- /* offline */
1862
+ await registerProvider(provider, catalog);
1863
+ } catch (error) {
1864
+ ctx.ui.notify(`Defaults saved; provider remains on its last registered catalogue: ${errorMessage(error)}`, "warning");
551
1865
  }
552
1866
  } else if (action === "remove") {
553
1867
  const sure = await ctx.ui.confirm("Remove endpoint", `Remove "${provider.name}" (${provider.baseUrl})?`);
554
1868
  if (sure) {
555
1869
  pi.unregisterProvider(provider.name);
556
1870
  deleteProvider(provider.name);
557
- ctx.ui.notify(`Removed "${provider.name}".`, "success");
1871
+ ctx.ui.notify(`Removed "${provider.name}".`, "info");
558
1872
  return;
559
1873
  }
560
1874
  }
@@ -571,17 +1885,51 @@ export default async function (pi: ExtensionAPI) {
571
1885
  if (!baseUrl.startsWith("http")) baseUrl = `http://${baseUrl}`;
572
1886
  baseUrl = baseUrl.replace(/\/+$/, "");
573
1887
 
574
- // Probe endpoint first — availability check
575
- let live = await runLoader(ctx, `Probing ${baseUrl}...`, (signal) => fetchModels(baseUrl, undefined, signal));
1888
+ const authMode = await runSelect(
1889
+ ctx,
1890
+ "Endpoint authentication",
1891
+ [
1892
+ { value: "none", label: "No API key", description: "connect without a configured bearer credential" },
1893
+ {
1894
+ value: "api-key",
1895
+ label: "Enter API key",
1896
+ description: "masked while typing · saved only in the private model-discovery config",
1897
+ },
1898
+ { value: "cancel", label: "← Cancel" },
1899
+ ],
1900
+ [baseUrl, "The key is sent as an Authorization: Bearer header for discovery and inference."],
1901
+ );
1902
+ if (!authMode || authMode === "cancel") return;
576
1903
 
577
- // If unauthorized or failed, offer API key
578
1904
  let apiKey: string | undefined;
579
- if (!live) {
580
- const retry = await ctx.ui.confirm("Endpoint unreachable or refused", "Try again with an API key?");
1905
+ if (authMode === "api-key") {
1906
+ const entered = await askSecret(ctx, "API key", "Paste or type the provider key. It will not be displayed.");
1907
+ if (entered === undefined) return;
1908
+ apiKey = entered.trim();
1909
+ if (!apiKey) {
1910
+ ctx.ui.notify("API key cannot be blank. Choose No API key for anonymous access.", "warning");
1911
+ return;
1912
+ }
1913
+ }
1914
+
1915
+ let live: { models: Record<string, unknown>[]; serverType: string } | null = null;
1916
+ for (;;) {
1917
+ live = await runLoader(ctx, `Probing ${baseUrl}${apiKey ? " with API key" : ""}...`, (signal) =>
1918
+ fetchModels(baseUrl, apiKey, signal),
1919
+ );
1920
+ if (live) break;
1921
+ const retry = await ctx.ui.confirm(
1922
+ "Provider probe failed",
1923
+ apiKey ? "Enter a replacement API key and retry?" : "Enter an API key and retry?",
1924
+ );
581
1925
  if (!retry) return;
582
- apiKey = (await ctx.ui.input("API key", ""))?.trim() || undefined;
583
- live = await runLoader(ctx, `Probing ${baseUrl} with key...`, (signal) => fetchModels(baseUrl, apiKey, signal));
584
- if (!live) return;
1926
+ const entered = await askSecret(ctx, "API key", "Paste or type the provider key. It will not be displayed.");
1927
+ if (entered === undefined) return;
1928
+ apiKey = entered.trim();
1929
+ if (!apiKey) {
1930
+ ctx.ui.notify("API key cannot be blank.", "warning");
1931
+ return;
1932
+ }
585
1933
  }
586
1934
 
587
1935
  if (live.models.length === 0) {
@@ -595,7 +1943,10 @@ export default async function (pi: ExtensionAPI) {
595
1943
  const configs = live.models.map(extractModelConfig);
596
1944
 
597
1945
  // Review screen: show exactly what the server reports
598
- const header = [`${live.serverType} · ${baseUrl} · online · ${configs.length} model(s)`];
1946
+ const header = [
1947
+ `${live.serverType} · ${baseUrl} · online · ${configs.length} model(s)`,
1948
+ `authentication: ${apiKey ? "API key configured" : "anonymous"}`,
1949
+ ];
599
1950
  const missing = configs.filter((c) => c.contextWindow === null || c.maxTokens === null || c.reasoning === null);
600
1951
  if (missing.length > 0) {
601
1952
  header.push(`${missing.length} model(s) have values the server didn't report (shown as ?)`);
@@ -648,15 +1999,13 @@ export default async function (pi: ExtensionAPI) {
648
1999
  provider.defaultMaxTokens = await askNumber(ctx, "Fallback max output tokens for unreported models", "16384");
649
2000
  }
650
2001
 
651
- const existing = loadProviders().find((p) => p.name === name);
652
- if (existing) pi.unregisterProvider(name);
653
-
654
2002
  try {
655
2003
  await registerProvider(provider, live);
2004
+ recordSuccessfulScan(provider, live.models, live.serverType, false);
656
2005
  upsertProvider(provider);
657
2006
  ctx.ui.notify(
658
2007
  `Registered ${configs.length} model(s) from ${live.serverType} as "${name}". Use /model to select.`,
659
- "success",
2008
+ "info",
660
2009
  );
661
2010
  } catch (err) {
662
2011
  ctx.ui.notify(`Failed to register: ${err instanceof Error ? err.message : String(err)}`, "error");
@@ -676,8 +2025,10 @@ export default async function (pi: ExtensionAPI) {
676
2025
  const items: SelectItem[] = providers.map((p) => ({
677
2026
  value: `provider:${p.name}`,
678
2027
  label: p.name,
679
- description: `${p.serverType ?? "?"} · ${p.baseUrl} · scanned ${
680
- p.lastScanned ? new Date(p.lastScanned).toLocaleString() : "never"
2028
+ description: `${p.serverType ?? "?"} · ${p.baseUrl} · ${
2029
+ p.lastScanError
2030
+ ? `${p.cachedModels?.length ? "cached" : "unavailable"} after failed live scan`
2031
+ : `live scan ${p.lastScanned ? new Date(p.lastScanned).toLocaleString() : "never completed"}`
681
2032
  }`,
682
2033
  }));
683
2034
  items.push({ value: "add", label: "+ Add endpoint", description: "discover models from an OpenAI-compatible server" });
@@ -695,23 +2046,42 @@ export default async function (pi: ExtensionAPI) {
695
2046
  await showAddScreen(ctx);
696
2047
  } else if (action === "rescan-all") {
697
2048
  const results = await runLoader(ctx, "Re-scanning all endpoints...", async () => {
698
- let ok = 0;
699
- let fail = 0;
2049
+ let live = 0;
2050
+ let cached = 0;
2051
+ let failed = 0;
700
2052
  for (const provider of loadProviders()) {
701
2053
  try {
702
- pi.unregisterProvider(provider.name);
703
- await registerProvider(provider);
2054
+ const registered = await registerProvider(provider);
2055
+ recordSuccessfulScan(provider, registered.rawModels, registered.serverType, false);
704
2056
  upsertProvider(provider);
705
- ok++;
706
- } catch {
707
- fail++;
2057
+ live++;
2058
+ } catch (error) {
2059
+ recordFailedScan(provider, error, false);
2060
+ if (provider.cachedModels?.length) {
2061
+ try {
2062
+ await registerProvider(provider, {
2063
+ models: provider.cachedModels,
2064
+ serverType: provider.serverType ?? "OpenAI-compatible",
2065
+ });
2066
+ upsertProvider(provider);
2067
+ cached++;
2068
+ continue;
2069
+ } catch {
2070
+ /* report below without removing the previously registered provider */
2071
+ }
2072
+ }
2073
+ upsertProvider(provider);
2074
+ failed++;
708
2075
  }
709
2076
  }
710
- return { ok, fail };
2077
+ return { live, cached, failed };
711
2078
  });
712
2079
  if (results) {
713
- const level = results.fail === 0 ? "success" : "warning";
714
- ctx.ui.notify(`Re-scanned ${results.ok} endpoint(s)${results.fail ? `, ${results.fail} failed` : ""}.`, level);
2080
+ const level = results.failed === 0 && results.cached === 0 ? "info" : "warning";
2081
+ ctx.ui.notify(
2082
+ `Re-scan complete: ${results.live} live${results.cached ? `, ${results.cached} kept on cached catalogues` : ""}${results.failed ? `, ${results.failed} unavailable without cache` : ""}.`,
2083
+ level,
2084
+ );
715
2085
  }
716
2086
  } else if (action.startsWith("provider:")) {
717
2087
  const name = action.slice("provider:".length);
@@ -745,16 +2115,24 @@ export default async function (pi: ExtensionAPI) {
745
2115
  // Tool: discover_models (LLM-callable)
746
2116
  // -----------------------------------------------------------------------
747
2117
 
748
- pi.registerTool({
2118
+ const discoverModelsParameters = Type.Object({
2119
+ url: Type.String({ description: "Base URL of the OpenAI-compatible endpoint (e.g., http://localhost:8080)" }),
2120
+ providerName: Type.Optional(Type.String({ description: "Name for the provider (auto-generated if omitted)" })),
2121
+ apiKey: Type.Optional(Type.String({ description: "API key if required" })),
2122
+ });
2123
+ type DiscoverModelsDetails = {
2124
+ providerName?: string;
2125
+ serverType?: string;
2126
+ modelCount?: number;
2127
+ profileCount?: number;
2128
+ };
2129
+
2130
+ pi.registerTool<typeof discoverModelsParameters, DiscoverModelsDetails>({
749
2131
  name: "discover_models",
750
2132
  label: "Discover Models",
751
2133
  description:
752
2134
  "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.",
753
- parameters: Type.Object({
754
- url: Type.String({ description: "Base URL of the OpenAI-compatible endpoint (e.g., http://localhost:8080)" }),
755
- providerName: Type.Optional(Type.String({ description: "Name for the provider (auto-generated if omitted)" })),
756
- apiKey: Type.Optional(Type.String({ description: "API key if required" })),
757
- }),
2135
+ parameters: discoverModelsParameters,
758
2136
  async execute(_toolCallId, params) {
759
2137
  let { url, providerName, apiKey } = params;
760
2138
  if (!url.startsWith("http")) url = `http://${url}`;
@@ -781,10 +2159,9 @@ export default async function (pi: ExtensionAPI) {
781
2159
  const provider: DiscoveredProvider = existing
782
2160
  ? { ...existing, baseUrl: url, apiKey: apiKey ?? existing.apiKey }
783
2161
  : { name: providerName, baseUrl: url, apiKey };
784
- if (existing) pi.unregisterProvider(providerName);
785
-
786
2162
  try {
787
- const { models: configs } = await registerProvider(provider, live);
2163
+ const { models: configs, profileCount } = await registerProvider(provider, live);
2164
+ recordSuccessfulScan(provider, live.models, live.serverType, false);
788
2165
  upsertProvider(provider);
789
2166
 
790
2167
  const lines = configs.map(
@@ -801,10 +2178,12 @@ export default async function (pi: ExtensionAPI) {
801
2178
  content: [
802
2179
  {
803
2180
  type: "text",
804
- text: `Endpoint online (${live.serverType}). Registered ${configs.length} model(s) as "${providerName}":\n${lines.join("\n")}${note}\n\nModels are now selectable via /model.`,
2181
+ text: `Endpoint online (${live.serverType}). Registered ${configs.length} base model(s)${
2182
+ profileCount ? ` + ${profileCount} profile(s)` : ""
2183
+ } as "${providerName}":\n${lines.join("\n")}${note}\n\nModels are now selectable via /model.`,
805
2184
  },
806
2185
  ],
807
- details: { providerName, serverType: live.serverType, modelCount: configs.length },
2186
+ details: { providerName, serverType: live.serverType, modelCount: configs.length, profileCount },
808
2187
  };
809
2188
  } catch (err) {
810
2189
  return {