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;
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
111fn 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 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 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}