Skip to main content

typesafe_rs/
config.rs

1use std::fmt;
2use std::time::Duration;
3
4use http::{HeaderMap, HeaderName, HeaderValue};
5use url::Url;
6
7use crate::error::Error;
8use crate::headers::is_protected;
9use crate::retry::RetryPolicy;
10
11/// Environment variable for the API key.
12pub const ENV_API_KEY: &str = "TYPESAFE_API_KEY";
13/// Environment variable for the API root URL.
14pub const ENV_BASE_URL: &str = "TYPESAFE_BASE_URL";
15/// Environment variable for the default model.
16pub const ENV_DEFAULT_MODEL: &str = "TYPESAFE_DEFAULT_MODEL";
17
18/// Default API root.
19pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
20/// Default model when none is configured.
21pub const DEFAULT_MODEL: &str = "jev-latest";
22/// Default per-attempt timeout, including the response body.
23pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
24
25/// API key wrapper that redacts itself in [`Debug`].
26#[derive(Clone)]
27pub struct SecretString(String);
28
29impl SecretString {
30    /// Wrap an owned secret.
31    #[must_use]
32    pub fn new(value: impl Into<String>) -> Self {
33        Self(value.into())
34    }
35
36    pub(crate) fn expose(&self) -> &str {
37        &self.0
38    }
39}
40
41impl From<String> for SecretString {
42    fn from(value: String) -> Self {
43        Self(value)
44    }
45}
46
47impl From<&str> for SecretString {
48    fn from(value: &str) -> Self {
49        Self(value.to_owned())
50    }
51}
52
53impl fmt::Debug for SecretString {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.write_str("SecretString([redacted])")
56    }
57}
58
59/// Client configuration. Explicit values win over environment, then defaults.
60#[derive(Clone, Debug)]
61pub struct ClientConfig {
62    /// API key. Falls back to [`ENV_API_KEY`].
63    pub api_key: Option<SecretString>,
64    /// API root. Falls back to [`ENV_BASE_URL`], then [`DEFAULT_BASE_URL`].
65    pub base_url: Option<Url>,
66    /// Default model. Falls back to [`ENV_DEFAULT_MODEL`], then [`DEFAULT_MODEL`].
67    pub default_model: Option<String>,
68    /// Per-attempt timeout including the body. Default: 10 s.
69    pub timeout: Duration,
70    /// Retry policy. Default matches the official SDKs.
71    pub retry: RetryPolicy,
72    /// Extra headers. Cannot override Authorization, Accept, or SDK identification headers.
73    pub default_headers: HeaderMap,
74}
75
76impl Default for ClientConfig {
77    fn default() -> Self {
78        Self {
79            api_key: None,
80            base_url: None,
81            default_model: None,
82            timeout: DEFAULT_TIMEOUT,
83            retry: RetryPolicy::default(),
84            default_headers: HeaderMap::new(),
85        }
86    }
87}
88
89impl ClientConfig {
90    /// Empty config that will read the process environment in [`crate::Client::new`].
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Set the API key.
97    #[must_use]
98    pub fn api_key(mut self, key: impl Into<SecretString>) -> Self {
99        self.api_key = Some(key.into());
100        self
101    }
102
103    /// Set the API root.
104    #[must_use]
105    pub fn base_url(mut self, url: Url) -> Self {
106        self.base_url = Some(url);
107        self
108    }
109
110    /// Parse and set the API root.
111    pub fn try_base_url(mut self, url: &str) -> Result<Self, Error> {
112        self.base_url = Some(parse_base_url(url)?);
113        Ok(self)
114    }
115
116    /// Set the default model.
117    #[must_use]
118    pub fn default_model(mut self, model: impl Into<String>) -> Self {
119        self.default_model = Some(model.into());
120        self
121    }
122
123    /// Set the per-attempt timeout.
124    #[must_use]
125    pub fn timeout(mut self, timeout: Duration) -> Self {
126        self.timeout = timeout;
127        self
128    }
129
130    /// Set the retry policy.
131    #[must_use]
132    pub fn retry(mut self, retry: RetryPolicy) -> Self {
133        self.retry = retry;
134        self
135    }
136
137    /// Insert a default header. Protected names (`Authorization`, `Accept`,
138    /// `User-Agent`, SDK identification, retry-count) are ignored.
139    pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self, Error> {
140        insert_user_header(&mut self.default_headers, name.as_ref(), value.as_ref())?;
141        Ok(self)
142    }
143
144    /// Replace extra default headers. Protected names are ignored at send time.
145    #[must_use]
146    pub fn default_headers(mut self, headers: HeaderMap) -> Self {
147        self.default_headers = headers;
148        self
149    }
150
151    /// Build an async [`Client`](crate::Client).
152    ///
153    /// # Errors
154    ///
155    /// Same as [`crate::Client::new`].
156    pub fn build(self) -> Result<crate::Client, Error> {
157        crate::Client::new(self)
158    }
159
160    /// Build a [`BlockingClient`](crate::BlockingClient).
161    ///
162    /// # Errors
163    ///
164    /// Same as [`crate::BlockingClient::new`].
165    #[cfg(feature = "blocking")]
166    #[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
167    pub fn build_blocking(self) -> Result<crate::BlockingClient, Error> {
168        crate::BlockingClient::new(self)
169    }
170
171    /// Fill unset fields from `lookup` (typically the process environment).
172    ///
173    /// Empty or whitespace-only values are ignored. Explicit fields are left unchanged.
174    pub fn overlay_env(
175        &mut self,
176        mut lookup: impl FnMut(&str) -> Option<String>,
177    ) -> Result<(), Error> {
178        if self.api_key.is_none() {
179            if let Some(value) = lookup(ENV_API_KEY).and_then(trim_nonempty) {
180                self.api_key = Some(SecretString::from(value));
181            }
182        }
183        if self.base_url.is_none() {
184            if let Some(value) = lookup(ENV_BASE_URL).and_then(trim_nonempty) {
185                self.base_url = Some(parse_base_url(&value)?);
186            }
187        }
188        if self.default_model.is_none() {
189            if let Some(value) = lookup(ENV_DEFAULT_MODEL).and_then(trim_nonempty) {
190                self.default_model = Some(value);
191            }
192        }
193        Ok(())
194    }
195}
196
197/// Per-call overrides.
198#[derive(Clone, Debug, Default)]
199pub struct CallOptions {
200    /// Override the per-attempt timeout.
201    pub timeout: Option<Duration>,
202    /// Override the retry policy.
203    pub retry: Option<RetryPolicy>,
204    /// Extra headers for this call (protected names are ignored).
205    pub headers: HeaderMap,
206    /// Override the model for `system_one`.
207    pub model: Option<String>,
208}
209
210impl CallOptions {
211    /// No overrides.
212    #[must_use]
213    pub fn new() -> Self {
214        Self::default()
215    }
216
217    /// Override timeout.
218    #[must_use]
219    pub fn timeout(mut self, timeout: Duration) -> Self {
220        self.timeout = Some(timeout);
221        self
222    }
223
224    /// Override retry policy.
225    #[must_use]
226    pub fn retry(mut self, retry: RetryPolicy) -> Self {
227        self.retry = Some(retry);
228        self
229    }
230
231    /// Override model.
232    #[must_use]
233    pub fn model(mut self, model: impl Into<String>) -> Self {
234        self.model = Some(model.into());
235        self
236    }
237
238    /// Insert a per-call header. Protected names are ignored.
239    pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<Self, Error> {
240        insert_user_header(&mut self.headers, name.as_ref(), value.as_ref())?;
241        Ok(self)
242    }
243
244    /// Replace extra per-call headers. Protected names are ignored at send time.
245    #[must_use]
246    pub fn headers(mut self, headers: HeaderMap) -> Self {
247        self.headers = headers;
248        self
249    }
250}
251
252fn insert_user_header(map: &mut HeaderMap, name: &str, value: &str) -> Result<(), Error> {
253    let name = HeaderName::from_bytes(name.as_bytes())
254        .map_err(|err| Error::InvalidRequest(format!("invalid header name {name:?}: {err}")))?;
255    let value = HeaderValue::from_str(value)
256        .map_err(|err| Error::InvalidRequest(format!("invalid header value: {err}")))?;
257    if !is_protected(&name) {
258        map.insert(name, value);
259    }
260    Ok(())
261}
262
263pub(crate) fn trim_nonempty(value: String) -> Option<String> {
264    let trimmed = value.trim();
265    if trimmed.is_empty() {
266        None
267    } else {
268        Some(trimmed.to_owned())
269    }
270}
271
272pub(crate) fn parse_base_url(raw: &str) -> Result<Url, Error> {
273    Url::parse(raw).map_err(|err| Error::InvalidRequest(format!("invalid base URL {raw:?}: {err}")))
274}
275
276pub(crate) fn strip_trailing_slashes(url: Url) -> Url {
277    let stripped = url.as_str().trim_end_matches('/');
278    Url::parse(stripped).unwrap_or(url)
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use std::collections::HashMap;
285
286    #[test]
287    fn overlay_code_wins_over_env() {
288        let mut cfg = ClientConfig::default()
289            .api_key("from-code")
290            .default_model("code-model");
291        let env = HashMap::from([(ENV_API_KEY, "from-env"), (ENV_DEFAULT_MODEL, "env-model")]);
292        cfg.overlay_env(|k| env.get(k).map(|s| (*s).to_owned()))
293            .unwrap();
294        assert_eq!(cfg.api_key.as_ref().unwrap().expose(), "from-code");
295        assert_eq!(cfg.default_model.as_deref(), Some("code-model"));
296    }
297
298    #[test]
299    fn overlay_env_fills_unset() {
300        let mut cfg = ClientConfig::default().api_key("from-code");
301        let env = HashMap::from([(ENV_DEFAULT_MODEL, "env-model")]);
302        cfg.overlay_env(|k| env.get(k).map(|s| (*s).to_owned()))
303            .unwrap();
304        assert_eq!(cfg.api_key.as_ref().unwrap().expose(), "from-code");
305        assert_eq!(cfg.default_model.as_deref(), Some("env-model"));
306    }
307
308    #[test]
309    fn overlay_ignores_blank_env() {
310        let mut cfg = ClientConfig::default();
311        cfg.overlay_env(|k| match k {
312            ENV_API_KEY => Some("   ".into()),
313            ENV_DEFAULT_MODEL => Some(String::new()),
314            _ => None,
315        })
316        .unwrap();
317        assert!(cfg.api_key.is_none());
318        assert!(cfg.default_model.is_none());
319    }
320
321    #[test]
322    fn secret_debug_redacts() {
323        let s = SecretString::from("sk-live-super-secret");
324        assert!(!format!("{s:?}").contains("sk-live"));
325        assert!(format!("{s:?}").contains("redacted"));
326    }
327
328    #[test]
329    fn invalid_env_base_url_errors() {
330        let mut cfg = ClientConfig::default();
331        let err = cfg
332            .overlay_env(|k| {
333                if k == ENV_BASE_URL {
334                    Some("not a url".into())
335                } else {
336                    None
337                }
338            })
339            .unwrap_err();
340        assert!(matches!(err, Error::InvalidRequest(_)));
341    }
342
343    #[test]
344    fn strip_trailing_slashes_keeps_path_prefix() {
345        let url = Url::parse("https://example.com/prefix/").unwrap();
346        let stripped = strip_trailing_slashes(url);
347        assert_eq!(stripped.as_str(), "https://example.com/prefix");
348    }
349
350    #[test]
351    fn header_builder_skips_protected_names() {
352        let cfg = ClientConfig::new()
353            .header("x-custom", "ok")
354            .unwrap()
355            .header("authorization", "Bearer stolen")
356            .unwrap();
357        assert_eq!(
358            cfg.default_headers
359                .get("x-custom")
360                .and_then(|v| v.to_str().ok()),
361            Some("ok")
362        );
363        assert!(cfg.default_headers.get("authorization").is_none());
364    }
365
366    #[test]
367    fn invalid_header_name_errors() {
368        let err = ClientConfig::new().header("not a name", "v").unwrap_err();
369        assert!(matches!(err, Error::InvalidRequest(_)));
370    }
371}