use std::{pin::Pin, sync::Arc};
use bytes::Bytes;
use futures::{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(_) => 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 { res.ok() });
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 { res.ok() });
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 close(self) -> ZaiResult<()> {
let _ = self.cmd_tx.send(Command::Close).await;
let _ = self.join.await;
Ok(())
}
}
fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
fn new_event_id() -> String {
format!("evt_{}", uuid::Uuid::new_v4().simple())
}