use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http::{HeaderMap, HeaderValue, Method, header};
use serde::Serialize;
use url::Url;
use crate::config::{
CallOptions, ClientConfig, DEFAULT_BASE_URL, DEFAULT_MODEL, strip_trailing_slashes,
};
use crate::error::{ApiError, Error, parse_error_body};
use crate::headers::{
merge_user_headers, request_id, retry_count_value, runtime_header, sdk_header, user_agent,
};
use crate::retry::RetryPolicy;
use crate::transport::{HttpTransport, RawResponse, join_endpoint};
use crate::types::{
ListModelsResponse, ModelCard, Questions, ResponseMeta, SystemOneRequest, SystemOneResponse,
validate_questions,
};
#[derive(Debug)]
struct Inner {
http: HttpTransport,
api_key: crate::config::SecretString,
base_url: Url,
default_model: String,
timeout: Duration,
retry: RetryPolicy,
default_headers: HeaderMap,
}
#[derive(Clone, Debug)]
pub struct Client {
inner: Arc<Inner>,
}
impl Client {
pub fn new(config: ClientConfig) -> Result<Self, Error> {
Self::new_with_env(config, |key| std::env::var(key).ok())
}
pub fn from_env() -> Result<Self, Error> {
Self::new(ClientConfig::default())
}
pub fn new_with_env(
mut config: ClientConfig,
lookup: impl FnMut(&str) -> Option<String>,
) -> Result<Self, Error> {
config.overlay_env(lookup)?;
if config.timeout.is_zero() {
return Err(Error::InvalidRequest(
"`timeout` must be a positive duration".to_owned(),
));
}
config.retry.validate()?;
let api_key = config.api_key.ok_or(Error::MissingApiKey)?;
let base_url =
strip_trailing_slashes(config.base_url.unwrap_or_else(|| {
Url::parse(DEFAULT_BASE_URL).expect("default base URL is valid")
}));
let default_model = config
.default_model
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| DEFAULT_MODEL.to_owned());
Ok(Self {
inner: Arc::new(Inner {
http: HttpTransport::new()?,
api_key,
base_url,
default_model,
timeout: config.timeout,
retry: config.retry,
default_headers: config.default_headers,
}),
})
}
#[must_use]
pub fn default_model(&self) -> &str {
&self.inner.default_model
}
#[must_use]
pub fn base_url(&self) -> &Url {
&self.inner.base_url
}
pub async fn system_one(
&self,
state: impl Serialize,
questions: Questions,
) -> Result<SystemOneResponse, Error> {
let state = serde_json::to_value(state).map_err(|err| {
Error::InvalidRequest(format!("state is not JSON-serializable: {err}"))
})?;
let req = SystemOneRequest::new(state, questions);
self.system_one_with(&req, CallOptions::default()).await
}
pub async fn system_one_with(
&self,
req: &SystemOneRequest,
opts: CallOptions,
) -> Result<SystemOneResponse, Error> {
validate_questions(&req.questions)?;
let model = opts
.model
.as_deref()
.filter(|m| !m.is_empty())
.or_else(|| {
if req.model.trim().is_empty() {
None
} else {
Some(req.model.as_str())
}
})
.unwrap_or(self.inner.default_model.as_str())
.to_owned();
let payload = serde_json::json!({
"state": req.state,
"model": model,
"questions": req.questions,
});
let body =
Bytes::from(serde_json::to_vec(&payload).map_err(|err| {
Error::InvalidRequest(format!("failed to serialize request: {err}"))
})?);
let raw = {
let fut = self.execute(Method::POST, "/v1/systemone", Some(body), &opts);
#[cfg(feature = "tracing")]
{
use tracing::Instrument;
let span = tracing::info_span!(
"typesafe.request",
http.request.method = "POST",
url.path = "/v1/systemone",
typesafe.model = model.as_str(),
typesafe.questions.count = req.questions.len(),
);
fut.instrument(span).await?
}
#[cfg(not(feature = "tracing"))]
{
fut.await?
}
};
let mut parsed: SystemOneResponse = decode_json(&raw, "/v1/systemone")?;
parsed.meta = meta_from_raw(&raw);
Ok(parsed)
}
#[must_use]
pub fn models(&self) -> Models<'_> {
Models { client: self }
}
pub async fn warm_up(&self) -> Result<(), Error> {
let raw = self
.execute(Method::GET, "/v1/models", None, &CallOptions::default())
.await?;
let _ = raw;
Ok(())
}
async fn execute(
&self,
method: Method,
path: &'static str,
body: Option<Bytes>,
opts: &CallOptions,
) -> Result<RawResponse, Error> {
let timeout = opts.timeout.unwrap_or(self.inner.timeout);
if timeout.is_zero() {
return Err(Error::InvalidRequest(
"`timeout` must be a positive duration".to_owned(),
));
}
let retry = opts
.retry
.clone()
.unwrap_or_else(|| self.inner.retry.clone());
retry.validate()?;
let url = join_endpoint(&self.inner.base_url, path)?;
let mut attempt = 0_u32;
loop {
let retries_left = retry.max_retries.saturating_sub(attempt);
let headers = self.attempt_headers(opts, body.is_some(), attempt);
match self
.inner
.http
.send(method.clone(), url.clone(), headers, body.clone(), timeout)
.await
{
Ok(mut raw) if raw.status.is_success() => {
raw.attempts = attempt + 1;
return Ok(raw);
}
Ok(raw) => {
let retryable = retry.http_statuses.contains_status(raw.status);
if retries_left > 0 && retryable {
let delay = retry.delay_after_failure(attempt, Some(&raw.headers));
emit_retry(attempt, delay, &raw.status.to_string());
tokio::time::sleep(delay).await;
attempt += 1;
continue;
}
let body = parse_error_body(&raw.body);
return Err(Error::Api(Box::new(ApiError::from_response(
raw.status,
body,
raw.headers,
path,
attempt + 1,
))));
}
Err(err) => {
let retryable = match &err {
Error::Timeout { .. } => retry.retry_timeouts,
Error::Connection(te) => retry.retries_connection(te.is_pre_send()),
_ => false,
};
if retries_left > 0 && retryable {
let delay = retry.delay_after_failure(attempt, None);
emit_retry(attempt, delay, &err.to_string());
tokio::time::sleep(delay).await;
attempt += 1;
continue;
}
return Err(err);
}
}
}
}
fn attempt_headers(&self, opts: &CallOptions, has_body: bool, attempt: u32) -> HeaderMap {
let mut headers = HeaderMap::new();
merge_user_headers(&mut headers, &self.inner.default_headers);
merge_user_headers(&mut headers, &opts.headers);
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", self.inner.api_key.expose()))
.unwrap_or_else(|_| HeaderValue::from_static("Bearer")),
);
headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(header::USER_AGENT, user_agent());
headers.insert("x-typesafe-sdk", sdk_header());
headers.insert("x-typesafe-runtime", runtime_header());
if has_body {
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
}
if attempt > 0 {
headers.insert("x-typesafe-retry-count", retry_count_value(attempt));
} else {
headers.remove("x-typesafe-retry-count");
}
headers
}
}
#[derive(Clone, Copy, Debug)]
pub struct Models<'a> {
client: &'a Client,
}
impl Models<'_> {
pub async fn list(&self) -> Result<Vec<ModelCard>, Error> {
self.list_with(&CallOptions::default()).await
}
pub async fn list_with(&self, opts: &CallOptions) -> Result<Vec<ModelCard>, Error> {
let raw = self
.client
.execute(Method::GET, "/v1/models", None, opts)
.await?;
let value: serde_json::Value = decode_json(&raw, "/v1/models")?;
match value.get("models") {
Some(serde_json::Value::Array(_)) => {
let parsed: ListModelsResponse =
serde_json::from_value(value).map_err(|source| Error::Decode {
source,
body: raw.body.clone(),
meta: Box::new(meta_from_raw(&raw)),
})?;
Ok(parsed.models)
}
_ => Err(Error::UnexpectedShape {
endpoint: "GET /v1/models",
meta: Box::new(meta_from_raw(&raw)),
}),
}
}
}
fn decode_json<T: serde::de::DeserializeOwned>(
raw: &RawResponse,
endpoint: &'static str,
) -> Result<T, Error> {
serde_json::from_slice(&raw.body).map_err(|source| {
let _ = endpoint;
Error::Decode {
source,
body: raw.body.clone(),
meta: Box::new(meta_from_raw(raw)),
}
})
}
fn meta_from_raw(raw: &RawResponse) -> ResponseMeta {
ResponseMeta {
request_id: request_id(&raw.headers),
status: Some(raw.status),
headers: raw.headers.clone(),
attempts: raw.attempts,
}
}
fn emit_retry(attempt: u32, delay: Duration, reason: &str) {
let _ = (attempt, delay, reason);
#[cfg(feature = "tracing")]
{
tracing::info!(
attempt,
delay_ms = delay.as_millis() as u64,
reason,
"retry_scheduled"
);
}
}