-
Notifications
You must be signed in to change notification settings - Fork 572
fix(integrations): openai/openai-agents: convert input message format #5248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
1f32952
795bcea
a623e13
3d3ce5b
ce29e47
7074f0b
e8a1adc
c1a2239
bd46a6a
04b27f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| from sentry_sdk.ai.utils import ( | ||
| set_data_normalized, | ||
| normalize_message_roles, | ||
| parse_data_uri, | ||
| truncate_and_annotate_messages, | ||
| ) | ||
| from sentry_sdk.consts import SPANDATA | ||
|
|
@@ -18,7 +19,7 @@ | |
| safe_serialize, | ||
| ) | ||
|
|
||
| from typing import TYPE_CHECKING | ||
| from typing import TYPE_CHECKING, Dict | ||
|
|
||
| if TYPE_CHECKING: | ||
| from typing import Any, Iterable, List, Optional, Callable, AsyncIterator, Iterator | ||
|
|
@@ -180,6 +181,84 @@ def _calculate_token_usage( | |
| ) | ||
|
|
||
|
|
||
| def _convert_message_parts(messages: "List[Dict[str, Any]]") -> "List[Dict[str, Any]]": | ||
| """ | ||
| Convert the message parts from OpenAI format to the `gen_ai.request.messages` format. | ||
| e.g: | ||
| { | ||
| "role": "user", | ||
| "content": [ | ||
| { | ||
| "text": "How many ponies do you see in the image?", | ||
| "type": "text" | ||
| }, | ||
| { | ||
| "type": "image_url", | ||
| "image_url": { | ||
| "url": "data:image/jpeg;base64,...", | ||
| "detail": "high" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| becomes: | ||
| { | ||
| "role": "user", | ||
| "content": [ | ||
| { | ||
| "text": "How many ponies do you see in the image?", | ||
| "type": "text" | ||
| }, | ||
| { | ||
| "type": "blob", | ||
| "modality": "image", | ||
| "mime_type": "image/jpeg", | ||
| "content": "data:image/jpeg;base64,..." | ||
| } | ||
| ] | ||
| } | ||
| """ | ||
|
|
||
| def _map_item(item: "Dict[str, Any]") -> "Dict[str, Any]": | ||
| if not isinstance(item, dict): | ||
| return item | ||
|
|
||
| if item.get("type") == "image_url": | ||
| image_url = item.get("image_url") or {} | ||
| url = image_url.get("url", "") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing type check for
|
||
| if url.startswith("data:"): | ||
| try: | ||
| mime_type, content = parse_data_uri(url) | ||
| return { | ||
| "type": "blob", | ||
| "modality": "image", | ||
| "mime_type": mime_type, | ||
| "content": content, | ||
| } | ||
| except ValueError: | ||
| # If parsing fails, return as URI | ||
| return { | ||
| "type": "uri", | ||
| "modality": "image", | ||
| "uri": url, | ||
| } | ||
| else: | ||
| return { | ||
| "type": "uri", | ||
| "modality": "image", | ||
| "uri": url, | ||
constantinius marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
constantinius marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return item | ||
constantinius marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| for message in messages: | ||
| if not isinstance(message, dict): | ||
| continue | ||
| content = message.get("content") | ||
| if isinstance(content, list): | ||
| message["content"] = [_map_item(item) for item in content] | ||
| return messages | ||
|
|
||
|
|
||
| def _set_input_data( | ||
| span: "Span", | ||
| kwargs: "dict[str, Any]", | ||
|
|
@@ -201,6 +280,8 @@ def _set_input_data( | |
| and integration.include_prompts | ||
| ): | ||
| normalized_messages = normalize_message_roles(messages) | ||
| normalized_messages = _convert_message_parts(normalized_messages) | ||
|
|
||
| scope = sentry_sdk.get_current_scope() | ||
| messages_data = truncate_and_annotate_messages(normalized_messages, span, scope) | ||
| if messages_data is not None: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
| from sentry_sdk.ai.utils import ( | ||
| GEN_AI_ALLOWED_MESSAGE_ROLES, | ||
| normalize_message_roles, | ||
| parse_data_uri, | ||
| set_data_normalized, | ||
| normalize_message_role, | ||
| truncate_and_annotate_messages, | ||
|
|
@@ -27,6 +28,124 @@ | |
| raise DidNotEnable("OpenAI Agents not installed") | ||
|
|
||
|
|
||
| def _transform_openai_agents_content_part( | ||
| content_part: "dict[str, Any]", | ||
| ) -> "dict[str, Any]": | ||
| """ | ||
| Transform an OpenAI Agents content part to Sentry-compatible format. | ||
|
|
||
| Handles multimodal content (images, audio, files) by converting them | ||
| to the standardized format: | ||
| - base64 encoded data -> type: "blob" | ||
| - URL references -> type: "uri" | ||
| - file_id references -> type: "file" | ||
| """ | ||
| if not isinstance(content_part, dict): | ||
| return content_part | ||
|
|
||
| part_type = content_part.get("type") | ||
|
|
||
| # Handle input_text (OpenAI Agents SDK text format) -> normalize to standard text format | ||
| if part_type == "input_text": | ||
| return { | ||
| "type": "text", | ||
| "text": content_part.get("text", ""), | ||
| } | ||
|
|
||
| # Handle image_url (OpenAI vision format) and input_image (OpenAI Agents SDK format) | ||
| if part_type in ("image_url", "input_image"): | ||
| # Get URL from either format | ||
| if part_type == "image_url": | ||
| image_url = content_part.get("image_url", {}) | ||
| url = ( | ||
| image_url.get("url", "") | ||
| if isinstance(image_url, dict) | ||
| else str(image_url) | ||
| ) | ||
| else: | ||
| # input_image format has image_url directly | ||
| url = content_part.get("image_url", "") | ||
|
|
||
| if url.startswith("data:"): | ||
| try: | ||
| mime_type, content = parse_data_uri(url) | ||
| return { | ||
| "type": "blob", | ||
| "modality": "image", | ||
| "mime_type": mime_type, | ||
| "content": content, | ||
| } | ||
| except ValueError: | ||
| # If parsing fails, return as URI | ||
| return { | ||
| "type": "uri", | ||
| "modality": "image", | ||
| "mime_type": "", | ||
| "uri": url, | ||
| } | ||
| else: | ||
| return { | ||
| "type": "uri", | ||
| "modality": "image", | ||
| "mime_type": "", | ||
| "uri": url, | ||
| } | ||
|
|
||
| # Handle input_audio (OpenAI audio input format) | ||
| if part_type == "input_audio": | ||
| input_audio = content_part.get("input_audio", {}) | ||
| audio_format = input_audio.get("format", "") | ||
|
Comment on lines
+96
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The code assumes 🔍 Detailed AnalysisThe function 💡 Suggested FixAdd 🤖 Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| mime_type = f"audio/{audio_format}" if audio_format else "" | ||
| return { | ||
| "type": "blob", | ||
| "modality": "audio", | ||
| "mime_type": mime_type, | ||
| "content": input_audio.get("data", ""), | ||
| } | ||
|
|
||
| # Handle image_file (Assistants API file-based images) | ||
| if part_type == "image_file": | ||
| image_file = content_part.get("image_file", {}) | ||
| return { | ||
| "type": "file", | ||
| "modality": "image", | ||
| "mime_type": "", | ||
| "file_id": image_file.get("file_id", ""), | ||
| } | ||
|
|
||
| # Handle file (document attachments) | ||
| if part_type == "file": | ||
| file_data = content_part.get("file", {}) | ||
| return { | ||
| "type": "file", | ||
| "modality": "document", | ||
| "mime_type": "", | ||
| "file_id": file_data.get("file_id", ""), | ||
| } | ||
|
|
||
| return content_part | ||
|
|
||
|
|
||
| def _transform_openai_agents_message_content(content: "Any") -> "Any": | ||
| """ | ||
| Transform OpenAI Agents message content, handling both string content and | ||
| list of content parts. | ||
| """ | ||
| if isinstance(content, str): | ||
| return content | ||
|
|
||
| if isinstance(content, (list, tuple)): | ||
| transformed = [] | ||
| for item in content: | ||
| if isinstance(item, dict): | ||
| transformed.append(_transform_openai_agents_content_part(item)) | ||
| else: | ||
| transformed.append(item) | ||
| return transformed | ||
|
|
||
| return content | ||
|
|
||
|
|
||
| def _capture_exception(exc: "Any") -> None: | ||
| set_span_errored() | ||
|
|
||
|
|
@@ -128,13 +247,15 @@ def _set_input_data( | |
| if "role" in message: | ||
| normalized_role = normalize_message_role(message.get("role")) | ||
| content = message.get("content") | ||
| # Transform content to handle multimodal data (images, audio, files) | ||
| transformed_content = _transform_openai_agents_message_content(content) | ||
| request_messages.append( | ||
| { | ||
| "role": normalized_role, | ||
| "content": ( | ||
| [{"type": "text", "text": content}] | ||
| if isinstance(content, str) | ||
| else content | ||
| [{"type": "text", "text": transformed_content}] | ||
| if isinstance(transformed_content, str) | ||
| else transformed_content | ||
| ), | ||
| } | ||
| ) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm worried about inconsistencies in the URI decoding across different AI integrations. Can this be centralized?