refactor(completion): forward any Responses API tool_choice type instead of keeping a hosted tool allowlist

Chat completions now accepts any tool_choice dict with a string type it has no shape for and sends it as is on openai, azure and litellm_proxy, where OpenAI validates the type itself (a typo is still a 400, from OpenAI). The provider gate keeps the 400 everywhere else. The HOSTED_TOOL_CHOICE_TYPES list is gone, so a new OpenAI hosted tool needs no change here.
This commit is contained in:
devin-ai-integration[bot] 2026-11-08 00:52:04 +00:00
parent 54dd6668d0
commit 5a6d5a8e08
7 changed files with 49 additions and 63 deletions

View file

@ -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": [<at least one tool>]}}'
@ -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,

View file

@ -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
),

View file

@ -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",

View file

@ -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(

View file

@ -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

View file

@ -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,

View file

@ -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),