Skip to main content

typesafe_systemone/
client.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::sync::Arc;
4use std::time::Duration;
5
6use serde::Serialize;
7use serde_json::Value;
8
9use crate::answer::{ModelInfo, ModelsResponse, SystemOneResponse};
10use crate::error::{Error, Result};
11use crate::question::Question;
12use crate::request::SystemOneRequest;
13use crate::retry::RetryPolicy;
14
15/// Production API root.
16pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
17/// Model used when none is configured: the latest stable Jev release.
18pub const DEFAULT_MODEL: &str = "jev-latest";
19const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20const MAX_ERROR_BODY: usize = 2048;
21
22const ENV_API_KEY: &str = "TYPESAFE_API_KEY";
23const ENV_MODEL: &str = "TYPESAFE_DEFAULT_MODEL";
24const ENV_BASE_URL: &str = "TYPESAFE_BASE_URL";
25
26struct Inner {
27    http: reqwest::Client,
28    api_key: String,
29    base_url: String,
30    model: String,
31    retry: RetryPolicy,
32    timeout: Duration,
33}
34
35/// Client for the TypeSafe System One API. Cheap to clone; shares one HTTP pool.
36#[derive(Clone)]
37pub struct Client {
38    inner: Arc<Inner>,
39}
40
41impl fmt::Debug for Client {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.debug_struct("Client")
44            .field("base_url", &self.inner.base_url)
45            .field("model", &self.inner.model)
46            .field("retry", &self.inner.retry)
47            .field("timeout", &self.inner.timeout)
48            .finish_non_exhaustive()
49    }
50}
51
52/// Builder for [`Client`]. Explicit values win over environment variables.
53#[derive(Default)]
54#[must_use = "call `.build()` to get a Client"]
55pub struct ClientBuilder {
56    api_key: Option<String>,
57    base_url: Option<String>,
58    model: Option<String>,
59    retry: Option<RetryPolicy>,
60    timeout: Option<Duration>,
61    http: Option<reqwest::Client>,
62}
63
64impl fmt::Debug for ClientBuilder {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.debug_struct("ClientBuilder")
67            .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
68            .field("base_url", &self.base_url)
69            .field("model", &self.model)
70            .field("retry", &self.retry)
71            .field("timeout", &self.timeout)
72            .finish_non_exhaustive()
73    }
74}
75
76impl ClientBuilder {
77    /// API key. Falls back to `TYPESAFE_API_KEY`.
78    pub fn api_key(mut self, key: impl Into<String>) -> Self {
79        self.api_key = Some(key.into());
80        self
81    }
82
83    /// API root without trailing slash. Falls back to `TYPESAFE_BASE_URL`, then
84    /// [`DEFAULT_BASE_URL`].
85    pub fn base_url(mut self, url: impl Into<String>) -> Self {
86        self.base_url = Some(url.into());
87        self
88    }
89
90    /// Default model for [`Client::system_one`]. Falls back to `TYPESAFE_DEFAULT_MODEL`,
91    /// then [`DEFAULT_MODEL`]. Pin a versioned id (e.g. `jev-1.13.0`) once thresholds are tuned.
92    pub fn model(mut self, model: impl Into<String>) -> Self {
93        self.model = Some(model.into());
94        self
95    }
96
97    /// Retry policy. Defaults to [`RetryPolicy::default`].
98    pub fn retry(mut self, retry: RetryPolicy) -> Self {
99        self.retry = Some(retry);
100        self
101    }
102
103    /// Per-request timeout. Defaults to 30 seconds.
104    pub fn timeout(mut self, timeout: Duration) -> Self {
105        self.timeout = Some(timeout);
106        self
107    }
108
109    /// Reuse an existing `reqwest::Client` (TLS backend, proxies, pools). When set, this
110    /// crate's own TLS features are irrelevant.
111    pub fn http_client(mut self, http: reqwest::Client) -> Self {
112        self.http = Some(http);
113        self
114    }
115
116    /// Build the client.
117    ///
118    /// # Errors
119    ///
120    /// [`Error::Config`] when no API key is set (builder or `TYPESAFE_API_KEY`), when the
121    /// default HTTP client cannot be built, or when this crate was compiled without a TLS
122    /// backend (`default-features = false`, neither `rustls` nor `native-tls`) and no
123    /// [`http_client`](Self::http_client) was supplied to stand in for one.
124    pub fn build(self) -> Result<Client> {
125        let api_key = self
126            .api_key
127            .filter(|k| !k.trim().is_empty())
128            .or_else(|| env_non_empty(ENV_API_KEY))
129            .ok_or_else(|| Error::Config(format!("no API key: pass ClientBuilder::api_key or set {ENV_API_KEY}")))?;
130        let base_url = self
131            .base_url
132            .or_else(|| env_non_empty(ENV_BASE_URL))
133            .unwrap_or_else(|| DEFAULT_BASE_URL.to_owned())
134            .trim_end_matches('/')
135            .to_owned();
136        let model = self
137            .model
138            .or_else(|| env_non_empty(ENV_MODEL))
139            .unwrap_or_else(|| DEFAULT_MODEL.to_owned());
140        let http = match self.http {
141            Some(h) => h,
142            None if cfg!(not(any(feature = "rustls", feature = "native-tls"))) => {
143                return Err(Error::Config(
144                    "no TLS backend: enable the `rustls` or `native-tls` feature, or pass a reqwest::Client via \
145                     ClientBuilder::http_client"
146                        .to_string(),
147                ));
148            }
149            None => reqwest::Client::builder()
150                .build()
151                .map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?,
152        };
153        Ok(Client {
154            inner: Arc::new(Inner {
155                http,
156                api_key,
157                base_url,
158                model,
159                retry: self.retry.unwrap_or_default(),
160                timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
161            }),
162        })
163    }
164}
165
166fn env_non_empty(name: &str) -> Option<String> {
167    std::env::var(name)
168        .ok()
169        .map(|v| v.trim().to_owned())
170        .filter(|v| !v.is_empty())
171}
172
173#[derive(Serialize)]
174struct SystemOneBody<'a> {
175    state: &'a Value,
176    model: &'a str,
177    questions: &'a BTreeMap<String, Question>,
178}
179
180impl Client {
181    /// Start configuring a client.
182    pub fn builder() -> ClientBuilder {
183        ClientBuilder::default()
184    }
185
186    /// Client configured entirely from `TYPESAFE_API_KEY`, `TYPESAFE_DEFAULT_MODEL` and
187    /// `TYPESAFE_BASE_URL`.
188    ///
189    /// # Errors
190    ///
191    /// Same as [`ClientBuilder::build`]; in practice a missing `TYPESAFE_API_KEY`.
192    pub fn from_env() -> Result<Self> {
193        Self::builder().build()
194    }
195
196    /// The model used when [`Client::system_one`] is called.
197    #[must_use]
198    pub fn default_model(&self) -> &str {
199        &self.inner.model
200    }
201
202    /// Start building a `POST /v1/systemone` call: state, questions, then `.send()`.
203    pub fn system_one(&self) -> SystemOneRequest<'_> {
204        SystemOneRequest::new(self)
205    }
206
207    /// Evaluate `state` against an existing map of `questions` with the client's default
208    /// model. The builder form is [`Client::system_one`].
209    ///
210    /// Questions are keyed by the ids you choose; answers come back under the same ids.
211    /// All questions see the same state and are evaluated independently.
212    ///
213    /// # Errors
214    ///
215    /// [`Error::RequestSerialization`] if `state` cannot be serialised; an HTTP-status
216    /// variant ([`Error::Authentication`], [`Error::UnprocessableEntity`], [`Error::RateLimit`],
217    /// …) for a non-2xx answer after retries; [`Error::Connection`] / [`Error::Timeout`] for
218    /// transport failures; [`Error::ResponseValidation`] if the body is not the documented
219    /// shape.
220    pub async fn evaluate<K>(
221        &self,
222        state: impl Serialize,
223        questions: impl IntoIterator<Item = (K, Question)>,
224    ) -> Result<SystemOneResponse>
225    where
226        K: Into<String>,
227    {
228        self.evaluate_with_model(&self.inner.model, state, questions).await
229    }
230
231    /// [`Client::evaluate`] with an explicit `model`.
232    ///
233    /// # Errors
234    ///
235    /// As [`Client::evaluate`].
236    pub async fn evaluate_with_model<K>(
237        &self,
238        model: &str,
239        state: impl Serialize,
240        questions: impl IntoIterator<Item = (K, Question)>,
241    ) -> Result<SystemOneResponse>
242    where
243        K: Into<String>,
244    {
245        let state = serde_json::to_value(state).map_err(Error::RequestSerialization)?;
246        let questions: BTreeMap<String, Question> = questions.into_iter().map(|(k, q)| (k.into(), q)).collect();
247        let body = serde_json::to_vec(&SystemOneBody {
248            state: &state,
249            model,
250            questions: &questions,
251        })
252        .map_err(Error::RequestSerialization)?;
253        let bytes = self.send(reqwest::Method::POST, "/v1/systemone", Some(body)).await?;
254        serde_json::from_slice(&bytes).map_err(Error::ResponseValidation)
255    }
256
257    /// List the model names and aliases this account may send in the `model` field.
258    ///
259    /// # Errors
260    ///
261    /// An HTTP-status variant for a non-2xx answer after retries, [`Error::Connection`] /
262    /// [`Error::Timeout`] for transport failures, [`Error::ResponseValidation`] for an
263    /// unexpected body.
264    pub async fn models(&self) -> Result<Vec<ModelInfo>> {
265        let bytes = self.send(reqwest::Method::GET, "/v1/models", None).await?;
266        let parsed: ModelsResponse = serde_json::from_slice(&bytes).map_err(Error::ResponseValidation)?;
267        Ok(parsed.models)
268    }
269
270    async fn send(&self, method: reqwest::Method, path: &str, body: Option<Vec<u8>>) -> Result<Vec<u8>> {
271        let url = format!("{}{}", self.inner.base_url, path);
272        let mut retry = 0u32;
273        loop {
274            let mut req = self
275                .inner
276                .http
277                .request(method.clone(), &url)
278                .bearer_auth(&self.inner.api_key)
279                .timeout(self.inner.timeout);
280            if let Some(b) = &body {
281                req = req
282                    .header(reqwest::header::CONTENT_TYPE, "application/json")
283                    .body(b.clone());
284            }
285
286            let outcome = match req.send().await {
287                Ok(resp) => {
288                    let status = resp.status().as_u16();
289                    if (200..300).contains(&status) {
290                        return resp.bytes().await.map(|b| b.to_vec()).map_err(Error::from_transport);
291                    }
292                    let retry_after = parse_retry_after(resp.headers());
293                    let message = read_error_body(resp).await;
294                    let err = Error::from_status(status, retry_after, message);
295                    if RetryPolicy::is_retryable_status(status) {
296                        Err((err, retry_after))
297                    } else {
298                        return Err(err);
299                    }
300                }
301                Err(e) => Err((Error::from_transport(e), None)),
302            };
303
304            let (err, retry_after) = match outcome {
305                Ok(never) => never,
306                Err(pair) => pair,
307            };
308            if retry >= self.inner.retry.max_retries {
309                return Err(err);
310            }
311            retry += 1;
312            tokio::time::sleep(self.inner.retry.delay(retry, retry_after)).await;
313        }
314    }
315}
316
317fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
318    headers
319        .get(reqwest::header::RETRY_AFTER)?
320        .to_str()
321        .ok()?
322        .trim()
323        .parse::<f64>()
324        .ok()
325        .filter(|s| s.is_finite() && *s >= 0.0)
326        .map(Duration::from_secs_f64)
327}
328
329async fn read_error_body(resp: reqwest::Response) -> String {
330    let text = resp.text().await.unwrap_or_default();
331    let message = serde_json::from_str::<Value>(&text)
332        .ok()
333        .and_then(|v| {
334            ["detail", "message", "error"].into_iter().find_map(|k| {
335                v.get(k)
336                    .map(|m| m.as_str().map_or_else(|| m.to_string(), str::to_owned))
337            })
338        })
339        .unwrap_or(text);
340    if message.len() > MAX_ERROR_BODY {
341        let mut end = MAX_ERROR_BODY;
342        while !message.is_char_boundary(end) {
343            end -= 1;
344        }
345        format!("{}…", &message[..end])
346    } else {
347        message
348    }
349}