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}