Skip to main content

typesafe_jev/
client.rs

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
10/// Statuses worth another attempt: timeouts, conflicts, throttling and server-side trouble.
11const RETRYABLE: &[u16] = &[408, 409, 425, 429, 500, 502, 503, 504, 529];
12
13/// How a [`Client`] reaches the API and how hard it tries.
14///
15/// Build one with `Config { field: value, ..Config::default() }`. Every field has a default that
16/// suits TypeSafe's hosted endpoint.
17#[derive(Debug, Clone, PartialEq)]
18pub struct Config {
19    /// The endpoint every request is POSTed to. [`DEFAULT_BASE_URL`], or a gateway in front of
20    /// it. The URL is used as given: a token is sent to whatever host it names.
21    pub base_url: String,
22    /// The model name sent in every request. [`DEFAULT_MODEL`] follows TypeSafe's latest Jev.
23    pub model: String,
24    /// Who serves the endpoint, for error messages only: `TypeSafe`, or the name of a gateway.
25    pub provider: String,
26    /// The `User-Agent` header. Defaults to this crate's name and version.
27    pub user_agent: String,
28    /// Time allowed to open a connection.
29    pub connect_timeout: Duration,
30    /// Time allowed for each of sending the request, waiting for the response and reading it.
31    pub timeout: Duration,
32    /// Attempts after the first for transient failures; see [`Error`] for what is transient.
33    pub max_retries: u32,
34    /// The longest a retry's exponential backoff grows to. The backoff starts at half a second
35    /// and doubles per attempt, up to this cap, times a random factor in [0.5, 1.5).
36    pub max_backoff: Duration,
37    /// Connections kept open, and the ceiling of the concurrency [`AdaptiveLimiter`]. Set it to
38    /// the number of threads that will call [`Client::ask`] at once.
39    pub pool_size: usize,
40    /// How long every thread waits after a throttled reply (HTTP 429 or 529) before the next
41    /// attempt, times a random factor in [1, 2).
42    pub throttle_pause: Duration,
43    /// Multiplies the retry backoff and any `Retry-After` wait. Zero makes retries immediate,
44    /// which tests want; [`Config::throttle_pause`] is separate. A negative or non-finite scale
45    /// also means no wait.
46    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
69/// A connection to the System One API. One shared pool; call [`ask`](Client::ask) from as many
70/// threads as [`Config::pool_size`] allows.
71pub 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    /// A client that sends `api_key` as a bearer token to [`Config::base_url`] over HTTPS.
85    /// Whitespace around the key, such as the newline at the end of a key file, is dropped.
86    ///
87    /// Nothing is sent until the first [`ask`](Client::ask): a key the API rejects is reported
88    /// then, as [`Error::Auth`].
89    ///
90    /// # Errors
91    ///
92    /// What no request could be sent with is reported here rather than retried later:
93    /// [`Error::Auth`] for a blank key or one that cannot go in a header, and
94    /// [`Error::InvalidConfig`] for a [`Config::base_url`] that is not an absolute `http` or
95    /// `https` URL or a [`Config::user_agent`] that is not a valid header value.
96    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    /// A client that reads its API key from the [`API_KEY_VAR`] environment variable.
102    ///
103    /// # Errors
104    ///
105    /// [`Error::Auth`] if the variable is unset or blank; otherwise as [`Client::new`].
106    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    /// A client over any [`Transport`], for tests and local fakes. [`Config::base_url`],
112    /// [`Config::user_agent`] and the timeouts are the transport's business and are ignored.
113    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    /// Receives one line per failed attempt (the status or connection error, and any
128    /// `Retry-After`) before the client retries. Nothing is reported without a reporter, and
129    /// the reporter changes neither the retry policy nor the request.
130    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    /// The model name sent in every request.
135    pub fn model(&self) -> &str {
136        &self.model
137    }
138
139    /// Requests, retries and tokens so far.
140    pub fn usage(&self) -> &Usage {
141        &self.usage
142    }
143
144    /// The concurrency gate, for inspection.
145    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    /// A product that is negative, NaN or too large for a `Duration` is no wait, not a panic.
156    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    /// Evaluates `questions` against `state` and returns the `answers` map, keyed like the
164    /// questions.
165    ///
166    /// `state` is any JSON the questions refer to. Each question is an object with a `type`
167    /// (`noul` or `score`) and `instructions`; a `score` question also lists its `criteria`. Each
168    /// answer is an object with the same `type` and the model's judgment: a `noul` probability in
169    /// [0, 1], or a `score` with its `confidence` and `probabilities`. The crate documentation
170    /// shows both shapes.
171    ///
172    /// Transient failures are retried with exponential backoff up to [`Config::max_retries`]
173    /// times, honouring `Retry-After` (capped at 30 seconds) and the shared [`AdaptiveLimiter`].
174    /// A `Retry-After` given as a date rather than as seconds is ignored in favour of the backoff.
175    /// The call blocks the calling thread; run it from a thread pool for concurrency.
176    ///
177    /// # Errors
178    ///
179    /// [`Error::Auth`] for HTTP 401 and 403, and [`Error::TokenLimit`] for a request larger than
180    /// the model's context, both without a retry. [`Error::Api`] for any other status that is not
181    /// worth retrying, for a reply that is not the documented JSON, and once the retries are
182    /// spent; the message then ends with the last failure.
183    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
236/// The model, the retry policy and the counters. The transport is left out: it holds the key.
237impl 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
251/// One slot of the limiter, given back when dropped: a transport that panics must not leak it,
252/// or the threads that are left would wait for a slot that never frees.
253struct 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
271/// The key as the environment supplies it: trimmed, and required to be non-blank.
272fn 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}