Merge pull request #22589 from Chesars/fix/vertex-preserve-any-type-schema

fix(vertex): preserve type schema semantics for JsonValuefields
This commit is contained in:
Cesar Garcia
2026-03-03 15:19:16 -03:00
committed by GitHub
2 changed files with 129 additions and 14 deletions
+30 -6
View File
@@ -571,14 +571,38 @@ def _filter_anyof_fields(schema_dict: Dict[str, Any]) -> Dict[str, Any]:
return schema_dict
def _is_any_type_schema(schema: dict) -> bool:
"""
Detect schemas that represent "any JSON value" (no type constraints).
In JSON Schema, an empty schema {} means "any value is valid".
Schemas with only metadata keys (title, description, default, examples)
but no type-constraining keywords also represent "any type".
Gemini's Schema proto uses TYPE_UNSPECIFIED (0) as default,
so omitting the type field is valid and means "any type".
"""
type_constraining_keys = {
"type",
"properties",
"items",
"anyOf",
"oneOf",
"allOf",
"enum",
"required",
"$ref",
"$schema",
}
return not any(key in type_constraining_keys for key in schema.keys())
def process_items(schema, depth=0):
if depth > DEFAULT_MAX_RECURSE_DEPTH:
raise ValueError(
f"Max depth of {DEFAULT_MAX_RECURSE_DEPTH} exceeded while processing schema. Please check the schema for excessive nesting."
)
if isinstance(schema, dict):
if "items" in schema and schema["items"] == {}:
schema["items"] = {"type": "object"}
for key, value in schema.items():
if isinstance(value, dict):
process_items(value, depth + 1)
@@ -677,9 +701,8 @@ def convert_anyof_null_to_nullable(schema, depth=0):
# remove null type
anyof.remove(atype)
contains_null = True
elif "type" not in atype and len(atype) == 0:
# Handle empty object case
atype["type"] = "object"
elif isinstance(atype, dict) and _is_any_type_schema(atype):
pass # preserve "any type" semantics — don't coerce to object
if len(anyof) == 0:
# Edge case: response schema with only null type present is invalid in Vertex AI
@@ -714,7 +737,8 @@ def add_object_type(schema):
# Gemini requires all function parameters to be type OBJECT
# Handle case where schema has no properties and no type (e.g. tools with no arguments)
if "type" not in schema and "anyOf" not in schema and "oneOf" not in schema and "allOf" not in schema:
schema["type"] = "object"
if not _is_any_type_schema(schema):
schema["type"] = "object"
properties = schema.get("properties", None)
if properties is not None:
@@ -212,7 +212,7 @@ def test_build_vertex_schema():
"properties": {
"state": {
"properties": {
"messages": {"items": {"type": "object"}, "type": "array"},
"messages": {"items": {}, "type": "array"},
"conversation_id": {"type": "string"},
},
"required": ["messages", "conversation_id"],
@@ -226,7 +226,7 @@ def test_build_vertex_schema():
"callbacks": {
"anyOf": [
{"type": "array", "nullable": True},
{"type": "object", "nullable": True},
{"nullable": True},
]
},
"run_name": {"type": "string"},
@@ -270,23 +270,28 @@ def test_process_items_basic():
"""Test basic functionality of process_items."""
from litellm.llms.vertex_ai.common_utils import process_items
# Test empty items
# Test empty items — should preserve "any type" semantics (not coerce to object)
schema = {"type": "array", "items": {}}
process_items(schema)
assert schema["items"] == {"type": "object"}
assert schema["items"] == {}
# Test nested items
# Test nested items — should preserve "any type" semantics
schema = {"type": "array", "items": {"type": "array", "items": {}}}
process_items(schema)
assert schema["items"]["items"] == {"type": "object"}
assert schema["items"]["items"] == {}
# Test items in properties
# Test items in properties — should preserve "any type" semantics
schema = {
"type": "object",
"properties": {"nested": {"type": "array", "items": {}}},
}
process_items(schema)
assert schema["properties"]["nested"]["items"] == {"type": "object"}
assert schema["properties"]["nested"]["items"] == {}
# Test items with actual type — should not be modified
schema = {"type": "array", "items": {"type": "string"}}
process_items(schema)
assert schema["items"] == {"type": "string"}
def test_vertex_ai_complex_response_schema():
@@ -1402,3 +1407,89 @@ def test_add_object_type_does_not_add_type_when_anyof_present():
# Verify type was not added (anyOf handles the type)
assert "type" not in input_schema, "type should not be added when anyOf is present"
def test_is_any_type_schema():
"""Test _is_any_type_schema correctly identifies unconstrained schemas."""
from litellm.llms.vertex_ai.common_utils import _is_any_type_schema
# Empty schema = any type
assert _is_any_type_schema({}) is True
# Only metadata keys = any type
assert _is_any_type_schema({"description": "Any value"}) is True
assert _is_any_type_schema({"title": "MyField"}) is True
assert _is_any_type_schema({"title": "X", "description": "Y", "default": 0}) is True
# Has type-constraining keys = NOT any type
assert _is_any_type_schema({"type": "object"}) is False
assert _is_any_type_schema({"type": "string"}) is False
assert _is_any_type_schema({"properties": {"a": {}}}) is False
assert _is_any_type_schema({"items": {"type": "string"}}) is False
assert _is_any_type_schema({"anyOf": [{"type": "string"}]}) is False
assert _is_any_type_schema({"$schema": "https://json-schema.org/draft/2020-12/schema"}) is False
assert _is_any_type_schema({"enum": ["a", "b"]}) is False
def test_add_object_type_preserves_any_type_schema():
"""Test add_object_type does NOT add type:object to empty schemas (any type)."""
from litellm.llms.vertex_ai.common_utils import add_object_type
# Empty schema should be preserved (any type)
schema = {}
add_object_type(schema)
assert "type" not in schema, "Empty schema (any type) should not get type: object"
# Schema with only description should be preserved
schema = {"description": "Any JSON value"}
add_object_type(schema)
assert "type" not in schema
# Schema with $schema key should still get type: object (tool with no args)
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"}
add_object_type(schema)
assert schema["type"] == "object"
def test_convert_anyof_preserves_any_type_members():
"""Test convert_anyof_null_to_nullable does NOT coerce empty anyOf members to object."""
from litellm.llms.vertex_ai.common_utils import convert_anyof_null_to_nullable
# anyOf with empty schema and null — empty should be preserved
schema = {
"anyOf": [
{},
{"type": "null"},
]
}
convert_anyof_null_to_nullable(schema)
# null should be removed, empty schema should be preserved (not coerced to object)
assert len(schema["anyOf"]) == 1
assert "type" not in schema["anyOf"][0] or schema["anyOf"][0].get("type") != "object"
assert schema["anyOf"][0].get("nullable") is True
def test_build_vertex_schema_jsonvalue():
"""
End-to-end: Pydantic JsonValue generates {} in $defs.
_build_vertex_schema should preserve any-type semantics.
Regression test for https://github.com/BerriAI/litellm/issues/22391
"""
from litellm.llms.vertex_ai.common_utils import _build_vertex_schema
# Simulates what Pydantic generates for a model with JsonValue field
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {}, # after $ref resolution, this is what JsonValue becomes
},
"required": ["name", "value"],
}
result = _build_vertex_schema(schema)
# The "value" field should NOT have been coerced to type: object
value_schema = result["properties"]["value"]
assert value_schema.get("type") != "object", (
"JsonValue schema {} should not be coerced to {type: object}"
)