@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.
- package/README.md +197 -22
- package/index.ts +1465 -86
- package/package.json +9 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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"))
|
|
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 (!
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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\/?$/,
|
|
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
|
-
|
|
295
|
-
models: piModels,
|
|
567
|
+
models: expandedRouters.models,
|
|
296
568
|
});
|
|
297
569
|
|
|
298
570
|
provider.serverType = serverType;
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
607
|
+
results.forEach((result, index) => {
|
|
313
608
|
if (result.status === "rejected") {
|
|
314
|
-
console.error(
|
|
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(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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(
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
530
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
549
|
-
} catch {
|
|
550
|
-
|
|
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}".`, "
|
|
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
|
-
|
|
575
|
-
|
|
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 (
|
|
580
|
-
const
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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 = [
|
|
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
|
-
"
|
|
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} ·
|
|
680
|
-
p.
|
|
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
|
|
699
|
-
let
|
|
2049
|
+
let live = 0;
|
|
2050
|
+
let cached = 0;
|
|
2051
|
+
let failed = 0;
|
|
700
2052
|
for (const provider of loadProviders()) {
|
|
701
2053
|
try {
|
|
702
|
-
|
|
703
|
-
|
|
2054
|
+
const registered = await registerProvider(provider);
|
|
2055
|
+
recordSuccessfulScan(provider, registered.rawModels, registered.serverType, false);
|
|
704
2056
|
upsertProvider(provider);
|
|
705
|
-
|
|
706
|
-
} catch {
|
|
707
|
-
|
|
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 {
|
|
2077
|
+
return { live, cached, failed };
|
|
711
2078
|
});
|
|
712
2079
|
if (results) {
|
|
713
|
-
const level = results.
|
|
714
|
-
ctx.ui.notify(
|
|
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
|
-
|
|
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:
|
|
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)
|
|
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 {
|