From ce038a0e0557f44fa049e4daab99f619f2b92f54 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:03:20 -0700 Subject: [PATCH] fix(schema): preserve multi-type arrays as anyOf instead of dropping branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from anomalyco/opencode#31877: JSON Schema type arrays like ["number","string"] (common in MCP tool schemas) were collapsed to the first non-null type, silently dropping every other branch. Several tool-call backends reject the array form outright — llama.cpp's grammar generator and Gemini via OpenAI-compatible transports (e.g. GitHub Copilot proxying to Gemini) 400 on it. _sanitize_node now mirrors @ai-sdk/google: a single non-null type stays type:X (+nullable if null was present), multiple non-null types become an anyOf of single-type schemas so no branch is lost, and an all-null array becomes type:null. Single-null collapse is unchanged. Verified nested (object props, array items) survive the full sanitize pipeline — combinator stripping is top-level-only and nullable-union collapse only fires on single-survivor unions, so multi-type anyOf is left intact. --- tests/tools/test_schema_sanitizer.py | 59 ++++++++++++++++++++++++++++ tools/schema_sanitizer.py | 44 +++++++++++++++------ 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/tests/tools/test_schema_sanitizer.py b/tests/tools/test_schema_sanitizer.py index 360457de2b2..90a6ffa3c04 100644 --- a/tests/tools/test_schema_sanitizer.py +++ b/tests/tools/test_schema_sanitizer.py @@ -80,6 +80,65 @@ def test_nullable_type_array_collapsed_to_single_string(): assert prop.get("nullable") is True +def test_multitype_array_becomes_anyof_no_branch_dropped(): + # Ported from anomalyco/opencode#31877: a genuine multi-type array such as + # ["number", "string"] (common in MCP tool schemas) must keep BOTH branches + # as an anyOf, not silently drop all but the first. Several backends + # (llama.cpp, Gemini via OpenAI-compatible transports) reject the array form. + tools = [_tool("t", { + "type": "object", + "properties": { + "status": {"type": ["number", "string"], "description": "status filter"}, + }, + })] + out = sanitize_tool_schemas(tools) + prop = out[0]["function"]["parameters"]["properties"]["status"] + assert "type" not in prop + assert prop["anyOf"] == [{"type": "number"}, {"type": "string"}] + assert prop.get("nullable") is None + # Sibling keywords survive alongside the generated anyOf. + assert prop["description"] == "status filter" + + +def test_multitype_array_with_null_lifts_nullable(): + tools = [_tool("t", { + "type": "object", + "properties": { + "v": {"type": ["integer", "boolean", "null"]}, + }, + })] + out = sanitize_tool_schemas(tools) + prop = out[0]["function"]["parameters"]["properties"]["v"] + assert "type" not in prop + assert prop["anyOf"] == [{"type": "integer"}, {"type": "boolean"}] + assert prop.get("nullable") is True + + +def test_all_null_type_array_becomes_null_type(): + tools = [_tool("t", { + "type": "object", + "properties": { + "n": {"type": ["null"]}, + }, + })] + out = sanitize_tool_schemas(tools) + prop = out[0]["function"]["parameters"]["properties"]["n"] + assert prop["type"] == "null" + + +def test_single_element_type_array_unwrapped(): + tools = [_tool("t", { + "type": "object", + "properties": { + "s": {"type": ["string"]}, + }, + })] + out = sanitize_tool_schemas(tools) + prop = out[0]["function"]["parameters"]["properties"]["s"] + assert prop["type"] == "string" + assert prop.get("nullable") is None + + def test_anyof_nested_objects_sanitized(): tools = [_tool("t", { "type": "object", diff --git a/tools/schema_sanitizer.py b/tools/schema_sanitizer.py index a2a5892d927..9f0bc98fcd2 100644 --- a/tools/schema_sanitizer.py +++ b/tools/schema_sanitizer.py @@ -235,7 +235,9 @@ def _sanitize_node(node: Any, path: str) -> Any: ``{"type": }`` so downstream consumers see a dict. - Injects ``properties: {}`` into object-typed nodes missing it. - Normalizes ``type: [X, "null"]`` arrays to single ``type: X`` (keeping - ``nullable: true`` as a hint). + ``nullable: true`` as a hint), and multi-type arrays like + ``["number", "string"]`` to an ``anyOf`` of single-type schemas so no + branch is dropped (ported from anomalyco/opencode#31877). - Recurses into ``properties``, ``items``, ``additionalProperties``, ``anyOf``, ``oneOf``, ``allOf``, and ``$defs`` / ``definitions``. """ @@ -268,23 +270,39 @@ def _sanitize_node(node: Any, path: str) -> Any: out: dict = {} for key, value in node.items(): - # type: [X, "null"] → type: X (the backend's tool-call parser only - # accepts singular string types; nullable is lost but the call still - # succeeds, and the model can still pass null on its own.) + # JSON Schema ``type`` arrays (e.g. ``["number", "string"]``, common + # in MCP tool schemas) are rejected by several tool-call backends: + # * llama.cpp's grammar generator only accepts a singular string type. + # * Gemini (including OpenAI-compatible transports such as GitHub + # Copilot proxying to Gemini) rejects the array form outright — + # plain @ai-sdk/google rewrites it, but the OpenAI-compatible path + # forwards it verbatim and the backend 400s. + # + # Normalize per the SDK's behavior: + # * single non-null type → ``type: X`` (+ ``nullable: true`` if the + # array also contained "null"). No data lost. + # * multiple non-null types → ``anyOf`` of single-type schemas, so + # EVERY branch survives instead of silently dropping all but the + # first. ``null`` is lifted into ``nullable: true``. + # * all-null / empty → ``type: "null"`` (or object fallback). + # Ported from anomalyco/opencode#31877. if key == "type" and isinstance(value, list): - non_null = [t for t in value if t != "null"] - if len(non_null) == 1 and isinstance(non_null[0], str): + 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] - if "null" in value: + if has_null: out.setdefault("nullable", True) continue - # Fallback: pick the first string type, drop the rest. - first_str = next((t for t in value if isinstance(t, str) and t != "null"), None) - if first_str: - out["type"] = first_str + if len(non_null) >= 2: + # Preserve all branches as a union instead of dropping them. + out["anyOf"] = [{"type": t} for t in non_null] + if has_null: + out.setdefault("nullable", True) continue - # All-null or empty list → treat as object. - out["type"] = "object" + # No usable non-null type: all-null array → type: "null"; + # otherwise an empty/garbage array → object fallback. + out["type"] = "null" if has_null else "object" continue if key in {"properties", "$defs", "definitions"} and isinstance(value, dict):