Skip to main content

rai_sdk/
config.rs

1//! Client-level configuration: credentials, endpoints, timeouts, retries.
2//!
3//! [`Config`] holds everything that is scoped to a client rather than to a
4//! single request. It can be built explicitly, loaded from the environment
5//! with [`Config::from_env`], or mixed: the getters fall back to environment
6//! variables when a field was not set programmatically, so an explicit value
7//! always wins over the environment.
8//!
9//! # Environment variables
10//!
11//! * `OPENAI_API_KEY`, `OPENAI_BASE_URL`
12//! * `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`
13//! * `OPENROUTER_API_KEY`, `OPENROUTER_BASE_URL`
14//! * `OPENROUTER_HTTP_REFERER` (or the legacy `OPENROUTER_APP_URL`),
15//!   `OPENROUTER_TITLE` (or the legacy `OPENROUTER_APP_TITLE`),
16//!   `OPENROUTER_CATEGORIES` (comma-separated)
17//! * `AI_TIMEOUT_SECONDS`
18//! * `AI_MAX_RETRIES`, `AI_RETRY_INITIAL_DELAY_MS`, `AI_RETRY_MAX_DELAY_MS`,
19//!   `AI_RETRY_BACKOFF_MULTIPLIER`, `AI_RETRY_JITTER`
20//!
21//! The OpenAI-compatible endpoint settings are the one exception: they have no
22//! environment variables and are always per client. See
23//! [`Config::openai_compatible_base_url`].
24//!
25//! # Examples
26//!
27//! ```no_run
28//! use std::time::Duration;
29//! use rai_sdk::{Config, RetryConfig};
30//!
31//! // Start from the environment, then override selected values.
32//! let config = Config::from_env()
33//!     .with_timeout(30)
34//!     .with_default_max_tokens(2048)
35//!     .with_retry_config(RetryConfig::new().with_initial_delay(Duration::from_millis(250)));
36//!
37//! assert_eq!(config.timeout(), 30);
38//! ```
39
40use serde::{Deserialize, Serialize};
41
42#[cfg(any(feature = "openai", feature = "anthropic", feature = "openrouter"))]
43use crate::error;
44use crate::retry::RetryConfig;
45
46/// The base URL Ollama serves its OpenAI-compatible API on by default.
47///
48/// Used by [`Config::with_ollama`] and
49/// [`ClientBuilder::ollama`](crate::ClientBuilder::ollama).
50pub const OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
51
52/// What an OpenAI-compatible endpoint supports beyond plain chat completions.
53///
54/// Endpoints that speak OpenAI's wire format vary in what they implement: a
55/// 3B model behind Ollama may have no tool support at all, and a runtime may
56/// accept `response_format` and then ignore it. This type is how a caller
57/// states what its endpoint can do. Nothing is probed — auto-detection would
58/// mean an extra round trip on every client build and still be wrong for the
59/// per-model cases.
60///
61/// The default assumes full compatibility, so a capable endpoint needs no
62/// configuration. Turn a capability off to convert what would be an opaque
63/// HTTP 400 partway through into an immediate, typed
64/// [`Error::CapabilityUnsupported`](crate::Error::CapabilityUnsupported).
65///
66/// # Examples
67///
68/// ```
69/// use rai_sdk::EndpointCapabilities;
70///
71/// // A small local model that cannot call tools but does honor JSON schemas.
72/// let capabilities = EndpointCapabilities::default().with_tool_calling(false);
73///
74/// assert!(!capabilities.tool_calling);
75/// assert!(capabilities.structured_output);
76/// ```
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78pub struct EndpointCapabilities {
79    /// Whether the endpoint accepts `tools` and can return tool calls.
80    pub tool_calling: bool,
81
82    /// Whether the endpoint honors `response_format` — JSON mode or a JSON
83    /// Schema.
84    pub structured_output: bool,
85}
86
87impl Default for EndpointCapabilities {
88    /// Assume the endpoint implements everything, which is what "OpenAI
89    /// compatible" claims.
90    fn default() -> Self {
91        Self::all()
92    }
93}
94
95impl EndpointCapabilities {
96    /// Every capability is supported.
97    pub fn all() -> Self {
98        Self {
99            tool_calling: true,
100            structured_output: true,
101        }
102    }
103
104    /// Chat completions only: no tool calling, no structured output.
105    pub fn text_only() -> Self {
106        Self {
107            tool_calling: false,
108            structured_output: false,
109        }
110    }
111
112    /// Declare whether the endpoint supports tool calling.
113    pub fn with_tool_calling(mut self, supported: bool) -> Self {
114        self.tool_calling = supported;
115        self
116    }
117
118    /// Declare whether the endpoint supports structured output.
119    pub fn with_structured_output(mut self, supported: bool) -> Self {
120        self.structured_output = supported;
121        self
122    }
123}
124
125/// Configuration for the AI SDK client.
126///
127/// Every field is optional. Unset credentials simply mean the corresponding
128/// provider is unavailable rather than an error at construction time; the
129/// failure surfaces as [`Error::ProviderNotConfigured`](crate::Error::ProviderNotConfigured)
130/// when that provider is actually used.
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct Config {
133    /// OpenAI API key.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub openai_api_key: Option<String>,
136
137    /// OpenAI API base URL (for proxies or Azure OpenAI).
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub openai_base_url: Option<String>,
140
141    /// Anthropic API key.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub anthropic_api_key: Option<String>,
144
145    /// Anthropic API base URL (for proxies).
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub anthropic_base_url: Option<String>,
148
149    /// OpenRouter API key.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub openrouter_api_key: Option<String>,
152
153    /// OpenRouter API base URL.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub openrouter_base_url: Option<String>,
156
157    /// Base URL of an OpenAI-compatible endpoint, such as
158    /// `http://localhost:11434/v1`.
159    ///
160    /// Setting this is what makes [`ProviderKind::OpenAICompatible`] available
161    /// on a client; there is no default endpoint and no environment variable,
162    /// because a process routinely talks to several at once. See
163    /// [`Config::openai_compatible_base_url`].
164    ///
165    /// [`ProviderKind::OpenAICompatible`]: crate::ProviderKind::OpenAICompatible
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub openai_compatible_base_url: Option<String>,
168
169    /// Bearer token for the OpenAI-compatible endpoint.
170    ///
171    /// Optional: endpoints that need no credential — the common case for a
172    /// local runtime — simply leave this unset, and no `Authorization` header
173    /// is sent.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub openai_compatible_api_key: Option<String>,
176
177    /// What the OpenAI-compatible endpoint supports beyond plain chat.
178    ///
179    /// Declared by the caller, never probed. Defaults to
180    /// [`EndpointCapabilities::default`], which assumes full compatibility.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub openai_compatible_capabilities: Option<EndpointCapabilities>,
183
184    /// Optional app URL for OpenRouter attribution headers.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub openrouter_http_referer: Option<String>,
187
188    /// Optional app title for OpenRouter attribution headers.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub openrouter_title: Option<String>,
191
192    /// Optional app categories for OpenRouter attribution headers.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub openrouter_categories: Option<Vec<String>>,
195
196    /// OpenRouter App URL (sent in headers).
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub openrouter_app_url: Option<String>,
199
200    /// OpenRouter App Title (sent in headers).
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub openrouter_app_title: Option<String>,
203
204    /// Request timeout in seconds (default: 120).
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub timeout_seconds: Option<u64>,
207
208    /// Default max tokens for generation (can be overridden per request).
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub default_max_tokens: Option<i32>,
211
212    /// Retry configuration for transient errors.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub retry_config: Option<RetryConfig>,
215}
216
217impl Config {
218    /// Create a new empty configuration.
219    pub fn new() -> Self {
220        Self::default()
221    }
222
223    /// Create configuration from environment variables.
224    ///
225    /// Reads every variable listed in the [module docs](self). Missing or
226    /// unparseable values are ignored, leaving the corresponding field unset
227    /// (and therefore at its default). The retry configuration is only
228    /// populated when at least one `AI_RETRY_*`/`AI_MAX_RETRIES` variable was
229    /// recognized.
230    pub fn from_env() -> Self {
231        let mut config = Self::new();
232
233        if let Ok(key) = std::env::var("OPENAI_API_KEY") {
234            config.openai_api_key = Some(key);
235        }
236        if let Ok(url) = std::env::var("OPENAI_BASE_URL") {
237            config.openai_base_url = Some(url);
238        }
239        if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
240            config.anthropic_api_key = Some(key);
241        }
242        if let Ok(url) = std::env::var("ANTHROPIC_BASE_URL") {
243            config.anthropic_base_url = Some(url);
244        }
245        if let Ok(key) = std::env::var("OPENROUTER_API_KEY") {
246            config.openrouter_api_key = Some(key);
247        }
248        if let Ok(url) = std::env::var("OPENROUTER_BASE_URL") {
249            config.openrouter_base_url = Some(url);
250        }
251        if let Ok(referer) = std::env::var("OPENROUTER_HTTP_REFERER") {
252            config.openrouter_http_referer = Some(referer);
253        } else if let Ok(url) = std::env::var("OPENROUTER_APP_URL") {
254            config.openrouter_app_url = Some(url.clone());
255            config.openrouter_http_referer = Some(url);
256        }
257        if let Ok(title) = std::env::var("OPENROUTER_TITLE") {
258            config.openrouter_title = Some(title);
259        } else if let Ok(title) = std::env::var("OPENROUTER_APP_TITLE") {
260            config.openrouter_app_title = Some(title.clone());
261            config.openrouter_title = Some(title);
262        }
263        if let Ok(categories) = std::env::var("OPENROUTER_CATEGORIES") {
264            let categories = parse_openrouter_categories(&categories);
265            if !categories.is_empty() {
266                config.openrouter_categories = Some(categories);
267            }
268        }
269        if let Ok(timeout) = std::env::var("AI_TIMEOUT_SECONDS") {
270            if let Ok(secs) = timeout.parse() {
271                config.timeout_seconds = Some(secs);
272            }
273        }
274
275        let mut retry = RetryConfig::default();
276        let mut retry_customized = false;
277
278        if let Ok(value) = std::env::var("AI_MAX_RETRIES") {
279            if let Ok(max_retries) = value.parse() {
280                retry.max_retries = max_retries;
281                retry_customized = true;
282            }
283        }
284        if let Ok(value) = std::env::var("AI_RETRY_INITIAL_DELAY_MS") {
285            if let Ok(milliseconds) = value.parse() {
286                retry.initial_delay = std::time::Duration::from_millis(milliseconds);
287                retry_customized = true;
288            }
289        }
290        if let Ok(value) = std::env::var("AI_RETRY_MAX_DELAY_MS") {
291            if let Ok(milliseconds) = value.parse() {
292                retry.max_delay = std::time::Duration::from_millis(milliseconds);
293                retry_customized = true;
294            }
295        }
296        if let Ok(value) = std::env::var("AI_RETRY_BACKOFF_MULTIPLIER") {
297            if let Ok(multiplier) = value.parse() {
298                retry.backoff_multiplier = multiplier;
299                retry_customized = true;
300            }
301        }
302        if let Ok(value) = std::env::var("AI_RETRY_JITTER") {
303            if let Some(jitter) = parse_bool(&value) {
304                retry.jitter = jitter;
305                retry_customized = true;
306            }
307        }
308
309        if retry_customized {
310            config.retry_config = Some(retry);
311        }
312
313        config
314    }
315
316    // ── Builder methods ──
317
318    /// Set the OpenAI API key.
319    pub fn with_openai_key(mut self, key: impl Into<String>) -> Self {
320        self.openai_api_key = Some(key.into());
321        self
322    }
323
324    /// Set the OpenAI base URL, for proxies or Azure OpenAI deployments.
325    pub fn with_openai_base_url(mut self, url: impl Into<String>) -> Self {
326        self.openai_base_url = Some(url.into());
327        self
328    }
329
330    /// Set the Anthropic API key.
331    pub fn with_anthropic_key(mut self, key: impl Into<String>) -> Self {
332        self.anthropic_api_key = Some(key.into());
333        self
334    }
335
336    /// Set the Anthropic base URL, for proxies.
337    pub fn with_anthropic_base_url(mut self, url: impl Into<String>) -> Self {
338        self.anthropic_base_url = Some(url.into());
339        self
340    }
341
342    /// Set the OpenRouter API key.
343    pub fn with_openrouter_key(mut self, key: impl Into<String>) -> Self {
344        self.openrouter_api_key = Some(key.into());
345        self
346    }
347
348    /// Set the OpenRouter base URL, for proxies.
349    pub fn with_openrouter_base_url(mut self, url: impl Into<String>) -> Self {
350        self.openrouter_base_url = Some(url.into());
351        self
352    }
353
354    /// Point this client at an OpenAI-compatible endpoint.
355    ///
356    /// The URL is the API root that serves `POST /chat/completions`, so it
357    /// usually ends in `/v1`.
358    pub fn with_openai_compatible_base_url(mut self, url: impl Into<String>) -> Self {
359        self.openai_compatible_base_url = Some(url.into());
360        self
361    }
362
363    /// Set the bearer token for the OpenAI-compatible endpoint.
364    ///
365    /// Leave it unset for endpoints that need no credential; no
366    /// `Authorization` header is sent then.
367    pub fn with_openai_compatible_key(mut self, key: impl Into<String>) -> Self {
368        self.openai_compatible_api_key = Some(key.into());
369        self
370    }
371
372    /// Declare what the OpenAI-compatible endpoint supports.
373    pub fn with_openai_compatible_capabilities(
374        mut self,
375        capabilities: EndpointCapabilities,
376    ) -> Self {
377        self.openai_compatible_capabilities = Some(capabilities);
378        self
379    }
380
381    /// Point this client at a local Ollama server ([`OLLAMA_BASE_URL`]).
382    ///
383    /// Shorthand for [`Config::with_openai_compatible_base_url`] with Ollama's
384    /// default address; pass the URL explicitly for any other host or port.
385    pub fn with_ollama(self) -> Self {
386        self.with_openai_compatible_base_url(OLLAMA_BASE_URL)
387    }
388
389    /// Set the OpenRouter `HTTP-Referer` attribution header.
390    pub fn with_openrouter_http_referer(mut self, referer: impl Into<String>) -> Self {
391        self.openrouter_http_referer = Some(referer.into());
392        self
393    }
394
395    /// Set the OpenRouter app title attribution header.
396    pub fn with_openrouter_title(mut self, title: impl Into<String>) -> Self {
397        self.openrouter_title = Some(title.into());
398        self
399    }
400
401    /// Set the OpenRouter app categories attribution header.
402    pub fn with_openrouter_categories(mut self, categories: Vec<String>) -> Self {
403        self.openrouter_categories = Some(categories);
404        self
405    }
406
407    /// Set the legacy OpenRouter app URL, which also sets the canonical
408    /// `HTTP-Referer` value.
409    pub fn with_openrouter_app_url(mut self, url: impl Into<String>) -> Self {
410        let url = url.into();
411        self.openrouter_app_url = Some(url.clone());
412        self.openrouter_http_referer = Some(url);
413        self
414    }
415
416    /// Set the legacy OpenRouter app title, which also sets the canonical
417    /// title value.
418    pub fn with_openrouter_app_title(mut self, title: impl Into<String>) -> Self {
419        let title = title.into();
420        self.openrouter_app_title = Some(title.clone());
421        self.openrouter_title = Some(title);
422        self
423    }
424
425    /// Set the HTTP request timeout, in seconds.
426    pub fn with_timeout(mut self, seconds: u64) -> Self {
427        self.timeout_seconds = Some(seconds);
428        self
429    }
430
431    /// Set the default `max_tokens` used when a request does not specify one.
432    pub fn with_default_max_tokens(mut self, max_tokens: i32) -> Self {
433        self.default_max_tokens = Some(max_tokens);
434        self
435    }
436
437    /// Set the retry policy applied to transient errors.
438    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
439        self.retry_config = Some(retry_config);
440        self
441    }
442
443    // ── Getters with env-var fallback ──
444
445    /// The OpenAI API key, falling back to `OPENAI_API_KEY`.
446    pub fn openai_key(&self) -> Option<String> {
447        self.openai_api_key
448            .clone()
449            .or_else(|| std::env::var("OPENAI_API_KEY").ok())
450    }
451
452    /// The Anthropic API key, falling back to `ANTHROPIC_API_KEY`.
453    pub fn anthropic_key(&self) -> Option<String> {
454        self.anthropic_api_key
455            .clone()
456            .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
457    }
458
459    /// The OpenRouter API key, falling back to `OPENROUTER_API_KEY`.
460    pub fn openrouter_key(&self) -> Option<String> {
461        self.openrouter_api_key
462            .clone()
463            .or_else(|| std::env::var("OPENROUTER_API_KEY").ok())
464    }
465
466    /// The OpenRouter base URL, falling back to `OPENROUTER_BASE_URL`.
467    ///
468    /// `None` means the provider uses its built-in default endpoint.
469    pub fn openrouter_base_url(&self) -> Option<String> {
470        self.openrouter_base_url
471            .clone()
472            .or_else(|| std::env::var("OPENROUTER_BASE_URL").ok())
473    }
474
475    /// The OpenAI-compatible endpoint's base URL, or `None` when this client
476    /// has none configured.
477    ///
478    /// Unlike every other getter here this one has **no environment-variable
479    /// fallback**, and that is deliberate. The other providers each name one
480    /// well-known service, so a process-wide `*_BASE_URL` is a sensible
481    /// override. "OpenAI-compatible" names no service at all: a single process
482    /// may talk to a local Ollama, a shared vLLM deployment, and a staging
483    /// gateway at the same time, each with its own credentials and
484    /// capabilities. That is per-client configuration, so it is set per client.
485    ///
486    /// `OPENAI_BASE_URL` keeps its existing meaning and still applies only to
487    /// the real OpenAI provider.
488    pub fn openai_compatible_base_url(&self) -> Option<String> {
489        self.openai_compatible_base_url.clone()
490    }
491
492    /// The OpenAI-compatible endpoint's bearer token, if one was set.
493    ///
494    /// No environment-variable fallback, for the reasons given on
495    /// [`Config::openai_compatible_base_url`].
496    pub fn openai_compatible_key(&self) -> Option<String> {
497        self.openai_compatible_api_key.clone()
498    }
499
500    /// What the OpenAI-compatible endpoint was declared to support, defaulting
501    /// to [`EndpointCapabilities::default`].
502    pub fn openai_compatible_capabilities(&self) -> EndpointCapabilities {
503        self.openai_compatible_capabilities.unwrap_or_default()
504    }
505
506    /// The OpenRouter `HTTP-Referer` value.
507    ///
508    /// Resolution order: the explicit referer, the legacy app URL,
509    /// `OPENROUTER_HTTP_REFERER`, then `OPENROUTER_APP_URL`.
510    pub fn openrouter_http_referer(&self) -> Option<String> {
511        self.openrouter_http_referer
512            .clone()
513            .or_else(|| self.openrouter_app_url.clone())
514            .or_else(|| std::env::var("OPENROUTER_HTTP_REFERER").ok())
515            .or_else(|| std::env::var("OPENROUTER_APP_URL").ok())
516    }
517
518    /// The OpenRouter app title used for attribution headers.
519    ///
520    /// Resolution order: the explicit title, the legacy app title,
521    /// `OPENROUTER_TITLE`, then `OPENROUTER_APP_TITLE`.
522    pub fn openrouter_title(&self) -> Option<String> {
523        self.openrouter_title
524            .clone()
525            .or_else(|| self.openrouter_app_title.clone())
526            .or_else(|| std::env::var("OPENROUTER_TITLE").ok())
527            .or_else(|| std::env::var("OPENROUTER_APP_TITLE").ok())
528    }
529
530    /// The OpenRouter app categories, falling back to the comma-separated
531    /// `OPENROUTER_CATEGORIES` variable. Empty lists are treated as unset.
532    pub fn openrouter_categories(&self) -> Option<Vec<String>> {
533        self.openrouter_categories.clone().or_else(|| {
534            std::env::var("OPENROUTER_CATEGORIES")
535                .ok()
536                .map(|categories| parse_openrouter_categories(&categories))
537                .filter(|categories| !categories.is_empty())
538        })
539    }
540
541    /// Deprecated alias for [`Config::openrouter_http_referer`], kept for
542    /// callers written against the older attribution field names.
543    pub fn openrouter_app_url(&self) -> Option<String> {
544        self.openrouter_http_referer()
545    }
546
547    /// Deprecated alias for [`Config::openrouter_title`], kept for callers
548    /// written against the older attribution field names.
549    pub fn openrouter_app_title(&self) -> Option<String> {
550        self.openrouter_title()
551    }
552
553    /// The effective retry policy, or [`RetryConfig::default`] when unset.
554    pub fn retry_config(&self) -> RetryConfig {
555        self.retry_config.clone().unwrap_or_default()
556    }
557
558    /// The effective HTTP timeout in seconds (defaults to 120).
559    pub fn timeout(&self) -> u64 {
560        self.timeout_seconds.unwrap_or(120)
561    }
562
563    // ── Validation ──
564
565    /// Check that OpenAI is usable.
566    ///
567    /// # Errors
568    ///
569    /// Returns [`Error::Config`](crate::Error::Config) if no OpenAI API key is
570    /// set programmatically or in `OPENAI_API_KEY`.
571    #[cfg(feature = "openai")]
572    pub fn validate_openai(&self) -> error::Result<()> {
573        if self.openai_key().is_none() {
574            return Err(error::Error::Config(
575                "OpenAI API key not configured. Set OPENAI_API_KEY env var or provide via config."
576                    .into(),
577            ));
578        }
579        Ok(())
580    }
581
582    /// Check that Anthropic is usable.
583    ///
584    /// # Errors
585    ///
586    /// Returns [`Error::Config`](crate::Error::Config) if no Anthropic API key
587    /// is set programmatically or in `ANTHROPIC_API_KEY`.
588    #[cfg(feature = "anthropic")]
589    pub fn validate_anthropic(&self) -> error::Result<()> {
590        if self.anthropic_key().is_none() {
591            return Err(error::Error::Config(
592                "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var or provide via config."
593                    .into(),
594            ));
595        }
596        Ok(())
597    }
598
599    /// Check that OpenRouter is usable.
600    ///
601    /// # Errors
602    ///
603    /// Returns [`Error::Config`](crate::Error::Config) if no OpenRouter API key
604    /// is set programmatically or in `OPENROUTER_API_KEY`.
605    #[cfg(feature = "openrouter")]
606    pub fn validate_openrouter(&self) -> error::Result<()> {
607        if self.openrouter_key().is_none() {
608            return Err(error::Error::Config(
609                "OpenRouter API key not configured. Set OPENROUTER_API_KEY env var or provide via config."
610                    .into(),
611            ));
612        }
613        Ok(())
614    }
615}
616
617fn parse_openrouter_categories(value: &str) -> Vec<String> {
618    value
619        .split(',')
620        .map(str::trim)
621        .filter(|category| !category.is_empty())
622        .map(ToOwned::to_owned)
623        .collect()
624}
625
626fn parse_bool(value: &str) -> Option<bool> {
627    match value.trim().to_ascii_lowercase().as_str() {
628        "1" | "true" | "yes" | "on" => Some(true),
629        "0" | "false" | "no" | "off" => Some(false),
630        _ => None,
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use std::time::Duration;
637
638    use super::*;
639
640    #[test]
641    fn openrouter_attribution_builders_set_canonical_fields() {
642        let config = Config::new()
643            .with_openrouter_base_url("https://proxy.example.com/api/v1")
644            .with_openrouter_http_referer("https://app.example.com")
645            .with_openrouter_title("Example App")
646            .with_openrouter_categories(vec!["productivity".to_string(), "agents".to_string()]);
647
648        assert_eq!(
649            config.openrouter_base_url(),
650            Some("https://proxy.example.com/api/v1".to_string())
651        );
652        assert_eq!(
653            config.openrouter_http_referer(),
654            Some("https://app.example.com".to_string())
655        );
656        assert_eq!(config.openrouter_title(), Some("Example App".to_string()));
657        assert_eq!(
658            config.openrouter_categories(),
659            Some(vec!["productivity".to_string(), "agents".to_string()])
660        );
661    }
662
663    #[test]
664    fn retry_config_defaults_when_not_set() {
665        assert_eq!(Config::new().retry_config().max_retries, 3);
666    }
667
668    #[test]
669    fn retry_config_builder_overrides_defaults() {
670        let retry = RetryConfig::new().with_initial_delay(Duration::from_millis(250));
671        let config = Config::new().with_retry_config(retry);
672
673        assert_eq!(
674            config.retry_config().initial_delay,
675            Duration::from_millis(250)
676        );
677    }
678
679    #[test]
680    fn openrouter_category_parser_trims_empty_values() {
681        assert_eq!(
682            parse_openrouter_categories(" agents, , productivity "),
683            vec!["agents".to_string(), "productivity".to_string()]
684        );
685    }
686}