sparrow/onboarding/
zero_question.rs1use std::collections::HashMap;
2
3use crate::config::{Budget, Config, ConfigStore, ProviderConfig, providers};
4
5const LOCAL_PROVIDER_ID: &str = "ollama";
6
7pub 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}