Skip to main content

liter_llm/client/
builder.rs

1//! Type-state builder for [`DefaultClient`].
2//!
3//! Use [`ClientBuilder`] when you want compile-time enforcement that both an
4//! API key and a provider name have been supplied before the client is
5//! constructed.  Calling [`build`](ClientBuilder::build) without setting both
6//! fields is a **compile error**, not a runtime error.
7//!
8//! [`ClientConfigBuilder`](super::config::ClientConfigBuilder) remains
9//! available for cases where the key or provider are not known until runtime
10//! (e.g. when loading from environment variables without an explicit provider
11//! hint).
12//!
13//! # Examples
14//!
15//! ```rust,no_run
16//! # #[cfg(any(feature = "native-http", feature = "wasm-http"))]
17//! # {
18//! use liter_llm::ClientBuilder;
19//!
20//! # async fn run() -> liter_llm::Result<()> {
21//! let client = ClientBuilder::new()
22//!     .api_key("sk-…")
23//!     .provider("openai")
24//!     .build()?;
25//! # Ok(())
26//! # }
27//! # }
28//! ```
29
30use std::sync::Arc;
31use std::time::Duration;
32
33use secrecy::SecretString;
34
35use crate::auth::CredentialProvider;
36#[cfg(any(feature = "native-http", feature = "wasm-http"))]
37use crate::error::Result;
38use crate::http::transport::TransportConfig;
39#[cfg(feature = "tower")]
40use crate::tower::{BudgetConfig, CacheConfig, CacheStore, InFlightLimitConfig, LlmHook, RateLimitConfig};
41
42/// Marker: no API key has been set on this builder.
43pub struct NoApiKey;
44/// Marker: an API key has been set on this builder.
45pub struct WithApiKey;
46/// Marker: no provider has been set on this builder.
47pub struct NoProvider;
48/// Marker: a provider name (model hint) has been set on this builder.
49pub struct WithProvider;
50
51/// Type-state builder for [`DefaultClient`](super::DefaultClient).
52///
53/// The two type parameters `K` and `P` track whether the required fields have
54/// been supplied:
55///
56/// - `K` is [`NoApiKey`] until [`api_key`](Self::api_key) is called, then
57///   [`WithApiKey`].
58/// - `P` is [`NoProvider`] until [`provider`](Self::provider) is called, then
59///   [`WithProvider`].
60///
61/// [`build`](Self::build) is only available when both are [`WithApiKey`] and
62/// [`WithProvider`].  Attempting to call it in any other state is a **compile
63/// error**.
64///
65/// All optional knobs (`base_url`, `timeout`, `transport`, …) are available
66/// at any point in the chain and do not affect the type-state.
67///
68/// # Examples
69///
70/// ```rust,no_run
71/// # #[cfg(any(feature = "native-http", feature = "wasm-http"))]
72/// # {
73/// use liter_llm::ClientBuilder;
74///
75/// # async fn run() -> liter_llm::Result<()> {
76/// let client = ClientBuilder::new()
77///     .api_key("sk-…")
78///     .provider("openai")
79///     .timeout(std::time::Duration::from_secs(30))
80///     .build()?;
81/// # Ok(())
82/// # }
83/// # }
84/// ```
85#[must_use = "call .build() to construct the client"]
86pub struct ClientBuilder<K = NoApiKey, P = NoProvider> {
87    api_key: SecretString,
88    provider_hint: String,
89    base_url: Option<String>,
90    timeout: Duration,
91    connect_timeout: Duration,
92    max_retries: u32,
93    max_response_bytes: Option<usize>,
94    transport: TransportConfig,
95    load_env: bool,
96    credential_provider: Option<Arc<dyn CredentialProvider>>,
97    #[cfg(feature = "tower")]
98    cache_config: Option<CacheConfig>,
99    #[cfg(feature = "tower")]
100    cache_store: Option<Arc<dyn CacheStore>>,
101    #[cfg(feature = "tower")]
102    budget_config: Option<BudgetConfig>,
103    #[cfg(feature = "tower")]
104    hooks: Vec<Arc<dyn LlmHook>>,
105    #[cfg(feature = "tower")]
106    cooldown_duration: Option<Duration>,
107    #[cfg(feature = "tower")]
108    rate_limit_config: Option<RateLimitConfig>,
109    #[cfg(feature = "tower")]
110    in_flight_limit_config: Option<InFlightLimitConfig>,
111    #[cfg(feature = "tower")]
112    health_check_interval: Option<Duration>,
113    #[cfg(feature = "tower")]
114    enable_cost_tracking: bool,
115    #[cfg(feature = "tower")]
116    enable_tracing: bool,
117    _key_state: std::marker::PhantomData<K>,
118    _provider_state: std::marker::PhantomData<P>,
119}
120
121impl ClientBuilder<NoApiKey, NoProvider> {
122    /// Create a new builder with no key and no provider set.
123    ///
124    /// Call [`api_key`](Self::api_key) and [`provider`](Self::provider) to
125    /// advance the type state, then call [`build`] when both are set.
126    pub fn new() -> Self {
127        Self {
128            api_key: SecretString::from(String::new()),
129            provider_hint: String::new(),
130            base_url: None,
131            timeout: Duration::from_secs(60),
132            connect_timeout: Duration::from_secs(10),
133            max_retries: 3,
134            max_response_bytes: None,
135            transport: TransportConfig::default(),
136            load_env: false,
137            credential_provider: None,
138            #[cfg(feature = "tower")]
139            cache_config: None,
140            #[cfg(feature = "tower")]
141            cache_store: None,
142            #[cfg(feature = "tower")]
143            budget_config: None,
144            #[cfg(feature = "tower")]
145            hooks: Vec::new(),
146            #[cfg(feature = "tower")]
147            cooldown_duration: None,
148            #[cfg(feature = "tower")]
149            rate_limit_config: None,
150            #[cfg(feature = "tower")]
151            in_flight_limit_config: None,
152            #[cfg(feature = "tower")]
153            health_check_interval: None,
154            #[cfg(feature = "tower")]
155            enable_cost_tracking: false,
156            #[cfg(feature = "tower")]
157            enable_tracing: false,
158            _key_state: std::marker::PhantomData,
159            _provider_state: std::marker::PhantomData,
160        }
161    }
162}
163
164impl Default for ClientBuilder<NoApiKey, NoProvider> {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl<K, P> ClientBuilder<K, P> {
171    /// Set the API key for authentication.
172    ///
173    /// Accepts any value that converts into a [`SecretString`] (including
174    /// `&str` and `String`).  The secret is zeroed on drop.
175    ///
176    /// Calling this method transitions the `K` type parameter from
177    /// [`NoApiKey`] to [`WithApiKey`].
178    pub fn api_key(self, key: impl Into<String>) -> ClientBuilder<WithApiKey, P> {
179        ClientBuilder {
180            api_key: SecretString::from(key.into()),
181            provider_hint: self.provider_hint,
182            base_url: self.base_url,
183            timeout: self.timeout,
184            connect_timeout: self.connect_timeout,
185            max_retries: self.max_retries,
186            max_response_bytes: self.max_response_bytes,
187            transport: self.transport,
188            load_env: self.load_env,
189            credential_provider: self.credential_provider,
190            #[cfg(feature = "tower")]
191            cache_config: self.cache_config,
192            #[cfg(feature = "tower")]
193            cache_store: self.cache_store,
194            #[cfg(feature = "tower")]
195            budget_config: self.budget_config,
196            #[cfg(feature = "tower")]
197            hooks: self.hooks,
198            #[cfg(feature = "tower")]
199            cooldown_duration: self.cooldown_duration,
200            #[cfg(feature = "tower")]
201            rate_limit_config: self.rate_limit_config,
202            #[cfg(feature = "tower")]
203            in_flight_limit_config: self.in_flight_limit_config,
204            #[cfg(feature = "tower")]
205            health_check_interval: self.health_check_interval,
206            #[cfg(feature = "tower")]
207            enable_cost_tracking: self.enable_cost_tracking,
208            #[cfg(feature = "tower")]
209            enable_tracing: self.enable_tracing,
210            _key_state: std::marker::PhantomData,
211            _provider_state: self._provider_state,
212        }
213    }
214
215    /// Set the provider by name or model-hint string.
216    ///
217    /// The string is forwarded to provider auto-detection in the same way
218    /// that the `model_hint` argument to `DefaultClient::new` works.  Use
219    /// a slash-prefixed name (e.g. `"openai"`, `"groq/llama3-70b"`,
220    /// `"anthropic"`) or a bare model name.
221    ///
222    /// Calling this method transitions the `P` type parameter from
223    /// [`NoProvider`] to [`WithProvider`].
224    pub fn provider(self, provider_name: impl Into<String>) -> ClientBuilder<K, WithProvider> {
225        ClientBuilder {
226            api_key: self.api_key,
227            provider_hint: provider_name.into(),
228            base_url: self.base_url,
229            timeout: self.timeout,
230            connect_timeout: self.connect_timeout,
231            max_retries: self.max_retries,
232            max_response_bytes: self.max_response_bytes,
233            transport: self.transport,
234            load_env: self.load_env,
235            credential_provider: self.credential_provider,
236            #[cfg(feature = "tower")]
237            cache_config: self.cache_config,
238            #[cfg(feature = "tower")]
239            cache_store: self.cache_store,
240            #[cfg(feature = "tower")]
241            budget_config: self.budget_config,
242            #[cfg(feature = "tower")]
243            hooks: self.hooks,
244            #[cfg(feature = "tower")]
245            cooldown_duration: self.cooldown_duration,
246            #[cfg(feature = "tower")]
247            rate_limit_config: self.rate_limit_config,
248            #[cfg(feature = "tower")]
249            in_flight_limit_config: self.in_flight_limit_config,
250            #[cfg(feature = "tower")]
251            health_check_interval: self.health_check_interval,
252            #[cfg(feature = "tower")]
253            enable_cost_tracking: self.enable_cost_tracking,
254            #[cfg(feature = "tower")]
255            enable_tracing: self.enable_tracing,
256            _key_state: self._key_state,
257            _provider_state: std::marker::PhantomData,
258        }
259    }
260
261    /// Override the provider base URL for all requests.
262    ///
263    /// Trailing slashes are trimmed automatically.  When set, provider
264    /// auto-detection is bypassed and all requests go to this URL.
265    pub fn base_url(mut self, url: impl Into<String>) -> Self {
266        let url = url.into();
267        self.base_url = Some(url.trim_end_matches('/').to_string());
268        self
269    }
270
271    /// Set the per-request timeout (default: 60 s).
272    pub fn timeout(mut self, timeout: Duration) -> Self {
273        self.timeout = timeout;
274        self
275    }
276
277    /// Set the connect timeout (default: 10 s).
278    ///
279    /// A connection that does not open in this time is retried up to `max_retries` times.
280    /// Zero is an error when the client is built, on every target. `wasm32` ignores any
281    /// other value.
282    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
283        self.connect_timeout = timeout;
284        self
285    }
286
287    /// Set the maximum number of retries on 429 / 5xx responses (default: 3).
288    pub fn max_retries(mut self, retries: u32) -> Self {
289        self.max_retries = retries;
290        self
291    }
292
293    /// Limit retained HTTP bodies; successful streams keep their existing frame bounds.
294    ///
295    /// # Errors
296    /// Returns an error when the limit is zero.
297    #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
298    pub fn max_response_bytes(mut self, limit: usize) -> Result<Self> {
299        super::config::validate_response_limit(Some(limit))?;
300        self.max_response_bytes = Some(limit);
301        Ok(self)
302    }
303
304    /// Set HTTP connection pooling, TCP keepalive, DNS caching, and version selection.
305    pub fn transport(mut self, config: TransportConfig) -> Self {
306        self.transport = config;
307        self
308    }
309
310    /// Enable or disable automatic API key loading from environment variables.
311    ///
312    /// When `true`, the client will attempt to read the provider's designated
313    /// environment variable (e.g. `OPENAI_API_KEY`) if no key was set via
314    /// [`api_key`](Self::api_key).  Defaults to `false` in [`ClientBuilder`]
315    /// (unlike [`ClientConfigBuilder`](super::config::ClientConfigBuilder)
316    /// which defaults to `true`) because the type-state design signals that
317    /// the key should be provided explicitly.
318    pub fn load_env(mut self, enabled: bool) -> Self {
319        self.load_env = enabled;
320        self
321    }
322
323    /// Set a dynamic credential provider for token-based or refreshable auth.
324    ///
325    /// When configured, the client calls `resolve()` before each request
326    /// instead of using the static API key for authentication.
327    pub fn credential_provider(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
328        self.credential_provider = Some(provider);
329        self
330    }
331
332    /// Set the response cache configuration for the Tower middleware stack.
333    #[cfg(feature = "tower")]
334    pub fn cache(mut self, config: CacheConfig) -> Self {
335        self.cache_config = Some(config);
336        self
337    }
338
339    /// Set a custom cache store backend for the Tower cache middleware.
340    #[cfg(feature = "tower")]
341    pub fn cache_store(mut self, store: Arc<dyn CacheStore>) -> Self {
342        self.cache_store = Some(store);
343        self
344    }
345
346    /// Set the budget enforcement configuration for the Tower middleware stack.
347    #[cfg(feature = "tower")]
348    pub fn budget(mut self, config: BudgetConfig) -> Self {
349        self.budget_config = Some(config);
350        self
351    }
352
353    /// Add a single hook to the Tower hooks middleware stack.
354    #[cfg(feature = "tower")]
355    pub fn hook(mut self, hook: Arc<dyn LlmHook>) -> Self {
356        self.hooks.push(hook);
357        self
358    }
359
360    /// Set the full list of hooks for the Tower hooks middleware stack.
361    #[cfg(feature = "tower")]
362    pub fn hooks(mut self, hooks: Vec<Arc<dyn LlmHook>>) -> Self {
363        self.hooks = hooks;
364        self
365    }
366
367    /// Set the cooldown duration after transient errors.
368    #[cfg(feature = "tower")]
369    pub fn cooldown(mut self, duration: Duration) -> Self {
370        self.cooldown_duration = Some(duration);
371        self
372    }
373
374    /// Set per-model rate limiting configuration.
375    #[cfg(feature = "tower")]
376    pub fn rate_limit(mut self, config: RateLimitConfig) -> Self {
377        self.rate_limit_config = Some(config);
378        self
379    }
380
381    /// Set the global per-client in-flight provider request limit.
382    #[cfg(feature = "tower")]
383    pub fn in_flight_limit(mut self, config: InFlightLimitConfig) -> Self {
384        self.in_flight_limit_config = Some(config);
385        self
386    }
387
388    /// Set the background health check interval.
389    #[cfg(feature = "tower")]
390    pub fn health_check(mut self, interval: Duration) -> Self {
391        self.health_check_interval = Some(interval);
392        self
393    }
394
395    /// Enable or disable per-request cost tracking.
396    #[cfg(feature = "tower")]
397    pub fn cost_tracking(mut self, enabled: bool) -> Self {
398        self.enable_cost_tracking = enabled;
399        self
400    }
401
402    /// Enable or disable OpenTelemetry-compatible tracing spans.
403    #[cfg(feature = "tower")]
404    pub fn tracing(mut self, enabled: bool) -> Self {
405        self.enable_tracing = enabled;
406        self
407    }
408}
409
410#[cfg(any(feature = "native-http", feature = "wasm-http"))]
411impl ClientBuilder<WithApiKey, WithProvider> {
412    /// Consume the builder and construct a [`DefaultClient`](super::DefaultClient).
413    ///
414    /// This method is only available when both [`api_key`](ClientBuilder::api_key)
415    /// and [`provider`](ClientBuilder::provider) have been called.  The
416    /// compiler enforces this at the type level.
417    ///
418    /// # Errors
419    ///
420    /// Returns `LiterLlmError::Authentication` if the provider requires an
421    /// environment variable that is unset (only relevant when the API key
422    /// supplied via [`api_key`] is an empty string and `load_env` is `true`).
423    ///
424    /// Returns `LiterLlmError::Http` if the underlying HTTP client cannot be
425    /// constructed.
426    pub fn build(self) -> Result<super::DefaultClient> {
427        use super::config::ClientConfig;
428
429        let config = ClientConfig {
430            api_key: self.api_key,
431            base_url: self.base_url,
432            timeout: self.timeout,
433            connect_timeout: self.connect_timeout,
434            max_retries: self.max_retries,
435            max_response_bytes: self.max_response_bytes,
436            extra_headers: Vec::new(),
437            credential_provider: self.credential_provider,
438            load_env: self.load_env,
439            transport: self.transport,
440            bedrock_region: None,
441            bedrock_cross_region_prefix: None,
442            bedrock_access_key_id: None,
443            bedrock_secret_access_key: None,
444            bedrock_session_token: None,
445            #[cfg(feature = "tower")]
446            cache_config: self.cache_config,
447            #[cfg(feature = "tower")]
448            cache_store: self.cache_store,
449            #[cfg(feature = "tower")]
450            budget_config: self.budget_config,
451            #[cfg(feature = "tower")]
452            hooks: self.hooks,
453            #[cfg(feature = "tower")]
454            cooldown_duration: self.cooldown_duration,
455            #[cfg(feature = "tower")]
456            rate_limit_config: self.rate_limit_config,
457            #[cfg(feature = "tower")]
458            in_flight_limit_config: self.in_flight_limit_config,
459            #[cfg(feature = "tower")]
460            health_check_interval: self.health_check_interval,
461            #[cfg(feature = "tower")]
462            enable_cost_tracking: self.enable_cost_tracking,
463            #[cfg(feature = "tower")]
464            enable_tracing: self.enable_tracing,
465        };
466
467        let hint = if self.provider_hint.is_empty() {
468            None
469        } else {
470            Some(self.provider_hint.as_str())
471        };
472
473        super::DefaultClient::new(config, hint)
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::client::ClientConfigBuilder;
481
482    #[test]
483    fn the_default_connect_timeout_is_ten_seconds() {
484        assert_eq!(ClientBuilder::new().connect_timeout, Duration::from_secs(10));
485    }
486
487    #[test]
488    fn the_typed_builder_and_the_config_builder_share_the_default_connect_timeout() {
489        assert_eq!(
490            ClientBuilder::new().connect_timeout,
491            ClientConfigBuilder::new("sk-test").build().connect_timeout
492        );
493    }
494
495    // `api_key()` and `provider()` each make a new builder of another type. The value set before
496    // the call must reach the built client, not the 10 s default.
497
498    #[cfg(any(feature = "native-http", feature = "wasm-http"))]
499    #[test]
500    fn a_connect_timeout_set_before_api_key_reaches_the_client() {
501        let client = ClientBuilder::new()
502            .provider("openai")
503            .connect_timeout(Duration::from_secs(3))
504            .api_key("sk-test")
505            .build()
506            .expect("build the client");
507        assert_eq!(client.config.connect_timeout, Duration::from_secs(3));
508    }
509
510    #[cfg(any(feature = "native-http", feature = "wasm-http"))]
511    #[test]
512    fn a_connect_timeout_set_before_provider_reaches_the_client() {
513        let client = ClientBuilder::new()
514            .api_key("sk-test")
515            .connect_timeout(Duration::from_secs(3))
516            .provider("openai")
517            .build()
518            .expect("build the client");
519        assert_eq!(client.config.connect_timeout, Duration::from_secs(3));
520    }
521}