mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
fix(responses): drop empty assistant message from responses input conversion
An assistant message item with empty text in Responses API input was converted into a content-less assistant chat message. When it trailed an assistant tool_calls message (e.g. Codex replaying a tool-only turn), strict OpenAI-compatible providers such as DeepSeek rejected the request with "assistant message with tool_calls must be followed by tool messages".
This commit is contained in:
parent
b76a858826
commit
8eec2c3872
2 changed files with 115 additions and 1 deletions
|
|
@ -462,9 +462,58 @@ class LiteLLMCompletionResponsesConfig:
|
|||
messages.extend(deduped_in_place)
|
||||
continue
|
||||
|
||||
messages.extend(chat_completion_messages)
|
||||
messages.extend(
|
||||
message
|
||||
for message in chat_completion_messages
|
||||
if not LiteLLMCompletionResponsesConfig._is_blank_assistant_message(message)
|
||||
)
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _is_blank_assistant_message(
|
||||
message: Union[
|
||||
AllMessageValues,
|
||||
GenericChatCompletionMessage,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionResponseMessage,
|
||||
],
|
||||
) -> bool:
|
||||
"""
|
||||
Whether an assistant message has no tool calls and no non-empty content.
|
||||
|
||||
Codex-style Responses input includes an assistant message item whose text
|
||||
is empty when that turn only produced a tool call. Converting it yields a
|
||||
content-less assistant message that strict OpenAI-compatible providers
|
||||
(e.g. DeepSeek) reject when it trails an assistant tool_calls message,
|
||||
since a tool_calls message must be followed by tool results.
|
||||
"""
|
||||
get = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value
|
||||
role = cast(object, get(message, "role"))
|
||||
if role != "assistant":
|
||||
return False
|
||||
tool_calls = cast(object, get(message, "tool_calls"))
|
||||
if tool_calls:
|
||||
return False
|
||||
content = cast(object, get(message, "content"))
|
||||
if content is None or content == "":
|
||||
return True
|
||||
if isinstance(content, str):
|
||||
return content.strip() == ""
|
||||
if isinstance(content, list):
|
||||
parts = cast(List[object], content)
|
||||
return all(LiteLLMCompletionResponsesConfig._is_empty_text_part(part) for part in parts)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_empty_text_part(part: object) -> bool:
|
||||
if not isinstance(part, dict):
|
||||
return False
|
||||
typed_part = cast(Dict[str, object], part)
|
||||
if typed_part.get("type") not in (None, "text"):
|
||||
return False
|
||||
text = typed_part.get("text")
|
||||
return not (isinstance(text, str) and bool(text.strip()))
|
||||
|
||||
@staticmethod
|
||||
def _deduplicate_tool_call_output_messages(
|
||||
tool_call_output_messages: List[
|
||||
|
|
|
|||
|
|
@ -735,6 +735,71 @@ class TestFunctionCallTransformation:
|
|||
tool_msg.get("tool_call_id") == "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5"
|
||||
)
|
||||
|
||||
def test_empty_assistant_message_after_tool_call_is_dropped(self):
|
||||
"""Regression: an empty assistant message trailing an assistant tool_calls message must be dropped.
|
||||
|
||||
Codex replays the prior turn as a function_call followed by an assistant
|
||||
message with empty output_text. The empty assistant message must not survive,
|
||||
otherwise strict OpenAI-compatible providers (e.g. DeepSeek) reject the request
|
||||
with "assistant message with tool_calls must be followed by tool messages".
|
||||
"""
|
||||
test_input = [
|
||||
{
|
||||
"type": "function_call",
|
||||
"arguments": '{"command": "ls"}',
|
||||
"call_id": "call_00_abc",
|
||||
"name": "shell_command",
|
||||
"id": "call_00_abc",
|
||||
"status": "completed",
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": ""}],
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_00_abc",
|
||||
"output": "file1\nfile2",
|
||||
},
|
||||
]
|
||||
|
||||
messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
|
||||
input=test_input
|
||||
)
|
||||
|
||||
assert [m.get("role") for m in messages] == ["assistant", "tool"]
|
||||
assert len(messages[0].get("tool_calls", [])) == 1
|
||||
|
||||
blank_assistant_messages = [
|
||||
message
|
||||
for message in messages
|
||||
if message.get("role") == "assistant" and not message.get("tool_calls")
|
||||
]
|
||||
assert blank_assistant_messages == []
|
||||
|
||||
def test_assistant_message_with_text_is_preserved(self):
|
||||
"""A non-empty assistant message must not be dropped by the blank-message filter."""
|
||||
test_input = [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hi"}],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "hello there"}],
|
||||
},
|
||||
]
|
||||
|
||||
messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
|
||||
input=test_input
|
||||
)
|
||||
|
||||
assert [m.get("role") for m in messages] == ["user", "assistant"]
|
||||
assert "hello there" in str(messages[1].get("content"))
|
||||
|
||||
def test_complete_request_transformation_with_function_calls(self):
|
||||
"""Test the complete request transformation that would be used by the responses API"""
|
||||
test_input = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue