GPT-Live: neither the voice model's seconds nor the backend model's tokens reach a span
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 48/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 活発
- 技術スタック
- python
調査の方向性
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.
索引モデルが issue の本文から書いたものです。
説明
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"},
- 主要言語
- Python
- スター
- 14.6k
- フォーク
- 3.9k
- 平均マージ
- 3日
- マージ済み PR(30日)
- 262
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
livekit/agents のほかの issue
-
enhancement
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
livekit/agents#8366 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 78/100
livekit/agents#8284 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
ci: unit tests pin Python 3.12 while the package advertises 3.14 support再び着手できるかも @huong8373tt-beep が 8 日前に担当しましたが、オープン中のプルリクエストはありません。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
livekit/agents#8231 · コメント 3 件 ·
メンテナーはふだん 1 日以内に返信
-
Assistant markup is stripped from chat history when expressive mode is off対応中かも @ShivamSharma43 が 30 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
livekit/agents#7647 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
似ている issue
-
binding:python bug
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
The Browser tab fails with a Wayland error when Open Terminal runs from a Wayland desktop sessionオープン
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
open-webui/open-terminal#210 ·
-
triage/confirmed
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
agentscope-ai/agentscope#3807 ·
メンテナーはふだん 2 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
NousResearch/hermes-agent#150542 ·
メンテナーはふだん 1 日以内に返信
-
難易度 1/5 1時間未満 初心者へのやさしさ 91/100
triton-inference-server/server#9069 ·
メンテナーはふだん 1 日以内に返信