1use crate::config::Config;
7use leviath_runtime::ProviderRegistry;
8
9pub use leviath_runtime::provider_creds::{ProviderCreds, build_provider_registry};
16
17fn cache_ttl_key(ttl: leviath_providers::anthropic::CacheTtl) -> &'static str {
22 match ttl {
23 leviath_providers::anthropic::CacheTtl::Ephemeral5m => "5m",
24 leviath_providers::anthropic::CacheTtl::Ephemeral1h => "1h",
25 }
26}
27
28pub fn provider_creds_from_config(config: &Config) -> Vec<ProviderCreds> {
33 let caps = &config.model_capabilities;
34 let timeout = config.request_timeout_secs;
35 let mut creds = Vec::new();
36
37 let keyed = [
38 ("anthropic", config.providers.anthropic_api_key.as_deref()),
39 ("openai", config.providers.openai_api_key.as_deref()),
40 ("google", config.providers.google_api_key.as_deref()),
41 ("openrouter", config.openrouter_api_key.as_deref()),
42 ];
43 for (name, key) in keyed {
44 if let Some(key) = key.map(str::trim).filter(|k| !k.is_empty()) {
48 let mut options = std::collections::HashMap::new();
52 if name == "anthropic"
53 && let Some(ttl) = config.providers.anthropic_cache_ttl
54 {
55 options.insert("cache_ttl".to_string(), cache_ttl_key(ttl).to_string());
56 }
57 creds.push(ProviderCreds {
58 name: name.to_string(),
59 api_key: Some(key.to_string()),
60 base_url: None,
61 model_capabilities: caps.clone(),
62 request_timeout_secs: timeout,
63 rate_limit: config.rate_limits.get(name).cloned(),
64 options,
65 });
66 }
67 }
68
69 creds.push(ProviderCreds {
71 name: "ollama".to_string(),
72 api_key: None,
73 base_url: Some(
74 config
75 .ollama_base_url
76 .as_deref()
77 .unwrap_or("http://localhost:11434")
78 .to_string(),
79 ),
80 model_capabilities: caps.clone(),
81 request_timeout_secs: timeout,
82 rate_limit: None,
83 options: std::collections::HashMap::new(),
84 });
85
86 if config.providers.claude_code_enabled {
92 let mut options = std::collections::HashMap::new();
93 if let Some(binary) = &config.providers.claude_code_binary {
94 options.insert("binary".to_string(), binary.clone());
95 }
96 if let Some(effort) = &config.providers.claude_code_effort {
97 options.insert("effort".to_string(), effort.clone());
98 }
99 creds.push(ProviderCreds {
100 name: "claude-code".to_string(),
101 api_key: None,
102 base_url: None,
103 model_capabilities: caps.clone(),
104 request_timeout_secs: None,
105 rate_limit: None,
106 options,
107 });
108 }
109
110 creds
111}
112
113pub fn build_provider_registry_from_config(
123 config: &Config,
124) -> Result<ProviderRegistry, leviath_providers::ProviderError> {
125 build_provider_registry_from_config_with(
126 config,
127 &leviath_providers::provider::build_http_client,
128 )
129}
130
131pub fn build_provider_registry_from_config_with(
137 config: &Config,
138 build_client: leviath_providers::provider::HttpClientFactory<'_>,
139) -> Result<ProviderRegistry, leviath_providers::ProviderError> {
140 let registry = leviath_runtime::provider_creds::build_provider_registry_with(
141 &provider_creds_from_config(config),
142 build_client,
143 )?;
144 Ok(attach_script_layer(
145 registry,
146 crate::config::providers_dir(),
147 config,
148 ))
149}
150
151fn attach_script_layer(
156 registry: ProviderRegistry,
157 dir: Option<std::path::PathBuf>,
158 config: &Config,
159) -> ProviderRegistry {
160 let Some(dir) = dir else {
161 return registry;
162 };
163 let overrides = config
164 .model_providers
165 .iter()
166 .map(|(name, mp)| (name.clone(), script_provider_spec(mp)))
167 .collect();
168 let layer = leviath_runtime::script_provider::ScriptProviderLayer::new(
169 dir,
170 overrides,
171 config.model_capabilities.clone(),
172 config.request_timeout_secs,
173 config.security.allow_env_vars.clone(),
174 );
175 registry.with_script_layer(std::sync::Arc::new(layer))
176}
177
178fn script_provider_spec(
183 mp: &crate::config::ModelProviderConfig,
184) -> leviath_runtime::script_provider::ScriptProviderSpec {
185 let mut cfg = serde_json::Map::new();
186 if let Some(b) = &mp.base_url {
187 cfg.insert("base_url".to_string(), serde_json::Value::String(b.clone()));
188 }
189 if let Some(k) = &mp.api_key {
190 cfg.insert("api_key".to_string(), serde_json::Value::String(k.clone()));
191 }
192 for (k, v) in &mp.extra {
193 cfg.insert(
194 k.clone(),
195 serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
196 );
197 }
198 leviath_runtime::script_provider::ScriptProviderSpec {
199 script: mp.script.clone(),
200 rate_limit: mp.rate_limit.clone(),
201 init_config: serde_json::Value::Object(cfg),
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn build_provider_registry_with_empty_config() {
211 let config = Config::default();
212 let registry =
213 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
214 assert!(registry.has("ollama"));
216 assert!(!registry.has("claude-code"));
220 assert!(!registry.has("anthropic"));
222 assert!(!registry.has("openai"));
223 assert!(!registry.has("google"));
224 }
225
226 #[test]
227 fn build_provider_registry_with_anthropic_key() {
228 let config = Config {
229 providers: crate::config::ProviderConfig {
230 anthropic_api_key: Some("sk-ant-test-key-12345".to_string()),
231 ..Config::default().providers
232 },
233 ..Config::default()
234 };
235 let registry =
236 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
237 assert!(registry.has("anthropic"));
238 }
239
240 #[test]
241 fn build_provider_registry_with_openai_key() {
242 let config = Config {
243 providers: crate::config::ProviderConfig {
244 openai_api_key: Some("sk-test-key-12345".to_string()),
245 ..Config::default().providers
246 },
247 ..Config::default()
248 };
249 let registry =
250 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
251 assert!(registry.has("openai"));
252 }
253
254 #[test]
255 fn build_provider_registry_with_google_key() {
256 let config = Config {
257 providers: crate::config::ProviderConfig {
258 google_api_key: Some("AIzatest12345".to_string()),
259 claude_code_enabled: false,
260 claude_code_binary: None,
261 claude_code_effort: None,
262 anthropic_cache_ttl: None,
263 ..Config::default().providers
264 },
265 ..Config::default()
266 };
267 let registry =
268 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
269 assert!(registry.has("google"));
270 }
271
272 #[test]
273 fn build_provider_registry_with_openrouter_key() {
274 let config = Config {
275 openrouter_api_key: Some("sk-or-test-12345".to_string()),
276 ..Config::default()
277 };
278 let registry =
279 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
280 assert!(registry.has("openrouter"));
281 }
282
283 #[test]
284 fn build_provider_registry_custom_ollama_url() {
285 let config = Config {
286 ollama_base_url: Some("http://my-server:11434".to_string()),
287 ..Config::default()
288 };
289 let registry =
290 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
291 assert!(registry.has("ollama"));
292 }
293
294 #[test]
295 fn script_provider_spec_assembles_init_config() {
296 let mut extra = std::collections::HashMap::new();
297 extra.insert("region".to_string(), toml::Value::String("us".to_string()));
298 let mp = crate::config::ModelProviderConfig {
299 script: Some("groq".to_string()),
300 api_key: Some("k".to_string()),
301 base_url: Some("http://api".to_string()),
302 rate_limit: Some(leviath_providers::RateLimitConfig {
303 requests_per_minute: 30,
304 tokens_per_minute: 1000,
305 }),
306 extra,
307 };
308 let spec = script_provider_spec(&mp);
309 assert_eq!(spec.script.as_deref(), Some("groq"));
310 assert!(spec.rate_limit.is_some());
311 assert_eq!(spec.init_config["base_url"], "http://api");
312 assert_eq!(spec.init_config["api_key"], "k");
313 assert_eq!(spec.init_config["region"], "us");
314 }
315
316 #[test]
317 fn attach_script_layer_without_home_is_a_noop() {
318 let registry = attach_script_layer(ProviderRegistry::new(), None, &Config::default());
321 assert!(!registry.has("groq"));
322 }
323
324 #[test]
325 fn build_registry_resolves_a_configured_script_provider() {
326 let home = tempfile::tempdir().unwrap();
327 let providers = home.path().join(".leviath").join("providers");
328 std::fs::create_dir_all(&providers).unwrap();
329 std::fs::write(
330 providers.join("groq.rhai"),
331 "fn initialize(config) { #{} }\nfn inference(state, request) { #{ content: \"ok\" } }",
332 )
333 .unwrap();
334
335 let mut model_providers = std::collections::HashMap::new();
336 model_providers.insert(
337 "groq".to_string(),
338 crate::config::ModelProviderConfig::default(),
339 );
340 let config = Config {
341 model_providers,
342 ..Config::default()
343 };
344 temp_env::with_var("LEVIATH_HOME", Some(home.path().as_os_str()), || {
345 let registry = build_provider_registry_from_config(&config)
346 .expect("an HTTPS client builds in tests");
347 assert!(registry.has("groq"));
348 assert!(registry.get("groq").is_some());
349 });
350 }
351
352 #[test]
355 fn build_provider_registry_all_keys_set() {
356 let config = Config {
357 providers: crate::config::ProviderConfig {
358 anthropic_api_key: Some("sk-ant-test".to_string()),
359 openai_api_key: Some("sk-test".to_string()),
360 google_api_key: Some("AIza-test".to_string()),
361 claude_code_enabled: false,
362 claude_code_binary: None,
363 claude_code_effort: None,
364 anthropic_cache_ttl: None,
365 fallback_order: Vec::new(),
366 },
367 openrouter_api_key: Some("sk-or-test".to_string()),
368 ollama_base_url: Some("http://custom:11434".to_string()),
369 ..Config::default()
370 };
371 let registry =
372 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
373 assert!(registry.has("anthropic"));
374 assert!(registry.has("openai"));
375 assert!(registry.has("google"));
376 assert!(registry.has("openrouter"));
377 assert!(registry.has("ollama"));
378 assert!(!registry.has("claude-code"));
380 }
381
382 #[test]
387 fn provider_creds_carry_the_anthropic_cache_ttl() {
388 use leviath_providers::anthropic::CacheTtl;
389
390 let mut config = Config::default();
391 config.providers.anthropic_api_key = Some("k".to_string());
392 config.providers.openai_api_key = Some("k".to_string());
393 config.providers.anthropic_cache_ttl = Some(CacheTtl::Ephemeral1h);
394
395 let creds = provider_creds_from_config(&config);
396 let anthropic = creds
397 .iter()
398 .find(|c| c.name == "anthropic")
399 .expect("anthropic is registered");
400 assert_eq!(
401 anthropic.options.get("cache_ttl").map(String::as_str),
402 Some("1h")
403 );
404
405 let openai = creds.iter().find(|c| c.name == "openai").expect("openai");
407 assert!(!openai.options.contains_key("cache_ttl"));
408 }
409
410 #[test]
411 fn the_five_minute_ttl_is_carried_explicitly_too() {
412 use leviath_providers::anthropic::CacheTtl;
413
414 let mut config = Config::default();
415 config.providers.anthropic_api_key = Some("k".to_string());
416 config.providers.anthropic_cache_ttl = Some(CacheTtl::Ephemeral5m);
417 let creds = provider_creds_from_config(&config);
418 assert_eq!(
419 creds[0].options.get("cache_ttl").map(String::as_str),
420 Some("5m")
421 );
422 }
423
424 #[test]
426 fn no_configured_ttl_carries_nothing() {
427 let mut config = Config::default();
428 config.providers.anthropic_api_key = Some("k".to_string());
429 let creds = provider_creds_from_config(&config);
430 assert!(!creds[0].options.contains_key("cache_ttl"));
431 }
432
433 #[test]
434 fn provider_creds_from_config_includes_defaults_and_keyed() {
435 let config = Config {
436 providers: crate::config::ProviderConfig {
437 anthropic_api_key: Some("sk-ant".to_string()),
438 ..Config::default().providers
439 },
440 ollama_base_url: Some("http://custom:11434".to_string()),
441 ..Config::default()
442 };
443 let creds = provider_creds_from_config(&config);
444 let names: Vec<&str> = creds.iter().map(|c| c.name.as_str()).collect();
445 assert!(names.contains(&"anthropic"));
448 assert!(names.contains(&"ollama"));
449 assert!(!names.contains(&"claude-code"));
450 assert!(!names.contains(&"openai"));
451 assert!(!names.contains(&"google"));
452 assert!(!names.contains(&"openrouter"));
453 let ollama = creds.iter().find(|c| c.name == "ollama").unwrap();
455 assert_eq!(ollama.base_url.as_deref(), Some("http://custom:11434"));
456 assert!(ollama.api_key.is_none());
457 }
458
459 #[test]
464 fn provider_creds_from_config_ignores_blank_keys() {
465 let config = Config {
466 providers: crate::config::ProviderConfig {
467 anthropic_api_key: Some(String::new()),
468 openai_api_key: Some(" ".to_string()),
469 google_api_key: Some("AIza-real".to_string()),
470 ..Config::default().providers
471 },
472 ..Config::default()
473 };
474 let creds = provider_creds_from_config(&config);
475 let names: Vec<&str> = creds.iter().map(|c| c.name.as_str()).collect();
476 assert!(
477 names.contains(&"google"),
478 "the configured provider must register: {names:?}"
479 );
480 assert!(!names.contains(&"anthropic"), "empty key must not register");
481 assert!(
482 !names.contains(&"openai"),
483 "whitespace-only key must not register"
484 );
485 }
486
487 #[test]
488 fn provider_creds_from_config_carries_rate_limits() {
489 let config = Config {
490 providers: crate::config::ProviderConfig {
491 anthropic_api_key: Some("sk-ant".to_string()),
492 openai_api_key: Some("sk-oa".to_string()),
493 ..Config::default().providers
494 },
495 rate_limits: std::collections::HashMap::from([(
496 "anthropic".to_string(),
497 leviath_providers::RateLimitConfig {
498 requests_per_minute: 50,
499 tokens_per_minute: 40_000,
500 },
501 )]),
502 ..Config::default()
503 };
504 let creds = provider_creds_from_config(&config);
505 let anthropic = creds.iter().find(|c| c.name == "anthropic").unwrap();
506 assert_eq!(
507 anthropic.rate_limit.as_ref().map(|r| r.requests_per_minute),
508 Some(50)
509 );
510 let openai = creds.iter().find(|c| c.name == "openai").unwrap();
512 assert!(openai.rate_limit.is_none());
513 }
514
515 #[test]
518 fn build_provider_registry_defaults_have_ollama_only() {
519 let config = Config::default();
520 let registry =
521 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
522 assert!(registry.has("ollama"));
525 assert!(!registry.has("claude-code"));
526 }
527
528 #[test]
529 fn enabling_claude_code_registers_it_with_its_options() {
530 let config = Config {
531 providers: crate::config::ProviderConfig {
532 claude_code_enabled: true,
533 claude_code_binary: Some("/opt/bin/claude".to_string()),
534 claude_code_effort: Some("low".to_string()),
535 ..Config::default().providers
536 },
537 ..Config::default()
538 };
539 let creds = provider_creds_from_config(&config);
540 let cc = creds
541 .iter()
542 .find(|c| c.name == "claude-code")
543 .expect("enabled ⇒ present");
544 assert_eq!(
545 cc.options.get("binary").map(String::as_str),
546 Some("/opt/bin/claude")
547 );
548 assert_eq!(cc.options.get("effort").map(String::as_str), Some("low"));
549 assert!(cc.api_key.is_none());
550 assert!(
551 build_provider_registry_from_config(&config)
552 .expect("an HTTPS client builds in tests")
553 .has("claude-code")
554 );
555 }
556
557 #[test]
558 fn enabling_claude_code_without_options_carries_none() {
559 let config = Config {
560 providers: crate::config::ProviderConfig {
561 claude_code_enabled: true,
562 ..Config::default().providers
563 },
564 ..Config::default()
565 };
566 let creds = provider_creds_from_config(&config);
567 let cc = creds.iter().find(|c| c.name == "claude-code").unwrap();
568 assert!(cc.options.is_empty());
571 }
572
573 #[test]
576 fn build_provider_registry_propagates_model_capabilities() {
577 use leviath_providers::ModelCapabilities;
578 let mut caps = std::collections::HashMap::new();
579 caps.insert(
580 "custom-model".to_string(),
581 ModelCapabilities {
582 supports_temperature: true,
583 supports_streaming: true,
584 supports_tools: true,
585 supports_system_prompt: true,
586 max_context_tokens: 9999,
587 max_output_tokens: 999,
588 }
589 .into(),
590 );
591 let config = crate::config::Config {
592 model_capabilities: caps,
593 providers: crate::config::ProviderConfig {
594 anthropic_api_key: Some("sk-ant-test".to_string()),
595 openai_api_key: None,
596 google_api_key: None,
597 claude_code_enabled: false,
598 claude_code_binary: None,
599 claude_code_effort: None,
600 anthropic_cache_ttl: None,
601 fallback_order: Vec::new(),
602 },
603 ..crate::config::Config::default()
604 };
605 let registry =
606 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
607 assert!(registry.has("anthropic"));
609 assert!(registry.has("ollama"));
611 }
612
613 #[test]
616 fn build_provider_registry_ollama_with_custom_url_propagates_caps() {
617 use leviath_providers::ModelCapabilities;
618 let mut caps = std::collections::HashMap::new();
619 caps.insert(
620 "llama3-8b".to_string(),
621 ModelCapabilities {
622 supports_temperature: false,
623 supports_streaming: false,
624 supports_tools: false,
625 supports_system_prompt: false,
626 max_context_tokens: 99,
627 max_output_tokens: 99,
628 }
629 .into(),
630 );
631 let config = crate::config::Config {
632 ollama_base_url: Some("http://custom-ollama:11434".to_string()),
633 model_capabilities: caps,
634 ..crate::config::Config::default()
635 };
636 let registry =
637 build_provider_registry_from_config(&config).expect("an HTTPS client builds in tests");
638 assert!(registry.has("ollama"));
639 }
640
641 }