From 79ec58ad33f716030c593c32bf8e16689ee78a18 Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 15 Sep 2026 08:06:43 +0000 Subject: [PATCH] fix(langfuse): emit litellm_call_id and response_id as generation metadata v2 put the provider response id inside the generation id. v4 observation ids are 16 hex chars derived from that string, so the ids move to generation metadata to keep generations searchable by response id Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/langfuse/langfuse.py | 12 ++++++ .../integrations/test_langfuse.py | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index 28fb4f90f2b..a38e6ffddd9 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -143,6 +143,17 @@ def _logging_id(start_time: datetime | None, response_obj: object) -> str | None return litellm.utils.get_logging_id(start_time, response_obj) +def _lookup_ids(litellm_call_id: str | None, response_obj: object) -> Mapping[str, str]: + """v2 carried the response id inside the generation id; v4 hashes ids to 16 hex chars, so they ride in metadata.""" + response_id: Final[object] = ( + cast(Mapping[str, object], response_obj).get("id") + if isinstance(response_obj, Mapping) + else getattr(response_obj, "id", None) + ) + ids: Final[tuple[tuple[str, object], ...]] = (("litellm_call_id", litellm_call_id), ("response_id", response_id)) + return MappingProxyType({key: str(value) for key, value in ids if value is not None}) + + def _as_steering_flag(value: object) -> bool: """A string ``str_to_bool`` does not recognise falls back to its truthiness.""" if isinstance(value, str): @@ -923,6 +934,7 @@ class LangFuseLogger: **(trace_params.get("metadata") or {}), **log_requester_metadata(redact_user_api_key_info(metadata=allowlisted_metadata)), # pyright: ignore[reportArgumentType] # TypedDict in, plain metadata dict out **enrichments, + **_lookup_ids(litellm_call_id, response_obj), }, "level": level, "version": _optional_str(clean_metadata.pop("version", None)), diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index d0acc010b15..f228cc23652 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -687,6 +687,44 @@ class TestLangfuseUsageDetails(unittest.TestCase): assert generation_metadata["litellm_response_cost"] == 0.25 assert generation_metadata["api_base"] == "https://real-api-base" + def test_generation_metadata_carries_the_call_id_and_response_id(self): + """ + v2's generation id was ``time-_``, so a generation could + be found from the provider response id. v4 hashes that string onto 16 hex chars, + which leaves nothing searchable unless both ids are emitted as metadata. + """ + payload = self._build_standard_logging_payload(trace_id="canary-trace-id") + kwargs = {**self._build_langfuse_kwargs(payload), "response_cost": 0.25} + metadata = self._canary_request_metadata() + self.use_real_langfuse_client() + + with patch( + "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", + side_effect=lambda generation_params, **kw: generation_params, + create=True, + ): + self.logger._log_langfuse_v2( + user_id="user-1", + metadata=metadata, + litellm_params={"metadata": metadata}, + output=None, + start_time=datetime.datetime(2024, 1, 1, 12, 0, 0), + end_time=datetime.datetime(2024, 1, 1, 12, 0, 1), + kwargs=kwargs, + optional_params={}, + input=None, + response_obj=litellm.ModelResponse( + id="chatcmpl-canary-response", choices=[{"message": {"role": "assistant", "content": "OK"}}] + ), + level="DEFAULT", + litellm_call_id="canary-call-id", + ) + + generation_metadata = self.exported_generation_metadata() + assert generation_metadata["litellm_call_id"] == "canary-call-id" + assert generation_metadata["response_id"] == "chatcmpl-canary-response" + assert "chatcmpl-canary-response" in self._emitted_payload_text() + def test_denied_steering_keys_and_enrichments(self): """ endpoint is a plain string, so without the deny-list it would ride the