fix: send media attached to a tool response to non-Gemini models

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 964308319
This commit is contained in:
George Weale
2026-08-13 14:52:11 -07:00
committed by Copybara-Service
parent 2cf4fd1ddc
commit 703cf43f6b
8 changed files with 366 additions and 2 deletions
@@ -156,6 +156,25 @@ def _media_blocks_for_part(part: types.Part) -> list[Any]:
]
def _function_response_media_blocks(
function_response: types.FunctionResponse,
) -> list[Any]:
"""Converts media a tool attached to its response into OCI content blocks."""
blocks: list[Any] = []
for response_part in function_response.parts or []:
blob = response_part.inline_data
if blob is None or blob.data is None or not blob.mime_type:
continue
blocks.extend(
_media_blocks_for_part(
types.Part(
inline_data=types.Blob(data=blob.data, mime_type=blob.mime_type)
)
)
)
return blocks
def _content_to_oci_message(content: types.Content) -> list[Any]:
"""Convert an ADK Content object to an OCI GenAI message.
@@ -191,6 +210,12 @@ def _content_to_oci_message(content: types.Content) -> list[Any]:
part.function_response.id or "",
json.dumps(result) if isinstance(result, dict) else str(result),
))
# A tool can attach media alongside the serializable part of its result.
# A tool message carries text only, so the media has to follow the tool
# results as its own message.
media_blocks.extend(
_function_response_media_blocks(part.function_response)
)
elif part.inline_data or part.file_data:
media_blocks.extend(_media_blocks_for_part(part))
+72 -1
View File
@@ -78,6 +78,13 @@ _MessageBlockParam: TypeAlias = Union[
anthropic_types.ToolResultBlockParam,
]
# The subset of block types Claude accepts inside a tool result.
_ToolResultContentBlockParam: TypeAlias = Union[
anthropic_types.TextBlockParam,
anthropic_types.ImageBlockParam,
anthropic_types.DocumentBlockParam,
]
# Attributes an Anthropic client exposes once it has resolved a credential,
# whichever source it came from: a static API key, a static bearer token, or a
# credential provider discovered from the environment or from the on-disk
@@ -330,6 +337,55 @@ def _normalize_image_media_type(mime_type: str) -> _ImageMediaType:
return cast(_ImageMediaType, normalized)
def _function_response_media_blocks(
function_response: types.FunctionResponse,
) -> list[_ToolResultContentBlockParam]:
"""Converts media a tool attached to its response into tool result blocks.
Media Claude cannot carry in a tool result is dropped with a warning rather
than raised on, because the tool that produced it is often third-party code
the caller cannot change, and losing one image is better than losing the
conversation.
"""
blocks: list[_ToolResultContentBlockParam] = []
for response_part in function_response.parts or []:
blob = response_part.inline_data
if blob is None or blob.data is None or not blob.mime_type:
continue
media_type = blob.mime_type.split(";", 1)[0].strip().lower()
data = base64.b64encode(blob.data).decode()
if media_type in _ANTHROPIC_IMAGE_MEDIA_TYPES:
blocks.append(
anthropic_types.ImageBlockParam(
type="image",
source=anthropic_types.Base64ImageSourceParam(
type="base64",
# Narrowed by the membership test above.
media_type=cast(_ImageMediaType, media_type),
data=data,
),
)
)
elif media_type == "application/pdf":
blocks.append(
anthropic_types.DocumentBlockParam(
type="document",
source=anthropic_types.Base64PDFSourceParam(
type="base64",
media_type="application/pdf",
data=data,
),
)
)
else:
logger.warning(
"Dropping tool result media of type %s, which Claude cannot receive"
" in a tool result.",
media_type,
)
return blocks
class _ToolUseIdSanitizer:
"""Maps invalid tool_use IDs to deterministic fallbacks.
@@ -426,10 +482,25 @@ def _part_to_message_block(
# dropped.
content = json.dumps(response_data)
# A tool can attach media alongside the serializable part of its result.
# It travels in a dedicated field, so it has to be mapped over explicitly
# or the model never sees it.
media_blocks = _function_response_media_blocks(function_response)
tool_result_content: Union[str, list[_ToolResultContentBlockParam]]
if media_blocks:
leading_text: list[_ToolResultContentBlockParam] = (
[anthropic_types.TextBlockParam(type="text", text=content)]
if content
else []
)
tool_result_content = leading_text + media_blocks
else:
tool_result_content = content
return anthropic_types.ToolResultBlockParam(
tool_use_id=sanitizer.sanitize(function_response.id),
type="tool_result",
content=content,
content=tool_result_content,
is_error=False,
)
elif _is_image_part(part):
+29 -1
View File
@@ -382,6 +382,23 @@ def _parse_logprobs(
)
def _function_response_media_content_parts(
function_response: types.FunctionResponse,
) -> list[dict[str, Any]]:
"""Converts media a tool attached to its response into content parts."""
media_content_parts: list[dict[str, Any]] = []
for response_part in function_response.parts or []:
blob = response_part.inline_data
if blob is None or blob.data is None or not blob.mime_type:
continue
data = base64.b64encode(blob.data).decode('utf-8')
media_content_parts.append({
'type': 'image_url',
'image_url': {'url': f'data:{blob.mime_type};base64,{data}'},
})
return media_content_parts
def _validate_model_string(model: str) -> bool:
"""Validates the model string for Apigee LLM.
@@ -736,7 +753,11 @@ class CompletionsHTTPClient:
content_parts: list[dict[str, Any]] = []
refusals: list[str] = []
function_responses = []
function_responses: list[dict[str, Any]] = []
# A tool can attach media alongside the serializable part of its result.
# A tool-role message carries text only, so the media has to follow the
# tool results as its own message.
response_media_parts: list[dict[str, Any]] = []
for part in content.parts or []:
self._process_content_part(
@@ -748,7 +769,14 @@ class CompletionsHTTPClient:
'tool_call_id': part.function_response.id,
'content': json.dumps(part.function_response.response),
})
response_media_parts.extend(
_function_response_media_content_parts(part.function_response)
)
if function_responses:
if response_media_parts:
function_responses.append(
{'role': 'user', 'content': response_media_parts}
)
return function_responses
message: dict[str, Any] = {'role': role}
+21
View File
@@ -1121,6 +1121,23 @@ def _extract_thought_signature_from_tool_call(
return None
def _function_response_media_parts(
function_response: types.FunctionResponse,
) -> list[types.Part]:
"""Converts media a tool attached to its response into content parts."""
media_parts: list[types.Part] = []
for response_part in function_response.parts or []:
blob = response_part.inline_data
if blob is None or blob.data is None or not blob.mime_type:
continue
media_parts.append(
types.Part(
inline_data=types.Blob(data=blob.data, mime_type=blob.mime_type)
)
)
return media_parts
async def _content_to_message_param(
content: types.Content,
*,
@@ -1168,6 +1185,10 @@ async def _content_to_message_param(
content=response_content,
)
)
# A tool can attach media alongside the serializable part of its
# result. A tool-role message carries text only, so the media has to
# follow the tool result as its own message.
non_tool_parts.extend(_function_response_media_parts(function_response))
else:
non_tool_parts.append(part)
@@ -242,6 +242,31 @@ def test_content_to_oci_message_function_response():
assert msg.content[0].text
def test_content_to_oci_message_function_response_with_media():
"""Media a tool attached to its response follows as its own message."""
import oci.generative_ai_inference.models as oci_models
part = Part.from_function_response(
name="draw_chart",
response={"title": "Revenue"},
parts=[
types.FunctionResponsePart.from_bytes(
data=b"chart", mime_type="image/png"
)
],
)
part.function_response.id = "call_xyz"
content = Content(role="user", parts=[part])
msgs = _content_to_oci_message(content)
assert len(msgs) == 2
assert isinstance(msgs[0], oci_models.ToolMessage)
assert msgs[0].tool_call_id == "call_xyz"
assert isinstance(msgs[1], oci_models.UserMessage)
assert msgs[1].content[0].image_url.url.startswith("data:image/png;base64,")
def test_content_to_oci_message_multiple_function_responses():
import oci.generative_ai_inference.models as oci_models
@@ -838,6 +838,89 @@ def test_part_to_message_block_with_multiple_content_items():
assert result["content"] == "First part\nSecond part"
def test_part_to_message_block_tool_result_with_image():
"""Media a tool attached to its response reaches Claude as an image block."""
image_data = b"chart-bytes"
part = types.Part.from_function_response(
name="draw_chart",
response={"title": "Revenue"},
parts=[
types.FunctionResponsePart.from_bytes(
data=image_data, mime_type="image/png"
)
],
)
part.function_response.id = "call_1"
result = part_to_message_block(part)
assert result["type"] == "tool_result"
text_block, image_block = result["content"]
assert text_block["type"] == "text"
assert "Revenue" in text_block["text"]
assert image_block["type"] == "image"
assert image_block["source"]["media_type"] == "image/png"
assert image_block["source"]["data"] == base64.b64encode(image_data).decode()
def test_part_to_message_block_tool_result_with_only_image():
"""A tool that returns nothing but media produces no empty text block."""
part = types.Part.from_function_response(
name="screenshot",
response={},
parts=[
types.FunctionResponsePart.from_bytes(
data=b"png-bytes", mime_type="image/png"
)
],
)
part.function_response.id = "call_2"
result = part_to_message_block(part)
assert [block["type"] for block in result["content"]] == ["image"]
def test_part_to_message_block_tool_result_with_pdf():
"""A PDF a tool attaches reaches Claude as a document block."""
pdf_data = b"%PDF-1.4 report"
part = types.Part.from_function_response(
name="build_report",
response={},
parts=[
types.FunctionResponsePart.from_bytes(
data=pdf_data, mime_type="application/pdf"
)
],
)
part.function_response.id = "call_3"
result = part_to_message_block(part)
document_block = result["content"][0]
assert document_block["type"] == "document"
assert document_block["source"]["media_type"] == "application/pdf"
assert document_block["source"]["data"] == base64.b64encode(pdf_data).decode()
def test_part_to_message_block_tool_result_drops_unsupported_media():
"""Media Claude cannot accept is dropped rather than failing the turn."""
part = types.Part.from_function_response(
name="record_audio",
response={"duration": 3},
parts=[
types.FunctionResponsePart.from_bytes(
data=b"wav-bytes", mime_type="audio/wav"
)
],
)
part.function_response.id = "call_4"
result = part_to_message_block(part)
assert result["content"] == json.dumps({"duration": 3})
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"
+51
View File
@@ -1102,3 +1102,54 @@ def test_content_conversion_rejects_incomplete_inline_data(
client = CompletionsHTTPClient(base_url='http://test')
with pytest.raises(ValueError, match='Inline data must include'):
client._content_to_messages(content)
def test_content_conversion_carries_function_response_media() -> None:
"""Media a tool attached to its response follows as its own message."""
part = types.Part.from_function_response(
name='draw_chart',
response={'title': 'Revenue'},
parts=[
types.FunctionResponsePart.from_bytes(
data=b'chart', mime_type='image/png'
)
],
)
part.function_response.id = 'call_1'
content = types.Content(role='user', parts=[part])
client = CompletionsHTTPClient(base_url='http://test')
messages = client._content_to_messages(content)
assert messages == [
{
'role': 'tool',
'tool_call_id': 'call_1',
'content': '{"title": "Revenue"}',
},
{
'role': 'user',
'content': [{
'type': 'image_url',
'image_url': {'url': 'data:image/png;base64,Y2hhcnQ='},
}],
},
]
def test_content_conversion_without_function_response_media() -> None:
"""A response carrying no media still converts to a lone tool message."""
part = types.Part.from_function_response(
name='lookup', response={'status': 'ok'}
)
part.function_response.id = 'call_1'
content = types.Content(role='user', parts=[part])
client = CompletionsHTTPClient(base_url='http://test')
messages = client._content_to_messages(content)
assert messages == [{
'role': 'tool',
'tool_call_id': 'call_1',
'content': '{"status": "ok"}',
}]
+60
View File
@@ -2243,6 +2243,63 @@ async def test_content_to_message_param_function_response_with_extra_parts():
]
@pytest.mark.asyncio
async def test_content_to_message_param_function_response_with_media():
"""Media a tool attached to its response follows as its own message."""
image_bytes = b"test_image_data"
tool_part = types.Part.from_function_response(
name="draw_chart",
response={"title": "Revenue"},
parts=[
types.FunctionResponsePart.from_bytes(
data=image_bytes, mime_type="image/png"
)
],
)
tool_part.function_response.id = "tool_call_1"
content = types.Content(role="user", parts=[tool_part])
messages = await _content_to_message_param(content)
assert messages == [
{
"role": "tool",
"tool_call_id": "tool_call_1",
"content": '{"title": "Revenue"}',
},
{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,dGVzdF9pbWFnZV9kYXRh"
},
}],
},
]
@pytest.mark.asyncio
async def test_content_to_message_param_function_response_without_media():
"""A response carrying no media still converts to a lone tool message."""
tool_part = types.Part.from_function_response(
name="lookup",
response={"status": "success"},
)
tool_part.function_response.id = "tool_call_1"
content = types.Content(role="user", parts=[tool_part])
message = await _content_to_message_param(content)
assert message == {
"role": "tool",
"tool_call_id": "tool_call_1",
"content": '{"status": "success"}',
}
@pytest.mark.asyncio
async def test_content_to_message_param_function_response_preserves_string():
"""Tests that string responses are used directly without double-serialization.
@@ -2267,6 +2324,9 @@ async def test_content_to_message_param_function_response_preserves_string():
mock_function_response = Mock(spec=types.FunctionResponse)
mock_function_response.response = response_payload
mock_function_response.id = "tool_call_1"
# Mock(spec=...) exposes none of a Pydantic model's fields, so every field
# the converter reads has to be set explicitly.
mock_function_response.parts = None
part.function_response = mock_function_response
content = types.Content(