Skip to content

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

Open
HQidea wants to merge 2 commits 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 2 commits 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 (Text(content="['a', 'b']")). That shape is not a valid OpenAI request anyway; the new behavior seems strictly more useful.
  • 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?

  • New unit tests in tests/test_prepare_input_messages_unit.py covering: 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, and content=None.
  • Full package suite: pytest tests/ → 213 passed, 7 skipped (was 202 passed + the 11 new tests failing before the fix).
  • ruff check and ruff format --check clean with the repo-pinned ruff (0.16.1).

Checklist

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

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

One or more co-authors of this pull request were not found. You must specify co-authors in commit message trailer via:

Co-authored-by: name <email>

Supported Co-authored-by: formats include:

  1. Anything <id+login@users.noreply.github.com> - it will locate your GitHub user by id part.
  2. Anything <login@users.noreply.github.com> - it will locate your GitHub user by login part.
  3. Anything <public-email> - it will locate your GitHub user by public-email part. Note that this email must be made public on Github.
  4. Anything <other-email> - it will locate your GitHub user by other-email part but only if that email was used before for any other CLA as a main commit author.
  5. login <any-valid-email> - it will locate your GitHub user by login part, note that login part must be at least 3 characters long.

Alternatively, if the co-author should not be included, remove the Co-authored-by: line from the commit message.

Please update your commit message(s) by doing git commit --amend and then git push [--force] and then request re-running CLA check via commenting on this pull request:

/easycla

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 the author · refreshed 2026-08-05 05:26 UTC

Two things need attention:

  • Required checks are failing — investigate the failures.
  • 4 review items — respond to each (e.g. link a commit, explain why not, ask a follow-up):
    • Inline threads: 1, 2, 3, 4
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.
  • Should this be with reviewers? Comment /dashboard route:reviewers to route it to them.
  • Anything wrong — including the routing? 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
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 []
@@ -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 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

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