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    max_retries: u32,
92    max_response_bytes: Option<usize>,
93    transport: TransportConfig,
94    load_env: bool,
95    credential_provider: Option<Arc<dyn CredentialProvider>>,
96    #[cfg(feature = "tower")]
97    cache_config: Option<CacheConfig>,
98    #[cfg(feature = "tower")]
99    cache_store: Option<Arc<dyn CacheStore>>,
100    #[cfg(feature = "tower")]
101    budget_config: Option<BudgetConfig>,
102    #[cfg(feature = "tower")]
103    hooks: Vec<Arc<dyn LlmHook>>,
104    #[cfg(feature = "tower")]
105    cooldown_duration: Option<Duration>,
106    #[cfg(feature = "tower")]
107    rate_limit_config: Option<RateLimitConfig>,
108    #[cfg(feature = "tower")]
109    in_flight_limit_config: Option<InFlightLimitConfig>,
110    #[cfg(feature = "tower")]
111    health_check_interval: Option<Duration>,
112    #[cfg(feature = "tower")]
113    enable_cost_tracking: bool,
114    #[cfg(feature = "tower")]
115    enable_tracing: bool,
116    _key_state: std::marker::PhantomData<K>,
117    _provider_state: std::marker::PhantomData<P>,
118}
119
120impl ClientBuilder<NoApiKey, NoProvider> {
121    /// Create a new builder with no key and no provider set.
122    ///
123    /// Call [`api_key`](Self::api_key) and [`provider`](Self::provider) to
124    /// advance the type state, then call [`build`] when both are set.
125    pub fn new() -> Self {
126        Self {
127            api_key: SecretString::from(String::new()),
128            provider_hint: String::new(),
129            base_url: None,
130            timeout: Duration::from_secs(60),
131            max_retries: 3,
132            max_response_bytes: None,
133            transport: TransportConfig::default(),
134            load_env: false,
135            credential_provider: None,
136            #[cfg(feature = "tower")]
137            cache_config: None,
138            #[cfg(feature = "tower")]
139            cache_store: None,
140            #[cfg(feature = "tower")]
141            budget_config: None,
142            #[cfg(feature = "tower")]
143            hooks: Vec::new(),
144            #[cfg(feature = "tower")]
145            cooldown_duration: None,
146            #[cfg(feature = "tower")]
147            rate_limit_config: None,
148            #[cfg(feature = "tower")]
149            in_flight_limit_config: None,
150            #[cfg(feature = "tower")]
151            health_check_interval: None,
152            #[cfg(feature = "tower")]
153            enable_cost_tracking: false,
154            #[cfg(feature = "tower")]
155            enable_tracing: false,
156            _key_state: std::marker::PhantomData,
157            _provider_state: std::marker::PhantomData,
158        }
159    }
160}
161
162impl Default for ClientBuilder<NoApiKey, NoProvider> {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168impl<K, P> ClientBuilder<K, P> {
169    /// Set the API key for authentication.
170    ///
171    /// Accepts any value that converts into a [`SecretString`] (including
172    /// `&str` and `String`).  The secret is zeroed on drop.
173    ///
174    /// Calling this method transitions the `K` type parameter from
175    /// [`NoApiKey`] to [`WithApiKey`].
176    pub fn api_key(self, key: impl Into<String>) -> ClientBuilder<WithApiKey, P> {
177        ClientBuilder {
178            api_key: SecretString::from(key.into()),
179            provider_hint: self.provider_hint,
180            base_url: self.base_url,
181            timeout: self.timeout,
182            max_retries: self.max_retries,
183            max_response_bytes: self.max_response_bytes,
184            transport: self.transport,
185            load_env: self.load_env,
186            credential_provider: self.credential_provider,
187            #[cfg(feature = "tower")]
188            cache_config: self.cache_config,
189            #[cfg(feature = "tower")]
190            cache_store: self.cache_store,
191            #[cfg(feature = "tower")]
192            budget_config: self.budget_config,
193            #[cfg(feature = "tower")]
194            hooks: self.hooks,
195            #[cfg(feature = "tower")]
196            cooldown_duration: self.cooldown_duration,
197            #[cfg(feature = "tower")]
198            rate_limit_config: self.rate_limit_config,
199            #[cfg(feature = "tower")]
200            in_flight_limit_config: self.in_flight_limit_config,
201            #[cfg(feature = "tower")]
202            health_check_interval: self.health_check_interval,
203            #[cfg(feature = "tower")]
204            enable_cost_tracking: self.enable_cost_tracking,
205            #[cfg(feature = "tower")]
206            enable_tracing: self.enable_tracing,
207            _key_state: std::marker::PhantomData,
208            _provider_state: self._provider_state,
209        }
210    }
211
212    /// Set the provider by name or model-hint string.
213    ///
214    /// The string is forwarded to provider auto-detection in the same way
215    /// that the `model_hint` argument to `DefaultClient::new` works.  Use
216    /// a slash-prefixed name (e.g. `"openai"`, `"groq/llama3-70b"`,
217    /// `"anthropic"`) or a bare model name.
218    ///
219    /// Calling this method transitions the `P` type parameter from
220    /// [`NoProvider`] to [`WithProvider`].
221    pub fn provider(self, provider_name: impl Into<String>) -> ClientBuilder<K, WithProvider> {
222        ClientBuilder {
223            api_key: self.api_key,
224            provider_hint: provider_name.into(),
225            base_url: self.base_url,
226            timeout: self.timeout,
227            max_retries: self.max_retries,
228            max_response_bytes: self.max_response_bytes,
229            transport: self.transport,
230            load_env: self.load_env,
231            credential_provider: self.credential_provider,
232            #[cfg(feature = "tower")]
233            cache_config: self.cache_config,
234            #[cfg(feature = "tower")]
235            cache_store: self.cache_store,
236            #[cfg(feature = "tower")]
237            budget_config: self.budget_config,
238            #[cfg(feature = "tower")]
239            hooks: self.hooks,
240            #[cfg(feature = "tower")]
241            cooldown_duration: self.cooldown_duration,
242            #[cfg(feature = "tower")]
243            rate_limit_config: self.rate_limit_config,
244            #[cfg(feature = "tower")]
245            in_flight_limit_config: self.in_flight_limit_config,
246            #[cfg(feature = "tower")]
247            health_check_interval: self.health_check_interval,
248            #[cfg(feature = "tower")]
249            enable_cost_tracking: self.enable_cost_tracking,
250            #[cfg(feature = "tower")]
251            enable_tracing: self.enable_tracing,
252            _key_state: self._key_state,
253            _provider_state: std::marker::PhantomData,
254        }
255    }
256
257    /// Override the provider base URL for all requests.
258    ///
259    /// Trailing slashes are trimmed automatically.  When set, provider
260    /// auto-detection is bypassed and all requests go to this URL.
261    pub fn base_url(mut self, url: impl Into<String>) -> Self {
262        let url = url.into();
263        self.base_url = Some(url.trim_end_matches('/').to_string());
264        self
265    }
266
267    /// Set the per-request timeout (default: 60 s).
268    pub fn timeout(mut self, timeout: Duration) -> Self {
269        self.timeout = timeout;
270        self
271    }
272
273    /// Set the maximum number of retries on 429 / 5xx responses (default: 3).
274    pub fn max_retries(mut self, retries: u32) -> Self {
275        self.max_retries = retries;
276        self
277    }
278
279    /// Limit retained HTTP bodies; successful streams keep their existing frame bounds.
280    ///
281    /// # Errors
282    /// Returns an error when the limit is zero.
283    #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
284    pub fn max_response_bytes(mut self, limit: usize) -> Result<Self> {
285        super::config::validate_response_limit(Some(limit))?;
286        self.max_response_bytes = Some(limit);
287        Ok(self)
288    }
289
290    /// Set HTTP connection pooling, TCP keepalive, DNS caching, and version selection.
291    pub fn transport(mut self, config: TransportConfig) -> Self {
292        self.transport = config;
293        self
294    }
295
296    /// Enable or disable automatic API key loading from environment variables.
297    ///
298    /// When `true`, the client will attempt to read the provider's designated
299    /// environment variable (e.g. `OPENAI_API_KEY`) if no key was set via
300    /// [`api_key`](Self::api_key).  Defaults to `false` in [`ClientBuilder`]
301    /// (unlike [`ClientConfigBuilder`](super::config::ClientConfigBuilder)
302    /// which defaults to `true`) because the type-state design signals that
303    /// the key should be provided explicitly.
304    pub fn load_env(mut self, enabled: bool) -> Self {
305        self.load_env = enabled;
306        self
307    }
308
309    /// Set a dynamic credential provider for token-based or refreshable auth.
310    ///
311    /// When configured, the client calls `resolve()` before each request
312    /// instead of using the static API key for authentication.
313    pub fn credential_provider(mut self, provider: Arc<dyn CredentialProvider>) -> Self {
314        self.credential_provider = Some(provider);
315        self
316    }
317
318    /// Set the response cache configuration for the Tower middleware stack.
319    #[cfg(feature = "tower")]
320    pub fn cache(mut self, config: CacheConfig) -> Self {
321        self.cache_config = Some(config);
322        self
323    }
324
325    /// Set a custom cache store backend for the Tower cache middleware.
326    #[cfg(feature = "tower")]
327    pub fn cache_store(mut self, store: Arc<dyn CacheStore>) -> Self {
328        self.cache_store = Some(store);
329        self
330    }
331
332    /// Set the budget enforcement configuration for the Tower middleware stack.
333    #[cfg(feature = "tower")]
334    pub fn budget(mut self, config: BudgetConfig) -> Self {
335        self.budget_config = Some(config);
336        self
337    }
338
339    /// Add a single hook to the Tower hooks middleware stack.
340    #[cfg(feature = "tower")]
341    pub fn hook(mut self, hook: Arc<dyn LlmHook>) -> Self {
342        self.hooks.push(hook);
343        self
344    }
345
346    /// Set the full list of hooks for the Tower hooks middleware stack.
347    #[cfg(feature = "tower")]
348    pub fn hooks(mut self, hooks: Vec<Arc<dyn LlmHook>>) -> Self {
349        self.hooks = hooks;
350        self
351    }
352
353    /// Set the cooldown duration after transient errors.
354    #[cfg(feature = "tower")]
355    pub fn cooldown(mut self, duration: Duration) -> Self {
356        self.cooldown_duration = Some(duration);
357        self
358    }
359
360    /// Set per-model rate limiting configuration.
361    #[cfg(feature = "tower")]
362    pub fn rate_limit(mut self, config: RateLimitConfig) -> Self {
363        self.rate_limit_config = Some(config);
364        self
365    }
366
367    /// Set the global per-client in-flight provider request limit.
368    #[cfg(feature = "tower")]
369    pub fn in_flight_limit(mut self, config: InFlightLimitConfig) -> Self {
370        self.in_flight_limit_config = Some(config);
371        self
372    }
373
374    /// Set the background health check interval.
375    #[cfg(feature = "tower")]
376    pub fn health_check(mut self, interval: Duration) -> Self {
377        self.health_check_interval = Some(interval);
378        self
379    }
380
381    /// Enable or disable per-request cost tracking.
382    #[cfg(feature = "tower")]
383    pub fn cost_tracking(mut self, enabled: bool) -> Self {
384        self.enable_cost_tracking = enabled;
385        self
386    }
387
388    /// Enable or disable OpenTelemetry-compatible tracing spans.
389    #[cfg(feature = "tower")]
390    pub fn tracing(mut self, enabled: bool) -> Self {
391        self.enable_tracing = enabled;
392        self
393    }
394}
395
396#[cfg(any(feature = "native-http", feature = "wasm-http"))]
397impl ClientBuilder<WithApiKey, WithProvider> {
398    /// Consume the builder and construct a [`DefaultClient`](super::DefaultClient).
399    ///
400    /// This method is only available when both [`api_key`](ClientBuilder::api_key)
401    /// and [`provider`](ClientBuilder::provider) have been called.  The
402    /// compiler enforces this at the type level.
403    ///
404    /// # Errors
405    ///
406    /// Returns `LiterLlmError::Authentication` if the provider requires an
407    /// environment variable that is unset (only relevant when the API key
408    /// supplied via [`api_key`] is an empty string and `load_env` is `true`).
409    ///
410    /// Returns `LiterLlmError::Http` if the underlying HTTP client cannot be
411    /// constructed.
412    pub fn build(self) -> Result<super::DefaultClient> {
413        use super::config::ClientConfig;
414
415        let config = ClientConfig {
416            api_key: self.api_key,
417            base_url: self.base_url,
418            timeout: self.timeout,
419            max_retries: self.max_retries,
420            max_response_bytes: self.max_response_bytes,
421            extra_headers: Vec::new(),
422            credential_provider: self.credential_provider,
423            load_env: self.load_env,
424            transport: self.transport,
425            bedrock_region: None,
426            bedrock_cross_region_prefix: None,
427            bedrock_access_key_id: None,
428            bedrock_secret_access_key: None,
429            bedrock_session_token: None,
430            #[cfg(feature = "tower")]
431            cache_config: self.cache_config,
432            #[cfg(feature = "tower")]
433            cache_store: self.cache_store,
434            #[cfg(feature = "tower")]
435            budget_config: self.budget_config,
436            #[cfg(feature = "tower")]
437            hooks: self.hooks,
438            #[cfg(feature = "tower")]
439            cooldown_duration: self.cooldown_duration,
440            #[cfg(feature = "tower")]
441            rate_limit_config: self.rate_limit_config,
442            #[cfg(feature = "tower")]
443            in_flight_limit_config: self.in_flight_limit_config,
444            #[cfg(feature = "tower")]
445            health_check_interval: self.health_check_interval,
446            #[cfg(feature = "tower")]
447            enable_cost_tracking: self.enable_cost_tracking,
448            #[cfg(feature = "tower")]
449            enable_tracing: self.enable_tracing,
450        };
451
452        let hint = if self.provider_hint.is_empty() {
453            None
454        } else {
455            Some(self.provider_hint.as_str())
456        };
457
458        super::DefaultClient::new(config, hint)
459    }
460}