1use std::fmt;
4use std::time::Duration;
5
6pub const DEFAULT_BASE_URL: &str = "https://api.x.ai/v1";
8
9pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
11pub const DEFAULT_MAX_RETRIES: u32 = 2;
13pub const DEFAULT_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
15pub const DEFAULT_RETRY_MAX_BACKOFF: Duration = Duration::from_secs(2);
17pub const DEFAULT_RETRY_JITTER_FACTOR: f64 = 0.0;
19
20fn normalize_base_url(value: &str) -> String {
21 value.trim().trim_end_matches('/').to_string()
22}
23
24#[derive(Debug, Clone, Copy)]
25pub(crate) struct RetryPolicy {
26 pub max_retries: u32,
27 pub initial_backoff: Duration,
28 pub max_backoff: Duration,
29 pub jitter_factor: f64,
30}
31
32impl Default for RetryPolicy {
33 fn default() -> Self {
34 Self {
35 max_retries: DEFAULT_MAX_RETRIES,
36 initial_backoff: DEFAULT_RETRY_INITIAL_BACKOFF,
37 max_backoff: DEFAULT_RETRY_MAX_BACKOFF,
38 jitter_factor: DEFAULT_RETRY_JITTER_FACTOR,
39 }
40 }
41}
42
43pub mod regions {
45 pub const US_EAST_1: &str = "https://us-east-1.api.x.ai/v1";
47 pub const EU_WEST_1: &str = "https://eu-west-1.api.x.ai/v1";
49}
50
51#[derive(Clone)]
53pub struct SecretString(String);
54
55impl SecretString {
56 pub fn new(value: impl Into<String>) -> Self {
58 Self(value.into())
59 }
60
61 pub fn expose(&self) -> &str {
63 &self.0
64 }
65}
66
67impl fmt::Debug for SecretString {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 write!(f, "[REDACTED]")
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct ClientConfig {
76 pub api_key: SecretString,
78 pub base_url: String,
80 pub timeout: Duration,
82}
83
84impl ClientConfig {
85 pub fn new(api_key: impl Into<String>) -> Self {
87 Self {
88 api_key: SecretString::new(api_key),
89 base_url: DEFAULT_BASE_URL.to_string(),
90 timeout: DEFAULT_TIMEOUT,
91 }
92 }
93
94 pub fn from_env() -> Result<Self, std::env::VarError> {
96 let api_key = std::env::var("XAI_API_KEY")?;
97 Ok(Self::new(api_key))
98 }
99}
100
101#[derive(Debug, Default)]
103pub struct XaiClientBuilder {
104 api_key: Option<String>,
105 base_url: Option<String>,
106 timeout: Option<Duration>,
107 max_retries: Option<u32>,
108 retry_initial_backoff: Option<Duration>,
109 retry_max_backoff: Option<Duration>,
110 retry_jitter_factor: Option<f64>,
111}
112
113impl XaiClientBuilder {
114 pub fn new() -> Self {
116 Self::default()
117 }
118
119 pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
121 self.api_key = Some(api_key.into());
122 self
123 }
124
125 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
127 let base_url = base_url.into();
128 self.base_url = Some(normalize_base_url(&base_url));
129 self
130 }
131
132 pub fn us_east_1(self) -> Self {
134 self.base_url(regions::US_EAST_1)
135 }
136
137 pub fn eu_west_1(self) -> Self {
139 self.base_url(regions::EU_WEST_1)
140 }
141
142 pub fn timeout(mut self, timeout: Duration) -> Self {
144 self.timeout = Some(timeout);
145 self
146 }
147
148 pub fn timeout_secs(self, secs: u64) -> Self {
150 self.timeout(Duration::from_secs(secs))
151 }
152
153 pub fn max_retries(mut self, max_retries: u32) -> Self {
155 self.max_retries = Some(max_retries);
156 self
157 }
158
159 pub fn disable_retries(self) -> Self {
161 self.max_retries(0)
162 }
163
164 pub fn retry_backoff(mut self, initial: Duration, max: Duration) -> Self {
166 self.retry_initial_backoff = Some(initial);
167 self.retry_max_backoff = Some(max);
168 self
169 }
170
171 pub fn retry_jitter(mut self, factor: f64) -> Self {
173 self.retry_jitter_factor = Some(factor.clamp(0.0, 1.0));
174 self
175 }
176
177 pub(crate) fn build_retry_policy(&self) -> RetryPolicy {
178 let initial_backoff = self
179 .retry_initial_backoff
180 .unwrap_or(DEFAULT_RETRY_INITIAL_BACKOFF);
181 let max_backoff = self
182 .retry_max_backoff
183 .unwrap_or(DEFAULT_RETRY_MAX_BACKOFF)
184 .max(initial_backoff);
185
186 RetryPolicy {
187 max_retries: self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES),
188 initial_backoff,
189 max_backoff,
190 jitter_factor: self
191 .retry_jitter_factor
192 .unwrap_or(DEFAULT_RETRY_JITTER_FACTOR),
193 }
194 }
195
196 pub fn build_config(self) -> crate::Result<ClientConfig> {
203 let XaiClientBuilder {
204 api_key,
205 base_url,
206 timeout,
207 max_retries: _,
208 retry_initial_backoff: _,
209 retry_max_backoff: _,
210 retry_jitter_factor: _,
211 } = self;
212
213 let api_key = api_key
214 .or_else(|| std::env::var("XAI_API_KEY").ok())
215 .ok_or_else(|| {
216 crate::Error::Config(
217 "API key not provided and XAI_API_KEY environment variable not set".to_string(),
218 )
219 })?;
220
221 Ok(ClientConfig {
222 api_key: SecretString::new(api_key),
223 base_url: normalize_base_url(base_url.as_deref().unwrap_or(DEFAULT_BASE_URL)),
224 timeout: timeout.unwrap_or(DEFAULT_TIMEOUT),
225 })
226 }
227
228 pub fn build(self) -> crate::Result<crate::XaiClient> {
235 let retry_policy = self.build_retry_policy();
236 let config = self.build_config()?;
237 crate::XaiClient::with_config_and_retry(config, retry_policy)
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
248 fn secret_string_debug_is_redacted() {
249 let secret = SecretString::new("super-secret-key-12345");
250 let debug_output = format!("{:?}", secret);
251 assert_eq!(debug_output, "[REDACTED]");
252 assert!(!debug_output.contains("super-secret-key"));
253 }
254
255 #[test]
256 fn secret_string_expose_returns_value() {
257 let secret = SecretString::new("my-key");
258 assert_eq!(secret.expose(), "my-key");
259 }
260
261 #[test]
262 fn secret_string_clone() {
263 let secret = SecretString::new("key");
264 let cloned = secret.clone();
265 assert_eq!(cloned.expose(), "key");
266 }
267
268 #[test]
271 fn client_config_new_defaults() {
272 let config = ClientConfig::new("test-key");
273 assert_eq!(config.api_key.expose(), "test-key");
274 assert_eq!(config.base_url, DEFAULT_BASE_URL);
275 assert_eq!(config.timeout, DEFAULT_TIMEOUT);
276 }
277
278 #[test]
279 fn client_config_debug_redacts_key() {
280 let config = ClientConfig::new("secret-key-value");
281 let debug_output = format!("{:?}", config);
282 assert!(debug_output.contains("[REDACTED]"));
283 assert!(!debug_output.contains("secret-key-value"));
284 }
285
286 #[test]
289 fn builder_chain_api_key() {
290 let config = XaiClientBuilder::new()
291 .api_key("my-key")
292 .build_config()
293 .unwrap();
294 assert_eq!(config.api_key.expose(), "my-key");
295 assert_eq!(config.base_url, DEFAULT_BASE_URL);
296 }
297
298 #[test]
299 fn builder_chain_base_url() {
300 let config = XaiClientBuilder::new()
301 .api_key("key")
302 .base_url("https://custom.api.com/v1")
303 .build_config()
304 .unwrap();
305 assert_eq!(config.base_url, "https://custom.api.com/v1");
306 }
307
308 #[test]
309 fn builder_chain_timeout() {
310 let config = XaiClientBuilder::new()
311 .api_key("key")
312 .timeout(Duration::from_secs(300))
313 .build_config()
314 .unwrap();
315 assert_eq!(config.timeout, Duration::from_secs(300));
316 }
317
318 #[test]
319 fn builder_chain_timeout_secs() {
320 let config = XaiClientBuilder::new()
321 .api_key("key")
322 .timeout_secs(60)
323 .build_config()
324 .unwrap();
325 assert_eq!(config.timeout, Duration::from_secs(60));
326 }
327
328 #[test]
329 fn builder_us_east_1() {
330 let config = XaiClientBuilder::new()
331 .api_key("key")
332 .us_east_1()
333 .build_config()
334 .unwrap();
335 assert_eq!(config.base_url, regions::US_EAST_1);
336 }
337
338 #[test]
339 fn builder_eu_west_1() {
340 let config = XaiClientBuilder::new()
341 .api_key("key")
342 .eu_west_1()
343 .build_config()
344 .unwrap();
345 assert_eq!(config.base_url, regions::EU_WEST_1);
346 }
347
348 #[test]
349 fn builder_without_key_fails() {
350 let result = XaiClientBuilder::new().build_config();
354 if std::env::var("XAI_API_KEY").is_err() {
357 assert!(result.is_err());
358 let err = result.unwrap_err();
359 let msg = format!("{err}");
360 assert!(msg.contains("API key"));
361 }
362 }
363
364 #[test]
365 fn builder_full_chain() {
366 let config = XaiClientBuilder::new()
367 .api_key("my-api-key")
368 .base_url("https://custom.example.com/v2")
369 .timeout_secs(30)
370 .build_config()
371 .unwrap();
372
373 assert_eq!(config.api_key.expose(), "my-api-key");
374 assert_eq!(config.base_url, "https://custom.example.com/v2");
375 assert_eq!(config.timeout, Duration::from_secs(30));
376 }
377
378 #[test]
379 fn builder_retry_policy_defaults() {
380 let policy = XaiClientBuilder::new().build_retry_policy();
381 assert_eq!(policy.max_retries, DEFAULT_MAX_RETRIES);
382 assert_eq!(policy.initial_backoff, DEFAULT_RETRY_INITIAL_BACKOFF);
383 assert_eq!(policy.max_backoff, DEFAULT_RETRY_MAX_BACKOFF);
384 assert_eq!(policy.jitter_factor, DEFAULT_RETRY_JITTER_FACTOR);
385 }
386
387 #[test]
388 fn builder_retry_policy_custom_values() {
389 let policy = XaiClientBuilder::new()
390 .max_retries(5)
391 .retry_backoff(Duration::from_millis(150), Duration::from_secs(3))
392 .retry_jitter(0.25)
393 .build_retry_policy();
394
395 assert_eq!(policy.max_retries, 5);
396 assert_eq!(policy.initial_backoff, Duration::from_millis(150));
397 assert_eq!(policy.max_backoff, Duration::from_secs(3));
398 assert!((policy.jitter_factor - 0.25).abs() < f64::EPSILON);
399 }
400
401 #[test]
402 fn builder_disable_retries_sets_zero_max_retries() {
403 let policy = XaiClientBuilder::new()
404 .disable_retries()
405 .build_retry_policy();
406 assert_eq!(policy.max_retries, 0);
407 }
408
409 #[test]
410 fn builder_default_is_empty() {
411 let builder = XaiClientBuilder::default();
412 let debug = format!("{:?}", builder);
413 assert!(debug.contains("XaiClientBuilder"));
414 }
415
416 #[test]
419 fn default_base_url_is_correct() {
420 assert_eq!(DEFAULT_BASE_URL, "https://api.x.ai/v1");
421 }
422
423 #[test]
424 fn default_timeout_is_120s() {
425 assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(120));
426 }
427
428 #[test]
429 fn regional_endpoints_are_valid() {
430 assert!(regions::US_EAST_1.starts_with("https://"));
431 assert!(regions::US_EAST_1.contains("us-east-1"));
432 assert!(regions::EU_WEST_1.starts_with("https://"));
433 assert!(regions::EU_WEST_1.contains("eu-west-1"));
434 }
435}