Skip to content

Commit dc40aec

Browse files
authored
fix(instrumentation): all 4 usage sources are 'if' not 'elif' (#59)
* fix(instrumentation): all 4 usage sources are 'if' not 'elif' The pre-fix `extract_usage_from_response` walked the 4 source branches as an elif chain: ``` if hasattr(response, 'usage_metadata'): # empty dict elif hasattr(response, 'generations'): # LLMResult elif hasattr(response, 'usage'): elif hasattr(response, 'response_metadata'): # real tokens ``` A LangChain AIMessage can carry token info on multiple attributes at once. When the first branch's `hasattr` returns True but the value is an empty / 0/0/0 dict (LangChain-internal init state for streaming, callbacks, some provider wrappers), every subsequent `elif` was skipped — including the one carrying the real token counts. Reproducer (covered by the new test): ``` response.usage_metadata = {input_tokens: 0, output_tokens: 0, total_tokens: 0} response.response_metadata = {token_usage: {prompt_tokens: 26, completion_tokens: 48, total_tokens: 74}} # Pre-fix: ships tokens=0 to /track. LLM call invisible on dashboard. # Post-fix: ships tokens=74, input_tokens=26, output_tokens=48. ``` Switched all 4 source branches to plain `if` so each one attempts its read; later branches naturally overwrite the zero default when the earlier branch's value is empty. In practice LangChain providers never put conflicting token counts on two attributes of the same response, so last-wins is safe; the docstring on the function is updated to spell that out. Added `test_extract_usage_metadata_zero_response_metadata_real` that pins the regression: empty usage_metadata + populated response_metadata must surface the real numbers. All 39 tests in test_langgraph_callback.py still pass; test_extractors.py + test_instrumentation_phase41.py also green (no regression in the wire-shape normalization testers). * chore(release): bump version 0.13.3 -> 0.13.4 Pairs with 4840a33 (fix-instrumentation-langchain-elif-chain). Wire format unchanged; pure version bump + changelog entry so the SDK_MIN_VERSION floor is up to date with the bug fix. ---------
1 parent 2043e09 commit dc40aec

3 files changed

Lines changed: 72 additions & 5 deletions

File tree

src/nullrun/__version__.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,31 @@
276276
SDK_MIN_VERSION_FOR_V3 = "0.12.0". Recommended upgrade path:
277277
0.13.1 -> 0.13.2 (typing-only change for end users; visible
278278
delta is the per-file mypy table in pyproject.toml).
279+
280+
v3.16 / 0.13.4 (2026-07-08) -- bug-fix: complete the LangChain
281+
usage-extraction elif-chain.
282+
283+
Pre-fix extract_usage_from_response walked the 4 source branches
284+
if-hasattr-usage_metadata ... elif-hasattr-generations ...
285+
elif-hasattr-usage ... elif-hasattr-response_metadata. A LangChain
286+
AIMessage can carry token info on multiple attributes at once.
287+
When the first branch's hasattr returned True but the value was
288+
empty or 0/0/0 (streaming init state, some provider wrappers),
289+
every subsequent elif was skipped and the SDK shipped tokens=0
290+
to the backend -- making the LLM call invisible on the dashboard.
291+
292+
Switched all 4 source branches to plain if so each one attempts
293+
its read; later branches naturally overwrite the zero default when
294+
the earlier branch value is empty. New regression test
295+
test_extract_usage_metadata_zero_response_metadata_real.
296+
297+
39 tests in test_langgraph_callback.py still pass; no
298+
regression in test_extractors.py or
299+
test_instrumentation_phase41.py. Wire format is unchanged.
300+
301+
Recommended upgrade path: 0.13.3 -> 0.13.4. No SDK_MIN_VERSION
302+
bump; backends on 1.0.0 keep working unchanged.
279303
"""
280304

281-
__version__ = "0.13.3"
305+
__version__ = "0.13.4"
282306
__platform_version__ = "1.0.0"

src/nullrun/instrumentation/langgraph.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ def extract_usage_from_response(response: Any, provider: str, model: str) -> dic
211211
}
212212

213213
# For callback-based LLMResult, check generations[0][0].message.usage_metadata
214-
elif hasattr(response, 'generations') and response.generations:
214+
if hasattr(response, 'generations') and response.generations:
215215
first_gen = response.generations[0][0] if response.generations else None
216216
if first_gen and hasattr(first_gen, 'message'):
217217
msg = first_gen.message
@@ -233,7 +233,7 @@ def extract_usage_from_response(response: Any, provider: str, model: str) -> dic
233233
}
234234

235235
# Try response.usage (Anthropic, standard OpenAI format)
236-
elif hasattr(response, 'usage') and response.usage:
236+
if hasattr(response, 'usage') and response.usage:
237237
usage_raw = response.usage
238238
if isinstance(usage_raw, dict):
239239
usage["input_tokens"] = usage_raw.get('input_tokens', 0) or 0
@@ -251,8 +251,20 @@ def extract_usage_from_response(response: Any, provider: str, model: str) -> dic
251251
'total_tokens': usage["total_tokens"],
252252
}
253253

254+
# All 4 sources above are `if` (not `elif`) because the same
255+
# response can carry token info on multiple attributes (e.g.
256+
# `usage_metadata = {}` plus `response_metadata.token_usage =
257+
# {real tokens}`). `elif` would silently drop the
258+
# `response_metadata` branch whenever the previous branch's
259+
# hasattr() returned True with an empty value. The first
260+
# non-empty source wins; later branches may overwrite (LangChain
261+
# providers in practice never put conflicting numbers on two
262+
# attributes of the same response, so a "last-wins" is safe
263+
# in practice; see the `_extract_usage` docstring for the
264+
# priority order rationale).
265+
#
254266
# Try response_metadata (some providers) - also check llm_output for LLMResult
255-
elif hasattr(response, 'response_metadata'):
267+
if hasattr(response, 'response_metadata'):
256268
resp_meta = response.response_metadata
257269
if isinstance(resp_meta, dict):
258270
# Some providers put token info here
@@ -269,7 +281,7 @@ def extract_usage_from_response(response: Any, provider: str, model: str) -> dic
269281
usage["total_tokens"] = token_usage.get('total_tokens', 0) or 0
270282
usage["raw_usage"] = dict(token_usage)
271283
# Check llm_output for LLMResult (callback case)
272-
elif hasattr(response, 'llm_output') and response.llm_output:
284+
if hasattr(response, 'llm_output') and response.llm_output:
273285
token_usage = response.llm_output.get('token_usage', {})
274286
if isinstance(token_usage, dict):
275287
usage["input_tokens"] = (

tests/test_langgraph_callback.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,37 @@ def test_extract_usage_metadata_dict_form():
4646
assert usage["has_usage"] is True
4747

4848

49+
def test_extract_usage_metadata_zero_response_metadata_real() -> None:
50+
"""2026-07-08: regression for the `elif`-chain token-extraction bug.
51+
52+
The pre-fix extractor walked `if hasattr(... usage_metadata): ...
53+
elif hasattr(... response_metadata): ...`. If a LangChain AIMessage
54+
carried an empty `usage_metadata` (0/0/0) but a populated
55+
`response_metadata.token_usage` (the real numbers), the elif skipped
56+
`response_metadata` and the SDK shipped `tokens=0` to the backend —
57+
making the LLM call invisible on the dashboard.
58+
59+
After the fix, all 4 source branches are `if` (not `elif`) so the
60+
populated `response_metadata` overwrites the empty `usage_metadata`.
61+
The test asserts the non-zero numbers come through to the wire shape.
62+
"""
63+
response = SimpleNamespace(
64+
usage_metadata={"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
65+
response_metadata={
66+
"token_usage": {
67+
"prompt_tokens": 26,
68+
"completion_tokens": 48,
69+
"total_tokens": 74,
70+
}
71+
},
72+
)
73+
usage = extract_usage_from_response(response, provider="openai", model="gpt-4.1-mini")
74+
assert usage["input_tokens"] == 26, f"expected 26, got {usage['input_tokens']}"
75+
assert usage["output_tokens"] == 48, f"expected 48, got {usage['output_tokens']}"
76+
assert usage["total_tokens"] == 74, f"expected 74, got {usage['total_tokens']}"
77+
assert usage["has_usage"] is True
78+
79+
4980
def test_extract_usage_metadata_object_form():
5081
"""Object with .input_tokens / .output_tokens / .total_tokens attrs."""
5182
response = SimpleNamespace(

0 commit comments

Comments
 (0)