From b6d55863ef72b7691e4023723313bf8fd6d8a425 Mon Sep 17 00:00:00 2001 From: VimalN2005 Date: Mon, 26 Oct 2026 16:22:03 +0530 Subject: [PATCH 1/4] fix(portkey): do not consume generator content and map image parts (#829) Signed-off-by: VimalN2005 --- .../.changelog/829.fixed | 1 + .../instrumentation/genai/portkey/utils.py | 59 +++++++-- .../tests/test_chat_completions.py | 117 ++++++++++++++++++ 3 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/829.fixed diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/829.fixed b/instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/829.fixed new file mode 100644 index 000000000..f0a676186 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/829.fixed @@ -0,0 +1 @@ +Do not consume generator message content or tools prior to the request, accept tuple inputs, and record image parts as BlobPart or UriPart. diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py index 80a808f16..e67b6e692 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py @@ -7,7 +7,7 @@ import json import sys -from collections.abc import Iterable, Iterator, Mapping +from collections.abc import Iterable, Iterator, Mapping, Sequence from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse @@ -17,6 +17,7 @@ from opentelemetry.util.genai.handler import TelemetryHandler from opentelemetry.util.genai.invocation import InferenceInvocation from opentelemetry.util.genai.types import ( + BlobPart, FunctionToolDefinition, InputMessage, MessagePart, @@ -27,7 +28,9 @@ ToolCallRequest, ToolCallResponse, ToolDefinition, + UriPart, ) +from opentelemetry.util.genai.utils import image_from_url if TYPE_CHECKING: from portkey_ai import AsyncPortkey, Portkey @@ -165,23 +168,45 @@ def _prepare_input_messages(messages: Iterable[Any]) -> list[InputMessage]: if isinstance(content, str): parts.append(Text(content=content)) elif isinstance(content, Mapping): - content_dict = cast(Mapping[str, Any], content) - content_type = content_dict.get("type") - if content_type == "text" and content_dict.get("text"): - parts.append(Text(content=str(content_dict["text"]))) - elif isinstance(content, Iterable): - for item in cast(Iterable[Any], content): + parts.extend( + _extract_content_part(cast(Mapping[str, Any], content)) + ) + elif isinstance(content, Sequence) and not isinstance( + content, (bytes, bytearray) + ): + for item in content: if isinstance(item, str): parts.append(Text(content=item)) elif isinstance(item, Mapping): - item_dict = cast(Mapping[str, Any], item) - item_type = item_dict.get("type") - if item_type == "text" and item_dict.get("text"): - parts.append(Text(content=str(item_dict["text"]))) + parts.extend( + _extract_content_part( + cast(Mapping[str, Any], item) + ) + ) + elif isinstance(item, (BlobPart, UriPart, Text)): + parts.append(item) chat_messages.append(InputMessage(role=str(role), parts=parts)) return chat_messages +def _extract_content_part(item: Mapping[str, Any]) -> list[MessagePart]: + item_type = get_property_value(item, "type") + text = get_property_value(item, "text") + if item_type == "text" or (item_type is None and isinstance(text, str)): + if text: + return [Text(content=str(text))] + return [] + if item_type in ("image_url", "input_image") or "image_url" in item: + image_url = get_property_value(item, "image_url") + if not isinstance(image_url, str): + image_url = get_property_value(image_url, "url") + if isinstance(image_url, str) and image_url: + part = image_from_url(image_url) + if part is not None: + return [part] + return [] + + def _prepare_tool_definitions( tools: Iterable[Any] | None, ) -> list[ToolDefinition] | None: @@ -323,14 +348,22 @@ def _apply_request_parameters( invocation.output_type = response_format tools = kwargs.get("tools") - if tools is not None and isinstance(tools, Iterable): + if ( + tools is not None + and isinstance(tools, Sequence) + and not isinstance(tools, (str, bytes, bytearray)) + ): invocation.tool_definitions = _prepare_tool_definitions( cast(Iterable[Any], tools) ) if capture_content: messages = kwargs.get("messages") - if messages is not None and isinstance(messages, Iterable): + if ( + messages is not None + and isinstance(messages, Sequence) + and not isinstance(messages, (str, bytes, bytearray)) + ): invocation.input_messages = _prepare_input_messages( cast(Iterable[Any], messages) ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py index d9bfafe10..c7c367c09 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py @@ -750,3 +750,120 @@ def test_chat_completions_provider_mapping( span.attributes.get(GenAIAttributes.GEN_AI_PROVIDER_NAME) == expected_provider ) + + +def test_sync_chat_completions_generator_content_not_consumed( + tracer_provider, logger_provider, meter_provider +): + 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") + mock_resp = _create_mock_chat_completion() + _setup_mock_chat(p, mock_resp) + + items = [{"type": "text", "text": "streaming piece"}] + gen = (item for item in items) + + p.chat.completions.create( + messages=[{"role": "user", "content": gen}], + model="gpt-4o", + ) + + assert list(gen) == items + + +def test_sync_chat_completions_tuple_inputs( + 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") + mock_resp = _create_mock_chat_completion() + _setup_mock_chat(p, mock_resp) + + p.chat.completions.create( + messages=( + { + "role": "user", + "content": ({"type": "text", "text": "tuple content"},), + }, + ), + model="gpt-4o", + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = json.loads( + spans[0].attributes.get(GenAIAttributes.GEN_AI_INPUT_MESSAGES) + ) + assert len(input_messages) == 1 + assert input_messages[0]["role"] == "user" + assert input_messages[0]["parts"][0]["content"] == "tuple content" + + +def test_sync_chat_completions_image_parts( + 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") + mock_resp = _create_mock_chat_completion() + _setup_mock_chat(p, mock_resp) + + base64_img = ( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAf" + "FcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + ) + p.chat.completions.create( + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Look at these images"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/test.png" + }, + }, + { + "type": "image_url", + "image_url": {"url": base64_img}, + }, + ], + } + ], + model="gpt-4o", + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = json.loads( + spans[0].attributes.get(GenAIAttributes.GEN_AI_INPUT_MESSAGES) + ) + assert len(input_messages) == 1 + parts = input_messages[0]["parts"] + assert len(parts) == 3 + assert parts[0]["type"] == "text" + assert parts[0]["content"] == "Look at these images" + assert parts[1]["type"] == "uri" + assert parts[1]["uri"] == "https://example.com/test.png" + assert parts[1]["modality"] == "image" + assert parts[2]["type"] == "blob" + assert parts[2]["modality"] == "image" + assert parts[2]["mime_type"] == "image/png" From 94e10f0a5b7453b737fd58ef533d622e37fe10e5 Mon Sep 17 00:00:00 2001 From: VimalN2005 Date: Tue, 27 Oct 2026 00:11:09 +0530 Subject: [PATCH 2/4] fix(portkey): capture iterators lazily, map audio/file/refusal parts, and bump floor util-genai Signed-off-by: VimalN2005 --- .../.changelog/829.fixed | 1 - .../.changelog/988.fixed | 1 + .../instrumentation/genai/portkey/patch.py | 70 +++- .../instrumentation/genai/portkey/utils.py | 309 +++++++++++++----- .../tests/test_chat_completions.py | 256 +++++++++++++++ 5 files changed, 559 insertions(+), 78 deletions(-) delete mode 100644 instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/829.fixed create mode 100644 instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/988.fixed diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/829.fixed b/instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/829.fixed deleted file mode 100644 index f0a676186..000000000 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/829.fixed +++ /dev/null @@ -1 +0,0 @@ -Do not consume generator message content or tools prior to the request, accept tuple inputs, and record image parts as BlobPart or UriPart. diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/988.fixed b/instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/988.fixed new file mode 100644 index 000000000..334929ddb --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/.changelog/988.fixed @@ -0,0 +1 @@ +Do not consume generator message content or tools prior to the request, accept tuple inputs, and record image, audio, file, and refusal parts. diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/patch.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/patch.py index 6b9baf9e6..1562d8968 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/patch.py @@ -12,6 +12,8 @@ from wrapt import wrap_function_wrapper from opentelemetry.instrumentation.genai.portkey.utils import ( + _prepare_input_messages, + _prepare_tool_definitions, create_inference_invocation, is_streaming, set_response_properties, @@ -22,6 +24,8 @@ ) from opentelemetry.instrumentation.utils import unwrap from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.invocation import InferenceInvocation +from opentelemetry.util.genai.iterator_capture import IteratorCapture logger = logging.getLogger(__name__) @@ -85,6 +89,34 @@ def unpatch_portkey() -> None: pass +def _capture_chat_request( + kwargs: dict[str, Any], capture_content: bool +) -> tuple[IteratorCapture, IteratorCapture]: + """Watch messages and tools as the SDK reads them.""" + return ( + IteratorCapture.from_property( + kwargs, "messages", enabled=capture_content + ), + IteratorCapture.from_property( + kwargs, "tools", enabled=True + ), + ) + + +def _apply_captured_request( + invocation: InferenceInvocation, + messages_capture: IteratorCapture, + tools_capture: IteratorCapture, +) -> None: + if messages_capture.snapshot() is not None: + invocation.input_messages = ( + messages_capture.extract(_prepare_input_messages) or [] + ) + tools_defs = tools_capture.extract(_prepare_tool_definitions) + if tools_defs is not None: + invocation.tool_definitions = tools_defs + + def _sync_completions_create( handler: TelemetryHandler, *, @@ -98,11 +130,26 @@ def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> Any: + messages_capture, tools_capture = ( + _capture_chat_request(kwargs, capture_content) + if not is_prompt + else (None, None) + ) invocation = create_inference_invocation( - handler, instance, kwargs, capture_content, is_prompt=is_prompt + handler, + instance, + kwargs, + capture_content=False if not is_prompt else capture_content, + is_prompt=is_prompt, ) try: - result = wrapped(*args, **kwargs) + try: + result = wrapped(*args, **kwargs) + finally: + if not is_prompt and messages_capture and tools_capture: + _apply_captured_request( + invocation, messages_capture, tools_capture + ) if is_streaming(kwargs): return PortkeyStreamWrapper( result, invocation, capture_content @@ -131,11 +178,26 @@ async def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> Any: + messages_capture, tools_capture = ( + _capture_chat_request(kwargs, capture_content) + if not is_prompt + else (None, None) + ) invocation = create_inference_invocation( - handler, instance, kwargs, capture_content, is_prompt=is_prompt + handler, + instance, + kwargs, + capture_content=False if not is_prompt else capture_content, + is_prompt=is_prompt, ) try: - result = await wrapped(*args, **kwargs) + try: + result = await wrapped(*args, **kwargs) + finally: + if not is_prompt and messages_capture and tools_capture: + _apply_captured_request( + invocation, messages_capture, tools_capture + ) if is_streaming(kwargs): return AsyncPortkeyStreamWrapper( result, invocation, capture_content diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py index e67b6e692..17c27305c 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import mimetypes import sys from collections.abc import Iterable, Iterator, Mapping, Sequence from typing import TYPE_CHECKING, Any, cast @@ -18,6 +19,7 @@ from opentelemetry.util.genai.invocation import InferenceInvocation from opentelemetry.util.genai.types import ( BlobPart, + FilePart, FunctionToolDefinition, InputMessage, MessagePart, @@ -28,9 +30,8 @@ ToolCallRequest, ToolCallResponse, ToolDefinition, - UriPart, ) -from opentelemetry.util.genai.utils import image_from_url +from opentelemetry.util.genai.utils import decode_base64, image_from_url if TYPE_CHECKING: from portkey_ai import AsyncPortkey, Portkey @@ -120,9 +121,176 @@ def get_server_address_and_port( return address, port -def _extract_tool_calls(tool_calls: Iterable[Any]) -> list[ToolCallRequest]: +_AUDIO_MIME_TYPES = {"wav": "audio/wav", "mp3": "audio/mpeg"} + + +def _audio_to_part(input_audio: object) -> MessagePart | None: + """Build a blob part for an ``input_audio`` descriptor.""" + if input_audio is None: + return None + data = get_property_value(input_audio, "data") + if not isinstance(data, str): + return None + decoded = decode_base64(data) + if decoded is None: + return None + audio_format = get_property_value(input_audio, "format") + mime_type = ( + _AUDIO_MIME_TYPES.get(audio_format) + if isinstance(audio_format, str) + else None + ) + return BlobPart( + mime_type=mime_type, + modality=Modality.AUDIO, + content=decoded, + ) + + +def _document_to_part(file_obj: object) -> MessagePart | None: + """Build a part for a file descriptor: a reference when the file is + hosted by OpenAI (``file_id``), a blob when it is uploaded inline + (``file_data``).""" + if file_obj is None: + return None + filename = get_property_value(file_obj, "filename") + mime_type = ( + mimetypes.guess_type(filename)[0] + if isinstance(filename, str) and filename + else None + ) + file_id = get_property_value(file_obj, "file_id") + if isinstance(file_id, str) and file_id: + return FilePart( + mime_type=mime_type, modality=Modality.DOCUMENT, file_id=file_id + ) + file_data = get_property_value(file_obj, "file_data") + if not isinstance(file_data, str) or not file_data: + return None + if file_data.startswith("data:"): + return image_from_url(file_data, modality=Modality.DOCUMENT) + content = decode_base64(file_data) + if content is None: + return None + return BlobPart( + mime_type=mime_type, modality=Modality.DOCUMENT, content=content + ) + + +def _tool_response_to_data(value: object) -> Any: + """Reduce a tool result payload to data the GenAI attributes can carry.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + return _tool_response_to_data(model_dump()) + if isinstance(value, Mapping): + mapping_val = cast(Mapping[object, object], value) + return { + str(key): _tool_response_to_data(item) + for key, item in mapping_val.items() + } + if isinstance(value, Sequence) and not isinstance( + value, (bytes, bytearray) + ): + seq_val = cast(Sequence[object], value) + return [_tool_response_to_data(item) for item in seq_val] + return None + + +def _content_to_parts(content: object) -> list[MessagePart]: + if isinstance(content, str): + return [Text(content=content)] + if isinstance(content, MessagePart): + return [content] + seq: Sequence[object] + if isinstance(content, Mapping): + seq = [cast(object, content)] + elif isinstance(content, Sequence) and not isinstance( + content, (bytes, bytearray) + ): + seq = cast(Sequence[object], content) + else: + return [] + + parts: list[MessagePart] = [] + for item in seq: + if isinstance(item, str): + parts.append(Text(content=item)) + continue + if isinstance(item, MessagePart): + parts.append(item) + continue + + part_type = get_property_value(item, "type") + text = get_property_value(item, "text") + if part_type in ("text", "input_text", "output_text") or ( + part_type is None and isinstance(text, str) + ): + if isinstance(text, str): + parts.append(Text(content=text)) + continue + + if part_type == "input_audio": + audio_part = _audio_to_part( + get_property_value(item, "input_audio") + ) + if audio_part is not None: + parts.append(audio_part) + continue + + if part_type in ("file", "input_file"): + file_part = _document_to_part( + get_property_value(item, "file") or item + ) + if file_part is not None: + parts.append(file_part) + continue + + if part_type == "refusal": + refusal = get_property_value(item, "refusal") + if isinstance(refusal, str): + parts.append(Text(content=refusal)) + continue + + if part_type not in ("image_url", "input_image") and ( + part_type is not None + or get_property_value(item, "image_url") is None + ): + continue + + image_url = get_property_value(item, "image_url") + if not isinstance(image_url, str): + image_url = get_property_value(image_url, "url") + if isinstance(image_url, str) and image_url: + image_part = image_from_url(image_url) + if image_part is not None: + parts.append(image_part) + continue + + file_id = get_property_value(item, "file_id") + if ( + part_type == "input_image" + and isinstance(file_id, str) + and file_id + ): + parts.append( + FilePart( + mime_type=None, + modality=Modality.IMAGE, + file_id=file_id, + ) + ) + + return parts + + +def _extract_tool_calls(tool_calls: object) -> list[ToolCallRequest]: + if not isinstance(tool_calls, Iterable): + return [] parts: list[ToolCallRequest] = [] - for tool_call in tool_calls: + tc_seq = cast(Iterable[object], tool_calls) + for tool_call in tc_seq: call_id = get_property_value(tool_call, "id") func_name = "" arguments: Any = None @@ -147,73 +315,62 @@ def _extract_tool_calls(tool_calls: Iterable[Any]) -> list[ToolCallRequest]: return parts -def _prepare_input_messages(messages: Iterable[Any]) -> list[InputMessage]: +def _prepare_input_messages( + messages: object | None, +) -> list[InputMessage]: + if not isinstance(messages, Iterable): + return [] chat_messages: list[InputMessage] = [] - for message in messages: + msg_seq = cast(Iterable[object], messages) + for message in msg_seq: role = get_property_value(message, "role") - if role is None: - continue + name = get_property_value(message, "name") parts: list[MessagePart] = [] content = get_property_value(message, "content") tool_calls = get_property_value(message, "tool_calls") tool_call_id = get_property_value(message, "tool_call_id") - if tool_calls is not None and isinstance(tool_calls, Iterable): - parts.extend(_extract_tool_calls(cast(Iterable[Any], tool_calls))) - if tool_call_id is not None: + if role == Role.ASSISTANT.value: + if tool_calls is not None: + parts.extend(_extract_tool_calls(tool_calls)) + parts.extend(_content_to_parts(content)) + refusal = get_property_value(message, "refusal") + if isinstance(refusal, str): + parts.append(Text(content=refusal)) + elif role == Role.TOOL.value or tool_call_id is not None: parts.append( - ToolCallResponse(id=str(tool_call_id), response=content) + ToolCallResponse( + id=str(tool_call_id) if tool_call_id is not None else None, + response=_tool_response_to_data(content), + ) ) - elif content: - if isinstance(content, str): - parts.append(Text(content=content)) - elif isinstance(content, Mapping): - parts.extend( - _extract_content_part(cast(Mapping[str, Any], content)) + else: + if tool_calls is not None: + parts.extend(_extract_tool_calls(tool_calls)) + parts.extend(_content_to_parts(content)) + refusal = get_property_value(message, "refusal") + if isinstance(refusal, str): + parts.append(Text(content=refusal)) + + if parts: + chat_messages.append( + InputMessage( + role=str(role) if role is not None else "", + parts=parts, + name=str(name) if name is not None else None, ) - elif isinstance(content, Sequence) and not isinstance( - content, (bytes, bytearray) - ): - for item in content: - if isinstance(item, str): - parts.append(Text(content=item)) - elif isinstance(item, Mapping): - parts.extend( - _extract_content_part( - cast(Mapping[str, Any], item) - ) - ) - elif isinstance(item, (BlobPart, UriPart, Text)): - parts.append(item) - chat_messages.append(InputMessage(role=str(role), parts=parts)) + ) return chat_messages -def _extract_content_part(item: Mapping[str, Any]) -> list[MessagePart]: - item_type = get_property_value(item, "type") - text = get_property_value(item, "text") - if item_type == "text" or (item_type is None and isinstance(text, str)): - if text: - return [Text(content=str(text))] - return [] - if item_type in ("image_url", "input_image") or "image_url" in item: - image_url = get_property_value(item, "image_url") - if not isinstance(image_url, str): - image_url = get_property_value(image_url, "url") - if isinstance(image_url, str) and image_url: - part = image_from_url(image_url) - if part is not None: - return [part] - return [] - - def _prepare_tool_definitions( - tools: Iterable[Any] | None, + tools: object | None, ) -> list[ToolDefinition] | None: - if not tools: + if not isinstance(tools, Iterable): return None + tools_seq = cast(Iterable[object], tools) definitions: list[ToolDefinition] = [] - for tool in tools: + for tool in tools_seq: tool_type = get_property_value(tool, "type") if tool_type == "function": func = get_property_value(tool, "function") @@ -287,6 +444,8 @@ def _apply_request_parameters( invocation: InferenceInvocation, kwargs: dict[str, Any], capture_content: bool, + *, + is_prompt: bool = False, ) -> None: if (temp := get_value(kwargs.get("temperature"))) is not None: invocation.temperature = _as_float(temp) @@ -347,27 +506,28 @@ def _apply_request_parameters( else: invocation.output_type = response_format - tools = kwargs.get("tools") - if ( - tools is not None - and isinstance(tools, Sequence) - and not isinstance(tools, (str, bytes, bytearray)) - ): - invocation.tool_definitions = _prepare_tool_definitions( - cast(Iterable[Any], tools) - ) - - if capture_content: - messages = kwargs.get("messages") + if is_prompt: + tools = kwargs.get("tools") if ( - messages is not None - and isinstance(messages, Sequence) - and not isinstance(messages, (str, bytes, bytearray)) + tools is not None + and isinstance(tools, Sequence) + and not isinstance(tools, (str, bytes, bytearray)) ): - invocation.input_messages = _prepare_input_messages( - cast(Iterable[Any], messages) + invocation.tool_definitions = _prepare_tool_definitions( + cast(object, tools) ) + if capture_content: + messages = kwargs.get("messages") + if ( + messages is not None + and isinstance(messages, Sequence) + and not isinstance(messages, (str, bytes, bytearray)) + ): + invocation.input_messages = _prepare_input_messages( + cast(object, messages) + ) + def create_inference_invocation( handler: TelemetryHandler, @@ -377,7 +537,8 @@ def create_inference_invocation( *, is_prompt: bool = False, ) -> InferenceInvocation: - """Create an InferenceInvocation for a chat or prompt completions request.""" + """Create an InferenceInvocation for a chat or prompt completions + request.""" client = getattr(instance, "_client", instance) provider = get_provider(client) address, port = get_server_address_and_port(client) @@ -398,7 +559,9 @@ def create_inference_invocation( prompt_id ) - _apply_request_parameters(invocation, kwargs, capture_content) + _apply_request_parameters( + invocation, kwargs, capture_content, is_prompt=is_prompt + ) return invocation diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py index c7c367c09..f76c52c8b 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py @@ -867,3 +867,259 @@ def test_sync_chat_completions_image_parts( assert parts[2]["type"] == "blob" assert parts[2]["modality"] == "image" assert parts[2]["mime_type"] == "image/png" + + +def test_sync_chat_completions_nested_tool_calls_generator( + 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") + mock_resp = _create_mock_chat_completion() + _setup_mock_chat(p, mock_resp) + + tool_calls_list = [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "San Francisco"}', + }, + } + ] + tool_calls_gen = (tc for tc in tool_calls_list) + seen_tool_calls: list[object] = [] + + def mock_create(*args, **kwargs): + messages = kwargs.get("messages", []) + for msg in messages: + if msg.get("role") == "assistant" and "tool_calls" in msg: + seen_tool_calls.extend(list(msg["tool_calls"])) + mock_raw = MagicMock() + mock_raw.text = json.dumps( + { + "id": mock_resp.id, + "model": mock_resp.model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Done", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + } + ) + mock_raw.headers = {} + return mock_raw + + openai_chat = ( + p.chat.completions.openai_client.with_raw_response.chat.completions + ) + mock_raw_fn = openai_chat.create + mock_raw_fn.side_effect = mock_create + + p.chat.completions.create( + messages=[ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": None, + "tool_calls": tool_calls_gen, + }, + ], + model="gpt-4o", + ) + + assert seen_tool_calls == tool_calls_list + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = json.loads( + spans[0].attributes.get(GenAIAttributes.GEN_AI_INPUT_MESSAGES) + ) + assert len(input_messages) == 2 + assistant_parts = input_messages[1]["parts"] + assert len(assistant_parts) == 1 + assert assistant_parts[0]["type"] == "tool_call" + assert assistant_parts[0]["id"] == "call_123" + assert assistant_parts[0]["name"] == "get_weather" + assert assistant_parts[0]["arguments"] == { + "location": "San Francisco" + } + + +def test_sync_chat_completions_audio_and_file_parts( + 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") + mock_resp = _create_mock_chat_completion() + _setup_mock_chat(p, mock_resp) + + wav_b64 = "ZmFrZSB3YXYgYnl0ZXM=" + mp3_b64 = "ZmFrZSBtcDMgYnl0ZXM=" + pdf_b64 = "JVBERi0xLjQK" + + p.chat.completions.create( + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Clip and doc"}, + { + "type": "input_audio", + "input_audio": { + "data": wav_b64, + "format": "wav", + }, + }, + { + "type": "input_audio", + "input_audio": { + "data": mp3_b64, + "format": "mp3", + }, + }, + { + "type": "file", + "file": {"file_id": "file-123"}, + }, + { + "type": "file", + "file": { + "filename": "spec.pdf", + "file_data": ( + f"data:application/pdf;base64,{pdf_b64}" + ), + }, + }, + { + "type": "input_file", + "file_id": "file-456", + }, + ], + } + ], + model="gpt-4o", + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = json.loads( + spans[0].attributes.get(GenAIAttributes.GEN_AI_INPUT_MESSAGES) + ) + assert len(input_messages) == 1 + parts = input_messages[0]["parts"] + assert len(parts) == 6 + + assert parts[0]["type"] == "text" + assert parts[0]["content"] == "Clip and doc" + + assert parts[1]["type"] == "blob" + assert parts[1]["modality"] == "audio" + assert parts[1]["mime_type"] == "audio/wav" + assert parts[1]["content"] == wav_b64 + + assert parts[2]["type"] == "blob" + assert parts[2]["modality"] == "audio" + assert parts[2]["mime_type"] == "audio/mpeg" + assert parts[2]["content"] == mp3_b64 + + assert parts[3]["type"] == "file" + assert parts[3]["modality"] == "document" + assert parts[3]["file_id"] == "file-123" + + assert parts[4]["type"] == "blob" + assert parts[4]["modality"] == "document" + assert parts[4]["mime_type"] == "application/pdf" + assert parts[4]["content"] == pdf_b64 + + assert parts[5]["type"] == "file" + assert parts[5]["modality"] == "document" + assert parts[5]["file_id"] == "file-456" + + +def test_sync_chat_completions_refusal_and_tool_response( + 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") + mock_resp = _create_mock_chat_completion() + _setup_mock_chat(p, mock_resp) + + p.chat.completions.create( + messages=[ + { + "role": "user", + "content": [ + {"type": "refusal", "refusal": "Cannot proceed"} + ], + }, + { + "role": "assistant", + "content": None, + "refusal": "I refuse this request", + }, + { + "role": "tool", + "tool_call_id": "call_456", + "content": {"result": "success"}, + }, + ], + model="gpt-4o", + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + input_messages = json.loads( + spans[0].attributes.get(GenAIAttributes.GEN_AI_INPUT_MESSAGES) + ) + assert len(input_messages) == 3 + + # User refusal part + assert input_messages[0]["role"] == "user" + assert input_messages[0]["parts"] == [ + {"type": "text", "content": "Cannot proceed"} + ] + + # Assistant refusal field + assert input_messages[1]["role"] == "assistant" + assert input_messages[1]["parts"] == [ + {"type": "text", "content": "I refuse this request"} + ] + + # Tool response message + assert input_messages[2]["role"] == "tool" + assert input_messages[2]["parts"] == [ + { + "type": "tool_call_response", + "id": "call_456", + "response": {"result": "success"}, + } + ] From 184b12119f08cff89982f90e40ad282b1f3775b8 Mon Sep 17 00:00:00 2001 From: VimalN2005 Date: Mon, 2 Nov 2026 18:00:19 +0530 Subject: [PATCH 3/4] fix(portkey): format code and support legacy client in nested tool calls test (#988) Signed-off-by: VimalN2005 --- .../instrumentation/genai/portkey/patch.py | 4 +- .../instrumentation/genai/portkey/utils.py | 6 +- .../tests/test_chat_completions.py | 63 +++++++++---------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/patch.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/patch.py index 1562d8968..69278b889 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/patch.py @@ -97,9 +97,7 @@ def _capture_chat_request( IteratorCapture.from_property( kwargs, "messages", enabled=capture_content ), - IteratorCapture.from_property( - kwargs, "tools", enabled=True - ), + IteratorCapture.from_property(kwargs, "tools", enabled=True), ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py index 17c27305c..6e6f1bc96 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/src/opentelemetry/instrumentation/genai/portkey/utils.py @@ -269,11 +269,7 @@ def _content_to_parts(content: object) -> list[MessagePart]: continue file_id = get_property_value(item, "file_id") - if ( - part_type == "input_image" - and isinstance(file_id, str) - and file_id - ): + if part_type == "input_image" and isinstance(file_id, str) and file_id: parts.append( FilePart( mime_type=None, diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py index f76c52c8b..1f7c78156 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py @@ -901,36 +901,37 @@ def mock_create(*args, **kwargs): for msg in messages: if msg.get("role") == "assistant" and "tool_calls" in msg: seen_tool_calls.extend(list(msg["tool_calls"])) - mock_raw = MagicMock() - mock_raw.text = json.dumps( - { - "id": mock_resp.id, - "model": mock_resp.model, - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Done", - }, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 10, - "completion_tokens": 5, - "total_tokens": 15, - }, - } - ) - mock_raw.headers = {} - return mock_raw + if hasattr(p.chat.completions, "openai_client"): + mock_raw = MagicMock() + mock_raw.text = json.dumps( + { + "id": mock_resp.id, + "model": mock_resp.model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Done", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + } + ) + mock_raw.headers = {} + return mock_raw + return mock_resp - openai_chat = ( - p.chat.completions.openai_client.with_raw_response.chat.completions - ) - mock_raw_fn = openai_chat.create - mock_raw_fn.side_effect = mock_create + if hasattr(p.chat.completions, "openai_client"): + p.chat.completions.openai_client.with_raw_response.chat.completions.create.side_effect = mock_create + else: + p.chat.completions._post.side_effect = mock_create p.chat.completions.create( messages=[ @@ -957,9 +958,7 @@ def mock_create(*args, **kwargs): assert assistant_parts[0]["type"] == "tool_call" assert assistant_parts[0]["id"] == "call_123" assert assistant_parts[0]["name"] == "get_weather" - assert assistant_parts[0]["arguments"] == { - "location": "San Francisco" - } + assert assistant_parts[0]["arguments"] == {"location": "San Francisco"} def test_sync_chat_completions_audio_and_file_parts( From c95ff92fe129594d00135e271bb0f529274d8eaf Mon Sep 17 00:00:00 2001 From: VimalN2005 Date: Mon, 2 Nov 2026 18:09:25 +0530 Subject: [PATCH 4/4] test(portkey): extract messages from body for legacy portkey client compatibility (#988) Signed-off-by: VimalN2005 --- .../tests/test_chat_completions.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py index 1f7c78156..41c47ef77 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/tests/test_chat_completions.py @@ -897,7 +897,12 @@ def test_sync_chat_completions_nested_tool_calls_generator( seen_tool_calls: list[object] = [] def mock_create(*args, **kwargs): - messages = kwargs.get("messages", []) + body = ( + kwargs.get("body") + if isinstance(kwargs.get("body"), dict) + else kwargs + ) + messages = body.get("messages", []) for msg in messages: if msg.get("role") == "assistant" and "tool_calls" in msg: seen_tool_calls.extend(list(msg["tool_calls"])) @@ -931,7 +936,7 @@ def mock_create(*args, **kwargs): if hasattr(p.chat.completions, "openai_client"): p.chat.completions.openai_client.with_raw_response.chat.completions.create.side_effect = mock_create else: - p.chat.completions._post.side_effect = mock_create + p.chat.completions._post = MagicMock(side_effect=mock_create) p.chat.completions.create( messages=[