From 611e2ab1360785a23a6e3e9c0488191b0db7c435 Mon Sep 17 00:00:00 2001 From: Michael Cusack Date: Wed, 21 Oct 2026 02:37:07 -0700 Subject: [PATCH 1/2] fix(otel): keep span attributes for token-array embedding input --- litellm/integrations/opentelemetry.py | 4 ++ tests/unit/integrations/test_opentelemetry.py | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 0d0e309034a..21651ff48cf 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2839,6 +2839,10 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): transformed: Final = [] for msg in messages: + # An embedding's input is logged as its messages, so a token-array input + # (`[[9906, 1917]]` or `[9906, 1917]`) yields lists or ints with no role or content. + if not isinstance(msg, dict): + continue role = msg.get("role", "user") content = msg.get("content", "") parts = [] diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index d14703e48a1..77fb0ac0bb7 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -3304,6 +3304,46 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): self.assertEqual(parsed[0]["parts"][0]["type"], "text") self.assertEqual(parsed[0]["parts"][0]["content"], "Hello world") + @parameterized.expand( + [ + ("batch_of_token_arrays", [[9906, 1917], [15339]]), + ("single_token_array", [9906, 1917]), + ] + ) + def test_token_array_embedding_input_keeps_span_attributes(self, _name, token_input): + """ + An embedding whose input is token arrays is logged with those arrays as its + messages. set_attributes must still set every attribute after the input messages + rather than stopping at the first entry that is not a message dict. + """ + otel = OpenTelemetry() + mock_span = MagicMock() + + kwargs = { + "model": "text-embedding-3-small", + "messages": token_input, + "optional_params": {}, + "litellm_params": {"custom_llm_provider": "openai"}, + "standard_logging_object": { + "id": "test-id", + "call_type": "aembedding", + "metadata": {}, + }, + } + response_obj = { + "model": "text-embedding-3-small", + "data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}], + "usage": {"prompt_tokens": 3, "total_tokens": 3}, + } + + with patch("litellm.integrations.opentelemetry.verbose_logger") as logger: + otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj) + logger.exception.assert_not_called() + + attrs = {c[0][0]: c[0][1] for c in mock_span.set_attribute.call_args_list} + self.assertEqual(json.loads(attrs["gen_ai.input.messages"]), []) + self.assertEqual(attrs["gen_ai.operation.name"], "aembedding") + def test_output_messages_uses_parts_structure(self): """ Test that gen_ai.output.messages uses the OTEL 1.38 parts array structure. From 00a38aa565f4e021e27e12f782e60dc4978ac17f Mon Sep 17 00:00:00 2001 From: Michael Cusack Date: Wed, 21 Oct 2026 03:29:43 -0700 Subject: [PATCH 2/2] fix(otel): skip only token-array entries so chat Message objects keep their output messages --- litellm/integrations/opentelemetry.py | 5 +++-- tests/unit/integrations/test_opentelemetry.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 21651ff48cf..8627fa070e5 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -2823,7 +2823,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): primitive_value: Final = self._cast_as_primitive_value_type(value) span.set_attribute(key, primitive_value) - def _transform_messages_to_otel_semantic_conventions(self, messages: list[dict] | str) -> list[dict]: + def _transform_messages_to_otel_semantic_conventions(self, messages: list[Any] | str) -> list[dict]: """ Transforms LiteLLM/OpenAI style messages into OTEL GenAI 1.38 compliant format. OTEL expects a 'parts' array instead of a single 'content' string. @@ -2841,7 +2841,8 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): for msg in messages: # An embedding's input is logged as its messages, so a token-array input # (`[[9906, 1917]]` or `[9906, 1917]`) yields lists or ints with no role or content. - if not isinstance(msg, dict): + # Anything else is message-like: a dict, or a `Message` object from chat output. + if isinstance(msg, (list, int)): continue role = msg.get("role", "user") content = msg.get("content", "") diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index 77fb0ac0bb7..1ca68d85cbf 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -3344,6 +3344,19 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase): self.assertEqual(json.loads(attrs["gen_ai.input.messages"]), []) self.assertEqual(attrs["gen_ai.operation.name"], "aembedding") + def test_choice_message_objects_are_still_transformed(self): + """ + Chat output reaches the message conversion as `Message` objects, not dicts, so + skipping token-array entries must not skip them. + """ + from litellm.types.utils import Choices, Message + + choices = [Choices(finish_reason="stop", index=0, message=Message(role="assistant", content="Hi"))] + self.assertEqual( + OpenTelemetry()._transform_choices_to_otel_semantic_conventions(choices), + [{"role": "assistant", "parts": [{"type": "text", "content": "Hi"}], "finish_reason": "stop"}], + ) + def test_output_messages_uses_parts_structure(self): """ Test that gen_ai.output.messages uses the OTEL 1.38 parts array structure.