Skip to main content

xai_rust/
config.rs

1//! Client configuration and builder.
2
3use std::fmt;
4use std::time::Duration;
5
6/// Default base URL for the xAI API.
7pub const DEFAULT_BASE_URL: &str = "https://api.x.ai/v1";
8
9/// Default request timeout (2 minutes).
10pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
11/// Default number of retries for transient failures.
12pub const DEFAULT_MAX_RETRIES: u32 = 2;
13/// Default initial retry backoff delay.
14pub const DEFAULT_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
15/// Default maximum retry backoff delay.
16pub const DEFAULT_RETRY_MAX_BACKOFF: Duration = Duration::from_secs(2);
17/// Default retry jitter factor (0.0 disables jitter).
18pub 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
43/// Regional endpoints for the xAI API.
44pub mod regions {
45    /// US East region endpoint.
46    pub const US_EAST_1: &str = "https://us-east-1.api.x.ai/v1";
47    /// EU West region endpoint.
48    pub const EU_WEST_1: &str = "https://eu-west-1.api.x.ai/v1";
49}
50
51/// A wrapper around the API key that redacts its value in `Debug` output.
52#[derive(Clone)]
53pub struct SecretString(String);
54
55impl SecretString {
56    /// Create a new secret string.
57    pub fn new(value: impl Into<String>) -> Self {
58        Self(value.into())
59    }
60
61    /// Get the secret value.
62    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/// Configuration for the xAI client.
74#[derive(Debug, Clone)]
75pub struct ClientConfig {
76    /// API key for authentication (redacted in Debug output).
77    pub api_key: SecretString,
78    /// Base URL for API requests.
79    pub base_url: String,
80    /// Request timeout.
81    pub timeout: Duration,
82}
83
84impl ClientConfig {
85    /// Create a new configuration with the given API key.
86    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    /// Create a configuration from environment variable XAI_API_KEY.
95    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/// Builder for configuring an xAI client.
102#[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    /// Create a new client builder.
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Set the API key.
120    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    /// Set the base URL.
126    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    /// Use the US East 1 regional endpoint.
133    pub fn us_east_1(self) -> Self {
134        self.base_url(regions::US_EAST_1)
135    }
136
137    /// Use the EU West 1 regional endpoint.
138    pub fn eu_west_1(self) -> Self {
139        self.base_url(regions::EU_WEST_1)
140    }
141
142    /// Set the request timeout.
143    pub fn timeout(mut self, timeout: Duration) -> Self {
144        self.timeout = Some(timeout);
145        self
146    }
147
148    /// Set the timeout in seconds.
149    pub fn timeout_secs(self, secs: u64) -> Self {
150        self.timeout(Duration::from_secs(secs))
151    }
152
153    /// Set maximum number of retry attempts for transient failures.
154    pub fn max_retries(mut self, max_retries: u32) -> Self {
155        self.max_retries = Some(max_retries);
156        self
157    }
158
159    /// Disable automatic retries.
160    pub fn disable_retries(self) -> Self {
161        self.max_retries(0)
162    }
163
164    /// Set exponential backoff bounds for retries.
165    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    /// Set jitter factor for retries (0.0 to 1.0).
172    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    /// Build the client configuration.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if no API key was provided and XAI_API_KEY
201    /// environment variable is not set.
202    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    /// Build the xAI client.
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if configuration is invalid or HTTP client
233    /// cannot be created.
234    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    // ── SecretString ──────────────────────────────────────────────────
246
247    #[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    // ── ClientConfig ──────────────────────────────────────────────────
269
270    #[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    // ── XaiClientBuilder ──────────────────────────────────────────────
287
288    #[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        // Make sure no XAI_API_KEY is set for this test
351        // The builder falls back to env var, so this test is best-effort
352        // We simply verify the error path exists
353        let result = XaiClientBuilder::new().build_config();
354        // If XAI_API_KEY is set in the environment, this will succeed
355        // If not, it should fail
356        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    // ── Constants ─────────────────────────────────────────────────────
417
418    #[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}