typesafe-sdk-rust 0.1.0

Async Rust SDK for the TypeSafe AI System One API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
//! The client: what a caller holds, clones and shares.
//!
//! A client owns its transport and is cheap to clone, so passing one to every
//! task is the intended use rather than something to work around with a shared
//! reference. The API key enters as a secret and is kept only as the finished
//! `Authorization` header value, marked sensitive, so no formatting of the
//! client or its configuration can print it.

use std::{ffi::OsString, fmt, sync::Arc, time::Duration};

use bytes::Bytes;
use http::{HeaderMap, uri::Scheme};
use secrecy::SecretString;
use serde::Serialize;

use crate::{
    codec,
    config::{Config, Explicit},
    error::Error,
    models::Models,
    question::PreparedQuestions,
    request::SystemOne,
    retry::RetryPolicy,
    transport::{self, HttpService, HttpVersion, HyperTransport, TransportSettings},
};

/// A client of the TypeSafe API.
///
/// Build one with [`Client::builder`], or with [`Client::from_env`] when the
/// environment holds everything. Cloning a client is cheap - the settings and
/// the transport are shared behind one reference count - so a clone per task
/// is the way to use one from many tasks, and all of them share one
/// connection pool.
///
/// `S` is the transport. The default, [`HyperTransport`], is an HTTP/2 client
/// over TLS; any `tower` service over `http` requests is accepted through
/// [`ClientBuilder::build_with_service`].
///
/// Every request runs on the caller's Tokio runtime, which needs its time
/// driver enabled: each attempt has a deadline, and HTTP/2 keep-alive pings
/// run on a timer.
pub struct Client<S = HyperTransport> {
    shared: Arc<Shared<S>>,
}

/// What every clone of one client shares.
pub(crate) struct Shared<S> {
    pub(crate) service: S,
    pub(crate) config: Config,
    /// The headers of a request without a body, built once.
    pub(crate) get_headers: HeaderMap,
    /// The headers of a request with a JSON body, built once.
    pub(crate) post_headers: HeaderMap,
    /// The default model as a JSON string, escaped once.
    pub(crate) model_json: Bytes,
    /// The retry policy of every call that does not bring its own.
    pub(crate) retry: RetryPolicy,
}

impl<S> Clone for Client<S> {
    fn clone(&self) -> Self {
        Self { shared: Arc::clone(&self.shared) }
    }
}

impl<S: fmt::Debug> fmt::Debug for Client<S> {
    /// The endpoints, the default model, the deadline, the response limit and
    /// the names of the default headers, then the transport. Never the API key
    /// and never a header value.
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("Client")
            .field("config", &self.shared.config)
            .field("transport", &self.shared.service)
            .finish()
    }
}

impl Client<HyperTransport> {
    /// A builder for a client; every setting it leaves unset comes from the
    /// environment, then from the SDK's default.
    #[must_use]
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    /// A client configured by the environment alone: `TYPESAFE_API_KEY`, and
    /// optionally `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL`.
    ///
    /// # Errors
    ///
    /// Returns an [`ErrorKind::Config`](crate::ErrorKind::Config) error when
    /// no API key is set, a value is unusable, or a variable is not UTF-8.
    pub fn from_env() -> Result<Self, Error> {
        Self::builder().build()
    }
}

impl<S> Client<S> {
    /// Assembles a client around a resolved configuration.
    fn assemble(config: Config, retry: RetryPolicy, service: S) -> Self {
        let get_headers = transport::base_headers(&config, false);
        let post_headers = transport::base_headers(&config, true);
        let mut model = Vec::with_capacity(config.default_model().len() + 2);
        codec::write_json_string(&mut model, config.default_model());
        Self {
            shared: Arc::new(Shared {
                service,
                config,
                get_headers,
                post_headers,
                model_json: Bytes::from(model),
                retry,
            }),
        }
    }

    /// What every clone of this client shares.
    pub(crate) fn shared(&self) -> &Shared<S> {
        &self.shared
    }
}

impl<S> Client<S>
where
    S: HttpService,
{
    /// A System One request asking `questions` about `state`.
    ///
    /// `state` is anything that serializes to a JSON string, object or array;
    /// it is encoded when the request is sent, straight into the body. The
    /// request is configured with the builder's methods and sent with
    /// [`send`](SystemOne::send).
    pub fn system_one<'a, T>(
        &'a self,
        state: &'a T,
        questions: &'a PreparedQuestions,
    ) -> SystemOne<'a, S, T>
    where
        T: Serialize + ?Sized,
    {
        SystemOne::new(self, state, questions)
    }

    /// The models resource.
    pub fn models(&self) -> Models<'_, S> {
        Models::new(self)
    }

    /// Lists the models once and drops the answer.
    ///
    /// That checks the API key and leaves an open connection in the pool, so
    /// requests started together afterwards share it instead of each opening
    /// one. Call it before a burst of concurrent requests.
    ///
    /// # Errors
    ///
    /// Returns what [`ListModels::send`](crate::models::ListModels::send)
    /// returns: an authentication failure, for a key the API refuses.
    pub async fn warm_up(&self) -> Result<(), Error> {
        self.models().list().send().await.map(drop)
    }
}

/// Configures a [`Client`].
///
/// Every setting is optional. The API key, base URL and default model fall
/// back to `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL` and
/// `TYPESAFE_DEFAULT_MODEL`, then to the SDK's defaults (no key, which fails;
/// `https://api.typesafe.ai`; `jev-latest`). The methods never fail: what
/// they are given is checked by [`build`](ClientBuilder::build).
#[derive(Default)]
pub struct ClientBuilder {
    api_key: Option<SecretString>,
    base_url: Option<String>,
    default_model: Option<String>,
    /// `None` leaves the default; `Some(None)` asks for no deadline.
    timeout: Option<Option<Duration>>,
    default_headers: Vec<(String, String)>,
    max_response_bytes: Option<usize>,
    extra_roots: Vec<Vec<u8>>,
    http_version: Option<HttpVersion>,
    connect_timeout: Option<Duration>,
    retry: Option<RetryPolicy>,
    user_agent_product: Option<String>,
    /// `false`, the default, sends `X-TypeSafe-Runtime`.
    omit_runtime_header: bool,
}

impl ClientBuilder {
    /// The API key. It is sent as `Authorization: Bearer <key>` and is
    /// printed nowhere.
    #[must_use]
    pub fn api_key(mut self, key: impl Into<String>) -> Self {
        self.api_key = Some(SecretString::from(key.into()));
        self
    }

    /// The API root, such as `https://api.typesafe.ai`; trailing slashes are
    /// removed and a path prefix is kept.
    ///
    /// It must be an absolute `http` or `https` URL without userinfo, query or
    /// fragment. An `http://` base URL sends the API key unencrypted; use it
    /// only for a local proxy or a test server. Do not put a credential in its
    /// path: the path is printed by the client's `Debug` and in every error
    /// message that names an endpoint, as the Python SDK prints it.
    #[must_use]
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = Some(url.into());
        self
    }

    /// The model a request names when the call does not name one.
    #[must_use]
    pub fn default_model(mut self, model: impl Into<String>) -> Self {
        self.default_model = Some(model.into());
        self
    }

    /// The deadline of each attempt, from the first byte sent to the last
    /// byte received. The default is 10 seconds.
    ///
    /// A large `state` on a slow link can take longer than that to upload;
    /// raise the deadline for it, or use [`no_timeout`](Self::no_timeout).
    #[must_use]
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(Some(timeout));
        self
    }

    /// No deadline on any attempt.
    #[must_use]
    pub fn no_timeout(mut self) -> Self {
        self.timeout = Some(None);
        self
    }

    /// A header sent on every request. A per-call header of the same name
    /// replaces it; the SDK's own headers - `Authorization`, `Accept`,
    /// `User-Agent`, `X-TypeSafe-SDK`, `X-TypeSafe-Runtime`, and
    /// `Content-Type` on a request with a body - always win, and
    /// `X-TypeSafe-Retry-Count` is dropped. The headers that frame a message
    /// or manage its connection belong to the transport and are dropped too:
    /// `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`,
    /// `Proxy-Connection`, `TE`, `Trailer` and `Upgrade`. `Host` is sent as
    /// given, on every protocol; over HTTP/2 the request's `:authority` still
    /// comes from the base URL. Over HTTP/2 a `Host` that differs from the
    /// base URL's authority is outside RFC 9113 (section 8.3.1), and a
    /// conforming server may refuse the request as malformed. A caller that
    /// needs another `Host` routes by the base URL instead, or speaks
    /// HTTP/1.1: [`HttpVersion::Auto`] does on an `http` base URL, and on an
    /// `https` one only when the server picks HTTP/1.1 through ALPN. A later
    /// call with the same name replaces an earlier one.
    ///
    /// No header set here or on a call reaches `User-Agent` or
    /// `X-TypeSafe-Runtime`: [`user_agent_product`](Self::user_agent_product)
    /// and [`send_runtime_header`](Self::send_runtime_header) are the only
    /// ways to change what they carry.
    #[must_use]
    pub fn default_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.default_headers.push((name.into(), value.into()));
        self
    }

    /// The largest response body a request reads, in bytes; 16 MiB unless
    /// set. A larger body is not read past the limit: a success response
    /// fails with [`ErrorKind::ResponseTooLarge`](crate::ErrorKind::ResponseTooLarge),
    /// a failure response is an API error without its body.
    #[must_use]
    pub fn max_response_bytes(mut self, limit: usize) -> Self {
        self.max_response_bytes = Some(limit);
        self
    }

    /// Trusts `der`, a DER-encoded certificate, in addition to the operating
    /// system's roots: for a corporate CA the system store lacks, or a test
    /// server's own certificate.
    ///
    /// The default transport only; see
    /// [`build_with_service`](Self::build_with_service).
    #[must_use]
    pub fn add_root_certificate(mut self, der: impl Into<Vec<u8>>) -> Self {
        self.extra_roots.push(der.into());
        self
    }

    /// Which HTTP versions the default transport speaks. The default is
    /// [`HttpVersion::Http2Only`] for an `https` base URL and
    /// [`HttpVersion::Auto`] for an `http` one.
    ///
    /// The default transport only; see
    /// [`build_with_service`](Self::build_with_service).
    #[must_use]
    pub fn http_version(mut self, version: HttpVersion) -> Self {
        self.http_version = Some(version);
        self
    }

    /// A deadline for opening a TCP connection, inside the deadline of the
    /// whole attempt. None unless set. When it passes, the request fails with
    /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) carrying this value.
    ///
    /// The default transport only; see
    /// [`build_with_service`](Self::build_with_service).
    #[must_use]
    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
        self.connect_timeout = Some(timeout);
        self
    }

    /// The retry policy of every call made through the client;
    /// [`RetryPolicy::default`] unless set. A call can replace it for itself
    /// with its own `retry`.
    #[must_use]
    pub fn retry(mut self, policy: RetryPolicy) -> Self {
        self.retry = Some(policy);
        self
    }

    /// A product that names the application, sent in `User-Agent` in front
    /// of the SDK's own: `user_agent_product("my-app/1.2.0")` sends
    /// `User-Agent: my-app/1.2.0 typesafe-sdk-rust/<version>`, the more
    /// significant product first as RFC 9110 (section 10.1.5) orders them.
    /// Unset, `User-Agent` is the SDK's identifier alone. `X-TypeSafe-SDK`
    /// always names the SDK alone. A later call replaces an earlier one.
    ///
    /// The product must be `name/version`, both parts tokens (RFC 9110,
    /// section 5.6.2: letters, digits and ``!#$%&'*+-.^_`|~``), with exactly
    /// one `/` and at most 64 bytes in all. That rules out whitespace,
    /// control characters, anything outside ASCII, a comment in parentheses
    /// and a product without a version.
    ///
    /// # Errors
    ///
    /// This method never fails. A product that breaks those rules makes
    /// [`build`](Self::build) and [`build_with_service`](Self::build_with_service)
    /// return an [`ErrorKind::Config`](crate::ErrorKind::Config) error naming
    /// the rule, before anything is sent.
    ///
    /// ```
    /// use typesafe_sdk::{Client, ErrorKind};
    ///
    /// // Building connects to nothing.
    /// let client = Client::builder()
    ///     .api_key("your-api-key")
    ///     .user_agent_product("my-app/1.2.0")
    ///     .build()?;
    /// # drop(client);
    ///
    /// let error = Client::builder()
    ///     .api_key("your-api-key")
    ///     .user_agent_product("my app")
    ///     .build()
    ///     .expect_err("a product with a space is refused");
    /// assert!(matches!(error.kind(), ErrorKind::Config));
    /// # Ok::<(), typesafe_sdk::Error>(())
    /// ```
    #[must_use]
    pub fn user_agent_product(mut self, product: impl Into<String>) -> Self {
        self.user_agent_product = Some(product.into());
        self
    }

    /// Whether requests carry `X-TypeSafe-Runtime: rust (<os>; <arch>)`,
    /// which tells the API the operating system and architecture the SDK was
    /// compiled for. The default is `true`; `false` leaves the header out of
    /// every request, so an application can keep its platform to itself.
    /// `X-TypeSafe-SDK` is sent either way. A later call replaces an earlier
    /// one.
    ///
    /// ```
    /// use typesafe_sdk::Client;
    ///
    /// // Building connects to nothing.
    /// let client = Client::builder().api_key("your-api-key").send_runtime_header(false).build()?;
    /// # drop(client);
    /// # Ok::<(), typesafe_sdk::Error>(())
    /// ```
    #[must_use]
    pub fn send_runtime_header(mut self, send: bool) -> Self {
        self.omit_runtime_header = !send;
        self
    }

    /// Builds a client with the default transport.
    ///
    /// Settings left unset are read from the environment. Nothing connects
    /// here: the first request, or [`Client::warm_up`], does.
    ///
    /// # Errors
    ///
    /// Returns an [`ErrorKind::Config`](crate::ErrorKind::Config) error when
    /// no API key is found or the key is blank or not printable ASCII; when
    /// the base URL is not an absolute `http` or `https` URL without
    /// userinfo, query or fragment; when the default model is blank; when a
    /// deadline or the response limit is zero; when a default header is not a
    /// valid header; when the [`user_agent_product`](Self::user_agent_product)
    /// is not a product token; when an environment variable is not UTF-8; or
    /// when the certificate verifier cannot be built, for an added root that
    /// is not a certificate among other causes. No message repeats the key, a
    /// header value or the URL.
    pub fn build(self) -> Result<Client<HyperTransport>, Error> {
        self.build_with_env(|name: &str| std::env::var_os(name))
    }

    /// Builds a client that sends its requests through `service`.
    ///
    /// This is how a client runs over a transport of the caller's own: a
    /// proxy, a recorder, a `tower` stack with its own middleware. The
    /// service owns its connections and their timeouts; the SDK still wraps
    /// each attempt in its own deadline and reads the response under its own
    /// limit.
    ///
    /// When the service fails, its error's text becomes the connection
    /// error's message, escaped and cut at 200 characters; the SDK cannot know
    /// what that text holds, so a service that prints a request header into
    /// its error puts that header's value into the message.
    ///
    /// # Errors
    ///
    /// Everything [`build`](Self::build) refuses except the certificate
    /// verifier, and, as a config error, any of
    /// [`add_root_certificate`](Self::add_root_certificate),
    /// [`http_version`](Self::http_version) and
    /// [`connect_timeout`](Self::connect_timeout): they configure the default
    /// transport, which this client does not have, and are refused rather
    /// than ignored.
    pub fn build_with_service<S>(self, service: S) -> Result<Client<S>, Error>
    where
        S: HttpService,
    {
        let mut unused = Vec::new();
        if !self.extra_roots.is_empty() {
            unused.push("add_root_certificate");
        }
        if self.http_version.is_some() {
            unused.push("http_version");
        }
        if self.connect_timeout.is_some() {
            unused.push("connect_timeout");
        }
        if !unused.is_empty() {
            let verb = if unused.len() == 1 { "configures" } else { "configure" };
            return Err(Error::config(format!(
                "{} {verb} the default transport, and a client built with \
                 build_with_service has a transport of its own.",
                unused.join(", ")
            )));
        }
        let (explicit, _, retry) = self.split()?;
        let config = Config::resolve(explicit, |name: &str| std::env::var_os(name))?;
        Ok(Client::assemble(config, retry, service))
    }

    /// [`build`](Self::build) with the environment read through `env`.
    pub(crate) fn build_with_env<V>(
        self,
        env: impl Fn(&str) -> Option<V>,
    ) -> Result<Client<HyperTransport>, Error>
    where
        V: Into<OsString>,
    {
        let (explicit, transport, retry) = self.split()?;
        let config = Config::resolve(explicit, env)?;
        let https = config.endpoints().system_one().scheme() == Some(&Scheme::HTTPS);
        let settings = TransportSettings {
            version: transport.version.unwrap_or(if https {
                HttpVersion::Http2Only
            } else {
                HttpVersion::Auto
            }),
            extra_roots: transport.extra_roots,
            connect_timeout: transport.connect_timeout,
        };
        Ok(Client::assemble(config, retry, HyperTransport::new(settings)?))
    }

    /// Checks what only the builder can check and separates the settings of
    /// the configuration from those of the default transport and the retry
    /// policy.
    fn split(self) -> Result<(Explicit, TransportChoices, RetryPolicy), Error> {
        let Self {
            api_key,
            base_url,
            default_model,
            timeout,
            default_headers,
            max_response_bytes,
            extra_roots,
            http_version,
            connect_timeout,
            retry,
            user_agent_product,
            omit_runtime_header,
        } = self;

        if connect_timeout.is_some_and(|timeout| timeout.is_zero()) {
            return Err(Error::config("connect_timeout must be a positive number of seconds."));
        }
        let mut headers = HeaderMap::with_capacity(default_headers.len());
        for (name, value) in &default_headers {
            let (name, value) =
                transport::parse_header(name, value, "default ").map_err(Error::config)?;
            headers.insert(name, value);
        }

        let explicit = Explicit {
            api_key,
            base_url,
            default_model,
            timeout,
            default_headers: headers,
            max_response_bytes,
            user_agent_product,
            omit_runtime_header,
        };
        Ok((
            explicit,
            TransportChoices { version: http_version, extra_roots, connect_timeout },
            retry.unwrap_or_default(),
        ))
    }
}

/// The builder's settings for the default transport, before the base URL
/// decides the default version.
struct TransportChoices {
    version: Option<HttpVersion>,
    extra_roots: Vec<Vec<u8>>,
    connect_timeout: Option<Duration>,
}

impl fmt::Debug for ClientBuilder {
    /// What was set, without the key, without header values, and with the
    /// roots as a count. The base URL is shown only once it has passed the
    /// checks [`build`](ClientBuilder::build) runs, and then as its endpoints,
    /// the way an error names them: a URL that failed them may still hold
    /// userinfo. A retry policy, a `User-Agent` product and a runtime header
    /// switched off are shown when they were set; the product is quoted and
    /// escaped, since until it is built it may hold anything.
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let base_url = self.base_url.as_deref().map(|url| {
            crate::config::endpoints(url.trim_end_matches('/'))
                .map_or_else(|_| Shown::Text("<not a usable URL>"), Shown::Endpoints)
        });
        let mut shown = formatter.debug_struct("ClientBuilder");
        shown
            .field("api_key", &self.api_key.as_ref().map(|_| Shown::Text("<redacted>")))
            .field("base_url", &base_url)
            .field("default_model", &self.default_model)
            .field("timeout", &self.timeout)
            .field(
                "default_headers",
                &self.default_headers.iter().map(|(name, _)| name).collect::<Vec<_>>(),
            )
            .field("max_response_bytes", &self.max_response_bytes)
            .field("extra_roots", &self.extra_roots.len())
            .field("http_version", &self.http_version)
            .field("connect_timeout", &self.connect_timeout);
        if let Some(retry) = &self.retry {
            shown.field("retry", retry);
        }
        if let Some(product) = &self.user_agent_product {
            shown.field("user_agent_product", &Shown::Quoted(crate::text::quoted(product)));
        }
        if self.omit_runtime_header {
            shown.field("send_runtime_header", &false);
        }
        shown.finish()
    }
}

/// A value the builder's `Debug` prints in place of the one it holds.
enum Shown {
    Text(&'static str),
    /// Text already quoted and escaped by [`crate::text::quoted`].
    Quoted(String),
    Endpoints(crate::config::Endpoints),
}

impl fmt::Debug for Shown {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Text(text) => formatter.write_str(text),
            Self::Quoted(text) => formatter.write_str(text),
            Self::Endpoints(endpoints) => fmt::Debug::fmt(endpoints, formatter),
        }
    }
}

#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;