1use std::collections::HashMap;
2use std::time::Duration;
3
4use crate::errors::Error;
5use crate::logging::LogLevel;
6use crate::retry::RetryPolicy;
7
8pub const DEFAULT_BASE_URL: &str = "https://api.typesafe.ai";
10pub const DEFAULT_MODEL: &str = "jev-latest";
12pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
14
15pub const API_KEY_ENV: &str = "TYPESAFE_API_KEY";
17pub const BASE_URL_ENV: &str = "TYPESAFE_BASE_URL";
19pub const DEFAULT_MODEL_ENV: &str = "TYPESAFE_DEFAULT_MODEL";
21pub const LOG_LEVEL_ENV: &str = "TYPESAFE_LOG_LEVEL";
23
24pub 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 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 pub fn api_key(mut self, key: impl Into<String>) -> Self {
73 self.api_key = Some(key.into());
74 self
75 }
76
77 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 pub fn default_model(mut self, model: impl Into<String>) -> Self {
87 self.default_model = Some(model.into());
88 self
89 }
90
91 pub fn timeout(mut self, timeout: Duration) -> Self {
93 self.timeout = Some(timeout);
94 self
95 }
96
97 pub fn retry(mut self, policy: RetryPolicy) -> Self {
99 self.retry = Some(policy);
100 self
101 }
102
103 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 pub fn http_client(mut self, client: reqwest::Client) -> Self {
117 self.http_client = Some(client);
118 self
119 }
120
121 pub fn log_level(mut self, level: LogLevel) -> Self {
123 self.log_level = Some(level);
124 self
125 }
126
127 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
175pub(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 assert_eq!(read_env("TYPESAFE_UNLIKELY_VAR_12345"), None);
195 }
196
197 #[test]
198 fn test_builder_missing_api_key() {
199 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}