Nothing open on GitHub. Fix merged in #40560 on 8 Jun 2026. Check it covers your case.
The short answer
0open issues
0open PRs
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #40560. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members concern a bug where Langfuse integration fails to capture or accumulate token usage and cost information in generation spans. The issues and fixes relate to sanitized responses and hook invocation behavior affecting usage data.
Open issues0
Open PRs0
Fixes on record
-
#40560 fix(langfuse): restore usage/cost when post_api_request sends a sanitized response
files:
plugins/observability/langfuse/__init__.py,tests/plugins/test_langfuse_plugin.pyShow the diff
-
plugins/observability/langfuse/__init__.py@@ -837,8 +837,16 @@ def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str = if output.get("tool_calls"): state.turn_tool_calls.extend(output["tool_calls"]) - # Extract usage: prefer response object, fall back to usage dict from post_api_request - if response is not None: + # Extract usage: prefer a real response object that carries usage, else + # fall back to the usage summary dict from post_api_request. + # + # post_api_request passes `response` as a SANITIZED dict (no ``.usage`` + # attribute) alongside a separate `usage` summary dict. Gating on + # ``response is not None`` here took the response-object path on that dict, + # where ``getattr(response, "usage", None)`` is always None — so usage and + # cost were silently dropped for every gateway turn. Gate on a real + # ``.usage`` attribute instead so the usage-dict fallback below is reached. + if getattr(response, "usage", None) is not None: usage_details, cost_details = _usage_and_cost( response, provider=provider, -
tests/plugins/test_langfuse_plugin.py@@ -704,3 +704,76 @@ def fake_end(o, *, output=None, metadata=None, **kw): assert ended["output"] == {"status": "done"} assert not state.tools + +class TestUsageFromSanitizedResponse: + """Regression: ``post_api_request`` delivers ``response`` as a sanitized + dict (no ``.usage`` attribute) plus a separate ``usage`` summary dict. The + post-call handler must read the ``usage`` dict instead of treating the dict + response as a usage-bearing object and dropping all token/cost data.""" + + def _setup(self, mod, monkeypatch): + # Active client so on_post_llm_call does not early-return. + monkeypatch.setattr(mod, "_get_langfuse", lambda: object()) + observation = object() + state = mod.TraceState(trace_id="trace-1", root_ctx=None, root_span=None) + state.generations[mod._request_key(1)] = observation + monkeypatch.setitem(mod._TRACE_STATE, mod._trace_key("task-1", "session-1"), state) + captured = {} + + def fake_end_observation(obs, *, output=None, metadata=None, usage_details=None, cost_details=None): + captured["usage_details"] = usage_details + + monkeypatch.setattr(mod, "_end_observation", fake_end_observation) + return captured + + def test_sanitized_dict_response_uses_usage_dict(self, monkeypatch): + sys.modules.pop("plugins.observability.langfuse", None) + mod = importlib.import_module("plugins.observability.langfuse") + captured = self._setup(mod, monkeypatch) + + # A plain dict has no ``.usage`` attribute — mirrors post_api_request. + mod.on_post_llm_call( + task_id="task-1", + session_id="session-1", + api_call_count=1, + model="gemini-3-flash-preview", + response={"model": "gemini-3-flash-preview", "usage": {"input_tokens": 100, "output_tokens": 20}}, + usage={"input_tokens": 100, "output_tokens": 20}, + assistant_content_chars=42, + ) + + # Before the fix the dict response shadowed the usage dict and tokens + # were lost (usage_details == {}). + assert captured["usage_details"] == {"input": 100, "output": 20} + + def test_real_response_object_with_usage_still_used(self, monkeypatch): + sys.modules.pop("plugins.observability.langfuse", None) + mod = importlib.import_module("plugins.observability.langfuse") + captured = self._setup(mod, monkeypatch) + + # A response object that genuinely carries usage must still take the + # response-object path (post_llm_call / legacy behavior). + seen = {} + + def fake_usage_and_cost(resp, **_): + seen["resp"] = resp + return {"input": 7, "output": 3}, {} + + monkeypatch.setattr(mod, "_usage_and_cost", fake_usage_and_cost) + + class _Resp: + usage = {"prompt_tokens": 7, "completion_tokens": 3} + + resp = _Resp() + mod.on_post_llm_call( + task_id="task-1", + session_id="session-1", + api_call_count=1, + model="gemini-3-flash-preview", + response=resp, + usage={"input_tokens": 999, "output_tokens": 999}, + assistant_content_chars=42, + ) + + assert seen["resp"] is resp + assert captured["usage_details"] == {"input": 7, "output": 3}
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#42327 fix(langfuse): accumulate usage across hook invocations for traces (#42306) · superseded by another PR →
#40560
Closed issues · 1
- Closed#42306[Bug]: observability/langfuse: GENERATION spans created but missing usage/token counts/costs (no post-LLM hook activity logged)
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #40560
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 8 Jun 2026