diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 1864ef734c..63decfea85 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -1,4 +1,5 @@ import re +from copy import deepcopy from enum import Enum from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union, get_type_hints @@ -617,7 +618,7 @@ def convert_anyof_null_to_nullable(schema, depth=0): if anyof is not None: contains_null = False for atype in anyof: - if atype == {"type": "null"}: + if isinstance(atype, dict) and atype.get("type") == "null": # remove null type anyof.remove(atype) contains_null = True @@ -735,7 +736,20 @@ def _convert_schema_types(schema, depth=0): type_val = schema["type"] if isinstance(type_val, list) and len(type_val) > 1: # Convert ["string", "number"] -> {"anyOf": [{"type": "STRING"}, {"type": "NUMBER"}]} - schema["anyOf"] = [{"type": t} for t in type_val if isinstance(t, str)] + # Preserve other schema fields by copying them into each non-null anyOf item. + base_schema = {k: v for k, v in schema.items() if k not in {"type", "anyOf"}} + any_of: List[Dict[str, Any]] = [] + for t in type_val: + if not isinstance(t, str): + continue + if t == "null": + # Keep null entry minimal so we can strip it later. + any_of.append({"type": "null"}) + continue + item_schema = deepcopy(base_schema) + item_schema["type"] = t + any_of.append(item_schema) + schema["anyOf"] = any_of schema.pop("type") elif isinstance(type_val, list) and len(type_val) == 1: schema["type"] = type_val[0] diff --git a/tests/local_testing/test_amazing_vertex_completion.py b/tests/local_testing/test_amazing_vertex_completion.py index 0373f5f435..745c90201f 100644 --- a/tests/local_testing/test_amazing_vertex_completion.py +++ b/tests/local_testing/test_amazing_vertex_completion.py @@ -3598,6 +3598,60 @@ def test_vertex_schema_test(): print(response) +def test_gemini_nullable_object_tool_schema_httpx(): + """ + Ensure nullable object tool params preserve nested properties in Vertex schema conversion. + """ + load_vertex_ai_credentials() + litellm._turn_on_debug() + + + tools = [{ + "type": "function", + "strict": True, + "function": { + "name": "create_support_ticket", + "description": "Create a paid user support ticket", + "parameters": { + "type": "object", + "additionalProperties": False, + "required": ["ticket_id", "customer_context"], + "properties": { + "ticket_id": { + "type": "string", + "description": "Unique identifier for the support ticket" + }, + "customer_context": { + "type": ["object", "null"], + "description": "Context about the paid customer, if available", + "additionalProperties": False, + "required": ["user_id", "plan"], + "properties": { + "user_id": { + "type": "string", + "description": "Internal user identifier" + }, + "plan": { + "type": "string", + "description": "Subscription plan name (e.g. pro, enterprise)" + } + } + } + } + } + } + }] + + response = litellm.completion( + model="vertex_ai/gemini-2.5-flash", + messages=[{"role": "user", "content": "call the tool"}], + tools=tools, + tool_choice="required", + ) + + print(response) + + def test_vertex_ai_response_id(): """Test that litellm preserves the response ID from Vertex AI's API for non-streaming responses""" from litellm.llms.custom_httpx.http_handler import HTTPHandler