diff --git a/litellm/constants.py b/litellm/constants.py index 072c2c358f7..d6dd4b5cc23 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -744,6 +744,7 @@ DEFAULT_CHAT_COMPLETION_PARAM_VALUES = { "store": None, "metadata": None, "context_management": None, + "include_server_side_tool_invocations": None, } openai_compatible_endpoints: List = [ diff --git a/litellm/llms/gemini/chat/transformation.py b/litellm/llms/gemini/chat/transformation.py index 72569e5c6cd..185e3aef0f7 100644 --- a/litellm/llms/gemini/chat/transformation.py +++ b/litellm/llms/gemini/chat/transformation.py @@ -92,6 +92,7 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig): "parallel_tool_calls", "web_search_options", "service_tier", + "include_server_side_tool_invocations", ] if supports_reasoning(model, custom_llm_provider="gemini"): supported_params.append("reasoning_effort") diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 6278de662f8..7de8cb6abda 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -1077,6 +1077,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): model: str, drop_params: bool, ) -> Dict: + if non_default_params.get("include_server_side_tool_invocations") is True: + optional_params["include_server_side_tool_invocations"] = True for param, value in non_default_params.items(): if param == "temperature": if VertexGeminiConfig._is_gemini_3_or_newer(model): diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 353d19b0198..4a5b9ec1b8d 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -3499,7 +3499,12 @@ def test_video_metadata_supported_for_all_gemini_models(): } ] - for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"]: + for model in [ + "gemini-1.5-pro", + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-3-pro-preview", + ]: contents = _gemini_convert_messages_with_history(messages=messages, model=model) file_part = None @@ -3509,19 +3514,25 @@ def test_video_metadata_supported_for_all_gemini_models(): break assert file_part is not None, f"{model}: file part should exist" - assert "video_metadata" in file_part, f"{model}: video_metadata should be present" + assert ( + "video_metadata" in file_part + ), f"{model}: video_metadata should be present" assert file_part["video_metadata"]["fps"] == 5, f"{model}: fps should be 5" # Per-part media_resolution is Gemini 3+ only; 2.x uses generation_config global for model in ["gemini-3-pro-preview"]: contents = _gemini_convert_messages_with_history(messages=messages, model=model) file_part = next(p for p in contents[0]["parts"] if "file_data" in p) - assert "media_resolution" in file_part, f"{model}: media_resolution should be present" + assert ( + "media_resolution" in file_part + ), f"{model}: media_resolution should be present" for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro"]: contents = _gemini_convert_messages_with_history(messages=messages, model=model) file_part = next(p for p in contents[0]["parts"] if "file_data" in p) - assert "media_resolution" not in file_part, f"{model}: per-part media_resolution should not be set" + assert ( + "media_resolution" not in file_part + ), f"{model}: per-part media_resolution should not be set" def test_chunk_parser_handles_prompt_feedback_block(): @@ -4154,8 +4165,9 @@ def test_vertex_ai_usage_metadata_with_document_tokens_in_prompt(): # DOCUMENT tokens should be included in text_tokens: 8 (TEXT) + 774 (DOCUMENT) = 782 assert result.prompt_tokens_details is not None - assert result.prompt_tokens_details.text_tokens == 782, \ - "DOCUMENT modality tokens should be added to text_tokens (8 TEXT + 774 DOCUMENT = 782)" + assert ( + result.prompt_tokens_details.text_tokens == 782 + ), "DOCUMENT modality tokens should be added to text_tokens (8 TEXT + 774 DOCUMENT = 782)" # Verify completion token details assert result.completion_tokens_details is not None @@ -4190,8 +4202,9 @@ def test_vertex_ai_usage_metadata_with_document_tokens_cached(): # DOCUMENT cached tokens map to cached_text_tokens, so: # text_tokens = (8 TEXT + 774 DOCUMENT) - 400 cached = 382 - assert result.prompt_tokens_details.text_tokens == 382, \ - "text_tokens should be (8 + 774) - 400 cached = 382" + assert ( + result.prompt_tokens_details.text_tokens == 382 + ), "text_tokens should be (8 + 774) - 400 cached = 382" assert result.prompt_tokens_details.cached_tokens == 400 @@ -4290,3 +4303,91 @@ def test_transform_response_does_not_leak_body_on_parse_failure(): msg = str(exc_info.value) assert "secret content" not in msg assert "Error converting to valid response block" in msg + + +class TestServerSideToolInvocationsSearchToolPreservation: + """ + Regression tests for https://github.com/BerriAI/litellm/issues/27479 + + When include_server_side_tool_invocations=True is passed alongside + mixed function + search tools, the search tool must NOT be dropped. + """ + + def test_search_tool_preserved_with_server_side_invocations(self): + """map_openai_params must preserve googleSearch when the flag is set, + even though tools iterates before the flag handler.""" + v = VertexGeminiConfig() + result = v.map_openai_params( + non_default_params={ + "tools": [ + {"google_search": {}}, + { + "type": "function", + "function": { + "name": "send_message", + "description": "Send a message", + "parameters": { + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + }, + }, + ], + "include_server_side_tool_invocations": True, + }, + optional_params={}, + model="gemini-3.1-pro-preview", + drop_params=False, + ) + tools = result["tools"] + has_google_search = any("googleSearch" in t for t in tools) + has_func_declarations = any("function_declarations" in t for t in tools) + assert has_google_search, "googleSearch should be preserved" + assert has_func_declarations, "function declarations should be preserved" + + def test_search_tool_dropped_without_server_side_invocations(self): + """Without include_server_side_tool_invocations, mixed tools should + still drop search tools (existing behavior).""" + v = VertexGeminiConfig() + result = v.map_openai_params( + non_default_params={ + "tools": [ + {"google_search": {}}, + { + "type": "function", + "function": { + "name": "send_message", + "description": "Send a message", + "parameters": { + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + }, + }, + ], + }, + optional_params={}, + model="gemini-3.1-pro-preview", + drop_params=False, + ) + tools = result["tools"] + has_google_search = any("googleSearch" in t for t in tools) + assert not has_google_search, "googleSearch should be dropped" + + def test_google_ai_studio_supports_server_side_invocations(self): + """GoogleAIStudioGeminiConfig must list the flag in supported params.""" + cfg = GoogleAIStudioGeminiConfig() + params = cfg.get_supported_openai_params(model="gemini-3.1-pro-preview") + assert "include_server_side_tool_invocations" in params + + def test_flag_in_default_chat_completion_params(self): + """include_server_side_tool_invocations must be in DEFAULT_CHAT_COMPLETION_PARAM_VALUES + so get_non_default_params does not strip it.""" + from litellm.constants import DEFAULT_CHAT_COMPLETION_PARAM_VALUES + + assert ( + "include_server_side_tool_invocations" + in DEFAULT_CHAT_COMPLETION_PARAM_VALUES + )