1use std::{pin::Pin, sync::Arc};
10
11use bytes::Bytes;
12use futures::{Stream, StreamExt};
13use tokio::{
14 sync::{broadcast, mpsc},
15 task::JoinHandle,
16};
17use tokio_stream::wrappers::BroadcastStream;
18use tracing::{debug, warn};
19
20use super::{
21 audio::{OutputAudioFormat, decode_base64, encode_wav_pcm_base64},
22 client::AuthMode,
23 events::{ClientEvent, ServerEvent},
24 jwt,
25 protocol::{ChatMode, RealtimeTool, SessionConfig, TurnDetectionType},
26 transport::{RealtimeTransport, WsMessage},
27};
28use crate::{ZaiResult, client::error::RealtimeErrorKind};
29
30enum Command {
33 ClientEvent(Box<ClientEvent>),
35 Close,
37}
38
39pub struct SessionBuilder {
45 api_key: Arc<String>,
46 auth: AuthMode,
47 realtime_url: String,
48 model_name: String,
49 session_config: SessionConfig,
50}
51
52impl SessionBuilder {
53 pub(super) fn new(
54 api_key: Arc<String>,
55 auth: AuthMode,
56 realtime_url: String,
57 model_name: String,
58 ) -> Self {
59 Self {
60 api_key,
61 auth,
62 realtime_url,
63 model_name,
64 session_config: SessionConfig::default(),
65 }
66 }
67
68 pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
70 self.session_config.instructions = Some(instructions.into());
71 self
72 }
73
74 pub fn turn_detection(mut self, vad: TurnDetectionType) -> Self {
76 self.session_config.turn_detection.type_ = vad;
77 self
78 }
79
80 pub fn output_audio_format(mut self, format: OutputAudioFormat) -> Self {
82 self.session_config.output_audio_format = format;
83 self
84 }
85
86 pub fn chat_mode(mut self, mode: ChatMode) -> Self {
88 self.session_config.beta_fields.chat_mode = Some(mode);
89 self
90 }
91
92 pub fn auto_search(mut self, enabled: bool) -> Self {
94 self.session_config.beta_fields.auto_search = Some(enabled);
95 self
96 }
97
98 pub fn tools(mut self, tools: Vec<RealtimeTool>) -> Self {
100 self.session_config.tools = tools;
101 self
102 }
103
104 pub fn session_config(mut self, config: SessionConfig) -> Self {
106 self.session_config = config;
107 self
108 }
109
110 #[tracing::instrument(name = "realtime.session.build", skip_all, fields(model = %self.model_name))]
112 pub async fn build(self) -> ZaiResult<RealtimeSession> {
113 let Self {
114 api_key,
115 auth,
116 realtime_url,
117 model_name,
118 session_config,
119 } = self;
120
121 let jwt_ttl = match auth {
122 AuthMode::Bearer => None,
123 AuthMode::Jwt { ttl_seconds } => Some(ttl_seconds),
124 };
125 let authorization = jwt::authorization_header(&api_key, jwt_ttl)?;
126
127 let mut transport =
128 super::transport::TungsteniteTransport::connect(&realtime_url, &authorization).await?;
129
130 let init = ClientEvent::SessionUpdate {
132 event_id: Some(new_event_id()),
133 session: session_config,
134 };
135 transport.send(serde_json::to_string(&init)?).await?;
136 debug!(model = %model_name, "Realtime session opened");
137
138 let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(64);
139 let (events_tx, _) = broadcast::channel::<ServerEvent>(256);
140 let (audio_tx, _) = broadcast::channel::<Bytes>(256);
141
142 let join = tokio::spawn(run_loop(
143 transport,
144 cmd_rx,
145 events_tx.clone(),
146 audio_tx.clone(),
147 ));
148
149 Ok(RealtimeSession {
150 cmd_tx,
151 events_tx,
152 audio_tx,
153 model_name,
154 join,
155 })
156 }
157}
158
159async fn run_loop<T: RealtimeTransport>(
163 mut transport: T,
164 mut cmd_rx: mpsc::Receiver<Command>,
165 events_tx: broadcast::Sender<ServerEvent>,
166 audio_tx: broadcast::Sender<Bytes>,
167) {
168 loop {
169 tokio::select! {
170 biased;
171 cmd = cmd_rx.recv() => match cmd {
172 Some(Command::ClientEvent(ev)) => {
173 let json = match serde_json::to_string(&ev) {
174 Ok(s) => s,
175 Err(_) => continue,
177 };
178 if transport.send(json).await.is_err() {
179 break;
180 }
181 },
182 Some(Command::Close) | None => {
183 let _ = transport.close().await;
184 debug!("Realtime session closed (client requested)");
185 break;
186 },
187 },
188 msg = transport.recv() => match msg {
189 Ok(Some(WsMessage::Text(text))) => match serde_json::from_str::<ServerEvent>(&text) {
190 Ok(ServerEvent::ResponseAudioDelta { delta, .. }) => {
191 if let Ok(bytes) = decode_base64(&delta) {
192 let _ = audio_tx.send(Bytes::from(bytes));
193 }
194 },
195 Ok(ServerEvent::Error { error }) => {
196 warn!(
197 code = ?error.code,
198 message = ?error.message,
199 "Realtime server error event"
200 );
201 let _ = events_tx.send(ServerEvent::Error { error });
202 },
203 Ok(event) => {
204 let _ = events_tx.send(event);
205 },
206 Err(_) => {
208 warn!(
209 bytes = text.len(),
210 "Dropping unparseable realtime frame"
211 );
212 },
213 },
214 Ok(Some(WsMessage::Binary(_))) => {},
215 Ok(None) => {
216 debug!("Realtime session closed (peer disconnected)");
218 break;
219 },
220 Err(e) => {
221 warn!(error = %e, "Realtime event loop terminated due to transport error");
222 break;
223 },
224 },
225 }
226 }
227}
228
229pub struct RealtimeSession {
234 cmd_tx: mpsc::Sender<Command>,
235 events_tx: broadcast::Sender<ServerEvent>,
236 audio_tx: broadcast::Sender<Bytes>,
237 model_name: String,
238 join: JoinHandle<()>,
239}
240
241impl RealtimeSession {
242 pub async fn send_audio(&self, pcm: Bytes) -> ZaiResult<()> {
245 let audio = encode_wav_pcm_base64(&pcm, 16_000);
246 self.dispatch(ClientEvent::InputAudioBufferAppend {
247 audio,
248 client_timestamp: Some(now_ms()),
249 })
250 .await
251 }
252
253 pub async fn commit_audio(&self) -> ZaiResult<()> {
256 self.dispatch(ClientEvent::InputAudioBufferCommit {
257 client_timestamp: Some(now_ms()),
258 })
259 .await
260 }
261
262 pub async fn send_text(&self, text: impl Into<String>) -> ZaiResult<()> {
264 self.dispatch(ClientEvent::ConversationItemCreate {
265 event_id: Some(new_event_id()),
266 item: super::protocol::RealtimeConversationItem::user_text(text),
267 })
268 .await
269 }
270
271 pub async fn send_function_output(
273 &self,
274 call_name: impl Into<String>,
275 output: impl Into<String>,
276 ) -> ZaiResult<()> {
277 self.dispatch(ClientEvent::ConversationItemCreate {
278 event_id: Some(new_event_id()),
279 item: super::protocol::RealtimeConversationItem::function_output(call_name, output),
280 })
281 .await
282 }
283
284 pub async fn create_response(&self) -> ZaiResult<()> {
286 self.dispatch(ClientEvent::ResponseCreate {
287 client_timestamp: Some(now_ms()),
288 })
289 .await
290 }
291
292 pub async fn cancel(&self) -> ZaiResult<()> {
294 self.dispatch(ClientEvent::ResponseCancel {
295 client_timestamp: Some(now_ms()),
296 })
297 .await
298 }
299
300 pub fn events(&self) -> Pin<Box<dyn Stream<Item = ServerEvent> + Send + '_>> {
305 let stream = BroadcastStream::new(self.events_tx.subscribe())
306 .filter_map(|res| async move { res.ok() });
307 Box::pin(stream)
308 }
309
310 pub fn audio_stream(&self) -> Pin<Box<dyn Stream<Item = Bytes> + Send + '_>> {
313 let stream = BroadcastStream::new(self.audio_tx.subscribe())
314 .filter_map(|res| async move { res.ok() });
315 Box::pin(stream)
316 }
317
318 pub fn model_name(&self) -> &str {
321 &self.model_name
322 }
323
324 #[tracing::instrument(name = "realtime.dispatch", skip(self, event))]
325 async fn dispatch(&self, event: ClientEvent) -> ZaiResult<()> {
326 self.cmd_tx
327 .send(Command::ClientEvent(Box::new(event)))
328 .await
329 .map_err(|_| RealtimeErrorKind::Closed.into())
330 }
331
332 pub async fn close(self) -> ZaiResult<()> {
334 let _ = self.cmd_tx.send(Command::Close).await;
335 let _ = self.join.await;
338 Ok(())
339 }
340}
341
342fn now_ms() -> i64 {
343 chrono::Utc::now().timestamp_millis()
344}
345
346fn new_event_id() -> String {
347 format!("evt_{}", uuid::Uuid::new_v4().simple())
348}