mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
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:
parent
ed9d29a9b4
commit
48f19ed323
2 changed files with 227 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue