Skip to main content

rvoip_vapi/
types.rs

1//! Public call configuration and control-message types.
2
3use std::fmt;
4
5use rvoip_core::{CapabilityDescriptor, CodecInfo};
6use serde::Serialize;
7use serde_json::{Map, Value};
8
9use crate::error::{Result, VapiError};
10
11/// Raw audio format on the Vapi WebSocket.
12#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
13pub enum VapiAudioFormat {
14    #[default]
15    MuLaw8Khz,
16    PcmS16Le16Khz,
17}
18
19impl VapiAudioFormat {
20    pub const fn frame_bytes(self) -> usize {
21        match self {
22            Self::MuLaw8Khz => 160,
23            Self::PcmS16Le16Khz => 640,
24        }
25    }
26
27    pub const fn timestamp_increment(self) -> u32 {
28        match self {
29            Self::MuLaw8Khz => 160,
30            Self::PcmS16Le16Khz => 320,
31        }
32    }
33
34    pub(crate) const fn payload_type(self) -> u8 {
35        match self {
36            Self::MuLaw8Khz => 0,
37            Self::PcmS16Le16Khz => 120,
38        }
39    }
40
41    pub fn codec(self) -> CodecInfo {
42        match self {
43            Self::MuLaw8Khz => CodecInfo {
44                name: "PCMU".into(),
45                clock_rate_hz: 8_000,
46                channels: 1,
47                fmtp: None,
48            },
49            Self::PcmS16Le16Khz => CodecInfo {
50                name: "pcm_s16le".into(),
51                clock_rate_hz: 16_000,
52                channels: 1,
53                fmtp: None,
54            },
55        }
56    }
57
58    pub fn capabilities(self) -> CapabilityDescriptor {
59        CapabilityDescriptor {
60            audio_codecs: vec![self.codec()],
61            max_streams_per_connection: 1,
62            ..CapabilityDescriptor::default()
63        }
64    }
65
66    pub(crate) const fn wire_format(self) -> &'static str {
67        match self {
68            Self::MuLaw8Khz => "mulaw",
69            Self::PcmS16Le16Khz => "pcm_s16le",
70        }
71    }
72
73    pub(crate) const fn sample_rate(self) -> u32 {
74        match self {
75            Self::MuLaw8Khz => 8_000,
76            Self::PcmS16Le16Khz => 16_000,
77        }
78    }
79}
80
81/// Select a saved Vapi assistant or supply a transient assistant definition.
82#[derive(Clone)]
83pub enum VapiAssistant {
84    Saved {
85        id: String,
86        overrides: Option<Value>,
87    },
88    Transient {
89        definition: Value,
90    },
91}
92
93impl VapiAssistant {
94    pub fn saved(id: impl Into<String>) -> Self {
95        Self::Saved {
96            id: id.into(),
97            overrides: None,
98        }
99    }
100
101    pub fn saved_with_overrides(id: impl Into<String>, overrides: Value) -> Self {
102        Self::Saved {
103            id: id.into(),
104            overrides: Some(overrides),
105        }
106    }
107
108    pub fn transient(definition: Value) -> Self {
109        Self::Transient { definition }
110    }
111
112    fn validate(&self) -> Result<()> {
113        match self {
114            Self::Saved { id, overrides } => {
115                if id.trim().is_empty() || id.chars().any(char::is_control) {
116                    return Err(VapiError::InvalidCallOptions("the assistant ID is invalid"));
117                }
118                if overrides.as_ref().is_some_and(|value| !value.is_object()) {
119                    return Err(VapiError::InvalidCallOptions(
120                        "assistant overrides must be a JSON object",
121                    ));
122                }
123            }
124            Self::Transient { definition } if !definition.is_object() => {
125                return Err(VapiError::InvalidCallOptions(
126                    "the transient assistant must be a JSON object",
127                ));
128            }
129            Self::Transient { .. } => {}
130        }
131        Ok(())
132    }
133
134    pub(crate) fn add_to_payload(&self, payload: &mut Map<String, Value>) {
135        match self {
136            Self::Saved { id, overrides } => {
137                payload.insert("assistantId".into(), Value::String(id.clone()));
138                if let Some(overrides) = overrides {
139                    payload.insert("assistantOverrides".into(), overrides.clone());
140                }
141            }
142            Self::Transient { definition } => {
143                payload.insert("assistant".into(), definition.clone());
144            }
145        }
146    }
147}
148
149impl fmt::Debug for VapiAssistant {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        formatter.write_str(match self {
152            Self::Saved { overrides, .. } if overrides.is_some() => {
153                "Saved { id: [redacted], overrides: present }"
154            }
155            Self::Saved { .. } => "Saved { id: [redacted], overrides: absent }",
156            Self::Transient { .. } => "Transient { definition: [redacted] }",
157        })
158    }
159}
160
161/// What the high-level attachment supervisor does when Vapi terminates first.
162#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
163pub enum VapiPeerFailurePolicy {
164    #[default]
165    EndCaller,
166    LeaveCallerConnected,
167}
168
169/// Per-call options carried opaquely through `OriginateRequest`.
170#[derive(Clone)]
171pub struct VapiCallOptions {
172    pub assistant: VapiAssistant,
173    pub audio_format: VapiAudioFormat,
174    pub name: Option<String>,
175    pub metadata: Option<Value>,
176    pub peer_failure_policy: VapiPeerFailurePolicy,
177}
178
179impl VapiCallOptions {
180    pub fn new(assistant: VapiAssistant) -> Self {
181        Self {
182            assistant,
183            audio_format: VapiAudioFormat::default(),
184            name: None,
185            metadata: None,
186            peer_failure_policy: VapiPeerFailurePolicy::default(),
187        }
188    }
189
190    pub fn with_audio_format(mut self, audio_format: VapiAudioFormat) -> Self {
191        self.audio_format = audio_format;
192        self
193    }
194
195    pub fn with_name(mut self, name: impl Into<String>) -> Self {
196        self.name = Some(name.into());
197        self
198    }
199
200    pub fn with_metadata(mut self, metadata: Value) -> Self {
201        self.metadata = Some(metadata);
202        self
203    }
204
205    pub fn with_peer_failure_policy(mut self, policy: VapiPeerFailurePolicy) -> Self {
206        self.peer_failure_policy = policy;
207        self
208    }
209
210    pub fn validate(&self) -> Result<()> {
211        self.assistant.validate()?;
212        if self.name.as_ref().is_some_and(|name| {
213            name.trim().is_empty() || name.len() > 40 || name.chars().any(char::is_control)
214        }) {
215            return Err(VapiError::InvalidCallOptions("the call name is invalid"));
216        }
217        if self
218            .metadata
219            .as_ref()
220            .is_some_and(|metadata| !metadata.is_object())
221        {
222            return Err(VapiError::InvalidCallOptions(
223                "call metadata must be a JSON object",
224            ));
225        }
226        Ok(())
227    }
228
229    pub(crate) fn create_call_payload(&self) -> Value {
230        let mut payload = Map::new();
231        self.assistant.add_to_payload(&mut payload);
232        payload.insert(
233            "transport".into(),
234            serde_json::json!({
235                "provider": "vapi.websocket",
236                "audioFormat": {
237                    "format": self.audio_format.wire_format(),
238                    "container": "raw",
239                    "sampleRate": self.audio_format.sample_rate(),
240                }
241            }),
242        );
243        if let Some(name) = &self.name {
244            payload.insert("name".into(), Value::String(name.clone()));
245        }
246        if let Some(metadata) = &self.metadata {
247            payload.insert("metadata".into(), metadata.clone());
248        }
249        Value::Object(payload)
250    }
251}
252
253impl fmt::Debug for VapiCallOptions {
254    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
255        formatter
256            .debug_struct("VapiCallOptions")
257            .field("assistant", &self.assistant)
258            .field("audio_format", &self.audio_format)
259            .field("name_present", &self.name.is_some())
260            .field("metadata_present", &self.metadata.is_some())
261            .field("peer_failure_policy", &self.peer_failure_policy)
262            .finish()
263    }
264}
265
266#[derive(Clone, Serialize)]
267#[serde(tag = "type")]
268pub(crate) enum VapiCommand {
269    #[serde(rename = "say")]
270    Say {
271        content: String,
272        #[serde(rename = "endCallAfterSpoken")]
273        end_call_after_spoken: bool,
274        #[serde(rename = "interruptAssistantEnabled")]
275        interrupt_assistant_enabled: bool,
276    },
277    #[serde(rename = "add-message")]
278    AddMessage {
279        message: AddedMessage,
280        #[serde(rename = "triggerResponseEnabled")]
281        trigger_response_enabled: bool,
282    },
283    #[serde(rename = "control")]
284    Control { control: &'static str },
285}
286
287#[derive(Clone, Serialize)]
288pub(crate) struct AddedMessage {
289    pub role: String,
290    pub content: String,
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn saved_and_transient_payloads_match_vapi_shape() {
299        let saved = VapiCallOptions::new(VapiAssistant::saved_with_overrides(
300            "assistant-id",
301            serde_json::json!({"firstMessage":"hello"}),
302        ))
303        .create_call_payload();
304        assert_eq!(saved["assistantId"], "assistant-id");
305        assert_eq!(saved["transport"]["provider"], "vapi.websocket");
306        assert_eq!(saved["transport"]["audioFormat"]["format"], "mulaw");
307        assert_eq!(saved["transport"]["audioFormat"]["sampleRate"], 8_000);
308
309        let transient = VapiCallOptions::new(VapiAssistant::transient(
310            serde_json::json!({"model":{"provider":"openai"}}),
311        ))
312        .with_audio_format(VapiAudioFormat::PcmS16Le16Khz)
313        .create_call_payload();
314        assert!(transient.get("assistantId").is_none());
315        assert!(transient["assistant"].is_object());
316        assert_eq!(transient["transport"]["audioFormat"]["format"], "pcm_s16le");
317        assert_eq!(transient["transport"]["audioFormat"]["sampleRate"], 16_000);
318    }
319
320    #[test]
321    fn option_diagnostics_redact_content() {
322        let options = VapiCallOptions::new(VapiAssistant::saved_with_overrides(
323            "assistant-canary",
324            serde_json::json!({"secret":"override-canary"}),
325        ))
326        .with_name("name-canary")
327        .with_metadata(serde_json::json!({"customer":"metadata-canary"}));
328        let debug = format!("{options:?}");
329        for canary in [
330            "assistant-canary",
331            "override-canary",
332            "name-canary",
333            "metadata-canary",
334        ] {
335            assert!(!debug.contains(canary));
336        }
337    }
338}