From 8eec2c38720df785c1fd48e9b208d31e3c76f2f2 Mon Sep 17 00:00:00 2001 From: vismaytiwari Date: Sun, 28 Jun 2026 18:16:09 +0530 Subject: [PATCH] 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". --- .../transformation.py | 51 ++++++++++++++- .../test_litellm_completion_responses.py | 65 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index d2a7edd21ef..1cad811beb5 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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[ 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 a0b1676068b..41d12e8ecf6 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 @@ -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 = [