feat(tracing): emit token usage on spans for SGP billing#458
Open
levilentz wants to merge 9 commits into
Open
Conversation
Contributor
|
Can we rebase and base this pr off of |
7c5249b to
b2fce50
Compare
b2fce50 to
3748d30
Compare
Contributor
|
Checked this against the harness work now on
All harness conformance suites (langgraph/codex/claude_code + |
Captures ResponseCompletedEvent usage in the streaming model (was zeroed) and response.usage in both tracing wrappers, writing span.output.usage for billing. Also implements stream_response on the tracing wrappers, which were abstract and raised TypeError on instantiation.
…spans Streaming calls now default stream_options.include_usage=True, the usage-only final chunk is collected, and both auto_send variants attach completion usage to span output. concat_completion_chunks no longer drops choices when a chunk has none.
TurnSpan.record_usage writes the billable aggregate to span.data (usage + cost_usd), encapsulating the contract so agents cannot re-introduce the double-count bug. Documents the usage/cost span contract in the tracing tutorial.
test_claude_agents_* now remove their placeholder packages after loading, which previously blocked real imports of the temporal plugin tree in later-collected tests. Formats touched files with ruff and narrows span.output types in new tests for pyright.
The wrappers never implemented abstract stream_response, so TemporalTracingModelProvider.get_model() raised TypeError on every call since introduction; no working callers can exist. The streaming provider plus run.py hooks are the live tracing path.
…sage 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.
3748d30 to
9e2426d
Compare
declan-scale
approved these changes
Jul 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes every SDK tracing adapter emit real token usage for tracking: the OpenAI Agents SDK temporal models, the LangGraph handler, and litellm streaming/auto_send previously dropped usage and billed zero. Adds
adk.tracing.turn_span()so agents record the per-turn aggregate (data["usage"]+cost_usd) instead of hand-rolling the contract that caused double-counting. Also fixes three bugs found along the way (uninstantiable tracing wrapper models, chunk concat wiping choices on empty-choices chunks,sys.modulesleakage in the claude_agents tests); 30 new tests,tests/libat 260 passing, ruff and pyright clean.Greptile Summary
This PR fixes all three tracing adapters (litellm, OpenAI Agents SDK, LangGraph) so they emit real token usage onto spans for SGP billing, and introduces
adk.tracing.turn_span()to record the per-turn rollup (data["usage"]+data["cost_usd"]) in the shape the backend bills from, preventing the double-counting bug that arose from hand-rolling that contract.TurnSpan/turn_span: new context manager that wraps the existingspan()lifecycle and exposesrecord_usage(TurnUsage | dict, cost_usd?), writing the aggregate tospan.data(notspan.output) so child LLM spans can still carryoutput["usage"]without double-counting.litellm.py+completions.py):_stream_kwargs_with_usageappendsstream_options={"include_usage": True}to every streaming call;concat_completion_chunksis switched fromziptozip_longestso a trailing usage-only chunk (emptychoices) no longer wipes the assembled content choices.TemporalTracingModelProvider/TemporalTracingResponsesModel/TemporalTracingChatCompletionsModelwrapper trio; fixessys.modulesstub leakage in claude_agents tests that was blocking collection of real submodule imports in later tests.Confidence Score: 5/5
Safe to merge — all three adapters now emit real usage, the zip_longest fix is correct and well-tested, and the sys.modules cleanup is a targeted fix with no collateral effects.
The changes are well-contained billing instrumentation: new read paths (usage extraction) rather than modifications to request or message logic. The zip_longest fix is provably correct, the TurnSpan API cleanly prevents double-counting by routing aggregate usage to span.data instead of output, and 30 new tests cover every code path including edge cases.
No files require special attention.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Agent participant TurnSpan participant TracingModule participant LiteLLMService participant Span Agent->>TracingModule: turn_span(trace_id, name, ...) TracingModule->>Span: start_span() TracingModule-->>Agent: yield TurnSpan(span) Agent->>LiteLLMService: chat_completion_stream_auto_send(...) Note over LiteLLMService: _stream_kwargs_with_usage() adds include_usage=True LiteLLMService->>Span: "span.output = {task_message, usage}" LiteLLMService-->>Agent: TaskMessage Agent->>TurnSpan: record_usage(result.usage, cost_usd) Note over TurnSpan: Aggregate to span.data["usage"] + cost_usd TurnSpan->>Span: "span.data = {usage, cost_usd}" Agent->>TracingModule: exit turn_span TracingModule->>Span: end_span()%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Agent participant TurnSpan participant TracingModule participant LiteLLMService participant Span Agent->>TracingModule: turn_span(trace_id, name, ...) TracingModule->>Span: start_span() TracingModule-->>Agent: yield TurnSpan(span) Agent->>LiteLLMService: chat_completion_stream_auto_send(...) Note over LiteLLMService: _stream_kwargs_with_usage() adds include_usage=True LiteLLMService->>Span: "span.output = {task_message, usage}" LiteLLMService-->>Agent: TaskMessage Agent->>TurnSpan: record_usage(result.usage, cost_usd) Note over TurnSpan: Aggregate to span.data["usage"] + cost_usd TurnSpan->>Span: "span.data = {usage, cost_usd}" Agent->>TracingModule: exit turn_span TracingModule->>Span: end_span()Comments Outside Diff (1)
src/agentex/lib/adk/_modules/_langgraph_tracing.py, line 89-91 (link)_extract_usageis called inside the innerfor generation in generation_listloop, sooutput["usage"]gets overwritten on each iteration. Forn=1calls (the common case) this is harmless, but if a caller ever requestsn > 1completions only the last generation'susage_metadatacontributes to the billing blob — earlier generations are silently dropped. Thellm_outputfallback is safe because it reads the response-level aggregate and returns the same dict every iteration, but theusage_metadatapath is per-generation and should accumulate.Prompt To Fix With AI
Reviews (5): Last reviewed commit: "refactor(adk): record_usage accepts harn..." | Re-trigger Greptile