Skip to main content

typesafe_rs/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use bytes::Bytes;
5use http::{HeaderMap, HeaderValue, Method, header};
6use serde::Serialize;
7use url::Url;
8
9use crate::config::{
10    CallOptions, ClientConfig, DEFAULT_BASE_URL, DEFAULT_MODEL, strip_trailing_slashes,
11};
12use crate::error::{ApiError, Error, parse_error_body};
13use crate::headers::{
14    merge_user_headers, request_id, retry_count_value, runtime_header, sdk_header, user_agent,
15};
16use crate::retry::RetryPolicy;
17use crate::transport::{HttpTransport, RawResponse, join_endpoint};
18use crate::types::{
19    ListModelsResponse, ModelCard, Questions, ResponseMeta, SystemOneRequest, SystemOneResponse,
20    validate_questions,
21};
22
23#[derive(Debug)]
24struct Inner {
25    http: HttpTransport,
26    api_key: crate::config::SecretString,
27    base_url: Url,
28    default_model: String,
29    timeout: Duration,
30    retry: RetryPolicy,
31    default_headers: HeaderMap,
32}
33
34/// Asynchronous TypeSafe System One client.
35///
36/// Cheap to clone (`Arc` internally) and safe to share across tasks (`Send + Sync`).
37///
38/// # Examples
39///
40/// ```no_run
41/// use typesafe_rs::{questions, Client, ClientConfig, Question};
42///
43/// # async fn run() -> typesafe_rs::Result<()> {
44/// let client = ClientConfig::new().api_key("sk-...").build()?;
45/// let response = client
46///     .system_one(
47///         "Help! My payouts have been failing for 3 days.",
48///         questions! { "urgent" => Question::noul("Does this convey urgency?") },
49///     )
50///     .await?;
51/// assert!(response.noul("urgent").is_some());
52/// # Ok(())
53/// # }
54/// ```
55#[derive(Clone, Debug)]
56pub struct Client {
57    inner: Arc<Inner>,
58}
59
60impl Client {
61    /// Build a client. Unset fields fall back to the process environment, then defaults.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`Error::MissingApiKey`] when no key is configured, or
66    /// [`Error::InvalidRequest`] for invalid timeout / retry / URL values.
67    pub fn new(config: ClientConfig) -> Result<Self, Error> {
68        Self::new_with_env(config, |key| std::env::var(key).ok())
69    }
70
71    /// [`Self::new`] using `TYPESAFE_*` from the process environment.
72    pub fn from_env() -> Result<Self, Error> {
73        Self::new(ClientConfig::default())
74    }
75
76    /// Like [`Self::new`], but environment fallbacks come from `lookup`.
77    ///
78    /// Intended for tests and embeddings that should not read process env.
79    pub fn new_with_env(
80        mut config: ClientConfig,
81        lookup: impl FnMut(&str) -> Option<String>,
82    ) -> Result<Self, Error> {
83        config.overlay_env(lookup)?;
84        if config.timeout.is_zero() {
85            return Err(Error::InvalidRequest(
86                "`timeout` must be a positive duration".to_owned(),
87            ));
88        }
89        config.retry.validate()?;
90        let api_key = config.api_key.ok_or(Error::MissingApiKey)?;
91        let base_url =
92            strip_trailing_slashes(config.base_url.unwrap_or_else(|| {
93                Url::parse(DEFAULT_BASE_URL).expect("default base URL is valid")
94            }));
95        let default_model = config
96            .default_model
97            .filter(|m| !m.trim().is_empty())
98            .unwrap_or_else(|| DEFAULT_MODEL.to_owned());
99        Ok(Self {
100            inner: Arc::new(Inner {
101                http: HttpTransport::new()?,
102                api_key,
103                base_url,
104                default_model,
105                timeout: config.timeout,
106                retry: config.retry,
107                default_headers: config.default_headers,
108            }),
109        })
110    }
111
112    /// Default model used when a request omits `model`.
113    #[must_use]
114    pub fn default_model(&self) -> &str {
115        &self.inner.default_model
116    }
117
118    /// Resolved API root, without a trailing slash.
119    #[must_use]
120    pub fn base_url(&self) -> &Url {
121        &self.inner.base_url
122    }
123
124    /// Evaluate `state` against `questions` using client defaults.
125    ///
126    /// `state` may be a string, object, or array. Question keys are returned on
127    /// [`SystemOneResponse::answers`](crate::SystemOneResponse::answers).
128    ///
129    /// # Errors
130    ///
131    /// Returns [`Error::InvalidRequest`] before any network call when the
132    /// question map is empty or a Choice/Score is under-specified. Network and
133    /// API failures use the rest of [`Error`].
134    pub async fn system_one(
135        &self,
136        state: impl Serialize,
137        questions: Questions,
138    ) -> Result<SystemOneResponse, Error> {
139        let state = serde_json::to_value(state).map_err(|err| {
140            Error::InvalidRequest(format!("state is not JSON-serializable: {err}"))
141        })?;
142        let req = SystemOneRequest::new(state, questions);
143        self.system_one_with(&req, CallOptions::default()).await
144    }
145
146    /// Evaluate a fully specified request with per-call options.
147    pub async fn system_one_with(
148        &self,
149        req: &SystemOneRequest,
150        opts: CallOptions,
151    ) -> Result<SystemOneResponse, Error> {
152        validate_questions(&req.questions)?;
153        let model = opts
154            .model
155            .as_deref()
156            .filter(|m| !m.is_empty())
157            .or_else(|| {
158                if req.model.trim().is_empty() {
159                    None
160                } else {
161                    Some(req.model.as_str())
162                }
163            })
164            .unwrap_or(self.inner.default_model.as_str())
165            .to_owned();
166        let payload = serde_json::json!({
167            "state": req.state,
168            "model": model,
169            "questions": req.questions,
170        });
171        let body =
172            Bytes::from(serde_json::to_vec(&payload).map_err(|err| {
173                Error::InvalidRequest(format!("failed to serialize request: {err}"))
174            })?);
175
176        let raw = {
177            let fut = self.execute(Method::POST, "/v1/systemone", Some(body), &opts);
178            #[cfg(feature = "tracing")]
179            {
180                use tracing::Instrument;
181                let span = tracing::info_span!(
182                    "typesafe.request",
183                    http.request.method = "POST",
184                    url.path = "/v1/systemone",
185                    typesafe.model = model.as_str(),
186                    typesafe.questions.count = req.questions.len(),
187                );
188                fut.instrument(span).await?
189            }
190            #[cfg(not(feature = "tracing"))]
191            {
192                fut.await?
193            }
194        };
195        let mut parsed: SystemOneResponse = decode_json(&raw, "/v1/systemone")?;
196        parsed.meta = meta_from_raw(&raw);
197        Ok(parsed)
198    }
199
200    /// Access the Models resource.
201    #[must_use]
202    pub fn models(&self) -> Models<'_> {
203        Models { client: self }
204    }
205
206    /// Establish a pooled connection by calling `GET /v1/models`.
207    pub async fn warm_up(&self) -> Result<(), Error> {
208        let raw = self
209            .execute(Method::GET, "/v1/models", None, &CallOptions::default())
210            .await?;
211        let _ = raw;
212        Ok(())
213    }
214
215    async fn execute(
216        &self,
217        method: Method,
218        path: &'static str,
219        body: Option<Bytes>,
220        opts: &CallOptions,
221    ) -> Result<RawResponse, Error> {
222        let timeout = opts.timeout.unwrap_or(self.inner.timeout);
223        if timeout.is_zero() {
224            return Err(Error::InvalidRequest(
225                "`timeout` must be a positive duration".to_owned(),
226            ));
227        }
228        let retry = opts
229            .retry
230            .clone()
231            .unwrap_or_else(|| self.inner.retry.clone());
232        retry.validate()?;
233
234        let url = join_endpoint(&self.inner.base_url, path)?;
235        let mut attempt = 0_u32;
236        loop {
237            let retries_left = retry.max_retries.saturating_sub(attempt);
238            let headers = self.attempt_headers(opts, body.is_some(), attempt);
239            match self
240                .inner
241                .http
242                .send(method.clone(), url.clone(), headers, body.clone(), timeout)
243                .await
244            {
245                Ok(mut raw) if raw.status.is_success() => {
246                    raw.attempts = attempt + 1;
247                    return Ok(raw);
248                }
249                Ok(raw) => {
250                    let retryable = retry.http_statuses.contains_status(raw.status);
251                    if retries_left > 0 && retryable {
252                        let delay = retry.delay_after_failure(attempt, Some(&raw.headers));
253                        emit_retry(attempt, delay, &raw.status.to_string());
254                        tokio::time::sleep(delay).await;
255                        attempt += 1;
256                        continue;
257                    }
258                    let body = parse_error_body(&raw.body);
259                    return Err(Error::Api(Box::new(ApiError::from_response(
260                        raw.status,
261                        body,
262                        raw.headers,
263                        path,
264                        attempt + 1,
265                    ))));
266                }
267                Err(err) => {
268                    let retryable = match &err {
269                        Error::Timeout { .. } => retry.retry_timeouts,
270                        Error::Connection(te) => retry.retries_connection(te.is_pre_send()),
271                        _ => false,
272                    };
273                    if retries_left > 0 && retryable {
274                        let delay = retry.delay_after_failure(attempt, None);
275                        emit_retry(attempt, delay, &err.to_string());
276                        tokio::time::sleep(delay).await;
277                        attempt += 1;
278                        continue;
279                    }
280                    return Err(err);
281                }
282            }
283        }
284    }
285
286    fn attempt_headers(&self, opts: &CallOptions, has_body: bool, attempt: u32) -> HeaderMap {
287        let mut headers = HeaderMap::new();
288        merge_user_headers(&mut headers, &self.inner.default_headers);
289        merge_user_headers(&mut headers, &opts.headers);
290        headers.insert(
291            header::AUTHORIZATION,
292            HeaderValue::from_str(&format!("Bearer {}", self.inner.api_key.expose()))
293                .unwrap_or_else(|_| HeaderValue::from_static("Bearer")),
294        );
295        headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
296        headers.insert(header::USER_AGENT, user_agent());
297        headers.insert("x-typesafe-sdk", sdk_header());
298        headers.insert("x-typesafe-runtime", runtime_header());
299        if has_body {
300            headers.insert(
301                header::CONTENT_TYPE,
302                HeaderValue::from_static("application/json"),
303            );
304        }
305        if attempt > 0 {
306            headers.insert("x-typesafe-retry-count", retry_count_value(attempt));
307        } else {
308            headers.remove("x-typesafe-retry-count");
309        }
310        headers
311    }
312}
313
314/// Models API resource.
315#[derive(Clone, Copy, Debug)]
316pub struct Models<'a> {
317    client: &'a Client,
318}
319
320impl Models<'_> {
321    /// List models available to the account.
322    ///
323    /// A body without a `models` array returns [`Error::UnexpectedShape`].
324    pub async fn list(&self) -> Result<Vec<ModelCard>, Error> {
325        self.list_with(&CallOptions::default()).await
326    }
327
328    /// [`Self::list`] with per-call options.
329    pub async fn list_with(&self, opts: &CallOptions) -> Result<Vec<ModelCard>, Error> {
330        let raw = self
331            .client
332            .execute(Method::GET, "/v1/models", None, opts)
333            .await?;
334        let value: serde_json::Value = decode_json(&raw, "/v1/models")?;
335        match value.get("models") {
336            Some(serde_json::Value::Array(_)) => {
337                let parsed: ListModelsResponse =
338                    serde_json::from_value(value).map_err(|source| Error::Decode {
339                        source,
340                        body: raw.body.clone(),
341                        meta: Box::new(meta_from_raw(&raw)),
342                    })?;
343                Ok(parsed.models)
344            }
345            _ => Err(Error::UnexpectedShape {
346                endpoint: "GET /v1/models",
347                meta: Box::new(meta_from_raw(&raw)),
348            }),
349        }
350    }
351}
352
353fn decode_json<T: serde::de::DeserializeOwned>(
354    raw: &RawResponse,
355    endpoint: &'static str,
356) -> Result<T, Error> {
357    serde_json::from_slice(&raw.body).map_err(|source| {
358        let _ = endpoint;
359        Error::Decode {
360            source,
361            body: raw.body.clone(),
362            meta: Box::new(meta_from_raw(raw)),
363        }
364    })
365}
366
367fn meta_from_raw(raw: &RawResponse) -> ResponseMeta {
368    ResponseMeta {
369        request_id: request_id(&raw.headers),
370        status: Some(raw.status),
371        headers: raw.headers.clone(),
372        attempts: raw.attempts,
373    }
374}
375
376fn emit_retry(attempt: u32, delay: Duration, reason: &str) {
377    let _ = (attempt, delay, reason);
378    #[cfg(feature = "tracing")]
379    {
380        tracing::info!(
381            attempt,
382            delay_ms = delay.as_millis() as u64,
383            reason,
384            "retry_scheduled"
385        );
386    }
387}