Skip to main content

typesafe/
client.rs

1//! The asynchronous client.
2
3use std::fmt;
4use std::future::{Future, IntoFuture};
5use std::path::PathBuf;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use bytes::Bytes;
11use http::header::{
12    ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT,
13};
14use http::{Method, StatusCode};
15use serde::Serialize;
16use serde_json::{Map, Value};
17
18use crate::cassette;
19use crate::constants::*;
20use crate::error::{ApiError, Error, ResponseValidationError, Result, lenient_body};
21use crate::question::Questions;
22use crate::response::{
23    DecodeFailure, DecodedSystemOne, ListModelsResponse, ResponseMeta, SystemOneResponse,
24    decode_models, decode_system_one,
25};
26use crate::retry::RetryPolicy;
27
28pub(crate) type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
29
30/// Builder for [`Client`]. Explicit settings win over environment variables; empty or
31/// whitespace-only environment values are ignored.
32///
33/// Its `Debug` output hides the API key and any credential-bearing header.
34#[must_use = "a builder does nothing until .build() is called"]
35#[derive(Default)]
36pub struct ClientBuilder {
37    api_key: Option<String>,
38    base_url: Option<String>,
39    model: Option<String>,
40    timeout: Option<Duration>,
41    retry: Option<RetryPolicy>,
42    headers: HeaderMap,
43    http: Option<reqwest::Client>,
44    record: Option<PathBuf>,
45    replay: Option<PathBuf>,
46}
47
48impl fmt::Debug for ClientBuilder {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.debug_struct("ClientBuilder")
51            .field("api_key", &self.api_key.as_ref().map(|_| "***"))
52            .field("base_url", &self.base_url)
53            .field("model", &self.model)
54            .field("timeout", &self.timeout)
55            .field("retry", &self.retry)
56            .field("headers", &redacted(&self.headers))
57            .field("http", &self.http)
58            .field("record", &self.record)
59            .field("replay", &self.replay)
60            .finish()
61    }
62}
63
64impl ClientBuilder {
65    /// API key (else `TYPESAFE_API_KEY`). Surrounding whitespace is trimmed; an empty key, or one
66    /// with whitespace, control or non-ASCII characters inside it, is rejected by `build`.
67    pub fn api_key(mut self, key: impl Into<String>) -> Self {
68        self.api_key = Some(key.into());
69        self
70    }
71
72    /// API root (else `TYPESAFE_BASE_URL`, else `https://api.typesafe.ai`).
73    pub fn base_url(mut self, url: impl Into<String>) -> Self {
74        self.base_url = Some(url.into());
75        self
76    }
77
78    /// Default model (else `TYPESAFE_DEFAULT_MODEL`, else `jev-latest`).
79    pub fn model(mut self, model: impl Into<String>) -> Self {
80        self.model = Some(model.into());
81        self
82    }
83
84    /// Per-attempt timeout (default 10s).
85    pub fn timeout(mut self, timeout: Duration) -> Self {
86        self.timeout = Some(timeout);
87        self
88    }
89
90    /// Default retry policy.
91    pub fn retry(mut self, policy: RetryPolicy) -> Self {
92        self.retry = Some(policy);
93        self
94    }
95
96    /// Extra header sent with every request. Authentication and SDK identification headers
97    /// cannot be overridden.
98    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
99        self.headers.insert(name, value);
100        self
101    }
102
103    /// Write each successful System One response to `<dir>/<key>.json` (else `TYPESAFE_RECORD`).
104    /// The directory is created if needed. See [`cassette`](crate::cassette).
105    pub fn record(mut self, dir: impl Into<PathBuf>) -> Self {
106        self.record = Some(dir.into());
107        self
108    }
109
110    /// Answer System One calls from `<dir>/<key>.json` instead of the network (else
111    /// `TYPESAFE_REPLAY`); no API key is needed. A request that was never recorded fails with
112    /// [`Error::ReplayMiss`]. See [`cassette`](crate::cassette).
113    pub fn replay(mut self, dir: impl Into<PathBuf>) -> Self {
114        self.replay = Some(dir.into());
115        self
116    }
117
118    /// Use your own `reqwest::Client` (proxies, TLS, connection pools…). Requires the
119    /// `reqwest-client` feature.
120    #[cfg(feature = "reqwest-client")]
121    #[cfg_attr(docsrs, doc(cfg(feature = "reqwest-client")))]
122    pub fn http_client(mut self, client: reqwest::Client) -> Self {
123        self.http = Some(client);
124        self
125    }
126
127    /// Build a [`blocking::Client`](crate::blocking::Client) (feature `blocking`).
128    ///
129    /// # Errors
130    ///
131    /// As for [`build`](Self::build), plus [`Error::Config`] if the runtime cannot be started.
132    #[cfg(feature = "blocking")]
133    #[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
134    pub fn build_blocking(self) -> Result<crate::blocking::Client> {
135        crate::blocking::Client::new(self.build()?)
136    }
137
138    /// Build the client.
139    ///
140    /// # Errors
141    ///
142    /// [`Error::Config`] if no API key is set (unless replaying) or it is malformed, the base URL
143    /// is not an http(s) URL, the timeout is zero, the retry policy is invalid, both record and
144    /// replay are set, or the record directory cannot be created. The cause, such as the
145    /// [`std::io::Error`] or the URL parse error, is the error's `source()`.
146    pub fn build(self) -> Result<Client> {
147        let cassette = match (
148            resolve_path(self.record, RECORD_ENV),
149            resolve_path(self.replay, REPLAY_ENV),
150        ) {
151            (Some(_), Some(_)) => {
152                return Err(Error::config(format!(
153                    "both record and replay are set, and a client does one or the other; unset \
154                     {RECORD_ENV} or {REPLAY_ENV}, or drop one of the builder calls"
155                )));
156            }
157            (Some(dir), None) => {
158                std::fs::create_dir_all(&dir).map_err(|e| {
159                    Error::config_caused(format!("cannot record into {}", dir.display()), e)
160                })?;
161                Some(Cassette::Record(dir))
162            }
163            (None, Some(dir)) => Some(Cassette::Replay(dir)),
164            (None, None) => None,
165        };
166        let replaying = matches!(cassette, Some(Cassette::Replay(_)));
167        // A replaying client never sends anything, so it has no use for a key.
168        let api_key = resolve(self.api_key, API_KEY_ENV, None)
169            .map(|k| k.trim().to_owned())
170            .filter(|k| !k.is_empty());
171        if api_key.is_none() && !replaying {
172            return Err(Error::config(format!(
173                "no API key was provided; pass api_key or set the {API_KEY_ENV} environment variable"
174            )));
175        }
176        if api_key
177            .as_deref()
178            .is_some_and(|k| !k.bytes().all(|b| b.is_ascii_graphic()))
179        {
180            return Err(Error::config(
181                "the API key must contain only printable ASCII characters without whitespace",
182            ));
183        }
184        let base_url = resolve(self.base_url, BASE_URL_ENV, Some(DEFAULT_BASE_URL))
185            .unwrap_or_default()
186            .trim_end_matches('/')
187            .to_owned();
188        check_base_url(&base_url)?;
189        let model = resolve(self.model, DEFAULT_MODEL_ENV, Some(DEFAULT_MODEL)).unwrap_or_default();
190        let timeout = check_timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))?;
191        let retry = self.retry.unwrap_or_default();
192        retry.validate()?;
193
194        let mut protected = HeaderMap::new();
195        if let Some(api_key) = api_key {
196            let mut auth = HeaderValue::from_str(&format!("Bearer {api_key}"))
197                .map_err(|e| Error::config_caused("the API key is not a valid header value", e))?;
198            auth.set_sensitive(true);
199            protected.insert(AUTHORIZATION, auth);
200        }
201        protected.insert(ACCEPT, HeaderValue::from_static("application/json"));
202        let ident = HeaderValue::from_str(&format!("{SDK_NAME}/{VERSION}")).expect("ascii");
203        protected.insert(USER_AGENT, ident.clone());
204        protected.insert(HeaderName::from_static(SDK_HEADER), ident);
205        protected.insert(
206            HeaderName::from_static(RUNTIME_HEADER),
207            HeaderValue::from_str(&format!(
208                "rust ({}; {})",
209                std::env::consts::OS,
210                std::env::consts::ARCH
211            ))
212            .expect("ascii"),
213        );
214
215        Ok(Client {
216            inner: Arc::new(Inner {
217                http: self.http.unwrap_or_default(),
218                base_url,
219                model,
220                timeout,
221                retry,
222                default_headers: self.headers,
223                protected,
224                cassette,
225            }),
226        })
227    }
228}
229
230fn resolve(explicit: Option<String>, env: &str, default: Option<&str>) -> Option<String> {
231    explicit
232        .or_else(|| {
233            std::env::var(env)
234                .ok()
235                .map(|v| v.trim().to_owned())
236                .filter(|v| !v.is_empty())
237        })
238        .or_else(|| default.map(str::to_owned))
239}
240
241fn resolve_path(explicit: Option<PathBuf>, env: &str) -> Option<PathBuf> {
242    explicit.or_else(|| resolve(None, env, None).map(PathBuf::from))
243}
244
245fn check_base_url(url: &str) -> Result<()> {
246    match reqwest::Url::parse(url) {
247        Ok(u) if matches!(u.scheme(), "http" | "https") && u.has_host() => Ok(()),
248        Ok(_) => Err(Error::config(format!(
249            "base_url must be an http(s) URL with a host, got {url:?}"
250        ))),
251        Err(e) => Err(Error::config_caused(
252            format!("base_url {url:?} is not a valid URL"),
253            e,
254        )),
255    }
256}
257
258fn check_timeout(t: Duration) -> Result<Duration> {
259    if t.is_zero() {
260        Err(Error::config("timeout must be a positive duration"))
261    } else {
262        Ok(t)
263    }
264}
265
266struct Inner {
267    http: reqwest::Client,
268    base_url: String,
269    model: String,
270    timeout: Duration,
271    retry: RetryPolicy,
272    default_headers: HeaderMap,
273    protected: HeaderMap,
274    cassette: Option<Cassette>,
275}
276
277/// Headers go through [`redacted`], so the API key and any gateway credential stay hidden.
278impl fmt::Debug for Inner {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        f.debug_struct("Inner")
281            .field("http", &self.http)
282            .field("base_url", &self.base_url)
283            .field("model", &self.model)
284            .field("timeout", &self.timeout)
285            .field("retry", &self.retry)
286            .field("default_headers", &redacted(&self.default_headers))
287            .field("protected", &redacted(&self.protected))
288            .field("cassette", &self.cassette)
289            .finish()
290    }
291}
292
293/// Where System One responses are recorded to or replayed from.
294#[derive(Debug)]
295enum Cassette {
296    Record(PathBuf),
297    Replay(PathBuf),
298}
299
300/// TypeSafe API client. Cheap to clone; clones share the connection pool.
301///
302/// ```no_run
303/// use typesafe::{Client, Choice, Noul, Questions, Score};
304///
305/// # async fn run() -> typesafe::Result<()> {
306/// let client = Client::from_env()?;
307/// let res = client
308///     .system_one(
309///         "I've been trying to connect Stripe for 3 days. Please help ASAP.",
310///         Questions::new()
311///             .with("department", Choice::new("Which team should handle this")
312///                 .option("billing", "Payment or subscription issues")
313///                 .option("technical", "Bugs or integration problems"))
314///             .with("frustration", Score::new("How frustrated", ["Calm", "Frustrated", "Very angry"]))
315///             .with("is_urgent", Noul::new("The message conveys urgency")),
316///     )
317///     .await?;
318/// println!("{}", res.choice("department").unwrap().choice);
319/// # Ok(()) }
320/// ```
321#[derive(Debug, Clone)]
322pub struct Client {
323    inner: Arc<Inner>,
324}
325
326impl Client {
327    /// Start configuring a client.
328    pub fn builder() -> ClientBuilder {
329        ClientBuilder::default()
330    }
331
332    /// A client configured entirely from the environment.
333    ///
334    /// # Errors
335    ///
336    /// [`Error::Config`], as for [`ClientBuilder::build`].
337    pub fn from_env() -> Result<Self> {
338        Self::builder().build()
339    }
340
341    /// The default model.
342    pub fn default_model(&self) -> &str {
343        &self.inner.model
344    }
345
346    /// Ask typed questions about `state` (a string, or anything `Serialize` that becomes a JSON
347    /// object/array). Returns a request builder: `.await` it directly or set per-call options first.
348    pub fn system_one<S: Serialize>(
349        &self,
350        state: S,
351        questions: impl Into<Questions>,
352    ) -> SystemOneRequest {
353        SystemOneRequest {
354            client: self.clone(),
355            state: serde_json::to_value(state),
356            questions: questions.into(),
357            model: None,
358            extra_body: Map::new(),
359            opts: CallOptions::default(),
360        }
361    }
362
363    /// The Models resource.
364    pub fn models(&self) -> Models {
365        Models {
366            client: self.clone(),
367        }
368    }
369
370    async fn execute(
371        &self,
372        method: Method,
373        path: &str,
374        body: Option<Bytes>,
375        opts: CallOptions,
376    ) -> Result<(Bytes, ResponseMeta, String)> {
377        let inner = &self.inner;
378        let retry = opts.retry.as_ref().unwrap_or(&inner.retry);
379        retry.validate()?;
380        let timeout = check_timeout(opts.timeout.unwrap_or(inner.timeout))?;
381        let url = format!("{}{}", inner.base_url, path);
382        let endpoint = format!("{method} {}", redact_url(&url));
383
384        let mut headers = inner.default_headers.clone();
385        headers.extend(opts.headers);
386        headers.remove(RETRY_COUNT_HEADER);
387        headers.extend(inner.protected.clone());
388        if body.is_some() {
389            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
390        }
391
392        let started = Instant::now();
393        let mut attempts: u32 = 0;
394        loop {
395            let mut h = headers.clone();
396            if attempts > 0 {
397                h.insert(
398                    HeaderName::from_static(RETRY_COUNT_HEADER),
399                    HeaderValue::from(attempts),
400                );
401                tracing::info!(%endpoint, retry = attempts, "retrying");
402            }
403            attempts += 1;
404            // `Bytes` clones share one buffer, so a retry does not copy the body.
405            let result = self
406                .attempt(&method, &url, &endpoint, h, body.clone(), timeout)
407                .await;
408            match result {
409                Ok((bytes, status, resp_headers)) => {
410                    let meta = ResponseMeta {
411                        status,
412                        headers: resp_headers,
413                        attempts,
414                    };
415                    return Ok((bytes, meta, endpoint));
416                }
417                Err(err) => {
418                    if !retry.is_retryable(&err) {
419                        return Err(err);
420                    }
421                    let delay = retry.delay(attempts, &err);
422                    if retry.should_stop(attempts, started.elapsed(), delay) {
423                        return Err(err);
424                    }
425                    if !delay.is_zero() {
426                        tokio::time::sleep(delay).await;
427                    }
428                }
429            }
430        }
431    }
432
433    async fn attempt(
434        &self,
435        method: &Method,
436        url: &str,
437        endpoint: &str,
438        headers: HeaderMap,
439        body: Option<Bytes>,
440        timeout: Duration,
441    ) -> Result<(Bytes, StatusCode, HeaderMap)> {
442        let t0 = Instant::now();
443        tracing::debug!(%endpoint, "->");
444        if tracing::enabled!(tracing::Level::TRACE) {
445            tracing::trace!(%endpoint, headers = ?redacted(&headers),
446                body = %body.as_deref().map(String::from_utf8_lossy).unwrap_or_default(), "->");
447        }
448        let mut req = self
449            .inner
450            .http
451            .request(method.clone(), url)
452            .headers(headers)
453            .timeout(timeout);
454        if let Some(b) = body {
455            req = req.body(b);
456        }
457        let map_err = |e: reqwest::Error| {
458            tracing::debug!(%endpoint, error = %e, "<- transport error");
459            if e.is_timeout() {
460                Error::Timeout(timeout)
461            } else {
462                Error::Connection(Box::new(e))
463            }
464        };
465        let resp = req.send().await.map_err(map_err)?;
466        let status = resp.status();
467        let resp_headers = resp.headers().clone();
468        let bytes = resp.bytes().await.map_err(map_err)?;
469
470        tracing::debug!(
471            %endpoint,
472            status = status.as_u16(),
473            elapsed_ms = t0.elapsed().as_millis() as u64,
474            request_id = resp_headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()).unwrap_or("-"),
475            "<-"
476        );
477        if tracing::enabled!(tracing::Level::TRACE) {
478            tracing::trace!(%endpoint, headers = ?redacted(&resp_headers),
479                body = %String::from_utf8_lossy(&bytes), "<-");
480        }
481
482        if !status.is_success() {
483            return Err(Error::Api(Box::new(ApiError::new(
484                status,
485                lenient_body(&bytes),
486                resp_headers,
487                Some(endpoint.to_owned()),
488            ))));
489        }
490        Ok((bytes, status, resp_headers))
491    }
492}
493
494fn validation_error(
495    status: StatusCode,
496    body: Option<Value>,
497    headers: HeaderMap,
498    endpoint: &str,
499    f: DecodeFailure,
500) -> Error {
501    Error::ResponseValidation(Box::new(ResponseValidationError {
502        status,
503        field_path: f.path,
504        detail: f.detail,
505        body,
506        headers,
507        endpoint: Some(endpoint.to_owned()),
508    }))
509}
510
511fn redact_url(url: &str) -> String {
512    match reqwest::Url::parse(url) {
513        Ok(mut u) => {
514            let _ = u.set_username("");
515            let _ = u.set_password(None);
516            u.set_query(None);
517            u.set_fragment(None);
518            u.to_string()
519        }
520        Err(_) => url.to_owned(),
521    }
522}
523
524/// A credential-bearing header: the known names, plus anything that says it carries one, as an AI
525/// gateway's own key does (`cf-aig-authorization`, `x-portkey-api-key`, `x-gateway-token`).
526fn is_secret(name: &str) -> bool {
527    let name = name.to_ascii_lowercase();
528    SECRET_HEADERS.contains(&name.as_str())
529        || ["authorization", "api-key", "token", "secret"]
530            .iter()
531            .any(|part| name.contains(part))
532}
533
534fn redacted(headers: &HeaderMap) -> Vec<(String, String)> {
535    headers
536        .iter()
537        .map(|(k, v)| {
538            let value = if is_secret(k.as_str()) {
539                "[REDACTED]".to_owned()
540            } else {
541                v.to_str().unwrap_or("<binary>").to_owned()
542            };
543            (k.as_str().to_owned(), value)
544        })
545        .collect()
546}
547
548#[derive(Debug, Default, Clone)]
549struct CallOptions {
550    retry: Option<RetryPolicy>,
551    timeout: Option<Duration>,
552    headers: HeaderMap,
553}
554
555macro_rules! call_option_methods {
556    () => {
557        /// Override the retry policy for this call.
558        pub fn retry(mut self, policy: RetryPolicy) -> Self {
559            self.opts.retry = Some(policy);
560            self
561        }
562
563        /// Override the per-attempt timeout for this call.
564        pub fn timeout(mut self, timeout: Duration) -> Self {
565            self.opts.timeout = Some(timeout);
566            self
567        }
568
569        /// Add a header for this call (protected headers still win).
570        pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
571            self.opts.headers.insert(name, value);
572            self
573        }
574    };
575}
576
577/// A pending `POST /v1/systemone`. Configure it, then `.await` it (or call [`send`](Self::send)).
578#[must_use = "requests do nothing until awaited"]
579#[derive(Debug)]
580pub struct SystemOneRequest {
581    client: Client,
582    state: serde_json::Result<Value>,
583    questions: Questions,
584    model: Option<String>,
585    extra_body: Map<String, Value>,
586    opts: CallOptions,
587}
588
589impl SystemOneRequest {
590    call_option_methods!();
591
592    /// Override the model for this call.
593    pub fn model(mut self, model: impl Into<String>) -> Self {
594        self.model = Some(model.into());
595        self
596    }
597
598    /// Add a top-level body field. A key named `state`, `model` or `questions` replaces the
599    /// standard field. Useful for API fields this SDK version does not model yet.
600    pub fn extra_body(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
601        self.extra_body.insert(key.into(), value.into());
602        self
603    }
604
605    /// Send the request.
606    ///
607    /// # Errors
608    ///
609    /// - [`Error::InvalidRequest`] if the state cannot be encoded or the questions are rejected
610    ///   locally (none, or a choice or score without criteria); nothing is sent.
611    /// - [`Error::Config`] if a per-call timeout or retry policy is invalid.
612    /// - [`Error::Api`], [`Error::Connection`] or [`Error::Timeout`] once retries are exhausted.
613    /// - [`Error::ResponseValidation`] if a 2xx body does not decode.
614    /// - [`Error::ReplayMiss`] if replaying and the request was never recorded, or
615    ///   [`Error::Config`] if its recording exists but cannot be read.
616    pub async fn send(self) -> Result<SystemOneResponse> {
617        let state = self.state.map_err(|e| {
618            Error::invalid_request_caused("the state could not be encoded as JSON", e)
619        })?;
620        self.questions.validate()?;
621        let model = self
622            .model
623            .unwrap_or_else(|| self.client.inner.model.clone());
624        // Serialized as a struct (not via `serde_json::Value`) so question order reaches the wire
625        // unchanged. `extra_body` keys replace the standard field of the same name.
626        let extra = &self.extra_body;
627        let body = SystemOneBody {
628            state: (!extra.contains_key("state")).then_some(&state),
629            model: (!extra.contains_key("model")).then_some(&model),
630            questions: (!extra.contains_key("questions")).then_some(&self.questions),
631            extra,
632        };
633        let bytes = serde_json::to_vec(&body).map_err(|e| {
634            Error::invalid_request_caused("the request body could not be encoded as JSON", e)
635        })?;
636
637        // Only a recording or replaying client needs the body's hash.
638        let cassette = self
639            .client
640            .inner
641            .cassette
642            .as_ref()
643            .map(|c| (c, cassette::body_key(&bytes)));
644        let (bytes, meta, endpoint) = match &cassette {
645            Some((Cassette::Replay(dir), key)) => replay(dir, key)?,
646            _ => {
647                self.client
648                    .execute(Method::POST, SYSTEM_ONE_PATH, Some(bytes.into()), self.opts)
649                    .await?
650            }
651        };
652        let decoded = decode_system_one(&bytes);
653        if let (Ok(_), Some((Cassette::Record(dir), key))) = (&decoded, &cassette)
654            && let Err(e) = cassette::write(dir, key, &bytes)
655        {
656            tracing::warn!(dir = %dir.display(), %key, error = %e, "could not record the response");
657        }
658        match decoded {
659            Ok(DecodedSystemOne {
660                model,
661                usage,
662                answers,
663                raw,
664            }) => Ok(SystemOneResponse {
665                model,
666                usage,
667                answers,
668                raw,
669                meta,
670            }),
671            Err(f) => Err(validation_error(
672                meta.status,
673                lenient_body(&bytes),
674                meta.headers,
675                &endpoint,
676                f,
677            )),
678        }
679    }
680}
681
682/// The recorded response for `key`, in the shape a live call returns: no headers, no attempts.
683fn replay(dir: &std::path::Path, key: &str) -> Result<(Bytes, ResponseMeta, String)> {
684    let path = cassette::path(dir, key);
685    let bytes = std::fs::read(&path).map_err(|e| match e.kind() {
686        std::io::ErrorKind::NotFound => Error::ReplayMiss {
687            key: key.to_owned(),
688            path: path.clone(),
689        },
690        _ => Error::config_caused(format!("cannot read the recording {}", path.display()), e),
691    })?;
692    tracing::debug!(path = %path.display(), "<- replayed");
693    let meta = ResponseMeta {
694        status: StatusCode::OK,
695        headers: HeaderMap::new(),
696        attempts: 0,
697    };
698    Ok((bytes.into(), meta, format!("replay {}", path.display())))
699}
700
701#[derive(Serialize)]
702struct SystemOneBody<'a> {
703    #[serde(skip_serializing_if = "Option::is_none")]
704    state: Option<&'a Value>,
705    #[serde(skip_serializing_if = "Option::is_none")]
706    model: Option<&'a str>,
707    #[serde(skip_serializing_if = "Option::is_none")]
708    questions: Option<&'a Questions>,
709    #[serde(flatten)]
710    extra: &'a Map<String, Value>,
711}
712
713impl IntoFuture for SystemOneRequest {
714    type Output = Result<SystemOneResponse>;
715    type IntoFuture = BoxFuture<'static, Self::Output>;
716
717    fn into_future(self) -> Self::IntoFuture {
718        Box::pin(self.send())
719    }
720}
721
722/// The Models resource.
723#[derive(Debug, Clone)]
724pub struct Models {
725    client: Client,
726}
727
728impl Models {
729    /// `GET /v1/models`.
730    pub fn list(&self) -> ListModelsRequest {
731        ListModelsRequest {
732            client: self.client.clone(),
733            opts: CallOptions::default(),
734        }
735    }
736}
737
738/// A pending `GET /v1/models`.
739#[must_use = "requests do nothing until awaited"]
740#[derive(Debug)]
741pub struct ListModelsRequest {
742    client: Client,
743    opts: CallOptions,
744}
745
746impl ListModelsRequest {
747    call_option_methods!();
748
749    /// Send the request.
750    ///
751    /// # Errors
752    ///
753    /// - [`Error::Config`] if a per-call timeout or retry policy is invalid, or the client is
754    ///   replaying (model listings are not recorded).
755    /// - [`Error::Api`], [`Error::Connection`] or [`Error::Timeout`] once retries are exhausted.
756    /// - [`Error::ResponseValidation`] if a 2xx body does not decode.
757    pub async fn send(self) -> Result<ListModelsResponse> {
758        if let Some(Cassette::Replay(dir)) = &self.client.inner.cassette {
759            return Err(Error::config(format!(
760                "listing models is not recorded, so a client replaying from {} cannot answer it",
761                dir.display()
762            )));
763        }
764        let (bytes, meta, endpoint) = self
765            .client
766            .execute(Method::GET, MODELS_PATH, None, self.opts)
767            .await?;
768        match decode_models(&bytes) {
769            Ok((models, raw)) => Ok(ListModelsResponse { models, raw, meta }),
770            Err(f) => Err(validation_error(
771                meta.status,
772                lenient_body(&bytes),
773                meta.headers,
774                &endpoint,
775                f,
776            )),
777        }
778    }
779}
780
781impl IntoFuture for ListModelsRequest {
782    type Output = Result<ListModelsResponse>;
783    type IntoFuture = BoxFuture<'static, Self::Output>;
784
785    fn into_future(self) -> Self::IntoFuture {
786        Box::pin(self.send())
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    #[test]
795    fn debug_output_hides_the_key() {
796        let builder = Client::builder()
797            .api_key("sk-very-secret")
798            .header(
799                HeaderName::from_static("x-portkey-api-key"),
800                HeaderValue::from_static("gw-very-secret"),
801            )
802            .base_url("https://example.test");
803        let shown = format!("{builder:?}");
804        assert!(!shown.contains("very-secret"), "{shown}");
805        assert!(
806            shown.contains("***") && shown.contains("example.test"),
807            "{shown}"
808        );
809
810        let client = builder.build().unwrap();
811        let shown = format!("{client:?}");
812        assert!(!shown.contains("very-secret"), "{shown}");
813        assert!(shown.contains("example.test"), "{shown}");
814    }
815
816    #[test]
817    fn masks_a_gateways_key_as_well_as_the_apis() {
818        for name in [
819            "Authorization",
820            "cookie",
821            "cf-aig-authorization",
822            "x-portkey-api-key",
823            "x-gateway-token",
824            "x-client-secret",
825        ] {
826            assert!(is_secret(name), "{name}");
827        }
828        for name in ["content-type", "x-typesafe-request-id", "retry-after"] {
829            assert!(!is_secret(name), "{name}");
830        }
831    }
832}