Skip to main content

typesafe/
config.rs

1//! Client settings and per-call options.
2
3use std::fmt;
4use std::time::Duration;
5
6use serde_json::{Map, Value};
7
8use crate::retry::RetryPolicy;
9
10pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
11pub const DEFAULT_MODEL: &str = "jev-latest";
12pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
13
14pub const ENV_API_KEY: &str = "TYPESAFE_API_KEY";
15pub const ENV_BASE_URL: &str = "TYPESAFE_BASE_URL";
16pub const ENV_DEFAULT_MODEL: &str = "TYPESAFE_DEFAULT_MODEL";
17
18/// Connection settings for a client.
19///
20/// | Setting | Environment variable | Default |
21/// | --- | --- | --- |
22/// | [`api_key`](Config::api_key) | `TYPESAFE_API_KEY` | none |
23/// | [`base_url`](Config::base_url) | `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` |
24/// | [`model`](Config::model) | `TYPESAFE_DEFAULT_MODEL` | `jev-latest` |
25/// | [`timeout`](Config::timeout) | | 10 s per response |
26/// | [`retry`](Config::retry) | | [`RetryPolicy::default`] |
27/// | [`header`](Config::header) | | extra request headers |
28///
29/// [`Config::from_env`] reads the environment variables, and the setters
30/// override them. The API key is kept out of `Debug` output.
31///
32/// A missing key is not an error here, so an app can start without one. Calls
33/// then fail with [`ErrorKind::NoApiKey`](crate::ErrorKind::NoApiKey) without
34/// making a request.
35///
36/// ```
37/// use std::time::Duration;
38/// use typesafe::Config;
39///
40/// let config = Config::from_env()
41///     .model("jev-1.13.0")
42///     .timeout(Duration::from_secs(5));
43/// ```
44#[derive(Clone)]
45pub struct Config {
46    pub(crate) api_key: Option<String>,
47    pub(crate) base_url: String,
48    pub(crate) model: String,
49    pub(crate) timeout: Duration,
50    pub(crate) retry: RetryPolicy,
51    pub(crate) headers: Vec<(String, String)>,
52}
53
54impl Default for Config {
55    fn default() -> Self {
56        Config::new()
57    }
58}
59
60impl Config {
61    /// The defaults, ignoring the environment.
62    pub fn new() -> Self {
63        Config {
64            api_key: None,
65            base_url: DEFAULT_BASE_URL.to_string(),
66            model: DEFAULT_MODEL.to_string(),
67            timeout: DEFAULT_TIMEOUT,
68            retry: RetryPolicy::default(),
69            headers: Vec::new(),
70        }
71    }
72
73    /// The defaults, with the key, base URL and model taken from the
74    /// environment where set. Blank values count as unset.
75    pub fn from_env() -> Self {
76        Config::from_lookup(|name| std::env::var(name).ok())
77    }
78
79    fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Self {
80        let get = |name: &str| {
81            get(name)
82                .map(|value| value.trim().to_string())
83                .filter(|value| !value.is_empty())
84        };
85        let mut config = Config::new();
86        config.api_key = get(ENV_API_KEY);
87        if let Some(url) = get(ENV_BASE_URL) {
88            config = config.base_url(url);
89        }
90        if let Some(model) = get(ENV_DEFAULT_MODEL) {
91            config.model = model;
92        }
93        config
94    }
95
96    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
97        self.api_key = Some(api_key.into());
98        self
99    }
100
101    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
102        self.base_url = base_url.into().trim_end_matches('/').to_string();
103        self
104    }
105
106    /// The model calls use unless they name one. Pin a versioned model such as
107    /// `jev-1.13.0` once thresholds are tuned; `jev-latest` moves when a new
108    /// release ships.
109    pub fn model(mut self, model: impl Into<String>) -> Self {
110        self.model = model.into();
111        self
112    }
113
114    /// How long to wait for each response.
115    pub fn timeout(mut self, timeout: Duration) -> Self {
116        self.timeout = timeout;
117        self
118    }
119
120    pub fn retry(mut self, retry: RetryPolicy) -> Self {
121        self.retry = retry;
122        self
123    }
124
125    /// An extra header sent with every request.
126    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
127        self.headers.push((name.into(), value.into()));
128        self
129    }
130
131    /// Whether there is an API key.
132    pub fn is_configured(&self) -> bool {
133        self.api_key.is_some()
134    }
135
136    /// The model calls use unless they name one.
137    pub fn default_model(&self) -> &str {
138        &self.model
139    }
140}
141
142impl fmt::Debug for Config {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.debug_struct("Config")
145            .field("api_key", &self.api_key.as_ref().map(|_| "[redacted]"))
146            .field("base_url", &self.base_url)
147            .field("model", &self.model)
148            .field("timeout", &self.timeout)
149            .field("retry", &self.retry)
150            .field("headers", &self.headers)
151            .finish()
152    }
153}
154
155/// Overrides for a single call.
156///
157/// ```
158/// use std::time::Duration;
159/// use typesafe::{CallOptions, RetryPolicy};
160///
161/// let options = CallOptions::new()
162///     .model("jev-1.13.0")
163///     .timeout(Duration::from_secs(2))
164///     .retry(RetryPolicy::disabled());
165/// ```
166#[derive(Clone, Debug, Default)]
167pub struct CallOptions {
168    pub(crate) model: Option<String>,
169    pub(crate) timeout: Option<Duration>,
170    pub(crate) retry: Option<RetryPolicy>,
171    pub(crate) headers: Vec<(String, String)>,
172    pub(crate) extra_body: Map<String, Value>,
173}
174
175impl CallOptions {
176    pub fn new() -> Self {
177        CallOptions::default()
178    }
179
180    pub fn model(mut self, model: impl Into<String>) -> Self {
181        self.model = Some(model.into());
182        self
183    }
184
185    pub fn timeout(mut self, timeout: Duration) -> Self {
186        self.timeout = Some(timeout);
187        self
188    }
189
190    pub fn retry(mut self, retry: RetryPolicy) -> Self {
191        self.retry = Some(retry);
192        self
193    }
194
195    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
196        self.headers.push((name.into(), value.into()));
197        self
198    }
199
200    /// An extra top-level request field, for API features this client
201    /// predates. It can't replace `state`, `model` or `questions`.
202    pub fn extra_body(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
203        self.extra_body.insert(key.into(), value.into());
204        self
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use std::collections::HashMap;
212
213    fn from(vars: &[(&str, &str)]) -> Config {
214        let vars: HashMap<String, String> = vars
215            .iter()
216            .map(|(k, v)| (k.to_string(), v.to_string()))
217            .collect();
218        Config::from_lookup(|name| vars.get(name).cloned())
219    }
220
221    #[test]
222    fn reads_the_environment() {
223        let config = from(&[
224            ("TYPESAFE_API_KEY", " key "),
225            ("TYPESAFE_BASE_URL", "http://localhost:4000/"),
226            ("TYPESAFE_DEFAULT_MODEL", "jev-1.13.0"),
227        ]);
228        assert_eq!(config.api_key.as_deref(), Some("key"));
229        assert_eq!(config.base_url, "http://localhost:4000");
230        assert_eq!(config.model, "jev-1.13.0");
231    }
232
233    #[test]
234    fn blank_values_are_unset() {
235        let config = from(&[("TYPESAFE_API_KEY", "  "), ("TYPESAFE_DEFAULT_MODEL", "")]);
236        assert!(!config.is_configured());
237        assert_eq!(config.model, DEFAULT_MODEL);
238        assert_eq!(config.base_url, DEFAULT_BASE_URL);
239    }
240
241    #[test]
242    fn setters_override_the_environment() {
243        let config = from(&[("TYPESAFE_API_KEY", "env")])
244            .api_key("given")
245            .model("m");
246        assert_eq!(config.api_key.as_deref(), Some("given"));
247        assert_eq!(config.default_model(), "m");
248    }
249
250    #[test]
251    fn debug_hides_the_key() {
252        let shown = format!("{:?}", Config::new().api_key("sk-secret"));
253        assert!(!shown.contains("sk-secret"));
254        assert!(shown.contains("[redacted]"));
255    }
256}