use std::{pin::Pin, sync::Arc};
use bytes::Bytes;
use futures_util::{Stream, StreamExt};
use tokio::{
sync::{broadcast, mpsc},
task::JoinHandle,
};
use tokio_stream::wrappers::BroadcastStream;
use tracing::{debug, warn};
use super::{
audio::{OutputAudioFormat, decode_base64, encode_wav_pcm_base64},
client::AuthMode,
events::{ClientEvent, ServerEvent},
jwt,
protocol::{ChatMode, RealtimeTool, SessionConfig, TurnDetectionType},
transport::{RealtimeTransport, WsMessage},
};
use crate::{ZaiResult, client::error::RealtimeErrorKind};
enum Command {
ClientEvent(Box<ClientEvent>),
Close,
}
pub struct SessionBuilder {
api_key: Arc<String>,
auth: AuthMode,
realtime_url: String,
model_name: String,
session_config: SessionConfig,
}
impl SessionBuilder {
pub(super) fn new(
api_key: Arc<String>,
auth: AuthMode,
realtime_url: String,
model_name: String,
) -> Self {
Self {
api_key,
auth,
realtime_url,
model_name,
session_config: SessionConfig::default(),
}
}
pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
self.session_config.instructions = Some(instructions.into());
self
}
pub fn turn_detection(mut self, vad: TurnDetectionType) -> Self {
self.session_config.turn_detection.type_ = vad;
self
}
pub fn output_audio_format(mut self, format: OutputAudioFormat) -> Self {
self.session_config.output_audio_format = format;
self
}
pub fn chat_mode(mut self, mode: ChatMode) -> Self {
self.session_config.beta_fields.chat_mode = Some(mode);
self
}
pub fn auto_search(mut self, enabled: bool) -> Self {
self.session_config.beta_fields.auto_search = Some(enabled);
self
}
pub fn tools(mut self, tools: Vec<RealtimeTool>) -> Self {
self.session_config.tools = tools;
self
}
pub fn session_config(mut self, config: SessionConfig) -> Self {
self.session_config = config;
self
}
#[tracing::instrument(name = "realtime.session.build", skip_all, fields(model = %self.model_name))]
pub async fn build(self) -> ZaiResult<RealtimeSession> {
let Self {
api_key,
auth,
realtime_url,
model_name,
session_config,
} = self;
let jwt_ttl = match auth {
AuthMode::Bearer => None,
AuthMode::Jwt { ttl_seconds } => Some(ttl_seconds),
};
let authorization = jwt::authorization_header(&api_key, jwt_ttl)?;
let mut transport =
super::transport::TungsteniteTransport::connect(&realtime_url, &authorization).await?;
let init = ClientEvent::SessionUpdate {
event_id: Some(new_event_id()),
session: session_config,
};
transport.send(serde_json::to_string(&init)?).await?;
debug!(model = %model_name, "Realtime session opened");
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(64);
let (events_tx, _) = broadcast::channel::<ServerEvent>(256);
let (audio_tx, _) = broadcast::channel::<Bytes>(256);
let join = tokio::spawn(run_loop(
transport,
cmd_rx,
events_tx.clone(),
audio_tx.clone(),
));
Ok(RealtimeSession {
cmd_tx,
events_tx,
audio_tx,
model_name,
join,
})
}
}
async fn run_loop<T: RealtimeTransport>(
mut transport: T,
mut cmd_rx: mpsc::Receiver<Command>,
events_tx: broadcast::Sender<ServerEvent>,
audio_tx: broadcast::Sender<Bytes>,
) {
loop {
tokio::select! {
biased;
cmd = cmd_rx.recv() => match cmd {
Some(Command::ClientEvent(ev)) => {
let json = match serde_json::to_string(&ev) {
Ok(s) => s,
Err(e) => {
warn!(
error = %e,
"Dropping realtime client event that failed to serialize"
);
continue;
}
};
if transport.send(json).await.is_err() {
break;
}
},
Some(Command::Close) | None => {
let _ = transport.close().await;
debug!("Realtime session closed (client requested)");
break;
},
},
msg = transport.recv() => match msg {
Ok(Some(WsMessage::Text(text))) => match serde_json::from_str::<ServerEvent>(&text) {
Ok(ServerEvent::ResponseAudioDelta { delta, .. }) => {
if let Ok(bytes) = decode_base64(&delta) {
let _ = audio_tx.send(Bytes::from(bytes));
}
},
Ok(ServerEvent::Error { error }) => {
warn!(
code = ?error.code,
message = ?error.message,
"Realtime server error event"
);
let _ = events_tx.send(ServerEvent::Error { error });
},
Ok(event) => {
let _ = events_tx.send(event);
},
Err(_) => {
warn!(
bytes = text.len(),
"Dropping unparseable realtime frame"
);
},
},
Ok(Some(WsMessage::Binary(_))) => {},
Ok(None) => {
debug!("Realtime session closed (peer disconnected)");
break;
},
Err(e) => {
warn!(error = %e, "Realtime event loop terminated due to transport error");
break;
},
},
}
}
}
pub struct RealtimeSession {
cmd_tx: mpsc::Sender<Command>,
events_tx: broadcast::Sender<ServerEvent>,
audio_tx: broadcast::Sender<Bytes>,
model_name: String,
join: JoinHandle<()>,
}
impl RealtimeSession {
pub async fn send_audio(&self, pcm: Bytes) -> ZaiResult<()> {
let audio = encode_wav_pcm_base64(&pcm, 16_000);
self.dispatch(ClientEvent::InputAudioBufferAppend {
audio,
client_timestamp: Some(now_ms()),
})
.await
}
pub async fn commit_audio(&self) -> ZaiResult<()> {
self.dispatch(ClientEvent::InputAudioBufferCommit {
client_timestamp: Some(now_ms()),
})
.await
}
pub async fn send_text(&self, text: impl Into<String>) -> ZaiResult<()> {
self.dispatch(ClientEvent::ConversationItemCreate {
event_id: Some(new_event_id()),
item: super::protocol::RealtimeConversationItem::user_text(text),
})
.await
}
pub async fn send_function_output(
&self,
call_name: impl Into<String>,
output: impl Into<String>,
) -> ZaiResult<()> {
self.dispatch(ClientEvent::ConversationItemCreate {
event_id: Some(new_event_id()),
item: super::protocol::RealtimeConversationItem::function_output(call_name, output),
})
.await
}
pub async fn create_response(&self) -> ZaiResult<()> {
self.dispatch(ClientEvent::ResponseCreate {
client_timestamp: Some(now_ms()),
})
.await
}
pub async fn cancel(&self) -> ZaiResult<()> {
self.dispatch(ClientEvent::ResponseCancel {
client_timestamp: Some(now_ms()),
})
.await
}
pub fn events(&self) -> Pin<Box<dyn Stream<Item = ServerEvent> + Send + '_>> {
let stream = BroadcastStream::new(self.events_tx.subscribe()).filter_map(|res| {
async move {
match res {
Ok(event) => Some(event),
Err(_) => {
warn!("Realtime events consumer lagged; some events were dropped");
None
},
}
}
});
Box::pin(stream)
}
pub fn audio_stream(&self) -> Pin<Box<dyn Stream<Item = Bytes> + Send + '_>> {
let stream = BroadcastStream::new(self.audio_tx.subscribe()).filter_map(|res| async move {
match res {
Ok(bytes) => Some(bytes),
Err(_) => {
warn!("Realtime audio consumer lagged; some audio chunks were dropped");
None
},
}
});
Box::pin(stream)
}
pub fn model_name(&self) -> &str {
&self.model_name
}
#[tracing::instrument(name = "realtime.dispatch", skip(self, event))]
async fn dispatch(&self, event: ClientEvent) -> ZaiResult<()> {
self.cmd_tx
.send(Command::ClientEvent(Box::new(event)))
.await
.map_err(|_| RealtimeErrorKind::Closed.into())
}
pub async fn request_close(&self) -> ZaiResult<()> {
self.cmd_tx
.send(Command::Close)
.await
.map_err(|_| RealtimeErrorKind::Closed.into())
}
pub async fn close(self) -> ZaiResult<()> {
let _ = self.cmd_tx.send(Command::Close).await;
if let Err(join_err) = self.join.await {
warn!(error = %join_err, "realtime event loop ended abnormally");
}
Ok(())
}
}
fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
fn new_event_id() -> String {
format!("evt_{}", uuid::Uuid::new_v4().simple())
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::Mutex;
struct HangingTransport {
closed: Arc<Mutex<bool>>,
}
#[async_trait]
impl RealtimeTransport for HangingTransport {
async fn send(&mut self, _msg: String) -> crate::ZaiResult<()> {
Ok(())
}
async fn recv(&mut self) -> crate::ZaiResult<Option<WsMessage>> {
std::future::pending().await
}
async fn close(&mut self) -> crate::ZaiResult<()> {
*self.closed.lock().unwrap() = true;
Ok(())
}
}
#[tokio::test]
async fn dropping_command_sender_terminates_loop_and_closes_transport() {
let closed = Arc::new(Mutex::new(false));
let transport = HangingTransport {
closed: Arc::clone(&closed),
};
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(8);
let (events_tx, _) = broadcast::channel::<ServerEvent>(16);
let (audio_tx, _) = broadcast::channel::<Bytes>(16);
let join = tokio::spawn(run_loop(transport, cmd_rx, events_tx, audio_tx));
drop(cmd_tx);
let joined = tokio::time::timeout(std::time::Duration::from_secs(2), join)
.await
.expect("run_loop did not terminate after the command sender dropped");
joined.expect("run_loop task panicked");
assert!(
*closed.lock().unwrap(),
"transport.close() was not invoked on teardown"
);
}
}
#[cfg(test)]
mod coverage_tests {
use super::*;
use async_trait::async_trait;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
struct ScriptedTransport {
messages: Vec<String>,
sent: Arc<Mutex<Vec<String>>>,
closed: Arc<Mutex<bool>>,
}
impl ScriptedTransport {
fn new(msgs: Vec<&str>) -> Self {
Self {
messages: msgs.into_iter().map(String::from).collect(),
sent: Arc::new(Mutex::new(Vec::new())),
closed: Arc::new(Mutex::new(false)),
}
}
}
#[async_trait]
impl RealtimeTransport for ScriptedTransport {
async fn send(&mut self, msg: String) -> crate::ZaiResult<()> {
self.sent.lock().unwrap().push(msg);
Ok(())
}
async fn recv(&mut self) -> crate::ZaiResult<Option<WsMessage>> {
if self.messages.is_empty() {
return Ok(None);
}
Ok(Some(WsMessage::Text(self.messages.remove(0))))
}
async fn close(&mut self) -> crate::ZaiResult<()> {
*self.closed.lock().unwrap() = true;
Ok(())
}
}
#[tokio::test]
async fn run_loop_processes_server_events() {
let transport = ScriptedTransport::new(vec![
r#"{"type":"session.created","session":{"id":"s1"}}"#,
r#"{"type":"session.updated","session":{"id":"s1"}}"#,
]);
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(8);
let (events_tx, _) = broadcast::channel::<ServerEvent>(16);
let (audio_tx, _) = broadcast::channel::<Bytes>(16);
let join = tokio::spawn(run_loop(transport, cmd_rx, events_tx, audio_tx));
drop(cmd_tx);
let _ = tokio::time::timeout(Duration::from_secs(2), join).await;
}
#[tokio::test]
async fn run_loop_handles_audio_delta() {
let audio_b64 = base64::engine::general_purpose::STANDARD.encode(b"hello");
use base64::Engine as _;
let json =
format!(r#"{{"type":"response.audio.delta","delta":"{audio_b64}","event_id":"e1"}}"#);
let transport = ScriptedTransport::new(vec![&json]);
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(8);
let (events_tx, _) = broadcast::channel::<ServerEvent>(16);
let (audio_tx, _) = broadcast::channel::<Bytes>(16);
let join = tokio::spawn(run_loop(transport, cmd_rx, events_tx, audio_tx));
drop(cmd_tx);
let _ = tokio::time::timeout(Duration::from_secs(2), join).await;
}
#[tokio::test]
async fn run_loop_handles_error_event() {
let transport = ScriptedTransport::new(vec![
r#"{"type":"error","error":{"code":"server_error","message":"oops"}}"#,
]);
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(8);
let (events_tx, _) = broadcast::channel::<ServerEvent>(16);
let (audio_tx, _) = broadcast::channel::<Bytes>(16);
let join = tokio::spawn(run_loop(transport, cmd_rx, events_tx, audio_tx));
drop(cmd_tx);
let _ = tokio::time::timeout(Duration::from_secs(2), join).await;
}
#[tokio::test]
async fn run_loop_handles_unparseable_frame() {
let transport = ScriptedTransport::new(vec!["not json at all"]);
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(8);
let (events_tx, _) = broadcast::channel::<ServerEvent>(16);
let (audio_tx, _) = broadcast::channel::<Bytes>(16);
let join = tokio::spawn(run_loop(transport, cmd_rx, events_tx, audio_tx));
drop(cmd_tx);
let _ = tokio::time::timeout(Duration::from_secs(2), join).await;
}
#[tokio::test]
async fn run_loop_sends_client_event() {
let transport = ScriptedTransport::new(vec![]);
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(8);
let (events_tx, _) = broadcast::channel::<ServerEvent>(16);
let (audio_tx, _) = broadcast::channel::<Bytes>(16);
let join = tokio::spawn(run_loop(transport, cmd_rx, events_tx, audio_tx));
cmd_tx
.send(Command::ClientEvent(Box::new(
ClientEvent::ResponseCreate {
client_timestamp: None,
},
)))
.await
.unwrap();
cmd_tx.send(Command::Close).await.unwrap();
let _ = tokio::time::timeout(Duration::from_secs(2), join).await;
}
#[tokio::test]
async fn run_loop_close_command_terminates() {
let transport = ScriptedTransport::new(vec![]);
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(8);
let (events_tx, _) = broadcast::channel::<ServerEvent>(16);
let (audio_tx, _) = broadcast::channel::<Bytes>(16);
let join = tokio::spawn(run_loop(transport, cmd_rx, events_tx, audio_tx));
cmd_tx.send(Command::Close).await.unwrap();
let result = tokio::time::timeout(Duration::from_secs(2), join).await;
assert!(result.is_ok(), "run_loop should terminate on Close command");
}
#[tokio::test]
async fn run_loop_peer_disconnect_terminates() {
let transport = ScriptedTransport::new(vec![]);
let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(8);
let (events_tx, _) = broadcast::channel::<ServerEvent>(16);
let (audio_tx, _) = broadcast::channel::<Bytes>(16);
let join = tokio::spawn(run_loop(transport, cmd_rx, events_tx, audio_tx));
drop(cmd_tx);
let result = tokio::time::timeout(Duration::from_secs(2), join).await;
assert!(
result.is_ok(),
"run_loop should terminate on peer disconnect"
);
}
#[test]
fn new_event_id_format() {
let id = new_event_id();
assert!(id.starts_with("evt_"));
}
#[test]
fn now_ms_returns_positive() {
let t = now_ms();
assert!(t > 0);
}
}