mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
commit
600d7b4a20
3 changed files with 185 additions and 17 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue