1use serde::Deserialize;
24use std::collections::HashMap;
25use std::path::PathBuf;
26
27use crate::error::LlmError;
28
29#[derive(Debug, Deserialize)]
35pub struct RobitConfig {
36 pub default_model: Option<String>,
38 pub providers: HashMap<String, ProviderConfig>,
40 pub app: Option<AppConfig>,
42}
43
44#[derive(Debug, Deserialize)]
46pub struct ProviderConfig {
47 pub name: Option<String>,
49 pub base_url: String,
51 pub api_key: String,
53 pub models: Vec<ModelConfig>,
55}
56
57#[derive(Debug, Deserialize)]
59pub struct ModelConfig {
60 pub id: String,
62 pub name: Option<String>,
64 pub context_window: Option<u64>,
66 pub max_output_tokens: Option<u64>,
68 pub temperature: Option<f32>,
70 pub max_tokens: Option<u32>,
72 pub supports_images: Option<bool>,
74 pub supports_tools: Option<bool>,
76}
77
78#[derive(Debug, Deserialize, Default)]
83pub struct AppConfig {
84 pub log_level: Option<String>,
85 pub max_steps: Option<usize>,
86 pub enabled_tools: Option<Vec<String>>,
87 pub enabled_skills: Option<Vec<String>>,
88 pub context: Option<ContextConfig>,
89 pub retry: Option<RetryConfig>,
90 pub auto_approve: Option<bool>,
91 pub global_storage: Option<bool>,
92}
93
94#[derive(Debug, Deserialize)]
95pub struct ContextConfig {
96 pub max_output_lines: Option<usize>,
97 pub max_output_bytes: Option<usize>,
98 pub reserve_ratio: Option<f32>,
99 pub compression_token_threshold: Option<usize>,
102 pub compression_enabled: Option<bool>,
104}
105
106#[derive(Debug, Deserialize)]
107pub struct RetryConfig {
108 pub max_retries: Option<u32>,
109 pub initial_backoff_ms: Option<u64>,
110 pub max_backoff_ms: Option<u64>,
111}
112
113#[derive(Debug, Clone)]
122pub struct ResolvedModel {
123 pub profile_name: String,
124 pub model_id: String,
125 pub base_url: String,
126 pub api_key: String,
127 pub max_tokens: Option<u32>,
128 pub temperature: Option<f32>,
129 pub context_window: Option<u64>,
130}
131
132fn robit_home() -> Result<PathBuf, LlmError> {
138 let home = dirs::home_dir()
139 .ok_or_else(|| LlmError::ConfigError("Cannot determine home directory".to_string()))?;
140 Ok(home.join(".robit"))
141}
142
143fn resolve_env_var(value: &str) -> String {
145 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
146 std::env::var(var_name).unwrap_or_else(|_| value.to_string())
147 } else {
148 value.to_string()
149 }
150}
151
152pub fn load_config(workdir: Option<&std::path::Path>) -> Result<RobitConfig, LlmError> {
161 load_env();
163
164 let path = find_config_path(workdir)?;
165
166 let content = std::fs::read_to_string(&path)
167 .map_err(|e| LlmError::ConfigError(format!("Failed to read {}: {}", path.display(), e)))?;
168
169 let mut config: RobitConfig = toml::from_str(&content)
170 .map_err(|e| LlmError::ConfigError(format!("Failed to parse config.toml: {}", e)))?;
171
172 for provider in config.providers.values_mut() {
174 provider.api_key = resolve_env_var(&provider.api_key);
175 }
176
177 Ok(config)
178}
179
180pub fn load_env() {
182 if let Ok(robit_dir) = robit_home() {
183 let env_path = robit_dir.join(".env");
184 if env_path.exists() {
185 let _ = dotenvy::from_path(&env_path);
186 }
187 }
188}
189
190fn find_config_path(workdir: Option<&std::path::Path>) -> Result<PathBuf, LlmError> {
192 if let Some(workdir) = workdir {
194 let local_path = workdir.join(".robit").join("config.toml");
195 if local_path.exists() {
196 return Ok(local_path);
197 }
198 }
199
200 if let Ok(cwd) = std::env::current_dir() {
202 let local_path = cwd.join(".robit").join("config.toml");
203 if local_path.exists() {
204 return Ok(local_path);
205 }
206 }
207
208 let global_path = robit_home()?.join("config.toml");
210 if global_path.exists() {
211 return Ok(global_path);
212 }
213
214 Err(LlmError::ConfigError(format!(
215 "Configuration file config.toml not found.\n\
216 Please create one of the following:\n\
217 - Project-local: .robit/config.toml\n\
218 - Global: {}",
219 global_path.display()
220 )))
221}
222
223pub fn resolve_profile(
231 config: &RobitConfig,
232 provider_name: Option<&str>,
233) -> Result<ResolvedModel, LlmError> {
234 let (provider_key, model_id) = if let Some(name) = provider_name {
235 let provider = config.providers.get(name).ok_or_else(|| {
237 LlmError::ConfigError(format!(
238 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
239 name,
240 config.providers.keys().collect::<Vec<_>>()
241 ))
242 })?;
243 let first_model = provider.models.first().ok_or_else(|| {
244 LlmError::ConfigError(format!("Provider '{}' has no models defined", name))
245 })?;
246 (name.to_string(), first_model.id.clone())
247 } else if let Some(ref default_model) = config.default_model {
248 parse_default_model(default_model)?
249 } else {
250 let (key, provider) = config.providers.iter().next().ok_or_else(|| {
252 LlmError::ConfigError("No providers defined in config.toml".to_string())
253 })?;
254 let first_model = provider.models.first().ok_or_else(|| {
255 LlmError::ConfigError(format!("Provider '{}' has no models defined", key))
256 })?;
257 (key.clone(), first_model.id.clone())
258 };
259
260 let provider = config.providers.get(&provider_key).ok_or_else(|| {
261 LlmError::ConfigError(format!(
262 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
263 provider_key,
264 config.providers.keys().collect::<Vec<_>>()
265 ))
266 })?;
267
268 let model = provider
270 .models
271 .iter()
272 .find(|m| m.id == model_id)
273 .ok_or_else(|| {
274 let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
275 LlmError::ConfigError(format!(
276 "Model '{}' not found in provider '{}'. Available models: {:?}",
277 model_id, provider_key, available
278 ))
279 })?;
280
281 if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
283 return Err(LlmError::ConfigError(format!(
284 "Provider '{}' API key is not configured or the environment variable is not set",
285 provider_key
286 )));
287 }
288
289 Ok(ResolvedModel {
290 profile_name: provider_key,
291 model_id: model.id.clone(),
292 base_url: provider.base_url.clone(),
293 api_key: provider.api_key.clone(),
294 max_tokens: model.max_tokens,
295 temperature: model.temperature,
296 context_window: model.context_window,
297 })
298}
299
300fn parse_default_model(default_model: &str) -> Result<(String, String), LlmError> {
304 let parts: Vec<&str> = default_model.splitn(2, '/').collect();
305 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
306 return Err(LlmError::ConfigError(format!(
307 "Invalid default_model '{}' format, expected 'provider/model' (e.g. 'deepseek/deepseek-chat')",
308 default_model
309 )));
310 }
311 Ok((parts[0].to_string(), parts[1].to_string()))
312}
313
314#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_resolve_env_var_with_env_set() {
324 std::env::set_var("ROBIT_TEST_KEY", "test-value-123");
325 assert_eq!(resolve_env_var("${ROBIT_TEST_KEY}"), "test-value-123");
326 std::env::remove_var("ROBIT_TEST_KEY");
327 }
328
329 #[test]
330 fn test_resolve_env_var_without_env() {
331 assert_eq!(
332 resolve_env_var("${ROBIT_NONEXISTENT_KEY}"),
333 "${ROBIT_NONEXISTENT_KEY}"
334 );
335 }
336
337 #[test]
338 fn test_resolve_env_var_plain_string() {
339 assert_eq!(resolve_env_var("plain-key"), "plain-key");
340 }
341
342 #[test]
343 fn test_parse_robit_config() {
344 let toml_str = r#"
345 default_model = "deepseek/deepseek-chat"
346
347 [providers.deepseek]
348 name = "DeepSeek"
349 base_url = "https://api.deepseek.com"
350 api_key = "sk-test-key"
351
352 [[providers.deepseek.models]]
353 id = "deepseek-chat"
354 name = "DeepSeek Chat"
355 context_window = 65536
356 max_output_tokens = 8192
357 temperature = 0.0
358 max_tokens = 4096
359
360 [[providers.deepseek.models]]
361 id = "deepseek-reasoner"
362 name = "DeepSeek Reasoner"
363 context_window = 65536
364 temperature = 0.6
365
366 [providers.qwen]
367 name = "通义千问"
368 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
369 api_key = "sk-qwen-key"
370
371 [[providers.qwen.models]]
372 id = "qwen-max"
373 name = "Qwen Max"
374 context_window = 32768
375
376 [app]
377 log_level = "DEBUG"
378 max_steps = 10
379 global_storage = true
380
381 [app.context]
382 max_output_lines = 500
383 reserve_ratio = 0.2
384
385 [app.retry]
386 max_retries = 3
387 "#;
388
389 let config: RobitConfig = toml::from_str(toml_str).unwrap();
390
391 assert_eq!(
393 config.default_model.as_deref(),
394 Some("deepseek/deepseek-chat")
395 );
396
397 assert_eq!(config.providers.len(), 2);
399
400 let ds = &config.providers["deepseek"];
402 assert_eq!(ds.name.as_deref(), Some("DeepSeek"));
403 assert_eq!(ds.base_url, "https://api.deepseek.com");
404 assert_eq!(ds.api_key, "sk-test-key");
405 assert_eq!(ds.models.len(), 2);
406 assert_eq!(ds.models[0].id, "deepseek-chat");
407 assert_eq!(ds.models[0].context_window, Some(65536));
408 assert_eq!(ds.models[0].temperature, Some(0.0));
409 assert_eq!(ds.models[0].max_tokens, Some(4096));
410 assert_eq!(ds.models[1].id, "deepseek-reasoner");
411 assert_eq!(ds.models[1].temperature, Some(0.6));
412
413 let qw = &config.providers["qwen"];
415 assert_eq!(qw.name.as_deref(), Some("通义千问"));
416 assert_eq!(qw.models.len(), 1);
417 assert_eq!(qw.models[0].id, "qwen-max");
418
419 let app = config.app.as_ref().unwrap();
421 assert_eq!(app.log_level.as_deref(), Some("DEBUG"));
422 assert_eq!(app.max_steps, Some(10));
423 assert_eq!(app.global_storage, Some(true));
424 assert!(app.context.is_some());
425 assert_eq!(app.context.as_ref().unwrap().max_output_lines, Some(500));
426 assert!(app.retry.is_some());
427 assert_eq!(app.retry.as_ref().unwrap().max_retries, Some(3));
428 }
429
430 #[test]
431 fn test_parse_config_minimal() {
432 let toml_str = r#"
433 [providers.default]
434 base_url = "https://api.deepseek.com"
435 api_key = "sk-test"
436
437 [[providers.default.models]]
438 id = "deepseek-chat"
439 "#;
440
441 let config: RobitConfig = toml::from_str(toml_str).unwrap();
442 assert!(config.default_model.is_none());
443 assert!(config.app.is_none());
444 assert_eq!(config.providers.len(), 1);
445 }
446
447 #[test]
448 fn test_resolve_profile_from_default_model() {
449 let config = make_test_config();
450 let resolved = resolve_profile(&config, None).unwrap();
451 assert_eq!(resolved.profile_name, "deepseek");
452 assert_eq!(resolved.model_id, "deepseek-chat");
453 assert_eq!(resolved.base_url, "https://api.deepseek.com");
454 assert_eq!(resolved.api_key, "sk-test");
455 assert_eq!(resolved.context_window, Some(65536));
456 assert_eq!(resolved.temperature, Some(0.0));
457 assert_eq!(resolved.max_tokens, Some(4096));
458 }
459
460 #[test]
461 fn test_resolve_profile_explicit_provider() {
462 let config = make_test_config();
463 let resolved = resolve_profile(&config, Some("qwen")).unwrap();
465 assert_eq!(resolved.profile_name, "qwen");
466 assert_eq!(resolved.model_id, "qwen-max");
467 assert_eq!(
468 resolved.base_url,
469 "https://dashscope.aliyuncs.com/compatible-mode/v1"
470 );
471 }
472
473 #[test]
474 fn test_resolve_profile_first_available() {
475 let toml_str = r#"
477 [providers.deepseek]
478 base_url = "https://api.deepseek.com"
479 api_key = "sk-test"
480
481 [[providers.deepseek.models]]
482 id = "deepseek-chat"
483 "#;
484 let config: RobitConfig = toml::from_str(toml_str).unwrap();
485 let resolved = resolve_profile(&config, None).unwrap();
486 assert_eq!(resolved.profile_name, "deepseek");
487 assert_eq!(resolved.model_id, "deepseek-chat");
488 }
489
490 #[test]
491 fn test_resolve_profile_not_found() {
492 let config = make_test_config();
493 let result = resolve_profile(&config, Some("nonexistent"));
494 assert!(result.is_err());
495 }
496
497 #[test]
498 fn test_resolve_profile_model_not_found() {
499 let toml_str = r#"
500 default_model = "deepseek/nonexistent-model"
501
502 [providers.deepseek]
503 base_url = "https://api.deepseek.com"
504 api_key = "sk-test"
505
506 [[providers.deepseek.models]]
507 id = "deepseek-chat"
508 "#;
509 let config: RobitConfig = toml::from_str(toml_str).unwrap();
510 let result = resolve_profile(&config, None);
511 assert!(result.is_err());
512 }
513
514 #[test]
515 fn test_resolve_profile_invalid_default_model_format() {
516 let toml_str = r#"
517 default_model = "invalid-no-slash"
518
519 [providers.deepseek]
520 base_url = "https://api.deepseek.com"
521 api_key = "sk-test"
522
523 [[providers.deepseek.models]]
524 id = "deepseek-chat"
525 "#;
526 let config: RobitConfig = toml::from_str(toml_str).unwrap();
527 let result = resolve_profile(&config, None);
528 assert!(result.is_err());
529 assert!(result
530 .unwrap_err()
531 .to_string()
532 .contains("Invalid default_model"));
533 }
534
535 #[test]
536 fn test_resolve_profile_empty_api_key() {
537 let toml_str = r#"
538 [providers.deepseek]
539 base_url = "https://api.deepseek.com"
540 api_key = ""
541
542 [[providers.deepseek.models]]
543 id = "deepseek-chat"
544 "#;
545 let config: RobitConfig = toml::from_str(toml_str).unwrap();
546 let result = resolve_profile(&config, None);
547 assert!(result.is_err());
548 }
549
550 #[test]
551 fn test_parse_enabled_skills() {
552 let toml_str = r#"
553 default_model = "deepseek/deepseek-chat"
554
555 [providers.deepseek]
556 base_url = "https://api.deepseek.com"
557 api_key = "sk-test"
558
559 [[providers.deepseek.models]]
560 id = "deepseek-chat"
561
562 [app]
563 enabled_skills = ["code-review", "refactor"]
564 "#;
565
566 let config: RobitConfig = toml::from_str(toml_str).unwrap();
567 let app = config.app.as_ref().unwrap();
568 assert!(app.enabled_skills.is_some());
569 let skills = app.enabled_skills.as_ref().unwrap();
570 assert_eq!(skills.len(), 2);
571 assert_eq!(skills[0], "code-review");
572 assert_eq!(skills[1], "refactor");
573 }
574
575 #[test]
576 fn test_parse_enabled_tools() {
577 let toml_str = r#"
578 default_model = "deepseek/deepseek-chat"
579
580 [providers.deepseek]
581 base_url = "https://api.deepseek.com"
582 api_key = "sk-test"
583
584 [[providers.deepseek.models]]
585 id = "deepseek-chat"
586
587 [app]
588 enabled_tools = ["read", "bash", "edit", "write", "grep", "find", "ls"]
589 "#;
590
591 let config: RobitConfig = toml::from_str(toml_str).unwrap();
592 let app = config.app.as_ref().unwrap();
593 assert!(app.enabled_tools.is_some());
594 let tools = app.enabled_tools.as_ref().unwrap();
595 assert_eq!(tools.len(), 7);
596 assert_eq!(tools[0], "read");
597 assert_eq!(tools[1], "bash");
598 assert_eq!(tools[2], "edit");
599 assert_eq!(tools[3], "write");
600 assert_eq!(tools[4], "grep");
601 assert_eq!(tools[5], "find");
602 assert_eq!(tools[6], "ls");
603 }
604
605 #[test]
606 fn test_parse_auto_approve() {
607 let toml_str = r#"
608 default_model = "deepseek/deepseek-chat"
609
610 [providers.deepseek]
611 base_url = "https://api.deepseek.com"
612 api_key = "sk-test"
613
614 [[providers.deepseek.models]]
615 id = "deepseek-chat"
616
617 [app]
618 auto_approve = true
619 "#;
620
621 let config: RobitConfig = toml::from_str(toml_str).unwrap();
622 let app = config.app.as_ref().unwrap();
623 assert_eq!(app.auto_approve, Some(true));
624 }
625
626 #[test]
627 fn test_parse_auto_approve_default_none() {
628 let toml_str = r#"
629 default_model = "deepseek/deepseek-chat"
630
631 [providers.deepseek]
632 base_url = "https://api.deepseek.com"
633 api_key = "sk-test"
634
635 [[providers.deepseek.models]]
636 id = "deepseek-chat"
637
638 [app]
639 "#;
640
641 let config: RobitConfig = toml::from_str(toml_str).unwrap();
642 let app = config.app.as_ref().unwrap();
643 assert_eq!(app.auto_approve, None);
644 }
645
646 fn make_test_config() -> RobitConfig {
647 let toml_str = r#"
648 default_model = "deepseek/deepseek-chat"
649
650 [providers.deepseek]
651 base_url = "https://api.deepseek.com"
652 api_key = "sk-test"
653
654 [[providers.deepseek.models]]
655 id = "deepseek-chat"
656 context_window = 65536
657 temperature = 0.0
658 max_tokens = 4096
659
660 [providers.qwen]
661 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
662 api_key = "sk-qwen-test"
663
664 [[providers.qwen.models]]
665 id = "qwen-max"
666 context_window = 32768
667 "#;
668
669 toml::from_str(toml_str).unwrap()
670 }
671}