1use std::fmt;
4use std::time::Duration;
5
6use url::Url;
7use zeroize::Zeroize;
8
9use crate::error::{Result, VapiError};
10
11#[derive(Clone, Eq, PartialEq)]
13pub struct VapiApiKey(String);
14
15impl VapiApiKey {
16 pub fn new(value: impl Into<String>) -> Result<Self> {
17 let mut value = value.into();
18 if value.trim().is_empty() {
19 value.zeroize();
20 return Err(VapiError::InvalidConfiguration(
21 "the API key must not be empty",
22 ));
23 }
24 if value.chars().any(char::is_control) {
25 value.zeroize();
26 return Err(VapiError::InvalidConfiguration(
27 "the API key contains control characters",
28 ));
29 }
30 Ok(Self(value))
31 }
32
33 pub(crate) fn expose_secret(&self) -> &str {
34 &self.0
35 }
36}
37
38impl fmt::Debug for VapiApiKey {
39 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40 formatter.write_str("VapiApiKey([redacted])")
41 }
42}
43
44impl Drop for VapiApiKey {
45 fn drop(&mut self) {
46 self.0.zeroize();
47 }
48}
49
50#[derive(Clone)]
52pub struct VapiConfig {
53 pub api_key: VapiApiKey,
54 pub api_base: Url,
55 pub http_timeout: Duration,
56 pub websocket_timeout: Duration,
57 pub websocket_io_timeout: Duration,
58 pub graceful_shutdown_timeout: Duration,
59 pub heartbeat_interval: Duration,
60 pub media_queue_capacity: usize,
61 pub control_queue_capacity: usize,
62 pub event_queue_capacity: usize,
63 pub max_message_bytes: usize,
64 pub startup_audio_frames: usize,
65 pub(crate) allow_insecure_transport: bool,
66}
67
68impl VapiConfig {
69 pub fn new(api_key: VapiApiKey) -> Self {
70 let api_base = match Url::parse("https://api.vapi.ai/") {
71 Ok(url) => url,
72 Err(_) => unreachable!("the built-in Vapi API URL is valid"),
73 };
74 Self {
75 api_key,
76 api_base,
77 http_timeout: Duration::from_secs(10),
78 websocket_timeout: Duration::from_secs(10),
79 websocket_io_timeout: Duration::from_secs(10),
80 graceful_shutdown_timeout: Duration::from_secs(2),
81 heartbeat_interval: Duration::from_secs(20),
82 media_queue_capacity: 100,
83 control_queue_capacity: 32,
84 event_queue_capacity: 100,
85 max_message_bytes: 1024 * 1024,
86 startup_audio_frames: 100,
87 allow_insecure_transport: false,
88 }
89 }
90
91 pub fn with_api_base(mut self, api_base: Url) -> Self {
92 self.api_base = api_base;
93 self
94 }
95
96 pub fn with_loopback_test_transport(mut self) -> Self {
101 self.allow_insecure_transport = true;
102 self
103 }
104
105 pub fn validate(&self) -> Result<()> {
106 if self.api_base.scheme() != "https"
107 && !(self.allow_insecure_transport && is_loopback_url(&self.api_base))
108 {
109 return Err(VapiError::InvalidConfiguration(
110 "the API base must use HTTPS",
111 ));
112 }
113 if self.http_timeout.is_zero()
114 || self.websocket_timeout.is_zero()
115 || self.websocket_io_timeout.is_zero()
116 || self.graceful_shutdown_timeout.is_zero()
117 || self.heartbeat_interval.is_zero()
118 {
119 return Err(VapiError::InvalidConfiguration(
120 "timeouts and heartbeat interval must be non-zero",
121 ));
122 }
123 if self.media_queue_capacity == 0
124 || self.control_queue_capacity == 0
125 || self.event_queue_capacity == 0
126 || self.startup_audio_frames == 0
127 {
128 return Err(VapiError::InvalidConfiguration(
129 "queue capacities must be non-zero",
130 ));
131 }
132 if self.startup_audio_frames > 100 {
133 return Err(VapiError::InvalidConfiguration(
134 "startup audio buffering cannot exceed 100 frames",
135 ));
136 }
137 if self.max_message_bytes < 640 {
138 return Err(VapiError::InvalidConfiguration(
139 "maximum message size is too small for one PCM frame",
140 ));
141 }
142 Ok(())
143 }
144
145 pub(crate) fn permits_websocket_url(&self, url: &Url) -> bool {
146 url.scheme() == "wss"
147 || (self.allow_insecure_transport && url.scheme() == "ws" && is_loopback_url(url))
148 }
149}
150
151impl fmt::Debug for VapiConfig {
152 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153 formatter
154 .debug_struct("VapiConfig")
155 .field("api_key", &self.api_key)
156 .field("api_base", &"[redacted]")
157 .field("http_timeout", &self.http_timeout)
158 .field("websocket_timeout", &self.websocket_timeout)
159 .field("websocket_io_timeout", &self.websocket_io_timeout)
160 .field("graceful_shutdown_timeout", &self.graceful_shutdown_timeout)
161 .field("heartbeat_interval", &self.heartbeat_interval)
162 .field("media_queue_capacity", &self.media_queue_capacity)
163 .field("control_queue_capacity", &self.control_queue_capacity)
164 .field("event_queue_capacity", &self.event_queue_capacity)
165 .field("max_message_bytes", &self.max_message_bytes)
166 .field("startup_audio_frames", &self.startup_audio_frames)
167 .field("insecure_transport", &self.allow_insecure_transport)
168 .finish()
169 }
170}
171
172fn is_loopback_url(url: &Url) -> bool {
173 url.host_str().is_some_and(|host| {
174 host.eq_ignore_ascii_case("localhost")
175 || host
176 .parse::<std::net::IpAddr>()
177 .is_ok_and(|address| address.is_loopback())
178 })
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn diagnostics_redact_key_and_endpoint() {
187 let config = VapiConfig::new(VapiApiKey::new("key-canary").unwrap())
188 .with_api_base(Url::parse("https://endpoint-canary.invalid/").unwrap());
189 let debug = format!("{config:?}");
190 assert!(!debug.contains("key-canary"));
191 assert!(!debug.contains("endpoint-canary"));
192 }
193
194 #[test]
195 fn insecure_transport_is_loopback_only() {
196 let key = VapiApiKey::new("test").unwrap();
197 let loopback = VapiConfig::new(key.clone())
198 .with_api_base(Url::parse("http://127.0.0.1:8000/").unwrap())
199 .with_loopback_test_transport();
200 assert!(loopback.validate().is_ok());
201
202 let remote = VapiConfig::new(key)
203 .with_api_base(Url::parse("http://example.com/").unwrap())
204 .with_loopback_test_transport();
205 assert!(remote.validate().is_err());
206 }
207
208 #[test]
209 fn startup_audio_is_capped_at_two_seconds() {
210 let mut config = VapiConfig::new(VapiApiKey::new("test").unwrap());
211 config.startup_audio_frames = 100;
212 assert!(config.validate().is_ok());
213 config.startup_audio_frames = 101;
214 assert!(config.validate().is_err());
215 }
216}