@maheidem/model-discovery 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,8 +4,9 @@ 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
7
+ - **Auto-detect server type** from headers and model data (incl. MTPLX's `capability` field)
8
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
9
10
  - **Auto-detect vision-capable models (VLMs)** — from architecture metadata, llama.cpp `--mmproj` args, or oMLX capabilities
10
11
  - **Auto-detect reasoning capability** — from `capabilities`, explicit `reasoning` fields, `--reasoning-budget`, and Qwen model names on oMLX
11
12
  - **Auto-detect reasoning format** — oMLX servers get `chat_template_kwargs` thinking support automatically
@@ -26,7 +27,7 @@ Interactive TUI for discovering and managing local AI model endpoints. Works wit
26
27
  pi install npm:@maheidem/model-discovery
27
28
 
28
29
  # Via git
29
- pi install git:github.com/Maheidem/model-discovery@v0.6.1
30
+ pi install git:github.com/Maheidem/model-discovery@v0.7.0
30
31
  ```
31
32
 
32
33
  ## Usage
@@ -67,6 +68,16 @@ Every field is read from what the server actually reports, first value found win
67
68
 
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**.
69
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
+
70
81
  ### Compatibility settings
71
82
 
72
83
  The extension attaches `compat` to each registered model (Pi does not merge provider-level compat into individual models):
@@ -196,6 +207,68 @@ If Pi has an `enabledModels` scope, press **Tab** in `/model` to switch from sco
196
207
 
197
208
  Profiles are retained if a model temporarily disappears during a re-scan.
198
209
 
210
+ ## Tool-schema repair (local endpoints)
211
+
212
+ llama.cpp's JSON-schema→grammar converter — the one behind llama.cpp, llama-swap, LM
213
+ Studio, and LiteLLM routes that forward to them — resolves `$ref` pointers **only
214
+ against the root of a tool schema document**. MCP servers that build schemas by nesting
215
+ Pydantic `model_json_schema()` output inside a hand-written parent routinely leave
216
+ `$defs` on an inner node while the `$ref`s inside it stay root-relative:
217
+
218
+ ```jsonc
219
+ { "properties": { "patch": {
220
+ "$defs": { "GuidelineMetricInput": { /* ... */ } }, // defs live here
221
+ "properties": { "metrics": { "items": { "$ref": "#/$defs/GuidelineMetricInput" } } }
222
+ }}}
223
+ ```
224
+
225
+ The pointer resolves against the document root, where `$defs` is not — so the server
226
+ rejects the **entire request**:
227
+
228
+ ```text
229
+ HTTP 400 {"code":400,"message":"JSON schema conversion failed:
230
+ Error resolving ref #/$defs/GuidelineMetricInput: $defs not in {...}"}
231
+ ```
232
+
233
+ Because the offending tool rides along in every tool list, *every* message in the
234
+ session fails, which looks like a broken endpoint, proxy, or model discovery rather
235
+ than a bad upstream schema.
236
+
237
+ A second llama.cpp b10612 bug was verified independently: `maxLength: 2000` below
238
+ an array's `items` schema produces `Failed to initialize samplers: failed to parse
239
+ grammar`, while 1999, 2001, and even 65536 all compile. This affected the
240
+ `okto_pulse_move_card` tool even before any `$ref` repair.
241
+
242
+ For self-hosted endpoints (private/loopback URL, or a detected local engine) the
243
+ extension normalises outgoing tool schemas in `before_provider_request`:
244
+
245
+ - `$defs` / `definitions` found at any depth are hoisted to a root registry, with
246
+ collisions de-duplicated and local refs rewritten to match;
247
+ - every `$ref` is inlined iteratively, so refs-to-refs collapse;
248
+ - unresolvable or recursive `$ref`s become permissive nodes instead of a hard 400;
249
+ - `$ref` / `$defs` never reach the wire, and annotation siblings (`description`,
250
+ `title`) are preserved;
251
+ - the exact nested-array `maxLength: 2000` failure is sent as 2001. This is the
252
+ least-permissive working neighbour; the MCP server still validates its real 2000 limit.
253
+
254
+ Cloud APIs and clean payloads are left byte-identical (the payload object's identity is
255
+ returned, no cloning). Repairing a 521-tool catalogue costs ~1.5 ms. Opt out per
256
+ provider with `"repairToolSchemas": false` in `~/.pi/agent/model-discovery.json`, or
257
+ globally with `PI_MODEL_DISCOVERY_NO_SCHEMA_REPAIR=1`. Each distinct repair is logged
258
+ once as `[model-discovery] <provider>: repaired N local tool schema(s): …`.
259
+
260
+ Live verification against llama-swap v251 → llama.cpp b10612: the raw 521-tool MCP
261
+ catalogue returned HTTP 400; all 16 affected schemas were repaired in flight; the same
262
+ request then returned HTTP 200 with no residual `$ref`/`$defs` on the wire.
263
+
264
+ Verification:
265
+
266
+ ```bash
267
+ npm test # includes schema-repair.test.ts
268
+ node --experimental-strip-types scripts/live-schema-repair-check.ts http://HOST
269
+ node --experimental-strip-types scripts/bisect-grammar.ts http://HOST MODEL FILTER
270
+ ```
271
+
199
272
  ## Offline resilience
200
273
 
201
274
  Every successful live scan atomically persists the raw model catalogue as the source's last known-good cache. Saved sources are scanned independently and concurrently at startup. If one source is offline, times out, rejects its credentials, or returns a malformed response:
package/index.ts CHANGED
@@ -40,6 +40,18 @@ 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";
49
+ import {
50
+ describeToolSchemaRepair,
51
+ isLocalEndpointUrl,
52
+ repairRequestToolSchemas,
53
+ type ToolSchemaRepairReport,
54
+ } from "./schema-repair.ts";
43
55
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
44
56
  import { join } from "node:path";
45
57
  import os from "node:os";
@@ -54,7 +66,6 @@ interface ModelOverride {
54
66
  reasoning?: boolean;
55
67
  input?: string[];
56
68
  }
57
-
58
69
  interface DiscoveredProvider {
59
70
  name: string;
60
71
  baseUrl: string;
@@ -68,23 +79,18 @@ interface DiscoveredProvider {
68
79
  profileSchemaVersion?: number;
69
80
  cachedModels?: Record<string, unknown>[];
70
81
  compat?: Record<string, unknown>;
82
+ /**
83
+ * Inline $defs/$ref in outgoing tool schemas for this endpoint (default: true for
84
+ * local/self-hosted endpoints, where llama.cpp-style grammar converters reject any
85
+ * $ref that is not resolvable at the document root). Set false to send verbatim.
86
+ */
87
+ repairToolSchemas?: boolean;
71
88
  /** Last successful live catalogue refresh (legacy name retained in storage). */
72
89
  lastScanned?: number;
73
90
  lastScanAttempt?: number;
74
91
  lastScanError?: string;
75
92
  }
76
93
 
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
94
  // ---------------------------------------------------------------------------
89
95
  // Storage
90
96
  // ---------------------------------------------------------------------------
@@ -153,10 +159,6 @@ function errorMessage(error: unknown): string {
153
159
  return error instanceof Error ? error.message : String(error);
154
160
  }
155
161
 
156
- function redactSecret(value: string, secret?: string): string {
157
- return secret ? value.replaceAll(secret, "[redacted]") : value;
158
- }
159
-
160
162
  function persistProviderScanState(provider: DiscoveredProvider): void {
161
163
  try {
162
164
  const providers = loadProviders();
@@ -251,204 +253,6 @@ function deleteModelProfileRouting(provider: DiscoveredProvider, modelId: string
251
253
  provider.modelProfileRouting = Object.keys(routing).length > 0 ? routing : undefined;
252
254
  }
253
255
 
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
256
  function generateProviderName(url: string): string {
453
257
  try {
454
258
  const u = new URL(url);
@@ -475,8 +279,36 @@ export default async function (pi: ExtensionAPI) {
475
279
  };
476
280
  const thinkingRoutes = new Map<string, RuntimeThinkingRoutes>();
477
281
  const fixedProfileLabels = new Map<string, string>();
282
+ /** Providers whose outgoing tool schemas get local grammar compatibility repair. */
283
+ const schemaRepairProviders = new Set<string>();
284
+ /** Repair notices already surfaced, so a per-request hook never spams the log. */
285
+ const schemaRepairNotices = new Set<string>();
478
286
  const routeKey = (providerName: string, modelId: string): string => `${providerName}/${modelId}`;
479
287
 
288
+ /**
289
+ * llama.cpp (and llama-swap / LM Studio / LiteLLM routes that forward to it) has
290
+ * strict JSON-schema→grammar compatibility limits: root-scoped $ref resolution and,
291
+ * in b10612, one exact nested maxLength parser failure. A single incompatible MCP
292
+ * tool makes *every* message 400. Local endpoints get their schemas normalised;
293
+ * cloud APIs stay byte-identical. See schema-repair.ts.
294
+ */
295
+ function shouldRepairToolSchemas(provider: DiscoveredProvider, serverType: string): boolean {
296
+ if (provider.repairToolSchemas === false) return false;
297
+ if (process.env.PI_MODEL_DISCOVERY_NO_SCHEMA_REPAIR) return false;
298
+ if (provider.repairToolSchemas === true) return true;
299
+ const LOCAL_ENGINES = ["llama.cpp", "oMLX", "Ollama", "vLLM", "SGLang", "LM Studio", "llama-swap"];
300
+ return LOCAL_ENGINES.some((needle) => serverType.toLowerCase().includes(needle.toLowerCase())) || isLocalEndpointUrl(provider.baseUrl);
301
+ }
302
+
303
+ function noteToolSchemaRepair(providerName: string, report: ToolSchemaRepairReport): void {
304
+ if (!report.changed) return;
305
+ const summary = describeToolSchemaRepair(report);
306
+ const signature = `${providerName}::${summary}`;
307
+ if (schemaRepairNotices.has(signature)) return;
308
+ schemaRepairNotices.add(signature);
309
+ console.error(`[model-discovery] ${providerName}: ${summary}`);
310
+ }
311
+
480
312
  // -----------------------------------------------------------------------
481
313
  // Provider registration with Pi's model registry
482
314
  // -----------------------------------------------------------------------
@@ -499,6 +331,8 @@ export default async function (pi: ExtensionAPI) {
499
331
  if (serverType === "llama.cpp" || serverType === "oMLX" || serverType === "Ollama") {
500
332
  if (compat.supportsDeveloperRole === undefined) compat.supportsDeveloperRole = false;
501
333
  }
334
+ if (shouldRepairToolSchemas(provider, serverType)) schemaRepairProviders.add(provider.name);
335
+ else schemaRepairProviders.delete(provider.name);
502
336
  if (serverType === "oMLX") {
503
337
  // Preserve the pre-profile base-model behavior. Fixed and adaptive profile
504
338
  // aliases supply their own complete chat-template kwargs independently.
@@ -622,9 +456,27 @@ export default async function (pi: ExtensionAPI) {
622
456
  }
623
457
 
624
458
  pi.on("before_provider_request", (event, ctx) => {
459
+ let payload: unknown = event.payload;
460
+ let touched = false;
461
+
625
462
  const active = activeThinkingRoute(ctx);
626
- if (!active) return undefined;
627
- return applyThinkingProfileRoute(event.payload, active.profile, active.runtime.repetitionPenaltyKey);
463
+ if (active) {
464
+ payload = applyThinkingProfileRoute(payload, active.profile, active.runtime.repetitionPenaltyKey);
465
+ touched = true;
466
+ }
467
+
468
+ // Repair local tool schemas so llama.cpp-style grammar converters accept them.
469
+ const providerName = ctx.model?.provider;
470
+ if (providerName && schemaRepairProviders.has(providerName)) {
471
+ const repaired = repairRequestToolSchemas(payload);
472
+ if (repaired.report.changed) {
473
+ noteToolSchemaRepair(providerName, repaired.report);
474
+ payload = repaired.payload;
475
+ touched = true;
476
+ }
477
+ }
478
+
479
+ return touched ? payload : undefined;
628
480
  });
629
481
 
630
482
  const updateThinkingProfileStatus = (ctx: ExtensionContext): void => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maheidem/model-discovery",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
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 schema-repair.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
  }