fix(responses): echo a named tool_choice in the Responses API shape on the chat-completions bridge

A streamed /v1/responses request with tool_choice {"type": "function", "name": ...}
that reaches a chat-completions-only deployment failed with HTTP 500 before the
first byte: the synthetic response.created and response.in_progress events copied
the chat-shaped tool_choice into ResponsesAPIResponse, whose ToolChoice type expects
the flat Responses API shape. The non-streamed path echoed "auto" regardless of the
request.

Both paths now normalize the request's tool_choice through the existing chat
transform and map it back to the Responses API vocabulary, validated by a
TypeAdapter(ToolChoice), so a named function is echoed as {"type": "function",
"name": ...} and a missing tool_choice is echoed as "auto".

Fixes #33689
This commit is contained in:
mateo-berri 2026-09-09 14:22:22 -07:00
parent 11b31c19be
commit cce1d2087b
4 changed files with 126 additions and 8 deletions

View file

@ -437,14 +437,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
response_created_event_data["temperature"] = self.responses_api_request["temperature"]
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"
response_created_event_data["tool_choice"] = (
LiteLLMCompletionResponsesConfig._transform_tool_choice_for_responses_api_response(
self.responses_api_request.get("tool_choice")
)
else:
response_created_event_data["tool_choice"] = "auto"
)
if "tools" in self.responses_api_request:
response_created_event_data["tools"] = self.responses_api_request["tools"]
else:

View file

@ -27,6 +27,7 @@ from openai.types.chat.chat_completion_named_tool_choice_param import (
)
from openai.types.responses import ResponseFunctionToolCall
from openai.types.responses.response_create_params import ResponseInputParam
from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam
from openai.types.responses.tool_param import FunctionToolParam
from pydantic import TypeAdapter
from typing_extensions import ReadOnly, TypedDict
@ -68,6 +69,7 @@ from litellm.types.llms.openai import (
ResponsesAPIOptionalRequestParams,
ResponsesAPIResponse,
ResponsesAPIStatus,
ToolChoice,
ValidChatCompletionMessageContentTypes,
ValidChatCompletionMessageContentTypesLiteral,
)
@ -126,6 +128,7 @@ _STR_KEY_DICT_ADAPTER: Final = TypeAdapter(dict[str, object])
_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object])
_DICT_ITEMS_LIST_ADAPTER: Final = TypeAdapter(list[dict[object, object]])
_TEXT_ADAPTER: Final = TypeAdapter(str)
_RESPONSES_API_TOOL_CHOICE_ADAPTER: Final = TypeAdapter(ToolChoice)
@runtime_checkable
@ -267,6 +270,17 @@ class LiteLLMCompletionResponsesConfig:
# Return as-is for unknown formats
return tool_choice
@staticmethod
def _transform_tool_choice_for_responses_api_response(tool_choice: object) -> ToolChoice:
normalized: Final = LiteLLMCompletionResponsesConfig._transform_tool_choice(tool_choice)
match normalized:
case None:
return "auto"
case {"type": "function", "function": {"name": str(function_name)}}:
return ToolChoiceFunctionParam(type="function", name=function_name)
case _:
return _RESPONSES_API_TOOL_CHOICE_ADAPTER.validate_python(normalized)
@staticmethod
def _should_drop_derived_web_search_options(model: str, custom_llm_provider: str | None) -> bool:
"""
@ -2263,7 +2277,9 @@ class LiteLLMCompletionResponsesConfig:
),
parallel_tool_calls=getattr(chat_completion_response, "parallel_tool_calls", False),
temperature=getattr(chat_completion_response, "temperature", 0),
tool_choice=getattr(chat_completion_response, "tool_choice", "auto"),
tool_choice=LiteLLMCompletionResponsesConfig._transform_tool_choice_for_responses_api_response(
responses_api_request.get("tool_choice")
),
tools=getattr(chat_completion_response, "tools", []),
top_p=getattr(chat_completion_response, "top_p", None),
max_output_tokens=getattr(chat_completion_response, "max_output_tokens", None),

View file

@ -1421,6 +1421,58 @@ class TestToolChoiceTransformation:
)
assert result == "required"
@pytest.mark.parametrize(
"request_tool_choice,expected",
[
({"type": "function", "name": "run_command"}, {"type": "function", "name": "run_command"}),
({"type": "function", "function": {"name": "run_command"}}, {"type": "function", "name": "run_command"}),
({"type": "custom", "name": "ApplyPatch"}, {"type": "function", "name": "ApplyPatch"}),
({"type": "tool"}, "required"),
({"type": "auto"}, "auto"),
("required", "required"),
("none", "none"),
(None, "auto"),
],
)
def test_transform_tool_choice_for_responses_api_response(self, request_tool_choice, expected):
result = LiteLLMCompletionResponsesConfig._transform_tool_choice_for_responses_api_response(
request_tool_choice
)
assert result == expected
def test_non_streamed_response_echoes_named_tool_choice_in_responses_api_shape(self):
chat_completion_response = ModelResponse(
id="chatcmpl-named-tool-choice",
created=1748575031,
model="claude-haiku-4-5",
object="chat.completion",
choices=[
Choices(
index=0,
finish_reason="tool_calls",
message=Message(
role="assistant",
content=None,
tool_calls=[
ChatCompletionMessageToolCall(
id="call_pwd",
type="function",
function=Function(name="run_command", arguments='{"command":"pwd"}'),
)
],
),
)
],
)
responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="Run the command pwd.",
responses_api_request={"tool_choice": {"type": "function", "name": "run_command"}},
chat_completion_response=chat_completion_response,
)
assert responses_api_response.tool_choice == {"type": "function", "name": "run_command"}
class TestContentTypeTransformation:
"""Test content type transformation from Responses API to Chat Completion format"""

View file

@ -628,3 +628,56 @@ def test_streamed_anthropic_tool_call_events_correlate_on_normalized_item_id():
assert item_dones[0].item.call_id == "toolu_01AbCdEf"
for evt in deltas + dones:
assert evt.item_id == added[0].item.id
def _tool_call_chunk(finish_reason: str | None = None) -> ModelResponseStream:
return ModelResponseStream(
id=CHAT_COMPLETION_ID,
created=1748575031,
model="claude-haiku-4-5",
object="chat.completion.chunk",
choices=[
StreamingChoices(
index=0,
delta=Delta(
role="assistant",
content=None,
tool_calls=[
{
"id": "call_pwd",
"type": "function",
"function": {"name": "run_command", "arguments": '{"command":"pwd"}'},
"index": 0,
}
],
),
finish_reason=finish_reason,
)
],
)
def test_streamed_named_tool_choice_is_echoed_in_responses_api_shape():
iterator = LiteLLMCompletionStreamingIterator(
model="claude-haiku-4-5",
litellm_custom_stream_wrapper=_FakeStreamWrapper([_tool_call_chunk(finish_reason="tool_calls")]),
request_input="Run the command pwd.",
responses_api_request={
"tools": [{"type": "function", "name": "run_command", "parameters": {"type": "object"}}],
"tool_choice": {"type": "function", "name": "run_command"},
},
custom_llm_provider="anthropic",
litellm_metadata={},
)
events = list(iterator)
response_events = [event for event in events if getattr(event, "type", None) in RESPONSE_ID_EVENT_TYPES]
assert [event.type for event in response_events] == [
"response.created",
"response.in_progress",
"response.completed",
]
assert response_events[0].response.tool_choice == {"type": "function", "name": "run_command"}
assert response_events[1].response.tool_choice == {"type": "function", "name": "run_command"}
assert any(getattr(event, "type", None) == "response.output_item.done" for event in events)