Skip to content

Commit dfedf11

Browse files
committed
fix(langchain-agents): a reported zero token count is not a missing one
extract_llm_usage read the llm_output fallback with `or`, so a genuine 0 was skipped in favour of the next key. With both counts at zero the bag came back all None, lang_chain_span_usage read that as the provider having said nothing, and the run went unreported: a turn that completed and cost nothing became indistinguishable from one that never reported, which is the distinction the reported flag exists to preserve. Now keyed on presence rather than truthiness. Three tests: a zero prompt count survives, both-zero still counts as reported, and a genuinely absent count is still absent. Found by Bugbot on #35.
1 parent bd65843 commit dfedf11

2 files changed

Lines changed: 57 additions & 4 deletions

File tree

packages/langchain-agents/src/launchdarkly_ai_langchain_agents/spans.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,15 +247,29 @@ def extract_llm_usage(output: Any) -> dict[str, Any]:
247247
llm_output = _get(output, "llm_output") or {}
248248
token_usage = llm_output.get("token_usage") or llm_output.get("usage")
249249
if token_usage:
250+
# `or` would read a real 0 as missing, and with both counts at zero the bag came back all
251+
# None, which lang_chain_span_usage reports as "the provider said nothing". A turn that
252+
# completed and reported zero is not the same as a turn that reported nothing: only the
253+
# second may leave the root without usage attributes.
250254
return {
251-
"input_tokens": token_usage.get("prompt_tokens")
252-
or token_usage.get("input_tokens"),
253-
"output_tokens": token_usage.get("completion_tokens")
254-
or token_usage.get("output_tokens"),
255+
"input_tokens": _first_present(
256+
token_usage, "prompt_tokens", "input_tokens"
257+
),
258+
"output_tokens": _first_present(
259+
token_usage, "completion_tokens", "output_tokens"
260+
),
255261
}
256262
return {}
257263

258264

265+
def _first_present(bag: dict[str, Any], *keys: str) -> Any:
266+
"""The first key actually present, so a reported 0 is kept rather than skipped."""
267+
for key in keys:
268+
if key in bag:
269+
return bag[key]
270+
return None
271+
272+
259273
def to_tool_definitions(config_tools: dict[str, Any]) -> list[ToolDefinitionInput]:
260274
"""The catalog handed to the agent, so a ``chat`` span reports what the model could call."""
261275
return [

packages/langchain-agents/tests/test_handler.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
from collections.abc import AsyncIterator
10+
from types import SimpleNamespace
1011
from typing import Any, ClassVar
1112
from unittest.mock import AsyncMock, MagicMock, patch
1213

@@ -1679,3 +1680,41 @@ def _get_type(self) -> str:
16791680
bundle.abandon_open_spans(set())
16801681

16811682
assert recorder.named("chat ")[0].ended == 1
1683+
1684+
1685+
class TestZeroTokensAreStillReported:
1686+
"""A reported 0 is not a missing count.
1687+
1688+
`extract_llm_usage` read the llm_output fallback with `or`, so a genuine 0 was skipped. With both
1689+
counts at zero the bag came back all None, `lang_chain_span_usage` read that as "the provider
1690+
said nothing", and the run went unreported: a turn that completed and cost nothing became
1691+
indistinguishable from one that never reported.
1692+
"""
1693+
1694+
def test_a_zero_prompt_count_survives(self) -> None:
1695+
from launchdarkly_ai_langchain_agents.spans import extract_llm_usage
1696+
1697+
result = SimpleNamespace(
1698+
generations=[],
1699+
llm_output={"token_usage": {"prompt_tokens": 0, "completion_tokens": 7}},
1700+
)
1701+
assert extract_llm_usage(result) == {"input_tokens": 0, "output_tokens": 7}
1702+
1703+
def test_both_zero_still_counts_as_reported(self) -> None:
1704+
from launchdarkly_ai_langchain_agents.spans import extract_llm_usage
1705+
from launchdarkly_ai_server import lang_chain_span_usage
1706+
1707+
result = SimpleNamespace(
1708+
generations=[],
1709+
llm_output={"token_usage": {"prompt_tokens": 0, "completion_tokens": 0}},
1710+
)
1711+
raw = extract_llm_usage(result)
1712+
assert raw == {"input_tokens": 0, "output_tokens": 0}
1713+
# The distinction that matters downstream: this is a reported turn, not an absent one.
1714+
assert lang_chain_span_usage(raw) is not None
1715+
1716+
def test_an_absent_count_is_still_none(self) -> None:
1717+
from launchdarkly_ai_langchain_agents.spans import extract_llm_usage
1718+
1719+
result = SimpleNamespace(generations=[], llm_output={"token_usage": {}})
1720+
assert extract_llm_usage(result) == {}

0 commit comments

Comments
 (0)