@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.
@@ -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
- function sanitizeSchemaNode(node, depth) {
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
- // Property names a `required` on THIS node may reference the node's OWN
330
- // `properties` only (see module header: matches Gemini's validator, which
331
- // does not resolve composition or parent scope).
332
- const propertyNames = isPlainObject(node.properties)
333
- ? new Set(Object.keys(node.properties))
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" && (propertyNames?.has(name) ?? false));
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
- // Recurse normally, then apply transform #7: a child property literally
463
- // named `properties` whose schema is object-shaped trips xAI's parser, so
464
- // collapse it to annotations only. (Only the `properties` MAP triggers the
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
- // `hasOwnProperty.call`, not `name in mergedProps`: the latter would treat a
662
- // required entry named after a prototype member ("toString", "constructor")
663
- // as present and fail to prune a genuinely-undeclared property.
664
- const required = [...requiredSet].filter((name) => Object.prototype.hasOwnProperty.call(mergedProps, 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
- return flattenRootAllOf(sanitizeSchemaNode(schema, 0));
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
  }