@maheidem/model-discovery 0.7.0 → 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 +62 -0
- package/index.ts +62 -2
- package/package.json +2 -2
- 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
|
@@ -207,6 +207,68 @@ If Pi has an `enabledModels` scope, press **Tab** in `/model` to switch from sco
|
|
|
207
207
|
|
|
208
208
|
Profiles are retained if a model temporarily disappears during a re-scan.
|
|
209
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
|
+
|
|
210
272
|
## Offline resilience
|
|
211
273
|
|
|
212
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
|
@@ -46,6 +46,12 @@ import {
|
|
|
46
46
|
redactSecret,
|
|
47
47
|
type ModelConfig,
|
|
48
48
|
} from "./providers.ts";
|
|
49
|
+
import {
|
|
50
|
+
describeToolSchemaRepair,
|
|
51
|
+
isLocalEndpointUrl,
|
|
52
|
+
repairRequestToolSchemas,
|
|
53
|
+
type ToolSchemaRepairReport,
|
|
54
|
+
} from "./schema-repair.ts";
|
|
49
55
|
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
50
56
|
import { join } from "node:path";
|
|
51
57
|
import os from "node:os";
|
|
@@ -73,6 +79,12 @@ interface DiscoveredProvider {
|
|
|
73
79
|
profileSchemaVersion?: number;
|
|
74
80
|
cachedModels?: Record<string, unknown>[];
|
|
75
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;
|
|
76
88
|
/** Last successful live catalogue refresh (legacy name retained in storage). */
|
|
77
89
|
lastScanned?: number;
|
|
78
90
|
lastScanAttempt?: number;
|
|
@@ -267,8 +279,36 @@ export default async function (pi: ExtensionAPI) {
|
|
|
267
279
|
};
|
|
268
280
|
const thinkingRoutes = new Map<string, RuntimeThinkingRoutes>();
|
|
269
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>();
|
|
270
286
|
const routeKey = (providerName: string, modelId: string): string => `${providerName}/${modelId}`;
|
|
271
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
|
+
|
|
272
312
|
// -----------------------------------------------------------------------
|
|
273
313
|
// Provider registration with Pi's model registry
|
|
274
314
|
// -----------------------------------------------------------------------
|
|
@@ -291,6 +331,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
291
331
|
if (serverType === "llama.cpp" || serverType === "oMLX" || serverType === "Ollama") {
|
|
292
332
|
if (compat.supportsDeveloperRole === undefined) compat.supportsDeveloperRole = false;
|
|
293
333
|
}
|
|
334
|
+
if (shouldRepairToolSchemas(provider, serverType)) schemaRepairProviders.add(provider.name);
|
|
335
|
+
else schemaRepairProviders.delete(provider.name);
|
|
294
336
|
if (serverType === "oMLX") {
|
|
295
337
|
// Preserve the pre-profile base-model behavior. Fixed and adaptive profile
|
|
296
338
|
// aliases supply their own complete chat-template kwargs independently.
|
|
@@ -414,9 +456,27 @@ export default async function (pi: ExtensionAPI) {
|
|
|
414
456
|
}
|
|
415
457
|
|
|
416
458
|
pi.on("before_provider_request", (event, ctx) => {
|
|
459
|
+
let payload: unknown = event.payload;
|
|
460
|
+
let touched = false;
|
|
461
|
+
|
|
417
462
|
const active = activeThinkingRoute(ctx);
|
|
418
|
-
if (
|
|
419
|
-
|
|
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;
|
|
420
480
|
});
|
|
421
481
|
|
|
422
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.7.
|
|
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 enrichment.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",
|
package/schema-repair.ts
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-schema repair for self-hosted OpenAI-compatible backends.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* llama.cpp's `json_schema_to_grammar` (the converter behind llama.cpp, llama-swap,
|
|
7
|
+
* LM Studio, and LiteLLM routes that forward to them) resolves `$ref` pointers
|
|
8
|
+
* **only against the root of the tool schema document**. MCP servers commonly build
|
|
9
|
+
* tool schemas by nesting Pydantic `model_json_schema()` output inside a hand-written
|
|
10
|
+
* parent schema, which leaves `$defs` sitting on an inner node while the `$ref`s
|
|
11
|
+
* inside it stay root-relative:
|
|
12
|
+
*
|
|
13
|
+
* { "properties": { "patch": {
|
|
14
|
+
* "$defs": { "GuidelineMetricInput": { ... } }, // defs live HERE
|
|
15
|
+
* "properties": { "metrics": { "items": { "$ref": "#/$defs/GuidelineMetricInput" } } }
|
|
16
|
+
* }}}
|
|
17
|
+
*
|
|
18
|
+
* The pointer says "document root", but `$defs` is not at the root, so llama.cpp
|
|
19
|
+
* rejects the whole request:
|
|
20
|
+
*
|
|
21
|
+
* HTTP 400 {"code":400,"message":"JSON schema conversion failed:
|
|
22
|
+
* Error resolving ref #/$defs/GuidelineMetricInput: $defs not in {...}"}
|
|
23
|
+
*
|
|
24
|
+
* The broken tool rides along in the tool list, so *every* message in the session
|
|
25
|
+
* fails — which looks like the endpoint, the proxy, or model discovery is broken.
|
|
26
|
+
*
|
|
27
|
+
* Verified live against llama-swap v251 -> llama.cpp b10612:
|
|
28
|
+
* nested `$defs` + root-relative `$ref` -> HTTP 400 (the failure above)
|
|
29
|
+
* `$defs` hoisted to document root -> HTTP 200 (~15 s grammar compile)
|
|
30
|
+
* `$ref`s fully inlined, no `$defs` -> HTTP 200 (fast; what this module does)
|
|
31
|
+
*
|
|
32
|
+
* That build has a second, exact converter bug: a string with `maxLength: 2000`
|
|
33
|
+
* below an array's `items` schema produces "Failed to initialize samplers: failed
|
|
34
|
+
* to parse grammar". The neighbouring values 1999 and 2001, and even 65536, work.
|
|
35
|
+
* The wire schema therefore uses 2001 at that exact position; the MCP server remains
|
|
36
|
+
* the source of truth and still validates the real 2000-character limit.
|
|
37
|
+
*
|
|
38
|
+
* BEHAVIOUR
|
|
39
|
+
* ---------
|
|
40
|
+
* `repairRequestToolSchemas(payload)` normalises every tool schema in an outgoing
|
|
41
|
+
* /chat/completions payload:
|
|
42
|
+
* 1. `$defs` / `definitions` found at ANY depth are hoisted to a root registry
|
|
43
|
+
* (name collisions are de-duplicated, local refs rewritten to match);
|
|
44
|
+
* 2. every `$ref` is inlined, iteratively, so refs-to-refs collapse;
|
|
45
|
+
* 3. unresolvable or cyclic refs become permissive nodes instead of a hard 400;
|
|
46
|
+
* 4. `$defs` / `definitions` / `$ref` never reach the wire;
|
|
47
|
+
* 5. nested-array `maxLength: 2000` is changed to 2001 for llama.cpp grammar
|
|
48
|
+
* compatibility (only that exact, proven-broken value).
|
|
49
|
+
*
|
|
50
|
+
* No-op (original object identity, zero cloning) when nothing needs repairing.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
export interface ToolSchemaRepairReport {
|
|
54
|
+
/** Names of tools whose parameters schema was rewritten. */
|
|
55
|
+
repairedTools: string[];
|
|
56
|
+
/** `$ref` targets that could not be resolved; loosened to accept anything. */
|
|
57
|
+
droppedRefs: string[];
|
|
58
|
+
/** `$ref` targets forming a cycle; loosened to accept anything. */
|
|
59
|
+
cyclicRefs: string[];
|
|
60
|
+
/** Exact nested-array maxLength=2000 occurrences changed to 2001. */
|
|
61
|
+
adjustedGrammarLimits: number;
|
|
62
|
+
/** True when the payload was replaced (false = original identity kept). */
|
|
63
|
+
changed: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const EMPTY_REPORT: ToolSchemaRepairReport = {
|
|
67
|
+
repairedTools: [],
|
|
68
|
+
droppedRefs: [],
|
|
69
|
+
cyclicRefs: [],
|
|
70
|
+
adjustedGrammarLimits: 0,
|
|
71
|
+
changed: false,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const NOTICE_LIMIT = 6;
|
|
75
|
+
const MAX_DEPTH = 512;
|
|
76
|
+
export const LLAMA_CPP_BROKEN_NESTED_MAX_LENGTH = 2000;
|
|
77
|
+
export const LLAMA_CPP_SAFE_NESTED_MAX_LENGTH = 2001;
|
|
78
|
+
|
|
79
|
+
const DEFS_KEYWORDS = ["$defs", "definitions"];
|
|
80
|
+
const REF_KEYWORDS = ["$ref"];
|
|
81
|
+
|
|
82
|
+
/** Keywords whose value is a single subschema. */
|
|
83
|
+
const SCHEMA_KEYWORDS = new Set([
|
|
84
|
+
"items",
|
|
85
|
+
"additionalItems",
|
|
86
|
+
"additionalProperties",
|
|
87
|
+
"unevaluatedItems",
|
|
88
|
+
"unevaluatedProperties",
|
|
89
|
+
"contains",
|
|
90
|
+
"propertyNames",
|
|
91
|
+
"if",
|
|
92
|
+
"then",
|
|
93
|
+
"else",
|
|
94
|
+
"not",
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/** Keywords whose value is an array of subschemas. */
|
|
98
|
+
const SCHEMA_ARRAY_KEYWORDS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
|
|
99
|
+
|
|
100
|
+
/** Keywords whose value is a map of name -> subschema. */
|
|
101
|
+
const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties", "dependentSchemas"]);
|
|
102
|
+
|
|
103
|
+
type Rec = Record<string, unknown>;
|
|
104
|
+
|
|
105
|
+
function isRecord(value: unknown): value is Rec {
|
|
106
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function fingerprint(value: unknown): string {
|
|
110
|
+
return JSON.stringify(value) ?? "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Detection (fast path)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export function schemaNeedsRepair(node: unknown, depth = 0, insideArrayItem = false): boolean {
|
|
118
|
+
if (depth > MAX_DEPTH) return false;
|
|
119
|
+
if (Array.isArray(node)) {
|
|
120
|
+
for (const item of node) if (schemaNeedsRepair(item, depth + 1, insideArrayItem)) return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (!isRecord(node)) return false;
|
|
124
|
+
if (insideArrayItem && node.maxLength === LLAMA_CPP_BROKEN_NESTED_MAX_LENGTH) return true;
|
|
125
|
+
for (const key of REF_KEYWORDS) if (typeof node[key] === "string") return true;
|
|
126
|
+
for (const key of DEFS_KEYWORDS) if (isRecord(node[key])) return true;
|
|
127
|
+
for (const [key, value] of Object.entries(node)) {
|
|
128
|
+
const childInsideArrayItem = insideArrayItem || (key === "items" && node.type === "array");
|
|
129
|
+
if (schemaNeedsRepair(value, depth + 1, childInsideArrayItem)) return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// JSON pointer helpers
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function decodeToken(token: string): string {
|
|
139
|
+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface ParsedRef {
|
|
143
|
+
kind: "defs" | "pointer" | "external";
|
|
144
|
+
tokens: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Split `$ref` into its kind and path tokens. Only same-document refs are resolvable. */
|
|
148
|
+
export function parseRef(ref: string): ParsedRef {
|
|
149
|
+
if (!ref.startsWith("#") && !ref.startsWith("/")) return { kind: "external", tokens: [] };
|
|
150
|
+
if (ref.includes("://")) return { kind: "external", tokens: [] };
|
|
151
|
+
const fragment = (ref.startsWith("#") ? ref.slice(1) : ref).replace(/^\//, "");
|
|
152
|
+
if (fragment === "") return { kind: "pointer", tokens: [] };
|
|
153
|
+
const tokens = fragment.split("/").map(decodeToken);
|
|
154
|
+
if (tokens.length > 1 && (tokens[0] === "$defs" || tokens[0] === "definitions")) {
|
|
155
|
+
return { kind: "defs", tokens: tokens.slice(1) };
|
|
156
|
+
}
|
|
157
|
+
return { kind: "pointer", tokens };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function lookupPointer(root: unknown, tokens: string[]): unknown {
|
|
161
|
+
let cursor: unknown = root;
|
|
162
|
+
for (const token of tokens) {
|
|
163
|
+
if (Array.isArray(cursor)) {
|
|
164
|
+
const index = Number(token);
|
|
165
|
+
if (!Number.isInteger(index) || index < 0 || index >= cursor.length) return undefined;
|
|
166
|
+
cursor = cursor[index];
|
|
167
|
+
} else if (isRecord(cursor) && token in cursor) {
|
|
168
|
+
cursor = cursor[token];
|
|
169
|
+
} else {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return cursor;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sanitizeName(raw: string): string {
|
|
177
|
+
return raw.replace(/[^A-Za-z0-9_]/g, "_");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Register `def` under a unique key; identical content reuses the existing key. */
|
|
181
|
+
function claimKey(registry: Rec, preferred: string, def: unknown): string {
|
|
182
|
+
const base = sanitizeName(preferred) || `Def${Object.keys(registry).length + 1}`;
|
|
183
|
+
if (!(base in registry)) return base;
|
|
184
|
+
if (fingerprint(registry[base]) === fingerprint(def)) return base;
|
|
185
|
+
let key = `${base}_2`;
|
|
186
|
+
let n = 2;
|
|
187
|
+
while (key in registry && fingerprint(registry[key]) !== fingerprint(def)) {
|
|
188
|
+
key = `${base}_${++n}`;
|
|
189
|
+
}
|
|
190
|
+
return key;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Rewrite `#/$defs/X` / `#/definitions/X` refs according to a rename map. */
|
|
194
|
+
function applyRenames(node: unknown, renames: Map<string, string>, depth = 0): unknown {
|
|
195
|
+
if (depth > MAX_DEPTH || renames.size === 0) return node;
|
|
196
|
+
if (Array.isArray(node)) return node.map((item) => applyRenames(item, renames, depth + 1));
|
|
197
|
+
if (!isRecord(node)) return node;
|
|
198
|
+
const next: Rec = {};
|
|
199
|
+
for (const [key, value] of Object.entries(node)) {
|
|
200
|
+
if (key === "$ref" && typeof value === "string") {
|
|
201
|
+
const parsed = parseRef(value);
|
|
202
|
+
if (parsed.kind === "defs" && parsed.tokens.length === 1) {
|
|
203
|
+
next[key] = `#/$defs/${renames.get(parsed.tokens[0]) ?? parsed.tokens[0]}`;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
next[key] = value;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
next[key] = applyRenames(value, renames, depth + 1);
|
|
210
|
+
}
|
|
211
|
+
return next;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Pass 1 — hoist every nested $defs / definitions to a root registry
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function hoistDefs(node: unknown, registry: Rec, depth = 0): unknown {
|
|
219
|
+
if (depth > MAX_DEPTH) return node;
|
|
220
|
+
if (Array.isArray(node)) return node.map((item) => hoistDefs(item, registry, depth + 1));
|
|
221
|
+
if (!isRecord(node)) return node;
|
|
222
|
+
|
|
223
|
+
// Definitions declared on THIS node (the Pydantic-nesting artefact). They are
|
|
224
|
+
// invisible to a root-scoped resolver, so lift them and rename on collision.
|
|
225
|
+
const renames = new Map<string, string>();
|
|
226
|
+
const pending: Array<{ key: string; def: unknown }> = [];
|
|
227
|
+
for (const defsKey of DEFS_KEYWORDS) {
|
|
228
|
+
const defs = node[defsKey];
|
|
229
|
+
if (!isRecord(defs)) continue;
|
|
230
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
231
|
+
const key = claimKey(registry, name, def);
|
|
232
|
+
renames.set(name, key);
|
|
233
|
+
pending.push({ key, def });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const out: Rec = {};
|
|
238
|
+
for (const [key, value] of Object.entries(node)) {
|
|
239
|
+
if (DEFS_KEYWORDS.includes(key)) continue;
|
|
240
|
+
out[key] = hoistDefs(applyRenames(value, renames), registry, depth + 1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Register after the subtree so nested defs inside them get claimed too.
|
|
244
|
+
for (const { key, def } of pending) {
|
|
245
|
+
if (!(key in registry)) {
|
|
246
|
+
registry[key] = hoistDefs(applyRenames(def, renames), registry, depth + 1);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Pass 2 — inline every $ref
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
interface InlineState {
|
|
258
|
+
dropped: string[];
|
|
259
|
+
cyclic: string[];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resolveRefTarget(ref: string, registry: Rec, root: Rec): unknown {
|
|
263
|
+
const parsed = parseRef(ref);
|
|
264
|
+
if (parsed.kind === "external") return undefined;
|
|
265
|
+
if (parsed.kind === "defs") {
|
|
266
|
+
const head = lookupPointer(registry, parsed.tokens);
|
|
267
|
+
if (head !== undefined) return head;
|
|
268
|
+
return lookupPointer(root, parsed.tokens); // fall back to literal root path
|
|
269
|
+
}
|
|
270
|
+
return lookupPointer(root, parsed.tokens);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function inlineRefs(node: unknown, registry: Rec, root: Rec, stack: string[], state: InlineState, depth = 0): unknown {
|
|
274
|
+
if (depth > MAX_DEPTH) return {};
|
|
275
|
+
if (Array.isArray(node)) return node.map((item) => inlineRefs(item, registry, root, stack, state, depth + 1));
|
|
276
|
+
if (!isRecord(node)) return node;
|
|
277
|
+
|
|
278
|
+
const own: Rec = {};
|
|
279
|
+
for (const [key, value] of Object.entries(node)) {
|
|
280
|
+
if (key === "$ref" || DEFS_KEYWORDS.includes(key)) continue;
|
|
281
|
+
if (SCHEMA_KEYWORDS.has(key)) own[key] = inlineRefs(value, registry, root, stack, state, depth + 1);
|
|
282
|
+
else if (SCHEMA_ARRAY_KEYWORDS.has(key) && Array.isArray(value)) {
|
|
283
|
+
own[key] = value.map((item) => inlineRefs(item, registry, root, stack, state, depth + 1));
|
|
284
|
+
} else if (SCHEMA_MAP_KEYWORDS.has(key) && isRecord(value)) {
|
|
285
|
+
const bag: Rec = {};
|
|
286
|
+
for (const [pk, pv] of Object.entries(value)) bag[pk] = inlineRefs(pv, registry, root, stack, state, depth + 1);
|
|
287
|
+
own[key] = bag;
|
|
288
|
+
} else if (key === "required" && Array.isArray(value)) own[key] = [...value];
|
|
289
|
+
else own[key] = value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const ref = node.$ref;
|
|
293
|
+
if (typeof ref !== "string") return own;
|
|
294
|
+
|
|
295
|
+
if (stack.includes(ref)) {
|
|
296
|
+
// Recursive schema — cannot inline infinitely. Accept anything at this node.
|
|
297
|
+
state.cyclic.push(ref);
|
|
298
|
+
return own;
|
|
299
|
+
}
|
|
300
|
+
const target = resolveRefTarget(ref, registry, root);
|
|
301
|
+
if (target === undefined) {
|
|
302
|
+
// Dangling pointer. llama.cpp fails the entire request on these.
|
|
303
|
+
state.dropped.push(ref);
|
|
304
|
+
return own;
|
|
305
|
+
}
|
|
306
|
+
const inlined = inlineRefs(target, registry, root, [...stack, ref], state, depth + 1);
|
|
307
|
+
// Target supplies structure; local siblings (description/title/examples) win.
|
|
308
|
+
return { ...(isRecord(inlined) ? inlined : {}), ...own };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Last line of defence: no `$ref` / `$defs` may survive onto the wire. */
|
|
312
|
+
function stripRefArtifacts(node: unknown, depth = 0): unknown {
|
|
313
|
+
if (depth > MAX_DEPTH) return {};
|
|
314
|
+
if (Array.isArray(node)) return node.map((item) => stripRefArtifacts(item, depth + 1));
|
|
315
|
+
if (!isRecord(node)) return node;
|
|
316
|
+
const next: Rec = {};
|
|
317
|
+
for (const [key, value] of Object.entries(node)) {
|
|
318
|
+
if (key === "$ref" || DEFS_KEYWORDS.includes(key)) continue;
|
|
319
|
+
next[key] = stripRefArtifacts(value, depth + 1);
|
|
320
|
+
}
|
|
321
|
+
return next;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface GrammarCompatibilityState {
|
|
325
|
+
adjustedLimits: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Work around llama.cpp b10612's exact nested-array maxLength=2000 parser bug.
|
|
330
|
+
* 1999, 2001, and much larger values work; 2001 is the least permissive safe wire
|
|
331
|
+
* value and the MCP tool still enforces its authoritative 2000-character limit.
|
|
332
|
+
*/
|
|
333
|
+
function normalizeGrammarCompatibility(
|
|
334
|
+
node: unknown,
|
|
335
|
+
state: GrammarCompatibilityState,
|
|
336
|
+
insideArrayItem = false,
|
|
337
|
+
depth = 0,
|
|
338
|
+
): unknown {
|
|
339
|
+
if (depth > MAX_DEPTH) return {};
|
|
340
|
+
if (Array.isArray(node)) {
|
|
341
|
+
return node.map((item) => normalizeGrammarCompatibility(item, state, insideArrayItem, depth + 1));
|
|
342
|
+
}
|
|
343
|
+
if (!isRecord(node)) return node;
|
|
344
|
+
const next: Rec = {};
|
|
345
|
+
for (const [key, value] of Object.entries(node)) {
|
|
346
|
+
if (key === "maxLength" && insideArrayItem && value === LLAMA_CPP_BROKEN_NESTED_MAX_LENGTH) {
|
|
347
|
+
next[key] = LLAMA_CPP_SAFE_NESTED_MAX_LENGTH;
|
|
348
|
+
state.adjustedLimits++;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const childInsideArrayItem = insideArrayItem || (key === "items" && node.type === "array");
|
|
352
|
+
next[key] = normalizeGrammarCompatibility(value, state, childInsideArrayItem, depth + 1);
|
|
353
|
+
}
|
|
354
|
+
return next;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Public API
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
export interface SingleSchemaRepair {
|
|
362
|
+
schema: unknown;
|
|
363
|
+
repaired: boolean;
|
|
364
|
+
dropped: string[];
|
|
365
|
+
cyclic: string[];
|
|
366
|
+
adjustedGrammarLimits: number;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Repair one tool `parameters` schema. Returns the original identity when clean. */
|
|
370
|
+
export function repairToolSchema(parameters: unknown): SingleSchemaRepair {
|
|
371
|
+
if (!isRecord(parameters) || !schemaNeedsRepair(parameters)) {
|
|
372
|
+
return { schema: parameters, repaired: false, dropped: [], cyclic: [], adjustedGrammarLimits: 0 };
|
|
373
|
+
}
|
|
374
|
+
const registry: Rec = {};
|
|
375
|
+
// Seed with the author's own root-level defs so root refs still resolve.
|
|
376
|
+
for (const defsKey of DEFS_KEYWORDS) {
|
|
377
|
+
const defs = parameters[defsKey];
|
|
378
|
+
if (isRecord(defs)) {
|
|
379
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
380
|
+
const key = claimKey(registry, name, def);
|
|
381
|
+
registry[key] = def;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const hoisted = hoistDefs(parameters, registry);
|
|
386
|
+
const state: InlineState = { dropped: [], cyclic: [] };
|
|
387
|
+
const inlined = inlineRefs(hoisted, registry, parameters, [], state);
|
|
388
|
+
const cleaned = stripRefArtifacts(inlined);
|
|
389
|
+
const grammarState: GrammarCompatibilityState = { adjustedLimits: 0 };
|
|
390
|
+
const compatible = normalizeGrammarCompatibility(cleaned, grammarState);
|
|
391
|
+
return {
|
|
392
|
+
schema: compatible,
|
|
393
|
+
repaired: fingerprint(compatible) !== fingerprint(parameters),
|
|
394
|
+
dropped: [...new Set(state.dropped)],
|
|
395
|
+
cyclic: [...new Set(state.cyclic)],
|
|
396
|
+
adjustedGrammarLimits: grammarState.adjustedLimits,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Repair every tool schema in an outgoing chat/completions payload (identity if clean). */
|
|
401
|
+
export function repairRequestToolSchemas(payload: unknown): { payload: unknown; report: ToolSchemaRepairReport } {
|
|
402
|
+
if (!isRecord(payload)) return { payload, report: EMPTY_REPORT };
|
|
403
|
+
const tools = payload.tools;
|
|
404
|
+
if (!Array.isArray(tools) || tools.length === 0) return { payload, report: EMPTY_REPORT };
|
|
405
|
+
|
|
406
|
+
const repairedTools: string[] = [];
|
|
407
|
+
const droppedRefs: string[] = [];
|
|
408
|
+
const cyclicRefs: string[] = [];
|
|
409
|
+
let adjustedGrammarLimits = 0;
|
|
410
|
+
let nextTools: unknown[] | undefined;
|
|
411
|
+
|
|
412
|
+
tools.forEach((tool, index) => {
|
|
413
|
+
if (!isRecord(tool)) return;
|
|
414
|
+
const fn = isRecord(tool.function) ? tool.function : undefined;
|
|
415
|
+
const holder: Rec | undefined = fn ?? tool;
|
|
416
|
+
const parameters = holder?.parameters;
|
|
417
|
+
if (!isRecord(parameters)) return;
|
|
418
|
+
const result = repairToolSchema(parameters);
|
|
419
|
+
if (!result.repaired) return;
|
|
420
|
+
|
|
421
|
+
repairedTools.push(String(fn?.name ?? tool.name ?? `tool[${index}]`));
|
|
422
|
+
droppedRefs.push(...result.dropped);
|
|
423
|
+
cyclicRefs.push(...result.cyclic);
|
|
424
|
+
adjustedGrammarLimits += result.adjustedGrammarLimits;
|
|
425
|
+
if (!nextTools) nextTools = [...tools];
|
|
426
|
+
nextTools[index] = fn
|
|
427
|
+
? { ...tool, function: { ...fn, parameters: result.schema } }
|
|
428
|
+
: { ...tool, parameters: result.schema };
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (!nextTools) return { payload, report: EMPTY_REPORT };
|
|
432
|
+
return {
|
|
433
|
+
payload: { ...payload, tools: nextTools },
|
|
434
|
+
report: {
|
|
435
|
+
repairedTools,
|
|
436
|
+
droppedRefs: [...new Set(droppedRefs)],
|
|
437
|
+
cyclicRefs: [...new Set(cyclicRefs)],
|
|
438
|
+
adjustedGrammarLimits,
|
|
439
|
+
changed: true,
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** One-line human summary for a log/status notice. */
|
|
445
|
+
export function describeToolSchemaRepair(report: ToolSchemaRepairReport): string {
|
|
446
|
+
if (!report.changed) return "";
|
|
447
|
+
const shown = report.repairedTools.slice(0, NOTICE_LIMIT).join(", ");
|
|
448
|
+
const more = report.repairedTools.length > NOTICE_LIMIT ? ` +${report.repairedTools.length - NOTICE_LIMIT} more` : "";
|
|
449
|
+
const parts = [`repaired ${report.repairedTools.length} local tool schema(s): ${shown}${more}`];
|
|
450
|
+
if (report.adjustedGrammarLimits > 0) {
|
|
451
|
+
parts.push(`worked around nested maxLength=2000 grammar bug in ${report.adjustedGrammarLimits} location(s)`);
|
|
452
|
+
}
|
|
453
|
+
if (report.droppedRefs.length > 0) parts.push(`loosened unresolvable refs: ${report.droppedRefs.slice(0, NOTICE_LIMIT).join(", ")}`);
|
|
454
|
+
if (report.cyclicRefs.length > 0) parts.push(`loosened recursive refs: ${report.cyclicRefs.slice(0, NOTICE_LIMIT).join(", ")}`);
|
|
455
|
+
return parts.join("; ");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** True for self-hosted endpoints on the local network (where grammar converters live). */
|
|
459
|
+
export function isLocalEndpointUrl(baseUrl: string): boolean {
|
|
460
|
+
let host: string;
|
|
461
|
+
try {
|
|
462
|
+
host = new URL(baseUrl).hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
463
|
+
} catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host === "::1" || host === "0.0.0.0") return true;
|
|
467
|
+
const octets = host.split(".").map(Number);
|
|
468
|
+
if (octets.length === 4 && octets.every((o) => Number.isInteger(o) && o >= 0 && o <= 255)) {
|
|
469
|
+
const [a, b] = octets as [number, number];
|
|
470
|
+
if (a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) return true;
|
|
471
|
+
}
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identify individual MCP tool schemas a llama.cpp endpoint cannot compile.
|
|
3
|
+
* node --experimental-strip-types scripts/bisect-grammar.ts BASE_URL [MODEL] [TOOL_FILTER]
|
|
4
|
+
*
|
|
5
|
+
* The repair is applied first; a failure here therefore isolates an additional
|
|
6
|
+
* converter limitation rather than the known $defs/$ref issue.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { repairRequestToolSchemas } from "../schema-repair.ts";
|
|
12
|
+
|
|
13
|
+
const BASE = process.argv[2];
|
|
14
|
+
const MODEL = process.argv[3] ?? "qwen3.8-27b";
|
|
15
|
+
const FILTER = process.argv[4];
|
|
16
|
+
const CACHE = process.env.PI_MCP_CACHE ?? join(homedir(), ".pi", "agent", "mcp-cache.json");
|
|
17
|
+
|
|
18
|
+
if (!BASE) {
|
|
19
|
+
console.error("usage: node --experimental-strip-types scripts/bisect-grammar.ts BASE_URL [MODEL] [TOOL_FILTER]");
|
|
20
|
+
process.exit(2);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cache = JSON.parse(readFileSync(CACHE, "utf8")) as {
|
|
24
|
+
servers: Record<string, { tools?: Record<string, unknown>[] }>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const tools: Record<string, unknown>[] = [];
|
|
28
|
+
for (const [server, spec] of Object.entries(cache.servers)) {
|
|
29
|
+
for (const tool of spec.tools ?? []) {
|
|
30
|
+
const fn = (tool.function ?? tool) as Record<string, unknown>;
|
|
31
|
+
const parameters = fn.parameters ?? fn.inputSchema;
|
|
32
|
+
if (!fn.name || !parameters) continue;
|
|
33
|
+
tools.push({ type: "function", function: { name: `${server}__${String(fn.name).replace(/[^A-Za-z0-9_-]/g, "_")}`, description: String(fn.description ?? ""), parameters } });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const repaired = repairRequestToolSchemas({ model: MODEL, messages: [{ role: "user", content: "x" }], max_tokens: 8, tools }).payload as { tools: Record<string, unknown>[] };
|
|
38
|
+
let candidates = repaired.tools;
|
|
39
|
+
if (FILTER) candidates = candidates.filter((t) => String((t.function as Record<string, unknown>).name).includes(FILTER));
|
|
40
|
+
|
|
41
|
+
async function post(subset: Record<string, unknown>[]): Promise<{ status: number; msg: string }> {
|
|
42
|
+
const res = await fetch(`${BASE}/v1/chat/completions`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/json" },
|
|
45
|
+
body: JSON.stringify({ model: MODEL, messages: [{ role: "user", content: "reply OK" }], max_tokens: 8, tools: subset }),
|
|
46
|
+
});
|
|
47
|
+
const body = await res.text();
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
49
|
+
return { status: res.status, msg: body.slice(0, 160) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`probing ${candidates.length} tool(s) against ${BASE} (${MODEL})`);
|
|
53
|
+
|
|
54
|
+
// 1. individual probe
|
|
55
|
+
const failing: string[] = [];
|
|
56
|
+
for (const tool of candidates) {
|
|
57
|
+
const name = String((tool.function as Record<string, unknown>).name);
|
|
58
|
+
const { status, msg } = await post([tool]);
|
|
59
|
+
if (status !== 200) {
|
|
60
|
+
failing.push(name);
|
|
61
|
+
console.log(`FAIL ${status} ${name}\n ${msg}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
console.log(`\nindividually failing: ${failing.length === 0 ? "none" : failing.join(", ")}`);
|
|
65
|
+
process.exit(0);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LIVE verification: push the local Pi MCP catalogue through the repair and against
|
|
3
|
+
* a llama.cpp-compatible endpoint. Not part of `npm test` (needs the network).
|
|
4
|
+
*
|
|
5
|
+
* node --experimental-strip-types scripts/live-schema-repair-check.ts BASE_URL [MODEL]
|
|
6
|
+
*
|
|
7
|
+
* This sends tool definitions but never executes a tool call.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { repairRequestToolSchemas, describeToolSchemaRepair } from "../schema-repair.ts";
|
|
13
|
+
|
|
14
|
+
const BASE = process.argv[2];
|
|
15
|
+
const MODEL = process.argv[3] ?? "qwen3.8-27b";
|
|
16
|
+
const CACHE = process.env.PI_MCP_CACHE ?? join(homedir(), ".pi", "agent", "mcp-cache.json");
|
|
17
|
+
|
|
18
|
+
if (!BASE) {
|
|
19
|
+
console.error("usage: node --experimental-strip-types scripts/live-schema-repair-check.ts BASE_URL [MODEL]");
|
|
20
|
+
process.exit(2);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function collectRefs(node: unknown, out: string[] = []): string[] {
|
|
24
|
+
if (Array.isArray(node)) for (const item of node) collectRefs(item, out);
|
|
25
|
+
else if (node && typeof node === "object") {
|
|
26
|
+
for (const [key, value] of Object.entries(node)) {
|
|
27
|
+
if (key === "$ref" && typeof value === "string") out.push(value);
|
|
28
|
+
else if (key === "$defs" || key === "definitions") out.push(`<${key}:${Object.keys(value as object).length} entries>`);
|
|
29
|
+
else collectRefs(value, out);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function post(tools: unknown[], model = MODEL): Promise<{ status: number; body: string }> {
|
|
36
|
+
const res = await fetch(`${BASE}/v1/chat/completions`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ model, messages: [{ role: "user", content: "reply with OK" }], max_tokens: 16, tools }),
|
|
40
|
+
});
|
|
41
|
+
return { status: res.status, body: await res.text() };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!existsSync(CACHE)) {
|
|
45
|
+
console.error(`no mcp cache at ${CACHE}`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cache = JSON.parse(readFileSync(CACHE, "utf8")) as { servers: Record<string, { tools?: Record<string, unknown>[] }> };
|
|
50
|
+
const servers = Object.entries(cache.servers);
|
|
51
|
+
const tools: Record<string, unknown>[] = [];
|
|
52
|
+
for (const [name, server] of servers) {
|
|
53
|
+
for (const tool of (server.tools ?? []) as Record<string, unknown>[]) {
|
|
54
|
+
// MCP catalogue entries carry `inputSchema`; OpenAI-shaped ones carry `function.parameters`.
|
|
55
|
+
const fn = (tool.function ?? tool) as Record<string, unknown>;
|
|
56
|
+
const parameters = fn.parameters ?? fn.inputSchema;
|
|
57
|
+
if (!fn.name || !parameters) continue;
|
|
58
|
+
tools.push({
|
|
59
|
+
type: "function",
|
|
60
|
+
function: { name: `${name}__${String(fn.name).replace(/[^A-Za-z0-9_-]/g, "_")}`, description: String(fn.description ?? ""), parameters },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const broken = tools.filter((t) => collectRefs((t as { function: { parameters: unknown } }).function.parameters).length > 0);
|
|
66
|
+
console.log(`endpoint : ${BASE}`);
|
|
67
|
+
console.log(`tools collected : ${tools.length} from ${servers.length} MCP servers`);
|
|
68
|
+
console.log(`tools w/ refs : ${broken.length}`);
|
|
69
|
+
for (const t of broken) console.log(` - ${String((t as { function: { name: string } }).function.name)}`);
|
|
70
|
+
|
|
71
|
+
const before = await post(tools);
|
|
72
|
+
console.log(`\nBEFORE repair -> HTTP ${before.status}`);
|
|
73
|
+
if (before.status !== 200) console.log(` ${before.body.slice(0, 260)}`);
|
|
74
|
+
|
|
75
|
+
const { payload, report } = repairRequestToolSchemas({ model: MODEL, messages: [{ role: "user", content: "reply with OK" }], max_tokens: 16, tools });
|
|
76
|
+
console.log(`\nrepair summary : ${describeToolSchemaRepair(report) || "(nothing to repair)"}`);
|
|
77
|
+
if (report.droppedRefs.length) console.log(` loosened refs : ${report.droppedRefs.join(", ")}`);
|
|
78
|
+
|
|
79
|
+
const after = await post((payload as { tools: unknown[] }).tools);
|
|
80
|
+
console.log(`\nAFTER repair -> HTTP ${after.status}`);
|
|
81
|
+
if (after.status === 200) console.log(" ✅ llama-swap/llama.cpp accepted the repaired tool set");
|
|
82
|
+
else console.log(` ❌ ${after.body.slice(0, 260)}`);
|
|
83
|
+
|
|
84
|
+
const leftovers = collectRefs((payload as { tools: unknown[] }).tools).filter((r) => r.startsWith("#") || r.includes("entries"));
|
|
85
|
+
console.log(`\nresidual refs/defs on the wire: ${leftovers.length === 0 ? "none ✅" : leftovers.join(", ")}`);
|
|
86
|
+
process.exit(after.status === 200 && leftovers.length === 0 ? 0 : 1);
|