Skip to main content

quantum_sdk/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
5use serde::de::DeserializeOwned;
6use serde::Serialize;
7use uuid::Uuid;
8
9use crate::error::{ApiError, ApiErrorBody, Error, Result};
10
11/// Max retries for transient errors (502, 503, 429).
12const MAX_RETRIES: u32 = 3;
13/// Initial backoff delay.
14const INITIAL_BACKOFF_MS: u64 = 500;
15
16/// Check if a status code is retryable.
17fn is_retryable(status: reqwest::StatusCode) -> bool {
18    matches!(status.as_u16(), 429 | 502 | 503 | 504)
19}
20
21/// Check if an error response body contains a permanent (non-retryable) error
22/// even when wrapped in a retryable status code (e.g. 502 wrapping a provider 400).
23fn is_permanent_error(body: &str) -> bool {
24    let lower = body.to_lowercase();
25    lower.contains("content moderation")
26        || lower.contains("content_policy")
27        || lower.contains("safety_block")
28        || lower.contains("invalid argument")
29        || lower.contains("invalid_request")
30        || (lower.contains("status 400") && lower.contains("rejected"))
31}
32
33/// The default Quantum AI API base URL.
34pub const DEFAULT_BASE_URL: &str = "https://api.quantumencoding.ai";
35
36/// The number of ticks in one US dollar (10 billion).
37pub const TICKS_PER_USD: i64 = 10_000_000_000;
38
39/// Common response metadata parsed from HTTP headers.
40#[derive(Debug, Clone, Default)]
41pub struct ResponseMeta {
42    /// Cost in ticks from X-QAI-Cost-Ticks header.
43    pub cost_ticks: i64,
44    /// Post-deduction credit balance in ticks from X-QAI-Balance-After header.
45    /// Zero if the server didn't include the header (e.g. cached / free calls).
46    pub balance_after: i64,
47    /// Request identifier from X-QAI-Request-Id header.
48    pub request_id: String,
49    /// Model identifier from X-QAI-Model header.
50    pub model: String,
51}
52
53/// Builder for constructing a [`Client`] with custom configuration.
54pub struct ClientBuilder {
55    api_key: String,
56    base_url: String,
57    timeout: Duration,
58    app: Option<String>,
59    extra_headers: Vec<(String, String)>,
60}
61
62/// Header names that callers may not override via `extra_header` / `app`.
63/// Attempts to set these return an error at `build()` so auth can never be
64/// silently clobbered by a caller-supplied header.
65fn is_reserved_header(name: &str) -> bool {
66    name.eq_ignore_ascii_case("authorization") || name.eq_ignore_ascii_case("x-api-key")
67}
68
69fn invalid_header_error(message: String) -> Error {
70    Error::Api(ApiError {
71        status_code: 0,
72        code: "invalid_header".to_string(),
73        message,
74        request_id: String::new(),
75    })
76}
77
78impl ClientBuilder {
79    /// Creates a new builder with the given API key.
80    pub fn new(api_key: impl Into<String>) -> Self {
81        Self {
82            api_key: api_key.into(),
83            base_url: DEFAULT_BASE_URL.to_string(),
84            timeout: Duration::from_secs(120),
85            app: None,
86            extra_headers: Vec::new(),
87        }
88    }
89
90    /// Sets the API base URL (default: `https://api.quantumencoding.ai`).
91    pub fn base_url(mut self, url: impl Into<String>) -> Self {
92        self.base_url = url.into();
93        self
94    }
95
96    /// Sets the request timeout for non-streaming requests (default: 120s).
97    ///
98    /// Media generation endpoints (video, dubbing, 3D) can take 1–5 minutes.
99    /// For these, use `Duration::from_secs(300)` or longer. Alternatively,
100    /// use the async jobs API (`create_job` / `poll_job`) which doesn't block.
101    pub fn timeout(mut self, timeout: Duration) -> Self {
102        self.timeout = timeout;
103        self
104    }
105
106    /// Tags every request with the calling app's identifier.
107    ///
108    /// Sent as `X-Quantum-App: <app>` on every HTTP request (including streaming).
109    /// The server uses this to route requests through app-specific paywall,
110    /// quota, or dispatch logic — for example, the Recipe Box trial-paywall
111    /// gate on `/qai/v1/chat`.
112    ///
113    /// Thin convenience wrapper around [`extra_header`](Self::extra_header).
114    /// If both `app(...)` and `extra_header("X-Quantum-App", ...)` are set,
115    /// the value from `app(...)` wins.
116    pub fn app(mut self, app: impl Into<String>) -> Self {
117        self.app = Some(app.into());
118        self
119    }
120
121    /// Adds an extra HTTP header to every request from this client.
122    ///
123    /// Useful for app identification, request tagging, A/B routing, etc.
124    /// Standard headers (`Authorization`, `X-API-Key`) are managed by the
125    /// builder and cannot be overridden — passing either here causes
126    /// [`build`](Self::build) to return an `invalid_header` error.
127    ///
128    /// Header names and values are validated at `build()` time, not here.
129    pub fn extra_header(
130        mut self,
131        name: impl Into<String>,
132        value: impl Into<String>,
133    ) -> Self {
134        self.extra_headers.push((name.into(), value.into()));
135        self
136    }
137
138    /// Builds the [`Client`].
139    pub fn build(self) -> Result<Client> {
140        let auth_value = format!("Bearer {}", self.api_key);
141        let auth_header = HeaderValue::from_str(&auth_value).map_err(|_| {
142            Error::Api(ApiError {
143                status_code: 0,
144                code: "invalid_api_key".to_string(),
145                message: "API key contains invalid header characters".to_string(),
146                request_id: String::new(),
147            })
148        })?;
149
150        // Resolve caller-supplied headers, with app() winning over any
151        // duplicate extra_header("X-Quantum-App", ...).
152        let mut caller_headers = self.extra_headers.clone();
153        if let Some(app) = self.app.as_ref() {
154            caller_headers.push(("X-Quantum-App".to_string(), app.clone()));
155        }
156
157        // Parse + validate caller headers up front so we can return a single
158        // typed error rather than failing partway through HeaderMap mutation.
159        let mut extra_headers_map = HeaderMap::new();
160        for (name, value) in &caller_headers {
161            if is_reserved_header(name) {
162                return Err(invalid_header_error(format!(
163                    "header '{name}' is reserved by the SDK and cannot be overridden via extra_header"
164                )));
165            }
166            let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
167                invalid_header_error(format!("invalid header name '{name}': {e}"))
168            })?;
169            let header_value = HeaderValue::from_str(value).map_err(|e| {
170                invalid_header_error(format!("invalid header value for '{name}': {e}"))
171            })?;
172            extra_headers_map.insert(header_name, header_value);
173        }
174
175        let mut headers = HeaderMap::new();
176        headers.insert(AUTHORIZATION, auth_header.clone());
177        // Also send X-API-Key for proxies that claim the Authorization header (e.g. Cloudflare -> Cloud Run IAM).
178        if let Ok(v) = HeaderValue::from_str(&self.api_key) {
179            headers.insert("X-API-Key", v);
180        }
181        // Caller-supplied headers are inserted *after* auth so the reserved
182        // guard above is the only way to override standard SDK headers.
183        for (name, value) in &extra_headers_map {
184            headers.insert(name.clone(), value.clone());
185        }
186
187        let http = reqwest::Client::builder()
188            .default_headers(headers)
189            .timeout(self.timeout)
190            .build()?;
191
192        Ok(Client {
193            inner: Arc::new(ClientInner {
194                base_url: self.base_url,
195                http,
196                auth_header,
197                extra_headers: extra_headers_map,
198            }),
199        })
200    }
201}
202
203struct ClientInner {
204    base_url: String,
205    http: reqwest::Client,
206    auth_header: HeaderValue,
207    /// Caller-supplied headers (via `ClientBuilder::extra_header` /
208    /// `ClientBuilder::app`). Already merged into the non-streaming
209    /// client's `default_headers`; the streaming paths build fresh
210    /// `reqwest::Client`s without defaults and must apply these
211    /// per-request.
212    extra_headers: HeaderMap,
213}
214
215/// The Quantum AI API client.
216///
217/// `Client` is cheaply cloneable (backed by `Arc`) and safe to share across tasks.
218///
219/// # Example
220///
221/// ```no_run
222/// let client = quantum_sdk::Client::new("qai_key_xxx");
223/// ```
224#[derive(Clone)]
225pub struct Client {
226    inner: Arc<ClientInner>,
227}
228
229impl Client {
230    /// Creates a new client with the given API key and default settings.
231    pub fn new(api_key: impl Into<String>) -> Self {
232        ClientBuilder::new(api_key)
233            .build()
234            .expect("default client configuration is valid")
235    }
236
237    /// Returns a [`ClientBuilder`] for custom configuration.
238    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
239        ClientBuilder::new(api_key)
240    }
241
242    /// Returns the base URL for this client.
243    pub(crate) fn base_url(&self) -> &str {
244        &self.inner.base_url
245    }
246
247    /// Returns the auth header value (e.g. "Bearer qai_xxx").
248    pub(crate) fn auth_header(&self) -> &HeaderValue {
249        &self.inner.auth_header
250    }
251
252    /// Sends a JSON POST request and deserializes the response.
253    ///
254    /// An `Idempotency-Key` header is automatically generated and reused across
255    /// retries, preventing duplicate charges when a 502/504 masks a successful
256    /// backend operation.
257    pub async fn post_json<Req: Serialize, Resp: DeserializeOwned>(
258        &self,
259        path: &str,
260        body: &Req,
261    ) -> Result<(Resp, ResponseMeta)> {
262        let url = format!("{}{}", self.inner.base_url, path);
263        let body_bytes = serde_json::to_vec(body)?;
264        // Same key for all retries — backend deduplicates on this.
265        let idempotency_key = Uuid::new_v4().to_string();
266
267        let mut last_err = None;
268        for attempt in 0..=MAX_RETRIES {
269            if attempt > 0 {
270                let delay = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1);
271                eprintln!("[sdk] Retry {attempt}/{MAX_RETRIES} for POST {path} in {delay}ms");
272                tokio::time::sleep(Duration::from_millis(delay)).await;
273            }
274
275            let resp = self
276                .inner
277                .http
278                .post(&url)
279                .header(CONTENT_TYPE, "application/json")
280                .header("Idempotency-Key", &idempotency_key)
281                .body(body_bytes.clone())
282                .send()
283                .await?;
284
285            let status = resp.status();
286            let meta = parse_response_meta(&resp);
287
288            if status.is_success() {
289                let body_text = resp.text().await?;
290                let result: Resp = serde_json::from_str(&body_text).map_err(|e| {
291                    let preview = if body_text.len() > 300 { &body_text[..300] } else { &body_text };
292                    eprintln!("[sdk] JSON decode error on {path}: {e}\n  body preview: {preview}");
293                    e
294                })?;
295                return Ok((result, meta));
296            }
297
298            if is_retryable(status) && attempt < MAX_RETRIES {
299                // Read body to check if it's a permanent error wrapped in 502
300                let body_text = resp.text().await.unwrap_or_default();
301                if is_permanent_error(&body_text) {
302                    eprintln!("[sdk] POST {path} returned {status} but error is permanent, not retrying");
303                    let err = parse_api_error_from_text(status, &body_text, &meta.request_id);
304                    return Err(err);
305                }
306                eprintln!("[sdk] POST {path} returned {status}, will retry");
307                let err = parse_api_error_from_text(status, &body_text, &meta.request_id);
308                last_err = Some(err);
309                continue;
310            }
311
312            return Err(parse_api_error(resp, &meta.request_id).await);
313        }
314
315        Err(last_err.unwrap_or_else(|| Error::Api(ApiError {
316            status_code: 502,
317            code: "retry_exhausted".into(),
318            message: format!("max retries ({MAX_RETRIES}) exceeded"),
319            request_id: String::new(),
320        })))
321    }
322
323    /// Sends a POST request and returns the raw JSON response.
324    /// Useful for fire-and-forget endpoints (logging, telemetry) where
325    /// the response type isn't worth defining a struct for.
326    pub async fn post_raw(
327        &self,
328        path: &str,
329        body: &serde_json::Value,
330    ) -> Result<serde_json::Value> {
331        let (resp, _meta): (serde_json::Value, _) = self.post_json(path, body).await?;
332        Ok(resp)
333    }
334
335    /// Sends a GET request and deserializes the response.
336    pub async fn get_json<Resp: DeserializeOwned>(
337        &self,
338        path: &str,
339    ) -> Result<(Resp, ResponseMeta)> {
340        let url = format!("{}{}", self.inner.base_url, path);
341
342        let mut last_err = None;
343        for attempt in 0..=MAX_RETRIES {
344            if attempt > 0 {
345                let delay = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1);
346                eprintln!("[sdk] Retry {attempt}/{MAX_RETRIES} for GET {path} in {delay}ms");
347                tokio::time::sleep(Duration::from_millis(delay)).await;
348            }
349
350            let resp = self.inner.http.get(&url).send().await?;
351            let status = resp.status();
352            let meta = parse_response_meta(&resp);
353
354            if status.is_success() {
355                let body_text = resp.text().await?;
356                let result: Resp = serde_json::from_str(&body_text).map_err(|e| {
357                    let preview = if body_text.len() > 300 { &body_text[..300] } else { &body_text };
358                    eprintln!("[sdk] JSON decode error on {path}: {e}\n  body preview: {preview}");
359                    e
360                })?;
361                return Ok((result, meta));
362            }
363
364            if is_retryable(status) && attempt < MAX_RETRIES {
365                let body_text = resp.text().await.unwrap_or_default();
366                if is_permanent_error(&body_text) {
367                    eprintln!("[sdk] GET {path} returned {status} but error is permanent, not retrying");
368                    return Err(parse_api_error_from_text(status, &body_text, &meta.request_id));
369                }
370                eprintln!("[sdk] GET {path} returned {status}, will retry");
371                last_err = Some(parse_api_error_from_text(status, &body_text, &meta.request_id));
372                continue;
373            }
374
375            return Err(parse_api_error(resp, &meta.request_id).await);
376        }
377
378        Err(last_err.unwrap_or_else(|| Error::Api(ApiError {
379            status_code: 502,
380            code: "retry_exhausted".into(),
381            message: format!("max retries ({MAX_RETRIES}) exceeded"),
382            request_id: String::new(),
383        })))
384    }
385
386    /// Sends a DELETE request and deserializes the response.
387    pub async fn delete_json<Resp: DeserializeOwned>(
388        &self,
389        path: &str,
390    ) -> Result<(Resp, ResponseMeta)> {
391        let url = format!("{}{}", self.inner.base_url, path);
392        let resp = self.inner.http.delete(&url).send().await?;
393
394        let meta = parse_response_meta(&resp);
395
396        if !resp.status().is_success() {
397            return Err(parse_api_error(resp, &meta.request_id).await);
398        }
399
400        let result: Resp = resp.json().await?;
401        Ok((result, meta))
402    }
403
404    /// Sends a POST request with an empty body and deserializes the response.
405    pub async fn post_json_empty<Resp: DeserializeOwned>(
406        &self,
407        path: &str,
408    ) -> Result<(Resp, ResponseMeta)> {
409        let url = format!("{}{}", self.inner.base_url, path);
410        let resp = self.inner.http.post(&url)
411            .header("content-type", "application/json")
412            .header("Idempotency-Key", Uuid::new_v4().to_string())
413            .body("{}")
414            .send()
415            .await?;
416
417        let meta = parse_response_meta(&resp);
418
419        if !resp.status().is_success() {
420            return Err(parse_api_error(resp, &meta.request_id).await);
421        }
422
423        let result: Resp = resp.json().await?;
424        Ok((result, meta))
425    }
426
427    /// Sends a PUT request with a JSON body and deserializes the response.
428    pub async fn put_json<Req: Serialize, Resp: DeserializeOwned>(
429        &self,
430        path: &str,
431        body: &Req,
432    ) -> Result<(Resp, ResponseMeta)> {
433        let url = format!("{}{}", self.inner.base_url, path);
434        let resp = self.inner.http.put(&url).json(body).send().await?;
435
436        let meta = parse_response_meta(&resp);
437
438        if !resp.status().is_success() {
439            return Err(parse_api_error(resp, &meta.request_id).await);
440        }
441
442        let result: Resp = resp.json().await?;
443        Ok((result, meta))
444    }
445
446    /// Sends a multipart POST request and deserializes the response.
447    pub async fn post_multipart<Resp: DeserializeOwned>(
448        &self,
449        path: &str,
450        form: reqwest::multipart::Form,
451    ) -> Result<(Resp, ResponseMeta)> {
452        let url = format!("{}{}", self.inner.base_url, path);
453        let resp = self.inner.http.post(&url)
454            .header("Idempotency-Key", Uuid::new_v4().to_string())
455            .multipart(form)
456            .send()
457            .await?;
458
459        let meta = parse_response_meta(&resp);
460
461        if !resp.status().is_success() {
462            return Err(parse_api_error(resp, &meta.request_id).await);
463        }
464
465        let result: Resp = resp.json().await?;
466        Ok((result, meta))
467    }
468
469    /// Sends a GET request expecting an SSE stream response.
470    /// Returns the raw reqwest::Response for the caller to read events from.
471    /// Uses a separate client without timeout — cancellation is via drop.
472    pub async fn get_stream_raw(
473        &self,
474        path: &str,
475    ) -> Result<(reqwest::Response, ResponseMeta)> {
476        let url = format!("{}{}", self.inner.base_url, path);
477
478        let stream_client = reqwest::Client::builder().build()?;
479
480        let mut req = stream_client
481            .get(&url)
482            .header(AUTHORIZATION, self.inner.auth_header.clone())
483            .header("Accept", "text/event-stream");
484        for (name, value) in &self.inner.extra_headers {
485            req = req.header(name, value);
486        }
487        let resp = req.send().await?;
488
489        let meta = parse_response_meta(&resp);
490
491        if !resp.status().is_success() {
492            return Err(parse_api_error(resp, &meta.request_id).await);
493        }
494
495        Ok((resp, meta))
496    }
497
498    /// Sends a JSON POST request expecting an SSE stream response.
499    /// Returns the raw reqwest::Response for the caller to read events from.
500    /// Uses a separate client without timeout -- cancellation is via drop.
501    pub async fn post_stream_raw(
502        &self,
503        path: &str,
504        body: &impl Serialize,
505    ) -> Result<(reqwest::Response, ResponseMeta)> {
506        let url = format!("{}{}", self.inner.base_url, path);
507
508        // Build a client without timeout for streaming.
509        let stream_client = reqwest::Client::builder().build()?;
510
511        let mut req = stream_client
512            .post(&url)
513            .header(AUTHORIZATION, self.inner.auth_header.clone())
514            .header(CONTENT_TYPE, "application/json")
515            .header("Accept", "text/event-stream")
516            .header("Idempotency-Key", Uuid::new_v4().to_string());
517        for (name, value) in &self.inner.extra_headers {
518            req = req.header(name, value);
519        }
520        let resp = req.json(body).send().await?;
521
522        let meta = parse_response_meta(&resp);
523
524        if !resp.status().is_success() {
525            return Err(parse_api_error(resp, &meta.request_id).await);
526        }
527
528        Ok((resp, meta))
529    }
530}
531
532/// Extracts response metadata from HTTP headers.
533fn parse_response_meta(resp: &reqwest::Response) -> ResponseMeta {
534    let headers = resp.headers();
535    let request_id = headers
536        .get("X-QAI-Request-Id")
537        .and_then(|v| v.to_str().ok())
538        .unwrap_or("")
539        .to_string();
540    let model = headers
541        .get("X-QAI-Model")
542        .and_then(|v| v.to_str().ok())
543        .unwrap_or("")
544        .to_string();
545    let cost_ticks = headers
546        .get("X-QAI-Cost-Ticks")
547        .and_then(|v| v.to_str().ok())
548        .and_then(|v| v.parse::<i64>().ok())
549        .unwrap_or(0);
550    let balance_after = headers
551        .get("X-QAI-Balance-After")
552        .and_then(|v| v.to_str().ok())
553        .and_then(|v| v.parse::<i64>().ok())
554        .unwrap_or(0);
555
556    ResponseMeta {
557        cost_ticks,
558        balance_after,
559        request_id,
560        model,
561    }
562}
563
564/// Parses an API error from a non-2xx response.
565async fn parse_api_error(resp: reqwest::Response, request_id: &str) -> Error {
566    let status_code = resp.status().as_u16();
567    let status_text = resp
568        .status()
569        .canonical_reason()
570        .unwrap_or("Unknown")
571        .to_string();
572
573    let body = resp.text().await.unwrap_or_default();
574
575    let (code, message) = if let Ok(err_body) = serde_json::from_str::<ApiErrorBody>(&body) {
576        let msg = if err_body.error.message.is_empty() {
577            body.clone()
578        } else {
579            err_body.error.message
580        };
581        let c = if !err_body.error.code.is_empty() {
582            err_body.error.code
583        } else if !err_body.error.error_type.is_empty() {
584            err_body.error.error_type
585        } else {
586            status_text
587        };
588        (c, msg)
589    } else {
590        (status_text, body)
591    };
592
593    Error::Api(ApiError {
594        status_code,
595        code,
596        message,
597        request_id: request_id.to_string(),
598    })
599}
600
601fn parse_api_error_from_text(status: reqwest::StatusCode, body: &str, request_id: &str) -> Error {
602    let status_code = status.as_u16();
603    let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
604
605    let (code, message) = if let Ok(err_body) = serde_json::from_str::<ApiErrorBody>(body) {
606        let msg = if err_body.error.message.is_empty() { body.to_string() } else { err_body.error.message };
607        let c = if !err_body.error.code.is_empty() { err_body.error.code }
608                else if !err_body.error.error_type.is_empty() { err_body.error.error_type }
609                else { status_text };
610        (c, msg)
611    } else {
612        (status_text, body.to_string())
613    };
614
615    Error::Api(ApiError { status_code, code, message, request_id: request_id.to_string() })
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn reserved_headers_rejected_at_build() {
624        for name in ["Authorization", "authorization", "X-API-Key", "x-api-key"] {
625            let result = ClientBuilder::new("qai_test")
626                .extra_header(name, "anything")
627                .build();
628            match result {
629                Err(Error::Api(api)) => assert_eq!(api.code, "invalid_header"),
630                Ok(_) => panic!("expected reject for reserved header '{name}'"),
631                Err(other) => panic!("unexpected error variant for '{name}': {other:?}"),
632            }
633        }
634    }
635
636    #[test]
637    fn invalid_header_name_rejected_at_build() {
638        let result = ClientBuilder::new("qai_test")
639            .extra_header("bad name with spaces", "v")
640            .build();
641        match result {
642            Err(Error::Api(api)) => assert_eq!(api.code, "invalid_header"),
643            Ok(_) => panic!("expected reject for invalid header name"),
644            Err(other) => panic!("unexpected error variant: {other:?}"),
645        }
646    }
647
648    #[test]
649    fn app_and_extra_header_build_succeeds() {
650        let _client = ClientBuilder::new("qai_test")
651            .app("recipe-box")
652            .extra_header("X-Correlation-Id", "abc-123")
653            .build()
654            .expect("valid builder should construct a Client");
655    }
656}