Skip to content

Commit d1d1108

Browse files
committed
feat(google_genai): add generate_images tracing with Attachment support
Add tracing for the Google GenAI generate_images API (both sync and async). Generated image bytes are wrapped in Attachment objects so the SDK uploads them to object storage and the Braintrust UI can render them, following the same pattern used for input-side inline_data in both the google_genai and adk integrations. Key changes: - ModelsGenerateImagesPatcher and AsyncModelsGenerateImagesPatcher - _generate_images_wrapper / _async_generate_images_wrapper - Output includes image metadata (size, mime type, safety attrs) plus an Attachment per generated image in {"image_url": {"url": attachment}} - VCR before_record_response sanitizes image bytes in cassettes - Sync and async test cases validate span structure, metrics, and Attachment objects
1 parent 4267bde commit d1d1108

6 files changed

Lines changed: 398 additions & 2 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
interactions:
2+
- request:
3+
body: '{"instances": [{"prompt": "A watercolor fox in a forest"}], "parameters":
4+
{"sampleCount": 1, "aspectRatio": "1:1", "safetySetting": "BLOCK_LOW_AND_ABOVE",
5+
"includeRaiReason": true}}'
6+
headers:
7+
Accept:
8+
- '*/*'
9+
Accept-Encoding:
10+
- gzip, deflate, zstd
11+
Connection:
12+
- keep-alive
13+
Content-Length:
14+
- '181'
15+
Content-Type:
16+
- application/json
17+
Host:
18+
- generativelanguage.googleapis.com
19+
user-agent:
20+
- google-genai-sdk/1.66.0 gl-python/3.13.3
21+
x-goog-api-client:
22+
- google-genai-sdk/1.66.0 gl-python/3.13.3
23+
method: POST
24+
uri: https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-fast-generate-001:predict
25+
response:
26+
body:
27+
string: '{"predictions": [{"bytesBase64Encoded": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
28+
"mimeType": "image/png"}]}'
29+
headers:
30+
Alt-Svc:
31+
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
32+
Content-Type:
33+
- application/json; charset=UTF-8
34+
Date:
35+
- Fri, 27 Mar 2026 01:18:45 GMT
36+
Server:
37+
- scaffolding on HTTPServer2
38+
Server-Timing:
39+
- gfet4t7; dur=4159
40+
Transfer-Encoding:
41+
- chunked
42+
Vary:
43+
- Origin
44+
- X-Origin
45+
- Referer
46+
X-Content-Type-Options:
47+
- nosniff
48+
X-Frame-Options:
49+
- SAMEORIGIN
50+
X-XSS-Protection:
51+
- '0'
52+
content-length:
53+
- '2622509'
54+
status:
55+
code: 200
56+
message: OK
57+
version: 1
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
interactions:
2+
- request:
3+
body: '{"instances": [{"prompt": "A watercolor fox in a forest"}], "parameters":
4+
{"sampleCount": 1, "aspectRatio": "1:1", "safetySetting": "BLOCK_LOW_AND_ABOVE",
5+
"includeRaiReason": true}}'
6+
headers:
7+
Content-Type:
8+
- application/json
9+
user-agent:
10+
- google-genai-sdk/1.66.0 gl-python/3.13.3
11+
x-goog-api-client:
12+
- google-genai-sdk/1.66.0 gl-python/3.13.3
13+
method: POST
14+
uri: https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-fast-generate-001:predict
15+
response:
16+
body:
17+
string: '{"predictions": [{"bytesBase64Encoded": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
18+
"mimeType": "image/png"}]}'
19+
headers:
20+
Alt-Svc:
21+
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
22+
Content-Type:
23+
- application/json; charset=UTF-8
24+
Date:
25+
- Fri, 27 Mar 2026 01:18:49 GMT
26+
Server:
27+
- scaffolding on HTTPServer2
28+
Server-Timing:
29+
- gfet4t7; dur=3224
30+
Transfer-Encoding:
31+
- chunked
32+
Vary:
33+
- Origin
34+
- X-Origin
35+
- Referer
36+
X-Content-Type-Options:
37+
- nosniff
38+
X-Frame-Options:
39+
- SAMEORIGIN
40+
X-XSS-Protection:
41+
- '0'
42+
content-length:
43+
- '2750553'
44+
status:
45+
code: 200
46+
message: OK
47+
version: 1

py/src/braintrust/integrations/google_genai/integration.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
AsyncModelsEmbedContentPatcher,
99
AsyncModelsGenerateContentPatcher,
1010
AsyncModelsGenerateContentStreamPatcher,
11+
AsyncModelsGenerateImagesPatcher,
1112
ModelsEmbedContentPatcher,
1213
ModelsGenerateContentPatcher,
1314
ModelsGenerateContentStreamPatcher,
15+
ModelsGenerateImagesPatcher,
1416
)
1517

1618

@@ -26,7 +28,9 @@ class GoogleGenAIIntegration(BaseIntegration):
2628
ModelsGenerateContentPatcher,
2729
ModelsGenerateContentStreamPatcher,
2830
ModelsEmbedContentPatcher,
31+
ModelsGenerateImagesPatcher,
2932
AsyncModelsGenerateContentPatcher,
3033
AsyncModelsGenerateContentStreamPatcher,
3134
AsyncModelsEmbedContentPatcher,
35+
AsyncModelsGenerateImagesPatcher,
3236
)

py/src/braintrust/integrations/google_genai/patchers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
_async_embed_content_wrapper,
77
_async_generate_content_stream_wrapper,
88
_async_generate_content_wrapper,
9+
_async_generate_images_wrapper,
910
_embed_content_wrapper,
1011
_generate_content_stream_wrapper,
1112
_generate_content_wrapper,
13+
_generate_images_wrapper,
1214
)
1315

1416

@@ -44,6 +46,15 @@ class ModelsEmbedContentPatcher(FunctionWrapperPatcher):
4446
wrapper = _embed_content_wrapper
4547

4648

49+
class ModelsGenerateImagesPatcher(FunctionWrapperPatcher):
50+
"""Patch ``Models.generate_images`` for tracing."""
51+
52+
name = "google_genai.models.generate_images"
53+
target_module = "google.genai.models"
54+
target_path = "Models.generate_images"
55+
wrapper = _generate_images_wrapper
56+
57+
4758
# ---------------------------------------------------------------------------
4859
# Async Models patchers
4960
# ---------------------------------------------------------------------------
@@ -74,3 +85,12 @@ class AsyncModelsEmbedContentPatcher(FunctionWrapperPatcher):
7485
target_module = "google.genai.models"
7586
target_path = "AsyncModels.embed_content"
7687
wrapper = _async_embed_content_wrapper
88+
89+
90+
class AsyncModelsGenerateImagesPatcher(FunctionWrapperPatcher):
91+
"""Patch ``AsyncModels.generate_images`` for tracing."""
92+
93+
name = "google_genai.async_models.generate_images"
94+
target_module = "google.genai.models"
95+
target_path = "AsyncModels.generate_images"
96+
wrapper = _async_generate_images_wrapper

py/src/braintrust/integrations/google_genai/test_google_genai.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import gzip
2+
import json
13
import os
24
import time
35
from pathlib import Path
46

57
import pytest
68
from braintrust import logger
79
from braintrust.integrations.google_genai import setup_genai
10+
from braintrust.logger import Attachment
811
from braintrust.test_helpers import init_test_logger
912
from braintrust.wrappers.test_utils import verify_autoinstrument_script
1013
from google.genai import types
@@ -14,7 +17,59 @@
1417
PROJECT_NAME = "test-genai-app"
1518
MODEL = "gemini-2.0-flash-001"
1619
EMBEDDING_MODEL = "gemini-embedding-001"
20+
IMAGE_MODEL = "imagen-4.0-fast-generate-001"
1721
FIXTURES_DIR = Path(__file__).parent.parent.parent.parent.parent / "internal/golden/fixtures"
22+
TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
23+
24+
25+
def _sanitize_generate_images_body(value):
26+
if isinstance(value, dict):
27+
return {
28+
key: (
29+
TINY_PNG_BASE64
30+
if key == "bytesBase64Encoded" and isinstance(val, str)
31+
else _sanitize_generate_images_body(val)
32+
)
33+
for key, val in value.items()
34+
}
35+
if isinstance(value, list):
36+
return [_sanitize_generate_images_body(item) for item in value]
37+
return value
38+
39+
40+
def _sanitize_generate_images_response(response):
41+
body = response.get("body", {})
42+
payload = body.get("string")
43+
if not payload:
44+
return response
45+
46+
is_bytes = isinstance(payload, bytes)
47+
is_gzipped = False
48+
49+
if is_bytes:
50+
raw_payload = payload
51+
if raw_payload[:2] == b"\x1f\x8b":
52+
raw_payload = gzip.decompress(raw_payload)
53+
is_gzipped = True
54+
payload = raw_payload.decode("utf-8")
55+
56+
try:
57+
parsed = json.loads(payload)
58+
except Exception:
59+
return response
60+
61+
sanitized = _sanitize_generate_images_body(parsed)
62+
if sanitized == parsed:
63+
return response
64+
65+
sanitized_payload = json.dumps(sanitized)
66+
if is_bytes:
67+
body["string"] = (
68+
gzip.compress(sanitized_payload.encode("utf-8")) if is_gzipped else sanitized_payload.encode("utf-8")
69+
)
70+
else:
71+
body["string"] = sanitized_payload
72+
return response
1873

1974

2075
@pytest.fixture(scope="module")
@@ -27,14 +82,19 @@ def before_record_request(request):
2782
request.method = request.method.upper()
2883
return request
2984

85+
def before_record_response(response):
86+
return _sanitize_generate_images_response(response)
87+
3088
return {
3189
"record_mode": record_mode,
90+
"decode_compressed_response": True,
3291
"filter_headers": [
3392
"authorization",
3493
"x-api-key",
3594
"x-goog-api-key",
3695
],
3796
"before_record_request": before_record_request,
97+
"before_record_response": before_record_response,
3898
}
3999

40100

@@ -669,6 +729,105 @@ def test_attachment_in_config(memory_logger):
669729
assert copied["temperature"] == 0.5
670730

671731

732+
@pytest.mark.vcr
733+
def test_generate_images(memory_logger):
734+
assert not memory_logger.pop()
735+
736+
client = Client()
737+
start = time.time()
738+
739+
response = client.models.generate_images(
740+
model=IMAGE_MODEL,
741+
prompt="A watercolor fox in a forest",
742+
config=types.GenerateImagesConfig(
743+
number_of_images=1,
744+
aspect_ratio="1:1",
745+
safety_filter_level="BLOCK_LOW_AND_ABOVE",
746+
include_rai_reason=True,
747+
),
748+
)
749+
end = time.time()
750+
751+
assert len(response.generated_images) == 1
752+
assert response.generated_images[0].image
753+
assert response.generated_images[0].image.image_bytes
754+
755+
spans = memory_logger.pop()
756+
assert len(spans) == 1
757+
span = spans[0]
758+
assert span["metadata"]["model"] == IMAGE_MODEL
759+
assert span["input"]["prompt"] == "A watercolor fox in a forest"
760+
assert span["input"]["config"]["number_of_images"] == 1
761+
assert span["input"]["config"]["aspect_ratio"] == "1:1"
762+
assert span["input"]["config"]["safety_filter_level"] == "BLOCK_LOW_AND_ABOVE"
763+
assert span["input"]["config"]["include_rai_reason"] is True
764+
assert span["output"]["generated_images_count"] == 1
765+
generated_image = span["output"]["generated_images"][0]
766+
assert generated_image["image_size_bytes"] > 0
767+
assert generated_image["mime_type"] in {"image/png", "image/jpeg", "image/webp"}
768+
769+
# Verify the image bytes are stored as an Attachment for upload to object storage
770+
assert "image_url" in generated_image
771+
attachment = generated_image["image_url"]["url"]
772+
assert isinstance(attachment, Attachment)
773+
assert attachment.reference["type"] == "braintrust_attachment"
774+
assert attachment.reference["content_type"] == generated_image["mime_type"]
775+
assert attachment.reference["filename"].startswith("generated_image_")
776+
assert attachment.reference["key"]
777+
778+
_assert_timing_metrics_are_valid(span["metrics"], start, end)
779+
780+
781+
@pytest.mark.vcr
782+
@pytest.mark.asyncio
783+
async def test_generate_images_async(memory_logger):
784+
assert not memory_logger.pop()
785+
786+
client = Client()
787+
start = time.time()
788+
789+
response = await client.aio.models.generate_images(
790+
model=IMAGE_MODEL,
791+
prompt="A watercolor fox in a forest",
792+
config=types.GenerateImagesConfig(
793+
number_of_images=1,
794+
aspect_ratio="1:1",
795+
safety_filter_level="BLOCK_LOW_AND_ABOVE",
796+
include_rai_reason=True,
797+
),
798+
)
799+
end = time.time()
800+
801+
assert len(response.generated_images) == 1
802+
assert response.generated_images[0].image
803+
assert response.generated_images[0].image.image_bytes
804+
805+
spans = memory_logger.pop()
806+
assert len(spans) == 1
807+
span = spans[0]
808+
assert span["metadata"]["model"] == IMAGE_MODEL
809+
assert span["input"]["prompt"] == "A watercolor fox in a forest"
810+
assert span["input"]["config"]["number_of_images"] == 1
811+
assert span["input"]["config"]["aspect_ratio"] == "1:1"
812+
assert span["input"]["config"]["safety_filter_level"] == "BLOCK_LOW_AND_ABOVE"
813+
assert span["input"]["config"]["include_rai_reason"] is True
814+
assert span["output"]["generated_images_count"] == 1
815+
generated_image = span["output"]["generated_images"][0]
816+
assert generated_image["image_size_bytes"] > 0
817+
assert generated_image["mime_type"] in {"image/png", "image/jpeg", "image/webp"}
818+
819+
# Verify the image bytes are stored as an Attachment for upload to object storage
820+
assert "image_url" in generated_image
821+
attachment = generated_image["image_url"]["url"]
822+
assert isinstance(attachment, Attachment)
823+
assert attachment.reference["type"] == "braintrust_attachment"
824+
assert attachment.reference["content_type"] == generated_image["mime_type"]
825+
assert attachment.reference["filename"].startswith("generated_image_")
826+
assert attachment.reference["key"]
827+
828+
_assert_timing_metrics_are_valid(span["metrics"], start, end)
829+
830+
672831
def test_nested_attachments_in_contents(memory_logger):
673832
"""Test that nested attachments in contents are preserved."""
674833
from braintrust.bt_json import bt_safe_deep_copy

0 commit comments

Comments
 (0)