Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 27 additions & 1 deletion ddtrace/llmobs/_integrations/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,11 @@ def openai_set_meta_tags_from_chat(
"""Extract prompt/response tags from a chat completion and set them as temporary "_ml_obs.meta.*" tags."""
input_messages: list[Message] = []
for m in kwargs.get("messages", []):
content = str(_get_attr(m, "content", ""))
raw_content = _get_attr(m, "content", "")
if isinstance(raw_content, list):
content = _extract_chat_content_parts(raw_content)
else:
content = str(raw_content) if raw_content else ""
Comment on lines +331 to +334
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Parse non-list content iterables before stringifying

This branch only parses multimodal parts when content is a list, so any SDK object that exposes parts as a non-list iterable (for example Pydantic ValidatorIterator/Iterable wrappers) still falls through to str(raw_content) and produces the same unreadable placeholder representation this fix is meant to eliminate. In those inputs, LLMObs will keep recording validator object strings instead of prompt text/image markers.

Useful? React with 👍 / 👎.

role = str(_get_attr(m, "role", ""))
processed_message: Message = Message(content=content, role=role)
tool_call_id = _get_attr(m, "tool_call_id", None)
Expand Down Expand Up @@ -821,6 +825,28 @@ def _extract_content_item_text(content_item: Any) -> str:
return ""


def _extract_chat_content_parts(content_parts: list) -> str:
"""Extract text from OpenAI Chat API multimodal content parts.

Handles content part types used by the Chat Completions API:
- {"type": "text", "text": "..."}
- {"type": "image_url", "image_url": {"url": "..."}}
- {"type": "input_audio", ...}
"""
texts = []
for part in content_parts:
part_type = _get_attr(part, "type", "")
if part_type == "text":
text = _get_attr(part, "text", "")
if text:
texts.append(str(text))
elif part_type == "image_url":
texts.append(IMAGE_FALLBACK_MARKER)
elif part_type == "input_audio":
texts.append("[audio]")
return "\n".join(texts)


def _normalize_prompt_variables(variables: dict[str, Any]) -> dict[str, Any]:
"""Converts OpenAI SDK response objects or dicts into simple key-value pairs.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fixes:
- |
LLM Observability: Fix handling of multimodal content parts in Chat Completions input messages. Messages with list-type content (e.g. text + image_url parts) are now correctly extracted instead of being serialized as a raw list string.
53 changes: 53 additions & 0 deletions tests/llmobs/test_integrations_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from ddtrace.llmobs._integrations.utils import _extract_chat_content_parts
from ddtrace.llmobs._integrations.utils import _extract_chat_template_from_instructions
from ddtrace.llmobs._integrations.utils import _normalize_prompt_variables

Expand Down Expand Up @@ -163,6 +164,58 @@ def __init__(self, file_url=None, file_id=None, filename=None, file_data=None):
assert result["file_fallback"] == "[file]"


class TestExtractChatContentParts:
def test_text_only(self):
parts = [{"type": "text", "text": "Hello world"}]
assert _extract_chat_content_parts(parts) == "Hello world"

def test_multiple_text_parts(self):
parts = [
{"type": "text", "text": "First part"},
{"type": "text", "text": "Second part"},
]
assert _extract_chat_content_parts(parts) == "First part\nSecond part"

def test_text_and_image_url(self):
parts = [
{"type": "text", "text": "Describe this image"},
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
]
assert _extract_chat_content_parts(parts) == "Describe this image\n[image]"

def test_image_url_only(self):
parts = [{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}]
assert _extract_chat_content_parts(parts) == "[image]"

def test_input_audio(self):
parts = [
{"type": "text", "text": "Transcribe this"},
{"type": "input_audio", "input_audio": {"data": "base64data", "format": "wav"}},
]
assert _extract_chat_content_parts(parts) == "Transcribe this\n[audio]"

def test_empty_list(self):
assert _extract_chat_content_parts([]) == ""

def test_empty_text(self):
parts = [{"type": "text", "text": ""}]
assert _extract_chat_content_parts(parts) == ""

def test_pydantic_like_objects(self):
"""Simulate Pydantic model objects with attribute access (like OpenAI SDK models)."""

class ContentPart:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)

parts = [
ContentPart(type="text", text="Return an integer up to 10."),
ContentPart(type="image_url", image_url=ContentPart(url="https://example.com/img.png")),
]
assert _extract_chat_content_parts(parts) == "Return an integer up to 10.\n[image]"


def test_extract_chat_template_with_falsy_values():
"""Test that falsy but valid values (0, False) are preserved in template extraction."""

Expand Down
Loading