GPT-Live: neither the voice model's seconds nor the backend model's tokens reach a span
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 48/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- python
- Lĩnh vực
- backend-api-design, observability, testing
Hướng nghiên cứu
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.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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"},
- Ngôn ngữ chính
- Python
- Star
- 14.6k
- Fork
- 3.9k
- Merge trung bình
- 2 ngày 22 giờ
- Pull request đã merge (30 ngày)
- 255
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của livekit/agents
-
enhancement
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
livekit/agents#8366 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 78/100
livekit/agents#8284 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
ci: unit tests pin Python 3.12 while the package advertises 3.14 supportCó thể làm lại được @huong8373tt-beep đã nhận 10 ngày trước và không có pull request nào đang mở. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
livekit/agents#8231 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Assistant markup is stripped from chat history when expressive mode is offCó thể đã có người làm @ShivamSharma43 đã nhận 31 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
livekit/agents#7647 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của livekit/agents
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
huggingface/accelerate#4511 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
pylint-dev/pylint#11791 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Rasterio granule reads never retry, so one 503 (Service Unavailable) fails the whole requestCó thể đã có người làm @chuckwondo đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
developmentseed/titiler-cmr#305 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
community enhancement
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày