1use std::fmt;
4
5use rvoip_core::{CapabilityDescriptor, CodecInfo};
6use serde::Serialize;
7use serde_json::{Map, Value};
8
9use crate::error::{Result, VapiError};
10
11#[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#[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
163pub enum VapiPeerFailurePolicy {
164 #[default]
165 EndCaller,
166 LeaveCallerConnected,
167}
168
169#[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}