From 8c0ad7cee8068c1c341f275708b02adb4f54aefe Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Wed, 10 Jun 2026 19:30:31 +0800 Subject: [PATCH 1/3] fix(otel): populate gen_ai.input.messages + gen_ai.output.messages for anthropic_messages call type (#30121) the anthropic-native /v1/messages route stores its messages list on optional_params['messages'] (not kwargs['messages']) and its response shape is a top-level content list of blocks (text / thinking / tool_use), not the openai choices array or the responses-api output array. two missing gates in OpenTelemetry.set_attributes: - input: coalesce kwargs.get('messages') with optional_params.get('messages') using the same is-not-None pattern the system-instructions block below already uses for kwargs['system'] / 'instructions' / 'system_instructions'. - output: add an elif response_obj.get('content') and isinstance(..., list) branch that maps text/thinking blocks into otel text parts, tool_use blocks into tool_call parts, emits per-call _tool_calls_kv_pair, and uses stop_reason as the gen_ai.response.finish_reasons value. choices and output branches still take precedence so openai + responses-api spans are unaffected. Fixes #30121 --- litellm/integrations/opentelemetry.py | 93 +++++++++- ...test_otel_anthropic_messages_attributes.py | 173 ++++++++++++++++++ 2 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index de543fa042b..07215f3ec47 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2293,8 +2293,23 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): tools = optional_params["tools"] self.set_tools_attributes(span, tools) - if kwargs.get("messages"): - transformed_messages = self._transform_messages_to_otel_semantic_conventions(kwargs.get("messages")) + # Coalesce messages from kwargs or optional_params: the + # anthropic-native /v1/messages (call_type="anthropic_messages") + # path stores the messages list on ``optional_params``, not on + # ``kwargs``. Same coalesce pattern the system-instructions block + # below uses. Without this, gen_ai.input.messages is empty for + # the entire Anthropic Messages call type (#30121). + input_messages = ( + kwargs.get("messages") + if kwargs.get("messages") is not None + else optional_params.get("messages") + ) + if input_messages: + transformed_messages = ( + self._transform_messages_to_otel_semantic_conventions( + input_messages + ) + ) self.safe_set_attribute( span=span, key=SpanAttributes.GEN_AI_INPUT_MESSAGES.value, @@ -2390,6 +2405,80 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): value=value, ) + elif response_obj.get("content") and isinstance( + response_obj.get("content"), list + ): + # Anthropic Messages API response: top-level "content" is + # a list of blocks (text / thinking / tool_use). The + # finish reason lives on "stop_reason". Without this + # branch, /v1/messages (call_type="anthropic_messages") + # spans never get gen_ai.output.messages or + # gen_ai.response.finish_reasons (#30121). + content_blocks = response_obj.get("content") or [] + parts: List[dict] = [] + tool_calls = [] + for block in content_blocks: + block_d = self._to_dict(block) or {} + btype = block_d.get("type") + if btype == "text": + parts.append( + {"type": "text", "content": block_d.get("text", "")} + ) + elif btype == "thinking": + parts.append( + { + "type": "text", + "content": block_d.get("thinking", ""), + } + ) + elif btype == "tool_use": + tool_input = block_d.get("input") or {} + parts.append( + { + "type": "tool_call", + "id": block_d.get("id", ""), + "name": block_d.get("name", ""), + "arguments": tool_input, + } + ) + tool_calls.append( + { + "function": { + "name": block_d.get("name", ""), + "arguments": safe_dumps(tool_input), + } + } + ) + + output_messages = [ + { + "role": response_obj.get("role", "assistant"), + "parts": parts, + } + ] + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value, + value=safe_dumps(output_messages), + ) + + 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, + ) + + stop_reason = response_obj.get("stop_reason") + if stop_reason: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value, + value=safe_dumps([stop_reason]), + ) + elif response_obj.get("output"): # Responses API: ResponsesAPIResponse has an "output" # list instead of "choices". Each item with diff --git a/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py b/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py new file mode 100644 index 00000000000..219945979df --- /dev/null +++ b/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py @@ -0,0 +1,173 @@ +""" +Regression for #30121. + +OpenTelemetry.set_attributes() missed both gen_ai.input.messages and +gen_ai.output.messages for the Anthropic-native /v1/messages route +(call_type="anthropic_messages") because: + + - Input gate checked ``kwargs.get("messages")`` only, but the + anthropic_messages handler stores the messages on + ``optional_params["messages"]``. + - Output gate had branches for OpenAI ``choices`` and Responses API + ``output`` but none for the Anthropic top-level ``content`` block list. + +Span otherwise had cost/usage/model fine — only the prompt/completion +content was missing in Langfuse, Phoenix, Arize, etc. +""" + +import unittest +from unittest.mock import MagicMock + +from litellm.integrations.opentelemetry import OpenTelemetry + + +def _base_kwargs() -> dict: + return { + "model": "claude-opus-4-7", + "litellm_params": {"custom_llm_provider": "anthropic"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "anthropic_messages", + "metadata": {}, + }, + } + + +def _attr_set(mock_span: MagicMock) -> dict: + out = {} + for c in mock_span.set_attribute.call_args_list: + args, _ = c + out[args[0]] = args[1] + return out + + +class TestOtelAnthropicMessagesInput(unittest.TestCase): + def test_messages_on_optional_params_populate_input_messages(self): + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = _base_kwargs() + kwargs["optional_params"] = { + "messages": [ + {"role": "user", "content": "list files"}, + {"role": "assistant", "content": "ok"}, + ] + } + + response_obj = { + "content": [{"type": "text", "text": "done"}], + "role": "assistant", + "stop_reason": "end_turn", + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + attrs = _attr_set(mock_span) + self.assertIn("gen_ai.input.messages", attrs) + self.assertIn("list files", attrs["gen_ai.input.messages"]) + + def test_kwargs_messages_still_win_when_both_present(self): + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = _base_kwargs() + kwargs["messages"] = [{"role": "user", "content": "from kwargs"}] + kwargs["optional_params"] = { + "messages": [{"role": "user", "content": "from optional"}] + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj={"content": []}) + + attrs = _attr_set(mock_span) + self.assertIn("gen_ai.input.messages", attrs) + self.assertIn("from kwargs", attrs["gen_ai.input.messages"]) + self.assertNotIn("from optional", attrs["gen_ai.input.messages"]) + + +class TestOtelAnthropicMessagesOutput(unittest.TestCase): + def test_anthropic_content_blocks_populate_output_messages(self): + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = _base_kwargs() + kwargs["optional_params"] = {"messages": [{"role": "user", "content": "hi"}]} + + response_obj = { + "content": [ + {"type": "text", "text": "hello"}, + { + "type": "tool_use", + "id": "tool_abc", + "name": "bash", + "input": {"command": "ls"}, + }, + ], + "role": "assistant", + "stop_reason": "tool_use", + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + attrs = _attr_set(mock_span) + self.assertIn("gen_ai.output.messages", attrs) + out = attrs["gen_ai.output.messages"] + self.assertIn("hello", out) + self.assertIn("bash", out) + self.assertIn("tool_abc", out) + + self.assertIn("gen_ai.response.finish_reasons", attrs) + self.assertIn("tool_use", attrs["gen_ai.response.finish_reasons"]) + + def test_thinking_block_is_serialised_as_text_part(self): + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = _base_kwargs() + response_obj = { + "content": [ + { + "type": "thinking", + "thinking": "deliberating...", + "signature": "sig", + }, + {"type": "text", "text": "answer"}, + ], + "role": "assistant", + "stop_reason": "end_turn", + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + attrs = _attr_set(mock_span) + self.assertIn("gen_ai.output.messages", attrs) + out = attrs["gen_ai.output.messages"] + self.assertIn("deliberating...", out) + self.assertIn("answer", out) + + def test_choices_still_takes_precedence_over_content_for_openai_shape(self): + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = _base_kwargs() + kwargs["standard_logging_object"]["call_type"] = "completion" + kwargs["messages"] = [{"role": "user", "content": "hi"}] + + # response with BOTH choices (openai shape) and a spurious content + # field — choices branch must win to preserve the openai contract. + response_obj = { + "choices": [ + { + "message": {"role": "assistant", "content": "openai-out"}, + "finish_reason": "stop", + } + ], + "content": [{"type": "text", "text": "anthropic-out"}], + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + attrs = _attr_set(mock_span) + self.assertIn("gen_ai.output.messages", attrs) + out = attrs["gen_ai.output.messages"] + self.assertIn("openai-out", out) + self.assertNotIn("anthropic-out", out) From 91ab01319bfda81430ba28c7eb15144772f1bb08 Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Thu, 11 Jun 2026 15:56:32 +0800 Subject: [PATCH 2/3] fix(otel): skip gen_ai.output.messages emission when parts is empty greptile flagged the unconditional emit as inconsistent with the adjacent Responses API branch (which guards 'if output_messages:'). mirror that guard so a content list of only unrecognised block types doesn't leave a blank assistant-message entry in observability tools. --- litellm/integrations/opentelemetry.py | 23 +++++++++--------- ...test_otel_anthropic_messages_attributes.py | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 07215f3ec47..eba8445b4b4 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2450,17 +2450,18 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): } ) - output_messages = [ - { - "role": response_obj.get("role", "assistant"), - "parts": parts, - } - ] - self.safe_set_attribute( - span=span, - key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value, - value=safe_dumps(output_messages), - ) + if parts: + output_messages = [ + { + "role": response_obj.get("role", "assistant"), + "parts": parts, + } + ] + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value, + value=safe_dumps(output_messages), + ) if tool_calls: kv_pairs = OpenTelemetry._tool_calls_kv_pair(tool_calls) # type: ignore diff --git a/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py b/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py index 219945979df..73c07bd74c9 100644 --- a/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py +++ b/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py @@ -144,6 +144,30 @@ class TestOtelAnthropicMessagesOutput(unittest.TestCase): self.assertIn("deliberating...", out) self.assertIn("answer", out) + def test_empty_or_unrecognised_content_blocks_skip_output_messages_emit(self): + """Greptile flagged the unconditional emit as inconsistent with the + Responses API branch, which guards ``if output_messages:``. Mirror + that guard so a content list of only unknown block types doesn't + leave a blank assistant-message entry in observability tools.""" + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = _base_kwargs() + response_obj = { + "content": [ + {"type": "future_block_type_unknown_to_litellm", "payload": "..."}, + ], + "role": "assistant", + "stop_reason": "end_turn", + } + + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + + attrs = _attr_set(mock_span) + self.assertNotIn("gen_ai.output.messages", attrs) + # stop_reason still emits even with empty parts. + self.assertIn("gen_ai.response.finish_reasons", attrs) + def test_choices_still_takes_precedence_over_content_for_openai_shape(self): otel = OpenTelemetry() mock_span = MagicMock() From 1c090d7d5456cb8f8c9ef88f85db93932f68f348 Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Thu, 16 Jul 2026 00:37:00 +0800 Subject: [PATCH 3/3] chore: ruff format --- litellm/integrations/opentelemetry.py | 18 ++++-------------- .../test_otel_anthropic_messages_attributes.py | 4 +--- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index eba8445b4b4..3c0da946a78 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2300,16 +2300,10 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): # below uses. Without this, gen_ai.input.messages is empty for # the entire Anthropic Messages call type (#30121). input_messages = ( - kwargs.get("messages") - if kwargs.get("messages") is not None - else optional_params.get("messages") + kwargs.get("messages") if kwargs.get("messages") is not None else optional_params.get("messages") ) if input_messages: - transformed_messages = ( - self._transform_messages_to_otel_semantic_conventions( - input_messages - ) - ) + transformed_messages = self._transform_messages_to_otel_semantic_conventions(input_messages) self.safe_set_attribute( span=span, key=SpanAttributes.GEN_AI_INPUT_MESSAGES.value, @@ -2405,9 +2399,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): value=value, ) - elif response_obj.get("content") and isinstance( - response_obj.get("content"), list - ): + elif response_obj.get("content") and isinstance(response_obj.get("content"), list): # Anthropic Messages API response: top-level "content" is # a list of blocks (text / thinking / tool_use). The # finish reason lives on "stop_reason". Without this @@ -2421,9 +2413,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): block_d = self._to_dict(block) or {} btype = block_d.get("type") if btype == "text": - parts.append( - {"type": "text", "content": block_d.get("text", "")} - ) + parts.append({"type": "text", "content": block_d.get("text", "")}) elif btype == "thinking": parts.append( { diff --git a/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py b/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py index 73c07bd74c9..e8c86a0d078 100644 --- a/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py +++ b/tests/test_litellm/integrations/test_otel_anthropic_messages_attributes.py @@ -72,9 +72,7 @@ class TestOtelAnthropicMessagesInput(unittest.TestCase): kwargs = _base_kwargs() kwargs["messages"] = [{"role": "user", "content": "from kwargs"}] - kwargs["optional_params"] = { - "messages": [{"role": "user", "content": "from optional"}] - } + kwargs["optional_params"] = {"messages": [{"role": "user", "content": "from optional"}]} otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj={"content": []})