Skip to main content

sparrow/onboarding/
zero_question.rs

1use std::collections::HashMap;
2
3use crate::config::{Budget, Config, ConfigStore, ProviderConfig, providers};
4
5const LOCAL_PROVIDER_ID: &str = "ollama";
6
7/// v0.9 first launch path: create a usable config without asking questions.
8///
9/// The older conversational setup still exists for pro users, but the default
10/// experience must reach "ready" before the user has to decide anything.
11pub async fn prepare_default_launch(
12    config: &Config,
13    store: &dyn ConfigStore,
14) -> anyhow::Result<Config> {
15    let mut next = config.clone();
16    next.experience.mode = "auto".into();
17    next.experience.language = "auto".into();
18    next.budget = Budget {
19        daily_usd: next.budget.daily_usd.min(5.0),
20        session_usd: next.budget.session_usd.min(0.50),
21        max_wall_secs: next.budget.max_wall_secs.or(Some(300)),
22        max_tokens: next.budget.max_tokens,
23    };
24
25    add_env_providers(&mut next.providers);
26    ensure_local_fallback(&mut next.providers);
27    prefer_free_first(&mut next);
28
29    store.save(&next)?;
30    Ok(next)
31}
32
33pub fn ready_message() -> &'static str {
34    "Sparrow est prêt. Qu’est-ce qu’on règle aujourd’hui ?"
35}
36
37fn add_env_providers(providers_map: &mut HashMap<String, ProviderConfig>) {
38    for def in providers::provider_registry() {
39        let Some(api_key_env) = def.api_key_env.clone() else {
40            continue;
41        };
42        let Ok(value) = std::env::var(&api_key_env) else {
43            continue;
44        };
45        if value.trim().is_empty() || providers_map.contains_key(&def.id) {
46            continue;
47        }
48        providers_map.insert(
49            def.id.clone(),
50            ProviderConfig {
51                adapter: def.adapter,
52                base_url: Some(def.base_url),
53                models: providers::default_models(&def.id),
54                api_key_env: Some(api_key_env),
55            },
56        );
57    }
58}
59
60fn ensure_local_fallback(providers_map: &mut HashMap<String, ProviderConfig>) {
61    providers_map
62        .entry(LOCAL_PROVIDER_ID.into())
63        .or_insert_with(|| ProviderConfig {
64            adapter: "ollama".into(),
65            base_url: Some(
66                std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://localhost:11434".into()),
67            ),
68            models: vec!["qwen3.5:32b".into(), "llama4:latest".into()],
69            api_key_env: None,
70        });
71}
72
73fn prefer_free_first(config: &mut Config) {
74    config.routing.free_first = true;
75    config.routing.on_budget = "downgrade".into();
76    config.routing.routing_mode = "auto".into();
77    config.routing.preferred_provider = None;
78    config.routing.preferred_model = None;
79
80    let first_remote = config
81        .providers
82        .keys()
83        .find(|name| name.as_str() != LOCAL_PROVIDER_ID)
84        .cloned()
85        .unwrap_or_else(|| LOCAL_PROVIDER_ID.into());
86
87    config.routing.policy = HashMap::from([
88        ("trivial".into(), LOCAL_PROVIDER_ID.into()),
89        ("small".into(), first_remote.clone()),
90        ("medium".into(), first_remote.clone()),
91        ("hard".into(), first_remote.clone()),
92        ("vision".into(), first_remote),
93    ]);
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    struct MemoryStore;
101
102    impl ConfigStore for MemoryStore {
103        fn load(&self) -> anyhow::Result<Config> {
104            Ok(Config::default())
105        }
106
107        fn save(&self, c: &Config) -> anyhow::Result<()> {
108            assert!(c.providers.contains_key(LOCAL_PROVIDER_ID));
109            assert_eq!(c.routing.policy.get("trivial").unwrap(), LOCAL_PROVIDER_ID);
110            assert!(c.budget.session_usd <= 0.50);
111            Ok(())
112        }
113    }
114
115    #[tokio::test]
116    async fn first_launch_is_zero_question_and_free_first() {
117        let prepared = prepare_default_launch(&Config::default(), &MemoryStore)
118            .await
119            .unwrap();
120
121        assert_eq!(prepared.experience.mode, "auto");
122        assert_eq!(prepared.routing.on_budget, "downgrade");
123        assert!(prepared.providers.contains_key(LOCAL_PROVIDER_ID));
124        assert_eq!(
125            ready_message(),
126            "Sparrow est prêt. Qu’est-ce qu’on règle aujourd’hui ?"
127        );
128    }
129}