fix(vertex_ai): normalize boolean JSON Schema nodes before the Vertex transform

Tool schemas from OpenAI-compatible clients and MCP servers can legally use
boolean JSON Schemas (e.g. `items: true` for untyped values). Vertex's Schema
type has no boolean form and the conversion walkers read every node as a dict,
so such a schema crashed _build_vertex_schema with
`AttributeError: 'bool' object has no attribute 'get'`, surfacing as a
litellm.APIConnectionError before any request reached Vertex.

`_normalize_boolean_schemas` runs right after $defs expansion and rewrites
`true` sub-schemas (items, property schemas, anyOf members) to the equivalent
empty schema {}, which the existing walkers complete into Vertex-valid form.
`false` rejects every value and is not representable in Vertex's Schema type,
so it raises ValueError instead of silently widening the caller's constraint.
The helper is pure: the caller's schema is not mutated. Guard-only fixes in
individual walkers were insufficient, as a bare bool crashes six of them
(convert_anyof_null_to_nullable, _convert_schema_types, _fix_enum_types,
_fix_enum_empty_strings, process_items, add_object_type).

Verified against a production request carrying 349 MCP tools: 348/349 tools
converted before (datadog_visualize_tabular_data crashed), 349/349 after.
Non-schema booleans (nullable flag, enum values) are untouched; positions the
walkers do not traverse (additionalProperties, not, if/then/else, etc.) keep
their previous behavior.
This commit is contained in:
Lucas Sugi 2026-09-03 10:46:12 -03:00
parent ed9d29a9b4
commit 48f19ed323
No known key found for this signature in database
GPG key ID: 740CC8F30DDA43EE
2 changed files with 227 additions and 0 deletions

View file

@ -597,6 +597,8 @@ def _build_vertex_schema(parameters: dict, add_property_ordering: bool = False):
# refs recursively and correctly detects/skips circular references.
unpack_defs(parameters, defs)
parameters = _normalize_boolean_schemas(cast("dict[str, object]", parameters)) # cast-ok: JSON Schema root
# 5. Nullable fields:
# * https://github.com/pydantic/pydantic/issues/1270
# * https://stackoverflow.com/a/58841311
@ -772,6 +774,42 @@ def filter_schema_fields(schema_dict: dict[str, object], valid_fields: set[str],
return result
def _normalize_boolean_schemas(schema: dict[str, object], depth: int = 0) -> dict[str, object]:
"""Resolve JSON Schema boolean sub-schemas before the Vertex conversion.
`true` matches everything, exactly like the empty schema `{}`, which the
walkers below already complete into Vertex-valid form. `false` rejects every
value; Vertex's Schema type cannot express that, so raising beats silently
widening the caller's constraint.
"""
if depth > DEFAULT_MAX_RECURSE_DEPTH:
raise ValueError(f"Max depth of {DEFAULT_MAX_RECURSE_DEPTH} exceeded while processing schema.")
def _node(value: object) -> object:
if isinstance(value, bool):
if value:
return {}
raise ValueError("boolean schema `false` (rejects every value) cannot be converted to a Vertex AI schema")
if isinstance(value, dict):
return _normalize_boolean_schemas(cast("dict[str, object]", value), depth + 1) # cast-ok: JSON Schema child
return value
properties: Final = cast("dict[str, object] | None", schema.get("properties", None)) # cast-ok: JSON Schema child
items: Final = schema.get("items", None)
anyof: Final = cast("list[object] | None", schema.get("anyOf", None)) # cast-ok: JSON Schema child
return {
**schema,
**(
{"properties": {name: _node(value) for name, value in properties.items()}}
if isinstance(properties, dict)
else {}
),
**({"items": _node(items)} if "items" in schema else {}),
**({"anyOf": [_node(member) for member in anyof]} if isinstance(anyof, list) else {}),
}
def convert_anyof_null_to_nullable(schema, depth=0):
if depth > DEFAULT_MAX_RECURSE_DEPTH:
raise ValueError(

View file

@ -143,6 +143,195 @@ def test_anyof_with_excessive_nesting():
convert_anyof_null_to_nullable(schema)
def test_normalize_boolean_schemas_items_true():
from copy import deepcopy
from litellm.llms.vertex_ai.common_utils import _normalize_boolean_schemas
schema = {
"type": "object",
"properties": {"rows": {"type": "array", "items": {"type": "array", "items": True}}},
}
original = deepcopy(schema)
normalized = _normalize_boolean_schemas(schema)
assert normalized["properties"]["rows"]["items"]["items"] == {}
assert schema == original
def test_normalize_boolean_schemas_properties_and_anyof_true():
from copy import deepcopy
from litellm.llms.vertex_ai.common_utils import _normalize_boolean_schemas
schema = {
"type": "object",
"properties": {"free_form": True, "typed": {"type": "string"}},
"anyOf": [True, {"type": "string"}],
}
original = deepcopy(schema)
normalized = _normalize_boolean_schemas(schema)
assert normalized["properties"]["free_form"] == {}
assert normalized["properties"]["typed"] == {"type": "string"}
assert normalized["anyOf"] == [{}, {"type": "string"}]
assert schema == original
def test_normalize_boolean_schemas_keeps_legitimate_bools():
from litellm.llms.vertex_ai.common_utils import _normalize_boolean_schemas
schema = {
"type": "object",
"properties": {
"flag": {"type": "boolean", "nullable": True},
"choice": {"enum": ["yes", "no", True]},
},
}
assert _normalize_boolean_schemas(schema) == schema
def test_normalize_boolean_schemas_tuple_items_passthrough():
from copy import deepcopy
from litellm.llms.vertex_ai.common_utils import _normalize_boolean_schemas
schema = {"type": "array", "items": [{"type": "string"}]}
original = deepcopy(schema)
normalized = _normalize_boolean_schemas(schema)
assert normalized == schema
assert schema == original
def test_normalize_boolean_schemas_excessive_depth_raises():
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.llms.vertex_ai.common_utils import _normalize_boolean_schemas
with pytest.raises(
ValueError,
match=f"Max depth of {DEFAULT_MAX_RECURSE_DEPTH} exceeded while processing schema.",
):
_normalize_boolean_schemas({"type": "object"}, depth=DEFAULT_MAX_RECURSE_DEPTH + 1)
@pytest.mark.parametrize(
"schema",
[
{"type": "object", "properties": {"v": {"type": "array", "items": False}}},
{"type": "object", "properties": {"v": False}},
{"type": "object", "properties": {"v": {"anyOf": [False, {"type": "string"}]}}},
],
)
def test_normalize_boolean_schemas_false_raises(schema):
from litellm.llms.vertex_ai.common_utils import _normalize_boolean_schemas
with pytest.raises(ValueError, match="rejects every value"):
_normalize_boolean_schemas(schema)
def _assert_no_boolean_sub_schemas(node: object) -> None:
if isinstance(node, dict):
for key, value in node.items():
if key in ("items", "additionalProperties") and isinstance(value, bool):
raise AssertionError(f"boolean sub-schema under {key!r}")
if key == "properties" and isinstance(value, dict):
for child in value.values():
if isinstance(child, bool):
raise AssertionError("boolean sub-schema in properties")
_assert_no_boolean_sub_schemas(child)
elif key in ("anyOf", "oneOf", "allOf") and isinstance(value, list):
for member in value:
if isinstance(member, bool):
raise AssertionError(f"boolean sub-schema in {key}")
_assert_no_boolean_sub_schemas(member)
else:
_assert_no_boolean_sub_schemas(value)
elif isinstance(node, list):
for item in node:
_assert_no_boolean_sub_schemas(item)
def test_build_vertex_schema_with_mcp_boolean_items():
from litellm.llms.vertex_ai.common_utils import _build_vertex_schema
parameters = {
"type": "object",
"properties": {
"rows": {
"items": {"items": True, "anyOf": [{"type": "array"}], "nullable": True},
"anyOf": [{"type": "array"}],
"nullable": True,
},
"headers": {
"items": {"type": "string"},
"anyOf": [{"type": "array"}],
"nullable": True,
},
},
}
_assert_no_boolean_sub_schemas(_build_vertex_schema(parameters))
def test_build_vertex_schema_with_boolean_anyof_member():
from litellm.llms.vertex_ai.common_utils import _build_vertex_schema
parameters = {"type": "object", "properties": {"value": {"anyOf": [True, {"type": "null"}]}}}
result = _build_vertex_schema(parameters)
_assert_no_boolean_sub_schemas(result)
assert result["properties"]["value"]["anyOf"][0]["nullable"] is True
def test_build_vertex_schema_with_false_property_raises():
from litellm.llms.vertex_ai.common_utils import _build_vertex_schema
with pytest.raises(ValueError, match="rejects every value"):
_build_vertex_schema({"type": "object", "properties": {"v": False}})
@pytest.mark.parametrize(
"parameters",
[
{"type": "object", "properties": {"v": {"type": "string"}}, "additionalProperties": False},
{
"type": "object",
"properties": {
"inner": {"type": "object", "properties": {"a": {"type": "string"}}, "additionalProperties": False}
},
},
{"type": "object", "properties": {"v": {"type": "string"}}, "patternProperties": {"^x": False}},
{"type": "object", "properties": {"v": {"allOf": [False, {"type": "string"}]}}},
{"type": "object", "properties": {"v": {"oneOf": [False, {"type": "string"}]}}},
],
)
def test_build_vertex_schema_unsupported_keywords_do_not_reach_vertex(parameters):
from litellm.llms.vertex_ai.common_utils import _build_vertex_schema
result = _build_vertex_schema(parameters)
_assert_no_boolean_sub_schemas(result)
def has_unsupported_keyword(node: object) -> bool:
if isinstance(node, dict):
for key, value in node.items():
if key in ("additionalProperties", "patternProperties", "oneOf", "allOf"):
return True
if has_unsupported_keyword(value):
return True
elif isinstance(node, list):
return any(has_unsupported_keyword(item) for item in node)
return False
assert not has_unsupported_keyword(result)
@pytest.mark.asyncio
async def test_get_supports_system_message():
"""Test get_supports_system_message with different models"""