Skip to main content

jev_sdk/
client.rs

1//! The TypeSafe API client.
2
3use reqwest::header::{self, HeaderMap, HeaderValue};
4use serde::Serialize;
5use serde_json::{Map, Value};
6use std::fmt;
7use std::net::IpAddr;
8use std::time::Duration;
9
10use crate::error::{ApiError, Error};
11use crate::models::ListModelsResponse;
12use crate::question::Question;
13use crate::response::SystemOneResponse;
14use crate::retry::RetryPolicy;
15
16/// Default API root.
17pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
18/// Default model used when none is configured.
19pub const DEFAULT_MODEL: &str = "jev-latest";
20/// Default request timeout.
21pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
22
23/// Environment variable for the API key.
24pub const ENV_API_KEY: &str = "TYPESAFE_API_KEY";
25/// Environment variable for the default model.
26pub const ENV_DEFAULT_MODEL: &str = "TYPESAFE_DEFAULT_MODEL";
27/// Environment variable for the API root.
28pub const ENV_BASE_URL: &str = "TYPESAFE_BASE_URL";
29
30const USER_AGENT: &str = concat!("jev-sdk/", env!("CARGO_PKG_VERSION"));
31
32/// Upper bound for a successful response body. Real System One responses are
33/// a few KiB; this is generous headroom. Guards against memory exhaustion
34/// from a hostile or buggy endpoint (only reachable via a custom base URL).
35const MAX_SUCCESS_BODY: u64 = 16 * 1024 * 1024; // 16 MiB
36
37/// Upper bound for how much of an *error* response body is read. Anything
38/// past this is discarded; the status code alone drives error handling.
39const MAX_ERROR_BODY: usize = 64 * 1024; // 64 KiB
40
41/// An asynchronous client for the TypeSafe AI System One API.
42///
43/// ```
44/// use jev_sdk::{Noul, TypeSafeClient};
45///
46/// # async fn example() -> Result<(), jev_sdk::Error> {
47/// let client = TypeSafeClient::new("apikey_...")?;
48/// let response = client
49///     .system_one(
50///         "I was charged twice. Please help.",
51///         [("billing", Noul::new("Is this about billing?"))],
52///     )
53///     .await?;
54/// println!("{:?}", response.noul("billing").unwrap().noul);
55/// # Ok(())
56/// # }
57/// ```
58#[derive(Clone)]
59pub struct TypeSafeClient {
60    http: reqwest::Client,
61    /// `Bearer <key>`. Marked sensitive *and* excluded from `Debug` below;
62    /// never log or print this value.
63    auth: HeaderValue,
64    base_url: String,
65    model: String,
66    retry: RetryPolicy,
67}
68
69// Manual impl so the API key can never leak through formatting, even if the
70// `HeaderValue` sensitive flag regresses.
71impl fmt::Debug for TypeSafeClient {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.debug_struct("TypeSafeClient")
74            .field("auth", &"[redacted]")
75            .field("base_url", &self.base_url)
76            .field("model", &self.model)
77            .field("retry", &self.retry)
78            .finish_non_exhaustive()
79    }
80}
81
82impl TypeSafeClient {
83    /// Create a client with the given API key and default settings.
84    pub fn new(api_key: impl Into<String>) -> Result<Self, Error> {
85        Self::builder().api_key(api_key).build()
86    }
87
88    /// Create a client from environment variables.
89    ///
90    /// * [`ENV_API_KEY`] (`TYPESAFE_API_KEY`) — required.
91    /// * [`ENV_DEFAULT_MODEL`] (`TYPESAFE_DEFAULT_MODEL`) — optional.
92    /// * [`ENV_BASE_URL`] (`TYPESAFE_BASE_URL`) — optional.
93    pub fn from_env() -> Result<Self, Error> {
94        Self::builder().build()
95    }
96
97    /// Start building a client with custom settings.
98    pub fn builder() -> ClientBuilder {
99        ClientBuilder::default()
100    }
101
102    /// The model this client uses by default.
103    pub fn model(&self) -> &str {
104        &self.model
105    }
106
107    /// Answer named questions about text or structured `state`.
108    ///
109    /// `questions` maps question ids (you choose them; answers come back under
110    /// the same keys) to [`Question`]s. `Noul`, `Choice`, and `Score` all
111    /// convert into `Question`, and the different types can be mixed freely in
112    /// one call — every question is evaluated in parallel against the same
113    /// state.
114    pub async fn system_one<S, I, K, Q>(
115        &self,
116        state: S,
117        questions: I,
118    ) -> Result<SystemOneResponse, Error>
119    where
120        S: Serialize,
121        I: IntoIterator<Item = (K, Q)>,
122        K: Into<String>,
123        Q: Into<Question>,
124    {
125        let model = self.model.clone();
126        self.system_one_with_model(&model, state, questions).await
127    }
128
129    /// Like [`TypeSafeClient::system_one`], with a per-call model override.
130    pub async fn system_one_with_model<S, I, K, Q>(
131        &self,
132        model: &str,
133        state: S,
134        questions: I,
135    ) -> Result<SystemOneResponse, Error>
136    where
137        S: Serialize,
138        I: IntoIterator<Item = (K, Q)>,
139        K: Into<String>,
140        Q: Into<Question>,
141    {
142        let questions = build_questions_map(questions)?;
143        let state =
144            serde_json::to_value(state).map_err(|e| Error::InvalidRequest(e.to_string()))?;
145
146        let mut body = Map::new();
147        body.insert("state".to_owned(), state);
148        body.insert("model".to_owned(), Value::String(model.to_owned()));
149        body.insert("questions".to_owned(), Value::Object(questions));
150
151        let url = self.endpoint("/v1/systemone");
152        let body = Value::Object(body);
153        self.send_with_retry(|| self.http.post(&url).json(&body))
154            .await
155    }
156
157    /// List the models available to the account.
158    pub async fn list_models(&self) -> Result<ListModelsResponse, Error> {
159        let url = self.endpoint("/v1/models");
160        self.send_with_retry(|| self.http.get(&url)).await
161    }
162
163    fn endpoint(&self, path: &str) -> String {
164        format!("{}{path}", self.base_url.trim_end_matches('/'))
165    }
166
167    async fn send_with_retry<T, F>(&self, make_request: F) -> Result<T, Error>
168    where
169        T: serde::de::DeserializeOwned,
170        F: Fn() -> reqwest::RequestBuilder,
171    {
172        let mut attempt = 0u32;
173        loop {
174            let request = make_request()
175                .header(header::AUTHORIZATION, self.auth.clone())
176                .header(header::ACCEPT, "application/json");
177            match request.send().await {
178                Ok(resp) => {
179                    let status = resp.status();
180                    if status.is_success() {
181                        let body = read_body_limited(resp, MAX_SUCCESS_BODY).await?;
182                        return serde_json::from_slice::<T>(&body)
183                            .map_err(|e| Error::Decode(e.to_string()));
184                    }
185                    let headers = resp.headers().clone();
186                    let body = read_body_prefix(resp, MAX_ERROR_BODY).await;
187                    let raw = String::from_utf8_lossy(&body);
188                    let err = ApiError::from_response(status, &raw);
189                    if attempt < self.retry.max_retries && self.retry.should_retry_status(status) {
190                        let wait = self.retry.delay_for(attempt, &headers);
191                        tokio::time::sleep(wait).await;
192                        attempt += 1;
193                        continue;
194                    }
195                    return Err(Error::Api(err));
196                }
197                Err(e) => {
198                    let transient = e.is_connect() || e.is_timeout();
199                    if attempt < self.retry.max_retries && transient {
200                        let wait = self.retry.backoff_for(attempt);
201                        tokio::time::sleep(wait).await;
202                        attempt += 1;
203                        continue;
204                    }
205                    return Err(Error::Connection(e.to_string()));
206                }
207            }
208        }
209    }
210}
211
212/// Builds [`TypeSafeClient`]s.
213///
214/// Explicit options take precedence over environment variables, which take
215/// precedence over defaults. Empty or whitespace-only environment values are
216/// ignored.
217#[derive(Default)]
218pub struct ClientBuilder {
219    api_key: Option<String>,
220    model: Option<String>,
221    base_url: Option<String>,
222    timeout: Option<Duration>,
223    retry: Option<RetryPolicy>,
224    default_headers: Option<HeaderMap>,
225    http_client: Option<reqwest::Client>,
226}
227
228// Manual impl so the API key can never leak through formatting.
229impl fmt::Debug for ClientBuilder {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.debug_struct("ClientBuilder")
232            .field("api_key", &self.api_key.as_ref().map(|_| "[redacted]"))
233            .field("model", &self.model)
234            .field("base_url", &self.base_url)
235            .field("timeout", &self.timeout)
236            .field("retry", &self.retry)
237            .field("default_headers", &self.default_headers)
238            .finish_non_exhaustive()
239    }
240}
241
242impl ClientBuilder {
243    /// Set the API key. Required unless [`ENV_API_KEY`] is set.
244    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
245        self.api_key = Some(api_key.into());
246        self
247    }
248
249    /// Set the default model (default: `jev-latest`).
250    pub fn model(mut self, model: impl Into<String>) -> Self {
251        self.model = Some(model.into());
252        self
253    }
254
255    /// Set the API root (default: `https://api.typesafe.ai`).
256    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
257        self.base_url = Some(base_url.into());
258        self
259    }
260
261    /// Set the per-request timeout (default: 60s).
262    pub fn timeout(mut self, timeout: Duration) -> Self {
263        self.timeout = Some(timeout);
264        self
265    }
266
267    /// Set the retry policy (default: [`RetryPolicy::default`]).
268    pub fn retry(mut self, retry: RetryPolicy) -> Self {
269        self.retry = Some(retry);
270        self
271    }
272
273    /// Additional headers to send with every request. `Authorization`,
274    /// `Accept`, and `Content-Type` remain managed by the SDK.
275    pub fn default_headers(mut self, headers: HeaderMap) -> Self {
276        self.default_headers = Some(headers);
277        self
278    }
279
280    /// Supply a preconfigured `reqwest` client.
281    ///
282    /// The `timeout` and `default_headers` options are ignored when a client
283    /// is supplied, and the supplied client's configuration wins. The caller
284    /// is then responsible for its hardening (TLS verification, redirect
285    /// policy, timeouts) — the SDK's defaults (redirects disabled, 60s
286    /// timeout, rustls with certificate verification) no longer apply.
287    pub fn http_client(mut self, client: reqwest::Client) -> Self {
288        self.http_client = Some(client);
289        self
290    }
291
292    /// Build the client.
293    pub fn build(self) -> Result<TypeSafeClient, Error> {
294        let api_key = resolve(self.api_key, ENV_API_KEY).ok_or_else(|| {
295            Error::Config(format!(
296                "missing API key: set {ENV_API_KEY} or use ClientBuilder::api_key"
297            ))
298        })?;
299        if api_key.trim().is_empty() {
300            return Err(Error::Config("API key is empty".into()));
301        }
302
303        let base_url =
304            resolve(self.base_url, ENV_BASE_URL).unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
305        validate_base_url(&base_url)?;
306
307        let model =
308            resolve(self.model, ENV_DEFAULT_MODEL).unwrap_or_else(|| DEFAULT_MODEL.to_owned());
309
310        let mut auth = HeaderValue::from_str(&format!("Bearer {api_key}"))
311            .map_err(|_| Error::Config("API key contains invalid header characters".into()))?;
312        auth.set_sensitive(true);
313
314        let http = match self.http_client {
315            Some(client) => client,
316            None => {
317                let mut builder = reqwest::Client::builder()
318                    .user_agent(USER_AGENT)
319                    .timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
320                    // API endpoints never legitimately redirect. Refusing to
321                    // follow them removes any risk of the Authorization
322                    // header being forwarded to an unexpected host.
323                    .redirect(reqwest::redirect::Policy::none());
324                if let Some(headers) = self.default_headers {
325                    builder = builder.default_headers(headers);
326                }
327                builder
328                    .build()
329                    .map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?
330            }
331        };
332
333        Ok(TypeSafeClient {
334            http,
335            auth,
336            base_url,
337            model,
338            retry: self.retry.unwrap_or_default(),
339        })
340    }
341}
342
343/// Resolve an explicit option against an environment variable; blank values
344/// lose.
345fn resolve(explicit: Option<String>, env: &str) -> Option<String> {
346    explicit.or_else(|| std::env::var(env).ok()).and_then(|v| {
347        let trimmed = v.trim();
348        (!trimmed.is_empty()).then_some(v)
349    })
350}
351
352/// Require the base URL to be valid and safe to send credentials to: HTTPS
353/// everywhere, with plain HTTP allowed only for loopback hosts (local test
354/// servers).
355fn validate_base_url(base_url: &str) -> Result<(), Error> {
356    let url = reqwest::Url::parse(base_url)
357        .map_err(|_| Error::Config(format!("invalid base URL: {base_url:?}")))?;
358    if url.scheme() == "https" {
359        return Ok(());
360    }
361    if url.scheme() == "http" && url.host_str().is_some_and(is_loopback_host) {
362        return Ok(());
363    }
364    Err(Error::Config(format!(
365        "base URL {base_url:?} must use https (http is only allowed for loopback hosts); \
366         refusing to send the API key over plaintext"
367    )))
368}
369
370/// Whether a host is loopback: `localhost` (including subdomains) or a
371/// loopback IP literal. `url::Url::host_str` keeps the brackets on IPv6
372/// literals, so strip them before parsing.
373fn is_loopback_host(host: &str) -> bool {
374    if host == "localhost" || host.ends_with(".localhost") {
375        return true;
376    }
377    let bare = host.strip_prefix('[').and_then(|h| h.strip_suffix(']'));
378    let candidate = bare.unwrap_or(host);
379    candidate.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
380}
381
382/// Read a successful response body, failing if it exceeds `limit` bytes.
383/// Works even when the server omits or lies about `Content-Length`.
384async fn read_body_limited(mut resp: reqwest::Response, limit: u64) -> Result<Vec<u8>, Error> {
385    if resp.content_length().is_some_and(|len| len > limit) {
386        return Err(Error::Decode(format!(
387            "response body too large (Content-Length exceeds {limit} bytes)"
388        )));
389    }
390    let mut buf = Vec::new();
391    while let Some(chunk) = resp
392        .chunk()
393        .await
394        .map_err(|e| Error::Connection(e.to_string()))?
395    {
396        if buf.len() as u64 + chunk.len() as u64 > limit {
397            return Err(Error::Decode(format!(
398                "response body exceeded the {limit}-byte limit"
399            )));
400        }
401        buf.extend_from_slice(&chunk);
402    }
403    Ok(buf)
404}
405
406/// Read at most `limit` bytes of an error response body; the rest is
407/// discarded. Error handling must not fail just because the body is huge.
408async fn read_body_prefix(mut resp: reqwest::Response, limit: usize) -> Vec<u8> {
409    let mut buf = Vec::new();
410    loop {
411        let remaining = limit.saturating_sub(buf.len());
412        if remaining == 0 {
413            break;
414        }
415        match resp.chunk().await {
416            Ok(Some(chunk)) => buf.extend_from_slice(&chunk[..chunk.len().min(remaining)]),
417            Ok(None) | Err(_) => break,
418        }
419    }
420    buf
421}
422
423fn build_questions_map<I, K, Q>(questions: I) -> Result<Map<String, Value>, Error>
424where
425    I: IntoIterator<Item = (K, Q)>,
426    K: Into<String>,
427    Q: Into<Question>,
428{
429    let mut map = Map::new();
430    for (key, question) in questions {
431        let question = question.into();
432        if let Question::Score(score) = &question {
433            if score.criteria.is_empty() {
434                return Err(Error::InvalidRequest(format!(
435                    "score question {:?} must have at least one criteria level",
436                    key.into()
437                )));
438            }
439        }
440        let key = key.into();
441        let value = serde_json::to_value(&question)
442            .map_err(|e| Error::InvalidRequest(format!("question {key:?}: {e}")))?;
443        map.insert(key, value);
444    }
445    if map.is_empty() {
446        return Err(Error::InvalidRequest(
447            "questions must not be empty".to_owned(),
448        ));
449    }
450    Ok(map)
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::question::{Choice, Noul, Score};
457
458    #[test]
459    fn builder_requires_api_key() {
460        // Ensure the env var cannot leak in from the test environment.
461        let key_present = std::env::var(ENV_API_KEY).is_ok();
462        let result = TypeSafeClient::builder().build();
463        if key_present {
464            assert!(result.is_ok());
465        } else {
466            assert!(matches!(result, Err(Error::Config(_))));
467        }
468    }
469
470    #[test]
471    fn builder_rejects_bad_base_url() {
472        let result = TypeSafeClient::builder()
473            .api_key("k")
474            .base_url("not a url")
475            .build();
476        assert!(matches!(result, Err(Error::Config(_))));
477    }
478
479    #[test]
480    fn builder_rejects_plaintext_http_base_url() {
481        // Plain HTTP to a remote host would leak the API key.
482        let result = TypeSafeClient::builder()
483            .api_key("k")
484            .base_url("http://api.typesafe.ai")
485            .build();
486        let err = result.unwrap_err();
487        assert!(matches!(err, Error::Config(_)));
488        assert!(err.to_string().contains("https"), "{err}");
489
490        // Other schemes are rejected too.
491        let result = TypeSafeClient::builder()
492            .api_key("k")
493            .base_url("ftp://example.com")
494            .build();
495        assert!(matches!(result.unwrap_err(), Error::Config(_)));
496    }
497
498    #[test]
499    fn builder_allows_loopback_http_for_testing() {
500        for url in [
501            "http://localhost:8080",
502            "http://127.0.0.1:8080",
503            "http://[::1]",
504            "http://test.localhost",
505            "https://api.example.com",
506        ] {
507            TypeSafeClient::builder()
508                .api_key("k")
509                .base_url(url)
510                .build()
511                .unwrap_or_else(|e| panic!("{url} should be accepted: {e}"));
512        }
513    }
514
515    #[test]
516    fn builder_rejects_key_with_control_characters() {
517        // A newline in the key must never reach a header (CRLF injection).
518        let result = TypeSafeClient::builder()
519            .api_key("good\r\nX-Injected: bad")
520            .build();
521        assert!(matches!(result.unwrap_err(), Error::Config(_)));
522    }
523
524    #[test]
525    fn debug_formatting_never_exposes_the_api_key() {
526        let secret = "apikey_supersecretvalue";
527        let builder = TypeSafeClient::builder().api_key(secret);
528        let client = TypeSafeClient::new(secret).unwrap();
529
530        for rendered in [format!("{builder:?}"), format!("{client:?}")] {
531            assert!(
532                !rendered.contains(secret),
533                "secret leaked into Debug output: {rendered}"
534            );
535            assert!(rendered.contains("[redacted]"), "{rendered}");
536        }
537    }
538
539    #[test]
540    fn rejects_empty_questions() {
541        let questions: Vec<(String, Question)> = vec![];
542        let err = build_questions_map(questions).unwrap_err();
543        assert!(matches!(err, Error::InvalidRequest(_)));
544    }
545
546    #[test]
547    fn rejects_empty_score_criteria() {
548        let questions = vec![(
549            "s",
550            Question::from(Score::new("rate", Vec::<String>::new())),
551        )];
552        let err = build_questions_map(questions).unwrap_err();
553        assert!(matches!(err, Error::InvalidRequest(_)));
554    }
555
556    #[test]
557    fn builds_mixed_question_map() {
558        let questions = vec![
559            (
560                "tone",
561                Question::from(Choice::options("Tone?", ["calm", "angry"])),
562            ),
563            ("billing", Question::from(Noul::new("Billing?"))),
564        ];
565        let map = build_questions_map(questions).unwrap();
566        assert_eq!(map.len(), 2);
567        assert_eq!(map["tone"]["type"], "choice");
568        assert_eq!(map["billing"]["type"], "noul");
569        // Insertion order preserved.
570        let keys: Vec<_> = map.keys().collect();
571        assert_eq!(keys, ["tone", "billing"]);
572    }
573}