Compare commits

...

1 Commits

Author SHA1 Message Date
teknium1
eba8cc564e fix(schema): preserve multi-type arrays as anyOf instead of dropping branches
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.
2026-06-18 17:03:20 -07:00
2 changed files with 90 additions and 13 deletions

View File

@@ -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",

View File

@@ -235,7 +235,9 @@ def _sanitize_node(node: Any, path: str) -> Any:
``{"type": <value>}`` 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):