From 35bdaae464778fa38dee8c045f1c1520131d7de2 Mon Sep 17 00:00:00 2001 From: mukktinaadh <159904553+mukktinaadh@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:03:36 +0530 Subject: [PATCH] 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 --- .../transformation.py | 60 ++++++++++++++++ .../test_litellm_completion_responses.py | 68 +++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 866698b1f96..da6e4b63a12 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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[ diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 426c73645c1..77e8461998f 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -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