mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
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
This commit is contained in:
parent
dddbaa803d
commit
01789ed36d
3 changed files with 296 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue