From 8176f30c734a8f3d96005b8bc3f00460573ff8f9 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 5 Mar 2026 15:04:52 -0700 Subject: [PATCH 1/2] feat: Add PDF document upload support to Anthropic LLM adapter Adds support for PDF document parts in the Anthropic adapter by converting inline PDF data to Anthropic's `DocumentBlockParam` format using base64 encoding, matching the existing pattern for image part handling. --- src/google/adk/models/anthropic_llm.py | 23 ++++++++ tests/unittests/models/test_anthropic_llm.py | 56 ++++++++++++++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index 1f7f37b043..61436b2ce7 100644 --- a/src/google/adk/models/anthropic_llm.py +++ b/src/google/adk/models/anthropic_llm.py @@ -90,6 +90,14 @@ def _is_image_part(part: types.Part) -> bool: ) +def _is_document_part(part: types.Part) -> bool: + return ( + part.inline_data + and part.inline_data.mime_type + and part.inline_data.mime_type == "application/pdf" + ) + + def part_to_message_block( part: types.Part, ) -> Union[ @@ -151,6 +159,14 @@ def part_to_message_block( type="base64", media_type=part.inline_data.mime_type, data=data ), ) + elif _is_document_part(part): + data = base64.b64encode(part.inline_data.data).decode() + return anthropic_types.DocumentBlockParam( + type="document", + source=dict( + type="base64", media_type=part.inline_data.mime_type, data=data + ), + ) elif part.executable_code: return anthropic_types.TextBlockParam( type="text", @@ -179,6 +195,13 @@ def content_to_message_param( ) continue + # Document data is not supported in Claude for assistant turns. + if content.role != "user" and _is_document_part(part): + logger.warning( + "Document data is not supported in Claude for assistant turns." + ) + continue + message_block.append(part_to_message_block(part)) return { diff --git a/tests/unittests/models/test_anthropic_llm.py b/tests/unittests/models/test_anthropic_llm.py index 50759659f5..9dfeb473d0 100644 --- a/tests/unittests/models/test_anthropic_llm.py +++ b/tests/unittests/models/test_anthropic_llm.py @@ -526,6 +526,24 @@ def test_part_to_message_block_with_multiple_content_items(): assert result["content"] == "First part\nSecond part" +def test_part_to_message_block_with_pdf_document(): + """Test that part_to_message_block handles PDF document parts.""" + pdf_data = b"%PDF-1.4 fake pdf content" + part = Part( + inline_data=types.Blob(mime_type="application/pdf", data=pdf_data) + ) + + result = part_to_message_block(part) + + assert isinstance(result, dict) + assert result["type"] == "document" + assert result["source"]["type"] == "base64" + assert result["source"]["media_type"] == "application/pdf" + import base64 + + assert result["source"]["data"] == base64.b64encode(pdf_data).decode() + + content_to_message_param_test_cases = [ ( "user_role_with_text_and_image", @@ -578,6 +596,40 @@ def test_part_to_message_block_with_multiple_content_items(): 1, # Image filtered out, only text remains True, # Should log warning ), + ( + "user_role_with_text_and_document", + Content( + role="user", + parts=[ + Part.from_text(text="Summarize this document."), + Part( + inline_data=types.Blob( + mime_type="application/pdf", data=b"fake_pdf_data" + ) + ), + ], + ), + "user", + 2, # Both text and document included + False, # Should not log warning + ), + ( + "model_role_with_text_and_document", + Content( + role="model", + parts=[ + Part.from_text(text="Here is the summary."), + Part( + inline_data=types.Blob( + mime_type="application/pdf", data=b"fake_pdf_data" + ) + ), + ], + ), + "assistant", + 1, # Document filtered out, only text remains + True, # Should log warning + ), ] @@ -597,9 +649,7 @@ def test_content_to_message_param_with_images( assert len(result["content"]) == expected_content_length if should_log_warning: - mock_logger.warning.assert_called_once_with( - "Image data is not supported in Claude for assistant turns." - ) + mock_logger.warning.assert_called_once() else: mock_logger.warning.assert_not_called() From 7239de5a1919bdd9f7792962d3d81dfa33d34035 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 5 Mar 2026 15:10:44 -0700 Subject: [PATCH 2/2] fix: Address code review feedback for document upload support - Use startswith() for MIME type check to handle parameters (e.g. "application/pdf; name=doc.pdf") - Move import base64 to top of test file per PEP 8 - Parameterize expected warning messages in tests instead of just checking that any warning was logged - Rename test function to test_content_to_message_param for clarity --- src/google/adk/models/anthropic_llm.py | 2 +- tests/unittests/models/test_anthropic_llm.py | 25 ++++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index 61436b2ce7..1ca6a3deb5 100644 --- a/src/google/adk/models/anthropic_llm.py +++ b/src/google/adk/models/anthropic_llm.py @@ -94,7 +94,7 @@ def _is_document_part(part: types.Part) -> bool: return ( part.inline_data and part.inline_data.mime_type - and part.inline_data.mime_type == "application/pdf" + and part.inline_data.mime_type.startswith("application/pdf") ) diff --git a/tests/unittests/models/test_anthropic_llm.py b/tests/unittests/models/test_anthropic_llm.py index 9dfeb473d0..a62982a5fd 100644 --- a/tests/unittests/models/test_anthropic_llm.py +++ b/tests/unittests/models/test_anthropic_llm.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 import json import os import sys @@ -539,8 +540,6 @@ def test_part_to_message_block_with_pdf_document(): assert result["type"] == "document" assert result["source"]["type"] == "base64" assert result["source"]["media_type"] == "application/pdf" - import base64 - assert result["source"]["data"] == base64.b64encode(pdf_data).decode() @@ -560,7 +559,7 @@ def test_part_to_message_block_with_pdf_document(): ), "user", 2, # Expected content length - False, # Should not log warning + None, # No warning expected ), ( "model_role_with_text_and_image", @@ -577,7 +576,7 @@ def test_part_to_message_block_with_pdf_document(): ), "assistant", 1, # Image filtered out, only text remains - True, # Should log warning + "Image data is not supported in Claude for assistant turns.", ), ( "assistant_role_with_text_and_image", @@ -594,7 +593,7 @@ def test_part_to_message_block_with_pdf_document(): ), "assistant", 1, # Image filtered out, only text remains - True, # Should log warning + "Image data is not supported in Claude for assistant turns.", ), ( "user_role_with_text_and_document", @@ -611,7 +610,7 @@ def test_part_to_message_block_with_pdf_document(): ), "user", 2, # Both text and document included - False, # Should not log warning + None, # No warning expected ), ( "model_role_with_text_and_document", @@ -628,28 +627,28 @@ def test_part_to_message_block_with_pdf_document(): ), "assistant", 1, # Document filtered out, only text remains - True, # Should log warning + "Document data is not supported in Claude for assistant turns.", ), ] @pytest.mark.parametrize( - "_, content, expected_role, expected_content_length, should_log_warning", + "_, content, expected_role, expected_content_length, expected_warning", content_to_message_param_test_cases, ids=[case[0] for case in content_to_message_param_test_cases], ) -def test_content_to_message_param_with_images( - _, content, expected_role, expected_content_length, should_log_warning +def test_content_to_message_param( + _, content, expected_role, expected_content_length, expected_warning ): - """Test content_to_message_param handles images correctly based on role.""" + """Test content_to_message_param handles images and documents based on role.""" with mock.patch("google.adk.models.anthropic_llm.logger") as mock_logger: result = content_to_message_param(content) assert result["role"] == expected_role assert len(result["content"]) == expected_content_length - if should_log_warning: - mock_logger.warning.assert_called_once() + if expected_warning: + mock_logger.warning.assert_called_once_with(expected_warning) else: mock_logger.warning.assert_not_called()