1616from ddtrace .llmobs ._constants import DISPATCH_ON_TOOL_CALL_OUTPUT_USED
1717from ddtrace .llmobs ._constants import FILE_FALLBACK_MARKER
1818from ddtrace .llmobs ._constants import IMAGE_FALLBACK_MARKER
19+ from ddtrace .llmobs ._constants import IMAGE_TOO_LARGE_MARKER
1920from ddtrace .llmobs ._constants import INPUT_COST_METRIC_KEY
2021from ddtrace .llmobs ._constants import INPUT_TOKENS_METRIC_KEY
2122from ddtrace .llmobs ._constants import INPUT_TYPE_FILE
3435# public names here so existing ``from ...utils import <helper>`` imports keep working.
3536from ddtrace .llmobs ._integrations .audio_utils import G711_SAMPLE_RATE # noqa: F401
3637from ddtrace .llmobs ._integrations .audio_utils import LLMOBS_AUDIO_INLINE_MAX_BYTES # noqa: F401
38+ from ddtrace .llmobs ._integrations .audio_utils import _base64_encoded_len
3739from ddtrace .llmobs ._integrations .audio_utils import audio_mime_type_from_format # noqa: F401
3840from ddtrace .llmobs ._integrations .audio_utils import concat_base64_audio # noqa: F401
3941from ddtrace .llmobs ._integrations .audio_utils import format_audio_part # noqa: F401
@@ -395,16 +397,81 @@ def format_image_part(data: Union[bytes, str], mime_type: str) -> ImagePart:
395397 return ImagePart (mime_type = mime_type , content = content )
396398
397399
398- def _extract_content_parts (parts : list ) -> tuple [str , list [AudioPart ]]:
399- """Extract readable text and audio segments from multimodal content parts (e.g., text + image + audio)."""
400+ # Budget for a single inline image, measured on its base64 form (what actually rides the span event).
401+ # Kept under the 5 MB per-event limit with headroom for the rest of the event: an oversize event has
402+ # its whole input *and* output replaced with a placeholder (see ``_writer._truncate_span_event``).
403+ # AIDEV-NOTE: per-image, not cumulative. N images that each fit can still bust the event limit
404+ # together; a shared per-request budget is a deliberate follow-up.
405+ LLMOBS_IMAGE_INLINE_MAX_BYTES = 4 * 1024 * 1024
406+
407+ # Tolerates extra media-type parameters (e.g. ``;charset=utf-8``) and is case-insensitive because
408+ # the data-URL scheme is. The payload class admits the whitespace some encoders use to wrap base64
409+ # across lines, but nothing else -- a non-base64 payload must fail here rather than be captured.
410+ _BASE64_IMAGE_DATA_URL = re .compile (r"^data:(image/[-\w.+]+)(?:;[\w.=+-]+)*;base64,([A-Za-z0-9+/=\s]+)$" , re .IGNORECASE )
411+
412+
413+ def format_image_part_with_guard (
414+ data : Union [bytes , str ], mime_type : str , max_bytes : int = LLMOBS_IMAGE_INLINE_MAX_BYTES
415+ ) -> Optional [ImagePart ]:
416+ """Build an ``ImagePart`` only when its base64 payload fits the inline budget.
417+
418+ Returns ``None`` for oversize images; callers keep a text marker instead so the surrounding
419+ message text and the model response survive.
420+ """
421+ if not data or not mime_type :
422+ return None
423+ encoded_len = _base64_encoded_len (len (data )) if isinstance (data , bytes ) else len (data )
424+ if encoded_len > max_bytes :
425+ logger .debug (
426+ "Image (%d encoded bytes) exceeds inline budget %d; omitting inline image content" , encoded_len , max_bytes
427+ )
428+ return None
429+ return format_image_part (data , mime_type )
430+
431+
432+ def _is_data_url (value : Any ) -> bool :
433+ """Whether ``value`` carries its payload inline as a ``data:`` URL rather than by reference."""
434+ return isinstance (value , str ) and value [:5 ].lower () == "data:"
435+
436+
437+ def _capture_inline_image (url : Any ) -> tuple [Optional [ImagePart ], Optional [str ]]:
438+ """Capture a ``data:image/...;base64,...`` URL as an ``ImagePart``.
439+
440+ Returns ``(part, None)`` on capture and ``(None, marker)`` when the payload must be dropped.
441+ ``(None, None)`` means ``url`` is not a data URL at all, so the caller keeps its own reference
442+ text: remote URLs and ``file_id``s are never fetched.
443+ """
444+ # Skip the regex for the remote-URL case, which is everything not starting with the scheme.
445+ if not _is_data_url (url ):
446+ return None , None
447+ match = _BASE64_IMAGE_DATA_URL .match (url )
448+ # Any data URL we can't parse must still not reach the caller's reference text, or the whole
449+ # payload would land in the message content.
450+ payload = "" .join (match .group (2 ).split ()) if match else ""
451+ if not payload :
452+ return None , IMAGE_FALLBACK_MARKER
453+ part = format_image_part_with_guard (payload , match .group (1 ).lower ())
454+ return (part , None ) if part else (None , IMAGE_TOO_LARGE_MARKER )
455+
456+
457+ def _extract_content_parts (parts : list ) -> tuple [str , list [AudioPart ], list [ImagePart ]]:
458+ """Extract readable text, audio and image segments from multimodal content parts."""
400459 extracted = []
401460 audio_parts : list [AudioPart ] = []
461+ image_parts : list [ImagePart ] = []
402462 for part in parts :
403463 part_type = _get_attr (part , "type" , "" )
404464 if part_type == "text" :
405465 extracted .append (str (_get_attr (part , "text" , "" )))
406466 elif part_type == "image_url" :
407- extracted .append (IMAGE_FALLBACK_MARKER )
467+ # Chat completions nest the URL as ``image_url.url``; some callers pass a bare string.
468+ image_url = _get_attr (part , "image_url" , None )
469+ image_part , marker = _capture_inline_image (_get_attr (image_url , "url" , None ) or image_url )
470+ if image_part :
471+ # Captured as a structured part (rendered inline), so no text marker is needed.
472+ image_parts .append (image_part )
473+ else :
474+ extracted .append (marker or IMAGE_FALLBACK_MARKER )
408475 elif part_type == "input_audio" :
409476 input_audio = _get_attr (part , "input_audio" , {}) or {}
410477 data = _get_attr (input_audio , "data" , "" )
@@ -418,7 +485,7 @@ def _extract_content_parts(parts: list) -> tuple[str, list[AudioPart]]:
418485 extracted .append (AUDIO_FALLBACK_MARKER )
419486 else :
420487 extracted .append (f"[{ part_type } ]" )
421- return "\n " .join (extracted ), audio_parts
488+ return "\n " .join (extracted ), audio_parts , image_parts
422489
423490
424491def openai_set_meta_tags_from_chat (
@@ -429,8 +496,9 @@ def openai_set_meta_tags_from_chat(
429496 for m in kwargs .get ("messages" , []):
430497 raw_content = _get_attr (m , "content" , "" )
431498 audio_parts : list [AudioPart ] = []
499+ image_parts : list [ImagePart ] = []
432500 if isinstance (raw_content , list ):
433- content , audio_parts = _extract_content_parts (raw_content )
501+ content , audio_parts , image_parts = _extract_content_parts (raw_content )
434502 elif raw_content is None :
435503 content = ""
436504 else :
@@ -439,6 +507,8 @@ def openai_set_meta_tags_from_chat(
439507 processed_message : Message = Message (content = content , role = role )
440508 if audio_parts :
441509 processed_message ["audio_parts" ] = audio_parts
510+ if image_parts :
511+ processed_message ["image_parts" ] = image_parts
442512 tool_call_id = _get_attr (m , "tool_call_id" , None )
443513 if tool_call_id :
444514 core .dispatch (DISPATCH_ON_TOOL_CALL_OUTPUT_USED , (tool_call_id , span ))
@@ -735,21 +805,32 @@ def _openai_parse_input_response_messages(
735805 # Handle regular message
736806 if role is not None and content is not None :
737807 processed_item_content = ""
808+ image_parts : list [ImagePart ] = []
738809 if isinstance (content , list ):
739810 for content_part in content :
740811 processed_item_content += str (_get_attr (content_part , "text" , "" ) or "" )
741812 processed_item_content += str (_get_attr (content_part , "refusal" , "" ) or "" )
742813
743814 content_part_type = _get_attr (content_part , "type" , None )
744815 if content_part_type == INPUT_TYPE_IMAGE :
745- processed_item_content += _extract_image_reference (content_part )
816+ image_part , marker = _capture_inline_image (_get_attr (content_part , "image_url" , None ))
817+ if image_part :
818+ image_parts .append (image_part )
819+ else :
820+ # str(): _extract_image_reference returns whatever ``image_url`` holds,
821+ # so a dict there would raise and cost the span all of its tags.
822+ processed_item_content += str (marker or _extract_image_reference (content_part ))
746823 elif content_part_type == INPUT_TYPE_FILE :
747824 processed_item_content += _extract_file_reference (content_part )
748825 else :
749826 processed_item_content = content
750- if processed_item_content :
827+ # An image-only message has no text left once the image is captured, but still must be
828+ # emitted so the captured image_parts reach the span.
829+ if processed_item_content or image_parts :
751830 processed_item ["content" ] = str (processed_item_content )
752831 processed_item ["role" ] = role
832+ if image_parts :
833+ processed_item ["image_parts" ] = image_parts
753834 elif item_type in ("function_call" , "custom_tool_call" ):
754835 # Process `ResponseFunctionToolCallParam` or ResponseCustomToolCallParam type from input messages
755836 arguments_str = arguments or input_ or OAI_HANDOFF_TOOL_ARG
@@ -913,6 +994,13 @@ def _openai_parse_output_response_messages(
913994 elif message_type == "mcp_list_tools" :
914995 mcp_tool_definitions .extend (_openai_get_tool_definitions (_get_attr (item , "tools" , [])))
915996 continue
997+ elif message_type in ("image_generation_call" , "computer_call_output" ):
998+ # Both carry an image the generic str(item) fallback below would dump whole into the
999+ # text: a generated image's ``result`` is raw base64, and a computer-use screenshot's
1000+ # ``image_url`` may be an inline data URL. Capturing them as image_parts is follow-up
1001+ # work; for now only the reference survives.
1002+ screenshot = _get_attr (item , "output" , None )
1003+ message .update ({"content" : _image_reference_text (screenshot or item ), "role" : "assistant" })
9161004 else :
9171005 message .update ({"content" : str (item ), "role" : "assistant" })
9181006
@@ -962,11 +1050,21 @@ def _extract_file_reference(obj: Any) -> str:
9621050 )
9631051
9641052
1053+ def _image_reference_text (obj : Any ) -> str :
1054+ """Reference text for an image, never the inline payload itself.
1055+
1056+ Prompt variables and chat templates are plain strings with nowhere to put an ``ImagePart``, so
1057+ an inline data URL degrades to the marker rather than putting megabytes of base64 on the span.
1058+ """
1059+ reference = _extract_image_reference (obj )
1060+ return IMAGE_FALLBACK_MARKER if _is_data_url (reference ) else reference
1061+
1062+
9651063def _extract_content_item_text (content_item : Any ) -> str :
9661064 """Extract text representation from a content item (text/image/file)."""
9671065 item_type = _get_attr (content_item , "type" , None )
9681066 if item_type == INPUT_TYPE_IMAGE :
969- return _extract_image_reference (content_item )
1067+ return _image_reference_text (content_item )
9701068 elif item_type == INPUT_TYPE_FILE :
9711069 return _extract_file_reference (content_item )
9721070 elif item_type == INPUT_TYPE_TEXT or item_type is None :
0 commit comments