1use std::fmt;
2use std::time::Duration;
3
4use serde_json::{json, Map, Value};
5
6use crate::limiter::{jitter, AdaptiveLimiter};
7use crate::transport::{Http, Transport};
8use crate::{Error, 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(&self, state: &Value, questions: &Map<String, Value>) -> Result<Map<String, Value>, Error> {
184 let body = serde_json::to_vec(&json!({"model": self.model, "state": state, "questions": questions}))
185 .map_err(|e| Error::Api(e.to_string()))?;
186 let mut last = String::from("unknown error");
187 for attempt in 0..=self.max_retries {
188 if attempt > 0 {
189 self.usage.record_retry();
190 let doublings = (attempt - 1).min(32) as i32;
191 self.sleep((0.5 * 2f64.powi(doublings)).min(self.max_backoff.as_secs_f64()) * (0.5 + jitter()));
192 }
193 let sent = {
194 let mut slot = Slot::acquire(&self.limiter);
195 let sent = self.transport.post(&body);
196 slot.throttled = matches!(&sent, Ok(r) if r.status == 429 || r.status == 529);
197 sent
198 };
199 let reply = match sent {
200 Ok(reply) => reply,
201 Err(e) => {
202 self.debug(&format!("attempt {}: {e}", attempt + 1));
203 last = e;
204 continue;
205 }
206 };
207 if reply.status == 200 {
208 let mut data: Value = serde_json::from_str(&reply.body).map_err(|e| Error::Api(format!("unreadable response: {e}")))?;
209 self.usage.record_reported(data.get("usage"));
210 return match data.get_mut("answers").map(Value::take) {
211 Some(Value::Object(answers)) => Ok(answers),
212 _ => Err(Error::Api("response has no `answers` object".into())),
213 };
214 }
215 let text: String = reply.body.chars().take(400).collect();
216 if reply.status == 401 || reply.status == 403 {
217 return Err(Error::Auth(format!("{} rejected the API key (HTTP {}): {text}", self.provider, reply.status)));
218 }
219 if text.contains("max_tokens_exceeded") || reply.status == 413 {
220 return Err(Error::TokenLimit(text));
221 }
222 if RETRYABLE.contains(&reply.status) {
223 last = format!("HTTP {}: {text}", reply.status);
224 self.debug(&format!("attempt {}: {last} retry-after={:?}", attempt + 1, reply.retry_after));
225 if let Some(seconds) = reply.retry_after.and_then(|v| v.trim().parse::<f64>().ok()) {
226 self.sleep(seconds.clamp(0.0, 30.0));
227 }
228 continue;
229 }
230 return Err(Error::Api(format!("HTTP {}: {text}", reply.status)));
231 }
232 Err(Error::Api(format!("gave up after {} retries: {last}", self.max_retries)))
233 }
234}
235
236impl fmt::Debug for Client {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 f.debug_struct("Client")
240 .field("model", &self.model)
241 .field("provider", &self.provider)
242 .field("max_retries", &self.max_retries)
243 .field("max_backoff", &self.max_backoff)
244 .field("backoff_scale", &self.backoff_scale)
245 .field("usage", &self.usage)
246 .field("limiter", &self.limiter)
247 .finish_non_exhaustive()
248 }
249}
250
251struct Slot<'a> {
254 limiter: &'a AdaptiveLimiter,
255 throttled: bool,
256}
257
258impl<'a> Slot<'a> {
259 fn acquire(limiter: &'a AdaptiveLimiter) -> Self {
260 limiter.acquire();
261 Slot { limiter, throttled: false }
262 }
263}
264
265impl Drop for Slot<'_> {
266 fn drop(&mut self) {
267 self.limiter.release(self.throttled);
268 }
269}
270
271fn api_key_from(value: Option<&str>) -> Result<String, Error> {
273 match value.map(str::trim) {
274 Some(key) if !key.is_empty() => Ok(key.to_owned()),
275 _ => Err(Error::Auth(format!("{API_KEY_VAR} is not set"))),
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn the_environment_key_is_trimmed_and_must_not_be_blank() {
285 assert_eq!(api_key_from(Some(" sk-1 \n")).unwrap(), "sk-1");
286 for missing in [None, Some(""), Some(" ")] {
287 let error = api_key_from(missing).unwrap_err();
288 assert!(matches!(&error, Error::Auth(m) if m.contains(API_KEY_VAR)), "{missing:?} -> {error:?}");
289 }
290 }
291
292 #[test]
293 fn defaults_point_at_typesafe() {
294 let cfg = Config::default();
295 assert_eq!((cfg.base_url.as_str(), cfg.model.as_str(), cfg.provider.as_str()), (DEFAULT_BASE_URL, DEFAULT_MODEL, "TypeSafe"));
296 assert!(cfg.user_agent.starts_with("typesafe-jev/"), "{}", cfg.user_agent);
297 assert_eq!((cfg.max_retries, cfg.max_backoff, cfg.pool_size, cfg.backoff_scale), (8, Duration::from_secs(30), 64, 1.0));
298 }
299}