168 lines
6.1 KiB
Python
168 lines
6.1 KiB
Python
|
|
"""
|
||
|
|
This file contains the calling OpenAI's `/v1/realtime` endpoint.
|
||
|
|
|
||
|
|
This requires websockets, and is currently only supported on LiteLLM Proxy.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from typing import Any, Optional, cast
|
||
|
|
|
||
|
|
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
|
||
|
|
from litellm.types.realtime import RealtimeQueryParams
|
||
|
|
|
||
|
|
from ....litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||
|
|
from ....litellm_core_utils.realtime_streaming import RealTimeStreaming
|
||
|
|
from ....llms.custom_httpx.http_handler import get_shared_realtime_ssl_context
|
||
|
|
from ..openai import OpenAIChatCompletion
|
||
|
|
|
||
|
|
|
||
|
|
class OpenAIRealtime(OpenAIChatCompletion):
|
||
|
|
"""
|
||
|
|
Base handler for OpenAI-compatible realtime WebSocket connections.
|
||
|
|
|
||
|
|
Subclasses can override template methods to customize:
|
||
|
|
- _get_default_api_base(): Default API base URL
|
||
|
|
- _get_additional_headers(): Extra headers beyond Authorization
|
||
|
|
- _get_ssl_config(): SSL configuration for WebSocket connection
|
||
|
|
"""
|
||
|
|
|
||
|
|
def _get_default_api_base(self) -> str:
|
||
|
|
"""
|
||
|
|
Get the default API base URL for this provider.
|
||
|
|
Override this in subclasses to set provider-specific defaults.
|
||
|
|
"""
|
||
|
|
return "https://api.openai.com/"
|
||
|
|
|
||
|
|
def _get_additional_headers(self, api_key: str) -> dict:
|
||
|
|
"""
|
||
|
|
Get additional headers beyond Authorization.
|
||
|
|
Override this in subclasses to customize headers (e.g., remove OpenAI-Beta).
|
||
|
|
|
||
|
|
Args:
|
||
|
|
api_key: API key for authentication
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Dictionary of additional headers
|
||
|
|
"""
|
||
|
|
return {
|
||
|
|
"Authorization": f"Bearer {api_key}",
|
||
|
|
"OpenAI-Beta": "realtime=v1",
|
||
|
|
}
|
||
|
|
|
||
|
|
def _get_ssl_config(self, url: str) -> Any:
|
||
|
|
"""
|
||
|
|
Get SSL configuration for WebSocket connection.
|
||
|
|
Override this in subclasses to customize SSL behavior.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
url: WebSocket URL (ws:// or wss://)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
SSL configuration (None, True, or SSLContext)
|
||
|
|
"""
|
||
|
|
if url.startswith("ws://"):
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Use the shared SSL context which respects custom CA certs and SSL settings
|
||
|
|
ssl_config = get_shared_realtime_ssl_context()
|
||
|
|
|
||
|
|
# If ssl_config is False (ssl_verify=False), websockets library needs True instead
|
||
|
|
# to establish connection without verification (False would fail)
|
||
|
|
if ssl_config is False:
|
||
|
|
return True
|
||
|
|
|
||
|
|
return ssl_config
|
||
|
|
|
||
|
|
def _construct_url(self, api_base: str, query_params: RealtimeQueryParams) -> str:
|
||
|
|
"""
|
||
|
|
Construct the backend websocket URL with all query parameters (including 'model').
|
||
|
|
"""
|
||
|
|
from httpx import URL
|
||
|
|
|
||
|
|
api_base = api_base.replace("https://", "wss://")
|
||
|
|
api_base = api_base.replace("http://", "ws://")
|
||
|
|
url = URL(api_base)
|
||
|
|
# Set the correct path
|
||
|
|
url = url.copy_with(path="/v1/realtime")
|
||
|
|
# Include all query parameters including 'model'
|
||
|
|
if query_params:
|
||
|
|
url = url.copy_with(params=query_params)
|
||
|
|
return str(url)
|
||
|
|
|
||
|
|
async def async_realtime(
|
||
|
|
self,
|
||
|
|
model: str,
|
||
|
|
websocket: Any,
|
||
|
|
logging_obj: LiteLLMLogging,
|
||
|
|
api_base: Optional[str] = None,
|
||
|
|
api_key: Optional[str] = None,
|
||
|
|
client: Optional[Any] = None,
|
||
|
|
timeout: Optional[float] = None,
|
||
|
|
query_params: Optional[RealtimeQueryParams] = None,
|
||
|
|
user_api_key_dict: Optional[Any] = None,
|
||
|
|
litellm_metadata: Optional[dict] = None,
|
||
|
|
**kwargs: Any,
|
||
|
|
):
|
||
|
|
import websockets
|
||
|
|
from websockets.asyncio.client import ClientConnection
|
||
|
|
|
||
|
|
if api_base is None:
|
||
|
|
api_base = self._get_default_api_base()
|
||
|
|
if api_key is None:
|
||
|
|
raise ValueError("api_key is required for OpenAI realtime calls")
|
||
|
|
|
||
|
|
# Use all query params if provided, else fallback to just model
|
||
|
|
if query_params is None:
|
||
|
|
query_params = {"model": model}
|
||
|
|
url = self._construct_url(api_base, query_params)
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Get provider-specific SSL configuration
|
||
|
|
ssl_config = self._get_ssl_config(url)
|
||
|
|
|
||
|
|
# Get provider-specific headers
|
||
|
|
headers = self._get_additional_headers(api_key)
|
||
|
|
|
||
|
|
# Log a masked request preview consistent with other endpoints.
|
||
|
|
logging_obj.pre_call(
|
||
|
|
input=None,
|
||
|
|
api_key=api_key,
|
||
|
|
additional_args={
|
||
|
|
"api_base": url,
|
||
|
|
"headers": headers,
|
||
|
|
"complete_input_dict": {"query_params": query_params},
|
||
|
|
},
|
||
|
|
)
|
||
|
|
async with websockets.connect( # type: ignore
|
||
|
|
url,
|
||
|
|
additional_headers=headers, # type: ignore
|
||
|
|
max_size=REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES,
|
||
|
|
ssl=ssl_config,
|
||
|
|
) as backend_ws:
|
||
|
|
realtime_streaming = RealTimeStreaming(
|
||
|
|
websocket,
|
||
|
|
cast(ClientConnection, backend_ws),
|
||
|
|
logging_obj,
|
||
|
|
user_api_key_dict=user_api_key_dict,
|
||
|
|
request_data={"litellm_metadata": litellm_metadata or {}},
|
||
|
|
)
|
||
|
|
await realtime_streaming.bidirectional_forward()
|
||
|
|
|
||
|
|
except websockets.exceptions.InvalidStatusCode as e: # type: ignore
|
||
|
|
await websocket.close(code=e.status_code, reason=str(e))
|
||
|
|
except Exception as e:
|
||
|
|
try:
|
||
|
|
await websocket.close(
|
||
|
|
code=1011, reason=f"Internal server error: {str(e)}"
|
||
|
|
)
|
||
|
|
except RuntimeError as close_error:
|
||
|
|
if "already completed" in str(close_error) or "websocket.close" in str(
|
||
|
|
close_error
|
||
|
|
):
|
||
|
|
# The WebSocket is already closed or the response is completed, so we can ignore this error
|
||
|
|
pass
|
||
|
|
else:
|
||
|
|
# If it's a different RuntimeError, we might want to log it or handle it differently
|
||
|
|
raise Exception(
|
||
|
|
f"Unexpected error while closing WebSocket: {close_error}"
|
||
|
|
)
|