Compare commits

..

2 Commits

Author SHA1 Message Date
Luke Street 7239de5a19 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
2026-03-05 15:10:44 -07:00
Luke Street 8176f30c73 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.
2026-03-05 15:04:52 -07:00
2 changed files with 90 additions and 78 deletions
+30 -4
View File
@@ -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.startswith("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 {
@@ -233,15 +256,18 @@ def _update_type_string(value_dict: dict[str, Any]):
if "type" in value_dict:
value_dict["type"] = value_dict["type"].lower()
if "properties" in value_dict:
for _, value in value_dict["properties"].items():
_update_type_string(value)
if "items" in value_dict:
# 'type' field could exist for items as well, this would be the case if
# items represent primitive types.
_update_type_string(value_dict["items"])
if "properties" in value_dict["items"]:
# There could be properties as well on the items, especially if the items
# are complex object themselves. We recursively traverse each individual
# property as well and fix the "type" value.
for _, value in value_dict["items"]["properties"].items():
_update_type_string(value)
def function_declaration_to_tool_param(
function_declaration: types.FunctionDeclaration,
+60 -74
View File
@@ -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
@@ -279,69 +280,6 @@ function_declaration_test_cases = [
},
),
),
(
"function_with_nested_object_parameter",
types.FunctionDeclaration(
name="update_profile",
description="Updates a user profile.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"profile": types.Schema(
type=types.Type.OBJECT,
description="The profile data",
properties={
"name": types.Schema(
type=types.Type.STRING,
description="Full name",
),
"address": types.Schema(
type=types.Type.OBJECT,
description="Mailing address",
properties={
"city": types.Schema(
type=types.Type.STRING,
),
"state": types.Schema(
type=types.Type.STRING,
),
},
),
},
),
},
required=["profile"],
),
),
anthropic_types.ToolParam(
name="update_profile",
description="Updates a user profile.",
input_schema={
"type": "object",
"properties": {
"profile": {
"type": "object",
"description": "The profile data",
"properties": {
"name": {
"type": "string",
"description": "Full name",
},
"address": {
"type": "object",
"description": "Mailing address",
"properties": {
"city": {"type": "string"},
"state": {"type": "string"},
},
},
},
},
},
"required": ["profile"],
},
),
),
(
"function_with_parameters_json_schema",
types.FunctionDeclaration(
@@ -589,6 +527,22 @@ 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"
assert result["source"]["data"] == base64.b64encode(pdf_data).decode()
content_to_message_param_test_cases = [
(
"user_role_with_text_and_image",
@@ -605,7 +559,7 @@ content_to_message_param_test_cases = [
),
"user",
2, # Expected content length
False, # Should not log warning
None, # No warning expected
),
(
"model_role_with_text_and_image",
@@ -622,7 +576,7 @@ content_to_message_param_test_cases = [
),
"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",
@@ -639,30 +593,62 @@ content_to_message_param_test_cases = [
),
"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",
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
None, # No warning expected
),
(
"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
"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_with(
"Image data is not supported in Claude for assistant turns."
)
if expected_warning:
mock_logger.warning.assert_called_once_with(expected_warning)
else:
mock_logger.warning.assert_not_called()