fix(otel): send cache and reasoning tokens in langfuse usage_details (#43553)

* fix(otel): send cache and reasoning tokens in langfuse usage_details

The OTel V2 Langfuse mapper only sent input, output and total, so cache reads, cache writes and reasoning tokens never reached Langfuse. Emit them as input_cached_tokens, input_cache_creation and output_reasoning_tokens, and send input/output net of those buckets so Langfuse does not price the same tokens twice.

Fixes #43542

* fix(otel): drop redundant comments from the usage_details change
This commit is contained in:
Ankit Jha 2026-09-29 10:00:56 +05:30 • committed by GitHub
parent 319b08b4b1
commit 60fca8298e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 93 additions and 2 deletions

View file

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

View file

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

View file

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