Coerce union-typed JSON Schema for Gemini tool declarations

Vendor MCP schemas with type: [a, b] failed SDK validation and killed every Gemini turn.
Null unions become nullable; multi-type unions become anyOf.
This commit is contained in:
Rohit C Prasad
2026-07-23 07:43:24 -07:00
committed by Rohit P
parent 52038c2136
commit d10990bdf7
2 changed files with 76 additions and 1 deletions
+13 -1
View File
@@ -257,7 +257,11 @@ def convert_messages(
def _sanitize_schema(schema: Any) -> Any: def _sanitize_schema(schema: Any) -> Any:
"""Strip JSON Schema keys Gemini's OpenAPI subset rejects (recursively).""" """Strip JSON Schema keys Gemini's OpenAPI subset rejects (recursively), and coerce
list-valued `type` (JSON Schema union, e.g. ["string", "number"] — common in vendor
MCP tool schemas) into shapes the API accepts: null joins as `nullable`, a single
remaining type stays `type`, several become `anyOf` (owner-hit 2026-07-23: monday's
compareValue union 400'd every Gemini turn in sessions with MCP tools)."""
if not isinstance(schema, dict): if not isinstance(schema, dict):
return schema return schema
cleaned: dict[str, Any] = {} cleaned: dict[str, Any] = {}
@@ -270,6 +274,14 @@ def _sanitize_schema(schema: Any) -> Any:
cleaned[key] = _sanitize_schema(value) cleaned[key] = _sanitize_schema(value)
elif key == "anyOf" and isinstance(value, list): elif key == "anyOf" and isinstance(value, list):
cleaned[key] = [_sanitize_schema(sub) for sub in value] cleaned[key] = [_sanitize_schema(sub) for sub in value]
elif key == "type" and isinstance(value, list):
types = [t for t in value if t != "null"]
if len(value) != len(types):
cleaned["nullable"] = True
if len(types) == 1:
cleaned["type"] = types[0]
elif types:
cleaned["anyOf"] = [{"type": t} for t in types]
else: else:
cleaned[key] = value cleaned[key] = value
return cleaned return cleaned
+63
View File
@@ -634,3 +634,66 @@ def test_stream_yields_reasoning_deltas_for_thought_parts():
assert [c.reasoning_delta for c in out if c.reasoning_delta] == ["mull ", "it over"] assert [c.reasoning_delta for c in out if c.reasoning_delta] == ["mull ", "it over"]
final = out[-1].turn final = out[-1].turn
assert final.text == "done" and final.reasoning == "mull it over" assert final.text == "done" and final.reasoning == "mull it over"
def test_sanitize_coerces_union_types():
"""Vendor MCP schemas use JSON-Schema union types (owner-hit 2026-07-23: monday's
compareValue `type: ['string','number']` 400'd every Gemini turn). Nullable unions
become `nullable`, multi-type unions become anyOf."""
cleaned = _sanitize_schema(
{
"type": "object",
"properties": {
"compareValue": {
"anyOf": [
{"type": "string"},
{"type": "array", "items": {"type": ["string", "number"]}},
]
},
"maybe": {"type": ["string", "null"]},
"nothing": {"type": ["null"]},
},
}
)
items = cleaned["properties"]["compareValue"]["anyOf"][1]["items"]
assert items == {"anyOf": [{"type": "string"}, {"type": "number"}]}
assert cleaned["properties"]["maybe"] == {"type": "string", "nullable": True}
assert cleaned["properties"]["nothing"] == {"nullable": True}
def test_union_type_schema_validates_as_sdk_config():
"""The exact failing shape must pass the SDK's GenerateContentConfig validation."""
types_mod = pytest.importorskip("google.genai.types")
tools = convert_tools(
[
{
"type": "function",
"function": {
"name": "mcp__monday__search",
"parameters": {
"type": "object",
"properties": {
"filters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"compareValue": {
"anyOf": [
{"type": "string"},
{"type": "number"},
{"type": "array", "items": {"type": "string"}},
{"type": "array", "items": {"type": ["string", "number"]}},
]
}
},
},
}
},
},
},
}
]
)
config = types_mod.GenerateContentConfig.model_validate({"tools": tools})
assert config.tools