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::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
112fn 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 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 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}