mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
ae53de36e8
commit
80d8e95228
4 changed files with 105 additions and 15 deletions
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue