Skip to content

Commit 1796508

Browse files
committed
feat(llmobs): capture inline base64 images for OpenAI chat and responses
Inline `data:image/...;base64,...` content is captured as typed `image_parts` on LLM span messages for both Chat Completions and the Responses API. Three paths previously wrote a raw base64 payload into span text instead: - the Responses API concatenated the whole data URL into the message content, which also dropped an image-only message because no text remained; - a reusable-prompt variable holding a data URL recorded it verbatim; - image-generation results and computer-use screenshots fell through to a `str(item)` fallback that renders every field of the SDK model, base64 included. Each now degrades to a marker, or to the remote reference where one exists. Capturing generated images as `image_parts` is follow-up work. A per-image size guard mirrors the audio helper and shares its base64 length calculation. An oversize inline image is left as a distinct marker so the surrounding text and the model response survive. Remote URLs and file_ids are not fetched. The guard is per image, not cumulative; a test pins that limit. MLOB-6408
1 parent 41afee5 commit 1796508

5 files changed

Lines changed: 507 additions & 12 deletions

File tree

ddtrace/llmobs/_constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,9 @@ class LLMObsSamplingDecision(str, Enum):
177177
IMAGE_FALLBACK_MARKER = "[image]"
178178
FILE_FALLBACK_MARKER = "[file]"
179179
AUDIO_FALLBACK_MARKER = "[audio]"
180+
# Distinct from IMAGE_FALLBACK_MARKER so a dropped inline image stays greppable instead of looking
181+
# like a remote reference we never fetch.
182+
IMAGE_TOO_LARGE_MARKER = "[image omitted: too large]"
180183

181184
# OpenAI input types
182185
INPUT_TYPE_IMAGE = "input_image"

ddtrace/llmobs/_integrations/utils.py

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ddtrace.llmobs._constants import DISPATCH_ON_TOOL_CALL_OUTPUT_USED
1717
from ddtrace.llmobs._constants import FILE_FALLBACK_MARKER
1818
from ddtrace.llmobs._constants import IMAGE_FALLBACK_MARKER
19+
from ddtrace.llmobs._constants import IMAGE_TOO_LARGE_MARKER
1920
from ddtrace.llmobs._constants import INPUT_COST_METRIC_KEY
2021
from ddtrace.llmobs._constants import INPUT_TOKENS_METRIC_KEY
2122
from ddtrace.llmobs._constants import INPUT_TYPE_FILE
@@ -34,6 +35,7 @@
3435
# public names here so existing ``from ...utils import <helper>`` imports keep working.
3536
from ddtrace.llmobs._integrations.audio_utils import G711_SAMPLE_RATE # noqa: F401
3637
from ddtrace.llmobs._integrations.audio_utils import LLMOBS_AUDIO_INLINE_MAX_BYTES # noqa: F401
38+
from ddtrace.llmobs._integrations.audio_utils import _base64_encoded_len
3739
from ddtrace.llmobs._integrations.audio_utils import audio_mime_type_from_format # noqa: F401
3840
from ddtrace.llmobs._integrations.audio_utils import concat_base64_audio # noqa: F401
3941
from 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

424491
def 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+
9651063
def _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:
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
features:
3+
- |
4+
LLM Observability: The OpenAI integration now captures inline base64 images from message content
5+
as ``image_parts`` on LLM span messages, for both Chat Completions and the Responses API, so
6+
image inputs render in LLM Observability. Images referenced by a remote URL or ``file_id`` are
7+
not fetched and keep their existing text reference. A single inline image whose base64 payload
8+
exceeds 4 MiB is left as an ``[image omitted: too large]`` marker instead. Note that this budget
9+
is per image, not per request: several inline images (or an image alongside inline audio) can
10+
still take a span event past the 5 MB per-event limit, at which point the event's input and
11+
output are replaced with a placeholder.
12+
fixes:
13+
- |
14+
LLM Observability: Fixes an issue where an inline base64 image sent in message content to the
15+
OpenAI Responses API (including through the OpenAI Agents SDK) was recorded as the entire base64
16+
data URL inside the message text, producing unreadable multi-megabyte span content.
17+
- |
18+
LLM Observability: Fixes an issue where an inline base64 image passed as a reusable-prompt
19+
variable to the OpenAI Responses API was recorded as the entire base64 data URL on the span.
20+
Such a variable is now recorded as ``[image]``; remote URL and ``file_id`` references are
21+
unchanged.
22+
- |
23+
LLM Observability: Fixes an issue where OpenAI Responses API image-generation results and
24+
computer-use screenshots were recorded as a stringified dump of the whole response item, which
25+
embedded the image's base64 payload in the output message. They are now recorded as an
26+
``[image]`` marker, or as the screenshot's URL when it is a remote reference.

tests/contrib/openai/test_openai_llmobs.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from ddtrace.internal.utils.version import parse_version
88
from ddtrace.llmobs import LLMObs
9+
from ddtrace.llmobs._integrations.utils import LLMOBS_IMAGE_INLINE_MAX_BYTES
910
from ddtrace.llmobs._integrations.utils import _est_tokens
1011
from ddtrace.llmobs._utils import _get_attr
1112
from ddtrace.llmobs._utils import _get_llmobs_data_metastruct
@@ -491,6 +492,72 @@ def test_chat_completion_multimodal_content(self, openai, openai_llmobs, test_sp
491492
tags={"ml_app": "<ml-app-name>", "service": "tests.contrib.openai", "integration": "openai"},
492493
)
493494

495+
def test_chat_completion_inline_image_captured(self, openai, openai_llmobs, test_spans):
496+
"""An inline base64 image reaches the span as an image_part, leaving only the text content."""
497+
with get_openai_vcr(subdirectory_name="v1").use_cassette("chat_completion_image_input.yaml"):
498+
client = openai.OpenAI()
499+
resp = client.chat.completions.create(
500+
model="gpt-4-vision-preview",
501+
messages=[
502+
{
503+
"role": "user",
504+
"content": [
505+
{"type": "text", "text": "What’s in this image?"},
506+
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAECAw=="}},
507+
],
508+
}
509+
],
510+
)
511+
spans = [s for trace in test_spans.pop_traces() for s in trace]
512+
assert len(spans) == 1
513+
assert_llmobs_span_data(
514+
_get_llmobs_data_metastruct(spans[0]),
515+
span_kind="llm",
516+
name="OpenAI.createChatCompletion",
517+
model_name=resp.model,
518+
model_provider="openai",
519+
input_messages=[
520+
{
521+
"role": "user",
522+
"content": "What’s in this image?",
523+
"image_parts": [{"mime_type": "image/png", "content": "AAECAw=="}],
524+
}
525+
],
526+
output_messages=[{"role": "assistant", "content": resp.choices[0].message.content}],
527+
metadata={},
528+
metrics={"input_tokens": 1118, "output_tokens": 16, "total_tokens": 1134},
529+
tags={"ml_app": "<ml-app-name>", "service": "tests.contrib.openai", "integration": "openai"},
530+
)
531+
532+
def test_chat_completion_oversize_inline_image_preserves_text_and_response(self, openai, openai_llmobs, test_spans):
533+
"""Regression: an oversize inline image degrades to a marker rather than costing the whole
534+
span input+output, so the message text and the model response both survive.
535+
"""
536+
oversize_data_url = "data:image/png;base64," + "A" * (LLMOBS_IMAGE_INLINE_MAX_BYTES + 4)
537+
with get_openai_vcr(subdirectory_name="v1").use_cassette("chat_completion_image_input.yaml"):
538+
client = openai.OpenAI()
539+
resp = client.chat.completions.create(
540+
model="gpt-4-vision-preview",
541+
messages=[
542+
{
543+
"role": "user",
544+
"content": [
545+
{"type": "text", "text": "What’s in this image?"},
546+
{"type": "image_url", "image_url": {"url": oversize_data_url}},
547+
],
548+
}
549+
],
550+
)
551+
spans = [s for trace in test_spans.pop_traces() for s in trace]
552+
assert len(spans) == 1
553+
span_event = _get_llmobs_data_metastruct(spans[0])
554+
assert span_event["meta"]["input"]["messages"] == [
555+
{"role": "user", "content": "What’s in this image?\n[image omitted: too large]"}
556+
]
557+
assert span_event["meta"]["output"]["messages"] == [
558+
{"role": "assistant", "content": resp.choices[0].message.content}
559+
]
560+
494561
def test_chat_completion_multimodal_lazy_iterator(self, openai, openai_llmobs, test_spans):
495562
"""Test that iterable message content is materialized to a list before the SDK
496563
consumes it, so post-call tag extraction still sees the content.

0 commit comments

Comments
 (0)