From 70b278ecce1d3fb9a10c9f5753aba56fbf2ef74d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:49:29 +0000 Subject: [PATCH] fix: strip trailing whitespace from final assistant message for Anthropic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4413. Anthropic API rejects requests where the final assistant message ends with trailing whitespace (400 invalid_request_error). This strips trailing whitespace from the last assistant message content in _format_messages_for_anthropic, handling both string and list content. Co-Authored-By: João --- .../llms/providers/anthropic/completion.py | 16 +++ .../tests/llms/anthropic/test_anthropic.py | 114 ++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py index 657488098..51812e289 100644 --- a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py +++ b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py @@ -631,6 +631,22 @@ class AnthropicCompletion(BaseLLM): # If first message is not from user, insert a user message at the beginning formatted_messages.insert(0, {"role": "user", "content": "Hello"}) + # Strip trailing whitespace from the final assistant message content. + # Anthropic rejects requests where the final assistant message ends + # with trailing whitespace (400 invalid_request_error). + if formatted_messages and formatted_messages[-1].get("role") == "assistant": + last_content = formatted_messages[-1].get("content") + if isinstance(last_content, str): + formatted_messages[-1]["content"] = last_content.rstrip() + elif isinstance(last_content, list): + for i in range(len(last_content) - 1, -1, -1): + block = last_content[i] + if isinstance(block, dict) and block.get("type") == "text": + text_val = block.get("text", "") + if isinstance(text_val, str): + block["text"] = text_val.rstrip() + break + return formatted_messages, system_message def _handle_completion( diff --git a/lib/crewai/tests/llms/anthropic/test_anthropic.py b/lib/crewai/tests/llms/anthropic/test_anthropic.py index c5ad5f273..9936c7b57 100644 --- a/lib/crewai/tests/llms/anthropic/test_anthropic.py +++ b/lib/crewai/tests/llms/anthropic/test_anthropic.py @@ -990,3 +990,117 @@ def test_anthropic_agent_kickoff_structured_output_with_tools(): assert result.pydantic.result == 42, f"Expected result 42 but got {result.pydantic.result}" assert result.pydantic.operation, "Operation should not be empty" assert result.pydantic.explanation, "Explanation should not be empty" + + +def test_anthropic_strips_trailing_whitespace_from_final_assistant_message(): + llm = LLM(model="anthropic/claude-3-5-sonnet-20241022") + + messages = [ + {"role": "user", "content": "Hello. Say world"}, + {"role": "assistant", "content": "Say: "}, + ] + + formatted_messages, _ = llm._format_messages_for_anthropic(messages) + + last_msg = formatted_messages[-1] + assert last_msg["role"] == "assistant" + assert last_msg["content"] == "Say:" + assert not last_msg["content"].endswith(" ") + + +def test_anthropic_strips_trailing_whitespace_tabs_and_newlines(): + llm = LLM(model="anthropic/claude-3-5-sonnet-20241022") + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Response \t\n "}, + ] + + formatted_messages, _ = llm._format_messages_for_anthropic(messages) + + last_msg = formatted_messages[-1] + assert last_msg["role"] == "assistant" + assert last_msg["content"] == "Response" + + +def test_anthropic_does_not_strip_whitespace_from_non_final_assistant_message(): + llm = LLM(model="anthropic/claude-3-5-sonnet-20241022") + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi "}, + {"role": "user", "content": "How are you?"}, + ] + + formatted_messages, _ = llm._format_messages_for_anthropic(messages) + + assert formatted_messages[-1]["role"] == "user" + assistant_msg = formatted_messages[1] + assert assistant_msg["role"] == "assistant" + assert assistant_msg["content"] == "Hi " + + +def test_anthropic_strips_trailing_whitespace_from_list_content_text_block(): + llm = LLM(model="anthropic/claude-3-5-sonnet-20241022") + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Some response "}, + ]}, + ] + + formatted_messages, _ = llm._format_messages_for_anthropic(messages) + + last_msg = formatted_messages[-1] + assert last_msg["role"] == "assistant" + last_block = last_msg["content"][-1] + assert last_block["text"] == "Some response" + + +def test_anthropic_strips_trailing_whitespace_only_from_last_text_block(): + llm = LLM(model="anthropic/claude-3-5-sonnet-20241022") + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "First block "}, + {"type": "text", "text": "Last block "}, + ]}, + ] + + formatted_messages, _ = llm._format_messages_for_anthropic(messages) + + last_msg = formatted_messages[-1] + blocks = last_msg["content"] + assert blocks[0]["text"] == "First block " + assert blocks[1]["text"] == "Last block" + + +def test_anthropic_no_strip_when_final_message_is_user(): + llm = LLM(model="anthropic/claude-3-5-sonnet-20241022") + + messages = [ + {"role": "user", "content": "Hello "}, + ] + + formatted_messages, _ = llm._format_messages_for_anthropic(messages) + + last_msg = formatted_messages[-1] + assert last_msg["role"] == "user" + assert last_msg["content"] == "Hello " + + +def test_anthropic_empty_assistant_content_not_affected(): + llm = LLM(model="anthropic/claude-3-5-sonnet-20241022") + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": ""}, + ] + + formatted_messages, _ = llm._format_messages_for_anthropic(messages) + + last_msg = formatted_messages[-1] + assert last_msg["role"] == "assistant" + assert last_msg["content"] == ""