mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
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:
parent
319b08b4b1
commit
60fca8298e
3 changed files with 93 additions and 2 deletions
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue