leviath_cli/commands/run/
session.rs1use crate::config::Config;
7use leviath_runtime::ProviderRegistry;
8
9pub use leviath_runtime::provider_creds::{ProviderCreds, build_provider_registry};
16
17pub fn provider_creds_from_config(config: &Config) -> Vec<ProviderCreds> {
22 let caps = &config.model_capabilities;
23 let timeout = config.request_timeout_secs;
24 let mut creds = Vec::new();
25
26 let keyed = [
27 ("anthropic", config.providers.anthropic_api_key.as_deref()),
28 ("openai", config.providers.openai_api_key.as_deref()),
29 ("google", config.providers.google_api_key.as_deref()),
30 ("openrouter", config.openrouter_api_key.as_deref()),
31 ];
32 for (name, key) in keyed {
33 if let Some(key) = key.map(str::trim).filter(|k| !k.is_empty()) {
37 creds.push(ProviderCreds {
38 name: name.to_string(),
39 api_key: Some(key.to_string()),
40 base_url: None,
41 model_capabilities: caps.clone(),
42 request_timeout_secs: timeout,
43 rate_limit: config.rate_limits.get(name).cloned(),
44 options: std::collections::HashMap::new(),
45 });
46 }
47 }
48
49 creds.push(ProviderCreds {
51 name: "ollama".to_string(),
52 api_key: None,
53 base_url: Some(
54 config
55 .ollama_base_url
56 .as_deref()
57 .unwrap_or("http://localhost:11434")
58 .to_string(),
59 ),
60 model_capabilities: caps.clone(),
61 request_timeout_secs: timeout,
62 rate_limit: None,
63 options: std::collections::HashMap::new(),
64 });
65
66 if config.providers.claude_code_enabled {
72 let mut options = std::collections::HashMap::new();
73 if let Some(binary) = &config.providers.claude_code_binary {
74 options.insert("binary".to_string(), binary.clone());
75 }
76 if let Some(effort) = &config.providers.claude_code_effort {
77 options.insert("effort".to_string(), effort.clone());
78 }
79 creds.push(ProviderCreds {
80 name: "claude-code".to_string(),
81 api_key: None,
82 base_url: None,
83 model_capabilities: caps.clone(),
84 request_timeout_secs: None,
85 rate_limit: None,
86 options,
87 });
88 }
89
90 creds
91}
92
93pub fn build_provider_registry_from_config(config: &Config) -> ProviderRegistry {
103 let registry = build_provider_registry(&provider_creds_from_config(config));
104 attach_script_layer(registry, crate::config::providers_dir(), config)
105}
106
107fn attach_script_layer(
112 registry: ProviderRegistry,
113 dir: Option<std::path::PathBuf>,
114 config: &Config,
115) -> ProviderRegistry {
116 let Some(dir) = dir else {
117 return registry;
118 };
119 let overrides = config
120 .model_providers
121 .iter()
122 .map(|(name, mp)| (name.clone(), script_provider_spec(mp)))
123 .collect();
124 let layer = leviath_runtime::script_provider::ScriptProviderLayer::new(
125 dir,
126 overrides,
127 config.model_capabilities.clone(),
128 config.request_timeout_secs,
129 config.security.allow_env_vars.clone(),
130 );
131 registry.with_script_layer(std::sync::Arc::new(layer))
132}
133
134fn script_provider_spec(
139 mp: &crate::config::ModelProviderConfig,
140) -> leviath_runtime::script_provider::ScriptProviderSpec {
141 let mut cfg = serde_json::Map::new();
142 if let Some(b) = &mp.base_url {
143 cfg.insert("base_url".to_string(), serde_json::Value::String(b.clone()));
144 }
145 if let Some(k) = &mp.api_key {
146 cfg.insert("api_key".to_string(), serde_json::Value::String(k.clone()));
147 }
148 for (k, v) in &mp.extra {
149 cfg.insert(
150 k.clone(),
151 serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
152 );
153 }
154 leviath_runtime::script_provider::ScriptProviderSpec {
155 script: mp.script.clone(),
156 rate_limit: mp.rate_limit.clone(),
157 init_config: serde_json::Value::Object(cfg),
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn build_provider_registry_with_empty_config() {
167 let config = Config::default();
168 let registry = build_provider_registry_from_config(&config);
169 assert!(registry.has("ollama"));
171 assert!(!registry.has("claude-code"));
175 assert!(!registry.has("anthropic"));
177 assert!(!registry.has("openai"));
178 assert!(!registry.has("google"));
179 }
180
181 #[test]
182 fn build_provider_registry_with_anthropic_key() {
183 let config = Config {
184 providers: crate::config::ProviderConfig {
185 anthropic_api_key: Some("sk-ant-test-key-12345".to_string()),
186 ..Config::default().providers
187 },
188 ..Config::default()
189 };
190 let registry = build_provider_registry_from_config(&config);
191 assert!(registry.has("anthropic"));
192 }
193
194 #[test]
195 fn build_provider_registry_with_openai_key() {
196 let config = Config {
197 providers: crate::config::ProviderConfig {
198 openai_api_key: Some("sk-test-key-12345".to_string()),
199 ..Config::default().providers
200 },
201 ..Config::default()
202 };
203 let registry = build_provider_registry_from_config(&config);
204 assert!(registry.has("openai"));
205 }
206
207 #[test]
208 fn build_provider_registry_with_google_key() {
209 let config = Config {
210 providers: crate::config::ProviderConfig {
211 google_api_key: Some("AIzatest12345".to_string()),
212 claude_code_enabled: false,
213 claude_code_binary: None,
214 claude_code_effort: None,
215 ..Config::default().providers
216 },
217 ..Config::default()
218 };
219 let registry = build_provider_registry_from_config(&config);
220 assert!(registry.has("google"));
221 }
222
223 #[test]
224 fn build_provider_registry_with_openrouter_key() {
225 let config = Config {
226 openrouter_api_key: Some("sk-or-test-12345".to_string()),
227 ..Config::default()
228 };
229 let registry = build_provider_registry_from_config(&config);
230 assert!(registry.has("openrouter"));
231 }
232
233 #[test]
234 fn build_provider_registry_custom_ollama_url() {
235 let config = Config {
236 ollama_base_url: Some("http://my-server:11434".to_string()),
237 ..Config::default()
238 };
239 let registry = build_provider_registry_from_config(&config);
240 assert!(registry.has("ollama"));
241 }
242
243 #[test]
244 fn script_provider_spec_assembles_init_config() {
245 let mut extra = std::collections::HashMap::new();
246 extra.insert("region".to_string(), toml::Value::String("us".to_string()));
247 let mp = crate::config::ModelProviderConfig {
248 script: Some("groq".to_string()),
249 api_key: Some("k".to_string()),
250 base_url: Some("http://api".to_string()),
251 rate_limit: Some(leviath_providers::RateLimitConfig {
252 requests_per_minute: 30,
253 tokens_per_minute: 1000,
254 }),
255 extra,
256 };
257 let spec = script_provider_spec(&mp);
258 assert_eq!(spec.script.as_deref(), Some("groq"));
259 assert!(spec.rate_limit.is_some());
260 assert_eq!(spec.init_config["base_url"], "http://api");
261 assert_eq!(spec.init_config["api_key"], "k");
262 assert_eq!(spec.init_config["region"], "us");
263 }
264
265 #[test]
266 fn attach_script_layer_without_home_is_a_noop() {
267 let registry = attach_script_layer(ProviderRegistry::new(), None, &Config::default());
270 assert!(!registry.has("groq"));
271 }
272
273 #[test]
274 fn build_registry_resolves_a_configured_script_provider() {
275 let home = tempfile::tempdir().unwrap();
276 let providers = home.path().join(".leviath").join("providers");
277 std::fs::create_dir_all(&providers).unwrap();
278 std::fs::write(
279 providers.join("groq.rhai"),
280 "fn initialize(config) { #{} }\nfn inference(state, request) { #{ content: \"ok\" } }",
281 )
282 .unwrap();
283
284 let mut model_providers = std::collections::HashMap::new();
285 model_providers.insert(
286 "groq".to_string(),
287 crate::config::ModelProviderConfig::default(),
288 );
289 let config = Config {
290 model_providers,
291 ..Config::default()
292 };
293 temp_env::with_var("LEVIATH_HOME", Some(home.path().as_os_str()), || {
294 let registry = build_provider_registry_from_config(&config);
295 assert!(registry.has("groq"));
296 assert!(registry.get("groq").is_some());
297 });
298 }
299
300 #[test]
303 fn build_provider_registry_all_keys_set() {
304 let config = Config {
305 providers: crate::config::ProviderConfig {
306 anthropic_api_key: Some("sk-ant-test".to_string()),
307 openai_api_key: Some("sk-test".to_string()),
308 google_api_key: Some("AIza-test".to_string()),
309 claude_code_enabled: false,
310 claude_code_binary: None,
311 claude_code_effort: None,
312 fallback_order: Vec::new(),
313 },
314 openrouter_api_key: Some("sk-or-test".to_string()),
315 ollama_base_url: Some("http://custom:11434".to_string()),
316 ..Config::default()
317 };
318 let registry = build_provider_registry_from_config(&config);
319 assert!(registry.has("anthropic"));
320 assert!(registry.has("openai"));
321 assert!(registry.has("google"));
322 assert!(registry.has("openrouter"));
323 assert!(registry.has("ollama"));
324 assert!(!registry.has("claude-code"));
326 }
327
328 #[test]
331 fn provider_creds_from_config_includes_defaults_and_keyed() {
332 let config = Config {
333 providers: crate::config::ProviderConfig {
334 anthropic_api_key: Some("sk-ant".to_string()),
335 ..Config::default().providers
336 },
337 ollama_base_url: Some("http://custom:11434".to_string()),
338 ..Config::default()
339 };
340 let creds = provider_creds_from_config(&config);
341 let names: Vec<&str> = creds.iter().map(|c| c.name.as_str()).collect();
342 assert!(names.contains(&"anthropic"));
345 assert!(names.contains(&"ollama"));
346 assert!(!names.contains(&"claude-code"));
347 assert!(!names.contains(&"openai"));
348 assert!(!names.contains(&"google"));
349 assert!(!names.contains(&"openrouter"));
350 let ollama = creds.iter().find(|c| c.name == "ollama").unwrap();
352 assert_eq!(ollama.base_url.as_deref(), Some("http://custom:11434"));
353 assert!(ollama.api_key.is_none());
354 }
355
356 #[test]
361 fn provider_creds_from_config_ignores_blank_keys() {
362 let config = Config {
363 providers: crate::config::ProviderConfig {
364 anthropic_api_key: Some(String::new()),
365 openai_api_key: Some(" ".to_string()),
366 google_api_key: Some("AIza-real".to_string()),
367 ..Config::default().providers
368 },
369 ..Config::default()
370 };
371 let creds = provider_creds_from_config(&config);
372 let names: Vec<&str> = creds.iter().map(|c| c.name.as_str()).collect();
373 assert!(
374 names.contains(&"google"),
375 "the configured provider must register: {names:?}"
376 );
377 assert!(!names.contains(&"anthropic"), "empty key must not register");
378 assert!(
379 !names.contains(&"openai"),
380 "whitespace-only key must not register"
381 );
382 }
383
384 #[test]
385 fn provider_creds_from_config_carries_rate_limits() {
386 let config = Config {
387 providers: crate::config::ProviderConfig {
388 anthropic_api_key: Some("sk-ant".to_string()),
389 openai_api_key: Some("sk-oa".to_string()),
390 ..Config::default().providers
391 },
392 rate_limits: std::collections::HashMap::from([(
393 "anthropic".to_string(),
394 leviath_providers::RateLimitConfig {
395 requests_per_minute: 50,
396 tokens_per_minute: 40_000,
397 },
398 )]),
399 ..Config::default()
400 };
401 let creds = provider_creds_from_config(&config);
402 let anthropic = creds.iter().find(|c| c.name == "anthropic").unwrap();
403 assert_eq!(
404 anthropic.rate_limit.as_ref().map(|r| r.requests_per_minute),
405 Some(50)
406 );
407 let openai = creds.iter().find(|c| c.name == "openai").unwrap();
409 assert!(openai.rate_limit.is_none());
410 }
411
412 #[test]
415 fn build_provider_registry_defaults_have_ollama_only() {
416 let config = Config::default();
417 let registry = build_provider_registry_from_config(&config);
418 assert!(registry.has("ollama"));
421 assert!(!registry.has("claude-code"));
422 }
423
424 #[test]
425 fn enabling_claude_code_registers_it_with_its_options() {
426 let config = Config {
427 providers: crate::config::ProviderConfig {
428 claude_code_enabled: true,
429 claude_code_binary: Some("/opt/bin/claude".to_string()),
430 claude_code_effort: Some("low".to_string()),
431 ..Config::default().providers
432 },
433 ..Config::default()
434 };
435 let creds = provider_creds_from_config(&config);
436 let cc = creds
437 .iter()
438 .find(|c| c.name == "claude-code")
439 .expect("enabled ⇒ present");
440 assert_eq!(
441 cc.options.get("binary").map(String::as_str),
442 Some("/opt/bin/claude")
443 );
444 assert_eq!(cc.options.get("effort").map(String::as_str), Some("low"));
445 assert!(cc.api_key.is_none());
446 assert!(build_provider_registry_from_config(&config).has("claude-code"));
447 }
448
449 #[test]
450 fn enabling_claude_code_without_options_carries_none() {
451 let config = Config {
452 providers: crate::config::ProviderConfig {
453 claude_code_enabled: true,
454 ..Config::default().providers
455 },
456 ..Config::default()
457 };
458 let creds = provider_creds_from_config(&config);
459 let cc = creds.iter().find(|c| c.name == "claude-code").unwrap();
460 assert!(cc.options.is_empty());
463 }
464
465 #[test]
468 fn build_provider_registry_propagates_model_capabilities() {
469 use leviath_providers::ModelCapabilities;
470 let mut caps = std::collections::HashMap::new();
471 caps.insert(
472 "custom-model".to_string(),
473 ModelCapabilities {
474 supports_temperature: true,
475 supports_streaming: true,
476 supports_tools: true,
477 supports_system_prompt: true,
478 max_context_tokens: 9999,
479 max_output_tokens: 999,
480 },
481 );
482 let config = crate::config::Config {
483 model_capabilities: caps,
484 providers: crate::config::ProviderConfig {
485 anthropic_api_key: Some("sk-ant-test".to_string()),
486 openai_api_key: None,
487 google_api_key: None,
488 claude_code_enabled: false,
489 claude_code_binary: None,
490 claude_code_effort: None,
491 fallback_order: Vec::new(),
492 },
493 ..crate::config::Config::default()
494 };
495 let registry = build_provider_registry_from_config(&config);
496 assert!(registry.has("anthropic"));
498 assert!(registry.has("ollama"));
500 }
501
502 #[test]
505 fn build_provider_registry_ollama_with_custom_url_propagates_caps() {
506 use leviath_providers::ModelCapabilities;
507 let mut caps = std::collections::HashMap::new();
508 caps.insert(
509 "llama3-8b".to_string(),
510 ModelCapabilities {
511 supports_temperature: false,
512 supports_streaming: false,
513 supports_tools: false,
514 supports_system_prompt: false,
515 max_context_tokens: 99,
516 max_output_tokens: 99,
517 },
518 );
519 let config = crate::config::Config {
520 ollama_base_url: Some("http://custom-ollama:11434".to_string()),
521 model_capabilities: caps,
522 ..crate::config::Config::default()
523 };
524 let registry = build_provider_registry_from_config(&config);
525 assert!(registry.has("ollama"));
526 }
527
528 }