diff --git a/litellm/integrations/otel/mappers/langfuse.py b/litellm/integrations/otel/mappers/langfuse.py index 9aff944cff0..68860931b76 100644 --- a/litellm/integrations/otel/mappers/langfuse.py +++ b/litellm/integrations/otel/mappers/langfuse.py @@ -56,9 +56,13 @@ class LangfuseMapper: "presence_penalty": lambda rp: rp.presence_penalty, "seed": lambda rp: rp.seed, } + # Langfuse prices every key, and litellm's prompt/completion counts include cache and reasoning tokens _USAGE_FIELDS: dict[str, Callable[[LLMUsage], AttrValue | None]] = { - "input": lambda u: u.input_tokens, - "output": lambda u: u.output_tokens, + "input": lambda u: u.uncached_input_tokens, + "input_cached_tokens": lambda u: u.cache_read_input_tokens or None, + "input_cache_creation": lambda u: u.cache_creation_input_tokens or None, + "output": lambda u: u.non_reasoning_output_tokens, + "output_reasoning_tokens": lambda u: u.reasoning_tokens or None, "total": lambda u: u.total_tokens, } diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 7e47abfb20d..c007eda7707 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -124,6 +124,20 @@ class LLMUsage: total_tokens: int | None = None cache_creation_input_tokens: int | None = None cache_read_input_tokens: int | None = None + reasoning_tokens: int | None = None + + @property + def uncached_input_tokens(self) -> int | None: + if self.input_tokens is None: + return None + cached: Final = (self.cache_read_input_tokens or 0) + (self.cache_creation_input_tokens or 0) + return max(self.input_tokens - cached, 0) + + @property + def non_reasoning_output_tokens(self) -> int | None: + if self.output_tokens is None: + return None + return max(self.output_tokens - (self.reasoning_tokens or 0), 0) @classmethod def from_standard_logging_payload(cls, payload: StandardLoggingPayload) -> LLMUsage: @@ -135,6 +149,10 @@ class LLMUsage: prompt_details: Final[Mapping[str, object]] = ( raw_details if isinstance(raw_details, Mapping) else MappingProxyType({}) ) + raw_completion_details: Final = usage_object.get("completion_tokens_details") + completion_details: Final[Mapping[str, object]] = ( + raw_completion_details if isinstance(raw_completion_details, Mapping) else MappingProxyType({}) + ) return cls( input_tokens=as_int(payload.get("prompt_tokens")), output_tokens=as_int(payload.get("completion_tokens")), @@ -150,6 +168,7 @@ class LLMUsage: prompt_details.get("cached_tokens"), usage_object.get("prompt_cache_hit_tokens"), ), + reasoning_tokens=_cache_token_value(completion_details.get("reasoning_tokens")), ) diff --git a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py index 1e2ae24a329..cdff9c960f3 100644 --- a/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/unit/integrations/otel/test_otel_v2_vendor_mappers.py @@ -137,6 +137,74 @@ def test_langfuse_mapper_observation_attrs(): assert attrs["langfuse.trace.metadata.team_id"] == "t1" +def _langfuse_usage_details(usage_object: Mapping[str, object]) -> dict[str, object]: + payload: Final = { + "call_type": "acompletion", + "custom_llm_provider": "openai", + "model": "gpt-4o", + "prompt_tokens": usage_object["prompt_tokens"], + "completion_tokens": usage_object["completion_tokens"], + "total_tokens": usage_object["total_tokens"], + "metadata": {"usage_object": usage_object}, + } + attrs: Final = LangfuseMapper().map(LLMCallSpanData.from_standard_logging_payload(payload)) + return json.loads(attrs["langfuse.observation.usage_details"]) + + +def test_langfuse_usage_details_split_openai_cached_and_reasoning_tokens(): + usage: Final = _langfuse_usage_details( + { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "prompt_tokens_details": {"cached_tokens": 60}, + "completion_tokens_details": {"reasoning_tokens": 30}, + } + ) + assert usage == { + "input": 40, + "input_cached_tokens": 60, + "output": 20, + "output_reasoning_tokens": 30, + "total": 150, + } + + +def test_langfuse_usage_details_split_anthropic_cache_read_and_creation_tokens(): + usage: Final = _langfuse_usage_details( + { + "prompt_tokens": 1000, + "completion_tokens": 40, + "total_tokens": 1040, + "cache_read_input_tokens": 800, + "cache_creation_input_tokens": 150, + "prompt_tokens_details": {"cached_tokens": 800, "cache_creation_tokens": 150}, + } + ) + assert usage == { + "input": 50, + "input_cached_tokens": 800, + "input_cache_creation": 150, + "output": 40, + "total": 1040, + } + + +def test_langfuse_usage_details_omit_zero_cache_and_reasoning_counts(): + usage: Final = _langfuse_usage_details( + { + "prompt_tokens": 12, + "completion_tokens": 8, + "total_tokens": 20, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "prompt_tokens_details": {"cached_tokens": 0}, + "completion_tokens_details": {"reasoning_tokens": 0}, + } + ) + assert usage == {"input": 12, "output": 8, "total": 20} + + def test_langfuse_mapper_names_the_trace_from_the_caller(): named = LangfuseMapper().map(_llm_call(trace=TraceControls(name="nightly-eval"))) assert named["langfuse.trace.name"] == "nightly-eval"