@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.
Files changed (4) hide show
  1. package/README.md +39 -3
  2. package/index.ts +6 -214
  3. package/package.json +5 -3
  4. 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 flags, and input modalities
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.6.0
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.6.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": ["index.ts"]
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
+ }