use async_trait::async_trait;
use meerkat_contracts::{
RealtimeAudioChunk, RealtimeCapabilities, RealtimeEvent, RealtimeInputChunk,
RealtimeTurningMode, RealtimeVideoChunk,
};
use meerkat_core::{PendingSystemContextAppend, RealtimeTranscriptEvent, ToolResult};
use meerkat_core::{SessionLlmIdentity, StopReason, ToolDef, types::Message, types::Usage};
use serde_json::Value;
use crate::LlmError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RealtimeExternalSessionTarget {
pub provider_session_id: String,
}
impl RealtimeExternalSessionTarget {
pub fn new(provider_session_id: impl Into<String>) -> Result<Self, LlmError> {
let provider_session_id = provider_session_id.into();
if provider_session_id.trim().is_empty() {
return Err(LlmError::InvalidRequest {
message: "provider realtime session id must not be empty".to_string(),
});
}
Ok(Self {
provider_session_id,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum RealtimeSessionEvent {
InputTranscriptPartial {
text: String,
},
InputTranscriptFinal {
text: String,
},
InputTranscriptFinalForItem {
item_id: String,
previous_item_id: Option<String>,
content_index: u32,
text: String,
},
TurnStarted,
TurnCommitted,
TurnCompleted {
response_id: String,
stop_reason: StopReason,
usage: Usage,
},
OutputTextDelta {
delta: String,
},
OutputTextDeltaForItem {
response_id: String,
delta_id: String,
item_id: String,
previous_item_id: Option<String>,
content_index: u32,
delta: String,
},
OutputAudioChunk {
chunk: RealtimeAudioChunk,
},
OutputVideoChunk {
chunk: RealtimeVideoChunk,
},
Interrupted {
response_id: Option<String>,
},
ToolCallRequested {
call_id: String,
tool_name: String,
arguments: Value,
},
AssistantTranscriptTruncated {
response_id: Option<String>,
item_id: String,
audio_played_ms: u64,
truncated_text: Option<String>,
},
RealtimeTranscript {
event: RealtimeTranscriptEvent,
},
}
impl RealtimeSessionEvent {
#[must_use]
pub fn to_public_event(&self) -> Option<RealtimeEvent> {
Some(match self {
Self::InputTranscriptPartial { text } => {
RealtimeEvent::InputTranscriptPartial { text: text.clone() }
}
Self::InputTranscriptFinal { text } => RealtimeEvent::InputTranscriptFinal {
text: text.clone(),
prosody_hint: None,
},
Self::InputTranscriptFinalForItem { text, .. } => RealtimeEvent::InputTranscriptFinal {
text: text.clone(),
prosody_hint: None,
},
Self::TurnStarted => RealtimeEvent::TurnStarted,
Self::TurnCommitted => RealtimeEvent::TurnCommitted,
Self::TurnCompleted { .. } => RealtimeEvent::TurnCompleted,
Self::OutputTextDelta { delta } => RealtimeEvent::OutputTextDelta {
delta: delta.clone(),
},
Self::OutputTextDeltaForItem { delta, .. } => RealtimeEvent::OutputTextDelta {
delta: delta.clone(),
},
Self::OutputAudioChunk { chunk } => RealtimeEvent::OutputAudioChunk {
chunk: chunk.clone(),
},
Self::OutputVideoChunk { chunk } => RealtimeEvent::OutputVideoChunk {
chunk: chunk.clone(),
},
Self::Interrupted { .. } => RealtimeEvent::Interrupted,
Self::ToolCallRequested {
call_id, tool_name, ..
} => RealtimeEvent::ToolCallRequested {
call_id: call_id.clone(),
tool_name: tool_name.clone(),
},
Self::AssistantTranscriptTruncated {
response_id: _,
item_id,
audio_played_ms,
truncated_text,
} => RealtimeEvent::AssistantTranscriptTruncated {
item_id: item_id.clone(),
audio_played_ms: *audio_played_ms,
truncated_text: truncated_text.clone(),
},
Self::RealtimeTranscript { .. } => return None,
})
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait RealtimeSession: Send {
fn capabilities(&self) -> &RealtimeCapabilities;
fn turning_mode(&self) -> RealtimeTurningMode;
async fn refresh_projection(
&mut self,
open_config: &RealtimeSessionOpenConfig,
) -> Result<(), LlmError>;
async fn send_input(&mut self, chunk: RealtimeInputChunk) -> Result<(), LlmError>;
async fn commit_turn(&mut self) -> Result<(), LlmError>;
async fn interrupt(&mut self) -> Result<(), LlmError>;
async fn truncate_assistant_output(
&mut self,
item_id: String,
content_index: u32,
audio_played_ms: u64,
) -> Result<(), LlmError>;
async fn submit_tool_result(&mut self, result: ToolResult) -> Result<(), LlmError>;
async fn submit_tool_error(&mut self, call_id: String, error: String) -> Result<(), LlmError>;
async fn next_event(&mut self) -> Result<Option<RealtimeSessionEvent>, LlmError>;
async fn close(&mut self) -> Result<(), LlmError>;
}
#[derive(Debug, Clone)]
pub struct RealtimeSessionOpenConfig {
pub turning_mode: RealtimeTurningMode,
pub llm_identity: SessionLlmIdentity,
pub visible_tools: Vec<ToolDef>,
pub seed_messages: Vec<Message>,
pub runtime_system_context: Vec<PendingSystemContextAppend>,
pub response_nudge_timeout_ms: Option<u64>,
pub response_nudge_max_attempts: Option<u8>,
}
impl RealtimeSessionOpenConfig {
#[must_use]
pub fn new(
turning_mode: RealtimeTurningMode,
llm_identity: SessionLlmIdentity,
visible_tools: Vec<ToolDef>,
seed_messages: Vec<Message>,
) -> Self {
Self {
turning_mode,
llm_identity,
visible_tools,
seed_messages,
runtime_system_context: Vec::new(),
response_nudge_timeout_ms: None,
response_nudge_max_attempts: None,
}
}
#[must_use]
pub fn with_runtime_system_context(
mut self,
runtime_system_context: Vec<PendingSystemContextAppend>,
) -> Self {
self.runtime_system_context = runtime_system_context;
self
}
#[must_use]
pub fn with_response_nudge_timeout_ms(mut self, timeout_ms: Option<u64>) -> Self {
self.response_nudge_timeout_ms = timeout_ms;
self
}
#[must_use]
pub fn with_response_nudge_max_attempts(mut self, max_attempts: Option<u8>) -> Self {
self.response_nudge_max_attempts = max_attempts;
self
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait RealtimeSessionFactory: Send + Sync {
fn capabilities(&self) -> RealtimeCapabilities;
async fn open_session(
&self,
open_config: &RealtimeSessionOpenConfig,
) -> Result<Box<dyn RealtimeSession>, LlmError>;
async fn attach_external_session(
&self,
target: &RealtimeExternalSessionTarget,
turning_mode: RealtimeTurningMode,
) -> Result<Box<dyn RealtimeSession>, LlmError>;
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn external_session_target_rejects_blank_provider_id() {
let error = match RealtimeExternalSessionTarget::new(" ") {
Ok(_) => panic!("blank provider id must fail"),
Err(error) => error,
};
assert!(matches!(error, LlmError::InvalidRequest { .. }));
}
#[test]
fn tool_call_projection_strips_internal_arguments_for_public_event() {
let public = RealtimeSessionEvent::ToolCallRequested {
call_id: "call_1".to_string(),
tool_name: "lookup".to_string(),
arguments: serde_json::json!({ "q": "otter" }),
}
.to_public_event();
assert_eq!(
public,
Some(RealtimeEvent::ToolCallRequested {
call_id: "call_1".to_string(),
tool_name: "lookup".to_string(),
})
);
}
}