Skip to main content

typesafe/
client.rs

1//! The asynchronous client.
2
3use std::future::{Future, IntoFuture};
4use std::pin::Pin;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use http::header::{
9    ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT,
10};
11use http::{Method, StatusCode};
12use serde::Serialize;
13use serde_json::{Map, Value};
14
15use crate::constants::*;
16use crate::error::{ApiError, Error, ResponseValidationError, Result, lenient_body};
17use crate::question::Questions;
18use crate::response::{
19    DecodeFailure, DecodedSystemOne, ListModelsResponse, ResponseMeta, SystemOneResponse,
20    decode_models, decode_system_one,
21};
22use crate::retry::RetryPolicy;
23
24type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
25
26/// Builder for [`Client`]. Explicit settings win over environment variables; empty or
27/// whitespace-only environment values are ignored.
28#[derive(Debug, Default)]
29pub struct ClientBuilder {
30    api_key: Option<String>,
31    base_url: Option<String>,
32    model: Option<String>,
33    timeout: Option<Duration>,
34    retry: Option<RetryPolicy>,
35    headers: HeaderMap,
36    http: Option<reqwest::Client>,
37}
38
39impl ClientBuilder {
40    /// API key (else `TYPESAFE_API_KEY`).
41    pub fn api_key(mut self, key: impl Into<String>) -> Self {
42        self.api_key = Some(key.into());
43        self
44    }
45
46    /// API root (else `TYPESAFE_BASE_URL`, else `https://api.typesafe.ai`).
47    pub fn base_url(mut self, url: impl Into<String>) -> Self {
48        self.base_url = Some(url.into());
49        self
50    }
51
52    /// Default model (else `TYPESAFE_DEFAULT_MODEL`, else `jev-latest`).
53    pub fn model(mut self, model: impl Into<String>) -> Self {
54        self.model = Some(model.into());
55        self
56    }
57
58    /// Per-attempt timeout (default 10s).
59    pub fn timeout(mut self, timeout: Duration) -> Self {
60        self.timeout = Some(timeout);
61        self
62    }
63
64    /// Default retry policy.
65    pub fn retry(mut self, policy: RetryPolicy) -> Self {
66        self.retry = Some(policy);
67        self
68    }
69
70    /// Extra header sent with every request. Authentication and SDK identification headers
71    /// cannot be overridden.
72    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
73        self.headers.insert(name, value);
74        self
75    }
76
77    /// Use your own `reqwest::Client` (proxies, TLS, connection pools…). Requires the
78    /// `reqwest-client` feature.
79    #[cfg(feature = "reqwest-client")]
80    pub fn http_client(mut self, client: reqwest::Client) -> Self {
81        self.http = Some(client);
82        self
83    }
84
85    /// Build the client.
86    pub fn build(self) -> Result<Client> {
87        let api_key = resolve(self.api_key, API_KEY_ENV, None).ok_or_else(|| {
88            Error::Config(format!(
89                "No API key was provided. Pass api_key or set the {API_KEY_ENV} environment variable."
90            ))
91        })?;
92        let base_url = resolve(self.base_url, BASE_URL_ENV, Some(DEFAULT_BASE_URL))
93            .unwrap_or_default()
94            .trim_end_matches('/')
95            .to_owned();
96        check_base_url(&base_url)?;
97        let model = resolve(self.model, DEFAULT_MODEL_ENV, Some(DEFAULT_MODEL)).unwrap_or_default();
98        let timeout = check_timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))?;
99        let retry = self.retry.unwrap_or_default();
100        retry.validate()?;
101
102        let mut auth = HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|_| {
103            Error::Config("The API key contains characters not allowed in a header.".into())
104        })?;
105        auth.set_sensitive(true);
106
107        let mut protected = HeaderMap::new();
108        protected.insert(AUTHORIZATION, auth);
109        protected.insert(ACCEPT, HeaderValue::from_static("application/json"));
110        let ident = HeaderValue::from_str(&format!("{SDK_NAME}/{VERSION}")).expect("ascii");
111        protected.insert(USER_AGENT, ident.clone());
112        protected.insert(HeaderName::from_static(SDK_HEADER), ident);
113        protected.insert(
114            HeaderName::from_static(RUNTIME_HEADER),
115            HeaderValue::from_str(&format!(
116                "rust ({}; {})",
117                std::env::consts::OS,
118                std::env::consts::ARCH
119            ))
120            .expect("ascii"),
121        );
122
123        Ok(Client {
124            inner: Arc::new(Inner {
125                http: self.http.unwrap_or_default(),
126                base_url,
127                model,
128                timeout,
129                retry,
130                default_headers: self.headers,
131                protected,
132            }),
133        })
134    }
135}
136
137fn resolve(explicit: Option<String>, env: &str, default: Option<&str>) -> Option<String> {
138    explicit
139        .or_else(|| {
140            std::env::var(env)
141                .ok()
142                .map(|v| v.trim().to_owned())
143                .filter(|v| !v.is_empty())
144        })
145        .or_else(|| default.map(str::to_owned))
146}
147
148fn check_base_url(url: &str) -> Result<()> {
149    match reqwest::Url::parse(url) {
150        Ok(u) if matches!(u.scheme(), "http" | "https") && u.has_host() => Ok(()),
151        Ok(_) => Err(Error::Config(format!(
152            "base_url must be an http(s) URL with a host, got {url:?}."
153        ))),
154        Err(e) => Err(Error::Config(format!(
155            "base_url {url:?} is not a valid URL: {e}."
156        ))),
157    }
158}
159
160fn check_timeout(t: Duration) -> Result<Duration> {
161    if t.is_zero() {
162        Err(Error::Config("timeout must be a positive duration.".into()))
163    } else {
164        Ok(t)
165    }
166}
167
168#[derive(Debug)]
169struct Inner {
170    http: reqwest::Client,
171    base_url: String,
172    model: String,
173    timeout: Duration,
174    retry: RetryPolicy,
175    default_headers: HeaderMap,
176    protected: HeaderMap,
177}
178
179/// TypeSafe API client. Cheap to clone; clones share the connection pool.
180///
181/// ```no_run
182/// use typesafe::{Client, Choice, Noul, Questions, Score};
183///
184/// # async fn run() -> typesafe::Result<()> {
185/// let client = Client::from_env()?;
186/// let res = client
187///     .system_one(
188///         "I've been trying to connect Stripe for 3 days. Please help ASAP.",
189///         Questions::new()
190///             .with("department", Choice::new("Which team should handle this")
191///                 .option("billing", "Payment or subscription issues")
192///                 .option("technical", "Bugs or integration problems"))
193///             .with("frustration", Score::new("How frustrated", ["Calm", "Frustrated", "Very angry"]))
194///             .with("is_urgent", Noul::new("The message conveys urgency")),
195///     )
196///     .await?;
197/// println!("{}", res.choice("department").unwrap().choice);
198/// # Ok(()) }
199/// ```
200#[derive(Debug, Clone)]
201pub struct Client {
202    inner: Arc<Inner>,
203}
204
205impl Client {
206    /// Start configuring a client.
207    pub fn builder() -> ClientBuilder {
208        ClientBuilder::default()
209    }
210
211    /// A client configured entirely from the environment.
212    pub fn from_env() -> Result<Self> {
213        Self::builder().build()
214    }
215
216    /// The default model.
217    pub fn default_model(&self) -> &str {
218        &self.inner.model
219    }
220
221    /// Ask typed questions about `state` (a string, or anything `Serialize` that becomes a JSON
222    /// object/array). Returns a request builder: `.await` it directly or set per-call options first.
223    pub fn system_one<S: Serialize>(
224        &self,
225        state: S,
226        questions: impl Into<Questions>,
227    ) -> SystemOneRequest {
228        SystemOneRequest {
229            client: self.clone(),
230            state: serde_json::to_value(state)
231                .map_err(|e| format!("The state could not be encoded as JSON: {e}")),
232            questions: questions.into(),
233            model: None,
234            extra_body: Map::new(),
235            opts: CallOptions::default(),
236        }
237    }
238
239    /// The Models resource.
240    pub fn models(&self) -> Models {
241        Models {
242            client: self.clone(),
243        }
244    }
245
246    async fn execute(
247        &self,
248        method: Method,
249        path: &str,
250        body: Option<Vec<u8>>,
251        opts: CallOptions,
252    ) -> Result<(Vec<u8>, ResponseMeta, String)> {
253        let inner = &self.inner;
254        let retry = opts.retry.as_ref().unwrap_or(&inner.retry);
255        retry.validate()?;
256        let timeout = check_timeout(opts.timeout.unwrap_or(inner.timeout))?;
257        let url = format!("{}{}", inner.base_url, path);
258        let endpoint = format!("{method} {}", redact_url(&url));
259
260        let mut headers = inner.default_headers.clone();
261        headers.extend(opts.headers);
262        headers.remove(RETRY_COUNT_HEADER);
263        headers.extend(inner.protected.clone());
264        if body.is_some() {
265            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
266        }
267
268        let started = Instant::now();
269        let mut attempts: u32 = 0;
270        loop {
271            let mut h = headers.clone();
272            if attempts > 0 {
273                h.insert(
274                    HeaderName::from_static(RETRY_COUNT_HEADER),
275                    HeaderValue::from(attempts),
276                );
277                tracing::info!(%endpoint, retry = attempts, "retrying");
278            }
279            attempts += 1;
280            let result = self
281                .attempt(&method, &url, &endpoint, h, body.clone(), timeout)
282                .await;
283            match result {
284                Ok((bytes, status, resp_headers)) => {
285                    let meta = ResponseMeta {
286                        status,
287                        headers: resp_headers,
288                        attempts,
289                    };
290                    return Ok((bytes, meta, endpoint));
291                }
292                Err(err) => {
293                    if !retry.is_retryable(&err) {
294                        return Err(err);
295                    }
296                    let delay = retry.delay(attempts, &err);
297                    if retry.should_stop(attempts, started.elapsed(), delay) {
298                        return Err(err);
299                    }
300                    if !delay.is_zero() {
301                        tokio::time::sleep(delay).await;
302                    }
303                }
304            }
305        }
306    }
307
308    async fn attempt(
309        &self,
310        method: &Method,
311        url: &str,
312        endpoint: &str,
313        headers: HeaderMap,
314        body: Option<Vec<u8>>,
315        timeout: Duration,
316    ) -> Result<(Vec<u8>, u16, HeaderMap)> {
317        let t0 = Instant::now();
318        tracing::debug!(%endpoint, "->");
319        if tracing::enabled!(tracing::Level::TRACE) {
320            tracing::trace!(%endpoint, headers = ?redacted(&headers),
321                body = %body.as_deref().map(String::from_utf8_lossy).unwrap_or_default(), "->");
322        }
323        let mut req = self
324            .inner
325            .http
326            .request(method.clone(), url)
327            .headers(headers)
328            .timeout(timeout);
329        if let Some(b) = body {
330            req = req.body(b);
331        }
332        let map_err = |e: reqwest::Error| {
333            tracing::debug!(%endpoint, error = %e, "<- transport error");
334            if e.is_timeout() {
335                Error::Timeout(timeout)
336            } else {
337                Error::Connection(Box::new(e))
338            }
339        };
340        let resp = req.send().await.map_err(map_err)?;
341        let status = resp.status();
342        let resp_headers = resp.headers().clone();
343        let bytes = resp.bytes().await.map_err(map_err)?;
344
345        tracing::debug!(
346            %endpoint,
347            status = status.as_u16(),
348            elapsed_ms = t0.elapsed().as_millis() as u64,
349            request_id = resp_headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()).unwrap_or("-"),
350            "<-"
351        );
352        if tracing::enabled!(tracing::Level::TRACE) {
353            tracing::trace!(%endpoint, headers = ?redacted(&resp_headers),
354                body = %String::from_utf8_lossy(&bytes), "<-");
355        }
356
357        if !status.is_success() {
358            return Err(Error::Api(Box::new(ApiError::new(
359                status.as_u16(),
360                lenient_body(&bytes),
361                resp_headers,
362                Some(endpoint.to_owned()),
363            ))));
364        }
365        Ok((bytes.to_vec(), status.as_u16(), resp_headers))
366    }
367}
368
369fn validation_error(
370    status: StatusCode,
371    body: Option<Value>,
372    headers: HeaderMap,
373    endpoint: &str,
374    f: DecodeFailure,
375) -> Error {
376    Error::ResponseValidation(Box::new(ResponseValidationError {
377        status: status.as_u16(),
378        field_path: f.path,
379        detail: f.detail,
380        body,
381        headers,
382        endpoint: Some(endpoint.to_owned()),
383    }))
384}
385
386fn redact_url(url: &str) -> String {
387    match reqwest::Url::parse(url) {
388        Ok(mut u) => {
389            let _ = u.set_username("");
390            let _ = u.set_password(None);
391            u.set_query(None);
392            u.set_fragment(None);
393            u.to_string()
394        }
395        Err(_) => url.to_owned(),
396    }
397}
398
399fn redacted(headers: &HeaderMap) -> Vec<(String, String)> {
400    headers
401        .iter()
402        .map(|(k, v)| {
403            let value = if SECRET_HEADERS.contains(&k.as_str()) {
404                "[REDACTED]".to_owned()
405            } else {
406                v.to_str().unwrap_or("<binary>").to_owned()
407            };
408            (k.as_str().to_owned(), value)
409        })
410        .collect()
411}
412
413#[derive(Debug, Default, Clone)]
414struct CallOptions {
415    retry: Option<RetryPolicy>,
416    timeout: Option<Duration>,
417    headers: HeaderMap,
418}
419
420macro_rules! call_option_methods {
421    () => {
422        /// Override the retry policy for this call.
423        pub fn retry(mut self, policy: RetryPolicy) -> Self {
424            self.opts.retry = Some(policy);
425            self
426        }
427
428        /// Override the per-attempt timeout for this call.
429        pub fn timeout(mut self, timeout: Duration) -> Self {
430            self.opts.timeout = Some(timeout);
431            self
432        }
433
434        /// Add a header for this call (protected headers still win).
435        pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
436            self.opts.headers.insert(name, value);
437            self
438        }
439    };
440}
441
442/// A pending `POST /v1/systemone`. Configure it, then `.await` it (or call [`send`](Self::send)).
443#[must_use = "requests do nothing until awaited"]
444#[derive(Debug)]
445pub struct SystemOneRequest {
446    client: Client,
447    state: std::result::Result<Value, String>,
448    questions: Questions,
449    model: Option<String>,
450    extra_body: Map<String, Value>,
451    opts: CallOptions,
452}
453
454impl SystemOneRequest {
455    call_option_methods!();
456
457    /// Override the model for this call.
458    pub fn model(mut self, model: impl Into<String>) -> Self {
459        self.model = Some(model.into());
460        self
461    }
462
463    /// Add a top-level body field. A key named `state`, `model` or `questions` replaces the
464    /// standard field. Useful for API fields this SDK version does not model yet.
465    pub fn extra_body(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
466        self.extra_body.insert(key.into(), value.into());
467        self
468    }
469
470    /// Send the request.
471    pub async fn send(self) -> Result<SystemOneResponse> {
472        let state = self.state.map_err(Error::InvalidRequest)?;
473        self.questions.validate()?;
474        let model = self
475            .model
476            .unwrap_or_else(|| self.client.inner.model.clone());
477        // Serialized as a struct (not via `serde_json::Value`) so question order reaches the wire
478        // unchanged. `extra_body` keys replace the standard field of the same name.
479        let extra = &self.extra_body;
480        let body = SystemOneBody {
481            state: (!extra.contains_key("state")).then_some(&state),
482            model: (!extra.contains_key("model")).then_some(&model),
483            questions: (!extra.contains_key("questions")).then_some(&self.questions),
484            extra,
485        };
486        let bytes = serde_json::to_vec(&body).map_err(|e| {
487            Error::InvalidRequest(format!(
488                "The request body could not be encoded as JSON: {e}"
489            ))
490        })?;
491
492        let (bytes, meta, endpoint) = self
493            .client
494            .execute(Method::POST, SYSTEM_ONE_PATH, Some(bytes), self.opts)
495            .await?;
496        match decode_system_one(&bytes) {
497            Ok(DecodedSystemOne {
498                model,
499                usage,
500                answers,
501                raw,
502            }) => Ok(SystemOneResponse {
503                model,
504                usage,
505                answers,
506                raw,
507                meta,
508            }),
509            Err(f) => Err(validation_error(
510                StatusCode::from_u16(meta.status).unwrap_or(StatusCode::OK),
511                lenient_body(&bytes),
512                meta.headers,
513                &endpoint,
514                f,
515            )),
516        }
517    }
518}
519
520#[derive(Serialize)]
521struct SystemOneBody<'a> {
522    #[serde(skip_serializing_if = "Option::is_none")]
523    state: Option<&'a Value>,
524    #[serde(skip_serializing_if = "Option::is_none")]
525    model: Option<&'a str>,
526    #[serde(skip_serializing_if = "Option::is_none")]
527    questions: Option<&'a Questions>,
528    #[serde(flatten)]
529    extra: &'a Map<String, Value>,
530}
531
532impl IntoFuture for SystemOneRequest {
533    type Output = Result<SystemOneResponse>;
534    type IntoFuture = BoxFuture<'static, Self::Output>;
535
536    fn into_future(self) -> Self::IntoFuture {
537        Box::pin(self.send())
538    }
539}
540
541/// The Models resource.
542#[derive(Debug, Clone)]
543pub struct Models {
544    client: Client,
545}
546
547impl Models {
548    /// `GET /v1/models`.
549    pub fn list(&self) -> ListModelsRequest {
550        ListModelsRequest {
551            client: self.client.clone(),
552            opts: CallOptions::default(),
553        }
554    }
555}
556
557/// A pending `GET /v1/models`.
558#[must_use = "requests do nothing until awaited"]
559#[derive(Debug)]
560pub struct ListModelsRequest {
561    client: Client,
562    opts: CallOptions,
563}
564
565impl ListModelsRequest {
566    call_option_methods!();
567
568    /// Send the request.
569    pub async fn send(self) -> Result<ListModelsResponse> {
570        let (bytes, meta, endpoint) = self
571            .client
572            .execute(Method::GET, MODELS_PATH, None, self.opts)
573            .await?;
574        match decode_models(&bytes) {
575            Ok((models, raw)) => Ok(ListModelsResponse { models, raw, meta }),
576            Err(f) => Err(validation_error(
577                StatusCode::from_u16(meta.status).unwrap_or(StatusCode::OK),
578                lenient_body(&bytes),
579                meta.headers,
580                &endpoint,
581                f,
582            )),
583        }
584    }
585}
586
587impl IntoFuture for ListModelsRequest {
588    type Output = Result<ListModelsResponse>;
589    type IntoFuture = BoxFuture<'static, Self::Output>;
590
591    fn into_future(self) -> Self::IntoFuture {
592        Box::pin(self.send())
593    }
594}