1use std::sync::Arc;
7use std::sync::Mutex;
8use std::sync::atomic::AtomicBool;
9use std::sync::atomic::Ordering;
10
11use ferrin_spec::DynRealtimeModel;
12use ferrin_spec::JsonValue;
13use ferrin_spec::ToolCallId;
14use ferrin_spec::ToolName;
15use ferrin_spec::realtime_model::ClientSecret;
16use ferrin_spec::realtime_model::ConversationItem;
17use ferrin_spec::realtime_model::ConversationRole;
18use ferrin_spec::realtime_model::RealtimeClientEvent;
19use ferrin_spec::realtime_model::RealtimeServerEvent;
20use ferrin_spec::realtime_model::RealtimeSessionConfig;
21use ferrin_tool::Tool;
22use ferrin_tool::ToolContext;
23use ferrin_tool::ToolSet;
24use ferrin_tool::execute_to_completion;
25use futures_util::SinkExt;
26use futures_util::StreamExt;
27use http::HeaderValue;
28use http::header::SEC_WEBSOCKET_PROTOCOL;
29use http::header::USER_AGENT;
30use tokio::net::TcpStream;
31use tokio::sync::mpsc;
32use tokio::task::JoinSet;
33use tokio_tungstenite::MaybeTlsStream;
34use tokio_tungstenite::WebSocketStream;
35use tokio_tungstenite::connect_async_tls_with_config;
36use tokio_tungstenite::tungstenite::Message;
37use tokio_tungstenite::tungstenite::client::IntoClientRequest;
38use tokio_util::sync::CancellationToken;
39
40use super::RealtimeEvent;
41use super::tools::ToolTurn;
42use crate::error::Error;
43
44type Socket = WebSocketStream<MaybeTlsStream<TcpStream>>;
45
46const OUTBOUND_BUFFER: usize = 64;
48
49struct Shared {
51 model: Arc<dyn DynRealtimeModel>,
52 outbound: mpsc::Sender<JsonValue>,
53 cancellation: CancellationToken,
54 closed: AtomicBool,
55 tool_turn: Mutex<ToolTurn>,
56 serialization: tokio::sync::Semaphore,
57}
58
59impl Shared {
60 fn tool_turn(&self) -> std::sync::MutexGuard<'_, ToolTurn> {
61 self.tool_turn
62 .lock()
63 .unwrap_or_else(std::sync::PoisonError::into_inner)
64 }
65}
66
67#[derive(Clone)]
71pub struct RealtimeHandle {
72 shared: Arc<Shared>,
73}
74
75impl std::fmt::Debug for RealtimeHandle {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct("RealtimeHandle")
78 .field("provider", self.shared.model.provider())
79 .field("model_id", self.shared.model.model_id())
80 .field("closed", &self.is_closed())
81 .finish()
82 }
83}
84
85impl RealtimeHandle {
86 pub async fn send(&self, event: RealtimeClientEvent) -> Result<(), Error> {
93 let _serialization = tokio::select! {
94 biased;
95 () = self.shared.cancellation.cancelled() => return Err(closed_error()),
96 permit = self.shared.serialization.acquire() => permit.map_err(|_| closed_error())?,
97 };
98 let raw = tokio::select! {
99 biased;
100 () = self.shared.cancellation.cancelled() => return Err(closed_error()),
101 result = self.shared.model.serialize_client_event(event) => result?,
102 };
103 if raw.is_null() {
104 Ok(())
105 } else {
106 self.send_raw(raw).await
107 }
108 }
109
110 pub async fn send_raw(&self, raw: JsonValue) -> Result<(), Error> {
116 self.shared
117 .outbound
118 .send(raw)
119 .await
120 .map_err(|_| closed_error())
121 }
122
123 pub async fn send_text(&self, text: impl Into<String>) -> Result<(), Error> {
129 self.send(RealtimeClientEvent::ConversationItemCreate {
130 item: ConversationItem::TextMessage {
131 role: ConversationRole::User,
132 text: text.into(),
133 },
134 })
135 .await?;
136 self.send(RealtimeClientEvent::ResponseCreate { options: None })
137 .await
138 }
139
140 pub async fn add_tool_output(&self, call_id: &str, output: &JsonValue) -> Result<(), Error> {
147 let name = self
148 .shared
149 .tool_turn()
150 .name(call_id)
151 .map(|name| name.as_str().to_owned());
152 self.send(RealtimeClientEvent::ConversationItemCreate {
153 item: ConversationItem::FunctionCallOutput {
154 call_id: call_id.to_owned(),
155 name,
156 output: output.to_string(),
157 },
158 })
159 .await?;
160 let request_response = self.shared.tool_turn().output_submitted(call_id);
161 if request_response {
162 self.send(RealtimeClientEvent::ResponseCreate { options: None })
163 .await?;
164 }
165 Ok(())
166 }
167
168 pub async fn request_response(&self) -> Result<(), Error> {
174 self.send(RealtimeClientEvent::ResponseCreate { options: None })
175 .await
176 }
177
178 pub fn close(&self) {
180 self.shared.cancellation.cancel();
181 }
182
183 #[must_use]
185 pub fn is_closed(&self) -> bool {
186 self.shared.closed.load(Ordering::Acquire)
187 }
188}
189
190fn closed_error() -> Error {
191 Error::message("realtime session is closed")
192}
193
194pub(super) struct StartOptions {
196 pub(super) model: Arc<dyn DynRealtimeModel>,
197 pub(super) secret: ClientSecret,
198 pub(super) config: RealtimeSessionConfig,
199 pub(super) tools: Arc<ToolSet>,
200 pub(super) tools_context: Option<JsonValue>,
201 pub(super) cancellation: CancellationToken,
202 pub(super) events: mpsc::Sender<RealtimeEvent>,
203}
204
205pub(super) async fn start(
208 options: StartOptions,
209 tasks: &mut JoinSet<()>,
210) -> Result<RealtimeHandle, Error> {
211 let StartOptions {
212 model,
213 secret,
214 config,
215 tools,
216 tools_context,
217 cancellation,
218 events,
219 } = options;
220 let ws_config = model.websocket_config(&secret.token, &secret.url);
221 let mut request = ws_config
222 .url
223 .as_str()
224 .into_client_request()
225 .map_err(Error::other)?;
226 if !ws_config.protocols.is_empty() {
227 let value = HeaderValue::from_str(&ws_config.protocols.join(", "))
228 .map_err(|error| Error::invalid_argument("protocols", error.to_string()))?;
229 request.headers_mut().insert(SEC_WEBSOCKET_PROTOCOL, value);
230 }
231 request
232 .headers_mut()
233 .insert(USER_AGENT, HeaderValue::from_static(crate::USER_AGENT));
234
235 let connect = connect_async_tls_with_config(request, None, false, None);
236 let (mut socket, _response) = tokio::select! {
237 result = connect => result.map_err(Error::other)?,
238 () = cancellation.cancelled() => return Err(Error::Cancelled),
239 };
240
241 let initial = model
242 .serialize_client_event(RealtimeClientEvent::SessionUpdate {
243 config: Box::new(config),
244 })
245 .await?;
246 if !initial.is_null() {
247 socket
248 .send(wire_message(initial))
249 .await
250 .map_err(Error::other)?;
251 }
252
253 let (outbound_tx, outbound_rx) = mpsc::channel(OUTBOUND_BUFFER);
254 let shared = Arc::new(Shared {
255 model,
256 outbound: outbound_tx,
257 cancellation,
258 closed: AtomicBool::new(false),
259 tool_turn: Mutex::new(ToolTurn::default()),
260 serialization: tokio::sync::Semaphore::new(1),
261 });
262 let handle = RealtimeHandle {
263 shared: Arc::clone(&shared),
264 };
265 tasks.spawn(run(Connection {
266 socket,
267 outbound: outbound_rx,
268 shared,
269 events,
270 tools,
271 tools_context,
272 }));
273 Ok(handle)
274}
275
276struct Connection {
277 socket: Socket,
278 outbound: mpsc::Receiver<JsonValue>,
279 shared: Arc<Shared>,
280 events: mpsc::Sender<RealtimeEvent>,
281 tools: Arc<ToolSet>,
282 tools_context: Option<JsonValue>,
283}
284
285async fn run(mut connection: Connection) {
286 let mut tool_tasks: JoinSet<()> = JoinSet::new();
287 loop {
288 let stop = tokio::select! {
289 biased;
290 () = connection.shared.cancellation.cancelled() => {
291 let _ = connection.socket.send(Message::Close(None)).await;
292 true
293 }
294 outbound = connection.outbound.recv() => match outbound {
295 Some(raw) => connection.send_raw(raw).await.is_err(),
296 None => {
297 let _ = connection.socket.send(Message::Close(None)).await;
298 true
299 }
300 },
301 frame = connection.socket.next() => match frame {
302 Some(Ok(Message::Text(text))) => {
303 connection.handle_text(text.as_str(), &mut tool_tasks).await
304 }
305 Some(Ok(Message::Binary(bytes))) => match std::str::from_utf8(&bytes) {
306 Ok(text) => connection.handle_text(text, &mut tool_tasks).await,
307 Err(_) => false,
308 },
309 Some(Ok(Message::Close(_))) | None => true,
310 Some(Ok(_)) => false,
311 Some(Err(error)) => {
312 connection.emit(Err(Error::other(error))).await;
313 true
314 }
315 },
316 Some(joined) = tool_tasks.join_next(), if !tool_tasks.is_empty() => {
317 if let Err(error) = joined
318 && !error.is_cancelled()
319 {
320 connection.emit(Err(Error::other(error))).await;
321 }
322 false
323 }
324 };
325 if stop {
326 break;
327 }
328 }
329 tool_tasks.shutdown().await;
330 connection.shared.closed.store(true, Ordering::Release);
331 connection.shared.cancellation.cancel();
332}
333
334impl Connection {
335 async fn emit(&self, item: RealtimeEvent) -> bool {
338 self.events.send(item).await.is_ok()
339 }
340
341 async fn send_raw(&mut self, raw: JsonValue) -> Result<(), ()> {
342 match self.socket.send(wire_message(raw)).await {
343 Ok(()) => Ok(()),
344 Err(error) => {
345 self.emit(Err(Error::other(error))).await;
346 Err(())
347 }
348 }
349 }
350
351 async fn handle_text(&mut self, text: &str, tool_tasks: &mut JoinSet<()>) -> bool {
353 let raw: JsonValue = match serde_json::from_str(text) {
354 Ok(raw) => raw,
355 Err(error) => {
356 tracing::debug!(error = %error, "ignoring non-JSON realtime message");
357 return false;
358 }
359 };
360 if let Some(reply) = self.shared.model.health_check_response(&raw)
361 && self.send_raw(reply).await.is_err()
362 {
363 return true;
364 }
365 let events = match self.shared.model.parse_server_event(raw) {
366 Ok(events) => events,
367 Err(error) => return !self.emit(Err(Error::from(error))).await,
368 };
369 for event in events {
370 if !self.handle_event(event, tool_tasks).await {
371 return true;
372 }
373 }
374 false
375 }
376
377 async fn handle_event(
380 &mut self,
381 event: RealtimeServerEvent,
382 tool_tasks: &mut JoinSet<()>,
383 ) -> bool {
384 let follow_up = match &event {
385 RealtimeServerEvent::FunctionCallArgumentsDone {
386 call_id,
387 name,
388 arguments,
389 ..
390 } => {
391 let name = ToolName::new(name.clone());
392 self.shared.tool_turn().record_name(call_id, &name);
393 Some(ToolCall {
394 call_id: call_id.clone(),
395 name,
396 arguments: arguments.clone(),
397 })
398 }
399 _ => None,
400 };
401 let response_done = matches!(event, RealtimeServerEvent::ResponseDone { .. });
402
403 if !self.emit(Ok(event)).await {
404 return false;
405 }
406 if let Some(call) = follow_up
407 && !self.start_tool_call(call, tool_tasks).await
408 {
409 return false;
410 }
411 if response_done && self.shared.tool_turn().response_done() {
412 let handle = RealtimeHandle {
413 shared: Arc::clone(&self.shared),
414 };
415 if let Err(error) = handle.request_response().await {
416 return self.emit(Err(error)).await;
417 }
418 }
419 true
420 }
421
422 async fn start_tool_call(&self, call: ToolCall, tool_tasks: &mut JoinSet<()>) -> bool {
425 let input: JsonValue = match serde_json::from_str(&call.arguments) {
426 Ok(input) => input,
427 Err(error) => {
428 return self
429 .emit(Err(Error::invalid_tool_input(
430 call.name,
431 call.arguments,
432 error,
433 )))
434 .await;
435 }
436 };
437 self.shared
438 .tool_turn()
439 .call_started(&call.call_id, &call.name);
440 let Some(tool) = self.tools.get(call.name.as_str()).map(Arc::clone) else {
441 let available = self.tools.names().cloned().collect();
442 return self
443 .emit(Err(Error::no_such_tool(call.name, available)))
444 .await;
445 };
446 if !tool.is_executable() {
447 return true;
448 }
449 let input = match tool.validate_input(&call.name, input) {
450 Ok(input) => input,
451 Err(error) => {
452 return self
453 .emit(Err(Error::invalid_tool_input(
454 call.name,
455 call.arguments,
456 error,
457 )))
458 .await;
459 }
460 };
461 let tools_context =
462 match tool.validate_named_context(&call.name, self.tools_context.as_ref()) {
463 Ok(context) => context,
464 Err(error) => {
465 return self
466 .emit(Err(Error::invalid_argument(
467 "tools_context",
468 error.to_string(),
469 )))
470 .await;
471 }
472 };
473 let ctx = ToolContext::new(ToolCallId::new(call.call_id.clone()))
474 .with_cancellation(self.shared.cancellation.child_token())
475 .with_tools_context(tools_context);
476 let handle = RealtimeHandle {
477 shared: Arc::clone(&self.shared),
478 };
479 let events = self.events.clone();
480 tool_tasks.spawn(execute_tool_call(
481 tool,
482 input,
483 ctx,
484 call.call_id,
485 handle,
486 events,
487 ));
488 true
489 }
490}
491
492fn wire_message(raw: JsonValue) -> Message {
493 match raw {
494 JsonValue::String(text) => Message::text(text),
495 other => Message::text(other.to_string()),
496 }
497}
498
499struct ToolCall {
500 call_id: String,
501 name: ToolName,
502 arguments: String,
503}
504
505async fn execute_tool_call(
506 tool: Arc<Tool>,
507 input: JsonValue,
508 ctx: ToolContext,
509 call_id: String,
510 handle: RealtimeHandle,
511 events: mpsc::Sender<RealtimeEvent>,
512) {
513 let needs_approval = tokio::select! {
514 biased;
515 () = ctx.cancellation.cancelled() => return,
516 required = tool.needs_approval().resolve(input.clone(), ctx.clone()) => required,
517 };
518 if needs_approval {
519 let _ = events.send(Err(Error::invalid_argument(
520 "tools",
521 "realtime tool requires application approval; submit its approved result or denial with add_tool_output",
522 ))).await;
523 return;
524 }
525 if ctx.cancellation.is_cancelled() {
526 let _ = events.send(Err(Error::Cancelled)).await;
527 return;
528 }
529 let Some(stream) = tool.execute(input, ctx) else {
530 return;
531 };
532 let result = match execute_to_completion(stream, |_| {}).await {
533 Ok(output) => handle.add_tool_output(&call_id, &output).await,
534 Err(error) => Err(Error::from(error)),
535 };
536 if let Err(error) = result {
537 let _ = events.send(Err(error)).await;
538 }
539}