use std::sync::Arc;
use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde::de::DeserializeOwned;
use serde::Serialize;
use uuid::Uuid;
use crate::error::{ApiError, ApiErrorBody, Error, Result};
const MAX_RETRIES: u32 = 3;
const INITIAL_BACKOFF_MS: u64 = 500;
fn is_retryable(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 429 | 502 | 503 | 504)
}
fn is_permanent_error(body: &str) -> bool {
let lower = body.to_lowercase();
lower.contains("content moderation")
|| lower.contains("content_policy")
|| lower.contains("safety_block")
|| lower.contains("invalid argument")
|| lower.contains("invalid_request")
|| (lower.contains("status 400") && lower.contains("rejected"))
}
pub const DEFAULT_BASE_URL: &str = "https://api.quantumencoding.ai";
pub const TICKS_PER_USD: i64 = 10_000_000_000;
#[derive(Debug, Clone, Default)]
pub struct ResponseMeta {
pub cost_ticks: i64,
pub balance_after: i64,
pub request_id: String,
pub model: String,
}
pub struct ClientBuilder {
api_key: String,
base_url: String,
timeout: Duration,
app: Option<String>,
extra_headers: Vec<(String, String)>,
}
fn is_reserved_header(name: &str) -> bool {
name.eq_ignore_ascii_case("authorization") || name.eq_ignore_ascii_case("x-api-key")
}
fn invalid_header_error(message: String) -> Error {
Error::Api(ApiError {
status_code: 0,
code: "invalid_header".to_string(),
message,
request_id: String::new(),
})
}
impl ClientBuilder {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
base_url: DEFAULT_BASE_URL.to_string(),
timeout: Duration::from_secs(120),
app: None,
extra_headers: Vec::new(),
}
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn app(mut self, app: impl Into<String>) -> Self {
self.app = Some(app.into());
self
}
pub fn extra_header(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> Self {
self.extra_headers.push((name.into(), value.into()));
self
}
pub fn build(self) -> Result<Client> {
let auth_value = format!("Bearer {}", self.api_key);
let auth_header = HeaderValue::from_str(&auth_value).map_err(|_| {
Error::Api(ApiError {
status_code: 0,
code: "invalid_api_key".to_string(),
message: "API key contains invalid header characters".to_string(),
request_id: String::new(),
})
})?;
let mut caller_headers = self.extra_headers.clone();
if let Some(app) = self.app.as_ref() {
caller_headers.push(("X-Quantum-App".to_string(), app.clone()));
}
let mut extra_headers_map = HeaderMap::new();
for (name, value) in &caller_headers {
if is_reserved_header(name) {
return Err(invalid_header_error(format!(
"header '{name}' is reserved by the SDK and cannot be overridden via extra_header"
)));
}
let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
invalid_header_error(format!("invalid header name '{name}': {e}"))
})?;
let header_value = HeaderValue::from_str(value).map_err(|e| {
invalid_header_error(format!("invalid header value for '{name}': {e}"))
})?;
extra_headers_map.insert(header_name, header_value);
}
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, auth_header.clone());
if let Ok(v) = HeaderValue::from_str(&self.api_key) {
headers.insert("X-API-Key", v);
}
for (name, value) in &extra_headers_map {
headers.insert(name.clone(), value.clone());
}
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(self.timeout)
.build()?;
Ok(Client {
inner: Arc::new(ClientInner {
base_url: self.base_url,
http,
auth_header,
extra_headers: extra_headers_map,
}),
})
}
}
struct ClientInner {
base_url: String,
http: reqwest::Client,
auth_header: HeaderValue,
extra_headers: HeaderMap,
}
#[derive(Clone)]
pub struct Client {
inner: Arc<ClientInner>,
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Self {
ClientBuilder::new(api_key)
.build()
.expect("default client configuration is valid")
}
pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
ClientBuilder::new(api_key)
}
pub(crate) fn base_url(&self) -> &str {
&self.inner.base_url
}
pub(crate) fn auth_header(&self) -> &HeaderValue {
&self.inner.auth_header
}
pub async fn post_json<Req: Serialize, Resp: DeserializeOwned>(
&self,
path: &str,
body: &Req,
) -> Result<(Resp, ResponseMeta)> {
let url = format!("{}{}", self.inner.base_url, path);
let body_bytes = serde_json::to_vec(body)?;
let idempotency_key = Uuid::new_v4().to_string();
let mut last_err = None;
for attempt in 0..=MAX_RETRIES {
if attempt > 0 {
let delay = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1);
eprintln!("[sdk] Retry {attempt}/{MAX_RETRIES} for POST {path} in {delay}ms");
tokio::time::sleep(Duration::from_millis(delay)).await;
}
let resp = self
.inner
.http
.post(&url)
.header(CONTENT_TYPE, "application/json")
.header("Idempotency-Key", &idempotency_key)
.body(body_bytes.clone())
.send()
.await?;
let status = resp.status();
let meta = parse_response_meta(&resp);
if status.is_success() {
let body_text = resp.text().await?;
let result: Resp = serde_json::from_str(&body_text).map_err(|e| {
let preview = if body_text.len() > 300 { &body_text[..300] } else { &body_text };
eprintln!("[sdk] JSON decode error on {path}: {e}\n body preview: {preview}");
e
})?;
return Ok((result, meta));
}
if is_retryable(status) && attempt < MAX_RETRIES {
let body_text = resp.text().await.unwrap_or_default();
if is_permanent_error(&body_text) {
eprintln!("[sdk] POST {path} returned {status} but error is permanent, not retrying");
let err = parse_api_error_from_text(status, &body_text, &meta.request_id);
return Err(err);
}
eprintln!("[sdk] POST {path} returned {status}, will retry");
let err = parse_api_error_from_text(status, &body_text, &meta.request_id);
last_err = Some(err);
continue;
}
return Err(parse_api_error(resp, &meta.request_id).await);
}
Err(last_err.unwrap_or_else(|| Error::Api(ApiError {
status_code: 502,
code: "retry_exhausted".into(),
message: format!("max retries ({MAX_RETRIES}) exceeded"),
request_id: String::new(),
})))
}
pub async fn post_raw(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value> {
let (resp, _meta): (serde_json::Value, _) = self.post_json(path, body).await?;
Ok(resp)
}
pub async fn get_json<Resp: DeserializeOwned>(
&self,
path: &str,
) -> Result<(Resp, ResponseMeta)> {
let url = format!("{}{}", self.inner.base_url, path);
let mut last_err = None;
for attempt in 0..=MAX_RETRIES {
if attempt > 0 {
let delay = INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1);
eprintln!("[sdk] Retry {attempt}/{MAX_RETRIES} for GET {path} in {delay}ms");
tokio::time::sleep(Duration::from_millis(delay)).await;
}
let resp = self.inner.http.get(&url).send().await?;
let status = resp.status();
let meta = parse_response_meta(&resp);
if status.is_success() {
let body_text = resp.text().await?;
let result: Resp = serde_json::from_str(&body_text).map_err(|e| {
let preview = if body_text.len() > 300 { &body_text[..300] } else { &body_text };
eprintln!("[sdk] JSON decode error on {path}: {e}\n body preview: {preview}");
e
})?;
return Ok((result, meta));
}
if is_retryable(status) && attempt < MAX_RETRIES {
let body_text = resp.text().await.unwrap_or_default();
if is_permanent_error(&body_text) {
eprintln!("[sdk] GET {path} returned {status} but error is permanent, not retrying");
return Err(parse_api_error_from_text(status, &body_text, &meta.request_id));
}
eprintln!("[sdk] GET {path} returned {status}, will retry");
last_err = Some(parse_api_error_from_text(status, &body_text, &meta.request_id));
continue;
}
return Err(parse_api_error(resp, &meta.request_id).await);
}
Err(last_err.unwrap_or_else(|| Error::Api(ApiError {
status_code: 502,
code: "retry_exhausted".into(),
message: format!("max retries ({MAX_RETRIES}) exceeded"),
request_id: String::new(),
})))
}
pub async fn delete_json<Resp: DeserializeOwned>(
&self,
path: &str,
) -> Result<(Resp, ResponseMeta)> {
let url = format!("{}{}", self.inner.base_url, path);
let resp = self.inner.http.delete(&url).send().await?;
let meta = parse_response_meta(&resp);
if !resp.status().is_success() {
return Err(parse_api_error(resp, &meta.request_id).await);
}
let result: Resp = resp.json().await?;
Ok((result, meta))
}
pub async fn post_json_empty<Resp: DeserializeOwned>(
&self,
path: &str,
) -> Result<(Resp, ResponseMeta)> {
let url = format!("{}{}", self.inner.base_url, path);
let resp = self.inner.http.post(&url)
.header("content-type", "application/json")
.header("Idempotency-Key", Uuid::new_v4().to_string())
.body("{}")
.send()
.await?;
let meta = parse_response_meta(&resp);
if !resp.status().is_success() {
return Err(parse_api_error(resp, &meta.request_id).await);
}
let result: Resp = resp.json().await?;
Ok((result, meta))
}
pub async fn put_json<Req: Serialize, Resp: DeserializeOwned>(
&self,
path: &str,
body: &Req,
) -> Result<(Resp, ResponseMeta)> {
let url = format!("{}{}", self.inner.base_url, path);
let resp = self.inner.http.put(&url).json(body).send().await?;
let meta = parse_response_meta(&resp);
if !resp.status().is_success() {
return Err(parse_api_error(resp, &meta.request_id).await);
}
let result: Resp = resp.json().await?;
Ok((result, meta))
}
pub async fn post_multipart<Resp: DeserializeOwned>(
&self,
path: &str,
form: reqwest::multipart::Form,
) -> Result<(Resp, ResponseMeta)> {
let url = format!("{}{}", self.inner.base_url, path);
let resp = self.inner.http.post(&url)
.header("Idempotency-Key", Uuid::new_v4().to_string())
.multipart(form)
.send()
.await?;
let meta = parse_response_meta(&resp);
if !resp.status().is_success() {
return Err(parse_api_error(resp, &meta.request_id).await);
}
let result: Resp = resp.json().await?;
Ok((result, meta))
}
pub async fn get_stream_raw(
&self,
path: &str,
) -> Result<(reqwest::Response, ResponseMeta)> {
let url = format!("{}{}", self.inner.base_url, path);
let stream_client = reqwest::Client::builder().build()?;
let mut req = stream_client
.get(&url)
.header(AUTHORIZATION, self.inner.auth_header.clone())
.header("Accept", "text/event-stream");
for (name, value) in &self.inner.extra_headers {
req = req.header(name, value);
}
let resp = req.send().await?;
let meta = parse_response_meta(&resp);
if !resp.status().is_success() {
return Err(parse_api_error(resp, &meta.request_id).await);
}
Ok((resp, meta))
}
pub async fn post_stream_raw(
&self,
path: &str,
body: &impl Serialize,
) -> Result<(reqwest::Response, ResponseMeta)> {
let url = format!("{}{}", self.inner.base_url, path);
let stream_client = reqwest::Client::builder().build()?;
let mut req = stream_client
.post(&url)
.header(AUTHORIZATION, self.inner.auth_header.clone())
.header(CONTENT_TYPE, "application/json")
.header("Accept", "text/event-stream")
.header("Idempotency-Key", Uuid::new_v4().to_string());
for (name, value) in &self.inner.extra_headers {
req = req.header(name, value);
}
let resp = req.json(body).send().await?;
let meta = parse_response_meta(&resp);
if !resp.status().is_success() {
return Err(parse_api_error(resp, &meta.request_id).await);
}
Ok((resp, meta))
}
}
fn parse_response_meta(resp: &reqwest::Response) -> ResponseMeta {
let headers = resp.headers();
let request_id = headers
.get("X-QAI-Request-Id")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let model = headers
.get("X-QAI-Model")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let cost_ticks = headers
.get("X-QAI-Cost-Ticks")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0);
let balance_after = headers
.get("X-QAI-Balance-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0);
ResponseMeta {
cost_ticks,
balance_after,
request_id,
model,
}
}
async fn parse_api_error(resp: reqwest::Response, request_id: &str) -> Error {
let status_code = resp.status().as_u16();
let status_text = resp
.status()
.canonical_reason()
.unwrap_or("Unknown")
.to_string();
let body = resp.text().await.unwrap_or_default();
let (code, message) = if let Ok(err_body) = serde_json::from_str::<ApiErrorBody>(&body) {
let msg = if err_body.error.message.is_empty() {
body.clone()
} else {
err_body.error.message
};
let c = if !err_body.error.code.is_empty() {
err_body.error.code
} else if !err_body.error.error_type.is_empty() {
err_body.error.error_type
} else {
status_text
};
(c, msg)
} else {
(status_text, body)
};
Error::Api(ApiError {
status_code,
code,
message,
request_id: request_id.to_string(),
})
}
fn parse_api_error_from_text(status: reqwest::StatusCode, body: &str, request_id: &str) -> Error {
let status_code = status.as_u16();
let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
let (code, message) = if let Ok(err_body) = serde_json::from_str::<ApiErrorBody>(body) {
let msg = if err_body.error.message.is_empty() { body.to_string() } else { err_body.error.message };
let c = if !err_body.error.code.is_empty() { err_body.error.code }
else if !err_body.error.error_type.is_empty() { err_body.error.error_type }
else { status_text };
(c, msg)
} else {
(status_text, body.to_string())
};
Error::Api(ApiError { status_code, code, message, request_id: request_id.to_string() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reserved_headers_rejected_at_build() {
for name in ["Authorization", "authorization", "X-API-Key", "x-api-key"] {
let result = ClientBuilder::new("qai_test")
.extra_header(name, "anything")
.build();
match result {
Err(Error::Api(api)) => assert_eq!(api.code, "invalid_header"),
Ok(_) => panic!("expected reject for reserved header '{name}'"),
Err(other) => panic!("unexpected error variant for '{name}': {other:?}"),
}
}
}
#[test]
fn invalid_header_name_rejected_at_build() {
let result = ClientBuilder::new("qai_test")
.extra_header("bad name with spaces", "v")
.build();
match result {
Err(Error::Api(api)) => assert_eq!(api.code, "invalid_header"),
Ok(_) => panic!("expected reject for invalid header name"),
Err(other) => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn app_and_extra_header_build_succeeds() {
let _client = ClientBuilder::new("qai_test")
.app("recipe-box")
.extra_header("X-Correlation-Id", "abc-123")
.build()
.expect("valid builder should construct a Client");
}
}