diff --git a/litellm/litellm_core_utils/allowed_tools_tool_choice.py b/litellm/litellm_core_utils/allowed_tools_tool_choice.py index cdc24481680..9a5aea8788e 100644 --- a/litellm/litellm_core_utils/allowed_tools_tool_choice.py +++ b/litellm/litellm_core_utils/allowed_tools_tool_choice.py @@ -8,22 +8,7 @@ from litellm.exceptions import BadRequestError ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS: Final = frozenset({"openai", "azure", "litellm_proxy"}) -HOSTED_TOOL_CHOICE_TYPES: Final = frozenset( - { - "file_search", - "web_search", - "web_search_preview", - "web_search_preview_2025_03_11", - "computer", - "computer_use", - "computer_use_preview", - "code_interpreter", - "image_generation", - "mcp", - "shell", - "apply_patch", - } -) +CHAT_TOOL_CHOICE_TYPES: Final = frozenset({"function", "allowed_tools", "auto", "none", "required"}) CHAT_ALLOWED_TOOLS_LAYOUT: Final = ( '{"type": "allowed_tools", "allowed_tools": {"mode": "auto" | "required", "tools": []}}' @@ -87,31 +72,33 @@ def raise_if_allowed_tools_tool_choice_unsupported( ) -def hosted_tool_choice_type(tool_choice: object) -> str | None: - """The hosted tool a Responses API ``tool_choice`` forces, such as ``web_search``, or ``None`` for any other choice.""" +def responses_only_tool_choice_type(tool_choice: object) -> str | None: + """The ``type`` of a ``tool_choice`` dict chat completions has no shape for, such as ``web_search`` or ``mcp``, + which only the Responses API accepts, or ``None`` for a chat tool choice.""" try: choice_type: Final = _TOOL_CHOICE_TYPE.validate_python(tool_choice).type except ValidationError: return None - return choice_type if choice_type in HOSTED_TOOL_CHOICE_TYPES else None + return None if choice_type in CHAT_TOOL_CHOICE_TYPES else choice_type -def raise_if_hosted_tool_choice_unsupported( +def raise_if_responses_only_tool_choice_unsupported( tool_choice: object, custom_llm_provider: str, model: str, ) -> None: - """Anthropic drops a tool_choice dict it does not recognise and Bedrock sends a tool named "", so a forced hosted - tool stops here with a 400 on providers that cannot run it. ``drop_params`` is deliberately not consulted: the - request required the tool, and dropping the choice would let the model answer without it.""" - hosted: Final = hosted_tool_choice_type(tool_choice) - if hosted is None or custom_llm_provider in ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS: + """Anthropic drops a tool_choice dict it does not recognise and Bedrock sends a tool named "", so a choice only the + Responses API accepts stops here with a 400 on providers without a Responses bridge. ``drop_params`` is deliberately + not consulted: the request required the tool, and dropping the choice would let the model answer without it.""" + choice_type: Final = responses_only_tool_choice_type(tool_choice) + if choice_type is None or custom_llm_provider in ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS: return raise BadRequestError( message=( - f"tool_choice type '{hosted}' forces an OpenAI hosted tool and is only supported for " - f"{', '.join(sorted(ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS))}, got custom_llm_provider={custom_llm_provider}. " - "drop_params does not drop it, since that would let the model answer without the tool the request required." + f"tool_choice={tool_choice} has type '{choice_type}', which only the Responses API accepts (a hosted tool " + f"such as web_search or mcp), so it is only supported for {', '.join(sorted(ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS))}, " + f"got custom_llm_provider={custom_llm_provider}. drop_params does not drop it, since that would let the model " + "answer without the tool the request required." ), model=model, llm_provider=custom_llm_provider, diff --git a/litellm/main.py b/litellm/main.py index febf9503a7e..75f7d9f8f6a 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -76,7 +76,7 @@ from litellm.exceptions import LiteLLMUnknownProvider from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.allowed_tools_tool_choice import ( raise_if_allowed_tools_tool_choice_unsupported, - raise_if_hosted_tool_choice_unsupported, + raise_if_responses_only_tool_choice_unsupported, ) from litellm.litellm_core_utils.asyncify import asyncify, run_async_function from litellm.litellm_core_utils.audio_utils.utils import ( @@ -5916,7 +5916,7 @@ def completion( custom_llm_provider=custom_llm_provider, model=model, ) - raise_if_hosted_tool_choice_unsupported( + raise_if_responses_only_tool_choice_unsupported( tool_choice=cast( # cast-ok: widening an untyped dict to object is always sound, the helper validates it with pydantic object, tool_choice ), diff --git a/litellm/utils.py b/litellm/utils.py index 23a21cfc856..bf6ae23e88f 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -86,7 +86,6 @@ from litellm.constants import ( from litellm.litellm_core_utils.agentic_followup_failure import is_agentic_followup_failure from litellm.litellm_core_utils.allowed_tools_tool_choice import ( CHAT_ALLOWED_TOOLS_LAYOUT, - HOSTED_TOOL_CHOICE_TYPES, parse_chat_allowed_tools_tool_choice, ) from litellm.litellm_core_utils.core_helpers import ( @@ -9757,14 +9756,9 @@ def validate_chat_completion_tool_choice( llm_provider="", ) - # Standard OpenAI format: {"type": "function", "function": {...}} - forces_hosted_tool: Final[bool] = ( - isinstance(tool_choice_type, str) and tool_choice_type in HOSTED_TOOL_CHOICE_TYPES - ) - if ( - tool_choice_type != "allowed_tools" - and not forces_hosted_tool - and (tool_choice_type is None or tool_choice.get("function") is None) + # any other string type (web_search, mcp, a tool OpenAI ships tomorrow) is forwarded as is; completion() gates it by provider + if not isinstance(tool_choice_type, str) or ( + tool_choice_type == "function" and tool_choice.get("function") is None ): raise BadRequestError( message=f"Invalid tool choice, tool_choice={tool_choice}. Please ensure tool_choice follows the OpenAI spec", diff --git a/tests/integration/providers/test_hosted_tool_choice_wire.py b/tests/integration/providers/test_responses_only_tool_choice_wire.py similarity index 93% rename from tests/integration/providers/test_hosted_tool_choice_wire.py rename to tests/integration/providers/test_responses_only_tool_choice_wire.py index 8060b8f9fdd..bc975c34f41 100644 --- a/tests/integration/providers/test_hosted_tool_choice_wire.py +++ b/tests/integration/providers/test_responses_only_tool_choice_wire.py @@ -35,7 +35,7 @@ def _chat_request(model: str, tool_choice: dict[str, JsonValue]) -> dict[str, Js [{"type": "web_search"}, {"type": "web_search_preview"}, {"type": "file_search"}], ids=_choice_id, ) -def test_hosted_tool_choice_reaches_the_responses_api_unchanged( +def test_responses_only_tool_choice_reaches_the_responses_api_unchanged( gateway: Gateway, provider: SharedProvider, tool_choice: dict[str, JsonValue] ) -> None: provider.expect(responses_reply("resp_hosted_tool_choice", "gpt-5.6-sol", "It is 18C in Paris.", stream=False)) @@ -49,7 +49,7 @@ def test_hosted_tool_choice_reaches_the_responses_api_unchanged( assert {"type": tool_choice["type"]} in list_value(sent["tools"]), sent["tools"] -def test_hosted_tool_choice_is_a_400_before_any_call_where_the_provider_cannot_enforce_it( +def test_responses_only_tool_choice_is_a_400_before_any_call_where_the_provider_cannot_enforce_it( gateway: Gateway, provider: SharedProvider ) -> None: response: Final = gateway.request( diff --git a/tests/unit/litellm_core_utils/test_allowed_tools_tool_choice.py b/tests/unit/litellm_core_utils/test_allowed_tools_tool_choice.py index d020c7fcd85..4c02aa060a0 100644 --- a/tests/unit/litellm_core_utils/test_allowed_tools_tool_choice.py +++ b/tests/unit/litellm_core_utils/test_allowed_tools_tool_choice.py @@ -7,11 +7,11 @@ import pytest from litellm.exceptions import BadRequestError from litellm.litellm_core_utils.allowed_tools_tool_choice import ( ChatAllowedToolsToolChoice, - hosted_tool_choice_type, is_allowed_tools_tool_choice, parse_chat_allowed_tools_tool_choice, - raise_if_hosted_tool_choice_unsupported, + raise_if_responses_only_tool_choice_unsupported, responses_allowed_tools_tool_choice, + responses_only_tool_choice_type, ) _CHAT_LAYOUT: Final[dict[str, object]] = { @@ -108,34 +108,39 @@ def test_is_allowed_tools_tool_choice(tool_choice: Mapping[str, object] | str | [ ({"type": "web_search"}, "web_search"), ({"type": "mcp", "server_label": "docs"}, "mcp"), + ({"type": "a_tool_openai_ships_next_month"}, "a_tool_openai_ships_next_month"), ({"type": "function", "function": {"name": "get_time"}}, None), ({"type": "allowed_tools"}, None), + ({"type": "auto"}, None), ("required", None), (None, None), ], ) -def test_hosted_tool_choice_type(tool_choice: Mapping[str, object] | str | None, expected: str | None): - assert hosted_tool_choice_type(tool_choice) == expected +def test_responses_only_tool_choice_type(tool_choice: Mapping[str, object] | str | None, expected: str | None): + assert responses_only_tool_choice_type(tool_choice) == expected @pytest.mark.parametrize("provider", ["openai", "azure", "litellm_proxy"]) -def test_hosted_tool_choice_passes_where_the_responses_api_runs_the_tool(provider: str): +def test_responses_only_tool_choice_passes_where_a_responses_bridge_exists(provider: str): assert ( - raise_if_hosted_tool_choice_unsupported({"type": "web_search"}, custom_llm_provider=provider, model="m") is None + raise_if_responses_only_tool_choice_unsupported({"type": "web_search"}, custom_llm_provider=provider, model="m") + is None ) @pytest.mark.parametrize("provider", ["anthropic", "bedrock", "gemini", "vertex_ai"]) -def test_hosted_tool_choice_is_a_400_where_the_provider_cannot_run_it(provider: str): - with pytest.raises(BadRequestError, match=r"tool_choice type 'file_search'.*only supported for") as exc: - raise_if_hosted_tool_choice_unsupported({"type": "file_search"}, custom_llm_provider=provider, model="m") +def test_responses_only_tool_choice_is_a_400_where_the_provider_cannot_run_it(provider: str): + with pytest.raises(BadRequestError, match=r"has type 'file_search'.*only supported for") as exc: + raise_if_responses_only_tool_choice_unsupported( + {"type": "file_search"}, custom_llm_provider=provider, model="m" + ) assert exc.value.status_code == 400 assert f"custom_llm_provider={provider}" in str(exc.value) -def test_a_function_tool_choice_is_not_a_hosted_tool_anywhere(): +def test_a_function_tool_choice_is_a_chat_choice_anywhere(): assert ( - raise_if_hosted_tool_choice_unsupported( + raise_if_responses_only_tool_choice_unsupported( {"type": "function", "function": {"name": "get_time"}}, custom_llm_provider="anthropic", model="m" ) is None diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 0509b8dcf30..63e61b5314f 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -614,7 +614,7 @@ _HOSTED_TOOL_CHOICE: Final = {"type": "web_search"} _HOSTED_TOOLS: Final[list[dict[str, object]]] = [*_TWO_TOOLS, {"type": "web_search"}] -def test_completion_forwards_a_hosted_tool_choice_to_the_responses_api_unchanged(respx_mock: respx.MockRouter): +def test_completion_forwards_a_responses_only_tool_choice_to_the_responses_api_unchanged(respx_mock: respx.MockRouter): route: Final = respx_mock.post("https://api.openai.com/v1/responses").respond( json={ "id": "resp_hosted", @@ -652,11 +652,11 @@ def test_completion_forwards_a_hosted_tool_choice_to_the_responses_api_unchanged @pytest.mark.parametrize("drop_params", [False, True]) @pytest.mark.parametrize("model", ["anthropic/claude-sonnet-4-5", "gemini/gemini-2.5-flash", "bedrock/converse/x"]) -def test_completion_hosted_tool_choice_is_a_400_where_the_provider_cannot_run_it( +def test_completion_responses_only_tool_choice_is_a_400_where_the_provider_cannot_run_it( respx_mock: respx.MockRouter, model: str, drop_params: bool ): with pytest.raises( - litellm.BadRequestError, match=r"tool_choice type 'web_search'.*only supported for azure, litellm_proxy, openai" + litellm.BadRequestError, match=r"has type 'web_search'.*only supported for azure, litellm_proxy, openai" ) as exc: litellm.completion( model=model, diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 63cc2aa60d7..4d022045657 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -8806,11 +8806,10 @@ def test_validate_tool_choice_cursor_format(): "tool_choice", [ {}, - {"type": "invalid"}, {"type": "function"}, {"name": "lookup_fruit"}, - {"type": "web_search_tool"}, {"type": ["web_search"]}, + {"type": None}, ], ) def test_validate_tool_choice_invalid_dict_is_a_400(tool_choice): @@ -8829,15 +8828,15 @@ def test_validate_tool_choice_invalid_dict_is_a_400(tool_choice): "tool_choice", [ {"type": "web_search"}, - {"type": "web_search_preview"}, {"type": "file_search"}, - {"type": "code_interpreter"}, {"type": "mcp", "server_label": "docs", "name": "search"}, + {"type": "a_tool_openai_ships_next_month"}, ], ids=lambda choice: str(choice["type"]), ) -def test_validate_tool_choice_forcing_a_hosted_tool_passes_through(tool_choice: dict[str, object]): - """Forcing an OpenAI hosted tool is a Responses API choice the bridge sends as is, so validation leaves it alone.""" +def test_validate_tool_choice_with_a_type_chat_has_no_shape_for_passes_through(tool_choice: dict[str, object]): + """A dict with a string type chat completions does not know is a Responses API choice (a hosted tool, or one OpenAI + adds later) that the bridge sends as is, so validation leaves it alone and the provider gate decides.""" assert validate_chat_completion_tool_choice(tool_choice, model=MODEL) is tool_choice @@ -8890,7 +8889,7 @@ def test_validate_tool_choice_invalid_type_is_a_400(tool_choice): def test_validate_tool_choice_without_model_is_still_a_400(): """Callers that predate the model argument keep getting a 400, with an empty model on the error.""" with pytest.raises(litellm.BadRequestError, match="Invalid tool choice") as exc_info: - validate_chat_completion_tool_choice({"type": "bogus"}) + validate_chat_completion_tool_choice({"type": "function"}) assert exc_info.value.status_code == 400 assert exc_info.value.model == "" @@ -10219,7 +10218,8 @@ def test_validate_chat_completion_user_messages(messages, expected_bool): "tool_choice, expected_bool", [ ({"type": "function", "function": {"name": "get_current_weather"}}, True), - ({"type": "tool", "name": "get_current_weather"}, False), + ({"type": "function", "name": "get_current_weather"}, False), + ({"type": "web_search"}, True), (None, True), ("auto", True), ("required", True),