Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
interactions:
- request:
body: '{"contents": [{"parts": [{"inlineData": {"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==",
"mimeType": "image/png"}}, {"text": "What color is this image?"}], "role": "user"}],
"generationConfig": {"maxOutputTokens": 150}}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2611607'
Content-Type:
- application/json
Host:
- generativelanguage.googleapis.com
user-agent:
- google-genai-sdk/1.70.0 gl-python/3.13.3
x-goog-api-client:
- google-genai-sdk/1.70.0 gl-python/3.13.3
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-001:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"text\": \"The image is predominantly blue.\"\n
\ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\":
\"STOP\",\n \"avgLogprobs\": -0.39939455191294354\n }\n ],\n \"usageMetadata\":
{\n \"promptTokenCount\": 1296,\n \"candidatesTokenCount\": 6,\n \"totalTokenCount\":
1302,\n \"promptTokensDetails\": [\n {\n \"modality\": \"IMAGE\",\n
\ \"tokenCount\": 1290\n },\n {\n \"modality\": \"TEXT\",\n
\ \"tokenCount\": 6\n }\n ],\n \"candidatesTokensDetails\":
[\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 6\n }\n
\ ]\n },\n \"modelVersion\": \"gemini-2.0-flash-001\",\n \"responseId\":
\"4qzNaamuO7Gx1MkPtOGkwQU\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 01 Apr 2026 23:40:19 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=2700
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Gemini-Service-Tier:
- standard
X-XSS-Protection:
- '0'
content-length:
- '756'
status:
code: 200
message: OK
version: 1
95 changes: 95 additions & 0 deletions py/src/braintrust/integrations/google_genai/test_google_genai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,6 +406,60 @@ def test_document_input(memory_logger):
_assert_metrics_are_valid(span["metrics"], start, end)


@pytest.mark.vcr
def test_image_input_wrapped_in_content(memory_logger):
"""Verify binary Parts inside a Content wrapper are traced as attachments.

This is a regression test for the case where the user passes a
``types.Content(parts=[Part.from_bytes(...), ...])`` instead of a flat
list of Part objects. Previously the Content object was returned
un-serialized, leaking raw bytes into the logged span.
"""
assert not memory_logger.pop()

image_data = (FIXTURES_DIR / "test-image.png").read_bytes()

client = Client()
start = time.time()
response = client.models.generate_content(
model=MODEL,
contents=[
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
),
],
config=types.GenerateContentConfig(
max_output_tokens=150,
),
)
end = time.time()

assert response.text

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == MODEL

# The contents list should contain one serialized Content dict.
contents = span["input"]["contents"]
assert len(contents) == 1
content = contents[0]
assert content["role"] == "user"
assert len(content["parts"]) == 2

# Binary part must be an attachment, not raw bytes.
_assert_attachment_part(content["parts"][0], content_type="image/png", filename="file.png")
assert content["parts"][1] == {"text": "What color is this image?"}
_assert_binary_not_logged(span, image_data)
assert span["output"]
_assert_metrics_are_valid(span["metrics"], start, end)


# Test 3: Tool Use (Sync)
@pytest.mark.vcr
@pytest.mark.parametrize(
Expand DownExpand Up@@ -1073,6 +1127,47 @@ class InteractionPayload(BaseModel):
assert materialized["media"]["image_url"]["url"] is materialized["media"]["data"]


def test_serialize_content_item_with_content_and_binary_part():
"""Content objects wrapping binary Parts must produce Attachment objects.

_serialize_content_item only replaces binary inline_data with Attachments;
non-binary parts are left as-is for bt_safe_deep_copy to serialise later.
"""
from braintrust.integrations.google_genai.tracing import _serialize_content_item
from braintrust.logger import Attachment

image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 # small fake PNG bytes
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
)

serialized = _serialize_content_item(content)

# The Content wrapper must be preserved with role + serialized parts.
assert isinstance(serialized, dict), f"Expected dict, got {type(serialized)}"
assert serialized["role"] == "user"
assert isinstance(serialized.get("parts"), list)
assert len(serialized["parts"]) == 2

# The binary part must have been converted to an attachment – not left as
# raw bytes or a model_dump of inline_data.
binary_part = serialized["parts"][0]
assert "image_url" in binary_part, f"Expected image_url key in binary part, got keys: {list(binary_part.keys())}"
attachment = binary_part["image_url"]["url"]
assert isinstance(attachment, Attachment), f"Expected Attachment, got {type(attachment)}"
assert attachment.reference["content_type"] == "image/png"
assert attachment.reference["filename"] == "file.png"

# The text part is left as the original Part object — bt_safe_deep_copy
# will serialise it downstream.
text_part = serialized["parts"][1]
assert getattr(text_part, "text", None) == "What color is this image?"


GROUNDING_MODEL = "gemini-2.0-flash-001"


Expand Down
104 changes: 70 additions & 34 deletions py/src/braintrust/integrations/google_genai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,55 +78,91 @@ def _serialize_input(api_client: Any, input: dict[str, Any]) -> dict[str, Any]:


def _serialize_contents(contents: Any) -> Any:
"""Serialize contents, converting binary data to base64-encoded data URLs."""
"""Serialize contents, converting binary inline_data into attachments.

Most of the heavy lifting (Pydantic model_dump, deep-copy, etc.) is
handled downstream by ``bt_safe_deep_copy``. This pass only needs to
walk the Content/Part tree and replace binary ``inline_data`` with
:class:`Attachment` objects so the background logger can upload them.
"""
if contents is None:
return None

# Handle list of contents
if isinstance(contents, list):
return [_serialize_content_item(item) for item in contents]

# Handle single content item
return _serialize_content_item(contents)


def _serialize_content_item(item: Any) -> Any:
"""Serialize a single content item, handling binary data."""
# If it's already a dict, return as-is
if isinstance(item, dict):
"""Replace binary inline_data inside a content item with Attachments.

Content-like objects (with ``parts``) are recursed into so nested
binary data is converted. Everything else is returned as-is for
``bt_safe_deep_copy`` to serialise later.
"""
if item is None or isinstance(item, (str, int, float, bool)):
return item

# Handle Part objects from google.genai
if hasattr(item, "__class__") and item.__class__.__name__ == "Part":
# Try to extract the data from the Part
if hasattr(item, "text") and item.text is not None:
return {"text": item.text}
elif hasattr(item, "inline_data"):
# Handle binary data (e.g., images)
inline_data = item.inline_data
if hasattr(inline_data, "data") and hasattr(inline_data, "mime_type"):
# Convert bytes to Attachment
data = inline_data.data
mime_type = inline_data.mime_type

# Ensure data is bytes
if isinstance(data, bytes):
resolved_attachment = _materialize_attachment(data, mime_type=mime_type, prefix="file")
if resolved_attachment is not None:
return resolved_attachment.multimodal_part_payload

# Try to use built-in serialization if available
if hasattr(item, "model_dump"):
return item.model_dump()
elif hasattr(item, "dump"):
return item.dump()
elif hasattr(item, "to_dict"):
return item.to_dict()

# Return the item as-is if we can't serialize it
# Content-like wrapper (has "parts") — recurse into each part.
parts = _get_parts(item)
if parts is not None:
serialized_parts = [_serialize_content_item(p) for p in parts]
if isinstance(item, dict):
return {**item, "parts": serialized_parts}
# Object form (e.g. types.Content): build a minimal dict so that
# the replaced Attachment parts survive bt_safe_deep_copy.
result: dict[str, Any] = {"parts": serialized_parts}
role = getattr(item, "role", None)
if role is not None:
result["role"] = role
return result

# Leaf part — replace binary inline_data with an Attachment if present.
resolved = _try_materialize_inline_data(item)
if resolved is not None:
return resolved

# No binary data — return as-is; bt_safe_deep_copy handles the rest.
return item


def _get_parts(item: Any) -> list[Any] | None:
"""Extract the ``parts`` list from a Content-like object or dict, or None."""
if isinstance(item, dict):
parts = item.get("parts")
return parts if isinstance(parts, list) else None
# Object with .parts that is not itself a Part.
if getattr(getattr(item, "__class__", None), "__name__", None) == "Part":
return None
parts = getattr(item, "parts", None)
return parts if isinstance(parts, list) else None


def _try_materialize_inline_data(item: Any) -> Any | None:
"""If *item* carries binary ``inline_data``, convert it to an attachment payload."""
if isinstance(item, dict):
inline_data = item.get("inline_data") or item.get("inlineData")
else:
inline_data = getattr(item, "inline_data", None)

if inline_data is None:
return None

if isinstance(inline_data, dict):
data = inline_data.get("data")
mime_type = inline_data.get("mime_type") or inline_data.get("mimeType")
else:
data = getattr(inline_data, "data", None)
mime_type = getattr(inline_data, "mime_type", None)

if not isinstance(data, bytes) or not isinstance(mime_type, str):
return None

resolved = _materialize_attachment(data, mime_type=mime_type, prefix="file")
return resolved.multimodal_part_payload if resolved is not None else None


def _serialize_tools(api_client: Any, input: Any | None) -> Any | None:
try:
from google.genai.models import (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
interactions:
- request:
body: '{"contents": [{"parts": [{"inlineData": {"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==",
"mimeType": "image/png"}}, {"text": "What color is this image?"}], "role": "user"}],
"generationConfig": {"maxOutputTokens": 150}}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2611607'
Content-Type:
- application/json
Host:
- generativelanguage.googleapis.com
user-agent:
- google-genai-sdk/1.70.0 gl-python/3.13.3
x-goog-api-client:
- google-genai-sdk/1.70.0 gl-python/3.13.3
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-001:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"text\": \"The image is predominantly blue.\"\n
\ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\":
\"STOP\",\n \"avgLogprobs\": -0.39939455191294354\n }\n ],\n \"usageMetadata\":
{\n \"promptTokenCount\": 1296,\n \"candidatesTokenCount\": 6,\n \"totalTokenCount\":
1302,\n \"promptTokensDetails\": [\n {\n \"modality\": \"IMAGE\",\n
\ \"tokenCount\": 1290\n },\n {\n \"modality\": \"TEXT\",\n
\ \"tokenCount\": 6\n }\n ],\n \"candidatesTokensDetails\":
[\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 6\n }\n
\ ]\n },\n \"modelVersion\": \"gemini-2.0-flash-001\",\n \"responseId\":
\"4qzNaamuO7Gx1MkPtOGkwQU\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 01 Apr 2026 23:40:19 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=2700
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Gemini-Service-Tier:
- standard
X-XSS-Protection:
- '0'
content-length:
- '756'
status:
code: 200
message: OK
version: 1
95 changes: 95 additions & 0 deletions py/src/braintrust/integrations/google_genai/test_google_genai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,6 +406,60 @@ def test_document_input(memory_logger):
_assert_metrics_are_valid(span["metrics"], start, end)


@pytest.mark.vcr
def test_image_input_wrapped_in_content(memory_logger):
"""Verify binary Parts inside a Content wrapper are traced as attachments.

This is a regression test for the case where the user passes a
``types.Content(parts=[Part.from_bytes(...), ...])`` instead of a flat
list of Part objects. Previously the Content object was returned
un-serialized, leaking raw bytes into the logged span.
"""
assert not memory_logger.pop()

image_data = (FIXTURES_DIR / "test-image.png").read_bytes()

client = Client()
start = time.time()
response = client.models.generate_content(
model=MODEL,
contents=[
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
),
],
config=types.GenerateContentConfig(
max_output_tokens=150,
),
)
end = time.time()

assert response.text

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == MODEL

# The contents list should contain one serialized Content dict.
contents = span["input"]["contents"]
assert len(contents) == 1
content = contents[0]
assert content["role"] == "user"
assert len(content["parts"]) == 2

# Binary part must be an attachment, not raw bytes.
_assert_attachment_part(content["parts"][0], content_type="image/png", filename="file.png")
assert content["parts"][1] == {"text": "What color is this image?"}
_assert_binary_not_logged(span, image_data)
assert span["output"]
_assert_metrics_are_valid(span["metrics"], start, end)


# Test 3: Tool Use (Sync)
@pytest.mark.vcr
@pytest.mark.parametrize(
Expand DownExpand Up@@ -1073,6 +1127,47 @@ class InteractionPayload(BaseModel):
assert materialized["media"]["image_url"]["url"] is materialized["media"]["data"]


def test_serialize_content_item_with_content_and_binary_part():
"""Content objects wrapping binary Parts must produce Attachment objects.

_serialize_content_item only replaces binary inline_data with Attachments;
non-binary parts are left as-is for bt_safe_deep_copy to serialise later.
"""
from braintrust.integrations.google_genai.tracing import _serialize_content_item
from braintrust.logger import Attachment

image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 # small fake PNG bytes
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
)

serialized = _serialize_content_item(content)

# The Content wrapper must be preserved with role + serialized parts.
assert isinstance(serialized, dict), f"Expected dict, got {type(serialized)}"
assert serialized["role"] == "user"
assert isinstance(serialized.get("parts"), list)
assert len(serialized["parts"]) == 2

# The binary part must have been converted to an attachment – not left as
# raw bytes or a model_dump of inline_data.
binary_part = serialized["parts"][0]
assert "image_url" in binary_part, f"Expected image_url key in binary part, got keys: {list(binary_part.keys())}"
attachment = binary_part["image_url"]["url"]
assert isinstance(attachment, Attachment), f"Expected Attachment, got {type(attachment)}"
assert attachment.reference["content_type"] == "image/png"
assert attachment.reference["filename"] == "file.png"

# The text part is left as the original Part object — bt_safe_deep_copy
# will serialise it downstream.
text_part = serialized["parts"][1]
assert getattr(text_part, "text", None) == "What color is this image?"


GROUNDING_MODEL = "gemini-2.0-flash-001"


Expand Down
104 changes: 70 additions & 34 deletions py/src/braintrust/integrations/google_genai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,55 +78,91 @@ def _serialize_input(api_client: Any, input: dict[str, Any]) -> dict[str, Any]:


def _serialize_contents(contents: Any) -> Any:
"""Serialize contents, converting binary data to base64-encoded data URLs."""
"""Serialize contents, converting binary inline_data into attachments.

Most of the heavy lifting (Pydantic model_dump, deep-copy, etc.) is
handled downstream by ``bt_safe_deep_copy``. This pass only needs to
walk the Content/Part tree and replace binary ``inline_data`` with
:class:`Attachment` objects so the background logger can upload them.
"""
if contents is None:
return None

# Handle list of contents
if isinstance(contents, list):
return [_serialize_content_item(item) for item in contents]

# Handle single content item
return _serialize_content_item(contents)


def _serialize_content_item(item: Any) -> Any:
"""Serialize a single content item, handling binary data."""
# If it's already a dict, return as-is
if isinstance(item, dict):
"""Replace binary inline_data inside a content item with Attachments.

Content-like objects (with ``parts``) are recursed into so nested
binary data is converted. Everything else is returned as-is for
``bt_safe_deep_copy`` to serialise later.
"""
if item is None or isinstance(item, (str, int, float, bool)):
return item

# Handle Part objects from google.genai
if hasattr(item, "__class__") and item.__class__.__name__ == "Part":
# Try to extract the data from the Part
if hasattr(item, "text") and item.text is not None:
return {"text": item.text}
elif hasattr(item, "inline_data"):
# Handle binary data (e.g., images)
inline_data = item.inline_data
if hasattr(inline_data, "data") and hasattr(inline_data, "mime_type"):
# Convert bytes to Attachment
data = inline_data.data
mime_type = inline_data.mime_type

# Ensure data is bytes
if isinstance(data, bytes):
resolved_attachment = _materialize_attachment(data, mime_type=mime_type, prefix="file")
if resolved_attachment is not None:
return resolved_attachment.multimodal_part_payload

# Try to use built-in serialization if available
if hasattr(item, "model_dump"):
return item.model_dump()
elif hasattr(item, "dump"):
return item.dump()
elif hasattr(item, "to_dict"):
return item.to_dict()

# Return the item as-is if we can't serialize it
# Content-like wrapper (has "parts") — recurse into each part.
parts = _get_parts(item)
if parts is not None:
serialized_parts = [_serialize_content_item(p) for p in parts]
if isinstance(item, dict):
return {**item, "parts": serialized_parts}
# Object form (e.g. types.Content): build a minimal dict so that
# the replaced Attachment parts survive bt_safe_deep_copy.
result: dict[str, Any] = {"parts": serialized_parts}
role = getattr(item, "role", None)
if role is not None:
result["role"] = role
return result

# Leaf part — replace binary inline_data with an Attachment if present.
resolved = _try_materialize_inline_data(item)
if resolved is not None:
return resolved

# No binary data — return as-is; bt_safe_deep_copy handles the rest.
return item


def _get_parts(item: Any) -> list[Any] | None:
"""Extract the ``parts`` list from a Content-like object or dict, or None."""
if isinstance(item, dict):
parts = item.get("parts")
return parts if isinstance(parts, list) else None
# Object with .parts that is not itself a Part.
if getattr(getattr(item, "__class__", None), "__name__", None) == "Part":
return None
parts = getattr(item, "parts", None)
return parts if isinstance(parts, list) else None


def _try_materialize_inline_data(item: Any) -> Any | None:
"""If *item* carries binary ``inline_data``, convert it to an attachment payload."""
if isinstance(item, dict):
inline_data = item.get("inline_data") or item.get("inlineData")
else:
inline_data = getattr(item, "inline_data", None)

if inline_data is None:
return None

if isinstance(inline_data, dict):
data = inline_data.get("data")
mime_type = inline_data.get("mime_type") or inline_data.get("mimeType")
else:
data = getattr(inline_data, "data", None)
mime_type = getattr(inline_data, "mime_type", None)

if not isinstance(data, bytes) or not isinstance(mime_type, str):
return None

resolved = _materialize_attachment(data, mime_type=mime_type, prefix="file")
return resolved.multimodal_part_payload if resolved is not None else None


def _serialize_tools(api_client: Any, input: Any | None) -> Any | None:
try:
from google.genai.models import (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
interactions:
- request:
body: '{"contents": [{"parts": [{"inlineData": {"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==",
"mimeType": "image/png"}}, {"text": "What color is this image?"}], "role": "user"}],
"generationConfig": {"maxOutputTokens": 150}}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2611607'
Content-Type:
- application/json
Host:
- generativelanguage.googleapis.com
user-agent:
- google-genai-sdk/1.70.0 gl-python/3.13.3
x-goog-api-client:
- google-genai-sdk/1.70.0 gl-python/3.13.3
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-001:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"text\": \"The image is predominantly blue.\"\n
\ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\":
\"STOP\",\n \"avgLogprobs\": -0.39939455191294354\n }\n ],\n \"usageMetadata\":
{\n \"promptTokenCount\": 1296,\n \"candidatesTokenCount\": 6,\n \"totalTokenCount\":
1302,\n \"promptTokensDetails\": [\n {\n \"modality\": \"IMAGE\",\n
\ \"tokenCount\": 1290\n },\n {\n \"modality\": \"TEXT\",\n
\ \"tokenCount\": 6\n }\n ],\n \"candidatesTokensDetails\":
[\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 6\n }\n
\ ]\n },\n \"modelVersion\": \"gemini-2.0-flash-001\",\n \"responseId\":
\"4qzNaamuO7Gx1MkPtOGkwQU\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 01 Apr 2026 23:40:19 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=2700
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Gemini-Service-Tier:
- standard
X-XSS-Protection:
- '0'
content-length:
- '756'
status:
code: 200
message: OK
version: 1
95 changes: 95 additions & 0 deletions py/src/braintrust/integrations/google_genai/test_google_genai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,6 +406,60 @@ def test_document_input(memory_logger):
_assert_metrics_are_valid(span["metrics"], start, end)


@pytest.mark.vcr
def test_image_input_wrapped_in_content(memory_logger):
"""Verify binary Parts inside a Content wrapper are traced as attachments.

This is a regression test for the case where the user passes a
``types.Content(parts=[Part.from_bytes(...), ...])`` instead of a flat
list of Part objects. Previously the Content object was returned
un-serialized, leaking raw bytes into the logged span.
"""
assert not memory_logger.pop()

image_data = (FIXTURES_DIR / "test-image.png").read_bytes()

client = Client()
start = time.time()
response = client.models.generate_content(
model=MODEL,
contents=[
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
),
],
config=types.GenerateContentConfig(
max_output_tokens=150,
),
)
end = time.time()

assert response.text

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == MODEL

# The contents list should contain one serialized Content dict.
contents = span["input"]["contents"]
assert len(contents) == 1
content = contents[0]
assert content["role"] == "user"
assert len(content["parts"]) == 2

# Binary part must be an attachment, not raw bytes.
_assert_attachment_part(content["parts"][0], content_type="image/png", filename="file.png")
assert content["parts"][1] == {"text": "What color is this image?"}
_assert_binary_not_logged(span, image_data)
assert span["output"]
_assert_metrics_are_valid(span["metrics"], start, end)


# Test 3: Tool Use (Sync)
@pytest.mark.vcr
@pytest.mark.parametrize(
Expand DownExpand Up@@ -1073,6 +1127,47 @@ class InteractionPayload(BaseModel):
assert materialized["media"]["image_url"]["url"] is materialized["media"]["data"]


def test_serialize_content_item_with_content_and_binary_part():
"""Content objects wrapping binary Parts must produce Attachment objects.

_serialize_content_item only replaces binary inline_data with Attachments;
non-binary parts are left as-is for bt_safe_deep_copy to serialise later.
"""
from braintrust.integrations.google_genai.tracing import _serialize_content_item
from braintrust.logger import Attachment

image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 # small fake PNG bytes
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
)

serialized = _serialize_content_item(content)

# The Content wrapper must be preserved with role + serialized parts.
assert isinstance(serialized, dict), f"Expected dict, got {type(serialized)}"
assert serialized["role"] == "user"
assert isinstance(serialized.get("parts"), list)
assert len(serialized["parts"]) == 2

# The binary part must have been converted to an attachment – not left as
# raw bytes or a model_dump of inline_data.
binary_part = serialized["parts"][0]
assert "image_url" in binary_part, f"Expected image_url key in binary part, got keys: {list(binary_part.keys())}"
attachment = binary_part["image_url"]["url"]
assert isinstance(attachment, Attachment), f"Expected Attachment, got {type(attachment)}"
assert attachment.reference["content_type"] == "image/png"
assert attachment.reference["filename"] == "file.png"

# The text part is left as the original Part object — bt_safe_deep_copy
# will serialise it downstream.
text_part = serialized["parts"][1]
assert getattr(text_part, "text", None) == "What color is this image?"


GROUNDING_MODEL = "gemini-2.0-flash-001"


Expand Down
104 changes: 70 additions & 34 deletions py/src/braintrust/integrations/google_genai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,55 +78,91 @@ def _serialize_input(api_client: Any, input: dict[str, Any]) -> dict[str, Any]:


def _serialize_contents(contents: Any) -> Any:
"""Serialize contents, converting binary data to base64-encoded data URLs."""
"""Serialize contents, converting binary inline_data into attachments.

Most of the heavy lifting (Pydantic model_dump, deep-copy, etc.) is
handled downstream by ``bt_safe_deep_copy``. This pass only needs to
walk the Content/Part tree and replace binary ``inline_data`` with
:class:`Attachment` objects so the background logger can upload them.
"""
if contents is None:
return None

# Handle list of contents
if isinstance(contents, list):
return [_serialize_content_item(item) for item in contents]

# Handle single content item
return _serialize_content_item(contents)


def _serialize_content_item(item: Any) -> Any:
"""Serialize a single content item, handling binary data."""
# If it's already a dict, return as-is
if isinstance(item, dict):
"""Replace binary inline_data inside a content item with Attachments.

Content-like objects (with ``parts``) are recursed into so nested
binary data is converted. Everything else is returned as-is for
``bt_safe_deep_copy`` to serialise later.
"""
if item is None or isinstance(item, (str, int, float, bool)):
return item

# Handle Part objects from google.genai
if hasattr(item, "__class__") and item.__class__.__name__ == "Part":
# Try to extract the data from the Part
if hasattr(item, "text") and item.text is not None:
return {"text": item.text}
elif hasattr(item, "inline_data"):
# Handle binary data (e.g., images)
inline_data = item.inline_data
if hasattr(inline_data, "data") and hasattr(inline_data, "mime_type"):
# Convert bytes to Attachment
data = inline_data.data
mime_type = inline_data.mime_type

# Ensure data is bytes
if isinstance(data, bytes):
resolved_attachment = _materialize_attachment(data, mime_type=mime_type, prefix="file")
if resolved_attachment is not None:
return resolved_attachment.multimodal_part_payload

# Try to use built-in serialization if available
if hasattr(item, "model_dump"):
return item.model_dump()
elif hasattr(item, "dump"):
return item.dump()
elif hasattr(item, "to_dict"):
return item.to_dict()

# Return the item as-is if we can't serialize it
# Content-like wrapper (has "parts") — recurse into each part.
parts = _get_parts(item)
if parts is not None:
serialized_parts = [_serialize_content_item(p) for p in parts]
if isinstance(item, dict):
return {**item, "parts": serialized_parts}
# Object form (e.g. types.Content): build a minimal dict so that
# the replaced Attachment parts survive bt_safe_deep_copy.
result: dict[str, Any] = {"parts": serialized_parts}
role = getattr(item, "role", None)
if role is not None:
result["role"] = role
return result

# Leaf part — replace binary inline_data with an Attachment if present.
resolved = _try_materialize_inline_data(item)
if resolved is not None:
return resolved

# No binary data — return as-is; bt_safe_deep_copy handles the rest.
return item


def _get_parts(item: Any) -> list[Any] | None:
"""Extract the ``parts`` list from a Content-like object or dict, or None."""
if isinstance(item, dict):
parts = item.get("parts")
return parts if isinstance(parts, list) else None
# Object with .parts that is not itself a Part.
if getattr(getattr(item, "__class__", None), "__name__", None) == "Part":
return None
parts = getattr(item, "parts", None)
return parts if isinstance(parts, list) else None


def _try_materialize_inline_data(item: Any) -> Any | None:
"""If *item* carries binary ``inline_data``, convert it to an attachment payload."""
if isinstance(item, dict):
inline_data = item.get("inline_data") or item.get("inlineData")
else:
inline_data = getattr(item, "inline_data", None)

if inline_data is None:
return None

if isinstance(inline_data, dict):
data = inline_data.get("data")
mime_type = inline_data.get("mime_type") or inline_data.get("mimeType")
else:
data = getattr(inline_data, "data", None)
mime_type = getattr(inline_data, "mime_type", None)

if not isinstance(data, bytes) or not isinstance(mime_type, str):
return None

resolved = _materialize_attachment(data, mime_type=mime_type, prefix="file")
return resolved.multimodal_part_payload if resolved is not None else None


def _serialize_tools(api_client: Any, input: Any | None) -> Any | None:
try:
from google.genai.models import (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
interactions:
- request:
body: '{"contents": [{"parts": [{"inlineData": {"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==",
"mimeType": "image/png"}}, {"text": "What color is this image?"}], "role": "user"}],
"generationConfig": {"maxOutputTokens": 150}}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2611607'
Content-Type:
- application/json
Host:
- generativelanguage.googleapis.com
user-agent:
- google-genai-sdk/1.70.0 gl-python/3.13.3
x-goog-api-client:
- google-genai-sdk/1.70.0 gl-python/3.13.3
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-001:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"text\": \"The image is predominantly blue.\"\n
\ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\":
\"STOP\",\n \"avgLogprobs\": -0.39939455191294354\n }\n ],\n \"usageMetadata\":
{\n \"promptTokenCount\": 1296,\n \"candidatesTokenCount\": 6,\n \"totalTokenCount\":
1302,\n \"promptTokensDetails\": [\n {\n \"modality\": \"IMAGE\",\n
\ \"tokenCount\": 1290\n },\n {\n \"modality\": \"TEXT\",\n
\ \"tokenCount\": 6\n }\n ],\n \"candidatesTokensDetails\":
[\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 6\n }\n
\ ]\n },\n \"modelVersion\": \"gemini-2.0-flash-001\",\n \"responseId\":
\"4qzNaamuO7Gx1MkPtOGkwQU\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 01 Apr 2026 23:40:19 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=2700
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Gemini-Service-Tier:
- standard
X-XSS-Protection:
- '0'
content-length:
- '756'
status:
code: 200
message: OK
version: 1
95 changes: 95 additions & 0 deletions py/src/braintrust/integrations/google_genai/test_google_genai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,6 +406,60 @@ def test_document_input(memory_logger):
_assert_metrics_are_valid(span["metrics"], start, end)


@pytest.mark.vcr
def test_image_input_wrapped_in_content(memory_logger):
"""Verify binary Parts inside a Content wrapper are traced as attachments.

This is a regression test for the case where the user passes a
``types.Content(parts=[Part.from_bytes(...), ...])`` instead of a flat
list of Part objects. Previously the Content object was returned
un-serialized, leaking raw bytes into the logged span.
"""
assert not memory_logger.pop()

image_data = (FIXTURES_DIR / "test-image.png").read_bytes()

client = Client()
start = time.time()
response = client.models.generate_content(
model=MODEL,
contents=[
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
),
],
config=types.GenerateContentConfig(
max_output_tokens=150,
),
)
end = time.time()

assert response.text

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == MODEL

# The contents list should contain one serialized Content dict.
contents = span["input"]["contents"]
assert len(contents) == 1
content = contents[0]
assert content["role"] == "user"
assert len(content["parts"]) == 2

# Binary part must be an attachment, not raw bytes.
_assert_attachment_part(content["parts"][0], content_type="image/png", filename="file.png")
assert content["parts"][1] == {"text": "What color is this image?"}
_assert_binary_not_logged(span, image_data)
assert span["output"]
_assert_metrics_are_valid(span["metrics"], start, end)


# Test 3: Tool Use (Sync)
@pytest.mark.vcr
@pytest.mark.parametrize(
Expand DownExpand Up@@ -1073,6 +1127,47 @@ class InteractionPayload(BaseModel):
assert materialized["media"]["image_url"]["url"] is materialized["media"]["data"]


def test_serialize_content_item_with_content_and_binary_part():
"""Content objects wrapping binary Parts must produce Attachment objects.

_serialize_content_item only replaces binary inline_data with Attachments;
non-binary parts are left as-is for bt_safe_deep_copy to serialise later.
"""
from braintrust.integrations.google_genai.tracing import _serialize_content_item
from braintrust.logger import Attachment

image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 # small fake PNG bytes
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
)

serialized = _serialize_content_item(content)

# The Content wrapper must be preserved with role + serialized parts.
assert isinstance(serialized, dict), f"Expected dict, got {type(serialized)}"
assert serialized["role"] == "user"
assert isinstance(serialized.get("parts"), list)
assert len(serialized["parts"]) == 2

# The binary part must have been converted to an attachment – not left as
# raw bytes or a model_dump of inline_data.
binary_part = serialized["parts"][0]
assert "image_url" in binary_part, f"Expected image_url key in binary part, got keys: {list(binary_part.keys())}"
attachment = binary_part["image_url"]["url"]
assert isinstance(attachment, Attachment), f"Expected Attachment, got {type(attachment)}"
assert attachment.reference["content_type"] == "image/png"
assert attachment.reference["filename"] == "file.png"

# The text part is left as the original Part object — bt_safe_deep_copy
# will serialise it downstream.
text_part = serialized["parts"][1]
assert getattr(text_part, "text", None) == "What color is this image?"


GROUNDING_MODEL = "gemini-2.0-flash-001"


Expand Down
104 changes: 70 additions & 34 deletions py/src/braintrust/integrations/google_genai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,55 +78,91 @@ def _serialize_input(api_client: Any, input: dict[str, Any]) -> dict[str, Any]:


def _serialize_contents(contents: Any) -> Any:
"""Serialize contents, converting binary data to base64-encoded data URLs."""
"""Serialize contents, converting binary inline_data into attachments.

Most of the heavy lifting (Pydantic model_dump, deep-copy, etc.) is
handled downstream by ``bt_safe_deep_copy``. This pass only needs to
walk the Content/Part tree and replace binary ``inline_data`` with
:class:`Attachment` objects so the background logger can upload them.
"""
if contents is None:
return None

# Handle list of contents
if isinstance(contents, list):
return [_serialize_content_item(item) for item in contents]

# Handle single content item
return _serialize_content_item(contents)


def _serialize_content_item(item: Any) -> Any:
"""Serialize a single content item, handling binary data."""
# If it's already a dict, return as-is
if isinstance(item, dict):
"""Replace binary inline_data inside a content item with Attachments.

Content-like objects (with ``parts``) are recursed into so nested
binary data is converted. Everything else is returned as-is for
``bt_safe_deep_copy`` to serialise later.
"""
if item is None or isinstance(item, (str, int, float, bool)):
return item

# Handle Part objects from google.genai
if hasattr(item, "__class__") and item.__class__.__name__ == "Part":
# Try to extract the data from the Part
if hasattr(item, "text") and item.text is not None:
return {"text": item.text}
elif hasattr(item, "inline_data"):
# Handle binary data (e.g., images)
inline_data = item.inline_data
if hasattr(inline_data, "data") and hasattr(inline_data, "mime_type"):
# Convert bytes to Attachment
data = inline_data.data
mime_type = inline_data.mime_type

# Ensure data is bytes
if isinstance(data, bytes):
resolved_attachment = _materialize_attachment(data, mime_type=mime_type, prefix="file")
if resolved_attachment is not None:
return resolved_attachment.multimodal_part_payload

# Try to use built-in serialization if available
if hasattr(item, "model_dump"):
return item.model_dump()
elif hasattr(item, "dump"):
return item.dump()
elif hasattr(item, "to_dict"):
return item.to_dict()

# Return the item as-is if we can't serialize it
# Content-like wrapper (has "parts") — recurse into each part.
parts = _get_parts(item)
if parts is not None:
serialized_parts = [_serialize_content_item(p) for p in parts]
if isinstance(item, dict):
return {**item, "parts": serialized_parts}
# Object form (e.g. types.Content): build a minimal dict so that
# the replaced Attachment parts survive bt_safe_deep_copy.
result: dict[str, Any] = {"parts": serialized_parts}
role = getattr(item, "role", None)
if role is not None:
result["role"] = role
return result

# Leaf part — replace binary inline_data with an Attachment if present.
resolved = _try_materialize_inline_data(item)
if resolved is not None:
return resolved

# No binary data — return as-is; bt_safe_deep_copy handles the rest.
return item


def _get_parts(item: Any) -> list[Any] | None:
"""Extract the ``parts`` list from a Content-like object or dict, or None."""
if isinstance(item, dict):
parts = item.get("parts")
return parts if isinstance(parts, list) else None
# Object with .parts that is not itself a Part.
if getattr(getattr(item, "__class__", None), "__name__", None) == "Part":
return None
parts = getattr(item, "parts", None)
return parts if isinstance(parts, list) else None


def _try_materialize_inline_data(item: Any) -> Any | None:
"""If *item* carries binary ``inline_data``, convert it to an attachment payload."""
if isinstance(item, dict):
inline_data = item.get("inline_data") or item.get("inlineData")
else:
inline_data = getattr(item, "inline_data", None)

if inline_data is None:
return None

if isinstance(inline_data, dict):
data = inline_data.get("data")
mime_type = inline_data.get("mime_type") or inline_data.get("mimeType")
else:
data = getattr(inline_data, "data", None)
mime_type = getattr(inline_data, "mime_type", None)

if not isinstance(data, bytes) or not isinstance(mime_type, str):
return None

resolved = _materialize_attachment(data, mime_type=mime_type, prefix="file")
return resolved.multimodal_part_payload if resolved is not None else None


def _serialize_tools(api_client: Any, input: Any | None) -> Any | None:
try:
from google.genai.models import (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
interactions:
- request:
body: '{"contents": [{"parts": [{"inlineData": {"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==",
"mimeType": "image/png"}}, {"text": "What color is this image?"}], "role": "user"}],
"generationConfig": {"maxOutputTokens": 150}}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2611607'
Content-Type:
- application/json
Host:
- generativelanguage.googleapis.com
user-agent:
- google-genai-sdk/1.70.0 gl-python/3.13.3
x-goog-api-client:
- google-genai-sdk/1.70.0 gl-python/3.13.3
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-001:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"text\": \"The image is predominantly blue.\"\n
\ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\":
\"STOP\",\n \"avgLogprobs\": -0.39939455191294354\n }\n ],\n \"usageMetadata\":
{\n \"promptTokenCount\": 1296,\n \"candidatesTokenCount\": 6,\n \"totalTokenCount\":
1302,\n \"promptTokensDetails\": [\n {\n \"modality\": \"IMAGE\",\n
\ \"tokenCount\": 1290\n },\n {\n \"modality\": \"TEXT\",\n
\ \"tokenCount\": 6\n }\n ],\n \"candidatesTokensDetails\":
[\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 6\n }\n
\ ]\n },\n \"modelVersion\": \"gemini-2.0-flash-001\",\n \"responseId\":
\"4qzNaamuO7Gx1MkPtOGkwQU\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 01 Apr 2026 23:40:19 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=2700
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Gemini-Service-Tier:
- standard
X-XSS-Protection:
- '0'
content-length:
- '756'
status:
code: 200
message: OK
version: 1
95 changes: 95 additions & 0 deletions py/src/braintrust/integrations/google_genai/test_google_genai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,6 +406,60 @@ def test_document_input(memory_logger):
_assert_metrics_are_valid(span["metrics"], start, end)


@pytest.mark.vcr
def test_image_input_wrapped_in_content(memory_logger):
"""Verify binary Parts inside a Content wrapper are traced as attachments.

This is a regression test for the case where the user passes a
``types.Content(parts=[Part.from_bytes(...), ...])`` instead of a flat
list of Part objects. Previously the Content object was returned
un-serialized, leaking raw bytes into the logged span.
"""
assert not memory_logger.pop()

image_data = (FIXTURES_DIR / "test-image.png").read_bytes()

client = Client()
start = time.time()
response = client.models.generate_content(
model=MODEL,
contents=[
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
),
],
config=types.GenerateContentConfig(
max_output_tokens=150,
),
)
end = time.time()

assert response.text

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == MODEL

# The contents list should contain one serialized Content dict.
contents = span["input"]["contents"]
assert len(contents) == 1
content = contents[0]
assert content["role"] == "user"
assert len(content["parts"]) == 2

# Binary part must be an attachment, not raw bytes.
_assert_attachment_part(content["parts"][0], content_type="image/png", filename="file.png")
assert content["parts"][1] == {"text": "What color is this image?"}
_assert_binary_not_logged(span, image_data)
assert span["output"]
_assert_metrics_are_valid(span["metrics"], start, end)


# Test 3: Tool Use (Sync)
@pytest.mark.vcr
@pytest.mark.parametrize(
Expand DownExpand Up@@ -1073,6 +1127,47 @@ class InteractionPayload(BaseModel):
assert materialized["media"]["image_url"]["url"] is materialized["media"]["data"]


def test_serialize_content_item_with_content_and_binary_part():
"""Content objects wrapping binary Parts must produce Attachment objects.

_serialize_content_item only replaces binary inline_data with Attachments;
non-binary parts are left as-is for bt_safe_deep_copy to serialise later.
"""
from braintrust.integrations.google_genai.tracing import _serialize_content_item
from braintrust.logger import Attachment

image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 # small fake PNG bytes
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
)

serialized = _serialize_content_item(content)

# The Content wrapper must be preserved with role + serialized parts.
assert isinstance(serialized, dict), f"Expected dict, got {type(serialized)}"
assert serialized["role"] == "user"
assert isinstance(serialized.get("parts"), list)
assert len(serialized["parts"]) == 2

# The binary part must have been converted to an attachment – not left as
# raw bytes or a model_dump of inline_data.
binary_part = serialized["parts"][0]
assert "image_url" in binary_part, f"Expected image_url key in binary part, got keys: {list(binary_part.keys())}"
attachment = binary_part["image_url"]["url"]
assert isinstance(attachment, Attachment), f"Expected Attachment, got {type(attachment)}"
assert attachment.reference["content_type"] == "image/png"
assert attachment.reference["filename"] == "file.png"

# The text part is left as the original Part object — bt_safe_deep_copy
# will serialise it downstream.
text_part = serialized["parts"][1]
assert getattr(text_part, "text", None) == "What color is this image?"


GROUNDING_MODEL = "gemini-2.0-flash-001"


Expand Down
104 changes: 70 additions & 34 deletions py/src/braintrust/integrations/google_genai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,55 +78,91 @@ def _serialize_input(api_client: Any, input: dict[str, Any]) -> dict[str, Any]:


def _serialize_contents(contents: Any) -> Any:
"""Serialize contents, converting binary data to base64-encoded data URLs."""
"""Serialize contents, converting binary inline_data into attachments.

Most of the heavy lifting (Pydantic model_dump, deep-copy, etc.) is
handled downstream by ``bt_safe_deep_copy``. This pass only needs to
walk the Content/Part tree and replace binary ``inline_data`` with
:class:`Attachment` objects so the background logger can upload them.
"""
if contents is None:
return None

# Handle list of contents
if isinstance(contents, list):
return [_serialize_content_item(item) for item in contents]

# Handle single content item
return _serialize_content_item(contents)


def _serialize_content_item(item: Any) -> Any:
"""Serialize a single content item, handling binary data."""
# If it's already a dict, return as-is
if isinstance(item, dict):
"""Replace binary inline_data inside a content item with Attachments.

Content-like objects (with ``parts``) are recursed into so nested
binary data is converted. Everything else is returned as-is for
``bt_safe_deep_copy`` to serialise later.
"""
if item is None or isinstance(item, (str, int, float, bool)):
return item

# Handle Part objects from google.genai
if hasattr(item, "__class__") and item.__class__.__name__ == "Part":
# Try to extract the data from the Part
if hasattr(item, "text") and item.text is not None:
return {"text": item.text}
elif hasattr(item, "inline_data"):
# Handle binary data (e.g., images)
inline_data = item.inline_data
if hasattr(inline_data, "data") and hasattr(inline_data, "mime_type"):
# Convert bytes to Attachment
data = inline_data.data
mime_type = inline_data.mime_type

# Ensure data is bytes
if isinstance(data, bytes):
resolved_attachment = _materialize_attachment(data, mime_type=mime_type, prefix="file")
if resolved_attachment is not None:
return resolved_attachment.multimodal_part_payload

# Try to use built-in serialization if available
if hasattr(item, "model_dump"):
return item.model_dump()
elif hasattr(item, "dump"):
return item.dump()
elif hasattr(item, "to_dict"):
return item.to_dict()

# Return the item as-is if we can't serialize it
# Content-like wrapper (has "parts") — recurse into each part.
parts = _get_parts(item)
if parts is not None:
serialized_parts = [_serialize_content_item(p) for p in parts]
if isinstance(item, dict):
return {**item, "parts": serialized_parts}
# Object form (e.g. types.Content): build a minimal dict so that
# the replaced Attachment parts survive bt_safe_deep_copy.
result: dict[str, Any] = {"parts": serialized_parts}
role = getattr(item, "role", None)
if role is not None:
result["role"] = role
return result

# Leaf part — replace binary inline_data with an Attachment if present.
resolved = _try_materialize_inline_data(item)
if resolved is not None:
return resolved

# No binary data — return as-is; bt_safe_deep_copy handles the rest.
return item


def _get_parts(item: Any) -> list[Any] | None:
"""Extract the ``parts`` list from a Content-like object or dict, or None."""
if isinstance(item, dict):
parts = item.get("parts")
return parts if isinstance(parts, list) else None
# Object with .parts that is not itself a Part.
if getattr(getattr(item, "__class__", None), "__name__", None) == "Part":
return None
parts = getattr(item, "parts", None)
return parts if isinstance(parts, list) else None


def _try_materialize_inline_data(item: Any) -> Any | None:
"""If *item* carries binary ``inline_data``, convert it to an attachment payload."""
if isinstance(item, dict):
inline_data = item.get("inline_data") or item.get("inlineData")
else:
inline_data = getattr(item, "inline_data", None)

if inline_data is None:
return None

if isinstance(inline_data, dict):
data = inline_data.get("data")
mime_type = inline_data.get("mime_type") or inline_data.get("mimeType")
else:
data = getattr(inline_data, "data", None)
mime_type = getattr(inline_data, "mime_type", None)

if not isinstance(data, bytes) or not isinstance(mime_type, str):
return None

resolved = _materialize_attachment(data, mime_type=mime_type, prefix="file")
return resolved.multimodal_part_payload if resolved is not None else None


def _serialize_tools(api_client: Any, input: Any | None) -> Any | None:
try:
from google.genai.models import (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
interactions:
- request:
body: '{"contents": [{"parts": [{"inlineData": {"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==",
"mimeType": "image/png"}}, {"text": "What color is this image?"}], "role": "user"}],
"generationConfig": {"maxOutputTokens": 150}}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2611607'
Content-Type:
- application/json
Host:
- generativelanguage.googleapis.com
user-agent:
- google-genai-sdk/1.70.0 gl-python/3.13.3
x-goog-api-client:
- google-genai-sdk/1.70.0 gl-python/3.13.3
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-001:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"text\": \"The image is predominantly blue.\"\n
\ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\":
\"STOP\",\n \"avgLogprobs\": -0.39939455191294354\n }\n ],\n \"usageMetadata\":
{\n \"promptTokenCount\": 1296,\n \"candidatesTokenCount\": 6,\n \"totalTokenCount\":
1302,\n \"promptTokensDetails\": [\n {\n \"modality\": \"IMAGE\",\n
\ \"tokenCount\": 1290\n },\n {\n \"modality\": \"TEXT\",\n
\ \"tokenCount\": 6\n }\n ],\n \"candidatesTokensDetails\":
[\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 6\n }\n
\ ]\n },\n \"modelVersion\": \"gemini-2.0-flash-001\",\n \"responseId\":
\"4qzNaamuO7Gx1MkPtOGkwQU\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 01 Apr 2026 23:40:19 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=2700
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Gemini-Service-Tier:
- standard
X-XSS-Protection:
- '0'
content-length:
- '756'
status:
code: 200
message: OK
version: 1
95 changes: 95 additions & 0 deletions py/src/braintrust/integrations/google_genai/test_google_genai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,6 +406,60 @@ def test_document_input(memory_logger):
_assert_metrics_are_valid(span["metrics"], start, end)


@pytest.mark.vcr
def test_image_input_wrapped_in_content(memory_logger):
"""Verify binary Parts inside a Content wrapper are traced as attachments.

This is a regression test for the case where the user passes a
``types.Content(parts=[Part.from_bytes(...), ...])`` instead of a flat
list of Part objects. Previously the Content object was returned
un-serialized, leaking raw bytes into the logged span.
"""
assert not memory_logger.pop()

image_data = (FIXTURES_DIR / "test-image.png").read_bytes()

client = Client()
start = time.time()
response = client.models.generate_content(
model=MODEL,
contents=[
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
),
],
config=types.GenerateContentConfig(
max_output_tokens=150,
),
)
end = time.time()

assert response.text

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == MODEL

# The contents list should contain one serialized Content dict.
contents = span["input"]["contents"]
assert len(contents) == 1
content = contents[0]
assert content["role"] == "user"
assert len(content["parts"]) == 2

# Binary part must be an attachment, not raw bytes.
_assert_attachment_part(content["parts"][0], content_type="image/png", filename="file.png")
assert content["parts"][1] == {"text": "What color is this image?"}
_assert_binary_not_logged(span, image_data)
assert span["output"]
_assert_metrics_are_valid(span["metrics"], start, end)


# Test 3: Tool Use (Sync)
@pytest.mark.vcr
@pytest.mark.parametrize(
Expand DownExpand Up@@ -1073,6 +1127,47 @@ class InteractionPayload(BaseModel):
assert materialized["media"]["image_url"]["url"] is materialized["media"]["data"]


def test_serialize_content_item_with_content_and_binary_part():
"""Content objects wrapping binary Parts must produce Attachment objects.

_serialize_content_item only replaces binary inline_data with Attachments;
non-binary parts are left as-is for bt_safe_deep_copy to serialise later.
"""
from braintrust.integrations.google_genai.tracing import _serialize_content_item
from braintrust.logger import Attachment

image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 # small fake PNG bytes
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
)

serialized = _serialize_content_item(content)

# The Content wrapper must be preserved with role + serialized parts.
assert isinstance(serialized, dict), f"Expected dict, got {type(serialized)}"
assert serialized["role"] == "user"
assert isinstance(serialized.get("parts"), list)
assert len(serialized["parts"]) == 2

# The binary part must have been converted to an attachment – not left as
# raw bytes or a model_dump of inline_data.
binary_part = serialized["parts"][0]
assert "image_url" in binary_part, f"Expected image_url key in binary part, got keys: {list(binary_part.keys())}"
attachment = binary_part["image_url"]["url"]
assert isinstance(attachment, Attachment), f"Expected Attachment, got {type(attachment)}"
assert attachment.reference["content_type"] == "image/png"
assert attachment.reference["filename"] == "file.png"

# The text part is left as the original Part object — bt_safe_deep_copy
# will serialise it downstream.
text_part = serialized["parts"][1]
assert getattr(text_part, "text", None) == "What color is this image?"


GROUNDING_MODEL = "gemini-2.0-flash-001"


Expand Down
104 changes: 70 additions & 34 deletions py/src/braintrust/integrations/google_genai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,55 +78,91 @@ def _serialize_input(api_client: Any, input: dict[str, Any]) -> dict[str, Any]:


def _serialize_contents(contents: Any) -> Any:
"""Serialize contents, converting binary data to base64-encoded data URLs."""
"""Serialize contents, converting binary inline_data into attachments.

Most of the heavy lifting (Pydantic model_dump, deep-copy, etc.) is
handled downstream by ``bt_safe_deep_copy``. This pass only needs to
walk the Content/Part tree and replace binary ``inline_data`` with
:class:`Attachment` objects so the background logger can upload them.
"""
if contents is None:
return None

# Handle list of contents
if isinstance(contents, list):
return [_serialize_content_item(item) for item in contents]

# Handle single content item
return _serialize_content_item(contents)


def _serialize_content_item(item: Any) -> Any:
"""Serialize a single content item, handling binary data."""
# If it's already a dict, return as-is
if isinstance(item, dict):
"""Replace binary inline_data inside a content item with Attachments.

Content-like objects (with ``parts``) are recursed into so nested
binary data is converted. Everything else is returned as-is for
``bt_safe_deep_copy`` to serialise later.
"""
if item is None or isinstance(item, (str, int, float, bool)):
return item

# Handle Part objects from google.genai
if hasattr(item, "__class__") and item.__class__.__name__ == "Part":
# Try to extract the data from the Part
if hasattr(item, "text") and item.text is not None:
return {"text": item.text}
elif hasattr(item, "inline_data"):
# Handle binary data (e.g., images)
inline_data = item.inline_data
if hasattr(inline_data, "data") and hasattr(inline_data, "mime_type"):
# Convert bytes to Attachment
data = inline_data.data
mime_type = inline_data.mime_type

# Ensure data is bytes
if isinstance(data, bytes):
resolved_attachment = _materialize_attachment(data, mime_type=mime_type, prefix="file")
if resolved_attachment is not None:
return resolved_attachment.multimodal_part_payload

# Try to use built-in serialization if available
if hasattr(item, "model_dump"):
return item.model_dump()
elif hasattr(item, "dump"):
return item.dump()
elif hasattr(item, "to_dict"):
return item.to_dict()

# Return the item as-is if we can't serialize it
# Content-like wrapper (has "parts") — recurse into each part.
parts = _get_parts(item)
if parts is not None:
serialized_parts = [_serialize_content_item(p) for p in parts]
if isinstance(item, dict):
return {**item, "parts": serialized_parts}
# Object form (e.g. types.Content): build a minimal dict so that
# the replaced Attachment parts survive bt_safe_deep_copy.
result: dict[str, Any] = {"parts": serialized_parts}
role = getattr(item, "role", None)
if role is not None:
result["role"] = role
return result

# Leaf part — replace binary inline_data with an Attachment if present.
resolved = _try_materialize_inline_data(item)
if resolved is not None:
return resolved

# No binary data — return as-is; bt_safe_deep_copy handles the rest.
return item


def _get_parts(item: Any) -> list[Any] | None:
"""Extract the ``parts`` list from a Content-like object or dict, or None."""
if isinstance(item, dict):
parts = item.get("parts")
return parts if isinstance(parts, list) else None
# Object with .parts that is not itself a Part.
if getattr(getattr(item, "__class__", None), "__name__", None) == "Part":
return None
parts = getattr(item, "parts", None)
return parts if isinstance(parts, list) else None


def _try_materialize_inline_data(item: Any) -> Any | None:
"""If *item* carries binary ``inline_data``, convert it to an attachment payload."""
if isinstance(item, dict):
inline_data = item.get("inline_data") or item.get("inlineData")
else:
inline_data = getattr(item, "inline_data", None)

if inline_data is None:
return None

if isinstance(inline_data, dict):
data = inline_data.get("data")
mime_type = inline_data.get("mime_type") or inline_data.get("mimeType")
else:
data = getattr(inline_data, "data", None)
mime_type = getattr(inline_data, "mime_type", None)

if not isinstance(data, bytes) or not isinstance(mime_type, str):
return None

resolved = _materialize_attachment(data, mime_type=mime_type, prefix="file")
return resolved.multimodal_part_payload if resolved is not None else None


def _serialize_tools(api_client: Any, input: Any | None) -> Any | None:
try:
from google.genai.models import (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
interactions:
- request:
body: '{"contents": [{"parts": [{"inlineData": {"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==",
"mimeType": "image/png"}}, {"text": "What color is this image?"}], "role": "user"}],
"generationConfig": {"maxOutputTokens": 150}}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2611607'
Content-Type:
- application/json
Host:
- generativelanguage.googleapis.com
user-agent:
- google-genai-sdk/1.70.0 gl-python/3.13.3
x-goog-api-client:
- google-genai-sdk/1.70.0 gl-python/3.13.3
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-001:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"text\": \"The image is predominantly blue.\"\n
\ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\":
\"STOP\",\n \"avgLogprobs\": -0.39939455191294354\n }\n ],\n \"usageMetadata\":
{\n \"promptTokenCount\": 1296,\n \"candidatesTokenCount\": 6,\n \"totalTokenCount\":
1302,\n \"promptTokensDetails\": [\n {\n \"modality\": \"IMAGE\",\n
\ \"tokenCount\": 1290\n },\n {\n \"modality\": \"TEXT\",\n
\ \"tokenCount\": 6\n }\n ],\n \"candidatesTokensDetails\":
[\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 6\n }\n
\ ]\n },\n \"modelVersion\": \"gemini-2.0-flash-001\",\n \"responseId\":
\"4qzNaamuO7Gx1MkPtOGkwQU\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 01 Apr 2026 23:40:19 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=2700
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Gemini-Service-Tier:
- standard
X-XSS-Protection:
- '0'
content-length:
- '756'
status:
code: 200
message: OK
version: 1
95 changes: 95 additions & 0 deletions py/src/braintrust/integrations/google_genai/test_google_genai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,6 +406,60 @@ def test_document_input(memory_logger):
_assert_metrics_are_valid(span["metrics"], start, end)


@pytest.mark.vcr
def test_image_input_wrapped_in_content(memory_logger):
"""Verify binary Parts inside a Content wrapper are traced as attachments.

This is a regression test for the case where the user passes a
``types.Content(parts=[Part.from_bytes(...), ...])`` instead of a flat
list of Part objects. Previously the Content object was returned
un-serialized, leaking raw bytes into the logged span.
"""
assert not memory_logger.pop()

image_data = (FIXTURES_DIR / "test-image.png").read_bytes()

client = Client()
start = time.time()
response = client.models.generate_content(
model=MODEL,
contents=[
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
),
],
config=types.GenerateContentConfig(
max_output_tokens=150,
),
)
end = time.time()

assert response.text

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == MODEL

# The contents list should contain one serialized Content dict.
contents = span["input"]["contents"]
assert len(contents) == 1
content = contents[0]
assert content["role"] == "user"
assert len(content["parts"]) == 2

# Binary part must be an attachment, not raw bytes.
_assert_attachment_part(content["parts"][0], content_type="image/png", filename="file.png")
assert content["parts"][1] == {"text": "What color is this image?"}
_assert_binary_not_logged(span, image_data)
assert span["output"]
_assert_metrics_are_valid(span["metrics"], start, end)


# Test 3: Tool Use (Sync)
@pytest.mark.vcr
@pytest.mark.parametrize(
Expand DownExpand Up@@ -1073,6 +1127,47 @@ class InteractionPayload(BaseModel):
assert materialized["media"]["image_url"]["url"] is materialized["media"]["data"]


def test_serialize_content_item_with_content_and_binary_part():
"""Content objects wrapping binary Parts must produce Attachment objects.

_serialize_content_item only replaces binary inline_data with Attachments;
non-binary parts are left as-is for bt_safe_deep_copy to serialise later.
"""
from braintrust.integrations.google_genai.tracing import _serialize_content_item
from braintrust.logger import Attachment

image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 # small fake PNG bytes
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
)

serialized = _serialize_content_item(content)

# The Content wrapper must be preserved with role + serialized parts.
assert isinstance(serialized, dict), f"Expected dict, got {type(serialized)}"
assert serialized["role"] == "user"
assert isinstance(serialized.get("parts"), list)
assert len(serialized["parts"]) == 2

# The binary part must have been converted to an attachment – not left as
# raw bytes or a model_dump of inline_data.
binary_part = serialized["parts"][0]
assert "image_url" in binary_part, f"Expected image_url key in binary part, got keys: {list(binary_part.keys())}"
attachment = binary_part["image_url"]["url"]
assert isinstance(attachment, Attachment), f"Expected Attachment, got {type(attachment)}"
assert attachment.reference["content_type"] == "image/png"
assert attachment.reference["filename"] == "file.png"

# The text part is left as the original Part object — bt_safe_deep_copy
# will serialise it downstream.
text_part = serialized["parts"][1]
assert getattr(text_part, "text", None) == "What color is this image?"


GROUNDING_MODEL = "gemini-2.0-flash-001"


Expand Down
104 changes: 70 additions & 34 deletions py/src/braintrust/integrations/google_genai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,55 +78,91 @@ def _serialize_input(api_client: Any, input: dict[str, Any]) -> dict[str, Any]:


def _serialize_contents(contents: Any) -> Any:
"""Serialize contents, converting binary data to base64-encoded data URLs."""
"""Serialize contents, converting binary inline_data into attachments.

Most of the heavy lifting (Pydantic model_dump, deep-copy, etc.) is
handled downstream by ``bt_safe_deep_copy``. This pass only needs to
walk the Content/Part tree and replace binary ``inline_data`` with
:class:`Attachment` objects so the background logger can upload them.
"""
if contents is None:
return None

# Handle list of contents
if isinstance(contents, list):
return [_serialize_content_item(item) for item in contents]

# Handle single content item
return _serialize_content_item(contents)


def _serialize_content_item(item: Any) -> Any:
"""Serialize a single content item, handling binary data."""
# If it's already a dict, return as-is
if isinstance(item, dict):
"""Replace binary inline_data inside a content item with Attachments.

Content-like objects (with ``parts``) are recursed into so nested
binary data is converted. Everything else is returned as-is for
``bt_safe_deep_copy`` to serialise later.
"""
if item is None or isinstance(item, (str, int, float, bool)):
return item

# Handle Part objects from google.genai
if hasattr(item, "__class__") and item.__class__.__name__ == "Part":
# Try to extract the data from the Part
if hasattr(item, "text") and item.text is not None:
return {"text": item.text}
elif hasattr(item, "inline_data"):
# Handle binary data (e.g., images)
inline_data = item.inline_data
if hasattr(inline_data, "data") and hasattr(inline_data, "mime_type"):
# Convert bytes to Attachment
data = inline_data.data
mime_type = inline_data.mime_type

# Ensure data is bytes
if isinstance(data, bytes):
resolved_attachment = _materialize_attachment(data, mime_type=mime_type, prefix="file")
if resolved_attachment is not None:
return resolved_attachment.multimodal_part_payload

# Try to use built-in serialization if available
if hasattr(item, "model_dump"):
return item.model_dump()
elif hasattr(item, "dump"):
return item.dump()
elif hasattr(item, "to_dict"):
return item.to_dict()

# Return the item as-is if we can't serialize it
# Content-like wrapper (has "parts") — recurse into each part.
parts = _get_parts(item)
if parts is not None:
serialized_parts = [_serialize_content_item(p) for p in parts]
if isinstance(item, dict):
return {**item, "parts": serialized_parts}
# Object form (e.g. types.Content): build a minimal dict so that
# the replaced Attachment parts survive bt_safe_deep_copy.
result: dict[str, Any] = {"parts": serialized_parts}
role = getattr(item, "role", None)
if role is not None:
result["role"] = role
return result

# Leaf part — replace binary inline_data with an Attachment if present.
resolved = _try_materialize_inline_data(item)
if resolved is not None:
return resolved

# No binary data — return as-is; bt_safe_deep_copy handles the rest.
return item


def _get_parts(item: Any) -> list[Any] | None:
"""Extract the ``parts`` list from a Content-like object or dict, or None."""
if isinstance(item, dict):
parts = item.get("parts")
return parts if isinstance(parts, list) else None
# Object with .parts that is not itself a Part.
if getattr(getattr(item, "__class__", None), "__name__", None) == "Part":
return None
parts = getattr(item, "parts", None)
return parts if isinstance(parts, list) else None


def _try_materialize_inline_data(item: Any) -> Any | None:
"""If *item* carries binary ``inline_data``, convert it to an attachment payload."""
if isinstance(item, dict):
inline_data = item.get("inline_data") or item.get("inlineData")
else:
inline_data = getattr(item, "inline_data", None)

if inline_data is None:
return None

if isinstance(inline_data, dict):
data = inline_data.get("data")
mime_type = inline_data.get("mime_type") or inline_data.get("mimeType")
else:
data = getattr(inline_data, "data", None)
mime_type = getattr(inline_data, "mime_type", None)

if not isinstance(data, bytes) or not isinstance(mime_type, str):
return None

resolved = _materialize_attachment(data, mime_type=mime_type, prefix="file")
return resolved.multimodal_part_payload if resolved is not None else None


def _serialize_tools(api_client: Any, input: Any | None) -> Any | None:
try:
from google.genai.models import (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
interactions:
- request:
body: '{"contents": [{"parts": [{"inlineData": {"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==",
"mimeType": "image/png"}}, {"text": "What color is this image?"}], "role": "user"}],
"generationConfig": {"maxOutputTokens": 150}}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2611607'
Content-Type:
- application/json
Host:
- generativelanguage.googleapis.com
user-agent:
- google-genai-sdk/1.70.0 gl-python/3.13.3
x-goog-api-client:
- google-genai-sdk/1.70.0 gl-python/3.13.3
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-001:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"text\": \"The image is predominantly blue.\"\n
\ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\":
\"STOP\",\n \"avgLogprobs\": -0.39939455191294354\n }\n ],\n \"usageMetadata\":
{\n \"promptTokenCount\": 1296,\n \"candidatesTokenCount\": 6,\n \"totalTokenCount\":
1302,\n \"promptTokensDetails\": [\n {\n \"modality\": \"IMAGE\",\n
\ \"tokenCount\": 1290\n },\n {\n \"modality\": \"TEXT\",\n
\ \"tokenCount\": 6\n }\n ],\n \"candidatesTokensDetails\":
[\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 6\n }\n
\ ]\n },\n \"modelVersion\": \"gemini-2.0-flash-001\",\n \"responseId\":
\"4qzNaamuO7Gx1MkPtOGkwQU\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 01 Apr 2026 23:40:19 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=2700
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Gemini-Service-Tier:
- standard
X-XSS-Protection:
- '0'
content-length:
- '756'
status:
code: 200
message: OK
version: 1
95 changes: 95 additions & 0 deletions py/src/braintrust/integrations/google_genai/test_google_genai.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -406,6 +406,60 @@ def test_document_input(memory_logger):
_assert_metrics_are_valid(span["metrics"], start, end)


@pytest.mark.vcr
def test_image_input_wrapped_in_content(memory_logger):
"""Verify binary Parts inside a Content wrapper are traced as attachments.

This is a regression test for the case where the user passes a
``types.Content(parts=[Part.from_bytes(...), ...])`` instead of a flat
list of Part objects. Previously the Content object was returned
un-serialized, leaking raw bytes into the logged span.
"""
assert not memory_logger.pop()

image_data = (FIXTURES_DIR / "test-image.png").read_bytes()

client = Client()
start = time.time()
response = client.models.generate_content(
model=MODEL,
contents=[
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
),
],
config=types.GenerateContentConfig(
max_output_tokens=150,
),
)
end = time.time()

assert response.text

spans = memory_logger.pop()
assert len(spans) == 1
span = spans[0]
assert span["metadata"]["model"] == MODEL

# The contents list should contain one serialized Content dict.
contents = span["input"]["contents"]
assert len(contents) == 1
content = contents[0]
assert content["role"] == "user"
assert len(content["parts"]) == 2

# Binary part must be an attachment, not raw bytes.
_assert_attachment_part(content["parts"][0], content_type="image/png", filename="file.png")
assert content["parts"][1] == {"text": "What color is this image?"}
_assert_binary_not_logged(span, image_data)
assert span["output"]
_assert_metrics_are_valid(span["metrics"], start, end)


# Test 3: Tool Use (Sync)
@pytest.mark.vcr
@pytest.mark.parametrize(
Expand DownExpand Up@@ -1073,6 +1127,47 @@ class InteractionPayload(BaseModel):
assert materialized["media"]["image_url"]["url"] is materialized["media"]["data"]


def test_serialize_content_item_with_content_and_binary_part():
"""Content objects wrapping binary Parts must produce Attachment objects.

_serialize_content_item only replaces binary inline_data with Attachments;
non-binary parts are left as-is for bt_safe_deep_copy to serialise later.
"""
from braintrust.integrations.google_genai.tracing import _serialize_content_item
from braintrust.logger import Attachment

image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 # small fake PNG bytes
content = types.Content(
role="user",
parts=[
types.Part.from_bytes(data=image_data, mime_type="image/png"),
types.Part.from_text(text="What color is this image?"),
],
)

serialized = _serialize_content_item(content)

# The Content wrapper must be preserved with role + serialized parts.
assert isinstance(serialized, dict), f"Expected dict, got {type(serialized)}"
assert serialized["role"] == "user"
assert isinstance(serialized.get("parts"), list)
assert len(serialized["parts"]) == 2

# The binary part must have been converted to an attachment – not left as
# raw bytes or a model_dump of inline_data.
binary_part = serialized["parts"][0]
assert "image_url" in binary_part, f"Expected image_url key in binary part, got keys: {list(binary_part.keys())}"
attachment = binary_part["image_url"]["url"]
assert isinstance(attachment, Attachment), f"Expected Attachment, got {type(attachment)}"
assert attachment.reference["content_type"] == "image/png"
assert attachment.reference["filename"] == "file.png"

# The text part is left as the original Part object — bt_safe_deep_copy
# will serialise it downstream.
text_part = serialized["parts"][1]
assert getattr(text_part, "text", None) == "What color is this image?"


GROUNDING_MODEL = "gemini-2.0-flash-001"


Expand Down
104 changes: 70 additions & 34 deletions py/src/braintrust/integrations/google_genai/tracing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,55 +78,91 @@ def _serialize_input(api_client: Any, input: dict[str, Any]) -> dict[str, Any]:


def _serialize_contents(contents: Any) -> Any:
"""Serialize contents, converting binary data to base64-encoded data URLs."""
"""Serialize contents, converting binary inline_data into attachments.

Most of the heavy lifting (Pydantic model_dump, deep-copy, etc.) is
handled downstream by ``bt_safe_deep_copy``. This pass only needs to
walk the Content/Part tree and replace binary ``inline_data`` with
:class:`Attachment` objects so the background logger can upload them.
"""
if contents is None:
return None

# Handle list of contents
if isinstance(contents, list):
return [_serialize_content_item(item) for item in contents]

# Handle single content item
return _serialize_content_item(contents)


def _serialize_content_item(item: Any) -> Any:
"""Serialize a single content item, handling binary data."""
# If it's already a dict, return as-is
if isinstance(item, dict):
"""Replace binary inline_data inside a content item with Attachments.

Content-like objects (with ``parts``) are recursed into so nested
binary data is converted. Everything else is returned as-is for
``bt_safe_deep_copy`` to serialise later.
"""
if item is None or isinstance(item, (str, int, float, bool)):
return item

# Handle Part objects from google.genai
if hasattr(item, "__class__") and item.__class__.__name__ == "Part":
# Try to extract the data from the Part
if hasattr(item, "text") and item.text is not None:
return {"text": item.text}
elif hasattr(item, "inline_data"):
# Handle binary data (e.g., images)
inline_data = item.inline_data
if hasattr(inline_data, "data") and hasattr(inline_data, "mime_type"):
# Convert bytes to Attachment
data = inline_data.data
mime_type = inline_data.mime_type

# Ensure data is bytes
if isinstance(data, bytes):
resolved_attachment = _materialize_attachment(data, mime_type=mime_type, prefix="file")
if resolved_attachment is not None:
return resolved_attachment.multimodal_part_payload

# Try to use built-in serialization if available
if hasattr(item, "model_dump"):
return item.model_dump()
elif hasattr(item, "dump"):
return item.dump()
elif hasattr(item, "to_dict"):
return item.to_dict()

# Return the item as-is if we can't serialize it
# Content-like wrapper (has "parts") — recurse into each part.
parts = _get_parts(item)
if parts is not None:
serialized_parts = [_serialize_content_item(p) for p in parts]
if isinstance(item, dict):
return {**item, "parts": serialized_parts}
# Object form (e.g. types.Content): build a minimal dict so that
# the replaced Attachment parts survive bt_safe_deep_copy.
result: dict[str, Any] = {"parts": serialized_parts}
role = getattr(item, "role", None)
if role is not None:
result["role"] = role
return result

# Leaf part — replace binary inline_data with an Attachment if present.
resolved = _try_materialize_inline_data(item)
if resolved is not None:
return resolved

# No binary data — return as-is; bt_safe_deep_copy handles the rest.
return item


def _get_parts(item: Any) -> list[Any] | None:
"""Extract the ``parts`` list from a Content-like object or dict, or None."""
if isinstance(item, dict):
parts = item.get("parts")
return parts if isinstance(parts, list) else None
# Object with .parts that is not itself a Part.
if getattr(getattr(item, "__class__", None), "__name__", None) == "Part":
return None
parts = getattr(item, "parts", None)
return parts if isinstance(parts, list) else None


def _try_materialize_inline_data(item: Any) -> Any | None:
"""If *item* carries binary ``inline_data``, convert it to an attachment payload."""
if isinstance(item, dict):
inline_data = item.get("inline_data") or item.get("inlineData")
else:
inline_data = getattr(item, "inline_data", None)

if inline_data is None:
return None

if isinstance(inline_data, dict):
data = inline_data.get("data")
mime_type = inline_data.get("mime_type") or inline_data.get("mimeType")
else:
data = getattr(inline_data, "data", None)
mime_type = getattr(inline_data, "mime_type", None)

if not isinstance(data, bytes) or not isinstance(mime_type, str):
return None

resolved = _materialize_attachment(data, mime_type=mime_type, prefix="file")
return resolved.multimodal_part_payload if resolved is not None else None


def _serialize_tools(api_client: Any, input: Any | None) -> Any | None:
try:
from google.genai.models import (
Expand Down
Loading