@maheidem/model-discovery 0.6.0 → 0.7.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 +39 -3
- package/index.ts +6 -214
- package/package.json +5 -3
- package/providers.ts +368 -0
package/README.md
CHANGED
|
@@ -4,9 +4,13 @@ Interactive TUI for discovering and managing local AI model endpoints. Works wit
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Auto-detect server type** from headers and model data
|
|
8
|
-
- **Read server-reported configuration** — context window, max tokens, reasoning
|
|
7
|
+
- **Auto-detect server type** from headers and model data (incl. MTPLX's `capability` field)
|
|
8
|
+
- **Read server-reported configuration** — context window, max tokens, reasoning, and vision, with per-model overrides on top
|
|
9
|
+
- **Native-endpoint enrichment** — llama.cpp `/props`, oMLX `/v1/models/status`, and Ollama `/api/tags` + `/api/ps` fill in what the OpenAI layer omits (real context windows, load state, VLM flags), silently best-effort
|
|
10
|
+
- **Auto-detect vision-capable models (VLMs)** — from architecture metadata, llama.cpp `--mmproj` args, or oMLX capabilities
|
|
11
|
+
- **Auto-detect reasoning capability** — from `capabilities`, explicit `reasoning` fields, `--reasoning-budget`, and Qwen model names on oMLX
|
|
9
12
|
- **Auto-detect reasoning format** — oMLX servers get `chat_template_kwargs` thinking support automatically
|
|
13
|
+
- **Per-model compatibility** — `supportsDeveloperRole: false` for llama.cpp, oMLX, and Ollama; Qwen thinking format for oMLX
|
|
10
14
|
- **Fine-tune per-model overrides** — context window, max output, reasoning, and vision support
|
|
11
15
|
- **Profile-routed native thinking levels** — Shift-Tab can select complete thinking and sampling presets
|
|
12
16
|
- **Named model presets** — reuse complete thinking/sampling bundles as fixed aliases or adaptive routes
|
|
@@ -23,7 +27,7 @@ Interactive TUI for discovering and managing local AI model endpoints. Works wit
|
|
|
23
27
|
pi install npm:@maheidem/model-discovery
|
|
24
28
|
|
|
25
29
|
# Via git
|
|
26
|
-
pi install git:github.com/Maheidem/model-discovery@v0.
|
|
30
|
+
pi install git:github.com/Maheidem/model-discovery@v0.7.0
|
|
27
31
|
```
|
|
28
32
|
|
|
29
33
|
## Usage
|
|
@@ -53,6 +57,38 @@ discover_models(url="http://192.168.1.100:8080", providerName="my-llama")
|
|
|
53
57
|
|
|
54
58
|
The tool also accepts `apiKey`, but literal tool arguments may be retained in the agent session. Prefer the masked `/discover` flow for secrets.
|
|
55
59
|
|
|
60
|
+
## How model settings are detected
|
|
61
|
+
|
|
62
|
+
Every field is read from what the server actually reports, first value found wins:
|
|
63
|
+
|
|
64
|
+
- **Context window** — `context_length` → `context_window` → `max_model_len` → `max_context_len` → `max_context_length` → llama.cpp `--ctx-size` (args or preset) → `meta.n_ctx` for loaded models → the source's default context window → `128000`
|
|
65
|
+
- **Max output tokens** — `max_tokens` → `max_output_tokens` → `max_completion_tokens` → llama.cpp `--n-predict` → the source's default → `16384`
|
|
66
|
+
- **Reasoning** — `capabilities` containing `reasoning` → an explicit `reasoning` field → llama.cpp `--reasoning-budget` ≠ 0 → Qwen model names on oMLX (when the server reports nothing)
|
|
67
|
+
- **Vision** — `architecture.input_modalities` (vLLM, SGLang), vision-specific architecture keys (`vision_config`, `vision_model`, `mm_proj`, `multi_modal_projector`), llama.cpp `--mmproj`/`--vision` args or a preset name mentioning mmproj/vision, and oMLX `capabilities` containing `vision`, `image`, or `multimodal`
|
|
68
|
+
|
|
69
|
+
Detected vision-capable models get `input: ["text", "image"]`, so Pi accepts image input for them. The source defaults and every detection can be corrected per model with **Edit model**.
|
|
70
|
+
|
|
71
|
+
### Native-endpoint enrichment
|
|
72
|
+
|
|
73
|
+
For server types that expose richer *native* (non-OpenAI) endpoints, the probe runs one best-effort enrichment pass after the catalogue fetch, filling only what `/v1/models` omitted — explicit values always win:
|
|
74
|
+
|
|
75
|
+
- **llama.cpp** — `GET /props`: the real runtime context window (`default_generation_settings.n_ctx`) and the authoritative VLM flag (`modalities.vision`)
|
|
76
|
+
- **oMLX** — `GET /v1/models/status`: the effective per-model context window, max output tokens, load state (drives the `[loaded]` flag), and a thinking-capable default
|
|
77
|
+
- **Ollama** — `GET /api/tags` + `GET /api/ps`: the model card's default context length and which models are currently loaded
|
|
78
|
+
|
|
79
|
+
Enrichment is silent best-effort: a missing or failing native endpoint (or a connection refusal) leaves the catalogue exactly as the OpenAI layer reported it, and the cached catalogue retains the last known-good enrichment for offline fallback.
|
|
80
|
+
|
|
81
|
+
### Compatibility settings
|
|
82
|
+
|
|
83
|
+
The extension attaches `compat` to each registered model (Pi does not merge provider-level compat into individual models):
|
|
84
|
+
|
|
85
|
+
- llama.cpp, oMLX, Ollama: `supportsDeveloperRole: false`
|
|
86
|
+
- oMLX: `thinkingFormat: "qwen-chat-template"` and `supportsReasoningEffort: true` for base models; fixed and adaptive profile aliases carry their own complete `chat_template_kwargs` independently
|
|
87
|
+
|
|
88
|
+
### Model list display
|
|
89
|
+
|
|
90
|
+
Each model shows `ctx <window> · max <tokens> · <source>`, where source is `server args` for a live llama.cpp process and `api` for other backends. Flags: `[vision]`, `[reasoning]`, `[reasoning?]` (undetermined and not overridden), `[loaded]` (llama.cpp, currently in memory), and `(edited)` when overrides are present.
|
|
91
|
+
|
|
56
92
|
## Native thinking levels
|
|
57
93
|
|
|
58
94
|
For reasoning-capable Qwen models on oMLX, the base model and sampling-only profiles translate Pi's native Shift-Tab level into request-scoped `chat_template_kwargs`:
|
package/index.ts
CHANGED
|
@@ -40,6 +40,12 @@ import {
|
|
|
40
40
|
validateProfileSampling,
|
|
41
41
|
validateProfileSlug,
|
|
42
42
|
} from "./profiles.ts";
|
|
43
|
+
import {
|
|
44
|
+
extractModelConfig,
|
|
45
|
+
fetchModels,
|
|
46
|
+
redactSecret,
|
|
47
|
+
type ModelConfig,
|
|
48
|
+
} from "./providers.ts";
|
|
43
49
|
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
44
50
|
import { join } from "node:path";
|
|
45
51
|
import os from "node:os";
|
|
@@ -54,7 +60,6 @@ interface ModelOverride {
|
|
|
54
60
|
reasoning?: boolean;
|
|
55
61
|
input?: string[];
|
|
56
62
|
}
|
|
57
|
-
|
|
58
63
|
interface DiscoveredProvider {
|
|
59
64
|
name: string;
|
|
60
65
|
baseUrl: string;
|
|
@@ -74,17 +79,6 @@ interface DiscoveredProvider {
|
|
|
74
79
|
lastScanError?: string;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
|
-
interface ModelConfig {
|
|
78
|
-
id: string;
|
|
79
|
-
name: string;
|
|
80
|
-
contextWindow: number | null;
|
|
81
|
-
maxTokens: number | null;
|
|
82
|
-
reasoning: boolean | null;
|
|
83
|
-
input: string[] | null;
|
|
84
|
-
source: string;
|
|
85
|
-
loaded?: boolean;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
82
|
// ---------------------------------------------------------------------------
|
|
89
83
|
// Storage
|
|
90
84
|
// ---------------------------------------------------------------------------
|
|
@@ -153,10 +147,6 @@ function errorMessage(error: unknown): string {
|
|
|
153
147
|
return error instanceof Error ? error.message : String(error);
|
|
154
148
|
}
|
|
155
149
|
|
|
156
|
-
function redactSecret(value: string, secret?: string): string {
|
|
157
|
-
return secret ? value.replaceAll(secret, "[redacted]") : value;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
150
|
function persistProviderScanState(provider: DiscoveredProvider): void {
|
|
161
151
|
try {
|
|
162
152
|
const providers = loadProviders();
|
|
@@ -251,204 +241,6 @@ function deleteModelProfileRouting(provider: DiscoveredProvider, modelId: string
|
|
|
251
241
|
provider.modelProfileRouting = Object.keys(routing).length > 0 ? routing : undefined;
|
|
252
242
|
}
|
|
253
243
|
|
|
254
|
-
// ---------------------------------------------------------------------------
|
|
255
|
-
// Server detection & model config extraction (reads real server data)
|
|
256
|
-
// ---------------------------------------------------------------------------
|
|
257
|
-
|
|
258
|
-
function detectServerType(headers: Headers, models: Record<string, unknown>[]): string {
|
|
259
|
-
const server = (headers.get("server") ?? "").toLowerCase();
|
|
260
|
-
const poweredBy = (headers.get("x-powered-by") ?? "").toLowerCase();
|
|
261
|
-
|
|
262
|
-
if (server.includes("llama-cpp") || server.includes("llama.cpp")) return "llama.cpp";
|
|
263
|
-
if (server.includes("ollama")) return "Ollama";
|
|
264
|
-
if (server.includes("vllm")) return "vLLM";
|
|
265
|
-
if (server.includes("sglang")) return "SGLang";
|
|
266
|
-
if (server.includes("lm-studio") || server.includes("lm studio") || server.includes("lmstudio")) return "LM Studio";
|
|
267
|
-
if (server.includes("omlx") || poweredBy.includes("omlx")) return "oMLX";
|
|
268
|
-
|
|
269
|
-
for (const m of models) {
|
|
270
|
-
const ownedBy = String(m.owned_by ?? "").toLowerCase();
|
|
271
|
-
if (ownedBy === "omlx") return "oMLX";
|
|
272
|
-
if (ownedBy === "vllm") return "vLLM";
|
|
273
|
-
if (ownedBy === "llamacpp") return "llama.cpp";
|
|
274
|
-
}
|
|
275
|
-
for (const m of models) {
|
|
276
|
-
if (String(m.id ?? "").includes(":")) return "Ollama";
|
|
277
|
-
}
|
|
278
|
-
return "OpenAI-compatible";
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function tryNum(v: unknown): number | null {
|
|
282
|
-
if (typeof v === "number" && !isNaN(v)) return v;
|
|
283
|
-
if (typeof v === "string") {
|
|
284
|
-
const n = parseInt(v, 10);
|
|
285
|
-
return isNaN(n) ? null : n;
|
|
286
|
-
}
|
|
287
|
-
return null;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function parseArgValue(args: string[] | undefined, flag: string): number | null {
|
|
291
|
-
if (!args) return null;
|
|
292
|
-
for (let i = 0; i < args.length - 1; i++) {
|
|
293
|
-
if (args[i] === flag) {
|
|
294
|
-
const n = parseInt(args[i + 1], 10);
|
|
295
|
-
return isNaN(n) ? null : n;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function parsePresetValue(preset: string | undefined, key: string): number | null {
|
|
302
|
-
if (!preset) return null;
|
|
303
|
-
const m = preset.match(new RegExp(`${key}\\s*=\\s*(\\d+)`, "i"));
|
|
304
|
-
if (m) {
|
|
305
|
-
const n = parseInt(m[1], 10);
|
|
306
|
-
return isNaN(n) ? null : n;
|
|
307
|
-
}
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Extract model config from whatever the server actually reports.
|
|
313
|
-
* Returns null for any field the server doesn't provide.
|
|
314
|
-
*/
|
|
315
|
-
function extractModelConfig(raw: Record<string, unknown>): ModelConfig {
|
|
316
|
-
const id = String(raw.id ?? "");
|
|
317
|
-
const name = String(raw.name ?? id);
|
|
318
|
-
const status = (raw.status && typeof raw.status === "object" ? raw.status : undefined) as
|
|
319
|
-
| Record<string, unknown>
|
|
320
|
-
| undefined;
|
|
321
|
-
const args = status?.args as string[] | undefined;
|
|
322
|
-
const preset = status?.preset as string | undefined;
|
|
323
|
-
|
|
324
|
-
// Context window: standard fields, then llama.cpp args/preset, then loaded meta
|
|
325
|
-
let contextWindow =
|
|
326
|
-
tryNum(raw.context_length) ??
|
|
327
|
-
tryNum(raw.context_window) ??
|
|
328
|
-
tryNum(raw.max_model_len) ??
|
|
329
|
-
tryNum(raw.max_context_len) ??
|
|
330
|
-
tryNum(raw.max_context_length) ??
|
|
331
|
-
parseArgValue(args, "--ctx-size") ??
|
|
332
|
-
parsePresetValue(preset, "ctx-size");
|
|
333
|
-
if (contextWindow === null && raw.meta && typeof raw.meta === "object") {
|
|
334
|
-
contextWindow = tryNum((raw.meta as Record<string, unknown>).n_ctx);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Max output tokens
|
|
338
|
-
const maxTokens =
|
|
339
|
-
tryNum(raw.max_tokens) ??
|
|
340
|
-
tryNum(raw.max_output_tokens) ??
|
|
341
|
-
tryNum(raw.max_completion_tokens) ??
|
|
342
|
-
parseArgValue(args, "--n-predict") ??
|
|
343
|
-
parsePresetValue(preset, "n-predict");
|
|
344
|
-
|
|
345
|
-
// Reasoning
|
|
346
|
-
let reasoning: boolean | null = null;
|
|
347
|
-
if (Array.isArray(raw.capabilities)) reasoning = (raw.capabilities as string[]).includes("reasoning");
|
|
348
|
-
if (reasoning === null && raw.reasoning !== undefined) reasoning = !!raw.reasoning;
|
|
349
|
-
if (reasoning === null) {
|
|
350
|
-
const budget = parseArgValue(args, "--reasoning-budget") ?? parsePresetValue(preset, "reasoning-budget");
|
|
351
|
-
if (budget !== null) reasoning = budget !== 0;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Input modalities
|
|
355
|
-
let input: string[] | null = null;
|
|
356
|
-
let hasVision = false;
|
|
357
|
-
|
|
358
|
-
// 1. Standard architecture.input_modalities (vLLM, SGLang, etc.)
|
|
359
|
-
if (raw.architecture && typeof raw.architecture === "object") {
|
|
360
|
-
const arch = raw.architecture as Record<string, unknown>;
|
|
361
|
-
const modalities = arch.input_modalities as string[] | undefined;
|
|
362
|
-
if (Array.isArray(modalities) && modalities.length > 0) {
|
|
363
|
-
input = [];
|
|
364
|
-
for (const m of modalities) {
|
|
365
|
-
const l = m.toLowerCase();
|
|
366
|
-
if (l.includes("text") && !input.includes("text")) input.push("text");
|
|
367
|
-
if ((l.includes("image") || l.includes("vision")) && !input.includes("image")) {
|
|
368
|
-
input.push("image");
|
|
369
|
-
hasVision = true;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
// Also check for vision-specific architecture keys
|
|
374
|
-
if (!hasVision && (arch.vision_config || arch.vision_model || arch.mm_proj || arch.multi_modal_projector)) {
|
|
375
|
-
hasVision = true;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// 2. Direct input array on the model object
|
|
380
|
-
if (!input && Array.isArray(raw.input)) {
|
|
381
|
-
input = raw.input as string[];
|
|
382
|
-
if (input.includes("image")) hasVision = true;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// 3. llama.cpp: --mmproj flag in args or preset (multimodal projector file)
|
|
386
|
-
if (!hasVision && args) {
|
|
387
|
-
for (const a of args) {
|
|
388
|
-
if (a.startsWith("--mmproj") || a.startsWith("--vision")) {
|
|
389
|
-
hasVision = true;
|
|
390
|
-
break;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
if (!hasVision && preset) {
|
|
395
|
-
if (/mmproj|vision/i.test(preset)) {
|
|
396
|
-
hasVision = true;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// 4. oMLX: check for vision-specific capabilities or model tags
|
|
401
|
-
if (!hasVision && Array.isArray(raw.capabilities)) {
|
|
402
|
-
const caps = (raw.capabilities as string[]).map((c: string) => c.toLowerCase());
|
|
403
|
-
if (caps.some((c: string) => c.includes("vision") || c.includes("image") || c.includes("multimodal"))) {
|
|
404
|
-
hasVision = true;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// 5. Build final input array — always include "text", add "image" if vision detected
|
|
409
|
-
if (hasVision) {
|
|
410
|
-
input = input && input.includes("image") ? input : ["text", "image"];
|
|
411
|
-
} else if (!input) {
|
|
412
|
-
input = ["text"];
|
|
413
|
-
} else if (!input.includes("text")) {
|
|
414
|
-
input.unshift("text");
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const loaded = status?.value === "loaded" ? true : status?.value === "unloaded" ? false : undefined;
|
|
418
|
-
const source = String(raw.source ?? (status ? "server args" : "api"));
|
|
419
|
-
|
|
420
|
-
return { id, name, contextWindow, maxTokens, reasoning, input, source, loaded };
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
async function fetchModels(
|
|
424
|
-
baseUrl: string,
|
|
425
|
-
apiKey?: string,
|
|
426
|
-
signal?: AbortSignal,
|
|
427
|
-
): Promise<{ models: Record<string, unknown>[]; serverType: string }> {
|
|
428
|
-
const url = baseUrl.replace(/\/+$/, "") + "/v1/models";
|
|
429
|
-
const headers: Record<string, string> = { Accept: "application/json" };
|
|
430
|
-
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
431
|
-
|
|
432
|
-
const response = await fetch(url, { headers, signal });
|
|
433
|
-
if (!response.ok) {
|
|
434
|
-
const body = await response.text().catch(() => "");
|
|
435
|
-
throw new Error(`HTTP ${response.status}: ${redactSecret(body.slice(0, 200), apiKey)}`);
|
|
436
|
-
}
|
|
437
|
-
const data = (await response.json()) as Record<string, unknown>;
|
|
438
|
-
if (!data || typeof data !== "object" || !Array.isArray(data.data)) {
|
|
439
|
-
throw new Error("Invalid /v1/models response: expected a data array.");
|
|
440
|
-
}
|
|
441
|
-
const models = data.data.filter(
|
|
442
|
-
(model): model is Record<string, unknown> =>
|
|
443
|
-
!!model && typeof model === "object" && typeof (model as Record<string, unknown>).id === "string" &&
|
|
444
|
-
(model as Record<string, unknown>).id !== "",
|
|
445
|
-
);
|
|
446
|
-
if (models.length !== data.data.length) {
|
|
447
|
-
throw new Error("Invalid /v1/models response: every model must have a non-empty string id.");
|
|
448
|
-
}
|
|
449
|
-
return { models, serverType: detectServerType(response.headers, models) };
|
|
450
|
-
}
|
|
451
|
-
|
|
452
244
|
function generateProviderName(url: string): string {
|
|
453
245
|
try {
|
|
454
246
|
const u = new URL(url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maheidem/model-discovery",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Interactive TUI for discovering local AI endpoints and defining named thinking/sampling profiles (llama.cpp, oMLX, Ollama, vLLM, SGLang, LM Studio).",
|
|
6
6
|
"keywords": [
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"url": "https://github.com/maheidem/model-discovery/issues"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
|
-
"test": "node --experimental-strip-types --test profiles.test.ts offline.test.ts"
|
|
22
|
+
"test": "node --experimental-strip-types --test profiles.test.ts offline.test.ts enrichment.test.ts"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"@earendil-works/pi-coding-agent": ">=0.84.0",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"typebox": "*"
|
|
28
28
|
},
|
|
29
29
|
"pi": {
|
|
30
|
-
"extensions": [
|
|
30
|
+
"extensions": [
|
|
31
|
+
"index.ts"
|
|
32
|
+
]
|
|
31
33
|
}
|
|
32
34
|
}
|
package/providers.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider detection and model config extraction.
|
|
3
|
+
*
|
|
4
|
+
* Pure module (no TUI, no storage): reads what the servers actually report — the
|
|
5
|
+
* OpenAI-layer `/v1/models` catalogue plus per-type *native* enrichment endpoints
|
|
6
|
+
* (llama.cpp `/props`, oMLX `/v1/models/status`, Ollama `/api/tags` + `/api/ps`) —
|
|
7
|
+
* and turns it into the ModelConfig that registerProvider() registers with Pi.
|
|
8
|
+
*
|
|
9
|
+
* The per-server API research this encodes lives in docs/providers/.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ModelConfig {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
contextWindow: number | null;
|
|
16
|
+
maxTokens: number | null;
|
|
17
|
+
reasoning: boolean | null;
|
|
18
|
+
input: string[] | null;
|
|
19
|
+
source: string;
|
|
20
|
+
loaded?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Plugin-internal enrichment merged into raw model objects by enrichModels().
|
|
25
|
+
* Values come from the server's *native* (non-OpenAI) endpoints and are consulted
|
|
26
|
+
* only when the /v1/models entry does not report the field itself.
|
|
27
|
+
*/
|
|
28
|
+
export interface ModelEnrichment {
|
|
29
|
+
/** Effective runtime context (llama.cpp /props n_ctx, oMLX max_context_window, Ollama default num_ctx). */
|
|
30
|
+
contextWindow?: number;
|
|
31
|
+
/** Effective max output tokens (oMLX per-model setting). */
|
|
32
|
+
maxTokens?: number;
|
|
33
|
+
/** Model is currently loaded in memory (oMLX status `loaded`, Ollama /api/ps). */
|
|
34
|
+
loaded?: boolean;
|
|
35
|
+
/** Authoritative VLM flag (llama.cpp /props `modalities.vision`). */
|
|
36
|
+
vision?: boolean;
|
|
37
|
+
/** Server-reported thinking-capable default (oMLX status `thinking_default`). */
|
|
38
|
+
thinkingDefault?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Key under which enrichModels() stashes a ModelEnrichment on a raw model object. */
|
|
42
|
+
export const ENRICH_KEY = "__md";
|
|
43
|
+
|
|
44
|
+
export function redactSecret(value: string, secret?: string): string {
|
|
45
|
+
return secret ? value.replaceAll(secret, "[redacted]") : value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Server detection (headers first, then model-object fingerprints)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export function detectServerType(headers: Headers, models: Record<string, unknown>[]): string {
|
|
53
|
+
const server = (headers.get("server") ?? "").toLowerCase();
|
|
54
|
+
const poweredBy = (headers.get("x-powered-by") ?? "").toLowerCase();
|
|
55
|
+
|
|
56
|
+
if (server.includes("llama-cpp") || server.includes("llama.cpp")) return "llama.cpp";
|
|
57
|
+
if (server.includes("ollama")) return "Ollama";
|
|
58
|
+
if (server.includes("vllm")) return "vLLM";
|
|
59
|
+
if (server.includes("sglang")) return "SGLang";
|
|
60
|
+
if (server.includes("lm-studio") || server.includes("lm studio") || server.includes("lmstudio")) return "LM Studio";
|
|
61
|
+
if (server.includes("omlx") || poweredBy.includes("omlx")) return "oMLX";
|
|
62
|
+
|
|
63
|
+
for (const m of models) {
|
|
64
|
+
const ownedBy = String(m.owned_by ?? "").toLowerCase();
|
|
65
|
+
if (ownedBy === "omlx") return "oMLX";
|
|
66
|
+
if (ownedBy === "vllm") return "vLLM";
|
|
67
|
+
if (ownedBy === "llamacpp") return "llama.cpp";
|
|
68
|
+
}
|
|
69
|
+
for (const m of models) {
|
|
70
|
+
// MTPLX /v1/models entries carry a `capability` field (chat/embedding/rerank)
|
|
71
|
+
if (typeof m.capability === "string" && (m.capability as string).length > 0) return "MTPLX";
|
|
72
|
+
}
|
|
73
|
+
for (const m of models) {
|
|
74
|
+
if (String(m.id ?? "").includes(":")) return "Ollama";
|
|
75
|
+
}
|
|
76
|
+
return "OpenAI-compatible";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Raw field parsing helpers
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
function tryNum(v: unknown): number | null {
|
|
84
|
+
if (typeof v === "number" && !isNaN(v)) return v;
|
|
85
|
+
if (typeof v === "string") {
|
|
86
|
+
const n = parseInt(v, 10);
|
|
87
|
+
return isNaN(n) ? null : n;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseArgValue(args: string[] | undefined, flag: string): number | null {
|
|
93
|
+
if (!args) return null;
|
|
94
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
95
|
+
if (args[i] === flag) {
|
|
96
|
+
const n = parseInt(args[i + 1], 10);
|
|
97
|
+
return isNaN(n) ? null : n;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parsePresetValue(preset: string | undefined, key: string): number | null {
|
|
104
|
+
if (!preset) return null;
|
|
105
|
+
const m = preset.match(new RegExp(`${key}\\s*=\\s*(\\d+)`, "i"));
|
|
106
|
+
if (m) {
|
|
107
|
+
const n = parseInt(m[1], 10);
|
|
108
|
+
return isNaN(n) ? null : n;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Model config extraction (reads real server data, null for anything missing)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export function extractModelConfig(raw: Record<string, unknown>): ModelConfig {
|
|
118
|
+
const id = String(raw.id ?? "");
|
|
119
|
+
const name = String(raw.name ?? id);
|
|
120
|
+
const status = (raw.status && typeof raw.status === "object" ? raw.status : undefined) as
|
|
121
|
+
| Record<string, unknown>
|
|
122
|
+
| undefined;
|
|
123
|
+
const args = status?.args as string[] | undefined;
|
|
124
|
+
const preset = status?.preset as string | undefined;
|
|
125
|
+
const enriched = (raw[ENRICH_KEY] ?? {}) as ModelEnrichment;
|
|
126
|
+
|
|
127
|
+
// Context window: standard fields, then llama.cpp args/preset, then loaded meta,
|
|
128
|
+
// then native-endpoint enrichment (llama.cpp /props, oMLX status, Ollama tags)
|
|
129
|
+
let contextWindow =
|
|
130
|
+
tryNum(raw.context_length) ??
|
|
131
|
+
tryNum(raw.context_window) ??
|
|
132
|
+
tryNum(raw.max_model_len) ??
|
|
133
|
+
tryNum(raw.max_context_len) ??
|
|
134
|
+
tryNum(raw.max_context_length) ??
|
|
135
|
+
parseArgValue(args, "--ctx-size") ??
|
|
136
|
+
parsePresetValue(preset, "ctx-size");
|
|
137
|
+
if (contextWindow === null && raw.meta && typeof raw.meta === "object") {
|
|
138
|
+
contextWindow = tryNum((raw.meta as Record<string, unknown>).n_ctx);
|
|
139
|
+
}
|
|
140
|
+
if (contextWindow === null) contextWindow = enriched.contextWindow ?? null;
|
|
141
|
+
|
|
142
|
+
// Max output tokens
|
|
143
|
+
let maxTokens =
|
|
144
|
+
tryNum(raw.max_tokens) ??
|
|
145
|
+
tryNum(raw.max_output_tokens) ??
|
|
146
|
+
tryNum(raw.max_completion_tokens) ??
|
|
147
|
+
parseArgValue(args, "--n-predict") ??
|
|
148
|
+
parsePresetValue(preset, "n-predict");
|
|
149
|
+
if (maxTokens === null) maxTokens = enriched.maxTokens ?? null;
|
|
150
|
+
|
|
151
|
+
// Reasoning
|
|
152
|
+
let reasoning: boolean | null = null;
|
|
153
|
+
if (Array.isArray(raw.capabilities)) reasoning = (raw.capabilities as string[]).includes("reasoning");
|
|
154
|
+
if (reasoning === null && raw.reasoning !== undefined) reasoning = !!raw.reasoning;
|
|
155
|
+
if (reasoning === null) {
|
|
156
|
+
const budget = parseArgValue(args, "--reasoning-budget") ?? parsePresetValue(preset, "reasoning-budget");
|
|
157
|
+
if (budget !== null) reasoning = budget !== 0;
|
|
158
|
+
}
|
|
159
|
+
if (reasoning === null && enriched.thinkingDefault === true) reasoning = true;
|
|
160
|
+
|
|
161
|
+
// Input modalities
|
|
162
|
+
let input: string[] | null = null;
|
|
163
|
+
let hasVision = false;
|
|
164
|
+
|
|
165
|
+
// 1. Standard architecture.input_modalities (vLLM, SGLang, etc.)
|
|
166
|
+
if (raw.architecture && typeof raw.architecture === "object") {
|
|
167
|
+
const arch = raw.architecture as Record<string, unknown>;
|
|
168
|
+
const modalities = arch.input_modalities as string[] | undefined;
|
|
169
|
+
if (Array.isArray(modalities) && modalities.length > 0) {
|
|
170
|
+
input = [];
|
|
171
|
+
for (const m of modalities) {
|
|
172
|
+
const l = m.toLowerCase();
|
|
173
|
+
if (l.includes("text") && !input.includes("text")) input.push("text");
|
|
174
|
+
if ((l.includes("image") || l.includes("vision")) && !input.includes("image")) {
|
|
175
|
+
input.push("image");
|
|
176
|
+
hasVision = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Also check for vision-specific architecture keys
|
|
181
|
+
if (!hasVision && (arch.vision_config || arch.vision_model || arch.mm_proj || arch.multi_modal_projector)) {
|
|
182
|
+
hasVision = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 2. Direct input array on the model object
|
|
187
|
+
if (!input && Array.isArray(raw.input)) {
|
|
188
|
+
input = raw.input as string[];
|
|
189
|
+
if (input.includes("image")) hasVision = true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 3. llama.cpp: --mmproj flag in args or preset (multimodal projector file)
|
|
193
|
+
if (!hasVision && args) {
|
|
194
|
+
for (const a of args) {
|
|
195
|
+
if (a.startsWith("--mmproj") || a.startsWith("--vision")) {
|
|
196
|
+
hasVision = true;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!hasVision && preset) {
|
|
202
|
+
if (/mmproj|vision/i.test(preset)) {
|
|
203
|
+
hasVision = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 4. oMLX: check for vision-specific capabilities or model tags
|
|
208
|
+
if (!hasVision && Array.isArray(raw.capabilities)) {
|
|
209
|
+
const caps = (raw.capabilities as string[]).map((c: string) => c.toLowerCase());
|
|
210
|
+
if (caps.some((c: string) => c.includes("vision") || c.includes("image") || c.includes("multimodal"))) {
|
|
211
|
+
hasVision = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 5. Enrichment: native endpoint reports an authoritative VLM flag (llama.cpp /props)
|
|
216
|
+
if (!hasVision && enriched.vision === true) hasVision = true;
|
|
217
|
+
|
|
218
|
+
// 6. Build final input array — always include "text", add "image" if vision detected
|
|
219
|
+
if (hasVision) {
|
|
220
|
+
input = input && input.includes("image") ? input : ["text", "image"];
|
|
221
|
+
} else if (!input) {
|
|
222
|
+
input = ["text"];
|
|
223
|
+
} else if (!input.includes("text")) {
|
|
224
|
+
input.unshift("text");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let loaded = status?.value === "loaded" ? true : status?.value === "unloaded" ? false : undefined;
|
|
228
|
+
if (loaded === undefined && enriched.loaded === true) loaded = true;
|
|
229
|
+
const source = String(raw.source ?? (status ? "server args" : "api"));
|
|
230
|
+
|
|
231
|
+
return { id, name, contextWindow, maxTokens, reasoning, input, source, loaded };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Live probe: OpenAI catalogue + native enrichment
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export async function fetchModels(
|
|
239
|
+
baseUrl: string,
|
|
240
|
+
apiKey?: string,
|
|
241
|
+
signal?: AbortSignal,
|
|
242
|
+
): Promise<{ models: Record<string, unknown>[]; serverType: string }> {
|
|
243
|
+
const url = baseUrl.replace(/\/+$/, "") + "/v1/models";
|
|
244
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
245
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
246
|
+
|
|
247
|
+
const response = await fetch(url, { headers, signal });
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
const body = await response.text().catch(() => "");
|
|
250
|
+
throw new Error(`HTTP ${response.status}: ${redactSecret(body.slice(0, 200), apiKey)}`);
|
|
251
|
+
}
|
|
252
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
253
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.data)) {
|
|
254
|
+
throw new Error("Invalid /v1/models response: expected a data array.");
|
|
255
|
+
}
|
|
256
|
+
const models = data.data.filter(
|
|
257
|
+
(model): model is Record<string, unknown> =>
|
|
258
|
+
!!model && typeof model === "object" && typeof (model as Record<string, unknown>).id === "string" &&
|
|
259
|
+
(model as Record<string, unknown>).id !== "",
|
|
260
|
+
);
|
|
261
|
+
if (models.length !== data.data.length) {
|
|
262
|
+
throw new Error("Invalid /v1/models response: every model must have a non-empty string id.");
|
|
263
|
+
}
|
|
264
|
+
const serverType = detectServerType(response.headers, models);
|
|
265
|
+
await enrichModels(baseUrl, apiKey, serverType, models);
|
|
266
|
+
return { models, serverType };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Best-effort enrichment from each server type's *native* (non-OpenAI) endpoints.
|
|
271
|
+
* Merges server-reported context windows, max tokens, load state, and VLM flags
|
|
272
|
+
* into the raw model objects (under ENRICH_KEY) for whatever /v1/models omitted.
|
|
273
|
+
* Never throws: a missing or failing native endpoint leaves the catalogue unchanged.
|
|
274
|
+
*/
|
|
275
|
+
export async function enrichModels(
|
|
276
|
+
baseUrl: string,
|
|
277
|
+
apiKey: string | undefined,
|
|
278
|
+
serverType: string,
|
|
279
|
+
models: Record<string, unknown>[],
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
282
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
283
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
284
|
+
const getJson = async (path: string): Promise<Record<string, unknown> | null> => {
|
|
285
|
+
try {
|
|
286
|
+
const res = await fetch(`${base}${path}`, { headers, signal: AbortSignal.timeout(1_000) });
|
|
287
|
+
if (!res.ok) return null;
|
|
288
|
+
const data = (await res.json()) as unknown;
|
|
289
|
+
return data && typeof data === "object" ? (data as Record<string, unknown>) : null;
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const list = (v: unknown): Record<string, unknown>[] => (Array.isArray(v) ? (v as Record<string, unknown>[]) : []);
|
|
295
|
+
const merge = (m: Record<string, unknown>, e: ModelEnrichment): void => {
|
|
296
|
+
const existing = (m[ENRICH_KEY] ?? {}) as ModelEnrichment;
|
|
297
|
+
m[ENRICH_KEY] = { ...existing, ...e };
|
|
298
|
+
};
|
|
299
|
+
try {
|
|
300
|
+
if (serverType === "llama.cpp") {
|
|
301
|
+
// Native /props: the real runtime context + the authoritative VLM flag
|
|
302
|
+
const props = await getJson("/props");
|
|
303
|
+
if (!props) return;
|
|
304
|
+
const gen = props.default_generation_settings as Record<string, unknown> | undefined;
|
|
305
|
+
const nCtx = gen ? tryNum(gen.n_ctx) : null;
|
|
306
|
+
const vision = Boolean((props.modalities as Record<string, unknown> | undefined)?.vision);
|
|
307
|
+
if ((nCtx !== null && nCtx > 0) || vision) {
|
|
308
|
+
for (const m of models) merge(m, { contextWindow: nCtx ?? undefined, vision });
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (serverType === "oMLX") {
|
|
313
|
+
// Extended /v1/models/status: effective context, per-model max tokens,
|
|
314
|
+
// load state, and the thinking-capable default
|
|
315
|
+
const status = await getJson("/v1/models/status");
|
|
316
|
+
if (!status) return;
|
|
317
|
+
const byId = new Map<string, ModelEnrichment>();
|
|
318
|
+
for (const entry of list(status.models)) {
|
|
319
|
+
const id = String(entry.id ?? "");
|
|
320
|
+
if (!id) continue;
|
|
321
|
+
const e: ModelEnrichment = {
|
|
322
|
+
contextWindow:
|
|
323
|
+
tryNum(entry.max_context_window) ?? tryNum(entry.model_context_length) ?? undefined,
|
|
324
|
+
maxTokens: tryNum(entry.max_tokens) ?? undefined,
|
|
325
|
+
loaded: entry.loaded === true ? true : undefined,
|
|
326
|
+
thinkingDefault: entry.thinking_default === true ? true : undefined,
|
|
327
|
+
};
|
|
328
|
+
byId.set(id, e);
|
|
329
|
+
// /v1/models may surface the user alias as the id — match both
|
|
330
|
+
const alias = String(entry.model_alias ?? "");
|
|
331
|
+
if (alias && alias !== id) byId.set(alias, e);
|
|
332
|
+
}
|
|
333
|
+
for (const m of models) {
|
|
334
|
+
const e = byId.get(String(m.id ?? ""));
|
|
335
|
+
if (e) merge(m, e);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (serverType === "Ollama") {
|
|
340
|
+
// Native /api/tags: model cards incl. the default context; /api/ps: loaded models
|
|
341
|
+
const tags = await getJson("/api/tags");
|
|
342
|
+
const tagByName = new Map<string, Record<string, unknown>>();
|
|
343
|
+
for (const t of list(tags?.models)) {
|
|
344
|
+
const name = String(t.name ?? "");
|
|
345
|
+
if (name) tagByName.set(name, t);
|
|
346
|
+
}
|
|
347
|
+
const loadedNames = new Set<string>();
|
|
348
|
+
for (const p of list((await getJson("/api/ps"))?.models)) {
|
|
349
|
+
const name = String(p.name ?? "");
|
|
350
|
+
if (name) loadedNames.add(name);
|
|
351
|
+
}
|
|
352
|
+
for (const m of models) {
|
|
353
|
+
const name = String(m.id ?? "");
|
|
354
|
+
const details = (tagByName.get(name)?.details ?? {}) as Record<string, unknown>;
|
|
355
|
+
const e: ModelEnrichment = {
|
|
356
|
+
contextWindow: tryNum(details.context_length) ?? undefined,
|
|
357
|
+
loaded: loadedNames.has(name) ? true : undefined,
|
|
358
|
+
};
|
|
359
|
+
if (e.contextWindow !== undefined || e.loaded !== undefined) merge(m, e);
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// vLLM / SGLang / LM Studio / MTPLX / generic: no reliable native metadata
|
|
364
|
+
// endpoint today (see docs/providers/ — context stays override-driven)
|
|
365
|
+
} catch {
|
|
366
|
+
/* enrichment is best-effort — never fail the scan */
|
|
367
|
+
}
|
|
368
|
+
}
|