Skip to content

Commit f60a543

Browse files
committed
feat(google_genai): add generate_images tracing support
Add tracing for the Google GenAI generate_images API (both sync and async). This includes patchers, input/output serialization, metric extraction, and VCR-based tests with image payload sanitization to keep cassettes small. Key changes: - ModelsGenerateImagesPatcher and AsyncModelsGenerateImagesPatcher - _generate_images_wrapper / _async_generate_images_wrapper - Output captures image count, sizes, mime types, and safety attrs without storing raw image bytes in spans - VCR before_record_response replaces image bytes with a tiny PNG - Sync and async test cases validating span structure and metrics
1 parent 4267bde commit f60a543

6 files changed

Lines changed: 365 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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import gzip
2+
import json
13
import os
24
import time
35
from pathlib import Path
@@ -14,7 +16,59 @@
1416
PROJECT_NAME = "test-genai-app"
1517
MODEL = "gemini-2.0-flash-001"
1618
EMBEDDING_MODEL = "gemini-embedding-001"
19+
IMAGE_MODEL = "imagen-4.0-fast-generate-001"
1720
FIXTURES_DIR = Path(__file__).parent.parent.parent.parent.parent / "internal/golden/fixtures"
21+
TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
22+
23+
24+
def _sanitize_generate_images_body(value):
25+
if isinstance(value, dict):
26+
return {
27+
key: (
28+
TINY_PNG_BASE64
29+
if key == "bytesBase64Encoded" and isinstance(val, str)
30+
else _sanitize_generate_images_body(val)
31+
)
32+
for key, val in value.items()
33+
}
34+
if isinstance(value, list):
35+
return [_sanitize_generate_images_body(item) for item in value]
36+
return value
37+
38+
39+
def _sanitize_generate_images_response(response):
40+
body = response.get("body", {})
41+
payload = body.get("string")
42+
if not payload:
43+
return response
44+
45+
is_bytes = isinstance(payload, bytes)
46+
is_gzipped = False
47+
48+
if is_bytes:
49+
raw_payload = payload
50+
if raw_payload[:2] == b"\x1f\x8b":
51+
raw_payload = gzip.decompress(raw_payload)
52+
is_gzipped = True
53+
payload = raw_payload.decode("utf-8")
54+
55+
try:
56+
parsed = json.loads(payload)
57+
except Exception:
58+
return response
59+
60+
sanitized = _sanitize_generate_images_body(parsed)
61+
if sanitized == parsed:
62+
return response
63+
64+
sanitized_payload = json.dumps(sanitized)
65+
if is_bytes:
66+
body["string"] = (
67+
gzip.compress(sanitized_payload.encode("utf-8")) if is_gzipped else sanitized_payload.encode("utf-8")
68+
)
69+
else:
70+
body["string"] = sanitized_payload
71+
return response
1872

1973

2074
@pytest.fixture(scope="module")
@@ -27,14 +81,19 @@ def before_record_request(request):
2781
request.method = request.method.upper()
2882
return request
2983

84+
def before_record_response(response):
85+
return _sanitize_generate_images_response(response)
86+
3087
return {
3188
"record_mode": record_mode,
89+
"decode_compressed_response": True,
3290
"filter_headers": [
3391
"authorization",
3492
"x-api-key",
3593
"x-goog-api-key",
3694
],
3795
"before_record_request": before_record_request,
96+
"before_record_response": before_record_response,
3897
}
3998

4099

@@ -669,6 +728,83 @@ def test_attachment_in_config(memory_logger):
669728
assert copied["temperature"] == 0.5
670729

671730

731+
@pytest.mark.vcr
732+
def test_generate_images(memory_logger):
733+
assert not memory_logger.pop()
734+
735+
client = Client()
736+
start = time.time()
737+
738+
response = client.models.generate_images(
739+
model=IMAGE_MODEL,
740+
prompt="A watercolor fox in a forest",
741+
config=types.GenerateImagesConfig(
742+
number_of_images=1,
743+
aspect_ratio="1:1",
744+
safety_filter_level="BLOCK_LOW_AND_ABOVE",
745+
include_rai_reason=True,
746+
),
747+
)
748+
end = time.time()
749+
750+
assert len(response.generated_images) == 1
751+
assert response.generated_images[0].image
752+
assert response.generated_images[0].image.image_bytes
753+
754+
spans = memory_logger.pop()
755+
assert len(spans) == 1
756+
span = spans[0]
757+
assert span["metadata"]["model"] == IMAGE_MODEL
758+
assert span["input"]["prompt"] == "A watercolor fox in a forest"
759+
assert span["input"]["config"]["number_of_images"] == 1
760+
assert span["input"]["config"]["aspect_ratio"] == "1:1"
761+
assert span["input"]["config"]["safety_filter_level"] == "BLOCK_LOW_AND_ABOVE"
762+
assert span["input"]["config"]["include_rai_reason"] is True
763+
assert span["output"]["generated_images_count"] == 1
764+
assert span["output"]["generated_images"][0]["image_size_bytes"] > 0
765+
assert span["output"]["generated_images"][0]["mime_type"] in {"image/png", "image/jpeg", "image/webp"}
766+
_assert_timing_metrics_are_valid(span["metrics"], start, end)
767+
768+
769+
@pytest.mark.vcr
770+
@pytest.mark.asyncio
771+
async def test_generate_images_async(memory_logger):
772+
assert not memory_logger.pop()
773+
774+
client = Client()
775+
start = time.time()
776+
777+
response = await client.aio.models.generate_images(
778+
model=IMAGE_MODEL,
779+
prompt="A watercolor fox in a forest",
780+
config=types.GenerateImagesConfig(
781+
number_of_images=1,
782+
aspect_ratio="1:1",
783+
safety_filter_level="BLOCK_LOW_AND_ABOVE",
784+
include_rai_reason=True,
785+
),
786+
)
787+
end = time.time()
788+
789+
assert len(response.generated_images) == 1
790+
assert response.generated_images[0].image
791+
assert response.generated_images[0].image.image_bytes
792+
793+
spans = memory_logger.pop()
794+
assert len(spans) == 1
795+
span = spans[0]
796+
assert span["metadata"]["model"] == IMAGE_MODEL
797+
assert span["input"]["prompt"] == "A watercolor fox in a forest"
798+
assert span["input"]["config"]["number_of_images"] == 1
799+
assert span["input"]["config"]["aspect_ratio"] == "1:1"
800+
assert span["input"]["config"]["safety_filter_level"] == "BLOCK_LOW_AND_ABOVE"
801+
assert span["input"]["config"]["include_rai_reason"] is True
802+
assert span["output"]["generated_images_count"] == 1
803+
assert span["output"]["generated_images"][0]["image_size_bytes"] > 0
804+
assert span["output"]["generated_images"][0]["mime_type"] in {"image/png", "image/jpeg", "image/webp"}
805+
_assert_timing_metrics_are_valid(span["metrics"], start, end)
806+
807+
672808
def test_nested_attachments_in_contents(memory_logger):
673809
"""Test that nested attachments in contents are preserved."""
674810
from braintrust.bt_json import bt_safe_deep_copy

0 commit comments

Comments
 (0)