1use std::fmt;
2use std::time::Duration;
3
4use serde::Serialize;
5
6use crate::limiter::{jitter, AdaptiveLimiter};
7use crate::transport::{Http, Transport};
8use crate::{Error, Questions, Response, Usage, API_KEY_VAR, DEFAULT_BASE_URL, DEFAULT_MODEL};
9
10const RETRYABLE: &[u16] = &[408, 409, 425, 429, 500, 502, 503, 504, 529];
12
13#[derive(Debug, Clone, PartialEq)]
18pub struct Config {
19 pub base_url: String,
22 pub model: String,
24 pub provider: String,
26 pub user_agent: String,
28 pub connect_timeout: Duration,
30 pub timeout: Duration,
32 pub max_retries: u32,
34 pub max_backoff: Duration,
37 pub pool_size: usize,
40 pub throttle_pause: Duration,
43 pub backoff_scale: f64,
47}
48
49impl Default for Config {
50 fn default() -> Self {
51 Config {
52 base_url: DEFAULT_BASE_URL.into(),
53 model: DEFAULT_MODEL.into(),
54 provider: "TypeSafe".into(),
55 user_agent: concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")).into(),
56 connect_timeout: Duration::from_secs(15),
57 timeout: Duration::from_secs(60),
58 max_retries: 8,
59 max_backoff: Duration::from_secs(30),
60 pool_size: 64,
61 throttle_pause: Duration::from_secs(1),
62 backoff_scale: 1.0,
63 }
64 }
65}
66
67type DebugReporter = dyn Fn(&str) + Send + Sync;
68
69pub struct Client {
72 model: String,
73 provider: String,
74 max_retries: u32,
75 max_backoff: Duration,
76 backoff_scale: f64,
77 usage: Usage,
78 limiter: AdaptiveLimiter,
79 transport: Box<dyn Transport>,
80 debug_reporter: Option<Box<DebugReporter>>,
81}
82
83impl Client {
84 pub fn new(api_key: &str, cfg: Config) -> Result<Self, Error> {
97 let http = Http::new(api_key, &cfg)?;
98 Ok(Self::with_transport(http, cfg))
99 }
100
101 pub fn from_env(cfg: Config) -> Result<Self, Error> {
107 let key = api_key_from(std::env::var(API_KEY_VAR).ok().as_deref())?;
108 Self::new(&key, cfg)
109 }
110
111 pub fn with_transport(transport: impl Transport + 'static, cfg: Config) -> Self {
114 Client {
115 model: cfg.model,
116 provider: cfg.provider,
117 max_retries: cfg.max_retries,
118 max_backoff: cfg.max_backoff,
119 backoff_scale: cfg.backoff_scale,
120 usage: Usage::default(),
121 limiter: AdaptiveLimiter::new(cfg.pool_size, cfg.throttle_pause),
122 transport: Box::new(transport),
123 debug_reporter: None,
124 }
125 }
126
127 pub fn set_debug_reporter(&mut self, reporter: impl Fn(&str) + Send + Sync + 'static) {
131 self.debug_reporter = Some(Box::new(reporter));
132 }
133
134 pub fn model(&self) -> &str {
136 &self.model
137 }
138
139 pub fn usage(&self) -> &Usage {
141 &self.usage
142 }
143
144 pub fn limiter(&self) -> &AdaptiveLimiter {
146 &self.limiter
147 }
148
149 fn debug(&self, msg: &str) {
150 if let Some(reporter) = &self.debug_reporter {
151 reporter(msg);
152 }
153 }
154
155 fn sleep(&self, seconds: f64) {
157 match Duration::try_from_secs_f64(seconds * self.backoff_scale) {
158 Ok(wait) if !wait.is_zero() => std::thread::sleep(wait),
159 _ => {}
160 }
161 }
162
163 pub fn ask<S: Serialize + ?Sized>(&self, state: &S, questions: &Questions) -> Result<Response, Error> {
183 let request = Request { model: &self.model, state, questions };
184 let body = serde_json::to_vec(&request).map_err(|e| Error::InvalidRequest(format!("the state does not serialize to JSON: {e}")))?;
185 self.send(&body)
186 }
187
188 fn send(&self, body: &[u8]) -> Result<Response, Error> {
190 let mut last = String::from("unknown error");
191 for attempt in 0..=self.max_retries {
192 if attempt > 0 {
193 self.usage.record_retry();
194 let doublings = (attempt - 1).min(32) as i32;
195 self.sleep((0.5 * 2f64.powi(doublings)).min(self.max_backoff.as_secs_f64()) * (0.5 + jitter()));
196 }
197 let sent = {
198 let mut slot = Slot::acquire(&self.limiter);
199 let sent = self.transport.post(body);
200 slot.throttled = matches!(&sent, Ok(r) if r.status == 429 || r.status == 529);
201 sent
202 };
203 let reply = match sent {
204 Ok(reply) => reply,
205 Err(e) => {
206 self.debug(&format!("attempt {}: {e}", attempt + 1));
207 last = e;
208 continue;
209 }
210 };
211 if reply.status == 200 {
212 let response: Response = serde_json::from_str(&reply.body).map_err(|e| Error::Api(format!("unreadable response: {e}")))?;
213 self.usage.record_reported(response.usage);
214 return Ok(response);
215 }
216 let text: String = reply.body.chars().take(400).collect();
217 if reply.status == 401 || reply.status == 403 {
218 return Err(Error::Auth(format!("{} rejected the API key (HTTP {}): {text}", self.provider, reply.status)));
219 }
220 if text.contains("max_tokens_exceeded") || reply.status == 413 {
221 return Err(Error::TokenLimit(text));
222 }
223 if reply.status == 422 {
224 return Err(Error::InvalidRequest(format!("HTTP 422: {text}")));
225 }
226 if RETRYABLE.contains(&reply.status) {
227 last = format!("HTTP {}: {text}", reply.status);
228 self.debug(&format!("attempt {}: {last} retry-after={:?}", attempt + 1, reply.retry_after));
229 if let Some(seconds) = reply.retry_after.and_then(|v| v.trim().parse::<f64>().ok()) {
230 self.sleep(seconds.clamp(0.0, 30.0));
231 }
232 continue;
233 }
234 return Err(Error::Api(format!("HTTP {}: {text}", reply.status)));
235 }
236 Err(Error::Api(format!("gave up after {} retries: {last}", self.max_retries)))
237 }
238}
239
240#[derive(Serialize)]
242struct Request<'a, S: ?Sized> {
243 model: &'a str,
244 state: &'a S,
245 questions: &'a Questions,
246}
247
248impl fmt::Debug for Client {
250 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251 f.debug_struct("Client")
252 .field("model", &self.model)
253 .field("provider", &self.provider)
254 .field("max_retries", &self.max_retries)
255 .field("max_backoff", &self.max_backoff)
256 .field("backoff_scale", &self.backoff_scale)
257 .field("usage", &self.usage)
258 .field("limiter", &self.limiter)
259 .finish_non_exhaustive()
260 }
261}
262
263struct Slot<'a> {
266 limiter: &'a AdaptiveLimiter,
267 throttled: bool,
268}
269
270impl<'a> Slot<'a> {
271 fn acquire(limiter: &'a AdaptiveLimiter) -> Self {
272 limiter.acquire();
273 Slot { limiter, throttled: false }
274 }
275}
276
277impl Drop for Slot<'_> {
278 fn drop(&mut self) {
279 self.limiter.release(self.throttled);
280 }
281}
282
283fn api_key_from(value: Option<&str>) -> Result<String, Error> {
285 match value.map(str::trim) {
286 Some(key) if !key.is_empty() => Ok(key.to_owned()),
287 _ => Err(Error::Auth(format!("{API_KEY_VAR} is not set"))),
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn the_environment_key_is_trimmed_and_must_not_be_blank() {
297 assert_eq!(api_key_from(Some(" sk-1 \n")).unwrap(), "sk-1");
298 for missing in [None, Some(""), Some(" ")] {
299 let error = api_key_from(missing).unwrap_err();
300 assert!(matches!(&error, Error::Auth(m) if m.contains(API_KEY_VAR)), "{missing:?} -> {error:?}");
301 }
302 }
303
304 #[test]
305 fn defaults_point_at_typesafe() {
306 let cfg = Config::default();
307 assert_eq!((cfg.base_url.as_str(), cfg.model.as_str(), cfg.provider.as_str()), (DEFAULT_BASE_URL, DEFAULT_MODEL, "TypeSafe"));
308 assert!(cfg.user_agent.starts_with("typesafe-jev/"), "{}", cfg.user_agent);
309 assert_eq!((cfg.max_retries, cfg.max_backoff, cfg.pool_size, cfg.backoff_scale), (8, Duration::from_secs(30), 64, 1.0));
310 }
311}