Skip to content

Commit e8a1adc

Browse files
committed
feat(integrations): add transformation functions for OpenAI Agents content and update message handling
1 parent 7074f0b commit e8a1adc

File tree

3 files changed

+258
-16
lines changed

3 files changed

+258
-16
lines changed

sentry_sdk/integrations/openai_agents/spans/invoke_agent.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
get_start_span_function,
44
set_data_normalized,
55
normalize_message_roles,
6+
normalize_message_role,
67
truncate_and_annotate_messages,
78
)
89
from sentry_sdk.consts import OP, SPANDATA
910
from sentry_sdk.scope import should_send_default_pii
1011
from sentry_sdk.utils import safe_serialize
1112

1213
from ..consts import SPAN_ORIGIN
13-
from ..utils import _set_agent_data, _set_usage_data
14+
from ..utils import (
15+
_set_agent_data,
16+
_set_usage_data,
17+
_transform_openai_agents_message_content,
18+
)
1419

1520
from typing import TYPE_CHECKING
1621

@@ -49,17 +54,40 @@ def invoke_agent_span(
4954

5055
original_input = kwargs.get("original_input")
5156
if original_input is not None:
52-
message = (
53-
original_input
54-
if isinstance(original_input, str)
55-
else safe_serialize(original_input)
56-
)
57-
messages.append(
58-
{
59-
"content": [{"text": message, "type": "text"}],
60-
"role": "user",
61-
}
62-
)
57+
if isinstance(original_input, str):
58+
# String input: wrap in text block
59+
messages.append(
60+
{
61+
"content": [{"text": original_input, "type": "text"}],
62+
"role": "user",
63+
}
64+
)
65+
elif isinstance(original_input, list) and len(original_input) > 0:
66+
# Check if list contains message objects (with type="message")
67+
# or content parts (input_text, input_image, etc.)
68+
first_item = original_input[0]
69+
if isinstance(first_item, dict) and first_item.get("type") == "message":
70+
# List of message objects - process each individually
71+
for msg in original_input:
72+
if isinstance(msg, dict) and msg.get("type") == "message":
73+
role = normalize_message_role(msg.get("role", "user"))
74+
content = msg.get("content")
75+
transformed = _transform_openai_agents_message_content(
76+
content
77+
)
78+
if isinstance(transformed, str):
79+
transformed = [{"text": transformed, "type": "text"}]
80+
elif not isinstance(transformed, list):
81+
transformed = [
82+
{"text": str(transformed), "type": "text"}
83+
]
84+
messages.append({"content": transformed, "role": role})
85+
else:
86+
# List of content parts - transform and wrap as user message
87+
content = _transform_openai_agents_message_content(original_input)
88+
if not isinstance(content, list):
89+
content = [{"text": str(content), "type": "text"}]
90+
messages.append({"content": content, "role": "user"})
6391

6492
if len(messages) > 0:
6593
normalized_messages = normalize_message_roles(messages)

sentry_sdk/integrations/openai_agents/utils.py

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,126 @@
2727
raise DidNotEnable("OpenAI Agents not installed")
2828

2929

30+
def _transform_openai_agents_content_part(
31+
content_part: "dict[str, Any]",
32+
) -> "dict[str, Any]":
33+
"""
34+
Transform an OpenAI Agents content part to Sentry-compatible format.
35+
36+
Handles multimodal content (images, audio, files) by converting them
37+
to the standardized format:
38+
- base64 encoded data -> type: "blob"
39+
- URL references -> type: "uri"
40+
- file_id references -> type: "file"
41+
"""
42+
if not isinstance(content_part, dict):
43+
return content_part
44+
45+
part_type = content_part.get("type")
46+
47+
# Handle input_text (OpenAI Agents SDK text format) -> normalize to standard text format
48+
if part_type == "input_text":
49+
return {
50+
"type": "text",
51+
"text": content_part.get("text", ""),
52+
}
53+
54+
# Handle image_url (OpenAI vision format) and input_image (OpenAI Agents SDK format)
55+
if part_type in ("image_url", "input_image"):
56+
# Get URL from either format
57+
if part_type == "image_url":
58+
image_url = content_part.get("image_url", {})
59+
url = (
60+
image_url.get("url", "")
61+
if isinstance(image_url, dict)
62+
else str(image_url)
63+
)
64+
else:
65+
# input_image format has image_url directly
66+
url = content_part.get("image_url", "")
67+
68+
if url.startswith("data:"):
69+
# Parse data URI: data:image/jpeg;base64,/9j/4AAQ...
70+
try:
71+
header, content = url.split(",", 1)
72+
mime_type = header.split(":")[1].split(";")[0] if ":" in header else ""
73+
return {
74+
"type": "blob",
75+
"modality": "image",
76+
"mime_type": mime_type,
77+
"content": content,
78+
}
79+
except (ValueError, IndexError):
80+
# If parsing fails, return as URI
81+
return {
82+
"type": "uri",
83+
"modality": "image",
84+
"mime_type": "",
85+
"uri": url,
86+
}
87+
else:
88+
return {
89+
"type": "uri",
90+
"modality": "image",
91+
"mime_type": "",
92+
"uri": url,
93+
}
94+
95+
# Handle input_audio (OpenAI audio input format)
96+
if part_type == "input_audio":
97+
input_audio = content_part.get("input_audio", {})
98+
audio_format = input_audio.get("format", "")
99+
mime_type = f"audio/{audio_format}" if audio_format else ""
100+
return {
101+
"type": "blob",
102+
"modality": "audio",
103+
"mime_type": mime_type,
104+
"content": input_audio.get("data", ""),
105+
}
106+
107+
# Handle image_file (Assistants API file-based images)
108+
if part_type == "image_file":
109+
image_file = content_part.get("image_file", {})
110+
return {
111+
"type": "file",
112+
"modality": "image",
113+
"mime_type": "",
114+
"file_id": image_file.get("file_id", ""),
115+
}
116+
117+
# Handle file (document attachments)
118+
if part_type == "file":
119+
file_data = content_part.get("file", {})
120+
return {
121+
"type": "file",
122+
"modality": "document",
123+
"mime_type": "",
124+
"file_id": file_data.get("file_id", ""),
125+
}
126+
127+
return content_part
128+
129+
130+
def _transform_openai_agents_message_content(content: "Any") -> "Any":
131+
"""
132+
Transform OpenAI Agents message content, handling both string content and
133+
list of content parts.
134+
"""
135+
if isinstance(content, str):
136+
return content
137+
138+
if isinstance(content, (list, tuple)):
139+
transformed = []
140+
for item in content:
141+
if isinstance(item, dict):
142+
transformed.append(_transform_openai_agents_content_part(item))
143+
else:
144+
transformed.append(item)
145+
return transformed
146+
147+
return content
148+
149+
30150
def _capture_exception(exc: "Any") -> None:
31151
set_span_errored()
32152

@@ -128,13 +248,15 @@ def _set_input_data(
128248
if "role" in message:
129249
normalized_role = normalize_message_role(message.get("role"))
130250
content = message.get("content")
251+
# Transform content to handle multimodal data (images, audio, files)
252+
transformed_content = _transform_openai_agents_message_content(content)
131253
request_messages.append(
132254
{
133255
"role": normalized_role,
134256
"content": (
135-
[{"type": "text", "text": content}]
136-
if isinstance(content, str)
137-
else content
257+
[{"type": "text", "text": transformed_content}]
258+
if isinstance(transformed_content, str)
259+
else transformed_content
138260
),
139261
}
140262
)

tests/integrations/openai_agents/test_openai_agents.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
from sentry_sdk import start_span
1010
from sentry_sdk.consts import SPANDATA
1111
from sentry_sdk.integrations.openai_agents import OpenAIAgentsIntegration
12-
from sentry_sdk.integrations.openai_agents.utils import _set_input_data, safe_serialize
12+
from sentry_sdk.integrations.openai_agents.utils import (
13+
_set_input_data,
14+
safe_serialize,
15+
_transform_openai_agents_content_part,
16+
_transform_openai_agents_message_content,
17+
)
1318
from sentry_sdk.utils import parse_version
1419

1520
import agents
@@ -1998,3 +2003,90 @@ def test_openai_agents_message_truncation(sentry_init, capture_events):
19982003
assert len(parsed_messages) == 2
19992004
assert "small message 4" in str(parsed_messages[0])
20002005
assert "small message 5" in str(parsed_messages[1])
2006+
2007+
2008+
def test_transform_image_url_to_blob():
2009+
"""Test that OpenAI image_url with data URI is converted to blob format."""
2010+
content_part = {
2011+
"type": "image_url",
2012+
"image_url": {
2013+
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD",
2014+
"detail": "high",
2015+
},
2016+
}
2017+
result = _transform_openai_agents_content_part(content_part)
2018+
assert result == {
2019+
"type": "blob",
2020+
"modality": "image",
2021+
"mime_type": "image/jpeg",
2022+
"content": "/9j/4AAQSkZJRgABAQAAAQABAAD",
2023+
}
2024+
2025+
2026+
def test_transform_image_url_to_uri():
2027+
"""Test that OpenAI image_url with HTTP URL is converted to uri format."""
2028+
content_part = {
2029+
"type": "image_url",
2030+
"image_url": {
2031+
"url": "https://example.com/image.jpg",
2032+
"detail": "low",
2033+
},
2034+
}
2035+
result = _transform_openai_agents_content_part(content_part)
2036+
assert result == {
2037+
"type": "uri",
2038+
"modality": "image",
2039+
"mime_type": "",
2040+
"uri": "https://example.com/image.jpg",
2041+
}
2042+
2043+
2044+
def test_transform_message_content_with_image():
2045+
"""Test that message content with image is properly transformed."""
2046+
content = [
2047+
{"type": "text", "text": "What is in this image?"},
2048+
{
2049+
"type": "image_url",
2050+
"image_url": {
2051+
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
2052+
},
2053+
},
2054+
]
2055+
result = _transform_openai_agents_message_content(content)
2056+
assert len(result) == 2
2057+
assert result[0] == {"type": "text", "text": "What is in this image?"}
2058+
assert result[1] == {
2059+
"type": "blob",
2060+
"modality": "image",
2061+
"mime_type": "image/png",
2062+
"content": "iVBORw0KGgoAAAANSUhEUg==",
2063+
}
2064+
2065+
2066+
def test_transform_input_image_to_blob():
2067+
"""Test that OpenAI Agents SDK input_image format is converted to blob format."""
2068+
# OpenAI Agents SDK uses input_image type with image_url as a direct string
2069+
content_part = {
2070+
"type": "input_image",
2071+
"image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==",
2072+
}
2073+
result = _transform_openai_agents_content_part(content_part)
2074+
assert result == {
2075+
"type": "blob",
2076+
"modality": "image",
2077+
"mime_type": "image/png",
2078+
"content": "iVBORw0KGgoAAAANSUhEUg==",
2079+
}
2080+
2081+
2082+
def test_transform_input_text_to_text():
2083+
"""Test that OpenAI Agents SDK input_text format is normalized to text format."""
2084+
content_part = {
2085+
"type": "input_text",
2086+
"text": "Hello, world!",
2087+
}
2088+
result = _transform_openai_agents_content_part(content_part)
2089+
assert result == {
2090+
"type": "text",
2091+
"text": "Hello, world!",
2092+
}

0 commit comments

Comments
 (0)