From 470befab4b6ee9e72345432349ec0bb66a12d888 Mon Sep 17 00:00:00 2001 From: Sina Azizi Date: Mon, 17 Aug 2026 12:53:36 -0700 Subject: [PATCH 1/3] fix(gemini): convert native tool dicts instead of silently dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _convert_tool_spec skipped any dict whose type is not "function", so a gemini-native tool like {"google_search": {}} vanished from the request with no error. Validate such dicts through types.Tool.model_validate — the SDK schema is the authority, no name table — and raise a typed InvalidRequestError when it rejects them. --- src/any_llm/providers/gemini/base.py | 2 +- src/any_llm/providers/gemini/utils.py | 11 +++- tests/unit/providers/test_gemini_provider.py | 65 ++++++++++++++++++-- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/any_llm/providers/gemini/base.py b/src/any_llm/providers/gemini/base.py index 99c4d8858..6d7abb7e1 100644 --- a/src/any_llm/providers/gemini/base.py +++ b/src/any_llm/providers/gemini/base.py @@ -164,7 +164,7 @@ def _convert_completion_params(params: CompletionParams, **kwargs: Any) -> dict[ if params.temperature is not None: kwargs["temperature"] = params.temperature if params.tools is not None: - kwargs["tools"] = _convert_tool_spec(params.tools) + kwargs["tools"] = _convert_tool_spec(params.tools, provider_name) if isinstance(params.tool_choice, str): kwargs["tool_config"] = _convert_tool_choice(params.tool_choice) if params.top_p is not None: diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index 67e9233b6..c7834231f 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -7,6 +7,7 @@ from google.genai import types from google.genai.pagers import Pager +from pydantic import ValidationError from any_llm.exceptions import InvalidRequestError from any_llm.logging import logger @@ -62,7 +63,7 @@ def _has_additional_properties(schema: Any) -> bool: return False -def _convert_tool_spec(tools: list[dict[str, Any] | Any]) -> list[types.Tool]: +def _convert_tool_spec(tools: list[dict[str, Any] | Any], provider_name: str) -> list[types.Tool]: converted_tools = [] function_declarations = [] @@ -72,6 +73,14 @@ def _convert_tool_spec(tools: list[dict[str, Any] | Any]) -> list[types.Tool]: continue if tool.get("type") != "function": + # No openai "function" wrapper: treat it as a gemini-native tool dict (e.g. + # {"google_search": {}}) and let the SDK schema validate it, instead of silently + # dropping it from the request. + try: + converted_tools.append(types.Tool.model_validate(tool)) + except ValidationError as exc: + msg = f"Unsupported tool: {tool}" + raise InvalidRequestError(msg, provider_name=provider_name) from exc continue function = tool["function"] diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index 4d2ab38dc..2b7e20f69 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -978,7 +978,7 @@ def test_convert_tool_spec_basic_mapping() -> None: } ] - tools = _convert_tool_spec(openai_tools) + tools = _convert_tool_spec(openai_tools, "gemini") assert len(tools) == 1 assert tools[0].function_declarations[0].name == "search" # type: ignore[index] @@ -994,7 +994,7 @@ def test_convert_tool_spec_basic_mapping() -> None: def test_convert_tool_spec_none_parameters() -> None: """Regression: parameters=None must not raise 'NoneType' object is not subscriptable.""" - tools = _convert_tool_spec([{"type": "function", "function": {"name": "ping", "parameters": None}}]) + tools = _convert_tool_spec([{"type": "function", "function": {"name": "ping", "parameters": None}}], "gemini") assert len(tools) == 1 decl = tools[0].function_declarations[0] # type: ignore[index] assert decl.name == "ping" @@ -1004,7 +1004,9 @@ def test_convert_tool_spec_none_parameters() -> None: def test_convert_tool_spec_parameters_missing_properties() -> None: """Regression: parameters without a 'properties' key must not raise KeyError.""" - tools = _convert_tool_spec([{"type": "function", "function": {"name": "ping", "parameters": {"type": "object"}}}]) + tools = _convert_tool_spec( + [{"type": "function", "function": {"name": "ping", "parameters": {"type": "object"}}}], "gemini" + ) assert len(tools) == 1 decl = tools[0].function_declarations[0] # type: ignore[index] assert decl.parameters.properties == {} # type: ignore[union-attr] @@ -1056,7 +1058,7 @@ def test_convert_tool_spec_json_schema_with_defs_and_refs() -> None: } ] - tools = _convert_tool_spec(openai_tools) + tools = _convert_tool_spec(openai_tools, "gemini") assert len(tools) == 1 decl = tools[0].function_declarations[0] # type: ignore[index] @@ -1076,7 +1078,9 @@ def test_convert_tool_spec_nested_ref_only() -> None: } }, } - tools = _convert_tool_spec([{"type": "function", "function": {"name": "nested", "parameters": raw_params}}]) + tools = _convert_tool_spec( + [{"type": "function", "function": {"name": "nested", "parameters": raw_params}}], "gemini" + ) decl = tools[0].function_declarations[0] # type: ignore[index] assert decl.parameters is None assert decl.parameters_json_schema == raw_params @@ -1104,7 +1108,8 @@ def test_convert_tool_spec_mixed_ref_and_plain_tools() -> None: "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, }, }, - ] + ], + "gemini", ) assert len(tools) == 1 decls = tools[0].function_declarations @@ -1136,6 +1141,54 @@ async def test_gemini_with_built_in_tools() -> None: assert generation_config.tools[0] == google_search +@pytest.mark.asyncio +async def test_gemini_native_tool_dict_converts() -> None: + """A gemini-native tool dict must reach the request as a typed Tool, not be dropped.""" + messages = [{"role": "user", "content": "Hello"}] + with mock_gemini_provider() as mock_genai: + provider = GeminiProvider(api_key="test-api-key") + await provider._acompletion( + CompletionParams(model_id="gemini-pro", messages=messages, tools=[{"google_search": {}}]), + ) + _, call_kwargs = mock_genai.return_value.aio.models.generate_content.call_args + generation_config = call_kwargs["config"] + assert len(generation_config.tools) == 1 + assert generation_config.tools[0].google_search is not None + + +@pytest.mark.asyncio +async def test_gemini_native_tool_dict_mixes_with_function_tools() -> None: + """Function tools and native tool dicts convert side by side.""" + messages = [{"role": "user", "content": "Hello"}] + function_tool = { + "type": "function", + "function": {"name": "get_weather", "description": "d", "parameters": {"type": "object", "properties": {}}}, + } + with mock_gemini_provider() as mock_genai: + provider = GeminiProvider(api_key="test-api-key") + await provider._acompletion( + CompletionParams(model_id="gemini-pro", messages=messages, tools=[function_tool, {"url_context": {}}]), + ) + _, call_kwargs = mock_genai.return_value.aio.models.generate_content.call_args + tools = call_kwargs["config"].tools + assert len(tools) == 2 + assert any(t.url_context is not None for t in tools) + assert any(t.function_declarations for t in tools) + + +@pytest.mark.parametrize("bad_tool", [{"type": "web_search"}, {"nonsense_tool": {}}]) +@pytest.mark.asyncio +async def test_gemini_unknown_tool_dict_raises(bad_tool: dict[str, Any]) -> None: + """A dict that is neither function format nor a valid native tool must raise, not vanish.""" + messages = [{"role": "user", "content": "Hello"}] + with mock_gemini_provider(): + provider = GeminiProvider(api_key="test-api-key") + with pytest.raises(InvalidRequestError, match="Unsupported tool"): + await provider._acompletion( + CompletionParams(model_id="gemini-pro", messages=messages, tools=[bad_tool]), + ) + + @pytest.mark.asyncio async def test_streaming_completion_includes_usage_data() -> None: """Test that streaming chunks include usage metadata when available.""" From b53a7b898ddbb000e7c9d87e843db51ef2348e6f Mon Sep 17 00:00:00 2001 From: Sina Azizi Date: Mon, 17 Aug 2026 13:09:54 -0700 Subject: [PATCH 2/3] chore(gemini): shorten native-tool-dict comment to one line --- src/any_llm/providers/gemini/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index c7834231f..10229d267 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -73,9 +73,7 @@ def _convert_tool_spec(tools: list[dict[str, Any] | Any], provider_name: str) -> continue if tool.get("type") != "function": - # No openai "function" wrapper: treat it as a gemini-native tool dict (e.g. - # {"google_search": {}}) and let the SDK schema validate it, instead of silently - # dropping it from the request. + # not openai function format: validate as a gemini-native tool dict, e.g. {"google_search": {}} try: converted_tools.append(types.Tool.model_validate(tool)) except ValidationError as exc: From c10c43f5ec57673ebe6d1017986b4ff4fd59fc8a Mon Sep 17 00:00:00 2001 From: Sina Azizi Date: Mon, 17 Aug 2026 13:23:00 -0700 Subject: [PATCH 3/3] test(gemini): pin provider context in unsupported-tool error message --- tests/unit/providers/test_gemini_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index 2b7e20f69..17467fd23 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -1183,7 +1183,7 @@ async def test_gemini_unknown_tool_dict_raises(bad_tool: dict[str, Any]) -> None messages = [{"role": "user", "content": "Hello"}] with mock_gemini_provider(): provider = GeminiProvider(api_key="test-api-key") - with pytest.raises(InvalidRequestError, match="Unsupported tool"): + with pytest.raises(InvalidRequestError, match=r"\[gemini\] Unsupported tool"): await provider._acompletion( CompletionParams(model_id="gemini-pro", messages=messages, tools=[bad_tool]), )