Skip to main content

leviath_runtime/
provider_creds.rs

1//! Decoupled provider credentials + registry construction.
2//!
3//! [`ProviderCreds`] is the plain-data seam that lets the run engine build a
4//! [`ProviderRegistry`] without depending on the CLI's `Config`/`ProviderConfig`
5//! types. The CLI owns the `Config -> Vec<ProviderCreds>` translation
6//! (`provider_creds_from_config`); this module owns everything downstream of it.
7
8use crate::ProviderRegistry;
9use std::sync::Arc;
10
11/// Decoupled provider credentials.
12///
13/// Plain data so [`build_provider_registry`] can instantiate providers without
14/// depending on the CLI's `Config`/`ProviderConfig` types. Build one per
15/// provider that should be registered.
16/// `Debug` is hand-written (below) so `api_key` cannot be printed.
17#[derive(Clone)]
18pub struct ProviderCreds {
19    /// Provider identifier: `anthropic` | `openai` | `google` | `openrouter` |
20    /// `ollama` | `claude-code`. Selects which provider is instantiated.
21    pub name: String,
22    /// API key, when the provider needs one (`None` for `ollama`/`claude-code`).
23    pub api_key: Option<String>,
24    /// Base URL override (used by `ollama`; `None` uses the built-in default).
25    pub base_url: Option<String>,
26    /// Per-model capability overrides forwarded to the provider.
27    pub model_capabilities: std::collections::HashMap<String, leviath_providers::ModelCapabilities>,
28    /// HTTP request timeout in seconds (`None` uses the provider default).
29    pub request_timeout_secs: Option<u64>,
30    /// Client-side rate limit (requests/tokens per minute) enforced before
31    /// each call. `None` sends requests unthrottled. Ignored by `ollama`
32    /// (a local server) and `claude-code` (a subprocess).
33    pub rate_limit: Option<leviath_providers::RateLimitConfig>,
34    /// Provider-specific settings that don't fit the api-key / base-URL shape.
35    ///
36    /// Currently only `claude-code` reads this, for `binary` (path to the
37    /// `claude` executable) and `effort` (reasoning level). Kept as a map rather
38    /// than named fields so one provider's options don't accrete onto a struct
39    /// shared by six.
40    pub options: std::collections::HashMap<String, String>,
41}
42
43/// Hand-written so the API key can never reach a log line.
44///
45/// A `#[derive(Debug)]` here meant a single `tracing::debug!(?creds)` - or an
46/// error context that formats a struct holding one - would print the key.
47/// Nothing did, which is when it is cheap to make impossible.
48impl std::fmt::Debug for ProviderCreds {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("ProviderCreds")
51            .field("name", &self.name)
52            .field(
53                "api_key",
54                match self.api_key {
55                    Some(_) => &"<set>",
56                    None => &"<unset>",
57                },
58            )
59            .field("base_url", &self.base_url)
60            .field("model_capabilities", &self.model_capabilities)
61            .field("request_timeout_secs", &self.request_timeout_secs)
62            .field("rate_limit", &self.rate_limit)
63            .field("options", &self.options)
64            .finish()
65    }
66}
67
68impl ProviderCreds {
69    /// A cred entry for a provider that needs no key, base URL, or options.
70    pub fn simple(name: impl Into<String>) -> Self {
71        Self {
72            name: name.into(),
73            api_key: None,
74            base_url: None,
75            model_capabilities: std::collections::HashMap::new(),
76            request_timeout_secs: None,
77            rate_limit: None,
78            options: std::collections::HashMap::new(),
79        }
80    }
81}
82
83/// Build a [`ProviderRegistry`] from decoupled [`ProviderCreds`].
84pub fn build_provider_registry(creds: &[ProviderCreds]) -> ProviderRegistry {
85    let mut registry = ProviderRegistry::new();
86
87    for c in creds {
88        let caps = c.model_capabilities.clone();
89        let timeout = c.request_timeout_secs;
90        match c.name.as_str() {
91            "anthropic" => {
92                if let Some(ref key) = c.api_key {
93                    registry.register(
94                        "anthropic".to_string(),
95                        Arc::new(leviath_providers::AnthropicProvider::with_overrides(
96                            key.clone(),
97                            caps,
98                            timeout,
99                            c.rate_limit.as_ref(),
100                        )),
101                    );
102                }
103            }
104            "openai" => {
105                if let Some(ref key) = c.api_key {
106                    registry.register(
107                        "openai".to_string(),
108                        Arc::new(leviath_providers::OpenAIProvider::with_overrides(
109                            key.clone(),
110                            caps,
111                            timeout,
112                            c.rate_limit.as_ref(),
113                        )),
114                    );
115                }
116            }
117            "google" => {
118                if let Some(ref key) = c.api_key {
119                    registry.register(
120                        "google".to_string(),
121                        Arc::new(leviath_providers::GeminiProvider::with_overrides(
122                            key.clone(),
123                            caps,
124                            timeout,
125                            c.rate_limit.as_ref(),
126                        )),
127                    );
128                }
129            }
130            "openrouter" => {
131                if let Some(ref key) = c.api_key {
132                    registry.register(
133                        "openrouter".to_string(),
134                        Arc::new(leviath_providers::OpenRouterProvider::with_overrides(
135                            key.clone(),
136                            caps,
137                            timeout,
138                            c.rate_limit.as_ref(),
139                        )),
140                    );
141                }
142            }
143            "ollama" => {
144                let url = c
145                    .base_url
146                    .clone()
147                    .unwrap_or_else(|| "http://localhost:11434".to_string());
148                registry.register(
149                    "ollama".to_string(),
150                    Arc::new(leviath_providers::OllamaProvider::with_overrides(
151                        url, caps, timeout,
152                    )),
153                );
154            }
155            "claude-code" => {
156                // Opt-in: the CLI puts the user's account email address into
157                // every call. The CLI-side config only emits this entry when
158                // the user has explicitly enabled the provider.
159                let binary = c
160                    .options
161                    .get("binary")
162                    .cloned()
163                    .unwrap_or_else(|| "claude".to_string());
164                registry.register(
165                    "claude-code".to_string(),
166                    Arc::new(leviath_providers::ClaudeCodeProvider::with_overrides(
167                        binary,
168                        c.options.get("effort").cloned(),
169                        Some(caps),
170                    )),
171                );
172            }
173            _ => {}
174        }
175    }
176
177    registry
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    /// One `tracing::debug!(?creds)` - or an error context that formats a struct
185    /// holding one - would otherwise print the provider key.
186    #[test]
187    fn debug_output_never_contains_the_api_key() {
188        let mut creds = ProviderCreds::simple("anthropic");
189        creds.api_key = Some("sk-ant-SECRET-VALUE".to_string());
190        creds.base_url = Some("https://api.example.com".to_string());
191
192        let rendered = format!("{creds:?}");
193        assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
194        assert!(rendered.contains("<set>"), "{rendered}");
195        // The parts that make a debug line useful survive.
196        assert!(rendered.contains("anthropic"), "{rendered}");
197        assert!(rendered.contains("api.example.com"), "{rendered}");
198
199        // A provider that needs no key says so rather than claiming one.
200        let keyless = format!("{:?}", ProviderCreds::simple("ollama"));
201        assert!(keyless.contains("<unset>"), "{keyless}");
202    }
203
204    #[test]
205    fn build_provider_registry_from_creds_slice() {
206        // Drives `build_provider_registry(&[ProviderCreds])` directly:
207        // every keyed provider, the ollama-with-default-url arm, claude-code,
208        // and an unknown provider name (the catch-all no-op arm).
209        let caps = std::collections::HashMap::new();
210        let creds = vec![
211            ProviderCreds {
212                name: "anthropic".to_string(),
213                api_key: Some("sk-ant".to_string()),
214                base_url: None,
215                model_capabilities: caps.clone(),
216                request_timeout_secs: Some(30),
217                rate_limit: None,
218                options: Default::default(),
219            },
220            ProviderCreds {
221                name: "openai".to_string(),
222                api_key: Some("sk-oa".to_string()),
223                base_url: None,
224                model_capabilities: caps.clone(),
225                request_timeout_secs: None,
226                rate_limit: None,
227                options: Default::default(),
228            },
229            ProviderCreds {
230                name: "google".to_string(),
231                api_key: Some("AIza".to_string()),
232                base_url: None,
233                model_capabilities: caps.clone(),
234                request_timeout_secs: None,
235                rate_limit: None,
236                options: Default::default(),
237            },
238            ProviderCreds {
239                name: "openrouter".to_string(),
240                api_key: Some("sk-or".to_string()),
241                base_url: None,
242                model_capabilities: caps.clone(),
243                request_timeout_secs: None,
244                rate_limit: None,
245                options: Default::default(),
246            },
247            ProviderCreds {
248                name: "ollama".to_string(),
249                api_key: None,
250                base_url: None, // exercise the default-URL fallback
251                model_capabilities: caps.clone(),
252                request_timeout_secs: None,
253                rate_limit: None,
254                options: Default::default(),
255            },
256            ProviderCreds {
257                name: "claude-code".to_string(),
258                api_key: None,
259                base_url: None,
260                model_capabilities: caps.clone(),
261                request_timeout_secs: None,
262                rate_limit: None,
263                options: Default::default(),
264            },
265            ProviderCreds {
266                name: "totally-unknown".to_string(),
267                api_key: Some("x".to_string()),
268                base_url: None,
269                model_capabilities: caps,
270                request_timeout_secs: None,
271                rate_limit: None,
272                options: Default::default(),
273            },
274        ];
275        let registry = build_provider_registry(&creds);
276        assert!(registry.has("anthropic"));
277        assert!(registry.has("openai"));
278        assert!(registry.has("google"));
279        assert!(registry.has("openrouter"));
280        assert!(registry.has("ollama"));
281        assert!(registry.has("claude-code"));
282        assert!(!registry.has("totally-unknown"));
283    }
284
285    #[test]
286    fn build_provider_registry_skips_keyed_providers_without_api_key() {
287        // The anthropic/openai/google/openrouter arms only register when an
288        // api_key is present; a `None` key exercises the skip (else) path of
289        // each `if let Some(ref key)` and leaves the provider unregistered.
290        let caps = std::collections::HashMap::new();
291        let creds: Vec<ProviderCreds> = ["anthropic", "openai", "google", "openrouter"]
292            .into_iter()
293            .map(|name| ProviderCreds {
294                name: name.to_string(),
295                api_key: None,
296                base_url: None,
297                model_capabilities: caps.clone(),
298                request_timeout_secs: None,
299                rate_limit: None,
300                options: Default::default(),
301            })
302            .collect();
303        let registry = build_provider_registry(&creds);
304        assert!(!registry.has("anthropic"));
305        assert!(!registry.has("openai"));
306        assert!(!registry.has("google"));
307        assert!(!registry.has("openrouter"));
308    }
309
310    #[test]
311    fn claude_code_reads_its_binary_and_effort_options() {
312        // The registry arm must thread both options through: constructing a
313        // default provider here would silently ignore a configured binary path
314        // or effort level.
315        let mut creds = ProviderCreds::simple("claude-code");
316        creds
317            .options
318            .insert("binary".to_string(), "/opt/bin/claude".to_string());
319        creds
320            .options
321            .insert("effort".to_string(), "low".to_string());
322        let registry = build_provider_registry(std::slice::from_ref(&creds));
323        assert!(registry.has("claude-code"));
324
325        // Options are consumed by the provider constructor, which is where the
326        // effort allow-list lives; an unusable value must not reach the CLI.
327        creds
328            .options
329            .insert("effort".to_string(), "warp-speed".to_string());
330        assert!(build_provider_registry(&[creds]).has("claude-code"));
331    }
332
333    #[test]
334    fn provider_creds_simple_has_no_key_or_options() {
335        let creds = ProviderCreds::simple("ollama");
336        assert_eq!(creds.name, "ollama");
337        assert!(creds.api_key.is_none());
338        assert!(creds.base_url.is_none());
339        assert!(creds.options.is_empty());
340        assert!(creds.model_capabilities.is_empty());
341        assert!(creds.request_timeout_secs.is_none());
342    }
343}