Skip to main content

typesafe_sdk/
config.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use crate::errors::Error;
5use crate::logging::LogLevel;
6use crate::retry::RetryPolicy;
7
8/// Default API root URL.
9pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
10/// Default model used when a request does not specify one.
11pub const DEFAULT_MODEL: &str = "jev-latest";
12/// Default per-attempt timeout.
13pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
14
15/// Environment variable for the API key.
16pub const API_KEY_ENV: &str = "TYPESAFE_API_KEY";
17/// Environment variable for the base URL.
18pub const BASE_URL_ENV: &str = "TYPESAFE_BASE_URL";
19/// Environment variable for the default model.
20pub const DEFAULT_MODEL_ENV: &str = "TYPESAFE_DEFAULT_MODEL";
21/// Environment variable for the log level.
22pub const LOG_LEVEL_ENV: &str = "TYPESAFE_LOG_LEVEL";
23
24/// Builder for [`crate::Client`].
25///
26/// Explicit settings take precedence over environment variables, which take
27/// precedence over SDK defaults. Empty or whitespace-only environment values
28/// are ignored.
29///
30/// # Example
31///
32/// ```rust,no_run
33/// use typesafe_sdk::ClientBuilder;
34///
35/// let client = ClientBuilder::new("sk-...")
36///     .base_url("https://api.typesafe.ai")
37///     .default_model("jev-latest")
38///     .build()?;
39/// # Ok::<_, typesafe_sdk::Error>(())
40/// ```
41pub struct ClientBuilder {
42    pub(crate) api_key: Option<String>,
43    pub(crate) base_url: Option<String>,
44    pub(crate) default_model: Option<String>,
45    pub(crate) timeout: Option<Duration>,
46    pub(crate) retry: Option<RetryPolicy>,
47    pub(crate) headers: HashMap<String, String>,
48    pub(crate) http_client: Option<reqwest::Client>,
49    pub(crate) log_level: Option<LogLevel>,
50}
51
52impl ClientBuilder {
53    /// Create a builder with the given API key.
54    ///
55    /// Use [`ClientBuilder::new`] when you have the key; use
56    /// [`Client::from_env`](crate::Client::from_env) to read `TYPESAFE_API_KEY`
57    /// from the environment.
58    pub fn new(api_key: impl Into<String>) -> Self {
59        Self {
60            api_key: Some(api_key.into()),
61            base_url: None,
62            default_model: None,
63            timeout: None,
64            retry: None,
65            headers: HashMap::new(),
66            http_client: None,
67            log_level: None,
68        }
69    }
70
71    /// Set the API key. Overrides the `TYPESAFE_API_KEY` environment variable.
72    pub fn api_key(mut self, key: impl Into<String>) -> Self {
73        self.api_key = Some(key.into());
74        self
75    }
76
77    /// Set the API root URL. Trailing slashes are stripped.
78    /// Default: `https://api.typesafe.ai`.
79    pub fn base_url(mut self, url: impl Into<String>) -> Self {
80        self.base_url = Some(url.into().trim_end_matches('/').to_string());
81        self
82    }
83
84    /// Set the default model for requests that do not name one.
85    /// Default: `jev-latest`.
86    pub fn default_model(mut self, model: impl Into<String>) -> Self {
87        self.default_model = Some(model.into());
88        self
89    }
90
91    /// Set the per-attempt timeout. Default: 10 seconds.
92    pub fn timeout(mut self, timeout: Duration) -> Self {
93        self.timeout = Some(timeout);
94        self
95    }
96
97    /// Set the retry policy. Default: [`RetryPolicy::default`].
98    pub fn retry(mut self, policy: RetryPolicy) -> Self {
99        self.retry = Some(policy);
100        self
101    }
102
103    /// Add a header sent with every request.
104    ///
105    /// Authentication, `Accept`, `Content-Type`, and SDK identification
106    /// headers are protected and cannot be overridden by user headers.
107    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
108        self.headers.insert(name.into(), value.into());
109        self
110    }
111
112    /// Set a custom HTTP client.
113    ///
114    /// The provided client is used as-is; its own timeout and TLS settings
115    /// apply. The SDK adds per-attempt timeouts through the request context.
116    pub fn http_client(mut self, client: reqwest::Client) -> Self {
117        self.http_client = Some(client);
118        self
119    }
120
121    /// Set the log level. Falls back to `TYPESAFE_LOG_LEVEL`, then `warn`.
122    pub fn log_level(mut self, level: LogLevel) -> Self {
123        self.log_level = Some(level);
124        self
125    }
126
127    /// Build the [`crate::Client`].
128    ///
129    /// Returns [`Error::Config`] when the API key is missing or configuration
130    /// is invalid.
131    pub fn build(self) -> Result<crate::Client, Error> {
132        let api_key = self
133            .api_key
134            .or_else(|| read_env(API_KEY_ENV))
135            .ok_or_else(|| {
136                Error::Config(format!(
137                    "no API key was provided; set the api_key or the {API_KEY_ENV} environment variable"
138                ))
139            })?;
140
141        let base_url = self
142            .base_url
143            .or_else(|| read_env(BASE_URL_ENV))
144            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
145
146        let default_model = self
147            .default_model
148            .or_else(|| read_env(DEFAULT_MODEL_ENV))
149            .unwrap_or_else(|| DEFAULT_MODEL.to_string());
150
151        let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
152
153        let retry = self.retry.unwrap_or_default();
154
155        let log_level = self
156            .log_level
157            .or_else(|| read_env(LOG_LEVEL_ENV).and_then(|v| LogLevel::parse(&v)))
158            .unwrap_or(LogLevel::Warn);
159
160        let http_client = self.http_client;
161
162        Ok(crate::Client::new_inner(
163            api_key,
164            base_url,
165            default_model,
166            timeout,
167            retry,
168            self.headers,
169            http_client,
170            log_level,
171        ))
172    }
173}
174
175/// Read a trimmed environment value.
176/// Returns `None` when the variable is unset or whitespace-only.
177pub(crate) fn read_env(name: &str) -> Option<String> {
178    let value = std::env::var(name).ok()?;
179    let trimmed = value.trim();
180    if trimmed.is_empty() {
181        None
182    } else {
183        Some(trimmed.to_string())
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_read_env_unset() {
193        // This env var is almost certainly not set
194        assert_eq!(read_env("TYPESAFE_UNLIKELY_VAR_12345"), None);
195    }
196
197    #[test]
198    fn test_builder_missing_api_key() {
199        // No explicit key and no env → should fail
200        let result = ClientBuilder {
201            api_key: None,
202            base_url: None,
203            default_model: None,
204            timeout: None,
205            retry: None,
206            headers: HashMap::new(),
207            http_client: None,
208            log_level: None,
209        }
210        .build();
211        assert!(result.is_err());
212        if let Err(Error::Config(msg)) = result {
213            assert!(msg.contains("no API key"));
214        } else {
215            panic!("expected Config error");
216        }
217    }
218
219    #[test]
220    fn test_builder_with_api_key() {
221        let result = ClientBuilder::new("sk-test").build();
222        assert!(result.is_ok());
223    }
224
225    #[test]
226    fn test_builder_strips_trailing_slash() {
227        let client = ClientBuilder::new("sk-test")
228            .base_url("https://api.typesafe.ai/")
229            .build()
230            .unwrap();
231        assert_eq!(client.base_url(), "https://api.typesafe.ai");
232    }
233}