Merge pull request #26675 from BerriAI/litellm_/zen-snyder-4c197e

fix(vertex): preserve items on array branches in anyOf with null + de-flake test
This commit is contained in:
yuneng-jiang 2026-04-28 10:31:34 -07:00 committed by GitHub
commit 600d7b4a20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 185 additions and 17 deletions

View file

@ -597,7 +597,14 @@ def process_items(schema, depth=0):
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"] == {}:
# Vertex requires `items` whenever `type == "array"` (even inside anyOf).
# Normalize: empty `items: {}` and missing-items both become {"type": "object"}.
type_val = schema.get("type")
if (
isinstance(type_val, str)
and type_val.lower() == "array"
and ("items" not in schema or schema.get("items") == {})
):
schema["items"] = {"type": "object"}
for key, value in schema.items():
if isinstance(value, dict):
@ -710,14 +717,10 @@ def convert_anyof_null_to_nullable(schema, depth=0):
if contains_null:
# 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
# Empty `items: {}` on array branches is left in place; downstream
# process_items() converts it to {"type": "object"}, which Vertex
# requires whenever type == "array" (even inside anyOf).
for atype in anyof:
# Remove items field if type is array and items is empty
if (
atype.get("type") == "array"
and "items" in atype
and not atype["items"]
):
atype.pop("items")
atype["nullable"] = True
properties = schema.get("properties", None)

View file

@ -3493,8 +3493,14 @@ def test_litellm_api_base(monkeypatch, provider, route):
def test_gemini_tool_calling_working_demo():
load_vertex_ai_credentials()
litellm._turn_on_debug()
"""
Regression test: tool params with anyOf containing a `{"type": "array"}`
branch (no items field at all) must synthesize items before the request
is sent to Vertex (Vertex rejects array types missing items).
"""
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
args = {
"messages": [
{
@ -3564,13 +3570,75 @@ def test_gemini_tool_calling_working_demo():
],
"vertex_location": "global",
}
response = completion(model="vertex_ai/gemini-3-flash-preview", **args)
print(response)
client = HTTPHandler()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/json"}
mock_response.json.return_value = {
"candidates": [
{
"content": {
"role": "model",
"parts": [{"text": "Hello!"}],
},
"finishReason": "STOP",
}
],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 5,
"totalTokenCount": 15,
},
}
with (
patch.object(client, "post", return_value=mock_response) as mock_post,
patch.object(
VertexBase,
"_ensure_access_token",
return_value=("fake-token", "fake-project"),
),
):
completion(
model="vertex_ai/gemini-3-flash-preview",
client=client,
**args,
)
sent_body = mock_post.call_args.kwargs.get(
"json"
) or mock_post.call_args.kwargs.get("data")
assert sent_body is not None, "expected request body to be sent"
if isinstance(sent_body, str):
sent_body = json.loads(sent_body)
function_decl = sent_body["tools"][0]["function_declarations"][0]
callbacks_schema = function_decl["parameters"]["properties"]["config"][
"properties"
]["callbacks"]
array_branches = [
branch
for branch in callbacks_schema["anyOf"]
if branch.get("type", "").lower() == "array"
]
assert array_branches, "expected an array branch in callbacks anyOf"
for branch in array_branches:
assert "items" in branch and branch["items"], (
f"array branch in callbacks.anyOf must include non-empty items "
f"(Vertex rejects array types missing items). Got: {branch}"
)
def test_gemini_tool_calling_not_working():
load_vertex_ai_credentials()
litellm._turn_on_debug()
"""
Regression test: tool params with anyOf containing both an empty-items
array branch and a null branch must serialize with items present on the
array branch (Vertex rejects array types missing `items`).
"""
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
args = {
"messages": [
{
@ -3637,8 +3705,64 @@ def test_gemini_tool_calling_not_working():
],
"vertex_location": "global",
}
response = completion(model="vertex_ai/gemini-3-flash-preview", **args)
print(response)
client = HTTPHandler()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/json"}
mock_response.json.return_value = {
"candidates": [
{
"content": {
"role": "model",
"parts": [{"text": "Hello!"}],
},
"finishReason": "STOP",
}
],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 5,
"totalTokenCount": 15,
},
}
with (
patch.object(client, "post", return_value=mock_response) as mock_post,
patch.object(
VertexBase,
"_ensure_access_token",
return_value=("fake-token", "fake-project"),
),
):
completion(
model="vertex_ai/gemini-3-flash-preview",
client=client,
**args,
)
sent_body = mock_post.call_args.kwargs.get(
"json"
) or mock_post.call_args.kwargs.get("data")
assert sent_body is not None, "expected request body to be sent"
if isinstance(sent_body, str):
sent_body = json.loads(sent_body)
function_decl = sent_body["tools"][0]["function_declarations"][0]
callbacks_schema = function_decl["parameters"]["properties"]["config"][
"properties"
]["callbacks"]
array_branches = [
branch
for branch in callbacks_schema["anyOf"]
if branch.get("type", "").lower() == "array"
]
assert array_branches, "expected an array branch in callbacks anyOf"
for branch in array_branches:
assert "items" in branch and branch["items"], (
f"array branch in callbacks.anyOf must include non-empty items "
f"(Vertex rejects array types missing items). Got: {branch}"
)
def test_vertex_ai_llama_tool_calling():

View file

@ -225,7 +225,11 @@ def test_build_vertex_schema():
"metadata": {"type": "object"},
"callbacks": {
"anyOf": [
{"type": "array", "nullable": True},
{
"type": "array",
"items": {"type": "object"},
"nullable": True,
},
{"type": "object", "nullable": True},
]
},
@ -288,6 +292,43 @@ def test_process_items_basic():
process_items(schema)
assert schema["properties"]["nested"]["items"] == {"type": "object"}
# Vertex rejects array types missing `items` entirely (not just empty).
# Synthesize {"type": "object"} so the request validates.
schema = {"type": "array"}
process_items(schema)
assert schema["items"] == {"type": "object"}
def test_build_vertex_schema_array_branch_missing_items_in_anyof():
"""
Regression: an `anyOf` branch with `{"type": "array"}` (no items) must
end up with synthesized `items: {"type": "object"}` after the schema
transform Vertex returns INVALID_ARGUMENT otherwise.
"""
from litellm.llms.vertex_ai.common_utils import _build_vertex_schema
parameters = {
"properties": {
"callbacks": {
"anyOf": [
{"type": "array"},
{"type": "object"},
{"type": "null"},
]
}
},
"type": "object",
}
result = _build_vertex_schema(parameters)
callbacks_anyof = result["properties"]["callbacks"]["anyOf"]
array_branches = [b for b in callbacks_anyof if b.get("type") == "array"]
assert array_branches, "expected an array branch to remain after transform"
for branch in array_branches:
assert branch.get("items") == {
"type": "object"
}, f"array branch must have items synthesized; got {branch}"
def test_vertex_ai_complex_response_schema():
import json