@juno-ai/bind 11.0.0 → 13.0.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 +1 -1
- package/tools/sanitize-schema.d.ts +194 -0
- package/tools/sanitize-schema.js +1041 -26
package/tools/sanitize-schema.js
CHANGED
|
@@ -100,6 +100,196 @@
|
|
|
100
100
|
* `properties` is an open per-database map with no fixed sub-schema), and the
|
|
101
101
|
* key name is never changed so the model still emits the right argument key.
|
|
102
102
|
* Confirmed live: `x-ai/grok-4.3` 400→200, Gemini/Claude unaffected.
|
|
103
|
+
* 8. Resolve a COMPOSITION BRANCH's `required` against the branch's own
|
|
104
|
+
* `properties` UNIONED with the enclosing node's `properties`, and — at the
|
|
105
|
+
* parameters ROOT only — make the surviving branch self-sufficient by copying
|
|
106
|
+
* the referenced property subschemas down into it and setting
|
|
107
|
+
* `type: "object"`. This is the narrow, measured exception to transform #3.
|
|
108
|
+
* The ordinary way to say "pass exactly one of these" puts the properties on
|
|
109
|
+
* the parent and only `required` in each branch:
|
|
110
|
+
*
|
|
111
|
+
* { type: "object", properties: { query: …, memoryItemId: … },
|
|
112
|
+
* oneOf: [ { required: ["query"] }, { required: ["memoryItemId"] } ] }
|
|
113
|
+
*
|
|
114
|
+
* which is how JSON Schema composition works, and which transform #3 alone
|
|
115
|
+
* sanitized to `oneOf: [{}, {}]` — a root xAI (Grok) hard-rejects
|
|
116
|
+
* (`tool parameter root must be an object type (root schema is an
|
|
117
|
+
* anyOf/oneOf union with a non-object branch)`, failing EVERY tool in the
|
|
118
|
+
* request), and which Gemini/OpenAI accept while silently losing the
|
|
119
|
+
* constraint, letting the model send both parameters or neither. Measured on
|
|
120
|
+
* xAI for branches of a ROOT union: `{type:"object", …}` or `{properties:…}`
|
|
121
|
+
* is accepted with EITHER alone sufficing; a bare `{required:[…]}` or `{}` is
|
|
122
|
+
* a 400. Hence the copy-down, which also makes the branch idempotent under
|
|
123
|
+
* this sanitizer (a second pass finds every `required` name in the branch's
|
|
124
|
+
* OWN `properties` and changes nothing).
|
|
125
|
+
*
|
|
126
|
+
* Two limits are load-bearing, both measured:
|
|
127
|
+
* - The parent scope is the ENCLOSING node only, and only for
|
|
128
|
+
* `allOf`/`anyOf`/`oneOf` elements. Widening transform #3 to resolve
|
|
129
|
+
* `required` against ALL ancestors at every node reintroduces the hard
|
|
130
|
+
* Gemini 400 that transform #3 exists to prevent ("required fields
|
|
131
|
+
* ['value'] are not defined in the schema properties" — still true on
|
|
132
|
+
* gemini-3.5/3.6/3.7-flash). Every non-branch node keeps transform #3
|
|
133
|
+
* exactly as it is, and so do `if`/`then`/`else` and `dependentSchemas`,
|
|
134
|
+
* which are not composition arrays.
|
|
135
|
+
* - The copy-down is ROOT-ONLY. The identical bare-`required` union nested
|
|
136
|
+
* under a property is accepted by all nine measured models including
|
|
137
|
+
* every Grok version, so a nested composition keeps its `required` (that
|
|
138
|
+
* is the fix) and is otherwise left alone — no provider constrains it,
|
|
139
|
+
* and churning it risks the transform-#5 `allOf` flatten.
|
|
140
|
+
* Only the names in the branch's own `required` are copied, so a branch does
|
|
141
|
+
* not inherit the parent's whole property set — and a single ceiling
|
|
142
|
+
* (`MAX_ROOT_COPY_DOWN`) bounds the copy-down across ALL branches of one
|
|
143
|
+
* root composition, because the per-branch bound does not bound N branches
|
|
144
|
+
* × P names. Past the ceiling the repair is skipped, leaving the branches
|
|
145
|
+
* bare for rule (a) below to drop.
|
|
146
|
+
* 9. Guarantee the parameters ROOT is one xAI will accept, as a final pass.
|
|
147
|
+
* xAI rejects a root that is not an object type, and the rejection takes the
|
|
148
|
+
* whole request (every tool) with it. Two distinct rules, each measured
|
|
149
|
+
* against grok-4.3/4.5/4.6 (and confirmed inert on the six Gemini/OpenAI
|
|
150
|
+
* models) through OpenRouter with `provider.allow_fallbacks: false` and one
|
|
151
|
+
* tool per request:
|
|
152
|
+
*
|
|
153
|
+
* a. Every element of a root `anyOf`/`oneOf` must be a plain object that
|
|
154
|
+
* is object-shaped, and must not be `{}`. A branch that declares a
|
|
155
|
+
* `type` is judged on that alone (`type: "object"` passes, anything
|
|
156
|
+
* else fails even when the branch also carries `properties` — measured
|
|
157
|
+
* 0/3, identical to a bare `{type:"string"}`); a branch with no `type`
|
|
158
|
+
* passes on `properties`. This holds even when the root ITSELF declares
|
|
159
|
+
* `type: "object"` and `properties` — an object root does not excuse a
|
|
160
|
+
* non-object branch. Rule (a) runs BEFORE the transform-#5 flatten,
|
|
161
|
+
* since that flatten refuses to merge an `allOf` while a union sibling
|
|
162
|
+
* is present; dropping the union afterwards would strand an `allOf`
|
|
163
|
+
* that the next pass would merge, breaking idempotence. A
|
|
164
|
+
* branch that still fails after #8 cannot be repaired (its `required`
|
|
165
|
+
* names something no `properties` map declares — the parent supplies it
|
|
166
|
+
* only via `patternProperties`/`additionalProperties`, or not at all —
|
|
167
|
+
* or it is a `$ref`/scalar/boolean branch), so the whole keyword is
|
|
168
|
+
* dropped. Losing one tool's constraint is strictly better than losing
|
|
169
|
+
* every tool in the request.
|
|
170
|
+
* b. A root carrying a composition keyword but declaring neither `type`
|
|
171
|
+
* nor `properties` is rejected on its OWN account, whatever the branches
|
|
172
|
+
* look like: `{allOf:[{$ref:…}], $defs:…}` and
|
|
173
|
+
* `{allOf:[{type:"object",…}, {not:…}]}` are both 0/3 on Grok. Adding
|
|
174
|
+
* `type: "object"` to such a root makes exactly those cases pass 9/9
|
|
175
|
+
* without touching the composition — so the un-flattenable root `allOf`
|
|
176
|
+
* that transform #5 deliberately preserves is RESCUED rather than
|
|
177
|
+
* discarded, and it stops being the silent 400 it is today. A non-object
|
|
178
|
+
* `type` the root declared for itself IS rewritten to `"object"` and
|
|
179
|
+
* recorded in the description — measured, such a root is rejected by
|
|
180
|
+
* every model. Nested nodes keep their declared types unless transform
|
|
181
|
+
* #12 applies.
|
|
182
|
+
*
|
|
183
|
+
* Rule (a) is all-or-nothing per keyword, because ONE unusable branch
|
|
184
|
+
* poisons the whole union on xAI even when its siblings are good (0/3 with a
|
|
185
|
+
* `{type:"string"}` sibling next to a valid object branch). Filtering the bad
|
|
186
|
+
* branches out instead is accepted by xAI (3/3) but is NOT what we do — it
|
|
187
|
+
* advertises a narrower tool, telling the model an arm is invalid so it never
|
|
188
|
+
* calls that shape, with nothing to surface the loss. Dropping the keyword
|
|
189
|
+
* leaves the parent's `properties` fully visible, so every shape stays
|
|
190
|
+
* callable and a wrong COMBINATION comes back from dispatch/the remote
|
|
191
|
+
* server as a recoverable tool error.
|
|
192
|
+
*
|
|
193
|
+
* A branch's OWN `anyOf`/`oneOf` disqualifies it too, whatever else it
|
|
194
|
+
* declares: `{type:"object", anyOf:[…]}` as a root union branch is 0/3 on
|
|
195
|
+
* Grok, as is a branch carrying both `allOf` and `anyOf` — the union is what
|
|
196
|
+
* poisons it. Scope is the branch's own keys and never its subtree: a
|
|
197
|
+
* composition on a branch's PROPERTY is accepted 9/9, one or two levels
|
|
198
|
+
* down, so a subtree scan would destroy constraints every provider honours.
|
|
199
|
+
* A branch's own `allOf` is likewise accepted 9/9 (even with a
|
|
200
|
+
* bare-`required` sub-branch), the same intersection-vs-union asymmetry xAI
|
|
201
|
+
* applies to the root — hence `ROOT_UNION_KEYS`, not `COMPOSITION_KEYS`.
|
|
202
|
+
*
|
|
203
|
+
* A root union of `$ref` branches is no longer a loss — transform #14 below
|
|
204
|
+
* inlines the targets so the union survives. What remains is the narrow
|
|
205
|
+
* residue it cannot inline: a target that is not a plain object schema, one
|
|
206
|
+
* whose expanded size exceeds the copy-down ceiling, and a self-referential
|
|
207
|
+
* root pointer. Those branches stay unusable and still drop the keyword.
|
|
208
|
+
*
|
|
209
|
+
* Rule (b) covers EVERY root, not only composition roots. A property sweep
|
|
210
|
+
* over recursive shapes (`__tests__/tool-schema/property/`) found the
|
|
211
|
+
* narrower gate emitting unusable roots for whole classes the fixtures
|
|
212
|
+
* never reached: a scalar or array root (reachable from transform #1
|
|
213
|
+
* collapsing `{type:["string","null"]}`), an annotation-only root, a
|
|
214
|
+
* `$defs`-only root, and a root whose `type` contradicts its `properties` —
|
|
215
|
+
* that last one rejected by all nine models, the only rule here with no
|
|
216
|
+
* tolerant provider. Rule (b) also DROPS a `const` or `$ref` carried by the
|
|
217
|
+
* root: measured `root schema is a const` / `root schema is a $ref`, 400
|
|
218
|
+
* even alongside `type: "object"` and `properties`, and for `$ref` even
|
|
219
|
+
* when the target resolves. Nested `const`/`$ref` are meaningful and
|
|
220
|
+
* untouched.
|
|
221
|
+
*
|
|
222
|
+
* A root `allOf`'s BRANCHES are deliberately not constrained by (a):
|
|
223
|
+
* measured, xAI accepts `$ref`, `not`, nested-`allOf` and even scalar
|
|
224
|
+
* branches under an `allOf` as long as (b) holds — only `anyOf`/`oneOf`
|
|
225
|
+
* branches are validated individually. A bare union root whose branches are
|
|
226
|
+
* all object-carrying is accepted too (xAI infers object-ness from them), so
|
|
227
|
+
* (b) leaves it alone. Compositions nested below the root are untouched.
|
|
228
|
+
*
|
|
229
|
+
* 10. Drop an EMPTY array-valued keyword (`allOf`/`anyOf`/`oneOf`/`prefixItems`)
|
|
230
|
+
* at any depth. xAI rejects one anywhere in the tree — `/properties/a/oneOf:
|
|
231
|
+
* [] has less than 1 item` — and it carries no constraint (an `allOf` of
|
|
232
|
+
* nothing is satisfied by everything). Empty OBJECTS (`properties: {}`,
|
|
233
|
+
* `$defs: {}`) and an empty `required: []` are accepted and left alone;
|
|
234
|
+
* `enum: []` and `required: []` were already omitted by transforms #2/#3.
|
|
235
|
+
* 11. Drop a `$ref` whose LOCAL target does not resolve. xAI resolves `#/…`
|
|
236
|
+
* pointers itself and 400s the whole request on a dangling one
|
|
237
|
+
* (`unresolvable $ref '#/$defs/Nope'`), which a third-party MCP server
|
|
238
|
+
* produces easily by shipping a subschema without its definitions — or by
|
|
239
|
+
* putting `$defs` on a nested node, since `#/$defs/X` is anchored at the
|
|
240
|
+
* document ROOT and a nested `$defs` is unaddressable that way. External
|
|
241
|
+
* refs (`https://…`) and resolvable non-`$defs` pointers (`#/properties/a`)
|
|
242
|
+
* are both accepted by xAI and left alone. Only the `$ref` keyword is
|
|
243
|
+
* dropped, so the rest of the node survives.
|
|
244
|
+
*
|
|
245
|
+
* 12. Give any node carrying `properties` an explicit `type: "object"`. Gemini
|
|
246
|
+
* rejects the type-less form outright — "Unable to submit request because
|
|
247
|
+
* `t` functionDeclaration `parameters.b` schema specified incorrect schema
|
|
248
|
+
* type field. For schema with properties, schema type should be OBJECT" —
|
|
249
|
+
* on gemini-3.5/3.6/3.7-flash, and rejects a CONTRADICTORY declared type
|
|
250
|
+
* (`{type:"string", properties:{…}}`) the same way, so the type is
|
|
251
|
+
* overwritten rather than merely defaulted. It is not composition-specific:
|
|
252
|
+
* a plain property subschema and an `items` schema fail identically. xAI
|
|
253
|
+
* and OpenAI accept every one of those forms, which is why this survived
|
|
254
|
+
* until a property sweep over recursive shapes went looking for it. The
|
|
255
|
+
* root is exempt in Gemini's own validator, but the rule is applied
|
|
256
|
+
* uniformly because it costs nothing and one rule beats two.
|
|
257
|
+
*
|
|
258
|
+
* It is resolved during the node's type resolution, not patched onto the
|
|
259
|
+
* finished node, so `dropEnum` sees the real type — an `enum` left on a
|
|
260
|
+
* node that has just become an object would otherwise be dropped by the
|
|
261
|
+
* NEXT pass instead of this one, breaking idempotence. A discarded
|
|
262
|
+
* contradictory type is folded into the description like transform #1's.
|
|
263
|
+
* This also makes a contradictory root-union BRANCH repairable: it arrives
|
|
264
|
+
* at transform #9 already retyped, so the constraint survives instead of
|
|
265
|
+
* the whole keyword being dropped.
|
|
266
|
+
*
|
|
267
|
+
* 13. Drop a `pattern` that uses a regex construct a provider's validator
|
|
268
|
+
* refuses, echoing it into the description instead. Measured: `(?=`, `(?!`,
|
|
269
|
+
* `(?<=`, `(?<!` AND named groups `(?<name>` are rejected by gpt-5.6-sol
|
|
270
|
+
* and gpt-5.6-terra (`Invalid JSON schema: regex lookaround is not
|
|
271
|
+
* supported` — the message says lookaround, but a named group fails
|
|
272
|
+
* identically, so the giveaway is the `(?<` prefix); a BACKREFERENCE
|
|
273
|
+
* (`\1`–`\9`) is rejected by grok-4.5. Non-capturing `(?:` is accepted
|
|
274
|
+
* everywhere and is deliberately NOT caught. This is the defect that broke
|
|
275
|
+
* Monad's own `spaces__invite_member` on every OpenAI model — a whole-
|
|
276
|
+
* request 400 from one tool's email pattern. The scan tracks backslash
|
|
277
|
+
* escaping and character classes, so a literal `\(\?=` or a `[(?=]` class
|
|
278
|
+
* keeps its pattern: dropping a pattern that would have been accepted
|
|
279
|
+
* costs a real constraint on every provider.
|
|
280
|
+
* 14. Inline a ROOT union branch that is a local `$ref`, so the union survives
|
|
281
|
+
* rule (a) instead of being dropped whole. `z.union([A, B])` renders as
|
|
282
|
+
* `{anyOf:[{$ref},{$ref}], $defs}` and xAI does not resolve a `$ref` union
|
|
283
|
+
* branch (0/3 even with `type: "object"` on the root) — so rule (a) used to
|
|
284
|
+
* drop the keyword, which for a root whose properties live entirely in
|
|
285
|
+
* `$defs` left the tool advertising NO parameters. Inlining produces
|
|
286
|
+
* ordinary object branches, accepted 9/9. Only branches that are not
|
|
287
|
+
* already usable are inlined, one level deep (a `$ref` inside the target
|
|
288
|
+
* resolves on its own, which is what makes a recursive definition
|
|
289
|
+
* terminate here), with branch keywords winning over the target's per
|
|
290
|
+
* 2020-12 `$ref` semantics and a cap for the same reason the copy-down has
|
|
291
|
+
* one. A branch whose target is not a plain object schema stays unusable
|
|
292
|
+
* and still falls through to the drop.
|
|
103
293
|
*
|
|
104
294
|
* When (1) or (2) discards information the model could use — a collapsed
|
|
105
295
|
* union type, or a wholly-dropped `enum` — that constraint is folded into the
|
|
@@ -174,6 +364,135 @@ const SUBSCHEMA_ARRAY_KEYS = new Set([
|
|
|
174
364
|
"oneOf",
|
|
175
365
|
"prefixItems",
|
|
176
366
|
]);
|
|
367
|
+
/** The three keywords whose array elements are COMPOSITION BRANCHES — branches
|
|
368
|
+
* that JSON Schema evaluates against the enclosing node, so their `required` may
|
|
369
|
+
* legitimately name a property the enclosing node declares (transform #8).
|
|
370
|
+
* `prefixItems` is in `SUBSCHEMA_ARRAY_KEYS` but NOT here: its elements are the
|
|
371
|
+
* item schemas of a tuple, a fresh scope, not branches of the enclosing object. */
|
|
372
|
+
const COMPOSITION_KEYS = new Set(["allOf", "anyOf", "oneOf"]);
|
|
373
|
+
/**
|
|
374
|
+
* Ceiling on how many property subschemas transform #8 may copy down across ALL
|
|
375
|
+
* branches of one root composition.
|
|
376
|
+
*
|
|
377
|
+
* The per-branch bound (a branch's own `required`) does NOT bound the total: a
|
|
378
|
+
* root `oneOf` of N branches each requiring P parent properties copies N × P
|
|
379
|
+
* subschemas from an input that is only O(N + P). Both counts are
|
|
380
|
+
* attacker-controlled for a third-party MCP `rawJsonSchema`, and the references
|
|
381
|
+
* are shared in memory but EXPAND on `JSON.stringify` when the schema is written
|
|
382
|
+
* into the request — measured at N=2000, P=100, a 1.2 MB input sanitizes to a
|
|
383
|
+
* 17.5 MB request body, a 14x amplification paid on every model call. That is
|
|
384
|
+
* the same class of hazard `flattenRootAllOf` guards against below.
|
|
385
|
+
*
|
|
386
|
+
* Past the ceiling the repair is skipped, which leaves the branch bare and lets
|
|
387
|
+
* transform #9 rule (a) drop the keyword — the module's usual tiebreak, losing
|
|
388
|
+
* one tool's constraint rather than degrading every request. The cap is far
|
|
389
|
+
* above any legitimate tool schema (a large discriminated union is tens of
|
|
390
|
+
* branches with a handful of `required` names each).
|
|
391
|
+
*/
|
|
392
|
+
const MAX_ROOT_COPY_DOWN = 512;
|
|
393
|
+
/**
|
|
394
|
+
* Recursive size of a subtree, in nodes and keys, stopping as soon as it exceeds
|
|
395
|
+
* `cap`. Used to price transform #14's inlining: counting only a target's
|
|
396
|
+
* TOP-LEVEL keys is no bound at all, because `{type, properties}` costs 2 no
|
|
397
|
+
* matter how large `properties` is — measured, that let a 0.11 MB schema with
|
|
398
|
+
* 256 `$ref` branches expand to 26 MB of request body, a 242x amplification on
|
|
399
|
+
* attacker-controlled input. Early exit keeps the pricing itself cheap and
|
|
400
|
+
* bounded, and the depth guard mirrors the walk's.
|
|
401
|
+
*/
|
|
402
|
+
function subtreeWeight(value, cap, depth = 0) {
|
|
403
|
+
if (depth > MAX_DEPTH)
|
|
404
|
+
return cap + 1;
|
|
405
|
+
if (Array.isArray(value)) {
|
|
406
|
+
let total = 1;
|
|
407
|
+
for (const item of value) {
|
|
408
|
+
total += subtreeWeight(item, cap - total, depth + 1);
|
|
409
|
+
if (total > cap)
|
|
410
|
+
return total;
|
|
411
|
+
}
|
|
412
|
+
return total;
|
|
413
|
+
}
|
|
414
|
+
if (!isPlainObject(value))
|
|
415
|
+
return 1;
|
|
416
|
+
let total = 1;
|
|
417
|
+
for (const item of Object.values(value)) {
|
|
418
|
+
total += 1 + subtreeWeight(item, cap - total, depth + 1);
|
|
419
|
+
if (total > cap)
|
|
420
|
+
return total;
|
|
421
|
+
}
|
|
422
|
+
return total;
|
|
423
|
+
}
|
|
424
|
+
/** Longest `pattern` echoed into a description when transform #13 drops it.
|
|
425
|
+
* A regex is guidance at that point, and an unbounded one would crowd out the
|
|
426
|
+
* rest of the tool's description in the model's context. */
|
|
427
|
+
const MAX_PATTERN_IN_PROSE = 120;
|
|
428
|
+
/**
|
|
429
|
+
* True if a `pattern` uses a regex construct some provider's validator refuses.
|
|
430
|
+
* Measured, one tool per request, `provider.allow_fallbacks: false`:
|
|
431
|
+
*
|
|
432
|
+
* - `(?=`, `(?!`, `(?<=`, `(?<!` and NAMED GROUPS `(?<name>` are all rejected
|
|
433
|
+
* by gpt-5.6-sol and gpt-5.6-terra (`Invalid JSON schema: regex lookaround
|
|
434
|
+
* is not supported`), and accepted by Gemini and Grok. The error says
|
|
435
|
+
* "lookaround", but a named group fails identically — the giveaway is the
|
|
436
|
+
* `(?<` prefix, not the semantics — so all of `(?<` goes.
|
|
437
|
+
* - A BACKREFERENCE (`\1`…`\9`) is rejected by grok-4.5 and accepted by the
|
|
438
|
+
* others.
|
|
439
|
+
* - Non-capturing `(?:` is accepted everywhere and must not be caught here.
|
|
440
|
+
*
|
|
441
|
+
* The scan tracks backslash escaping and character classes so a LITERAL `\(?=`
|
|
442
|
+
* or a `[(?=]` class is not mistaken for the construct — dropping a pattern
|
|
443
|
+
* that would have been accepted costs a real constraint on every provider.
|
|
444
|
+
* Inside a class, `\1` is an octal escape rather than a backreference, so
|
|
445
|
+
* backreference detection is suppressed there too.
|
|
446
|
+
*/
|
|
447
|
+
function usesUnsupportedRegexConstruct(pattern) {
|
|
448
|
+
// A deliberately NAIVE character-class scan: `[` opens, the next `]` closes.
|
|
449
|
+
// RFC-correct regex treats a `]` in first position as a literal (`[]a]` is the
|
|
450
|
+
// two-member class `]`,`a`), and an earlier revision of this function did too
|
|
451
|
+
// — but the providers do not, and they are what we are modelling. Measured:
|
|
452
|
+
// `^[(?=]+$` is accepted 5/5 (the `(?=` reads as class content under BOTH
|
|
453
|
+
// parses), while `^[](?=)]+$` is REJECTED by gpt-5.6-sol/terra and accepted by
|
|
454
|
+
// Grok and Gemini. That split is only explicable if OpenAI closes the class at
|
|
455
|
+
// the first `]`, leaving `(?=` outside — i.e. the naive parse. Matching the
|
|
456
|
+
// strict spec here would keep a pattern OpenAI 400s the whole request over.
|
|
457
|
+
let inClass = false;
|
|
458
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
459
|
+
const char = pattern[i];
|
|
460
|
+
if (char === "\\") {
|
|
461
|
+
const next = pattern[i + 1];
|
|
462
|
+
if (!inClass && next !== undefined) {
|
|
463
|
+
// `\1`–`\9` is a backreference (`\0` is NUL), rejected by grok-4.5.
|
|
464
|
+
if (next >= "1" && next <= "9")
|
|
465
|
+
return true;
|
|
466
|
+
// `\k<name>` is a NAMED backreference, rejected by grok-4.5 AND
|
|
467
|
+
// gpt-5.6-sol.
|
|
468
|
+
if (next === "k" && pattern[i + 2] === "<")
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
i++; // skip the escaped character
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (inClass) {
|
|
475
|
+
if (char === "]")
|
|
476
|
+
inClass = false;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (char === "[") {
|
|
480
|
+
inClass = true;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (char === "(" && pattern[i + 1] === "?") {
|
|
484
|
+
const kind = pattern[i + 2];
|
|
485
|
+
// `=`/`!` lookahead and `<` (lookbehind OR named group) are rejected by
|
|
486
|
+
// the OpenAI models; `>` (atomic group) is rejected by grok-4.5. Inline
|
|
487
|
+
// flags `(?i)` and non-capturing `(?:` are accepted everywhere and must
|
|
488
|
+
// NOT be caught.
|
|
489
|
+
if (kind === "=" || kind === "!" || kind === "<" || kind === ">") {
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
177
496
|
/** Name → subschema maps. `properties` is listed here for completeness, but a
|
|
178
497
|
* dedicated `key === "properties"` branch in `sanitizeSchemaNode` intercepts it
|
|
179
498
|
* first (to apply transform #7) and `continue`s — so the generic map handler
|
|
@@ -190,6 +509,13 @@ const SUBSCHEMA_MAP_KEYS = new Set([
|
|
|
190
509
|
function isPlainObject(value) {
|
|
191
510
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
192
511
|
}
|
|
512
|
+
/** Own-property test. `hasOwnProperty.call`, never `name in map`: the latter
|
|
513
|
+
* answers `true` for a prototype member ("toString", "valueOf"), which would let
|
|
514
|
+
* a `required: ["toString"]` survive against a `properties` map that never
|
|
515
|
+
* declared it — the exact dangling `required` transform #3 exists to prune. */
|
|
516
|
+
function hasOwn(map, name) {
|
|
517
|
+
return Object.prototype.hasOwnProperty.call(map, name);
|
|
518
|
+
}
|
|
193
519
|
/** Join words as a human list: ["a"]→"a", ["a","b"]→"a or b", ["a","b","c"]→"a, b, or c". */
|
|
194
520
|
function humanJoin(items) {
|
|
195
521
|
if (items.length <= 1)
|
|
@@ -316,7 +642,164 @@ function sanitizeSchemaMap(value, depth) {
|
|
|
316
642
|
}
|
|
317
643
|
return out;
|
|
318
644
|
}
|
|
319
|
-
|
|
645
|
+
/**
|
|
646
|
+
* Sanitize a `properties` MAP and apply transform #7 to it: a child property
|
|
647
|
+
* literally named `properties` whose schema is object-shaped trips xAI's parser,
|
|
648
|
+
* so it is collapsed to annotations only. (Only the `properties` map triggers the
|
|
649
|
+
* `/properties/properties` collision — `patternProperties`/`$defs` keys are
|
|
650
|
+
* patterns and definition names, not property names, so they go through the
|
|
651
|
+
* generic map handler.)
|
|
652
|
+
*
|
|
653
|
+
* Computed once per node, ahead of the key walk, because two things need the
|
|
654
|
+
* SANITIZED map rather than the raw one: the `properties` keyword itself, and
|
|
655
|
+
* transform #8, which resolves a composition branch's `required` against it and
|
|
656
|
+
* copies subschemas down from it. Copying the sanitized form is what makes
|
|
657
|
+
* transform #8 idempotent — a raw subschema copied into a branch would still be
|
|
658
|
+
* awaiting collapse, so a second pass would not be a no-op.
|
|
659
|
+
*/
|
|
660
|
+
function sanitizePropertiesKeyword(value, depth) {
|
|
661
|
+
const mapped = sanitizeSchemaMap(value, depth);
|
|
662
|
+
if (isPlainObject(mapped) &&
|
|
663
|
+
isPlainObject(mapped.properties) &&
|
|
664
|
+
looksObjectShapedToXai(mapped.properties)) {
|
|
665
|
+
mapped.properties = neutralizeXaiPropertiesField(mapped.properties);
|
|
666
|
+
}
|
|
667
|
+
return mapped;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Transform #8, second half (ROOT branches only): make a composition branch
|
|
671
|
+
* self-sufficient so it stands on its own as an object schema.
|
|
672
|
+
*
|
|
673
|
+
* A branch whose `required` survived the parent-scope resolution but whose names
|
|
674
|
+
* live in the PARENT's `properties` gets those subschemas copied down and, if it
|
|
675
|
+
* declared no `type`, `type: "object"`. Only the names in this branch's own
|
|
676
|
+
* `required` are copied — a branch does not inherit the parent's whole property
|
|
677
|
+
* set. A branch that needs no repair is returned unchanged (same reference), so
|
|
678
|
+
* the common case of an already-object branch costs nothing and re-sanitizing is
|
|
679
|
+
* a no-op.
|
|
680
|
+
*
|
|
681
|
+
* `parentProperties` is the parent's SANITIZED map, so the copied subschemas are
|
|
682
|
+
* shared by reference with the parent's — consistent with the module's
|
|
683
|
+
* read-only-result contract, and cheaper than a second sanitize of the same
|
|
684
|
+
* subtree.
|
|
685
|
+
*
|
|
686
|
+
* A branch naming something the parent supplies only via `patternProperties` /
|
|
687
|
+
* `additionalProperties` cannot be made self-sufficient. It cannot reach here
|
|
688
|
+
* with such a name (the first half of transform #8 resolves `required` against
|
|
689
|
+
* explicit `properties` only, so the name was already pruned), leaving an
|
|
690
|
+
* unrepairable branch for transform #9 to catch.
|
|
691
|
+
*/
|
|
692
|
+
function makeBranchSelfSufficient(branch, parentProperties, budget) {
|
|
693
|
+
if (!parentProperties)
|
|
694
|
+
return branch;
|
|
695
|
+
if (!Array.isArray(branch.required) || branch.required.length === 0) {
|
|
696
|
+
return branch;
|
|
697
|
+
}
|
|
698
|
+
// `required`/`properties` are object-only keywords, so repairing a branch that
|
|
699
|
+
// declares a non-object `type` would emit a self-contradictory schema — and an
|
|
700
|
+
// unmeasured one, in a function whose whole justification is emitting only
|
|
701
|
+
// shapes measured to pass. Leave it alone; transform #9 rule (a) drops the
|
|
702
|
+
// keyword, since a non-object-typed branch is unusable at a root union anyway.
|
|
703
|
+
if (hasOwn(branch, "type") && branch.type !== "object")
|
|
704
|
+
return branch;
|
|
705
|
+
const ownProperties = isPlainObject(branch.properties)
|
|
706
|
+
? branch.properties
|
|
707
|
+
: null;
|
|
708
|
+
// `missing` — declared by the parent, to be copied down. `unsatisfiable` —
|
|
709
|
+
// declared by NEITHER map. The latter is unreachable through the walk (the
|
|
710
|
+
// parent-scope resolution has already pruned any such name), but it is
|
|
711
|
+
// handled rather than trusted: the invariant this function must not break is
|
|
712
|
+
// that every emitted `required` name is a declared property, so an
|
|
713
|
+
// unsatisfiable name is dropped from `required` instead of being carried
|
|
714
|
+
// through on an unchanged branch. A future refactor that makes this reachable
|
|
715
|
+
// then degrades to transform #3's behavior rather than emitting the dangling
|
|
716
|
+
// `required` transform #3 exists to prevent.
|
|
717
|
+
const missing = [];
|
|
718
|
+
const unsatisfiable = new Set();
|
|
719
|
+
for (const name of branch.required) {
|
|
720
|
+
if (typeof name !== "string")
|
|
721
|
+
continue;
|
|
722
|
+
if (ownProperties && hasOwn(ownProperties, name))
|
|
723
|
+
continue;
|
|
724
|
+
if (hasOwn(parentProperties, name))
|
|
725
|
+
missing.push(name);
|
|
726
|
+
else
|
|
727
|
+
unsatisfiable.add(name);
|
|
728
|
+
}
|
|
729
|
+
if (missing.length === 0 && unsatisfiable.size === 0)
|
|
730
|
+
return branch;
|
|
731
|
+
// Global copy-down ceiling (see MAX_ROOT_COPY_DOWN). Skipping the repair
|
|
732
|
+
// leaves the branch bare, so rule (a) drops the keyword rather than emitting a
|
|
733
|
+
// request body inflated by an adversarial schema.
|
|
734
|
+
if (missing.length > budget.remaining)
|
|
735
|
+
return branch;
|
|
736
|
+
budget.remaining -= missing.length;
|
|
737
|
+
const mergedProperties = {};
|
|
738
|
+
if (ownProperties) {
|
|
739
|
+
for (const [name, sub] of Object.entries(ownProperties)) {
|
|
740
|
+
safeSet(mergedProperties, name, sub);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// The branch's OWN declaration of a name wins over the parent's — only names
|
|
744
|
+
// it does not declare are copied down. `?? {}` because a hand-built (non-JSON)
|
|
745
|
+
// schema can carry an explicitly `undefined` property value, which `hasOwn`
|
|
746
|
+
// accepts but `JSON.stringify` drops on the wire — leaving a `required` naming
|
|
747
|
+
// a property the provider never sees. The empty schema accepts anything, so it
|
|
748
|
+
// is the neutral stand-in.
|
|
749
|
+
for (const name of missing) {
|
|
750
|
+
safeSet(mergedProperties, name, parentProperties[name] ?? {});
|
|
751
|
+
}
|
|
752
|
+
// Key order is deterministic — injected keys go in fixed positions around the
|
|
753
|
+
// branch's existing keys — so a second pass, which finds both already present
|
|
754
|
+
// and returns the branch untouched, produces the identical object.
|
|
755
|
+
const out = {};
|
|
756
|
+
if (!hasOwn(branch, "type"))
|
|
757
|
+
out.type = "object";
|
|
758
|
+
for (const [key, value] of Object.entries(branch)) {
|
|
759
|
+
if (key === "properties") {
|
|
760
|
+
out.properties = mergedProperties;
|
|
761
|
+
}
|
|
762
|
+
else if (key === "required" && unsatisfiable.size > 0) {
|
|
763
|
+
const kept = branch.required.filter((name) => typeof name === "string" && !unsatisfiable.has(name));
|
|
764
|
+
// An empty `required: []` is valid but noise; omit it, as transform #3 does.
|
|
765
|
+
if (kept.length > 0)
|
|
766
|
+
out.required = kept;
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
safeSet(out, key, value);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (!hasOwn(branch, "properties"))
|
|
773
|
+
out.properties = mergedProperties;
|
|
774
|
+
return out;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Recurse into one element of an `allOf`/`anyOf`/`oneOf` array (transform #8).
|
|
778
|
+
* Differs from `sanitizeSubschema` only in that the branch is told its parent's
|
|
779
|
+
* property names, so its `required` resolves against the composition scope JSON
|
|
780
|
+
* Schema actually gives it. `makeSelfSufficient` is set for the parameters ROOT
|
|
781
|
+
* only (see the module header).
|
|
782
|
+
*/
|
|
783
|
+
function sanitizeCompositionBranch(branch, depth, parentProperties, budget) {
|
|
784
|
+
if (depth > MAX_DEPTH)
|
|
785
|
+
return true;
|
|
786
|
+
// Arrays and boolean/primitive schemas carry no `required` to resolve.
|
|
787
|
+
if (!isPlainObject(branch))
|
|
788
|
+
return sanitizeSubschema(branch, depth);
|
|
789
|
+
const sanitized = sanitizeSchemaNode(branch, depth, parentProperties);
|
|
790
|
+
// A non-null budget marks a ROOT composition — the only place the
|
|
791
|
+
// self-sufficiency repair applies (see the module header).
|
|
792
|
+
return budget
|
|
793
|
+
? makeBranchSelfSufficient(sanitized, parentProperties, budget)
|
|
794
|
+
: sanitized;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* @param parentProperties When this node is a composition branch, the enclosing
|
|
798
|
+
* node's SANITIZED `properties` map — the extra scope its `required` may name
|
|
799
|
+
* (transform #8). `undefined`/`null` for every other node, which keeps
|
|
800
|
+
* transform #3's own-properties-only rule exactly as it was.
|
|
801
|
+
*/
|
|
802
|
+
function sanitizeSchemaNode(node, depth, parentProperties) {
|
|
320
803
|
// Depth guard: stop walking absurdly nested input rather than overflowing
|
|
321
804
|
// the stack. Real tool schemas are a few levels deep; anything past
|
|
322
805
|
// MAX_DEPTH is adversarial, so the subtree is replaced with the
|
|
@@ -326,18 +809,39 @@ function sanitizeSchemaNode(node, depth) {
|
|
|
326
809
|
if (depth > MAX_DEPTH)
|
|
327
810
|
return {};
|
|
328
811
|
const childDepth = depth + 1;
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
const
|
|
333
|
-
?
|
|
812
|
+
// Sanitized `properties`, computed ahead of the key walk because transform #8
|
|
813
|
+
// needs it before the walk reaches whichever of `properties` / `oneOf` comes
|
|
814
|
+
// first in key order. Emitted verbatim when the walk reaches `properties`.
|
|
815
|
+
const sanitizedProperties = hasOwn(node, "properties")
|
|
816
|
+
? sanitizePropertiesKeyword(node.properties, childDepth)
|
|
817
|
+
: undefined;
|
|
818
|
+
const ownProperties = isPlainObject(sanitizedProperties)
|
|
819
|
+
? sanitizedProperties
|
|
334
820
|
: null;
|
|
821
|
+
// Property names a `required` on THIS node may reference — the node's OWN
|
|
822
|
+
// `properties` (see module header: transform #3 matches Gemini's validator,
|
|
823
|
+
// which does not resolve parent scope), PLUS, when this node is a composition
|
|
824
|
+
// branch, the enclosing node's `properties` (transform #8: a branch of an
|
|
825
|
+
// `allOf`/`anyOf`/`oneOf` IS evaluated against the enclosing node, so naming
|
|
826
|
+
// its properties is correct JSON Schema, not a dangling reference).
|
|
827
|
+
//
|
|
828
|
+
// Probed against the two maps directly rather than unioned into a `Set`.
|
|
829
|
+
// Building that set per node costs O(parent properties) for EVERY branch, so
|
|
830
|
+
// a schema with P parent properties and B branches is O(P × B) — and both are
|
|
831
|
+
// attacker-controlled for a third-party MCP `rawJsonSchema`. At P = B = 3000
|
|
832
|
+
// that measured ~1.1s of synchronously blocked event loop, which is a denial
|
|
833
|
+
// of service for every tenant on the process, not just the one whose
|
|
834
|
+
// connector served the schema (the same hazard `flattenRootAllOf` guards
|
|
835
|
+
// against below). Two `hasOwn` probes per `required` entry make it O(P + B).
|
|
836
|
+
const declaresProperty = (name) => (ownProperties !== null && hasOwn(ownProperties, name)) ||
|
|
837
|
+
(parentProperties != null && hasOwn(parentProperties, name));
|
|
335
838
|
// Resolve the node's effective single `type`, collapsing a JSON Schema
|
|
336
839
|
// `type` ARRAY (a union, e.g. `["string","number","boolean"]` or a nullable
|
|
337
840
|
// `["string","null"]`) to the first non-"null" member. Gemini's
|
|
338
841
|
// function-declaration schema requires a single `type` and hard-rejects a
|
|
339
842
|
// type array, which manifests as a misleading downstream error. Computed up
|
|
340
843
|
// front (not in key order) because the `enum` decision below depends on it.
|
|
844
|
+
const notesFromRetype = [];
|
|
341
845
|
let typeToEmit = node.type;
|
|
342
846
|
let emitType = "type" in node;
|
|
343
847
|
let singleType;
|
|
@@ -355,6 +859,30 @@ function sanitizeSchemaNode(node, depth) {
|
|
|
355
859
|
else if (typeof node.type === "string") {
|
|
356
860
|
singleType = node.type;
|
|
357
861
|
}
|
|
862
|
+
// Transform #12: a node carrying `properties` IS an object, and Gemini
|
|
863
|
+
// requires it to say so — "For schema with properties, schema type should be
|
|
864
|
+
// OBJECT" on all of gemini-3.5/3.6/3.7-flash, both when the `type` is missing
|
|
865
|
+
// and when it contradicts (`{type:"string", properties:{…}}` fails the same
|
|
866
|
+
// way). Not composition-specific: a plain property subschema and an `items`
|
|
867
|
+
// schema fail identically. xAI and OpenAI accept every one of those forms,
|
|
868
|
+
// which is why it survived until a property sweep against live inference went
|
|
869
|
+
// looking.
|
|
870
|
+
//
|
|
871
|
+
// Resolved HERE rather than patched onto the finished node so the rest of the
|
|
872
|
+
// walk sees the real type: `dropEnum` below keys off it, and an `enum` left
|
|
873
|
+
// behind on a node that has just become an object would be dropped by the
|
|
874
|
+
// NEXT pass instead of this one — an idempotence break.
|
|
875
|
+
const retypedFromProperties = isPlainObject(sanitizedProperties) && singleType !== "object";
|
|
876
|
+
if (retypedFromProperties) {
|
|
877
|
+
if (singleType !== undefined) {
|
|
878
|
+
// A contradictory declared type is discarded; record it like transform #1
|
|
879
|
+
// records a collapsed union.
|
|
880
|
+
notesFromRetype.push(`Declared as ${singleType}, but carries properties, so it is treated as an object.`);
|
|
881
|
+
}
|
|
882
|
+
typeToEmit = "object";
|
|
883
|
+
emitType = true;
|
|
884
|
+
singleType = "object";
|
|
885
|
+
}
|
|
358
886
|
// Gemini accepts `enum` only on string-typed properties. Drop it outright
|
|
359
887
|
// when the node has an explicit NON-string type (boolean/number/integer/
|
|
360
888
|
// array/object/null); when the type is "string" or absent, the enum is
|
|
@@ -365,7 +893,7 @@ function sanitizeSchemaNode(node, depth) {
|
|
|
365
893
|
// could use. Fold that information into the node's `description` (as prose,
|
|
366
894
|
// which every provider accepts) so the model still sees it. Computed up
|
|
367
895
|
// front so it can be appended wherever `description` appears in key order.
|
|
368
|
-
const notes = [];
|
|
896
|
+
const notes = [...notesFromRetype];
|
|
369
897
|
if (Array.isArray(node.type)) {
|
|
370
898
|
const typeNames = node.type.filter((t) => typeof t === "string");
|
|
371
899
|
if (typeNames.length > 1) {
|
|
@@ -390,6 +918,17 @@ function sanitizeSchemaNode(node, depth) {
|
|
|
390
918
|
notes.push(`May also be ${humanJoin(dropped.map(describeEnumValue))}.`);
|
|
391
919
|
}
|
|
392
920
|
}
|
|
921
|
+
// Transform #13, decided up front so its note joins the others before
|
|
922
|
+
// `descNote` is frozen and the loop below can simply skip the keyword.
|
|
923
|
+
const dropPattern = typeof node.pattern === "string" &&
|
|
924
|
+
usesUnsupportedRegexConstruct(node.pattern);
|
|
925
|
+
if (dropPattern) {
|
|
926
|
+
const pattern = node.pattern;
|
|
927
|
+
const shown = pattern.length > MAX_PATTERN_IN_PROSE
|
|
928
|
+
? `${pattern.slice(0, MAX_PATTERN_IN_PROSE)}…`
|
|
929
|
+
: pattern;
|
|
930
|
+
notes.push(`Should match the pattern ${shown}.`);
|
|
931
|
+
}
|
|
393
932
|
const descNote = notes.join(" ");
|
|
394
933
|
let descriptionEmitted = false;
|
|
395
934
|
const out = {};
|
|
@@ -433,7 +972,7 @@ function sanitizeSchemaNode(node, depth) {
|
|
|
433
972
|
}
|
|
434
973
|
if (key === "required") {
|
|
435
974
|
if (Array.isArray(value)) {
|
|
436
|
-
const pruned = value.filter((name) => typeof name === "string" && (
|
|
975
|
+
const pruned = value.filter((name) => typeof name === "string" && declaresProperty(name));
|
|
437
976
|
// An empty `required: []` is valid but noise; omit it entirely.
|
|
438
977
|
if (pruned.length > 0)
|
|
439
978
|
out.required = pruned;
|
|
@@ -442,6 +981,17 @@ function sanitizeSchemaNode(node, depth) {
|
|
|
442
981
|
// trip a strict validator) — drop it.
|
|
443
982
|
continue;
|
|
444
983
|
}
|
|
984
|
+
if (key === "pattern") {
|
|
985
|
+
// Transform #13: a regex construct some provider refuses takes the WHOLE
|
|
986
|
+
// request down, so the pattern is dropped and echoed as prose instead —
|
|
987
|
+
// the model still sees the intent, and arguments are validated at
|
|
988
|
+
// dispatch and by the remote server regardless. A supported pattern is a
|
|
989
|
+
// real constraint every provider honours and is kept verbatim.
|
|
990
|
+
if (dropPattern)
|
|
991
|
+
continue;
|
|
992
|
+
out.pattern = value;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
445
995
|
if (key === "dependencies") {
|
|
446
996
|
// draft-07: name → (subschema | string[]). Subschema values are
|
|
447
997
|
// sanitized (and self-prune their own `required`); string[] values are
|
|
@@ -459,19 +1009,9 @@ function sanitizeSchemaNode(node, depth) {
|
|
|
459
1009
|
continue;
|
|
460
1010
|
}
|
|
461
1011
|
if (key === "properties") {
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
// `/properties/properties` collision — `patternProperties`/`$defs` keys are
|
|
466
|
-
// patterns/definition names, not property names, so they go through the
|
|
467
|
-
// generic map handler below.)
|
|
468
|
-
const mapped = sanitizeSchemaMap(value, childDepth);
|
|
469
|
-
if (isPlainObject(mapped) &&
|
|
470
|
-
isPlainObject(mapped.properties) &&
|
|
471
|
-
looksObjectShapedToXai(mapped.properties)) {
|
|
472
|
-
mapped.properties = neutralizeXaiPropertiesField(mapped.properties);
|
|
473
|
-
}
|
|
474
|
-
out.properties = mapped;
|
|
1012
|
+
// Already sanitized (with transform #7 applied) ahead of this walk, since
|
|
1013
|
+
// transform #8 below may need it first. Emit it in its original position.
|
|
1014
|
+
out.properties = sanitizedProperties;
|
|
475
1015
|
continue;
|
|
476
1016
|
}
|
|
477
1017
|
if (SUBSCHEMA_MAP_KEYS.has(key)) {
|
|
@@ -494,7 +1034,31 @@ function sanitizeSchemaNode(node, depth) {
|
|
|
494
1034
|
out[key] = sanitizeSubschema(value, childDepth);
|
|
495
1035
|
continue;
|
|
496
1036
|
}
|
|
1037
|
+
if (COMPOSITION_KEYS.has(key)) {
|
|
1038
|
+
// Transform #10: an EMPTY array-valued keyword is a hard 400 on xAI at
|
|
1039
|
+
// any depth — `/properties/a/oneOf: [] has less than 1 item` — for all
|
|
1040
|
+
// three composition keywords and for `prefixItems` below. It carries no
|
|
1041
|
+
// constraint anyway (an `allOf` of nothing is satisfied by everything), so
|
|
1042
|
+
// it is omitted rather than emitted. Empty OBJECTS (`properties: {}`,
|
|
1043
|
+
// `$defs: {}`) and an empty `required: []` are accepted and unaffected.
|
|
1044
|
+
if (Array.isArray(value) && value.length === 0)
|
|
1045
|
+
continue;
|
|
1046
|
+
// Transform #8: each element is evaluated against THIS node, so it is
|
|
1047
|
+
// handed this node's property scope. The self-sufficiency repair is
|
|
1048
|
+
// applied at the parameters root only (`depth === 0`) — nested
|
|
1049
|
+
// compositions are accepted as-is by every measured provider.
|
|
1050
|
+
// One budget per root composition keyword, shared across its branches so
|
|
1051
|
+
// the ceiling bounds the TOTAL copy-down, not each branch individually.
|
|
1052
|
+
const budget = depth === 0 ? { remaining: MAX_ROOT_COPY_DOWN } : null;
|
|
1053
|
+
out[key] = Array.isArray(value)
|
|
1054
|
+
? value.map((v) => sanitizeCompositionBranch(v, childDepth, ownProperties, budget))
|
|
1055
|
+
: value;
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
497
1058
|
if (SUBSCHEMA_ARRAY_KEYS.has(key)) {
|
|
1059
|
+
// `prefixItems: []` — same empty-array rejection as the compositions above.
|
|
1060
|
+
if (Array.isArray(value) && value.length === 0)
|
|
1061
|
+
continue;
|
|
498
1062
|
out[key] = Array.isArray(value)
|
|
499
1063
|
? value.map((v) => sanitizeSubschema(v, childDepth))
|
|
500
1064
|
: value;
|
|
@@ -511,6 +1075,11 @@ function sanitizeSchemaNode(node, depth) {
|
|
|
511
1075
|
// them to, add one so the dropped information still reaches the model.
|
|
512
1076
|
if (descNote && !descriptionEmitted)
|
|
513
1077
|
out.description = descNote;
|
|
1078
|
+
// Transform #12, emission half. A node that DECLARED a `type` had it rewritten
|
|
1079
|
+
// in the key loop above; one that declared none needs the key appended, since
|
|
1080
|
+
// the loop only ever emits keys the node actually had.
|
|
1081
|
+
if (retypedFromProperties && !hasOwn(node, "type"))
|
|
1082
|
+
out.type = "object";
|
|
514
1083
|
return out;
|
|
515
1084
|
}
|
|
516
1085
|
/**
|
|
@@ -658,10 +1227,10 @@ function flattenRootAllOf(root) {
|
|
|
658
1227
|
const uniqueDescriptions = [...new Set(descriptions)];
|
|
659
1228
|
if (uniqueDescriptions.length > 0)
|
|
660
1229
|
out.description = uniqueDescriptions.join(" ");
|
|
661
|
-
// `
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
const required = [...requiredSet].filter((name) =>
|
|
1230
|
+
// `hasOwn`, not `name in mergedProps`: the latter would treat a required
|
|
1231
|
+
// entry named after a prototype member ("toString", "constructor") as present
|
|
1232
|
+
// and fail to prune a genuinely-undeclared property.
|
|
1233
|
+
const required = [...requiredSet].filter((name) => hasOwn(mergedProps, name));
|
|
665
1234
|
if (required.length > 0)
|
|
666
1235
|
out.required = required;
|
|
667
1236
|
if (additionalProperties !== undefined) {
|
|
@@ -669,15 +1238,461 @@ function flattenRootAllOf(root) {
|
|
|
669
1238
|
}
|
|
670
1239
|
return out;
|
|
671
1240
|
}
|
|
1241
|
+
/** The two composition keywords whose ROOT branches xAI validates individually.
|
|
1242
|
+
* `allOf` is deliberately absent: measured, its branches are unconstrained (a
|
|
1243
|
+
* `$ref`, `not`, nested-`allOf` or scalar branch all pass) — for `allOf` it is
|
|
1244
|
+
* the ROOT's own object-ness that matters, which is the second half of
|
|
1245
|
+
* transform #9. */
|
|
1246
|
+
const ROOT_UNION_KEYS = ["anyOf", "oneOf"];
|
|
1247
|
+
/**
|
|
1248
|
+
* True if a sanitized branch of a ROOT `anyOf`/`oneOf` is one xAI accepts.
|
|
1249
|
+
* Measured on grok-4.3/4.5/4.6: `{type:"object", properties:…, required:…}`,
|
|
1250
|
+
* `{properties:…, required:…}` and `{type:"object", required:…}` all pass —
|
|
1251
|
+
* `type: "object"` OR `properties`, either alone, suffices — while a bare
|
|
1252
|
+
* `{required:[…]}`, a `{}`, and a `{type:"string"}` are a 400 that fails every
|
|
1253
|
+
* tool in the request. The rule holds even when the root itself declares
|
|
1254
|
+
* `type: "object"` and `properties`, so an object root does not excuse a
|
|
1255
|
+
* non-object branch.
|
|
1256
|
+
*/
|
|
1257
|
+
function isUsableRootBranch(branch) {
|
|
1258
|
+
if (!isPlainObject(branch))
|
|
1259
|
+
return false;
|
|
1260
|
+
if (Object.keys(branch).length === 0)
|
|
1261
|
+
return false;
|
|
1262
|
+
// A branch carrying its OWN `anyOf`/`oneOf` is unusable however object-shaped
|
|
1263
|
+
// it otherwise looks: `{type:"object", anyOf:[…]}` as a root union branch is
|
|
1264
|
+
// 0/3 on grok-4.3/4.5/4.6 (and a branch carrying BOTH `allOf` and `anyOf` is
|
|
1265
|
+
// 0/3 too — the union is what poisons it), while every Gemini and GPT model
|
|
1266
|
+
// accepts it. The `type` is not the problem, so the checks below never see it.
|
|
1267
|
+
//
|
|
1268
|
+
// This is NOT the recursion that was rejected earlier. Making such a branch
|
|
1269
|
+
// *usable* is impossible — that was the right call. But "cannot be made
|
|
1270
|
+
// usable" means it must be REPORTED unusable, which routes it into the drop
|
|
1271
|
+
// path that already handles the other unrepairable branch shapes correctly.
|
|
1272
|
+
//
|
|
1273
|
+
// Scope is the branch's OWN keys, never its subtree. A composition on a
|
|
1274
|
+
// branch's PROPERTY is accepted 9/9 (`{type:"object", properties:{a:{anyOf:
|
|
1275
|
+
// […]}}}`), as is one two levels down, so a subtree scan would destroy
|
|
1276
|
+
// constraints every provider honours. `ROOT_UNION_KEYS`, not
|
|
1277
|
+
// `COMPOSITION_KEYS`: a branch's own `allOf` is accepted 9/9 — including with
|
|
1278
|
+
// a bare-`required` sub-branch — which is the same intersection-vs-union
|
|
1279
|
+
// asymmetry xAI applies to the root itself.
|
|
1280
|
+
if (ROOT_UNION_KEYS.some((key) => hasOwn(branch, key)))
|
|
1281
|
+
return false;
|
|
1282
|
+
// A `$ref` disqualifies a branch too, and NOT only when it stands alone:
|
|
1283
|
+
// `{$ref, type:"object"}` and `{$ref, properties}` are both 0/3 on
|
|
1284
|
+
// grok-4.3/4.5/4.6 (accepted by Gemini and OpenAI). xAI does not resolve a
|
|
1285
|
+
// union branch's `$ref` at all, so sibling object keywords do not redeem it.
|
|
1286
|
+
// Transform #14 gets first refusal on these — it inlines the target and keeps
|
|
1287
|
+
// the siblings — so only an un-inlinable one reaches the drop.
|
|
1288
|
+
if (hasOwn(branch, "$ref"))
|
|
1289
|
+
return false;
|
|
1290
|
+
// A declared `type` decides on its own: `properties` alongside an explicit
|
|
1291
|
+
// NON-object type is a self-contradictory branch (object keywords on a
|
|
1292
|
+
// non-object schema), and measured, xAI rejects it exactly as it rejects a
|
|
1293
|
+
// bare `{type:"string"}` — `{type:"string", properties:{…}, required:[…]}` is
|
|
1294
|
+
// 0/3 on grok-4.3/4.5/4.6 while the same branch without the `type` is 3/3. So
|
|
1295
|
+
// `properties` suffices only when the branch declares no type at all.
|
|
1296
|
+
// (Transform #1 has already collapsed any `type` ARRAY to a scalar by now.)
|
|
1297
|
+
if (hasOwn(branch, "type"))
|
|
1298
|
+
return branch.type === "object";
|
|
1299
|
+
return isPlainObject(branch.properties);
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* True if the sanitized root reads as an object root to xAI, which rejects a
|
|
1303
|
+
* parameters root that is not one. Measured: `{type:"object", …}` and
|
|
1304
|
+
* `{properties:…}` pass, an entirely empty `{}` passes, and a union root whose
|
|
1305
|
+
* branches are all object-carrying passes (xAI infers object-ness from the
|
|
1306
|
+
* branches) — but a root carrying only `$defs`, or only an `allOf`, is a 400.
|
|
1307
|
+
*/
|
|
1308
|
+
function isObjectRoot(root) {
|
|
1309
|
+
// A declared `type` decides first, and it is the strictest rule measured
|
|
1310
|
+
// anywhere in this module: a root whose `type` is not "object" is rejected by
|
|
1311
|
+
// grok-4.3/4.5/4.6, gpt-5.6-sol AND gemini-3.7-flash — 0/5 — with or without
|
|
1312
|
+
// a `properties` map alongside it. Every other root rule has at least one
|
|
1313
|
+
// tolerant provider; this one has none.
|
|
1314
|
+
if (hasOwn(root, "type"))
|
|
1315
|
+
return root.type === "object";
|
|
1316
|
+
if (isPlainObject(root.properties))
|
|
1317
|
+
return true;
|
|
1318
|
+
if (Object.keys(root).length === 0)
|
|
1319
|
+
return true;
|
|
1320
|
+
// Only ever called after `dropUnusableRootUnions`, so a union still present
|
|
1321
|
+
// here is one whose branches are all usable.
|
|
1322
|
+
return ROOT_UNION_KEYS.some((key) => Array.isArray(root[key]) && root[key].length > 0);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Transform #9 — the root-usability pass (see the module header). Two rules,
|
|
1326
|
+
* both measured against grok-4.3/4.5/4.6 through OpenRouter with
|
|
1327
|
+
* `provider.allow_fallbacks: false` and one tool per request:
|
|
1328
|
+
*
|
|
1329
|
+
* a. A branch of a root `anyOf`/`oneOf` that transform #8 could not make
|
|
1330
|
+
* object-carrying cannot be repaired at all — its `required` names something
|
|
1331
|
+
* no `properties` map declares, or it is a `$ref`/scalar/boolean branch. Drop
|
|
1332
|
+
* the whole keyword rather than emit a root that 400s every tool in the
|
|
1333
|
+
* request: losing one tool's constraint beats losing every tool. (An empty
|
|
1334
|
+
* composition array constrains nothing and goes the same way.)
|
|
1335
|
+
* b. A composition root that declares neither `type` nor `properties` is
|
|
1336
|
+
* rejected on its own account, whatever its branches look like — `{allOf:
|
|
1337
|
+
* [{$ref:…}]}` and `{allOf:[…, {not:…}]}` are 0/3 on Grok. Adding
|
|
1338
|
+
* `type: "object"` makes exactly those roots pass (9/9) while changing
|
|
1339
|
+
* nothing about the composition, so the un-flattenable `allOf` that
|
|
1340
|
+
* transform #5 deliberately preserves is rescued rather than discarded.
|
|
1341
|
+
* Nothing is added when the root already declares a `type`.
|
|
1342
|
+
*
|
|
1343
|
+
* Applies to the parameters root only — no provider constrains a nested
|
|
1344
|
+
* composition, and every measured model accepts the nested forms handled here.
|
|
1345
|
+
* Runs after `flattenRootAllOf`, so a root `allOf` that flattened losslessly is
|
|
1346
|
+
* already gone and only an un-flattenable one reaches rule (b).
|
|
1347
|
+
*/
|
|
1348
|
+
/**
|
|
1349
|
+
* Transform #14: inline a ROOT union branch that is a local `$ref`, so the union
|
|
1350
|
+
* survives rule (a) instead of being dropped whole.
|
|
1351
|
+
*
|
|
1352
|
+
* `z.union([A, B])` renders as `{anyOf: [{$ref}, {$ref}], $defs}`, and xAI does
|
|
1353
|
+
* not resolve a `$ref` union branch — 0/3 even with `type: "object"` on the
|
|
1354
|
+
* root. Rule (a) therefore dropped the keyword and, with the properties living
|
|
1355
|
+
* in `$defs`, the tool was left advertising no parameters at all. Inlining the
|
|
1356
|
+
* target makes the branch an ordinary object branch, which is accepted 9/9, and
|
|
1357
|
+
* keeps the constraint the union was expressing.
|
|
1358
|
+
*
|
|
1359
|
+
* Only branches that are NOT already usable are inlined, so a well-formed union
|
|
1360
|
+
* is untouched and the emitted schema does not grow for no reason. One level
|
|
1361
|
+
* only: a `$ref` inside the inlined target is left to resolve on its own, which
|
|
1362
|
+
* is both what providers accept and what makes a recursive definition
|
|
1363
|
+
* terminate here rather than loop. Sibling keywords on the branch are kept and
|
|
1364
|
+
* win over the target's, matching 2020-12 `$ref` semantics. The branch count is
|
|
1365
|
+
* capped for the same reason the copy-down is — both counts are
|
|
1366
|
+
* attacker-controlled, and the inlined objects are shared references that expand
|
|
1367
|
+
* on `JSON.stringify`.
|
|
1368
|
+
*/
|
|
1369
|
+
function inlineRootUnionRefs(root) {
|
|
1370
|
+
let changed = false;
|
|
1371
|
+
const out = {};
|
|
1372
|
+
// ONE budget for the whole root, not one per keyword: a root carrying both an
|
|
1373
|
+
// `anyOf` and a `oneOf` would otherwise get twice the ceiling, which
|
|
1374
|
+
// contradicts the "single ceiling across one root composition" invariant that
|
|
1375
|
+
// transform #8's copy-down also states.
|
|
1376
|
+
let budget = MAX_ROOT_COPY_DOWN;
|
|
1377
|
+
for (const [key, value] of Object.entries(root)) {
|
|
1378
|
+
if (!ROOT_UNION_KEYS.includes(key) || !Array.isArray(value)) {
|
|
1379
|
+
safeSet(out, key, value);
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
const branches = value.map((branch) => {
|
|
1383
|
+
if (isUsableRootBranch(branch))
|
|
1384
|
+
return branch;
|
|
1385
|
+
if (!isPlainObject(branch) || typeof branch.$ref !== "string")
|
|
1386
|
+
return branch;
|
|
1387
|
+
const ref = branch.$ref;
|
|
1388
|
+
if (ref !== "#" && !ref.startsWith("#/"))
|
|
1389
|
+
return branch;
|
|
1390
|
+
const target = resolveLocalPointer(root, ref);
|
|
1391
|
+
// A self-referential root pointer would inline the whole document into
|
|
1392
|
+
// one of its own branches; nothing useful, and unbounded.
|
|
1393
|
+
if (!isPlainObject(target) || target === root)
|
|
1394
|
+
return branch;
|
|
1395
|
+
// Priced by EXPANDED size — the whole subtree that will be duplicated
|
|
1396
|
+
// into the request body — not by the target's top-level key count.
|
|
1397
|
+
const size = subtreeWeight(target, budget);
|
|
1398
|
+
if (size > budget)
|
|
1399
|
+
return branch;
|
|
1400
|
+
budget -= size;
|
|
1401
|
+
const inlined = {};
|
|
1402
|
+
for (const [k, v] of Object.entries(target))
|
|
1403
|
+
safeSet(inlined, k, v);
|
|
1404
|
+
for (const [k, v] of Object.entries(branch)) {
|
|
1405
|
+
if (k === "$ref")
|
|
1406
|
+
continue;
|
|
1407
|
+
safeSet(inlined, k, v);
|
|
1408
|
+
}
|
|
1409
|
+
changed = true;
|
|
1410
|
+
return inlined;
|
|
1411
|
+
});
|
|
1412
|
+
out[key] = branches;
|
|
1413
|
+
}
|
|
1414
|
+
return changed ? out : root;
|
|
1415
|
+
}
|
|
1416
|
+
function dropUnusableRootUnions(root) {
|
|
1417
|
+
// All-or-nothing, and deliberately so. ONE unusable branch poisons the whole
|
|
1418
|
+
// keyword on xAI — `{type:"object", properties:{…}, oneOf:[{type:"object",
|
|
1419
|
+
// required:[…]}, {type:"string"}]}` is 0/3 on Grok despite the first branch
|
|
1420
|
+
// being perfectly good — so a mixed union cannot simply be left alone.
|
|
1421
|
+
//
|
|
1422
|
+
// Filtering the bad branches out instead of dropping the keyword IS accepted
|
|
1423
|
+
// (a lone surviving usable branch measures 3/3), and it is not what we do:
|
|
1424
|
+
// filtering ADVERTISES A NARROWER TOOL. `oneOf: [A, B]` filtered to
|
|
1425
|
+
// `oneOf: [A]` tells the model the B-shaped call is invalid, so it will never
|
|
1426
|
+
// make one, and nothing surfaces that the arm went missing. Dropping the
|
|
1427
|
+
// keyword instead leaves the parent's `properties` fully visible: the model
|
|
1428
|
+
// can still call the tool either way, and a wrong COMBINATION is caught at
|
|
1429
|
+
// dispatch by the zod schema and by the remote MCP server, where it comes back
|
|
1430
|
+
// as a recoverable tool error the model can retry. That matches every other
|
|
1431
|
+
// transform here — they discard advisory constraints, never callable shapes.
|
|
1432
|
+
const dropped = new Set();
|
|
1433
|
+
for (const key of ROOT_UNION_KEYS) {
|
|
1434
|
+
const branches = root[key];
|
|
1435
|
+
if (!Array.isArray(branches))
|
|
1436
|
+
continue;
|
|
1437
|
+
if (branches.length === 0 || !branches.every(isUsableRootBranch)) {
|
|
1438
|
+
dropped.add(key);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
if (dropped.size === 0)
|
|
1442
|
+
return root;
|
|
1443
|
+
const out = {};
|
|
1444
|
+
for (const [key, value] of Object.entries(root)) {
|
|
1445
|
+
if (dropped.has(key))
|
|
1446
|
+
continue;
|
|
1447
|
+
safeSet(out, key, value);
|
|
1448
|
+
}
|
|
1449
|
+
return out;
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Keywords that make xAI classify the parameters ROOT as something other than an
|
|
1453
|
+
* object schema, whatever else it declares. Measured on grok-4.5: a root
|
|
1454
|
+
* carrying `const` is `root schema is a const` and a root carrying `$ref` is
|
|
1455
|
+
* `root schema is a $ref` — both 400, both even when the root also declares
|
|
1456
|
+
* `type: "object"` AND `properties`, and the `$ref` case even when the target
|
|
1457
|
+
* resolves. Neither can describe a callable argument object, so both are dropped
|
|
1458
|
+
* at the root and their information folded into the description. Nested `const`
|
|
1459
|
+
* and `$ref` are meaningful and untouched.
|
|
1460
|
+
*/
|
|
1461
|
+
const ROOT_REJECTED_KEYWORDS = ["const", "$ref"];
|
|
1462
|
+
/**
|
|
1463
|
+
* Transform #9, rule (b). Runs AFTER `flattenRootAllOf`, so a root `allOf` that
|
|
1464
|
+
* merged losslessly is already gone and only a composition that survived — or a
|
|
1465
|
+
* root left bare by rule (a) — reaches this check. Applies to EVERY root, not
|
|
1466
|
+
* just composition roots.
|
|
1467
|
+
*/
|
|
1468
|
+
function ensureObjectRoot(root) {
|
|
1469
|
+
const rejected = ROOT_REJECTED_KEYWORDS.filter((key) => hasOwn(root, key));
|
|
1470
|
+
if (rejected.length > 0) {
|
|
1471
|
+
const stripped = {};
|
|
1472
|
+
for (const [key, value] of Object.entries(root)) {
|
|
1473
|
+
if (rejected.includes(key))
|
|
1474
|
+
continue;
|
|
1475
|
+
safeSet(stripped, key, value);
|
|
1476
|
+
}
|
|
1477
|
+
const note = rejected
|
|
1478
|
+
.map((key) => key === "const"
|
|
1479
|
+
? `Pinned to a single value (${describeEnumValue(root.const)}) at the schema root.`
|
|
1480
|
+
: `Declared as a reference (${describeEnumValue(root.$ref)}) at the schema root.`)
|
|
1481
|
+
.join(" ");
|
|
1482
|
+
stripped.description =
|
|
1483
|
+
typeof stripped.description === "string" && stripped.description.length > 0
|
|
1484
|
+
? `${stripped.description} ${note}`
|
|
1485
|
+
: note;
|
|
1486
|
+
return ensureObjectRoot(stripped);
|
|
1487
|
+
}
|
|
1488
|
+
if (isObjectRoot(root))
|
|
1489
|
+
return root;
|
|
1490
|
+
// Applies to EVERY root, not just composition roots. A property-based sweep
|
|
1491
|
+
// over recursive schema shapes found the narrower gate emitting unusable
|
|
1492
|
+
// roots for whole classes the example fixtures never covered: a scalar or
|
|
1493
|
+
// array root (`{type:"string"}`, reachable from transform #1 collapsing
|
|
1494
|
+
// `{type:["string","null"]}`), an annotation-only root (`{description}`), a
|
|
1495
|
+
// `$defs`-only root, and a root whose `type` contradicts its `properties`.
|
|
1496
|
+
// Measured, all of them fail Grok, the scalar ones also fail gpt-5.6-sol, and
|
|
1497
|
+
// the contradictory-type one fails all nine. `{type:"object"}` plus whatever
|
|
1498
|
+
// else the root carried is accepted 9/9 in every case.
|
|
1499
|
+
//
|
|
1500
|
+
// Forcing `type: "object"` over a declared scalar type is a rewrite, not a
|
|
1501
|
+
// repair, and it is the right one: function-call arguments are always a named
|
|
1502
|
+
// object on the wire, so a scalar parameters root cannot describe a callable
|
|
1503
|
+
// tool no matter which provider reads it. Sibling keywords are left as they
|
|
1504
|
+
// are — an `items` stranded on an object root is inert, and every model
|
|
1505
|
+
// accepts it, so removing it would be surgery with no measured benefit.
|
|
1506
|
+
const declaredType = typeof root.type === "string" ? root.type : undefined;
|
|
1507
|
+
const notes = [];
|
|
1508
|
+
const out = { type: "object" };
|
|
1509
|
+
for (const [key, value] of Object.entries(root)) {
|
|
1510
|
+
if (key === "type")
|
|
1511
|
+
continue;
|
|
1512
|
+
// `enum` is the ONE keyword whose treatment depends on the node's type
|
|
1513
|
+
// (transform #2 accepts it only on a string-typed or type-less node), so
|
|
1514
|
+
// retyping the root to "object" while keeping it would leave a root the
|
|
1515
|
+
// node walk would sanitize differently on a second pass — i.e. break
|
|
1516
|
+
// idempotence, which the property sweep catches immediately. Drop it here
|
|
1517
|
+
// and surface the values as prose, exactly as transform #2 does.
|
|
1518
|
+
if (key === "enum") {
|
|
1519
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
1520
|
+
notes.push(`Allowed values: ${value.map(describeEnumValue).join(", ")}.`);
|
|
1521
|
+
}
|
|
1522
|
+
continue;
|
|
1523
|
+
}
|
|
1524
|
+
safeSet(out, key, value);
|
|
1525
|
+
}
|
|
1526
|
+
if (declaredType !== undefined) {
|
|
1527
|
+
// Fold the discarded type into the description, as transforms #1 and #2 do
|
|
1528
|
+
// for the constraints they drop.
|
|
1529
|
+
notes.push(`Declared as ${declaredType} at the schema root; arguments are passed as an object.`);
|
|
1530
|
+
}
|
|
1531
|
+
// `enum` is dropped above whenever the root is retyped, INCLUDING when it
|
|
1532
|
+
// declared no type at all (`{enum:["a"]}`). Keying that on a declared scalar
|
|
1533
|
+
// type left an `enum` sitting on a now-object root, which the next pass would
|
|
1534
|
+
// drop under transform #2 — an idempotence break the generator never reached
|
|
1535
|
+
// because it emitted no type-less `enum` root.
|
|
1536
|
+
if (notes.length > 0) {
|
|
1537
|
+
const note = notes.join(" ");
|
|
1538
|
+
out.description =
|
|
1539
|
+
typeof out.description === "string" && out.description.length > 0
|
|
1540
|
+
? `${out.description} ${note}`
|
|
1541
|
+
: note;
|
|
1542
|
+
}
|
|
1543
|
+
return out;
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Resolve a local JSON Pointer (`#/a/b`) against the document root, returning
|
|
1547
|
+
* `undefined` when any segment is missing. Segment unescaping is the RFC 6901
|
|
1548
|
+
* order — `~1` before `~0` — because doing it the other way turns a literal
|
|
1549
|
+
* `~01` into `/` instead of `~1`.
|
|
1550
|
+
*/
|
|
1551
|
+
function resolveLocalPointer(root, ref) {
|
|
1552
|
+
if (ref === "#")
|
|
1553
|
+
return root;
|
|
1554
|
+
const path = ref.slice(2);
|
|
1555
|
+
// `#/` and `#//`-style paths carry no real segments; RFC 6901 makes an empty
|
|
1556
|
+
// path the document root, and treating "" as a property name would prune a
|
|
1557
|
+
// reference that is technically valid.
|
|
1558
|
+
if (path === "")
|
|
1559
|
+
return root;
|
|
1560
|
+
const segments = path.split("/");
|
|
1561
|
+
let current = root;
|
|
1562
|
+
for (const raw of segments) {
|
|
1563
|
+
// A `$ref` is a URI, so its fragment is percent-encoded: `#/$defs/a%20b`
|
|
1564
|
+
// addresses the key `"a b"`. Decode that layer FIRST, then the pointer
|
|
1565
|
+
// layer's `~1`/`~0`. Skipping the decode strips a perfectly valid reference
|
|
1566
|
+
// as unresolvable and silently loses the constraint it carried. A malformed
|
|
1567
|
+
// escape (`%zz`) throws, and the raw text is the right fallback there.
|
|
1568
|
+
let decoded;
|
|
1569
|
+
try {
|
|
1570
|
+
decoded = decodeURIComponent(raw);
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
decoded = raw;
|
|
1574
|
+
}
|
|
1575
|
+
const segment = decoded.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
1576
|
+
if (Array.isArray(current)) {
|
|
1577
|
+
const index = Number(segment);
|
|
1578
|
+
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
1579
|
+
return undefined;
|
|
1580
|
+
}
|
|
1581
|
+
current = current[index];
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
if (!isPlainObject(current) || !hasOwn(current, segment))
|
|
1585
|
+
return undefined;
|
|
1586
|
+
current = current[segment];
|
|
1587
|
+
}
|
|
1588
|
+
return current;
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Transform #11: drop a `$ref` whose LOCAL target does not resolve.
|
|
1592
|
+
*
|
|
1593
|
+
* xAI resolves `#/...` pointers itself and 400s the whole request when one
|
|
1594
|
+
* dangles — `unresolvable $ref '#/$defs/Nope': key 'Nope' not found under
|
|
1595
|
+
* '#/$defs'`, and `key '$defs' not found in schema` when the document has no
|
|
1596
|
+
* `$defs` at all. This is easy to produce in practice: a third-party MCP server
|
|
1597
|
+
* that ships a subschema without its definitions, or one whose `$defs` sits on a
|
|
1598
|
+
* nested node (a `#/$defs/X` pointer is anchored at the document ROOT, so a
|
|
1599
|
+
* nested `$defs` is genuinely unaddressable that way).
|
|
1600
|
+
*
|
|
1601
|
+
* Only local pointers are checked. An external `$ref` (`https://…`) is accepted
|
|
1602
|
+
* by xAI and left alone, as is a resolvable non-`$defs` pointer such as
|
|
1603
|
+
* `#/properties/a` — both measured. Dropping only the `$ref` keyword leaves the
|
|
1604
|
+
* rest of the node intact; a node that was nothing but a dangling `$ref` becomes
|
|
1605
|
+
* the unconstrained `{}`, which every model accepts. This runs after the node
|
|
1606
|
+
* walk, so it resolves against the SANITIZED document — the one actually sent.
|
|
1607
|
+
*/
|
|
1608
|
+
function pruneUnresolvableRefs(node, root, depth, state = { changed: false }) {
|
|
1609
|
+
// `{}` rather than `true`, matching `sanitizeSchemaNode`'s guard: a boolean
|
|
1610
|
+
// schema is not a plain object, so `canLosslesslyFlattenAllOf` would refuse a
|
|
1611
|
+
// branch it would otherwise merge.
|
|
1612
|
+
if (depth > MAX_DEPTH)
|
|
1613
|
+
return {};
|
|
1614
|
+
if (Array.isArray(node)) {
|
|
1615
|
+
return node.map((item) => pruneUnresolvableRefs(item, root, depth + 1, state));
|
|
1616
|
+
}
|
|
1617
|
+
if (!isPlainObject(node))
|
|
1618
|
+
return node;
|
|
1619
|
+
const out = {};
|
|
1620
|
+
for (const [key, value] of Object.entries(node)) {
|
|
1621
|
+
if (key === "$ref" &&
|
|
1622
|
+
typeof value === "string" &&
|
|
1623
|
+
(value === "#" || value.startsWith("#/")) &&
|
|
1624
|
+
resolveLocalPointer(root, value) === undefined) {
|
|
1625
|
+
state.changed = true;
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
// Opaque data keywords may legitimately contain a `$ref`-named key that is
|
|
1629
|
+
// not a reference (the walk treats them as data for the same reason), so
|
|
1630
|
+
// they are copied rather than descended into.
|
|
1631
|
+
if (key === "enum" || key === "const" || key === "default" || key === "examples") {
|
|
1632
|
+
safeSet(out, key, value);
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
safeSet(out, key, pruneUnresolvableRefs(value, root, depth + 1, state));
|
|
1636
|
+
}
|
|
1637
|
+
return out;
|
|
1638
|
+
}
|
|
1639
|
+
/** Prune against `root`, returning the ORIGINAL reference when nothing was
|
|
1640
|
+
* dropped so the pipeline can detect a fixed point without a deep compare
|
|
1641
|
+
* (this module deliberately never hands an untrusted value to
|
|
1642
|
+
* `JSON.stringify`, whose own recursion would outrun the depth guard). */
|
|
1643
|
+
function prunedOrSame(root) {
|
|
1644
|
+
const state = { changed: false };
|
|
1645
|
+
const pruned = pruneUnresolvableRefs(root, root, 0, state);
|
|
1646
|
+
if (!state.changed)
|
|
1647
|
+
return root;
|
|
1648
|
+
return isPlainObject(pruned) ? pruned : {};
|
|
1649
|
+
}
|
|
672
1650
|
/**
|
|
673
1651
|
* Return a sanitized, non-mutating copy of a tool parameter JSON Schema (see
|
|
674
1652
|
* the module header for the immutability caveat — opaque leaf values are
|
|
675
1653
|
* shared by reference, so treat the result as read-only). A non-object root
|
|
676
1654
|
* (boolean schema, or malformed third-party payload) yields an empty object
|
|
677
1655
|
* rather than throwing.
|
|
1656
|
+
*
|
|
1657
|
+
* Idempotent: `sanitizeToolSchema(sanitizeToolSchema(x))` deep-equals
|
|
1658
|
+
* `sanitizeToolSchema(x)`. Transform #8's copy-down depends on that — a repaired
|
|
1659
|
+
* branch that a second pass re-emptied would be no repair at all.
|
|
678
1660
|
*/
|
|
679
1661
|
export function sanitizeToolSchema(schema) {
|
|
680
1662
|
if (!isPlainObject(schema))
|
|
681
1663
|
return {};
|
|
682
|
-
|
|
1664
|
+
const walked = sanitizeSchemaNode(schema, 0);
|
|
1665
|
+
// Rule (a) runs BEFORE the flatten, not after. `canLosslesslyFlattenAllOf`
|
|
1666
|
+
// refuses to merge an `allOf` while a `oneOf`/`anyOf` sibling is present (the
|
|
1667
|
+
// root is then not a pure intersection) — so dropping an unusable union
|
|
1668
|
+
// afterwards would leave behind an `allOf` that the NEXT pass would flatten,
|
|
1669
|
+
// breaking idempotence. Removing the union first makes the root a genuine
|
|
1670
|
+
// intersection, which is exactly when flattening it is correct.
|
|
1671
|
+
// The root passes are run to a FIXED POINT rather than once.
|
|
1672
|
+
//
|
|
1673
|
+
// Ordering alone cannot satisfy both constraints. Transform #11 has to run
|
|
1674
|
+
// BEFORE the flatten, because dropping a dangling `$ref` can empty the node
|
|
1675
|
+
// that carried it and an emptied `allOf` branch is merge-safe — pruning after
|
|
1676
|
+
// would strand a composition for the NEXT pass to merge. But it also has to
|
|
1677
|
+
// run AFTER the root rewrites, because those remove root keywords a nested
|
|
1678
|
+
// `$ref` may point at: `{properties:{x:{$ref:"#/const"}}, const:{}}` loses its
|
|
1679
|
+
// `const` to transform #9 and is left with exactly the dangling reference #11
|
|
1680
|
+
// exists to prevent, and a flatten can invalidate `#/allOf/0` the same way.
|
|
1681
|
+
//
|
|
1682
|
+
// Iterating settles it: each pass returns its INPUT REFERENCE when it changes
|
|
1683
|
+
// nothing, so a round that touches nothing ends the loop, and the bound keeps
|
|
1684
|
+
// a pathological schema from spinning. Two rounds cover every shape the
|
|
1685
|
+
// property sweep generates; the third is there so the bound is a backstop
|
|
1686
|
+
// rather than the mechanism.
|
|
1687
|
+
let doc = walked;
|
|
1688
|
+
for (let round = 0; round < 3; round++) {
|
|
1689
|
+
const before = doc;
|
|
1690
|
+
// #14 precedes rule (a): an inlinable `$ref` branch becomes an ordinary
|
|
1691
|
+
// object branch, so the union is kept rather than dropped. Anything still
|
|
1692
|
+
// unusable falls through to the drop.
|
|
1693
|
+
doc = ensureObjectRoot(flattenRootAllOf(dropUnusableRootUnions(inlineRootUnionRefs(prunedOrSame(doc)))));
|
|
1694
|
+
if (doc === before)
|
|
1695
|
+
break;
|
|
1696
|
+
}
|
|
1697
|
+
return doc;
|
|
683
1698
|
}
|