@maheidem/model-discovery 0.1.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/README.md +60 -0
- package/index.ts +818 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @maheidem/model-discovery
|
|
2
|
+
|
|
3
|
+
Interactive TUI for discovering and managing local AI model endpoints. Works with llama.cpp, oMLX, Ollama, vLLM, SGLang, LM Studio, and any OpenAI-compatible server.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Auto-detect** server type from headers and model data (no manual config)
|
|
8
|
+
- **Read real server-reported configs** — context window, max tokens, reasoning flags, input modalities
|
|
9
|
+
- **Fine-tune per-model overrides** — set context window, max tokens, reasoning toggles
|
|
10
|
+
- **Multi-endpoint management** — add, scan, and register multiple local servers
|
|
11
|
+
- **LLM-callable tool** — the `discover_models` tool lets the agent discover endpoints on your behalf
|
|
12
|
+
- **Persistent storage** — providers saved across sessions in `~/.pi/agent/model-discovery.json`
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Via npm
|
|
18
|
+
pi install npm:@maheidem/model-discovery
|
|
19
|
+
|
|
20
|
+
# Via git
|
|
21
|
+
pi install git:github.com/Maheidem/model-discovery@v0.1.0
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Interactive UI
|
|
27
|
+
|
|
28
|
+
Run `/discover` in pi to open the management TUI:
|
|
29
|
+
|
|
30
|
+
- **Add endpoint** — enter a URL, probe it, review models, register
|
|
31
|
+
- **Scan existing** — re-scan registered endpoints for fresh model lists
|
|
32
|
+
- **Edit per-model** — override context window, max tokens, reasoning flags
|
|
33
|
+
- **Remove** — unregister and delete saved endpoints
|
|
34
|
+
|
|
35
|
+
### With arguments
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
/discover http://192.168.1.100:8080 # jump straight to adding this endpoint
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### LLM Tool
|
|
42
|
+
|
|
43
|
+
The `discover_models` tool can be called by the agent:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
discover_models(url="http://localhost:8080", providerName="my-llama", apiKey="optional-key")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Storage
|
|
50
|
+
|
|
51
|
+
Discovered providers are persisted in `~/.pi/agent/model-discovery.json`.
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- pi coding agent with TUI support
|
|
56
|
+
- Access to OpenAI-compatible model servers on your network
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Discovery Extension
|
|
3
|
+
*
|
|
4
|
+
* Single entry point: /discover — opens a full TUI to manage OpenAI-compatible
|
|
5
|
+
* endpoints (llama.cpp, oMLX, Ollama, vLLM, ...). Reads the actual
|
|
6
|
+
* server-reported configuration (no hardcoded model database) and asks for
|
|
7
|
+
* anything the server doesn't provide.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* /discover # open the management UI
|
|
11
|
+
* /discover http://ip:port # jump straight into adding that endpoint
|
|
12
|
+
*
|
|
13
|
+
* The LLM can also call the `discover_models` tool.
|
|
14
|
+
* Discovered providers persist across sessions in ~/.pi/agent/model-discovery.json
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { BorderedLoader, DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface ModelOverride {
|
|
30
|
+
contextWindow?: number;
|
|
31
|
+
maxTokens?: number;
|
|
32
|
+
reasoning?: boolean;
|
|
33
|
+
input?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface DiscoveredProvider {
|
|
37
|
+
name: string;
|
|
38
|
+
baseUrl: string;
|
|
39
|
+
apiKey?: string;
|
|
40
|
+
serverType?: string;
|
|
41
|
+
defaultContextWindow?: number;
|
|
42
|
+
defaultMaxTokens?: number;
|
|
43
|
+
modelOverrides?: Record<string, ModelOverride>;
|
|
44
|
+
compat?: Record<string, unknown>;
|
|
45
|
+
lastScanned?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ModelConfig {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
contextWindow: number | null;
|
|
52
|
+
maxTokens: number | null;
|
|
53
|
+
reasoning: boolean | null;
|
|
54
|
+
input: string[] | null;
|
|
55
|
+
source: string;
|
|
56
|
+
loaded?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Storage
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const STORAGE_PATH = join(os.homedir(), ".pi", "agent", "model-discovery.json");
|
|
64
|
+
|
|
65
|
+
function loadProviders(): DiscoveredProvider[] {
|
|
66
|
+
try {
|
|
67
|
+
if (existsSync(STORAGE_PATH)) {
|
|
68
|
+
return JSON.parse(readFileSync(STORAGE_PATH, "utf-8")) as DiscoveredProvider[];
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
/* ignore */
|
|
72
|
+
}
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function saveProviders(providers: DiscoveredProvider[]): void {
|
|
77
|
+
writeFileSync(STORAGE_PATH, JSON.stringify(providers, null, 2), "utf-8");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function upsertProvider(provider: DiscoveredProvider): void {
|
|
81
|
+
const all = loadProviders();
|
|
82
|
+
const idx = all.findIndex((p) => p.name === provider.name);
|
|
83
|
+
if (idx >= 0) all[idx] = provider;
|
|
84
|
+
else all.push(provider);
|
|
85
|
+
saveProviders(all);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function deleteProvider(name: string): void {
|
|
89
|
+
saveProviders(loadProviders().filter((p) => p.name !== name));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Server detection & model config extraction (reads real server data)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function detectServerType(headers: Headers, models: Record<string, unknown>[]): string {
|
|
97
|
+
const server = (headers.get("server") ?? "").toLowerCase();
|
|
98
|
+
const poweredBy = (headers.get("x-powered-by") ?? "").toLowerCase();
|
|
99
|
+
|
|
100
|
+
if (server.includes("llama-cpp") || server.includes("llama.cpp")) return "llama.cpp";
|
|
101
|
+
if (server.includes("ollama")) return "Ollama";
|
|
102
|
+
if (server.includes("vllm")) return "vLLM";
|
|
103
|
+
if (server.includes("sglang")) return "SGLang";
|
|
104
|
+
if (server.includes("lm-studio")) return "LM Studio";
|
|
105
|
+
if (server.includes("omlx") || poweredBy.includes("omlx")) return "oMLX";
|
|
106
|
+
|
|
107
|
+
for (const m of models) {
|
|
108
|
+
const ownedBy = String(m.owned_by ?? "").toLowerCase();
|
|
109
|
+
if (ownedBy === "omlx") return "oMLX";
|
|
110
|
+
if (ownedBy === "vllm") return "vLLM";
|
|
111
|
+
if (ownedBy === "llamacpp") return "llama.cpp";
|
|
112
|
+
}
|
|
113
|
+
for (const m of models) {
|
|
114
|
+
if (String(m.id ?? "").includes(":")) return "Ollama";
|
|
115
|
+
}
|
|
116
|
+
return "OpenAI-compatible";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function tryNum(v: unknown): number | null {
|
|
120
|
+
if (typeof v === "number" && !isNaN(v)) return v;
|
|
121
|
+
if (typeof v === "string") {
|
|
122
|
+
const n = parseInt(v, 10);
|
|
123
|
+
return isNaN(n) ? null : n;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseArgValue(args: string[] | undefined, flag: string): number | null {
|
|
129
|
+
if (!args) return null;
|
|
130
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
131
|
+
if (args[i] === flag) {
|
|
132
|
+
const n = parseInt(args[i + 1], 10);
|
|
133
|
+
return isNaN(n) ? null : n;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parsePresetValue(preset: string | undefined, key: string): number | null {
|
|
140
|
+
if (!preset) return null;
|
|
141
|
+
const m = preset.match(new RegExp(`${key}\\s*=\\s*(\\d+)`, "i"));
|
|
142
|
+
if (m) {
|
|
143
|
+
const n = parseInt(m[1], 10);
|
|
144
|
+
return isNaN(n) ? null : n;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extract model config from whatever the server actually reports.
|
|
151
|
+
* Returns null for any field the server doesn't provide.
|
|
152
|
+
*/
|
|
153
|
+
function extractModelConfig(raw: Record<string, unknown>): ModelConfig {
|
|
154
|
+
const id = String(raw.id ?? "");
|
|
155
|
+
const name = String(raw.name ?? id);
|
|
156
|
+
const status = (raw.status && typeof raw.status === "object" ? raw.status : undefined) as
|
|
157
|
+
| Record<string, unknown>
|
|
158
|
+
| undefined;
|
|
159
|
+
const args = status?.args as string[] | undefined;
|
|
160
|
+
const preset = status?.preset as string | undefined;
|
|
161
|
+
|
|
162
|
+
// Context window: standard fields, then llama.cpp args/preset, then loaded meta
|
|
163
|
+
let contextWindow =
|
|
164
|
+
tryNum(raw.context_length) ??
|
|
165
|
+
tryNum(raw.context_window) ??
|
|
166
|
+
tryNum(raw.max_model_len) ??
|
|
167
|
+
tryNum(raw.max_context_len) ??
|
|
168
|
+
tryNum(raw.max_context_length) ??
|
|
169
|
+
parseArgValue(args, "--ctx-size") ??
|
|
170
|
+
parsePresetValue(preset, "ctx-size");
|
|
171
|
+
if (contextWindow === null && raw.meta && typeof raw.meta === "object") {
|
|
172
|
+
contextWindow = tryNum((raw.meta as Record<string, unknown>).n_ctx);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Max output tokens
|
|
176
|
+
const maxTokens =
|
|
177
|
+
tryNum(raw.max_tokens) ??
|
|
178
|
+
tryNum(raw.max_output_tokens) ??
|
|
179
|
+
tryNum(raw.max_completion_tokens) ??
|
|
180
|
+
parseArgValue(args, "--n-predict") ??
|
|
181
|
+
parsePresetValue(preset, "n-predict");
|
|
182
|
+
|
|
183
|
+
// Reasoning
|
|
184
|
+
let reasoning: boolean | null = null;
|
|
185
|
+
if (Array.isArray(raw.capabilities)) reasoning = (raw.capabilities as string[]).includes("reasoning");
|
|
186
|
+
if (reasoning === null && raw.reasoning !== undefined) reasoning = !!raw.reasoning;
|
|
187
|
+
if (reasoning === null) {
|
|
188
|
+
const budget = parseArgValue(args, "--reasoning-budget") ?? parsePresetValue(preset, "reasoning-budget");
|
|
189
|
+
if (budget !== null) reasoning = budget !== 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Input modalities
|
|
193
|
+
let input: string[] | null = null;
|
|
194
|
+
if (raw.architecture && typeof raw.architecture === "object") {
|
|
195
|
+
const modalities = (raw.architecture as Record<string, unknown>).input_modalities as string[] | undefined;
|
|
196
|
+
if (Array.isArray(modalities) && modalities.length > 0) {
|
|
197
|
+
input = [];
|
|
198
|
+
for (const m of modalities) {
|
|
199
|
+
const l = m.toLowerCase();
|
|
200
|
+
if (l.includes("text") && !input.includes("text")) input.push("text");
|
|
201
|
+
if ((l.includes("image") || l.includes("vision")) && !input.includes("image")) input.push("image");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!input && Array.isArray(raw.input)) input = raw.input as string[];
|
|
206
|
+
if (input && !input.includes("image") && args?.some((a) => a.startsWith("--mmproj"))) {
|
|
207
|
+
input.push("image");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const loaded = status?.value === "loaded" ? true : status?.value === "unloaded" ? false : undefined;
|
|
211
|
+
const source = String(raw.source ?? (status ? "server args" : "api"));
|
|
212
|
+
|
|
213
|
+
return { id, name, contextWindow, maxTokens, reasoning, input, source, loaded };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function fetchModels(
|
|
217
|
+
baseUrl: string,
|
|
218
|
+
apiKey?: string,
|
|
219
|
+
signal?: AbortSignal,
|
|
220
|
+
): Promise<{ models: Record<string, unknown>[]; serverType: string }> {
|
|
221
|
+
const url = baseUrl.replace(/\/+$/, "") + "/v1/models";
|
|
222
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
223
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
224
|
+
|
|
225
|
+
const response = await fetch(url, { headers, signal });
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
const body = await response.text().catch(() => "");
|
|
228
|
+
throw new Error(`HTTP ${response.status}: ${body.slice(0, 200)}`);
|
|
229
|
+
}
|
|
230
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
231
|
+
const models = (data.data as Record<string, unknown>[]) ?? [];
|
|
232
|
+
return { models, serverType: detectServerType(response.headers, models) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function generateProviderName(url: string): string {
|
|
236
|
+
try {
|
|
237
|
+
const u = new URL(url);
|
|
238
|
+
return `local-${u.hostname.replace(/\./g, "-")}${u.port ? `-${u.port}` : ""}`;
|
|
239
|
+
} catch {
|
|
240
|
+
return `local-${Date.now()}`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function fmt(n: number | null | undefined): string {
|
|
245
|
+
if (n === null || n === undefined) return "?";
|
|
246
|
+
// Use comma grouping regardless of system locale (avoid "262.144" confusion).
|
|
247
|
+
return n.toLocaleString("en-US");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Extension
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
export default async function (pi: ExtensionAPI) {
|
|
255
|
+
// -----------------------------------------------------------------------
|
|
256
|
+
// Provider registration with Pi's model registry
|
|
257
|
+
// -----------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
async function registerProvider(
|
|
260
|
+
provider: DiscoveredProvider,
|
|
261
|
+
prefetched?: { models: Record<string, unknown>[]; serverType: string },
|
|
262
|
+
): Promise<{ models: ModelConfig[]; serverType: string }> {
|
|
263
|
+
const { models, serverType } = prefetched ?? (await fetchModels(provider.baseUrl, provider.apiKey, AbortSignal.timeout(2_000)));
|
|
264
|
+
if (models.length === 0) throw new Error("No models found at this endpoint.");
|
|
265
|
+
|
|
266
|
+
const compat: Record<string, unknown> = { ...provider.compat };
|
|
267
|
+
if (serverType === "llama.cpp" || serverType === "oMLX" || serverType === "Ollama") {
|
|
268
|
+
if (compat.supportsDeveloperRole === undefined) compat.supportsDeveloperRole = false;
|
|
269
|
+
if (compat.supportsReasoningEffort === undefined) compat.supportsReasoningEffort = false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const configs = models.map(extractModelConfig);
|
|
273
|
+
const piModels = configs.map((c) => {
|
|
274
|
+
const ov = provider.modelOverrides?.[c.id];
|
|
275
|
+
return {
|
|
276
|
+
id: c.id,
|
|
277
|
+
name: c.name,
|
|
278
|
+
reasoning: ov?.reasoning ?? c.reasoning ?? false,
|
|
279
|
+
input: ov?.input ?? c.input ?? ["text"],
|
|
280
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
281
|
+
contextWindow: ov?.contextWindow ?? c.contextWindow ?? provider.defaultContextWindow ?? 128_000,
|
|
282
|
+
maxTokens: ov?.maxTokens ?? c.maxTokens ?? provider.defaultMaxTokens ?? 16_384,
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// OpenAI SDK appends /chat/completions to baseUrl.
|
|
287
|
+
// Ensure baseUrl ends with /v1 so the full URL is .../v1/chat/completions.
|
|
288
|
+
const sdkBaseUrl = provider.baseUrl.replace(/\/v1\/?$/, '') + '/v1';
|
|
289
|
+
pi.registerProvider(provider.name, {
|
|
290
|
+
name: `${serverType} (${provider.name})`,
|
|
291
|
+
baseUrl: sdkBaseUrl,
|
|
292
|
+
apiKey: provider.apiKey || "local",
|
|
293
|
+
api: "openai-completions",
|
|
294
|
+
compat,
|
|
295
|
+
models: piModels,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
provider.serverType = serverType;
|
|
299
|
+
provider.lastScanned = Date.now();
|
|
300
|
+
return { models: configs, serverType };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Register saved providers at startup (concurrent — one dead endpoint can't block the others)
|
|
304
|
+
const providers = loadProviders();
|
|
305
|
+
if (providers.length > 0) {
|
|
306
|
+
const results = await Promise.allSettled(
|
|
307
|
+
providers.map(async (provider) => {
|
|
308
|
+
await registerProvider(provider);
|
|
309
|
+
return provider.name;
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
for (const result of results) {
|
|
313
|
+
if (result.status === "rejected") {
|
|
314
|
+
console.error(`[model-discovery] Failed to register a provider:`, result.reason);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// -----------------------------------------------------------------------
|
|
320
|
+
// UI helpers (Pi-standard SelectList dialog)
|
|
321
|
+
// -----------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
async function runSelect(
|
|
324
|
+
ctx: ExtensionCommandContext,
|
|
325
|
+
title: string,
|
|
326
|
+
items: SelectItem[],
|
|
327
|
+
headerLines: string[] = [],
|
|
328
|
+
): Promise<string | null> {
|
|
329
|
+
return await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
330
|
+
const container = new Container();
|
|
331
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
332
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
333
|
+
for (const line of headerLines) {
|
|
334
|
+
container.addChild(new Text(theme.fg("muted", line), 1, 0));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const selectList = new SelectList(items, Math.min(items.length, 12), {
|
|
338
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
339
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
340
|
+
description: (t: string) => theme.fg("muted", t),
|
|
341
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
342
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
343
|
+
});
|
|
344
|
+
selectList.onSelect = (item) => done(item.value);
|
|
345
|
+
selectList.onCancel = () => done(null);
|
|
346
|
+
container.addChild(selectList);
|
|
347
|
+
|
|
348
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc back • type to filter"), 1, 0));
|
|
349
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
render: (w: number) => container.render(w),
|
|
353
|
+
invalidate: () => container.invalidate(),
|
|
354
|
+
handleInput: (data: string) => {
|
|
355
|
+
selectList.handleInput(data);
|
|
356
|
+
tui.requestRender();
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function runLoader<T>(
|
|
363
|
+
ctx: ExtensionCommandContext,
|
|
364
|
+
message: string,
|
|
365
|
+
work: (signal: AbortSignal) => Promise<T>,
|
|
366
|
+
): Promise<T | null> {
|
|
367
|
+
return await ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
|
|
368
|
+
const loader = new BorderedLoader(tui, theme, message);
|
|
369
|
+
loader.onAbort = () => done(null);
|
|
370
|
+
work(loader.signal)
|
|
371
|
+
.then((result) => done(result))
|
|
372
|
+
.catch((err) => {
|
|
373
|
+
ctx.ui.notify(`${err instanceof Error ? err.message : String(err)}`, "error");
|
|
374
|
+
done(null);
|
|
375
|
+
});
|
|
376
|
+
return loader;
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function askNumber(
|
|
381
|
+
ctx: ExtensionCommandContext,
|
|
382
|
+
title: string,
|
|
383
|
+
placeholder: string,
|
|
384
|
+
): Promise<number | undefined> {
|
|
385
|
+
const raw = (await ctx.ui.input(title, placeholder))?.trim();
|
|
386
|
+
if (!raw) return undefined;
|
|
387
|
+
const n = parseInt(raw.replace(/[,._\s]/g, ""), 10);
|
|
388
|
+
return isNaN(n) ? undefined : n;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function modelFlags(c: ModelConfig, ov?: ModelOverride): string {
|
|
392
|
+
const flags: string[] = [];
|
|
393
|
+
const input = ov?.input ?? c.input;
|
|
394
|
+
const reasoning = ov?.reasoning ?? c.reasoning;
|
|
395
|
+
if (input?.includes("image")) flags.push("vision");
|
|
396
|
+
if (reasoning === true) flags.push("reasoning");
|
|
397
|
+
if (reasoning === null && ov?.reasoning === undefined) flags.push("reasoning?");
|
|
398
|
+
if (c.loaded === true) flags.push("loaded");
|
|
399
|
+
return flags.length > 0 ? ` [${flags.join(", ")}]` : "";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function modelDescription(c: ModelConfig, provider?: DiscoveredProvider): string {
|
|
403
|
+
const ov = provider?.modelOverrides?.[c.id];
|
|
404
|
+
const ctxVal = ov?.contextWindow ?? c.contextWindow ?? provider?.defaultContextWindow ?? null;
|
|
405
|
+
const maxVal = ov?.maxTokens ?? c.maxTokens ?? provider?.defaultMaxTokens ?? null;
|
|
406
|
+
const ovMark = ov && Object.keys(ov).length > 0 ? " (edited)" : "";
|
|
407
|
+
return `ctx ${fmt(ctxVal)} · max ${fmt(maxVal)} · ${c.source}${ovMark}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// -----------------------------------------------------------------------
|
|
411
|
+
// Screen: model detail / edit
|
|
412
|
+
// -----------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
async function showModelScreen(
|
|
415
|
+
ctx: ExtensionCommandContext,
|
|
416
|
+
provider: DiscoveredProvider,
|
|
417
|
+
config: ModelConfig,
|
|
418
|
+
): Promise<void> {
|
|
419
|
+
for (;;) {
|
|
420
|
+
const ov = provider.modelOverrides?.[config.id] ?? {};
|
|
421
|
+
const effCtx = ov.contextWindow ?? config.contextWindow ?? provider.defaultContextWindow ?? null;
|
|
422
|
+
const effMax = ov.maxTokens ?? config.maxTokens ?? provider.defaultMaxTokens ?? null;
|
|
423
|
+
const effReasoning = ov.reasoning ?? config.reasoning;
|
|
424
|
+
const effInput = ov.input ?? config.input ?? ["text"];
|
|
425
|
+
|
|
426
|
+
const header = [
|
|
427
|
+
`server reports: ctx ${fmt(config.contextWindow)} · max ${fmt(config.maxTokens)} · reasoning ${
|
|
428
|
+
config.reasoning === null ? "unknown" : config.reasoning
|
|
429
|
+
} (${config.source})`,
|
|
430
|
+
`effective: ctx ${fmt(effCtx)} · max ${fmt(effMax)} · reasoning ${
|
|
431
|
+
effReasoning === null ? "unknown" : effReasoning
|
|
432
|
+
} · input ${effInput.join("+")}`,
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
const items: SelectItem[] = [
|
|
436
|
+
{ value: "ctx", label: "Set context window", description: `current: ${fmt(effCtx)}` },
|
|
437
|
+
{ value: "max", label: "Set max output tokens", description: `current: ${fmt(effMax)}` },
|
|
438
|
+
{
|
|
439
|
+
value: "reasoning",
|
|
440
|
+
label: "Toggle reasoning",
|
|
441
|
+
description: `current: ${effReasoning === null ? "unknown" : effReasoning ? "on" : "off"}`,
|
|
442
|
+
},
|
|
443
|
+
];
|
|
444
|
+
if (Object.keys(ov).length > 0) {
|
|
445
|
+
items.push({ value: "clear", label: "Clear overrides", description: "revert to server-reported values" });
|
|
446
|
+
}
|
|
447
|
+
items.push({ value: "back", label: "← Back" });
|
|
448
|
+
|
|
449
|
+
const action = await runSelect(ctx, `Model: ${config.id}${modelFlags(config, ov)}`, items, header);
|
|
450
|
+
if (!action || action === "back") return;
|
|
451
|
+
|
|
452
|
+
if (action === "ctx") {
|
|
453
|
+
const n = await askNumber(ctx, `Context window for ${config.id}`, String(effCtx ?? 128000));
|
|
454
|
+
if (n !== undefined) {
|
|
455
|
+
provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, contextWindow: n } };
|
|
456
|
+
}
|
|
457
|
+
} else if (action === "max") {
|
|
458
|
+
const n = await askNumber(ctx, `Max output tokens for ${config.id}`, String(effMax ?? 16384));
|
|
459
|
+
if (n !== undefined) {
|
|
460
|
+
provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, maxTokens: n } };
|
|
461
|
+
}
|
|
462
|
+
} else if (action === "reasoning") {
|
|
463
|
+
provider.modelOverrides = {
|
|
464
|
+
...provider.modelOverrides,
|
|
465
|
+
[config.id]: { ...ov, reasoning: !(effReasoning ?? false) },
|
|
466
|
+
};
|
|
467
|
+
} else if (action === "clear") {
|
|
468
|
+
if (provider.modelOverrides) {
|
|
469
|
+
delete provider.modelOverrides[config.id];
|
|
470
|
+
if (Object.keys(provider.modelOverrides).length === 0) provider.modelOverrides = undefined;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Persist + re-register with new values
|
|
475
|
+
upsertProvider(provider);
|
|
476
|
+
try {
|
|
477
|
+
await registerProvider(provider);
|
|
478
|
+
} catch {
|
|
479
|
+
/* endpoint may be down; overrides still saved */
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// -----------------------------------------------------------------------
|
|
485
|
+
// Screen: endpoint detail
|
|
486
|
+
// -----------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
async function showEndpointScreen(ctx: ExtensionCommandContext, provider: DiscoveredProvider): Promise<void> {
|
|
489
|
+
// Fetch live data
|
|
490
|
+
let live = await runLoader(ctx, `Scanning ${provider.baseUrl}...`, (signal) =>
|
|
491
|
+
fetchModels(provider.baseUrl, provider.apiKey, signal),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
for (;;) {
|
|
495
|
+
const header: string[] = [];
|
|
496
|
+
let configs: ModelConfig[] = [];
|
|
497
|
+
if (live) {
|
|
498
|
+
configs = live.models.map(extractModelConfig);
|
|
499
|
+
header.push(`${live.serverType} · ${provider.baseUrl} · online · ${configs.length} model(s)`);
|
|
500
|
+
} else {
|
|
501
|
+
header.push(`${provider.serverType ?? "?"} · ${provider.baseUrl} · OFFLINE (showing saved config)`);
|
|
502
|
+
}
|
|
503
|
+
if (provider.lastScanned) {
|
|
504
|
+
header.push(`last scan: ${new Date(provider.lastScanned).toLocaleString()}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const items: SelectItem[] = configs.map((c) => ({
|
|
508
|
+
value: `model:${c.id}`,
|
|
509
|
+
label: `${c.id}${modelFlags(c, provider.modelOverrides?.[c.id])}`,
|
|
510
|
+
description: modelDescription(c, provider),
|
|
511
|
+
}));
|
|
512
|
+
items.push({ value: "rescan", label: "⟳ Re-scan endpoint", description: "fetch fresh model list and re-register" });
|
|
513
|
+
items.push({
|
|
514
|
+
value: "defaults",
|
|
515
|
+
label: "✎ Edit fallback defaults",
|
|
516
|
+
description: `used when server reports nothing — ctx ${fmt(provider.defaultContextWindow ?? null)} · max ${fmt(provider.defaultMaxTokens ?? null)}`,
|
|
517
|
+
});
|
|
518
|
+
items.push({ value: "remove", label: "✗ Remove endpoint", description: "unregister provider and delete saved config" });
|
|
519
|
+
items.push({ value: "back", label: "← Back" });
|
|
520
|
+
|
|
521
|
+
const action = await runSelect(ctx, `Endpoint: ${provider.name}`, items, header);
|
|
522
|
+
if (!action || action === "back") return;
|
|
523
|
+
|
|
524
|
+
if (action.startsWith("model:")) {
|
|
525
|
+
const id = action.slice("model:".length);
|
|
526
|
+
const config = configs.find((c) => c.id === id);
|
|
527
|
+
if (config) await showModelScreen(ctx, provider, config);
|
|
528
|
+
} else if (action === "rescan") {
|
|
529
|
+
live = await runLoader(ctx, `Scanning ${provider.baseUrl}...`, (signal) =>
|
|
530
|
+
fetchModels(provider.baseUrl, provider.apiKey, signal),
|
|
531
|
+
);
|
|
532
|
+
if (live) {
|
|
533
|
+
try {
|
|
534
|
+
await registerProvider(provider, live);
|
|
535
|
+
upsertProvider(provider);
|
|
536
|
+
ctx.ui.notify(`Re-registered ${live.models.length} model(s) from ${live.serverType}.`, "success");
|
|
537
|
+
} catch (err) {
|
|
538
|
+
ctx.ui.notify(`${err instanceof Error ? err.message : String(err)}`, "error");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
} else if (action === "defaults") {
|
|
542
|
+
const cw = await askNumber(ctx, "Default context window (blank = keep)", String(provider.defaultContextWindow ?? 128000));
|
|
543
|
+
if (cw !== undefined) provider.defaultContextWindow = cw;
|
|
544
|
+
const mt = await askNumber(ctx, "Default max output tokens (blank = keep)", String(provider.defaultMaxTokens ?? 16384));
|
|
545
|
+
if (mt !== undefined) provider.defaultMaxTokens = mt;
|
|
546
|
+
upsertProvider(provider);
|
|
547
|
+
try {
|
|
548
|
+
await registerProvider(provider, live ?? undefined);
|
|
549
|
+
} catch {
|
|
550
|
+
/* offline */
|
|
551
|
+
}
|
|
552
|
+
} else if (action === "remove") {
|
|
553
|
+
const sure = await ctx.ui.confirm("Remove endpoint", `Remove "${provider.name}" (${provider.baseUrl})?`);
|
|
554
|
+
if (sure) {
|
|
555
|
+
pi.unregisterProvider(provider.name);
|
|
556
|
+
deleteProvider(provider.name);
|
|
557
|
+
ctx.ui.notify(`Removed "${provider.name}".`, "success");
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// -----------------------------------------------------------------------
|
|
565
|
+
// Screen: add endpoint
|
|
566
|
+
// -----------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
async function showAddScreen(ctx: ExtensionCommandContext, presetUrl?: string): Promise<void> {
|
|
569
|
+
let baseUrl = presetUrl ?? (await ctx.ui.input("Endpoint URL", "http://192.168.1.100:8080"))?.trim();
|
|
570
|
+
if (!baseUrl) return;
|
|
571
|
+
if (!baseUrl.startsWith("http")) baseUrl = `http://${baseUrl}`;
|
|
572
|
+
baseUrl = baseUrl.replace(/\/+$/, "");
|
|
573
|
+
|
|
574
|
+
// Probe endpoint first — availability check
|
|
575
|
+
let live = await runLoader(ctx, `Probing ${baseUrl}...`, (signal) => fetchModels(baseUrl, undefined, signal));
|
|
576
|
+
|
|
577
|
+
// If unauthorized or failed, offer API key
|
|
578
|
+
let apiKey: string | undefined;
|
|
579
|
+
if (!live) {
|
|
580
|
+
const retry = await ctx.ui.confirm("Endpoint unreachable or refused", "Try again with an API key?");
|
|
581
|
+
if (!retry) return;
|
|
582
|
+
apiKey = (await ctx.ui.input("API key", ""))?.trim() || undefined;
|
|
583
|
+
live = await runLoader(ctx, `Probing ${baseUrl} with key...`, (signal) => fetchModels(baseUrl, apiKey, signal));
|
|
584
|
+
if (!live) return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (live.models.length === 0) {
|
|
588
|
+
ctx.ui.notify("Endpoint is online but reports no models.", "warning");
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const name = (await ctx.ui.input("Provider name", generateProviderName(baseUrl)))?.trim() || generateProviderName(baseUrl);
|
|
593
|
+
|
|
594
|
+
const provider: DiscoveredProvider = { name, baseUrl, apiKey };
|
|
595
|
+
const configs = live.models.map(extractModelConfig);
|
|
596
|
+
|
|
597
|
+
// Review screen: show exactly what the server reports
|
|
598
|
+
const header = [`${live.serverType} · ${baseUrl} · online · ${configs.length} model(s)`];
|
|
599
|
+
const missing = configs.filter((c) => c.contextWindow === null || c.maxTokens === null || c.reasoning === null);
|
|
600
|
+
if (missing.length > 0) {
|
|
601
|
+
header.push(`${missing.length} model(s) have values the server didn't report (shown as ?)`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
for (;;) {
|
|
605
|
+
const items: SelectItem[] = configs.map((c) => ({
|
|
606
|
+
value: `model:${c.id}`,
|
|
607
|
+
label: `${c.id}${modelFlags(c, provider.modelOverrides?.[c.id])}`,
|
|
608
|
+
description: modelDescription(c, provider),
|
|
609
|
+
}));
|
|
610
|
+
items.push({ value: "register", label: "✓ Register endpoint", description: `save as "${name}" and make models available in /model` });
|
|
611
|
+
items.push({ value: "cancel", label: "✗ Cancel" });
|
|
612
|
+
|
|
613
|
+
const action = await runSelect(ctx, `Review: ${name}`, items, header);
|
|
614
|
+
if (!action || action === "cancel") {
|
|
615
|
+
ctx.ui.notify("Discovery cancelled.", "info");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (action.startsWith("model:")) {
|
|
620
|
+
const id = action.slice("model:".length);
|
|
621
|
+
const config = configs.find((c) => c.id === id);
|
|
622
|
+
if (config) {
|
|
623
|
+
// During add flow, edit without registering yet
|
|
624
|
+
const ov = provider.modelOverrides?.[config.id] ?? {};
|
|
625
|
+
const effCtx = ov.contextWindow ?? config.contextWindow;
|
|
626
|
+
const effMax = ov.maxTokens ?? config.maxTokens;
|
|
627
|
+
const cw = await askNumber(ctx, `Context window for ${config.id} (blank = keep)`, String(effCtx ?? 128000));
|
|
628
|
+
if (cw !== undefined) provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...ov, contextWindow: cw } };
|
|
629
|
+
const updated = provider.modelOverrides?.[config.id] ?? ov;
|
|
630
|
+
const mt = await askNumber(ctx, `Max output tokens for ${config.id} (blank = keep)`, String(effMax ?? 16384));
|
|
631
|
+
if (mt !== undefined) provider.modelOverrides = { ...provider.modelOverrides, [config.id]: { ...updated, maxTokens: mt } };
|
|
632
|
+
}
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (action === "register") {
|
|
637
|
+
// If values are still missing, ask for provider-wide fallbacks
|
|
638
|
+
const stillMissingCtx = configs.some(
|
|
639
|
+
(c) => (provider.modelOverrides?.[c.id]?.contextWindow ?? c.contextWindow) === null,
|
|
640
|
+
);
|
|
641
|
+
const stillMissingMax = configs.some(
|
|
642
|
+
(c) => (provider.modelOverrides?.[c.id]?.maxTokens ?? c.maxTokens) === null,
|
|
643
|
+
);
|
|
644
|
+
if (stillMissingCtx) {
|
|
645
|
+
provider.defaultContextWindow = await askNumber(ctx, "Fallback context window for unreported models", "128000");
|
|
646
|
+
}
|
|
647
|
+
if (stillMissingMax) {
|
|
648
|
+
provider.defaultMaxTokens = await askNumber(ctx, "Fallback max output tokens for unreported models", "16384");
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const existing = loadProviders().find((p) => p.name === name);
|
|
652
|
+
if (existing) pi.unregisterProvider(name);
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
await registerProvider(provider, live);
|
|
656
|
+
upsertProvider(provider);
|
|
657
|
+
ctx.ui.notify(
|
|
658
|
+
`Registered ${configs.length} model(s) from ${live.serverType} as "${name}". Use /model to select.`,
|
|
659
|
+
"success",
|
|
660
|
+
);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
ctx.ui.notify(`Failed to register: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// -----------------------------------------------------------------------
|
|
670
|
+
// Screen: main menu
|
|
671
|
+
// -----------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
async function showMainScreen(ctx: ExtensionCommandContext): Promise<void> {
|
|
674
|
+
for (;;) {
|
|
675
|
+
const providers = loadProviders();
|
|
676
|
+
const items: SelectItem[] = providers.map((p) => ({
|
|
677
|
+
value: `provider:${p.name}`,
|
|
678
|
+
label: p.name,
|
|
679
|
+
description: `${p.serverType ?? "?"} · ${p.baseUrl} · scanned ${
|
|
680
|
+
p.lastScanned ? new Date(p.lastScanned).toLocaleString() : "never"
|
|
681
|
+
}`,
|
|
682
|
+
}));
|
|
683
|
+
items.push({ value: "add", label: "+ Add endpoint", description: "discover models from an OpenAI-compatible server" });
|
|
684
|
+
if (providers.length > 0) {
|
|
685
|
+
items.push({ value: "rescan-all", label: "⟳ Re-scan all", description: "refresh model lists from every endpoint" });
|
|
686
|
+
}
|
|
687
|
+
items.push({ value: "quit", label: "✗ Close" });
|
|
688
|
+
|
|
689
|
+
const action = await runSelect(ctx, "Model Discovery", items, [
|
|
690
|
+
providers.length === 0 ? "No endpoints yet — add your first one." : `${providers.length} endpoint(s) registered`,
|
|
691
|
+
]);
|
|
692
|
+
if (!action || action === "quit") return;
|
|
693
|
+
|
|
694
|
+
if (action === "add") {
|
|
695
|
+
await showAddScreen(ctx);
|
|
696
|
+
} else if (action === "rescan-all") {
|
|
697
|
+
const results = await runLoader(ctx, "Re-scanning all endpoints...", async () => {
|
|
698
|
+
let ok = 0;
|
|
699
|
+
let fail = 0;
|
|
700
|
+
for (const provider of loadProviders()) {
|
|
701
|
+
try {
|
|
702
|
+
pi.unregisterProvider(provider.name);
|
|
703
|
+
await registerProvider(provider);
|
|
704
|
+
upsertProvider(provider);
|
|
705
|
+
ok++;
|
|
706
|
+
} catch {
|
|
707
|
+
fail++;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return { ok, fail };
|
|
711
|
+
});
|
|
712
|
+
if (results) {
|
|
713
|
+
const level = results.fail === 0 ? "success" : "warning";
|
|
714
|
+
ctx.ui.notify(`Re-scanned ${results.ok} endpoint(s)${results.fail ? `, ${results.fail} failed` : ""}.`, level);
|
|
715
|
+
}
|
|
716
|
+
} else if (action.startsWith("provider:")) {
|
|
717
|
+
const name = action.slice("provider:".length);
|
|
718
|
+
const provider = loadProviders().find((p) => p.name === name);
|
|
719
|
+
if (provider) await showEndpointScreen(ctx, provider);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// -----------------------------------------------------------------------
|
|
725
|
+
// Command: /discover — single entry point
|
|
726
|
+
// -----------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
pi.registerCommand("discover", {
|
|
729
|
+
description: "Manage local model endpoints (llama.cpp, oMLX, Ollama, vLLM, ...)",
|
|
730
|
+
handler: async (args, ctx) => {
|
|
731
|
+
if (ctx.mode !== "tui") {
|
|
732
|
+
ctx.ui.notify("/discover requires interactive mode", "error");
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const url = args?.trim();
|
|
736
|
+
if (url) {
|
|
737
|
+
await showAddScreen(ctx, url.startsWith("http") ? url : `http://${url}`);
|
|
738
|
+
} else {
|
|
739
|
+
await showMainScreen(ctx);
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// -----------------------------------------------------------------------
|
|
745
|
+
// Tool: discover_models (LLM-callable)
|
|
746
|
+
// -----------------------------------------------------------------------
|
|
747
|
+
|
|
748
|
+
pi.registerTool({
|
|
749
|
+
name: "discover_models",
|
|
750
|
+
label: "Discover Models",
|
|
751
|
+
description:
|
|
752
|
+
"Discover and register models from an OpenAI-compatible endpoint (llama.cpp, oMLX, Ollama, vLLM). Reads actual server config. Use when the user asks to add a local model server.",
|
|
753
|
+
parameters: Type.Object({
|
|
754
|
+
url: Type.String({ description: "Base URL of the OpenAI-compatible endpoint (e.g., http://localhost:8080)" }),
|
|
755
|
+
providerName: Type.Optional(Type.String({ description: "Name for the provider (auto-generated if omitted)" })),
|
|
756
|
+
apiKey: Type.Optional(Type.String({ description: "API key if required" })),
|
|
757
|
+
}),
|
|
758
|
+
async execute(_toolCallId, params) {
|
|
759
|
+
let { url, providerName, apiKey } = params;
|
|
760
|
+
if (!url.startsWith("http")) url = `http://${url}`;
|
|
761
|
+
url = url.replace(/\/+$/, "");
|
|
762
|
+
providerName = providerName || generateProviderName(url);
|
|
763
|
+
|
|
764
|
+
let live: { models: Record<string, unknown>[]; serverType: string };
|
|
765
|
+
try {
|
|
766
|
+
live = await fetchModels(url, apiKey);
|
|
767
|
+
} catch (err) {
|
|
768
|
+
return {
|
|
769
|
+
content: [
|
|
770
|
+
{ type: "text", text: `Endpoint unavailable: ${err instanceof Error ? err.message : String(err)}` },
|
|
771
|
+
],
|
|
772
|
+
details: {},
|
|
773
|
+
isError: true,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
if (live.models.length === 0) {
|
|
777
|
+
return { content: [{ type: "text", text: "Endpoint online but reports no models." }], details: {} };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const existing = loadProviders().find((p) => p.name === providerName);
|
|
781
|
+
const provider: DiscoveredProvider = existing
|
|
782
|
+
? { ...existing, baseUrl: url, apiKey: apiKey ?? existing.apiKey }
|
|
783
|
+
: { name: providerName, baseUrl: url, apiKey };
|
|
784
|
+
if (existing) pi.unregisterProvider(providerName);
|
|
785
|
+
|
|
786
|
+
try {
|
|
787
|
+
const { models: configs } = await registerProvider(provider, live);
|
|
788
|
+
upsertProvider(provider);
|
|
789
|
+
|
|
790
|
+
const lines = configs.map(
|
|
791
|
+
(c) =>
|
|
792
|
+
`- ${c.id}${modelFlags(c, provider.modelOverrides?.[c.id])}: ${modelDescription(c, provider)}`,
|
|
793
|
+
);
|
|
794
|
+
const missing = configs.filter((c) => c.contextWindow === null || c.maxTokens === null);
|
|
795
|
+
const note =
|
|
796
|
+
missing.length > 0
|
|
797
|
+
? `\n\n${missing.length} model(s) had unreported values (defaults applied: ctx ${fmt(provider.defaultContextWindow ?? 128000)}, max ${fmt(provider.defaultMaxTokens ?? 16384)}). The user can fine-tune them via /discover.`
|
|
798
|
+
: "";
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
content: [
|
|
802
|
+
{
|
|
803
|
+
type: "text",
|
|
804
|
+
text: `Endpoint online (${live.serverType}). Registered ${configs.length} model(s) as "${providerName}":\n${lines.join("\n")}${note}\n\nModels are now selectable via /model.`,
|
|
805
|
+
},
|
|
806
|
+
],
|
|
807
|
+
details: { providerName, serverType: live.serverType, modelCount: configs.length },
|
|
808
|
+
};
|
|
809
|
+
} catch (err) {
|
|
810
|
+
return {
|
|
811
|
+
content: [{ type: "text", text: `Failed to register: ${err instanceof Error ? err.message : String(err)}` }],
|
|
812
|
+
details: {},
|
|
813
|
+
isError: true,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maheidem/model-discovery",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive TUI for discovering and managing local AI model endpoints (llama.cpp, oMLX, Ollama, vLLM, SGLang, LM Studio). Reads real server-reported model configurations — no hardcoded databases.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"extension",
|
|
8
|
+
"model-discovery",
|
|
9
|
+
"local-llm",
|
|
10
|
+
"llama.cpp",
|
|
11
|
+
"ollama",
|
|
12
|
+
"vllm"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/Maheidem/model-discovery",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/maheidem/model-discovery/issues"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
20
|
+
"@earendil-works/pi-tui": "*",
|
|
21
|
+
"typebox": "*"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": ["index.ts"]
|
|
25
|
+
}
|
|
26
|
+
}
|