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:
vismaytiwari 2026-06-28 18:16:09 +05:30
parent b76a858826
commit 8eec2c3872
2 changed files with 115 additions and 1 deletions

View file

@ -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[

View file

@ -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 = [