From 4cc8ca455ad678f29bdbde1f3569e3492dfb2a85 Mon Sep 17 00:00:00 2001 From: Chris Hoogeboom Date: Fri, 12 Jun 2026 06:51:41 -0400 Subject: [PATCH] feat(otel): emit v2 cost breakdown + stamp tracer scope version (#30156) Read the StandardLoggingPayload cost_breakdown into a typed LLMCost on LLMCallSpanData and emit each component under litellm.cost.* (absent components omitted, so spans stay sparse). Stamp litellm.__version__ as the instrumentation scope version so every v2 span carries a deterministic scope.version. Tests under tests/test_litellm/integrations/otel/. --- litellm/integrations/otel/mappers/genai.py | 14 +++ litellm/integrations/otel/model/payloads.py | 48 ++++++++++ .../integrations/otel/plumbing/providers.py | 6 +- .../otel/test_otel_v2_components.py | 92 +++++++++++++++++++ .../integrations/otel/test_otel_v2_emitter.py | 36 ++++++++ 5 files changed, 195 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py index 6c61feced4..d4f14e97a7 100644 --- a/litellm/integrations/otel/mappers/genai.py +++ b/litellm/integrations/otel/mappers/genai.py @@ -63,6 +63,20 @@ class GenAIMapper: # routing) onto the boundary-born LLM span — stamp it directly here. LiteLLM.PROVIDER_MODEL: lambda d: d.identity.provider_model or None, f"{LiteLLM.COST_PREFIX}total": lambda d: d.response_cost, + # Per-component cost breakdown (from the StandardLoggingPayload + # ``cost_breakdown``). Each component is omitted when the source didn't + # report it, so spans stay sparse rather than carrying zeros. + f"{LiteLLM.COST_PREFIX}input": lambda d: d.cost.input, + f"{LiteLLM.COST_PREFIX}output": lambda d: d.cost.output, + f"{LiteLLM.COST_PREFIX}cache_read": lambda d: d.cost.cache_read, + f"{LiteLLM.COST_PREFIX}cache_creation": lambda d: d.cost.cache_creation, + f"{LiteLLM.COST_PREFIX}tool_usage": lambda d: d.cost.tool_usage, + f"{LiteLLM.COST_PREFIX}original": lambda d: d.cost.original, + f"{LiteLLM.COST_PREFIX}discount_amount": lambda d: d.cost.discount_amount, + f"{LiteLLM.COST_PREFIX}discount_percent": lambda d: d.cost.discount_percent, + f"{LiteLLM.COST_PREFIX}margin_fixed_amount": lambda d: d.cost.margin_fixed_amount, + f"{LiteLLM.COST_PREFIX}margin_percent": lambda d: d.cost.margin_percent, + f"{LiteLLM.COST_PREFIX}margin_total_amount": lambda d: d.cost.margin_total_amount, LiteLLM.REQUEST_STREAMING: lambda d: d.is_streaming, } diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index bbef40ba37..82b7df5922 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -34,6 +34,7 @@ __all__ = [ "RequestIdentity", "GuardrailSpanData", "LLMCallSpanData", + "LLMCost", "LLMRequestParams", "LLMUsage", "MCPToolCallSpanData", @@ -91,6 +92,49 @@ class LLMUsage: total_tokens: int | None = None +@dataclass(frozen=True) +class LLMCost: + """Per-component cost breakdown, from the StandardLoggingPayload + ``cost_breakdown`` (``litellm.types.utils.CostBreakdown``). + + Each field is the USD cost of one component, or ``None`` when the source did + not report it — so the mapper omits absent components instead of emitting 0. + The final (post-discount/post-margin) total is carried separately on + ``LLMCallSpanData.response_cost``. Free-form ``additional_costs`` are not + surfaced here: span attributes are scalar and there is no agreed key shape + for them yet. + """ + + input: float | None = None + output: float | None = None + cache_read: float | None = None + cache_creation: float | None = None + tool_usage: float | None = None + original: float | None = None + discount_amount: float | None = None + discount_percent: float | None = None + margin_fixed_amount: float | None = None + margin_percent: float | None = None + margin_total_amount: float | None = None + + @classmethod + def from_breakdown(cls, breakdown: Mapping[str, object] | None) -> "LLMCost": + b = breakdown or {} + return cls( + input=as_float(b.get("input_cost")), + output=as_float(b.get("output_cost")), + cache_read=as_float(b.get("cache_read_cost")), + cache_creation=as_float(b.get("cache_creation_cost")), + tool_usage=as_float(b.get("tool_usage_cost")), + original=as_float(b.get("original_cost")), + discount_amount=as_float(b.get("discount_amount")), + discount_percent=as_float(b.get("discount_percent")), + margin_fixed_amount=as_float(b.get("margin_fixed_amount")), + margin_percent=as_float(b.get("margin_percent")), + margin_total_amount=as_float(b.get("margin_total_amount")), + ) + + @dataclass(frozen=True) class SpanError: error_type: str | None = None @@ -255,6 +299,7 @@ class LLMCallSpanData: server: ServerInfo | None identity: RequestIdentity is_streaming: bool | None = None + cost: LLMCost = field(default_factory=LLMCost) tools: tuple[ToolDefinition, ...] = () # Raw messages and response, needed by vendor mappers (OpenInference, # Langfuse, Weave) that stamp message-level attributes. ``messages_in`` is @@ -302,6 +347,9 @@ class LLMCallSpanData: finish_reasons=finish_reasons, error=_parse_error(payload), response_cost=as_float(payload.get("response_cost")), + cost=LLMCost.from_breakdown( + cast("Mapping[str, object] | None", payload.get("cost_breakdown")) + ), server=ServerInfo.from_api_base(context.api_base), identity=context.identity, is_streaming=as_bool(payload.get("stream")), diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index 40a0e41b90..4c98802479 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -17,6 +17,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( ) from opentelemetry.trace import Span, SpanKind, Tracer +from litellm._version import version as litellm_version from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config from litellm.integrations.otel.model.semconv import LiteLLM from litellm.integrations.otel.model.spans import LiteLLMSpanKind @@ -207,7 +208,10 @@ def build_tracer_provider( def get_tracer(provider: TracerProvider, name: str = "litellm") -> Tracer: - return provider.get_tracer(name) + # Stamp the instrumentation scope with the LiteLLM package version so every + # emitted span carries a deterministic ``scope.version`` (the standard OTel + # location for the emitting library's version) for downstream consumers. + return provider.get_tracer(name, litellm_version) def in_memory_provider( diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_components.py b/tests/test_litellm/integrations/otel/test_otel_v2_components.py index 86d84bd810..9d81c193af 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_components.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_components.py @@ -29,6 +29,7 @@ from litellm.integrations.otel.plumbing.metrics import ( from litellm.integrations.otel.model.payloads import ( # noqa: E402 GuardrailSpanData, LLMCallSpanData, + LLMCost, LLMRequestParams, LLMUsage, ProxyRequestSpanData, @@ -224,6 +225,97 @@ def test_genai_mapper_all_request_params(): assert attrs["server.port"] == 443 +def test_genai_mapper_cost_breakdown(): + from litellm.integrations.otel.model.semconv import LiteLLM + + data = LLMCallSpanData( + operation=GenAIOperation.CHAT, + provider="anthropic", + request_model="claude-sonnet-4-6", + response_model=None, + response_id=None, + request_params=LLMRequestParams(), + usage=LLMUsage(), + finish_reasons=(), + error=None, + response_cost=0.012, + server=None, + identity=RequestIdentity(call_id=None), + cost=LLMCost( + input=0.004, + output=0.006, + cache_read=0.001, + cache_creation=0.0, + tool_usage=0.0005, + original=0.013, + discount_amount=0.001, + discount_percent=0.077, + margin_total_amount=0.0, + # margin_fixed_amount / margin_percent left unset on purpose + ), + ) + attrs = GenAIMapper().map(data) + assert attrs[f"{LiteLLM.COST_PREFIX}total"] == 0.012 + assert attrs[f"{LiteLLM.COST_PREFIX}input"] == 0.004 + assert attrs[f"{LiteLLM.COST_PREFIX}output"] == 0.006 + assert attrs[f"{LiteLLM.COST_PREFIX}cache_read"] == 0.001 + assert attrs[f"{LiteLLM.COST_PREFIX}cache_creation"] == 0.0 + assert attrs[f"{LiteLLM.COST_PREFIX}tool_usage"] == 0.0005 + assert attrs[f"{LiteLLM.COST_PREFIX}original"] == 0.013 + assert attrs[f"{LiteLLM.COST_PREFIX}discount_amount"] == 0.001 + assert attrs[f"{LiteLLM.COST_PREFIX}discount_percent"] == 0.077 + assert attrs[f"{LiteLLM.COST_PREFIX}margin_total_amount"] == 0.0 + # Components the source did not report are omitted, not zero-filled. + assert f"{LiteLLM.COST_PREFIX}margin_fixed_amount" not in attrs + assert f"{LiteLLM.COST_PREFIX}margin_percent" not in attrs + + +def test_genai_mapper_cost_breakdown_absent(): + # No cost_breakdown → only the rolled-up total (from response_cost) emits. + from litellm.integrations.otel.model.semconv import LiteLLM + + attrs = GenAIMapper().map(_full_llm_call()) + assert attrs[f"{LiteLLM.COST_PREFIX}total"] == 0.002 + assert not any( + k.startswith(LiteLLM.COST_PREFIX) and k != f"{LiteLLM.COST_PREFIX}total" + for k in attrs + ) + + +def test_llm_cost_from_breakdown_maps_costbreakdown_keys(): + cost = LLMCost.from_breakdown( + { + "input_cost": 0.004, + "output_cost": 0.006, + "cache_read_cost": 0.001, + "cache_creation_cost": 0.002, + "tool_usage_cost": 0.0005, + "original_cost": 0.013, + "discount_amount": 0.001, + "discount_percent": 0.077, + "margin_fixed_amount": 0.0, + "margin_percent": 0.1, + "margin_total_amount": 0.0011, + "total_cost": 0.012, # carried on response_cost, not LLMCost + } + ) + assert cost.input == 0.004 + assert cost.output == 0.006 + assert cost.cache_read == 0.001 + assert cost.cache_creation == 0.002 + assert cost.tool_usage == 0.0005 + assert cost.original == 0.013 + assert cost.discount_amount == 0.001 + assert cost.discount_percent == 0.077 + assert cost.margin_fixed_amount == 0.0 + assert cost.margin_percent == 0.1 + assert cost.margin_total_amount == 0.0011 + + +def test_llm_cost_from_breakdown_none_is_empty(): + assert LLMCost.from_breakdown(None) == LLMCost() + + def test_genai_mapper_guardrail_and_service(): from litellm.integrations.otel.model.semconv import LiteLLM diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py index 2dbedda1ab..48190a798d 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py @@ -57,6 +57,42 @@ def _engine(legacy_compat=True): return SpanEmitter(tracer, cfg), exporter +def test_llm_call_span_cost_breakdown(): + engine, exporter = _engine() + data = LLMCallSpanData.from_standard_logging_payload( + _payload( + cost_breakdown={ + "input_cost": 0.004, + "output_cost": 0.006, + "cache_read_cost": 0.001, + "total_cost": 0.011, + } + ) + ) + engine.emit(SpanRole.LLM_CALL, data) + (span,) = exporter.get_finished_spans() + a = span.attributes + # The rolled-up total stays sourced from response_cost. + assert a[f"{LiteLLM.COST_PREFIX}total"] == 0.002 + # Per-component breakdown now rides the span. + assert a[f"{LiteLLM.COST_PREFIX}input"] == 0.004 + assert a[f"{LiteLLM.COST_PREFIX}output"] == 0.006 + assert a[f"{LiteLLM.COST_PREFIX}cache_read"] == 0.001 + # Unreported components are omitted, not zero-filled. + assert f"{LiteLLM.COST_PREFIX}margin_total_amount" not in a + + +def test_tracer_scope_carries_litellm_version(): + from litellm._version import version as litellm_version + + cfg = OpenTelemetryV2Config(exporter="in_memory") + provider, exporter = providers.in_memory_provider(cfg) + tracer = providers.get_tracer(provider, "litellm-test") + tracer.start_span("probe").end() + (span,) = exporter.get_finished_spans() + assert span.instrumentation_scope.version == litellm_version + + def test_llm_call_span_golden(): engine, exporter = _engine() data = LLMCallSpanData.from_standard_logging_payload(_payload())