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 Arc::new(crate::tools::TelekinesisTool::new(
254 workspace.to_path_buf(),
255 Arc::clone(&policy),
256 )),
257 ];
258 #[cfg(feature = "plugin-web")]
259 {
260 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 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}