fix: drop blank assistant messages inserted after tool_calls

The /responses endpoint's message transformation can insert an empty
assistant message (content=[{"type":"text","text":""}]) right after an
assistant message with tool_calls.  Providers like DeepSeek require
tool_calls to be immediately followed by tool messages; the blank
assistant message in between violates that contract and triggers
HTTP 400: "tool_calls must be followed by tool messages"

Added _drop_blank_assistant_messages_after_tool_calls() as a post-
processing pass in _transform_response_input_param_to_chat_completion_message
that strips these empty assistant messages when they appear directly
after an assistant message with tool_calls

Closes #31553
This commit is contained in:
mukktinaadh 2026-07-01 08:03:36 +05:30
parent 50b936c75e
commit 35bdaae464
2 changed files with 128 additions and 0 deletions

View file

@ -487,8 +487,68 @@ class LiteLLMCompletionResponsesConfig:
continue
messages.extend(chat_completion_messages)
# Drop blank assistant messages that follow an assistant message with
# tool_calls. Providers like DeepSeek require that tool_calls be
# immediately followed by tool messages; an empty assistant message in
# between violates that contract and triggers HTTP 400.
messages = LiteLLMCompletionResponsesConfig._drop_blank_assistant_messages_after_tool_calls(messages)
return messages
@staticmethod
def _is_blank_content(content: Any) -> bool:
"""Return True when *content* carries no meaningful information."""
if content is None:
return True
if isinstance(content, str):
return content.strip() == ""
if isinstance(content, list):
# e.g. [{"type": "text", "text": ""}]
return all(
isinstance(item, dict) and item.get("type") == "text" and not (item.get("text") or "").strip()
for item in content
)
return False
@staticmethod
def _has_tool_calls(msg: Any) -> bool:
"""Return True when the message contains tool_calls."""
tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None)
return isinstance(tool_calls, Sequence) and not isinstance(tool_calls, (str, bytes)) and len(tool_calls) > 0
@staticmethod
def _drop_blank_assistant_messages_after_tool_calls(
messages: List[Any],
) -> List[Any]:
"""
Remove blank assistant messages that immediately follow an assistant
message with tool_calls.
Providers like DeepSeek require that an assistant message with
tool_calls is immediately followed by tool messages. The Responses
API transformation can insert an empty assistant message (e.g.
content=[{"type":"text","text":""}]) right after a tool-calling
assistant message; this helper strips those out.
"""
if len(messages) < 2:
return messages
result: list = [] # mutable-ok: single-pass filter that depends on previous element
for msg in messages:
role = msg.get("role") if isinstance(msg, dict) else getattr(msg, "role", None)
if (
role == "assistant"
and result
and not LiteLLMCompletionResponsesConfig._has_tool_calls(msg)
):
prev = result[-1]
prev_role = prev.get("role") if isinstance(prev, dict) else getattr(prev, "role", None)
if prev_role == "assistant" and LiteLLMCompletionResponsesConfig._has_tool_calls(prev):
content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
if LiteLLMCompletionResponsesConfig._is_blank_content(content):
continue
result.append(msg)
return result
@staticmethod
def _deduplicate_tool_call_output_messages(
tool_call_output_messages: List[

View file

@ -2432,3 +2432,71 @@ def test_function_call_tool_id_falls_back_to_unique_id_for_degenerate_call_id():
id="fc_2", call_id="call_tokyo", name="get_weather", arguments="{}"
)
assert convert(openai)["id"] == "call_tokyo"
class TestDropBlankAssistantMessagesAfterToolCalls:
"""Regression tests for issue #31553: blank assistant messages after tool_calls
break providers like DeepSeek that require tool messages immediately after."""
def test_blank_assistant_after_tool_calls_is_dropped(self):
"""A blank assistant message right after an assistant with tool_calls should be removed"""
messages = [
{
"role": "assistant",
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "shell", "arguments": "{}"}}],
},
{"role": "assistant", "content": [{"type": "text", "text": ""}]},
{"role": "tool", "content": "output", "tool_call_id": "call_1"},
]
result = LiteLLMCompletionResponsesConfig._drop_blank_assistant_messages_after_tool_calls(messages)
assert len(result) == 2
assert result[0]["role"] == "assistant"
assert result[0].get("tool_calls") is not None
assert result[1]["role"] == "tool"
def test_blank_string_content_after_tool_calls_is_dropped(self):
"""Empty string content should also be dropped"""
messages = [
{
"role": "assistant",
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}],
},
{"role": "assistant", "content": ""},
{"role": "tool", "content": "ok", "tool_call_id": "call_1"},
]
result = LiteLLMCompletionResponsesConfig._drop_blank_assistant_messages_after_tool_calls(messages)
assert len(result) == 2
def test_nonempty_assistant_after_tool_calls_is_kept(self):
"""An assistant message with actual content should NOT be dropped"""
messages = [
{
"role": "assistant",
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "f", "arguments": "{}"}}],
},
{"role": "assistant", "content": "Here is my analysis"},
{"role": "tool", "content": "ok", "tool_call_id": "call_1"},
]
result = LiteLLMCompletionResponsesConfig._drop_blank_assistant_messages_after_tool_calls(messages)
assert len(result) == 3
def test_blank_assistant_not_after_tool_calls_is_kept(self):
"""A blank assistant message NOT preceded by tool_calls should be kept"""
messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": ""},
]
result = LiteLLMCompletionResponsesConfig._drop_blank_assistant_messages_after_tool_calls(messages)
assert len(result) == 2
def test_no_messages_returns_empty(self):
result = LiteLLMCompletionResponsesConfig._drop_blank_assistant_messages_after_tool_calls([])
assert result == []
def test_is_blank_content(self):
assert LiteLLMCompletionResponsesConfig._is_blank_content(None) is True
assert LiteLLMCompletionResponsesConfig._is_blank_content("") is True
assert LiteLLMCompletionResponsesConfig._is_blank_content(" ") is True
assert LiteLLMCompletionResponsesConfig._is_blank_content([{"type": "text", "text": ""}]) is True
assert LiteLLMCompletionResponsesConfig._is_blank_content("hello") is False
assert LiteLLMCompletionResponsesConfig._is_blank_content([{"type": "text", "text": "hi"}]) is False