fix(vertex): drop bare {} schemas from anyOf before adding nullable=True

When anyOf contains a mix of concrete types, bare {} (any-type), and null,
convert_anyof_null_to_nullable was adding nullable=True to the {} entry,
producing {nullable: True} with no type field. Gemini rejects this as an
anyOf entry without a concrete type, breaking tool calls that use
Optional[List[...]] or similar union types (common in LangChain/Pydantic).

Fix: strip any-type schemas from anyOf before the nullable=True pass.
If only any-type schemas remain after null removal (anyOf: [{}, null]),
collapse the anyOf entirely and set nullable=True on the parent schema
instead — correctly representing 'any nullable value' for Gemini.

Regression introduced by da941e4261.
This commit is contained in:
Ishaan Jaffer 2026-03-07 15:55:37 -08:00
parent 6dd2a9a3ea
commit 4c95ac2ca0
3 changed files with 87 additions and 27 deletions

View file

@ -712,6 +712,20 @@ def convert_anyof_null_to_nullable(schema, depth=0):
)
if contains_null:
# Drop any-type schemas (bare {}) from anyOf before adding nullable=True.
# Adding nullable=True to {} produces {"nullable": True} with no type field,
# which Gemini rejects as an anyOf entry without a concrete type.
for atype in list(anyof):
if isinstance(atype, dict) and _is_any_type_schema(atype):
anyof.remove(atype)
if len(anyof) == 0:
# All remaining entries were any-type schemas (e.g. anyOf: [{}, null]).
# This means "any nullable value" — collapse anyOf and mark parent nullable.
del schema["anyOf"]
schema["nullable"] = True
return
# set all types to nullable following guidance found here: https://cloud.google.com/vertex-ai/generative-ai/docs/samples/generativeaionvertexai-gemini-controlled-generation-response-schema-3#generativeaionvertexai_gemini_controlled_generation_response_schema_3-python
for atype in anyof:
# Remove items field if type is array and items is empty

View file

@ -2110,7 +2110,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/eu/gpt-5.1-chat": {
"cache_read_input_token_cost": 1.4e-07,
@ -2143,7 +2144,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/eu/gpt-5.1-codex": {
"cache_read_input_token_cost": 1.4e-07,
@ -2410,7 +2412,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/global/gpt-5.1-chat": {
"cache_read_input_token_cost": 1.25e-07,
@ -2443,7 +2446,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/global/gpt-5.1-codex": {
"cache_read_input_token_cost": 1.25e-07,
@ -3456,7 +3460,8 @@
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_service_tier": true,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/gpt-5.1-chat-2025-11-13": {
"cache_read_input_token_cost": 1.25e-07,
@ -3491,7 +3496,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": false,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/gpt-5.1-codex-2025-11-13": {
"cache_read_input_token_cost": 1.25e-07,
@ -3906,7 +3912,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/gpt-5.1-chat": {
"cache_read_input_token_cost": 1.25e-07,
@ -3939,7 +3946,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/gpt-5.1-codex": {
"cache_read_input_token_cost": 1.25e-07,
@ -5273,7 +5281,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/us/gpt-5.1-chat": {
"cache_read_input_token_cost": 1.4e-07,
@ -5306,7 +5315,8 @@
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"supports_none_reasoning_effort": true
},
"azure/us/gpt-5.1-codex": {
"cache_read_input_token_cost": 1.4e-07,
@ -21068,18 +21078,18 @@
"input_cost_per_token_flex": 1.5e-05,
"input_cost_per_token_batches": 1.5e-05,
"input_cost_per_token_priority": 6e-05,
"input_cost_per_token_above_272k_tokens_priority": 1.2e-04,
"input_cost_per_token_above_272k_tokens_priority": 0.00012,
"litellm_provider": "openai",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.8e-04,
"output_cost_per_token_above_272k_tokens": 2.7e-04,
"output_cost_per_token": 0.00018,
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
"output_cost_per_token_batches": 9e-05,
"output_cost_per_token_priority": 2.7e-04,
"output_cost_per_token_above_272k_tokens_priority": 4.05e-04,
"output_cost_per_token_priority": 0.00027,
"output_cost_per_token_above_272k_tokens_priority": 0.000405,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",
@ -21117,18 +21127,18 @@
"input_cost_per_token_flex": 1.5e-05,
"input_cost_per_token_batches": 1.5e-05,
"input_cost_per_token_priority": 6e-05,
"input_cost_per_token_above_272k_tokens_priority": 1.2e-04,
"input_cost_per_token_above_272k_tokens_priority": 0.00012,
"litellm_provider": "openai",
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.8e-04,
"output_cost_per_token_above_272k_tokens": 2.7e-04,
"output_cost_per_token": 0.00018,
"output_cost_per_token_above_272k_tokens": 0.00027,
"output_cost_per_token_flex": 9e-05,
"output_cost_per_token_batches": 9e-05,
"output_cost_per_token_priority": 2.7e-04,
"output_cost_per_token_above_272k_tokens_priority": 4.05e-04,
"output_cost_per_token_priority": 0.00027,
"output_cost_per_token_above_272k_tokens_priority": 0.000405,
"supported_endpoints": [
"/v1/chat/completions",
"/v1/batch",

View file

@ -1451,11 +1451,15 @@ def test_add_object_type_preserves_any_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."""
def test_convert_anyof_pure_any_type_collapses_to_nullable_parent():
"""
anyOf: [{}, null] means "any nullable value".
The empty schema {} (any-type) inside anyOf cannot receive nullable=True and be
sent to Gemini Gemini rejects {"nullable": True} with no type field as an anyOf
entry. Instead we collapse the anyOf and set nullable=True on the parent.
"""
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": [
{},
@ -1463,10 +1467,42 @@ def test_convert_anyof_preserves_any_type_members():
]
}
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
# anyOf should be removed; parent gets nullable=True to represent "any nullable value"
assert "anyOf" not in schema
assert schema.get("nullable") is True
def test_convert_anyof_drops_empty_schema_when_concrete_type_present():
"""
Regression test for tool schemas with anyOf: [array, {}, null].
The bare {} (any-type) inside anyOf must be dropped Gemini rejects
{"nullable": True} with no type field. The concrete array type should
remain with nullable=True.
Broken by da941e4261c5 (preserve type schema semantics for JsonValue fields).
"""
from litellm.llms.vertex_ai.common_utils import convert_anyof_null_to_nullable
schema = {
"properties": {
"callbacks": {
"anyOf": [
{"items": {}, "type": "array"},
{},
{"type": "null"},
]
}
}
}
convert_anyof_null_to_nullable(schema)
callbacks_anyof = schema["properties"]["callbacks"]["anyOf"]
# {} and {"type": "null"} should both be gone; only the array entry remains
assert len(callbacks_anyof) == 1
assert callbacks_anyof[0].get("type") == "array"
assert callbacks_anyof[0].get("nullable") is True
# empty items should have been stripped too
assert "items" not in callbacks_anyof[0]
def test_build_vertex_schema_jsonvalue():