Skip to main content

leviath_cli/commands/run/
session.rs

1//! Provider registry construction from the CLI's `Config`.
2//!
3//! Turning what the caller typed into a task lives in
4//! [`super::task`](crate::commands::run::task).
5
6use crate::config::Config;
7use leviath_runtime::ProviderRegistry;
8
9// `ProviderCreds` + `build_provider_registry(&[ProviderCreds])` live in
10// `leviath-runtime` (plain data + provider instantiation, no `Config`
11// dependency). Re-exported here so `commands::run`'s public re-export and all
12// existing call sites keep resolving. The `Config`-based translators
13// (`provider_creds_from_config` / `build_provider_registry_from_config`) stay
14// below because they need the CLI's `Config`.
15pub use leviath_runtime::provider_creds::{ProviderCreds, build_provider_registry};
16
17/// Build the list of [`ProviderCreds`] a [`Config`] implies. `ollama` is always
18/// present (it needs no key); the API-key providers are included only when their
19/// key is configured, and `claude-code` only when explicitly enabled. This is the
20/// sole point that reads provider settings out of `Config`.
21pub 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        // A blank key is not a key: `lev setup` writes empty strings for
34        // providers the user skipped, and registering one produces a provider
35        // that authenticates as nobody and fails at the first call.
36        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    // Ollama is always available (no key); carry any configured base URL.
50    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    // Claude Code needs no API key, but it is opt-in rather than always-on: the
67    // CLI puts the user's account email address into every call and that cannot
68    // be turned off. Leaving it unregistered is also how it stays out of an
69    // agent's model fallback chain - `resolve_stage_model` skips any provider
70    // the registry doesn't have.
71    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
93/// Convenience wrapper: build a [`ProviderRegistry`] straight from a [`Config`].
94///
95/// Kept as a `fn(&Config) -> ProviderRegistry` so it can be passed as the
96/// registry-builder seam that `run`/`models`/`dashboard` inject for tests.
97///
98/// Native providers are registered eagerly from [`provider_creds_from_config`];
99/// a [`ScriptProviderLayer`](leviath_runtime::script_provider::ScriptProviderLayer)
100/// is then attached so Rhai *script providers* resolve lazily and
101/// hot-reload from `~/.leviath/providers/`.
102pub 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
107/// Attach a [`ScriptProviderLayer`](leviath_runtime::script_provider::ScriptProviderLayer)
108/// over `dir` (the providers directory) when one is available; otherwise return
109/// the registry unchanged. Split out so both the with-dir and no-home paths are
110/// unit-testable.
111fn 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
134/// Translate a CLI [`ModelProviderConfig`](crate::config::ModelProviderConfig)
135/// into the runtime's plain-data
136/// [`ScriptProviderSpec`](leviath_runtime::script_provider::ScriptProviderSpec):
137/// `base_url`/`api_key`/extra keys become the `initialize(config)` map.
138fn 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        // Ollama needs no key and is always on.
170        assert!(registry.has("ollama"));
171        // Claude Code needs no key either, but is opt-in - a default config
172        // must not reach the user's Claude subscription (or send their account
173        // email to it) without them having said yes.
174        assert!(!registry.has("claude-code"));
175        // Should NOT have anthropic, openai, google without keys
176        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        // No providers directory (no resolvable home) → registry unchanged, no
268        // script provider resolves.
269        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    // ─── build_provider_registry with all keys ──────────────────────────
301
302    #[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        // Every key in the world doesn't enable Claude Code - only opting in does.
325        assert!(!registry.has("claude-code"));
326    }
327
328    // ─── ProviderCreds seam ─────────────────────────────────────────────
329
330    #[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        // anthropic (keyed) + ollama, but not openai/google/openrouter, and not
343        // claude-code (opt-in, not enabled here).
344        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        // The ollama base URL is carried through.
351        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    /// `lev setup` writes an empty string for a provider the user skipped, so
357    /// a blank key must not register one: doing so produced a provider that
358    /// authenticates as nobody and fails at the first call, and it crowded out
359    /// the provider the user actually configured.
360    #[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        // A provider without a [rate_limits.<name>] entry stays unthrottled.
408        let openai = creds.iter().find(|c| c.name == "openai").unwrap();
409        assert!(openai.rate_limit.is_none());
410    }
411
412    // ─── resolve_task: multiline file content ───────────────────────────
413
414    #[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        // Ollama is present regardless of key configuration; claude-code is not,
419        // until the user opts in.
420        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        // Absent settings stay absent so the provider applies its own defaults
461        // (the `claude` binary on PATH, DEFAULT_EFFORT).
462        assert!(cc.options.is_empty());
463    }
464
465    // ─── resolve_task: file with only comments in editor-like format ────
466
467    #[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        // Verify anthropic provider was registered
497        assert!(registry.has("anthropic"));
498        // Verify ollama always registered
499        assert!(registry.has("ollama"));
500    }
501
502    // ─── launch_editor: candidates exhausted when no editors available ────
503
504    #[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    // ─── resolve_task: None arg, non-TTY stdin ───────────────────────────
529}