From 433bc885b682b76c8395e97bf736f8ba2d287e62 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Mon, 5 Jan 2026 12:12:27 +0100 Subject: [PATCH 1/4] fix(integrations): google-genai: reworked `gen_ai.request.messages` extraction from parameters --- sentry_sdk/integrations/google_genai/utils.py | 360 ++++++++++++++++-- .../google_genai/test_google_genai.py | 323 ++++++++++++++++ 2 files changed, 652 insertions(+), 31 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index 03423c385a..ac3870a888 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -1,3 +1,4 @@ +import base64 import copy import inspect from functools import wraps @@ -12,6 +13,7 @@ Optional, Union, TypedDict, + Dict, ) import sentry_sdk @@ -19,6 +21,7 @@ set_data_normalized, truncate_and_annotate_messages, normalize_message_roles, + redact_blob_message_parts, ) from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.scope import should_send_default_pii @@ -145,44 +148,323 @@ def get_model_name(model: "Union[str, Model]") -> str: return str(model) -def extract_contents_text(contents: "ContentListUnion") -> "Optional[str]": - """Extract text from contents parameter which can have various formats.""" +def extract_contents_messages(contents: "ContentListUnion") -> "List[Dict[str, Any]]": + """Extract messages from contents parameter which can have various formats. + + Returns a list of message dictionaries in the format: + - System: {"role": "system", "content": "string"} + - User/Assistant: {"role": "user"|"assistant", "content": [{"text": "...", "type": "text"}, ...]} + """ if contents is None: - return None + return [] - # Simple string case + messages = [] + + # Handle string case if isinstance(contents, str): - return contents + return [{"role": "user", "content": contents}] - # List of contents or parts + # Handle list case - process each item (non-recursive, flatten at top level) if isinstance(contents, list): - texts = [] for item in contents: - # Recursively extract text from each item - extracted = extract_contents_text(item) - if extracted: - texts.append(extracted) - return " ".join(texts) if texts else None + item_messages = extract_contents_messages(item) + messages.extend(item_messages) + return messages - # Dictionary case + # Handle dictionary case (ContentDict) if isinstance(contents, dict): - if "text" in contents: - return contents["text"] - # Try to extract from parts if present in dict - if "parts" in contents: - return extract_contents_text(contents["parts"]) + role = contents.get("role", "user") + parts = contents.get("parts") + + if parts: + content_parts = [] + tool_messages = [] + + for part in parts: + part_result = _extract_part_content(part) + if part_result is None: + continue + + if isinstance(part_result, dict) and part_result.get("role") == "tool": + # Tool message - add separately + tool_messages.append(part_result) + else: + # Regular content part + content_parts.append(part_result) + + # Add main message if we have content parts + if content_parts: + # Normalize role: "model" -> "assistant" + normalized_role = "assistant" if role == "model" else role or "user" + messages.append({"role": normalized_role, "content": content_parts}) + + # Add tool messages + messages.extend(tool_messages) + elif "text" in contents: + # Simple text in dict + messages.append( + { + "role": role or "user", + "content": [{"text": contents["text"], "type": "text"}], + } + ) + + return messages + + # Handle Content object + if hasattr(contents, "parts") and contents.parts: + role = getattr(contents, "role", None) or "user" + content_parts = [] + tool_messages = [] + + for part in contents.parts: + part_result = _extract_part_content(part) + if part_result is None: + continue + + if isinstance(part_result, dict) and part_result.get("role") == "tool": + tool_messages.append(part_result) + else: + content_parts.append(part_result) - # Content object with parts - recurse into parts - if getattr(contents, "parts", None): - return extract_contents_text(contents.parts) + if content_parts: + normalized_role = "assistant" if role == "model" else role + messages.append({"role": normalized_role, "content": content_parts}) - # Direct text attribute - if hasattr(contents, "text"): - return contents.text + messages.extend(tool_messages) + return messages + + # Handle Part object directly + part_result = _extract_part_content(contents) + if part_result: + if isinstance(part_result, dict) and part_result.get("role") == "tool": + return [part_result] + else: + return [{"role": "user", "content": [part_result]}] + + # Handle PIL.Image.Image + try: + from PIL import Image as PILImage + + if isinstance(contents, PILImage.Image): + blob_part = _extract_pil_image(contents) + if blob_part: + return [{"role": "user", "content": [blob_part]}] + except ImportError: + pass + + # Handle File object + if hasattr(contents, "uri") and hasattr(contents, "mime_type"): + # File object + file_uri = getattr(contents, "uri", None) + mime_type = getattr(contents, "mime_type", None) + if file_uri and mime_type: + blob_part = { + "type": "blob", + "mime_type": mime_type, + "file_uri": file_uri, + } + return [{"role": "user", "content": [blob_part]}] + + # Handle direct text attribute + if hasattr(contents, "text") and contents.text: + return [ + {"role": "user", "content": [{"text": str(contents.text), "type": "text"}]} + ] + + return [] + + +def _extract_part_content(part: "Any") -> "Optional[dict[str, Any]]": + """Extract content from a Part object or dict. + + Returns: + - dict for content part (text/blob) or tool message + - None if part should be skipped + """ + if part is None: + return None + + # Handle dict Part + if isinstance(part, dict): + # Check for function_response first (tool message) + if "function_response" in part: + return _extract_tool_message_from_part(part) + + if "text" in part: + return {"text": part["text"], "type": "text"} + + if "file_data" in part: + file_data = part["file_data"] + if isinstance(file_data, dict): + return { + "type": "blob", + "mime_type": file_data.get("mime_type"), + "file_uri": file_data.get("file_uri"), + } + + if "inline_data" in part: + inline_data = part["inline_data"] + if isinstance(inline_data, dict): + data = inline_data.get("data") + mime_type = inline_data.get("mime_type") + if data and mime_type: + # Encode bytes to base64 + if isinstance(data, bytes): + data_b64 = base64.b64encode(data).decode("utf-8") + return { + "type": "blob", + "mime_type": mime_type, + "content": f"data:{mime_type};base64,{data_b64}", + } + + return None + + # Handle Part object + # Check for function_response (tool message) + if hasattr(part, "function_response") and part.function_response: + return _extract_tool_message_from_part(part) + + # Handle text + if hasattr(part, "text") and part.text: + return {"text": part.text, "type": "text"} + + # Handle file_data + if hasattr(part, "file_data") and part.file_data: + file_data = part.file_data + file_uri = getattr(file_data, "file_uri", None) + mime_type = getattr(file_data, "mime_type", None) + if file_uri and mime_type: + return { + "type": "blob", + "mime_type": mime_type, + "file_uri": file_uri, + } + + # Handle inline_data + if hasattr(part, "inline_data") and part.inline_data: + inline_data = part.inline_data + data = getattr(inline_data, "data", None) + mime_type = getattr(inline_data, "mime_type", None) + if data and mime_type: + # Encode bytes to base64 + if isinstance(data, bytes): + data_b64 = base64.b64encode(data).decode("utf-8") + return { + "type": "blob", + "mime_type": mime_type, + "content": f"data:{mime_type};base64,{data_b64}", + } return None +def _extract_tool_message_from_part(part: "Any") -> "Optional[dict[str, Any]]": + """Extract tool message from a Part with function_response. + + Returns: + {"role": "tool", "content": {"toolCallId": "...", "toolName": "...", "output": "..."}} + or None if not a valid tool message + """ + function_response = None + + if isinstance(part, dict): + function_response = part.get("function_response") + elif hasattr(part, "function_response"): + function_response = part.function_response + + if not function_response: + return None + + # Extract fields from function_response + tool_call_id = None + tool_name = None + output = None + + if isinstance(function_response, dict): + tool_call_id = function_response.get("id") + tool_name = function_response.get("name") + response_dict = function_response.get("response", {}) + # Prefer "output" key if present, otherwise use entire response + output = response_dict.get("output", response_dict) + else: + # FunctionResponse object + tool_call_id = getattr(function_response, "id", None) + tool_name = getattr(function_response, "name", None) + response_obj = getattr(function_response, "response", None) + if response_obj: + if isinstance(response_obj, dict): + output = response_obj.get("output", response_obj) + else: + output = response_obj + + if not tool_name: + return None + + return { + "role": "tool", + "content": { + "toolCallId": str(tool_call_id) if tool_call_id else None, + "toolName": str(tool_name), + "output": safe_serialize(output) if output is not None else None, + }, + } + + +def _extract_pil_image(image: "Any") -> "Optional[dict[str, Any]]": + """Extract blob part from PIL.Image.Image.""" + try: + from PIL import Image as PILImage + import io + + if not isinstance(image, PILImage.Image): + return None + + # Get format, default to JPEG + format_str = image.format or "JPEG" + suffix = format_str.lower() + mime_type = f"image/{suffix}" + + # Convert to bytes + bytes_io = io.BytesIO() + image.save(bytes_io, format=format_str) + image_bytes = bytes_io.getvalue() + + # Encode to base64 + data_b64 = base64.b64encode(image_bytes).decode("utf-8") + + return { + "type": "blob", + "mime_type": mime_type, + "content": f"data:{mime_type};base64,{data_b64}", + } + except Exception: + return None + + +def extract_contents_text(contents: "ContentListUnion") -> "Optional[str]": + """Extract text from contents parameter which can have various formats. + + This is a compatibility function that extracts text from messages. + For new code, use extract_contents_messages instead. + """ + messages = extract_contents_messages(contents) + if not messages: + return None + + texts = [] + for message in messages: + content = message.get("content") + if isinstance(content, str): + texts.append(content) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + texts.append(part.get("text", "")) + + return " ".join(texts) if texts else None + + def _format_tools_for_span( tools: "Iterable[Tool | Callable[..., Any]]", ) -> "Optional[List[dict[str, Any]]]": @@ -457,16 +739,32 @@ def set_span_data_for_request( if config and hasattr(config, "system_instruction"): system_instruction = config.system_instruction if system_instruction: - system_text = extract_contents_text(system_instruction) - if system_text: - messages.append({"role": "system", "content": system_text}) + system_messages = extract_contents_messages(system_instruction) + # System instruction should be a single system message + # Extract text from all messages and combine into one system message + system_texts = [] + for msg in system_messages: + content = msg.get("content") + if isinstance(content, list): + # Extract text from content parts + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + system_texts.append(part.get("text", "")) + elif isinstance(content, str): + system_texts.append(content) + + if system_texts: + messages.append( + {"role": "system", "content": " ".join(system_texts)} + ) - # Add user message - contents_text = extract_contents_text(contents) - if contents_text: - messages.append({"role": "user", "content": contents_text}) + # Extract messages from contents + contents_messages = extract_contents_messages(contents) + messages.extend(contents_messages) if messages: + # Redact blob message parts + messages = redact_blob_message_parts(messages) normalized_messages = normalize_message_roles(messages) scope = sentry_sdk.get_current_scope() messages_data = truncate_and_annotate_messages( diff --git a/tests/integrations/google_genai/test_google_genai.py b/tests/integrations/google_genai/test_google_genai.py index a49822f3d4..9a0ffa005a 100644 --- a/tests/integrations/google_genai/test_google_genai.py +++ b/tests/integrations/google_genai/test_google_genai.py @@ -1,3 +1,4 @@ +import base64 import json import pytest from unittest import mock @@ -8,6 +9,7 @@ from sentry_sdk import start_transaction from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.google_genai import GoogleGenAIIntegration +from sentry_sdk.integrations.google_genai.utils import extract_contents_messages @pytest.fixture @@ -1417,3 +1419,324 @@ async def test_async_embed_content_span_origin( assert event["contexts"]["trace"]["origin"] == "manual" for span in event["spans"]: assert span["origin"] == "auto.ai.google_genai" + + +# Tests for extract_contents_messages function +def test_extract_contents_messages_none(): + """Test extract_contents_messages with None input""" + result = extract_contents_messages(None) + assert result == [] + + +def test_extract_contents_messages_string(): + """Test extract_contents_messages with string input""" + result = extract_contents_messages("Hello world") + assert result == [{"role": "user", "content": "Hello world"}] + + +def test_extract_contents_messages_content_object(): + """Test extract_contents_messages with Content object""" + content = genai_types.Content( + role="user", parts=[genai_types.Part(text="Test message")] + ) + result = extract_contents_messages(content) + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"] == [{"text": "Test message", "type": "text"}] + + +def test_extract_contents_messages_content_object_model_role(): + """Test extract_contents_messages with Content object having model role""" + content = genai_types.Content( + role="model", parts=[genai_types.Part(text="Assistant response")] + ) + result = extract_contents_messages(content) + assert len(result) == 1 + assert result[0]["role"] == "assistant" + assert result[0]["content"] == [{"text": "Assistant response", "type": "text"}] + + +def test_extract_contents_messages_content_object_no_role(): + """Test extract_contents_messages with Content object without role""" + content = genai_types.Content(parts=[genai_types.Part(text="No role message")]) + result = extract_contents_messages(content) + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"] == [{"text": "No role message", "type": "text"}] + + +def test_extract_contents_messages_part_object(): + """Test extract_contents_messages with Part object""" + part = genai_types.Part(text="Direct part") + result = extract_contents_messages(part) + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"] == [{"text": "Direct part", "type": "text"}] + + +def test_extract_contents_messages_file_data(): + """Test extract_contents_messages with file_data""" + file_data = genai_types.FileData( + file_uri="gs://bucket/file.jpg", mime_type="image/jpeg" + ) + part = genai_types.Part(file_data=file_data) + content = genai_types.Content(parts=[part]) + result = extract_contents_messages(content) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert len(result[0]["content"]) == 1 + blob_part = result[0]["content"][0] + assert blob_part["type"] == "blob" + assert blob_part["mime_type"] == "image/jpeg" + assert blob_part["file_uri"] == "gs://bucket/file.jpg" + + +def test_extract_contents_messages_inline_data(): + """Test extract_contents_messages with inline_data (binary)""" + # Create inline data with bytes + image_bytes = b"fake_image_data" + blob = genai_types.Blob(data=image_bytes, mime_type="image/png") + part = genai_types.Part(inline_data=blob) + content = genai_types.Content(parts=[part]) + result = extract_contents_messages(content) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert len(result[0]["content"]) == 1 + blob_part = result[0]["content"][0] + assert blob_part["type"] == "blob" + assert blob_part["mime_type"] == "image/png" + assert "content" in blob_part + # Verify base64 encoding + expected_b64 = base64.b64encode(image_bytes).decode("utf-8") + assert blob_part["content"] == f"data:image/png;base64,{expected_b64}" + + +def test_extract_contents_messages_function_response(): + """Test extract_contents_messages with function_response (tool message)""" + function_response = genai_types.FunctionResponse( + id="call_123", name="get_weather", response={"output": "sunny"} + ) + part = genai_types.Part(function_response=function_response) + content = genai_types.Content(parts=[part]) + result = extract_contents_messages(content) + + assert len(result) == 1 + assert result[0]["role"] == "tool" + assert result[0]["content"]["toolCallId"] == "call_123" + assert result[0]["content"]["toolName"] == "get_weather" + assert result[0]["content"]["output"] == '"sunny"' + + +def test_extract_contents_messages_function_response_with_output_key(): + """Test extract_contents_messages with function_response that has output key""" + function_response = genai_types.FunctionResponse( + id="call_456", name="get_time", response={"output": "3:00 PM", "error": None} + ) + part = genai_types.Part(function_response=function_response) + content = genai_types.Content(parts=[part]) + result = extract_contents_messages(content) + + assert len(result) == 1 + assert result[0]["role"] == "tool" + assert result[0]["content"]["toolCallId"] == "call_456" + assert result[0]["content"]["toolName"] == "get_time" + # Should prefer "output" key + assert result[0]["content"]["output"] == '"3:00 PM"' + + +def test_extract_contents_messages_mixed_parts(): + """Test extract_contents_messages with mixed content parts""" + content = genai_types.Content( + role="user", + parts=[ + genai_types.Part(text="Text part"), + genai_types.Part( + file_data=genai_types.FileData( + file_uri="gs://bucket/image.jpg", mime_type="image/jpeg" + ) + ), + ], + ) + result = extract_contents_messages(content) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert len(result[0]["content"]) == 2 + assert result[0]["content"][0] == {"text": "Text part", "type": "text"} + assert result[0]["content"][1]["type"] == "blob" + assert result[0]["content"][1]["file_uri"] == "gs://bucket/image.jpg" + + +def test_extract_contents_messages_list(): + """Test extract_contents_messages with list input""" + contents = [ + "First message", + genai_types.Content( + role="user", parts=[genai_types.Part(text="Second message")] + ), + ] + result = extract_contents_messages(contents) + + assert len(result) == 2 + assert result[0] == {"role": "user", "content": "First message"} + assert result[1]["role"] == "user" + assert result[1]["content"] == [{"text": "Second message", "type": "text"}] + + +def test_extract_contents_messages_dict_content(): + """Test extract_contents_messages with dict (ContentDict)""" + content_dict = {"role": "user", "parts": [{"text": "Dict message"}]} + result = extract_contents_messages(content_dict) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"] == [{"text": "Dict message", "type": "text"}] + + +def test_extract_contents_messages_dict_with_text(): + """Test extract_contents_messages with dict containing text key""" + content_dict = {"role": "user", "text": "Simple text"} + result = extract_contents_messages(content_dict) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"] == [{"text": "Simple text", "type": "text"}] + + +def test_extract_contents_messages_file_object(): + """Test extract_contents_messages with File object""" + file_obj = genai_types.File( + name="files/123", uri="gs://bucket/file.pdf", mime_type="application/pdf" + ) + result = extract_contents_messages(file_obj) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert len(result[0]["content"]) == 1 + blob_part = result[0]["content"][0] + assert blob_part["type"] == "blob" + assert blob_part["mime_type"] == "application/pdf" + assert blob_part["file_uri"] == "gs://bucket/file.pdf" + + +@pytest.mark.skipif( + not hasattr(genai_types, "PIL_Image") or genai_types.PIL_Image is None, + reason="PIL not available", +) +def test_extract_contents_messages_pil_image(): + """Test extract_contents_messages with PIL.Image.Image""" + try: + from PIL import Image as PILImage + + # Create a simple test image + img = PILImage.new("RGB", (10, 10), color="red") + result = extract_contents_messages(img) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert len(result[0]["content"]) == 1 + blob_part = result[0]["content"][0] + assert blob_part["type"] == "blob" + assert blob_part["mime_type"].startswith("image/") + assert "content" in blob_part + assert blob_part["content"].startswith("data:image/") + except ImportError: + pytest.skip("PIL not available") + + +def test_extract_contents_messages_tool_and_text(): + """Test extract_contents_messages with both tool message and text""" + content = genai_types.Content( + role="user", + parts=[ + genai_types.Part(text="User question"), + genai_types.Part( + function_response=genai_types.FunctionResponse( + id="call_789", name="search", response={"output": "results"} + ) + ), + ], + ) + result = extract_contents_messages(content) + + # Should have two messages: one user message and one tool message + assert len(result) == 2 + # First should be user message with text + assert result[0]["role"] == "user" + assert result[0]["content"] == [{"text": "User question", "type": "text"}] + # Second should be tool message + assert result[1]["role"] == "tool" + assert result[1]["content"]["toolCallId"] == "call_789" + assert result[1]["content"]["toolName"] == "search" + + +def test_extract_contents_messages_empty_parts(): + """Test extract_contents_messages with Content object with empty parts""" + content = genai_types.Content(role="user", parts=[]) + result = extract_contents_messages(content) + + assert result == [] + + +def test_extract_contents_messages_empty_list(): + """Test extract_contents_messages with empty list""" + result = extract_contents_messages([]) + assert result == [] + + +def test_extract_contents_messages_dict_inline_data(): + """Test extract_contents_messages with dict containing inline_data""" + content_dict = { + "role": "user", + "parts": [{"inline_data": {"data": b"binary_data", "mime_type": "image/gif"}}], + } + result = extract_contents_messages(content_dict) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert len(result[0]["content"]) == 1 + blob_part = result[0]["content"][0] + assert blob_part["type"] == "blob" + assert blob_part["mime_type"] == "image/gif" + expected_b64 = base64.b64encode(b"binary_data").decode("utf-8") + assert blob_part["content"] == f"data:image/gif;base64,{expected_b64}" + + +def test_extract_contents_messages_dict_function_response(): + """Test extract_contents_messages with dict containing function_response""" + content_dict = { + "role": "user", + "parts": [ + { + "function_response": { + "id": "dict_call_1", + "name": "dict_tool", + "response": {"result": "success"}, + } + } + ], + } + result = extract_contents_messages(content_dict) + + assert len(result) == 1 + assert result[0]["role"] == "tool" + assert result[0]["content"]["toolCallId"] == "dict_call_1" + assert result[0]["content"]["toolName"] == "dict_tool" + assert result[0]["content"]["output"] == '{"result": "success"}' + + +def test_extract_contents_messages_object_with_text_attribute(): + """Test extract_contents_messages with object that has text attribute""" + + class TextObject: + def __init__(self): + self.text = "Object text" + + obj = TextObject() + result = extract_contents_messages(obj) + + assert len(result) == 1 + assert result[0]["role"] == "user" + assert result[0]["content"] == [{"text": "Object text", "type": "text"}] From 4244319b862f2e109ad8556cb624debf8916e58f Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Thu, 8 Jan 2026 14:19:57 +0100 Subject: [PATCH 2/4] fix(integrations): address cursor review comments --- sentry_sdk/integrations/google_genai/utils.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index ac3870a888..f2d8467463 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -291,10 +291,10 @@ def _extract_part_content(part: "Any") -> "Optional[dict[str, Any]]": if "function_response" in part: return _extract_tool_message_from_part(part) - if "text" in part: + if part.get("text"): return {"text": part["text"], "type": "text"} - if "file_data" in part: + if part.get("file_data"): file_data = part["file_data"] if isinstance(file_data, dict): return { @@ -303,7 +303,7 @@ def _extract_part_content(part: "Any") -> "Optional[dict[str, Any]]": "file_uri": file_data.get("file_uri"), } - if "inline_data" in part: + if part.get("inline_data"): inline_data = part["inline_data"] if isinstance(inline_data, dict): data = inline_data.get("data") @@ -384,7 +384,7 @@ def _extract_tool_message_from_part(part: "Any") -> "Optional[dict[str, Any]]": if isinstance(function_response, dict): tool_call_id = function_response.get("id") tool_name = function_response.get("name") - response_dict = function_response.get("response", {}) + response_dict = function_response.get("response") or {} # Prefer "output" key if present, otherwise use entire response output = response_dict.get("output", response_dict) else: @@ -763,8 +763,6 @@ def set_span_data_for_request( messages.extend(contents_messages) if messages: - # Redact blob message parts - messages = redact_blob_message_parts(messages) normalized_messages = normalize_message_roles(messages) scope = sentry_sdk.get_current_scope() messages_data = truncate_and_annotate_messages( From f72aa457298a1ec351dd69c7f126ff63c54f3029 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Thu, 8 Jan 2026 15:21:55 +0100 Subject: [PATCH 3/4] fix(integrations): ensure file_data returns valid blob structure only if mime_type and file_uri are present (Cursor comment) --- sentry_sdk/integrations/google_genai/utils.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index f2d8467463..f36288dd3c 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -297,11 +297,14 @@ def _extract_part_content(part: "Any") -> "Optional[dict[str, Any]]": if part.get("file_data"): file_data = part["file_data"] if isinstance(file_data, dict): - return { - "type": "blob", - "mime_type": file_data.get("mime_type"), - "file_uri": file_data.get("file_uri"), - } + mime_type = file_data.get("mime_type") + file_uri = file_data.get("file_uri") + if mime_type and file_uri: + return { + "type": "blob", + "mime_type": mime_type, + "file_uri": file_uri, + } if part.get("inline_data"): inline_data = part["inline_data"] From 2be041984f3623ba900582f846d74d50bb0aad54 Mon Sep 17 00:00:00 2001 From: Fabian Schindler Date: Thu, 8 Jan 2026 15:26:15 +0100 Subject: [PATCH 4/4] fix(integrations): add type ignore for missing PIL.Image import --- sentry_sdk/integrations/google_genai/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index f36288dd3c..05d2b4bdba 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -244,7 +244,7 @@ def extract_contents_messages(contents: "ContentListUnion") -> "List[Dict[str, A # Handle PIL.Image.Image try: - from PIL import Image as PILImage + from PIL import Image as PILImage # type: ignore[import-not-found] if isinstance(contents, PILImage.Image): blob_part = _extract_pil_image(contents) @@ -417,7 +417,7 @@ def _extract_tool_message_from_part(part: "Any") -> "Optional[dict[str, Any]]": def _extract_pil_image(image: "Any") -> "Optional[dict[str, Any]]": """Extract blob part from PIL.Image.Image.""" try: - from PIL import Image as PILImage + from PIL import Image as PILImage # type: ignore[import-not-found] import io if not isinstance(image, PILImage.Image):