use std::fmt;
use rvoip_core::{CapabilityDescriptor, CodecInfo};
use serde::Serialize;
use serde_json::{Map, Value};
use crate::error::{Result, VapiError};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum VapiAudioFormat {
#[default]
MuLaw8Khz,
PcmS16Le16Khz,
}
impl VapiAudioFormat {
pub const fn frame_bytes(self) -> usize {
match self {
Self::MuLaw8Khz => 160,
Self::PcmS16Le16Khz => 640,
}
}
pub const fn timestamp_increment(self) -> u32 {
match self {
Self::MuLaw8Khz => 160,
Self::PcmS16Le16Khz => 320,
}
}
pub(crate) const fn payload_type(self) -> u8 {
match self {
Self::MuLaw8Khz => 0,
Self::PcmS16Le16Khz => 120,
}
}
pub fn codec(self) -> CodecInfo {
match self {
Self::MuLaw8Khz => CodecInfo {
name: "PCMU".into(),
clock_rate_hz: 8_000,
channels: 1,
fmtp: None,
},
Self::PcmS16Le16Khz => CodecInfo {
name: "pcm_s16le".into(),
clock_rate_hz: 16_000,
channels: 1,
fmtp: None,
},
}
}
pub fn capabilities(self) -> CapabilityDescriptor {
CapabilityDescriptor {
audio_codecs: vec![self.codec()],
max_streams_per_connection: 1,
..CapabilityDescriptor::default()
}
}
pub(crate) const fn wire_format(self) -> &'static str {
match self {
Self::MuLaw8Khz => "mulaw",
Self::PcmS16Le16Khz => "pcm_s16le",
}
}
pub(crate) const fn sample_rate(self) -> u32 {
match self {
Self::MuLaw8Khz => 8_000,
Self::PcmS16Le16Khz => 16_000,
}
}
}
#[derive(Clone)]
pub enum VapiAssistant {
Saved {
id: String,
overrides: Option<Value>,
},
Transient {
definition: Value,
},
}
impl VapiAssistant {
pub fn saved(id: impl Into<String>) -> Self {
Self::Saved {
id: id.into(),
overrides: None,
}
}
pub fn saved_with_overrides(id: impl Into<String>, overrides: Value) -> Self {
Self::Saved {
id: id.into(),
overrides: Some(overrides),
}
}
pub fn transient(definition: Value) -> Self {
Self::Transient { definition }
}
fn validate(&self) -> Result<()> {
match self {
Self::Saved { id, overrides } => {
if id.trim().is_empty() || id.chars().any(char::is_control) {
return Err(VapiError::InvalidCallOptions("the assistant ID is invalid"));
}
if overrides.as_ref().is_some_and(|value| !value.is_object()) {
return Err(VapiError::InvalidCallOptions(
"assistant overrides must be a JSON object",
));
}
}
Self::Transient { definition } if !definition.is_object() => {
return Err(VapiError::InvalidCallOptions(
"the transient assistant must be a JSON object",
));
}
Self::Transient { .. } => {}
}
Ok(())
}
pub(crate) fn add_to_payload(&self, payload: &mut Map<String, Value>) {
match self {
Self::Saved { id, overrides } => {
payload.insert("assistantId".into(), Value::String(id.clone()));
if let Some(overrides) = overrides {
payload.insert("assistantOverrides".into(), overrides.clone());
}
}
Self::Transient { definition } => {
payload.insert("assistant".into(), definition.clone());
}
}
}
}
impl fmt::Debug for VapiAssistant {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Saved { overrides, .. } if overrides.is_some() => {
"Saved { id: [redacted], overrides: present }"
}
Self::Saved { .. } => "Saved { id: [redacted], overrides: absent }",
Self::Transient { .. } => "Transient { definition: [redacted] }",
})
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum VapiPeerFailurePolicy {
#[default]
EndCaller,
LeaveCallerConnected,
}
#[derive(Clone)]
pub struct VapiCallOptions {
pub assistant: VapiAssistant,
pub audio_format: VapiAudioFormat,
pub name: Option<String>,
pub metadata: Option<Value>,
pub peer_failure_policy: VapiPeerFailurePolicy,
}
impl VapiCallOptions {
pub fn new(assistant: VapiAssistant) -> Self {
Self {
assistant,
audio_format: VapiAudioFormat::default(),
name: None,
metadata: None,
peer_failure_policy: VapiPeerFailurePolicy::default(),
}
}
pub fn with_audio_format(mut self, audio_format: VapiAudioFormat) -> Self {
self.audio_format = audio_format;
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn with_peer_failure_policy(mut self, policy: VapiPeerFailurePolicy) -> Self {
self.peer_failure_policy = policy;
self
}
pub fn validate(&self) -> Result<()> {
self.assistant.validate()?;
if self.name.as_ref().is_some_and(|name| {
name.trim().is_empty() || name.len() > 40 || name.chars().any(char::is_control)
}) {
return Err(VapiError::InvalidCallOptions("the call name is invalid"));
}
if self
.metadata
.as_ref()
.is_some_and(|metadata| !metadata.is_object())
{
return Err(VapiError::InvalidCallOptions(
"call metadata must be a JSON object",
));
}
Ok(())
}
pub(crate) fn create_call_payload(&self) -> Value {
let mut payload = Map::new();
self.assistant.add_to_payload(&mut payload);
payload.insert(
"transport".into(),
serde_json::json!({
"provider": "vapi.websocket",
"audioFormat": {
"format": self.audio_format.wire_format(),
"container": "raw",
"sampleRate": self.audio_format.sample_rate(),
}
}),
);
if let Some(name) = &self.name {
payload.insert("name".into(), Value::String(name.clone()));
}
if let Some(metadata) = &self.metadata {
payload.insert("metadata".into(), metadata.clone());
}
Value::Object(payload)
}
}
impl fmt::Debug for VapiCallOptions {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("VapiCallOptions")
.field("assistant", &self.assistant)
.field("audio_format", &self.audio_format)
.field("name_present", &self.name.is_some())
.field("metadata_present", &self.metadata.is_some())
.field("peer_failure_policy", &self.peer_failure_policy)
.finish()
}
}
#[derive(Clone, Serialize)]
#[serde(tag = "type")]
pub(crate) enum VapiCommand {
#[serde(rename = "say")]
Say {
content: String,
#[serde(rename = "endCallAfterSpoken")]
end_call_after_spoken: bool,
#[serde(rename = "interruptAssistantEnabled")]
interrupt_assistant_enabled: bool,
},
#[serde(rename = "add-message")]
AddMessage {
message: AddedMessage,
#[serde(rename = "triggerResponseEnabled")]
trigger_response_enabled: bool,
},
#[serde(rename = "control")]
Control { control: &'static str },
}
#[derive(Clone, Serialize)]
pub(crate) struct AddedMessage {
pub role: String,
pub content: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saved_and_transient_payloads_match_vapi_shape() {
let saved = VapiCallOptions::new(VapiAssistant::saved_with_overrides(
"assistant-id",
serde_json::json!({"firstMessage":"hello"}),
))
.create_call_payload();
assert_eq!(saved["assistantId"], "assistant-id");
assert_eq!(saved["transport"]["provider"], "vapi.websocket");
assert_eq!(saved["transport"]["audioFormat"]["format"], "mulaw");
assert_eq!(saved["transport"]["audioFormat"]["sampleRate"], 8_000);
let transient = VapiCallOptions::new(VapiAssistant::transient(
serde_json::json!({"model":{"provider":"openai"}}),
))
.with_audio_format(VapiAudioFormat::PcmS16Le16Khz)
.create_call_payload();
assert!(transient.get("assistantId").is_none());
assert!(transient["assistant"].is_object());
assert_eq!(transient["transport"]["audioFormat"]["format"], "pcm_s16le");
assert_eq!(transient["transport"]["audioFormat"]["sampleRate"], 16_000);
}
#[test]
fn option_diagnostics_redact_content() {
let options = VapiCallOptions::new(VapiAssistant::saved_with_overrides(
"assistant-canary",
serde_json::json!({"secret":"override-canary"}),
))
.with_name("name-canary")
.with_metadata(serde_json::json!({"customer":"metadata-canary"}));
let debug = format!("{options:?}");
for canary in [
"assistant-canary",
"override-canary",
"name-canary",
"metadata-canary",
] {
assert!(!debug.contains(canary));
}
}
}