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;