Skip to main content

typesafe_ai_rs/
config.rs

1use crate::{
2    CancellationToken, Error, HeaderMap, RetryPolicy, DEFAULT_BASE_URL, DEFAULT_MODEL,
3    DEFAULT_TIMEOUT,
4};
5use std::{fmt, time::Duration};
6
7/// Per-client log filtering. Messages use the `typesafe_ai_rs` target of the `log` facade.
8/// Applications install their own logger; this library never installs a global logger.
9pub type LogLevel = log::LevelFilter;
10
11/// Per-call overrides. An omitted field inherits the client's setting.
12#[derive(Clone, Default)]
13pub struct RequestOptions {
14    /// Complete retry policy override; use struct update syntax for partial changes.
15    pub retry: Option<RetryPolicy>,
16    /// Timeout per attempt, including reading the response body.
17    pub timeout: Option<Duration>,
18    /// Additional headers. SDK protocol and authentication headers remain protected.
19    pub headers: HeaderMap,
20    /// Cancels async requests, response reads, and retry delays.
21    /// Blocking calls reject this option; use the async client for cancellation.
22    pub cancellation_token: Option<CancellationToken>,
23}
24
25impl fmt::Debug for RequestOptions {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        f.debug_struct("RequestOptions")
28            .field("retry", &self.retry)
29            .field("timeout", &self.timeout)
30            .field("header_count", &self.headers.len())
31            .field("cancellable", &self.cancellation_token.is_some())
32            .finish()
33    }
34}
35
36#[derive(Clone, Default)]
37pub(crate) struct ConfigBuilder {
38    pub api_key: Option<String>,
39    pub base_url: Option<String>,
40    pub model: Option<String>,
41    pub timeout: Option<Duration>,
42    pub retry: RetryPolicy,
43    pub headers: HeaderMap,
44    pub log_level: Option<LogLevel>,
45}
46
47impl fmt::Debug for ConfigBuilder {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.debug_struct("ConfigBuilder").finish_non_exhaustive()
50    }
51}
52
53#[derive(Clone)]
54pub(crate) struct Config {
55    pub authorization: reqwest::header::HeaderValue,
56    pub base_url: String,
57    pub model: String,
58    pub timeout: Duration,
59    pub retry: RetryPolicy,
60    pub headers: HeaderMap,
61    pub log_level: LogLevel,
62}
63
64impl fmt::Debug for Config {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.debug_struct("Config")
67            .field("base_url", &self.base_url)
68            .field("model", &self.model)
69            .finish_non_exhaustive()
70    }
71}
72
73fn env(name: &str) -> Option<String> {
74    std::env::var(name)
75        .ok()
76        .map(|v| v.trim().to_owned())
77        .filter(|v| !v.is_empty())
78}
79
80pub(crate) fn validate_timeout(timeout: Duration) -> Result<(), Error> {
81    if timeout.is_zero() || std::time::Instant::now().checked_add(timeout).is_none() {
82        return Err(Error::Configuration(
83            "timeout must be positive and representable".into(),
84        ));
85    }
86    Ok(())
87}
88
89impl ConfigBuilder {
90    pub fn resolve(self) -> Result<Config, Error> {
91        let key = self
92            .api_key
93            .or_else(|| env("TYPESAFE_API_KEY"))
94            .filter(|v| !v.trim().is_empty())
95            .ok_or_else(|| {
96                Error::Configuration("Pass an API key or set TYPESAFE_API_KEY".into())
97            })?;
98        let mut authorization = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}"))
99            .map_err(|_| {
100                Error::Configuration("API key contains invalid HTTP header characters".into())
101            })?;
102        authorization.set_sensitive(true);
103        let base_url = self
104            .base_url
105            .or_else(|| env("TYPESAFE_BASE_URL"))
106            .unwrap_or_else(|| DEFAULT_BASE_URL.into());
107        let url = reqwest::Url::parse(&base_url)
108            .map_err(|_| Error::Configuration("base_url must be an absolute HTTP(S) URL".into()))?;
109        if !matches!(url.scheme(), "https" | "http")
110            || url.host_str().is_none()
111            || !url.username().is_empty()
112            || url.password().is_some()
113            || url.query().is_some()
114            || url.fragment().is_some()
115        {
116            return Err(Error::Configuration(
117                "base_url must be an HTTP(S) root without credentials, query, or fragment".into(),
118            ));
119        }
120        let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
121        validate_timeout(timeout)?;
122        self.retry.validate()?;
123        let log_level = match self.log_level {
124            Some(level) => level,
125            None => match env("TYPESAFE_LOG_LEVEL").as_deref() {
126                None => LogLevel::Warn,
127                Some("debug") => LogLevel::Debug,
128                Some("info") => LogLevel::Info,
129                Some("warn" | "warning") => LogLevel::Warn,
130                Some("error") => LogLevel::Error,
131                Some("off") => LogLevel::Off,
132                Some(_) => {
133                    return Err(Error::Configuration(
134                        "TYPESAFE_LOG_LEVEL must be debug, info, warn, warning, error, or off"
135                            .into(),
136                    ))
137                }
138            },
139        };
140        Ok(Config {
141            authorization,
142            base_url: url.as_str().trim_end_matches('/').to_owned(),
143            model: self
144                .model
145                .or_else(|| env("TYPESAFE_DEFAULT_MODEL"))
146                .unwrap_or_else(|| DEFAULT_MODEL.into()),
147            timeout,
148            retry: self.retry,
149            headers: self.headers,
150            log_level,
151        })
152    }
153}
154
155// Both transports expose the same configuration without maintaining two copies.
156macro_rules! config_methods {
157    () => {
158        /// API key; defaults to `TYPESAFE_API_KEY`.
159        pub fn api_key(mut self, value: impl Into<String>) -> Self {
160            self.config.api_key = Some(value.into());
161            self
162        }
163        /// API root; defaults to `TYPESAFE_BASE_URL` or the public API.
164        pub fn base_url(mut self, value: impl Into<String>) -> Self {
165            self.config.base_url = Some(value.into());
166            self
167        }
168        /// Default model; defaults to `TYPESAFE_DEFAULT_MODEL` or `jev-latest`.
169        pub fn model(mut self, value: impl Into<String>) -> Self {
170            self.config.model = Some(value.into());
171            self
172        }
173        /// Timeout for each complete HTTP attempt.
174        pub fn timeout(mut self, value: std::time::Duration) -> Self {
175            self.config.timeout = Some(value);
176            self
177        }
178        /// Retry policy for all calls unless overridden per request.
179        pub fn retry(mut self, value: crate::RetryPolicy) -> Self {
180            self.config.retry = value;
181            self
182        }
183        /// Default headers; request headers take precedence except protected protocol headers.
184        pub fn default_headers(mut self, value: crate::HeaderMap) -> Self {
185            self.config.headers = value;
186            self
187        }
188        /// Filter messages emitted through the application's `log` implementation.
189        pub fn log_level(mut self, value: crate::LogLevel) -> Self {
190            self.config.log_level = Some(value);
191            self
192        }
193    };
194}
195pub(crate) use config_methods;