diff --git a/litellm/integrations/otel/mappers/langfuse.py b/litellm/integrations/otel/mappers/langfuse.py index e76cffde881..9aff944cff0 100644 --- a/litellm/integrations/otel/mappers/langfuse.py +++ b/litellm/integrations/otel/mappers/langfuse.py @@ -39,7 +39,7 @@ LANGFUSE_TRACE_TAGS: Final = "langfuse.trace.tags" class LangfuseMapper: _LLM_CALL_ATTRS: dict[str, Callable[[LLMCallSpanData], AttrValue | None]] = { - "langfuse.observation.type": lambda d: "generation", + "langfuse.observation.type": lambda _: "generation", "langfuse.observation.model.name": lambda d: d.request_model or None, "langfuse.observation.metadata.provider": lambda d: d.provider or None, "langfuse.observation.id": lambda d: d.identity.call_id or None, @@ -68,7 +68,9 @@ class LangfuseMapper: collect(LangfuseMapper._MODEL_PARAMS, d.request_params) ), LANGFUSE_OBSERVATION_INPUT: lambda d: serialize_messages(d.messages_in), - LANGFUSE_OBSERVATION_OUTPUT: lambda d: serialize_messages(output_messages(d)), + LANGFUSE_OBSERVATION_OUTPUT: lambda d: ( + d.embedding_output.as_json() if d.embedding_output is not None else serialize_messages(output_messages(d)) + ), "langfuse.observation.usage_details": lambda d: json_if(collect(LangfuseMapper._USAGE_FIELDS, d.usage)), "langfuse.observation.cost_details": lambda d: ( json.dumps({"total": d.response_cost}) if d.response_cost is not None else None diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 33da1549fd5..467c286db9d 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from enum import Enum from types import MappingProxyType @@ -353,6 +353,24 @@ class ToolDefinition: parameters_json: str | None = None # JSON-serialized schema (str so it's an AttrValue) +@dataclass(frozen=True, slots=True) +class EmbeddingOutput: + count: int + dimensions: int | None + + @classmethod + def from_response(cls, response: Mapping[str, object]) -> EmbeddingOutput | None: + vectors: Final = tuple(row.get("embedding") for row in _dicts(response.get("data"))) + if not vectors: + return None + first: Final = vectors[0] + width: Final = len(cast(Sequence[object], first)) if isinstance(first, list) else None + return cls(count=len(vectors), dimensions=width) + + def as_json(self) -> str: + return json.dumps({"count": self.count, "dimensions": self.dimensions}) + + @dataclass(frozen=True) class LLMCallSpanData: operation: GenAIOperation @@ -386,6 +404,7 @@ class LLMCallSpanData: call_type: str | None = None request_route: str | None = None trace: TraceControls = field(default_factory=TraceControls) + embedding_output: EmbeddingOutput | None = None @classmethod def from_standard_logging_payload( @@ -413,8 +432,12 @@ class LLMCallSpanData: # no prompt/response text. finish_reasons: Final = _finish_reasons(choices_out) call_type: Final = as_str(payload.get("call_type")) + operation: Final = resolve_operation(call_type) + embedding_output: Final = ( + EmbeddingOutput.from_response(response) if operation is GenAIOperation.EMBEDDINGS else None + ) return cls( - operation=resolve_operation(call_type), + operation=operation, provider=resolve_provider(as_str(payload.get("custom_llm_provider"))), request_model=context.request_model, response_model=context.response_model, @@ -437,6 +460,7 @@ class LLMCallSpanData: call_type=call_type or None, request_route=request_route or context.identity.request_route, trace=trace or TraceControls(), + embedding_output=embedding_output if capture_content else None, ) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py index c5c77a12a62..f4a8691f72f 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py @@ -1,6 +1,7 @@ """Tests for the OTel v2 sources of truth: span registry, semconv keys, config, and the typed StandardLoggingPayload adapter. These need no OTel SDK.""" +import json import logging import re from pathlib import Path @@ -12,11 +13,11 @@ import litellm from litellm.integrations.otel import ( BAGGAGE_PROMOTED_KEYS, DB, + HTTP, Error, GenAI, GenAIOperation, GenAIOutputType, - HTTP, LiteLLM, OpenTelemetryV2Config, Server, @@ -29,8 +30,8 @@ from litellm.integrations.otel import ( from litellm.integrations.otel.mappers.genai import GenAIMapper from litellm.integrations.otel.model import spans as spans_mod from litellm.integrations.otel.model.metadata import LLMCallEvent -from litellm.integrations.otel.model.trace_controls import TraceControls, caller_trace_controls from litellm.integrations.otel.model.payloads import ( + EmbeddingOutput, LLMCallSpanData, RequestIdentity, _upstream_address_port, @@ -43,6 +44,7 @@ from litellm.integrations.otel.model.spans import ( root_roles, validate_registry, ) +from litellm.integrations.otel.model.trace_controls import TraceControls, caller_trace_controls @pytest.fixture(autouse=True) @@ -696,6 +698,46 @@ def test_content_capture_gated_off_by_default(): assert data.finish_reasons == ("stop",) +def _embedding_payload(vectors: list[object], **overrides): + rows = [{"object": "embedding", "index": i, "embedding": vector} for i, vector in enumerate(vectors)] + return _sample_payload( + call_type="aembedding", + model="text-embedding-3-small", + response={"model": "text-embedding-3-small", "object": "list", "data": rows}, + **overrides, + ) + + +def test_embedding_response_is_summarized_as_vector_count_and_width(): + data = LLMCallSpanData.from_standard_logging_payload( + _embedding_payload([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]), capture_content=True + ) + + assert data.embedding_output == EmbeddingOutput(count=2, dimensions=3) + assert json.loads(data.embedding_output.as_json()) == {"count": 2, "dimensions": 3} + assert data.choices_out == () + + +def test_embedding_summary_follows_the_content_capture_gate(): + assert LLMCallSpanData.from_standard_logging_payload(_embedding_payload([[0.1]])).embedding_output is None + + +def test_embedding_summary_leaves_width_unknown_for_base64_vectors(): + data = LLMCallSpanData.from_standard_logging_payload(_embedding_payload(["AAAA"]), capture_content=True) + + assert data.embedding_output == EmbeddingOutput(count=1, dimensions=None) + + +def test_embedding_summary_is_absent_without_vectors_and_for_chat_data_lists(): + empty = LLMCallSpanData.from_standard_logging_payload(_embedding_payload([]), capture_content=True) + chat = LLMCallSpanData.from_standard_logging_payload( + _sample_payload(response={"data": [{"embedding": [0.1]}]}), capture_content=True + ) + + assert empty.embedding_output is None + assert chat.embedding_output is None + + def test_request_identity_prefers_canonical_team_keys(): from litellm.integrations.otel.model.payloads import RequestIdentity diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py index bd83357305e..c5ebc4bc53a 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py @@ -11,15 +11,14 @@ import pytest from litellm.integrations.otel import GenAIOperation from litellm.integrations.otel.mappers import ( - GenAIMapper, LangfuseMapper, LangtraceMapper, OpenInferenceMapper, WeaveMapper, resolve_mappers, ) -from litellm.integrations.otel.model.trace_controls import TraceControls from litellm.integrations.otel.model.payloads import ( + EmbeddingOutput, LLMCallSpanData, LLMRequestParams, LLMUsage, @@ -27,6 +26,7 @@ from litellm.integrations.otel.model.payloads import ( ServerInfo, ToolDefinition, ) +from litellm.integrations.otel.model.trace_controls import TraceControls def _llm_call(**overrides): @@ -174,6 +174,28 @@ def test_langfuse_mapper_skips_when_no_messages(): assert "langfuse.observation.output" not in attrs +def test_langfuse_mapper_renders_an_embedding_call_with_a_vector_summary_as_output(): + data = _llm_call( + operation=GenAIOperation.EMBEDDINGS, + request_model="text-embedding-3-small", + messages_in=({"role": "user", "content": "hello"},), + choices_out=(), + finish_reasons=(), + embedding_output=EmbeddingOutput(count=2, dimensions=1536), + ) + attrs = LangfuseMapper().map(data) + + assert attrs["langfuse.observation.type"] == "generation" + assert json.loads(attrs["langfuse.observation.output"]) == {"count": 2, "dimensions": 1536} + assert json.loads(attrs["langfuse.observation.input"]) == [{"role": "user", "content": "hello"}] + + +def test_langfuse_mapper_keeps_chat_output_when_no_embedding_summary(): + attrs = LangfuseMapper().map(_llm_call(embedding_output=None)) + + assert json.loads(attrs["langfuse.observation.output"]) == [{"role": "assistant", "content": "Sunny."}] + + # --------------------------------------------------------------------------- # # Weave # --------------------------------------------------------------------------- #