use reqwest::header::{self, HeaderMap, HeaderValue};
use serde::Serialize;
use serde_json::{Map, Value};
use std::fmt;
use std::net::IpAddr;
use std::time::Duration;
use crate::error::{ApiError, Error};
use crate::models::ListModelsResponse;
use crate::question::Question;
use crate::response::SystemOneResponse;
use crate::retry::RetryPolicy;
pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
pub const DEFAULT_MODEL: &str = "jev-latest";
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
pub const ENV_API_KEY: &str = "TYPESAFE_API_KEY";
pub const ENV_DEFAULT_MODEL: &str = "TYPESAFE_DEFAULT_MODEL";
pub const ENV_BASE_URL: &str = "TYPESAFE_BASE_URL";
const USER_AGENT: &str = concat!("jev-sdk/", env!("CARGO_PKG_VERSION"));
const MAX_SUCCESS_BODY: u64 = 16 * 1024 * 1024;
const MAX_ERROR_BODY: usize = 64 * 1024;
#[derive(Clone)]
pub struct TypeSafeClient {
http: reqwest::Client,
auth: HeaderValue,
base_url: String,
model: String,
retry: RetryPolicy,
}
impl fmt::Debug for TypeSafeClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TypeSafeClient")
.field("auth", &"[redacted]")
.field("base_url", &self.base_url)
.field("model", &self.model)
.field("retry", &self.retry)
.finish_non_exhaustive()
}
}
impl TypeSafeClient {
pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
Self::builder().api_key(api_key).build()
}
pub fn from_env() -> Result<Self, Error> {
Self::builder().build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn model(&self) -> &str {
&self.model
}
pub async fn system_one<S, I, K, Q>(
&self,
state: S,
questions: I,
) -> Result<SystemOneResponse, Error>
where
S: Serialize,
I: IntoIterator<Item = (K, Q)>,
K: Into<String>,
Q: Into<Question>,
{
let model = self.model.clone();
self.system_one_with_model(&model, state, questions).await
}
pub async fn system_one_with_model<S, I, K, Q>(
&self,
model: &str,
state: S,
questions: I,
) -> Result<SystemOneResponse, Error>
where
S: Serialize,
I: IntoIterator<Item = (K, Q)>,
K: Into<String>,
Q: Into<Question>,
{
let questions = build_questions_map(questions)?;
let state =
serde_json::to_value(state).map_err(|e| Error::InvalidRequest(e.to_string()))?;
let mut body = Map::new();
body.insert("state".to_owned(), state);
body.insert("model".to_owned(), Value::String(model.to_owned()));
body.insert("questions".to_owned(), Value::Object(questions));
let url = self.endpoint("/v1/systemone");
let body = Value::Object(body);
self.send_with_retry(|| self.http.post(&url).json(&body))
.await
}
pub async fn list_models(&self) -> Result<ListModelsResponse, Error> {
let url = self.endpoint("/v1/models");
self.send_with_retry(|| self.http.get(&url)).await
}
fn endpoint(&self, path: &str) -> String {
format!("{}{path}", self.base_url.trim_end_matches('/'))
}
async fn send_with_retry<T, F>(&self, make_request: F) -> Result<T, Error>
where
T: serde::de::DeserializeOwned,
F: Fn() -> reqwest::RequestBuilder,
{
let mut attempt = 0u32;
loop {
let request = make_request()
.header(header::AUTHORIZATION, self.auth.clone())
.header(header::ACCEPT, "application/json");
match request.send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
let body = read_body_limited(resp, MAX_SUCCESS_BODY).await?;
return serde_json::from_slice::<T>(&body)
.map_err(|e| Error::Decode(e.to_string()));
}
let headers = resp.headers().clone();
let body = read_body_prefix(resp, MAX_ERROR_BODY).await;
let raw = String::from_utf8_lossy(&body);
let err = ApiError::from_response(status, &raw);
if attempt < self.retry.max_retries && self.retry.should_retry_status(status) {
let wait = self.retry.delay_for(attempt, &headers);
tokio::time::sleep(wait).await;
attempt += 1;
continue;
}
return Err(Error::Api(err));
}
Err(e) => {
let transient = e.is_connect() || e.is_timeout();
if attempt < self.retry.max_retries && transient {
let wait = self.retry.backoff_for(attempt);
tokio::time::sleep(wait).await;
attempt += 1;
continue;
}
return Err(Error::Connection(e.to_string()));
}
}
}
}
}
#[derive(Default)]
pub struct ClientBuilder {
api_key: Option<String>,
model: Option<String>,
base_url: Option<String>,
timeout: Option<Duration>,
retry: Option<RetryPolicy>,
default_headers: Option<HeaderMap>,
http_client: Option<reqwest::Client>,
}
impl fmt::Debug for ClientBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClientBuilder")
.field("api_key", &self.api_key.as_ref().map(|_| "[redacted]"))
.field("model", &self.model)
.field("base_url", &self.base_url)
.field("timeout", &self.timeout)
.field("retry", &self.retry)
.field("default_headers", &self.default_headers)
.finish_non_exhaustive()
}
}
impl ClientBuilder {
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn retry(mut self, retry: RetryPolicy) -> Self {
self.retry = Some(retry);
self
}
pub fn default_headers(mut self, headers: HeaderMap) -> Self {
self.default_headers = Some(headers);
self
}
pub fn http_client(mut self, client: reqwest::Client) -> Self {
self.http_client = Some(client);
self
}
pub fn build(self) -> Result<TypeSafeClient, Error> {
let api_key = resolve(self.api_key, ENV_API_KEY).ok_or_else(|| {
Error::Config(format!(
"missing API key: set {ENV_API_KEY} or use ClientBuilder::api_key"
))
})?;
if api_key.trim().is_empty() {
return Err(Error::Config("API key is empty".into()));
}
let base_url =
resolve(self.base_url, ENV_BASE_URL).unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
validate_base_url(&base_url)?;
let model =
resolve(self.model, ENV_DEFAULT_MODEL).unwrap_or_else(|| DEFAULT_MODEL.to_owned());
let mut auth = HeaderValue::from_str(&format!("Bearer {api_key}"))
.map_err(|_| Error::Config("API key contains invalid header characters".into()))?;
auth.set_sensitive(true);
let http = match self.http_client {
Some(client) => client,
None => {
let mut builder = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
.redirect(reqwest::redirect::Policy::none());
if let Some(headers) = self.default_headers {
builder = builder.default_headers(headers);
}
builder
.build()
.map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?
}
};
Ok(TypeSafeClient {
http,
auth,
base_url,
model,
retry: self.retry.unwrap_or_default(),
})
}
}
fn resolve(explicit: Option<String>, env: &str) -> Option<String> {
explicit.or_else(|| std::env::var(env).ok()).and_then(|v| {
let trimmed = v.trim();
(!trimmed.is_empty()).then_some(v)
})
}
fn validate_base_url(base_url: &str) -> Result<(), Error> {
let url = reqwest::Url::parse(base_url)
.map_err(|_| Error::Config(format!("invalid base URL: {base_url:?}")))?;
if url.scheme() == "https" {
return Ok(());
}
if url.scheme() == "http" && url.host_str().is_some_and(is_loopback_host) {
return Ok(());
}
Err(Error::Config(format!(
"base URL {base_url:?} must use https (http is only allowed for loopback hosts); \
refusing to send the API key over plaintext"
)))
}
fn is_loopback_host(host: &str) -> bool {
if host == "localhost" || host.ends_with(".localhost") {
return true;
}
let bare = host.strip_prefix('[').and_then(|h| h.strip_suffix(']'));
let candidate = bare.unwrap_or(host);
candidate.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
async fn read_body_limited(mut resp: reqwest::Response, limit: u64) -> Result<Vec<u8>, Error> {
if resp.content_length().is_some_and(|len| len > limit) {
return Err(Error::Decode(format!(
"response body too large (Content-Length exceeds {limit} bytes)"
)));
}
let mut buf = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| Error::Connection(e.to_string()))?
{
if buf.len() as u64 + chunk.len() as u64 > limit {
return Err(Error::Decode(format!(
"response body exceeded the {limit}-byte limit"
)));
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
async fn read_body_prefix(mut resp: reqwest::Response, limit: usize) -> Vec<u8> {
let mut buf = Vec::new();
loop {
let remaining = limit.saturating_sub(buf.len());
if remaining == 0 {
break;
}
match resp.chunk().await {
Ok(Some(chunk)) => buf.extend_from_slice(&chunk[..chunk.len().min(remaining)]),
Ok(None) | Err(_) => break,
}
}
buf
}
fn build_questions_map<I, K, Q>(questions: I) -> Result<Map<String, Value>, Error>
where
I: IntoIterator<Item = (K, Q)>,
K: Into<String>,
Q: Into<Question>,
{
let mut map = Map::new();
for (key, question) in questions {
let question = question.into();
if let Question::Score(score) = &question {
if score.criteria.is_empty() {
return Err(Error::InvalidRequest(format!(
"score question {:?} must have at least one criteria level",
key.into()
)));
}
}
let key = key.into();
let value = serde_json::to_value(&question)
.map_err(|e| Error::InvalidRequest(format!("question {key:?}: {e}")))?;
map.insert(key, value);
}
if map.is_empty() {
return Err(Error::InvalidRequest(
"questions must not be empty".to_owned(),
));
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::question::{Choice, Noul, Score};
#[test]
fn builder_requires_api_key() {
let key_present = std::env::var(ENV_API_KEY).is_ok();
let result = TypeSafeClient::builder().build();
if key_present {
assert!(result.is_ok());
} else {
assert!(matches!(result, Err(Error::Config(_))));
}
}
#[test]
fn builder_rejects_bad_base_url() {
let result = TypeSafeClient::builder()
.api_key("k")
.base_url("not a url")
.build();
assert!(matches!(result, Err(Error::Config(_))));
}
#[test]
fn builder_rejects_plaintext_http_base_url() {
let result = TypeSafeClient::builder()
.api_key("k")
.base_url("http://api.typesafe.ai")
.build();
let err = result.unwrap_err();
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("https"), "{err}");
let result = TypeSafeClient::builder()
.api_key("k")
.base_url("ftp://example.com")
.build();
assert!(matches!(result.unwrap_err(), Error::Config(_)));
}
#[test]
fn builder_allows_loopback_http_for_testing() {
for url in [
"http://localhost:8080",
"http://127.0.0.1:8080",
"http://[::1]",
"http://test.localhost",
"https://api.example.com",
] {
TypeSafeClient::builder()
.api_key("k")
.base_url(url)
.build()
.unwrap_or_else(|e| panic!("{url} should be accepted: {e}"));
}
}
#[test]
fn builder_rejects_key_with_control_characters() {
let result = TypeSafeClient::builder()
.api_key("good\r\nX-Injected: bad")
.build();
assert!(matches!(result.unwrap_err(), Error::Config(_)));
}
#[test]
fn debug_formatting_never_exposes_the_api_key() {
let secret = "apikey_supersecretvalue";
let builder = TypeSafeClient::builder().api_key(secret);
let client = TypeSafeClient::new(secret).unwrap();
for rendered in [format!("{builder:?}"), format!("{client:?}")] {
assert!(
!rendered.contains(secret),
"secret leaked into Debug output: {rendered}"
);
assert!(rendered.contains("[redacted]"), "{rendered}");
}
}
#[test]
fn rejects_empty_questions() {
let questions: Vec<(String, Question)> = vec![];
let err = build_questions_map(questions).unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[test]
fn rejects_empty_score_criteria() {
let questions = vec![(
"s",
Question::from(Score::new("rate", Vec::<String>::new())),
)];
let err = build_questions_map(questions).unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)));
}
#[test]
fn builds_mixed_question_map() {
let questions = vec![
(
"tone",
Question::from(Choice::options("Tone?", ["calm", "angry"])),
),
("billing", Question::from(Noul::new("Billing?"))),
];
let map = build_questions_map(questions).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map["tone"]["type"], "choice");
assert_eq!(map["billing"]["type"], "noul");
let keys: Vec<_> = map.keys().collect();
assert_eq!(keys, ["tone", "billing"]);
}
}