From 7cd8ec35c99ab57631fe1d55a7d4e3c46421efa2 Mon Sep 17 00:00:00 2001 From: mike-luabase Date: Fri, 6 Jun 2025 07:59:09 -0400 Subject: [PATCH 1/5] fix: prevent Anthropic API errors from empty message content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check for empty text content before adding to assistant messages - Only append messages with non-empty content to avoid API errors - Filter empty strings in user prompts before yielding text blocks This fixes the invalid_request_error: "all messages must have non-empty content except for the optional final assistant message" 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- pydantic_ai_slim/pydantic_ai/models/anthropic.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index 69808aa5f0..ace2d0ca7f 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -322,7 +322,8 @@ async def _map_message(self, messages: list[ModelMessage]) -> tuple[str, list[Be assistant_content_params: list[BetaTextBlockParam | BetaToolUseBlockParam] = [] for response_part in m.parts: if isinstance(response_part, TextPart): - assistant_content_params.append(BetaTextBlockParam(text=response_part.content, type='text')) + if response_part.content: # Only add non-empty text + assistant_content_params.append(BetaTextBlockParam(text=response_part.content, type='text')) else: tool_use_block_param = BetaToolUseBlockParam( id=_guard_tool_call_id(t=response_part), @@ -331,7 +332,8 @@ async def _map_message(self, messages: list[ModelMessage]) -> tuple[str, list[Be input=response_part.args_as_dict(), ) assistant_content_params.append(tool_use_block_param) - anthropic_messages.append(BetaMessageParam(role='assistant', content=assistant_content_params)) + if len(assistant_content_params) > 0: + anthropic_messages.append(BetaMessageParam(role='assistant', content=assistant_content_params)) else: assert_never(m) system_prompt = '\n\n'.join(system_prompt_parts) @@ -344,11 +346,13 @@ async def _map_user_prompt( part: UserPromptPart, ) -> AsyncGenerator[BetaContentBlockParam]: if isinstance(part.content, str): - yield BetaTextBlockParam(text=part.content, type='text') + if part.content: # Only yield non-empty text + yield BetaTextBlockParam(text=part.content, type='text') else: for item in part.content: if isinstance(item, str): - yield BetaTextBlockParam(text=item, type='text') + if item: # Only yield non-empty text + yield BetaTextBlockParam(text=item, type='text') elif isinstance(item, BinaryContent): if item.is_image: yield BetaImageBlockParam( From 15470dc007802855f9c9b0511700ef6860b6dbb1 Mon Sep 17 00:00:00 2001 From: mike-luabase Date: Fri, 6 Jun 2025 09:38:39 -0400 Subject: [PATCH 2/5] chore: add noqa comment for complexity (following existing pattern) --- pydantic_ai_slim/pydantic_ai/models/anthropic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index ace2d0ca7f..1a80245a02 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -283,7 +283,7 @@ def _get_tools(self, model_request_parameters: ModelRequestParameters) -> list[B tools += [self._map_tool_definition(r) for r in model_request_parameters.output_tools] return tools - async def _map_message(self, messages: list[ModelMessage]) -> tuple[str, list[BetaMessageParam]]: + async def _map_message(self, messages: list[ModelMessage]) -> tuple[str, list[BetaMessageParam]]: # noqa: C901 """Just maps a `pydantic_ai.Message` to a `anthropic.types.MessageParam`.""" system_prompt_parts: list[str] = [] anthropic_messages: list[BetaMessageParam] = [] From 69e2e86cf2d7df80a9c4344a0afd0b5921158af1 Mon Sep 17 00:00:00 2001 From: mike-luabase Date: Fri, 6 Jun 2025 10:05:25 -0400 Subject: [PATCH 3/5] test: add tests for empty content filtering to achieve 100% coverage --- tests/models/test_anthropic.py | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index 8490d6204a..e9a6d8aff3 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -1063,3 +1063,54 @@ async def test_anthropic_model_empty_message_on_history(allow_model_requests: No What specifically would you like to know about potatoes?\ """) + + +async def test_anthropic_empty_content_filtering(env: TestEnv): + """Test the empty content filtering logic directly.""" + + from typing import cast + + from anthropic.types.beta import BetaContentBlockParam + + from pydantic_ai.messages import ( + ModelMessage, + ModelRequest, + ModelResponse, + SystemPromptPart, + TextPart, + UserPromptPart, + ) + + # Test _map_user_prompt with empty string + parts: list[BetaContentBlockParam] = [] + async for part in AnthropicModel._map_user_prompt(UserPromptPart(content='')): # type: ignore[attr-defined] + parts.append(part) + assert len(parts) == 0 + + # Test _map_user_prompt with list containing empty strings + parts = [] + async for part in AnthropicModel._map_user_prompt(UserPromptPart(content=['', 'Hello', '', 'World'])): # type: ignore[attr-defined] + parts.append(part) + assert len(parts) == 2 + assert cast(dict[str, str], parts[0])['text'] == 'Hello' + assert cast(dict[str, str], parts[1])['text'] == 'World' + + # Test _map_message with empty assistant response + env.set('ANTHROPIC_API_KEY', 'test-key') + model = AnthropicModel('claude-3-5-sonnet-latest', provider='anthropic') + messages: list[ModelMessage] = [ + ModelRequest(parts=[SystemPromptPart(content='You are helpful')], kind='request'), + ModelResponse(parts=[TextPart(content='')], kind='response'), # Empty response + ModelRequest(parts=[UserPromptPart(content='Hello')], kind='request'), + ] + _, anthropic_messages = await model._map_message(messages) # type: ignore[attr-defined] + # The empty assistant message should be filtered out + assert len(anthropic_messages) == 1 + assert anthropic_messages[0]['role'] == 'user' + + # Test with only empty assistant parts + messages_resp: list[ModelMessage] = [ + ModelResponse(parts=[TextPart(content=''), TextPart(content='')], kind='response'), + ] + _, anthropic_messages = await model._map_message(messages_resp) # type: ignore[attr-defined] + assert len(anthropic_messages) == 0 # No messages should be added From 1f25aaa800be0b9898cfa42f6c4b46c2a2a03ba7 Mon Sep 17 00:00:00 2001 From: mike-luabase Date: Thu, 12 Jun 2025 07:38:36 -0400 Subject: [PATCH 4/5] test: use snapshot testing for empty content filtering test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated test_anthropic_empty_content_filtering to use snapshot() for better visibility of what gets sent to the Anthropic API, as requested in PR review. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/models/test_anthropic.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index e9a6d8aff3..717ebb33dc 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -1105,8 +1105,7 @@ async def test_anthropic_empty_content_filtering(env: TestEnv): ] _, anthropic_messages = await model._map_message(messages) # type: ignore[attr-defined] # The empty assistant message should be filtered out - assert len(anthropic_messages) == 1 - assert anthropic_messages[0]['role'] == 'user' + assert anthropic_messages == snapshot([{'role': 'user', 'content': [{'text': 'Hello', 'type': 'text'}]}]) # Test with only empty assistant parts messages_resp: list[ModelMessage] = [ From 733241ae0b2bbe1d375a13e33cf92931610fc791 Mon Sep 17 00:00:00 2001 From: mike-luabase Date: Thu, 12 Jun 2025 07:40:20 -0400 Subject: [PATCH 5/5] test: refactor empty content tests to use _map_message and snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review feedback, refactored the empty content filtering tests to: - Use _map_message instead of testing _map_user_prompt directly - Use snapshot testing for better visibility of the output - Test at a higher level rather than testing private methods directly This makes the tests more maintainable and provides clearer output. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/models/test_anthropic.py | 36 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index 717ebb33dc..a4009c2400 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -1068,10 +1068,6 @@ async def test_anthropic_model_empty_message_on_history(allow_model_requests: No async def test_anthropic_empty_content_filtering(env: TestEnv): """Test the empty content filtering logic directly.""" - from typing import cast - - from anthropic.types.beta import BetaContentBlockParam - from pydantic_ai.messages import ( ModelMessage, ModelRequest, @@ -1081,23 +1077,27 @@ async def test_anthropic_empty_content_filtering(env: TestEnv): UserPromptPart, ) - # Test _map_user_prompt with empty string - parts: list[BetaContentBlockParam] = [] - async for part in AnthropicModel._map_user_prompt(UserPromptPart(content='')): # type: ignore[attr-defined] - parts.append(part) - assert len(parts) == 0 + # Initialize model for all tests + env.set('ANTHROPIC_API_KEY', 'test-key') + model = AnthropicModel('claude-3-5-sonnet-latest', provider='anthropic') + + # Test _map_message with empty string in user prompt + messages_empty_string: list[ModelMessage] = [ + ModelRequest(parts=[UserPromptPart(content='')], kind='request'), + ] + _, anthropic_messages = await model._map_message(messages_empty_string) # type: ignore[attr-defined] + assert anthropic_messages == snapshot([]) # Empty content should be filtered out - # Test _map_user_prompt with list containing empty strings - parts = [] - async for part in AnthropicModel._map_user_prompt(UserPromptPart(content=['', 'Hello', '', 'World'])): # type: ignore[attr-defined] - parts.append(part) - assert len(parts) == 2 - assert cast(dict[str, str], parts[0])['text'] == 'Hello' - assert cast(dict[str, str], parts[1])['text'] == 'World' + # Test _map_message with list containing empty strings in user prompt + messages_mixed_content: list[ModelMessage] = [ + ModelRequest(parts=[UserPromptPart(content=['', 'Hello', '', 'World'])], kind='request'), + ] + _, anthropic_messages = await model._map_message(messages_mixed_content) # type: ignore[attr-defined] + assert anthropic_messages == snapshot( + [{'role': 'user', 'content': [{'text': 'Hello', 'type': 'text'}, {'text': 'World', 'type': 'text'}]}] + ) # Test _map_message with empty assistant response - env.set('ANTHROPIC_API_KEY', 'test-key') - model = AnthropicModel('claude-3-5-sonnet-latest', provider='anthropic') messages: list[ModelMessage] = [ ModelRequest(parts=[SystemPromptPart(content='You are helpful')], kind='request'), ModelResponse(parts=[TextPart(content='')], kind='response'), # Empty response