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/// The `options` spelling of a cache TTL, matching what the config accepts.
18///
19/// The map is `String -> String`, so the enum has to be named somehow; using
20/// the same spelling the TOML uses keeps one vocabulary rather than two.
21fn 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
28/// Build the list of [`ProviderCreds`] a [`Config`] implies. `ollama` is always
29/// present (it needs no key); the API-key providers are included only when their
30/// key is configured, and `claude-code` only when explicitly enabled. This is the
31/// sole point that reads provider settings out of `Config`.
32pub 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        // A blank key is not a key: `lev setup` writes empty strings for
45        // providers the user skipped, and registering one produces a provider
46        // that authenticates as nobody and fails at the first call.
47        if let Some(key) = key.map(str::trim).filter(|k| !k.is_empty()) {
48            // The options map rather than a named field, for the reason it
49            // exists: one provider's settings should not accrete onto every
50            // provider's struct.
51            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    // Ollama is always available (no key); carry any configured base URL.
70    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    // Claude Code needs no API key, but it is opt-in rather than always-on: the
87    // CLI puts the user's account email address into every call and that cannot
88    // be turned off. Leaving it unregistered is also how it stays out of an
89    // agent's model fallback chain - `resolve_stage_model` skips any provider
90    // the registry doesn't have.
91    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
113/// Convenience wrapper: build a [`ProviderRegistry`] straight from a [`Config`].
114///
115/// Kept as a `fn(&Config) -> ProviderRegistry` so it can be passed as the
116/// registry-builder seam that `run`/`models`/`dashboard` inject for tests.
117///
118/// Native providers are registered eagerly from [`provider_creds_from_config`];
119/// a [`ScriptProviderLayer`](leviath_runtime::script_provider::ScriptProviderLayer)
120/// is then attached so Rhai *script providers* resolve lazily and
121/// hot-reload from `~/.leviath/providers/`.
122pub 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
131/// [`build_provider_registry_from_config`], with client construction injected.
132///
133/// The seam that makes "this machine cannot build an HTTPS client" reachable
134/// from a test: reqwest will not fail to build one in any environment a test can
135/// arrange, so the failure has to be handed in.
136pub 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
151/// Attach a [`ScriptProviderLayer`](leviath_runtime::script_provider::ScriptProviderLayer)
152/// over `dir` (the providers directory) when one is available; otherwise return
153/// the registry unchanged. Split out so both the with-dir and no-home paths are
154/// unit-testable.
155fn 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
178/// Translate a CLI [`ModelProviderConfig`](crate::config::ModelProviderConfig)
179/// into the runtime's plain-data
180/// [`ScriptProviderSpec`](leviath_runtime::script_provider::ScriptProviderSpec):
181/// `base_url`/`api_key`/extra keys become the `initialize(config)` map.
182fn 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        // Ollama needs no key and is always on.
215        assert!(registry.has("ollama"));
216        // Claude Code needs no key either, but is opt-in - a default config
217        // must not reach the user's Claude subscription (or send their account
218        // email to it) without them having said yes.
219        assert!(!registry.has("claude-code"));
220        // Should NOT have anthropic, openai, google without keys
221        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        // No providers directory (no resolvable home) → registry unchanged, no
319        // script provider resolves.
320        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    // ─── build_provider_registry with all keys ──────────────────────────
353
354    #[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        // Every key in the world doesn't enable Claude Code - only opting in does.
379        assert!(!registry.has("claude-code"));
380    }
381
382    // ─── ProviderCreds seam ─────────────────────────────────────────────
383
384    /// The cache TTL reaches the provider through the creds, which is the whole
385    /// path #345 was missing: the enum existed and nothing could select it.
386    #[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        // Only Anthropic's, since only Anthropic reads it.
406        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    /// Unset means unset: no entry, so the provider keeps its own default.
425    #[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        // anthropic (keyed) + ollama, but not openai/google/openrouter, and not
446        // claude-code (opt-in, not enabled here).
447        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        // The ollama base URL is carried through.
454        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    /// `lev setup` writes an empty string for a provider the user skipped, so
460    /// a blank key must not register one: doing so produced a provider that
461    /// authenticates as nobody and fails at the first call, and it crowded out
462    /// the provider the user actually configured.
463    #[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        // A provider without a [rate_limits.<name>] entry stays unthrottled.
511        let openai = creds.iter().find(|c| c.name == "openai").unwrap();
512        assert!(openai.rate_limit.is_none());
513    }
514
515    // ─── resolve_task: multiline file content ───────────────────────────
516
517    #[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        // Ollama is present regardless of key configuration; claude-code is not,
523        // until the user opts in.
524        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        // Absent settings stay absent so the provider applies its own defaults
569        // (the `claude` binary on PATH, DEFAULT_EFFORT).
570        assert!(cc.options.is_empty());
571    }
572
573    // ─── resolve_task: file with only comments in editor-like format ────
574
575    #[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        // Verify anthropic provider was registered
608        assert!(registry.has("anthropic"));
609        // Verify ollama always registered
610        assert!(registry.has("ollama"));
611    }
612
613    // ─── launch_editor: candidates exhausted when no editors available ────
614
615    #[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    // ─── resolve_task: None arg, non-TTY stdin ───────────────────────────
642}