1use crate::ProviderRegistry;
9use std::sync::Arc;
10
11#[derive(Clone)]
18pub struct ProviderCreds {
19 pub name: String,
22 pub api_key: Option<String>,
24 pub base_url: Option<String>,
26 pub model_capabilities:
28 std::collections::HashMap<String, leviath_providers::ModelCapabilityOverride>,
29 pub request_timeout_secs: Option<u64>,
31 pub rate_limit: Option<leviath_providers::RateLimitConfig>,
35 pub options: std::collections::HashMap<String, String>,
42}
43
44impl 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 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#[derive(Default)]
86struct ClientCache {
87 by_timeout: std::collections::HashMap<Option<u64>, leviath_providers::provider::HttpClient>,
88}
89
90impl ClientCache {
91 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
111pub 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
118pub 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 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 .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 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 #[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 #[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 assert!(rendered.contains("anthropic"), "{rendered}");
282 assert!(rendered.contains("api.example.com"), "{rendered}");
283
284 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 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, 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 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 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 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 fn failing_client(
439 _timeout: Option<u64>,
440 ) -> std::result::Result<
441 leviath_providers::provider::HttpClient,
442 leviath_providers::provider::HttpError,
443 > {
444 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 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 assert_eq!(
464 std::mem::discriminant(&err),
465 std::mem::discriminant(&leviath_providers::ProviderError::ClientBuild(
466 String::new()
467 ))
468 );
469 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 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 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 assert_eq!(
510 builds.load(Ordering::SeqCst),
511 2,
512 "expected one client per distinct timeout"
513 );
514 }
515}