1use 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#[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 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 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 pub fn model(mut self, model: impl Into<String>) -> Self {
110 self.model = model.into();
111 self
112 }
113
114 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 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 pub fn is_configured(&self) -> bool {
133 self.api_key.is_some()
134 }
135
136 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#[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 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}