Files
2026-07-13 13:39:38 +08:00

2206 lines
93 KiB
Python

from __future__ import annotations
import asyncio
import base64
import contextlib
import copy
import json
import os
import time
import weakref
from collections.abc import Iterator
from dataclasses import dataclass, replace
from typing import Any, Literal, overload
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import aiohttp
from pydantic import BaseModel, ValidationError
from livekit import rtc
from livekit.agents import APIConnectionError, APIError, io, llm, utils
from livekit.agents.metrics import RealtimeModelMetrics
from livekit.agents.metrics.base import Metadata
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
APIConnectOptions,
NotGivenOr,
)
from livekit.agents.utils import is_given
from livekit.agents.voice.generation import remove_instructions
from openai.types import realtime
from openai.types.beta.realtime.session import (
InputAudioNoiseReduction,
InputAudioTranscription,
TurnDetection,
)
from openai.types.beta.realtime.session_update_event import (
Session as AzureSession,
SessionInputAudioNoiseReduction as AzureNoiseReduction,
SessionInputAudioTranscription as AzureInputAudioTranscription,
SessionTurnDetection as AzureTurnDetection,
SessionUpdateEvent as AzureSessionUpdateEvent,
)
from openai.types.realtime import (
AudioTranscription,
ConversationItemAdded,
ConversationItemCreateEvent,
ConversationItemDeletedEvent,
ConversationItemDeleteEvent,
ConversationItemInputAudioTranscriptionCompletedEvent,
ConversationItemInputAudioTranscriptionDeltaEvent,
ConversationItemInputAudioTranscriptionFailedEvent,
ConversationItemTruncateEvent,
InputAudioBufferAppendEvent,
InputAudioBufferClearEvent,
InputAudioBufferCommitEvent,
InputAudioBufferSpeechStartedEvent,
InputAudioBufferSpeechStoppedEvent,
NoiseReductionType,
RealtimeAudioConfig,
RealtimeAudioConfigInput,
RealtimeAudioConfigOutput,
RealtimeAudioInputTurnDetection,
RealtimeClientEvent,
RealtimeConversationItemFunctionCall,
RealtimeErrorEvent,
RealtimeFunctionTool,
RealtimeReasoning,
RealtimeResponseCreateParams,
RealtimeSessionCreateRequest,
ResponseAudioDeltaEvent,
ResponseAudioDoneEvent,
ResponseCancelEvent,
ResponseContentPartAddedEvent,
ResponseCreatedEvent,
ResponseCreateEvent,
ResponseDoneEvent,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
ResponseTextDeltaEvent,
ResponseTextDoneEvent,
SessionUpdateEvent,
)
from openai.types.realtime.realtime_audio_config_input import NoiseReduction
from openai.types.realtime.realtime_session_create_response import (
Tracing,
)
from openai.types.realtime.realtime_truncation import RealtimeTruncation
from ..log import logger
from ..models import RealtimeModels
from .utils import (
AZURE_DEFAULT_INPUT_AUDIO_TRANSCRIPTION,
AZURE_DEFAULT_TURN_DETECTION,
DEFAULT_MAX_RESPONSE_OUTPUT_TOKENS,
DEFAULT_MAX_SESSION_DURATION,
calculate_confidence_from_logprobs,
livekit_item_to_openai_item,
openai_item_to_livekit_item,
to_audio_transcription,
to_noise_reduction,
to_oai_tool_choice,
to_turn_detection,
)
# When a response is created with the OpenAI Realtime API, those events are sent in this order:
# 1. response.created (contains resp_id)
# 2. response.output_item.added (contains item_id)
# 3. conversation.item.added
# 4. response.content_part.added (type audio/text)
# 5. response.output_audio_transcript.delta (x2, x3, x4, etc)
# 6. response.output_audio.delta (x2, x3, x4, etc)
# 7. response.content_part.done
# 8. response.output_item.done (contains item_status: "completed/incomplete")
# 9. response.done (contains status_details for cancelled/failed/turn_detected/content_filter)
#
# Ourcode assumes a response will generate only one item with type "message"
SAMPLE_RATE = 24000
NUM_CHANNELS = 1
OPENAI_BASE_URL = "https://api.openai.com/v1"
DEFAULT_VOICE = "marin"
lk_oai_debug = int(os.getenv("LK_OPENAI_DEBUG", 0))
# Azure OpenAI Realtime API uses old-style (beta) event names.
# This mapping normalizes them to the current OpenAI GA event names
# so the handler code only deals with one set of names.
_AZURE_EVENT_MAPPING: dict[str, str] = {
"response.text.delta": "response.output_text.delta",
"response.text.done": "response.output_text.done",
"response.audio_transcript.delta": "response.output_audio_transcript.delta",
"response.audio_transcript.done": "response.output_audio_transcript.done",
"response.audio.delta": "response.output_audio.delta",
"response.audio.done": "response.output_audio.done",
"conversation.item.created": "conversation.item.added",
}
def _convert_model(obj: BaseModel, target_cls: type[BaseModel]) -> BaseModel:
"""Convert a Pydantic model to a different type with the same field structure."""
return target_cls.model_validate(
obj.model_dump(by_alias=True, exclude_unset=True, exclude_defaults=True)
)
def _oai_session_to_azure(session: RealtimeSessionCreateRequest) -> AzureSession:
"""Convert a new-style OpenAI RealtimeSessionCreateRequest to Azure's old-style flat format.
Azure OpenAI Realtime API doesn't support the newer nested `audio` config or
`output_modalities` / `type` fields. Instead it uses flat top-level fields like
`modalities`, `voice`, `input_audio_format`, `turn_detection`, etc.
"""
mapped: dict[str, Any] = {}
# Flatten output_modalities → modalities (Azure uses the old field name)
# Azure requires ["audio", "text"] when audio is enabled — ["audio"] alone is not allowed
if session.output_modalities is not None:
if "audio" in session.output_modalities:
mapped["modalities"] = ["audio", "text"]
else:
mapped["modalities"] = list(session.output_modalities)
mapped["input_audio_format"] = "pcm16"
mapped["output_audio_format"] = "pcm16"
# Flatten nested audio config to top-level fields, converting types
if session.audio is not None:
inp = session.audio.input
out = session.audio.output
if inp is not None:
if inp.noise_reduction is not None:
mapped["input_audio_noise_reduction"] = _convert_model(
inp.noise_reduction, AzureNoiseReduction
)
if inp.transcription is not None:
mapped["input_audio_transcription"] = _convert_model(
inp.transcription, AzureInputAudioTranscription
)
if inp.turn_detection is not None:
mapped["turn_detection"] = _convert_model(inp.turn_detection, AzureTurnDetection)
if out is not None:
if out.voice is not None:
mapped["voice"] = out.voice
if out.speed is not None:
mapped["speed"] = out.speed
# Fields that map 1:1
if session.model is not None:
mapped["model"] = session.model
if session.instructions is not None:
mapped["instructions"] = session.instructions
if session.tools is not None:
mapped["tools"] = session.tools
if session.tool_choice is not None:
mapped["tool_choice"] = session.tool_choice
if session.max_output_tokens is not None:
mapped["max_response_output_tokens"] = session.max_output_tokens
if session.tracing is not None:
mapped["tracing"] = session.tracing
if session.reasoning is not None:
mapped["reasoning"] = session.reasoning
return AzureSession.model_construct(**mapped)
def _normalize_azure_client_event(event: dict[str, Any]) -> None:
"""In-place normalization of client event dicts for legacy Azure compatibility.
The legacy Azure Realtime API uses "text" for assistant content parts,
while the newer OpenAI API uses "output_text".
"""
item = event.get("item")
if item is None:
return
for content_part in item.get("content", ()):
if content_part.get("type") == "output_text":
content_part["type"] = "text"
@dataclass
class _RealtimeOptions:
model: str
voice: str
tool_choice: llm.ToolChoice | None
input_audio_transcription: AudioTranscription | None
input_audio_noise_reduction: NoiseReduction | None
turn_detection: RealtimeAudioInputTurnDetection | None
max_response_output_tokens: int | Literal["inf"] | None
tracing: Tracing | None
truncation: RealtimeTruncation | None
reasoning: RealtimeReasoning | None
api_key: str | None
base_url: str
is_azure: bool
azure_deployment: str | None
entra_token: str | None
api_version: str | None
modalities: list[Literal["text", "audio"]]
max_session_duration: float | None
"""reset the connection after this many seconds if provided"""
conn_options: APIConnectOptions
speed: float = 1.0
@dataclass
class _MessageGeneration:
message_id: str
text_ch: utils.aio.Chan[str]
audio_ch: utils.aio.Chan[rtc.AudioFrame]
modalities: asyncio.Future[list[Literal["text", "audio"]]]
audio_transcript: str = ""
@dataclass
class _ResponseGeneration:
message_ch: utils.aio.Chan[llm.MessageGeneration]
function_ch: utils.aio.Chan[llm.FunctionCall]
messages: dict[str, _MessageGeneration]
_done_fut: asyncio.Future[None]
_created_timestamp: float
"""timestamp when the response was created"""
_first_token_timestamp: float | None = None
"""timestamp when the first token was received"""
def _close(self) -> None:
for msg in self.messages.values():
if not msg.text_ch.closed:
msg.text_ch.close()
if not msg.audio_ch.closed:
msg.audio_ch.close()
self.function_ch.close()
self.message_ch.close()
class _DiscardedGeneration:
"""Marks a response cancelled before it surfaced, so its trailing events are skipped."""
pass
class RealtimeModel(llm.RealtimeModel):
@overload
def __init__(
self,
*,
model: RealtimeModels | str = "gpt-realtime",
voice: str = DEFAULT_VOICE,
modalities: NotGivenOr[list[Literal["text", "audio"]]] = NOT_GIVEN,
input_audio_transcription: NotGivenOr[
AudioTranscription | InputAudioTranscription | None
] = NOT_GIVEN,
input_audio_noise_reduction: NotGivenOr[
NoiseReductionType | NoiseReduction | InputAudioNoiseReduction | None
] = NOT_GIVEN,
turn_detection: NotGivenOr[
RealtimeAudioInputTurnDetection | TurnDetection | None
] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
tracing: NotGivenOr[Tracing | None] = NOT_GIVEN,
truncation: NotGivenOr[RealtimeTruncation | None] = NOT_GIVEN,
reasoning: NotGivenOr[RealtimeReasoning | None] = NOT_GIVEN,
api_key: str | None = None,
base_url: NotGivenOr[str] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
max_session_duration: NotGivenOr[float | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
temperature: NotGivenOr[float] = NOT_GIVEN, # deprecated, unused in v1
) -> None: ...
@overload
def __init__(
self,
*,
azure_deployment: str | None = None,
entra_token: str | None = None,
api_key: str | None = None,
api_version: str | None = None,
base_url: NotGivenOr[str] = NOT_GIVEN,
voice: str = DEFAULT_VOICE,
modalities: NotGivenOr[list[Literal["text", "audio"]]] = NOT_GIVEN,
input_audio_transcription: NotGivenOr[
AudioTranscription | InputAudioTranscription | None
] = NOT_GIVEN,
input_audio_noise_reduction: NotGivenOr[
NoiseReductionType | NoiseReduction | InputAudioNoiseReduction | None
] = NOT_GIVEN,
turn_detection: NotGivenOr[
RealtimeAudioInputTurnDetection | TurnDetection | None
] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
tracing: NotGivenOr[Tracing | None] = NOT_GIVEN,
truncation: NotGivenOr[RealtimeTruncation | None] = NOT_GIVEN,
reasoning: NotGivenOr[RealtimeReasoning | None] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
max_session_duration: NotGivenOr[float | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
temperature: NotGivenOr[float] = NOT_GIVEN, # deprecated, unused in v1
) -> None: ...
def __init__(
self,
*,
model: str = "gpt-realtime",
voice: str = DEFAULT_VOICE,
modalities: NotGivenOr[list[Literal["text", "audio"]]] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
base_url: NotGivenOr[str] = NOT_GIVEN,
input_audio_transcription: NotGivenOr[
AudioTranscription | InputAudioTranscription | None
] = NOT_GIVEN,
input_audio_noise_reduction: NotGivenOr[
NoiseReductionType | NoiseReduction | InputAudioNoiseReduction | None
] = NOT_GIVEN,
turn_detection: NotGivenOr[
RealtimeAudioInputTurnDetection | TurnDetection | None
] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
tracing: NotGivenOr[Tracing | None] = NOT_GIVEN,
truncation: NotGivenOr[RealtimeTruncation | None] = NOT_GIVEN,
reasoning: NotGivenOr[RealtimeReasoning | None] = NOT_GIVEN,
api_key: str | None = None,
http_session: aiohttp.ClientSession | None = None,
azure_deployment: str | None = None,
entra_token: str | None = None,
max_session_duration: NotGivenOr[float | None] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
temperature: NotGivenOr[float] = NOT_GIVEN, # deprecated, unused in v1
**kwargs: Any,
) -> None:
"""
Initialize a Realtime model client for OpenAI or Azure OpenAI.
Args:
model (str): Realtime model name, e.g., "gpt-realtime".
voice (str): Voice used for audio responses. Defaults to "marin".
modalities (list[Literal["text", "audio"]] | NotGiven): Modalities to enable. Defaults to ["text", "audio"] if not provided.
tool_choice (llm.ToolChoice | None | NotGiven): Tool selection policy for responses.
base_url (str | NotGiven): HTTP base URL of the OpenAI/Azure API. If not provided, uses OPENAI_BASE_URL for OpenAI; for Azure, constructed from AZURE_OPENAI_ENDPOINT.
input_audio_transcription (AudioTranscription | None | NotGiven): Options for transcribing input audio.
input_audio_noise_reduction (NoiseReductionType | NoiseReduction | InputAudioNoiseReduction | None | NotGiven): Input audio noise reduction settings.
turn_detection (RealtimeAudioInputTurnDetection | None | NotGiven): Server-side turn-detection options.
speed (float | NotGiven): Audio playback speed multiplier.
tracing (Tracing | None | NotGiven): Tracing configuration for OpenAI Realtime.
truncation (RealtimeTruncation | None | NotGiven): Truncation configuration for OpenAI Realtime.
reasoning (RealtimeReasoning | None | NotGiven): Reasoning config for reasoning-capable models (e.g. ``gpt-realtime-2``), e.g. ``RealtimeReasoning(effort="low")``.
api_key (str | None): OpenAI API key. If None and not using Azure, read from OPENAI_API_KEY.
http_session (aiohttp.ClientSession | None): Optional shared HTTP session.
azure_deployment (str | None): Azure deployment name. Presence of any Azure-specific option enables Azure mode.
entra_token (str | None): Azure Entra token auth (alternative to api_key).
max_session_duration (float | None | NotGiven): Seconds before recycling the connection.
conn_options (APIConnectOptions): Retry/backoff and connection settings.
temperature (float | NotGiven): Deprecated; ignored by Realtime v1.
Raises:
ValueError: If OPENAI_API_KEY is missing in non-Azure mode, or if Azure endpoint cannot be determined when in Azure mode.
Examples:
Basic OpenAI usage:
```python
from livekit.plugins.openai.realtime import RealtimeModel
from openai.types import realtime
model = RealtimeModel(
voice="marin",
modalities=["audio"],
input_audio_transcription=realtime.AudioTranscription(
model="gpt-4o-transcribe",
),
input_audio_noise_reduction="near_field",
turn_detection=realtime.realtime_audio_input_turn_detection.SemanticVad(
type="semantic_vad",
create_response=True,
eagerness="auto",
interrupt_response=True,
),
)
session = AgentSession(llm=model)
```
"""
api_version: str | None = kwargs.get("api_version") or os.getenv("OPENAI_API_VERSION")
if kwargs.get("api_version"):
logger.warning(
"The `api_version` parameter is deprecated and will be removed on April 30, 2026."
)
elif os.getenv("OPENAI_API_VERSION"):
logger.warning(
"The OPENAI_API_VERSION environment variable is deprecated and will be removed "
"on April 30, 2026."
)
modalities = modalities if is_given(modalities) else ["text", "audio"]
super().__init__(
capabilities=llm.RealtimeCapabilities(
message_truncation=True,
turn_detection=turn_detection is not None,
user_transcription=input_audio_transcription is not None,
auto_tool_reply_generation=False,
audio_output="audio" in modalities,
manual_function_calls=True,
mutable_chat_context=True,
mutable_instructions=True,
mutable_tools=True,
per_response_tool_choice=True,
)
)
is_azure = (
api_version is not None or entra_token is not None or azure_deployment is not None
)
api_key = api_key or os.environ.get("OPENAI_API_KEY")
if api_key is None and not is_azure:
raise ValueError(
"The api_key client option must be set either by passing api_key "
"to the client or by setting the OPENAI_API_KEY environment variable"
)
if is_given(base_url):
base_url_val = base_url
else:
if is_azure:
azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
if azure_endpoint is None:
raise ValueError(
"Missing Azure endpoint. Please pass base_url "
"or set AZURE_OPENAI_ENDPOINT environment variable."
)
base_url_val = f"{azure_endpoint.rstrip('/')}/openai"
else:
base_url_val = os.getenv("OPENAI_BASE_URL", OPENAI_BASE_URL)
self._opts = _RealtimeOptions(
model=model,
voice=voice,
tool_choice=tool_choice or None,
modalities=modalities,
input_audio_transcription=to_audio_transcription(input_audio_transcription),
input_audio_noise_reduction=to_noise_reduction(input_audio_noise_reduction),
turn_detection=to_turn_detection(turn_detection),
api_key=api_key,
base_url=base_url_val,
is_azure=is_azure,
azure_deployment=azure_deployment,
entra_token=entra_token,
api_version=api_version,
max_response_output_tokens=DEFAULT_MAX_RESPONSE_OUTPUT_TOKENS, # type: ignore
speed=speed if is_given(speed) else 1.0,
tracing=tracing if is_given(tracing) else None,
truncation=truncation if is_given(truncation) else None,
reasoning=reasoning if is_given(reasoning) else None,
max_session_duration=max_session_duration
if is_given(max_session_duration)
else DEFAULT_MAX_SESSION_DURATION,
conn_options=conn_options,
)
self._http_session = http_session
self._http_session_owned = False
self._sessions = weakref.WeakSet[RealtimeSession]()
self._provider_label = "OpenAI Realtime API"
@property
def model(self) -> str:
return self._opts.model
@property
def provider(self) -> str:
from urllib.parse import urlparse
return urlparse(self._opts.base_url).netloc
@classmethod
def with_azure(
cls,
*,
azure_deployment: str,
azure_endpoint: str | None = None,
api_key: str | None = None,
entra_token: str | None = None,
base_url: str | None = None,
voice: str = DEFAULT_VOICE,
modalities: NotGivenOr[list[Literal["text", "audio"]]] = NOT_GIVEN,
input_audio_transcription: NotGivenOr[
AudioTranscription | InputAudioTranscription | None
] = NOT_GIVEN,
input_audio_noise_reduction: NoiseReductionType | InputAudioNoiseReduction | None = None,
turn_detection: NotGivenOr[
RealtimeAudioInputTurnDetection | TurnDetection | None
] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
tracing: NotGivenOr[Tracing | None] = NOT_GIVEN,
reasoning: NotGivenOr[RealtimeReasoning | None] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
max_session_duration: NotGivenOr[float | None] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN, # deprecated, unused in v1
**kwargs: Any,
) -> RealtimeModel:
"""
Create a RealtimeModel configured for Azure OpenAI.
Args:
azure_deployment (str): Azure OpenAI deployment name.
azure_endpoint (str | None): Azure endpoint URL; if None, taken from AZURE_OPENAI_ENDPOINT.
api_key (str | None): Azure API key; if None, taken from AZURE_OPENAI_API_KEY. Omit if using `entra_token`.
entra_token (str | None): Azure Entra token for AAD auth. Provide instead of `api_key`.
base_url (str | None): Explicit base URL. Mutually exclusive with `azure_endpoint`. If provided, used as-is.
voice (str): Voice used for audio responses.
modalities (list[Literal["text", "audio"]] | NotGiven): Modalities to enable. Defaults to ["text", "audio"] if not provided.
input_audio_transcription (AudioTranscription | InputAudioTranscription | None | NotGiven): Transcription options; defaults to Azure-optimized values when not provided.
input_audio_noise_reduction (NoiseReductionType | InputAudioNoiseReduction | None): Input noise reduction settings. Defaults to None.
turn_detection (RealtimeAudioInputTurnDetection | TurnDetection | None | NotGiven): Server-side VAD; defaults to Azure-optimized values when not provided.
speed (float | NotGiven): Audio playback speed multiplier.
tracing (Tracing | None | NotGiven): Tracing configuration for OpenAI Realtime.
reasoning (RealtimeReasoning | None | NotGiven): Reasoning config for reasoning-capable models, e.g. ``RealtimeReasoning(effort="low")``.
http_session (aiohttp.ClientSession | None): Optional shared HTTP session.
max_session_duration (float | None | NotGiven): Seconds before recycling the connection.
temperature (float | NotGiven): Deprecated; ignored by Realtime v1.
Returns:
RealtimeModel: Configured client for Azure OpenAI Realtime.
Raises:
ValueError: If credentials are missing, Azure endpoint cannot be determined, or both `base_url` and `azure_endpoint` are provided.
Examples:
Azure usage with api-version 2024-10-01-preview:
```python
from livekit.plugins.openai.realtime import RealtimeModel
from openai.types.beta import realtime
model = openai.realtime.RealtimeModel.with_azure(
azure_deployment="gpt-realtime",
azure_endpoint="https://yourendpoint.azure.com",
api_version="2024-10-01-preview",
api_key="your-api-key",
modalities=["text", "audio"],
input_audio_transcription=realtime.session.InputAudioTranscription(
model="gpt-4o-transcribe",
),
input_audio_noise_reduction=realtime.session.InputAudioNoiseReduction(
type="near_field",
),
turn_detection=realtime.session.TurnDetection(
type="semantic_vad",
create_response=True,
eagerness="auto",
interrupt_response=True,
),
)
```
Azure usage with api-version 2025-08-28:
```python
from livekit.plugins.openai.realtime import RealtimeModel
from openai.types import realtime
model = RealtimeModel(
azure_deployment="gpt-realtime",
azure_endpoint="https://yourendpoint.azure.com",
api_version="2024-10-01-preview",
api_key="your-api-key",
input_audio_transcription=realtime.AudioTranscription(
model="gpt-4o-transcribe",
),
input_audio_noise_reduction="near_field",
turn_detection=realtime.realtime_audio_input_turn_detection.SemanticVad(
type="semantic_vad",
create_response=True,
eagerness="auto",
interrupt_response=True,
),
)
```
"""
if kwargs.get("api_version"):
logger.warning(
"The `api_version` parameter in `with_azure` is deprecated and will be removed "
"on April 30, 2026."
)
elif os.getenv("OPENAI_API_VERSION"):
logger.warning(
"The OPENAI_API_VERSION environment variable is deprecated and will be removed "
"on April 30, 2026."
)
api_key = api_key or os.getenv("AZURE_OPENAI_API_KEY")
if api_key is None and entra_token is None:
raise ValueError(
"Missing credentials. Please pass one of `api_key`, `entra_token`, "
"or the `AZURE_OPENAI_API_KEY` environment variable."
)
api_version: str | None = kwargs.get("api_version") or os.getenv("OPENAI_API_VERSION")
if base_url is None:
azure_endpoint = azure_endpoint or os.getenv("AZURE_OPENAI_ENDPOINT")
if azure_endpoint is None:
raise ValueError(
"Missing Azure endpoint. Please pass the `azure_endpoint` "
"parameter or set the `AZURE_OPENAI_ENDPOINT` environment variable."
)
base_url = f"{azure_endpoint.rstrip('/')}/openai"
elif azure_endpoint is not None:
raise ValueError("base_url and azure_endpoint are mutually exclusive")
if not is_given(input_audio_transcription):
input_audio_transcription = AZURE_DEFAULT_INPUT_AUDIO_TRANSCRIPTION
if not is_given(turn_detection):
turn_detection = AZURE_DEFAULT_TURN_DETECTION
return RealtimeModel(
voice=voice,
modalities=modalities,
input_audio_transcription=input_audio_transcription,
input_audio_noise_reduction=input_audio_noise_reduction,
turn_detection=turn_detection,
speed=speed,
tracing=tracing,
reasoning=reasoning,
api_key=api_key,
http_session=http_session,
azure_deployment=azure_deployment,
api_version=api_version,
entra_token=entra_token,
base_url=base_url,
max_session_duration=max_session_duration,
)
def update_options(
self,
*,
voice: NotGivenOr[str] = NOT_GIVEN,
turn_detection: NotGivenOr[
RealtimeAudioInputTurnDetection | TurnDetection | None
] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
input_audio_transcription: NotGivenOr[
InputAudioTranscription | AudioTranscription | None
] = NOT_GIVEN,
input_audio_noise_reduction: NotGivenOr[
NoiseReduction | NoiseReductionType | InputAudioNoiseReduction | None
] = NOT_GIVEN,
max_response_output_tokens: NotGivenOr[int | Literal["inf"] | None] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
tracing: NotGivenOr[Tracing | None] = NOT_GIVEN,
truncation: NotGivenOr[RealtimeTruncation | None] = NOT_GIVEN,
reasoning: NotGivenOr[RealtimeReasoning | None] = NOT_GIVEN,
temperature: NotGivenOr[float] = NOT_GIVEN, # deprecated, unused in v1
) -> None:
if is_given(voice):
self._opts.voice = voice
if is_given(turn_detection):
self._opts.turn_detection = to_turn_detection(turn_detection)
if is_given(tool_choice):
self._opts.tool_choice = tool_choice
if is_given(input_audio_transcription):
self._opts.input_audio_transcription = to_audio_transcription(input_audio_transcription)
if is_given(input_audio_noise_reduction):
self._opts.input_audio_noise_reduction = to_noise_reduction(input_audio_noise_reduction)
if is_given(max_response_output_tokens):
self._opts.max_response_output_tokens = max_response_output_tokens
if is_given(speed):
self._opts.speed = speed
if is_given(tracing):
self._opts.tracing = tracing
if is_given(truncation):
self._opts.truncation = truncation
if is_given(reasoning):
self._opts.reasoning = reasoning
for sess in self._sessions:
sess.update_options(
voice=voice,
turn_detection=self._opts.turn_detection,
tool_choice=tool_choice,
input_audio_transcription=self._opts.input_audio_transcription,
input_audio_noise_reduction=self._opts.input_audio_noise_reduction,
max_response_output_tokens=max_response_output_tokens,
speed=speed,
tracing=tracing,
truncation=truncation,
reasoning=reasoning,
)
def _ensure_http_session(self) -> aiohttp.ClientSession:
if not self._http_session:
try:
self._http_session = utils.http_context.http_session()
except RuntimeError:
self._http_session = aiohttp.ClientSession()
self._http_session_owned = True
return self._http_session
def session(self) -> RealtimeSession:
sess = RealtimeSession(self)
self._sessions.add(sess)
return sess
async def aclose(self) -> None:
if self._http_session_owned and self._http_session:
await self._http_session.close()
def process_base_url(
url: str,
model: str,
is_azure: bool = False,
azure_deployment: str | None = None,
api_version: str | None = None,
) -> str:
if url.startswith("http"):
url = url.replace("http", "ws", 1)
parsed_url = urlparse(url)
query_params = parse_qs(parsed_url.query)
path_stripped = parsed_url.path.rstrip("/")
if is_azure:
if path_stripped in ["", "/openai"]:
# newer Azure API (no api_version) uses /v1/realtime; legacy uses /realtime
path = path_stripped + ("/v1/realtime" if not api_version else "/realtime")
elif path_stripped == "/openai/v1":
path = "/openai/v1/realtime"
else:
path = parsed_url.path
else:
if not parsed_url.path or path_stripped in ["", "/v1", "/openai", "/openai/v1"]:
path = path_stripped + "/realtime"
else:
path = parsed_url.path
if is_azure:
query_params.pop("api-version", None) # remove from endpoint URL if present
if api_version:
# legacy Azure API: use api-version + deployment query params
query_params["api-version"] = [api_version]
if azure_deployment:
query_params["deployment"] = [azure_deployment]
else:
# newer Azure API: use azure_deployment as model query param
if "model" not in query_params and azure_deployment:
query_params["model"] = [azure_deployment]
else:
if "model" not in query_params:
query_params["model"] = [model]
new_query = urlencode(query_params, doseq=True)
new_url = urlunparse((parsed_url.scheme, parsed_url.netloc, path, "", new_query, ""))
return new_url
class RealtimeSession(
llm.RealtimeSession[Literal["openai_server_event_received", "openai_client_event_queued"]]
):
"""
A session for the OpenAI Realtime API.
This class is used to interact with the OpenAI Realtime API.
It is responsible for sending events to the OpenAI Realtime API and receiving events from it.
It exposes two more events:
- openai_server_event_received: expose the raw server events from the OpenAI Realtime API
- openai_client_event_queued: expose the raw client events sent to the OpenAI Realtime API
"""
def __init__(self, realtime_model: RealtimeModel) -> None:
super().__init__(realtime_model)
self._realtime_model: RealtimeModel = realtime_model
# per-session copy of opts so update_options can diff against session's own state
self._opts = replace(realtime_model._opts)
self._tools = llm.ToolContext.empty()
self._msg_ch = utils.aio.Chan[RealtimeClientEvent | dict[str, Any]]()
self._input_resampler: rtc.AudioResampler | None = None
self._instructions: str | None = None
# set on aclose; trailing server events are ignored while it's set
self._closing = False
self._main_atask = asyncio.create_task(self._main_task(), name="RealtimeSession._main_task")
self.send_event(self._create_session_update_event())
self._response_created_futures: dict[str, asyncio.Future[llm.GenerationCreatedEvent]] = {}
self._item_delete_future: dict[str, asyncio.Future] = {}
self._item_create_future: dict[str, asyncio.Future] = {}
# generate_reply event_ids cancelled or timed out before response.created arrived; the
# response is cancelled by id and discarded when it finally arrives
self._discarded_event_ids: set[str] = set()
# accumulates partial input-audio transcripts per (item_id, content_index)
self._input_transcript_accumulators: dict[str, dict[int, str]] = {}
self._current_generation: _ResponseGeneration | _DiscardedGeneration | None = None
self._remote_chat_ctx = llm.remote_chat_context.RemoteChatContext()
self._update_chat_ctx_lock = asyncio.Lock()
self._update_fnc_ctx_lock = asyncio.Lock()
# 100ms chunks
self._bstream = utils.audio.AudioByteStream(
SAMPLE_RATE, NUM_CHANNELS, samples_per_channel=SAMPLE_RATE // 10
)
self._pushed_duration_s: float = 0 # duration of audio pushed to the OpenAI Realtime API
def send_event(self, event: RealtimeClientEvent | dict[str, Any]) -> None:
with contextlib.suppress(utils.aio.channel.ChanClosed):
self._msg_ch.send_nowait(event)
@utils.log_exceptions(logger=logger)
async def _main_task(self) -> None:
num_retries: int = 0
max_retries = self._opts.conn_options.max_retry
async def _reconnect() -> None:
logger.debug(
f"reconnecting to {self._realtime_model._provider_label}",
extra={"max_session_duration": self._opts.max_session_duration},
)
events: list[RealtimeClientEvent | dict[str, Any]] = []
# options and instructions
events.append(self._create_session_update_event())
# tools
tools = self._tools.flatten()
if tools:
events.append(self._create_tools_update_event(tools))
# chat context
chat_ctx = self.chat_ctx.copy(
exclude_function_call=True,
exclude_instructions=True,
exclude_empty_message=True,
exclude_handoff=True,
exclude_config_update=True,
)
old_chat_ctx = self._remote_chat_ctx
self._remote_chat_ctx = llm.remote_chat_context.RemoteChatContext()
self._input_transcript_accumulators.clear()
events.extend(self._create_update_chat_ctx_events(chat_ctx))
try:
for ev in events:
# certain events could already be in dict format
if isinstance(ev, BaseModel):
ev = ev.model_dump(
by_alias=True, exclude_unset=True, exclude_defaults=False
)
if self._opts.is_azure and self._opts.api_version:
_normalize_azure_client_event(ev)
self.emit("openai_client_event_queued", ev)
await ws_conn.send_str(json.dumps(ev))
except Exception as e:
self._remote_chat_ctx = old_chat_ctx # restore the old chat context
raise APIConnectionError(
message=(
f"Failed to send message to {self._realtime_model._provider_label} during session re-connection"
),
) from e
for fut in self._response_created_futures.values():
if not fut.done():
fut.set_exception(
llm.RealtimeError("pending response discarded due to session reconnection")
)
self._response_created_futures.clear()
self._discarded_event_ids.clear()
self._close_current_generation("session reconnection")
logger.debug(f"reconnected to {self._realtime_model._provider_label}")
self.emit("session_reconnected", llm.RealtimeSessionReconnectedEvent())
reconnecting = False
while not self._msg_ch.closed:
try:
ws_conn = await self._create_ws_conn()
if reconnecting:
await _reconnect()
num_retries = 0 # reset the retry counter
await self._run_ws(ws_conn)
except APIError as e:
if max_retries == 0 or not e.retryable:
self._emit_error(e, recoverable=False)
raise
elif num_retries == max_retries:
self._emit_error(e, recoverable=False)
raise APIConnectionError(
f"{self._realtime_model._provider_label} connection failed after {num_retries} attempts",
) from e
else:
self._emit_error(e, recoverable=True)
retry_interval = self._opts.conn_options._interval_for_retry(num_retries)
logger.warning(
f"{self._realtime_model._provider_label} connection failed, retrying in {retry_interval}s",
exc_info=e,
extra={"attempt": num_retries, "max_retries": max_retries},
)
await asyncio.sleep(retry_interval)
num_retries += 1
except Exception as e:
self._emit_error(e, recoverable=False)
raise
reconnecting = True
async def _create_ws_conn(self) -> aiohttp.ClientWebSocketResponse:
headers = {"User-Agent": "LiveKit Agents"}
if self._opts.is_azure:
if self._opts.entra_token:
headers["Authorization"] = f"Bearer {self._opts.entra_token}"
if self._opts.api_key:
headers["api-key"] = self._opts.api_key
else:
headers["Authorization"] = f"Bearer {self._opts.api_key}"
url = process_base_url(
self._opts.base_url,
self._opts.model,
is_azure=self._opts.is_azure,
api_version=self._opts.api_version,
azure_deployment=self._opts.azure_deployment,
)
if lk_oai_debug:
logger.debug(f"connecting to Realtime API: {url}")
t0 = time.perf_counter()
try:
ws = await asyncio.wait_for(
self._realtime_model._ensure_http_session().ws_connect(url=url, headers=headers),
self._opts.conn_options.timeout,
)
self._report_connection_acquired(time.perf_counter() - t0)
return ws
except aiohttp.ClientError as e:
raise APIConnectionError(
f"{self._realtime_model._provider_label} client connection error"
) from e
except asyncio.TimeoutError as e:
raise APIConnectionError(
message=f"{self._realtime_model._provider_label} connection timed out",
) from e
async def _run_ws(self, ws_conn: aiohttp.ClientWebSocketResponse) -> None:
closing = False
@utils.log_exceptions(logger=logger)
async def _send_task() -> None:
nonlocal closing
async for msg in self._msg_ch:
try:
if isinstance(msg, BaseModel):
msg = msg.model_dump(
by_alias=True, exclude_unset=True, exclude_defaults=False
)
# Azure uses "text" for assistant content parts, while
# the new API uses "output_text" for assistant content.
if self._opts.is_azure and self._opts.api_version:
_normalize_azure_client_event(msg)
self.emit("openai_client_event_queued", msg)
await ws_conn.send_str(json.dumps(msg))
if lk_oai_debug:
msg_copy = msg.copy()
if msg_copy["type"] == "input_audio_buffer.append":
msg_copy = {**msg_copy, "audio": "..."}
logger.debug(f">>> {msg_copy}")
except Exception:
logger.exception("failed to send event")
closing = True
await ws_conn.close()
@utils.log_exceptions(logger=logger)
async def _recv_task() -> None:
while True:
msg = await ws_conn.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSING,
):
if closing: # closing is expected, see _send_task
return
# this will trigger a reconnection
raise APIConnectionError(
message=f"{self._realtime_model._provider_label} connection closed unexpectedly"
)
if msg.type != aiohttp.WSMsgType.TEXT:
continue
if self._closing:
# draining after aclose; the generation is already discarded
continue
event = json.loads(msg.data)
# Azure OpenAI uses old-style event names from the beta API.
# Normalize them to the current OpenAI event names so the rest
# of the handler code only needs to deal with one set of names.
if self._opts.is_azure:
event_type = event.get("type", "")
normalized = _AZURE_EVENT_MAPPING.get(event_type)
if normalized is not None:
event["type"] = normalized
# emit the raw json dictionary instead of the BaseModel because different
# providers can have different event types that are not part of the OpenAI Realtime API # noqa: E501
self.emit("openai_server_event_received", event)
try:
if lk_oai_debug:
event_copy = event.copy()
if event_copy["type"] == "response.output_audio.delta":
event_copy = {**event_copy, "delta": "..."}
logger.debug(f"<<< {event_copy}")
if event["type"] == "input_audio_buffer.speech_started":
self._handle_input_audio_buffer_speech_started(
InputAudioBufferSpeechStartedEvent.construct(**event)
)
elif event["type"] == "input_audio_buffer.speech_stopped":
self._handle_input_audio_buffer_speech_stopped(
InputAudioBufferSpeechStoppedEvent.construct(**event)
)
elif event["type"] == "response.created":
self._handle_response_created(ResponseCreatedEvent.construct(**event))
elif event["type"] == "response.output_item.added":
self._handle_response_output_item_added(
ResponseOutputItemAddedEvent.construct(**event)
)
elif event["type"] == "response.content_part.added":
self._handle_response_content_part_added(
ResponseContentPartAddedEvent.construct(**event)
)
elif event["type"] == "conversation.item.added":
self._handle_conversion_item_added(ConversationItemAdded.construct(**event))
elif event["type"] == "conversation.item.deleted":
self._handle_conversion_item_deleted(
ConversationItemDeletedEvent.construct(**event)
)
elif event["type"] == "conversation.item.input_audio_transcription.delta":
self._handle_conversion_item_input_audio_transcription_delta(
ConversationItemInputAudioTranscriptionDeltaEvent.construct(**event)
)
elif event["type"] == "conversation.item.input_audio_transcription.completed":
self._handle_conversion_item_input_audio_transcription_completed(
ConversationItemInputAudioTranscriptionCompletedEvent.construct(**event)
)
elif event["type"] == "conversation.item.input_audio_transcription.failed":
self._handle_conversion_item_input_audio_transcription_failed(
ConversationItemInputAudioTranscriptionFailedEvent.construct(**event)
)
elif event["type"] == "response.output_text.delta":
self._handle_response_text_delta(ResponseTextDeltaEvent.construct(**event))
elif event["type"] == "response.output_text.done":
self._handle_response_text_done(ResponseTextDoneEvent.construct(**event))
elif event["type"] == "response.output_audio_transcript.delta":
self._handle_response_audio_transcript_delta(event)
elif event["type"] == "response.output_audio.delta":
self._handle_response_audio_delta(
ResponseAudioDeltaEvent.construct(**event)
)
elif event["type"] == "response.output_audio.done":
self._handle_response_audio_done(ResponseAudioDoneEvent.construct(**event))
elif event["type"] == "response.output_item.done":
self._handle_response_output_item_done(
ResponseOutputItemDoneEvent.construct(**event)
)
elif event["type"] == "response.done":
self._handle_response_done(ResponseDoneEvent.construct(**event))
elif event["type"] == "error":
self._handle_error(RealtimeErrorEvent.construct(**event))
elif lk_oai_debug:
logger.debug(f"unhandled event: {event['type']}", extra={"event": event})
except Exception:
if event["type"] == "response.output_audio.delta":
event["delta"] = event["delta"][:10] + "..."
logger.exception("failed to handle event", extra={"event": event})
tasks = [
asyncio.create_task(_recv_task(), name="_recv_task"),
asyncio.create_task(_send_task(), name="_send_task"),
]
wait_reconnect_task: asyncio.Task | None = None
if self._opts.max_session_duration is not None:
wait_reconnect_task = asyncio.create_task(
asyncio.sleep(self._opts.max_session_duration),
name="_timeout_task",
)
tasks.append(wait_reconnect_task)
try:
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
# propagate exceptions from completed tasks
for task in done:
if task != wait_reconnect_task:
task.result()
if (
wait_reconnect_task
and wait_reconnect_task in done
and isinstance(self._current_generation, _ResponseGeneration)
):
# wait for the current generation to complete before reconnecting
await self._current_generation._done_fut
closing = True
finally:
await utils.aio.cancel_and_wait(*tasks)
await ws_conn.close()
def _wrap_session_update(
self, event_id: str, session: RealtimeSessionCreateRequest
) -> SessionUpdateEvent | dict[str, Any]:
"""Wrap a session object in the appropriate event type.
For Azure, converts the new-style session to the old flat format
and returns a dict (since AzureSessionUpdateEvent is not part of
the RealtimeClientEvent union).
"""
if self._opts.is_azure and self._opts.api_version:
# legacy Azure API: convert to old flat format
return AzureSessionUpdateEvent(
type="session.update",
session=_oai_session_to_azure(session),
event_id=event_id,
).model_dump(by_alias=True, exclude_unset=True, exclude_defaults=False)
return SessionUpdateEvent(
type="session.update",
session=session,
event_id=event_id,
)
def _create_session_update_event(self) -> SessionUpdateEvent | dict[str, Any]:
audio_format = realtime.realtime_audio_formats.AudioPCM(rate=SAMPLE_RATE, type="audio/pcm")
# they do not support both text and audio modalities, it'll respond in audio + transcript
modality = "audio" if "audio" in self._opts.modalities else "text"
opts = self._opts
session = RealtimeSessionCreateRequest(
type="realtime",
model=opts.model,
output_modalities=[modality],
audio=RealtimeAudioConfig(
input=RealtimeAudioConfigInput(
format=audio_format,
noise_reduction=opts.input_audio_noise_reduction,
transcription=opts.input_audio_transcription,
turn_detection=opts.turn_detection,
),
output=RealtimeAudioConfigOutput(
format=audio_format,
speed=opts.speed,
voice=opts.voice,
),
),
max_output_tokens=opts.max_response_output_tokens,
tool_choice=to_oai_tool_choice(opts.tool_choice),
tracing=opts.tracing,
)
if self._instructions is not None:
session.instructions = self._instructions
if opts.truncation is not None:
session.truncation = opts.truncation
if opts.reasoning is not None:
session.reasoning = opts.reasoning
return self._wrap_session_update(
event_id=utils.shortuuid("session_update_"), session=session
)
@property
def chat_ctx(self) -> llm.ChatContext:
return self._remote_chat_ctx.to_chat_ctx()
@property
def tools(self) -> llm.ToolContext:
return self._tools.copy()
def update_options(
self,
*,
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
voice: NotGivenOr[str] = NOT_GIVEN,
turn_detection: NotGivenOr[RealtimeAudioInputTurnDetection | None] = NOT_GIVEN,
max_response_output_tokens: NotGivenOr[int | Literal["inf"] | None] = NOT_GIVEN,
input_audio_transcription: NotGivenOr[AudioTranscription | None] = NOT_GIVEN,
input_audio_noise_reduction: NotGivenOr[
NoiseReductionType | NoiseReduction | InputAudioNoiseReduction | None
] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
tracing: NotGivenOr[Tracing | None] = NOT_GIVEN,
truncation: NotGivenOr[RealtimeTruncation | None] = NOT_GIVEN,
reasoning: NotGivenOr[RealtimeReasoning | None] = NOT_GIVEN,
) -> None:
session = RealtimeSessionCreateRequest(type="realtime")
has_changes = False
if is_given(tool_choice):
current_oai = to_oai_tool_choice(self._opts.tool_choice)
next_oai = to_oai_tool_choice(tool_choice)
self._opts.tool_choice = tool_choice
if current_oai != next_oai:
session.tool_choice = next_oai
has_changes = True
if is_given(max_response_output_tokens):
if self._opts.max_response_output_tokens != max_response_output_tokens:
session.max_output_tokens = max_response_output_tokens
has_changes = True
self._opts.max_response_output_tokens = max_response_output_tokens
if is_given(tracing):
if self._opts.tracing != tracing:
session.tracing = tracing # type: ignore[assignment]
has_changes = True
self._opts.tracing = tracing
if is_given(truncation):
if self._opts.truncation != truncation:
session.truncation = truncation
has_changes = True
self._opts.truncation = truncation
if is_given(reasoning):
if self._opts.reasoning != reasoning:
# setting reasoning to None clears it server-side
session.reasoning = reasoning
has_changes = True
self._opts.reasoning = reasoning
has_audio_config = False
audio_output = RealtimeAudioConfigOutput()
audio_input = RealtimeAudioConfigInput()
audio_config = RealtimeAudioConfig(output=audio_output, input=audio_input)
if is_given(voice):
if self._opts.voice != voice:
audio_output.voice = voice
has_audio_config = True
self._opts.voice = voice
if is_given(turn_detection):
if self._opts.turn_detection != turn_detection:
audio_input.turn_detection = turn_detection
has_audio_config = True
self._opts.turn_detection = turn_detection
if is_given(input_audio_transcription):
if self._opts.input_audio_transcription != input_audio_transcription:
audio_input.transcription = input_audio_transcription
has_audio_config = True
self._opts.input_audio_transcription = input_audio_transcription
if is_given(input_audio_noise_reduction):
input_audio_noise_reduction = to_noise_reduction(input_audio_noise_reduction)
if self._opts.input_audio_noise_reduction != input_audio_noise_reduction:
audio_input.noise_reduction = input_audio_noise_reduction
has_audio_config = True
self._opts.input_audio_noise_reduction = input_audio_noise_reduction
if is_given(speed):
if self._opts.speed != speed:
audio_output.speed = speed
has_audio_config = True
self._opts.speed = speed
if has_audio_config:
session.audio = audio_config
has_changes = True
if has_changes:
self.send_event(
self._wrap_session_update(
event_id=utils.shortuuid("options_update_"), session=session
)
)
async def update_chat_ctx(self, chat_ctx: llm.ChatContext) -> None:
async with self._update_chat_ctx_lock:
chat_ctx = chat_ctx.copy(
exclude_handoff=True,
exclude_config_update=True,
)
# only remove the instructions but keep other system messages
remove_instructions(chat_ctx)
events = self._create_update_chat_ctx_events(chat_ctx)
futs: list[asyncio.Future[None]] = []
for ev in events:
futs.append(f := asyncio.Future[None]())
if isinstance(ev, ConversationItemDeleteEvent):
self._item_delete_future[ev.item_id] = f
elif isinstance(ev, ConversationItemCreateEvent):
assert ev.item.id is not None
self._item_create_future[ev.item.id] = f
self.send_event(ev)
if not futs:
return
try:
await asyncio.wait_for(asyncio.gather(*futs, return_exceptions=True), timeout=5.0)
except asyncio.TimeoutError:
# clean up timed-out futures so late server responses don't hit
# InvalidStateError when calling set_result on cancelled futures
for ev in events:
if isinstance(ev, ConversationItemDeleteEvent):
self._item_delete_future.pop(ev.item_id, None)
elif isinstance(ev, ConversationItemCreateEvent):
assert ev.item.id is not None
self._item_create_future.pop(ev.item.id, None)
raise llm.RealtimeError("update_chat_ctx timed out.") from None
def _create_update_chat_ctx_events(
self, chat_ctx: llm.ChatContext
) -> list[ConversationItemCreateEvent | ConversationItemDeleteEvent]:
events: list[ConversationItemCreateEvent | ConversationItemDeleteEvent] = []
remote_ctx = self._remote_chat_ctx.to_chat_ctx()
# Empty message content can mean either:
# - a local placeholder that should not be created remotely, or
# - an existing remote item with non-text content (audio/images) that is not
# synced into the agent-side ChatContext.
# Keep empty messages that already exist remotely so we do not delete them.
remote_ids = {item.id for item in remote_ctx.items}
chat_ctx = llm.ChatContext(
[
item
for item in chat_ctx.items
if item.type != "message" or item.content or item.id in remote_ids
]
)
diff_ops = llm.utils.compute_chat_ctx_diff(remote_ctx, chat_ctx)
def _delete_item(msg_id: str) -> None:
events.append(
ConversationItemDeleteEvent(
type="conversation.item.delete",
item_id=msg_id,
event_id=utils.shortuuid("chat_ctx_delete_"),
)
)
def _create_item(previous_msg_id: str | None, msg_id: str) -> None:
chat_item = chat_ctx.get_by_id(msg_id)
assert chat_item is not None
events.append(
ConversationItemCreateEvent(
type="conversation.item.create",
item=livekit_item_to_openai_item(chat_item),
previous_item_id=("root" if previous_msg_id is None else previous_msg_id),
event_id=utils.shortuuid("chat_ctx_create_"),
)
)
def _is_content_empty(msg_id: str) -> bool:
remote_item = remote_ctx.get_by_id(msg_id)
if remote_item and remote_item.type == "message" and not remote_item.content:
return True
return False
for msg_id in diff_ops.to_remove:
_delete_item(msg_id)
for previous_msg_id, msg_id in diff_ops.to_create:
_create_item(previous_msg_id, msg_id)
# update the items with the same id but different content
for previous_msg_id, msg_id in diff_ops.to_update:
# empty content almost always means the content is not synced down
# we don't want to recreate these items there
if _is_content_empty(msg_id):
continue
_delete_item(msg_id)
_create_item(previous_msg_id, msg_id)
return events
async def update_tools(self, tools: list[llm.Tool]) -> None:
async with self._update_fnc_ctx_lock:
ev = self._create_tools_update_event(tools)
self.send_event(ev)
retained_tool_names: set[str] = set()
for t in ev["session"]["tools"]:
if name := t.get("name"):
retained_tool_names.add(name)
# TODO(dz): handle MCP tools
retained_tools = [
tool
for tool in tools
if (
isinstance(tool, (llm.FunctionTool, llm.RawFunctionTool))
and tool.info.name in retained_tool_names
)
or isinstance(tool, llm.ProviderTool)
]
self._tools = llm.ToolContext(retained_tools)
# this function can be overrided
def _convert_tools_to_oai(self, tools: list[llm.Tool]) -> list[RealtimeFunctionTool]:
oai_tools: list[RealtimeFunctionTool] = []
for tool in tools:
if isinstance(tool, llm.FunctionTool):
tool_desc = llm.utils.build_legacy_openai_schema(tool, internally_tagged=True)
elif isinstance(tool, llm.RawFunctionTool):
# copy to avoid modifying original
tool_desc = dict(tool.info.raw_schema)
tool_desc.pop("meta", None) # meta is not supported by OpenAI Realtime API
tool_desc["type"] = "function" # internally tagged
elif isinstance(tool, llm.ProviderTool):
continue # currently only xAI supports ProviderTools
else:
logger.error(
f"{self._realtime_model._provider_label} doesn't support this tool type",
extra={"tool": tool},
)
continue
try:
session_tool = RealtimeFunctionTool.model_validate(tool_desc)
oai_tools.append(session_tool)
except ValidationError:
logger.error(
f"{self._realtime_model._provider_label} doesn't support this tool",
extra={"tool": tool_desc},
)
continue
return oai_tools
def _create_tools_update_event(self, tools: list[llm.Tool]) -> dict[str, Any]:
oai_tools = self._convert_tools_to_oai(tools)
event = self._wrap_session_update(
event_id=utils.shortuuid("tools_update_"),
session=RealtimeSessionCreateRequest.model_construct(
type="realtime",
model=self._opts.model,
tools=oai_tools, # type: ignore
),
)
if isinstance(event, dict):
return event
return event.model_dump(by_alias=True, exclude_unset=True, exclude_defaults=False)
async def update_instructions(self, instructions: str) -> None:
self.send_event(
self._wrap_session_update(
event_id=utils.shortuuid("instructions_update_"),
session=RealtimeSessionCreateRequest.model_construct(
type="realtime",
instructions=instructions,
),
)
)
self._instructions = instructions
def push_audio(self, frame: rtc.AudioFrame) -> None:
for f in self._resample_audio(frame):
data = f.data.tobytes()
for nf in self._bstream.write(data):
self.send_event(
InputAudioBufferAppendEvent(
type="input_audio_buffer.append",
audio=base64.b64encode(nf.data).decode("utf-8"),
)
)
self._pushed_duration_s += nf.duration
def push_video(self, frame: rtc.VideoFrame) -> None:
message = llm.ChatMessage(
role="user",
content=[llm.ImageContent(image=frame)],
)
oai_item = livekit_item_to_openai_item(message)
self.send_event(
ConversationItemCreateEvent(
type="conversation.item.create",
item=oai_item,
event_id=utils.shortuuid("video_"),
)
)
def commit_audio(self) -> None:
if self._pushed_duration_s > 0.1: # OpenAI requires at least 100ms of audio
self.send_event(InputAudioBufferCommitEvent(type="input_audio_buffer.commit"))
self._pushed_duration_s = 0
def clear_audio(self) -> None:
self.send_event(InputAudioBufferClearEvent(type="input_audio_buffer.clear"))
self._pushed_duration_s = 0
def generate_reply(
self,
*,
instructions: NotGivenOr[str] = NOT_GIVEN,
tool_choice: NotGivenOr[llm.ToolChoice] = NOT_GIVEN,
tools: NotGivenOr[list[llm.Tool]] = NOT_GIVEN,
) -> asyncio.Future[llm.GenerationCreatedEvent]:
event_id = utils.shortuuid("response_create_")
fut = asyncio.Future[llm.GenerationCreatedEvent]()
self._response_created_futures[event_id] = fut
if is_given(instructions) and self._instructions:
# in OpenAI realtime, the session-level instructions are completely replaced
# by the new instructions for this response
instructions = f"{self._instructions}\n{instructions}"
params = RealtimeResponseCreateParams(
instructions=instructions or None,
metadata={"client_event_id": event_id},
)
if is_given(tool_choice):
params.tool_choice = to_oai_tool_choice(tool_choice)
if is_given(tools):
params.tools = self._convert_tools_to_oai(tools) # type: ignore
self.send_event(
ResponseCreateEvent(type="response.create", event_id=event_id, response=params)
)
def _on_timeout() -> None:
self._response_created_futures.pop(event_id, None)
if fut and not fut.done():
# discard the response if the server still creates it after the timeout
self._discarded_event_ids.add(event_id)
fut.set_exception(llm.RealtimeError("generate_reply timed out."))
handle = asyncio.get_event_loop().call_later(10.0, _on_timeout)
def _on_fut_done(f: asyncio.Future[llm.GenerationCreatedEvent]) -> None:
handle.cancel()
self._response_created_futures.pop(event_id, None)
if f.cancelled():
# response.create was already sent; cancel the response server-side
self.send_event(ResponseCancelEvent(type="response.cancel"))
# the cancel above is a no-op if the response isn't created yet; discard it by id
# when it arrives
self._discarded_event_ids.add(event_id)
fut.add_done_callback(_on_fut_done)
return fut
@property
def has_active_generation(self) -> bool:
return self._current_generation is not None or len(self._response_created_futures) > 0
def interrupt(self) -> None:
if not self.has_active_generation:
return
self.send_event(ResponseCancelEvent(type="response.cancel"))
def truncate(
self,
*,
message_id: str,
modalities: list[Literal["text", "audio"]],
audio_end_ms: int,
audio_transcript: NotGivenOr[str] = NOT_GIVEN,
) -> None:
if "audio" in modalities:
if audio_end_ms > 0:
self.send_event(
ConversationItemTruncateEvent(
type="conversation.item.truncate",
content_index=0,
item_id=message_id,
audio_end_ms=audio_end_ms,
)
)
else:
self.send_event(
ConversationItemDeleteEvent(
type="conversation.item.delete",
item_id=message_id,
event_id=utils.shortuuid("chat_ctx_delete_"),
)
)
elif utils.is_given(audio_transcript):
# sync the forwarded text to the remote chat ctx
chat_ctx = self.chat_ctx.copy(
exclude_handoff=True,
exclude_config_update=True,
)
if (idx := chat_ctx.index_by_id(message_id)) is not None:
new_item = copy.copy(chat_ctx.items[idx])
assert new_item.type == "message"
new_item.content = [audio_transcript]
chat_ctx.items[idx] = new_item
events = self._create_update_chat_ctx_events(chat_ctx)
for ev in events:
self.send_event(ev)
async def aclose(self) -> None:
self._closing = True
self._close_current_generation("session closed")
self._msg_ch.close()
await self._main_atask
def _close_current_generation(self, reason: str | None = None) -> None:
"""Close all channels and resolve _done_fut for the current generation.
This prevents consumers from hanging indefinitely when a generation is
interrupted by a reconnection or session close.
"""
if isinstance(self._current_generation, _DiscardedGeneration):
self._current_generation = None
return
if self._current_generation is None or self._current_generation._done_fut.done():
return
for generation in self._current_generation.messages.values():
generation.text_ch.close()
generation.audio_ch.close()
if not generation.modalities.done():
generation.modalities.set_result(self._opts.modalities)
self._current_generation.function_ch.close()
self._current_generation.message_ch.close()
with contextlib.suppress(asyncio.InvalidStateError):
self._current_generation._done_fut.set_result(None)
self._current_generation = None
if reason:
logger.warning(f"in-progress generation discarded due to {reason}")
def _resample_audio(self, frame: rtc.AudioFrame) -> Iterator[rtc.AudioFrame]:
if self._input_resampler:
if frame.sample_rate != self._input_resampler._input_rate:
# input audio changed to a different sample rate
self._input_resampler = None
if self._input_resampler is None and (
frame.sample_rate != SAMPLE_RATE or frame.num_channels != NUM_CHANNELS
):
self._input_resampler = rtc.AudioResampler(
input_rate=frame.sample_rate,
output_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
)
if self._input_resampler:
# TODO(long): flush the resampler when the input source is changed
yield from self._input_resampler.push(frame)
else:
yield frame
def _handle_input_audio_buffer_speech_started(
self, _: InputAudioBufferSpeechStartedEvent
) -> None:
self.emit("input_speech_started", llm.InputSpeechStartedEvent())
def _handle_input_audio_buffer_speech_stopped(
self, _: InputAudioBufferSpeechStoppedEvent
) -> None:
user_transcription_enabled = self._opts.input_audio_transcription is not None
self.emit(
"input_speech_stopped",
llm.InputSpeechStoppedEvent(user_transcription_enabled=user_transcription_enabled),
)
def _handle_response_created(self, event: ResponseCreatedEvent) -> None:
assert event.response.id is not None, "response.id is None"
client_event_id: str | None = None
if isinstance(event.response.metadata, dict):
client_event_id = event.response.metadata.get("client_event_id")
if client_event_id and client_event_id in self._discarded_event_ids:
# interrupted or timed out before the server created it: cancel by id and mark it
# discarded so its trailing events are skipped, instead of surfacing it
self._discarded_event_ids.discard(client_event_id)
self.send_event(
ResponseCancelEvent(type="response.cancel", response_id=event.response.id)
)
self._current_generation = _DiscardedGeneration()
logger.warning("discarding response that arrived after it was timed out or interrupted")
return
self._current_generation = _ResponseGeneration(
message_ch=utils.aio.Chan(),
function_ch=utils.aio.Chan(),
messages={},
_created_timestamp=time.time(),
_done_fut=asyncio.Future(),
)
generation_ev = llm.GenerationCreatedEvent(
message_stream=self._current_generation.message_ch,
function_stream=self._current_generation.function_ch,
user_initiated=False,
response_id=event.response.id,
)
if client_event_id and (fut := self._response_created_futures.pop(client_event_id, None)):
if not fut.done():
generation_ev.user_initiated = True
fut.set_result(generation_ev)
else:
logger.warning("response of generate_reply received after it's timed out.")
self.emit("generation_created", generation_ev)
def _handle_response_output_item_added(self, event: ResponseOutputItemAddedEvent) -> None:
if isinstance(self._current_generation, _DiscardedGeneration):
return
assert self._current_generation is not None, "current_generation is None"
assert (item_id := event.item.id) is not None, "item.id is None"
assert (item_type := event.item.type) is not None, "item.type is None"
if item_type == "message":
item_generation = _MessageGeneration(
message_id=item_id,
text_ch=utils.aio.Chan(),
audio_ch=utils.aio.Chan(),
modalities=asyncio.Future(),
)
if not self._realtime_model.capabilities.audio_output:
item_generation.audio_ch.close()
item_generation.modalities.set_result(["text"])
self._current_generation.message_ch.send_nowait(
llm.MessageGeneration(
message_id=item_id,
text_stream=item_generation.text_ch,
audio_stream=item_generation.audio_ch,
modalities=item_generation.modalities,
)
)
self._current_generation.messages[item_id] = item_generation
def _handle_response_content_part_added(self, event: ResponseContentPartAddedEvent) -> None:
if isinstance(self._current_generation, _DiscardedGeneration):
return
assert self._current_generation is not None, "current_generation is None"
assert (item_id := event.item_id) is not None, "item_id is None"
assert (item_type := event.part.type) is not None, "part.type is None"
if item_type == "text" and self._realtime_model.capabilities.audio_output:
logger.warning(
f"Text response received from {self._realtime_model._provider_label} in audio modality."
)
with contextlib.suppress(asyncio.InvalidStateError):
self._current_generation.messages[item_id].modalities.set_result(
["text"] if item_type == "text" else ["audio", "text"]
)
def _handle_conversion_item_added(self, event: ConversationItemAdded) -> None:
assert event.item.id is not None, "item.id is None"
try:
lk_item = openai_item_to_livekit_item(event.item)
self._remote_chat_ctx.insert(event.previous_item_id, lk_item)
self.emit(
"remote_item_added",
llm.RemoteItemAddedEvent(previous_item_id=event.previous_item_id, item=lk_item),
)
except ValueError as e:
logger.warning(
f"failed to insert item `{event.item.id}`: {str(e)}",
)
if fut := self._item_create_future.pop(event.item.id, None):
if fut.cancelled():
logger.error(f"item create future for `{event.item.id}` was already cancelled")
else:
fut.set_result(None)
def _handle_conversion_item_deleted(self, event: ConversationItemDeletedEvent) -> None:
assert event.item_id is not None, "item_id is None"
self._input_transcript_accumulators.pop(event.item_id, None)
try:
self._remote_chat_ctx.delete(event.item_id)
except ValueError as e:
logger.warning(
f"failed to delete item `{event.item_id}`: {str(e)}",
)
if fut := self._item_delete_future.pop(event.item_id, None):
if fut.cancelled():
logger.error(f"item delete future for `{event.item_id}` was already cancelled")
else:
fut.set_result(None)
def _handle_conversion_item_input_audio_transcription_delta(
self, event: ConversationItemInputAudioTranscriptionDeltaEvent
) -> None:
if not event.delta:
return
content_index = event.content_index or 0
by_index = self._input_transcript_accumulators.setdefault(event.item_id, {})
accumulated = by_index.get(content_index, "") + event.delta
by_index[content_index] = accumulated
self.emit(
"input_audio_transcription_completed",
llm.InputTranscriptionCompleted(
item_id=event.item_id, transcript=accumulated, is_final=False
),
)
def _clear_transcript_accumulator(self, item_id: str, content_index: int) -> str | None:
by_index = self._input_transcript_accumulators.get(item_id)
if by_index is None:
return None
partial = by_index.pop(content_index, None)
if not by_index:
self._input_transcript_accumulators.pop(item_id, None)
return partial
def _handle_conversion_item_input_audio_transcription_completed(
self, event: ConversationItemInputAudioTranscriptionCompletedEvent
) -> None:
self._clear_transcript_accumulator(event.item_id, event.content_index or 0)
confidence = calculate_confidence_from_logprobs(event.logprobs)
if remote_item := self._remote_chat_ctx.get(event.item_id):
assert isinstance(remote_item.item, llm.ChatMessage)
remote_item.item.content.append(event.transcript)
remote_item.item.transcript_confidence = confidence
self.emit(
"input_audio_transcription_completed",
llm.InputTranscriptionCompleted(
item_id=event.item_id,
transcript=event.transcript,
is_final=True,
confidence=confidence,
),
)
def _handle_conversion_item_input_audio_transcription_failed(
self, event: ConversationItemInputAudioTranscriptionFailedEvent
) -> None:
logger.error(
f"{self._realtime_model._provider_label} failed to transcribe input audio",
extra={"error": event.error},
)
# close any open partial stream so consumers waiting for is_final don't hang
partial = self._clear_transcript_accumulator(event.item_id, event.content_index or 0)
if partial is None:
return
self.emit(
"input_audio_transcription_completed",
llm.InputTranscriptionCompleted(
item_id=event.item_id, transcript=partial, is_final=True
),
)
def _handle_response_text_delta(self, event: ResponseTextDeltaEvent) -> None:
if isinstance(self._current_generation, _DiscardedGeneration):
return
assert self._current_generation is not None, "current_generation is None"
item_generation = self._current_generation.messages[event.item_id]
if (
item_generation.audio_ch.closed
and self._current_generation._first_token_timestamp is None
):
# only if audio is not available
self._current_generation._first_token_timestamp = time.time()
item_generation.text_ch.send_nowait(event.delta)
item_generation.audio_transcript += event.delta
def _handle_response_text_done(self, event: ResponseTextDoneEvent) -> None:
if isinstance(self._current_generation, _DiscardedGeneration):
return
assert self._current_generation is not None, "current_generation is None"
def _handle_response_audio_transcript_delta(self, event: dict[str, Any]) -> None:
if isinstance(self._current_generation, _DiscardedGeneration):
return
assert self._current_generation is not None, "current_generation is None"
item_id = event["item_id"]
delta = event["delta"]
if (start_time := event.get("start_time")) is not None:
delta = io.TimedString(delta, start_time=start_time)
item_generation = self._current_generation.messages[item_id]
item_generation.text_ch.send_nowait(delta)
item_generation.audio_transcript += delta
def _handle_response_audio_delta(self, event: ResponseAudioDeltaEvent) -> None:
if isinstance(self._current_generation, _DiscardedGeneration):
return
assert self._current_generation is not None, "current_generation is None"
item_generation = self._current_generation.messages[event.item_id]
if self._current_generation._first_token_timestamp is None:
self._current_generation._first_token_timestamp = time.time()
if not item_generation.modalities.done():
item_generation.modalities.set_result(["audio", "text"])
data = base64.b64decode(event.delta)
item_generation.audio_ch.send_nowait(
rtc.AudioFrame(
data=data,
sample_rate=SAMPLE_RATE,
num_channels=NUM_CHANNELS,
samples_per_channel=len(data) // 2,
)
)
def _handle_response_audio_done(self, _: ResponseAudioDoneEvent) -> None:
if isinstance(self._current_generation, _DiscardedGeneration):
return
assert self._current_generation is not None, "current_generation is None"
def _handle_response_output_item_done(self, event: ResponseOutputItemDoneEvent) -> None:
if isinstance(self._current_generation, _DiscardedGeneration):
return
assert self._current_generation is not None, "current_generation is None"
assert (item_id := event.item.id) is not None, "item.id is None"
assert (item_type := event.item.type) is not None, "item.type is None"
if item_type == "function_call" and isinstance(
event.item, RealtimeConversationItemFunctionCall
):
self._handle_function_call(event.item)
elif item_type == "message":
item_generation = self._current_generation.messages[item_id]
item_generation.text_ch.close()
item_generation.audio_ch.close()
if not item_generation.modalities.done():
# in case message modalities is not set, this shouldn't happen
item_generation.modalities.set_result(self._opts.modalities)
def _handle_function_call(self, item: RealtimeConversationItemFunctionCall) -> None:
assert isinstance(self._current_generation, _ResponseGeneration), (
"current_generation is None"
)
assert item.id is not None, "item.id is None"
assert item.call_id is not None, "call_id is None"
assert item.name is not None, "name is None"
assert item.arguments is not None, "arguments is None"
self._current_generation.function_ch.send_nowait(
llm.FunctionCall(
id=item.id,
call_id=item.call_id,
name=item.name,
arguments=item.arguments,
)
)
def _handle_response_done(self, event: ResponseDoneEvent) -> None:
if isinstance(self._current_generation, _DiscardedGeneration):
self._current_generation = None
return
if self._current_generation is None:
return # OpenAI has a race condition where we could receive response.done without any previous response.created (This happens generally during interruption) # noqa: E501
assert self._current_generation is not None, "current_generation is None"
created_timestamp = self._current_generation._created_timestamp
first_token_timestamp = self._current_generation._first_token_timestamp
for generation in self._current_generation.messages.values():
if not generation.modalities.done():
generation.modalities.set_result(self._opts.modalities)
for item_id, item_generation in self._current_generation.messages.items():
if (remote_item := self._remote_chat_ctx.get(item_id)) and isinstance(
remote_item.item, llm.ChatMessage
):
remote_item.item.content.append(item_generation.audio_transcript)
self._current_generation._close()
with contextlib.suppress(asyncio.InvalidStateError):
if event.response.status in ("failed", "incomplete"):
details = event.response.status_details
msg = f"response {event.response.status}"
if details and details.error:
msg = f"{msg}: [{details.error.type}] {details.error.code}"
elif details and details.reason:
msg = f"{msg}: {details.reason}"
self._current_generation._done_fut.set_exception(llm.RealtimeError(msg))
else:
self._current_generation._done_fut.set_result(None)
self._current_generation = None
# calculate metrics
usage = (
event.response.usage.model_dump(exclude_defaults=True) if event.response.usage else {}
)
ttft = first_token_timestamp - created_timestamp if first_token_timestamp else -1
duration = time.time() - created_timestamp
metrics = RealtimeModelMetrics(
timestamp=created_timestamp,
request_id=event.response.id or "",
ttft=ttft,
duration=duration,
cancelled=event.response.status == "cancelled",
label=self._realtime_model.label,
input_tokens=usage.get("input_tokens", 0),
output_tokens=usage.get("output_tokens", 0),
total_tokens=usage.get("total_tokens", 0),
tokens_per_second=usage.get("output_tokens", 0) / duration if duration > 0 else 0,
input_token_details=RealtimeModelMetrics.InputTokenDetails(
audio_tokens=usage.get("input_token_details", {}).get("audio_tokens", 0),
cached_tokens=usage.get("input_token_details", {}).get("cached_tokens", 0),
text_tokens=usage.get("input_token_details", {}).get("text_tokens", 0),
cached_tokens_details=RealtimeModelMetrics.CachedTokenDetails(
text_tokens=usage.get("input_token_details", {})
.get("cached_tokens_details", {})
.get("text_tokens", 0),
audio_tokens=usage.get("input_token_details", {})
.get("cached_tokens_details", {})
.get("audio_tokens", 0),
image_tokens=usage.get("input_token_details", {})
.get("cached_tokens_details", {})
.get("image_tokens", 0),
),
image_tokens=usage.get("input_token_details", {}).get("image_tokens", 0),
),
output_token_details=RealtimeModelMetrics.OutputTokenDetails(
text_tokens=usage.get("output_token_details", {}).get("text_tokens", 0),
audio_tokens=usage.get("output_token_details", {}).get("audio_tokens", 0),
image_tokens=usage.get("output_token_details", {}).get("image_tokens", 0),
),
metadata=Metadata(
model_name=self._realtime_model.model, model_provider=self._realtime_model.provider
),
)
self.emit("metrics_collected", metrics)
self._handle_response_done_but_not_complete(event)
def _handle_response_done_but_not_complete(self, event: ResponseDoneEvent) -> None:
"""Handle response done but not complete, i.e. cancelled, incomplete or failed.
For example this method will emit an error if we receive a "failed" status, e.g.
with type "invalid_request_error" due to code "inference_rate_limit_exceeded".
In other failures it will emit a debug level log.
"""
if event.response.status == "completed":
return
provider_label = self._realtime_model._provider_label
if event.response.status == "failed":
if event.response.status_details and hasattr(event.response.status_details, "error"):
error_type = getattr(event.response.status_details.error, "type", "unknown")
error_body = event.response.status_details.error
message = f"{provider_label} response failed with error type: {error_type}"
else:
error_body = None
message = f"{provider_label} response failed with unknown error"
self._emit_error(
APIError(
message=message,
body=error_body,
retryable=True,
),
# all possible faulures undocumented by openai,
# so we assume optimistically all retryable/recoverable
recoverable=True,
)
elif event.response.status in {"cancelled", "incomplete"}:
status_details = event.response.status_details
if isinstance(status_details, str):
status_type = status_details
status_reason = None
else:
status_type = status_details.type if status_details else None
status_reason = status_details.reason if status_details else None
logger.debug(
"%s response done but not complete with status: %s (type=%s, reason=%s)",
provider_label,
event.response.status,
status_type,
status_reason,
extra={
"event_id": event.response.id,
"event_response_status": event.response.status,
"event_response_status_type": status_type,
"event_response_status_reason": status_reason,
},
)
else:
logger.debug("Unknown response status: %s", event.response.status)
def _handle_error(self, event: RealtimeErrorEvent) -> None:
if event.error.message.startswith("Cancellation failed"):
return
provider_label = self._realtime_model._provider_label
logger.error(
f"{provider_label} returned an error: {event.error}",
extra={"error": event.error},
)
self._emit_error(
APIError(
message=f"{provider_label} returned an error",
body=event.error,
retryable=True,
),
recoverable=True,
)
# response errors are handled by _handle_response_done via _done_fut.
# error events here are for non-response errors (e.g. invalid request).
def _emit_error(self, error: Exception, recoverable: bool) -> None:
self.emit(
"error",
llm.RealtimeModelError(
timestamp=time.time(),
label=self._realtime_model._label,
error=error,
recoverable=recoverable,
),
)