Skip to content

Commit 3ccfdcd

Browse files
committed
automatically collect session usage
exposing this as AgentSession.usage, ensuring collection is performed for each model & provider pair. this also updates the UsageSummary fields to be more consistent - prompt_token -> input_token - completion_token -> output_token
1 parent d6ec0cc commit 3ccfdcd

17 files changed

Lines changed: 2705 additions & 2607 deletions

examples/bank-ivr/ivr_navigator_agent.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,12 @@ async def dtmf_session(ctx: JobContext) -> None:
109109
)
110110
logger.info(f"==> User request: {user_request}")
111111

112-
usage_collector = metrics.UsageCollector()
113-
114112
@session.on("metrics_collected")
115113
def _on_metrics_collected(ev: MetricsCollectedEvent) -> None:
116114
metrics.log_metrics(ev.metrics)
117-
usage_collector.collect(ev.metrics)
118115

119116
async def log_usage() -> None:
120-
summary = usage_collector.get_summary()
121-
logger.info(f"Usage: {summary}")
117+
logger.info(f"Usage: {session.usage}")
122118

123119
ctx.add_shutdown_callback(log_usage)
124120

examples/bank-ivr/ivr_system_agent.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -645,16 +645,12 @@ async def bank_ivr_session(ctx: JobContext) -> None:
645645
userdata=state,
646646
)
647647

648-
usage_collector = metrics.UsageCollector()
649-
650648
@session.on("metrics_collected")
651649
def _on_metrics(ev: MetricsCollectedEvent) -> None:
652650
metrics.log_metrics(ev.metrics)
653-
usage_collector.collect(ev.metrics)
654651

655652
async def log_usage() -> None:
656-
summary = usage_collector.get_summary()
657-
logger.info("Usage summary: %s", summary)
653+
logger.info("Usage summary: %s", session.usage)
658654

659655
ctx.add_shutdown_callback(log_usage)
660656

examples/dtmf/basic_dtmf_agent.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,16 +142,12 @@ async def entrypoint(ctx: JobContext) -> None:
142142
turn_detection=MultilingualModel(),
143143
)
144144

145-
usage_collector = metrics.UsageCollector()
146-
147145
@session.on("metrics_collected")
148146
def _on_metrics_collected(ev: MetricsCollectedEvent) -> None:
149147
metrics.log_metrics(ev.metrics)
150-
usage_collector.collect(ev.metrics)
151148

152149
async def log_usage() -> None:
153-
summary = usage_collector.get_summary()
154-
logger.info(f"Usage: {summary}")
150+
logger.info(f"Usage: {session.usage}")
155151

156152
ctx.add_shutdown_callback(log_usage)
157153

examples/survey/survey_agent.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
cli,
1919
inference,
2020
llm,
21-
metrics,
2221
room_io,
2322
)
2423
from livekit.agents.beta.workflows import GetEmailTask, TaskGroup
@@ -347,11 +346,8 @@ async def entrypoint(ctx: JobContext):
347346
preemptive_generation=True,
348347
)
349348

350-
usage_collector = metrics.UsageCollector()
351-
352349
async def log_usage():
353-
summary = usage_collector.get_summary()
354-
logger.info(f"Usage: {summary}")
350+
logger.info(f"Usage: {session.usage}")
355351

356352
ctx.add_shutdown_callback(log_usage)
357353

examples/voice_agents/basic_agent.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
metrics,
1616
room_io,
1717
)
18+
from livekit.agents.beta.tools import EndCallTool
1819
from livekit.agents.llm import function_tool
1920
from livekit.plugins import silero
2021
from livekit.plugins.turn_detector.multilingual import MultilingualModel
@@ -35,6 +36,7 @@ def __init__(self) -> None:
3536
"do not use emojis, asterisks, markdown, or other special characters in your responses."
3637
"You are curious and friendly, and have a sense of humor."
3738
"you will speak english to the user",
39+
tools=[EndCallTool()],
3840
)
3941

4042
async def on_enter(self) -> None:
@@ -106,16 +108,12 @@ async def entrypoint(ctx: JobContext) -> None:
106108
)
107109

108110
# log metrics as they are emitted, and total usage after session is over
109-
usage_collector = metrics.UsageCollector()
110-
111111
@session.on("metrics_collected")
112112
def _on_metrics_collected(ev: MetricsCollectedEvent) -> None:
113113
metrics.log_metrics(ev.metrics)
114-
usage_collector.collect(ev.metrics)
115114

116-
async def log_usage() -> None:
117-
summary = usage_collector.get_summary()
118-
logger.info(f"Usage: {summary}")
115+
async def log_usage():
116+
logger.info(f"Usage: {session.usage}")
119117

120118
# shutdown callbacks are triggered when the session is over
121119
ctx.add_shutdown_callback(log_usage)

examples/voice_agents/multi_agent.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,16 +152,12 @@ async def entrypoint(ctx: JobContext):
152152
)
153153

154154
# log metrics as they are emitted, and total usage after session is over
155-
usage_collector = metrics.UsageCollector()
156-
157155
@session.on("metrics_collected")
158156
def _on_metrics_collected(ev: MetricsCollectedEvent):
159157
metrics.log_metrics(ev.metrics)
160-
usage_collector.collect(ev.metrics)
161158

162159
async def log_usage():
163-
summary = usage_collector.get_summary()
164-
logger.info(f"Usage: {summary}")
160+
logger.info(f"Usage: {session.usage}")
165161

166162
ctx.add_shutdown_callback(log_usage)
167163

examples/voice_agents/zapier_mcp_integration.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,9 @@ async def entrypoint(ctx: JobContext):
6161
participant = await ctx.wait_for_participant()
6262
logger.info(f"starting voice assistant for participant {participant.identity}")
6363

64-
usage_collector = metrics.UsageCollector()
65-
66-
# Log metrics and collect usage data
64+
# Log metrics as they are collected
6765
def on_metrics_collected(agent_metrics: metrics.AgentMetrics):
6866
metrics.log_metrics(agent_metrics)
69-
usage_collector.collect(agent_metrics)
7067

7168
# Get MCP server URL from environment variable
7269
zapier_mcp_server = os.getenv("ZAPIER_MCP_SERVER")

livekit-agents/livekit/agents/job.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ def make_session_report(self, session: AgentSession | None = None) -> SessionRep
274274
started_at=session._started_at,
275275
events=session._recorded_events,
276276
chat_history=session.history.copy(),
277+
usage=session.usage,
277278
)
278279

279280
if recorder_io:
Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import dataclasses
12
from copy import deepcopy
23
from dataclasses import dataclass
34

@@ -6,87 +7,139 @@
67

78
@dataclass
89
class UsageSummary:
9-
llm_prompt_tokens: int = 0
10-
llm_prompt_cached_tokens: int = 0
10+
"""Usage summary for a specific model/provider combination."""
11+
12+
provider: str = ""
13+
"""The provider name (e.g., 'openai', 'deepgram', 'elevenlabs')."""
14+
model: str = ""
15+
"""The model name (e.g., 'gpt-4o', 'nova-2', 'eleven_turbo_v2')."""
16+
17+
llm_input_tokens: int = 0
18+
llm_input_cached_tokens: int = 0
1119
llm_input_audio_tokens: int = 0
1220
llm_input_cached_audio_tokens: int = 0
1321
llm_input_text_tokens: int = 0
1422
llm_input_cached_text_tokens: int = 0
1523
llm_input_image_tokens: int = 0
1624
llm_input_cached_image_tokens: int = 0
17-
llm_completion_tokens: int = 0
25+
llm_output_tokens: int = 0
1826
llm_output_audio_tokens: int = 0
1927
llm_output_image_tokens: int = 0
2028
llm_output_text_tokens: int = 0
2129
tts_characters_count: int = 0
2230
tts_audio_duration: float = 0.0
2331
stt_audio_duration: float = 0.0
2432

25-
# properties for naming consistency: prompt = input, completion = output
33+
# backwards-compatible property aliases
34+
@property
35+
def llm_prompt_tokens(self) -> int:
36+
return self.llm_input_tokens
37+
38+
@llm_prompt_tokens.setter
39+
def llm_prompt_tokens(self, value: int) -> None:
40+
self.llm_input_tokens = value
41+
2642
@property
27-
def llm_input_tokens(self) -> int:
28-
return self.llm_prompt_tokens
43+
def llm_prompt_cached_tokens(self) -> int:
44+
return self.llm_input_cached_tokens
2945

30-
@llm_input_tokens.setter
31-
def llm_input_tokens(self, value: int) -> None:
32-
self.llm_prompt_tokens = value
46+
@llm_prompt_cached_tokens.setter
47+
def llm_prompt_cached_tokens(self, value: int) -> None:
48+
self.llm_input_cached_tokens = value
3349

3450
@property
35-
def llm_output_tokens(self) -> int:
36-
return self.llm_completion_tokens
51+
def llm_completion_tokens(self) -> int:
52+
return self.llm_output_tokens
3753

38-
@llm_output_tokens.setter
39-
def llm_output_tokens(self, value: int) -> None:
40-
self.llm_completion_tokens = value
54+
@llm_completion_tokens.setter
55+
def llm_completion_tokens(self, value: int) -> None:
56+
self.llm_output_tokens = value
57+
58+
def to_dict(self) -> dict:
59+
"""Returns a dict with only non-zero/non-empty values."""
60+
return {k: v for k, v in dataclasses.asdict(self).items() if v}
61+
62+
def __repr__(self) -> str:
63+
items = ", ".join(f"{k}={v!r}" for k, v in self.to_dict().items())
64+
return f"UsageSummary({items})"
4165

4266

4367
class UsageCollector:
68+
"""Collects and aggregates usage metrics per model/provider combination."""
69+
4470
def __init__(self) -> None:
45-
self._summary = UsageSummary()
71+
self._summaries: dict[tuple[str, str], UsageSummary] = {}
4672

4773
def __call__(self, metrics: AgentMetrics) -> None:
4874
self.collect(metrics)
4975

76+
def _get_summary(self, provider: str, model: str) -> UsageSummary:
77+
"""Get or create a UsageSummary for the given provider/model combination."""
78+
key = (provider, model)
79+
if key not in self._summaries:
80+
self._summaries[key] = UsageSummary(provider=provider, model=model)
81+
return self._summaries[key]
82+
83+
def _extract_provider_model(
84+
self, metrics: LLMMetrics | STTMetrics | TTSMetrics | RealtimeModelMetrics
85+
) -> tuple[str, str]:
86+
"""Extract provider and model from metrics metadata."""
87+
provider = ""
88+
model = ""
89+
if metrics.metadata:
90+
provider = metrics.metadata.model_provider or ""
91+
model = metrics.metadata.model_name or ""
92+
return provider, model
93+
5094
def collect(self, metrics: AgentMetrics) -> None:
5195
if isinstance(metrics, LLMMetrics):
52-
self._summary.llm_prompt_tokens += metrics.prompt_tokens
53-
self._summary.llm_prompt_cached_tokens += metrics.prompt_cached_tokens
54-
self._summary.llm_completion_tokens += metrics.completion_tokens
96+
provider, model = self._extract_provider_model(metrics)
97+
summary = self._get_summary(provider, model)
98+
summary.llm_input_tokens += metrics.prompt_tokens
99+
summary.llm_input_cached_tokens += metrics.prompt_cached_tokens
100+
summary.llm_output_tokens += metrics.completion_tokens
55101

56102
elif isinstance(metrics, RealtimeModelMetrics):
57-
self._summary.llm_prompt_tokens += metrics.input_tokens
58-
self._summary.llm_prompt_cached_tokens += metrics.input_token_details.cached_tokens
103+
provider, model = self._extract_provider_model(metrics)
104+
summary = self._get_summary(provider, model)
105+
summary.llm_input_tokens += metrics.input_tokens
106+
summary.llm_input_cached_tokens += metrics.input_token_details.cached_tokens
59107

60-
self._summary.llm_input_text_tokens += metrics.input_token_details.text_tokens
61-
self._summary.llm_input_cached_text_tokens += (
108+
summary.llm_input_text_tokens += metrics.input_token_details.text_tokens
109+
summary.llm_input_cached_text_tokens += (
62110
metrics.input_token_details.cached_tokens_details.text_tokens
63111
if metrics.input_token_details.cached_tokens_details
64112
else 0
65113
)
66-
self._summary.llm_input_image_tokens += metrics.input_token_details.image_tokens
67-
self._summary.llm_input_cached_image_tokens += (
114+
summary.llm_input_image_tokens += metrics.input_token_details.image_tokens
115+
summary.llm_input_cached_image_tokens += (
68116
metrics.input_token_details.cached_tokens_details.image_tokens
69117
if metrics.input_token_details.cached_tokens_details
70118
else 0
71119
)
72-
self._summary.llm_input_audio_tokens += metrics.input_token_details.audio_tokens
73-
self._summary.llm_input_cached_audio_tokens += (
120+
summary.llm_input_audio_tokens += metrics.input_token_details.audio_tokens
121+
summary.llm_input_cached_audio_tokens += (
74122
metrics.input_token_details.cached_tokens_details.audio_tokens
75123
if metrics.input_token_details.cached_tokens_details
76124
else 0
77125
)
78126

79-
self._summary.llm_output_text_tokens += metrics.output_token_details.text_tokens
80-
self._summary.llm_output_image_tokens += metrics.output_token_details.image_tokens
81-
self._summary.llm_output_audio_tokens += metrics.output_token_details.audio_tokens
82-
self._summary.llm_completion_tokens += metrics.output_tokens
127+
summary.llm_output_text_tokens += metrics.output_token_details.text_tokens
128+
summary.llm_output_image_tokens += metrics.output_token_details.image_tokens
129+
summary.llm_output_audio_tokens += metrics.output_token_details.audio_tokens
130+
summary.llm_output_tokens += metrics.output_tokens
83131

84132
elif isinstance(metrics, TTSMetrics):
85-
self._summary.tts_characters_count += metrics.characters_count
86-
self._summary.tts_audio_duration += metrics.audio_duration
133+
provider, model = self._extract_provider_model(metrics)
134+
summary = self._get_summary(provider, model)
135+
summary.tts_characters_count += metrics.characters_count
136+
summary.tts_audio_duration += metrics.audio_duration
87137

88138
elif isinstance(metrics, STTMetrics):
89-
self._summary.stt_audio_duration += metrics.audio_duration
139+
provider, model = self._extract_provider_model(metrics)
140+
summary = self._get_summary(provider, model)
141+
summary.stt_audio_duration += metrics.audio_duration
90142

91-
def get_summary(self) -> UsageSummary:
92-
return deepcopy(self._summary)
143+
def get_summary(self) -> list[UsageSummary]:
144+
"""Returns a list of usage summaries, one per model/provider combination."""
145+
return [deepcopy(s) for s in self._summaries.values()]

livekit-agents/livekit/agents/telemetry/trace_types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,16 @@
5757
ATTR_TTS_METRICS = "lk.tts_metrics"
5858
ATTR_REALTIME_MODEL_METRICS = "lk.realtime_model_metrics"
5959

60+
# latency span attributes
61+
ATTR_LLM_NODE_TTFT = "lk.ttft"
62+
ATTR_TTS_NODE_TTFB = "lk.ttfb"
63+
ATTR_E2E_LATENCY = "lk.e2e_latency"
64+
6065
# OpenTelemetry GenAI attributes
6166
# OpenTelemetry specification: https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/
6267
ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
6368
ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
69+
ATTR_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"
6470
ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
6571
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
6672

0 commit comments

Comments
 (0)