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
11pub const ENV_API_KEY: &str = "TYPESAFE_API_KEY";
13pub const ENV_BASE_URL: &str = "TYPESAFE_BASE_URL";
15pub const ENV_DEFAULT_MODEL: &str = "TYPESAFE_DEFAULT_MODEL";
17
18pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
20pub const DEFAULT_MODEL: &str = "jev-latest";
22pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
24
25#[derive(Clone)]
27pub struct SecretString(String);
28
29impl SecretString {
30 #[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#[derive(Clone, Debug)]
61pub struct ClientConfig {
62 pub api_key: Option<SecretString>,
64 pub base_url: Option<Url>,
66 pub default_model: Option<String>,
68 pub timeout: Duration,
70 pub retry: RetryPolicy,
72 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 #[must_use]
92 pub fn new() -> Self {
93 Self::default()
94 }
95
96 #[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 #[must_use]
105 pub fn base_url(mut self, url: Url) -> Self {
106 self.base_url = Some(url);
107 self
108 }
109
110 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 #[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 #[must_use]
125 pub fn timeout(mut self, timeout: Duration) -> Self {
126 self.timeout = timeout;
127 self
128 }
129
130 #[must_use]
132 pub fn retry(mut self, retry: RetryPolicy) -> Self {
133 self.retry = retry;
134 self
135 }
136
137 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 #[must_use]
146 pub fn default_headers(mut self, headers: HeaderMap) -> Self {
147 self.default_headers = headers;
148 self
149 }
150
151 pub fn build(self) -> Result<crate::Client, Error> {
157 crate::Client::new(self)
158 }
159
160 #[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 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#[derive(Clone, Debug, Default)]
199pub struct CallOptions {
200 pub timeout: Option<Duration>,
202 pub retry: Option<RetryPolicy>,
204 pub headers: HeaderMap,
206 pub model: Option<String>,
208}
209
210impl CallOptions {
211 #[must_use]
213 pub fn new() -> Self {
214 Self::default()
215 }
216
217 #[must_use]
219 pub fn timeout(mut self, timeout: Duration) -> Self {
220 self.timeout = Some(timeout);
221 self
222 }
223
224 #[must_use]
226 pub fn retry(mut self, retry: RetryPolicy) -> Self {
227 self.retry = Some(retry);
228 self
229 }
230
231 #[must_use]
233 pub fn model(mut self, model: impl Into<String>) -> Self {
234 self.model = Some(model.into());
235 self
236 }
237
238 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 #[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}