mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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:
parent
54dd6668d0
commit
5a6d5a8e08
7 changed files with 49 additions and 63 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue