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