From f60e855aa1aaec035f9af96d2a6f72f4db616e13 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 10:14:07 +0000 Subject: [PATCH 1/5] fix(gemini): support tool_choice 'none' and named-function form tool_choice="none" hit a bare KeyError in _convert_tool_choice because the mode map only knew "auto" and "required", and the OpenAI named-function dict was skipped entirely by an isinstance(str) guard, so a forced function silently fell back to Gemini's default AUTO mode. Both values are produced by the library's own Messages bridge (_convert_tool_choice_to_openai maps Anthropic {"type": "none"} and {"type": "tool"}), so client.messages() against Gemini could not disable or force tool use. Map "none" to FunctionCallingConfigMode.NONE, translate the named-function dict to ANY plus allowed_function_names, and raise UnsupportedParameterError with the offending value for anything else. Co-authored-by: Tony Coder <407243179@qq.com> --- src/any_llm/providers/gemini/base.py | 4 +- src/any_llm/providers/gemini/utils.py | 25 +++++++-- tests/unit/providers/test_gemini_provider.py | 56 ++++++++++++++++++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/any_llm/providers/gemini/base.py b/src/any_llm/providers/gemini/base.py index 7a0904efb..7d49f0892 100644 --- a/src/any_llm/providers/gemini/base.py +++ b/src/any_llm/providers/gemini/base.py @@ -165,8 +165,8 @@ def _convert_completion_params(params: CompletionParams, **kwargs: Any) -> dict[ kwargs["temperature"] = params.temperature if params.tools is not None: 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.tool_choice is not None: + kwargs["tool_config"] = _convert_tool_choice(params.tool_choice, provider_name) if params.top_p is not None: kwargs["top_p"] = params.top_p if params.stop is not None: diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index 0668b978d..9d9acf0a9 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -10,7 +10,7 @@ from google.genai.pagers import Pager from pydantic import ValidationError -from any_llm.exceptions import InvalidRequestError +from any_llm.exceptions import InvalidRequestError, UnsupportedParameterError from any_llm.logging import logger from any_llm.types.batch import Batch, BatchRequestCounts, BatchResult, BatchResultError, BatchResultItem from any_llm.types.completion import ( @@ -128,13 +128,32 @@ def _convert_tool_spec(tools: list[dict[str, Any] | Any], provider_name: str) -> return converted_tools -def _convert_tool_choice(tool_choice: str) -> types.ToolConfig: +def _convert_tool_choice(tool_choice: str | dict[str, Any], provider_name: str) -> types.ToolConfig: + error_message = "tool_choice" + additional_message = f"Unsupported tool_choice: {tool_choice}" + + if isinstance(tool_choice, dict): + function = tool_choice.get("function") if tool_choice.get("type") == "function" else None + name = function.get("name") if isinstance(function, dict) else None + if not name: + raise UnsupportedParameterError(error_message, provider_name, additional_message) + return types.ToolConfig( + function_calling_config=types.FunctionCallingConfig( + mode=types.FunctionCallingConfigMode.ANY, + allowed_function_names=[name], + ) + ) + tool_choice_to_mode = { "required": types.FunctionCallingConfigMode.ANY, "auto": types.FunctionCallingConfigMode.AUTO, + "none": types.FunctionCallingConfigMode.NONE, } + mode = tool_choice_to_mode.get(tool_choice) + if mode is None: + raise UnsupportedParameterError(error_message, provider_name, additional_message) - return types.ToolConfig(function_calling_config=types.FunctionCallingConfig(mode=tool_choice_to_mode[tool_choice])) + return types.ToolConfig(function_calling_config=types.FunctionCallingConfig(mode=mode)) def _parse_data_uri(data_uri: str, field_name: str, provider_name: str) -> tuple[str, bytes]: diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index feaf07184..7e1c5f450 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -301,6 +301,62 @@ async def test_completion_with_tool_choice_auto(tool_choice: str, expected_mode: assert generation_config.tool_config.function_calling_config.mode.value == expected_mode +@pytest.mark.asyncio +async def test_completion_with_tool_choice_none_disables_function_calling() -> None: + """tool_choice='none' must map to Gemini's NONE mode instead of raising.""" + 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, tool_choice="none"), + ) + + _, call_kwargs = mock_genai.return_value.aio.models.generate_content.call_args + generation_config = call_kwargs["config"] + + assert generation_config.tool_config.function_calling_config.mode.value == "NONE" + + +@pytest.mark.asyncio +async def test_completion_with_named_function_tool_choice() -> None: + """The OpenAI named-function tool_choice must force that function via allowed_function_names.""" + 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, + tool_choice={"type": "function", "function": {"name": "get_weather"}}, + ), + ) + + _, call_kwargs = mock_genai.return_value.aio.models.generate_content.call_args + function_calling_config = call_kwargs["config"].tool_config.function_calling_config + + assert function_calling_config.mode.value == "ANY" + assert function_calling_config.allowed_function_names == ["get_weather"] + + +@pytest.mark.parametrize( + "tool_choice", + ["sometimes", {"type": "custom", "custom": {"name": "get_weather"}}, {"type": "function"}], +) +@pytest.mark.asyncio +async def test_completion_with_unsupported_tool_choice_raises(tool_choice: str | dict[str, Any]) -> None: + """Unrecognized tool_choice values report the offending value rather than leaking a KeyError.""" + messages = [{"role": "user", "content": "Hello"}] + + with mock_gemini_provider(): + provider = GeminiProvider(api_key="test-api-key") + with pytest.raises(UnsupportedParameterError, match="tool_choice"): + await provider._acompletion( + CompletionParams(model_id="gemini-pro", messages=messages, tool_choice=tool_choice), + ) + + @pytest.mark.asyncio async def test_completion_without_tool_choice() -> None: """Test that completion works correctly without tool_choice.""" From 3e0ad7d35757ba27b17d1b190497ba3adb03c415 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 11:25:45 +0000 Subject: [PATCH 2/5] fix(gemini): require a non-empty string tool_choice function name A truthy non-string name such as {"type": "function", "function": {"name": 1}} skipped the guard and reached types.FunctionCallingConfig, surfacing a raw pydantic ValidationError instead of UnsupportedParameterError. Co-authored-by: Tony Coder <407243179@qq.com> --- src/any_llm/providers/gemini/utils.py | 2 +- tests/unit/providers/test_gemini_provider.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index 9d9acf0a9..ff4e4f9b8 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -135,7 +135,7 @@ def _convert_tool_choice(tool_choice: str | dict[str, Any], provider_name: str) if isinstance(tool_choice, dict): function = tool_choice.get("function") if tool_choice.get("type") == "function" else None name = function.get("name") if isinstance(function, dict) else None - if not name: + if not isinstance(name, str) or not name: raise UnsupportedParameterError(error_message, provider_name, additional_message) return types.ToolConfig( function_calling_config=types.FunctionCallingConfig( diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index 7e1c5f450..b4215799e 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -342,7 +342,12 @@ async def test_completion_with_named_function_tool_choice() -> None: @pytest.mark.parametrize( "tool_choice", - ["sometimes", {"type": "custom", "custom": {"name": "get_weather"}}, {"type": "function"}], + [ + "sometimes", + {"type": "custom", "custom": {"name": "get_weather"}}, + {"type": "function"}, + {"type": "function", "function": {"name": 1}}, + ], ) @pytest.mark.asyncio async def test_completion_with_unsupported_tool_choice_raises(tool_choice: str | dict[str, Any]) -> None: From 56626f31aed7dce38ff3707aaeba7cae0c4c8959 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 19:04:40 +0000 Subject: [PATCH 3/5] fix(gemini): map the allowed_tools tool_choice form OpenAI's allowed_tools choice with mode="required" is expressible on Gemini as ANY mode with every listed name in allowed_function_names, so raising for it turned a supported request into an error. mode="auto" stays rejected because Gemini honors allowed_function_names only in ANY mode. Names are validated as before: reject unless every entry is a non-empty string. Co-authored-by: Tony Coder <407243179@qq.com> --- src/any_llm/providers/gemini/utils.py | 16 ++++-- tests/unit/providers/test_gemini_provider.py | 51 ++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index ff4e4f9b8..34ddc36b0 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -133,14 +133,22 @@ def _convert_tool_choice(tool_choice: str | dict[str, Any], provider_name: str) additional_message = f"Unsupported tool_choice: {tool_choice}" if isinstance(tool_choice, dict): - function = tool_choice.get("function") if tool_choice.get("type") == "function" else None - name = function.get("name") if isinstance(function, dict) else None - if not isinstance(name, str) or not name: + if tool_choice.get("type") == "allowed_tools": + allowed = tool_choice.get("allowed_tools") + # Gemini only honors allowed_function_names in ANY mode, so mode="auto" has no equivalent. + if not isinstance(allowed, dict) or allowed.get("mode") != "required": + raise UnsupportedParameterError(error_message, provider_name, additional_message) + functions = [tool.get("function") for tool in allowed.get("tools", []) if isinstance(tool, dict)] + else: + functions = [tool_choice.get("function")] if tool_choice.get("type") == "function" else [] + raw_names = [function.get("name") if isinstance(function, dict) else None for function in functions] + names = [name for name in raw_names if isinstance(name, str) and name] + if not names or len(names) != len(raw_names): raise UnsupportedParameterError(error_message, provider_name, additional_message) return types.ToolConfig( function_calling_config=types.FunctionCallingConfig( mode=types.FunctionCallingConfigMode.ANY, - allowed_function_names=[name], + allowed_function_names=names, ) ) diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index b4215799e..14024924f 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -340,6 +340,37 @@ async def test_completion_with_named_function_tool_choice() -> None: assert function_calling_config.allowed_function_names == ["get_weather"] +@pytest.mark.asyncio +async def test_completion_with_allowed_tools_tool_choice() -> None: + """A required allowed_tools choice must forward every listed name in ANY mode.""" + 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, + tool_choice={ + "type": "allowed_tools", + "allowed_tools": { + "mode": "required", + "tools": [ + {"type": "function", "function": {"name": "get_weather"}}, + {"type": "function", "function": {"name": "get_time"}}, + ], + }, + }, + ), + ) + + _, call_kwargs = mock_genai.return_value.aio.models.generate_content.call_args + function_calling_config = call_kwargs["config"].tool_config.function_calling_config + + assert function_calling_config.mode.value == "ANY" + assert function_calling_config.allowed_function_names == ["get_weather", "get_time"] + + @pytest.mark.parametrize( "tool_choice", [ @@ -347,6 +378,26 @@ async def test_completion_with_named_function_tool_choice() -> None: {"type": "custom", "custom": {"name": "get_weather"}}, {"type": "function"}, {"type": "function", "function": {"name": 1}}, + # Gemini honors allowed_function_names only in ANY mode, so "auto" cannot be expressed. + { + "type": "allowed_tools", + "allowed_tools": {"mode": "auto", "tools": [{"type": "function", "function": {"name": "get_weather"}}]}, + }, + {"type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": []}}, + { + "type": "allowed_tools", + "allowed_tools": { + "mode": "required", + "tools": [ + {"type": "function", "function": {"name": "get_weather"}}, + {"type": "function", "function": {"name": ""}}, + ], + }, + }, + { + "type": "allowed_tools", + "allowed_tools": {"mode": "required", "tools": [{"type": "function", "function": {"name": 1}}]}, + }, ], ) @pytest.mark.asyncio From 78d7d7d2e9dcee972033baed535fd5fcaaabd618 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 19:15:07 +0000 Subject: [PATCH 4/5] fix(gemini): validate allowed_tools tools as a list of function entries allowed.get("tools", []) returned the stored value whenever the key was present, so tools=None or tools=1 raised TypeError from the comprehension instead of UnsupportedParameterError. Non-dict entries were also filtered out, letting a mixed list through with a silently narrowed tool set. Require a list and keep every entry so an unusable one fails the existing name check. Co-authored-by: Tony Coder <407243179@qq.com> --- src/any_llm/providers/gemini/utils.py | 7 ++++++- tests/unit/providers/test_gemini_provider.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index 34ddc36b0..1b64d76ac 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -138,7 +138,12 @@ def _convert_tool_choice(tool_choice: str | dict[str, Any], provider_name: str) # Gemini only honors allowed_function_names in ANY mode, so mode="auto" has no equivalent. if not isinstance(allowed, dict) or allowed.get("mode") != "required": raise UnsupportedParameterError(error_message, provider_name, additional_message) - functions = [tool.get("function") for tool in allowed.get("tools", []) if isinstance(tool, dict)] + allowed_tools = allowed.get("tools") + if not isinstance(allowed_tools, list): + raise UnsupportedParameterError(error_message, provider_name, additional_message) + # Every entry is kept so that an unusable one fails the name check below rather than + # being dropped, which would silently narrow the set of tools the caller asked for. + functions = [tool.get("function") if isinstance(tool, dict) else None for tool in allowed_tools] else: functions = [tool_choice.get("function")] if tool_choice.get("type") == "function" else [] raw_names = [function.get("name") if isinstance(function, dict) else None for function in functions] diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index 14024924f..9dd5c0bcf 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -384,6 +384,16 @@ async def test_completion_with_allowed_tools_tool_choice() -> None: "allowed_tools": {"mode": "auto", "tools": [{"type": "function", "function": {"name": "get_weather"}}]}, }, {"type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": []}}, + {"type": "allowed_tools", "allowed_tools": {"mode": "required"}}, + {"type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": None}}, + {"type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": 1}}, + { + "type": "allowed_tools", + "allowed_tools": { + "mode": "required", + "tools": [{"type": "function", "function": {"name": "get_weather"}}, "get_time"], + }, + }, { "type": "allowed_tools", "allowed_tools": { From c15d90a8445596f0064a9c662ef2b686d751f02d Mon Sep 17 00:00:00 2001 From: Sina Azizi Date: Sat, 29 Aug 2026 11:54:02 -0700 Subject: [PATCH 5/5] fix(gemini): reject allowed_tools entries that are not function tools --- src/any_llm/providers/gemini/utils.py | 5 ++++- tests/unit/providers/test_gemini_provider.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index 1b64d76ac..2e3706af3 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -143,7 +143,10 @@ def _convert_tool_choice(tool_choice: str | dict[str, Any], provider_name: str) raise UnsupportedParameterError(error_message, provider_name, additional_message) # Every entry is kept so that an unusable one fails the name check below rather than # being dropped, which would silently narrow the set of tools the caller asked for. - functions = [tool.get("function") if isinstance(tool, dict) else None for tool in allowed_tools] + functions = [ + tool.get("function") if isinstance(tool, dict) and tool.get("type") == "function" else None + for tool in allowed_tools + ] else: functions = [tool_choice.get("function")] if tool_choice.get("type") == "function" else [] raw_names = [function.get("name") if isinstance(function, dict) else None for function in functions] diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index 9dd5c0bcf..bb4ab94e0 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -408,6 +408,10 @@ async def test_completion_with_allowed_tools_tool_choice() -> None: "type": "allowed_tools", "allowed_tools": {"mode": "required", "tools": [{"type": "function", "function": {"name": 1}}]}, }, + { + "type": "allowed_tools", + "allowed_tools": {"mode": "required", "tools": [{"type": "custom", "function": {"name": "get_weather"}}]}, + }, ], ) @pytest.mark.asyncio