Skip to content

Commit 3748d30

Browse files
levilentzdeclan-scale
authored andcommitted
refactor(adk): record_usage accepts harness TurnUsage; drop bespoke usage helpers
TurnSpan.record_usage now takes the TurnUsage every harness turn adapter reports (cost_usd lifted to data automatically) or a plain dict, replacing the individual-count kwargs and the lib/core/tracing/usage.py helpers that duplicated what next's harness provides.
1 parent 8434c56 commit 3748d30

7 files changed

Lines changed: 95 additions & 240 deletions

File tree

examples/tutorials/10_async/00_base/030_tracing/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ Token usage on spans is what the backend bills from, and it reads two shapes:
5252
(litellm, OpenAI Agents SDK, LangGraph) emit this automatically. Summed only when no
5353
aggregate exists in the trace.
5454

55-
Record the turn rollup with `adk.tracing.turn_span()` instead of hand-writing usage keys:
55+
Record the turn rollup with `adk.tracing.turn_span()` instead of hand-writing usage keys.
56+
It accepts the harness `TurnUsage` that every turn adapter reports (`LangGraphTurn.usage()`,
57+
`run_turn(...).usage`, `ClaudeCodeTurn.usage()`, ...), cost included:
5658

5759
```python
5860
async with adk.tracing.turn_span(
@@ -61,9 +63,9 @@ async with adk.tracing.turn_span(
6163
input={"prompt": prompt},
6264
task_id=task.id,
6365
) as turn:
64-
result = await run_llm_calls()
65-
turn.output = {"response": result.text}
66-
turn.record_usage(usage=result.usage, cost_usd=result.cost_usd)
66+
result = await run_turn(...)
67+
turn.output = {"response": result.final_output}
68+
turn.record_usage(result.usage) # TurnUsage; cost_usd stamped automatically
6769
```
6870

6971
**Never put usage on both a rollup span's `output` and its per-call children's

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

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
TracingActivityName,
2121
)
2222
from agentex.lib.core.tracing.tracer import AsyncTracer
23-
from agentex.lib.core.tracing.usage import usage_from_counts, validate_usage_blob
23+
from agentex.lib.core.harness.types import TurnUsage
2424
from agentex.types.span import Span
2525
from agentex.lib.utils.logging import make_logger
2626
from agentex.lib.utils.model_utils import BaseModel
@@ -31,6 +31,21 @@
3131
DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1)
3232
TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC = "agentex.tracing.temporal_span_activity.dropped"
3333

34+
# Token key spellings the backend accepts when billing usage from spans.
35+
RECOGNIZED_USAGE_KEYS = frozenset(
36+
{
37+
"input_tokens",
38+
"prompt_tokens",
39+
"output_tokens",
40+
"completion_tokens",
41+
"cached_input_tokens",
42+
"cached_tokens",
43+
"reasoning_tokens",
44+
"total_tokens",
45+
"cost_usd",
46+
}
47+
)
48+
3449

3550
def _record_temporal_span_activity_dropped(event_type: str) -> None:
3651
try:
@@ -62,36 +77,38 @@ def __init__(self, span: Span | None):
6277

6378
def record_usage(
6479
self,
65-
usage: dict[str, Any] | None = None,
80+
usage: TurnUsage | dict[str, Any] | None = None,
6681
cost_usd: float | None = None,
67-
*,
68-
input_tokens: int | None = None,
69-
output_tokens: int | None = None,
70-
total_tokens: int | None = None,
71-
cached_input_tokens: int | None = None,
72-
reasoning_tokens: int | None = None,
7382
) -> None:
7483
"""Record the turn's aggregate usage on the span's ``data``.
7584
76-
Pass either a prebuilt ``usage`` mapping (framework token spellings
77-
like ``prompt_tokens``/``completion_tokens`` are accepted by the
78-
backend) or individual token counts, plus an optional ``cost_usd``.
79-
Individual counts are merged over the ``usage`` mapping. The usage must
80-
be this turn's own tokens, not a session-cumulative total.
85+
Pass the harness ``TurnUsage`` (e.g. ``LangGraphTurn.usage()`` or
86+
``run_turn(...).usage``) — its ``cost_usd`` is stamped automatically —
87+
or a plain dict with backend-recognized token spellings
88+
(``prompt_tokens``/``completion_tokens`` also work). An explicit
89+
``cost_usd`` argument overrides any cost carried by ``usage``. The
90+
usage must be this turn's own tokens, not a session-cumulative total.
8191
"""
8292
if self.span is None:
8393
return
8494

85-
blob: dict[str, Any] = validate_usage_blob(usage) if usage else {}
86-
blob.update(
87-
usage_from_counts(
88-
input_tokens=input_tokens,
89-
output_tokens=output_tokens,
90-
total_tokens=total_tokens,
91-
cached_input_tokens=cached_input_tokens,
92-
reasoning_tokens=reasoning_tokens,
93-
)
94-
)
95+
blob: dict[str, Any]
96+
if isinstance(usage, TurnUsage):
97+
blob = usage.model_dump(exclude_none=True)
98+
# cost lives beside the blob as data["cost_usd"], not inside it
99+
blob_cost = blob.pop("cost_usd", None)
100+
if cost_usd is None:
101+
cost_usd = blob_cost
102+
elif usage is not None:
103+
blob = dict(usage)
104+
if not any(key in RECOGNIZED_USAGE_KEYS for key in blob):
105+
logger.warning(
106+
"TurnSpan.record_usage: usage has no recognized token keys and will "
107+
f"not be billed. Got keys {sorted(blob)}; expected any of "
108+
f"{sorted(RECOGNIZED_USAGE_KEYS)}."
109+
)
110+
else:
111+
blob = {}
95112

96113
if self.span.data is not None and not isinstance(self.span.data, dict):
97114
logger.warning(
@@ -250,12 +267,12 @@ async def turn_span(
250267
Per-call child spans (LLM adapters) may still carry
251268
``output["usage"]``; the backend de-dups them against this aggregate.
252269
253-
Example::
270+
Example (with a harness turn, e.g. ``LangGraphTurn`` / ``run_turn``)::
254271
255272
async with adk.tracing.turn_span(trace_id=task.id, name="turn", input={...}, task_id=task.id) as turn:
256-
result = await run_llm_calls()
257-
turn.output = {"response": result.text}
258-
turn.record_usage(usage=result.usage, cost_usd=result.cost_usd)
273+
result = await run_turn(...)
274+
turn.output = {"response": result.final_output}
275+
turn.record_usage(result.usage) # TurnUsage, cost_usd included
259276
"""
260277
async with self.span(
261278
trace_id=trace_id,

src/agentex/lib/core/temporal/plugins/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@
5252
"streaming_parent_span_id",
5353
"TemporalStreamingHooks",
5454
"stream_lifecycle_content",
55-
]
55+
]

src/agentex/lib/core/temporal/plugins/openai_agents/models/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
__all__ = [
1313
"TemporalStreamingModel",
1414
"TemporalStreamingModelProvider",
15-
]
15+
]

src/agentex/lib/core/tracing/usage.py

Lines changed: 0 additions & 115 deletions
This file was deleted.

tests/lib/adk/test_tracing_module.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import agentex.lib.adk._modules.tracing as _tracing_mod
1010
from agentex.types.span import Span
11+
from agentex.lib.core.harness.types import TurnUsage
1112
from agentex.lib.adk._modules.tracing import TurnSpan, TracingModule
1213
from agentex.lib.core.services.adk.tracing import TracingService
1314

@@ -289,23 +290,63 @@ async def test_turn_span_records_aggregate_usage_in_data(self):
289290
# The aggregate lives in data, never in output — output stays payload-only
290291
assert ended_span.output == {"response": "hello"}
291292

292-
async def test_turn_span_record_usage_with_individual_counts(self):
293+
async def test_turn_span_record_usage_with_turn_usage(self):
293294
mock_service, module = _make_module()
294295
started = _make_span()
295296
mock_service.start_span.return_value = started
296297
mock_service.end_span.return_value = started
297298

299+
turn_usage = TurnUsage(
300+
model="gpt-4o",
301+
input_tokens=10,
302+
output_tokens=5,
303+
cached_input_tokens=2,
304+
total_tokens=15,
305+
cost_usd=0.5,
306+
)
298307
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
299308
async with module.turn_span(trace_id="trace-123", name="turn") as turn:
300-
turn.record_usage(input_tokens=10, output_tokens=5, cached_input_tokens=2)
309+
turn.record_usage(turn_usage)
301310

302311
ended_span = mock_service.end_span.call_args.kwargs["span"]
312+
# cost_usd is lifted out of the blob to data["cost_usd"]
313+
assert ended_span.data["cost_usd"] == 0.5
303314
assert ended_span.data["usage"] == {
315+
"model": "gpt-4o",
304316
"input_tokens": 10,
305317
"output_tokens": 5,
306318
"cached_input_tokens": 2,
319+
"total_tokens": 15,
320+
"num_tool_calls": 0,
321+
"num_reasoning_blocks": 0,
307322
}
308323

324+
async def test_turn_span_explicit_cost_overrides_turn_usage_cost(self):
325+
mock_service, module = _make_module()
326+
started = _make_span()
327+
mock_service.start_span.return_value = started
328+
mock_service.end_span.return_value = started
329+
330+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
331+
async with module.turn_span(trace_id="trace-123", name="turn") as turn:
332+
turn.record_usage(TurnUsage(input_tokens=1, cost_usd=0.5), cost_usd=0.75)
333+
334+
ended_span = mock_service.end_span.call_args.kwargs["span"]
335+
assert ended_span.data["cost_usd"] == 0.75
336+
337+
async def test_turn_span_warns_on_unrecognized_usage_keys(self, caplog):
338+
mock_service, module = _make_module()
339+
started = _make_span()
340+
mock_service.start_span.return_value = started
341+
mock_service.end_span.return_value = started
342+
343+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
344+
async with module.turn_span(trace_id="trace-123", name="turn") as turn:
345+
with caplog.at_level("WARNING"):
346+
turn.record_usage(usage={"inputTokens": 10})
347+
348+
assert any("no recognized token keys" in message for message in caplog.messages)
349+
309350
async def test_turn_span_preserves_existing_data(self):
310351
mock_service, module = _make_module()
311352
started = _make_span(data={"custom": "value"})

0 commit comments

Comments
 (0)