"""Sanitize tool JSON schemas for broad LLM-backend compatibility. Some backends are strict about JSON Schema shapes that OpenAI/Anthropic/most cloud providers silently accept — llama.cpp's ``json-schema-to-grammar`` fails the whole request (``Unrecognized schema: "object"``), Anthropic rejects nullable ``anyOf`` at the top of ``input_schema``, Fireworks rejects ``default`` beside ``$ref``, OpenAI's Codex backend rejects top-level combinators. Known hostile constructs: * ``{"type": "object"}`` with no ``properties``. * A bare string (``"object"``) where a schema dict belongs (malformed MCP output). * ``"type": ["string", "null"]`` array types. * ``anyOf``/``oneOf`` unions whose only purpose is to permit ``null``. * ``default`` (etc.) alongside ``$ref`` — e.g. ``{"$ref": "#/$defs/Foo", "default": null}``. This module walks the final tool schema tree (after MCP normalization and any per-tool dynamic rebuilds) and fixes those in place on a deep copy. It is deliberately conservative: it only modifies shapes the backend couldn't use. """ from __future__ import annotations import copy import logging import re from typing import Any, Callable logger = logging.getLogger(__name__) # Anthropic (and Bedrock/Vertex/Azure fronting it) reject tool input schemas # whose property keys don't match this pattern; one bad key anywhere in the # tools array 400s the entire request (Cloudflare's MCP ships 61 such keys). _PROP_KEY_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$") _PROP_KEY_BAD_CHARS = re.compile(r"[^a-zA-Z0-9_.-]") _UNION_KEYS = ("anyOf", "oneOf") # Outer-node metadata carried onto a union's replacement node. _UNION_META_KEYS = ("title", "description", "default", "examples") def _empty_object() -> dict: return {"type": "object", "properties": {}} def sanitize_property_key(key: str) -> str: """Deterministically map an arbitrary property key to a conforming one.""" return _PROP_KEY_BAD_CHARS.sub("_", key)[:64] or "param" def _rename_property_keys(props: dict, path: str) -> dict[str, str]: """Return {original_key: conforming_key} for one properties dict. Identity entries are omitted. Deterministic (insertion order, numeric suffixes on collision) so the model-visible schema and the dispatch-time reverse map computed from the registry's original schema always agree. """ renames: dict[str, str] = {} taken = {k for k in props if _PROP_KEY_RE.match(k)} for key in props: if _PROP_KEY_RE.match(key): continue base = sanitize_property_key(key) candidate, i = base, 2 while candidate in taken: suffix = f"_{i}" candidate = base[: 64 - len(suffix)] + suffix i += 1 taken.add(candidate) renames[key] = candidate logger.debug( "schema_sanitizer[%s]: renamed property key %r -> %r " "(provider key-pattern compat)", path, key, candidate, ) return renames def unrename_tool_args(params_schema: Any, args: Any) -> Any: """Map sanitized property keys in model-emitted args back to wire names. ``params_schema`` is the ORIGINAL (unsanitized) registry schema. Recurses into object values and array items; unknown keys pass through untouched. """ if not isinstance(params_schema, dict) or not isinstance(args, dict): return args props = params_schema.get("properties") if not isinstance(props, dict): return args reverse = {v: k for k, v in _rename_property_keys(props, "").items()} out = {} for key, value in args.items(): orig = reverse.get(key, key) subschema = props.get(orig) if isinstance(subschema, dict): if isinstance(value, dict): value = unrename_tool_args(subschema, value) elif isinstance(value, list) and isinstance(subschema.get("items"), dict): value = [ unrename_tool_args(subschema["items"], item) if isinstance(item, dict) else item for item in value ] out[orig] = value return out def sanitize_tool_schemas(tools: list[dict]) -> list[dict]: """Return a deep-copied ``tools`` list (OpenAI format) with each tool's parameter schema sanitized; callers may mutate the result freely.""" if not tools: return tools return [_sanitize_single_tool(tool) for tool in tools] def _sanitize_single_tool(tool: dict) -> dict: """Deep-copy and sanitize a single OpenAI-format tool entry.""" out = copy.deepcopy(tool) fn = out.get("function") if isinstance(out, dict) else None if not isinstance(fn, dict): return out params = fn.get("parameters") if not isinstance(params, dict): # missing / non-dict → minimal valid shape fn["parameters"] = _empty_object() return out name = fn.get("name", "") top = _sanitize_node(params, path=name) # Guarantee the top level is an object with properties. if not isinstance(top, dict): top = _empty_object() else: if top.get("type") != "object": top["type"] = "object" if not isinstance(top.get("properties"), dict): top["properties"] = {} # Collapse nullable unions the recursive pass leaves intact (it only # handles the array-form ``type: [X, "null"]``); keep ``nullable: true`` so # runtime coercion (``model_tools._schema_allows_null``) still maps a # model-emitted ``"null"`` string to Python ``None``. top = strip_nullable_unions(top, keep_nullable_hint=True) top = _strip_top_level_combinators(top, path=name) fn["parameters"] = _strip_ref_siblings(top) return out # Sibling keywords strict JSON Schema validators reject alongside ``$ref``. _REF_FORBIDDEN_SIBLINGS = frozenset({"default"}) def _strip_ref_siblings(node: Any) -> Any: """Recursively drop forbidden sibling keywords from nodes carrying ``$ref`` (Fireworks: ``keyword(s) ['default'] not allowed at the same level as $ref``).""" if isinstance(node, list): return [_strip_ref_siblings(item) for item in node] if not isinstance(node, dict): return node out = {key: _strip_ref_siblings(value) for key, value in node.items()} if "$ref" in out: for key in _REF_FORBIDDEN_SIBLINGS: out.pop(key, None) return out _TOP_LEVEL_FORBIDDEN_KEYS = ("allOf", "anyOf", "oneOf", "enum", "not") def _strip_top_level_combinators(params: dict, *, path: str = "") -> dict: """Drop combinator keywords from the TOP level of a parameters schema only. OpenAI's Codex backend rejects ``oneOf/anyOf/allOf/enum/not`` at the top level. They are usually conditional-required hints; dropping them does not change which argument values are valid (handlers re-validate). Nested combinators are preserved. """ if not isinstance(params, dict): return params out = dict(params) for key in _TOP_LEVEL_FORBIDDEN_KEYS: if key in out: logger.debug( "schema_sanitizer[%s]: stripped top-level %r combinator " "from tool parameters (strict-backend compat)", path, key, ) out.pop(key, None) return out def _is_null_branch(item: Any) -> bool: return isinstance(item, dict) and item.get("type") == "null" def _carry_union_meta(outer: dict, replacement: dict, *, skip_default_on_ref: bool) -> None: """Copy outer-union metadata onto *replacement* where absent.""" for meta_key in _UNION_META_KEYS: if meta_key in outer and meta_key not in replacement: # ``default`` is illegal alongside ``$ref`` on strict backends. if skip_default_on_ref and meta_key == "default" and "$ref" in replacement: continue replacement[meta_key] = outer[meta_key] def strip_nullable_unions( schema: Any, *, keep_nullable_hint: bool = True, ) -> Any: """Collapse ``anyOf``/``oneOf`` nullable unions to the single non-null branch. MCP/Pydantic optional fields arrive as ``{"anyOf": [{"type": "string"}, {"type": "null"}], "default": null}``; Anthropic rejects the null branch, and optionality is already expressed by the parent's ``required``. Only collapses when a null branch was dropped AND exactly one non-null branch survives. Outer metadata is carried over. ``keep_nullable_hint`` sets ``nullable: true`` on the replacement for downstream consumers (runtime ``"null"`` → ``None`` coercion). """ if isinstance(schema, list): return [strip_nullable_unions(item, keep_nullable_hint=keep_nullable_hint) for item in schema] if not isinstance(schema, dict): return schema stripped = { k: strip_nullable_unions(v, keep_nullable_hint=keep_nullable_hint) for k, v in schema.items() } for key in _UNION_KEYS: variants = stripped.get(key) if not isinstance(variants, list): continue non_null = [item for item in variants if not _is_null_branch(item)] if len(non_null) == 1 and len(non_null) != len(variants): replacement = dict(non_null[0]) if isinstance(non_null[0], dict) else {} if keep_nullable_hint: replacement.setdefault("nullable", True) _carry_union_meta(stripped, replacement, skip_default_on_ref=True) return strip_nullable_unions(replacement, keep_nullable_hint=keep_nullable_hint) return stripped _CONST_PRIMITIVE_TYPES: dict[type, str] = { bool: "boolean", int: "integer", float: "number", str: "string", } def _const_branch_type(branch: Any) -> str | None: """JSON-Schema primitive type of a pure ``const`` branch, else None. Qualifies when the dict carries a primitive ``const`` and any declared ``type`` matches it; ``title``/``description`` are allowed, any other constraining keyword disqualifies. """ if not isinstance(branch, dict) or "const" not in branch: return None if set(branch) - {"const", "type", "title", "description"}: return None value = branch["const"] # ``type(value) is`` (not isinstance): bool is a subclass of int. json_type = _CONST_PRIMITIVE_TYPES.get(type(value)) if json_type is None: return None declared = branch.get("type") if declared is not None and declared != json_type: return None return json_type def collapse_const_unions(schema: Any) -> Any: """Collapse ``anyOf``/``oneOf`` unions of same-typed consts to ``enum``. Ported from block/goose ``tool_schema_normalize.rs`` (Apache-2.0). MCP servers generated from Rust/TS union types emit ``{"anyOf": [{"const": "red"}, {"const": "green"}]}``; strict backends mishandle these while ``{"type": "string", "enum": [...]}`` is universal. Applies only when EVERY non-null branch is a pure ``const`` of one primitive type (``bool`` never merges with ``integer``). One ``{"type": "null"}`` branch is tolerated and recorded as ``nullable: true`` (``strip_nullable_unions`` only handles single-non-null unions, so null+multi-const unions land here). Enum order preserves branch order; outer metadata is carried over; input is never mutated. """ if isinstance(schema, list): return [collapse_const_unions(item) for item in schema] if not isinstance(schema, dict): return schema out = {k: collapse_const_unions(v) for k, v in schema.items()} for key in _UNION_KEYS: variants = out.get(key) if not isinstance(variants, list) or not variants: continue null_branches = [ item for item in variants if _is_null_branch(item) and "const" not in item ] const_branches = [item for item in variants if item not in null_branches] if len(null_branches) > 1 or not const_branches: continue branch_types = {_const_branch_type(item) for item in const_branches} if len(branch_types) != 1 or None in branch_types: continue replacement: dict = { "type": branch_types.pop(), "enum": [item["const"] for item in const_branches], } if null_branches: replacement["nullable"] = True _carry_union_meta(out, replacement, skip_default_on_ref=False) return replacement return out _BARE_TYPE_NAMES = frozenset({"object", "string", "number", "integer", "boolean", "array", "null"}) # Sibling keywords whose values are NOT schemas: recursing would mistake literal # strings like "path" for bare-string schemas. Passed through unchanged # (``required`` remapped through property renames). _NON_SCHEMA_LIST_KEYS = frozenset({"required", "enum", "examples", "dependentRequired"}) def _normalize_type_array(value: list, out: dict) -> None: """Normalize a ``type: [...]`` array into *out*. Several backends reject array types (llama.cpp's grammar generator; Gemini via OpenAI-compatible transports 400s). Per the AI-SDK behavior: one non-null type → ``type: X`` (+ ``nullable`` if ``null`` present); several → ``anyOf`` of single-type schemas so EVERY branch survives; none → ``null`` or the object fallback. Ported from anomalyco/opencode#31877. """ has_null = "null" in value non_null = [t for t in value if isinstance(t, str) and t != "null"] if len(non_null) == 1: out["type"] = non_null[0] elif len(non_null) >= 2: out["anyOf"] = [{"type": t} for t in non_null] else: out["type"] = "null" if has_null else "object" return if has_null: out.setdefault("nullable", True) def _sanitize_node(node: Any, path: str) -> Any: """Recursively sanitize a JSON-Schema fragment. - Bare-string schema values become ``{"type": }`` (unknown strings become a permissive object schema rather than something backends reject). - Object-typed nodes gain ``properties: {}`` (llama.cpp can't constrain a free-form object). - ``type`` arrays are normalized (see ``_normalize_type_array``). - Recurses into ``properties``, ``items``, ``additionalProperties``, ``anyOf``/``oneOf``/``allOf`` and ``$defs``/``definitions``; property keys are renamed to the provider-safe pattern and ``required`` follows. - ``required`` entries that don't exist in ``properties`` are pruned (malformed MCP schemas; built-in/plugin tools skip the MCP-level check). """ if isinstance(node, str): if node in _BARE_TYPE_NAMES: logger.debug( "schema_sanitizer[%s]: replacing bare-string schema %r " "with {'type': %r}", path, node, node, ) return _empty_object() if node == "object" else {"type": node} logger.debug( "schema_sanitizer[%s]: replacing non-schema string %r " "with empty object schema", path, node, ) return _empty_object() if isinstance(node, list): return [_sanitize_node(item, f"{path}[{i}]") for i, item in enumerate(node)] if not isinstance(node, dict): return node # Renames are computed up front so ``required`` can be remapped even when # it precedes ``properties`` in the source dict. prop_renames: dict[str, str] = {} if isinstance(node.get("properties"), dict): prop_renames = _rename_property_keys(node["properties"], f"{path}.properties") out: dict = {} for key, value in node.items(): if key == "type" and isinstance(value, list): _normalize_type_array(value, out) elif key in {"properties", "$defs", "definitions"} and isinstance(value, dict): renames = prop_renames if key == "properties" else {} out[key] = { renames.get(sub_k, sub_k): _sanitize_node(sub_v, f"{path}.{key}.{renames.get(sub_k, sub_k)}") for sub_k, sub_v in value.items() } elif key in {"items", "additionalProperties"}: # Bool ``additionalProperties`` is valid and widely accepted; # ``items: true/false`` is non-standard but preserved rather than dropped. out[key] = value if isinstance(value, bool) else _sanitize_node(value, f"{path}.{key}") elif key in {"anyOf", "oneOf", "allOf"} and isinstance(value, list): out[key] = [_sanitize_node(item, f"{path}.{key}[{i}]") for i, item in enumerate(value)] elif key in _NON_SCHEMA_LIST_KEYS: if key == "required" and prop_renames and isinstance(value, list): out[key] = [prop_renames.get(r, r) if isinstance(r, str) else r for r in value] else: out[key] = copy.deepcopy(value) if isinstance(value, (list, dict)) else value else: out[key] = _sanitize_node(value, f"{path}.{key}") if isinstance(value, (dict, list)) else value if out.get("type") == "object": if not isinstance(out.get("properties"), dict): out["properties"] = {} if isinstance(out.get("required"), list): props = out.get("properties") or {} valid = [r for r in out["required"] if isinstance(r, str) and r in props] if not valid: out.pop("required", None) elif len(valid) != len(out["required"]): out["required"] = valid return out # ============================================================================= # Reactive strips — only invoked after a backend rejects a schema # ============================================================================= _STRIP_ON_RECOVERY_KEYS = frozenset({"pattern", "format"}) def _reactive_strip(tools: list[dict], strip_node: Callable[[dict], int], log_msg: str) -> tuple[list[dict], int]: """Walk every tool's parameters in place, applying *strip_node* to each dict node (it returns how many keywords it removed). Handles OpenAI format (``{"function": {"parameters": ...}}``) and Responses format (``{"name": ..., "parameters": ...}`` — codex_responses mode, xAI, etc.). Returns ``(tools, stripped_count)`` — the same list reference.""" if not tools: return tools, 0 stripped = 0 def _walk(node: Any) -> None: nonlocal stripped if isinstance(node, dict): stripped += strip_node(node) for v in node.values(): _walk(v) elif isinstance(node, list): for item in node: _walk(item) for tool in tools: if not isinstance(tool, dict): continue fn = tool.get("function") if isinstance(fn, dict) and isinstance(fn.get("parameters"), dict): _walk(fn["parameters"]) continue if isinstance(tool.get("parameters"), dict): _walk(tool["parameters"]) if stripped: logger.info(log_msg, stripped) return tools, stripped def strip_pattern_and_format(tools: list[dict]) -> tuple[list[dict], int]: """Strip ``pattern``/``format`` keywords from tool schemas, in place. Reactive: invoked only after llama.cpp's grammar converter rejected a schema with HTTP 400. Its regex engine supports a small ECMAScript subset (no ``\\d``/``\\w``/``\\s``) and most ``format`` values; cloud providers rely on these as prompting hints, so they stay in the default schema. Only strips as a sibling of ``type``/combinators (i.e. on schema nodes), so a property literally *named* ``pattern`` (``search_files``) is untouched — property names live inside ``properties``, not beside ``type``. """ def _strip(node: dict) -> int: if not ("type" in node or "anyOf" in node or "oneOf" in node or "allOf" in node): return 0 hits = [k for k in node if k in _STRIP_ON_RECOVERY_KEYS] for k in hits: node.pop(k, None) return len(hits) return _reactive_strip( tools, _strip, "schema_sanitizer: stripped %d pattern/format keyword(s) from " "tool schemas (llama.cpp grammar-parse recovery)", ) def strip_slash_enum(tools: list[dict]) -> tuple[list[dict], int]: """Strip ``enum`` keywords whose string values contain ``/``, in place. xAI's ``/v1/responses`` and ``/v1/chat/completions`` compile schemas to a grammar that rejects ``/`` in enum values (HTTP 400 before any token) — typically MCP enums of HuggingFace model IDs or owner/name env IDs. The constraint is a prompting hint only; the model still sees the description. """ def _strip(node: dict) -> int: enum_val = node.get("enum") if isinstance(enum_val, list) and any(isinstance(v, str) and "/" in v for v in enum_val): node.pop("enum", None) return 1 return 0 return _reactive_strip( tools, _strip, "schema_sanitizer: stripped %d enum keyword(s) containing '/' " "from tool schemas (xAI Responses grammar-compile recovery)", )