From c0da139540345e1319c5936435f611a6aa307c44 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Mon, 27 Apr 2026 22:51:39 +0530 Subject: [PATCH 1/8] fix(otel): populate gen_ai.output.messages and gen_ai.system_instructions for Responses API Fixes #25840 The OTel integration's set_attributes() method never populates gen_ai.output.messages, gen_ai.system_instructions, or gen_ai.response.finish_reasons for /v1/responses calls because ResponsesAPIResponse uses 'output' instead of 'choices' and the system prompt arrives as 'instructions' instead of 'system_instructions'. Changes: - Add elif branch for response_obj.get('output') to extract response text from Responses API output items (type='message'/output_text) and tool calls (type='function_call') - Coalesce system_instructions/instructions/system kwargs so the system prompt is captured for Responses API, Anthropic Messages API, and Vertex AI Gemini paths - Handle plain-string system prompts without unnecessary wrapping - Extract response_obj.get('status') as finish reason for Responses API - Add _transform_responses_api_output_to_otel() method --- litellm/integrations/opentelemetry.py | 111 +++++++++++++++++++++++--- 1 file changed, 101 insertions(+), 10 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index b6d91d0b76d..b26850657d3 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1678,17 +1678,35 @@ class OpenTelemetry(CustomLogger): value=safe_dumps(transformed_messages), ) - if kwargs.get("system_instructions"): - transformed_system_instructions = ( - self._transform_messages_to_otel_semantic_conventions( - kwargs.get("system_instructions") + # Coalesce the different kwarg names that carry the system + # prompt depending on the call path: + # - "system_instructions" — Vertex AI Gemini chat-completion + # - "instructions" — OpenAI Responses API + # - "system" — Anthropic Messages API + system_instructions = ( + kwargs.get("system_instructions") + or kwargs.get("instructions") + or kwargs.get("system") + ) + if system_instructions: + if isinstance(system_instructions, str): + # Plain text system prompt — no transformation needed + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value, + value=system_instructions, + ) + else: + transformed_system_instructions = ( + self._transform_messages_to_otel_semantic_conventions( + system_instructions + ) + ) + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value, + value=safe_dumps(transformed_system_instructions), ) - ) - self.safe_set_attribute( - span=span, - key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value, - value=safe_dumps(transformed_system_instructions), - ) self.safe_set_attribute( span=span, @@ -1747,6 +1765,32 @@ class OpenTelemetry(CustomLogger): value=value, ) + elif response_obj.get("output"): + # Responses API: ResponsesAPIResponse has an "output" + # list instead of "choices". Each item with + # type="message" contains a "content" list of + # OutputText objects (type="output_text"). + output_messages = ( + self._transform_responses_api_output_to_otel( + response_obj.get("output") + ) + ) + if output_messages: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value, + value=safe_dumps(output_messages), + ) + + # Extract finish reason from ResponsesAPIResponse.status + status = response_obj.get("status") + if status: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value, + value=safe_dumps([status]), + ) + except Exception as e: self.handle_callback_failure( callback_name=self.callback_name or "opentelemetry" @@ -1842,6 +1886,53 @@ class OpenTelemetry(CustomLogger): transformed.append(transformed_msg) return transformed + def _transform_responses_api_output_to_otel( + self, output: List[dict] + ) -> List[dict]: + """ + Transform Responses API output items into OTEL GenAI 1.38 format. + + The Responses API returns output as a list of items, each with a + ``type`` field. Message items (``type="message"``) contain a + ``content`` list of ``OutputText`` objects with ``type="output_text"`` + and ``text`` fields. + + This method converts them to the same ``{"role": ..., "parts": [...]}`` + format used by ``_transform_choices_to_otel_semantic_conventions``. + """ + transformed = [] + for item in output: + if not isinstance(item, dict): + continue + if item.get("type") == "message": + role = item.get("role", "assistant") + parts = [] + for content in item.get("content", []): + if not isinstance(content, dict): + continue + if content.get("type") == "output_text": + text = content.get("text", "") + if text: + parts.append({"type": "text", "content": text}) + if parts: + transformed.append({"role": role, "parts": parts}) + elif item.get("type") == "function_call": + # Surface tool calls from Responses API output + tool_call = { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "name": item.get("name", ""), + "arguments": item.get("arguments", ""), + } + ], + } + if item.get("call_id"): + tool_call["parts"][0]["id"] = item["call_id"] + transformed.append(tool_call) + return transformed + def set_raw_request_attributes(self, span: Span, kwargs, response_obj): try: # Only set provider-specific raw payload attributes on this span. From 4d2e13c9070b74b0945613516e09917e15421023 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Mon, 27 Apr 2026 23:12:34 +0530 Subject: [PATCH 2/8] test(otel): add tests for Responses API output messages, system instructions, and finish reasons Add 21 tests covering the new Responses API OTel attribute handling: TestOpenTelemetryResponsesAPI (13 tests): - gen_ai.output.messages from output items (text, function_call, mixed, multi-part) - gen_ai.response.finish_reasons from ResponsesAPIResponse.status - gen_ai.system_instructions from instructions/system/system_instructions kwargs - Precedence and absence edge cases - Regression test for existing choices-based responses TestTransformResponsesAPIOutput (8 tests): - Message with output_text, function_call items, unknown types - Edge cases: empty output, empty text, missing call_id, default role, non-dict items --- .../integrations/test_opentelemetry.py | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index f7106471894..5f294005641 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -2859,3 +2859,459 @@ class TestResponseIdFallback(unittest.TestCase): otel.set_attributes(mock_span, kwargs, response_obj) mock_span.set_attribute.assert_any_call("litellm.call_id", call_id) + + + +class TestOpenTelemetryResponsesAPI(unittest.TestCase): + """ + Tests for Responses API (/v1/responses) OTel span attributes. + + The Responses API uses ``output`` (list of output items) instead of + ``choices``, ``instructions`` instead of ``system_instructions``, and + ``status`` instead of per-choice ``finish_reason``. + + See: https://github.com/BerriAI/litellm/issues/25840 + """ + + def _base_kwargs(self, **overrides): + """Return minimal kwargs for set_attributes with Responses API defaults.""" + kwargs = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "What is 2+2?"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "resp_abc123", + "call_type": "responses", + "metadata": {}, + }, + } + kwargs.update(overrides) + return kwargs + + def _responses_api_response_obj(self, text="The answer is 4.", status="completed"): + """Return a dict mimicking ResponsesAPIResponse with a message output.""" + return { + "id": "resp_abc123", + "model": "gpt-4o", + "status": status, + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": text, + } + ], + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30, + }, + } + + def _get_attr(self, mock_span, attr_name): + """Extract the value set for a specific attribute name, or None.""" + calls = [ + call + for call in mock_span.set_attribute.call_args_list + if call[0][0] == attr_name + ] + if not calls: + return None + return calls[0][0][1] + + # ------------------------------------------------------------------ + # gen_ai.output.messages + # ------------------------------------------------------------------ + + def test_output_messages_populated_for_responses_api(self): + """gen_ai.output.messages must be set when response has output items.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs() + response_obj = self._responses_api_response_obj(text="The answer is 4.") + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + self.assertIsNotNone(raw, "gen_ai.output.messages should be set") + + parsed = json.loads(raw) + self.assertIsInstance(parsed, list) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0]["role"], "assistant") + self.assertIn("parts", parsed[0]) + self.assertEqual(parsed[0]["parts"][0]["type"], "text") + self.assertEqual(parsed[0]["parts"][0]["content"], "The answer is 4.") + + def test_output_messages_with_multiple_content_items(self): + """Multiple output_text items in a single message should all appear as parts.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_multi", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "First paragraph."}, + {"type": "output_text", "text": "Second paragraph."}, + ], + } + ], + } + + otel.set_attributes( + span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj + ) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + parsed = json.loads(raw) + self.assertEqual(len(parsed[0]["parts"]), 2) + self.assertEqual(parsed[0]["parts"][0]["content"], "First paragraph.") + self.assertEqual(parsed[0]["parts"][1]["content"], "Second paragraph.") + + def test_output_messages_with_function_call(self): + """function_call output items should appear as tool_call parts.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_fc", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_abc", + "arguments": '{"location": "SF"}', + } + ], + } + + otel.set_attributes( + span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj + ) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + parsed = json.loads(raw) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0]["role"], "assistant") + self.assertEqual(parsed[0]["parts"][0]["type"], "tool_call") + self.assertEqual(parsed[0]["parts"][0]["name"], "get_weather") + self.assertEqual(parsed[0]["parts"][0]["arguments"], '{"location": "SF"}') + self.assertEqual(parsed[0]["parts"][0]["id"], "call_abc") + + def test_output_messages_mixed_message_and_function_call(self): + """Mixed output with both message and function_call items.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_mixed", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Let me check the weather."}, + ], + }, + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_xyz", + "arguments": "{}", + }, + ], + } + + otel.set_attributes( + span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj + ) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + parsed = json.loads(raw) + self.assertEqual(len(parsed), 2) + self.assertEqual(parsed[0]["role"], "assistant") + self.assertEqual(parsed[0]["parts"][0]["content"], "Let me check the weather.") + self.assertEqual(parsed[1]["parts"][0]["type"], "tool_call") + + def test_output_messages_empty_text_skipped(self): + """Output items with empty text should not produce parts.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_empty", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": ""}], + } + ], + } + + otel.set_attributes( + span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj + ) + + # No output messages should be set since the text is empty + raw = self._get_attr(mock_span, "gen_ai.output.messages") + self.assertIsNone(raw, "Empty output text should not produce gen_ai.output.messages") + + def test_choices_still_work(self): + """Existing choices-based responses must still work (no regression).""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "completion", + "metadata": {}, + }, + } + + response_obj = { + "id": "chatcmpl-123", + "model": "gpt-4", + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "Hi there!"}, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15}, + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + raw = self._get_attr(mock_span, "gen_ai.output.messages") + parsed = json.loads(raw) + self.assertEqual(parsed[0]["parts"][0]["content"], "Hi there!") + self.assertEqual(parsed[0]["finish_reason"], "stop") + + # ------------------------------------------------------------------ + # gen_ai.response.finish_reasons + # ------------------------------------------------------------------ + + def test_finish_reasons_from_status(self): + """gen_ai.response.finish_reasons should use ResponsesAPIResponse.status.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + otel.set_attributes( + span=mock_span, + kwargs=self._base_kwargs(), + response_obj=self._responses_api_response_obj(status="completed"), + ) + + raw = self._get_attr(mock_span, "gen_ai.response.finish_reasons") + self.assertIsNotNone(raw) + parsed = json.loads(raw) + self.assertEqual(parsed, ["completed"]) + + def test_finish_reasons_incomplete_status(self): + """Non-completed status values should still be captured.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + otel.set_attributes( + span=mock_span, + kwargs=self._base_kwargs(), + response_obj=self._responses_api_response_obj(status="incomplete"), + ) + + raw = self._get_attr(mock_span, "gen_ai.response.finish_reasons") + parsed = json.loads(raw) + self.assertEqual(parsed, ["incomplete"]) + + # ------------------------------------------------------------------ + # gen_ai.system_instructions + # ------------------------------------------------------------------ + + def test_system_instructions_from_instructions_kwarg(self): + """Responses API passes system prompt as kwargs['instructions'].""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs(instructions="You are a math tutor.") + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + value = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertEqual(value, "You are a math tutor.") + + def test_system_instructions_from_system_kwarg(self): + """Anthropic Messages API passes system prompt as kwargs['system'].""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs(system="You are a helpful assistant.") + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + value = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertEqual(value, "You are a helpful assistant.") + + def test_system_instructions_from_system_instructions_kwarg(self): + """Vertex AI Gemini path uses kwargs['system_instructions'] (existing behavior).""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs( + system_instructions=[{"role": "system", "content": "Be concise."}] + ) + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + raw = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertIsNotNone(raw) + parsed = json.loads(raw) + self.assertEqual(parsed[0]["role"], "system") + self.assertIn("parts", parsed[0]) + + def test_system_instructions_precedence(self): + """system_instructions takes precedence over instructions and system.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs( + system_instructions="From Gemini", + instructions="From Responses API", + system="From Anthropic", + ) + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + # system_instructions (string) should win — it's checked first + value = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertEqual(value, "From Gemini") + + def test_no_system_instructions_when_absent(self): + """No gen_ai.system_instructions attr when none of the kwargs are set.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs() + response_obj = self._responses_api_response_obj() + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + value = self._get_attr(mock_span, "gen_ai.system_instructions") + self.assertIsNone(value) + + +class TestTransformResponsesAPIOutput(unittest.TestCase): + """ + Unit tests for _transform_responses_api_output_to_otel. + """ + + def test_message_with_output_text(self): + otel = OpenTelemetry() + output = [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!"}], + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["role"], "assistant") + self.assertEqual(result[0]["parts"], [{"type": "text", "content": "Hello!"}]) + + def test_function_call_item(self): + otel = OpenTelemetry() + output = [ + { + "type": "function_call", + "name": "search", + "call_id": "call_1", + "arguments": '{"q": "test"}', + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["role"], "assistant") + self.assertEqual(result[0]["parts"][0]["type"], "tool_call") + self.assertEqual(result[0]["parts"][0]["name"], "search") + self.assertEqual(result[0]["parts"][0]["id"], "call_1") + + def test_function_call_without_call_id(self): + otel = OpenTelemetry() + output = [ + { + "type": "function_call", + "name": "search", + "arguments": "{}", + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertNotIn("id", result[0]["parts"][0]) + + def test_unknown_type_ignored(self): + otel = OpenTelemetry() + output = [{"type": "reasoning", "content": "thinking..."}] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(result, []) + + def test_non_dict_items_ignored(self): + otel = OpenTelemetry() + output = ["not a dict", 42, None] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(result, []) + + def test_empty_output(self): + otel = OpenTelemetry() + result = otel._transform_responses_api_output_to_otel([]) + self.assertEqual(result, []) + + def test_message_with_empty_text_skipped(self): + otel = OpenTelemetry() + output = [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": ""}], + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(result, []) + + def test_message_default_role(self): + """Messages without explicit role should default to assistant.""" + otel = OpenTelemetry() + output = [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Hi"}], + } + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(result[0]["role"], "assistant") From e70b0c97a4c8d6aef4efd7d1738172acfe4510ea Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Tue, 28 Apr 2026 11:26:14 +0530 Subject: [PATCH 3/8] style: apply black formatting to opentelemetry.py --- litellm/integrations/opentelemetry.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index b26850657d3..664aeadc7c2 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1770,10 +1770,8 @@ class OpenTelemetry(CustomLogger): # list instead of "choices". Each item with # type="message" contains a "content" list of # OutputText objects (type="output_text"). - output_messages = ( - self._transform_responses_api_output_to_otel( - response_obj.get("output") - ) + output_messages = self._transform_responses_api_output_to_otel( + response_obj.get("output") ) if output_messages: self.safe_set_attribute( @@ -1886,9 +1884,7 @@ class OpenTelemetry(CustomLogger): transformed.append(transformed_msg) return transformed - def _transform_responses_api_output_to_otel( - self, output: List[dict] - ) -> List[dict]: + def _transform_responses_api_output_to_otel(self, output: List[dict]) -> List[dict]: """ Transform Responses API output items into OTEL GenAI 1.38 format. From c30d58f7e30a0568c265814d70cea2301bea51a4 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Tue, 28 Apr 2026 12:30:39 +0530 Subject: [PATCH 4/8] fix: resolve mypy indexed assignment error in function_call handling Build the tool_call part dict separately with an explicit type annotation so mypy can track the type, avoiding the 'Unsupported target for indexed assignment' error on tool_call["parts"][0]["id"]. --- litellm/integrations/opentelemetry.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 664aeadc7c2..8084a9d0f22 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1914,19 +1914,14 @@ class OpenTelemetry(CustomLogger): transformed.append({"role": role, "parts": parts}) elif item.get("type") == "function_call": # Surface tool calls from Responses API output - tool_call = { - "role": "assistant", - "parts": [ - { - "type": "tool_call", - "name": item.get("name", ""), - "arguments": item.get("arguments", ""), - } - ], + part: dict = { + "type": "tool_call", + "name": item.get("name", ""), + "arguments": item.get("arguments", ""), } if item.get("call_id"): - tool_call["parts"][0]["id"] = item["call_id"] - transformed.append(tool_call) + part["id"] = item["call_id"] + transformed.append({"role": "assistant", "parts": [part]}) return transformed def set_raw_request_attributes(self, span: Span, kwargs, response_obj): From 466b4ddae31beb94635f72adc796d731b485930f Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Tue, 28 Apr 2026 13:33:33 +0530 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20Pydantic=20compat,=20falsy=20fallthrough,=20per-too?= =?UTF-8?q?l-call=20attrs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace isinstance(item, dict) with hasattr(item, 'get') so Pydantic model instances (ResponseOutputMessage, ResponseFunctionToolCall) are accepted alongside plain dicts (P1) - Use 'is not None' guards instead of or-chain for system_instructions coalescing to prevent falsy values (e.g. []) falling through to the wrong kwarg (P2) - Emit per-tool-call span attributes (gen_ai.completion.N.function_call.*) for Responses API function_call items, matching the choices branch parity with _tool_calls_kv_pair (P2) - Add 4 new tests: Pydantic-like objects, falsy fallthrough guard, per-tool-call attribute emission, multiple tool call indexing --- litellm/integrations/opentelemetry.py | 62 ++++++- .../integrations/test_opentelemetry.py | 171 ++++++++++++++++++ 2 files changed, 227 insertions(+), 6 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 8084a9d0f22..90e647d86bf 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1683,10 +1683,16 @@ class OpenTelemetry(CustomLogger): # - "system_instructions" — Vertex AI Gemini chat-completion # - "instructions" — OpenAI Responses API # - "system" — Anthropic Messages API + # Use `is not None` rather than truthiness to avoid falsy + # values (e.g. []) falling through to the wrong kwarg. system_instructions = ( kwargs.get("system_instructions") - or kwargs.get("instructions") - or kwargs.get("system") + if kwargs.get("system_instructions") is not None + else ( + kwargs.get("instructions") + if kwargs.get("instructions") is not None + else kwargs.get("system") + ) ) if system_instructions: if isinstance(system_instructions, str): @@ -1770,8 +1776,9 @@ class OpenTelemetry(CustomLogger): # list instead of "choices". Each item with # type="message" contains a "content" list of # OutputText objects (type="output_text"). + output_items = response_obj.get("output") output_messages = self._transform_responses_api_output_to_otel( - response_obj.get("output") + output_items ) if output_messages: self.safe_set_attribute( @@ -1780,6 +1787,43 @@ class OpenTelemetry(CustomLogger): value=safe_dumps(output_messages), ) + # Emit per-tool-call span attributes (parity with + # the choices branch that calls _tool_calls_kv_pair). + # Convert Responses API function_call items to the + # ChatCompletionMessageToolCall format expected by + # _tool_calls_kv_pair. + tool_calls = [] + for out_item in output_items: + if ( + hasattr(out_item, "get") + and out_item.get("type") == "function_call" + ): + tool_calls.append( + { + "function": { + "name": out_item.get("name", ""), + "arguments": out_item.get("arguments", ""), + } + } + ) + if tool_calls: + kv_pairs = OpenTelemetry._tool_calls_kv_pair(tool_calls) # type: ignore + for key, value in kv_pairs.items(): + self.safe_set_attribute( + span=span, + key=key, + value=value, + ) + + # Extract finish reason from ResponsesAPIResponse.status + status = response_obj.get("status") + if status: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value, + value=safe_dumps([status]), + ) + # Extract finish reason from ResponsesAPIResponse.status status = response_obj.get("status") if status: @@ -1884,7 +1928,7 @@ class OpenTelemetry(CustomLogger): transformed.append(transformed_msg) return transformed - def _transform_responses_api_output_to_otel(self, output: List[dict]) -> List[dict]: + def _transform_responses_api_output_to_otel(self, output: List) -> List[dict]: """ Transform Responses API output items into OTEL GenAI 1.38 format. @@ -1893,18 +1937,24 @@ class OpenTelemetry(CustomLogger): ``content`` list of ``OutputText`` objects with ``type="output_text"`` and ``text`` fields. + Items may be plain dicts or Pydantic model instances (e.g. + ``ResponseOutputMessage``, ``ResponseFunctionToolCall``). Both + expose a ``.get()`` method via ``BaseLiteLLMOpenAIResponseObject``, + so we use ``hasattr(item, "get")`` rather than ``isinstance(item, + dict)`` to accept either form. + This method converts them to the same ``{"role": ..., "parts": [...]}`` format used by ``_transform_choices_to_otel_semantic_conventions``. """ transformed = [] for item in output: - if not isinstance(item, dict): + if not hasattr(item, "get"): continue if item.get("type") == "message": role = item.get("role", "assistant") parts = [] for content in item.get("content", []): - if not isinstance(content, dict): + if not hasattr(content, "get"): continue if content.get("type") == "output_text": text = content.get("text", "") diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 5f294005641..56aba4bc5ed 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -3315,3 +3315,174 @@ class TestTransformResponsesAPIOutput(unittest.TestCase): ] result = otel._transform_responses_api_output_to_otel(output) self.assertEqual(result[0]["role"], "assistant") + + + def test_pydantic_like_objects_accepted(self): + """Items with .get() but not isinstance(dict) should be accepted.""" + + class FakeOutputItem: + """Mimics BaseLiteLLMOpenAIResponseObject duck-typing.""" + + def __init__(self, data): + self._data = data + + def get(self, key, default=None): + return self._data.get(key, default) + + class FakeContent: + def __init__(self, data): + self._data = data + + def get(self, key, default=None): + return self._data.get(key, default) + + otel = OpenTelemetry() + output = [ + FakeOutputItem( + { + "type": "message", + "role": "assistant", + "content": [ + FakeContent({"type": "output_text", "text": "Pydantic works!"}), + ], + } + ) + ] + result = otel._transform_responses_api_output_to_otel(output) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["parts"][0]["content"], "Pydantic works!") + + +class TestSystemInstructionsPrecedence(unittest.TestCase): + """Tests for the is-not-None precedence in system_instructions coalescing.""" + + def _get_attr(self, mock_span, attr_name): + calls = [ + call + for call in mock_span.set_attribute.call_args_list + if call[0][0] == attr_name + ] + if not calls: + return None + return calls[0][0][1] + + def _base_kwargs(self, **overrides): + kwargs = { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Hi"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "responses", + "metadata": {}, + }, + } + kwargs.update(overrides) + return kwargs + + def test_empty_list_system_instructions_does_not_fallthrough(self): + """An empty list for system_instructions should NOT fall through to instructions.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = self._base_kwargs( + system_instructions=[], + instructions="Should not be used", + ) + response_obj = {"id": "r1", "model": "gpt-4o"} + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + # system_instructions is [] (falsy but not None), so it wins. + # Since it's an empty list, no attribute should be set (nothing to transform). + value = self._get_attr(mock_span, "gen_ai.system_instructions") + # The empty list is truthy for `is not None` but produces empty + # transformed output — the attribute should NOT contain "Should not be used". + if value is not None: + self.assertNotIn("Should not be used", str(value)) + + +class TestResponsesAPIToolCallSpanAttributes(unittest.TestCase): + """Tests for per-tool-call span attributes on Responses API function_call items.""" + + def _base_kwargs(self): + return { + "model": "gpt-4o", + "messages": [{"role": "user", "content": "What is the weather?"}], + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "resp_tc", + "call_type": "responses", + "metadata": {}, + }, + } + + def test_per_tool_call_attributes_emitted(self): + """function_call output items should produce per-tool-call span attributes.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_tc", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_abc", + "arguments": '{"location": "SF"}', + } + ], + } + + otel.set_attributes(span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj) + + # Verify per-tool-call attributes were set (same format as choices branch) + attr_names = [call[0][0] for call in mock_span.set_attribute.call_args_list] + tool_call_attrs = [a for a in attr_names if "function_call" in a] + self.assertTrue(len(tool_call_attrs) > 0, "Per-tool-call span attributes should be emitted") + + # Verify the name attribute specifically + mock_span.set_attribute.assert_any_call( + "gen_ai.completion.0.function_call.name", "get_weather" + ) + mock_span.set_attribute.assert_any_call( + "gen_ai.completion.0.function_call.arguments", '{"location": "SF"}' + ) + + def test_multiple_tool_calls_indexed(self): + """Multiple function_call items should be indexed correctly.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + response_obj = { + "id": "resp_tc2", + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "type": "function_call", + "name": "get_weather", + "call_id": "call_1", + "arguments": "{}", + }, + { + "type": "function_call", + "name": "get_time", + "call_id": "call_2", + "arguments": "{}", + }, + ], + } + + otel.set_attributes(span=mock_span, kwargs=self._base_kwargs(), response_obj=response_obj) + + mock_span.set_attribute.assert_any_call( + "gen_ai.completion.0.function_call.name", "get_weather" + ) + mock_span.set_attribute.assert_any_call( + "gen_ai.completion.1.function_call.name", "get_time" + ) From 9928618788389e623aa0c57b9249d084bfe72482 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Tue, 28 Apr 2026 19:58:58 +0530 Subject: [PATCH 6/8] fix: remove duplicate gen_ai.response.finish_reasons block --- litellm/integrations/opentelemetry.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 90e647d86bf..d116fc44658 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1824,15 +1824,6 @@ class OpenTelemetry(CustomLogger): value=safe_dumps([status]), ) - # Extract finish reason from ResponsesAPIResponse.status - status = response_obj.get("status") - if status: - self.safe_set_attribute( - span=span, - key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value, - value=safe_dumps([status]), - ) - except Exception as e: self.handle_callback_failure( callback_name=self.callback_name or "opentelemetry" From 53102529ca6e817cd398f23bcc0bd63501c9f169 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Wed, 29 Apr 2026 19:00:43 +0530 Subject: [PATCH 7/8] ci: retrigger checks after retargeting to litellm_oss_staging_04_27_2026 From c319a19c25d746dbcfd27ab3c69984f5d66eb358 Mon Sep 17 00:00:00 2001 From: Aneesh-Fiddler Date: Thu, 30 Apr 2026 11:04:08 +0530 Subject: [PATCH 8/8] fix: handle raw Pydantic v2 models from openai SDK in output transformation The openai SDK returns ResponseOutputMessage and ResponseOutputText as raw Pydantic v2 models that lack .get() (unlike LiteLLM's own wrapper objects). Add a _to_dict() helper that normalizes plain dicts, BaseLiteLLMOpenAIResponseObject (has .get()), and raw Pydantic models (has .model_dump()) into a consistent dict interface. --- litellm/integrations/opentelemetry.py | 54 +++++++++++++++++++-------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index d116fc44658..a12d67de4b4 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1794,15 +1794,13 @@ class OpenTelemetry(CustomLogger): # _tool_calls_kv_pair. tool_calls = [] for out_item in output_items: - if ( - hasattr(out_item, "get") - and out_item.get("type") == "function_call" - ): + item_d = self._to_dict(out_item) + if item_d and item_d.get("type") == "function_call": tool_calls.append( { "function": { - "name": out_item.get("name", ""), - "arguments": out_item.get("arguments", ""), + "name": item_d.get("name", ""), + "arguments": item_d.get("arguments", ""), } } ) @@ -1919,6 +1917,31 @@ class OpenTelemetry(CustomLogger): transformed.append(transformed_msg) return transformed + @staticmethod + def _to_dict(obj) -> Optional[dict]: + """Normalize an object to a plain dict. + + Handles three forms that appear in practice: + + 1. Plain ``dict`` — returned as-is. + 2. LiteLLM's ``BaseLiteLLMOpenAIResponseObject`` — exposes a + ``.get()`` method that delegates to ``__dict__``. + 3. Raw Pydantic v2 models from the ``openai`` SDK (e.g. + ``ResponseOutputMessage``, ``ResponseOutputText``) — these do + **not** have ``.get()`` but do have ``.model_dump()``. + + Returns ``None`` for anything else so callers can skip it. + """ + if isinstance(obj, dict): + return obj + if hasattr(obj, "get"): + # BaseLiteLLMOpenAIResponseObject duck-type + return obj # type: ignore[return-value] + if hasattr(obj, "model_dump"): + # Raw Pydantic v2 model (e.g. openai SDK types) + return obj.model_dump() # type: ignore[union-attr] + return None + def _transform_responses_api_output_to_otel(self, output: List) -> List[dict]: """ Transform Responses API output items into OTEL GenAI 1.38 format. @@ -1928,24 +1951,25 @@ class OpenTelemetry(CustomLogger): ``content`` list of ``OutputText`` objects with ``type="output_text"`` and ``text`` fields. - Items may be plain dicts or Pydantic model instances (e.g. - ``ResponseOutputMessage``, ``ResponseFunctionToolCall``). Both - expose a ``.get()`` method via ``BaseLiteLLMOpenAIResponseObject``, - so we use ``hasattr(item, "get")`` rather than ``isinstance(item, - dict)`` to accept either form. + Items may be plain dicts, LiteLLM wrapper objects (with ``.get()``), + or raw Pydantic v2 models from the ``openai`` SDK (with + ``.model_dump()``). We normalize each item to a dict via + ``_to_dict`` before processing. This method converts them to the same ``{"role": ..., "parts": [...]}`` format used by ``_transform_choices_to_otel_semantic_conventions``. """ transformed = [] - for item in output: - if not hasattr(item, "get"): + for raw_item in output: + item = self._to_dict(raw_item) + if item is None: continue if item.get("type") == "message": role = item.get("role", "assistant") parts = [] - for content in item.get("content", []): - if not hasattr(content, "get"): + for raw_content in item.get("content", []): + content = self._to_dict(raw_content) + if content is None: continue if content.get("type") == "output_text": text = content.get("text", "")