Skip to main content

quantum_sdk/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
5use serde::de::DeserializeOwned;
6use serde::Serialize;
7
8use crate::error::{ApiError, ApiErrorBody, Error, Result};
9
10/// Max retries for transient errors (502, 503, 429).
11const MAX_RETRIES: u32 = 3;
12/// Initial backoff delay.
13const INITIAL_BACKOFF_MS: u64 = 500;
14
15/// Check if a status code is retryable.
16fn is_retryable(status: reqwest::StatusCode) -> bool {
17    matches!(status.as_u16(), 429 | 502 | 503 | 504)
18}
19
20/// Check if an error response body contains a permanent (non-retryable) error
21/// even when wrapped in a retryable status code (e.g. 502 wrapping a provider 400).
22fn is_permanent_error(body: &str) -> bool {
23    let lower = body.to_lowercase();
24    lower.contains("content moderation")
25        || lower.contains("content_policy")
26        || lower.contains("safety_block")
27        || lower.contains("invalid argument")
28        || lower.contains("invalid_request")
29        || (lower.contains("status 400") && lower.contains("rejected"))
30}
31
32/// The default Quantum AI API base URL.
33pub const DEFAULT_BASE_URL: &str = "https://api.quantumencoding.ai";
34
35/// The number of ticks in one US dollar (10 billion).
36pub const TICKS_PER_USD: i64 = 10_000_000_000;
37
38/// Common response metadata parsed from HTTP headers.
39#[derive(Debug, Clone, Default)]
40pub struct ResponseMeta {
41    /// Cost in ticks from X-QAI-Cost-Ticks header.
42    pub cost_ticks: i64,
43    /// Request identifier from X-QAI-Request-Id header.
44    pub request_id: String,
45    /// Model identifier from X-QAI-Model header.
46    pub model: String,
47}
48
49/// Builder for constructing a [`Client`] with custom configuration.
50pub struct ClientBuilder {
51    api_key: String,
52    base_url: String,
53    timeout: Duration,
54}
55
56impl ClientBuilder {
57    /// Creates a new builder with the given API key.
58    pub fn new(api_key: impl Into<String>) -> Self {
59        Self {
60            api_key: api_key.into(),
61            base_url: DEFAULT_BASE_URL.to_string(),
62            timeout: Duration::from_secs(60),
63        }
64    }
65
66    /// Sets the API base URL (default: `https://api.quantumencoding.ai`).
67    pub fn base_url(mut self, url: impl Into<String>) -> Self {
68        self.base_url = url.into();
69        self
70    }
71
72    /// Sets the request timeout for non-streaming requests (default: 60s).
73    pub fn timeout(mut self, timeout: Duration) -> Self {
74        self.timeout = timeout;
75        self
76    }
77
78    /// Builds the [`Client`].
79    pub fn build(self) -> Result<Client> {
80        let auth_value = format!("Bearer {}", self.api_key);
81        let auth_header = HeaderValue::from_str(&auth_value).map_err(|_| {
82            Error::Api(ApiError {
83                status_code: 0,
84                code: "invalid_api_key".to_string(),
85                message: "API key contains invalid header characters".to_string(),
86                request_id: String::new(),
87            })
88        })?;
89
90        let mut headers = HeaderMap::new();
91        headers.insert(AUTHORIZATION, auth_header.clone());
92        // Also send X-API-Key for proxies that claim the Authorization header (e.g. Cloudflare -> Cloud Run IAM).
93        if let Ok(v) = HeaderValue::from_str(&self.api_key) {
94            headers.insert("X-API-Key", v);
95        }
96
97        let http = reqwest::Client::builder()
98            .default_headers(headers)
99            .timeout(self.timeout)
100            .build()?;
101
102        Ok(Client {
103            inner: Arc::new(ClientInner {
104                base_url: self.base_url,
105                http,
106                auth_header,
107            }),
108        })
109    }
110}
111
112struct ClientInner {
113    base_url: String,
114    http: reqwest::Client,
115    auth_header: HeaderValue,
116}
117
118/// The Quantum AI API client.
119///
120/// `Client` is cheaply cloneable (backed by `Arc`) and safe to share across tasks.
121///
122/// # Example
123///
124/// ```no_run
125/// let client = quantum_sdk::Client::new("qai_key_xxx");
126/// ```
127#[derive(Clone)]
128pub struct Client {
129    inner: Arc<ClientInner>,
130}
131
132impl Client {
133    /// Creates a new client with the given API key and default settings.
134    pub fn new(api_key: impl Into<String>) -> Self {
135        ClientBuilder::new(api_key)
136            .build()
137            .expect("default client configuration is valid")
138    }
139
140    /// Returns a [`ClientBuilder`] for custom configuration.
141    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
142        ClientBuilder::new(api_key)
143    }
144
145    /// Returns the base URL for this client.
146    pub(crate) fn base_url(&self) -> &str {
147        &self.inner.base_url
148    }
149
150    /// Returns the auth header value (e.g. "Bearer qai_xxx").
151    pub(crate) fn auth_header(&self) -> &HeaderValue {
152        &self.inner.auth_header
153    }
154
155    /// Sends a JSON POST request and deserializes the response.
156    pub async fn post_json<Req: Serialize, Resp: DeserializeOwned>(
157        &self,
158        path: &str,
159        body: &Req,
160    ) -> Result<(Resp, ResponseMeta)> {
161        let url = format!("{}{}", self.inner.base_url, path);
162        let body_bytes = serde_json::to_vec(body)?;
163
164        let mut last_err = None;
165        for attempt in 0..=MAX_RETRIES {
166            if attempt > 0 {
167                let delay = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1);
168                eprintln!("[sdk] Retry {attempt}/{MAX_RETRIES} for POST {path} in {delay}ms");
169                tokio::time::sleep(Duration::from_millis(delay)).await;
170            }
171
172            let resp = self
173                .inner
174                .http
175                .post(&url)
176                .header(CONTENT_TYPE, "application/json")
177                .body(body_bytes.clone())
178                .send()
179                .await?;
180
181            let status = resp.status();
182            let meta = parse_response_meta(&resp);
183
184            if status.is_success() {
185                let body_text = resp.text().await?;
186                let result: Resp = serde_json::from_str(&body_text).map_err(|e| {
187                    let preview = if body_text.len() > 300 { &body_text[..300] } else { &body_text };
188                    eprintln!("[sdk] JSON decode error on {path}: {e}\n  body preview: {preview}");
189                    e
190                })?;
191                return Ok((result, meta));
192            }
193
194            if is_retryable(status) && attempt < MAX_RETRIES {
195                // Read body to check if it's a permanent error wrapped in 502
196                let body_text = resp.text().await.unwrap_or_default();
197                if is_permanent_error(&body_text) {
198                    eprintln!("[sdk] POST {path} returned {status} but error is permanent, not retrying");
199                    let err = parse_api_error_from_text(status, &body_text, &meta.request_id);
200                    return Err(err);
201                }
202                eprintln!("[sdk] POST {path} returned {status}, will retry");
203                let err = parse_api_error_from_text(status, &body_text, &meta.request_id);
204                last_err = Some(err);
205                continue;
206            }
207
208            return Err(parse_api_error(resp, &meta.request_id).await);
209        }
210
211        Err(last_err.unwrap_or_else(|| Error::Api(ApiError {
212            status_code: 502,
213            code: "retry_exhausted".into(),
214            message: format!("max retries ({MAX_RETRIES}) exceeded"),
215            request_id: String::new(),
216        })))
217    }
218
219    /// Sends a GET request and deserializes the response.
220    pub async fn get_json<Resp: DeserializeOwned>(
221        &self,
222        path: &str,
223    ) -> Result<(Resp, ResponseMeta)> {
224        let url = format!("{}{}", self.inner.base_url, path);
225
226        let mut last_err = None;
227        for attempt in 0..=MAX_RETRIES {
228            if attempt > 0 {
229                let delay = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1);
230                eprintln!("[sdk] Retry {attempt}/{MAX_RETRIES} for GET {path} in {delay}ms");
231                tokio::time::sleep(Duration::from_millis(delay)).await;
232            }
233
234            let resp = self.inner.http.get(&url).send().await?;
235            let status = resp.status();
236            let meta = parse_response_meta(&resp);
237
238            if status.is_success() {
239                let body_text = resp.text().await?;
240                let result: Resp = serde_json::from_str(&body_text).map_err(|e| {
241                    let preview = if body_text.len() > 300 { &body_text[..300] } else { &body_text };
242                    eprintln!("[sdk] JSON decode error on {path}: {e}\n  body preview: {preview}");
243                    e
244                })?;
245                return Ok((result, meta));
246            }
247
248            if is_retryable(status) && attempt < MAX_RETRIES {
249                let body_text = resp.text().await.unwrap_or_default();
250                if is_permanent_error(&body_text) {
251                    eprintln!("[sdk] GET {path} returned {status} but error is permanent, not retrying");
252                    return Err(parse_api_error_from_text(status, &body_text, &meta.request_id));
253                }
254                eprintln!("[sdk] GET {path} returned {status}, will retry");
255                last_err = Some(parse_api_error_from_text(status, &body_text, &meta.request_id));
256                continue;
257            }
258
259            return Err(parse_api_error(resp, &meta.request_id).await);
260        }
261
262        Err(last_err.unwrap_or_else(|| Error::Api(ApiError {
263            status_code: 502,
264            code: "retry_exhausted".into(),
265            message: format!("max retries ({MAX_RETRIES}) exceeded"),
266            request_id: String::new(),
267        })))
268    }
269
270    /// Sends a DELETE request and deserializes the response.
271    pub async fn delete_json<Resp: DeserializeOwned>(
272        &self,
273        path: &str,
274    ) -> Result<(Resp, ResponseMeta)> {
275        let url = format!("{}{}", self.inner.base_url, path);
276        let resp = self.inner.http.delete(&url).send().await?;
277
278        let meta = parse_response_meta(&resp);
279
280        if !resp.status().is_success() {
281            return Err(parse_api_error(resp, &meta.request_id).await);
282        }
283
284        let result: Resp = resp.json().await?;
285        Ok((result, meta))
286    }
287
288    /// Sends a multipart POST request and deserializes the response.
289    pub async fn post_multipart<Resp: DeserializeOwned>(
290        &self,
291        path: &str,
292        form: reqwest::multipart::Form,
293    ) -> Result<(Resp, ResponseMeta)> {
294        let url = format!("{}{}", self.inner.base_url, path);
295        let resp = self.inner.http.post(&url).multipart(form).send().await?;
296
297        let meta = parse_response_meta(&resp);
298
299        if !resp.status().is_success() {
300            return Err(parse_api_error(resp, &meta.request_id).await);
301        }
302
303        let result: Resp = resp.json().await?;
304        Ok((result, meta))
305    }
306
307    /// Sends a GET request expecting an SSE stream response.
308    /// Returns the raw reqwest::Response for the caller to read events from.
309    /// Uses a separate client without timeout — cancellation is via drop.
310    pub async fn get_stream_raw(
311        &self,
312        path: &str,
313    ) -> Result<(reqwest::Response, ResponseMeta)> {
314        let url = format!("{}{}", self.inner.base_url, path);
315
316        let stream_client = reqwest::Client::builder().build()?;
317
318        let resp = stream_client
319            .get(&url)
320            .header(AUTHORIZATION, self.inner.auth_header.clone())
321            .header("Accept", "text/event-stream")
322            .send()
323            .await?;
324
325        let meta = parse_response_meta(&resp);
326
327        if !resp.status().is_success() {
328            return Err(parse_api_error(resp, &meta.request_id).await);
329        }
330
331        Ok((resp, meta))
332    }
333
334    /// Sends a JSON POST request expecting an SSE stream response.
335    /// Returns the raw reqwest::Response for the caller to read events from.
336    /// Uses a separate client without timeout -- cancellation is via drop.
337    pub async fn post_stream_raw(
338        &self,
339        path: &str,
340        body: &impl Serialize,
341    ) -> Result<(reqwest::Response, ResponseMeta)> {
342        let url = format!("{}{}", self.inner.base_url, path);
343
344        // Build a client without timeout for streaming.
345        let stream_client = reqwest::Client::builder().build()?;
346
347        let resp = stream_client
348            .post(&url)
349            .header(AUTHORIZATION, self.inner.auth_header.clone())
350            .header(CONTENT_TYPE, "application/json")
351            .header("Accept", "text/event-stream")
352            .json(body)
353            .send()
354            .await?;
355
356        let meta = parse_response_meta(&resp);
357
358        if !resp.status().is_success() {
359            return Err(parse_api_error(resp, &meta.request_id).await);
360        }
361
362        Ok((resp, meta))
363    }
364}
365
366/// Extracts response metadata from HTTP headers.
367fn parse_response_meta(resp: &reqwest::Response) -> ResponseMeta {
368    let headers = resp.headers();
369    let request_id = headers
370        .get("X-QAI-Request-Id")
371        .and_then(|v| v.to_str().ok())
372        .unwrap_or("")
373        .to_string();
374    let model = headers
375        .get("X-QAI-Model")
376        .and_then(|v| v.to_str().ok())
377        .unwrap_or("")
378        .to_string();
379    let cost_ticks = headers
380        .get("X-QAI-Cost-Ticks")
381        .and_then(|v| v.to_str().ok())
382        .and_then(|v| v.parse::<i64>().ok())
383        .unwrap_or(0);
384
385    ResponseMeta {
386        cost_ticks,
387        request_id,
388        model,
389    }
390}
391
392/// Parses an API error from a non-2xx response.
393async fn parse_api_error(resp: reqwest::Response, request_id: &str) -> Error {
394    let status_code = resp.status().as_u16();
395    let status_text = resp
396        .status()
397        .canonical_reason()
398        .unwrap_or("Unknown")
399        .to_string();
400
401    let body = resp.text().await.unwrap_or_default();
402
403    let (code, message) = if let Ok(err_body) = serde_json::from_str::<ApiErrorBody>(&body) {
404        let msg = if err_body.error.message.is_empty() {
405            body.clone()
406        } else {
407            err_body.error.message
408        };
409        let c = if !err_body.error.code.is_empty() {
410            err_body.error.code
411        } else if !err_body.error.error_type.is_empty() {
412            err_body.error.error_type
413        } else {
414            status_text
415        };
416        (c, msg)
417    } else {
418        (status_text, body)
419    };
420
421    Error::Api(ApiError {
422        status_code,
423        code,
424        message,
425        request_id: request_id.to_string(),
426    })
427}
428
429fn parse_api_error_from_text(status: reqwest::StatusCode, body: &str, request_id: &str) -> Error {
430    let status_code = status.as_u16();
431    let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
432
433    let (code, message) = if let Ok(err_body) = serde_json::from_str::<ApiErrorBody>(body) {
434        let msg = if err_body.error.message.is_empty() { body.to_string() } else { err_body.error.message };
435        let c = if !err_body.error.code.is_empty() { err_body.error.code }
436                else if !err_body.error.error_type.is_empty() { err_body.error.error_type }
437                else { status_text };
438        (c, msg)
439    } else {
440        (status_text, body.to_string())
441    };
442
443    Error::Api(ApiError { status_code, code, message, request_id: request_id.to_string() })
444}