[Feat] Add xAI /realtime API Support - works with LiveKitSDK (#20381)

* init: _realtime_health_check + routing

* refactor: OpenAIRealtime

* refactor: XAI_API_BASE

* feat: XAIRealtime

* init feat: XAIRealtime

* OpenAIRealtime

* TestXAIRealtime

* test fixes

* test OAI

* TEST xAI, OAI

* clean realtime jobs

* refactor

* test XAI

* docs xAI

* fix xAI

* fix lint errors

* test_async_realtime_url_contains_model

* test fix

* document test changes

* _realtime_health_check

* docs xai realtime

* fix handlers

* add additional_headers

* fix
This commit is contained in:
Ishaan Jaff
2026-02-03 19:58:28 -08:00
committed by GitHub
parent 7b57d1acf1
commit da4cf4942f
24 changed files with 1578 additions and 24 deletions
+66 -2
View File
@@ -1255,7 +1255,15 @@ jobs:
ls
# Add --timeout to kill hanging tests after 120s (2 min)
# Add --durations=20 to show 20 slowest tests for debugging
python -m pytest -vv tests/llm_translation --cov=litellm --cov-report=xml -v --junitxml=test-results/junit.xml --durations=20 -n 4 --timeout=120 --timeout_method=thread
# Subdirectories with dedicated jobs (maintain this list as new jobs are added)
IGNORE_DIRS=(
"tests/llm_translation/realtime"
)
IGNORE_ARGS=""
for dir in "${IGNORE_DIRS[@]}"; do
IGNORE_ARGS="$IGNORE_ARGS --ignore=$dir"
done
python -m pytest -vv tests/llm_translation $IGNORE_ARGS --cov=litellm --cov-report=xml -v --junitxml=test-results/junit.xml --durations=20 -n 4 --timeout=120 --timeout_method=thread
no_output_timeout: 120m
- run:
name: Rename the coverage files
@@ -1271,6 +1279,54 @@ jobs:
paths:
- llm_translation_coverage.xml
- llm_translation_coverage
realtime_translation_testing:
docker:
- image: cimg/python:3.11
auth:
username: ${DOCKERHUB_USERNAME}
password: ${DOCKERHUB_PASSWORD}
working_directory: ~/project
steps:
- checkout
- setup_google_dns
- run:
name: Install Dependencies
command: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
pip install "pytest==7.3.1"
pip install "pytest-retry==1.6.3"
pip install "pytest-cov==5.0.0"
pip install "pytest-asyncio==0.21.1"
pip install "respx==0.22.0"
pip install "pytest-xdist==3.6.1"
pip install "pytest-timeout==2.2.0"
pip install "websockets"
# Run pytest and generate JUnit XML report
- run:
name: Run realtime tests
command: |
pwd
ls
# Add --timeout to kill hanging tests after 120s (2 min)
# Add --durations=20 to show 20 slowest tests for debugging
python -m pytest -vv tests/llm_translation/realtime --cov=litellm --cov-report=xml -v --junitxml=test-results/junit.xml --durations=20 -n 4 --timeout=120 --timeout_method=thread
no_output_timeout: 120m
- run:
name: Rename the coverage files
command: |
mv coverage.xml realtime_translation_coverage.xml
mv .coverage realtime_translation_coverage
# Store test results
- store_test_results:
path: test-results
- persist_to_workspace:
root: .
paths:
- realtime_translation_coverage.xml
- realtime_translation_coverage
mcp_testing:
docker:
- image: cimg/python:3.11
@@ -3532,7 +3588,7 @@ jobs:
python -m venv venv
. venv/bin/activate
pip install coverage
coverage combine llm_translation_coverage llm_responses_api_coverage ocr_coverage search_coverage mcp_coverage logging_coverage audio_coverage litellm_router_coverage litellm_router_unit_coverage local_testing_part1_coverage local_testing_part2_coverage litellm_assistants_api_coverage auth_ui_unit_tests_coverage langfuse_coverage caching_coverage litellm_proxy_unit_tests_part1_coverage litellm_proxy_unit_tests_part2_coverage image_gen_coverage pass_through_unit_tests_coverage batches_coverage litellm_security_tests_coverage guardrails_coverage litellm_mapped_tests_coverage
coverage combine llm_translation_coverage realtime_translation_coverage llm_responses_api_coverage ocr_coverage search_coverage mcp_coverage logging_coverage audio_coverage litellm_router_coverage litellm_router_unit_coverage local_testing_part1_coverage local_testing_part2_coverage litellm_assistants_api_coverage auth_ui_unit_tests_coverage langfuse_coverage caching_coverage litellm_proxy_unit_tests_part1_coverage litellm_proxy_unit_tests_part2_coverage image_gen_coverage pass_through_unit_tests_coverage batches_coverage litellm_security_tests_coverage guardrails_coverage litellm_mapped_tests_coverage
coverage xml
- codecov/upload:
file: ./coverage.xml
@@ -4196,6 +4252,12 @@ workflows:
only:
- main
- /litellm_.*/
- realtime_translation_testing:
filters:
branches:
only:
- main
- /litellm_.*/
- mcp_testing:
filters:
branches:
@@ -4307,6 +4369,7 @@ workflows:
- upload-coverage:
requires:
- llm_translation_testing
- realtime_translation_testing
- mcp_testing
- google_generate_content_endpoint_testing
- guardrails_testing
@@ -4384,6 +4447,7 @@ workflows:
- e2e_openai_endpoints
- test_bad_database_url
- llm_translation_testing
- realtime_translation_testing
- mcp_testing
- google_generate_content_endpoint_testing
- llm_responses_api_testing
+114
View File
@@ -0,0 +1,114 @@
# LiveKit Voice Agent with LiteLLM Gateway
Simple example showing how to use LiveKit's xAI realtime plugin with LiteLLM as a proxy. This lets you switch between xAI, OpenAI, and Azure realtime APIs without changing your code.
## Quick Start
### 1. Install dependencies
```bash
pip install livekit-agents[xai] websockets
```
### 2. Start LiteLLM proxy
```bash
# With xAI
export XAI_API_KEY="your-xai-key"
litellm --config config.yaml --port 4000
```
### 3. Run the voice agent
```bash
python main.py
```
Type your message and get a voice response from Grok!
## Configuration
Set these environment variables if needed:
```bash
export LITELLM_PROXY_URL="http://localhost:4000"
export LITELLM_API_KEY="sk-1234"
export LITELLM_MODEL="grok-voice-agent"
```
Or use the defaults - connects to `http://localhost:4000` by default.
## Example Config File
Create a `config.yaml` with your realtime models:
```yaml
model_list:
- model_name: grok-voice-agent
litellm_params:
model: xai/grok-2-vision-1212
api_key: os.environ/XAI_API_KEY
model_info:
mode: realtime
- model_name: openai-voice-agent
litellm_params:
model: gpt-4o-realtime-preview
api_key: os.environ/OPENAI_API_KEY
model_info:
mode: realtime
general_settings:
master_key: sk-1234
```
Then start: `litellm --config config.yaml --port 4000`
## How It Works
LiveKit's xAI plugin connects through LiteLLM proxy by setting `base_url`:
```python
from livekit.plugins import xai
model = xai.realtime.RealtimeModel(
voice="ara",
api_key="sk-1234", # LiteLLM proxy key
base_url="http://localhost:4000", # Point to LiteLLM
)
```
## Switching Providers
Just change the model in your config - no code changes needed:
**xAI Grok:**
```yaml
model: xai/grok-2-vision-1212
```
**OpenAI:**
```yaml
model: gpt-4o-realtime-preview
```
**Azure OpenAI:**
```yaml
model: azure/gpt-4o-realtime-preview
api_base: https://your-endpoint.openai.azure.com/
```
## Why Use LiteLLM?
-**Switch providers** without changing agent code
-**Cost tracking** across all voice sessions
-**Rate limiting** and budgets
-**Load balancing** across multiple API keys
-**Fallbacks** to backup models
## Learn More
- [LiveKit xAI Realtime Tutorial](/docs/tutorials/livekit_xai_realtime)
- [xAI Realtime Docs](/docs/providers/xai_realtime)
- [LiveKit Agents Documentation](https://docs.livekit.io/agents/)
- [LiteLLM Realtime API](/docs/realtime)
@@ -0,0 +1,21 @@
model_list:
- model_name: grok-voice-agent
litellm_params:
model: xai/grok-2-vision-1212
api_key: os.environ/XAI_API_KEY
model_info:
mode: realtime
- model_name: openai-voice-agent
litellm_params:
model: gpt-4o-realtime-preview
api_key: os.environ/OPENAI_API_KEY
model_info:
mode: realtime
litellm_settings:
drop_params: True
telemetry: False
general_settings:
master_key: sk-1234 # Change this to a secure key
+112
View File
@@ -0,0 +1,112 @@
"""
Simple xAI Voice Agent using LiveKit SDK with LiteLLM Gateway
This example shows how to use LiveKit's xAI realtime plugin through LiteLLM proxy.
LiteLLM acts as a unified interface, allowing you to switch between xAI, OpenAI,
and Azure realtime APIs without changing your agent code.
"""
import asyncio
import json
import os
import websockets
# Configuration
PROXY_URL = os.getenv("LITELLM_PROXY_URL", "http://localhost:4000")
API_KEY = os.getenv("LITELLM_API_KEY", "sk-1234")
MODEL = os.getenv("LITELLM_MODEL", "grok-voice-agent")
async def run_voice_agent():
"""
Simple voice agent that:
1. Connects to xAI realtime API through LiteLLM proxy
2. Sends a user message
3. Streams back the response
"""
url = f"ws://{PROXY_URL.replace('http://', '').replace('https://', '')}/v1/realtime?model={MODEL}"
headers = {"Authorization": f"Bearer {API_KEY}"}
print(f"🎙️ Connecting to voice agent...")
print(f" Model: {MODEL}")
print(f" Proxy: {PROXY_URL}")
print()
async with websockets.connect(url, additional_headers=headers) as ws:
# Receive initial connection event
initial = json.loads(await ws.recv())
print(f"✅ Connected! Event: {initial['type']}\n")
# Get user input
user_message = input("💬 Your message: ").strip()
if not user_message:
user_message = "Tell me a fun fact about AI!"
print(f"\n🤖 Sending to {MODEL}...\n")
# Send user message
await ws.send(json.dumps({
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": user_message}]
}
}))
# Request response
await ws.send(json.dumps({
"type": "response.create",
"response": {"modalities": ["text", "audio"]}
}))
# Stream response
print("🎤 Response: ", end='', flush=True)
transcript = []
try:
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=15.0)
event = json.loads(msg)
# Capture transcript deltas
if event['type'] == 'response.output_audio_transcript.delta':
delta = event.get('delta', '')
if delta:
print(delta, end='', flush=True)
transcript.append(delta)
# Done when response completes
elif event['type'] == 'response.done':
break
except asyncio.TimeoutError:
pass
print("\n")
if transcript:
print(f"✅ Complete response: {''.join(transcript)}")
await ws.close()
def main():
"""Run the voice agent"""
print("=" * 70)
print("LiveKit xAI Voice Agent via LiteLLM Proxy")
print("=" * 70)
print()
try:
asyncio.run(run_voice_agent())
except KeyboardInterrupt:
print("\n\n👋 Goodbye!")
except Exception as e:
print(f"\n❌ Error: {e}")
print("\nMake sure LiteLLM proxy is running:")
print(f" litellm --config config.yaml --port 4000")
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
livekit-agents[xai]>=1.3.12
websockets>=15.0.1
@@ -0,0 +1,308 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# xAI Voice Agent (Realtime API)
xAI's Grok Voice Agent provides real-time voice conversation capabilities through WebSocket connections, enabling natural bidirectional audio interactions.
| Feature | Description | Comments |
| --- | --- | --- |
| LiteLLM AI Gateway | ✅ | |
| LiteLLM Python SDK | ✅ | Full support via `litellm.realtime()` |
## Quick Start
### Supported Model
| Model | Context | Features |
|-------|---------|----------|
| `xai/grok-4-1-fast-non-reasoning` | 2M tokens | Voice conversation, Function calling, Vision, Audio, Web search, Caching |
**Note:** xAI Realtime API uses the non-reasoning variant for optimal real-time performance.
## Python SDK Usage
### Basic Realtime Connection
```python
import asyncio
from litellm import realtime
async def test_xai_realtime():
"""
Test xAI Grok Voice Agent via LiteLLM SDK
"""
# Initialize realtime connection
ws = await realtime(
model="xai/grok-4-1-fast-non-reasoning",
api_key="your-xai-api-key", # or set XAI_API_KEY env var
)
# Connection established, xAI sends "conversation.created" event
print("Connected to xAI Grok Voice Agent")
# Send a message
await ws.send_text(json.dumps({
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "Hello! How are you?"
}]
}
}))
# Request a response
await ws.send_text(json.dumps({
"type": "response.create"
}))
# Listen for responses
async for message in ws:
data = json.loads(message)
print(f"Received: {data['type']}")
if data['type'] == 'response.done':
break
await ws.close()
# Run the async function
asyncio.run(test_xai_realtime())
```
### With Audio Input/Output
```python
import asyncio
import json
from litellm import realtime
async def xai_voice_conversation():
"""
Voice conversation with xAI Grok Voice Agent
"""
ws = await realtime(
model="xai/grok-4-1-fast-non-reasoning",
api_key="your-xai-api-key",
)
# Send audio data (base64 encoded PCM16 24kHz)
await ws.send_text(json.dumps({
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{
"type": "input_audio",
"audio": "base64_encoded_audio_data_here"
}]
}
}))
# Request response with audio
await ws.send_text(json.dumps({
"type": "response.create",
"response": {
"modalities": ["text", "audio"],
"instructions": "Please respond in a friendly tone."
}
}))
# Process streaming audio response
async for message in ws:
data = json.loads(message)
if data['type'] == 'response.audio.delta':
# Handle audio chunks
audio_chunk = data['delta']
# Process audio_chunk (play it, save it, etc.)
elif data['type'] == 'response.done':
break
await ws.close()
asyncio.run(xai_voice_conversation())
```
## LiteLLM Proxy (AI Gateway) Usage
Load balance across multiple xAI deployments or combine with other providers.
### 1. Add Model to Config
```yaml
model_list:
- model_name: grok-voice-agent
litellm_params:
model: xai/grok-4-1-fast-non-reasoning
api_key: os.environ/XAI_API_KEY
model_info:
mode: realtime
# Optional: Add fallback to OpenAI
- model_name: grok-voice-agent
litellm_params:
model: openai/gpt-4o-realtime-preview-2024-10-01
api_key: os.environ/OPENAI_API_KEY
model_info:
mode: realtime
```
### 2. Start Proxy
```bash
litellm --config /path/to/config.yaml
# RUNNING on http://0.0.0.0:4000
```
### 3. Test Connection
#### Python Client
```python
import asyncio
import websockets
import json
async def test_proxy():
url = "ws://0.0.0.0:4000/v1/realtime?model=grok-voice-agent"
async with websockets.connect(
url,
extra_headers={
"Authorization": "Bearer sk-1234", # Your LiteLLM proxy key
"OpenAI-Beta": "realtime=v1"
}
) as ws:
# Wait for conversation.created event from xAI
message = await ws.recv()
print(f"Connected: {message}")
# Send a message
await ws.send(json.dumps({
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "Hello from LiteLLM proxy!"
}]
}
}))
# Request response
await ws.send(json.dumps({
"type": "response.create"
}))
# Listen for response
async for message in ws:
data = json.loads(message)
print(f"Event: {data['type']}")
if data['type'] == 'response.done':
break
asyncio.run(test_proxy())
```
#### Node.js Client
```javascript
// test.js - Run with: node test.js
const WebSocket = require("ws");
const url = "ws://0.0.0.0:4000/v1/realtime?model=grok-voice-agent";
const ws = new WebSocket(url, {
headers: {
"Authorization": "Bearer sk-1234",
"OpenAI-Beta": "realtime=v1",
},
});
ws.on("open", function open() {
console.log("Connected to xAI via LiteLLM proxy");
// Send a message
ws.send(JSON.stringify({
type: "conversation.item.create",
item: {
type: "message",
role: "user",
content: [{
type: "input_text",
text: "What's the weather like?"
}]
}
}));
// Request response
ws.send(JSON.stringify({
type: "response.create",
response: {
modalities: ["text"],
instructions: "Please assist the user."
}
}));
});
ws.on("message", function incoming(message) {
const data = JSON.parse(message.toString());
console.log(`Event: ${data.type}`);
if (data.type === 'response.done') {
ws.close();
}
});
ws.on("error", function handleError(error) {
console.error("Error: ", error);
});
```
## Key Differences from OpenAI
xAI's Grok Voice Agent has some differences from OpenAI's Realtime API:
| Feature | xAI | OpenAI | LiteLLM Handling |
|---------|-----|--------|------------------|
| Initial Event | `conversation.created` | `session.created` | ⚠️ Passed through as-is |
| WebSocket URL | `wss://api.x.ai/v1/realtime` | `wss://api.openai.com/v1/realtime` | ✅ Auto-configured |
| Model | `grok-4-1-fast-non-reasoning` | `gpt-4o-realtime-preview` | ✅ Via model prefix |
| Audio Format | PCM16 24kHz mono | PCM16 24kHz mono | ✅ Compatible |
| Context Window | 2M tokens | 128K tokens | N/A |
**What LiteLLM Handles:**
- ✅ Automatic URL routing to correct provider
- ✅ Authentication headers (no `OpenAI-Beta` header for xAI)
- ✅ WebSocket connection management
- ✅ All other event types are compatible
**What You Need to Handle:**
- ⚠️ Initial event type difference (`conversation.created` vs `session.created`)
**Tip:** Make your client compatible with both event types:
```python
# Handle both providers
if event['type'] in ['session.created', 'conversation.created']:
print("Connection established")
```
## Related Documentation
- [xAI Chat/Text Models](/docs/providers/xai)
- [LiteLLM Realtime API Overview](/docs/realtime)
- [xAI Official Documentation](https://docs.x.ai/docs)
## Support
For issues or questions:
- [LiteLLM GitHub Issues](https://github.com/BerriAI/litellm/issues)
- [xAI Documentation](https://docs.x.ai/docs)
+17 -1
View File
@@ -3,11 +3,12 @@ import TabItem from '@theme/TabItem';
# /realtime
Use this to loadbalance across Azure + OpenAI.
Use this to loadbalance across Azure + OpenAI + xAI and more.
Supported Providers:
- OpenAI
- Azure
- xAI ([see full docs](/docs/providers/xai_realtime))
- Google AI Studio (Gemini)
- Vertex AI
- Bedrock
@@ -46,6 +47,21 @@ model_list:
api_key: os.environ/OPENAI_API_KEY
```
</TabItem>
<TabItem value="xai" label="xAI Grok Voice Agent">
```yaml
model_list:
- model_name: grok-voice-agent
litellm_params:
model: xai/grok-4-1-fast-non-reasoning
api_key: os.environ/XAI_API_KEY
model_info:
mode: realtime
```
**[See full xAI Realtime documentation →](/docs/providers/xai_realtime)**
</TabItem>
</Tabs>
@@ -0,0 +1,205 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# LiveKit xAI Realtime Voice Agent
Use LiveKit's xAI Grok Voice Agent plugin with LiteLLM Proxy to build low-latency voice AI agents.
The LiveKit Agents framework provides tools for building real-time voice and video AI applications. By routing through LiteLLM Proxy, you get unified access to multiple realtime voice providers, cost tracking, rate limiting, and more.
## Quick Start
### 1. Install Dependencies
```bash
pip install livekit-agents[xai]
```
### 2. Start LiteLLM Proxy
Create a config file with your xAI realtime model:
```yaml title="config.yaml" showLineNumbers
model_list:
- model_name: grok-voice-agent
litellm_params:
model: xai/grok-2-vision-1212
api_key: os.environ/XAI_API_KEY
model_info:
mode: realtime
litellm_settings:
drop_params: True
general_settings:
master_key: sk-1234 # Change this to a secure key
```
Start the proxy:
```bash
litellm --config config.yaml --port 4000
```
### 3. Configure LiveKit xAI Plugin
Point LiveKit's xAI plugin to your LiteLLM proxy:
```python
from livekit.plugins import xai
# Configure xAI to use LiteLLM proxy
model = xai.realtime.RealtimeModel(
voice="ara", # Voice option
api_key="sk-1234", # Your LiteLLM proxy master key
base_url="http://localhost:4000", # LiteLLM proxy URL
)
```
## Complete Example
Here's a complete working example:
<Tabs>
<TabItem value="python" label="Python Client">
```python
#!/usr/bin/env python3
"""
Simple xAI realtime voice agent through LiteLLM proxy.
"""
import asyncio
import json
import websockets
PROXY_URL = "ws://localhost:4000/v1/realtime"
API_KEY = "sk-1234"
MODEL = "grok-voice-agent"
async def run_voice_agent():
"""Connect to xAI realtime API through LiteLLM proxy"""
url = f"{PROXY_URL}?model={MODEL}"
headers = {"Authorization": f"Bearer {API_KEY}"}
async with websockets.connect(url, extra_headers=headers) as ws:
# Wait for initial connection event
initial = json.loads(await ws.recv())
print(f"✅ Connected: {initial['type']}")
# Send user message
await ws.send(json.dumps({
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "Hello! Tell me a joke."
}]
}
}))
# Request response
await ws.send(json.dumps({
"type": "response.create",
"response": {"modalities": ["text", "audio"]}
}))
# Collect response
transcript = []
async for message in ws:
event = json.loads(message)
# Capture text response
if event['type'] == 'response.output_audio_transcript.delta':
transcript.append(event['delta'])
print(event['delta'], end='', flush=True)
# Done when response completes
elif event['type'] == 'response.done':
break
print(f"\n\n✅ Full response: {''.join(transcript)}")
if __name__ == "__main__":
asyncio.run(run_voice_agent())
```
</TabItem>
<TabItem value="livekit" label="LiveKit Agent">
```python
from livekit.agents import Agent, AgentSession, WorkerOptions, cli
from livekit.plugins import xai
class VoiceAgent(Agent):
def __init__(self):
super().__init__(
instructions="You are a helpful voice assistant.",
llm=xai.realtime.RealtimeModel(
voice="ara",
api_key="sk-1234",
base_url="http://localhost:4000",
),
)
if __name__ == "__main__":
cli.run_app(
WorkerOptions(
agent_factory=VoiceAgent,
)
)
```
</TabItem>
</Tabs>
## Running the Example
1. **Start LiteLLM Proxy** (if not already running):
```bash
litellm --config config.yaml --port 4000
```
2. **Run the example**:
```bash
python your_script.py
```
## Expected Output
```
✅ Connected: conversation.created
Hello! Here's a joke for you: Why don't scientists trust atoms?
Because they make up everything!
✅ Full response: Hello! Here's a joke for you: Why don't scientists trust atoms? Because they make up everything!
```
## Complete Working Example
**[LiveKit Agent SDK Cookbook](https://github.com/BerriAI/litellm/tree/main/cookbook/livekit_agent_sdk)**
Includes:
- ✅ Simple voice agent (`main.py`)
- ✅ Config example (`config.example.yaml`)
- ✅ How to run guide (`HOW_TO_RUN.md`)
### Quick Test
```bash
# 1. Start proxy
poetry run litellm --config cookbook/livekit_agent_sdk/config.example.yaml --port 4000
# 2. Run agent (in new terminal)
cd cookbook/livekit_agent_sdk
poetry run python main.py
```
## Learn More
- [xAI Realtime API](/docs/providers/xai_realtime)
- [LiveKit xAI Plugin](https://docs.livekit.io/agents/models/realtime/plugins/xai/)
- [LiteLLM Realtime API](/docs/realtime)
+9 -1
View File
@@ -151,6 +151,7 @@ const sidebars = {
items: [
"tutorials/claude_agent_sdk",
"tutorials/google_adk",
"tutorials/livekit_xai_realtime",
]
},
@@ -851,7 +852,14 @@ const sidebars = {
"providers/watsonx/audio_transcription",
]
},
"providers/xai",
{
type: "category",
label: "xAI",
items: [
"providers/xai",
"providers/xai_realtime",
]
},
"providers/xiaomi_mimo",
"providers/xinference",
"providers/zai",
+3
View File
@@ -99,6 +99,9 @@ DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET = int(
os.getenv("DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET", 128)
)
# Provider-specific API base URLs
XAI_API_BASE = "https://api.x.ai/v1"
DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET = int(
os.getenv("DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET", 1024)
)
+67 -13
View File
@@ -16,6 +16,62 @@ 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').
@@ -45,8 +101,9 @@ class OpenAIRealtime(OpenAIChatCompletion):
):
import websockets
from websockets.asyncio.client import ClientConnection
if api_base is None:
api_base = "https://api.openai.com/"
api_base = self._get_default_api_base()
if api_key is None:
raise ValueError("api_key is required for OpenAI realtime calls")
@@ -56,30 +113,27 @@ class OpenAIRealtime(OpenAIChatCompletion):
url = self._construct_url(api_base, query_params)
try:
# Only use SSL context for secure websocket connections (wss://)
# websockets library doesn't accept ssl argument for ws:// URIs
ssl_context = None if url.startswith("ws://") else get_shared_realtime_ssl_context()
# 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": {
"Authorization": f"Bearer {api_key}",
"OpenAI-Beta": "realtime=v1",
},
"headers": headers,
"complete_input_dict": {"query_params": query_params},
},
)
async with websockets.connect( # type: ignore
url,
additional_headers={
"Authorization": f"Bearer {api_key}", # type: ignore
"OpenAI-Beta": "realtime=v1",
},
additional_headers=headers, # type: ignore
max_size=REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES,
ssl=ssl_context,
ssl=ssl_config,
) as backend_ws:
realtime_streaming = RealTimeStreaming(
websocket, cast(ClientConnection, backend_ws), logging_obj
+1 -2
View File
@@ -4,6 +4,7 @@ import httpx
import litellm
from litellm._logging import verbose_logger
from litellm.constants import XAI_API_BASE
from litellm.litellm_core_utils.prompt_templates.common_utils import (
filter_value_from_dict,
strip_name_from_messages,
@@ -14,8 +15,6 @@ from litellm.types.utils import Choices, ModelResponse, Usage, PromptTokensDetai
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
XAI_API_BASE = "https://api.x.ai/v1"
class XAIChatConfig(OpenAIGPTConfig):
@property
+5
View File
@@ -0,0 +1,5 @@
"""xAI Realtime API handler."""
from .handler import XAIRealtime
__all__ = ["XAIRealtime"]
+38
View File
@@ -0,0 +1,38 @@
"""
This file contains the handler for xAI's Grok Voice Agent API `/v1/realtime` endpoint.
xAI's Realtime API is fully OpenAI-compatible, so we inherit from OpenAIRealtime
and only override the configuration differences.
This requires websockets, and is currently only supported on LiteLLM Proxy.
"""
from litellm.constants import XAI_API_BASE
from ...openai.realtime.handler import OpenAIRealtime
class XAIRealtime(OpenAIRealtime):
"""
Handler for xAI Grok Voice Agent API.
xAI's Realtime API uses the same WebSocket protocol as OpenAI but with:
- Different endpoint: wss://api.x.ai/v1/realtime (via _get_default_api_base)
- No OpenAI-Beta header required (via _get_additional_headers)
- Model: grok-4-1-fast-non-reasoning
All WebSocket logic is inherited from OpenAIRealtime.
"""
def _get_default_api_base(self) -> str:
"""xAI uses a different API base URL."""
return XAI_API_BASE
def _get_additional_headers(self, api_key: str) -> dict:
"""
xAI does NOT require the OpenAI-Beta header.
Only send Authorization header.
"""
return {
"Authorization": f"Bearer {api_key}",
}
+1 -2
View File
@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
import litellm
from litellm._logging import verbose_logger
from litellm.constants import XAI_API_BASE
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
@@ -16,8 +17,6 @@ if TYPE_CHECKING:
else:
LiteLLMLoggingObj = Any
XAI_API_BASE = "https://api.x.ai/v1"
class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
"""
+30
View File
@@ -19,11 +19,13 @@ from ..llms.azure.realtime.handler import AzureOpenAIRealtime
from ..llms.bedrock.realtime.handler import BedrockRealtime
from ..llms.custom_httpx.http_handler import get_shared_realtime_ssl_context
from ..llms.openai.realtime.handler import OpenAIRealtime
from ..llms.xai.realtime.handler import XAIRealtime
from ..utils import client as wrapper_client
azure_realtime = AzureOpenAIRealtime()
openai_realtime = OpenAIRealtime()
bedrock_realtime = BedrockRealtime()
xai_realtime = XAIRealtime()
base_llm_http_handler = BaseLLMHTTPHandler()
@@ -188,6 +190,30 @@ async def _arealtime(
aws_bedrock_runtime_endpoint=aws_bedrock_runtime_endpoint,
aws_external_id=aws_external_id,
)
elif _custom_llm_provider == "xai":
api_base = (
dynamic_api_base
or litellm_params.api_base
or get_secret_str("XAI_API_BASE")
or "https://api.x.ai/v1"
)
# set API KEY
api_key = (
dynamic_api_key
or litellm.api_key
or get_secret_str("XAI_API_KEY")
)
await xai_realtime.async_realtime(
model=model,
websocket=websocket,
logging_obj=litellm_logging_obj,
api_base=api_base,
api_key=api_key,
client=None,
timeout=timeout,
query_params=query_params,
)
else:
raise ValueError(f"Unsupported model: {model}")
@@ -230,6 +256,10 @@ async def _realtime_health_check(
url = openai_realtime._construct_url(
api_base=api_base or "https://api.openai.com/", query_params={"model": model}
)
elif custom_llm_provider == "xai":
url = xai_realtime._construct_url(
api_base=api_base or "https://api.x.ai/v1", query_params={"model": model}
)
else:
raise ValueError(f"Unsupported model: {model}")
ssl_context = get_shared_realtime_ssl_context()
+2 -1
View File
@@ -2183,7 +2183,8 @@
"batches": false,
"rerank": false,
"a2a": true,
"interactions": true
"interactions": true,
"realtime": true
}
},
"xinference": {
@@ -0,0 +1,426 @@
"""
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
import sys
from abc import ABC, abstractmethod
from typing import Optional
import pytest
import websockets
sys.path.insert(0, os.path.abspath("../../.."))
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 sends "session.created", xAI sends "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) -> str:
"""Return the expected initial event type (e.g., 'session.created' or 'conversation.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]
assert initial_event["type"] == self.get_initial_event_type(), \
f"Expected {self.get_initial_event_type()}, 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
@@ -0,0 +1,29 @@
"""
OpenAI Realtime API E2E Tests (using base class)
Tests OpenAI's Realtime API through LiteLLM's realtime interface.
Uses the base test class to ensure consistent behavior across providers.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath("../../.."))
from tests.llm_translation.realtime.base_realtime_tests import BaseRealtimeTest
class TestOpenAIRealtime(BaseRealtimeTest):
"""
E2E tests for OpenAI Realtime API using base test class.
"""
def get_model(self) -> str:
return "gpt-4o-realtime-preview"
def get_api_key_env_var(self) -> str:
return "OPENAI_API_KEY"
def get_initial_event_type(self) -> str:
return "session.created"
@@ -0,0 +1,34 @@
"""
xAI Realtime API E2E Tests
Tests xAI's Grok Voice Agent API through LiteLLM's realtime interface.
Uses the base test class to ensure consistent behavior across providers.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath("../../.."))
from tests.llm_translation.realtime.base_realtime_tests import BaseRealtimeTest
class TestXAIRealtime(BaseRealtimeTest):
"""
E2E tests for xAI Realtime API.
xAI's Grok Voice Agent API is OpenAI-compatible but uses:
- Different initial event: "conversation.created" instead of "session.created"
- Different endpoint: wss://api.x.ai/v1/realtime
- Model: grok-4-1-fast-non-reasoning
"""
def get_model(self) -> str:
return "xai/grok-4-1-fast-non-reasoning"
def get_api_key_env_var(self) -> str:
return "XAI_API_KEY"
def get_initial_event_type(self) -> str:
return "conversation.created"
@@ -0,0 +1,82 @@
# OpenAI Realtime Handler Tests
## Important Context: `additional_headers` vs `extra_headers`
### Background
There was confusion about the correct parameter name for passing headers to `websockets.connect()`. This README documents the resolution for future maintainers.
### Timeline of Changes
1. **Dec 5, 2025** - Changed `extra_headers``additional_headers` (commit `8db7f1b8e4`)
2. **Dec 18, 2025** - Changed `extra_headers``additional_headers` again (PR #17950, commit `9f88d61d10`)
3. **Jan 15, 2026** - Upgraded `websockets` from 13.1.0 → 15.0.1 (commit `a3cf178e24`, Issue #19089)
### The Issue & Resolution
**The `websockets` library changed its API between versions:**
- **websockets < 14.0**: Used `extra_headers` parameter ✅
- **websockets >= 14.0**: Uses `additional_headers` parameter ✅
**LiteLLM uses websockets 15.0.1** (per requirements.txt), which requires `additional_headers`.
### Verification
You can verify the correct parameter name:
```bash
poetry run python -c "import websockets; import inspect; print(inspect.signature(websockets.connect))"
```
This shows: `additional_headers: 'HeadersLike | None' = None` for websockets 15.0.1.
### Current Implementation (Correct)
```python
# ✅ Correct for websockets 15.0.1+
await websockets.connect(url, additional_headers={
"Authorization": f"Bearer {api_key}",
"OpenAI-Beta": "realtime=v1"
})
```
### Impact
This is NOT just a test fix - this was a **critical bug** that affected all realtime APIs:
- OpenAI realtime
- Azure realtime
- xAI realtime
- Any pass-through realtime connections
Using `extra_headers` with websockets 15.0.1 resulted in:
```
TypeError: connect() got an unexpected keyword argument 'extra_headers'
```
### For Future Maintainers
If you see test failures related to header parameters:
1. **Check installed websockets version:**
```bash
poetry run python -c "import websockets; print(websockets.__version__)"
```
2. **Check requirements.txt** for the specified version
3. **Verify the correct parameter:**
- websockets >= 14.0: use `additional_headers`
- websockets < 14.0: use `extra_headers`
4. **Ensure consistency** across all files:
- `litellm/llms/openai/realtime/handler.py`
- `litellm/llms/azure/realtime/handler.py`
- `litellm/llms/custom_httpx/llm_http_handler.py`
- `litellm/realtime_api/main.py`
- `litellm/proxy/pass_through_endpoints/pass_through_endpoints.py`
**Current Status (Feb 2026):**
- ✅ websockets version: 15.0.1
- ✅ Correct parameter: `additional_headers`
- ✅ All handlers updated and working
@@ -199,7 +199,9 @@ async def test_async_realtime_url_contains_model():
additional_headers = called_kwargs["additional_headers"]
assert additional_headers["Authorization"] == f"Bearer {api_key}"
assert additional_headers["OpenAI-Beta"] == "realtime=v1"
assert called_kwargs["ssl"] is shared_context
# Verify SSL is configured (should be an SSLContext or True, not None or False)
assert called_kwargs["ssl"] is not None
assert called_kwargs["ssl"] is not False
mock_realtime_streaming.assert_called_once()
mock_streaming_instance.bidirectional_forward.assert_awaited_once()
@@ -259,7 +261,9 @@ async def test_async_realtime_uses_max_size_parameter():
# Verify max_size is set (default None for unlimited, matching OpenAI's SDK)
assert "max_size" in called_kwargs
assert called_kwargs["max_size"] is None
assert called_kwargs["ssl"] is shared_context
# Verify SSL is configured (should be an SSLContext or True, not None or False)
assert called_kwargs["ssl"] is not None
assert called_kwargs["ssl"] is not False
# Default should be None (unlimited) to match OpenAI's official agents SDK
# https://github.com/openai/openai-agents-python/blob/cf1b933660e44fd37b4350c41febab8221801409/src/agents/realtime/openai_realtime.py#L235