Repository navigation
fix(portkey): do not drain generator content and capture image parts - #843
SAMurai-16 wants to merge 10 commits into
Conversation
Pull request dashboard statusWaiting 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): Status above doesn't look right?
|
- 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
201b09b to
a68881c
Compare
This comment has been minimized.
This comment has been minimized.
|
I bumped the opentelemetry-util-genai dependency floor to |
| ] | ||
|
|
||
|
|
||
| def test_chat_completions_generator_content_captured( |
There was a problem hiding this comment.
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"}]| 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 |
There was a problem hiding this comment.
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):
| 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 | |
| ) |
| __all__ = [ | ||
| "_prepare_input_messages", | ||
| "_prepare_tool_definitions", | ||
| "create_inference_invocation", | ||
| "is_streaming", | ||
| "set_response_properties", | ||
| ] |
There was a problem hiding this comment.
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(): |
There was a problem hiding this comment.
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
Description
_prepare_input_messages(portkey/utils.py), iterating anyIterablemessage content consumed generators and one-shot iterators before the wrapped Portkey SDK call executed, leaving the downstream provider request with empty content (same class of issue as [genai-openai] With content capture enabled, generator/iteratormessagesandtoolsare consumed and the request is sent empty #825)._prepare_input_messagespreviously only extractedtype: "text"parts;type: "image_url"items were dropped rather than being recorded asUriPartorBlobPart.To resolve this:
Sequences (excludingstr,bytes, andbytearray) in_prepare_input_messagesso generator content is left unconsumed for the SDK.image_urlparts toUriPart(for remote URLs) andBlobPart(for inline base64data:URLs) viaimage_from_url.SimpleNamespace/SDK models).Fixes #829
Type of change
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 recordparts: [].test_chat_completions_image_parts: verifies remote URLs map toUriPartand inlinedata:URLs decode toBlobPart.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)ruff,ruff-formatpassed)Checklist