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 = "rs-ai")]
13use crate::providers::codex::CodexProvider;
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 matches!(cfg.provider.name.as_str(), "anthropic" | "claude") {
51 cfg.provider.name = "chatgpt".to_string();
52 cfg.provider.api_key = None;
53 if cfg.model.starts_with("claude") {
54 cfg.model = "gpt-5.5".to_string();
55 }
56 }
57
58 if cfg.provider.api_key.is_none() {
59 #[cfg(feature = "rs-ai")]
60 if let Some((token, _, _)) =
61 crate::providers::shared_credentials::load(rs_ai_oauth::OAuthProvider::ChatGpt)
62 {
63 cfg.provider.name = "chatgpt".to_string();
64 cfg.provider.api_key = Some(token);
65 }
66 }
67
68 if cfg.provider.api_key.is_none() {
69 if let Ok(key) = std::env::var("OPENAI_API_KEY") {
70 cfg.provider.name = "openai".to_string();
71 cfg.provider.api_key = Some(key);
72 }
73 }
74
75 if cfg.provider.name == "ollama" && cfg.provider.base_url.is_none() {
76 if let Ok(url) = std::env::var("OLLAMA_BASE_URL") {
77 cfg.provider.base_url = Some(url);
78 }
79 }
80
81 if cfg.embeddings.api_key.is_none() {
82 match cfg.embeddings.provider.as_str() {
83 "openai" | "openai_compat" => {
84 if let Ok(key) = std::env::var("OPENAI_API_KEY") {
85 cfg.embeddings.api_key = Some(key);
86 }
87 }
88 "ollama" | "local" => {
89 if cfg.embeddings.base_url.is_none() {
90 if let Ok(url) = std::env::var("OLLAMA_BASE_URL") {
91 cfg.embeddings.base_url = Some(url);
92 }
93 }
94 }
95 "gemini" => {
96 if let Ok(key) = std::env::var("GEMINI_API_KEY") {
97 cfg.embeddings.api_key = Some(key);
98 }
99 }
100 _ => {}
101 }
102 }
103
104 apply_default_model(&mut cfg);
105
106 cfg
107}
108
109fn apply_default_model(cfg: &mut Config) {
116 if !cfg.model.trim().is_empty() {
117 return;
118 }
119 if let Some(model) = default_model_for_provider(&cfg.provider.name) {
120 cfg.model = model.to_string();
121 }
122}
123
124pub fn build_provider(cfg: &Config) -> Arc<dyn Provider> {
125 let api_key = cfg.provider.api_key.clone().unwrap_or_default();
126
127 match cfg.provider.name.as_str() {
128 #[cfg(feature = "rs-ai")]
129 "chatgpt" => Arc::new(CodexProvider::new(api_key)),
130 #[cfg(feature = "provider-copilot")]
131 "github-copilot" | "copilot" => {
132 if let Ok(p) = crate::providers::copilot::CopilotProvider::from_openclaw() {
133 Arc::new(p)
134 } else {
135 Arc::new(crate::providers::copilot::CopilotProvider::new(&api_key))
136 }
137 }
138 #[cfg(feature = "rs-ai")]
139 "gemini" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
140 "gemini",
141 &cfg.model,
142 &api_key,
143 cfg.provider.base_url.clone(),
144 None,
145 )),
146 #[cfg(feature = "rs-ai")]
147 "xai" | "grok" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
148 "xai",
149 &cfg.model,
150 &api_key,
151 cfg.provider.base_url.clone(),
152 None,
153 )),
154 #[cfg(feature = "rs-ai")]
155 "cloudflare" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
156 "cloudflare",
157 &cfg.model,
158 &api_key,
159 None,
160 cfg.provider.base_url.clone(),
161 )),
162 "ollama" => {
163 #[cfg(feature = "provider-ollama")]
164 {
165 let url = cfg
166 .provider
167 .base_url
168 .clone()
169 .unwrap_or_else(|| "http://localhost:11434".into());
170 Arc::new(OllamaProvider::new(url))
171 }
172 #[cfg(not(feature = "provider-ollama"))]
173 {
174 panic!("provider=ollama requires building with the provider-ollama feature");
175 }
176 }
177 "openai" => Arc::new(OpenAiCompatProvider::openai(&api_key)),
178 "openrouter" => Arc::new(OpenAiCompatProvider::openrouter(&api_key)),
179 "groq" => Arc::new(OpenAiCompatProvider::groq(&api_key)),
180 "together" => Arc::new(OpenAiCompatProvider::together(&api_key)),
181 "mistral" => Arc::new(OpenAiCompatProvider::mistral(&api_key)),
182 "deepseek" => Arc::new(OpenAiCompatProvider::deepseek(&api_key)),
183 "fireworks" => Arc::new(OpenAiCompatProvider::fireworks(&api_key)),
184 "perplexity" => Arc::new(OpenAiCompatProvider::perplexity(&api_key)),
185 #[cfg(not(feature = "rs-ai"))]
186 "xai" | "grok" => Arc::new(OpenAiCompatProvider::xai(&api_key)),
187 "moonshot" | "kimi" => Arc::new(OpenAiCompatProvider::moonshot(&api_key)),
188 "venice" => Arc::new(OpenAiCompatProvider::venice(&api_key)),
189 "huggingface" => Arc::new(OpenAiCompatProvider::huggingface(&api_key)),
190 "siliconflow" => Arc::new(OpenAiCompatProvider::siliconflow(&api_key)),
191 "cerebras" => Arc::new(OpenAiCompatProvider::cerebras(&api_key)),
192 "minimax" => Arc::new(OpenAiCompatProvider::minimax(&api_key)),
193 "vercel" => Arc::new(OpenAiCompatProvider::vercel(&api_key)),
194 other => {
195 let url = cfg
196 .provider
197 .base_url
198 .clone()
199 .unwrap_or_else(|| "https://api.openai.com/v1".into());
200 Arc::new(OpenAiCompatProvider::new(&api_key, url, other))
201 }
202 }
203}
204
205pub fn build_base_tools(
206 workspace: &Path,
207 policy: Arc<ExecutionPolicy>,
208 memory: Arc<dyn MemoryBackend>,
209 embedding_provider: Option<Arc<dyn EmbeddingProvider>>,
210 provider: Arc<dyn Provider>,
211 cfg: &Config,
212 #[cfg(feature = "zkr-memory")] zkr_store: Option<Arc<crate::memory::zkr::ZkrStore>>,
213) -> Vec<Arc<dyn Tool>> {
214 let toolsets = &cfg.toolsets;
215 let mut tools: Vec<Arc<dyn Tool>> = vec![
216 Arc::new(ShellTool::new(workspace.to_path_buf(), Arc::clone(&policy))),
217 Arc::new(FileReadTool::new(workspace.to_path_buf())),
218 Arc::new(FileWriteTool::new(workspace.to_path_buf())),
219 Arc::new(crate::tools::edit::EditTool::new(workspace.to_path_buf())),
220 Arc::new(MemorySearchTool::new(workspace.to_path_buf())),
221 Arc::new(MemoryGetTool::new(workspace.to_path_buf())),
222 Arc::new(SessionSearchTool::new(Arc::clone(&memory))),
223 Arc::new(crate::tools::doctor::DoctorTool::new()),
224 Arc::new(BuildRunnerTool::with_default_config(
225 workspace.to_path_buf(),
226 Arc::clone(&policy),
227 )),
228 Arc::new(crate::tools::dynamic::CreateToolTool::new(Arc::clone(
229 &policy,
230 ))),
231 Arc::new(crate::tools::dynamic::ListCustomToolsTool::new()),
232 Arc::new(BriefTool::new(
233 Arc::clone(&provider),
234 cfg.agent.fast_model.clone(),
235 )),
236 Arc::new(ConfigTool::new(workspace.join("apollo.json"))),
237 Arc::new(SleepTool),
238 Arc::new(TodoWriteTool::new(workspace.to_path_buf())),
239 Arc::new(crate::tools::TelekinesisTool::new(
240 workspace.to_path_buf(),
241 Arc::clone(&policy),
242 )),
243 ];
244 #[cfg(feature = "plugin-web")]
245 {
246 if !provider.capabilities().native_web_search {
250 tools.push(Arc::new(crate::tools::web_search::WebSearchTool::new()));
251 }
252 tools.push(Arc::new(crate::tools::web_fetch::WebFetchTool::new()));
253 }
254 #[cfg(feature = "plugin-browser")]
255 tools.push(Arc::new(crate::tools::browser::BrowserTool::new()));
256 #[cfg(feature = "plugin-advanced")]
257 {
258 tools.push(Arc::new(crate::tools::mcp::McpTool::new()));
259 tools.push(Arc::new(VibemaniaTool::new(workspace.to_path_buf())));
260 tools.push(Arc::new(WorktreeTool::new(workspace.to_path_buf())));
261 }
262 #[cfg(feature = "plugin-skills")]
263 tools.push(Arc::new(SkillManagerTool::new(workspace.to_path_buf())));
264 if let Some(provider) = embedding_provider {
265 tools.push(Arc::new(EmbeddingStatusTool::new(Arc::clone(&provider))));
266 tools.push(Arc::new(EmbeddingStoreTool::new(
267 Arc::clone(&provider),
268 Arc::clone(&memory),
269 )));
270 tools.push(Arc::new(EmbeddingSearchTool::new(provider, memory)));
271 }
272 #[cfg(feature = "computer-use-praefectus")]
273 match crate::tools::praefectus::PraefectusTool::new(workspace, Arc::clone(&policy)) {
274 Ok(tool) => tools.push(Arc::new(tool)),
275 Err(error) => tracing::error!("failed to initialize Praefectus: {error}"),
276 }
277 #[cfg(feature = "zkr-memory")]
278 if let Some(store) = zkr_store {
279 tools.push(Arc::new(crate::tools::zkr::ZkrTool::new(store)));
280 }
281 #[cfg(feature = "rs-ai")]
282 {
283 let media_key = cfg.provider.api_key.clone().unwrap_or_default();
284 let media_provider = cfg.provider.name.clone();
285 tools.push(Arc::new(crate::tools::media::ImageGenerationTool::new(
286 workspace.to_path_buf(),
287 media_key.clone(),
288 media_provider.clone(),
289 )));
290 tools.push(Arc::new(crate::tools::media::TextToSpeechTool::new(
291 workspace.to_path_buf(),
292 media_key.clone(),
293 media_provider.clone(),
294 )));
295 tools.push(Arc::new(crate::tools::media::SpeechToTextTool::new(
296 media_key,
297 media_provider,
298 )));
299 }
300 tools
301 .into_iter()
302 .filter(|tool| is_tool_enabled(tool.name(), toolsets))
303 .collect()
304}
305
306pub fn build_embedding_provider(
307 cfg: &Config,
308) -> anyhow::Result<Option<Arc<dyn EmbeddingProvider>>> {
309 if !cfg.embeddings.enabled {
310 return Ok(None);
311 }
312
313 let provider_name = cfg.embeddings.provider.trim().to_ascii_lowercase();
314 let model = cfg.embeddings.model.clone();
315 let base_url = cfg.embeddings.base_url.clone();
316 let api_key = cfg.embeddings.api_key.clone();
317
318 let provider = create_embedding_provider(&provider_name, api_key, model, base_url)?;
319 Ok(Some(provider))
320}
321
322#[cfg(feature = "zkr-memory")]
323pub fn build_zkr_store(
324 workspace: &Path,
325 cfg: &Config,
326) -> anyhow::Result<Option<Arc<crate::memory::zkr::ZkrStore>>> {
327 if !cfg.zkr.enabled {
328 return Ok(None);
329 }
330 let person_id = cfg
331 .memory
332 .principal_id
333 .as_deref()
334 .unwrap_or(&cfg.zkr.person_id);
335 Ok(Some(Arc::new(crate::memory::zkr::ZkrStore::open(
336 &workspace.join(&cfg.zkr.database),
337 cfg.zkr.tenant_id.clone(),
338 person_id.to_string(),
339 )?)))
340}
341
342pub async fn build_memory_backend(
343 workspace: &Path,
344 cfg: &Config,
345) -> anyhow::Result<Arc<dyn MemoryBackend>> {
346 let storage_root = workspace.join(&cfg.storage.root);
347 std::fs::create_dir_all(&storage_root)?;
348 let backend = cfg.storage.backend.trim().to_ascii_lowercase();
349 if backend != "surreal" {
350 anyhow::bail!(
351 "storage.backend={} is not supported; only surreal is available",
352 cfg.storage.backend
353 );
354 }
355
356 let surreal_path = storage_root.join("memory.surreal");
357 let memory = SurrealMemory::new(surreal_path.as_path()).await?;
358
359 let warmup = memory.clone();
360 tokio::spawn(async move {
361 if let Err(e) = warmup.db().await {
362 tracing::error!("memory backend failed to open: {e:#}");
363 }
364 });
365
366 Ok(Arc::new(memory))
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn require_config_file_errors_when_missing() {
375 let path = "/tmp/apollo-bootstrap-missing-config-test-xyz123.json";
376 let err = require_config_file(path).unwrap_err();
377 assert!(err.to_string().contains("Config not found"));
378 assert!(err.to_string().contains(path));
379 }
380
381 #[test]
382 fn require_config_file_ok_when_present() {
383 let dir = tempfile::tempdir().unwrap();
384 let path = dir.path().join("apollo.json");
385 std::fs::write(&path, "{}").unwrap();
386 require_config_file(path.to_str().unwrap()).unwrap();
387 }
388}
389
390#[cfg(test)]
391mod default_model_tests {
392 use super::*;
393
394 fn config_with(provider: &str, model: &str) -> Config {
395 let mut cfg = Config::default_config();
396 cfg.provider.name = provider.to_string();
397 cfg.model = model.to_string();
398 cfg
399 }
400
401 #[test]
402 fn a_blank_model_is_filled_from_the_provider() {
403 let mut cfg = config_with("xai", "");
404 apply_default_model(&mut cfg);
405 assert_eq!(cfg.model, "grok-build-0.1");
406
407 let mut cfg = config_with("chatgpt", "");
408 apply_default_model(&mut cfg);
409 assert_eq!(cfg.model, "gpt-5.5");
410 }
411
412 #[test]
413 fn a_configured_model_is_never_replaced() {
414 let mut cfg = config_with("chatgpt", "gpt-5.4-mini");
417 apply_default_model(&mut cfg);
418 assert_eq!(cfg.model, "gpt-5.4-mini");
419 }
420
421 #[test]
422 fn whitespace_counts_as_unset() {
423 let mut cfg = config_with("chatgpt", " ");
424 apply_default_model(&mut cfg);
425 assert_eq!(cfg.model, "gpt-5.5");
426 }
427
428 #[test]
429 fn an_unknown_provider_leaves_the_model_alone() {
430 let mut cfg = config_with("some-private-gateway", "");
431 apply_default_model(&mut cfg);
432 assert!(cfg.model.is_empty(), "must not invent a model id");
433 }
434}