Skip to main content

rtb_ai/
config.rs

1//! [`Config`] + [`Provider`] + base-URL validation.
2
3use std::time::Duration;
4
5use secrecy::SecretString;
6use serde::{Deserialize, Serialize};
7use url::Url;
8
9use crate::error::AiError;
10
11/// Which provider to talk to. Picks the wire protocol and the auth
12/// header shape.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15#[non_exhaustive]
16pub enum Provider {
17    /// Anthropic Cloud — uses the direct-`reqwest` path so prompt
18    /// caching / extended thinking / citations all work.
19    Anthropic,
20    /// Self-hosted Anthropic-compatible endpoint (Claude Code Local,
21    /// in-house proxy). Same wire format as Cloud.
22    AnthropicLocal,
23    /// `OpenAI` Cloud — via `genai`.
24    OpenAi,
25    /// `OpenAI`-compatible endpoints (Together, Fireworks, vLLM, …) —
26    /// via `genai`.
27    OpenAiCompatible,
28    /// Google Gemini — via `genai`.
29    Gemini,
30    /// Local Ollama — via `genai`.
31    Ollama,
32}
33
34impl Provider {
35    /// `true` when the provider runs through our direct-`reqwest`
36    /// Anthropic Messages path. Drives method dispatch in
37    /// [`crate::AiClient`].
38    #[must_use]
39    pub const fn is_anthropic(self) -> bool {
40        matches!(self, Self::Anthropic | Self::AnthropicLocal)
41    }
42}
43
44/// Configuration for [`crate::AiClient`].
45#[derive(Debug, Clone)]
46pub struct Config {
47    /// Which provider to target.
48    pub provider: Provider,
49    /// Model identifier — provider-specific. When empty,
50    /// [`Config::default`] picks the provider's flagship.
51    pub model: String,
52    /// Override the provider's default endpoint. `None` uses the
53    /// vendor's documented production URL.
54    pub base_url: Option<Url>,
55    /// API key, resolved at config-build time via
56    /// [`rtb_credentials::Resolver`]. Held as a [`SecretString`]:
57    /// `Debug` renders `[REDACTED]`, memory zeroed on drop.
58    pub api_key: SecretString,
59    /// Per-request timeout. Defaults to 60 s.
60    pub timeout: Duration,
61    /// Test-only escape hatch: when `true`, [`validate_base_url`]
62    /// accepts `http://` and `127.0.0.1` endpoints. Intended for
63    /// `wiremock` integration. Production callers leave this `false`.
64    pub allow_insecure_base_url: bool,
65}
66
67impl Default for Config {
68    /// Anthropic + Claude Opus 4.7 + 60 s timeout. The default API
69    /// key is empty — callers must populate it via the resolver
70    /// before [`crate::AiClient::new`].
71    fn default() -> Self {
72        Self {
73            provider: Provider::Anthropic,
74            model: "claude-opus-4-7".into(),
75            base_url: None,
76            api_key: SecretString::from(String::new()),
77            timeout: Duration::from_secs(60),
78            allow_insecure_base_url: false,
79        }
80    }
81}
82
83/// Validate a user-supplied base URL.
84///
85/// Rejects:
86/// - Non-`https` schemes (unless `allow_insecure` is set).
87/// - URLs carrying userinfo (`https://user:pw@host/...`) — credentials
88///   in the URL are an antipattern.
89/// - Placeholder hosts (`example.com`, `example.org`, `*.example.com`).
90///
91/// Mirrors `rtb_vcs::http`'s policy on its own base-URL fields.
92///
93/// # Errors
94///
95/// [`AiError::InvalidConfig`] when any of the above checks fail.
96pub fn validate_base_url(url: &Url, allow_insecure: bool) -> Result<(), AiError> {
97    match url.scheme() {
98        "https" => {}
99        "http" if allow_insecure => {}
100        other => {
101            return Err(AiError::InvalidConfig(format!(
102                "base_url scheme {other:?} not permitted (set allow_insecure_base_url for tests)"
103            )));
104        }
105    }
106    if url.has_authority() {
107        let has_userinfo = !url.username().is_empty() || url.password().is_some();
108        if has_userinfo {
109            return Err(AiError::InvalidConfig(
110                "base_url must not embed userinfo (`user:pass@host`)".into(),
111            ));
112        }
113    }
114    if let Some(host) = url.host_str() {
115        let lower = host.to_ascii_lowercase();
116        if lower == "example.com"
117            || lower == "example.org"
118            || lower.ends_with(".example.com")
119            || lower.ends_with(".example.org")
120        {
121            return Err(AiError::InvalidConfig(format!(
122                "base_url host {host:?} is a documentation placeholder",
123            )));
124        }
125    }
126    Ok(())
127}