mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
Merge pull request #40462 from BerriAI/litellm_fix_responses_stream_named_tool_choice
fix(responses): echo a named tool_choice in the Responses API shape on the chat-completions bridge
This commit is contained in:
commit
db7ca65b69
4 changed files with 193 additions and 9 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ 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_custom_param import ToolChoiceCustomParam
|
||||
from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam
|
||||
from openai.types.responses.tool_param import FunctionToolParam
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -68,6 +70,7 @@ from litellm.types.llms.openai import (
|
|||
ResponsesAPIOptionalRequestParams,
|
||||
ResponsesAPIResponse,
|
||||
ResponsesAPIStatus,
|
||||
ToolChoice,
|
||||
ValidChatCompletionMessageContentTypes,
|
||||
ValidChatCompletionMessageContentTypesLiteral,
|
||||
)
|
||||
|
|
@ -126,6 +129,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 +271,27 @@ class LiteLLMCompletionResponsesConfig:
|
|||
# Return as-is for unknown formats
|
||||
return tool_choice
|
||||
|
||||
@staticmethod
|
||||
def _transform_tool_choice_for_responses_api_response(tool_choice: object) -> ToolChoice:
|
||||
if tool_choice is None:
|
||||
return "auto"
|
||||
try:
|
||||
return _RESPONSES_API_TOOL_CHOICE_ADAPTER.validate_python(tool_choice)
|
||||
except ValidationError:
|
||||
return LiteLLMCompletionResponsesConfig._chat_tool_choice_as_responses_api_tool_choice(tool_choice)
|
||||
|
||||
@staticmethod
|
||||
def _chat_tool_choice_as_responses_api_tool_choice(tool_choice: object) -> ToolChoice:
|
||||
match tool_choice, LiteLLMCompletionResponsesConfig._transform_tool_choice(tool_choice):
|
||||
case {"type": "custom"}, {"function": {"name": str(custom_name)}}:
|
||||
return ToolChoiceCustomParam(type="custom", name=custom_name)
|
||||
case _, {"type": "function", "function": {"name": str(function_name)}}:
|
||||
return ToolChoiceFunctionParam(type="function", name=function_name)
|
||||
case _, "none" | "auto" | "required" as normalized:
|
||||
return normalized
|
||||
case _, _:
|
||||
return "auto"
|
||||
|
||||
@staticmethod
|
||||
def _should_drop_derived_web_search_options(model: str, custom_llm_provider: str | None) -> bool:
|
||||
"""
|
||||
|
|
@ -2263,7 +2288,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),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -1421,6 +1422,88 @@ 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": "custom", "name": "ApplyPatch"}),
|
||||
({"type": "custom", "custom": {"name": "ApplyPatch"}}, {"type": "custom", "name": "ApplyPatch"}),
|
||||
({"type": "function"}, "required"),
|
||||
({"type": "tool"}, "required"),
|
||||
({"type": "auto"}, "auto"),
|
||||
("required", "required"),
|
||||
("none", "none"),
|
||||
(None, "auto"),
|
||||
("any", "auto"),
|
||||
("run_command", "auto"),
|
||||
({"name": "run_command"}, "auto"),
|
||||
],
|
||||
)
|
||||
def test_transform_tool_choice_for_responses_api_response(
|
||||
self, request_tool_choice: object, expected: str | dict[str, str]
|
||||
) -> None:
|
||||
result: Final = 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) -> None:
|
||||
chat_completion_response: Final = 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: Final = 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"}
|
||||
|
||||
def test_non_streamed_response_with_unrecognized_tool_choice_echoes_auto(self) -> None:
|
||||
chat_completion_response: Final = ModelResponse(
|
||||
id="chatcmpl-unrecognized-tool-choice",
|
||||
created=1748575031,
|
||||
model="claude-haiku-4-5",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choices(
|
||||
index=0,
|
||||
finish_reason="stop",
|
||||
message=Message(role="assistant", content="/Users/dev"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
responses_api_response: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||
request_input="Run the command pwd.",
|
||||
responses_api_request={"tool_choice": "any"},
|
||||
chat_completion_response=chat_completion_response,
|
||||
)
|
||||
|
||||
assert responses_api_response.tool_choice == "auto"
|
||||
|
||||
|
||||
class TestContentTypeTransformation:
|
||||
"""Test content type transformation from Responses API to Chat Completion format"""
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ spend tracking stores, so a follow-up previous_response_id still finds the conve
|
|||
"""
|
||||
|
||||
import json
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -628,3 +629,79 @@ 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() -> None:
|
||||
iterator: Final = 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: Final = list(iterator)
|
||||
|
||||
response_events: Final = [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 [event.response.tool_choice for event in response_events] == [
|
||||
{"type": "function", "name": "run_command"},
|
||||
{"type": "function", "name": "run_command"},
|
||||
{"type": "function", "name": "run_command"},
|
||||
]
|
||||
assert any(getattr(event, "type", None) == "response.output_item.done" for event in events)
|
||||
|
||||
|
||||
def test_streamed_unrecognized_tool_choice_is_echoed_as_auto() -> None:
|
||||
iterator: Final = 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": "any",
|
||||
},
|
||||
custom_llm_provider="anthropic",
|
||||
litellm_metadata={},
|
||||
)
|
||||
|
||||
response_events: Final = [
|
||||
event for event in iterator if getattr(event, "type", None) in RESPONSE_ID_EVENT_TYPES
|
||||
]
|
||||
|
||||
assert [event.response.tool_choice for event in response_events] == ["auto", "auto", "auto"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue