/** * Sanitize a tool's JSON Schema before it is handed to the model. * * Why this exists * --------------- * Tool schemas reach the LLM verbatim — for MCP tools the third-party * server's `inputSchema` is forwarded as-is (`rawJsonSchema` in * `buildToolDefinitions`). Some providers validate that schema strictly * and reject the *entire* request (every tool, not just the bad one) on * the first violation. The one that bit us in production was Google * Gemini via OpenRouter: * * OpenRouter 400 — Provider: Google (Google AI Studio), INVALID_ARGUMENT: * GenerateContentRequest.tools[0].function_declarations[50] * .parameters.properties[issue_fields].items.required[0]: * property is not defined * * That message is a *symptom*, not the cause. The GitHub MCP `issue_write` * tool's `issue_fields.items` schema contained constructs Gemini's * function-declaration schema (a strict OpenAPI 3.0 subset) does not * support: a property typed with a `type` ARRAY (`value: { type: * ["string","number","boolean"] }`) and an `enum` on a boolean property * (`delete: { type: "boolean", enum: [true] }`). When Gemini's translator * hits an unsupported construct it drops the enclosing `properties` object, * after which the (perfectly valid) `required: ["field_name"]` dangles — * hence the misleading "property is not defined". The root causes were * confirmed empirically by bisecting the real schema against live Gemini * inference via OpenRouter; OpenAI and Anthropic accept all of it. * * What it does (applied to EVERY tool for EVERY model) * ---------------------------------------------------- * The transforms only remove or normalize constructs that carry no real * constraint for a model's tool call. Each was verified against live Gemini: * * 1. Resolve a `type` ARRAY, which Gemini rejects outright (it requires a * scalar `type`). Two cases, and they are not the same kind of loss: * * a. NULLABLE single type — exactly one non-`"null"` member plus `"null"`, * in either order, duplicates tolerated. Rewritten to * `anyOf: [ , {type:"null"} ]`, where the T branch carries * the node's type-bearing keywords (`minLength`, `enum`, `properties`, * `items`, bounds, …) under `type: T` and the node keeps its * annotations. The branch is re-entered through this same walk, so * every per-node transform applies to it — #12 retypes it to `"object"` * when it carries `properties`, #13 drops an unsupported `pattern` into * prose ON the branch, #2 filters its `enum`, #3 resolves its * `required` against its own `properties`. * * Collapsing this case instead was lossy in a way that broke callers in * production. A model handed `{type:"string", minLength:1, * description:"… or send null …"}` cannot express "none" in the half of * the declaration it treats as binding, so it INVENTS a value that * satisfies the type: `slack_send_message.thread_ts` (authored * `type:["string","null"], minLength:1`) received `"/"`, `". "`, * `".000001"` and the like for thousands of calls, each refused by * Slack and each retried; a Drive pagination cursor had already been * seen taking `"x"`, `" "`, `"undefined"`, `"null"` and `"/"`. Prose * loses to the schema every time. The `anyOf` shape is not a guess: it * passes under `properties` on grok-4.3/4.5/4.6, * gemini-3.5/3.6/3.7-flash and gpt-5.6-terra/sol/luna, and it ships * today on every pagination cursor of six connectors. No "Accepts T or * null." note is added — the schema now says it — and an `enum` that * listed `null` contributes only its non-null members to the branch, * with no "May also be null." note, since the null branch carries that. * A sibling `enum`/`const` that EXCLUDES one arm keeps case (b) * instead: those keywords are AND-ed with `type`, so * `{type:["string","null"], enum:["x"]}` means "must be \"x\"", and a * union whose null branch the enum never reaches would admit a value * the node forbade. * * Four shapes opt out. The parameters ROOT, which has to be an object * for every provider (transform #9 owns it). A branch of a ROOT * `anyOf`/`oneOf`, because a root union branch carrying its OWN `anyOf` * is 0/3 on Grok — rewriting there would turn a usable branch into one * rule (a) drops, taking the whole keyword and every shape it * advertised with it. And a COMPOSITION BRANCH whose `required` reaches * the ENCLOSING node's `properties` — the extra scope transform #8 * gives it — because nesting such a name inside an `anyOf` puts it two * levels from that scope, where transform #3 prunes it away entirely * (a branch whose `required` its own `properties` cover needs no * exception). All three keep case (b). (One residue is known and accepted: a `$defs` target * rewritten this way, then inlined into a root union branch by * transform #14, is unusable for the same 0/3 reason and still drops * the keyword — the same narrow class #14 already documents.) * * b. Everything else — a genuine MULTI-type union, which has no shape * every provider accepts. Collapsed to the first non-`"null"` member * (`["string","number","boolean"]` → `"string"`) with the alternatives * folded into `description`; an array of only `"null"` drops the * `type`. The lost alternatives are advisory — arguments are still * validated at dispatch against the tool's zod schema and by the remote * MCP server. * 2. Constrain `enum` to Gemini's rule: it is accepted ONLY as a list of * strings on a string-typed (or type-less) property. So drop `enum` * entirely when the node has an explicit non-string type (boolean, * number, integer, array, object, null), and otherwise filter it to its * string members (dropping any non-string leftovers — e.g. after a union * `type` collapsed to "string") and omit it if none remain. `const`, * which Gemini DOES support on every type, is left untouched — do not * convert it to `enum`, which would manufacture the rejected case. * 3. Drop `required` entries with no matching key in the SAME node's own * `properties` map (evaluated per node, against direct `properties` * only — Gemini does not resolve composition or parent scope, and a * `required` naming an absent property is a hard 400). A genuinely * dangling `required` is invalid everywhere; this is a safety net, not * the primary fix for the issue above. * 4. Strip the `$schema` dialect declaration, which function-calling APIs * ignore. We deliberately KEEP `$id`: for schemas that use relative * `$ref`s it defines the base URI / identifies subschemas, and we * preserve `$ref`, so dropping `$id` could break reference resolution. * 5. Flatten a top-level `allOf` of object subschemas into a single object * schema (merge `properties`, union `required`). A function's parameters * root produced by a Zod intersection (`a.and(b)`) renders as `{ allOf: * [ {type:object…}, {type:object…} ] }` with no root `type`/`properties`. * xAI (Grok) via OpenRouter strictly requires the parameters ROOT to be a * plain object schema and rejects the whole request with a 502 "Invalid * arguments passed to the model" on a composition root; Gemini tolerates * it. Merging is safe for tool calling — `allOf` means "satisfy every * branch", i.e. an object carrying the union of all branches' properties. * Confirmed empirically against live Grok (`document__convert_excel` / * `document__ocr`). Only `allOf` is flattened (a true intersection); * `anyOf`/`oneOf` roots are left untouched. The flatten is applied ONLY * when it's lossless — every branch is a plain object schema with * merge-safe keywords; a branch carrying `$ref`, `not`, `if`, nested * composition, a non-object `type`, etc. leaves the `allOf` intact rather * than dropping the unmerged constraint. * 6. Drop a boolean `additionalProperties: false`. xAI (Grok) via OpenRouter * rejects that form on a NESTED object schema with a hard 400 ("property * schema 'false' is not supported"), failing the WHOLE request (every * tool). The constraint it carries — "no keys beyond those declared" — is * advisory for tool calling (arguments are validated at dispatch against * the tool's zod schema and by the remote MCP server), so it is dropped * rather than allowed to sink the request. `additionalProperties: true` * (the JSON Schema default, a no-op) is left as-is, and an OBJECT-valued * `additionalProperties` is a real subschema constraint every provider * accepts — it is kept and recursed into. Confirmed against the production * failure (Notion `notion-create-pages`, * `pages[].properties.properties.additionalProperties`). * 7. Neutralize a tool parameter literally NAMED `properties` whose schema is * object-shaped. xAI (Grok) via OpenRouter mis-reads such a field as the * JSON-Schema `properties` keyword — it descends in, injects an * `additionalProperties: false`, then rejects its own injection, failing the * WHOLE request with `/properties/properties/additionalProperties: property * schema 'false' is not supported` (so transform #6 alone never helped — * the rejected `false` is xAI's own, not ours). Notion's `notion-create-pages` * / `update-page` carry exactly this field (the page's `properties` map), so * it 400s the default Grok agent. xAI accepts the field only when it does NOT * look like an object schema, so we collapse an object-shaped * `properties`-named node to annotations only (`description` / `title`). * Advisory-safe (args validated at dispatch + by the remote server; Notion's * `properties` is an open per-database map with no fixed sub-schema), and the * key name is never changed so the model still emits the right argument key. * Confirmed live: `x-ai/grok-4.3` 400→200, Gemini/Claude unaffected. * 8. Resolve a COMPOSITION BRANCH's `required` against the branch's own * `properties` UNIONED with the enclosing node's `properties`, and — at the * parameters ROOT only — make the surviving branch self-sufficient by copying * the referenced property subschemas down into it and setting * `type: "object"`. This is the narrow, measured exception to transform #3. * The ordinary way to say "pass exactly one of these" puts the properties on * the parent and only `required` in each branch: * * { type: "object", properties: { query: …, memoryItemId: … }, * oneOf: [ { required: ["query"] }, { required: ["memoryItemId"] } ] } * * which is how JSON Schema composition works, and which transform #3 alone * sanitized to `oneOf: [{}, {}]` — a root xAI (Grok) hard-rejects * (`tool parameter root must be an object type (root schema is an * anyOf/oneOf union with a non-object branch)`, failing EVERY tool in the * request), and which Gemini/OpenAI accept while silently losing the * constraint, letting the model send both parameters or neither. Measured on * xAI for branches of a ROOT union: `{type:"object", …}` or `{properties:…}` * is accepted with EITHER alone sufficing; a bare `{required:[…]}` or `{}` is * a 400. Hence the copy-down, which also makes the branch idempotent under * this sanitizer (a second pass finds every `required` name in the branch's * OWN `properties` and changes nothing). * * Two limits are load-bearing, both measured: * - The parent scope is the ENCLOSING node only, and only for * `allOf`/`anyOf`/`oneOf` elements. Widening transform #3 to resolve * `required` against ALL ancestors at every node reintroduces the hard * Gemini 400 that transform #3 exists to prevent ("required fields * ['value'] are not defined in the schema properties" — still true on * gemini-3.5/3.6/3.7-flash). Every non-branch node keeps transform #3 * exactly as it is, and so do `if`/`then`/`else` and `dependentSchemas`, * which are not composition arrays. * - The copy-down is ROOT-ONLY. The identical bare-`required` union nested * under a property is accepted by all nine measured models including * every Grok version, so a nested composition keeps its `required` (that * is the fix) and is otherwise left alone — no provider constrains it, * and churning it risks the transform-#5 `allOf` flatten. * Only the names in the branch's own `required` are copied, so a branch does * not inherit the parent's whole property set — and a single ceiling * (`MAX_ROOT_COPY_DOWN`) bounds the copy-down across ALL branches of one * root composition, because the per-branch bound does not bound N branches * × P names. Past the ceiling the repair is skipped, leaving the branches * bare for rule (a) below to drop. * 9. Guarantee the parameters ROOT is one xAI will accept, as a final pass. * xAI rejects a root that is not an object type, and the rejection takes the * whole request (every tool) with it. Two distinct rules, each measured * against grok-4.3/4.5/4.6 (and confirmed inert on the six Gemini/OpenAI * models) through OpenRouter with `provider.allow_fallbacks: false` and one * tool per request: * * a. Every element of a root `anyOf`/`oneOf` must be a plain object that * is object-shaped, and must not be `{}`. A branch that declares a * `type` is judged on that alone (`type: "object"` passes, anything * else fails even when the branch also carries `properties` — measured * 0/3, identical to a bare `{type:"string"}`); a branch with no `type` * passes on `properties`. This holds even when the root ITSELF declares * `type: "object"` and `properties` — an object root does not excuse a * non-object branch. Rule (a) runs BEFORE the transform-#5 flatten, * since that flatten refuses to merge an `allOf` while a union sibling * is present; dropping the union afterwards would strand an `allOf` * that the next pass would merge, breaking idempotence. A * branch that still fails after #8 cannot be repaired (its `required` * names something no `properties` map declares — the parent supplies it * only via `patternProperties`/`additionalProperties`, or not at all — * or it is a `$ref`/scalar/boolean branch), so the whole keyword is * dropped. Losing one tool's constraint is strictly better than losing * every tool in the request. * b. A root carrying a composition keyword but declaring neither `type` * nor `properties` is rejected on its OWN account, whatever the branches * look like: `{allOf:[{$ref:…}], $defs:…}` and * `{allOf:[{type:"object",…}, {not:…}]}` are both 0/3 on Grok. Adding * `type: "object"` to such a root makes exactly those cases pass 9/9 * without touching the composition — so the un-flattenable root `allOf` * that transform #5 deliberately preserves is RESCUED rather than * discarded, and it stops being the silent 400 it is today. A non-object * `type` the root declared for itself IS rewritten to `"object"` and * recorded in the description — measured, such a root is rejected by * every model. Nested nodes keep their declared types unless transform * #12 applies. * * Rule (a) is all-or-nothing per keyword, because ONE unusable branch * poisons the whole union on xAI even when its siblings are good (0/3 with a * `{type:"string"}` sibling next to a valid object branch). Filtering the bad * branches out instead is accepted by xAI (3/3) but is NOT what we do — it * advertises a narrower tool, telling the model an arm is invalid so it never * calls that shape, with nothing to surface the loss. Dropping the keyword * leaves the parent's `properties` fully visible, so every shape stays * callable and a wrong COMBINATION comes back from dispatch/the remote * server as a recoverable tool error. * * A branch's OWN `anyOf`/`oneOf` disqualifies it too, whatever else it * declares: `{type:"object", anyOf:[…]}` as a root union branch is 0/3 on * Grok, as is a branch carrying both `allOf` and `anyOf` — the union is what * poisons it. Scope is the branch's own keys and never its subtree: a * composition on a branch's PROPERTY is accepted 9/9, one or two levels * down, so a subtree scan would destroy constraints every provider honours. * A branch's own `allOf` is likewise accepted 9/9 (even with a * bare-`required` sub-branch), the same intersection-vs-union asymmetry xAI * applies to the root — hence `ROOT_UNION_KEYS`, not `COMPOSITION_KEYS`. * * A root union of `$ref` branches is no longer a loss — transform #14 below * inlines the targets so the union survives. What remains is the narrow * residue it cannot inline: a target that is not a plain object schema * (a union of its own included, which is what a `$defs` entry transform * #1a rewrote becomes), one whose expanded size exceeds the copy-down * ceiling, and a self-referential root pointer. Those branches stay * unusable and still drop the keyword. * * Rule (b) covers EVERY root, not only composition roots. A property sweep * over recursive shapes (`__tests__/tool-schema/property/`) found the * narrower gate emitting unusable roots for whole classes the fixtures * never reached: a scalar or array root (reachable from transform #1 * collapsing `{type:["string","null"]}`), an annotation-only root, a * `$defs`-only root, and a root whose `type` contradicts its `properties` — * that last one rejected by all nine models, the only rule here with no * tolerant provider. Rule (b) also DROPS a `const` or `$ref` carried by the * root: measured `root schema is a const` / `root schema is a $ref`, 400 * even alongside `type: "object"` and `properties`, and for `$ref` even * when the target resolves. Nested `const`/`$ref` are meaningful and * untouched. * * A root `allOf`'s BRANCHES are deliberately not constrained by (a): * measured, xAI accepts `$ref`, `not`, nested-`allOf` and even scalar * branches under an `allOf` as long as (b) holds — only `anyOf`/`oneOf` * branches are validated individually. A bare union root whose branches are * all object-carrying is accepted too (xAI infers object-ness from them), so * (b) leaves it alone. Compositions nested below the root are untouched. * * 10. Drop an EMPTY array-valued keyword (`allOf`/`anyOf`/`oneOf`/`prefixItems`) * at any depth. xAI rejects one anywhere in the tree — `/properties/a/oneOf: * [] has less than 1 item` — and it carries no constraint (an `allOf` of * nothing is satisfied by everything). Empty OBJECTS (`properties: {}`, * `$defs: {}`) and an empty `required: []` are accepted and left alone; * `enum: []` and `required: []` were already omitted by transforms #2/#3. * 11. Drop a `$ref` whose LOCAL target does not resolve. xAI resolves `#/…` * pointers itself and 400s the whole request on a dangling one * (`unresolvable $ref '#/$defs/Nope'`), which a third-party MCP server * produces easily by shipping a subschema without its definitions — or by * putting `$defs` on a nested node, since `#/$defs/X` is anchored at the * document ROOT and a nested `$defs` is unaddressable that way. External * refs (`https://…`) and resolvable non-`$defs` pointers (`#/properties/a`) * are both accepted by xAI and left alone. Only the `$ref` keyword is * dropped, so the rest of the node survives. * * 12. Give any node carrying `properties` an explicit `type: "object"`. Gemini * rejects the type-less form outright — "Unable to submit request because * `t` functionDeclaration `parameters.b` schema specified incorrect schema * type field. For schema with properties, schema type should be OBJECT" — * on gemini-3.5/3.6/3.7-flash, and rejects a CONTRADICTORY declared type * (`{type:"string", properties:{…}}`) the same way, so the type is * overwritten rather than merely defaulted. It is not composition-specific: * a plain property subschema and an `items` schema fail identically. xAI * and OpenAI accept every one of those forms, which is why this survived * until a property sweep over recursive shapes went looking for it. The * root is exempt in Gemini's own validator, but the rule is applied * uniformly because it costs nothing and one rule beats two. * * It is resolved during the node's type resolution, not patched onto the * finished node, so `dropEnum` sees the real type — an `enum` left on a * node that has just become an object would otherwise be dropped by the * NEXT pass instead of this one, breaking idempotence. A discarded * contradictory type is folded into the description like transform #1's. * This also makes a contradictory root-union BRANCH repairable: it arrives * at transform #9 already retyped, so the constraint survives instead of * the whole keyword being dropped. * * 13. Drop a `pattern` that uses a regex construct a provider's validator * refuses, echoing it into the description instead. Measured: `(?=`, `(?!`, * `(?<=`, `(?` are rejected by gpt-5.6-sol * and gpt-5.6-terra (`Invalid JSON schema: regex lookaround is not * supported` — the message says lookaround, but a named group fails * identically, so the giveaway is the `(?<` prefix); a BACKREFERENCE * (`\1`–`\9`) is rejected by grok-4.5. Non-capturing `(?:` is accepted * everywhere and is deliberately NOT caught. This is the defect that broke * Monad's own `spaces__invite_member` on every OpenAI model — a whole- * request 400 from one tool's email pattern. The scan tracks backslash * escaping and character classes, so a literal `\(\?=` or a `[(?=]` class * keeps its pattern: dropping a pattern that would have been accepted * costs a real constraint on every provider. * 14. Inline a ROOT union branch that is a local `$ref`, so the union survives * rule (a) instead of being dropped whole. `z.union([A, B])` renders as * `{anyOf:[{$ref},{$ref}], $defs}` and xAI does not resolve a `$ref` union * branch (0/3 even with `type: "object"` on the root) — so rule (a) used to * drop the keyword, which for a root whose properties live entirely in * `$defs` left the tool advertising NO parameters. Inlining produces * ordinary object branches, accepted 9/9. Only branches that are not * already usable are inlined, one level deep (a `$ref` inside the target * resolves on its own, which is what makes a recursive definition * terminate here), with branch keywords winning over the target's per * 2020-12 `$ref` semantics and a cap for the same reason the copy-down has * one. A branch whose target is not a plain object schema stays unusable * and still falls through to the drop. * * When (1b) or (2) discards information the model could use — a collapsed * multi-type union, or a wholly-dropped `enum` — that constraint is folded into * the node's `description` as prose ("Accepts string, number, or boolean.", * "Allowed values: true.") so the model still sees it. `description` is a free * string every provider accepts, so this is always safe. Prose is the fallback, * never the first choice: where a shape every provider accepts exists, the * schema says it instead — which is the whole of (1a). * * It deliberately leaves meaningful validation keywords (`format`, * `pattern`, object- or `true`-valued `additionalProperties`, `const`, string * `enum`, length/range bounds, `$ref`, `$id`) intact so compliant providers * keep their guidance. The two structural exceptions are both xAI-rejected: the * boolean `false` form of `additionalProperties` (transform #6), and the * object-structure of a property literally named `properties` (transform #7), * which is collapsed to annotations only. * * The walk is keyword-aware: it only recurses into positions that JSON * Schema defines as subschemas, and treats `enum` / `const` / `default` / * `examples` values (and the `required` array itself) as opaque data. That * way a tool parameter literally named `properties` or `required`, or an * example value that happens to contain those keys, is never mistaken for * schema structure and corrupted by OUR walk. (Transform #7 separately * rewrites a `properties`-named object field — not because we confuse it, but * because xAI's parser does.) * * Robustness: the walk is depth-bounded (`MAX_DEPTH`) so a pathologically * nested third-party schema can't overflow the stack, and the entry point * tolerates a non-object root (a boolean schema, or a malformed server's * payload) by returning an empty object instead of throwing. Past the bound * the subtree is *replaced*, not returned verbatim — a bomb passed through * would simply overflow one frame later, when the caller serializes the * result into a request body. For the same reason nothing in this module * hands an untrusted value to `JSON.stringify`, which recurses on its own and * so would reintroduce the unbounded walk the depth guard exists to prevent. * * Non-mutating, but NOT a full deep clone: the returned object has a fresh * spine (every object/array node the walk descends into is rebuilt), so the * input `rawJsonSchema` — shared across runs — is never mutated. Opaque * leaf values (`enum`, `const`, `default`, `examples`, `pattern`, …) are * carried over by reference, so callers must treat the result as read-only. */ /** * Return a sanitized, non-mutating copy of a tool parameter JSON Schema (see * the module header for the immutability caveat — opaque leaf values are * shared by reference, so treat the result as read-only). A non-object root * (boolean schema, or malformed third-party payload) yields an empty object * rather than throwing. * * Idempotent: `sanitizeToolSchema(sanitizeToolSchema(x))` deep-equals * `sanitizeToolSchema(x)`. Transform #8's copy-down depends on that — a repaired * branch that a second pass re-emptied would be no repair at all. */ export declare function sanitizeToolSchema(schema: Record): Record;