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:
28        std::collections::HashMap<String, leviath_providers::ModelCapabilityOverride>,
29    /// HTTP request timeout in seconds (`None` uses the provider default).
30    pub request_timeout_secs: Option<u64>,
31    /// Client-side rate limit (requests/tokens per minute) enforced before
32    /// each call. `None` sends requests unthrottled. Ignored by `ollama`
33    /// (a local server) and `claude-code` (a subprocess).
34    pub rate_limit: Option<leviath_providers::RateLimitConfig>,
35    /// Provider-specific settings that don't fit the api-key / base-URL shape.
36    ///
37    /// Currently only `claude-code` reads this, for `binary` (path to the
38    /// `claude` executable) and `effort` (reasoning level). Kept as a map rather
39    /// than named fields so one provider's options don't accrete onto a struct
40    /// shared by six.
41    pub options: std::collections::HashMap<String, String>,
42}
43
44/// Hand-written so the API key can never reach a log line.
45///
46/// A `#[derive(Debug)]` here meant a single `tracing::debug!(?creds)` - or an
47/// error context that formats a struct holding one - would print the key.
48/// Nothing did, which is when it is cheap to make impossible.
49impl std::fmt::Debug for ProviderCreds {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("ProviderCreds")
52            .field("name", &self.name)
53            .field(
54                "api_key",
55                match self.api_key {
56                    Some(_) => &"<set>",
57                    None => &"<unset>",
58                },
59            )
60            .field("base_url", &self.base_url)
61            .field("model_capabilities", &self.model_capabilities)
62            .field("request_timeout_secs", &self.request_timeout_secs)
63            .field("rate_limit", &self.rate_limit)
64            .field("options", &self.options)
65            .finish()
66    }
67}
68
69impl ProviderCreds {
70    /// A cred entry for a provider that needs no key, base URL, or options.
71    pub fn simple(name: impl Into<String>) -> Self {
72        Self {
73            name: name.into(),
74            api_key: None,
75            base_url: None,
76            model_capabilities: std::collections::HashMap::new(),
77            request_timeout_secs: None,
78            rate_limit: None,
79            options: std::collections::HashMap::new(),
80        }
81    }
82}
83
84/// Outbound HTTPS clients, one per distinct request timeout.
85#[derive(Default)]
86struct ClientCache {
87    by_timeout: std::collections::HashMap<Option<u64>, leviath_providers::provider::HttpClient>,
88}
89
90impl ClientCache {
91    /// The client for `timeout`, building it on first request.
92    ///
93    /// Providers sharing a timeout share a connection pool; before this, each
94    /// provider built its own client, so a daemon with five configured held
95    /// five pools.
96    fn get_or_build(
97        &mut self,
98        timeout: Option<u64>,
99        build: leviath_providers::provider::HttpClientFactory<'_>,
100    ) -> Result<leviath_providers::provider::HttpClient, leviath_providers::ProviderError> {
101        if let Some(client) = self.by_timeout.get(&timeout) {
102            return Ok(client.clone());
103        }
104        let built = build(timeout)
105            .map_err(|e| leviath_providers::ProviderError::ClientBuild(e.to_string()))?;
106        self.by_timeout.insert(timeout, built.clone());
107        Ok(built)
108    }
109}
110
111/// Build a [`ProviderRegistry`] from decoupled [`ProviderCreds`].
112pub fn build_provider_registry(
113    creds: &[ProviderCreds],
114) -> Result<ProviderRegistry, leviath_providers::ProviderError> {
115    build_provider_registry_with(creds, &leviath_providers::provider::build_http_client)
116}
117
118/// [`build_provider_registry`], with client construction injected.
119///
120/// One client per distinct request timeout, shared by every provider that wants
121/// it. Previously each provider built its own, so a daemon with five providers
122/// configured held five connection pools; the timeout is part of the key because
123/// `apply_request_timeout` deliberately defers to the client-level timeout when
124/// a stage sets none, so collapsing distinct timeouts onto one client would
125/// silently retime requests.
126pub fn build_provider_registry_with(
127    creds: &[ProviderCreds],
128    build_client: leviath_providers::provider::HttpClientFactory<'_>,
129) -> Result<ProviderRegistry, leviath_providers::ProviderError> {
130    let mut registry = ProviderRegistry::new();
131    // One client per distinct timeout, built on first use. Lazy because
132    // `claude-code` drives a local CLI and needs no HTTP client at all - eager
133    // construction would let a certificate-store failure block a provider that
134    // never touches a certificate.
135    let mut clients = ClientCache::default();
136
137    for c in creds {
138        let caps = c.model_capabilities.clone();
139        let timeout = c.request_timeout_secs;
140        match c.name.as_str() {
141            "anthropic" => {
142                if let Some(ref key) = c.api_key {
143                    registry.register(
144                        "anthropic".to_string(),
145                        Arc::new(
146                            leviath_providers::AnthropicProvider::with_overrides(
147                                clients.get_or_build(timeout, build_client)?,
148                                key.clone(),
149                                caps,
150                                c.rate_limit.as_ref(),
151                            )
152                            // An unrecognised value keeps the default rather
153                            // than failing the daemon's boot over a cache
154                            // setting; the config layer is what validates it.
155                            .with_cache_ttl(
156                                match c.options.get("cache_ttl").map(String::as_str) {
157                                    Some("1h") => {
158                                        leviath_providers::anthropic::CacheTtl::Ephemeral1h
159                                    }
160                                    _ => leviath_providers::anthropic::CacheTtl::Ephemeral5m,
161                                },
162                            ),
163                        ),
164                    );
165                }
166            }
167            "openai" => {
168                if let Some(ref key) = c.api_key {
169                    registry.register(
170                        "openai".to_string(),
171                        Arc::new(leviath_providers::OpenAIProvider::with_overrides(
172                            clients.get_or_build(timeout, build_client)?,
173                            key.clone(),
174                            caps,
175                            c.rate_limit.as_ref(),
176                        )),
177                    );
178                }
179            }
180            "google" => {
181                if let Some(ref key) = c.api_key {
182                    registry.register(
183                        "google".to_string(),
184                        Arc::new(leviath_providers::GeminiProvider::with_overrides(
185                            clients.get_or_build(timeout, build_client)?,
186                            key.clone(),
187                            caps,
188                            c.rate_limit.as_ref(),
189                        )),
190                    );
191                }
192            }
193            "openrouter" => {
194                if let Some(ref key) = c.api_key {
195                    registry.register(
196                        "openrouter".to_string(),
197                        Arc::new(leviath_providers::OpenRouterProvider::with_overrides(
198                            clients.get_or_build(timeout, build_client)?,
199                            key.clone(),
200                            caps,
201                            c.rate_limit.as_ref(),
202                        )),
203                    );
204                }
205            }
206            "ollama" => {
207                let url = c
208                    .base_url
209                    .clone()
210                    .unwrap_or_else(|| "http://localhost:11434".to_string());
211                registry.register(
212                    "ollama".to_string(),
213                    Arc::new(leviath_providers::OllamaProvider::with_overrides(
214                        clients.get_or_build(timeout, build_client)?,
215                        url,
216                        caps,
217                    )),
218                );
219            }
220            "claude-code" => {
221                // Opt-in: the CLI puts the user's account email address into
222                // every call. The CLI-side config only emits this entry when
223                // the user has explicitly enabled the provider.
224                let binary = c
225                    .options
226                    .get("binary")
227                    .cloned()
228                    .unwrap_or_else(|| "claude".to_string());
229                registry.register(
230                    "claude-code".to_string(),
231                    Arc::new(leviath_providers::ClaudeCodeProvider::with_overrides(
232                        binary,
233                        c.options.get("effort").cloned(),
234                        Some(caps),
235                    )),
236                );
237            }
238            _ => {}
239        }
240    }
241
242    Ok(registry)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    /// The cache TTL reaches the provider, and an unrecognised value keeps the
250    /// default rather than failing the daemon's boot over a cache setting.
251    #[test]
252    fn the_anthropic_cache_ttl_is_read_from_the_options_map() {
253        for configured in [Some("1h"), Some("5m"), Some("nonsense"), None] {
254            let mut cred = ProviderCreds::simple("anthropic");
255            cred.api_key = Some("k".to_string());
256            if let Some(value) = configured {
257                cred.options
258                    .insert("cache_ttl".to_string(), value.to_string());
259            }
260            let registry = build_provider_registry(&[cred])
261                .expect("a cache setting must never fail the build");
262            assert!(
263                registry.get("anthropic").is_some(),
264                "configured {configured:?}"
265            );
266        }
267    }
268
269    /// One `tracing::debug!(?creds)` - or an error context that formats a struct
270    /// holding one - would otherwise print the provider key.
271    #[test]
272    fn debug_output_never_contains_the_api_key() {
273        let mut creds = ProviderCreds::simple("anthropic");
274        creds.api_key = Some("sk-ant-SECRET-VALUE".to_string());
275        creds.base_url = Some("https://api.example.com".to_string());
276
277        let rendered = format!("{creds:?}");
278        assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
279        assert!(rendered.contains("<set>"), "{rendered}");
280        // The parts that make a debug line useful survive.
281        assert!(rendered.contains("anthropic"), "{rendered}");
282        assert!(rendered.contains("api.example.com"), "{rendered}");
283
284        // A provider that needs no key says so rather than claiming one.
285        let keyless = format!("{:?}", ProviderCreds::simple("ollama"));
286        assert!(keyless.contains("<unset>"), "{keyless}");
287    }
288
289    #[test]
290    fn build_provider_registry_from_creds_slice() {
291        // Drives `build_provider_registry(&[ProviderCreds]).expect("an HTTPS client builds in tests")` directly:
292        // every keyed provider, the ollama-with-default-url arm, claude-code,
293        // and an unknown provider name (the catch-all no-op arm).
294        let caps = std::collections::HashMap::new();
295        let creds = vec![
296            ProviderCreds {
297                name: "anthropic".to_string(),
298                api_key: Some("sk-ant".to_string()),
299                base_url: None,
300                model_capabilities: caps.clone(),
301                request_timeout_secs: Some(30),
302                rate_limit: None,
303                options: Default::default(),
304            },
305            ProviderCreds {
306                name: "openai".to_string(),
307                api_key: Some("sk-oa".to_string()),
308                base_url: None,
309                model_capabilities: caps.clone(),
310                request_timeout_secs: None,
311                rate_limit: None,
312                options: Default::default(),
313            },
314            ProviderCreds {
315                name: "google".to_string(),
316                api_key: Some("AIza".to_string()),
317                base_url: None,
318                model_capabilities: caps.clone(),
319                request_timeout_secs: None,
320                rate_limit: None,
321                options: Default::default(),
322            },
323            ProviderCreds {
324                name: "openrouter".to_string(),
325                api_key: Some("sk-or".to_string()),
326                base_url: None,
327                model_capabilities: caps.clone(),
328                request_timeout_secs: None,
329                rate_limit: None,
330                options: Default::default(),
331            },
332            ProviderCreds {
333                name: "ollama".to_string(),
334                api_key: None,
335                base_url: None, // exercise the default-URL fallback
336                model_capabilities: caps.clone(),
337                request_timeout_secs: None,
338                rate_limit: None,
339                options: Default::default(),
340            },
341            ProviderCreds {
342                name: "claude-code".to_string(),
343                api_key: None,
344                base_url: None,
345                model_capabilities: caps.clone(),
346                request_timeout_secs: None,
347                rate_limit: None,
348                options: Default::default(),
349            },
350            ProviderCreds {
351                name: "totally-unknown".to_string(),
352                api_key: Some("x".to_string()),
353                base_url: None,
354                model_capabilities: caps,
355                request_timeout_secs: None,
356                rate_limit: None,
357                options: Default::default(),
358            },
359        ];
360        let registry = build_provider_registry(&creds).expect("an HTTPS client builds in tests");
361        assert!(registry.has("anthropic"));
362        assert!(registry.has("openai"));
363        assert!(registry.has("google"));
364        assert!(registry.has("openrouter"));
365        assert!(registry.has("ollama"));
366        assert!(registry.has("claude-code"));
367        assert!(!registry.has("totally-unknown"));
368    }
369
370    #[test]
371    fn build_provider_registry_skips_keyed_providers_without_api_key() {
372        // The anthropic/openai/google/openrouter arms only register when an
373        // api_key is present; a `None` key exercises the skip (else) path of
374        // each `if let Some(ref key)` and leaves the provider unregistered.
375        let caps = std::collections::HashMap::new();
376        let creds: Vec<ProviderCreds> = ["anthropic", "openai", "google", "openrouter"]
377            .into_iter()
378            .map(|name| ProviderCreds {
379                name: name.to_string(),
380                api_key: None,
381                base_url: None,
382                model_capabilities: caps.clone(),
383                request_timeout_secs: None,
384                rate_limit: None,
385                options: Default::default(),
386            })
387            .collect();
388        let registry = build_provider_registry(&creds).expect("an HTTPS client builds in tests");
389        assert!(!registry.has("anthropic"));
390        assert!(!registry.has("openai"));
391        assert!(!registry.has("google"));
392        assert!(!registry.has("openrouter"));
393    }
394
395    #[test]
396    fn claude_code_reads_its_binary_and_effort_options() {
397        // The registry arm must thread both options through: constructing a
398        // default provider here would silently ignore a configured binary path
399        // or effort level.
400        let mut creds = ProviderCreds::simple("claude-code");
401        creds
402            .options
403            .insert("binary".to_string(), "/opt/bin/claude".to_string());
404        creds
405            .options
406            .insert("effort".to_string(), "low".to_string());
407        let registry = build_provider_registry(std::slice::from_ref(&creds))
408            .expect("an HTTPS client builds in tests");
409        assert!(registry.has("claude-code"));
410
411        // Options are consumed by the provider constructor, which is where the
412        // effort allow-list lives; an unusable value must not reach the CLI.
413        creds
414            .options
415            .insert("effort".to_string(), "warp-speed".to_string());
416        assert!(
417            build_provider_registry(&[creds])
418                .expect("an HTTPS client builds in tests")
419                .has("claude-code")
420        );
421    }
422
423    #[test]
424    fn provider_creds_simple_has_no_key_or_options() {
425        let creds = ProviderCreds::simple("ollama");
426        assert_eq!(creds.name, "ollama");
427        assert!(creds.api_key.is_none());
428        assert!(creds.base_url.is_none());
429        assert!(creds.options.is_empty());
430        assert!(creds.model_capabilities.is_empty());
431        assert!(creds.request_timeout_secs.is_none());
432    }
433
434    // ─── The client-build failure path ──────────────────────────────────────
435
436    /// A factory that always fails, standing in for a machine whose root
437    /// certificate store cannot be read.
438    fn failing_client(
439        _timeout: Option<u64>,
440    ) -> std::result::Result<
441        leviath_providers::provider::HttpClient,
442        leviath_providers::provider::HttpError,
443    > {
444        // The only way to obtain a `reqwest::Error` is to have reqwest produce
445        // one; a request to an unroutable scheme does that without any I/O.
446        Err(leviath_providers::provider::malformed_url_error())
447    }
448
449    #[test]
450    fn every_http_provider_fails_the_registry_when_its_client_will_not_build() {
451        // One case per branch that needs a client. A single provider would leave
452        // the other arms' error paths unproven, which is exactly the hole this
453        // seam exists to close.
454        for name in ["anthropic", "openai", "google", "openrouter", "ollama"] {
455            let mut cred = ProviderCreds::simple(name);
456            cred.api_key = Some("k".to_string());
457            let err = build_provider_registry_with(&[cred], &failing_client)
458                .err()
459                .expect("a failing client factory should fail the registry");
460            // Discriminant rather than `matches!`: the macro expands to a
461            // match with a `_ => false` arm that nothing reaches, which the
462            // 100% gate reads as an uncovered region.
463            assert_eq!(
464                std::mem::discriminant(&err),
465                std::mem::discriminant(&leviath_providers::ProviderError::ClientBuild(
466                    String::new()
467                ))
468            );
469            // The message has to name the cause; a bare "request failed" would
470            // send someone looking at their network, not their cert store.
471            assert!(err.to_string().contains("root certificate store"));
472        }
473    }
474
475    #[test]
476    fn a_provider_that_needs_no_http_client_is_unaffected() {
477        // `claude-code` drives a local CLI. Building its entry must not depend
478        // on an HTTPS client, so a failing factory leaves it registered.
479        let registry =
480            build_provider_registry_with(&[ProviderCreds::simple("claude-code")], &failing_client)
481                .expect("claude-code needs no HTTPS client");
482        assert!(registry.has("claude-code"));
483    }
484
485    #[test]
486    fn providers_sharing_a_timeout_share_one_client() {
487        // Atomic rather than `Cell`: the factory is `Send + Sync`, because the
488        // one in `verify` is held across an await.
489        use std::sync::atomic::{AtomicUsize, Ordering};
490        let builds = AtomicUsize::new(0);
491        let counting = |timeout: Option<u64>| {
492            builds.fetch_add(1, Ordering::SeqCst);
493            leviath_providers::provider::build_http_client(timeout)
494        };
495        let creds: Vec<ProviderCreds> = [("anthropic", 30), ("openai", 30), ("google", 60)]
496            .into_iter()
497            .map(|(name, secs)| {
498                let mut c = ProviderCreds::simple(name);
499                c.api_key = Some("k".to_string());
500                c.request_timeout_secs = Some(secs);
501                c
502            })
503            .collect();
504        let registry =
505            build_provider_registry_with(&creds, &counting).expect("clients build in tests");
506        assert!(registry.has("anthropic") && registry.has("openai") && registry.has("google"));
507        // Two distinct timeouts, so two clients - not one per provider, which is
508        // what this crate did before and what the connection pools paid for.
509        assert_eq!(
510            builds.load(Ordering::SeqCst),
511            2,
512            "expected one client per distinct timeout"
513        );
514    }
515}