Compare commits

..

3 Commits

Author SHA1 Message Date
Luke Street e4e7f34154 Merge branch 'main' into fix/anthropic-nested-schema-type-conversion 2026-03-05 15:14:22 -07:00
Rohit Yanamadala 339a22e381 Merge branch 'main' into fix/anthropic-nested-schema-type-conversion 2026-02-19 11:45:29 -08:00
Luke Street 6975461c86 fix: recurse into object properties in _update_type_string for Anthropic adapter
`_update_type_string` lowercases Gemini-style type strings (e.g.
"STRING" -> "string") for Anthropic API compatibility. It already
recursed into `items` (arrays) and `items.properties` (arrays of
objects), but not into top-level `properties` of object types. This
caused tool schemas with nested object parameters (e.g. Pydantic
models) to produce invalid JSON Schema with uppercase type strings,
resulting in Anthropic API 400 errors.

Recurse into `properties` at every level, which also subsumes the
existing `items.properties` handling.
2026-02-12 12:08:14 -07:00
2 changed files with 78 additions and 90 deletions
+4 -30
View File
@@ -90,14 +90,6 @@ 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[
@@ -159,14 +151,6 @@ 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",
@@ -195,13 +179,6 @@ 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 {
@@ -256,18 +233,15 @@ 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,
+74 -60
View File
@@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import base64
import json
import os
import sys
@@ -280,6 +279,69 @@ 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(
@@ -527,22 +589,6 @@ 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",
@@ -559,7 +605,7 @@ content_to_message_param_test_cases = [
),
"user",
2, # Expected content length
None, # No warning expected
False, # Should not log warning
),
(
"model_role_with_text_and_image",
@@ -576,7 +622,7 @@ content_to_message_param_test_cases = [
),
"assistant",
1, # Image filtered out, only text remains
"Image data is not supported in Claude for assistant turns.",
True, # Should log warning
),
(
"assistant_role_with_text_and_image",
@@ -593,62 +639,30 @@ content_to_message_param_test_cases = [
),
"assistant",
1, # Image filtered out, only text remains
"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.",
True, # Should log warning
),
]
@pytest.mark.parametrize(
"_, content, expected_role, expected_content_length, expected_warning",
"_, content, expected_role, expected_content_length, should_log_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(
_, content, expected_role, expected_content_length, expected_warning
def test_content_to_message_param_with_images(
_, content, expected_role, expected_content_length, should_log_warning
):
"""Test content_to_message_param handles images and documents based on role."""
"""Test content_to_message_param handles images correctly 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 expected_warning:
mock_logger.warning.assert_called_once_with(expected_warning)
if should_log_warning:
mock_logger.warning.assert_called_once_with(
"Image data is not supported in Claude for assistant turns."
)
else:
mock_logger.warning.assert_not_called()