mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
fix(completion): accept a tool_choice that forces an OpenAI hosted tool (#50578)
* fix(completion): accept a tool_choice that forces an OpenAI hosted tool
litellm.completion rejected tool_choice={"type": "web_search"} before any request, while /v1/responses took the same choice. The chat validator now forwards a hosted tool type unchanged and a provider gate turns it into a 400 off openai, azure and litellm_proxy, where the choice would otherwise be dropped or mangled.
Fixes #50566
* fix(utils): keep a non-string tool_choice type a 400, not a TypeError
A list or dict in tool_choice["type"] is unhashable, so the hosted tool lookup needs the string check first. The bridge test that used file_search as an untranslatable choice now uses a type that really is unknown.
* 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.
* Revert "refactor(completion): forward any Responses API tool_choice type instead of keeping a hosted tool allowlist"
This reverts commit 5a6d5a8e08.
* fix(utils): add tool_search to the hosted tool_choice types
OpenAI lists it among the supported tool_choice.type values (live error on 2026-11-08), alongside the ones already in the set.
---------
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
07c7229895
commit
7e6479b3a0
8 changed files with 256 additions and 5 deletions
|
|
@ -8,6 +8,24 @@ from litellm.exceptions import BadRequestError
|
|||
|
||||
ALLOWED_TOOLS_TOOL_CHOICE_PROVIDERS: Final = frozenset({"openai", "azure", "litellm_proxy"})
|
||||
|
||||
HOSTED_TOOL_CHOICE_TYPES: Final = frozenset(
|
||||
{
|
||||
"file_search",
|
||||
"tool_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_ALLOWED_TOOLS_LAYOUT: Final = (
|
||||
'{"type": "allowed_tools", "allowed_tools": {"mode": "auto" | "required", "tools": [<at least one tool>]}}'
|
||||
)
|
||||
|
|
@ -70,6 +88,37 @@ 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."""
|
||||
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
|
||||
|
||||
|
||||
def raise_if_hosted_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:
|
||||
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."
|
||||
),
|
||||
model=model,
|
||||
llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
|
||||
def parse_chat_allowed_tools_tool_choice(tool_choice: object) -> ChatAllowedToolsToolChoice | None:
|
||||
try:
|
||||
return _CHAT_ALLOWED_TOOLS_TOOL_CHOICE.validate_python(tool_choice)
|
||||
|
|
|
|||
|
|
@ -76,6 +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,
|
||||
)
|
||||
from litellm.litellm_core_utils.asyncify import asyncify, run_async_function
|
||||
from litellm.litellm_core_utils.audio_utils.utils import (
|
||||
|
|
@ -5915,6 +5916,13 @@ def completion(
|
|||
custom_llm_provider=custom_llm_provider,
|
||||
model=model,
|
||||
)
|
||||
raise_if_hosted_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
|
||||
),
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
model=model,
|
||||
)
|
||||
|
||||
if provider_specific_header is not None:
|
||||
headers.update(
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ 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.attempt_settlement import mark_attempt_answered
|
||||
|
|
@ -9761,7 +9762,14 @@ def validate_chat_completion_tool_choice(
|
|||
)
|
||||
|
||||
# Standard OpenAI format: {"type": "function", "function": {...}}
|
||||
if tool_choice_type != "allowed_tools" and (tool_choice_type is None or tool_choice.get("function") is None):
|
||||
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)
|
||||
):
|
||||
raise BadRequestError(
|
||||
message=f"Invalid tool choice, tool_choice={tool_choice}. Please ensure tool_choice follows the OpenAI spec",
|
||||
model=model,
|
||||
|
|
|
|||
60
tests/integration/providers/test_hosted_tool_choice_wire.py
Normal file
60
tests/integration/providers/test_hosted_tool_choice_wire.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""A chat completions ``tool_choice`` that forces an OpenAI hosted tool, such as ``{"type": "web_search"}``, reaches the
|
||||
Responses API unchanged, and is a 400 before any provider call where the provider cannot enforce it."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support.client import JSON_OBJECT, Gateway, list_value
|
||||
from integration._support.openai_wire import responses_reply
|
||||
from integration._support.provider import SharedProvider
|
||||
from pydantic import JsonValue
|
||||
|
||||
_RESPONSES_MODEL: Final = "openai/responses/gpt-5.6-sol"
|
||||
_ANTHROPIC_MODEL: Final = "anthropic/claude-haiku-4-5"
|
||||
_WEATHER_TOOL: Final[dict[str, JsonValue]] = {
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}},
|
||||
}
|
||||
|
||||
|
||||
def _choice_id(tool_choice: dict[str, JsonValue]) -> str:
|
||||
return str(tool_choice["type"])
|
||||
|
||||
|
||||
def _chat_request(model: str, tool_choice: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "What is the weather in Paris right now?"}],
|
||||
"tools": [_WEATHER_TOOL, {"type": tool_choice["type"]}],
|
||||
"tool_choice": tool_choice,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_choice",
|
||||
[{"type": "web_search"}, {"type": "web_search_preview"}, {"type": "file_search"}],
|
||||
ids=_choice_id,
|
||||
)
|
||||
def test_hosted_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))
|
||||
response: Final = gateway.request("POST", "/v1/chat/completions", _chat_request(_RESPONSES_MODEL, tool_choice))
|
||||
assert response.status_code == 200, response.text
|
||||
assert "It is 18C in Paris." in response.text, response.text
|
||||
received: Final = provider.received()
|
||||
assert [(request.method, request.target) for request in received] == [("POST", "/responses")]
|
||||
sent: Final = JSON_OBJECT.validate_json(received[0].body)
|
||||
assert sent["tool_choice"] == tool_choice
|
||||
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(
|
||||
gateway: Gateway, provider: SharedProvider
|
||||
) -> None:
|
||||
response: Final = gateway.request(
|
||||
"POST", "/v1/chat/completions", _chat_request(_ANTHROPIC_MODEL, {"type": "web_search"})
|
||||
)
|
||||
assert response.status_code == 400, response.text
|
||||
assert "web_search" in response.text and "only supported for" in response.text, response.text
|
||||
assert provider.received() == ()
|
||||
|
|
@ -4,10 +4,13 @@ from typing import Final
|
|||
|
||||
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,
|
||||
responses_allowed_tools_tool_choice,
|
||||
)
|
||||
|
||||
|
|
@ -98,3 +101,42 @@ def test_parse_rejects_anything_but_the_chat_layout(tool_choice: dict[str, objec
|
|||
)
|
||||
def test_is_allowed_tools_tool_choice(tool_choice: Mapping[str, object] | str | None, expected: bool):
|
||||
assert is_allowed_tools_tool_choice(tool_choice) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_choice", "expected"),
|
||||
[
|
||||
({"type": "web_search"}, "web_search"),
|
||||
({"type": "mcp", "server_label": "docs"}, "mcp"),
|
||||
({"type": "function", "function": {"name": "get_time"}}, None),
|
||||
({"type": "allowed_tools"}, 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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", ["openai", "azure", "litellm_proxy"])
|
||||
def test_hosted_tool_choice_passes_where_the_responses_api_runs_the_tool(provider: str):
|
||||
assert (
|
||||
raise_if_hosted_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")
|
||||
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():
|
||||
assert (
|
||||
raise_if_hosted_tool_choice_unsupported(
|
||||
{"type": "function", "function": {"name": "get_time"}}, custom_llm_provider="anthropic", model="m"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5949,12 +5949,12 @@ async def test_bridge_rejects_untranslatable_tool_choice_with_a_400(stream: bool
|
|||
model="anthropic/claude-haiku-4-5",
|
||||
input="Which fruit is red?",
|
||||
tools=[{"type": "function", "name": "lookup_fruit", "parameters": {"type": "object"}}],
|
||||
tool_choice={"type": "file_search"},
|
||||
tool_choice={"type": "web_search_tool"},
|
||||
stream=stream,
|
||||
api_key="sk-unused",
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "tool_choice={'type': 'file_search'}" in str(exc_info.value)
|
||||
assert "tool_choice={'type': 'web_search_tool'}" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_cache_control_on_user_message():
|
||||
|
|
|
|||
|
|
@ -610,6 +610,69 @@ def test_completion_allowed_tools_tool_choice_is_a_400_where_the_provider_cannot
|
|||
assert not respx_mock.calls
|
||||
|
||||
|
||||
_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):
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/responses").respond(
|
||||
json={
|
||||
"id": "resp_hosted",
|
||||
"object": "response",
|
||||
"created_at": 1,
|
||||
"status": "completed",
|
||||
"model": "gpt-5.6",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_hosted",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "18C and foggy", "annotations": []}],
|
||||
}
|
||||
],
|
||||
"usage": {"input_tokens": 40, "output_tokens": 5, "total_tokens": 45},
|
||||
}
|
||||
)
|
||||
|
||||
response: Final = litellm.completion(
|
||||
model="openai/responses/gpt-5.6",
|
||||
api_key="sk-test",
|
||||
api_base="https://api.openai.com/v1",
|
||||
messages=[{"role": "user", "content": "What is the weather in Paris right now?"}],
|
||||
tools=_HOSTED_TOOLS,
|
||||
tool_choice=_HOSTED_TOOL_CHOICE,
|
||||
)
|
||||
|
||||
sent: Final = json.loads(route.calls.last.request.content)
|
||||
assert sent["tool_choice"] == _HOSTED_TOOL_CHOICE
|
||||
assert {"type": "web_search"} in sent["tools"]
|
||||
assert response.choices[0].message.content == "18C and foggy"
|
||||
|
||||
|
||||
@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(
|
||||
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"
|
||||
) as exc:
|
||||
litellm.completion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "What is the weather in Paris right now?"}],
|
||||
tools=_TWO_TOOLS,
|
||||
tool_choice=_HOSTED_TOOL_CHOICE,
|
||||
drop_params=drop_params,
|
||||
api_key="fake_api_key",
|
||||
aws_access_key_id="fake",
|
||||
aws_secret_access_key="fake",
|
||||
aws_region_name="us-east-1",
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert not respx_mock.calls
|
||||
|
||||
|
||||
_CLAUDE_TOOL_SEARCH_CALL: Final = {
|
||||
"id": "srvtoolu_01search",
|
||||
"type": "function",
|
||||
|
|
@ -6390,7 +6453,9 @@ def test_aiohttp_openai_warns_only_when_http2_enabled(
|
|||
assert warned is http2_on
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_choice", [{"type": "bogus"}, {"name": "lookup_fruit"}, {"type": "file_search"}])
|
||||
@pytest.mark.parametrize(
|
||||
"tool_choice", [{"type": "bogus"}, {"name": "lookup_fruit"}, {"type": "function", "name": "lookup_fruit"}]
|
||||
)
|
||||
def test_completion_rejects_untranslatable_tool_choice_with_a_400(tool_choice):
|
||||
with pytest.raises(litellm.BadRequestError) as exc_info:
|
||||
litellm.completion(
|
||||
|
|
|
|||
|
|
@ -8802,7 +8802,8 @@ def test_validate_tool_choice_cursor_format():
|
|||
{"type": "invalid"},
|
||||
{"type": "function"},
|
||||
{"name": "lookup_fruit"},
|
||||
{"type": "file_search"},
|
||||
{"type": "web_search_tool"},
|
||||
{"type": ["web_search"]},
|
||||
],
|
||||
)
|
||||
def test_validate_tool_choice_invalid_dict_is_a_400(tool_choice):
|
||||
|
|
@ -8816,6 +8817,24 @@ def test_validate_tool_choice_invalid_dict_is_a_400(tool_choice):
|
|||
assert exc_info.value.model == MODEL
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
|
||||
@pytest.mark.parametrize(
|
||||
"tool_choice",
|
||||
[
|
||||
{"type": "web_search"},
|
||||
{"type": "web_search_preview"},
|
||||
{"type": "file_search"},
|
||||
{"type": "tool_search"},
|
||||
{"type": "code_interpreter"},
|
||||
{"type": "mcp", "server_label": "docs", "name": "search"},
|
||||
],
|
||||
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."""
|
||||
assert validate_chat_completion_tool_choice(tool_choice, model=MODEL) is tool_choice
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
|
||||
@pytest.mark.parametrize("mode", ["auto", "required"])
|
||||
def test_validate_tool_choice_allowed_tools_chat_layout_passes_through(mode: str):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue