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