fix(responses): unwrap object-form tool_choice before calling the Responses API

Clients send tool_choice as {"type": "auto"} (Cursor on chat completions,
Claude Code's Anthropic tool_choice shape). validate_chat_completion_tool_choice
recognized that shape but returned it verbatim, and the chat -> Responses API
bridge only normalized {"type": "function"}, so the wrapper reached OpenAI and
the whole call failed with:

  Invalid value: 'auto'. Supported values are: 'code_interpreter', ...,
  'web_search_preview', ... (param: tool_choice.type)

That broke every tool call, web search included, on responses-mode models.

Unwrap {"type": "auto"|"none"|"required"} to the bare string at both layers:
the chat completions validation boundary where the shape is first accepted,
and the Responses API bridge that owns the Responses tool_choice contract.
No OpenAI surface accepts the object form for these values, so the previous
passthrough only deferred the 400 to the provider.
This commit is contained in:
Scott Wilson 2026-08-03 16:34:14 -04:00
parent ae53de36e8
commit 80d8e95228
4 changed files with 105 additions and 15 deletions

View file

@ -169,6 +169,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
if not isinstance(tool_choice, dict):
return tool_choice
choice_type: Final = tool_choice.get("type")
if isinstance(choice_type, str) and choice_type in ("auto", "none", "required"):
return choice_type
if choice_type not in ("function", "custom"):
return tool_choice
if isinstance(tool_choice.get("name"), str) and tool_choice.get("name"):

View file

@ -7577,17 +7577,13 @@ def validate_chat_completion_tool_choice(
Prevents user errors like: https://github.com/BerriAI/litellm/issues/7483
"""
from litellm.types.llms.openai import (
ChatCompletionToolChoiceObjectParam,
ChatCompletionToolChoiceStringValues,
)
if tool_choice is None or isinstance(tool_choice, str):
return tool_choice
elif isinstance(tool_choice, dict):
# Handle Cursor IDE format: {"type": "auto"} -> return as-is
if tool_choice.get("type") in ["auto", "none", "required"] and "function" not in tool_choice:
return tool_choice
# Handle Cursor IDE format: {"type": "auto"} -> unwrap to the bare string
tool_choice_type = tool_choice.get("type")
if tool_choice_type in ("auto", "none", "required") and "function" not in tool_choice:
return tool_choice_type
# Standard OpenAI format: {"type": "function", "function": {...}}
if tool_choice.get("type") is None or tool_choice.get("function") is None:

View file

@ -28,12 +28,15 @@ def test_validate_tool_choice_standard_dict():
def test_validate_tool_choice_cursor_format():
"""Test Cursor IDE format: {"type": "auto"} -> {"type": "auto"}."""
assert validate_chat_completion_tool_choice({"type": "auto"}) == {"type": "auto"}
assert validate_chat_completion_tool_choice({"type": "none"}) == {"type": "none"}
assert validate_chat_completion_tool_choice({"type": "required"}) == {
"type": "required"
}
"""Cursor IDE format {"type": "auto"} must be unwrapped to the bare string.
No OpenAI surface accepts the object form of these values. Forwarding it
verbatim makes the provider reject the call with
"Invalid value: 'auto' ... param: tool_choice.type".
"""
assert validate_chat_completion_tool_choice({"type": "auto"}) == "auto"
assert validate_chat_completion_tool_choice({"type": "none"}) == "none"
assert validate_chat_completion_tool_choice({"type": "required"}) == "required"
def test_validate_tool_choice_invalid_dict():

View file

@ -2474,7 +2474,9 @@ def test_map_optional_params_tool_choice_chat_nested_to_responses_api():
{"type": "function", "name": "foo", "function": {"name": "bar"}},
{"type": "function", "name": "foo"},
),
({"type": "required"}, {"type": "required"}),
({"type": "auto"}, "auto"),
({"type": "none"}, "none"),
({"type": "required"}, "required"),
(
{"type": "custom", "custom": {"name": "ApplyPatch"}},
{"type": "custom", "name": "ApplyPatch"},
@ -3400,3 +3402,90 @@ def test_output_item_done_with_stream_map_keeps_empty_delta():
)
assert chunk.choices[0].delta.tool_calls is None
assert chunk.choices[0].finish_reason is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tool_choice,expected_wire_tool_choice",
[
({"type": "auto"}, "auto"),
({"type": "none"}, "none"),
({"type": "required"}, "required"),
("auto", "auto"),
({"type": "function", "function": {"name": "get_weather"}}, {"type": "function", "name": "get_weather"}),
],
)
async def test_acompletion_bridge_normalizes_tool_choice_on_the_wire(tool_choice, expected_wire_tool_choice):
"""Object-wrapped tool_choice must never reach /v1/responses.
Clients (Cursor, Claude Code via /v1/messages) send ``{"type": "auto"}``.
The Responses API only accepts a hosted-tool name in ``tool_choice.type``,
so forwarding the wrapper verbatim fails the whole call with
``Invalid value: 'auto' ... param: tool_choice.type`` -- which broke every
tool call, including web search, on responses-mode models.
"""
from unittest.mock import AsyncMock
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
responses_payload = {
"id": "resp_bridge_tool_choice",
"object": "response",
"created_at": 1734366691,
"status": "completed",
"model": "gpt-5.5",
"output": [
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi", "annotations": []}],
}
],
"parallel_tool_calls": True,
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
"error": None,
"incomplete_details": None,
"instructions": None,
"metadata": None,
"temperature": None,
"tool_choice": "auto",
"tools": [],
"top_p": None,
"max_output_tokens": None,
"previous_response_id": None,
"reasoning": None,
"truncation": None,
"user": None,
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = json.dumps(responses_payload)
mock_response.headers = httpx.Headers({})
mock_response.json.return_value = responses_payload
with patch.object(AsyncHTTPHandler, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = mock_response
await litellm.acompletion(
model="openai/responses/gpt-5.5",
messages=[{"role": "user", "content": "what is the DJIA today"}],
api_key="fake-api-key",
tools=[
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {"type": "object", "properties": {}},
},
}
],
tool_choice=tool_choice,
)
mock_post.assert_called_once()
post_kwargs = mock_post.call_args.kwargs
request_body = post_kwargs["json"] if "json" in post_kwargs else json.loads(post_kwargs["data"])
assert request_body["tool_choice"] == expected_wire_tool_choice