litellm/tests/llm_translation/realtime/base_realtime_tests.py
yuneng-jiang 6a0d03914c
test: drop the cwd-relative sys.path.insert calls from the test suite (#37802)
* test: drop the cwd-relative sys.path.insert calls from the test suite

TQ003 stands at 1,077 across 1,058 files, and 1,015 of them are the same shape:
sys.path.insert(0, os.path.abspath("../..")) and its deeper siblings. The
argument resolves against the working directory rather than the file, so from
the repo root, where every job runs pytest, it inserts the directory two levels
above the checkout. It has never pointed at litellm. The package is installed
into the environment anyway, which is what actually makes the import work, and
what the rule's message has said all along.

Removing them leaves 1,634 imports of sys and os with no remaining reference,
and those go too, except where another test module imports the name back out of
the file. The rest of TQ003 is 62 call sites that resolve against __file__ or a
variable, which are a different question and are left alone.

Collection is identical either way: 45,871 tests and the same 51 pre-existing
collection errors before and after, and ruff reports no new undefined name.

* test: drop the duplicate imports the sys.path sweep exposed to F811

* test(pre-call-utils): restore the os import the new bedrock tests need
2026-08-22 09:25:58 -07:00

462 lines
17 KiB
Python

"""
Base test class for LiteLLM Realtime API E2E tests.
Provides common test infrastructure for testing realtime WebSocket connections
across different providers (OpenAI, xAI, etc.)
"""
import asyncio
import json
import os
from abc import ABC, abstractmethod
from typing import Optional, Tuple, Union
import pytest
import websockets
import litellm
class RealTimeWebSocketClient:
"""
Mock WebSocket client for testing realtime connections.
Captures messages sent from the backend and provides a simple interface
for testing connection success.
"""
def __init__(self):
self.messages_sent = []
self.messages_received = []
self.received_initial_event = False
self.connection_successful = False
self.close_code = None
self.close_reason = None
# Required by realtime_streaming.py - import exceptions module
from websockets import exceptions as websockets_exceptions
self.exceptions = websockets_exceptions
async def accept(self):
"""Accept the WebSocket connection"""
pass
async def send_text(self, message):
"""Receive message from backend and store it"""
self.messages_sent.append(message)
try:
if isinstance(message, bytes):
message_str = message.decode("utf-8")
else:
message_str = message
msg_data = json.loads(message_str)
msg_type = msg_data.get("type", "unknown")
# Pretty print API response
print(f"\n{'='*80}")
print(
f"API RESPONSE #{len(self.messages_received) + 1} - Event: {msg_type}"
)
print(f"{'='*80}")
print(json.dumps(msg_data, indent=2, sort_keys=False))
print(f"{'='*80}\n")
self.messages_received.append(msg_data)
# Check for initial connection event
if not self.received_initial_event and self._is_initial_event(msg_type):
self.received_initial_event = True
self.connection_successful = True
except (json.JSONDecodeError, UnicodeDecodeError) as e:
# Non-JSON messages are acceptable
print(f"\n[Non-JSON message: {e}]")
print(f"Raw content: {str(message)[:200]}\n")
pass
def _is_initial_event(self, msg_type: str) -> bool:
"""Check if message type is an initial connection event"""
# OpenAI and xAI send "session.created"; some providers send "conversation.created"
return msg_type in ["session.created", "conversation.created"]
async def receive_text(self):
"""
Wait briefly for messages, then close connection.
This allows the backend forwarding task to send messages.
"""
print(f"\nWaiting for connection to establish...")
max_wait = 5.0
check_interval = 0.1
waited = 0.0
while waited < max_wait:
if self.connection_successful:
print(f"Connection successful after {waited:.1f}s\n")
break
await asyncio.sleep(check_interval)
waited += check_interval
if not self.connection_successful:
print(f"Warning: No initial event received after {max_wait}s\n")
# If we have a pending message to send, send it now
if hasattr(self, "_pending_client_message") and self._pending_client_message:
print(f"Sending client message to backend...\n")
# This simulates receiving a message from the client that needs to be forwarded to backend
# We return it as if it came from the client
msg = self._pending_client_message
self._pending_client_message = None
return msg
# Close connection to end the test
print(f"\n{'='*80}")
print(f"TEST COMPLETE - Closing connection")
print(f"Total messages received from API: {len(self.messages_received)}")
print(f"{'='*80}\n")
raise websockets.exceptions.ConnectionClosed(None, None)
def queue_client_message(self, message: str):
"""Queue a message to be sent from 'client' to backend"""
self._pending_client_message = message
async def close(self, code=1000, reason=""):
"""Close the WebSocket"""
self.close_code = code
self.close_reason = reason
@property
def headers(self):
return {}
class BaseRealtimeTest(ABC):
"""
Abstract base test class for realtime API tests.
Child classes must implement:
- get_model(): Return the model name to test
- get_api_key_env_var(): Return the environment variable name for the API key
- get_initial_event_type(): Return the expected initial event type (e.g., "session.created")
"""
@abstractmethod
def get_model(self) -> str:
"""Return the model name to test (e.g., 'gpt-4o-realtime-preview-2024-10-01')"""
pass
@abstractmethod
def get_api_key_env_var(self) -> str:
"""Return the environment variable name for the API key (e.g., 'OPENAI_API_KEY')"""
pass
@abstractmethod
def get_initial_event_type(self) -> Union[str, Tuple[str, ...]]:
"""Return the expected initial event type(s).
May return a single event type (e.g. ``'session.created'``) or a tuple
of acceptable types when the upstream provider can legitimately emit
more than one initial event (e.g. xAI's Grok Voice Agent has shipped
both ``conversation.created`` and ``session.created``).
"""
pass
def get_skip_reason(self) -> str:
"""Return the skip reason when API key is missing"""
return f"No {self.get_api_key_env_var()} provided"
def should_skip(self) -> bool:
"""Check if tests should be skipped due to missing API key"""
return os.environ.get(self.get_api_key_env_var()) is None
@pytest.mark.asyncio
async def test_realtime_connection(self):
"""
Test basic realtime WebSocket connection.
Verifies that:
1. Connection is established successfully
2. Initial event is received
3. Messages are properly forwarded
"""
litellm._turn_on_debug()
if self.should_skip():
pytest.skip(self.get_skip_reason())
websocket_client = RealTimeWebSocketClient()
caught_exception = None
print(f"\n{'='*80}")
print(f"STARTING REALTIME CONNECTION TEST")
print(f"Model: {self.get_model()}")
print(f"API Key Env Var: {self.get_api_key_env_var()}")
print(f"{'='*80}\n")
try:
await litellm._arealtime(
model=self.get_model(),
websocket=websocket_client,
api_key=os.environ.get(self.get_api_key_env_var()),
timeout=60,
)
except websockets.exceptions.ConnectionClosed:
pass
except Exception as e:
print(f"\nException: {type(e).__name__}: {e}\n")
caught_exception = e
# Build debug info
error_details = []
error_details.append(f"messages_sent: {len(websocket_client.messages_sent)}")
error_details.append(
f"messages_received: {len(websocket_client.messages_received)}"
)
error_details.append(f"close_code: {websocket_client.close_code}")
error_details.append(f"close_reason: {websocket_client.close_reason}")
if caught_exception:
error_details.append(
f"exception: {type(caught_exception).__name__}: {caught_exception}"
)
# Skip on transient connection failures
if (
not websocket_client.connection_successful
and websocket_client.close_code is not None
):
pytest.skip(f"Transient connection failure: {'; '.join(error_details)}")
# Assertions
assert (
websocket_client.connection_successful
), f"Failed to connect. Debug: {'; '.join(error_details)}"
assert websocket_client.received_initial_event, f"Did not receive initial event"
assert len(websocket_client.messages_received) > 0, "No messages received"
# Verify initial event
initial_event = websocket_client.messages_received[0]
expected_event_type = self.get_initial_event_type()
if isinstance(expected_event_type, str):
allowed_event_types: Tuple[str, ...] = (expected_event_type,)
else:
allowed_event_types = tuple(expected_event_type)
assert (
initial_event["type"] in allowed_event_types
), f"Expected one of {allowed_event_types}, got {initial_event.get('type')}"
@pytest.mark.asyncio
async def test_realtime_with_query_params(self):
"""
Test realtime connection with explicit query parameters.
Verifies that query params are properly passed to the backend.
"""
litellm._turn_on_debug()
if self.should_skip():
pytest.skip(self.get_skip_reason())
from litellm.types.realtime import RealtimeQueryParams
websocket_client = RealTimeWebSocketClient()
caught_exception = None
# Strip provider prefix from model name for query params
model_name = self.get_model()
if "/" in model_name:
model_name = model_name.split("/", 1)[1]
query_params: RealtimeQueryParams = {"model": model_name}
try:
await litellm._arealtime(
model=self.get_model(),
websocket=websocket_client,
api_key=os.environ.get(self.get_api_key_env_var()),
query_params=query_params,
timeout=60,
)
except websockets.exceptions.ConnectionClosed:
pass
except Exception as e:
caught_exception = e
# Build debug info
error_details = []
error_details.append(f"messages_sent: {len(websocket_client.messages_sent)}")
error_details.append(
f"messages_received: {len(websocket_client.messages_received)}"
)
if caught_exception:
error_details.append(
f"exception: {type(caught_exception).__name__}: {caught_exception}"
)
# Skip on transient failures
if (
not websocket_client.connection_successful
and websocket_client.close_code is not None
):
pytest.skip(f"Transient connection failure: {'; '.join(error_details)}")
# Assertions
assert (
websocket_client.connection_successful
), f"Failed to connect. Debug: {'; '.join(error_details)}"
assert len(websocket_client.messages_received) > 0, "No messages received"
@pytest.mark.asyncio
async def test_send_user_message(self):
"""
Test sending an actual user message and receiving responses.
This creates a more realistic conversation flow.
"""
if self.should_skip():
pytest.skip(self.get_skip_reason())
litellm._turn_on_debug()
# Create a custom websocket client that sends a message
class InteractiveWebSocketClient(RealTimeWebSocketClient):
def __init__(self):
super().__init__()
self.sent_user_message = False
self.response_messages = []
self.wait_for_responses = True
async def receive_text(self):
"""Enhanced receive that sends a user message after connection"""
print(f"\n{'='*80}")
print(f"CLIENT-SIDE RECEIVE HANDLER")
print(f"{'='*80}\n")
# Wait for initial connection
max_wait = 5.0
check_interval = 0.1
waited = 0.0
while waited < max_wait:
if self.connection_successful:
print(f"Connection established after {waited:.1f}s\n")
break
await asyncio.sleep(check_interval)
waited += check_interval
# Step 1: Send a user message after connection is established
if self.connection_successful and not self.sent_user_message:
self.sent_user_message = True
user_msg_data = {
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Say hi back to me!"}
],
},
}
user_msg = json.dumps(user_msg_data)
print(f"\n{'='*80}")
print(f"STEP 1: SENDING USER MESSAGE TO BACKEND")
print(f"{'='*80}")
print(json.dumps(user_msg_data, indent=2))
print(f"{'='*80}\n")
return user_msg
# Step 2: Trigger the response after user message is acknowledged
if not hasattr(self, "triggered_response"):
self.triggered_response = True
# Wait a bit for the user message to be processed
await asyncio.sleep(0.5)
response_create_data = {"type": "response.create"}
response_create = json.dumps(response_create_data)
print(f"\n{'='*80}")
print(f"STEP 2: TRIGGERING LLM RESPONSE")
print(f"{'='*80}")
print(json.dumps(response_create_data, indent=2))
print(f"{'='*80}\n")
return response_create
# Step 3: Wait for LLM responses
if self.wait_for_responses:
print(f"\nSTEP 3: Waiting 5 seconds for LLM to respond...\n")
await asyncio.sleep(5.0)
self.wait_for_responses = False
# Collect response info
for msg in self.messages_received:
msg_type = msg.get("type", "unknown")
if msg_type not in ["conversation.created", "ping"]:
self.response_messages.append(msg)
print(
f"\nReceived {len(self.response_messages)} response messages (excluding init/ping)\n"
)
print(f"\n{'='*80}")
print(f"CLOSING CONNECTION")
print(f"Total messages received: {len(self.messages_received)}")
print(f"{'='*80}\n")
raise websockets.exceptions.ConnectionClosed(None, None)
websocket_client = InteractiveWebSocketClient()
caught_exception = None
print(f"\n{'='*80}")
print(f"STARTING INTERACTIVE MESSAGE TEST")
print(f"Model: {self.get_model()}")
print(f"Message: 'Say hi back to me!'")
print(f"{'='*80}\n")
try:
await litellm._arealtime(
model=self.get_model(),
websocket=websocket_client,
api_key=os.environ.get(self.get_api_key_env_var()),
timeout=60,
)
except websockets.exceptions.ConnectionClosed:
pass
except Exception as e:
print(f"\nException: {type(e).__name__}: {e}\n")
caught_exception = e
# Print results
print(f"\n{'='*80}")
print(f"TEST RESULTS SUMMARY")
print(f"{'='*80}")
print(f"Connection successful: {websocket_client.connection_successful}")
print(f"User message sent: {websocket_client.sent_user_message}")
print(f"Total messages received: {len(websocket_client.messages_received)}")
print(
f"Response messages (excluding init/ping): {len(websocket_client.response_messages)}"
)
if websocket_client.response_messages:
print(f"\nResponse Event Types:")
for i, msg in enumerate(websocket_client.response_messages, 1):
print(f" {i}. {msg.get('type', 'unknown')}")
print(f"{'='*80}\n")
# Skip if no responses (might be timing issue)
if not websocket_client.response_messages:
pytest.skip("No response messages received (might be timing/network issue)")
assert websocket_client.connection_successful, "Failed to establish connection"
assert websocket_client.sent_user_message, "Failed to send user message"
def test_query_params_construction(self):
"""Test that query params are constructed correctly"""
from litellm.types.realtime import RealtimeQueryParams
# Strip provider prefix from model name
model_name = self.get_model()
if "/" in model_name:
model_name = model_name.split("/", 1)[1]
query_params: RealtimeQueryParams = {"model": model_name}
assert "model" in query_params
assert query_params["model"] == model_name