Skip to main content

apollo/
bootstrap.rs

1//! Runtime/bootstrap helpers used by the CLI entrypoint.
2
3use std::path::Path;
4use std::sync::Arc;
5
6use crate::config::Config;
7use crate::memory::embeddings::{create_embedding_provider, EmbeddingProvider};
8use crate::memory::search::{MemoryGetTool, MemorySearchTool, SessionSearchTool};
9use crate::memory::surreal::SurrealMemory;
10use crate::memory::MemoryBackend;
11use crate::policy::ExecutionPolicy;
12#[cfg(feature = "rs-ai")]
13use crate::providers::codex::CodexProvider;
14use crate::providers::defaults::default_model_for_provider;
15#[cfg(feature = "provider-ollama")]
16use crate::providers::ollama::OllamaProvider;
17use crate::providers::openai_compat::OpenAiCompatProvider;
18use crate::providers::Provider;
19use crate::tools::build_runner::BuildRunnerTool;
20use crate::tools::embeddings::{EmbeddingSearchTool, EmbeddingStatusTool, EmbeddingStoreTool};
21use crate::tools::file_ops::{FileReadTool, FileWriteTool};
22use crate::tools::shell::ShellTool;
23#[cfg(feature = "plugin-skills")]
24use crate::tools::skill_manager::SkillManagerTool;
25use crate::tools::toolsets::is_tool_enabled;
26use crate::tools::{BriefTool, ConfigTool, SleepTool, TodoWriteTool, Tool};
27#[cfg(feature = "plugin-advanced")]
28use crate::tools::{VibemaniaTool, WorktreeTool};
29
30pub fn load_config(path: &str) -> Config {
31    load_config_workspace(path, None)
32}
33
34pub fn require_config_file(path: &str) -> anyhow::Result<()> {
35    if !Path::new(path).exists() {
36        anyhow::bail!("Config not found at {path}. Run `apollo init` to create one.");
37    }
38    Ok(())
39}
40
41pub fn load_config_workspace(path: &str, workspace: Option<&Path>) -> Config {
42    let mut cfg = Config::load(path).unwrap_or_else(|_| {
43        tracing::warn!("Config not found at {}, using defaults", path);
44        Config::default_config()
45    });
46    if let Some(ws) = workspace {
47        crate::plugins::apply_workspace_manifest(&mut cfg, ws);
48    }
49
50    if matches!(cfg.provider.name.as_str(), "anthropic" | "claude") {
51        cfg.provider.name = "chatgpt".to_string();
52        cfg.provider.api_key = None;
53        if cfg.model.starts_with("claude") {
54            cfg.model = "gpt-5.5".to_string();
55        }
56    }
57
58    if cfg.provider.api_key.is_none() {
59        #[cfg(feature = "rs-ai")]
60        if let Some((token, _, _)) =
61            crate::providers::shared_credentials::load(rs_ai_oauth::OAuthProvider::ChatGpt)
62        {
63            cfg.provider.name = "chatgpt".to_string();
64            cfg.provider.api_key = Some(token);
65        }
66    }
67
68    if cfg.provider.api_key.is_none() {
69        if let Ok(key) = std::env::var("OPENAI_API_KEY") {
70            cfg.provider.name = "openai".to_string();
71            cfg.provider.api_key = Some(key);
72        }
73    }
74
75    if cfg.provider.name == "ollama" && cfg.provider.base_url.is_none() {
76        if let Ok(url) = std::env::var("OLLAMA_BASE_URL") {
77            cfg.provider.base_url = Some(url);
78        }
79    }
80
81    if cfg.embeddings.api_key.is_none() {
82        match cfg.embeddings.provider.as_str() {
83            "openai" | "openai_compat" => {
84                if let Ok(key) = std::env::var("OPENAI_API_KEY") {
85                    cfg.embeddings.api_key = Some(key);
86                }
87            }
88            "ollama" | "local" => {
89                if cfg.embeddings.base_url.is_none() {
90                    if let Ok(url) = std::env::var("OLLAMA_BASE_URL") {
91                        cfg.embeddings.base_url = Some(url);
92                    }
93                }
94            }
95            "gemini" => {
96                if let Ok(key) = std::env::var("GEMINI_API_KEY") {
97                    cfg.embeddings.api_key = Some(key);
98                }
99            }
100            _ => {}
101        }
102    }
103
104    apply_default_model(&mut cfg);
105
106    cfg
107}
108
109/// Fill in the model for the configured provider when none was chosen.
110///
111/// Credential detection can change the provider, so this runs once at the end
112/// from whichever provider actually won. It only ever fills a blank: a model
113/// already in the config is never replaced, including when the credential that
114/// was detected belongs to a different provider.
115fn apply_default_model(cfg: &mut Config) {
116    if !cfg.model.trim().is_empty() {
117        return;
118    }
119    if let Some(model) = default_model_for_provider(&cfg.provider.name) {
120        cfg.model = model.to_string();
121    }
122}
123
124pub fn build_provider(cfg: &Config) -> Arc<dyn Provider> {
125    let api_key = cfg.provider.api_key.clone().unwrap_or_default();
126
127    match cfg.provider.name.as_str() {
128        #[cfg(feature = "rs-ai")]
129        "chatgpt" => Arc::new(CodexProvider::new(api_key)),
130        #[cfg(feature = "provider-copilot")]
131        "github-copilot" | "copilot" => {
132            if let Ok(p) = crate::providers::copilot::CopilotProvider::from_openclaw() {
133                Arc::new(p)
134            } else {
135                Arc::new(crate::providers::copilot::CopilotProvider::new(&api_key))
136            }
137        }
138        #[cfg(feature = "rs-ai")]
139        "gemini" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
140            "gemini",
141            &cfg.model,
142            &api_key,
143            cfg.provider.base_url.clone(),
144            None,
145        )),
146        #[cfg(feature = "rs-ai")]
147        "xai" | "grok" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
148            "xai",
149            &cfg.model,
150            &api_key,
151            cfg.provider.base_url.clone(),
152            None,
153        )),
154        #[cfg(feature = "rs-ai")]
155        "cloudflare" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
156            "cloudflare",
157            &cfg.model,
158            &api_key,
159            None,
160            cfg.provider.base_url.clone(),
161        )),
162        "ollama" => {
163            #[cfg(feature = "provider-ollama")]
164            {
165                let url = cfg
166                    .provider
167                    .base_url
168                    .clone()
169                    .unwrap_or_else(|| "http://localhost:11434".into());
170                Arc::new(OllamaProvider::new(url))
171            }
172            #[cfg(not(feature = "provider-ollama"))]
173            {
174                panic!("provider=ollama requires building with the provider-ollama feature");
175            }
176        }
177        "openai" => Arc::new(OpenAiCompatProvider::openai(&api_key)),
178        "openrouter" => Arc::new(OpenAiCompatProvider::openrouter(&api_key)),
179        "groq" => Arc::new(OpenAiCompatProvider::groq(&api_key)),
180        "together" => Arc::new(OpenAiCompatProvider::together(&api_key)),
181        "mistral" => Arc::new(OpenAiCompatProvider::mistral(&api_key)),
182        "deepseek" => Arc::new(OpenAiCompatProvider::deepseek(&api_key)),
183        "fireworks" => Arc::new(OpenAiCompatProvider::fireworks(&api_key)),
184        "perplexity" => Arc::new(OpenAiCompatProvider::perplexity(&api_key)),
185        #[cfg(not(feature = "rs-ai"))]
186        "xai" | "grok" => Arc::new(OpenAiCompatProvider::xai(&api_key)),
187        "moonshot" | "kimi" => Arc::new(OpenAiCompatProvider::moonshot(&api_key)),
188        "venice" => Arc::new(OpenAiCompatProvider::venice(&api_key)),
189        "huggingface" => Arc::new(OpenAiCompatProvider::huggingface(&api_key)),
190        "siliconflow" => Arc::new(OpenAiCompatProvider::siliconflow(&api_key)),
191        "cerebras" => Arc::new(OpenAiCompatProvider::cerebras(&api_key)),
192        "minimax" => Arc::new(OpenAiCompatProvider::minimax(&api_key)),
193        "vercel" => Arc::new(OpenAiCompatProvider::vercel(&api_key)),
194        other => {
195            let url = cfg
196                .provider
197                .base_url
198                .clone()
199                .unwrap_or_else(|| "https://api.openai.com/v1".into());
200            Arc::new(OpenAiCompatProvider::new(&api_key, url, other))
201        }
202    }
203}
204
205pub fn build_base_tools(
206    workspace: &Path,
207    policy: Arc<ExecutionPolicy>,
208    memory: Arc<dyn MemoryBackend>,
209    embedding_provider: Option<Arc<dyn EmbeddingProvider>>,
210    provider: Arc<dyn Provider>,
211    cfg: &Config,
212    #[cfg(feature = "zkr-memory")] zkr_store: Option<Arc<crate::memory::zkr::ZkrStore>>,
213) -> Vec<Arc<dyn Tool>> {
214    let toolsets = &cfg.toolsets;
215    let mut tools: Vec<Arc<dyn Tool>> = vec![
216        Arc::new(ShellTool::new(workspace.to_path_buf(), Arc::clone(&policy))),
217        Arc::new(FileReadTool::new(workspace.to_path_buf())),
218        Arc::new(FileWriteTool::new(workspace.to_path_buf())),
219        Arc::new(crate::tools::edit::EditTool::new(workspace.to_path_buf())),
220        Arc::new(MemorySearchTool::new(workspace.to_path_buf())),
221        Arc::new(MemoryGetTool::new(workspace.to_path_buf())),
222        Arc::new(SessionSearchTool::new(Arc::clone(&memory))),
223        Arc::new(crate::tools::doctor::DoctorTool::new()),
224        Arc::new(BuildRunnerTool::with_default_config(
225            workspace.to_path_buf(),
226            Arc::clone(&policy),
227        )),
228        Arc::new(crate::tools::dynamic::CreateToolTool::new(Arc::clone(
229            &policy,
230        ))),
231        Arc::new(crate::tools::dynamic::ListCustomToolsTool::new()),
232        Arc::new(BriefTool::new(
233            Arc::clone(&provider),
234            cfg.agent.fast_model.clone(),
235        )),
236        Arc::new(ConfigTool::new(workspace.join("apollo.json"))),
237        Arc::new(SleepTool),
238        Arc::new(TodoWriteTool::new(workspace.to_path_buf())),
239        Arc::new(crate::tools::TelekinesisTool::new(
240            workspace.to_path_buf(),
241            Arc::clone(&policy),
242        )),
243    ];
244    #[cfg(feature = "plugin-web")]
245    {
246        // A provider that searches server-side already answers to the name
247        // `web_search`. Registering apollo's tool too would send the model two
248        // tools with one name, which the API rejects outright.
249        if !provider.capabilities().native_web_search {
250            tools.push(Arc::new(crate::tools::web_search::WebSearchTool::new()));
251        }
252        tools.push(Arc::new(crate::tools::web_fetch::WebFetchTool::new()));
253    }
254    #[cfg(feature = "plugin-browser")]
255    tools.push(Arc::new(crate::tools::browser::BrowserTool::new()));
256    #[cfg(feature = "plugin-advanced")]
257    {
258        tools.push(Arc::new(crate::tools::mcp::McpTool::new()));
259        tools.push(Arc::new(VibemaniaTool::new(workspace.to_path_buf())));
260        tools.push(Arc::new(WorktreeTool::new(workspace.to_path_buf())));
261    }
262    #[cfg(feature = "plugin-skills")]
263    tools.push(Arc::new(SkillManagerTool::new(workspace.to_path_buf())));
264    if let Some(provider) = embedding_provider {
265        tools.push(Arc::new(EmbeddingStatusTool::new(Arc::clone(&provider))));
266        tools.push(Arc::new(EmbeddingStoreTool::new(
267            Arc::clone(&provider),
268            Arc::clone(&memory),
269        )));
270        tools.push(Arc::new(EmbeddingSearchTool::new(provider, memory)));
271    }
272    #[cfg(feature = "computer-use-praefectus")]
273    match crate::tools::praefectus::PraefectusTool::new(workspace, Arc::clone(&policy)) {
274        Ok(tool) => tools.push(Arc::new(tool)),
275        Err(error) => tracing::error!("failed to initialize Praefectus: {error}"),
276    }
277    #[cfg(feature = "zkr-memory")]
278    if let Some(store) = zkr_store {
279        tools.push(Arc::new(crate::tools::zkr::ZkrTool::new(store)));
280    }
281    #[cfg(feature = "rs-ai")]
282    {
283        let media_key = cfg.provider.api_key.clone().unwrap_or_default();
284        let media_provider = cfg.provider.name.clone();
285        tools.push(Arc::new(crate::tools::media::ImageGenerationTool::new(
286            workspace.to_path_buf(),
287            media_key.clone(),
288            media_provider.clone(),
289        )));
290        tools.push(Arc::new(crate::tools::media::TextToSpeechTool::new(
291            workspace.to_path_buf(),
292            media_key.clone(),
293            media_provider.clone(),
294        )));
295        tools.push(Arc::new(crate::tools::media::SpeechToTextTool::new(
296            media_key,
297            media_provider,
298        )));
299    }
300    tools
301        .into_iter()
302        .filter(|tool| is_tool_enabled(tool.name(), toolsets))
303        .collect()
304}
305
306pub fn build_embedding_provider(
307    cfg: &Config,
308) -> anyhow::Result<Option<Arc<dyn EmbeddingProvider>>> {
309    if !cfg.embeddings.enabled {
310        return Ok(None);
311    }
312
313    let provider_name = cfg.embeddings.provider.trim().to_ascii_lowercase();
314    let model = cfg.embeddings.model.clone();
315    let base_url = cfg.embeddings.base_url.clone();
316    let api_key = cfg.embeddings.api_key.clone();
317
318    let provider = create_embedding_provider(&provider_name, api_key, model, base_url)?;
319    Ok(Some(provider))
320}
321
322#[cfg(feature = "zkr-memory")]
323pub fn build_zkr_store(
324    workspace: &Path,
325    cfg: &Config,
326) -> anyhow::Result<Option<Arc<crate::memory::zkr::ZkrStore>>> {
327    if !cfg.zkr.enabled {
328        return Ok(None);
329    }
330    let person_id = cfg
331        .memory
332        .principal_id
333        .as_deref()
334        .unwrap_or(&cfg.zkr.person_id);
335    Ok(Some(Arc::new(crate::memory::zkr::ZkrStore::open(
336        &workspace.join(&cfg.zkr.database),
337        cfg.zkr.tenant_id.clone(),
338        person_id.to_string(),
339    )?)))
340}
341
342pub async fn build_memory_backend(
343    workspace: &Path,
344    cfg: &Config,
345) -> anyhow::Result<Arc<dyn MemoryBackend>> {
346    let storage_root = workspace.join(&cfg.storage.root);
347    std::fs::create_dir_all(&storage_root)?;
348    let backend = cfg.storage.backend.trim().to_ascii_lowercase();
349    if backend != "surreal" {
350        anyhow::bail!(
351            "storage.backend={} is not supported; only surreal is available",
352            cfg.storage.backend
353        );
354    }
355
356    let surreal_path = storage_root.join("memory.surreal");
357    let memory = SurrealMemory::new(surreal_path.as_path()).await?;
358
359    let warmup = memory.clone();
360    tokio::spawn(async move {
361        if let Err(e) = warmup.db().await {
362            tracing::error!("memory backend failed to open: {e:#}");
363        }
364    });
365
366    Ok(Arc::new(memory))
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn require_config_file_errors_when_missing() {
375        let path = "/tmp/apollo-bootstrap-missing-config-test-xyz123.json";
376        let err = require_config_file(path).unwrap_err();
377        assert!(err.to_string().contains("Config not found"));
378        assert!(err.to_string().contains(path));
379    }
380
381    #[test]
382    fn require_config_file_ok_when_present() {
383        let dir = tempfile::tempdir().unwrap();
384        let path = dir.path().join("apollo.json");
385        std::fs::write(&path, "{}").unwrap();
386        require_config_file(path.to_str().unwrap()).unwrap();
387    }
388}
389
390#[cfg(test)]
391mod default_model_tests {
392    use super::*;
393
394    fn config_with(provider: &str, model: &str) -> Config {
395        let mut cfg = Config::default_config();
396        cfg.provider.name = provider.to_string();
397        cfg.model = model.to_string();
398        cfg
399    }
400
401    #[test]
402    fn a_blank_model_is_filled_from_the_provider() {
403        let mut cfg = config_with("xai", "");
404        apply_default_model(&mut cfg);
405        assert_eq!(cfg.model, "grok-build-0.1");
406
407        let mut cfg = config_with("chatgpt", "");
408        apply_default_model(&mut cfg);
409        assert_eq!(cfg.model, "gpt-5.5");
410    }
411
412    #[test]
413    fn a_configured_model_is_never_replaced() {
414        // Regression: credential detection used to overwrite the model even
415        // when the config named one, so an OAuth user lost their choice.
416        let mut cfg = config_with("chatgpt", "gpt-5.4-mini");
417        apply_default_model(&mut cfg);
418        assert_eq!(cfg.model, "gpt-5.4-mini");
419    }
420
421    #[test]
422    fn whitespace_counts_as_unset() {
423        let mut cfg = config_with("chatgpt", "   ");
424        apply_default_model(&mut cfg);
425        assert_eq!(cfg.model, "gpt-5.5");
426    }
427
428    #[test]
429    fn an_unknown_provider_leaves_the_model_alone() {
430        let mut cfg = config_with("some-private-gateway", "");
431        apply_default_model(&mut cfg);
432        assert!(cfg.model.is_empty(), "must not invent a model id");
433    }
434}