From 01789ed36dadc92fa129297ce23dfaa2a31799eb Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Mon, 26 Oct 2026 02:22:09 +0800 Subject: [PATCH] fix(otel): populate gen_ai.input/output.messages for anthropic_messages call type (#30121) The Anthropic-native /v1/messages route keeps its messages on optional_params['messages'] (not kwargs['messages']), and its response is a top-level content list of blocks (text / thinking / tool_use), not the OpenAI choices array or the Responses API output array. - input: fall back to optional_params['messages'] when kwargs has none. - output: parse the content blocks (text and thinking -> text parts, tool_use -> tool_call parts plus the per-call function_call attributes, stop_reason -> finish reason) in a small typed helper, opentelemetry_utils/anthropic_messages.py. The choices and output branches still win, so OpenAI and Responses API spans are unchanged. Fixes #30121 --- litellm/integrations/opentelemetry.py | 28 ++- .../opentelemetry_utils/anthropic_messages.py | 76 +++++++ ...test_otel_anthropic_messages_attributes.py | 195 ++++++++++++++++++ 3 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 litellm/integrations/opentelemetry_utils/anthropic_messages.py create mode 100644 tests/unit/integrations/test_otel_anthropic_messages_attributes.py diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 0d0e309034a..c5fff1dd86f 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -22,6 +22,7 @@ from litellm.integrations._types.open_inference import ( ) from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.langtrace import LANGTRACE_TRACE_PATH +from litellm.integrations.opentelemetry_utils.anthropic_messages import parse_anthropic_message from litellm.integrations.opentelemetry_utils.gen_ai_semconv import ( OTEL_SEMCONV_STABILITY_OPT_IN_ENV, OTELGenAISemconvMixin, @@ -2649,12 +2650,14 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): tools: Final = 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")) + # The Anthropic-native /v1/messages route (call_type="anthropic_messages") keeps the + # messages on optional_params rather than kwargs. + input_messages: Final = kwargs.get("messages") or optional_params.get("messages") + if input_messages: self.safe_set_attribute( span=span, key=SpanAttributes.GEN_AI_INPUT_MESSAGES.value, - value=safe_dumps(transformed_messages), + value=safe_dumps(self._transform_messages_to_otel_semantic_conventions(input_messages)), ) # Coalesce the different kwarg names that carry the system @@ -2710,6 +2713,9 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): ############################################# ########## LLM Response Attributes ########## ############################################# + anthropic_output: Final = parse_anthropic_message( + cast("object", response_obj) # cast-ok: response_obj is Any; the parser narrows it itself + ) if response_obj is not None: if response_obj.get("choices"): transformed_choices: Final = self._transform_choices_to_otel_semantic_conventions( @@ -2746,6 +2752,22 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): value=value, ) + elif anthropic_output is not None: + if anthropic_output.parts: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value, + value=safe_dumps([{"role": anthropic_output.role, "parts": list(anthropic_output.parts)}]), + ) + for key, value in OpenTelemetry._tool_calls_kv_pair(list(anthropic_output.tool_calls)).items(): + self.safe_set_attribute(span=span, key=key, value=value) + if anthropic_output.stop_reason: + self.safe_set_attribute( + span=span, + key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value, + value=safe_dumps([anthropic_output.stop_reason]), + ) + elif response_obj.get("output"): # Responses API: ResponsesAPIResponse has an "output" # list instead of "choices". Each item with diff --git a/litellm/integrations/opentelemetry_utils/anthropic_messages.py b/litellm/integrations/opentelemetry_utils/anthropic_messages.py new file mode 100644 index 00000000000..8e7d742ed69 --- /dev/null +++ b/litellm/integrations/opentelemetry_utils/anthropic_messages.py @@ -0,0 +1,76 @@ +"""Parse an Anthropic Messages API response (the ``/v1/messages`` route) into what OpenTelemetry records. + +Chat completions carry ``choices`` and the Responses API carries ``output``; an Anthropic Messages +response carries a top-level ``content`` list of blocks (text / thinking / tool_use) and a +``stop_reason``, so it needs its own reading. +""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Final + +from pydantic import ConfigDict, TypeAdapter + +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.types.utils import ChatCompletionMessageToolCall, Function + +_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True)) +_JSON_ITEMS: Final = TypeAdapter(list[object], config=ConfigDict(hide_input_in_errors=True)) + + +@dataclass(frozen=True, slots=True) +class AnthropicMessageOutput: + role: str + parts: tuple[Mapping[str, object], ...] + tool_calls: tuple[ChatCompletionMessageToolCall, ...] + stop_reason: str | None + + +def _part(block: Mapping[str, object]) -> Mapping[str, object] | None: + block_type: Final = block.get("type") + if block_type == "text": + return {"type": "text", "content": block.get("text", "")} + if block_type == "thinking": + return {"type": "text", "content": block.get("thinking", "")} + if block_type == "tool_use": + return { + "type": "tool_call", + "id": block.get("id", ""), + "name": block.get("name", ""), + "arguments": block.get("input") or {}, + } + return None + + +def _tool_call(block: Mapping[str, object]) -> ChatCompletionMessageToolCall: + return ChatCompletionMessageToolCall( + function=Function(arguments=safe_dumps(block.get("input") or {}), name=str(block.get("name", ""))), + id=str(block.get("id", "")), + type="function", + ) + + +def parse_anthropic_message(response_obj: object) -> AnthropicMessageOutput | None: + """The output side of an Anthropic Messages response, or None when ``response_obj`` is not one. + + A response with no recognised blocks still comes back (empty ``parts``), so its ``stop_reason`` + is recorded; blocks of a type LiteLLM does not know are skipped rather than emitted blank. + """ + if not isinstance(response_obj, Mapping): + return None + message: Final = _JSON_OBJECT.validate_python(response_obj) + content: Final = message.get("content") + if not isinstance(content, list) or not content: + return None + blocks: Final = [ + _JSON_OBJECT.validate_python(block) + for block in _JSON_ITEMS.validate_python(content) + if isinstance(block, Mapping) + ] + stop_reason: Final = message.get("stop_reason") + return AnthropicMessageOutput( + role=str(message.get("role") or "assistant"), + parts=tuple(part for block in blocks if (part := _part(block)) is not None), + tool_calls=tuple(_tool_call(block) for block in blocks if block.get("type") == "tool_use"), + stop_reason=stop_reason if isinstance(stop_reason, str) and stop_reason else None, + ) diff --git a/tests/unit/integrations/test_otel_anthropic_messages_attributes.py b/tests/unit/integrations/test_otel_anthropic_messages_attributes.py new file mode 100644 index 00000000000..e8c86a0d078 --- /dev/null +++ b/tests/unit/integrations/test_otel_anthropic_messages_attributes.py @@ -0,0 +1,195 @@ +""" +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_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() + + 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)