@maheidem/model-discovery 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@maheidem/model-discovery",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
- "description": "Interactive TUI for discovering local AI endpoints and defining named thinking/sampling profiles (llama.cpp, oMLX, Ollama, vLLM, SGLang, LM Studio).",
5
+ "description": "Interactive Pi wizard for discovering local AI endpoints and defining named thinking/sampling profiles.",
6
+ "license": "MIT",
6
7
  "keywords": [
7
8
  "pi-package",
8
9
  "extension",
@@ -18,17 +19,39 @@
18
19
  "bugs": {
19
20
  "url": "https://github.com/maheidem/model-discovery/issues"
20
21
  },
22
+ "files": [
23
+ "index.ts",
24
+ "application.ts",
25
+ "commands.ts",
26
+ "storage.ts",
27
+ "ui-model.ts",
28
+ "profiles.ts",
29
+ "providers.ts",
30
+ "schema-repair.ts",
31
+ "ui/wizard-shell.ts",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
21
35
  "scripts": {
22
- "test": "node --experimental-strip-types --test profiles.test.ts offline.test.ts enrichment.test.ts"
36
+ "test": "node tests/run.mjs",
37
+ "typecheck": "tsc -p tsconfig.json",
38
+ "prepack": "npm run typecheck && npm test"
23
39
  },
24
40
  "peerDependencies": {
25
- "@earendil-works/pi-coding-agent": ">=0.84.0",
41
+ "@earendil-works/pi-coding-agent": "*",
26
42
  "@earendil-works/pi-tui": "*",
27
43
  "typebox": "*"
28
44
  },
45
+ "devDependencies": {
46
+ "@earendil-works/pi-coding-agent": "^0.84.4",
47
+ "@earendil-works/pi-tui": "^0.84.4",
48
+ "@types/node": "^22.0.0",
49
+ "typebox": "^1.0.17",
50
+ "typescript": "^5.9.3"
51
+ },
29
52
  "pi": {
30
53
  "extensions": [
31
- "index.ts"
54
+ "./index.ts"
32
55
  ]
33
56
  }
34
57
  }
@@ -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
+ }