Python: Fix GeminiChatClient dropping image/file content (#6751)

* Python: Fix GeminiChatClient dropping image/file content

GeminiChatClient._convert_message_contents only handled text and function_call content, so data/uri (image, PDF, audio) parts were silently dropped and never reached Gemini. Convert data URIs to inline_data Parts and external URIs to file_data Parts, warning on genuinely unconvertible content. Adds tests for the multimodal conversion paths.

Fixes #6688

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review: strip data-URI mime params and handle non-inferable URIs

Strip parameters (e.g. charset) from a data URI media type before passing it to Gemini, and wrap types.Part.from_uri so a URI with no media_type and no guessable extension is passed through as file_data without a mime type instead of raising ValueError. Adds tests for both paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review: reuse shared data-URI helpers

Reuse _get_data_bytes and detect_media_type_from_base64 from agent_framework instead of reimplementing base64 extraction/decoding and data-URI header parsing in the Gemini client. This also removes the manual header parsing that previously needed charset-parameter stripping. Updates tests accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Giles Odigwe
2026-06-30 14:00:54 -07:00
committed by GitHub
parent f01bea77bb
commit 43f2095244
2 changed files with 186 additions and 0 deletions
@@ -24,10 +24,13 @@ from agent_framework import (
Message,
ResponseStream,
UsageDetails,
detect_media_type_from_base64,
validate_tool_mode,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._types import _get_data_bytes # type: ignore[reportPrivateUsage]
from agent_framework.exceptions import ContentError
from agent_framework.observability import ChatTelemetryLayer
from google import genai
from google.auth.credentials import Credentials
@@ -678,10 +681,56 @@ class RawGeminiChatClient(
parts.append(raw_part.model_copy(update={"function_call": function_call}, deep=True))
else:
parts.append(types.Part(function_call=function_call))
case "data" | "uri":
part = self._convert_data_or_uri_content(content)
if part is not None:
parts.append(part)
case _:
logger.debug("Skipping unsupported content type for Gemini: %s", content.type)
return parts
def _convert_data_or_uri_content(self, content: Content) -> types.Part | None:
"""Convert a ``data`` or ``uri`` Content to a Gemini Part.
Data URIs (``type="data"``) become ``inline_data`` Parts with the decoded bytes.
External URIs (``type="uri"``) become ``file_data`` Parts referencing the resource.
Args:
content: The framework Content object, expected to be of type ``data`` or ``uri``.
Returns:
A Gemini Part carrying the multimodal content, or None if the content cannot be
converted (e.g. missing URI, non-base64 data URI, or undecodable data).
"""
uri = content.uri
if not uri:
logger.warning("Skipping %s content for Gemini: missing uri", content.type)
return None
if uri.startswith("data:"):
try:
raw_bytes = _get_data_bytes(content)
except ContentError:
logger.warning("Skipping data content for Gemini: data URI is not valid base64")
return None
if not raw_bytes:
logger.warning("Skipping data content for Gemini: no data found in URI")
return None
mime_type = content.media_type or detect_media_type_from_base64(data_bytes=raw_bytes)
if not mime_type:
logger.warning("Skipping data content for Gemini: missing media_type")
return None
return types.Part.from_bytes(data=raw_bytes, mime_type=mime_type)
try:
return types.Part.from_uri(file_uri=uri, mime_type=content.media_type)
except ValueError:
# from_uri raises when no media_type is given and one cannot be inferred from the URI
# (e.g. presigned URLs or API endpoints without an extension). Pass the URI through
# without a mime type rather than dropping the content or raising.
logger.warning("Could not determine media_type for URI content; sending to Gemini without one: %s", uri)
return types.Part(file_data=types.FileData(file_uri=uri, mime_type=None))
def _convert_function_result(
self,
content: Content,
@@ -711,6 +711,143 @@ def test_function_call_part_preserves_thought_signature_from_raw_part() -> None:
assert parts[0].function_call.args == {"location": "Paris"}
# multimodal (data/uri) parts
def test_data_content_converted_to_inline_data_part() -> None:
"""Content.from_data is converted to a Gemini inline_data Part so images reach the model."""
import base64
client, _ = _make_gemini_client()
png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
content = Content.from_data(data=png, media_type="image/png")
assert content.type == "data"
parts = client._convert_message_contents([content], {})
assert len(parts) == 1
assert parts[0].inline_data is not None
assert parts[0].inline_data.mime_type == "image/png"
assert parts[0].inline_data.data == png
def test_data_uri_content_converted_to_inline_data_part() -> None:
"""A data URI created via Content.from_uri becomes an inline_data Part with decoded bytes."""
import base64
client, _ = _make_gemini_client()
png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
content = Content.from_uri(uri=f"data:image/png;base64,{base64.b64encode(png).decode()}")
assert content.type == "data"
parts = client._convert_message_contents([content], {})
assert len(parts) == 1
assert parts[0].inline_data is not None
assert parts[0].inline_data.mime_type == "image/png"
assert parts[0].inline_data.data == png
def test_external_uri_content_converted_to_file_data_part() -> None:
"""Content.from_uri with an external URL becomes a Gemini file_data Part."""
client, _ = _make_gemini_client()
content = Content.from_uri(uri="https://example.com/image.png", media_type="image/png")
assert content.type == "uri"
parts = client._convert_message_contents([content], {})
assert len(parts) == 1
assert parts[0].file_data is not None
assert parts[0].file_data.file_uri == "https://example.com/image.png"
assert parts[0].file_data.mime_type == "image/png"
def test_text_and_image_content_both_reach_the_model() -> None:
"""A multimodal message keeps both the text and the image parts."""
import base64
client, _ = _make_gemini_client()
png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
text = Content.from_text("What is in this image?")
image = Content.from_data(data=png, media_type="image/png")
parts = client._convert_message_contents([text, image], {})
assert len(parts) == 2
assert parts[0].text == "What is in this image?"
assert any(p.inline_data is not None for p in parts)
def test_non_base64_data_uri_is_skipped(caplog: pytest.LogCaptureFixture) -> None:
"""A data URI that is not base64-encoded is skipped with a warning rather than crashing."""
client, _ = _make_gemini_client()
content = Content.from_text("placeholder")
content.type = "data" # type: ignore[assignment]
content.uri = "data:text/plain,hello"
with caplog.at_level(logging.WARNING):
parts = client._convert_message_contents([content], {})
assert parts == []
assert any("base64" in r.message for r in caplog.records)
def test_data_uri_media_type_parameters_are_stripped() -> None:
"""Parameters in a data URI media type (e.g. charset) are dropped before reaching Gemini."""
client, _ = _make_gemini_client()
content = Content.from_uri(uri="data:text/plain;charset=utf-8;base64,aGVsbG8=")
assert content.type == "data"
parts = client._convert_message_contents([content], {})
assert len(parts) == 1
assert parts[0].inline_data is not None
assert parts[0].inline_data.mime_type == "text/plain"
def test_data_uri_media_type_detected_from_bytes_when_missing() -> None:
"""When a data URI has no media_type, it is detected from the decoded bytes' magic bytes."""
import base64
client, _ = _make_gemini_client()
png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
content = Content.from_text("placeholder")
content.type = "data" # type: ignore[assignment]
content.uri = f"data:;base64,{base64.b64encode(png).decode()}"
content.media_type = None
parts = client._convert_message_contents([content], {})
assert len(parts) == 1
assert parts[0].inline_data is not None
assert parts[0].inline_data.mime_type == "image/png"
def test_external_uri_without_inferable_media_type_is_passed_through(caplog: pytest.LogCaptureFixture) -> None:
"""A URI with no media_type and no guessable extension is sent as file_data without crashing."""
client, _ = _make_gemini_client()
content = Content.from_uri(uri="https://api.example.com/files/123")
assert content.type == "uri"
assert content.media_type is None
with caplog.at_level(logging.WARNING):
parts = client._convert_message_contents([content], {})
assert len(parts) == 1
assert parts[0].file_data is not None
assert parts[0].file_data.file_uri == "https://api.example.com/files/123"
assert parts[0].file_data.mime_type is None
assert any("media_type" in r.message for r in caplog.records)
# code execution parts