openai: convert list-of-parts message content to semconv message parts - #358
openai: convert list-of-parts message content to semconv message parts#358HQidea wants to merge 2 commits into
Conversation
Chat Completions `content` may be a plain string or a list of typed
content parts. `_prepare_input_messages` (and `_prepare_output_messages`)
gated content on `_is_text_part`, which only accepts `str` or an iterable
of `str`, so the list form was dropped entirely and such messages were
recorded in `gen_ai.input.messages` as `{"role": ..., "parts": []}`.
Replace the gate with a per-part converter mirroring the anthropic
package's `convert_content_to_parts`:
- `{"type": "text"}` parts -> `Text` (one per part)
- `{"type": "image_url"}` -> `Uri` (modality `image`; data: URLs recorded
as sent, not decoded)
- `{"type": "input_audio"}` -> `Blob` (modality `audio`, base64-decoded)
- `{"type": "file"}` with `file_id` -> `File`
- `{"type": "refusal"}` -> `Text` (the message's user-visible text)
- unrecognized part types are skipped instead of nuking the message
Plain-string content behaves exactly as before. A list of plain strings
now yields one `Text` part per string (previously the whole list was
stringified into a single part).
Fixes open-telemetry#357
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via: Supported
Alternatively, if the co-author should not be included, remove the Please update your commit message(s) by doing |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pull request dashboard statusWaiting on the author · refreshed 2026-08-05 05:26 UTC Two things need attention:
Status above doesn't look right?
|
There was a problem hiding this comment.
Pull request overview
Fixes OpenAI Chat Completions message content handling so list-of-parts (multimodal / typed parts) is converted into semconv MessagePart models instead of being silently dropped, improving gen_ai.input.messages / gen_ai.output.messages fidelity in the OpenAI GenAI instrumentation.
Changes:
- Add per-part conversion for OpenAI
contentvalues (string or list-of-parts) into semconvText/Uri/Blob/File. - Apply the same conversion for both input messages and output messages.
- Add focused unit tests covering the supported OpenAI part variants and edge cases, plus a changelog fragment.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/utils.py | Adds OpenAI content-part → semconv MessagePart conversion and uses it in input/output message preparation. |
| instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_prepare_input_messages_unit.py | Adds unit tests to pin expected conversion for text/image/audio/file/refusal parts and mixed cases. |
| instrumentation/opentelemetry-instrumentation-genai-openai/.changelog/358.fixed | Adds a changelog fragment documenting the bug fix. |
| def _decode_base64(data: str) -> bytes | None: | ||
| try: | ||
| return base64.b64decode(data) | ||
| except Exception: # pylint: disable=broad-exception-caught | ||
| return None |
| def _content_to_parts(content: Any) -> list[MessagePart]: | ||
| """Convert an OpenAI message ``content`` value — a plain string or a | ||
| list of content parts — to semconv message parts.""" | ||
| if isinstance(content, str): | ||
| return [Text(content=content)] | ||
| if isinstance(content, Iterable): | ||
| parts: list[MessagePart] = [] | ||
| for item in content: | ||
| part = _convert_content_part(item) | ||
| if part is not None: | ||
| parts.append(part) | ||
| return parts | ||
| return [] |
| @@ -0,0 +1 @@ | |||
| fix chat message content being dropped from `gen_ai.input.messages`/`gen_ai.output.messages` when it is a list of content parts | |||
lmolkova
left a comment
There was a problem hiding this comment.
LGTM, but let's write more realistic tests. Thanks!
| def test_string_content_is_single_text_part(): | ||
| messages = [{"role": "user", "content": "Say this is a test"}] | ||
|
|
||
| result = _prepare_input_messages(messages) |
There was a problem hiding this comment.
please write realistic tests against real instrumentation flow - this is a private method that might or might not be called
Description
Chat Completions
contentmay be a plain string or a list of typed content parts (the standard OpenAI shape for multi-part text and multimodal requests)._prepare_input_messages(and_prepare_output_messages) gated content on_is_text_part, which only acceptsstror an iterable ofstr, so the list form was silently dropped and such messages were recorded ingen_ai.input.messagesas{"role": ..., "parts": []}even with content capture enabled.This replaces the gate with a per-part converter mirroring the anthropic package's
convert_content_to_parts, using the semconv part models fromopentelemetry-util-genai:{"type": "text"}parts → oneTextpart each{"type": "image_url"}→Uri(modalityimage;data:URLs are recorded as sent, not decoded){"type": "input_audio"}→Blob(modalityaudio, base64-decoded, mime type fromformat){"type": "file"}with afile_id→File{"type": "refusal"}→Text(the refusal string is the message's user-visible text)Behavior notes:
Text(content=<string>)).Textpart per string; previously the whole list was stringified into a single part (Text(content="['a', 'b']")). That shape is not a valid OpenAI request anyway; the new behavior seems strictly more useful.get_property_value, so both TypedDict/dict parts and attribute objects work.Fixes #357
Type of change
How has this been tested?
tests/test_prepare_input_messages_unit.pycovering: string content (unchanged), text-parts list (the regression), attribute-object parts, mixed text+image_url,data:image URLs, input_audio → Blob, file → File, assistant list content combined with tool_calls, refusal, unrecognized part types, list-of-strings, andcontent=None.pytest tests/→ 213 passed, 7 skipped (was 202 passed + the 11 new tests failing before the fix).ruff checkandruff format --checkclean with the repo-pinned ruff (0.16.1).Checklist