Skip to main content

xai_rust/
client.rs

1//! xAI API client implementation.
2
3use httpdate::parse_http_date;
4use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
5use reqwest::{RequestBuilder, Response, StatusCode};
6use std::sync::Arc;
7use std::time::Duration;
8use std::time::{SystemTime, UNIX_EPOCH};
9use tokio::time::sleep;
10#[cfg(feature = "telemetry")]
11use tracing::{debug, warn};
12
13#[cfg(feature = "files")]
14use crate::api::FilesApi;
15#[cfg(feature = "realtime")]
16use crate::api::RealtimeApi;
17use crate::api::{
18    AuthApi, BatchApi, ChatApi, CollectionsApi, DocumentsApi, EmbeddingsApi, ImagesApi, ModelsApi,
19    ResponsesApi, TokenizerApi, VideosApi,
20};
21use crate::config::{ClientConfig, RetryPolicy, XaiClientBuilder};
22use crate::sync::SyncXaiClient;
23use crate::{Error, Result};
24
25const SDK_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
26const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
27
28/// The main xAI API client.
29///
30/// Use this client to interact with all xAI API endpoints.
31///
32/// # Example
33///
34/// ```rust,no_run
35/// use xai_rust::XaiClient;
36///
37/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
38/// // Create from API key
39/// let client = XaiClient::new("your-api-key")?;
40///
41/// // Or use the builder
42/// let client = XaiClient::builder()
43///     .api_key("your-api-key")
44///     .timeout_secs(300)
45///     .build()?;
46///
47/// // Or load from XAI_API_KEY environment variable
48/// let client = XaiClient::from_env()?;
49/// # Ok(())
50/// # }
51/// ```
52#[derive(Debug, Clone)]
53pub struct XaiClient {
54    inner: Arc<ClientInner>,
55}
56
57#[derive(Debug)]
58struct ClientInner {
59    config: ClientConfig,
60    http: reqwest::Client,
61    retry_policy: RetryPolicy,
62}
63
64impl XaiClient {
65    /// Create a new client with the given API key.
66    ///
67    /// Uses default configuration (global endpoint, 120s timeout).
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the API key contains invalid header characters
72    /// or the HTTP client cannot be created.
73    pub fn new(api_key: impl Into<String>) -> Result<Self> {
74        let config = ClientConfig::new(api_key);
75        Self::with_config(config)
76    }
77
78    /// Create a new client from the XAI_API_KEY environment variable.
79    pub fn from_env() -> Result<Self> {
80        let config = ClientConfig::from_env()
81            .map_err(|_| Error::Config("XAI_API_KEY environment variable not set".to_string()))?;
82        Self::with_config(config)
83    }
84
85    /// Create a new client builder.
86    pub fn builder() -> XaiClientBuilder {
87        XaiClientBuilder::new()
88    }
89
90    /// Create a blocking/sync facade from this async client.
91    ///
92    /// The sync facade is intended for non-async applications and should not be
93    /// used from inside an active async runtime.
94    pub fn sync(&self) -> Result<SyncXaiClient> {
95        SyncXaiClient::with_async_client(self.clone())
96    }
97
98    /// Convert this async client into a blocking/sync facade.
99    ///
100    /// The sync facade is intended for non-async applications and should not be
101    /// used from inside an active async runtime.
102    pub fn into_sync(self) -> Result<SyncXaiClient> {
103        SyncXaiClient::with_async_client(self)
104    }
105
106    /// Create a client with the given configuration.
107    pub fn with_config(config: ClientConfig) -> Result<Self> {
108        Self::with_config_and_retry(config, RetryPolicy::default())
109    }
110
111    pub(crate) fn with_config_and_retry(
112        config: ClientConfig,
113        retry_policy: RetryPolicy,
114    ) -> Result<Self> {
115        let mut headers = HeaderMap::new();
116        headers.insert(
117            AUTHORIZATION,
118            HeaderValue::from_str(&format!("Bearer {}", config.api_key.expose()))
119                .map_err(|_| Error::Config("Invalid API key format".to_string()))?,
120        );
121        headers.insert(USER_AGENT, HeaderValue::from_static(SDK_USER_AGENT));
122
123        let http = reqwest::Client::builder()
124            .default_headers(headers)
125            .timeout(config.timeout)
126            .build()?;
127
128        Ok(Self {
129            inner: Arc::new(ClientInner {
130                config,
131                http,
132                retry_policy,
133            }),
134        })
135    }
136
137    /// Get the base URL for API requests.
138    pub fn base_url(&self) -> &str {
139        &self.inner.config.base_url
140    }
141
142    /// Get a reference to the HTTP client.
143    pub(crate) fn http(&self) -> &reqwest::Client {
144        &self.inner.http
145    }
146
147    pub(crate) fn retry_policy(&self) -> RetryPolicy {
148        self.inner.retry_policy
149    }
150
151    pub(crate) async fn send(&self, request: RequestBuilder) -> Result<Response> {
152        self.send_with_retry_policy(request, None).await
153    }
154
155    pub(crate) async fn send_with_retry_policy(
156        &self,
157        request: RequestBuilder,
158        retry_policy_override: Option<RetryPolicy>,
159    ) -> Result<Response> {
160        let retry_policy = retry_policy_override.unwrap_or(self.inner.retry_policy);
161
162        #[cfg(feature = "telemetry")]
163        let (request_method, request_url) = request_telemetry_fields(&request);
164
165        let mut current = Some(request);
166
167        for attempt in 0..=retry_policy.max_retries {
168            #[cfg(feature = "telemetry")]
169            debug!(
170                attempt,
171                max_retries = retry_policy.max_retries,
172                method = %request_method,
173                url = %request_url,
174                "xai-rust request attempt"
175            );
176
177            let req = current
178                .take()
179                .ok_or_else(|| Error::Config("Request cannot be retried".to_string()))?;
180            let retry_clone = req.try_clone();
181
182            match req.send().await {
183                Ok(response) => {
184                    if attempt < retry_policy.max_retries
185                        && is_retryable_status(response.status())
186                        && retry_clone.is_some()
187                    {
188                        let delay = retry_delay_from_response(
189                            &retry_policy,
190                            attempt,
191                            response.status(),
192                            response.headers().get("retry-after"),
193                        );
194                        let delay = apply_jitter(delay, retry_policy.jitter_factor);
195                        #[cfg(feature = "telemetry")]
196                        warn!(
197                            attempt,
198                            status = response.status().as_u16(),
199                            delay_ms = delay.as_millis() as u64,
200                            method = %request_method,
201                            url = %request_url,
202                            "xai-rust retrying request after retryable status"
203                        );
204                        sleep(delay).await;
205                        current = retry_clone;
206                        continue;
207                    }
208                    return Ok(response);
209                }
210                Err(err) => {
211                    if attempt < retry_policy.max_retries && is_retryable_transport_error(&err) {
212                        if let Some(next) = retry_clone {
213                            let delay = exponential_backoff_delay(&retry_policy, attempt);
214                            let delay = apply_jitter(delay, retry_policy.jitter_factor);
215                            #[cfg(feature = "telemetry")]
216                            warn!(
217                                attempt,
218                                delay_ms = delay.as_millis() as u64,
219                                error = %err,
220                                method = %request_method,
221                                url = %request_url,
222                                "xai-rust retrying request after transport error"
223                            );
224                            sleep(delay).await;
225                            current = Some(next);
226                            continue;
227                        }
228                    }
229                    #[cfg(feature = "telemetry")]
230                    warn!(
231                        error = %err,
232                        method = %request_method,
233                        url = %request_url,
234                        "xai-rust request failed"
235                    );
236                    return Err(Error::Http(err));
237                }
238            }
239        }
240
241        Err(Error::Timeout)
242    }
243
244    /// Get the API key.
245    #[cfg(any(feature = "realtime", test))]
246    pub(crate) fn api_key(&self) -> &str {
247        self.inner.config.api_key.expose()
248    }
249
250    /// URL-encode a path segment for safe interpolation into API URLs.
251    pub(crate) fn encode_path(segment: &str) -> String {
252        if is_unreserved_path_segment(segment.as_bytes()) {
253            return segment.to_owned();
254        }
255        encode_path_segment_slow(segment.as_bytes())
256    }
257
258    // API namespaces
259
260    /// Access the Responses API.
261    ///
262    /// The Responses API is the primary endpoint for chat interactions.
263    ///
264    /// # Example
265    ///
266    /// ```rust,no_run
267    /// use xai_rust::{XaiClient, Role};
268    ///
269    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
270    /// let client = XaiClient::from_env()?;
271    ///
272    /// let response = client.responses()
273    ///     .create("grok-4")
274    ///     .message(Role::User, "Hello!")
275    ///     .send()
276    ///     .await?;
277    /// # Ok(())
278    /// # }
279    /// ```
280    pub fn responses(&self) -> ResponsesApi {
281        ResponsesApi::new(self.clone())
282    }
283
284    /// Access the Auth API.
285    ///
286    /// # Example
287    ///
288    /// ```rust,no_run
289    /// use xai_rust::XaiClient;
290    ///
291    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
292    /// let client = XaiClient::from_env()?;
293    /// let key = client.auth().api_key().await?;
294    /// println!("Authenticated as: {}", key.api_key);
295    /// # Ok(())
296    /// # }
297    /// ```
298    pub fn auth(&self) -> AuthApi {
299        AuthApi::new(self.clone())
300    }
301
302    /// Access the Chat Completions API (legacy).
303    ///
304    /// Note: The Chat Completions API is deprecated. Use `responses()` instead.
305    pub fn chat(&self) -> ChatApi {
306        ChatApi::new(self.clone())
307    }
308
309    /// Access the Images API.
310    ///
311    /// # Example
312    ///
313    /// ```rust,no_run
314    /// use xai_rust::XaiClient;
315    ///
316    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
317    /// let client = XaiClient::from_env()?;
318    ///
319    /// let response = client.images()
320    ///     .generate("grok-2-image", "A cat in a tree")
321    ///     .send()
322    ///     .await?;
323    /// # Ok(())
324    /// # }
325    /// ```
326    pub fn images(&self) -> ImagesApi {
327        ImagesApi::new(self.clone())
328    }
329
330    /// Access the Videos API.
331    ///
332    /// # Example
333    ///
334    /// ```rust,no_run
335    /// use xai_rust::XaiClient;
336    ///
337    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
338    /// let client = XaiClient::from_env()?;
339    /// let video = client.videos().get("video-123").await?;
340    /// println!("Video: {:?}", video.id);
341    /// # Ok(())
342    /// # }
343    /// ```
344    pub fn videos(&self) -> VideosApi {
345        VideosApi::new(self.clone())
346    }
347
348    /// Access the Files API.
349    ///
350    /// # Example
351    ///
352    /// ```rust,no_run
353    /// use xai_rust::XaiClient;
354    ///
355    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
356    /// let client = XaiClient::from_env()?;
357    ///
358    /// let files = client.files().list().await?;
359    /// # Ok(())
360    /// # }
361    /// ```
362    #[cfg(feature = "files")]
363    pub fn files(&self) -> FilesApi {
364        FilesApi::new(self.clone())
365    }
366
367    /// Access the Models API.
368    ///
369    /// # Example
370    ///
371    /// ```rust,no_run
372    /// use xai_rust::XaiClient;
373    ///
374    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
375    /// let client = XaiClient::from_env()?;
376    ///
377    /// let models = client.models().list().await?;
378    /// # Ok(())
379    /// # }
380    /// ```
381    pub fn models(&self) -> ModelsApi {
382        ModelsApi::new(self.clone())
383    }
384
385    /// Access the Realtime API for voice interactions.
386    ///
387    /// # Example
388    ///
389    /// ```rust,no_run
390    /// use xai_rust::{XaiClient, Voice, AudioFormat};
391    ///
392    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
393    /// let client = XaiClient::from_env()?;
394    ///
395    /// let session = client.realtime()
396    ///     .connect("grok-4")
397    ///     .voice(Voice::Ara)
398    ///     .audio_format(AudioFormat::Pcm16)
399    ///     .start()
400    ///     .await?;
401    /// # Ok(())
402    /// # }
403    /// ```
404    #[cfg(feature = "realtime")]
405    pub fn realtime(&self) -> RealtimeApi {
406        RealtimeApi::new(self.clone())
407    }
408
409    /// Access the Batch API for processing multiple requests.
410    ///
411    /// # Example
412    ///
413    /// ```rust,no_run
414    /// use xai_rust::XaiClient;
415    ///
416    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
417    /// let client = XaiClient::from_env()?;
418    ///
419    /// let batch = client.batch().create("my-batch").await?;
420    /// println!("Created batch: {}", batch.id);
421    /// # Ok(())
422    /// # }
423    /// ```
424    pub fn batch(&self) -> BatchApi {
425        BatchApi::new(self.clone())
426    }
427
428    /// Access the Collections API for document storage and retrieval.
429    ///
430    /// # Example
431    ///
432    /// ```rust,no_run
433    /// use xai_rust::XaiClient;
434    ///
435    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
436    /// let client = XaiClient::from_env()?;
437    ///
438    /// let collection = client.collections().create_named("my-docs").await?;
439    /// println!("Created collection: {}", collection.id);
440    /// # Ok(())
441    /// # }
442    /// ```
443    pub fn collections(&self) -> CollectionsApi {
444        CollectionsApi::new(self.clone())
445    }
446
447    /// Access the Documents API.
448    ///
449    /// # Example
450    ///
451    /// ```rust,no_run
452    /// use xai_rust::api::DocumentsSearchRequest;
453    /// use xai_rust::XaiClient;
454    ///
455    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
456    /// let client = XaiClient::from_env()?;
457    /// let response = client.documents().search(DocumentsSearchRequest::new("query")).await?;
458    /// println!("Found {} results", response.results.len());
459    /// # Ok(())
460    /// # }
461    /// ```
462    pub fn documents(&self) -> DocumentsApi {
463        DocumentsApi::new(self.clone())
464    }
465
466    /// Access the Embeddings API.
467    ///
468    /// # Example
469    ///
470    /// ```rust,no_run
471    /// use serde_json::json;
472    /// use xai_rust::api::EmbeddingsRequest;
473    /// use xai_rust::XaiClient;
474    ///
475    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
476    /// let client = XaiClient::from_env()?;
477    /// let response = client
478    ///     .embeddings()
479    ///     .create(EmbeddingsRequest::new("text-embedding", json!("Hello")))
480    ///     .await?;
481    /// println!("embedding result {}",
482    ///     response.data.len());
483    /// # Ok(())
484    /// # }
485    /// ```
486    pub fn embeddings(&self) -> EmbeddingsApi {
487        EmbeddingsApi::new(self.clone())
488    }
489
490    /// Access the Tokenizer API for counting tokens.
491    ///
492    /// # Example
493    ///
494    /// ```rust,no_run
495    /// use xai_rust::XaiClient;
496    ///
497    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
498    /// let client = XaiClient::from_env()?;
499    ///
500    /// let count = client.tokenizer()
501    ///     .count_tokens("grok-4", "Hello, world!")
502    ///     .await?;
503    ///
504    /// println!("Token count: {}", count);
505    /// # Ok(())
506    /// # }
507    /// ```
508    pub fn tokenizer(&self) -> TokenizerApi {
509        TokenizerApi::new(self.clone())
510    }
511}
512
513fn is_retryable_status(status: StatusCode) -> bool {
514    matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504)
515}
516
517fn is_retryable_transport_error(err: &reqwest::Error) -> bool {
518    err.is_timeout() || err.is_connect() || err.is_request()
519}
520
521fn exponential_backoff_delay(policy: &RetryPolicy, attempt: u32) -> Duration {
522    let max_millis = policy.max_backoff.as_millis();
523    let initial_millis = policy.initial_backoff.as_millis();
524    if max_millis == 0 || initial_millis == 0 {
525        return Duration::ZERO;
526    }
527
528    let factor_shift = attempt.min(16);
529    let factor = 1u128 << factor_shift;
530    let delayed = initial_millis.saturating_mul(factor).min(max_millis);
531    Duration::from_millis(delayed as u64)
532}
533
534fn retry_delay_from_response(
535    policy: &RetryPolicy,
536    attempt: u32,
537    status: StatusCode,
538    retry_after: Option<&HeaderValue>,
539) -> Duration {
540    if is_retryable_status(status) {
541        if let Some(delay) = retry_after.and_then(parse_retry_after_delay) {
542            return delay.min(policy.max_backoff);
543        }
544    }
545
546    exponential_backoff_delay(policy, attempt)
547}
548
549fn parse_retry_after_delay(retry_after: &HeaderValue) -> Option<Duration> {
550    let value = retry_after.to_str().ok()?.trim();
551    if value.is_empty() {
552        return None;
553    }
554
555    if let Ok(seconds) = value.parse::<u64>() {
556        return Some(Duration::from_secs(seconds));
557    }
558
559    let retry_at = parse_http_date(value).ok()?;
560    Some(
561        retry_at
562            .duration_since(SystemTime::now())
563            .unwrap_or(Duration::ZERO),
564    )
565}
566
567fn apply_jitter(delay: Duration, factor: f64) -> Duration {
568    if delay.is_zero() || factor <= 0.0 {
569        return delay;
570    }
571
572    let clamped_factor = factor.clamp(0.0, 1.0);
573    let delay_secs = delay.as_secs_f64();
574    let half_range = delay_secs * clamped_factor * 0.5;
575    if half_range <= f64::EPSILON {
576        return delay;
577    }
578
579    let min = (delay_secs - half_range).max(0.0);
580    let max = delay_secs + half_range;
581    let sample = SystemTime::now()
582        .duration_since(UNIX_EPOCH)
583        .map(|d| d.subsec_nanos() as f64 / 1_000_000_000.0)
584        .unwrap_or(0.5);
585    Duration::from_secs_f64(min + (max - min) * sample)
586}
587
588#[inline]
589fn is_unreserved_path_segment(segment: &[u8]) -> bool {
590    let mut i = 0;
591    while i < segment.len() {
592        let b = segment[i];
593        if !matches!(
594            b,
595            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
596        ) {
597            return false;
598        }
599        i += 1;
600    }
601    true
602}
603
604#[inline]
605fn encode_path_segment_slow(segment: &[u8]) -> String {
606    let mut encoded = String::with_capacity(segment.len() + 8);
607    for &b in segment {
608        if should_percent_encode_path_byte(b) {
609            encoded.push('%');
610            encoded.push(HEX_UPPER[(b >> 4) as usize] as char);
611            encoded.push(HEX_UPPER[(b & 0x0F) as usize] as char);
612        } else {
613            encoded.push(b as char);
614        }
615    }
616    encoded
617}
618
619#[inline]
620fn should_percent_encode_path_byte(b: u8) -> bool {
621    b >= 0x80
622        || matches!(
623            b,
624            0x00..=0x20
625                | b'"'
626                | b'#'
627                | b'%'
628                | b'/'
629                | b'<'
630                | b'>'
631                | b'?'
632                | b'['
633                | b'\\'
634                | b']'
635                | b'^'
636                | b'`'
637                | b'{'
638                | b'|'
639                | b'}'
640        )
641}
642
643#[cfg(feature = "opt-bench-internals")]
644#[doc(hidden)]
645pub fn __opt_bench_encode_path(segment: &str) -> String {
646    XaiClient::encode_path(segment)
647}
648
649#[cfg(feature = "telemetry")]
650fn request_telemetry_fields(request: &RequestBuilder) -> (String, String) {
651    request
652        .try_clone()
653        .and_then(|builder| builder.build().ok())
654        .map(|built| (built.method().to_string(), built.url().to_string()))
655        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()))
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::config::DEFAULT_BASE_URL;
662    use httpdate::fmt_http_date;
663    use serde_json::json;
664    use std::sync::{
665        atomic::{AtomicUsize, Ordering},
666        Arc,
667    };
668    use wiremock::matchers::{header, method, path};
669    use wiremock::{Mock, MockServer, ResponseTemplate};
670
671    // ── XaiClient::new ──────────────────────────────────────────────
672
673    #[test]
674    fn client_new_returns_ok() {
675        let client = XaiClient::new("test-api-key-1234");
676        assert!(client.is_ok());
677    }
678
679    #[test]
680    fn client_new_sets_base_url() {
681        let client = XaiClient::new("test-key").unwrap();
682        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
683    }
684
685    #[test]
686    fn client_new_preserves_api_key() {
687        let client = XaiClient::new("my-secret-key").unwrap();
688        assert_eq!(client.api_key(), "my-secret-key");
689    }
690
691    #[test]
692    fn client_clone_shares_inner() {
693        let client1 = XaiClient::new("key").unwrap();
694        let client2 = client1.clone();
695        assert_eq!(client1.base_url(), client2.base_url());
696        assert_eq!(client1.api_key(), client2.api_key());
697    }
698
699    // ── XaiClient::builder ──────────────────────────────────────────
700
701    #[test]
702    fn client_builder_returns_builder() {
703        let builder = XaiClient::builder();
704        // Just ensure it compiles and returns something
705        let debug = format!("{:?}", builder);
706        assert!(debug.contains("XaiClientBuilder"));
707    }
708
709    #[test]
710    fn client_from_builder() {
711        let client = XaiClient::builder().api_key("builder-key").build().unwrap();
712        assert_eq!(client.api_key(), "builder-key");
713        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
714    }
715
716    #[test]
717    fn client_from_builder_custom_url() {
718        let client = XaiClient::builder()
719            .api_key("key")
720            .base_url("https://custom.api.com/v2")
721            .build()
722            .unwrap();
723        assert_eq!(client.base_url(), "https://custom.api.com/v2");
724    }
725
726    #[test]
727    fn client_from_builder_custom_url_trims_trailing_slash() {
728        let client = XaiClient::builder()
729            .api_key("key")
730            .base_url("https://custom.api.com/v2/")
731            .build()
732            .unwrap();
733        assert_eq!(client.base_url(), "https://custom.api.com/v2");
734    }
735
736    // ── XaiClient::with_config ──────────────────────────────────────
737
738    #[test]
739    fn client_with_config() {
740        let config = ClientConfig::new("config-key");
741        let client = XaiClient::with_config(config).unwrap();
742        assert_eq!(client.api_key(), "config-key");
743    }
744
745    #[tokio::test]
746    async fn client_sets_default_user_agent_header() {
747        let server = MockServer::start().await;
748        Mock::given(method("GET"))
749            .and(path("/models"))
750            .and(header("user-agent", SDK_USER_AGENT))
751            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
752                "object": "list",
753                "data": []
754            })))
755            .expect(1)
756            .mount(&server)
757            .await;
758
759        let client = XaiClient::builder()
760            .api_key("test-key")
761            .base_url(server.uri())
762            .build()
763            .unwrap();
764
765        let response = client.models().list().await.unwrap();
766        assert_eq!(response.object, "list");
767        assert!(response.data.is_empty());
768    }
769
770    #[tokio::test]
771    async fn client_retries_transient_http_statuses() {
772        let server = MockServer::start().await;
773        let call_count = Arc::new(AtomicUsize::new(0));
774        let responder_count = Arc::clone(&call_count);
775
776        Mock::given(method("GET"))
777            .and(path("/models"))
778            .respond_with(move |_req: &wiremock::Request| {
779                let count = responder_count.fetch_add(1, Ordering::SeqCst);
780                if count == 0 {
781                    ResponseTemplate::new(503).set_body_json(json!({
782                        "error": {"message": "temporary", "type": "server_error"}
783                    }))
784                } else {
785                    ResponseTemplate::new(200).set_body_json(json!({
786                        "object": "list",
787                        "data": []
788                    }))
789                }
790            })
791            .mount(&server)
792            .await;
793
794        let client = XaiClient::builder()
795            .api_key("test-key")
796            .base_url(server.uri())
797            .max_retries(1)
798            .retry_backoff(Duration::ZERO, Duration::ZERO)
799            .build()
800            .unwrap();
801
802        let list = client.models().list().await.unwrap();
803        assert_eq!(list.object, "list");
804        assert_eq!(call_count.load(Ordering::SeqCst), 2);
805    }
806
807    #[tokio::test]
808    async fn client_disable_retries_returns_first_error_response() {
809        let server = MockServer::start().await;
810
811        Mock::given(method("GET"))
812            .and(path("/models"))
813            .respond_with(ResponseTemplate::new(503).set_body_json(json!({
814                "error": {"message": "service unavailable", "type": "server_error"}
815            })))
816            .expect(1)
817            .mount(&server)
818            .await;
819
820        let client = XaiClient::builder()
821            .api_key("test-key")
822            .base_url(server.uri())
823            .disable_retries()
824            .build()
825            .unwrap();
826
827        let err = client.models().list().await.unwrap_err();
828        assert!(matches!(err, Error::Api { status: 503, .. }));
829    }
830
831    // ── XaiClient::encode_path ──────────────────────────────────────
832
833    #[test]
834    fn encode_path_no_special_chars() {
835        let encoded = XaiClient::encode_path("simple-path");
836        assert_eq!(encoded, "simple-path");
837    }
838
839    #[test]
840    fn encode_path_with_spaces() {
841        let encoded = XaiClient::encode_path("path with spaces");
842        assert_eq!(encoded, "path%20with%20spaces");
843    }
844
845    #[test]
846    fn encode_path_with_slashes() {
847        let encoded = XaiClient::encode_path("a/b/c");
848        assert_eq!(encoded, "a%2Fb%2Fc");
849    }
850
851    #[test]
852    fn encode_path_with_special_chars() {
853        let encoded = XaiClient::encode_path("file@name#1");
854        assert!(encoded.contains("%40") || encoded.contains("@")); // @ may be encoded
855        assert!(encoded.contains("%23")); // # is always encoded
856    }
857
858    #[test]
859    fn encode_path_with_unicode() {
860        let encoded = XaiClient::encode_path("héllo world");
861        assert_eq!(encoded, "h%C3%A9llo%20world");
862    }
863
864    #[test]
865    fn encode_path_empty_string() {
866        let encoded = XaiClient::encode_path("");
867        assert_eq!(encoded, "");
868    }
869
870    #[test]
871    fn encode_path_with_percent() {
872        let encoded = XaiClient::encode_path("100%done");
873        assert!(encoded.contains("%25")); // % gets encoded as %25
874    }
875
876    #[test]
877    fn apply_jitter_zero_factor_returns_same_delay() {
878        let delay = Duration::from_millis(800);
879        assert_eq!(apply_jitter(delay, 0.0), delay);
880    }
881
882    #[test]
883    fn apply_jitter_stays_within_expected_bounds() {
884        let delay = Duration::from_millis(1000);
885        let jittered = apply_jitter(delay, 0.2);
886        assert!(jittered >= Duration::from_millis(900));
887        assert!(jittered <= Duration::from_millis(1100));
888    }
889
890    #[test]
891    fn retry_delay_from_response_uses_retry_after_seconds_for_retryable_status() {
892        let policy = RetryPolicy {
893            max_retries: 2,
894            initial_backoff: Duration::from_millis(200),
895            max_backoff: Duration::from_secs(10),
896            jitter_factor: 0.0,
897        };
898        let retry_after = HeaderValue::from_static("7");
899
900        let delay = retry_delay_from_response(
901            &policy,
902            0,
903            StatusCode::SERVICE_UNAVAILABLE,
904            Some(&retry_after),
905        );
906        assert_eq!(delay, Duration::from_secs(7));
907    }
908
909    #[test]
910    fn retry_delay_from_response_parses_http_date_retry_after() {
911        let policy = RetryPolicy {
912            max_retries: 2,
913            initial_backoff: Duration::from_millis(200),
914            max_backoff: Duration::from_secs(5),
915            jitter_factor: 0.0,
916        };
917        let retry_after =
918            HeaderValue::from_str(&fmt_http_date(SystemTime::now() + Duration::from_secs(600)))
919                .unwrap();
920
921        let delay = retry_delay_from_response(
922            &policy,
923            0,
924            StatusCode::TOO_MANY_REQUESTS,
925            Some(&retry_after),
926        );
927        assert_eq!(delay, policy.max_backoff);
928    }
929
930    #[test]
931    fn retry_delay_from_response_with_past_http_date_returns_zero() {
932        let policy = RetryPolicy {
933            max_retries: 2,
934            initial_backoff: Duration::from_millis(200),
935            max_backoff: Duration::from_secs(5),
936            jitter_factor: 0.0,
937        };
938        let retry_after = HeaderValue::from_str(&fmt_http_date(UNIX_EPOCH)).unwrap();
939
940        let delay = retry_delay_from_response(
941            &policy,
942            1,
943            StatusCode::TOO_MANY_REQUESTS,
944            Some(&retry_after),
945        );
946        assert_eq!(delay, Duration::ZERO);
947    }
948
949    #[test]
950    fn retry_delay_from_response_invalid_retry_after_falls_back_to_exponential() {
951        let policy = RetryPolicy {
952            max_retries: 2,
953            initial_backoff: Duration::from_millis(250),
954            max_backoff: Duration::from_secs(10),
955            jitter_factor: 0.0,
956        };
957        let retry_after = HeaderValue::from_static("invalid");
958
959        let delay = retry_delay_from_response(
960            &policy,
961            2,
962            StatusCode::TOO_MANY_REQUESTS,
963            Some(&retry_after),
964        );
965        assert_eq!(delay, Duration::from_millis(1000));
966    }
967
968    #[cfg(feature = "telemetry")]
969    #[test]
970    fn request_telemetry_fields_extract_method_and_url() {
971        let http = reqwest::Client::new();
972        let request = http.post("https://example.com/v1/test");
973        let (method, url) = request_telemetry_fields(&request);
974        assert_eq!(method, "POST");
975        assert_eq!(url, "https://example.com/v1/test");
976    }
977
978    // ── API namespace accessors ─────────────────────────────────────
979
980    #[test]
981    fn client_responses_api() {
982        let client = XaiClient::new("key").unwrap();
983        let _api = client.responses(); // Should not panic
984    }
985
986    #[test]
987    fn client_auth_api() {
988        let client = XaiClient::new("key").unwrap();
989        let _api = client.auth();
990    }
991
992    #[test]
993    fn client_sync_facade_from_ref() {
994        let client = XaiClient::new("key").unwrap();
995        let sync = client.sync().unwrap();
996        assert_eq!(sync.base_url(), client.base_url());
997    }
998
999    #[test]
1000    fn client_into_sync_facade() {
1001        let client = XaiClient::new("key").unwrap();
1002        let sync = client.into_sync().unwrap();
1003        assert_eq!(sync.base_url(), DEFAULT_BASE_URL);
1004    }
1005
1006    #[test]
1007    fn client_chat_api() {
1008        let client = XaiClient::new("key").unwrap();
1009        let _api = client.chat();
1010    }
1011
1012    #[test]
1013    fn client_images_api() {
1014        let client = XaiClient::new("key").unwrap();
1015        let _api = client.images();
1016    }
1017
1018    #[test]
1019    fn client_videos_api() {
1020        let client = XaiClient::new("key").unwrap();
1021        let _api = client.videos();
1022    }
1023
1024    #[test]
1025    fn client_models_api() {
1026        let client = XaiClient::new("key").unwrap();
1027        let _api = client.models();
1028    }
1029
1030    #[test]
1031    fn client_batch_api() {
1032        let client = XaiClient::new("key").unwrap();
1033        let _api = client.batch();
1034    }
1035
1036    #[test]
1037    fn client_collections_api() {
1038        let client = XaiClient::new("key").unwrap();
1039        let _api = client.collections();
1040    }
1041
1042    #[test]
1043    fn client_documents_api() {
1044        let client = XaiClient::new("key").unwrap();
1045        let _api = client.documents();
1046    }
1047
1048    #[test]
1049    fn client_embeddings_api() {
1050        let client = XaiClient::new("key").unwrap();
1051        let _api = client.embeddings();
1052    }
1053
1054    #[test]
1055    fn client_tokenizer_api() {
1056        let client = XaiClient::new("key").unwrap();
1057        let _api = client.tokenizer();
1058    }
1059
1060    #[cfg(feature = "files")]
1061    #[test]
1062    fn client_files_api() {
1063        let client = XaiClient::new("key").unwrap();
1064        let _api = client.files();
1065    }
1066
1067    #[cfg(feature = "realtime")]
1068    #[test]
1069    fn client_realtime_api() {
1070        let client = XaiClient::new("key").unwrap();
1071        let _api = client.realtime();
1072    }
1073}