diff --git a/coworker/providers/gemini_provider.py b/coworker/providers/gemini_provider.py index 683d21b2..93ef234a 100644 --- a/coworker/providers/gemini_provider.py +++ b/coworker/providers/gemini_provider.py @@ -257,7 +257,11 @@ def convert_messages( 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): return schema cleaned: dict[str, Any] = {} @@ -270,6 +274,14 @@ def _sanitize_schema(schema: Any) -> Any: cleaned[key] = _sanitize_schema(value) elif key == "anyOf" and isinstance(value, list): 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: cleaned[key] = value return cleaned diff --git a/tests/test_gemini_provider.py b/tests/test_gemini_provider.py index f356042f..5064a7a8 100644 --- a/tests/test_gemini_provider.py +++ b/tests/test_gemini_provider.py @@ -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"] final = out[-1].turn 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