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