@juno-ai/bind 10.0.0 → 12.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,106 @@
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 `type` the
178
+ * schema declared for itself is never overridden.
179
+ *
180
+ * Rule (a) is all-or-nothing per keyword, because ONE unusable branch
181
+ * poisons the whole union on xAI even when its siblings are good (0/3 with a
182
+ * `{type:"string"}` sibling next to a valid object branch). Filtering the bad
183
+ * branches out instead is accepted by xAI (3/3) but is NOT what we do — it
184
+ * advertises a narrower tool, telling the model an arm is invalid so it never
185
+ * calls that shape, with nothing to surface the loss. Dropping the keyword
186
+ * leaves the parent's `properties` fully visible, so every shape stays
187
+ * callable and a wrong COMBINATION comes back from dispatch/the remote
188
+ * server as a recoverable tool error.
189
+ *
190
+ * Two shapes are known losses, both measured and both unavoidable here:
191
+ * a root union of `$ref` branches (xAI does not resolve them — 0/3 even with
192
+ * an object root), and a root union whose branch is itself a composition
193
+ * (`{oneOf:[{anyOf:[…]}]}` — 0/3 even with `type:"object"` stamped on the
194
+ * root). Both drop to a root that is accepted but advertises no properties.
195
+ * Recovering the first means inlining same-document `$ref`s before rule (a).
196
+ *
197
+ * A root `allOf`'s BRANCHES are deliberately not constrained by (a):
198
+ * measured, xAI accepts `$ref`, `not`, nested-`allOf` and even scalar
199
+ * branches under an `allOf` as long as (b) holds — only `anyOf`/`oneOf`
200
+ * branches are validated individually. A bare union root whose branches are
201
+ * all object-carrying is accepted too (xAI infers object-ness from them), so
202
+ * (b) leaves it alone. Compositions nested below the root are untouched.
103
203
  *
104
204
  * When (1) or (2) discards information the model could use — a collapsed
105
205
  * union type, or a wholly-dropped `enum` — that constraint is folded into the
@@ -174,6 +274,32 @@ const SUBSCHEMA_ARRAY_KEYS = new Set([
174
274
  "oneOf",
175
275
  "prefixItems",
176
276
  ]);
277
+ /** The three keywords whose array elements are COMPOSITION BRANCHES — branches
278
+ * that JSON Schema evaluates against the enclosing node, so their `required` may
279
+ * legitimately name a property the enclosing node declares (transform #8).
280
+ * `prefixItems` is in `SUBSCHEMA_ARRAY_KEYS` but NOT here: its elements are the
281
+ * item schemas of a tuple, a fresh scope, not branches of the enclosing object. */
282
+ const COMPOSITION_KEYS = new Set(["allOf", "anyOf", "oneOf"]);
283
+ /**
284
+ * Ceiling on how many property subschemas transform #8 may copy down across ALL
285
+ * branches of one root composition.
286
+ *
287
+ * The per-branch bound (a branch's own `required`) does NOT bound the total: a
288
+ * root `oneOf` of N branches each requiring P parent properties copies N × P
289
+ * subschemas from an input that is only O(N + P). Both counts are
290
+ * attacker-controlled for a third-party MCP `rawJsonSchema`, and the references
291
+ * are shared in memory but EXPAND on `JSON.stringify` when the schema is written
292
+ * into the request — measured at N=2000, P=100, a 1.2 MB input sanitizes to a
293
+ * 17.5 MB request body, a 14x amplification paid on every model call. That is
294
+ * the same class of hazard `flattenRootAllOf` guards against below.
295
+ *
296
+ * Past the ceiling the repair is skipped, which leaves the branch bare and lets
297
+ * transform #9 rule (a) drop the keyword — the module's usual tiebreak, losing
298
+ * one tool's constraint rather than degrading every request. The cap is far
299
+ * above any legitimate tool schema (a large discriminated union is tens of
300
+ * branches with a handful of `required` names each).
301
+ */
302
+ const MAX_ROOT_COPY_DOWN = 512;
177
303
  /** Name → subschema maps. `properties` is listed here for completeness, but a
178
304
  * dedicated `key === "properties"` branch in `sanitizeSchemaNode` intercepts it
179
305
  * first (to apply transform #7) and `continue`s — so the generic map handler
@@ -190,6 +316,13 @@ const SUBSCHEMA_MAP_KEYS = new Set([
190
316
  function isPlainObject(value) {
191
317
  return typeof value === "object" && value !== null && !Array.isArray(value);
192
318
  }
319
+ /** Own-property test. `hasOwnProperty.call`, never `name in map`: the latter
320
+ * answers `true` for a prototype member ("toString", "valueOf"), which would let
321
+ * a `required: ["toString"]` survive against a `properties` map that never
322
+ * declared it — the exact dangling `required` transform #3 exists to prune. */
323
+ function hasOwn(map, name) {
324
+ return Object.prototype.hasOwnProperty.call(map, name);
325
+ }
193
326
  /** Join words as a human list: ["a"]→"a", ["a","b"]→"a or b", ["a","b","c"]→"a, b, or c". */
194
327
  function humanJoin(items) {
195
328
  if (items.length <= 1)
@@ -316,7 +449,164 @@ function sanitizeSchemaMap(value, depth) {
316
449
  }
317
450
  return out;
318
451
  }
319
- function sanitizeSchemaNode(node, depth) {
452
+ /**
453
+ * Sanitize a `properties` MAP and apply transform #7 to it: a child property
454
+ * literally named `properties` whose schema is object-shaped trips xAI's parser,
455
+ * so it is collapsed to annotations only. (Only the `properties` map triggers the
456
+ * `/properties/properties` collision — `patternProperties`/`$defs` keys are
457
+ * patterns and definition names, not property names, so they go through the
458
+ * generic map handler.)
459
+ *
460
+ * Computed once per node, ahead of the key walk, because two things need the
461
+ * SANITIZED map rather than the raw one: the `properties` keyword itself, and
462
+ * transform #8, which resolves a composition branch's `required` against it and
463
+ * copies subschemas down from it. Copying the sanitized form is what makes
464
+ * transform #8 idempotent — a raw subschema copied into a branch would still be
465
+ * awaiting collapse, so a second pass would not be a no-op.
466
+ */
467
+ function sanitizePropertiesKeyword(value, depth) {
468
+ const mapped = sanitizeSchemaMap(value, depth);
469
+ if (isPlainObject(mapped) &&
470
+ isPlainObject(mapped.properties) &&
471
+ looksObjectShapedToXai(mapped.properties)) {
472
+ mapped.properties = neutralizeXaiPropertiesField(mapped.properties);
473
+ }
474
+ return mapped;
475
+ }
476
+ /**
477
+ * Transform #8, second half (ROOT branches only): make a composition branch
478
+ * self-sufficient so it stands on its own as an object schema.
479
+ *
480
+ * A branch whose `required` survived the parent-scope resolution but whose names
481
+ * live in the PARENT's `properties` gets those subschemas copied down and, if it
482
+ * declared no `type`, `type: "object"`. Only the names in this branch's own
483
+ * `required` are copied — a branch does not inherit the parent's whole property
484
+ * set. A branch that needs no repair is returned unchanged (same reference), so
485
+ * the common case of an already-object branch costs nothing and re-sanitizing is
486
+ * a no-op.
487
+ *
488
+ * `parentProperties` is the parent's SANITIZED map, so the copied subschemas are
489
+ * shared by reference with the parent's — consistent with the module's
490
+ * read-only-result contract, and cheaper than a second sanitize of the same
491
+ * subtree.
492
+ *
493
+ * A branch naming something the parent supplies only via `patternProperties` /
494
+ * `additionalProperties` cannot be made self-sufficient. It cannot reach here
495
+ * with such a name (the first half of transform #8 resolves `required` against
496
+ * explicit `properties` only, so the name was already pruned), leaving an
497
+ * unrepairable branch for transform #9 to catch.
498
+ */
499
+ function makeBranchSelfSufficient(branch, parentProperties, budget) {
500
+ if (!parentProperties)
501
+ return branch;
502
+ if (!Array.isArray(branch.required) || branch.required.length === 0) {
503
+ return branch;
504
+ }
505
+ // `required`/`properties` are object-only keywords, so repairing a branch that
506
+ // declares a non-object `type` would emit a self-contradictory schema — and an
507
+ // unmeasured one, in a function whose whole justification is emitting only
508
+ // shapes measured to pass. Leave it alone; transform #9 rule (a) drops the
509
+ // keyword, since a non-object-typed branch is unusable at a root union anyway.
510
+ if (hasOwn(branch, "type") && branch.type !== "object")
511
+ return branch;
512
+ const ownProperties = isPlainObject(branch.properties)
513
+ ? branch.properties
514
+ : null;
515
+ // `missing` — declared by the parent, to be copied down. `unsatisfiable` —
516
+ // declared by NEITHER map. The latter is unreachable through the walk (the
517
+ // parent-scope resolution has already pruned any such name), but it is
518
+ // handled rather than trusted: the invariant this function must not break is
519
+ // that every emitted `required` name is a declared property, so an
520
+ // unsatisfiable name is dropped from `required` instead of being carried
521
+ // through on an unchanged branch. A future refactor that makes this reachable
522
+ // then degrades to transform #3's behavior rather than emitting the dangling
523
+ // `required` transform #3 exists to prevent.
524
+ const missing = [];
525
+ const unsatisfiable = new Set();
526
+ for (const name of branch.required) {
527
+ if (typeof name !== "string")
528
+ continue;
529
+ if (ownProperties && hasOwn(ownProperties, name))
530
+ continue;
531
+ if (hasOwn(parentProperties, name))
532
+ missing.push(name);
533
+ else
534
+ unsatisfiable.add(name);
535
+ }
536
+ if (missing.length === 0 && unsatisfiable.size === 0)
537
+ return branch;
538
+ // Global copy-down ceiling (see MAX_ROOT_COPY_DOWN). Skipping the repair
539
+ // leaves the branch bare, so rule (a) drops the keyword rather than emitting a
540
+ // request body inflated by an adversarial schema.
541
+ if (missing.length > budget.remaining)
542
+ return branch;
543
+ budget.remaining -= missing.length;
544
+ const mergedProperties = {};
545
+ if (ownProperties) {
546
+ for (const [name, sub] of Object.entries(ownProperties)) {
547
+ safeSet(mergedProperties, name, sub);
548
+ }
549
+ }
550
+ // The branch's OWN declaration of a name wins over the parent's — only names
551
+ // it does not declare are copied down. `?? {}` because a hand-built (non-JSON)
552
+ // schema can carry an explicitly `undefined` property value, which `hasOwn`
553
+ // accepts but `JSON.stringify` drops on the wire — leaving a `required` naming
554
+ // a property the provider never sees. The empty schema accepts anything, so it
555
+ // is the neutral stand-in.
556
+ for (const name of missing) {
557
+ safeSet(mergedProperties, name, parentProperties[name] ?? {});
558
+ }
559
+ // Key order is deterministic — injected keys go in fixed positions around the
560
+ // branch's existing keys — so a second pass, which finds both already present
561
+ // and returns the branch untouched, produces the identical object.
562
+ const out = {};
563
+ if (!hasOwn(branch, "type"))
564
+ out.type = "object";
565
+ for (const [key, value] of Object.entries(branch)) {
566
+ if (key === "properties") {
567
+ out.properties = mergedProperties;
568
+ }
569
+ else if (key === "required" && unsatisfiable.size > 0) {
570
+ const kept = branch.required.filter((name) => typeof name === "string" && !unsatisfiable.has(name));
571
+ // An empty `required: []` is valid but noise; omit it, as transform #3 does.
572
+ if (kept.length > 0)
573
+ out.required = kept;
574
+ }
575
+ else {
576
+ safeSet(out, key, value);
577
+ }
578
+ }
579
+ if (!hasOwn(branch, "properties"))
580
+ out.properties = mergedProperties;
581
+ return out;
582
+ }
583
+ /**
584
+ * Recurse into one element of an `allOf`/`anyOf`/`oneOf` array (transform #8).
585
+ * Differs from `sanitizeSubschema` only in that the branch is told its parent's
586
+ * property names, so its `required` resolves against the composition scope JSON
587
+ * Schema actually gives it. `makeSelfSufficient` is set for the parameters ROOT
588
+ * only (see the module header).
589
+ */
590
+ function sanitizeCompositionBranch(branch, depth, parentProperties, budget) {
591
+ if (depth > MAX_DEPTH)
592
+ return true;
593
+ // Arrays and boolean/primitive schemas carry no `required` to resolve.
594
+ if (!isPlainObject(branch))
595
+ return sanitizeSubschema(branch, depth);
596
+ const sanitized = sanitizeSchemaNode(branch, depth, parentProperties);
597
+ // A non-null budget marks a ROOT composition — the only place the
598
+ // self-sufficiency repair applies (see the module header).
599
+ return budget
600
+ ? makeBranchSelfSufficient(sanitized, parentProperties, budget)
601
+ : sanitized;
602
+ }
603
+ /**
604
+ * @param parentProperties When this node is a composition branch, the enclosing
605
+ * node's SANITIZED `properties` map — the extra scope its `required` may name
606
+ * (transform #8). `undefined`/`null` for every other node, which keeps
607
+ * transform #3's own-properties-only rule exactly as it was.
608
+ */
609
+ function sanitizeSchemaNode(node, depth, parentProperties) {
320
610
  // Depth guard: stop walking absurdly nested input rather than overflowing
321
611
  // the stack. Real tool schemas are a few levels deep; anything past
322
612
  // MAX_DEPTH is adversarial, so the subtree is replaced with the
@@ -326,12 +616,32 @@ function sanitizeSchemaNode(node, depth) {
326
616
  if (depth > MAX_DEPTH)
327
617
  return {};
328
618
  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))
619
+ // Sanitized `properties`, computed ahead of the key walk because transform #8
620
+ // needs it before the walk reaches whichever of `properties` / `oneOf` comes
621
+ // first in key order. Emitted verbatim when the walk reaches `properties`.
622
+ const sanitizedProperties = hasOwn(node, "properties")
623
+ ? sanitizePropertiesKeyword(node.properties, childDepth)
624
+ : undefined;
625
+ const ownProperties = isPlainObject(sanitizedProperties)
626
+ ? sanitizedProperties
334
627
  : null;
628
+ // Property names a `required` on THIS node may reference — the node's OWN
629
+ // `properties` (see module header: transform #3 matches Gemini's validator,
630
+ // which does not resolve parent scope), PLUS, when this node is a composition
631
+ // branch, the enclosing node's `properties` (transform #8: a branch of an
632
+ // `allOf`/`anyOf`/`oneOf` IS evaluated against the enclosing node, so naming
633
+ // its properties is correct JSON Schema, not a dangling reference).
634
+ //
635
+ // Probed against the two maps directly rather than unioned into a `Set`.
636
+ // Building that set per node costs O(parent properties) for EVERY branch, so
637
+ // a schema with P parent properties and B branches is O(P × B) — and both are
638
+ // attacker-controlled for a third-party MCP `rawJsonSchema`. At P = B = 3000
639
+ // that measured ~1.1s of synchronously blocked event loop, which is a denial
640
+ // of service for every tenant on the process, not just the one whose
641
+ // connector served the schema (the same hazard `flattenRootAllOf` guards
642
+ // against below). Two `hasOwn` probes per `required` entry make it O(P + B).
643
+ const declaresProperty = (name) => (ownProperties !== null && hasOwn(ownProperties, name)) ||
644
+ (parentProperties != null && hasOwn(parentProperties, name));
335
645
  // Resolve the node's effective single `type`, collapsing a JSON Schema
336
646
  // `type` ARRAY (a union, e.g. `["string","number","boolean"]` or a nullable
337
647
  // `["string","null"]`) to the first non-"null" member. Gemini's
@@ -433,7 +743,7 @@ function sanitizeSchemaNode(node, depth) {
433
743
  }
434
744
  if (key === "required") {
435
745
  if (Array.isArray(value)) {
436
- const pruned = value.filter((name) => typeof name === "string" && (propertyNames?.has(name) ?? false));
746
+ const pruned = value.filter((name) => typeof name === "string" && declaresProperty(name));
437
747
  // An empty `required: []` is valid but noise; omit it entirely.
438
748
  if (pruned.length > 0)
439
749
  out.required = pruned;
@@ -459,19 +769,9 @@ function sanitizeSchemaNode(node, depth) {
459
769
  continue;
460
770
  }
461
771
  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;
772
+ // Already sanitized (with transform #7 applied) ahead of this walk, since
773
+ // transform #8 below may need it first. Emit it in its original position.
774
+ out.properties = sanitizedProperties;
475
775
  continue;
476
776
  }
477
777
  if (SUBSCHEMA_MAP_KEYS.has(key)) {
@@ -494,6 +794,19 @@ function sanitizeSchemaNode(node, depth) {
494
794
  out[key] = sanitizeSubschema(value, childDepth);
495
795
  continue;
496
796
  }
797
+ if (COMPOSITION_KEYS.has(key)) {
798
+ // Transform #8: each element is evaluated against THIS node, so it is
799
+ // handed this node's property scope. The self-sufficiency repair is
800
+ // applied at the parameters root only (`depth === 0`) — nested
801
+ // compositions are accepted as-is by every measured provider.
802
+ // One budget per root composition keyword, shared across its branches so
803
+ // the ceiling bounds the TOTAL copy-down, not each branch individually.
804
+ const budget = depth === 0 ? { remaining: MAX_ROOT_COPY_DOWN } : null;
805
+ out[key] = Array.isArray(value)
806
+ ? value.map((v) => sanitizeCompositionBranch(v, childDepth, ownProperties, budget))
807
+ : value;
808
+ continue;
809
+ }
497
810
  if (SUBSCHEMA_ARRAY_KEYS.has(key)) {
498
811
  out[key] = Array.isArray(value)
499
812
  ? value.map((v) => sanitizeSubschema(v, childDepth))
@@ -658,10 +971,10 @@ function flattenRootAllOf(root) {
658
971
  const uniqueDescriptions = [...new Set(descriptions)];
659
972
  if (uniqueDescriptions.length > 0)
660
973
  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));
974
+ // `hasOwn`, not `name in mergedProps`: the latter would treat a required
975
+ // entry named after a prototype member ("toString", "constructor") as present
976
+ // and fail to prune a genuinely-undeclared property.
977
+ const required = [...requiredSet].filter((name) => hasOwn(mergedProps, name));
665
978
  if (required.length > 0)
666
979
  out.required = required;
667
980
  if (additionalProperties !== undefined) {
@@ -669,15 +982,159 @@ function flattenRootAllOf(root) {
669
982
  }
670
983
  return out;
671
984
  }
985
+ /** The two composition keywords whose ROOT branches xAI validates individually.
986
+ * `allOf` is deliberately absent: measured, its branches are unconstrained (a
987
+ * `$ref`, `not`, nested-`allOf` or scalar branch all pass) — for `allOf` it is
988
+ * the ROOT's own object-ness that matters, which is the second half of
989
+ * transform #9. */
990
+ const ROOT_UNION_KEYS = ["anyOf", "oneOf"];
991
+ /**
992
+ * True if a sanitized branch of a ROOT `anyOf`/`oneOf` is one xAI accepts.
993
+ * Measured on grok-4.3/4.5/4.6: `{type:"object", properties:…, required:…}`,
994
+ * `{properties:…, required:…}` and `{type:"object", required:…}` all pass —
995
+ * `type: "object"` OR `properties`, either alone, suffices — while a bare
996
+ * `{required:[…]}`, a `{}`, and a `{type:"string"}` are a 400 that fails every
997
+ * tool in the request. The rule holds even when the root itself declares
998
+ * `type: "object"` and `properties`, so an object root does not excuse a
999
+ * non-object branch.
1000
+ */
1001
+ function isUsableRootBranch(branch) {
1002
+ if (!isPlainObject(branch))
1003
+ return false;
1004
+ if (Object.keys(branch).length === 0)
1005
+ return false;
1006
+ // Deliberately NOT recursive: a branch carrying only a nested composition
1007
+ // (`{oneOf: [{anyOf: [{type:"object",…}, …]}]}`) is rejected 0/3 on Grok even
1008
+ // with `type: "object"` stamped on the root, so counting it usable because its
1009
+ // own elements are object-shaped would reintroduce the 400. Such a root drops
1010
+ // to `{}`, which every model accepts — see the known-loss list in the header.
1011
+ // A declared `type` decides on its own: `properties` alongside an explicit
1012
+ // NON-object type is a self-contradictory branch (object keywords on a
1013
+ // non-object schema), and measured, xAI rejects it exactly as it rejects a
1014
+ // bare `{type:"string"}` — `{type:"string", properties:{…}, required:[…]}` is
1015
+ // 0/3 on grok-4.3/4.5/4.6 while the same branch without the `type` is 3/3. So
1016
+ // `properties` suffices only when the branch declares no type at all.
1017
+ // (Transform #1 has already collapsed any `type` ARRAY to a scalar by now.)
1018
+ if (hasOwn(branch, "type"))
1019
+ return branch.type === "object";
1020
+ return isPlainObject(branch.properties);
1021
+ }
1022
+ /**
1023
+ * True if the sanitized root reads as an object root to xAI, which rejects a
1024
+ * parameters root that is not one. Measured: `{type:"object", …}` and
1025
+ * `{properties:…}` pass, an entirely empty `{}` passes, and a union root whose
1026
+ * branches are all object-carrying passes (xAI infers object-ness from the
1027
+ * branches) — but a root carrying only `$defs`, or only an `allOf`, is a 400.
1028
+ */
1029
+ function isObjectRoot(root) {
1030
+ if (root.type === "object" || isPlainObject(root.properties))
1031
+ return true;
1032
+ if (Object.keys(root).length === 0)
1033
+ return true;
1034
+ // Only ever called after `dropUnusableRootUnions`, so a union still present
1035
+ // here is one whose branches are all usable.
1036
+ return ROOT_UNION_KEYS.some((key) => Array.isArray(root[key]) && root[key].length > 0);
1037
+ }
1038
+ /**
1039
+ * Transform #9 — the root-usability pass (see the module header). Two rules,
1040
+ * both measured against grok-4.3/4.5/4.6 through OpenRouter with
1041
+ * `provider.allow_fallbacks: false` and one tool per request:
1042
+ *
1043
+ * a. A branch of a root `anyOf`/`oneOf` that transform #8 could not make
1044
+ * object-carrying cannot be repaired at all — its `required` names something
1045
+ * no `properties` map declares, or it is a `$ref`/scalar/boolean branch. Drop
1046
+ * the whole keyword rather than emit a root that 400s every tool in the
1047
+ * request: losing one tool's constraint beats losing every tool. (An empty
1048
+ * composition array constrains nothing and goes the same way.)
1049
+ * b. A composition root that declares neither `type` nor `properties` is
1050
+ * rejected on its own account, whatever its branches look like — `{allOf:
1051
+ * [{$ref:…}]}` and `{allOf:[…, {not:…}]}` are 0/3 on Grok. Adding
1052
+ * `type: "object"` makes exactly those roots pass (9/9) while changing
1053
+ * nothing about the composition, so the un-flattenable `allOf` that
1054
+ * transform #5 deliberately preserves is rescued rather than discarded.
1055
+ * Nothing is added when the root already declares a `type`.
1056
+ *
1057
+ * Applies to the parameters root only — no provider constrains a nested
1058
+ * composition, and every measured model accepts the nested forms handled here.
1059
+ * Runs after `flattenRootAllOf`, so a root `allOf` that flattened losslessly is
1060
+ * already gone and only an un-flattenable one reaches rule (b).
1061
+ */
1062
+ function dropUnusableRootUnions(root) {
1063
+ // All-or-nothing, and deliberately so. ONE unusable branch poisons the whole
1064
+ // keyword on xAI — `{type:"object", properties:{…}, oneOf:[{type:"object",
1065
+ // required:[…]}, {type:"string"}]}` is 0/3 on Grok despite the first branch
1066
+ // being perfectly good — so a mixed union cannot simply be left alone.
1067
+ //
1068
+ // Filtering the bad branches out instead of dropping the keyword IS accepted
1069
+ // (a lone surviving usable branch measures 3/3), and it is not what we do:
1070
+ // filtering ADVERTISES A NARROWER TOOL. `oneOf: [A, B]` filtered to
1071
+ // `oneOf: [A]` tells the model the B-shaped call is invalid, so it will never
1072
+ // make one, and nothing surfaces that the arm went missing. Dropping the
1073
+ // keyword instead leaves the parent's `properties` fully visible: the model
1074
+ // can still call the tool either way, and a wrong COMBINATION is caught at
1075
+ // dispatch by the zod schema and by the remote MCP server, where it comes back
1076
+ // as a recoverable tool error the model can retry. That matches every other
1077
+ // transform here — they discard advisory constraints, never callable shapes.
1078
+ const dropped = new Set();
1079
+ for (const key of ROOT_UNION_KEYS) {
1080
+ const branches = root[key];
1081
+ if (!Array.isArray(branches))
1082
+ continue;
1083
+ if (branches.length === 0 || !branches.every(isUsableRootBranch)) {
1084
+ dropped.add(key);
1085
+ }
1086
+ }
1087
+ if (dropped.size === 0)
1088
+ return root;
1089
+ const out = {};
1090
+ for (const [key, value] of Object.entries(root)) {
1091
+ if (dropped.has(key))
1092
+ continue;
1093
+ safeSet(out, key, value);
1094
+ }
1095
+ return out;
1096
+ }
1097
+ /**
1098
+ * Transform #9, rule (b). Runs AFTER `flattenRootAllOf`, so a root `allOf` that
1099
+ * merged losslessly is already gone and only a composition that survived — or a
1100
+ * root left bare by rule (a) — reaches this check.
1101
+ *
1102
+ * `carriedComposition` is evaluated on the pre-drop root: a root reduced to
1103
+ * `{$defs: …}` by rule (a) still needs typing, even though it no longer carries
1104
+ * a composition keyword to point at.
1105
+ */
1106
+ function ensureObjectRoot(root, carriedComposition) {
1107
+ // Scoped to composition roots — the roots transforms #5/#8/#9 own — and never
1108
+ // overrides a `type` the schema declared for itself.
1109
+ if (!carriedComposition || hasOwn(root, "type") || isObjectRoot(root)) {
1110
+ return root;
1111
+ }
1112
+ const typed = { type: "object" };
1113
+ for (const [key, value] of Object.entries(root))
1114
+ safeSet(typed, key, value);
1115
+ return typed;
1116
+ }
672
1117
  /**
673
1118
  * Return a sanitized, non-mutating copy of a tool parameter JSON Schema (see
674
1119
  * the module header for the immutability caveat — opaque leaf values are
675
1120
  * shared by reference, so treat the result as read-only). A non-object root
676
1121
  * (boolean schema, or malformed third-party payload) yields an empty object
677
1122
  * rather than throwing.
1123
+ *
1124
+ * Idempotent: `sanitizeToolSchema(sanitizeToolSchema(x))` deep-equals
1125
+ * `sanitizeToolSchema(x)`. Transform #8's copy-down depends on that — a repaired
1126
+ * branch that a second pass re-emptied would be no repair at all.
678
1127
  */
679
1128
  export function sanitizeToolSchema(schema) {
680
1129
  if (!isPlainObject(schema))
681
1130
  return {};
682
- return flattenRootAllOf(sanitizeSchemaNode(schema, 0));
1131
+ const walked = sanitizeSchemaNode(schema, 0);
1132
+ const carriedComposition = [...COMPOSITION_KEYS].some((key) => Array.isArray(walked[key]));
1133
+ // Rule (a) runs BEFORE the flatten, not after. `canLosslesslyFlattenAllOf`
1134
+ // refuses to merge an `allOf` while a `oneOf`/`anyOf` sibling is present (the
1135
+ // root is then not a pure intersection) — so dropping an unusable union
1136
+ // afterwards would leave behind an `allOf` that the NEXT pass would flatten,
1137
+ // breaking idempotence. Removing the union first makes the root a genuine
1138
+ // intersection, which is exactly when flattening it is correct.
1139
+ return ensureObjectRoot(flattenRootAllOf(dropUnusableRootUnions(walked)), carriedComposition);
683
1140
  }