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;