From 5ab11fd2c36d5023752e5e520d8b22ebd59c0fab Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:07:39 +0000 Subject: [PATCH] fix(responses): echo forced named tool_choice in flat Responses API shape --- .../streaming_iterator.py | 6 +- .../transformation.py | 35 ++++++++ .../test_litellm_completion_responses.py | 89 +++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index cf69654d15d..81bc4a90a43 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -336,10 +336,10 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): if "text" in self.responses_api_request: response_created_event_data["text"] = self.responses_api_request["text"] if "tool_choice" in self.responses_api_request: - # Transform tool_choice from dict format (e.g., {"type": "auto"}) to string format response_created_event_data["tool_choice"] = ( - LiteLLMCompletionResponsesConfig._transform_tool_choice(self.responses_api_request["tool_choice"]) - or "auto" + LiteLLMCompletionResponsesConfig._transform_tool_choice_for_response( + self.responses_api_request["tool_choice"] + ) ) else: response_created_event_data["tool_choice"] = "auto" diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 6b1ca3564e3..a9e54bc14c7 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -166,6 +166,41 @@ class LiteLLMCompletionResponsesConfig: # Return as-is for unknown formats return tool_choice + @staticmethod + def _transform_tool_choice_for_response( + tool_choice: Any, + ) -> str | dict[str, Any]: + """ + Normalize tool_choice into a shape valid for ResponsesAPIResponse.tool_choice + when echoing the request back on response events. + + Unlike _transform_tool_choice (which targets the Chat Completions request and + emits the nested {"type": "function", "function": {"name": ...}} shape), the + Responses API response object only accepts a string or the flat Responses API + shape {"type": "function", "name": "..."}, so a forced named tool must keep the + flat shape here to avoid a ResponsesAPIResponse ValidationError. + """ + if tool_choice is None: + return "auto" + + if isinstance(tool_choice, str): + return tool_choice + + if isinstance(tool_choice, dict): + tool_choice_type = tool_choice.get("type") + + if tool_choice_type == "function": + function_name = tool_choice.get("name") or tool_choice.get("function", {}).get("name") + if function_name: + return {"type": "function", "name": function_name} + return "required" + if tool_choice_type in ("auto", "none", "required"): + return tool_choice_type + if tool_choice_type in ("tool", "any"): + return "required" + + return tool_choice + @staticmethod def _should_drop_derived_web_search_options(model: str, custom_llm_provider: str | None) -> bool: """ diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index d8e3f495ced..46f4d882ef7 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -974,6 +974,95 @@ class TestToolChoiceTransformation: assert result == "required" +class TestToolChoiceForResponseTransformation: + """Regression tests for issue #33689: forcing a named tool call on the + litellm_completion bridge with stream=true crashed with a + ResponsesAPIResponse ValidationError because the response event echoed the + nested Chat Completions tool_choice shape instead of the flat Responses API one""" + + @pytest.mark.parametrize( + "tool_choice, expected", + [ + ("auto", "auto"), + ("none", "none"), + ("required", "required"), + (None, "auto"), + ({"type": "function", "name": "get_weather"}, {"type": "function", "name": "get_weather"}), + ( + {"type": "function", "function": {"name": "get_weather"}}, + {"type": "function", "name": "get_weather"}, + ), + ({"type": "function"}, "required"), + ({"type": "function", "name": ""}, "required"), + ({"type": "auto"}, "auto"), + ({"type": "none"}, "none"), + ({"type": "tool"}, "required"), + ({"type": "any"}, "required"), + ], + ) + def test_transform_tool_choice_for_response_shapes(self, tool_choice, expected): + result = LiteLLMCompletionResponsesConfig._transform_tool_choice_for_response( + tool_choice + ) + assert result == expected + + @pytest.mark.parametrize( + "tool_choice", + [ + "auto", + "none", + "required", + None, + {"type": "function", "name": "get_weather"}, + {"type": "function", "function": {"name": "get_weather"}}, + {"type": "function"}, + {"type": "auto"}, + {"type": "tool"}, + ], + ) + def test_transform_tool_choice_for_response_is_valid_on_response_object( + self, tool_choice + ): + """Every normalized value must construct a ResponsesAPIResponse without raising""" + from litellm.types.llms.openai import ResponsesAPIResponse + + result = LiteLLMCompletionResponsesConfig._transform_tool_choice_for_response( + tool_choice + ) + ResponsesAPIResponse(id="resp_1", created_at=1, output=[], tool_choice=result) + + def test_forced_named_tool_response_created_event_does_not_raise(self): + """End-to-end guard: building the response.created event for a forced named + tool call must produce a ResponsesAPIResponse with the flat function shape""" + from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, + ) + + iterator = LiteLLMCompletionStreamingIterator.__new__( + LiteLLMCompletionStreamingIterator + ) + iterator.model = "claude-3-5-sonnet-latest" + iterator._sequence_number = 0 + iterator._cached_response_id = None + iterator.responses_api_request = { + "tool_choice": {"type": "function", "name": "get_weather"}, + "tools": [ + { + "type": "function", + "name": "get_weather", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + + event = iterator.create_response_created_event() + + assert event.response.tool_choice == { + "type": "function", + "name": "get_weather", + } + + class TestContentTypeTransformation: """Test content type transformation from Responses API to Chat Completion format"""