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}