From 80d8e952280580e21346b9c200b2d0035d55dfc8 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 3 Aug 2026 16:34:14 -0400 Subject: [PATCH 1/3] 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. --- .../transformation.py | 2 + litellm/utils.py | 12 +-- .../test_validate_tool_choice.py | 15 +-- ...responses_transformation_transformation.py | 91 ++++++++++++++++++- 4 files changed, 105 insertions(+), 15 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index f31e228e456..d3c5290bd9a 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -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"): diff --git a/litellm/utils.py b/litellm/utils.py index d93c88e05a0..f4a9ee19e4f 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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: diff --git a/tests/litellm_utils_tests/test_validate_tool_choice.py b/tests/litellm_utils_tests/test_validate_tool_choice.py index 8150403c145..c3f80f31864 100644 --- a/tests/litellm_utils_tests/test_validate_tool_choice.py +++ b/tests/litellm_utils_tests/test_validate_tool_choice.py @@ -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(): diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index b8bd5c951ee..70563aa880a 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -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 From 889c1f584a6bda2d1812c1142117b5d1eed01932 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Wed, 5 Aug 2026 23:23:12 -0400 Subject: [PATCH 2/3] test(responses): annotate the tool_choice bridge test signature --- ...extras_litellm_responses_transformation_transformation.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 70563aa880a..c2484970ed9 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -3415,7 +3415,10 @@ def test_output_item_done_with_stream_map_keeps_empty_delta(): ({"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): +async def test_acompletion_bridge_normalizes_tool_choice_on_the_wire( + tool_choice: str | dict[str, object], + expected_wire_tool_choice: str | dict[str, str], +) -> None: """Object-wrapped tool_choice must never reach /v1/responses. Clients (Cursor, Claude Code via /v1/messages) send ``{"type": "auto"}``. From 98bfbb99d23f3818f875defa9dc8cfddf34f31c3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:01:54 -0700 Subject: [PATCH 3/3] refactor(responses): drop commentary from the tool_choice fix --- litellm/utils.py | 1 - tests/litellm_utils_tests/test_validate_tool_choice.py | 7 +------ ...as_litellm_responses_transformation_transformation.py | 9 +-------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/litellm/utils.py b/litellm/utils.py index db1d7784f41..68f4278c87a 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7708,7 +7708,6 @@ def validate_chat_completion_tool_choice( if tool_choice is None or isinstance(tool_choice, str): return tool_choice elif isinstance(tool_choice, dict): - # 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 diff --git a/tests/litellm_utils_tests/test_validate_tool_choice.py b/tests/litellm_utils_tests/test_validate_tool_choice.py index c3f80f31864..0e6294a7cd4 100644 --- a/tests/litellm_utils_tests/test_validate_tool_choice.py +++ b/tests/litellm_utils_tests/test_validate_tool_choice.py @@ -28,12 +28,7 @@ def test_validate_tool_choice_standard_dict(): def test_validate_tool_choice_cursor_format(): - """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". - """ + """Cursor IDE format {"type": "auto"} is unwrapped to the bare string.""" 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" diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index c2484970ed9..5508931b35d 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -3419,14 +3419,7 @@ async def test_acompletion_bridge_normalizes_tool_choice_on_the_wire( tool_choice: str | dict[str, object], expected_wire_tool_choice: str | dict[str, str], ) -> None: - """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. - """ + """Object-wrapped tool_choice must never reach /v1/responses.""" from unittest.mock import AsyncMock from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler