mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 06:22:12 +00:00
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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user