This commit is contained in:
devin-ai-integration[bot] 2026-08-27 18:18:38 -05:00 committed by GitHub
commit 12bafbe553
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 127 additions and 3 deletions

View file

@ -433,10 +433,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"

View file

@ -255,6 +255,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:
"""

View file

@ -1288,6 +1288,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"""