mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
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:
parent
50b936c75e
commit
35bdaae464
2 changed files with 128 additions and 0 deletions
|
|
@ -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[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue