GPT-Live: neither the voice model's seconds nor the backend model's tokens reach a span
I maintainer di solito rispondono entro 1 giorno
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 48/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- python
- Ambito
- backend-api-design, observability, testing
Direzione di ricerca
Start with AgentActivity in livekit/agents/voice/agent_activity.py and the metric recording helpers in livekit/agents/telemetry/utils.py; trace how realtime metrics are matched to spans and how LLMStream records llm_request spans. The proposed test is in tests/test_gpt_live_model.py, and tests/trace_schema.py defines the expected parent relationship. Done means voice usage and backend token metrics appear on spans under the session with the specified attributes.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Environment
- livekit-agents 1.8.10 (also unchanged on
mainas of 652eee4ea) GPTLiveModel(delegation="responses") run throughDuplexRealtimeAdapter- Traces exported over OTLP with
set_tracer_providerto a backend that prices generations fromgen_ai.*attributes (Langfuse here)
Reproduction
Run any call with GPT-Live and a tracer provider:
from livekit.agents import AgentSession
from livekit.agents.telemetry import set_tracer_provider
from livekit.plugins.openai.realtime import GPTLiveModel
set_tracer_provider(provider) # any SDK TracerProvider with an exporter
session = AgentSession(llm=GPTLiveModel(responses_options={"model": "gpt-5.6-luna"}))
await session.start(agent=MyAgent(), room=ctx.room)
Talk for a few minutes, with at least one turn that makes the backend run a tool.
Observed
- Every
realtime_inferencespan carries usage 0. On one real call we had 31 of them, all at zero. The session seconds GPT-Live reports about once a minute (session.usage.updated, andsession.closedat the end) appear on no span. - The backend Responses model has no span at all. Its tokens are reported as
LLMMetricsand reach the usage collector, but no trace shows them. - A backend that prices from traces therefore shows zero cost for the voice model and nothing for the backend model.
Expected
- Each usage update of the voice model is on a span, under the session, with the voice model's name and the seconds in
lk.realtime_model_metrics. - Each backend response is an
llm_requestspan with the backend model's name, its tokens (input, cached, cache write, output, reasoning) and its response id, using the samegen_ai.*attributes as a pipelinellm_request.
Cause
_handle_usageemits aRealtimeModelMetricswithrequest_id=self._session_id. The usage is the session's, cumulative, not one response's._on_metrics_collectedputs realtime metrics only on the span found in_realtime_spansunder that request id. Those spans are keyed by the generation'sresponse_id, which for a duplex model is the adapter's own burst id (item_…). The duplex model never sees that id, so the lookup can never match, and the metrics are forwarded as they are. This holds for anyDuplexModel, not only GPT-Live.- The backend's tokens are emitted as
LLMMetricsonresponse.completed. Anllm_requestspan is opened only byLLMStream, which a realtime session never runs, so nothing records them.
Proposed fix
The fix lives in AgentActivity, which owns the session's root span context:
- The realtime session's
metrics_collectedgoes to a small handler of its own,_on_realtime_metrics_collected, which then forwards to_on_metrics_collectedas before. - An
LLMMetricscoming from a realtime session gets anllm_requestspan under the session. A newtelemetry.utils.record_llm_metrics, next torecord_realtime_metrics, fills it with the existinggen_aihelpers: operationchat, provider, request and response model, usage, response id, andlk.llm_metrics. - A
RealtimeModelMetricsfrom a duplex model gets arealtime_metricsspan of its own under the session, filled byrecord_realtime_metrics. That is the span name already used for realtime metrics that arrive after their inference span. - Both spans are parented explicitly to the session's root context, not to whatever context the plugin's task happens to run in.
tests/trace_schema.pyallowsagent_sessionas their parent.
Known limits:
- Each duplex usage report becomes a span, including those with no usage: the delta of a
session.closedthat adds no seconds, and the_report_connection_acquiredevent of a reconnect. They cost nothing, but they show up as empty generations. - The backend span is instantaneous: GPT-Live reports
duration=0andttft=-1for backend responses, so a trace shows no latency for them. - A
DuplexRealtimeAdapterwrapped in aRealtimeModelFallbackAdapteris not recognised as duplex, so its usage still gets no span. The sameisinstancecheck is already used elsewhere inAgentActivity.
The diff is against main at 652eee4ea. It includes a test that drives GPTLiveModel through an AgentSession with an in-memory exporter.
diff --git a/livekit-agents/livekit/agents/telemetry/utils.py b/livekit-agents/livekit/agents/telemetry/utils.py
index 6370d27bf..3bf552886 100644
--- a/livekit-agents/livekit/agents/telemetry/utils.py
+++ b/livekit-agents/livekit/agents/telemetry/utils.py
@@ -12,7 +12,7 @@ from ..types import ATTRIBUTE_REDACTION_ENABLED, NOT_GIVEN, NotGivenOr
from . import trace_types
if TYPE_CHECKING:
- from ..metrics import RealtimeModelMetrics
+ from ..metrics import LLMMetrics, RealtimeModelMetrics
REDACTED_EXCEPTION_MESSAGE = "exception details redacted"
@@ -169,3 +169,23 @@ def record_realtime_metrics(span: trace.Span, ev: RealtimeModelMetrics) -> None:
with tracer.use_span(span):
with tracer.start_span("realtime_metrics") as child:
child.set_attributes(attrs)
+
+
+def record_llm_metrics(span: trace.Span, ev: LLMMetrics) -> None:
+ """Record a text model's usage on its ``llm_request`` span, when no ``LLMStream`` ran it.
+
+ A realtime model's own text model (GPT-Live's backend Responses model) reports only these.
+ """
+ from .gen_ai import set_request_attributes, set_response_attributes, set_usage_attributes
+
+ model_name = ev.metadata.model_name if ev.metadata else None
+ span.set_attribute(trace_types.ATTR_LLM_METRICS, ev.model_dump_json())
+ set_request_attributes(
+ span,
+ operation=trace_types.GenAIOperationName.CHAT,
+ provider=ev.metadata.model_provider if ev.metadata else None,
+ model=model_name,
+ output_type=trace_types.GenAIOutputType.TEXT,
+ )
+ set_usage_attributes(span, ev)
+ set_response_attributes(span, response_id=ev.request_id or None, model=model_name)
diff --git a/livekit-agents/livekit/agents/voice/agent_activity.py b/livekit-agents/livekit/agents/voice/agent_activity.py
index d5630fdad..62dea908d 100644
--- a/livekit-agents/livekit/agents/voice/agent_activity.py
+++ b/livekit-agents/livekit/agents/voice/agent_activity.py
@@ -1166,7 +1166,7 @@ class AgentActivity(RecognitionHooks):
"input_audio_transcription_completed",
self._on_input_audio_transcription_completed,
)
- self._rt_session.off("metrics_collected", self._on_metrics_collected)
+ self._rt_session.off("metrics_collected", self._on_realtime_metrics_collected)
self._rt_session.off("remote_item_added", self._on_remote_item_added)
self._rt_session.off("error", self._on_error)
if isinstance(self._rt_session, _FallbackRealtimeSession):
@@ -1287,7 +1287,7 @@ class AgentActivity(RecognitionHooks):
"input_audio_transcription_completed",
self._on_input_audio_transcription_completed,
)
- self._rt_session.on("metrics_collected", self._on_metrics_collected)
+ self._rt_session.on("metrics_collected", self._on_realtime_metrics_collected)
self._rt_session.on("remote_item_added", self._on_remote_item_added)
self._rt_session.on("error", self._on_error)
@@ -1646,7 +1646,7 @@ class AgentActivity(RecognitionHooks):
if self._rt_session is not None:
await self._rt_session.aclose()
# after aclose, so a model that reports its final usage while closing is still counted
- self._rt_session.off("metrics_collected", self._on_metrics_collected)
+ self._rt_session.off("metrics_collected", self._on_realtime_metrics_collected)
if self._realtime_spans is not None:
self._realtime_spans.clear()
@@ -2221,6 +2221,27 @@ class AgentActivity(RecognitionHooks):
SessionUsageUpdatedEvent(usage=self._session.usage),
)
+ def _on_realtime_metrics_collected(
+ self,
+ ev: STTMetrics | TTSMetrics | VADMetrics | LLMMetrics | RealtimeModelMetrics,
+ ) -> None:
+ # what no inference span can carry gets a span of its own, under the session
+ if isinstance(ev, LLMMetrics):
+ # a realtime model's own text model (GPT-Live's backend) runs no LLMStream, which
+ # is what opens an llm_request span
+ with tracer.start_span("llm_request", context=self._session._root_span_context) as span:
+ trace_utils.record_llm_metrics(span, ev)
+ elif isinstance(ev, RealtimeModelMetrics) and isinstance(
+ self.llm, llm.DuplexRealtimeAdapter
+ ):
+ # a duplex model reports usage for its session: the realtime spans are keyed by the
+ # adapter's own speech bursts, whose ids the model never sees, so none would match
+ with tracer.start_span(
+ "realtime_metrics", context=self._session._root_span_context
+ ) as span:
+ trace_utils.record_realtime_metrics(span, ev)
+ self._on_metrics_collected(ev)
+
def _on_remote_item_added(self, ev: llm.RemoteItemAddedEvent) -> None:
# add the remote item to the local chat context as a placeholder
local_chat_ctx = self._agent._chat_ctx
diff --git a/tests/test_gpt_live_model.py b/tests/test_gpt_live_model.py
index 6d68ef7eb..f47d854ae 100644
--- a/tests/test_gpt_live_model.py
+++ b/tests/test_gpt_live_model.py
@@ -3,10 +3,11 @@ from __future__ import annotations
import asyncio
import base64
import contextlib
+import contextvars
import json
import logging
import time
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Iterator
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any
@@ -16,13 +17,24 @@ import aiohttp
import numpy as np
import pytest
from aiohttp import web
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from livekit import rtc
-from livekit.agents import APIConnectionError, APIConnectOptions, APIError, inference, llm
+from livekit.agents import (
+ Agent,
+ AgentSession,
+ APIConnectionError,
+ APIConnectOptions,
+ APIError,
+ inference,
+ llm,
+)
from livekit.agents.inference.realtime import gpt_live as inference_gpt_live
from livekit.agents.llm._realtime import gpt_live as gpt_live_model
from livekit.agents.metrics import LLMMetrics, RealtimeModelMetrics
-from livekit.agents.telemetry import pii
+from livekit.agents.telemetry import pii, set_tracer_provider, trace_types, tracer
from livekit.plugins.openai.realtime.gpt_live_model import (
GPTLiveDelegation,
GPTLiveModel,
@@ -31,6 +43,8 @@ from livekit.plugins.openai.realtime.gpt_live_model import (
from livekit.plugins.openai.realtime.gpt_live_types import ServiceTier
from livekit.plugins.openai.tools import WebSearch
+from .trace_schema import assert_trace_well_formed
+
pytestmark = pytest.mark.unit
@@ -1602,6 +1616,83 @@ async def test_a_delegated_model_is_billed_under_its_own_name(
await model.aclose()
[email protected]
+def span_exporter() -> Iterator[InMemorySpanExporter]:
+ original_provider = tracer._tracer_provider
+ provider = TracerProvider()
+ exporter = InMemorySpanExporter()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ set_tracer_provider(provider)
+ try:
+ yield exporter
+ finally:
+ set_tracer_provider(original_provider)
+ provider.shutdown()
+
+
[email protected]_concurrent
+async def test_both_models_report_their_usage_on_spans_under_the_session(
+ monkeypatch: pytest.MonkeyPatch, span_exporter: InMemorySpanExporter
+) -> None:
+ """The voice model is billed by the second and the backend by the token: both must reach a
+ span, or a tracing backend cannot price the call. The events are handled in an empty
+ context, as the plugin's own task may run in any: the spans still hang off the session."""
+ ws = _connect_hook(monkeypatch)
+ model = GPTLiveModel(api_key="sk-test")
+ async with AgentSession(llm=model, aec_warmup_duration=None) as session:
+ await session.start(Agent(instructions="Be concise."))
+ for _ in range(100):
+ if ws.session is not None and ws.session.session_id == "live_test":
+ break
+ await asyncio.sleep(0.01)
+ live = ws.session
+ assert live is not None and live.session_id == "live_test"
+
+ for event in (
+ {"type": "session.usage.updated", "usage": {"seconds": 14.0}},
+ _response_event("item_d1", _completed("resp_1")),
+ ):
+ contextvars.Context().run(live._handle_event, event)
+
+ spans = span_exporter.get_finished_spans()
+ (agent_session,) = [s for s in spans if s.name == "agent_session"]
+ usage = [s for s in spans if s.name == "realtime_metrics"]
+ (backend,) = [s for s in spans if s.name == "llm_request"]
+ assert agent_session.context is not None
+
+ # the update carries 14 seconds; the fake service closes the session with no more
+ assert [
+ json.loads(str(s.attributes[trace_types.ATTR_REALTIME_MODEL_METRICS]))["session_duration"]
+ for s in usage
+ ] == [14.0, 0.0]
+ assert [s.parent.span_id if s.parent else None for s in [*usage, backend]] == [
+ agent_session.context.span_id
+ ] * 3
+ assert usage[0].attributes[trace_types.ATTR_GEN_AI_REQUEST_MODEL] == "gpt-live-1"
+ assert {k: v for k, v in backend.attributes.items() if k.startswith("gen_ai.")} == {
+ "gen_ai.operation.name": "chat",
+ "gen_ai.provider.name": "openai",
+ "gen_ai.request.model": "gpt-5.6-sol",
+ "gen_ai.output.type": "text",
+ "gen_ai.response.id": "resp_1",
+ "gen_ai.response.model": "gpt-5.6-sol",
+ "gen_ai.usage.input_tokens": 376,
+ "gen_ai.usage.output_tokens": 18,
+ "gen_ai.usage.cache_read.input_tokens": 100,
+ "gen_ai.usage.input_cached_tokens": 100,
+ "gen_ai.usage.cache_write.input_tokens": 20,
+ "gen_ai.usage.reasoning.output_tokens": 5,
+ "gen_ai.usage.reasoning_tokens": 5,
+ }
+ backend_metrics = json.loads(str(backend.attributes[trace_types.ATTR_LLM_METRICS]))
+ assert (
+ backend_metrics["request_id"],
+ backend_metrics["prompt_tokens"],
+ backend_metrics["metadata"]["model_name"],
+ ) == ("resp_1", 376, "gpt-5.6-sol")
+ assert_trace_well_formed(spans)
+
+
def _user_events(session: GPTLiveSession) -> list[tuple[str, Any]]:
events: list[tuple[str, Any]] = []
for name in (
diff --git a/tests/trace_schema.py b/tests/trace_schema.py
index 52870ee9e..39ad8bf68 100644
--- a/tests/trace_schema.py
+++ b/tests/trace_schema.py
@@ -80,7 +80,8 @@ SPAN_PARENTS: dict[str, frozenset[str | None]] = {
"function_tool": {"agent_turn"},
"agent_speaking": {"agent_turn"},
"realtime_inference": {"agent_turn"},
- "realtime_metrics": {"realtime_inference", "agent_turn"},
+ # a duplex model's usage is the session's, not a turn's (GPT-Live)
+ "realtime_metrics": {"realtime_inference", "agent_turn", "agent_session"},
# -- model requests: under the node that made them, or the feature that owns them.
# An adapter's request span stands in for the provider's and opens no attempt span of
# its own: each wrapped request nests directly under it, with its own attempts:
@@ -92,6 +93,8 @@ SPAN_PARENTS: dict[str, frozenset[str | None]] = {
"keyterm_detection",
"judge_evaluation",
"amd",
+ # a realtime model's own text model (GPT-Live's backend), which no node calls
+ "agent_session",
},
"llm_fallback_adapter": {"llm_node", "keyterm_detection", "judge_evaluation", "amd"},
"llm_request_run": {"llm_request"},
- Lingua principale
- Python
- Stelle
- 14.6k
- Fork
- 3.9k
- Merge medio
- 2g 23h
- PR unite (30g)
- 263
Preparare l'ambiente
Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di livekit/agents
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
livekit/agents#8366 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 78/100
livekit/agents#8284 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
ci: unit tests pin Python 3.12 while the package advertises 3.14 supportForse di nuovo libera @huong8373tt-beep l’ha presa 8 giorni fa e non c’è nessuna pull request aperta. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
livekit/agents#8231 · 3 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
Assistant markup is stripped from chat history when expressive mode is offForse già presa @ShivamSharma43 l’ha presa 29 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
livekit/agents#7647 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
docs: openai.LLM vs openai.responses.LLM for HTTP OpenAI-compat hostsForse di nuovo libera @AbarnaaSree l’ha presa 69 giorni fa e non c’è nessuna pull request aperta. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
livekit/agents#7022 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di livekit/agents
Issue simili
-
documentation good first issue
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
MarketSquare/robotframework-browser#5479 ·
I maintainer di solito rispondono entro 1 giorno
-
Visual COBOL: missing 11.0 and 12.0, and 9.0 date differs from Rocket Software's lifecycle pageAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
endoflife-date/endoflife.date#11561 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
OCA/l10n-brazil#5602 ·
I maintainer di solito rispondono entro 1 giorno
-
documentation review-2.0.0
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
dkuenzel/meshcore-home-gateway#106 ·
I maintainer di solito rispondono entro 1 giorno