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