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        Arc::new(crate::tools::TelekinesisTool::new(
254            workspace.to_path_buf(),
255            Arc::clone(&policy),
256        )),
257    ];
258    #[cfg(feature = "plugin-web")]
259    {
260        // A provider that searches server-side already answers to the name
261        // `web_search`. Registering apollo's tool too would send the model two
262        // tools with one name, which the API rejects outright.
263        if !provider.capabilities().native_web_search {
264            tools.push(Arc::new(crate::tools::web_search::WebSearchTool::new()));
265        }
266        tools.push(Arc::new(crate::tools::web_fetch::WebFetchTool::new()));
267    }
268    #[cfg(feature = "plugin-browser")]
269    tools.push(Arc::new(crate::tools::browser::BrowserTool::new()));
270    #[cfg(feature = "plugin-advanced")]
271    {
272        tools.push(Arc::new(crate::tools::mcp::McpTool::new()));
273        tools.push(Arc::new(VibemaniaTool::new(workspace.to_path_buf())));
274        tools.push(Arc::new(WorktreeTool::new(workspace.to_path_buf())));
275    }
276    #[cfg(feature = "plugin-skills")]
277    tools.push(Arc::new(SkillManagerTool::new(workspace.to_path_buf())));
278    if let Some(provider) = embedding_provider {
279        tools.push(Arc::new(EmbeddingStatusTool::new(Arc::clone(&provider))));
280        tools.push(Arc::new(EmbeddingStoreTool::new(
281            Arc::clone(&provider),
282            Arc::clone(&memory),
283        )));
284        tools.push(Arc::new(EmbeddingSearchTool::new(provider, memory)));
285    }
286    #[cfg(feature = "computer-use-praefectus")]
287    match crate::tools::praefectus::PraefectusTool::new(workspace, Arc::clone(&policy)) {
288        Ok(tool) => tools.push(Arc::new(tool)),
289        Err(error) => tracing::error!("failed to initialize Praefectus: {error}"),
290    }
291    #[cfg(feature = "zkr-memory")]
292    if let Some(store) = zkr_store {
293        tools.push(Arc::new(crate::tools::zkr::ZkrTool::new(store)));
294    }
295    #[cfg(feature = "rs-ai")]
296    {
297        let media_key = cfg.provider.api_key.clone().unwrap_or_default();
298        let media_provider = cfg.provider.name.clone();
299        tools.push(Arc::new(crate::tools::media::ImageGenerationTool::new(
300            workspace.to_path_buf(),
301            media_key.clone(),
302            media_provider.clone(),
303        )));
304        tools.push(Arc::new(crate::tools::media::TextToSpeechTool::new(
305            workspace.to_path_buf(),
306            media_key.clone(),
307            media_provider.clone(),
308        )));
309        tools.push(Arc::new(crate::tools::media::SpeechToTextTool::new(
310            media_key,
311            media_provider,
312        )));
313    }
314    tools
315        .into_iter()
316        .filter(|tool| is_tool_enabled(tool.name(), toolsets))
317        .collect()
318}
319
320pub fn build_embedding_provider(
321    cfg: &Config,
322) -> anyhow::Result<Option<Arc<dyn EmbeddingProvider>>> {
323    if !cfg.embeddings.enabled {
324        return Ok(None);
325    }
326
327    let provider_name = cfg.embeddings.provider.trim().to_ascii_lowercase();
328    let model = cfg.embeddings.model.clone();
329    let base_url = cfg.embeddings.base_url.clone();
330    let api_key = cfg.embeddings.api_key.clone();
331
332    let provider = create_embedding_provider(&provider_name, api_key, model, base_url)?;
333    Ok(Some(provider))
334}
335
336#[cfg(feature = "zkr-memory")]
337pub fn build_zkr_store(
338    workspace: &Path,
339    cfg: &Config,
340) -> anyhow::Result<Option<Arc<crate::memory::zkr::ZkrStore>>> {
341    if !cfg.zkr.enabled {
342        return Ok(None);
343    }
344    let person_id = cfg
345        .memory
346        .principal_id
347        .as_deref()
348        .unwrap_or(&cfg.zkr.person_id);
349    Ok(Some(Arc::new(crate::memory::zkr::ZkrStore::open(
350        &workspace.join(&cfg.zkr.database),
351        cfg.zkr.tenant_id.clone(),
352        person_id.to_string(),
353    )?)))
354}
355
356pub async fn build_memory_backend(
357    workspace: &Path,
358    cfg: &Config,
359) -> anyhow::Result<Arc<dyn MemoryBackend>> {
360    let storage_root = workspace.join(&cfg.storage.root);
361    std::fs::create_dir_all(&storage_root)?;
362    let backend = cfg.storage.backend.trim().to_ascii_lowercase();
363    if backend != "surreal" {
364        anyhow::bail!(
365            "storage.backend={} is not supported; only surreal is available",
366            cfg.storage.backend
367        );
368    }
369
370    let surreal_path = storage_root.join("memory.surreal");
371    let memory = SurrealMemory::new(surreal_path.as_path()).await?;
372
373    let warmup = memory.clone();
374    tokio::spawn(async move {
375        if let Err(e) = warmup.db().await {
376            tracing::error!("memory backend failed to open: {e:#}");
377        }
378    });
379
380    Ok(Arc::new(memory))
381}
382
383fn resolve_openclaw_token(provider: &str) -> anyhow::Result<String> {
384    let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("No home dir"))?;
385    let auth_path = home.join(".openclaw/agents/main/agent/auth-profiles.json");
386
387    if !auth_path.exists() {
388        return Err(anyhow::anyhow!("No auth-profiles.json found"));
389    }
390
391    let content = std::fs::read_to_string(&auth_path)?;
392    let data: serde_json::Value = serde_json::from_str(&content)?;
393
394    let profile_key = format!("{}:default", provider);
395    if let Some(profile) = data["profiles"][&profile_key].as_object() {
396        if let Some(token) = profile.get("token").and_then(|t| t.as_str()) {
397            if !token.is_empty() {
398                tracing::info!("Loaded {} token from OpenClaw auth-profiles", provider);
399                return Ok(token.to_string());
400            }
401        }
402        if let Some(key) = profile.get("key").and_then(|k| k.as_str()) {
403            if !key.is_empty() {
404                tracing::info!("Loaded {} API key from OpenClaw auth-profiles", provider);
405                return Ok(key.to_string());
406            }
407        }
408    }
409
410    if let Some(profiles) = data["profiles"].as_object() {
411        for (key, value) in profiles {
412            if let Some(p) = value["provider"].as_str() {
413                if p == provider {
414                    if let Some(token) = value["token"].as_str() {
415                        if !token.is_empty() {
416                            tracing::info!(
417                                "Loaded {} token from OpenClaw profile {}",
418                                provider,
419                                key
420                            );
421                            return Ok(token.to_string());
422                        }
423                    }
424                    if let Some(key_val) = value["key"].as_str() {
425                        if !key_val.is_empty() {
426                            tracing::info!("Loaded {} key from OpenClaw profile {}", provider, key);
427                            return Ok(key_val.to_string());
428                        }
429                    }
430                }
431            }
432        }
433    }
434
435    Err(anyhow::anyhow!(
436        "No {} credentials in auth-profiles",
437        provider
438    ))
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn require_config_file_errors_when_missing() {
447        let path = "/tmp/apollo-bootstrap-missing-config-test-xyz123.json";
448        let err = require_config_file(path).unwrap_err();
449        assert!(err.to_string().contains("Config not found"));
450        assert!(err.to_string().contains(path));
451    }
452
453    #[test]
454    fn require_config_file_ok_when_present() {
455        let dir = tempfile::tempdir().unwrap();
456        let path = dir.path().join("apollo.json");
457        std::fs::write(&path, "{}").unwrap();
458        require_config_file(path.to_str().unwrap()).unwrap();
459    }
460}
461
462#[cfg(test)]
463mod default_model_tests {
464    use super::*;
465
466    fn config_with(provider: &str, model: &str) -> Config {
467        let mut cfg = Config::default_config();
468        cfg.provider.name = provider.to_string();
469        cfg.model = model.to_string();
470        cfg
471    }
472
473    #[test]
474    fn a_blank_model_is_filled_from_the_provider() {
475        let mut cfg = config_with("xai", "");
476        apply_default_model(&mut cfg);
477        assert_eq!(cfg.model, "grok-build-0.1");
478
479        let mut cfg = config_with("chatgpt", "");
480        apply_default_model(&mut cfg);
481        assert_eq!(cfg.model, "gpt-5.6-sol");
482    }
483
484    #[test]
485    fn a_configured_model_is_never_replaced() {
486        // Regression: credential detection used to overwrite the model even
487        // when the config named one, so an OAuth user lost their choice.
488        let mut cfg = config_with("anthropic", "claude-haiku-4-5");
489        apply_default_model(&mut cfg);
490        assert_eq!(cfg.model, "claude-haiku-4-5");
491    }
492
493    #[test]
494    fn whitespace_counts_as_unset() {
495        let mut cfg = config_with("anthropic", "   ");
496        apply_default_model(&mut cfg);
497        assert_eq!(cfg.model, "claude-fable-5");
498    }
499
500    #[test]
501    fn an_unknown_provider_leaves_the_model_alone() {
502        let mut cfg = config_with("some-private-gateway", "");
503        apply_default_model(&mut cfg);
504        assert!(cfg.model.is_empty(), "must not invent a model id");
505    }
506}