Skip to main content

openai_compat/
realtime.rs

1//! Realtime WebSocket support for the OpenAI Realtime API.
2//!
3//! This module provides a thin async session over a WebSocket connection:
4//! it opens a connection (mirroring `openai-python`'s
5//! `resources/realtime/realtime.py` URL/header building), then lets you
6//! [`RealtimeSession::send`] and [`RealtimeSession::recv`] JSON events.
7//!
8//! # Scope
9//!
10//! The full typed event surface of the Realtime API is large (80+ client and
11//! server event types). Typing every one of them is intentionally **out of
12//! scope** here. Instead, every event is a [`serde_json::Value`] carrying the
13//! standard envelope `{"type": "...", ...}`. A handful of typed constructors
14//! for the most common client events live in the [`events`] submodule; build
15//! any other event as a plain JSON value.
16//!
17//! ```no_run
18//! use openai_compat::realtime::{self, RealtimeConnectOptions};
19//! use serde_json::json;
20//!
21//! # async fn run() -> Result<(), realtime::RealtimeError> {
22//! let mut session = realtime::connect(RealtimeConnectOptions {
23//!     api_key: "sk-...".into(),
24//!     base_url: "https://api.openai.com/v1".into(),
25//!     model: "gpt-4o-realtime-preview".into(),
26//!     organization: None,
27//!     project: None,
28//!     extra_headers: Vec::new(),
29//! })
30//! .await?;
31//!
32//! session
33//!     .send(realtime::events::session_update(json!({ "modalities": ["text"] })))
34//!     .await?;
35//!
36//! while let Some(event) = session.recv().await? {
37//!     if event["type"] == "response.done" {
38//!         break;
39//!     }
40//! }
41//! session.close().await?;
42//! # Ok(())
43//! # }
44//! ```
45
46use futures_util::{SinkExt, StreamExt};
47use tokio::net::TcpStream;
48use tokio_tungstenite::tungstenite::client::IntoClientRequest;
49use tokio_tungstenite::tungstenite::http::{HeaderName, HeaderValue};
50use tokio_tungstenite::tungstenite::Message;
51use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
52
53/// Errors produced by the realtime WebSocket module.
54///
55/// Kept local to this module (rather than reusing the crate-wide
56/// `OpenAIError`) so the realtime surface stays self-contained.
57#[derive(Debug, thiserror::Error)]
58#[non_exhaustive]
59pub enum RealtimeError {
60    /// Failed to build the request or establish the WebSocket connection.
61    #[error("realtime connection error: {0}")]
62    Connect(String),
63    /// A WebSocket protocol-level failure while sending or receiving frames.
64    #[error("realtime protocol error: {0}")]
65    Protocol(String),
66    /// Failed to (de)serialize a JSON event.
67    #[error("JSON error: {0}")]
68    Json(#[from] serde_json::Error),
69}
70
71/// Options controlling how a realtime connection is opened.
72///
73/// `connect` takes `base_url` + `api_key` directly (rather than a `Client`)
74/// so the realtime module has no dependency on client configuration; an
75/// accessor on the client can populate these fields.
76#[derive(Debug, Clone)]
77pub struct RealtimeConnectOptions {
78    /// API key sent as `Authorization: Bearer <api_key>`.
79    pub api_key: String,
80    /// HTTP(S) base URL, e.g. `https://api.openai.com/v1`. The scheme is
81    /// converted to `ws`/`wss` and `/realtime` is appended.
82    pub base_url: String,
83    /// Realtime model, sent as the `model` query parameter.
84    pub model: String,
85    /// Optional `OpenAI-Organization` header.
86    pub organization: Option<String>,
87    /// Optional `OpenAI-Project` header.
88    pub project: Option<String>,
89    /// Additional headers to attach to the upgrade request.
90    pub extra_headers: Vec<(String, String)>,
91}
92
93/// Build the realtime WebSocket URL from an HTTP(S) base URL.
94///
95/// Mirrors `_prepare_url` in `realtime.py`: swap the scheme (`http`→`ws`,
96/// `https`→`wss`), strip a trailing slash, append `/realtime`, and add the
97/// `model` query parameter. An empty `base_url` falls back to the crate
98/// default. A `ws://`/`wss://` base is accepted as-is (useful for tests).
99pub fn build_realtime_url(base_url: &str, model: &str) -> Result<String, RealtimeError> {
100    let base = base_url.trim();
101    let base = if base.is_empty() {
102        crate::DEFAULT_BASE_URL
103    } else {
104        base
105    };
106    let base = base.trim_end_matches('/');
107
108    // A query or fragment on the base URL would end up in the middle of the
109    // final URL (`.../v1?k=v/realtime?model=...`) — reject rather than emit
110    // a silently broken URL.
111    if base.contains('?') || base.contains('#') {
112        return Err(RealtimeError::Connect(format!(
113            "base_url must not contain a query or fragment: {base}"
114        )));
115    }
116
117    let ws_base = if let Some(rest) = base.strip_prefix("https://") {
118        format!("wss://{rest}")
119    } else if let Some(rest) = base.strip_prefix("http://") {
120        format!("ws://{rest}")
121    } else if base.starts_with("wss://") || base.starts_with("ws://") {
122        base.to_string()
123    } else {
124        return Err(RealtimeError::Connect(format!(
125            "unsupported base_url scheme: {base}"
126        )));
127    };
128
129    Ok(format!(
130        "{ws_base}/realtime?model={}",
131        encode_query_component(model)
132    ))
133}
134
135/// Minimal percent-encoding for a query-parameter value. Realtime model
136/// names are simple, but this keeps the URL well-formed if one contains a
137/// reserved character.
138fn encode_query_component(value: &str) -> String {
139    let mut out = String::with_capacity(value.len());
140    for byte in value.bytes() {
141        match byte {
142            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
143                out.push(byte as char);
144            }
145            _ => out.push_str(&format!("%{byte:02X}")),
146        }
147    }
148    out
149}
150
151/// Open a realtime WebSocket connection.
152///
153/// Sets `Authorization: Bearer <api_key>` and `OpenAI-Beta: realtime=v1`,
154/// plus org/project and any `extra_headers`, then connects over TLS (rustls).
155pub async fn connect(options: RealtimeConnectOptions) -> Result<RealtimeSession, RealtimeError> {
156    let url = build_realtime_url(&options.base_url, &options.model)?;
157
158    let mut request = url
159        .into_client_request()
160        .map_err(|e| RealtimeError::Connect(e.to_string()))?;
161    let headers = request.headers_mut();
162
163    let auth = HeaderValue::from_str(&format!("Bearer {}", options.api_key))
164        .map_err(|e| RealtimeError::Connect(format!("invalid api key header: {e}")))?;
165    headers.insert("Authorization", auth);
166    // Beta-era header; the GA API ignores it but some OpenAI-compatible
167    // gateways still expect it, so it is kept for compatibility.
168    headers.insert("OpenAI-Beta", HeaderValue::from_static("realtime=v1"));
169
170    if let Some(org) = &options.organization {
171        let value = HeaderValue::from_str(org)
172            .map_err(|e| RealtimeError::Connect(format!("invalid organization header: {e}")))?;
173        headers.insert("OpenAI-Organization", value);
174    }
175    if let Some(project) = &options.project {
176        let value = HeaderValue::from_str(project)
177            .map_err(|e| RealtimeError::Connect(format!("invalid project header: {e}")))?;
178        headers.insert("OpenAI-Project", value);
179    }
180    for (name, value) in &options.extra_headers {
181        let header_name = HeaderName::from_bytes(name.as_bytes())
182            .map_err(|e| RealtimeError::Connect(format!("invalid header name {name}: {e}")))?;
183        let header_value = HeaderValue::from_str(value)
184            .map_err(|e| RealtimeError::Connect(format!("invalid value for {name}: {e}")))?;
185        headers.insert(header_name, header_value);
186    }
187
188    let (ws, _response) = connect_async(request)
189        .await
190        .map_err(|e| RealtimeError::Connect(e.to_string()))?;
191
192    Ok(RealtimeSession { ws })
193}
194
195/// A live realtime WebSocket session.
196///
197/// Wraps the underlying WebSocket stream and exposes JSON send/receive.
198pub struct RealtimeSession {
199    ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
200}
201
202impl RealtimeSession {
203    /// Send a JSON event as a text frame.
204    ///
205    /// Not cancel-safe: dropping this future mid-flight (e.g. racing it in
206    /// `tokio::select!`) can leave a partial frame on the connection. Let it
207    /// run to completion.
208    pub async fn send(&mut self, event: serde_json::Value) -> Result<(), RealtimeError> {
209        let text = serde_json::to_string(&event)?;
210        self.ws
211            .send(Message::Text(text))
212            .await
213            .map_err(|e| RealtimeError::Protocol(e.to_string()))?;
214        Ok(())
215    }
216
217    /// Receive the next JSON event.
218    ///
219    /// Returns `Ok(None)` on a clean close. Ping/Pong frames are handled
220    /// automatically by the underlying library and skipped here; non-text
221    /// frames other than binary JSON are skipped as well. This method is
222    /// cancel-safe. Keep polling it even when idle — server pings are only
223    /// answered while a read is in progress, and an unpolled session may be
224    /// dropped by the server.
225    pub async fn recv(&mut self) -> Result<Option<serde_json::Value>, RealtimeError> {
226        while let Some(message) = self.ws.next().await {
227            let message = message.map_err(|e| RealtimeError::Protocol(e.to_string()))?;
228            match message {
229                Message::Text(text) => {
230                    let value = serde_json::from_str(text.as_str())?;
231                    return Ok(Some(value));
232                }
233                Message::Binary(bytes) => {
234                    let value = serde_json::from_slice(bytes.as_ref())?;
235                    return Ok(Some(value));
236                }
237                Message::Close(_) => return Ok(None),
238                Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => continue,
239            }
240        }
241        Ok(None)
242    }
243
244    /// Close the connection with a normal closure handshake.
245    pub async fn close(mut self) -> Result<(), RealtimeError> {
246        self.ws
247            .close(None)
248            .await
249            .map_err(|e| RealtimeError::Protocol(e.to_string()))?;
250        Ok(())
251    }
252}
253
254/// Typed constructors for the most common realtime **client** events.
255///
256/// Each returns a [`serde_json::Value`] with the standard
257/// `{"type": "...", ...}` envelope. Build any event not covered here as a
258/// plain JSON value.
259pub mod events {
260    use serde_json::{json, Value};
261
262    /// `{"type": "session.update", "session": <session>}`.
263    pub fn session_update(session: Value) -> Value {
264        json!({ "type": "session.update", "session": session })
265    }
266
267    /// `{"type": "input_audio_buffer.append", "audio": <base64_audio>}`.
268    pub fn input_audio_buffer_append(base64_audio: &str) -> Value {
269        json!({ "type": "input_audio_buffer.append", "audio": base64_audio })
270    }
271
272    /// `{"type": "input_audio_buffer.commit"}`.
273    pub fn input_audio_buffer_commit() -> Value {
274        json!({ "type": "input_audio_buffer.commit" })
275    }
276
277    /// `{"type": "conversation.item.create", "item": <item>}`.
278    pub fn conversation_item_create(item: Value) -> Value {
279        json!({ "type": "conversation.item.create", "item": item })
280    }
281
282    /// `{"type": "response.create"}`.
283    pub fn response_create() -> Value {
284        json!({ "type": "response.create" })
285    }
286
287    /// `{"type": "response.create", "response": <response>}`.
288    pub fn response_create_with(response: Value) -> Value {
289        json!({ "type": "response.create", "response": response })
290    }
291}