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
11const MAX_RETRIES: u32 = 3;
13const INITIAL_BACKOFF_MS: u64 = 500;
15
16fn is_retryable(status: reqwest::StatusCode) -> bool {
18 matches!(status.as_u16(), 429 | 502 | 503 | 504)
19}
20
21fn 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
33pub const DEFAULT_BASE_URL: &str = "https://api.quantumencoding.ai";
35
36pub const TICKS_PER_USD: i64 = 10_000_000_000;
38
39#[derive(Debug, Clone, Default)]
41pub struct ResponseMeta {
42 pub cost_ticks: i64,
44 pub balance_after: i64,
47 pub request_id: String,
49 pub model: String,
51}
52
53pub 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
62fn 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 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 pub fn base_url(mut self, url: impl Into<String>) -> Self {
92 self.base_url = url.into();
93 self
94 }
95
96 pub fn timeout(mut self, timeout: Duration) -> Self {
102 self.timeout = timeout;
103 self
104 }
105
106 pub fn app(mut self, app: impl Into<String>) -> Self {
117 self.app = Some(app.into());
118 self
119 }
120
121 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 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 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 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 if let Ok(v) = HeaderValue::from_str(&self.api_key) {
179 headers.insert("X-API-Key", v);
180 }
181 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 extra_headers: HeaderMap,
213}
214
215#[derive(Clone)]
225pub struct Client {
226 inner: Arc<ClientInner>,
227}
228
229impl Client {
230 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 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
239 ClientBuilder::new(api_key)
240 }
241
242 pub(crate) fn base_url(&self) -> &str {
244 &self.inner.base_url
245 }
246
247 pub(crate) fn auth_header(&self) -> &HeaderValue {
249 &self.inner.auth_header
250 }
251
252 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 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 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 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 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 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 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 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 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 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 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 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
532fn 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
564async 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}