Skip to content

Commit c0a3017

Browse files
committed
feat(tracing): emit token usage from LangGraph tracing handler
1 parent b782a54 commit c0a3017

2 files changed

Lines changed: 127 additions & 0 deletions

File tree

src/agentex/lib/adk/_modules/_langgraph_tracing.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from agentex.types.span import Span
1515
from agentex.lib.utils.logging import make_logger
1616
from agentex.lib.adk._modules.tracing import TracingModule
17+
from agentex.lib.core.tracing.usage import usage_from_counts
1718

1819
logger = make_logger(__name__)
1920

@@ -220,9 +221,44 @@ def _serialize_llm_result(response: LLMResult) -> dict[str, Any]:
220221

221222
if msg and hasattr(msg, "tool_calls") and msg.tool_calls:
222223
output["tool_calls"] = [{"name": tc["name"], "args": tc["args"]} for tc in msg.tool_calls]
224+
225+
# Per-call usage for billing; deduped against any turn aggregate
226+
usage = _extract_usage(response, msg)
227+
if usage:
228+
output["usage"] = usage
223229
return output
224230

225231

232+
def _extract_usage(response: LLMResult, msg: Any) -> dict[str, Any] | None:
233+
"""Pull token usage from an LLMResult in backend-billable key spellings.
234+
235+
Prefers the provider-normalized ``AIMessage.usage_metadata``; falls back to
236+
``LLMResult.llm_output["token_usage"]`` (OpenAI-style keys, which the
237+
backend also accepts).
238+
"""
239+
usage_metadata = getattr(msg, "usage_metadata", None) if msg is not None else None
240+
if isinstance(usage_metadata, dict) and usage_metadata:
241+
input_details = usage_metadata.get("input_token_details") or {}
242+
output_details = usage_metadata.get("output_token_details") or {}
243+
return usage_from_counts(
244+
input_tokens=usage_metadata.get("input_tokens"),
245+
output_tokens=usage_metadata.get("output_tokens"),
246+
total_tokens=usage_metadata.get("total_tokens"),
247+
cached_input_tokens=input_details.get("cache_read"),
248+
reasoning_tokens=output_details.get("reasoning"),
249+
)
250+
251+
llm_output = getattr(response, "llm_output", None) or {}
252+
token_usage = llm_output.get("token_usage") or llm_output.get("usage")
253+
if isinstance(token_usage, dict) and token_usage:
254+
return {
255+
key: value
256+
for key, value in token_usage.items()
257+
if isinstance(value, (int, float))
258+
}
259+
return None
260+
261+
226262
def create_langgraph_tracing_handler(
227263
trace_id: str,
228264
parent_span_id: str | None = None,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Tests for usage extraction in the LangGraph tracing handler.
2+
3+
Uses duck-typed fakes rather than real langchain_core objects because the ADK
4+
test conftest mocks langchain_core; ``_serialize_llm_result`` only relies on
5+
attribute access, so the fakes match the runtime contract.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from types import SimpleNamespace
11+
12+
from agentex.lib.adk._modules._langgraph_tracing import _serialize_llm_result
13+
14+
15+
def _make_result(msg=None, text: str = "hello", llm_output=None):
16+
generation = SimpleNamespace(text=text, message=msg)
17+
return SimpleNamespace(generations=[[generation]], llm_output=llm_output)
18+
19+
20+
def _make_message(content="hello", usage_metadata=None, tool_calls=None):
21+
return SimpleNamespace(content=content, usage_metadata=usage_metadata, tool_calls=tool_calls)
22+
23+
24+
class TestSerializeLLMResultUsage:
25+
def test_usage_from_usage_metadata(self):
26+
msg = _make_message(
27+
usage_metadata={
28+
"input_tokens": 100,
29+
"output_tokens": 40,
30+
"total_tokens": 140,
31+
"input_token_details": {"cache_read": 25},
32+
"output_token_details": {"reasoning": 12},
33+
}
34+
)
35+
output = _serialize_llm_result(_make_result(msg=msg))
36+
37+
assert output["content"] == "hello"
38+
assert output["usage"] == {
39+
"input_tokens": 100,
40+
"output_tokens": 40,
41+
"total_tokens": 140,
42+
"cached_input_tokens": 25,
43+
"reasoning_tokens": 12,
44+
}
45+
46+
def test_usage_metadata_without_details(self):
47+
msg = _make_message(usage_metadata={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15})
48+
output = _serialize_llm_result(_make_result(msg=msg))
49+
50+
assert output["usage"] == {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
51+
52+
def test_fallback_to_llm_output_token_usage(self):
53+
msg = _make_message(usage_metadata=None)
54+
result = _make_result(
55+
msg=msg,
56+
llm_output={
57+
"token_usage": {
58+
"prompt_tokens": 30,
59+
"completion_tokens": 20,
60+
"total_tokens": 50,
61+
# Nested detail dicts are dropped: the span blob stays flat
62+
"completion_tokens_details": {"reasoning_tokens": 4},
63+
}
64+
},
65+
)
66+
output = _serialize_llm_result(result)
67+
68+
assert output["usage"] == {
69+
"prompt_tokens": 30,
70+
"completion_tokens": 20,
71+
"total_tokens": 50,
72+
}
73+
74+
def test_no_usage_available_omits_key(self):
75+
msg = _make_message(usage_metadata=None)
76+
output = _serialize_llm_result(_make_result(msg=msg))
77+
78+
assert "usage" not in output
79+
assert output["content"] == "hello"
80+
81+
def test_content_blocks_and_tool_calls_still_serialized(self):
82+
msg = _make_message(
83+
content=[{"type": "text", "text": "block text"}],
84+
usage_metadata={"input_tokens": 1, "output_tokens": 2, "total_tokens": 3},
85+
tool_calls=[{"name": "search", "args": {"q": "x"}}],
86+
)
87+
output = _serialize_llm_result(_make_result(msg=msg))
88+
89+
assert output["content"] == "block text"
90+
assert output["tool_calls"] == [{"name": "search", "args": {"q": "x"}}]
91+
assert output["usage"]["total_tokens"] == 3

0 commit comments

Comments
 (0)