Skip to main content

openkind_client/client/
builder.rs

1//! Client builder and environment resolution logic.
2
3use 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/// Build an async client for the SystemOne API.
17///
18/// Settings resolve in priority order: explicit builder value →
19/// `OPENKIND_*` environment variable → `TYPESAFE_*` environment variable
20/// (for drop-in parity with the Python SDK) → SDK default. The API key is
21/// required unless [`allow_unauthenticated`](Self::allow_unauthenticated) is
22/// selected; everything else has a default. Cloudflare mode selects its own
23/// account URL and `typesafe/jev` model unless overridden explicitly.
24///
25/// # Examples
26/// ```
27/// use openkind_client::Client;
28///
29/// # fn demo() -> Result<(), openkind_client::Error> {
30/// // Hosted TypeSafe API (default base URL).
31/// let client = Client::builder().api_key("sk-...").build()?;
32///
33/// // Local daemon.
34/// let local = Client::builder()
35///     .api_key("dev-key")
36///     .base_url("http://127.0.0.1:18080")
37///     .build()?;
38/// # Ok(())
39/// # }
40/// ```
41#[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    /// A builder with every setting unset (environment/defaults apply).
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Bearer API key. Falls back to `OPENKIND_API_KEY`, then
62    /// `TYPESAFE_API_KEY`, when unset.
63    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    /// Allow a missing API key when connecting to an unauthenticated daemon.
69    /// Supplied and environment keys still resolve and validate normally.
70    pub fn allow_unauthenticated(mut self) -> Self {
71        self.allow_unauthenticated = true;
72        self
73    }
74
75    /// API base URL, e.g. `http://127.0.0.1:18080`. Falls back to
76    /// `OPENKIND_BASE_URL`, then `TYPESAFE_BASE_URL`, then
77    /// [`DEFAULT_BASE_URL`].
78    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    /// Use Cloudflare Workers AI Jev through the account's `/ai/run` API.
84    /// The API token is supplied with [`api_key`](Self::api_key). An explicit
85    /// [`base_url`](Self::base_url) may override the Cloudflare host for a proxy
86    /// or test server. Cloudflare mode ignores base URL and model env defaults.
87    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    /// Model alias used by [`Client::system_one`] when the request does not
93    /// name one. Falls back to `OPENKIND_DEFAULT_MODEL`, then
94    /// `TYPESAFE_DEFAULT_MODEL`, then [`DEFAULT_MODEL`].
95    pub fn default_model(mut self, model: impl Into<String>) -> Self {
96        self.default_model = Some(model.into());
97        self
98    }
99
100    /// Per-attempt request timeout. Default 10s.
101    pub fn timeout(mut self, timeout: Duration) -> Self {
102        self.timeout = Some(timeout);
103        self
104    }
105
106    /// TCP connect timeout. Unset by default.
107    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
108        self.connect_timeout = Some(timeout);
109        self
110    }
111
112    /// Retry behavior. Defaults to [`RetryPolicy::default`].
113    pub fn retry(mut self, retry: RetryPolicy) -> Self {
114        self.retry = Some(retry);
115        self
116    }
117
118    /// A header sent with every request.
119    pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
120        self.default_headers.insert(name, value);
121        self
122    }
123
124    /// Supply a preconfigured `reqwest::Client` (connection pooling, proxy,
125    /// TLS settings). Default headers and timeouts are still applied
126    /// per-request by this SDK.
127    pub fn http_client(mut self, client: reqwest::Client) -> Self {
128        self.http_client = Some(client);
129        self
130    }
131
132    /// Validate settings and construct the [`Client`].
133    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        // Caller defaults first — but the SDK-managed headers below always
207        // win on collision, mirroring the Python SDK's protected headers.
208        // The retry-count header is stripped entirely: it is client-managed.
209        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        // The direct HTTP/1.1 transport applies only when this SDK owns the
249        // HTTP client, the base URL is plain http, and no proxy is
250        // configured — otherwise reqwest handles everything, exactly as
251        // before.
252        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
284/// Resolve: explicit value → primary env var → fallback env var, ignoring
285/// empty/whitespace environment values (matches the Python SDK).
286fn resolve_setting(explicit: Option<String>, primary: &str, fallback: &str) -> Option<String> {
287    resolve_lookup(explicit, non_empty_env, primary, fallback)
288}
289
290/// Pure precedence core, split out so unit tests can inject lookups without
291/// touching process-global environment state.
292pub(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
307/// Resolve and validate the API key like the Python SDK's
308/// `resolve_and_validate_api_key`: an explicit key wins even when invalid
309/// (trimmed, never falling back to the environment), environment values are
310/// trimmed and must be non-empty, and the survivor must be printable ASCII
311/// without whitespace.
312pub(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
345/// Trim and drop empty/whitespace environment values, matching the Python
346/// SDK (`os.environ.get(env, "").strip() or default`).
347pub(crate) fn clean_env_value(raw: String) -> Option<String> {
348    let value = raw.trim();
349    (!value.is_empty()).then(|| value.to_owned())
350}