Merge pull request #41982 from BerriAI/litellm_otel_v2_langfuse_embedding_output

* fix(otel v2): summarize embedding vectors as Langfuse observation output

The v2 LLM span built its output only from response choices, so /v1/embeddings
rendered a Langfuse generation with input, usage and cost but a blank output.
Embedding calls now carry an EmbeddingOutput(count, dimensions) summary that the
Langfuse mapper serializes as the observation output, and they are exported with
the embedding observation type instead of generation. Chat and Responses output
mapping is unchanged.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel v2): keep embedding observations typed as generation in Langfuse

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yucheng <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng-berri 2026-09-19 10:53:13 -07:00 committed by GitHub
commit b6758159dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 98 additions and 8 deletions

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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
# --------------------------------------------------------------------------- #