Skip to content

fix(portkey): do not drain generator content and capture image parts - #843

Open
SAMurai-16 wants to merge 10 commits into
open-telemetry:mainfrom
SAMurai-16:fix/portkey-generator-content-and-image-parts
Open

SAMurai-16 wants to merge 10 commits into
open-telemetry:mainfrom
SAMurai-16:fix/portkey-generator-content-and-image-parts

Conversation

@SAMurai-16

Copy link
Copy Markdown
Contributor

Description

To resolve this:

  • Only walk materialized Sequences (excluding str, bytes, and bytearray) in _prepare_input_messages so generator content is left unconsumed for the SDK.
  • Map image_url parts to UriPart (for remote URLs) and BlobPart (for inline base64 data: URLs) via image_from_url.
  • Support both dictionary parts and object instances with property access (SimpleNamespace/SDK models).
  • Add unit and integration tests covering generator content and image parts.

Fixes #829

Type of change

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

How has this been tested?

Added unit and integration tests in instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py:

  • test_chat_completions_generator_content_not_consumed: verifies the caller's generator remains unconsumed while span attributes record parts: [].
  • test_chat_completions_image_parts: verifies remote URLs map to UriPart and inline data: URLs decode to BlobPart.
  • test_prepare_input_messages_*: unit tests covering generators, iterators, byte sequence handling, image URLs, and object models.

Executed test suite:

  • pytest instrumentation/opentelemetry-instrumentation-genai-portkey/tests -v (210 passed, 3 skipped)
  • Pre-commit lint and format checks (ruff, ruff-format passed)

Checklist

  • Followed the style guidelines of this project
  • Changelog updated if the change requires an entry (will add once PR number is assigned)
  • Unit tests added

Copilot AI balanced review requested due to automatic review settings October 3, 2026 13:07
@SAMurai-16
SAMurai-16 requested a review from a team as a code owner October 3, 2026 13:07

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Pull request dashboard status

Waiting on the author · refreshed 2026-10-22 04:37 UTC

Respond to 4 review items (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.

- Only walk materialized Sequences in _prepare_input_messages to leave generator content unconsumed for the SDK

- Map image_url parts to UriPart and BlobPart via image_from_url

- Add unit and integration tests covering generator content and image parts
@SAMurai-16
SAMurai-16 force-pushed the fix/portkey-generator-content-and-image-parts branch from 201b09b to a68881c Compare October 3, 2026 17:36
@opentelemetry-pr-dashboard

This comment has been minimized.

@SAMurai-16
SAMurai-16 requested a review from lmolkova October 16, 2026 18:55
@SAMurai-16

Copy link
Copy Markdown
Contributor Author

I bumped the opentelemetry-util-genai dependency floor to 1.3b0.dev and updated the test mocks so they properly handle the legacy HTTP _post interface in portkey-ai==1.0.0 alongside the modern openai_client wrapper, making sure both the oldest and latest CI test environments pass cleanly.

@SAMurai-16
SAMurai-16 requested a review from lmolkova October 21, 2026 11:09
]


def test_chat_completions_generator_content_captured(

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.

The new generator tests only cover the success path. Could we add one where the SDK reads the generator and then fails, to check messages are still recorded on the error span? A stream=True variant (sync and async) would cover the streaming path too.

def test_chat_completions_generator_messages_captured_on_error(
    tracer_provider, logger_provider, meter_provider, span_exporter
):
    def consume_then_fail(*args, **kwargs):
        _consume_chat_request(*args, **kwargs)
        raise RuntimeError("boom")

    with instrument(
        PortkeyInstrumentor(),
        tracer_provider=tracer_provider,
        logger_provider=logger_provider,
        meter_provider=meter_provider,
        content_capture="SPAN_ONLY",
    ):
        p = Portkey(api_key="test_pk", provider="openai")
        if hasattr(p.chat.completions, "openai_client"):
            p.chat.completions.openai_client = MagicMock()
            p.chat.completions.openai_client.with_raw_response.chat.completions.create.side_effect = consume_then_fail
        p.chat.completions._post = MagicMock(side_effect=consume_then_fail)

        with pytest.raises(RuntimeError, match="boom"):
            p.chat.completions.create(
                messages=(m for m in [{"role": "user", "content": "Hello"}]),
                model="gpt-4o",
            )

    span = span_exporter.get_finished_spans()[0]
    assert span.attributes[ErrorAttributes.ERROR_TYPE] == "RuntimeError"
    input_messages = json.loads(span.attributes[GenAIAttributes.GEN_AI_INPUT_MESSAGES])
    assert input_messages[0]["parts"] == [{"type": "text", "content": "Hello"}]

Comment on lines +117 to +129
if capture_content:
if (
msgs := messages_capture.extract(
_prepare_input_messages
)
) is not None:
invocation.input_messages = msgs
if (
defs := tools_capture.extract(
_prepare_tool_definitions
)
) is not None:
invocation.tool_definitions = defs

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.

nit: extract already returns None when capture is disabled, so the capture_content and is not None checks can go (same in the async wrapper):

Suggested change
if capture_content:
if (
msgs := messages_capture.extract(
_prepare_input_messages
)
) is not None:
invocation.input_messages = msgs
if (
defs := tools_capture.extract(
_prepare_tool_definitions
)
) is not None:
invocation.tool_definitions = defs
invocation.input_messages = (
messages_capture.extract(_prepare_input_messages) or []
)
invocation.tool_definitions = tools_capture.extract(
_prepare_tool_definitions
)

Comment on lines +39 to +45
__all__ = [
"_prepare_input_messages",
"_prepare_tool_definitions",
"create_inference_invocation",
"is_streaming",
"set_response_properties",
]

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.

nit: listing _-prefixed names in __all__ is a bit contradictory. Since patch.py imports them, could we drop the underscore (prepare_input_messages, prepare_tool_definitions) and the __all__ block?

]


def test_prepare_input_messages_generator_content_not_consumed():

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.

These tests call the internal _prepare_input_messages directly. Could we go through p.chat.completions.create and check gen_ai.input.messages on the span instead, like test_chat_completions_image_parts does? Then the _prepare_input_messages import can go. The generator/iterator cases are already covered end-to-end above. A parametrized test covers the part types:

@pytest.mark.parametrize(
    ("content_part", "expected"),
    [
        (
            {"type": "input_audio", "input_audio": {"data": "UklGRg==", "format": "wav"}},
            [{"type": "blob", "modality": "audio", "mime_type": "audio/wav", "content": "UklGRg=="}],
        ),
        (
            {"type": "input_audio", "input_audio": {"data": "!!!notbase64!!!", "format": "wav"}},
            [],
        ),
        (
            {"type": "file", "file": {"file_id": "file-abc123", "filename": "doc.pdf"}},
            [{"type": "file", "modality": "document", "mime_type": "application/pdf", "file_id": "file-abc123"}],
        ),
        (
            {"type": "refusal", "refusal": "I cannot help with that."},
            [{"type": "text", "content": "I cannot help with that."}],
        ),
        (
            {"type": "image_url", "image_url": SimpleNamespace(url="https://example.com/obj.png")},
            [{"type": "uri", "modality": "image", "mime_type": None, "uri": "https://example.com/obj.png"}],
        ),
    ],
)
def test_chat_completions_content_parts(
    content_part, expected, tracer_provider, logger_provider, meter_provider, span_exporter
):
    with instrument(
        PortkeyInstrumentor(),
        tracer_provider=tracer_provider,
        logger_provider=logger_provider,
        meter_provider=meter_provider,
        content_capture="SPAN_ONLY",
    ):
        p = Portkey(api_key="test_pk", provider="openai")
        _setup_mock_chat(p, _create_mock_chat_completion())
        p.chat.completions.create(
            messages=[{"role": "assistant", "content": [content_part]}],
            model="gpt-4o",
        )

    span = span_exporter.get_finished_spans()[0]
    messages = json.loads(span.attributes[GenAIAttributes.GEN_AI_INPUT_MESSAGES])
    assert messages[0]["parts"] == expected

This branch has not been deployed

No deployments
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.

[genai-portkey] With content capture enabled, generator/iterator message content is consumed and sent empty

3 participants