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