Skip to content

Commit 8384ee9

Browse files
committed
feat(tracing): emit token usage from litellm streaming and auto_send 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.
1 parent c0a3017 commit 8384ee9

5 files changed

Lines changed: 308 additions & 10 deletions

File tree

src/agentex/lib/core/services/adk/providers/litellm.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@
2424
logger = logging.make_logger(__name__)
2525

2626

27+
def _stream_kwargs_with_usage(llm_config: LLMConfig) -> dict:
28+
"""Completion kwargs with usage reporting enabled on the final stream chunk.
29+
30+
litellm only reports usage for streaming calls when
31+
``stream_options={"include_usage": True}`` is set; default it on so usage
32+
reaches the span. Callers can still opt out by explicitly passing
33+
``stream_options={"include_usage": False}``.
34+
"""
35+
kwargs = llm_config.model_dump()
36+
stream_options = kwargs.get("stream_options") or {}
37+
kwargs["stream_options"] = {"include_usage": True, **stream_options}
38+
return kwargs
39+
40+
2741
class LiteLLMService:
2842
def __init__(
2943
self,
@@ -122,7 +136,11 @@ async def chat_completion_auto_send(
122136

123137
if span:
124138
if streaming_context.task_message:
125-
span.output = streaming_context.task_message.model_dump()
139+
output = streaming_context.task_message.model_dump()
140+
# Per-call usage for billing; deduped against any turn aggregate
141+
if completion.usage is not None:
142+
output["usage"] = completion.usage.model_dump()
143+
span.output = output
126144
return streaming_context.task_message if streaming_context.task_message else None
127145

128146
async def chat_completion_stream(
@@ -151,20 +169,23 @@ async def chat_completion_stream(
151169
if self.llm_gateway is None:
152170
raise ValueError("LLM Gateway is not set")
153171

172+
completion_kwargs = _stream_kwargs_with_usage(llm_config)
154173
trace = self.tracer.trace(trace_id)
155174
async with trace.span(
156175
parent_id=parent_span_id,
157176
name="chat_completion_stream",
158-
input=llm_config.model_dump(),
177+
input=completion_kwargs,
159178
) as span:
160179
# Direct streaming outside temporal - yield each chunk as it comes
161180
chunks: list[Completion] = []
162181
async for chunk in self.llm_gateway.acompletion_stream(
163-
**llm_config.model_dump()
182+
**completion_kwargs
164183
):
165184
chunks.append(chunk)
166185
yield chunk
167186
if span:
187+
# The usage-bearing final chunk survives concat, so the dumped
188+
# completion carries usage for billing
168189
span.output = concat_completion_chunks(chunks).model_dump()
169190

170191
async def chat_completion_stream_auto_send(
@@ -192,11 +213,12 @@ async def chat_completion_stream_auto_send(
192213
if not llm_config.stream:
193214
llm_config.stream = True
194215

216+
completion_kwargs = _stream_kwargs_with_usage(llm_config)
195217
trace = self.tracer.trace(trace_id)
196218
async with trace.span(
197219
parent_id=parent_span_id,
198220
name="chat_completion_stream_auto_send",
199-
input=llm_config.model_dump(),
221+
input=completion_kwargs,
200222
) as span:
201223
# Use streaming context manager
202224
async with self.streaming_service.streaming_task_message_context(
@@ -210,9 +232,12 @@ async def chat_completion_stream_auto_send(
210232
# Get the streaming response
211233
chunks = []
212234
async for response in self.llm_gateway.acompletion_stream(
213-
**llm_config.model_dump()
235+
**completion_kwargs
214236
):
215237
heartbeat_if_in_workflow("chat completion streaming")
238+
# Store every chunk for final message assembly, including
239+
# the usage-only final chunk, which has no choices
240+
chunks.append(response)
216241
if (
217242
response.choices
218243
and len(response.choices) > 0
@@ -230,9 +255,6 @@ async def chat_completion_stream_auto_send(
230255
)
231256
heartbeat_if_in_workflow("content chunk streamed")
232257

233-
# Store the chunk for final message assembly
234-
chunks.append(response)
235-
236258
# Update the final message content
237259
complete_message = concat_completion_chunks(chunks)
238260
if (
@@ -257,6 +279,10 @@ async def chat_completion_stream_auto_send(
257279

258280
if span:
259281
if streaming_context.task_message:
260-
span.output = streaming_context.task_message.model_dump()
282+
output = streaming_context.task_message.model_dump()
283+
# Per-call usage for billing; deduped against any turn aggregate
284+
if complete_message.usage is not None:
285+
output["usage"] = complete_message.usage.model_dump()
286+
span.output = output
261287

262288
return streaming_context.task_message if streaming_context.task_message else None

src/agentex/lib/utils/completions.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from copy import deepcopy
44
from typing import Any
55
from functools import reduce, singledispatch
6+
from itertools import zip_longest
67

78
from agentex.lib.types.llm_messages import (
89
Delta,
@@ -21,7 +22,13 @@ def _concat_chunks(_a: None, b: Any):
2122

2223
@_concat_chunks.register
2324
def _(a: Completion, b: Completion) -> Completion:
24-
a.choices = [_concat_chunks(*c) for c in zip(a.choices, b.choices, strict=False)]
25+
# Chunks can have unequal choices: with stream_options.include_usage the
26+
# final chunk carries usage but no choices. Keep the unpaired side instead
27+
# of truncating to the shorter list.
28+
a.choices = [
29+
x if y is None else y if x is None else _concat_chunks(x, y)
30+
for x, y in zip_longest(a.choices, b.choices)
31+
]
2532
a.usage = _concat_chunks(a.usage, b.usage)
2633

2734
return a
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Tests that LiteLLMService puts LLM token usage on spans for billing.
2+
3+
Covers the paths that previously dropped usage: both auto_send variants (span
4+
output was only the TaskMessage dump) and streaming (litellm omits usage unless
5+
``stream_options.include_usage`` is set).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from datetime import UTC, datetime
11+
from contextlib import asynccontextmanager
12+
from unittest.mock import AsyncMock, MagicMock
13+
14+
from agentex.types.span import Span
15+
from agentex.types.task_message import TaskMessage
16+
from agentex.types.task_message_content import TextContent
17+
from agentex.lib.types.llm_messages import (
18+
Delta,
19+
Usage,
20+
Choice,
21+
LLMConfig,
22+
Completion,
23+
AssistantMessage,
24+
)
25+
from agentex.lib.core.services.adk.providers.litellm import (
26+
LiteLLMService,
27+
_stream_kwargs_with_usage,
28+
)
29+
30+
31+
class FakeTrace:
32+
def __init__(self) -> None:
33+
self.spans: list[Span] = []
34+
35+
@asynccontextmanager
36+
async def span(self, name, parent_id=None, input=None, data=None, task_id=None):
37+
span = Span(
38+
id=f"span-{len(self.spans)}",
39+
name=name,
40+
start_time=datetime.now(UTC),
41+
trace_id="trace-1",
42+
parent_id=parent_id,
43+
input=input,
44+
)
45+
self.spans.append(span)
46+
yield span
47+
48+
49+
class FakeTracer:
50+
def __init__(self) -> None:
51+
self.trace_obj = FakeTrace()
52+
53+
def trace(self, trace_id):
54+
return self.trace_obj
55+
56+
57+
def _make_streaming_service():
58+
streaming_context = MagicMock()
59+
streaming_context.task_message = TaskMessage(
60+
id="msg-1",
61+
task_id="task-1",
62+
content=TextContent(author="agent", content="", format="markdown"),
63+
)
64+
streaming_context.stream_update = AsyncMock()
65+
66+
@asynccontextmanager
67+
async def fake_context(**kwargs):
68+
yield streaming_context
69+
70+
streaming_service = MagicMock()
71+
streaming_service.streaming_task_message_context = fake_context
72+
return streaming_service, streaming_context
73+
74+
75+
def _make_service(llm_gateway) -> tuple[LiteLLMService, FakeTracer]:
76+
streaming_service, _ = _make_streaming_service()
77+
tracer = FakeTracer()
78+
service = LiteLLMService(
79+
agentex_client=MagicMock(),
80+
streaming_service=streaming_service,
81+
tracer=tracer,
82+
llm_gateway=llm_gateway,
83+
)
84+
return service, tracer
85+
86+
87+
def _stream_gateway(chunks, captured_kwargs):
88+
gateway = MagicMock()
89+
90+
def acompletion_stream(**kwargs):
91+
captured_kwargs.update(kwargs)
92+
93+
async def stream():
94+
for chunk in chunks:
95+
yield chunk
96+
97+
return stream()
98+
99+
gateway.acompletion_stream = acompletion_stream
100+
return gateway
101+
102+
103+
def _delta_chunk(content: str, role: str | None = None) -> Completion:
104+
return Completion(choices=[Choice(index=0, delta=Delta(content=content, role=role))])
105+
106+
107+
def _usage_only_chunk() -> Completion:
108+
return Completion(
109+
choices=[],
110+
usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
111+
)
112+
113+
114+
class TestStreamKwargsWithUsage:
115+
def test_defaults_include_usage_on(self):
116+
config = LLMConfig(model="gpt-4o", messages=[], stream=True)
117+
assert _stream_kwargs_with_usage(config)["stream_options"] == {"include_usage": True}
118+
119+
def test_caller_opt_out_preserved(self):
120+
config = LLMConfig(model="gpt-4o", messages=[], stream=True, stream_options={"include_usage": False})
121+
assert _stream_kwargs_with_usage(config)["stream_options"] == {"include_usage": False}
122+
123+
def test_merges_with_other_stream_options(self):
124+
config = LLMConfig(model="gpt-4o", messages=[], stream=True, stream_options={"other": 1})
125+
assert _stream_kwargs_with_usage(config)["stream_options"] == {"include_usage": True, "other": 1}
126+
127+
128+
class TestChatCompletionAutoSend:
129+
async def test_span_output_carries_usage(self):
130+
completion = Completion(
131+
choices=[Choice(index=0, message=AssistantMessage(content="Hello!"), finish_reason="stop")],
132+
usage=Usage(prompt_tokens=7, completion_tokens=3, total_tokens=10),
133+
)
134+
gateway = MagicMock()
135+
gateway.acompletion = AsyncMock(return_value=completion)
136+
service, tracer = _make_service(gateway)
137+
138+
await service.chat_completion_auto_send(
139+
task_id="task-1",
140+
llm_config=LLMConfig(model="gpt-4o", messages=[], stream=False),
141+
trace_id="trace-1",
142+
)
143+
144+
span = tracer.trace_obj.spans[0]
145+
assert span.output["usage"] == {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}
146+
147+
async def test_span_output_omits_usage_when_absent(self):
148+
completion = Completion(
149+
choices=[Choice(index=0, message=AssistantMessage(content="Hello!"), finish_reason="stop")],
150+
)
151+
gateway = MagicMock()
152+
gateway.acompletion = AsyncMock(return_value=completion)
153+
service, tracer = _make_service(gateway)
154+
155+
await service.chat_completion_auto_send(
156+
task_id="task-1",
157+
llm_config=LLMConfig(model="gpt-4o", messages=[], stream=False),
158+
trace_id="trace-1",
159+
)
160+
161+
assert "usage" not in tracer.trace_obj.spans[0].output
162+
163+
164+
class TestChatCompletionStream:
165+
async def test_stream_requests_usage_and_span_output_carries_it(self):
166+
captured_kwargs: dict = {}
167+
chunks = [_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!"), _usage_only_chunk()]
168+
service, tracer = _make_service(_stream_gateway(chunks, captured_kwargs))
169+
170+
results = []
171+
async for chunk in service.chat_completion_stream(
172+
llm_config=LLMConfig(model="gpt-4o", messages=[], stream=True),
173+
trace_id="trace-1",
174+
):
175+
results.append(chunk)
176+
177+
assert len(results) == 3
178+
assert captured_kwargs["stream_options"] == {"include_usage": True}
179+
span = tracer.trace_obj.spans[0]
180+
assert span.output["usage"] == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
181+
assert span.output["choices"][0]["message"]["content"] == "Hello!"
182+
183+
184+
class TestChatCompletionStreamAutoSend:
185+
async def test_usage_only_final_chunk_reaches_span_output(self):
186+
captured_kwargs: dict = {}
187+
chunks = [_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!"), _usage_only_chunk()]
188+
service, tracer = _make_service(_stream_gateway(chunks, captured_kwargs))
189+
190+
await service.chat_completion_stream_auto_send(
191+
task_id="task-1",
192+
llm_config=LLMConfig(model="gpt-4o", messages=[], stream=True),
193+
trace_id="trace-1",
194+
)
195+
196+
assert captured_kwargs["stream_options"] == {"include_usage": True}
197+
span = tracer.trace_obj.spans[0]
198+
assert span.output["usage"] == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
199+
# TaskMessage dump is still the base of the span output
200+
assert span.output["id"] == "msg-1"
201+
202+
async def test_stream_without_usage_chunk_omits_usage(self):
203+
captured_kwargs: dict = {}
204+
chunks = [_delta_chunk("Hi", role="assistant")]
205+
service, tracer = _make_service(_stream_gateway(chunks, captured_kwargs))
206+
207+
await service.chat_completion_stream_auto_send(
208+
task_id="task-1",
209+
llm_config=LLMConfig(model="gpt-4o", messages=[], stream=True),
210+
trace_id="trace-1",
211+
)
212+
213+
assert "usage" not in tracer.trace_obj.spans[0].output

tests/lib/utils/__init__.py

Whitespace-only changes.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from __future__ import annotations
2+
3+
from agentex.lib.utils.completions import concat_completion_chunks
4+
from agentex.lib.types.llm_messages import Delta, Usage, Choice, Completion
5+
6+
7+
def _delta_chunk(content: str, role: str | None = None) -> Completion:
8+
return Completion(choices=[Choice(index=0, delta=Delta(content=content, role=role))])
9+
10+
11+
def _usage_only_chunk(prompt: int, completion: int) -> Completion:
12+
# stream_options.include_usage: litellm/OpenAI send a final chunk that has
13+
# usage but an empty choices list
14+
return Completion(
15+
choices=[],
16+
usage=Usage(prompt_tokens=prompt, completion_tokens=completion, total_tokens=prompt + completion),
17+
)
18+
19+
20+
class TestConcatCompletionChunks:
21+
def test_concatenates_delta_content(self):
22+
result = concat_completion_chunks([_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!")])
23+
24+
assert result.choices[0].message.content == "Hello!"
25+
26+
def test_trailing_usage_only_chunk_keeps_choices_and_usage(self):
27+
result = concat_completion_chunks(
28+
[_delta_chunk("Hel", role="assistant"), _delta_chunk("lo!"), _usage_only_chunk(10, 5)]
29+
)
30+
31+
assert result.choices[0].message.content == "Hello!"
32+
assert result.usage is not None
33+
assert result.usage.prompt_tokens == 10
34+
assert result.usage.completion_tokens == 5
35+
assert result.usage.total_tokens == 15
36+
37+
def test_usage_summed_across_chunks(self):
38+
chunk_a = _delta_chunk("a", role="assistant")
39+
chunk_a.usage = Usage(prompt_tokens=1, completion_tokens=2, total_tokens=3)
40+
chunk_b = _delta_chunk("b")
41+
chunk_b.usage = Usage(prompt_tokens=4, completion_tokens=5, total_tokens=9)
42+
43+
result = concat_completion_chunks([chunk_a, chunk_b])
44+
45+
assert result.usage.prompt_tokens == 5
46+
assert result.usage.completion_tokens == 7
47+
assert result.usage.total_tokens == 12
48+
49+
def test_no_usage_chunks_leave_usage_none(self):
50+
result = concat_completion_chunks([_delta_chunk("x", role="assistant")])
51+
52+
assert result.usage is None

0 commit comments

Comments
 (0)