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/.
This commit is contained in:
Chris Hoogeboom
2026-06-12 16:21:41 +05:30
committed by GitHub
parent dd34b09893
commit 4cc8ca455a
5 changed files with 195 additions and 1 deletions
@@ -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,
}
@@ -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")),
@@ -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(
@@ -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
@@ -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())