@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 +75 -2
- package/index.ts +68 -216
- package/package.json +5 -3
- package/providers.ts +368 -0
- package/schema-repair.ts +473 -0
- package/scripts/bisect-grammar.ts +65 -0
- package/scripts/live-schema-repair-check.ts +86 -0
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.
|
|
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 (
|
|
627
|
-
|
|
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.
|
|
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": [
|
|
30
|
+
"extensions": [
|
|
31
|
+
"index.ts"
|
|
32
|
+
]
|
|
31
33
|
}
|
|
32
34
|
}
|