1use std::sync::Arc;
4use std::time::Duration;
5
6use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION};
7
8use super::core::{Client, ClientInner};
9use super::options::{
10 DEFAULT_BASE_URL, DEFAULT_MODEL, DEFAULT_TIMEOUT, RUNTIME_HEADER, SDK_HEADER, SDK_NAME,
11 USER_AGENT_HEADER,
12};
13use crate::error::Error;
14use crate::retry::RetryPolicy;
15
16#[derive(Clone, Default)]
42pub struct ClientBuilder {
43 api_key: Option<String>,
44 allow_unauthenticated: bool,
45 base_url: Option<String>,
46 cloudflare_account: Option<String>,
47 default_model: Option<String>,
48 timeout: Option<Duration>,
49 connect_timeout: Option<Duration>,
50 retry: Option<RetryPolicy>,
51 default_headers: HeaderMap,
52 http_client: Option<reqwest::Client>,
53}
54
55impl ClientBuilder {
56 pub fn new() -> Self {
58 Self::default()
59 }
60
61 pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
64 self.api_key = Some(api_key.into());
65 self
66 }
67
68 pub fn allow_unauthenticated(mut self) -> Self {
71 self.allow_unauthenticated = true;
72 self
73 }
74
75 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
79 self.base_url = Some(base_url.into());
80 self
81 }
82
83 pub fn cloudflare_account(mut self, account_id: impl Into<String>) -> Self {
88 self.cloudflare_account = Some(account_id.into());
89 self
90 }
91
92 pub fn default_model(mut self, model: impl Into<String>) -> Self {
96 self.default_model = Some(model.into());
97 self
98 }
99
100 pub fn timeout(mut self, timeout: Duration) -> Self {
102 self.timeout = Some(timeout);
103 self
104 }
105
106 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
108 self.connect_timeout = Some(timeout);
109 self
110 }
111
112 pub fn retry(mut self, retry: RetryPolicy) -> Self {
114 self.retry = Some(retry);
115 self
116 }
117
118 pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
120 self.default_headers.insert(name, value);
121 self
122 }
123
124 pub fn http_client(mut self, client: reqwest::Client) -> Self {
128 self.http_client = Some(client);
129 self
130 }
131
132 pub fn build(self) -> Result<Client, Error> {
134 let api_key = if self.allow_unauthenticated {
135 resolve_optional_api_key(self.api_key, non_empty_env).map_err(Error::Config)?
136 } else {
137 Some(resolve_api_key(self.api_key, non_empty_env).map_err(Error::Config)?)
138 };
139
140 let cloudflare_account = self.cloudflare_account;
141 if let Some(id) = &cloudflare_account {
142 if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
143 return Err(Error::Config(
144 "cloudflare account id must be alphanumeric".into(),
145 ));
146 }
147 }
148 let base_url = match (&self.base_url, &cloudflare_account) {
149 (Some(url), _) => url.clone(),
150 (None, Some(id)) => format!("https://api.cloudflare.com/client/v4/accounts/{id}"),
151 (None, None) => resolve_setting(None, "OPENKIND_BASE_URL", "TYPESAFE_BASE_URL")
152 .unwrap_or_else(|| DEFAULT_BASE_URL.to_owned()),
153 };
154 let base_url = base_url.trim().trim_end_matches('/').to_owned();
155 if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
156 return Err(Error::Config(format!(
157 "base_url must start with http:// or https://, got `{base_url}`"
158 )));
159 }
160 let parsed_base =
161 reqwest::Url::parse(&base_url).map_err(|_| Error::Config("invalid base URL".into()))?;
162 if parsed_base.query().is_some()
163 || parsed_base.fragment().is_some()
164 || !parsed_base.username().is_empty()
165 || parsed_base.password().is_some()
166 {
167 return Err(Error::Config(
168 "base_url must not contain a query, fragment, or credentials".into(),
169 ));
170 }
171
172 let default_model = if cloudflare_account.is_some() {
173 self.default_model
174 .unwrap_or_else(|| "typesafe/jev".to_owned())
175 } else {
176 resolve_setting(
177 self.default_model,
178 "OPENKIND_DEFAULT_MODEL",
179 "TYPESAFE_DEFAULT_MODEL",
180 )
181 .unwrap_or_else(|| DEFAULT_MODEL.to_owned())
182 };
183
184 let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
185 if timeout.is_zero() {
186 return Err(Error::Config("timeout must be greater than zero".into()));
187 }
188
189 let retry = self.retry.unwrap_or_default();
190 retry.validate()?;
191
192 let owns_http_client = self.http_client.is_none();
193 let http = match self.http_client {
194 Some(client) => client,
195 None => {
196 let mut builder = reqwest::Client::builder();
197 if let Some(connect_timeout) = self.connect_timeout {
198 builder = builder.connect_timeout(connect_timeout);
199 }
200 builder
201 .build()
202 .map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?
203 }
204 };
205
206 let mut base_headers = HeaderMap::with_capacity(8);
210 for (name, value) in self.default_headers {
211 if let Some(name) = name {
212 if !super::transport::is_user_overridable(name.as_str(), false) {
213 continue;
214 }
215 base_headers.insert(name, value);
216 }
217 }
218 let sdk_version = env!("CARGO_PKG_VERSION");
219 if let Some(api_key) = api_key {
220 base_headers.insert(
221 AUTHORIZATION,
222 HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|_| {
223 Error::Config("api_key contains invalid header characters".into())
224 })?,
225 );
226 }
227 base_headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
228 base_headers.insert(
229 USER_AGENT_HEADER,
230 HeaderValue::from_str(&format!("{SDK_NAME}/{sdk_version}"))
231 .map_err(|_| Error::Config("user agent construction failed".into()))?,
232 );
233 base_headers.insert(
234 SDK_HEADER,
235 HeaderValue::from_str(&format!("{SDK_NAME}/{sdk_version}"))
236 .map_err(|_| Error::Config("sdk header construction failed".into()))?,
237 );
238 base_headers.insert(
239 RUNTIME_HEADER,
240 HeaderValue::from_str(&format!(
241 "rust ({}; {})",
242 std::env::consts::OS,
243 std::env::consts::ARCH
244 ))
245 .map_err(|_| Error::Config("runtime header construction failed".into()))?,
246 );
247
248 let fast_h1 = match (
253 owns_http_client,
254 parsed_base,
255 super::http1::proxy_env_present(),
256 ) {
257 (true, url, false) if url.scheme() == "http" => {
258 match (url.host_str(), url.port_or_known_default()) {
259 (Some(host), Some(port)) => {
260 Some(super::http1::H1Pool::new(host, port, self.connect_timeout))
261 }
262 _ => None,
263 }
264 }
265 _ => None,
266 };
267
268 Ok(Client {
269 inner: Arc::new(ClientInner {
270 http,
271 urls: super::core::EndpointUrls::new(&base_url, cloudflare_account.is_some())?,
272 base_url,
273 is_cloudflare: cloudflare_account.is_some(),
274 default_model,
275 timeout,
276 retry,
277 base_headers,
278 fast_h1,
279 }),
280 })
281 }
282}
283
284fn resolve_setting(explicit: Option<String>, primary: &str, fallback: &str) -> Option<String> {
287 resolve_lookup(explicit, non_empty_env, primary, fallback)
288}
289
290pub(crate) fn resolve_lookup(
293 explicit: Option<String>,
294 lookup: impl Fn(&str) -> Option<String>,
295 primary: &str,
296 fallback: &str,
297) -> Option<String> {
298 explicit
299 .or_else(|| lookup(primary))
300 .or_else(|| lookup(fallback))
301}
302
303fn non_empty_env(name: &str) -> Option<String> {
304 clean_env_value(std::env::var(name).ok()?)
305}
306
307pub(crate) fn resolve_api_key(
313 explicit: Option<String>,
314 lookup: impl Fn(&str) -> Option<String>,
315) -> Result<String, String> {
316 resolve_optional_api_key(explicit, lookup)?.ok_or_else(|| {
317 "no API key provided; pass ClientBuilder::api_key or set \
318 OPENKIND_API_KEY (or TYPESAFE_API_KEY)"
319 .into()
320 })
321}
322
323pub(crate) fn resolve_optional_api_key(
324 explicit: Option<String>,
325 lookup: impl Fn(&str) -> Option<String>,
326) -> Result<Option<String>, String> {
327 let explicitly_supplied = explicit.is_some();
328 let resolved = match explicit {
329 Some(value) => Some(value.trim().to_owned()),
330 None => lookup("OPENKIND_API_KEY")
331 .or_else(|| lookup("OPENDECISION_API_KEY"))
332 .or_else(|| lookup("TYPESAFE_API_KEY")),
333 }
334 .filter(|key| !key.is_empty());
335 match resolved {
336 Some(key) if key.chars().all(|c| matches!(c, '!'..='~')) => Ok(Some(key)),
337 Some(_) => {
338 Err("API key must contain only printable ASCII characters without whitespace".into())
339 }
340 None if explicitly_supplied => Err("API key must be nonempty".into()),
341 None => Ok(None),
342 }
343}
344
345pub(crate) fn clean_env_value(raw: String) -> Option<String> {
348 let value = raw.trim();
349 (!value.is_empty()).then(|| value.to_owned())
350}