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.
193    #[must_use]
194    pub fn api_key(mut self, key: impl Into<String>) -> Self {
195        self.api_key = Some(SecretString::from(key.into()));
196        self
197    }
198
199    /// The API root, such as `https://api.typesafe.ai`; trailing slashes are
200    /// removed and a path prefix is kept.
201    ///
202    /// It must be an absolute `http` or `https` URL without userinfo, query or
203    /// fragment. An `http://` base URL sends the API key unencrypted; use it
204    /// only for a local proxy or a test server. Do not put a credential in its
205    /// path: the path is printed by the client's `Debug` and in every error
206    /// message that names an endpoint, as the Python SDK prints it.
207    #[must_use]
208    pub fn base_url(mut self, url: impl Into<String>) -> Self {
209        self.base_url = Some(url.into());
210        self
211    }
212
213    /// The model a request names when the call does not name one.
214    #[must_use]
215    pub fn default_model(mut self, model: impl Into<String>) -> Self {
216        self.default_model = Some(model.into());
217        self
218    }
219
220    /// The deadline of each attempt, from the first byte sent to the last
221    /// byte received. The default is 10 seconds.
222    ///
223    /// A large `state` on a slow link can take longer than that to upload;
224    /// raise the deadline for it, or use [`no_timeout`](Self::no_timeout).
225    #[must_use]
226    pub fn timeout(mut self, timeout: Duration) -> Self {
227        self.timeout = Some(Some(timeout));
228        self
229    }
230
231    /// No deadline on any attempt.
232    #[must_use]
233    pub fn no_timeout(mut self) -> Self {
234        self.timeout = Some(None);
235        self
236    }
237
238    /// A header sent on every request. A per-call header of the same name
239    /// replaces it; the SDK's own headers - `Authorization`, `Accept`,
240    /// `User-Agent`, `X-TypeSafe-SDK`, `X-TypeSafe-Runtime`, and
241    /// `Content-Type` on a request with a body - always win, and
242    /// `X-TypeSafe-Retry-Count` is dropped. The headers that frame a message
243    /// or manage its connection belong to the transport and are dropped too:
244    /// `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`,
245    /// `Proxy-Connection`, `TE`, `Trailer` and `Upgrade`. `Host` is sent as
246    /// given, on every protocol; over HTTP/2 the request's `:authority` still
247    /// comes from the base URL. Over HTTP/2 a `Host` that differs from the
248    /// base URL's authority is outside RFC 9113 (section 8.3.1), and a
249    /// conforming server may refuse the request as malformed. A caller that
250    /// needs another `Host` routes by the base URL instead, or speaks
251    /// HTTP/1.1: [`HttpVersion::Auto`] does on an `http` base URL, and on an
252    /// `https` one only when the server picks HTTP/1.1 through ALPN. A later
253    /// call with the same name replaces an earlier one.
254    ///
255    /// No header set here or on a call reaches `User-Agent` or
256    /// `X-TypeSafe-Runtime`: [`user_agent_product`](Self::user_agent_product)
257    /// and [`send_runtime_header`](Self::send_runtime_header) are the only
258    /// ways to change what they carry.
259    #[must_use]
260    pub fn default_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
261        self.default_headers.push((name.into(), value.into()));
262        self
263    }
264
265    /// The largest response body a request reads, in bytes; 16 MiB unless
266    /// set. A larger body is not read past the limit: a success response
267    /// fails with [`ErrorKind::ResponseTooLarge`](crate::ErrorKind::ResponseTooLarge),
268    /// a failure response is an API error without its body.
269    #[must_use]
270    pub fn max_response_bytes(mut self, limit: usize) -> Self {
271        self.max_response_bytes = Some(limit);
272        self
273    }
274
275    /// Trusts `der`, a DER-encoded certificate, in addition to the operating
276    /// system's roots: for a corporate CA the system store lacks, or a test
277    /// server's own certificate.
278    ///
279    /// The default transport only; see
280    /// [`build_with_service`](Self::build_with_service).
281    #[must_use]
282    pub fn add_root_certificate(mut self, der: impl Into<Vec<u8>>) -> Self {
283        self.extra_roots.push(der.into());
284        self
285    }
286
287    /// Which HTTP versions the default transport speaks. The default is
288    /// [`HttpVersion::Http2Only`] for an `https` base URL and
289    /// [`HttpVersion::Auto`] for an `http` one.
290    ///
291    /// The default transport only; see
292    /// [`build_with_service`](Self::build_with_service).
293    #[must_use]
294    pub fn http_version(mut self, version: HttpVersion) -> Self {
295        self.http_version = Some(version);
296        self
297    }
298
299    /// A deadline for opening a TCP connection, inside the deadline of the
300    /// whole attempt. None unless set. When it passes, the request fails with
301    /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) carrying this value.
302    ///
303    /// The default transport only; see
304    /// [`build_with_service`](Self::build_with_service).
305    #[must_use]
306    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
307        self.connect_timeout = Some(timeout);
308        self
309    }
310
311    /// The retry policy of every call made through the client;
312    /// [`RetryPolicy::default`] unless set. A call can replace it for itself
313    /// with its own `retry`.
314    #[must_use]
315    pub fn retry(mut self, policy: RetryPolicy) -> Self {
316        self.retry = Some(policy);
317        self
318    }
319
320    /// A product that names the application, sent in `User-Agent` in front
321    /// of the SDK's own: `user_agent_product("my-app/1.2.0")` sends
322    /// `User-Agent: my-app/1.2.0 typesafe-sdk-rust/<version>`, the more
323    /// significant product first as RFC 9110 (section 10.1.5) orders them.
324    /// Unset, `User-Agent` is the SDK's identifier alone. `X-TypeSafe-SDK`
325    /// always names the SDK alone. A later call replaces an earlier one.
326    ///
327    /// The product must be `name/version`, both parts tokens (RFC 9110,
328    /// section 5.6.2: letters, digits and ``!#$%&'*+-.^_`|~``), with exactly
329    /// one `/` and at most 64 bytes in all. That rules out whitespace,
330    /// control characters, anything outside ASCII, a comment in parentheses
331    /// and a product without a version.
332    ///
333    /// # Errors
334    ///
335    /// This method never fails. A product that breaks those rules makes
336    /// [`build`](Self::build) and [`build_with_service`](Self::build_with_service)
337    /// return an [`ErrorKind::Config`](crate::ErrorKind::Config) error naming
338    /// the rule, before anything is sent.
339    ///
340    /// ```
341    /// use typesafe_sdk::{Client, ErrorKind};
342    ///
343    /// // Building connects to nothing.
344    /// let client = Client::builder()
345    ///     .api_key("your-api-key")
346    ///     .user_agent_product("my-app/1.2.0")
347    ///     .build()?;
348    /// # drop(client);
349    ///
350    /// let error = Client::builder()
351    ///     .api_key("your-api-key")
352    ///     .user_agent_product("my app")
353    ///     .build()
354    ///     .expect_err("a product with a space is refused");
355    /// assert!(matches!(error.kind(), ErrorKind::Config));
356    /// # Ok::<(), typesafe_sdk::Error>(())
357    /// ```
358    #[must_use]
359    pub fn user_agent_product(mut self, product: impl Into<String>) -> Self {
360        self.user_agent_product = Some(product.into());
361        self
362    }
363
364    /// Whether requests carry `X-TypeSafe-Runtime: rust (<os>; <arch>)`,
365    /// which tells the API the operating system and architecture the SDK was
366    /// compiled for. The default is `true`; `false` leaves the header out of
367    /// every request, so an application can keep its platform to itself.
368    /// `X-TypeSafe-SDK` is sent either way. A later call replaces an earlier
369    /// one.
370    ///
371    /// ```
372    /// use typesafe_sdk::Client;
373    ///
374    /// // Building connects to nothing.
375    /// let client = Client::builder().api_key("your-api-key").send_runtime_header(false).build()?;
376    /// # drop(client);
377    /// # Ok::<(), typesafe_sdk::Error>(())
378    /// ```
379    #[must_use]
380    pub fn send_runtime_header(mut self, send: bool) -> Self {
381        self.omit_runtime_header = !send;
382        self
383    }
384
385    /// Builds a client with the default transport.
386    ///
387    /// Settings left unset are read from the environment. Nothing connects
388    /// here: the first request, or [`Client::warm_up`], does.
389    ///
390    /// # Errors
391    ///
392    /// Returns an [`ErrorKind::Config`](crate::ErrorKind::Config) error when
393    /// no API key is found or the key is blank or not printable ASCII; when
394    /// the base URL is not an absolute `http` or `https` URL without
395    /// userinfo, query or fragment; when the default model is blank; when a
396    /// deadline or the response limit is zero; when a default header is not a
397    /// valid header; when the [`user_agent_product`](Self::user_agent_product)
398    /// is not a product token; when an environment variable is not UTF-8; or
399    /// when the certificate verifier cannot be built, for an added root that
400    /// is not a certificate among other causes. No message repeats the key, a
401    /// header value or the URL.
402    pub fn build(self) -> Result<Client<HyperTransport>, Error> {
403        self.build_with_env(|name: &str| std::env::var_os(name))
404    }
405
406    /// Builds a client that sends its requests through `service`.
407    ///
408    /// This is how a client runs over a transport of the caller's own: a
409    /// proxy, a recorder, a `tower` stack with its own middleware. The
410    /// service owns its connections and their timeouts; the SDK still wraps
411    /// each attempt in its own deadline and reads the response under its own
412    /// limit.
413    ///
414    /// When the service fails, its error's text becomes the connection
415    /// error's message, escaped and cut at 200 characters; the SDK cannot know
416    /// what that text holds, so a service that prints a request header into
417    /// its error puts that header's value into the message.
418    ///
419    /// # Errors
420    ///
421    /// Everything [`build`](Self::build) refuses except the certificate
422    /// verifier, and, as a config error, any of
423    /// [`add_root_certificate`](Self::add_root_certificate),
424    /// [`http_version`](Self::http_version) and
425    /// [`connect_timeout`](Self::connect_timeout): they configure the default
426    /// transport, which this client does not have, and are refused rather
427    /// than ignored.
428    pub fn build_with_service<S>(self, service: S) -> Result<Client<S>, Error>
429    where
430        S: HttpService,
431    {
432        let mut unused = Vec::new();
433        if !self.extra_roots.is_empty() {
434            unused.push("add_root_certificate");
435        }
436        if self.http_version.is_some() {
437            unused.push("http_version");
438        }
439        if self.connect_timeout.is_some() {
440            unused.push("connect_timeout");
441        }
442        if !unused.is_empty() {
443            let verb = if unused.len() == 1 { "configures" } else { "configure" };
444            return Err(Error::config(format!(
445                "{} {verb} the default transport, and a client built with \
446                 build_with_service has a transport of its own.",
447                unused.join(", ")
448            )));
449        }
450        let (explicit, _, retry) = self.split()?;
451        let config = Config::resolve(explicit, |name: &str| std::env::var_os(name))?;
452        Ok(Client::assemble(config, retry, service))
453    }
454
455    /// [`build`](Self::build) with the environment read through `env`.
456    pub(crate) fn build_with_env<V>(
457        self,
458        env: impl Fn(&str) -> Option<V>,
459    ) -> Result<Client<HyperTransport>, Error>
460    where
461        V: Into<OsString>,
462    {
463        let (explicit, transport, retry) = self.split()?;
464        let config = Config::resolve(explicit, env)?;
465        let https = config.endpoints().system_one().scheme() == Some(&Scheme::HTTPS);
466        let settings = TransportSettings {
467            version: transport.version.unwrap_or(if https {
468                HttpVersion::Http2Only
469            } else {
470                HttpVersion::Auto
471            }),
472            extra_roots: transport.extra_roots,
473            connect_timeout: transport.connect_timeout,
474        };
475        Ok(Client::assemble(config, retry, HyperTransport::new(settings)?))
476    }
477
478    /// Checks what only the builder can check and separates the settings of
479    /// the configuration from those of the default transport and the retry
480    /// policy.
481    fn split(self) -> Result<(Explicit, TransportChoices, RetryPolicy), Error> {
482        let Self {
483            api_key,
484            base_url,
485            default_model,
486            timeout,
487            default_headers,
488            max_response_bytes,
489            extra_roots,
490            http_version,
491            connect_timeout,
492            retry,
493            user_agent_product,
494            omit_runtime_header,
495        } = self;
496
497        if connect_timeout.is_some_and(|timeout| timeout.is_zero()) {
498            return Err(Error::config("connect_timeout must be a positive number of seconds."));
499        }
500        let mut headers = HeaderMap::with_capacity(default_headers.len());
501        for (name, value) in &default_headers {
502            let (name, value) =
503                transport::parse_header(name, value, "default ").map_err(Error::config)?;
504            headers.insert(name, value);
505        }
506
507        let explicit = Explicit {
508            api_key,
509            base_url,
510            default_model,
511            timeout,
512            default_headers: headers,
513            max_response_bytes,
514            user_agent_product,
515            omit_runtime_header,
516        };
517        Ok((
518            explicit,
519            TransportChoices { version: http_version, extra_roots, connect_timeout },
520            retry.unwrap_or_default(),
521        ))
522    }
523}
524
525/// The builder's settings for the default transport, before the base URL
526/// decides the default version.
527struct TransportChoices {
528    version: Option<HttpVersion>,
529    extra_roots: Vec<Vec<u8>>,
530    connect_timeout: Option<Duration>,
531}
532
533impl fmt::Debug for ClientBuilder {
534    /// What was set, without the key, without header values, and with the
535    /// roots as a count. The base URL is shown only once it has passed the
536    /// checks [`build`](ClientBuilder::build) runs, and then as its endpoints,
537    /// the way an error names them: a URL that failed them may still hold
538    /// userinfo. A retry policy, a `User-Agent` product and a runtime header
539    /// switched off are shown when they were set; the product is quoted and
540    /// escaped, since until it is built it may hold anything.
541    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
542        let base_url = self.base_url.as_deref().map(|url| {
543            crate::config::endpoints(url.trim_end_matches('/'))
544                .map_or_else(|_| Shown::Text("<not a usable URL>"), Shown::Endpoints)
545        });
546        let mut shown = formatter.debug_struct("ClientBuilder");
547        shown
548            .field("api_key", &self.api_key.as_ref().map(|_| Shown::Text("<redacted>")))
549            .field("base_url", &base_url)
550            .field("default_model", &self.default_model)
551            .field("timeout", &self.timeout)
552            .field(
553                "default_headers",
554                &self.default_headers.iter().map(|(name, _)| name).collect::<Vec<_>>(),
555            )
556            .field("max_response_bytes", &self.max_response_bytes)
557            .field("extra_roots", &self.extra_roots.len())
558            .field("http_version", &self.http_version)
559            .field("connect_timeout", &self.connect_timeout);
560        if let Some(retry) = &self.retry {
561            shown.field("retry", retry);
562        }
563        if let Some(product) = &self.user_agent_product {
564            shown.field("user_agent_product", &Shown::Quoted(crate::text::quoted(product)));
565        }
566        if self.omit_runtime_header {
567            shown.field("send_runtime_header", &false);
568        }
569        shown.finish()
570    }
571}
572
573/// A value the builder's `Debug` prints in place of the one it holds.
574enum Shown {
575    Text(&'static str),
576    /// Text already quoted and escaped by [`crate::text::quoted`].
577    Quoted(String),
578    Endpoints(crate::config::Endpoints),
579}
580
581impl fmt::Debug for Shown {
582    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
583        match self {
584            Self::Text(text) => formatter.write_str(text),
585            Self::Quoted(text) => formatter.write_str(text),
586            Self::Endpoints(endpoints) => fmt::Debug::fmt(endpoints, formatter),
587        }
588    }
589}
590
591#[cfg(test)]
592#[path = "client_tests.rs"]
593mod tests;