Skip to content

openai: convert list-of-parts message content to semconv message parts - #358

Open
HQidea wants to merge 1 commit into
open-telemetry:mainfrom
HQidea:fix/openai-content-parts-input-messages
Open

openai: convert list-of-parts message content to semconv message parts#358
HQidea wants to merge 1 commit into
open-telemetry:mainfrom
HQidea:fix/openai-content-parts-input-messages

Conversation

@HQidea

@HQidea HQidea commented Aug 5, 2026

Copy link
Copy Markdown

Description

Chat Completions content may 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 accepts str or an iterable of str, so the list form was silently dropped and such messages were recorded in gen_ai.input.messages as {"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 from opentelemetry-util-genai:

  • {"type": "text"} parts → one Text part each
  • {"type": "image_url"}Uri (modality image; data: URLs are recorded as sent, not decoded)
  • {"type": "input_audio"}Blob (modality audio, base64-decoded, mime type from format)
  • {"type": "file"} with a file_idFile
  • {"type": "refusal"}Text (the refusal string is the message's user-visible text)
  • unrecognized part types are skipped instead of dropping the whole message

Behavior notes:

  • Plain-string content behaves exactly as before (Text(content=<string>)).
  • A list of plain strings now yields one Text part per string; previously the whole list was stringified into a single part. That shape is not a valid OpenAI request anyway; the new behavior seems strictly more useful.
  • Mapping-shaped content is explicitly guarded (iterating a dict would have produced Text parts for its keys); base64 decode failures catch ValueError only.
  • Parts are converted via get_property_value, so both TypedDict/dict parts and attribute objects work.

Fixes #357

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How has this been tested?

  • Realistic flow tests: test_chat_completion_multiturn_content_parts (multi-turn conversation whose messages use {"type": "text"} part lists) and test_chat_completion_multimodal_content_parts (text + remote/data image_url + input_audio + file parts in one request) drive instrumented chat.completions.create calls against cassettes and assert gen_ai.input.messages / gen_ai.output.messages on the exported span. Both fail against the unpatched code (verified by reverting utils.py to main).
  • A slim unit module (test_prepare_input_messages_unit.py) covers degenerate content shapes a well-formed request would not carry: unknown part types, mapping-shaped content, invalid base64 audio, attribute-object parts, list-of-strings, None.
  • Full package suite: pytest tests/ → 262 passed, 8 skipped.
  • ruff check and ruff format --check clean with the repo-pinned ruff.

Checklist

  • Followed the style guidelines of this project
  • Changelog updated if the change requires an entry
  • Unit tests added
  • Documentation updated (n/a)

@HQidea
HQidea requested a review from a team as a code owner August 5, 2026 02:51
Copilot AI lite review requested due to automatic review settings August 5, 2026 02:51
@linux-foundation-easycla

linux-foundation-easycla Bot commented Aug 5, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: HQidea / name: HQidea (39aa107)

HQidea added a commit to HQidea/opentelemetry-python-genai that referenced this pull request Aug 5, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Aug 5, 2026

Copy link
Copy Markdown

Pull request dashboard status

Waiting on reviewers · refreshed 2026-08-13 04:33 UTC

Review the latest changes.

Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Anything look wrong? Report it with what you expected; it helps us improve the dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 content values (string or list-of-parts) into semconv Text/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.

Comment on lines +173 to +177
def _decode_base64(data: str) -> bytes | None:
try:
return base64.b64decode(data)
except Exception: # pylint: disable=broad-exception-caught
return None

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Narrowed to except ValueError (binascii.Error is a ValueError subclass). For context, the blanket form was copied from the anthropic package's _decode_base64; that one may deserve the same narrowing separately.

Comment on lines +241 to +253
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 []

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added an explicit Mapping guard that records no parts — a bare dict is not a valid content shape, and iterating it would have produced Text parts for its keys. Covered by a unit test.

@@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reworded to match the existing fragments' style: capitalized sentence with RST double-backtick literals.

@lmolkova lmolkova left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please write realistic tests against real instrumentation flow - this is a private method that might or might not be called

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Rewrote the coverage to drive the real instrumentation flow: test_chat_completion_multiturn_content_parts and test_chat_completion_multimodal_content_parts (in test_chat_completions.py) make instrumented chat.completions.create calls against cassettes and assert gen_ai.input.messages / gen_ai.output.messages on the exported span. Both fail against the unpatched code. A slim unit module remains only for degenerate content shapes a well-formed request wouldn't carry (unknown part types, mapping-shaped content, invalid base64, None).

@opentelemetry-pr-dashboard

Copy link
Copy Markdown

Hi @HQidea — just a friendly reminder that this pull request is waiting on you.

There are still items that need your attention. See the dashboard status comment for the full list. You don't need to push a code change to hand it back — replying to move each discussion forward is enough, whether that's answering a question, explaining why no change is needed, or asking a follow-up. The dashboard then automatically routes it back to reviewers.

If you believe this pull request is incorrectly routed as waiting on the author, comment /dashboard route:reviewers to route it from waiting on the author to waiting on reviewers.

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). Mapping-shaped content is explicitly
guarded (iterating it would have produced Text parts for its keys), and
base64 decoding failures only catch ValueError.

Coverage is driven through the real instrumentation flow
(test_chat_completion_multiturn_content_parts and
test_chat_completion_multimodal_content_parts, cassette-based, asserting
gen_ai.input.messages on the exported span); a slim unit module keeps
the degenerate shapes a well-formed request would not carry.

Implemented with Claude (Anthropic) assistance.

Fixes open-telemetry#357
@HQidea
HQidea force-pushed the fix/openai-content-parts-input-messages branch from b8e3bb9 to 39aa107 Compare August 13, 2026 04:29
@HQidea

HQidea commented Aug 13, 2026

Copy link
Copy Markdown
Author

/easycla

@HQidea

HQidea commented Aug 13, 2026

Copy link
Copy Markdown
Author

@lmolkova sorry for the slow follow-up, and thanks for the review — updated as suggested. Ready for another look whenever you have a chance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

openai: chat message content recorded as empty parts when it is a list of content parts

3 participants