Skip to main content

typesafe_sdk/
client.rs

1//! The client: what a caller holds, clones and shares.
2//!
3//! A client owns its transport and is cheap to clone, so passing one to every
4//! task is the intended use rather than something to work around with a shared
5//! reference. The API key enters as a secret and is kept only as the finished
6//! `Authorization` header value, marked sensitive, so no formatting of the
7//! client or its configuration can print it.
8
9use std::{ffi::OsString, fmt, sync::Arc, time::Duration};
10
11use bytes::Bytes;
12use http::{HeaderMap, uri::Scheme};
13use secrecy::SecretString;
14use serde::Serialize;
15
16use crate::{
17    codec,
18    config::{Config, Explicit},
19    error::Error,
20    models::Models,
21    question::PreparedQuestions,
22    request::SystemOne,
23    retry::RetryPolicy,
24    transport::{self, HttpService, HttpVersion, HyperTransport, TransportSettings},
25};
26
27/// A client of the TypeSafe API.
28///
29/// Build one with [`Client::builder`], or with [`Client::from_env`] when the
30/// environment holds everything. Cloning a client is cheap - the settings and
31/// the transport are shared behind one reference count - so a clone per task
32/// is the way to use one from many tasks, and all of them share one
33/// connection pool.
34///
35/// `S` is the transport. The default, [`HyperTransport`], is an HTTP/2 client
36/// over TLS; any `tower` service over `http` requests is accepted through
37/// [`ClientBuilder::build_with_service`].
38///
39/// Every request runs on the caller's Tokio runtime, which needs its time
40/// driver enabled: each attempt has a deadline, and HTTP/2 keep-alive pings
41/// run on a timer.
42pub struct Client<S = HyperTransport> {
43    shared: Arc<Shared<S>>,
44}
45
46/// What every clone of one client shares.
47pub(crate) struct Shared<S> {
48    pub(crate) service: S,
49    pub(crate) config: Config,
50    /// The headers of a request without a body, built once.
51    pub(crate) get_headers: HeaderMap,
52    /// The headers of a request with a JSON body, built once.
53    pub(crate) post_headers: HeaderMap,
54    /// The default model as a JSON string, escaped once.
55    pub(crate) model_json: Bytes,
56    /// The retry policy of every call that does not bring its own.
57    pub(crate) retry: RetryPolicy,
58}
59
60impl<S> Clone for Client<S> {
61    fn clone(&self) -> Self {
62        Self { shared: Arc::clone(&self.shared) }
63    }
64}
65
66impl<S: fmt::Debug> fmt::Debug for Client<S> {
67    /// The endpoints, the default model, the deadline, the response limit and
68    /// the names of the default headers, then the transport. Never the API key
69    /// and never a header value.
70    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        formatter
72            .debug_struct("Client")
73            .field("config", &self.shared.config)
74            .field("transport", &self.shared.service)
75            .finish()
76    }
77}
78
79impl Client<HyperTransport> {
80    /// A builder for a client; every setting it leaves unset comes from the
81    /// environment, then from the SDK's default.
82    #[must_use]
83    pub fn builder() -> ClientBuilder {
84        ClientBuilder::default()
85    }
86
87    /// A client configured by the environment alone: `TYPESAFE_API_KEY`, and
88    /// optionally `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL`.
89    ///
90    /// # Errors
91    ///
92    /// Returns an [`ErrorKind::Config`](crate::ErrorKind::Config) error when
93    /// no API key is set, a value is unusable, or a variable is not UTF-8.
94    pub fn from_env() -> Result<Self, Error> {
95        Self::builder().build()
96    }
97}
98
99impl<S> Client<S> {
100    /// Assembles a client around a resolved configuration.
101    fn assemble(config: Config, retry: RetryPolicy, service: S) -> Self {
102        let get_headers = transport::base_headers(&config, false);
103        let post_headers = transport::base_headers(&config, true);
104        let mut model = Vec::with_capacity(config.default_model().len() + 2);
105        codec::write_json_string(&mut model, config.default_model());
106        Self {
107            shared: Arc::new(Shared {
108                service,
109                config,
110                get_headers,
111                post_headers,
112                model_json: Bytes::from(model),
113                retry,
114            }),
115        }
116    }
117
118    /// What every clone of this client shares.
119    pub(crate) fn shared(&self) -> &Shared<S> {
120        &self.shared
121    }
122}
123
124impl<S> Client<S>
125where
126    S: HttpService,
127{
128    /// A System One request asking `questions` about `state`.
129    ///
130    /// `state` is anything that serializes to a JSON string, object or array;
131    /// it is encoded when the request is sent, straight into the body. The
132    /// request is configured with the builder's methods and sent with
133    /// [`send`](SystemOne::send).
134    pub fn system_one<'a, T>(
135        &'a self,
136        state: &'a T,
137        questions: &'a PreparedQuestions,
138    ) -> SystemOne<'a, S, T>
139    where
140        T: Serialize + ?Sized,
141    {
142        SystemOne::new(self, state, questions)
143    }
144
145    /// The models resource.
146    pub fn models(&self) -> Models<'_, S> {
147        Models::new(self)
148    }
149
150    /// Lists the models once and drops the answer.
151    ///
152    /// That checks the API key and leaves an open connection in the pool, so
153    /// requests started together afterwards share it instead of each opening
154    /// one. Call it before a burst of concurrent requests.
155    ///
156    /// # Errors
157    ///
158    /// Returns what [`ListModels::send`](crate::models::ListModels::send)
159    /// returns: an authentication failure, for a key the API refuses.
160    pub async fn warm_up(&self) -> Result<(), Error> {
161        self.models().list().send().await.map(drop)
162    }
163}
164
165/// Configures a [`Client`].
166///
167/// Every setting is optional. The API key, base URL and default model fall
168/// back to `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL` and
169/// `TYPESAFE_DEFAULT_MODEL`, then to the SDK's defaults (no key, which fails;
170/// `https://api.typesafe.ai`; `jev-latest`). The methods never fail: what
171/// they are given is checked by [`build`](ClientBuilder::build).
172#[derive(Default)]
173pub struct ClientBuilder {
174    api_key: Option<SecretString>,
175    base_url: Option<String>,
176    default_model: Option<String>,
177    /// `None` leaves the default; `Some(None)` asks for no deadline.
178    timeout: Option<Option<Duration>>,
179    default_headers: Vec<(String, String)>,
180    max_response_bytes: Option<usize>,
181    extra_roots: Vec<Vec<u8>>,
182    http_version: Option<HttpVersion>,
183    connect_timeout: Option<Duration>,
184    retry: Option<RetryPolicy>,
185    user_agent_product: Option<String>,
186    /// `false`, the default, sends `X-TypeSafe-Runtime`.
187    omit_runtime_header: bool,
188}
189
190impl ClientBuilder {
191    /// The API key. It is sent as `Authorization: Bearer <key>` and is
192    /// printed nowhere. Leading and trailing whitespace is stripped; an empty
193    /// key, internal whitespace, control and non-ASCII characters are refused.
194    #[must_use]
195    pub fn api_key(mut self, key: impl Into<String>) -> Self {
196        self.api_key = Some(SecretString::from(key.into()));
197        self
198    }
199
200    /// The API root, such as `https://api.typesafe.ai`; trailing slashes are
201    /// removed and a path prefix is kept.
202    ///
203    /// It must be an absolute `http` or `https` URL without userinfo, query or
204    /// fragment. An `http://` base URL sends the API key unencrypted; use it
205    /// only for a local proxy or a test server. Do not put a credential in its
206    /// path: the path is printed by the client's `Debug` and in every error
207    /// message that names an endpoint, as the Python SDK prints it.
208    #[must_use]
209    pub fn base_url(mut self, url: impl Into<String>) -> Self {
210        self.base_url = Some(url.into());
211        self
212    }
213
214    /// The model a request names when the call does not name one.
215    #[must_use]
216    pub fn default_model(mut self, model: impl Into<String>) -> Self {
217        self.default_model = Some(model.into());
218        self
219    }
220
221    /// The deadline of each attempt, from the first byte sent to the last
222    /// byte received. The default is 10 seconds.
223    ///
224    /// A large `state` on a slow link can take longer than that to upload;
225    /// raise the deadline for it, or use [`no_timeout`](Self::no_timeout).
226    #[must_use]
227    pub fn timeout(mut self, timeout: Duration) -> Self {
228        self.timeout = Some(Some(timeout));
229        self
230    }
231
232    /// No deadline on any attempt.
233    #[must_use]
234    pub fn no_timeout(mut self) -> Self {
235        self.timeout = Some(None);
236        self
237    }
238
239    /// A header sent on every request. A per-call header of the same name
240    /// replaces it; the SDK's own headers - `Authorization`, `Accept`,
241    /// `User-Agent`, `X-TypeSafe-SDK`, `X-TypeSafe-Runtime`, and
242    /// `Content-Type` on a request with a body - always win, and
243    /// `X-TypeSafe-Retry-Count` is dropped. The headers that frame a message
244    /// or manage its connection belong to the transport and are dropped too:
245    /// `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`,
246    /// `Proxy-Connection`, `TE`, `Trailer` and `Upgrade`. `Host` is sent as
247    /// given, on every protocol; over HTTP/2 the request's `:authority` still
248    /// comes from the base URL. Over HTTP/2 a `Host` that differs from the
249    /// base URL's authority is outside RFC 9113 (section 8.3.1), and a
250    /// conforming server may refuse the request as malformed. A caller that
251    /// needs another `Host` routes by the base URL instead, or speaks
252    /// HTTP/1.1: [`HttpVersion::Auto`] does on an `http` base URL, and on an
253    /// `https` one only when the server picks HTTP/1.1 through ALPN. A later
254    /// call with the same name replaces an earlier one.
255    ///
256    /// No header set here or on a call reaches `User-Agent` or
257    /// `X-TypeSafe-Runtime`: [`user_agent_product`](Self::user_agent_product)
258    /// and [`send_runtime_header`](Self::send_runtime_header) are the only
259    /// ways to change what they carry.
260    #[must_use]
261    pub fn default_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
262        self.default_headers.push((name.into(), value.into()));
263        self
264    }
265
266    /// The largest response body a request reads, in bytes; 16 MiB unless
267    /// set. A larger body is not read past the limit: a success response
268    /// fails with [`ErrorKind::ResponseTooLarge`](crate::ErrorKind::ResponseTooLarge),
269    /// a failure response is an API error without its body.
270    #[must_use]
271    pub fn max_response_bytes(mut self, limit: usize) -> Self {
272        self.max_response_bytes = Some(limit);
273        self
274    }
275
276    /// Trusts `der`, a DER-encoded certificate, in addition to the operating
277    /// system's roots: for a corporate CA the system store lacks, or a test
278    /// server's own certificate.
279    ///
280    /// The default transport only; see
281    /// [`build_with_service`](Self::build_with_service).
282    #[must_use]
283    pub fn add_root_certificate(mut self, der: impl Into<Vec<u8>>) -> Self {
284        self.extra_roots.push(der.into());
285        self
286    }
287
288    /// Which HTTP versions the default transport speaks. The default is
289    /// [`HttpVersion::Http2Only`] for an `https` base URL and
290    /// [`HttpVersion::Auto`] for an `http` one.
291    ///
292    /// The default transport only; see
293    /// [`build_with_service`](Self::build_with_service).
294    #[must_use]
295    pub fn http_version(mut self, version: HttpVersion) -> Self {
296        self.http_version = Some(version);
297        self
298    }
299
300    /// A deadline for opening a TCP connection, inside the deadline of the
301    /// whole attempt. None unless set. When it passes, the request fails with
302    /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) carrying this value.
303    ///
304    /// The default transport only; see
305    /// [`build_with_service`](Self::build_with_service).
306    #[must_use]
307    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
308        self.connect_timeout = Some(timeout);
309        self
310    }
311
312    /// The retry policy of every call made through the client;
313    /// [`RetryPolicy::default`] unless set. A call can replace it for itself
314    /// with its own `retry`.
315    #[must_use]
316    pub fn retry(mut self, policy: RetryPolicy) -> Self {
317        self.retry = Some(policy);
318        self
319    }
320
321    /// A product that names the application, sent in `User-Agent` in front
322    /// of the SDK's own: `user_agent_product("my-app/1.2.0")` sends
323    /// `User-Agent: my-app/1.2.0 typesafe-sdk-rust/<version>`, the more
324    /// significant product first as RFC 9110 (section 10.1.5) orders them.
325    /// Unset, `User-Agent` is the SDK's identifier alone. `X-TypeSafe-SDK`
326    /// always names the SDK alone. A later call replaces an earlier one.
327    ///
328    /// The product must be `name/version`, both parts tokens (RFC 9110,
329    /// section 5.6.2: letters, digits and ``!#$%&'*+-.^_`|~``), with exactly
330    /// one `/` and at most 64 bytes in all. That rules out whitespace,
331    /// control characters, anything outside ASCII, a comment in parentheses
332    /// and a product without a version.
333    ///
334    /// # Errors
335    ///
336    /// This method never fails. A product that breaks those rules makes
337    /// [`build`](Self::build) and [`build_with_service`](Self::build_with_service)
338    /// return an [`ErrorKind::Config`](crate::ErrorKind::Config) error naming
339    /// the rule, before anything is sent.
340    ///
341    /// ```
342    /// use typesafe_sdk::{Client, ErrorKind};
343    ///
344    /// // Building connects to nothing.
345    /// let client = Client::builder()
346    ///     .api_key("your-api-key")
347    ///     .user_agent_product("my-app/1.2.0")
348    ///     .build()?;
349    /// # drop(client);
350    ///
351    /// let error = Client::builder()
352    ///     .api_key("your-api-key")
353    ///     .user_agent_product("my app")
354    ///     .build()
355    ///     .expect_err("a product with a space is refused");
356    /// assert!(matches!(error.kind(), ErrorKind::Config));
357    /// # Ok::<(), typesafe_sdk::Error>(())
358    /// ```
359    #[must_use]
360    pub fn user_agent_product(mut self, product: impl Into<String>) -> Self {
361        self.user_agent_product = Some(product.into());
362        self
363    }
364
365    /// Whether requests carry `X-TypeSafe-Runtime: rust (<os>; <arch>)`,
366    /// which tells the API the operating system and architecture the SDK was
367    /// compiled for. The default is `true`; `false` leaves the header out of
368    /// every request, so an application can keep its platform to itself.
369    /// `X-TypeSafe-SDK` is sent either way. A later call replaces an earlier
370    /// one.
371    ///
372    /// ```
373    /// use typesafe_sdk::Client;
374    ///
375    /// // Building connects to nothing.
376    /// let client = Client::builder().api_key("your-api-key").send_runtime_header(false).build()?;
377    /// # drop(client);
378    /// # Ok::<(), typesafe_sdk::Error>(())
379    /// ```
380    #[must_use]
381    pub fn send_runtime_header(mut self, send: bool) -> Self {
382        self.omit_runtime_header = !send;
383        self
384    }
385
386    /// Builds a client with the default transport.
387    ///
388    /// Settings left unset are read from the environment. Nothing connects
389    /// here: the first request, or [`Client::warm_up`], does.
390    ///
391    /// # Errors
392    ///
393    /// Returns an [`ErrorKind::Config`](crate::ErrorKind::Config) error when
394    /// no API key is found or the key is empty after trimming or holds
395    /// whitespace, a control or a non-ASCII character; when
396    /// the base URL is not an absolute `http` or `https` URL without
397    /// userinfo, query or fragment; when the default model is blank; when a
398    /// deadline or the response limit is zero; when a default header is not a
399    /// valid header; when the [`user_agent_product`](Self::user_agent_product)
400    /// is not a product token; when an environment variable is not UTF-8; or
401    /// when the certificate verifier cannot be built, for an added root that
402    /// is not a certificate among other causes. No message repeats the key, a
403    /// header value or the URL.
404    pub fn build(self) -> Result<Client<HyperTransport>, Error> {
405        self.build_with_env(|name: &str| std::env::var_os(name))
406    }
407
408    /// Builds a client that sends its requests through `service`.
409    ///
410    /// This is how a client runs over a transport of the caller's own: a
411    /// proxy, a recorder, a `tower` stack with its own middleware. The
412    /// service owns its connections and their timeouts; the SDK still wraps
413    /// each attempt in its own deadline and reads the response under its own
414    /// limit.
415    ///
416    /// When the service fails, its error's text becomes the connection
417    /// error's message, escaped and cut at 200 characters. The request's
418    /// credentials are replaced by `***` first: the API key, and the value of
419    /// every header whose name is a secret one (`authorization`,
420    /// `proxy-authorization`, `x-api-key`, `api-key`, `cookie`, `set-cookie`,
421    /// or any name containing `token` or `secret`) or that is flagged
422    /// sensitive, as it is, as `{:?}` of a `str`, `str::escape_debug`, `{:?}`
423    /// of a `HeaderValue` and of `Bytes`, and a JSON string write it, and each
424    /// of those escaped once more as `{:?}` of a `str` writes it, which is how
425    /// a derived `Debug` prints a `String` field holding one. When
426    /// any of those occurs in the error's `Display`, `{:?}` or `{:#?}`, or in
427    /// any error below it, the [`source`](std::error::Error::source) is a
428    /// redacted copy that cannot be downcast. The message is redacted after
429    /// escaping as well, so a covered form the escaping creates by chance is
430    /// `***` too. The value of any other header a service prints stays in the
431    /// message, and so does a credential written in a form not listed here:
432    /// as a list of byte values, `{:x?}`, percent-encoded or in base64.
433    ///
434    /// # Errors
435    ///
436    /// Everything [`build`](Self::build) refuses except the certificate
437    /// verifier, and, as a config error, any of
438    /// [`add_root_certificate`](Self::add_root_certificate),
439    /// [`http_version`](Self::http_version) and
440    /// [`connect_timeout`](Self::connect_timeout): they configure the default
441    /// transport, which this client does not have, and are refused rather
442    /// than ignored.
443    pub fn build_with_service<S>(self, service: S) -> Result<Client<S>, Error>
444    where
445        S: HttpService,
446    {
447        let mut unused = Vec::new();
448        if !self.extra_roots.is_empty() {
449            unused.push("add_root_certificate");
450        }
451        if self.http_version.is_some() {
452            unused.push("http_version");
453        }
454        if self.connect_timeout.is_some() {
455            unused.push("connect_timeout");
456        }
457        if !unused.is_empty() {
458            let verb = if unused.len() == 1 { "configures" } else { "configure" };
459            return Err(Error::config(format!(
460                "{} {verb} the default transport, and a client built with \
461                 build_with_service has a transport of its own.",
462                unused.join(", ")
463            )));
464        }
465        let (explicit, _, retry) = self.split()?;
466        let config = Config::resolve(explicit, |name: &str| std::env::var_os(name))?;
467        Ok(Client::assemble(config, retry, service))
468    }
469
470    /// [`build`](Self::build) with the environment read through `env`.
471    pub(crate) fn build_with_env<V>(
472        self,
473        env: impl Fn(&str) -> Option<V>,
474    ) -> Result<Client<HyperTransport>, Error>
475    where
476        V: Into<OsString>,
477    {
478        let (explicit, transport, retry) = self.split()?;
479        let config = Config::resolve(explicit, env)?;
480        let https = config.endpoints().system_one().scheme() == Some(&Scheme::HTTPS);
481        let settings = TransportSettings {
482            version: transport.version.unwrap_or(if https {
483                HttpVersion::Http2Only
484            } else {
485                HttpVersion::Auto
486            }),
487            extra_roots: transport.extra_roots,
488            connect_timeout: transport.connect_timeout,
489        };
490        Ok(Client::assemble(config, retry, HyperTransport::new(settings)?))
491    }
492
493    /// Checks what only the builder can check and separates the settings of
494    /// the configuration from those of the default transport and the retry
495    /// policy.
496    fn split(self) -> Result<(Explicit, TransportChoices, RetryPolicy), Error> {
497        let Self {
498            api_key,
499            base_url,
500            default_model,
501            timeout,
502            default_headers,
503            max_response_bytes,
504            extra_roots,
505            http_version,
506            connect_timeout,
507            retry,
508            user_agent_product,
509            omit_runtime_header,
510        } = self;
511
512        if connect_timeout.is_some_and(|timeout| timeout.is_zero()) {
513            return Err(Error::config("connect_timeout must be a positive number of seconds."));
514        }
515        let mut headers = HeaderMap::with_capacity(default_headers.len());
516        for (name, value) in &default_headers {
517            let (name, value) =
518                transport::parse_header(name, value, "default ").map_err(Error::config)?;
519            headers.insert(name, value);
520        }
521
522        let explicit = Explicit {
523            api_key,
524            base_url,
525            default_model,
526            timeout,
527            default_headers: headers,
528            max_response_bytes,
529            user_agent_product,
530            omit_runtime_header,
531        };
532        Ok((
533            explicit,
534            TransportChoices { version: http_version, extra_roots, connect_timeout },
535            retry.unwrap_or_default(),
536        ))
537    }
538}
539
540/// The builder's settings for the default transport, before the base URL
541/// decides the default version.
542struct TransportChoices {
543    version: Option<HttpVersion>,
544    extra_roots: Vec<Vec<u8>>,
545    connect_timeout: Option<Duration>,
546}
547
548impl fmt::Debug for ClientBuilder {
549    /// What was set, without the key, without header values, and with the
550    /// roots as a count. The base URL is shown only once it has passed the
551    /// checks [`build`](ClientBuilder::build) runs, and then as its endpoints,
552    /// the way an error names them: a URL that failed them may still hold
553    /// userinfo. A retry policy, a `User-Agent` product and a runtime header
554    /// switched off are shown when they were set; the product is quoted and
555    /// escaped, since until it is built it may hold anything.
556    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
557        let base_url = self.base_url.as_deref().map(|url| {
558            crate::config::endpoints(url.trim_end_matches('/'))
559                .map_or_else(|_| Shown::Text("<not a usable URL>"), Shown::Endpoints)
560        });
561        let mut shown = formatter.debug_struct("ClientBuilder");
562        shown
563            .field("api_key", &self.api_key.as_ref().map(|_| Shown::Text("<redacted>")))
564            .field("base_url", &base_url)
565            .field("default_model", &self.default_model)
566            .field("timeout", &self.timeout)
567            .field(
568                "default_headers",
569                &self.default_headers.iter().map(|(name, _)| name).collect::<Vec<_>>(),
570            )
571            .field("max_response_bytes", &self.max_response_bytes)
572            .field("extra_roots", &self.extra_roots.len())
573            .field("http_version", &self.http_version)
574            .field("connect_timeout", &self.connect_timeout);
575        if let Some(retry) = &self.retry {
576            shown.field("retry", retry);
577        }
578        if let Some(product) = &self.user_agent_product {
579            shown.field("user_agent_product", &Shown::Quoted(crate::text::quoted(product)));
580        }
581        if self.omit_runtime_header {
582            shown.field("send_runtime_header", &false);
583        }
584        shown.finish()
585    }
586}
587
588/// A value the builder's `Debug` prints in place of the one it holds.
589enum Shown {
590    Text(&'static str),
591    /// Text already quoted and escaped by [`crate::text::quoted`].
592    Quoted(String),
593    Endpoints(crate::config::Endpoints),
594}
595
596impl fmt::Debug for Shown {
597    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
598        match self {
599            Self::Text(text) => formatter.write_str(text),
600            Self::Quoted(text) => formatter.write_str(text),
601            Self::Endpoints(endpoints) => fmt::Debug::fmt(endpoints, formatter),
602        }
603    }
604}
605
606#[cfg(test)]
607#[path = "client_tests.rs"]
608mod tests;