use std::fmt;
use std::time::Duration;
use serde::Serialize;
use crate::limiter::{jitter, AdaptiveLimiter};
use crate::transport::{Http, Transport};
use crate::{Error, Questions, Response, Usage, API_KEY_VAR, DEFAULT_BASE_URL, DEFAULT_MODEL};
const RETRYABLE: &[u16] = &[408, 409, 425, 429, 500, 502, 503, 504, 529];
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub base_url: String,
pub model: String,
pub provider: String,
pub user_agent: String,
pub connect_timeout: Duration,
pub timeout: Duration,
pub max_retries: u32,
pub max_backoff: Duration,
pub pool_size: usize,
pub throttle_pause: Duration,
pub backoff_scale: f64,
}
impl Default for Config {
fn default() -> Self {
Config {
base_url: DEFAULT_BASE_URL.into(),
model: DEFAULT_MODEL.into(),
provider: "TypeSafe".into(),
user_agent: concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")).into(),
connect_timeout: Duration::from_secs(15),
timeout: Duration::from_secs(60),
max_retries: 8,
max_backoff: Duration::from_secs(30),
pool_size: 64,
throttle_pause: Duration::from_secs(1),
backoff_scale: 1.0,
}
}
}
type DebugReporter = dyn Fn(&str) + Send + Sync;
pub struct Client {
model: String,
provider: String,
max_retries: u32,
max_backoff: Duration,
backoff_scale: f64,
usage: Usage,
limiter: AdaptiveLimiter,
transport: Box<dyn Transport>,
debug_reporter: Option<Box<DebugReporter>>,
}
impl Client {
pub fn new(api_key: &str, cfg: Config) -> Result<Self, Error> {
let http = Http::new(api_key, &cfg)?;
Ok(Self::with_transport(http, cfg))
}
pub fn from_env(cfg: Config) -> Result<Self, Error> {
let key = api_key_from(std::env::var(API_KEY_VAR).ok().as_deref())?;
Self::new(&key, cfg)
}
pub fn with_transport(transport: impl Transport + 'static, cfg: Config) -> Self {
Client {
model: cfg.model,
provider: cfg.provider,
max_retries: cfg.max_retries,
max_backoff: cfg.max_backoff,
backoff_scale: cfg.backoff_scale,
usage: Usage::default(),
limiter: AdaptiveLimiter::new(cfg.pool_size, cfg.throttle_pause),
transport: Box::new(transport),
debug_reporter: None,
}
}
pub fn set_debug_reporter(&mut self, reporter: impl Fn(&str) + Send + Sync + 'static) {
self.debug_reporter = Some(Box::new(reporter));
}
pub fn model(&self) -> &str {
&self.model
}
pub fn usage(&self) -> &Usage {
&self.usage
}
pub fn limiter(&self) -> &AdaptiveLimiter {
&self.limiter
}
fn debug(&self, msg: &str) {
if let Some(reporter) = &self.debug_reporter {
reporter(msg);
}
}
fn sleep(&self, seconds: f64) {
match Duration::try_from_secs_f64(seconds * self.backoff_scale) {
Ok(wait) if !wait.is_zero() => std::thread::sleep(wait),
_ => {}
}
}
pub fn ask<S: Serialize + ?Sized>(&self, state: &S, questions: &Questions) -> Result<Response, Error> {
let request = Request { model: &self.model, state, questions };
let body = serde_json::to_vec(&request).map_err(|e| Error::InvalidRequest(format!("the state does not serialize to JSON: {e}")))?;
self.send(&body)
}
fn send(&self, body: &[u8]) -> Result<Response, Error> {
let mut last = String::from("unknown error");
for attempt in 0..=self.max_retries {
if attempt > 0 {
self.usage.record_retry();
let doublings = (attempt - 1).min(32) as i32;
self.sleep((0.5 * 2f64.powi(doublings)).min(self.max_backoff.as_secs_f64()) * (0.5 + jitter()));
}
let sent = {
let mut slot = Slot::acquire(&self.limiter);
let sent = self.transport.post(body);
slot.throttled = matches!(&sent, Ok(r) if r.status == 429 || r.status == 529);
sent
};
let reply = match sent {
Ok(reply) => reply,
Err(e) => {
self.debug(&format!("attempt {}: {e}", attempt + 1));
last = e;
continue;
}
};
if reply.status == 200 {
let response: Response = serde_json::from_str(&reply.body).map_err(|e| Error::Api(format!("unreadable response: {e}")))?;
self.usage.record_reported(response.usage);
return Ok(response);
}
let text: String = reply.body.chars().take(400).collect();
if reply.status == 401 || reply.status == 403 {
return Err(Error::Auth(format!("{} rejected the API key (HTTP {}): {text}", self.provider, reply.status)));
}
if text.contains("max_tokens_exceeded") || reply.status == 413 {
return Err(Error::TokenLimit(text));
}
if reply.status == 422 {
return Err(Error::InvalidRequest(format!("HTTP 422: {text}")));
}
if RETRYABLE.contains(&reply.status) {
last = format!("HTTP {}: {text}", reply.status);
self.debug(&format!("attempt {}: {last} retry-after={:?}", attempt + 1, reply.retry_after));
if let Some(seconds) = reply.retry_after.and_then(|v| v.trim().parse::<f64>().ok()) {
self.sleep(seconds.clamp(0.0, 30.0));
}
continue;
}
return Err(Error::Api(format!("HTTP {}: {text}", reply.status)));
}
Err(Error::Api(format!("gave up after {} retries: {last}", self.max_retries)))
}
}
#[derive(Serialize)]
struct Request<'a, S: ?Sized> {
model: &'a str,
state: &'a S,
questions: &'a Questions,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("model", &self.model)
.field("provider", &self.provider)
.field("max_retries", &self.max_retries)
.field("max_backoff", &self.max_backoff)
.field("backoff_scale", &self.backoff_scale)
.field("usage", &self.usage)
.field("limiter", &self.limiter)
.finish_non_exhaustive()
}
}
struct Slot<'a> {
limiter: &'a AdaptiveLimiter,
throttled: bool,
}
impl<'a> Slot<'a> {
fn acquire(limiter: &'a AdaptiveLimiter) -> Self {
limiter.acquire();
Slot { limiter, throttled: false }
}
}
impl Drop for Slot<'_> {
fn drop(&mut self) {
self.limiter.release(self.throttled);
}
}
fn api_key_from(value: Option<&str>) -> Result<String, Error> {
match value.map(str::trim) {
Some(key) if !key.is_empty() => Ok(key.to_owned()),
_ => Err(Error::Auth(format!("{API_KEY_VAR} is not set"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_environment_key_is_trimmed_and_must_not_be_blank() {
assert_eq!(api_key_from(Some(" sk-1 \n")).unwrap(), "sk-1");
for missing in [None, Some(""), Some(" ")] {
let error = api_key_from(missing).unwrap_err();
assert!(matches!(&error, Error::Auth(m) if m.contains(API_KEY_VAR)), "{missing:?} -> {error:?}");
}
}
#[test]
fn defaults_point_at_typesafe() {
let cfg = Config::default();
assert_eq!((cfg.base_url.as_str(), cfg.model.as_str(), cfg.provider.as_str()), (DEFAULT_BASE_URL, DEFAULT_MODEL, "TypeSafe"));
assert!(cfg.user_agent.starts_with("typesafe-jev/"), "{}", cfg.user_agent);
assert_eq!((cfg.max_retries, cfg.max_backoff, cfg.pool_size, cfg.backoff_scale), (8, Duration::from_secs(30), 64, 1.0));
}
}