NousResearch/hermes-agent

Langfuse missing usage/cost data in spans

Cluster 42306. Data as of

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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #40560 fix(langfuse): restore usage/cost when post_api_request sends a sanitized response

    merged to main

    files: plugins/observability/langfuse/__init__.py, tests/plugins/test_langfuse_plugin.py

    Show 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.00 means 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 Lab

At a glance

Merged PRs
#40560
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
8 Jun 2026

For your agent

The same facts, typed.