Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1f32952
fix(ai): redact message parts content of type blob
constantinius Dec 17, 2025
795bcea
fix(ai): skip non dict messages
constantinius Dec 17, 2025
a623e13
fix(ai): typing
constantinius Dec 17, 2025
3d3ce5b
fix(ai): content items may not be dicts
constantinius Dec 17, 2025
ce29e47
fix(integrations): OpenAI input messages are now being converted to t…
constantinius Dec 17, 2025
7074f0b
test(integrations): add test for message conversion
constantinius Dec 17, 2025
e8a1adc
feat(integrations): add transformation functions for OpenAI Agents co…
constantinius Jan 8, 2026
c1a2239
feat(ai): implement parse_data_uri function and integrate it into Ope…
constantinius Jan 8, 2026
bd46a6a
Merge branch 'master' into constantinius/fix/integrations/openai-repo…
constantinius Jan 13, 2026
04b27f4
fix: review comment
constantinius Jan 13, 2026
f8345d0
Merge branch 'master' into constantinius/fix/integrations/openai-repo…
constantinius Jan 14, 2026
b74bdb9
fix(integrations): addressing review comments
constantinius Jan 14, 2026
8080904
fix: review comment
constantinius Jan 15, 2026
05b1a79
fix(integrations): extract text content from OpenAI responses instead…
constantinius Jan 15, 2026
bd78165
feat(ai): Add shared content transformation functions for multimodal …
constantinius Jan 15, 2026
4795c3b
Merge shared content transformation functions
constantinius Jan 15, 2026
df59f49
refactor(openai): Use shared transform_message_content from ai/utils
constantinius Jan 15, 2026
412b93e
refactor(ai): split transform_content_part into SDK-specific functions
constantinius Jan 15, 2026
b99640e
Merge SDK-specific transform functions
constantinius Jan 15, 2026
4fba982
refactor(openai): use transform_openai_content_part directly
constantinius Jan 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 277 additions & 0 deletions sentry_sdk/ai/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,243 @@ def parse_data_uri(url: str) -> "Tuple[str, str]":
return mime_type, content


def get_modality_from_mime_type(mime_type: str) -> str:
"""
Infer the content modality from a MIME type string.

Args:
mime_type: A MIME type string (e.g., "image/jpeg", "audio/mp3")

Returns:
One of: "image", "audio", "video", or "document"
Defaults to "image" for unknown or empty MIME types.

Examples:
"image/jpeg" -> "image"
"audio/mp3" -> "audio"
"video/mp4" -> "video"
"application/pdf" -> "document"
"text/plain" -> "document"
"""
if not mime_type:
return "image" # Default fallback

mime_lower = mime_type.lower()
if mime_lower.startswith("image/"):
return "image"
elif mime_lower.startswith("audio/"):
return "audio"
elif mime_lower.startswith("video/"):
return "video"
elif mime_lower.startswith("application/") or mime_lower.startswith("text/"):
return "document"
else:
return "image" # Default fallback for unknown types


def transform_content_part(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we create separate transform_openai_content_part(), transform_anthropic_content_part(), and so on, in addition to the generic function?

From my perspective, the heuristics we have for determining if a content block is openai, or anthropic, or another style, is best-effort and can break when input schemas evolve. Maybe a provider becomes more permissive in what they accept, etc ...

We can reduce the risk of trudging into the wrong code paths in client libraries for openai, anthropic, and so forth, by calling the specific methods like transform_openai_content_part where possible.

And in generic libraries like langchain we accept that the heuristics in the generic transform_content_part() is the best we can do.

content_part: "Dict[str, Any]",
) -> "Optional[Dict[str, Any]]":
"""
Transform a content part from various AI SDK formats to Sentry's standardized format.

Supported input formats:
- OpenAI/LiteLLM: {"type": "image_url", "image_url": {"url": "..."}}
- Anthropic: {"type": "image|document", "source": {"type": "base64|url|file", ...}}
- Google: {"inline_data": {...}} or {"file_data": {...}}
- Generic: {"type": "image|audio|video|file", "base64|url|file_id": "...", "mime_type": "..."}

Output format (one of):
- {"type": "blob", "modality": "...", "mime_type": "...", "content": "..."}
- {"type": "uri", "modality": "...", "mime_type": "...", "uri": "..."}
- {"type": "file", "modality": "...", "mime_type": "...", "file_id": "..."}

Args:
content_part: A dictionary representing a content part from an AI SDK

Returns:
A transformed dictionary in standardized format, or None if the format
is unrecognized or transformation fails.
"""
if not isinstance(content_part, dict):
return None

block_type = content_part.get("type")

# Handle OpenAI/LiteLLM image_url format
# {"type": "image_url", "image_url": {"url": "..."}} or {"type": "image_url", "image_url": "..."}
if block_type == "image_url":
image_url_data = content_part.get("image_url")
if isinstance(image_url_data, str):
url = image_url_data
elif isinstance(image_url_data, dict):
url = image_url_data.get("url", "")
else:
return None

if not url:
return None

# Check if it's a data URI (base64 encoded)
if url.startswith("data:"):
try:
mime_type, content = parse_data_uri(url)
return {
"type": "blob",
"modality": get_modality_from_mime_type(mime_type),
"mime_type": mime_type,
"content": content,
}
except ValueError:
# If parsing fails, return as URI
return {
"type": "uri",
"modality": "image",
"mime_type": "",
"uri": url,
}
else:
# Regular URL
return {
"type": "uri",
"modality": "image",
"mime_type": "",
"uri": url,
}

# Handle Anthropic format with source dict
# {"type": "image|document", "source": {"type": "base64|url|file", "media_type": "...", "data|url|file_id": "..."}}
if block_type in ("image", "document") and "source" in content_part:
source = content_part.get("source")
if not isinstance(source, dict):
return None

source_type = source.get("type")
media_type = source.get("media_type", "")
modality = (
"document"
if block_type == "document"
else get_modality_from_mime_type(media_type)
)

if source_type == "base64":
return {
"type": "blob",
"modality": modality,
"mime_type": media_type,
"content": source.get("data", ""),
}
elif source_type == "url":
return {
"type": "uri",
"modality": modality,
"mime_type": media_type,
"uri": source.get("url", ""),
}
elif source_type == "file":
return {
"type": "file",
"modality": modality,
"mime_type": media_type,
"file_id": source.get("file_id", ""),
}
return None

# Handle Google inline_data format
# {"inline_data": {"mime_type": "...", "data": "..."}}
if "inline_data" in content_part:
inline_data = content_part.get("inline_data")
if isinstance(inline_data, dict):
mime_type = inline_data.get("mime_type", "")
return {
"type": "blob",
"modality": get_modality_from_mime_type(mime_type),
"mime_type": mime_type,
"content": inline_data.get("data", ""),
}
return None

# Handle Google file_data format
# {"file_data": {"mime_type": "...", "file_uri": "..."}}
if "file_data" in content_part:
file_data = content_part.get("file_data")
if isinstance(file_data, dict):
mime_type = file_data.get("mime_type", "")
return {
"type": "uri",
"modality": get_modality_from_mime_type(mime_type),
"mime_type": mime_type,
"uri": file_data.get("file_uri", ""),
}
return None

# Handle generic format with direct fields (LangChain style)
# {"type": "image|audio|video|file", "base64|url|file_id": "...", "mime_type": "..."}
if block_type in ("image", "audio", "video", "file"):
mime_type = content_part.get("mime_type", "")
modality = block_type if block_type != "file" else "document"

# Check for base64 encoded content
if "base64" in content_part:
return {
"type": "blob",
"modality": modality,
"mime_type": mime_type,
"content": content_part.get("base64", ""),
}
# Check for URL reference
elif "url" in content_part:
return {
"type": "uri",
"modality": modality,
"mime_type": mime_type,
"uri": content_part.get("url", ""),
}
# Check for file_id reference
elif "file_id" in content_part:
return {
"type": "file",
"modality": modality,
"mime_type": mime_type,
"file_id": content_part.get("file_id", ""),
}

# Unrecognized format
return None


def transform_message_content(content: "Any") -> "Any":
"""
Transform message content, handling both string content and list of content blocks.

For list content, each item is transformed using transform_content_part().
Items that cannot be transformed (return None) are kept as-is.

Args:
content: Message content - can be a string, list of content blocks, or other

Returns:
- String content: returned as-is
- List content: list with each transformable item converted to standardized format
- Other: returned as-is
"""
if isinstance(content, str):
return content

if isinstance(content, (list, tuple)):
transformed = []
for item in content:
if isinstance(item, dict):
result = transform_content_part(item)
# If transformation succeeded, use the result; otherwise keep original
transformed.append(result if result is not None else item)
else:
transformed.append(item)
return transformed

return content


def _normalize_data(data: "Any", unpack: bool = True) -> "Any":
# convert pydantic data (e.g. OpenAI v1+) to json compatible format
if hasattr(data, "model_dump"):
Expand Down Expand Up @@ -107,6 +344,46 @@ def set_data_normalized(
span.set_data(key, json.dumps(normalized))


def extract_response_output(
output_items: "Any",
) -> "Tuple[List[Any], List[Dict[str, Any]]]":
"""
Extract response text and tool calls from OpenAI Responses API output.

This handles the output format from OpenAI's Responses API where each output
item has a `type` field that can be "message" or "function_call".

Args:
output_items: Iterable of output items from the response

Returns:
Tuple of (response_texts, tool_calls) where:
- response_texts: List of text strings or dicts for unknown message types
- tool_calls: List of tool call dicts
"""
response_texts = [] # type: List[Any]
tool_calls = [] # type: List[Dict[str, Any]]

for output in output_items:
if output.type == "function_call":
if hasattr(output, "model_dump"):
tool_calls.append(output.model_dump())
elif hasattr(output, "dict"):
tool_calls.append(output.dict())
elif output.type == "message":
for output_message in output.content:
try:
response_texts.append(output_message.text)
except AttributeError:
# Unknown output message type, just return the json
if hasattr(output_message, "model_dump"):
response_texts.append(output_message.model_dump())
elif hasattr(output_message, "dict"):
response_texts.append(output_message.dict())

return response_texts, tool_calls


def normalize_message_role(role: str) -> str:
"""
Normalize a message role to one of the 4 allowed gen_ai role values.
Expand Down
Loading
Loading