use serde::{Deserialize, Serialize};
use super::protocol::{RealtimeConversationItem, RealtimeResponse, SessionConfig};
#[derive(Debug, Clone, Deserialize)]
pub struct ServerErrorBody {
#[serde(rename = "type", default)]
pub type_: Option<String>,
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum ClientEvent {
#[serde(rename = "session.update")]
SessionUpdate {
#[serde(skip_serializing_if = "Option::is_none")]
event_id: Option<String>,
session: SessionConfig,
},
#[serde(rename = "input_audio_buffer.append")]
InputAudioBufferAppend {
audio: String,
#[serde(skip_serializing_if = "Option::is_none")]
client_timestamp: Option<i64>,
},
#[serde(rename = "input_audio_buffer.append_video_frame")]
InputAudioBufferAppendVideoFrame {
video_frame: String,
#[serde(skip_serializing_if = "Option::is_none")]
client_timestamp: Option<i64>,
},
#[serde(rename = "input_audio_buffer.commit")]
InputAudioBufferCommit {
#[serde(skip_serializing_if = "Option::is_none")]
client_timestamp: Option<i64>,
},
#[serde(rename = "input_audio_buffer.clear")]
InputAudioBufferClear,
#[serde(rename = "conversation.item.create")]
ConversationItemCreate {
#[serde(skip_serializing_if = "Option::is_none")]
event_id: Option<String>,
item: RealtimeConversationItem,
},
#[serde(rename = "response.create")]
ResponseCreate {
#[serde(skip_serializing_if = "Option::is_none")]
client_timestamp: Option<i64>,
},
#[serde(rename = "response.cancel")]
ResponseCancel {
#[serde(skip_serializing_if = "Option::is_none")]
client_timestamp: Option<i64>,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum ServerEvent {
#[serde(rename = "error")]
Error {
error: ServerErrorBody,
},
#[serde(rename = "session.created")]
SessionCreated,
#[serde(rename = "session.updated")]
SessionUpdated,
#[serde(rename = "conversation.created")]
ConversationCreated,
#[serde(rename = "conversation.item.created")]
ConversationItemCreated {
item: RealtimeConversationItem,
},
#[serde(rename = "conversation.item.input_audio_transcription.completed")]
InputAudioTranscriptionCompleted {
item_id: String,
transcript: String,
},
#[serde(rename = "conversation.item.input_audio_transcription.failed")]
InputAudioTranscriptionFailed {
item_id: String,
error: ServerErrorBody,
},
#[serde(rename = "input_audio_buffer.committed")]
InputAudioBufferCommitted {
#[serde(default)]
item_id: Option<String>,
},
#[serde(rename = "input_audio_buffer.cleared")]
InputAudioBufferCleared,
#[serde(rename = "input_audio_buffer.speech_started")]
InputAudioBufferSpeechStarted,
#[serde(rename = "input_audio_buffer.speech_stopped")]
InputAudioBufferSpeechStopped,
#[serde(rename = "response.created")]
ResponseCreated {
response: RealtimeResponse,
},
#[serde(rename = "response.done")]
ResponseDone {
response: RealtimeResponse,
},
#[serde(rename = "response.audio.delta")]
ResponseAudioDelta {
response_id: String,
#[serde(default)]
item_id: Option<String>,
delta: String,
},
#[serde(rename = "response.audio.done")]
ResponseAudioDone {
response_id: String,
#[serde(default)]
item_id: Option<String>,
},
#[serde(rename = "response.audio_transcript.delta")]
ResponseAudioTranscriptDelta {
response_id: String,
delta: String,
},
#[serde(rename = "response.audio_transcript.done")]
ResponseAudioTranscriptDone {
response_id: String,
transcript: String,
},
#[serde(rename = "response.function_call_arguments.done")]
ResponseFunctionCallArgumentsDone {
response_id: String,
name: String,
arguments: String,
},
#[serde(rename = "response.function_call.simple_browser")]
ResponseFunctionCallSimpleBrowser,
#[serde(rename = "heartbeat")]
Heartbeat,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_update_serializes_to_official_shape() {
let ev = ClientEvent::SessionUpdate {
event_id: Some("evt_1".into()),
session: SessionConfig::default(),
};
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["type"], "session.update");
assert_eq!(json["event_id"], "evt_1");
assert_eq!(json["session"]["input_audio_format"], "wav");
assert_eq!(json["session"]["output_audio_format"], "pcm");
assert_eq!(json["session"]["turn_detection"]["type"], "client_vad");
}
#[test]
fn audio_append_round_trips() {
let ev = ClientEvent::InputAudioBufferAppend {
audio: "UklGRiQ".into(),
client_timestamp: Some(1731999464667),
};
let json = serde_json::to_string(&ev).unwrap();
assert!(json.contains("\"type\":\"input_audio_buffer.append\""));
assert!(json.contains("\"audio\":\"UklGRiQ\""));
assert!(json.contains("\"client_timestamp\":1731999464667"));
}
#[test]
fn server_audio_delta_parses_official_example() {
let raw = r#"{"event_id":"event89","type":"response.audio.delta","client_timestamp":1737454096061,"response_id":"respbc50304acdea479b8bd55efd5346dbdf","output_index":0,"content_index":0,"delta":"+w6hBu39"}"#;
let ev: ServerEvent = serde_json::from_str(raw).unwrap();
match ev {
ServerEvent::ResponseAudioDelta {
response_id, delta, ..
} => {
assert_eq!(response_id, "respbc50304acdea479b8bd55efd5346dbdf");
assert_eq!(delta, "+w6hBu39");
},
_ => panic!("wrong variant"),
}
}
#[test]
fn server_response_done_parses_official_example() {
let raw = r#"{"event_id":"eventb94","type":"response.done","client_timestamp":1739001415611,"response":{"id":"respee64945eafb44facac88cea6f9de86f5","object":"realtime.response","status":"completed","usage":{"total_tokens":7,"input_tokens":4,"output_tokens":3,"input_token_details":{"text_tokens":4,"audio_tokens":0},"output_token_details":{"text_tokens":3,"audio_tokens":0}}}}"#;
let ev: ServerEvent = serde_json::from_str(raw).unwrap();
match ev {
ServerEvent::ResponseDone { response } => {
assert_eq!(response.status.as_deref(), Some("completed"));
let usage = response.usage.unwrap();
assert_eq!(usage.total_tokens, 7);
assert_eq!(usage.output_tokens, 3);
},
_ => panic!("wrong variant"),
}
}
#[test]
fn server_error_parses() {
let raw = r#"{"event_id":"event_890","type":"error","error":{"type":"invalid_request_error","code":"invalid_event","message":"The 'type' field is missing."}}"#;
let ev: ServerEvent = serde_json::from_str(raw).unwrap();
match ev {
ServerEvent::Error { error } => {
assert_eq!(error.code.as_deref(), Some("invalid_event"));
},
_ => panic!("wrong variant"),
}
}
}