1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::env;
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SemanticConfig {
12 #[serde(default = "default_enabled")]
14 pub enabled: bool,
15
16 #[serde(default = "default_provider")]
18 pub provider: String,
19
20 #[serde(default)]
22 pub model: Option<String>,
23
24 #[serde(default)]
26 pub auto_execute: bool,
27
28 #[serde(default = "default_agentic_enabled")]
30 pub agentic_enabled: bool,
31
32 #[serde(default = "default_max_iterations")]
34 pub max_iterations: usize,
35
36 #[serde(default = "default_max_tools")]
38 pub max_tools_per_phase: usize,
39
40 #[serde(default = "default_evaluation_enabled")]
42 pub evaluation_enabled: bool,
43
44 #[serde(default = "default_strictness")]
46 pub evaluation_strictness: f32,
47}
48
49fn default_enabled() -> bool {
50 true
51}
52
53fn default_provider() -> String {
54 "openai".to_string()
55}
56
57fn default_agentic_enabled() -> bool {
58 false }
60
61fn default_max_iterations() -> usize {
62 2
63}
64
65fn default_max_tools() -> usize {
66 5
67}
68
69fn default_evaluation_enabled() -> bool {
70 true
71}
72
73fn default_strictness() -> f32 {
74 0.5
75}
76
77impl Default for SemanticConfig {
78 fn default() -> Self {
79 Self {
80 enabled: true,
81 provider: "openai".to_string(),
82 model: None,
83 auto_execute: false,
84 agentic_enabled: false,
85 max_iterations: 2,
86 max_tools_per_phase: 5,
87 evaluation_enabled: true,
88 evaluation_strictness: 0.5,
89 }
90 }
91}
92
93fn apply_env_overrides(mut config: SemanticConfig) -> SemanticConfig {
101 if let Ok(provider) = env::var("REFLEX_PROVIDER") {
102 if !provider.is_empty() {
103 log::debug!("Overriding provider from REFLEX_PROVIDER env var: {}", provider);
104 config.provider = provider;
105 }
106 }
107
108 if let Ok(model) = env::var("REFLEX_MODEL") {
109 if !model.is_empty() {
110 log::debug!("Overriding model from REFLEX_MODEL env var: {}", model);
111 config.model = Some(model);
112 }
113 }
114
115 config
116}
117
118pub fn load_config(_cache_dir: &Path) -> Result<SemanticConfig> {
126 let home = match dirs::home_dir() {
128 Some(h) => h,
129 None => {
130 log::debug!("Could not determine home directory, using defaults");
131 return Ok(apply_env_overrides(SemanticConfig::default()));
132 }
133 };
134
135 let config_path = home.join(".reflex").join("config.toml");
136
137 if !config_path.exists() {
138 log::debug!("No ~/.reflex/config.toml found, using default semantic config");
139 return Ok(apply_env_overrides(SemanticConfig::default()));
140 }
141
142 let config_str = std::fs::read_to_string(&config_path)
143 .context("Failed to read ~/.reflex/config.toml")?;
144
145 let toml_value: toml::Value = toml::from_str(&config_str)
146 .context("Failed to parse ~/.reflex/config.toml")?;
147
148 if let Some(semantic_table) = toml_value.get("semantic") {
150 let config: SemanticConfig = semantic_table.clone().try_into()
151 .context("Failed to parse [semantic] section in ~/.reflex/config.toml")?;
152 log::debug!("Loaded semantic config from ~/.reflex/config.toml: provider={}", config.provider);
153 Ok(apply_env_overrides(config))
154 } else {
155 log::debug!("No [semantic] section in ~/.reflex/config.toml, using defaults");
156 Ok(apply_env_overrides(SemanticConfig::default()))
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162struct UserConfig {
163 #[serde(default)]
164 credentials: Option<Credentials>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168struct Credentials {
169 #[serde(default)]
170 openai_api_key: Option<String>,
171 #[serde(default)]
172 anthropic_api_key: Option<String>,
173 #[serde(default)]
174 openrouter_api_key: Option<String>,
175 #[serde(default)]
176 openai_compatible_api_key: Option<String>,
177 #[serde(default)]
178 openai_model: Option<String>,
179 #[serde(default)]
180 anthropic_model: Option<String>,
181 #[serde(default)]
182 openrouter_model: Option<String>,
183 #[serde(default)]
184 openai_compatible_model: Option<String>,
185 #[serde(default)]
186 openrouter_sort: Option<String>,
187 #[serde(default)]
188 openai_compatible_base_url: Option<String>,
189}
190
191fn load_user_config() -> Result<Option<UserConfig>> {
193 let home = match dirs::home_dir() {
194 Some(h) => h,
195 None => {
196 log::debug!("Could not determine home directory");
197 return Ok(None);
198 }
199 };
200
201 let config_path = home.join(".reflex").join("config.toml");
202
203 if !config_path.exists() {
204 log::debug!("No user config found at ~/.reflex/config.toml");
205 return Ok(None);
206 }
207
208 let config_str = std::fs::read_to_string(&config_path)
209 .context("Failed to read ~/.reflex/config.toml")?;
210
211 let config: UserConfig = toml::from_str(&config_str)
212 .context("Failed to parse ~/.reflex/config.toml")?;
213
214 Ok(Some(config))
215}
216
217pub fn get_api_key(provider: &str) -> Result<String> {
225 let provider_lc = provider.to_lowercase();
226 let is_openai_compatible =
227 provider_lc == "openai-compatible" || provider_lc == "openai_compatible";
228
229 if let Ok(Some(user_config)) = load_user_config() {
231 if let Some(credentials) = &user_config.credentials {
232 let key = match provider_lc.as_str() {
234 "openai" => credentials.openai_api_key.as_ref(),
235 "anthropic" => credentials.anthropic_api_key.as_ref(),
236 "openrouter" => credentials.openrouter_api_key.as_ref(),
237 "openai-compatible" | "openai_compatible" => {
238 credentials.openai_compatible_api_key.as_ref()
239 }
240 _ => None,
241 };
242
243 if let Some(api_key) = key {
244 log::debug!("Using {} API key from ~/.reflex/config.toml", provider);
245 return Ok(api_key.clone());
246 }
247 }
248 }
249
250 if let Ok(key) = env::var("REFLEX_AI_API_KEY") {
252 if !key.is_empty() {
253 log::debug!("Using API key from REFLEX_AI_API_KEY env var for provider '{}'", provider);
254 return Ok(key);
255 }
256 }
257
258 let env_var = match provider_lc.as_str() {
260 "openai" => "OPENAI_API_KEY",
261 "anthropic" => "ANTHROPIC_API_KEY",
262 "openrouter" => "OPENROUTER_API_KEY",
263 "openai-compatible" | "openai_compatible" => "OPENAI_COMPATIBLE_API_KEY",
264 _ => anyhow::bail!("Unknown provider: {}", provider),
265 };
266
267 if let Ok(key) = env::var(env_var) {
268 return Ok(key);
269 }
270
271 if is_openai_compatible {
275 log::debug!("No API key configured for openai-compatible; sending requests without auth header");
276 return Ok(String::new());
277 }
278
279 Err(anyhow::anyhow!(
280 "API key not found for provider '{}'.\n\
281 \n\
282 Either:\n\
283 1. Run 'rfx llm config' to set up your API key interactively\n\
284 2. Set REFLEX_AI_API_KEY (works with any provider)\n\
285 3. Set the {} environment variable\n\
286 \n\
287 Example: export REFLEX_AI_API_KEY=sk-...",
288 provider, env_var
289 ))
290}
291
292pub fn is_any_api_key_configured() -> bool {
301 if let Ok(Some(user_config)) = load_user_config() {
303 if let Some(credentials) = &user_config.credentials {
304 if credentials.openai_api_key.is_some()
306 || credentials.anthropic_api_key.is_some()
307 || credentials.openrouter_api_key.is_some()
308 || credentials.openai_compatible_api_key.is_some()
309 || credentials.openai_compatible_base_url.is_some()
312 {
313 log::debug!("Found provider credential in ~/.reflex/config.toml");
314 return true;
315 }
316 }
317 }
318
319 if let Ok(key) = env::var("REFLEX_AI_API_KEY") {
321 if !key.is_empty() {
322 log::debug!("Found REFLEX_AI_API_KEY env var");
323 return true;
324 }
325 }
326
327 let env_vars = [
329 "OPENAI_API_KEY",
330 "ANTHROPIC_API_KEY",
331 "OPENROUTER_API_KEY",
332 "OPENAI_COMPATIBLE_API_KEY",
333 "OPENAI_COMPATIBLE_BASE_URL",
334 ];
335
336 for env_var in &env_vars {
337 if env::var(env_var).is_ok() {
338 log::debug!("Found {} environment variable", env_var);
339 return true;
340 }
341 }
342
343 log::debug!("No provider credentials found in config or environment variables");
344 false
345}
346
347pub fn get_user_model(provider: &str) -> Option<String> {
352 if let Ok(Some(user_config)) = load_user_config() {
353 if let Some(credentials) = &user_config.credentials {
354 let model = match provider.to_lowercase().as_str() {
355 "openai" => credentials.openai_model.as_ref(),
356 "anthropic" => credentials.anthropic_model.as_ref(),
357 "openrouter" => credentials.openrouter_model.as_ref(),
358 "openai-compatible" | "openai_compatible" => {
359 credentials.openai_compatible_model.as_ref()
360 }
361 _ => None,
362 };
363
364 if let Some(model_name) = model {
365 log::debug!("Using {} model from ~/.reflex/config.toml: {}", provider, model_name);
366 return Some(model_name.clone());
367 }
368 }
369 }
370
371 None
372}
373
374pub fn resolve_model(
388 config: &SemanticConfig,
389 override_model: Option<&str>,
390) -> Option<String> {
391 resolve_model_for(&config.provider, config.model.as_deref(), override_model)
392}
393
394pub fn resolve_model_for(
400 provider: &str,
401 project_model: Option<&str>,
402 override_model: Option<&str>,
403) -> Option<String> {
404 override_model
405 .map(String::from)
406 .or_else(|| project_model.map(String::from))
407 .or_else(|| get_user_model(provider))
408}
409
410pub fn save_user_provider(provider: &str, model: Option<&str>) -> Result<()> {
415 let home = dirs::home_dir().context("Cannot find home directory")?;
416 let config_dir = home.join(".reflex");
417 let config_path = config_dir.join("config.toml");
418
419 std::fs::create_dir_all(&config_dir)
421 .context("Failed to create ~/.reflex directory")?;
422
423 let mut config: toml::Value = if config_path.exists() {
425 let content = std::fs::read_to_string(&config_path)
426 .context("Failed to read ~/.reflex/config.toml")?;
427 toml::from_str(&content)
428 .context("Failed to parse ~/.reflex/config.toml")?
429 } else {
430 toml::Value::Table(toml::map::Map::new())
431 };
432
433 let credentials = config
435 .as_table_mut()
436 .context("Config root is not a table")?
437 .entry("credentials")
438 .or_insert(toml::Value::Table(toml::map::Map::new()))
439 .as_table_mut()
440 .context("[credentials] is not a table")?;
441
442 if let Some(m) = model {
444 let key = format!("{}_model", provider.to_lowercase());
445 credentials.insert(key, toml::Value::String(m.to_string()));
446 log::info!("Saved {} model: {}", provider, m);
447 }
448
449 let toml_str = toml::to_string_pretty(&config)
451 .context("Failed to serialize config to TOML")?;
452 std::fs::write(&config_path, toml_str)
453 .context("Failed to write ~/.reflex/config.toml")?;
454
455 Ok(())
456}
457
458pub fn get_provider_options(provider: &str) -> Option<HashMap<String, String>> {
463 let provider_lc = provider.to_lowercase();
464
465 match provider_lc.as_str() {
466 "openrouter" => {
467 if let Ok(Some(user_config)) = load_user_config() {
468 if let Some(credentials) = &user_config.credentials {
469 if let Some(sort) = &credentials.openrouter_sort {
470 let mut opts = HashMap::new();
471 opts.insert("sort".to_string(), sort.clone());
472 return Some(opts);
473 }
474 }
475 }
476 None
477 }
478 "openai-compatible" | "openai_compatible" => {
479 let base_url = load_user_config()
481 .ok()
482 .flatten()
483 .and_then(|cfg| cfg.credentials)
484 .and_then(|c| c.openai_compatible_base_url)
485 .or_else(|| env::var("OPENAI_COMPATIBLE_BASE_URL").ok())
486 .filter(|s| !s.is_empty());
487
488 base_url.map(|url| {
489 let mut opts = HashMap::new();
490 opts.insert("base_url".to_string(), url);
491 opts
492 })
493 }
494 _ => None,
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use std::sync::{Mutex, MutexGuard};
502 use tempfile::TempDir;
503
504 static ENV_LOCK: Mutex<()> = Mutex::new(());
512
513 fn env_guard() -> MutexGuard<'static, ()> {
517 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
518 }
519
520 #[test]
521 fn test_default_config() {
522 let config = SemanticConfig::default();
523 assert_eq!(config.enabled, true);
524 assert_eq!(config.provider, "openai");
525 assert_eq!(config.model, None);
526 assert_eq!(config.auto_execute, false);
527 }
528
529 #[test]
530 fn test_load_config_no_file() {
531 let _g = env_guard();
532 let temp = TempDir::new().unwrap();
533
534 unsafe {
536 env::set_var("HOME", temp.path());
537 }
538 let config = load_config(temp.path()).unwrap();
539 unsafe {
540 env::remove_var("HOME");
541 }
542
543 assert_eq!(config.provider, "openai");
545 assert_eq!(config.enabled, true);
546 }
547
548 #[test]
549 fn test_load_config_with_semantic_section() {
550 let _g = env_guard();
551 let temp = TempDir::new().unwrap();
552 let reflex_dir = temp.path().join(".reflex");
553 std::fs::create_dir_all(&reflex_dir).unwrap();
554 let config_path = reflex_dir.join("config.toml");
555
556 std::fs::write(
557 &config_path,
558 r#"
559[semantic]
560enabled = true
561provider = "anthropic"
562model = "claude-3-5-sonnet-20241022"
563auto_execute = true
564 "#,
565 )
566 .unwrap();
567
568 unsafe {
570 env::set_var("HOME", temp.path());
571 }
572 let config = load_config(temp.path()).unwrap();
573 unsafe {
574 env::remove_var("HOME");
575 }
576
577 assert_eq!(config.enabled, true);
578 assert_eq!(config.provider, "anthropic");
579 assert_eq!(config.model, Some("claude-3-5-sonnet-20241022".to_string()));
580 assert_eq!(config.auto_execute, true);
581 }
582
583 #[test]
584 fn test_load_config_without_semantic_section() {
585 let _g = env_guard();
586 let temp = TempDir::new().unwrap();
587 let reflex_dir = temp.path().join(".reflex");
588 std::fs::create_dir_all(&reflex_dir).unwrap();
589 let config_path = reflex_dir.join("config.toml");
590
591 std::fs::write(
592 &config_path,
593 r#"
594[index]
595languages = []
596 "#,
597 )
598 .unwrap();
599
600 unsafe {
602 env::set_var("HOME", temp.path());
603 }
604 let config = load_config(temp.path()).unwrap();
605 unsafe {
606 env::remove_var("HOME");
607 }
608
609 assert_eq!(config.provider, "openai");
611 }
612
613 #[test]
614 fn test_get_api_key_env_var() {
615 let _g = env_guard();
616 let temp = TempDir::new().unwrap();
617
618 unsafe {
620 env::set_var("HOME", temp.path());
621 env::set_var("OPENAI_API_KEY", "test-key-123");
622 }
623
624 let key = get_api_key("openai").unwrap();
625 assert_eq!(key, "test-key-123");
626
627 unsafe {
628 env::remove_var("OPENAI_API_KEY");
629 env::remove_var("HOME");
630 }
631 }
632
633 #[test]
634 fn test_get_api_key_missing() {
635 let _g = env_guard();
636 let temp = TempDir::new().unwrap();
637
638 unsafe {
640 env::set_var("HOME", temp.path());
641 env::remove_var("OPENROUTER_API_KEY");
642 env::remove_var("REFLEX_AI_API_KEY");
643 }
644
645 let result = get_api_key("openrouter");
646 assert!(result.is_err());
647 assert!(result.unwrap_err().to_string().contains("OPENROUTER_API_KEY"));
648
649 unsafe {
650 env::remove_var("HOME");
651 }
652 }
653
654 #[test]
655 fn test_get_api_key_unknown_provider() {
656 let _g = env_guard();
657 let result = get_api_key("unknown");
658 assert!(result.is_err());
659 assert!(result.unwrap_err().to_string().contains("Unknown provider"));
660 }
661
662 #[test]
663 fn test_env_override_provider() {
664 let _g = env_guard();
665 let temp = TempDir::new().unwrap();
666
667 unsafe {
668 env::set_var("HOME", temp.path());
669 env::set_var("REFLEX_PROVIDER", "openrouter");
670 }
671
672 let config = load_config(temp.path()).unwrap();
673
674 unsafe {
675 env::remove_var("REFLEX_PROVIDER");
676 env::remove_var("HOME");
677 }
678
679 assert_eq!(config.provider, "openrouter");
680 }
681
682 #[test]
683 fn test_env_override_model() {
684 let _g = env_guard();
685 let temp = TempDir::new().unwrap();
686
687 unsafe {
688 env::set_var("HOME", temp.path());
689 env::set_var("REFLEX_MODEL", "google/gemini-2.5-flash");
690 }
691
692 let config = load_config(temp.path()).unwrap();
693
694 unsafe {
695 env::remove_var("REFLEX_MODEL");
696 env::remove_var("HOME");
697 }
698
699 assert_eq!(config.model, Some("google/gemini-2.5-flash".to_string()));
700 assert_eq!(config.provider, "openai");
702 }
703
704 #[test]
705 fn test_get_api_key_generic_env_var() {
706 let _g = env_guard();
707 let temp = TempDir::new().unwrap();
708
709 unsafe {
710 env::set_var("HOME", temp.path());
711 env::remove_var("OPENROUTER_API_KEY");
712 env::set_var("REFLEX_AI_API_KEY", "generic-key-456");
713 }
714
715 let key = get_api_key("openrouter").unwrap();
716 assert_eq!(key, "generic-key-456");
717
718 unsafe {
719 env::remove_var("REFLEX_AI_API_KEY");
720 env::remove_var("HOME");
721 }
722 }
723
724 #[test]
725 fn test_get_api_key_openai_compatible_returns_empty_when_unset() {
726 let _g = env_guard();
727 let temp = TempDir::new().unwrap();
728
729 unsafe {
730 env::set_var("HOME", temp.path());
731 env::remove_var("OPENAI_COMPATIBLE_API_KEY");
732 env::remove_var("REFLEX_AI_API_KEY");
733 }
734
735 let key = get_api_key("openai-compatible").unwrap();
737 assert_eq!(key, "");
738
739 unsafe {
740 env::remove_var("HOME");
741 }
742 }
743
744 #[test]
745 fn test_get_provider_options_openai_compatible_from_config() {
746 let _g = env_guard();
747 let temp = TempDir::new().unwrap();
748 let reflex_dir = temp.path().join(".reflex");
749 std::fs::create_dir_all(&reflex_dir).unwrap();
750 let config_path = reflex_dir.join("config.toml");
751
752 std::fs::write(
753 &config_path,
754 r#"
755[credentials]
756openai_compatible_base_url = "http://localhost:1234/v1"
757openai_compatible_model = "qwen2.5-coder"
758 "#,
759 )
760 .unwrap();
761
762 unsafe {
763 env::set_var("HOME", temp.path());
764 env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
765 }
766
767 let opts = get_provider_options("openai-compatible");
768 let model = get_user_model("openai-compatible");
769
770 unsafe {
771 env::remove_var("HOME");
772 }
773
774 let opts = opts.expect("base_url should be discovered from config");
775 assert_eq!(
776 opts.get("base_url").map(|s| s.as_str()),
777 Some("http://localhost:1234/v1")
778 );
779 assert_eq!(model, Some("qwen2.5-coder".to_string()));
780 }
781
782 #[test]
783 fn test_get_provider_options_openai_compatible_from_env() {
784 let _g = env_guard();
785 let temp = TempDir::new().unwrap();
786
787 unsafe {
788 env::set_var("HOME", temp.path());
789 env::set_var("OPENAI_COMPATIBLE_BASE_URL", "http://localhost:11434/v1");
790 }
791
792 let opts = get_provider_options("openai-compatible");
793
794 unsafe {
795 env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
796 env::remove_var("HOME");
797 }
798
799 let opts = opts.expect("base_url should be discovered from env var");
800 assert_eq!(
801 opts.get("base_url").map(|s| s.as_str()),
802 Some("http://localhost:11434/v1")
803 );
804 }
805
806 fn config_with(provider: &str, project_model: Option<&str>) -> SemanticConfig {
807 SemanticConfig {
808 provider: provider.to_string(),
809 model: project_model.map(String::from),
810 ..SemanticConfig::default()
811 }
812 }
813
814 #[test]
815 fn resolve_model_prefers_override() {
816 let config = config_with("openai", Some("gpt-4o"));
817 let resolved = resolve_model(&config, Some("gpt-4o-2024-08-06"));
818 assert_eq!(resolved.as_deref(), Some("gpt-4o-2024-08-06"));
819 }
820
821 #[test]
822 fn resolve_model_falls_back_to_project_config() {
823 let config = config_with("openai", Some("gpt-4o"));
824 let resolved = resolve_model(&config, None);
825 assert_eq!(resolved.as_deref(), Some("gpt-4o"));
826 }
827
828 #[test]
829 fn resolve_model_returns_none_when_unset() {
830 let _g = env_guard();
831 let temp = TempDir::new().unwrap();
834 unsafe {
835 env::set_var("HOME", temp.path());
836 }
837
838 let config = config_with("openai", None);
839 let resolved = resolve_model(&config, None);
840
841 unsafe {
842 env::remove_var("HOME");
843 }
844
845 assert_eq!(resolved, None);
846 }
847
848 #[test]
849 fn resolve_model_for_openai_compatible_reads_user_config() {
850 let _g = env_guard();
851 let temp = TempDir::new().unwrap();
855 let reflex_dir = temp.path().join(".reflex");
856 std::fs::create_dir_all(&reflex_dir).unwrap();
857 std::fs::write(
858 reflex_dir.join("config.toml"),
859 r#"
860[credentials]
861openai_compatible_model = "gpt-oss:20b-cloud"
862 "#,
863 )
864 .unwrap();
865
866 unsafe {
867 env::set_var("HOME", temp.path());
868 }
869
870 let resolved = resolve_model_for("openai-compatible", None, None);
871
872 unsafe {
873 env::remove_var("HOME");
874 }
875
876 assert_eq!(resolved.as_deref(), Some("gpt-oss:20b-cloud"));
877 }
878
879 #[test]
880 fn resolve_model_for_override_beats_user_config() {
881 let _g = env_guard();
882 let temp = TempDir::new().unwrap();
883 let reflex_dir = temp.path().join(".reflex");
884 std::fs::create_dir_all(&reflex_dir).unwrap();
885 std::fs::write(
886 reflex_dir.join("config.toml"),
887 r#"
888[credentials]
889openrouter_model = "anthropic/claude-opus-4"
890 "#,
891 )
892 .unwrap();
893
894 unsafe {
895 env::set_var("HOME", temp.path());
896 }
897
898 let resolved = resolve_model_for("openrouter", None, Some("openai/gpt-4o"));
899
900 unsafe {
901 env::remove_var("HOME");
902 }
903
904 assert_eq!(resolved.as_deref(), Some("openai/gpt-4o"));
905 }
906}