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 #[serde(default = "default_timeout_seconds")]
50 pub timeout_seconds: u64,
51}
52
53fn default_enabled() -> bool {
54 true
55}
56
57fn default_provider() -> String {
58 "openai".to_string()
59}
60
61fn default_agentic_enabled() -> bool {
62 false }
64
65fn default_max_iterations() -> usize {
66 2
67}
68
69fn default_max_tools() -> usize {
70 5
71}
72
73fn default_evaluation_enabled() -> bool {
74 true
75}
76
77fn default_strictness() -> f32 {
78 0.5
79}
80
81fn default_timeout_seconds() -> u64 {
82 30
83}
84
85impl Default for SemanticConfig {
86 fn default() -> Self {
87 Self {
88 enabled: true,
89 provider: "openai".to_string(),
90 model: None,
91 auto_execute: false,
92 agentic_enabled: false,
93 max_iterations: 2,
94 max_tools_per_phase: 5,
95 evaluation_enabled: true,
96 evaluation_strictness: 0.5,
97 timeout_seconds: 30,
98 }
99 }
100}
101
102fn apply_env_overrides(mut config: SemanticConfig) -> SemanticConfig {
110 if let Ok(provider) = env::var("REFLEX_PROVIDER") {
111 if !provider.is_empty() {
112 log::debug!("Overriding provider from REFLEX_PROVIDER env var: {}", provider);
113 config.provider = provider;
114 }
115 }
116
117 if let Ok(model) = env::var("REFLEX_MODEL") {
118 if !model.is_empty() {
119 log::debug!("Overriding model from REFLEX_MODEL env var: {}", model);
120 config.model = Some(model);
121 }
122 }
123
124 if let Ok(val) = env::var("REFLEX_LLM_TIMEOUT_SECONDS") {
125 match val.trim().parse::<u64>() {
126 Ok(secs) if secs > 0 => {
127 log::debug!("Overriding LLM timeout from REFLEX_LLM_TIMEOUT_SECONDS: {}s", secs);
128 config.timeout_seconds = secs;
129 }
130 _ => log::warn!("REFLEX_LLM_TIMEOUT_SECONDS is invalid (must be a positive integer): {}", val),
131 }
132 }
133
134 config
135}
136
137pub fn load_config(_cache_dir: &Path) -> Result<SemanticConfig> {
145 let home = match dirs::home_dir() {
147 Some(h) => h,
148 None => {
149 log::debug!("Could not determine home directory, using defaults");
150 return Ok(apply_env_overrides(SemanticConfig::default()));
151 }
152 };
153
154 let config_path = home.join(".reflex").join("config.toml");
155
156 if !config_path.exists() {
157 log::debug!("No ~/.reflex/config.toml found, using default semantic config");
158 return Ok(apply_env_overrides(SemanticConfig::default()));
159 }
160
161 let config_str = std::fs::read_to_string(&config_path)
162 .context("Failed to read ~/.reflex/config.toml")?;
163
164 let toml_value: toml::Value = toml::from_str(&config_str)
165 .context("Failed to parse ~/.reflex/config.toml")?;
166
167 let known_sections = ["semantic", "credentials", "index", "search", "performance"];
169 if let Some(table) = toml_value.as_table() {
170 for key in table.keys() {
171 if !known_sections.contains(&key.as_str()) {
172 eprintln!("[warn] ~/.reflex/config.toml: unknown section '[{}]' — ignored", key);
173 }
174 }
175 }
176
177 let known_semantic_keys = [
179 "provider", "model", "auto_execute",
180 ];
181 if let Some(toml::Value::Table(sem_table)) = toml_value.get("semantic") {
182 for key in sem_table.keys() {
183 if !known_semantic_keys.contains(&key.as_str()) {
184 eprintln!("[warn] ~/.reflex/config.toml: unknown key '[semantic].{}' — ignored", key);
185 }
186 }
187 }
188
189 if let Some(semantic_table) = toml_value.get("semantic") {
191 let config: SemanticConfig = semantic_table.clone().try_into()
192 .context("Failed to parse [semantic] section in ~/.reflex/config.toml")?;
193 log::debug!("Loaded semantic config from ~/.reflex/config.toml: provider={}", config.provider);
194 Ok(apply_env_overrides(config))
195 } else {
196 log::debug!("No [semantic] section in ~/.reflex/config.toml, using defaults");
197 Ok(apply_env_overrides(SemanticConfig::default()))
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203struct UserConfig {
204 #[serde(default)]
205 credentials: Option<Credentials>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209struct Credentials {
210 #[serde(default)]
211 openai_api_key: Option<String>,
212 #[serde(default)]
213 anthropic_api_key: Option<String>,
214 #[serde(default)]
215 openrouter_api_key: Option<String>,
216 #[serde(default)]
217 openai_compatible_api_key: Option<String>,
218 #[serde(default)]
219 openai_model: Option<String>,
220 #[serde(default)]
221 anthropic_model: Option<String>,
222 #[serde(default)]
223 openrouter_model: Option<String>,
224 #[serde(default)]
225 openai_compatible_model: Option<String>,
226 #[serde(default)]
227 openrouter_sort: Option<String>,
228 #[serde(default)]
229 openai_compatible_base_url: Option<String>,
230}
231
232fn load_user_config() -> Result<Option<UserConfig>> {
234 let home = match dirs::home_dir() {
235 Some(h) => h,
236 None => {
237 log::debug!("Could not determine home directory");
238 return Ok(None);
239 }
240 };
241
242 let config_path = home.join(".reflex").join("config.toml");
243
244 if !config_path.exists() {
245 log::debug!("No user config found at ~/.reflex/config.toml");
246 return Ok(None);
247 }
248
249 let config_str = std::fs::read_to_string(&config_path)
250 .context("Failed to read ~/.reflex/config.toml")?;
251
252 let config: UserConfig = toml::from_str(&config_str)
253 .context("Failed to parse ~/.reflex/config.toml")?;
254
255 Ok(Some(config))
256}
257
258pub fn get_api_key(provider: &str) -> Result<String> {
266 let provider_lc = provider.to_lowercase();
267 let is_openai_compatible =
268 provider_lc == "openai-compatible" || provider_lc == "openai_compatible";
269
270 if let Ok(Some(user_config)) = load_user_config() {
272 if let Some(credentials) = &user_config.credentials {
273 let key = match provider_lc.as_str() {
275 "openai" => credentials.openai_api_key.as_ref(),
276 "anthropic" => credentials.anthropic_api_key.as_ref(),
277 "openrouter" => credentials.openrouter_api_key.as_ref(),
278 "openai-compatible" | "openai_compatible" => {
279 credentials.openai_compatible_api_key.as_ref()
280 }
281 _ => None,
282 };
283
284 if let Some(api_key) = key {
285 log::debug!("Using {} API key from ~/.reflex/config.toml", provider);
286 return Ok(api_key.clone());
287 }
288 }
289 }
290
291 if let Ok(key) = env::var("REFLEX_AI_API_KEY") {
293 if !key.is_empty() {
294 log::debug!("Using API key from REFLEX_AI_API_KEY env var for provider '{}'", provider);
295 return Ok(key);
296 }
297 }
298
299 let env_var = match provider_lc.as_str() {
301 "openai" => "OPENAI_API_KEY",
302 "anthropic" => "ANTHROPIC_API_KEY",
303 "openrouter" => "OPENROUTER_API_KEY",
304 "openai-compatible" | "openai_compatible" => "OPENAI_COMPATIBLE_API_KEY",
305 _ => anyhow::bail!("Unknown provider: {}", provider),
306 };
307
308 if let Ok(key) = env::var(env_var) {
309 return Ok(key);
310 }
311
312 if is_openai_compatible {
316 log::debug!("No API key configured for openai-compatible; sending requests without auth header");
317 return Ok(String::new());
318 }
319
320 Err(anyhow::anyhow!(
321 "API key not found for provider '{}'.\n\
322 \n\
323 Either:\n\
324 1. Run 'rfx llm config' to set up your API key interactively\n\
325 2. Set REFLEX_AI_API_KEY (works with any provider)\n\
326 3. Set the {} environment variable\n\
327 \n\
328 Example: export REFLEX_AI_API_KEY=sk-...",
329 provider, env_var
330 ))
331}
332
333pub fn is_any_api_key_configured() -> bool {
342 if let Ok(Some(user_config)) = load_user_config() {
344 if let Some(credentials) = &user_config.credentials {
345 if credentials.openai_api_key.is_some()
347 || credentials.anthropic_api_key.is_some()
348 || credentials.openrouter_api_key.is_some()
349 || credentials.openai_compatible_api_key.is_some()
350 || credentials.openai_compatible_base_url.is_some()
353 {
354 log::debug!("Found provider credential in ~/.reflex/config.toml");
355 return true;
356 }
357 }
358 }
359
360 if let Ok(key) = env::var("REFLEX_AI_API_KEY") {
362 if !key.is_empty() {
363 log::debug!("Found REFLEX_AI_API_KEY env var");
364 return true;
365 }
366 }
367
368 let env_vars = [
370 "OPENAI_API_KEY",
371 "ANTHROPIC_API_KEY",
372 "OPENROUTER_API_KEY",
373 "OPENAI_COMPATIBLE_API_KEY",
374 "OPENAI_COMPATIBLE_BASE_URL",
375 ];
376
377 for env_var in &env_vars {
378 if env::var(env_var).is_ok() {
379 log::debug!("Found {} environment variable", env_var);
380 return true;
381 }
382 }
383
384 log::debug!("No provider credentials found in config or environment variables");
385 false
386}
387
388pub fn get_user_model(provider: &str) -> Option<String> {
393 if let Ok(Some(user_config)) = load_user_config() {
394 if let Some(credentials) = &user_config.credentials {
395 let model = match provider.to_lowercase().as_str() {
396 "openai" => credentials.openai_model.as_ref(),
397 "anthropic" => credentials.anthropic_model.as_ref(),
398 "openrouter" => credentials.openrouter_model.as_ref(),
399 "openai-compatible" | "openai_compatible" => {
400 credentials.openai_compatible_model.as_ref()
401 }
402 _ => None,
403 };
404
405 if let Some(model_name) = model {
406 log::debug!("Using {} model from ~/.reflex/config.toml: {}", provider, model_name);
407 return Some(model_name.clone());
408 }
409 }
410 }
411
412 let provider_lc = provider.to_lowercase();
414 if provider_lc == "openai-compatible" || provider_lc == "openai_compatible" {
415 if let Ok(model) = env::var("OPENAI_COMPATIBLE_MODEL") {
416 if !model.is_empty() {
417 log::debug!("Using openai-compatible model from OPENAI_COMPATIBLE_MODEL env var: {}", model);
418 return Some(model);
419 }
420 }
421 }
422
423 None
424}
425
426pub fn resolve_model(
440 config: &SemanticConfig,
441 override_model: Option<&str>,
442) -> Option<String> {
443 resolve_model_for(&config.provider, config.model.as_deref(), override_model)
444}
445
446pub fn resolve_model_for(
452 provider: &str,
453 project_model: Option<&str>,
454 override_model: Option<&str>,
455) -> Option<String> {
456 override_model
457 .map(String::from)
458 .or_else(|| project_model.map(String::from))
459 .or_else(|| get_user_model(provider))
460}
461
462pub fn save_user_provider(provider: &str, model: Option<&str>) -> Result<()> {
467 let home = dirs::home_dir().context("Cannot find home directory")?;
468 let config_dir = home.join(".reflex");
469 let config_path = config_dir.join("config.toml");
470
471 std::fs::create_dir_all(&config_dir)
473 .context("Failed to create ~/.reflex directory")?;
474
475 let mut config: toml::Value = if config_path.exists() {
477 let content = std::fs::read_to_string(&config_path)
478 .context("Failed to read ~/.reflex/config.toml")?;
479 toml::from_str(&content)
480 .context("Failed to parse ~/.reflex/config.toml")?
481 } else {
482 toml::Value::Table(toml::map::Map::new())
483 };
484
485 let credentials = config
487 .as_table_mut()
488 .context("Config root is not a table")?
489 .entry("credentials")
490 .or_insert(toml::Value::Table(toml::map::Map::new()))
491 .as_table_mut()
492 .context("[credentials] is not a table")?;
493
494 if let Some(m) = model {
496 let key = format!("{}_model", provider.to_lowercase());
497 credentials.insert(key, toml::Value::String(m.to_string()));
498 log::info!("Saved {} model: {}", provider, m);
499 }
500
501 let toml_str = toml::to_string_pretty(&config)
503 .context("Failed to serialize config to TOML")?;
504 std::fs::write(&config_path, toml_str)
505 .context("Failed to write ~/.reflex/config.toml")?;
506
507 Ok(())
508}
509
510pub fn get_provider_options(provider: &str) -> Option<HashMap<String, String>> {
515 let provider_lc = provider.to_lowercase();
516
517 match provider_lc.as_str() {
518 "openrouter" => {
519 if let Ok(Some(user_config)) = load_user_config() {
520 if let Some(credentials) = &user_config.credentials {
521 if let Some(sort) = &credentials.openrouter_sort {
522 let mut opts = HashMap::new();
523 opts.insert("sort".to_string(), sort.clone());
524 return Some(opts);
525 }
526 }
527 }
528 None
529 }
530 "openai-compatible" | "openai_compatible" => {
531 let base_url = load_user_config()
533 .ok()
534 .flatten()
535 .and_then(|cfg| cfg.credentials)
536 .and_then(|c| c.openai_compatible_base_url)
537 .or_else(|| env::var("OPENAI_COMPATIBLE_BASE_URL").ok())
538 .filter(|s| !s.is_empty());
539
540 base_url.map(|url| {
541 let mut opts = HashMap::new();
542 opts.insert("base_url".to_string(), url);
543 opts
544 })
545 }
546 _ => None,
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use std::sync::{Mutex, MutexGuard};
554 use tempfile::TempDir;
555
556 static ENV_LOCK: Mutex<()> = Mutex::new(());
564
565 fn env_guard() -> MutexGuard<'static, ()> {
569 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
570 }
571
572 #[test]
573 fn test_default_config() {
574 let config = SemanticConfig::default();
575 assert_eq!(config.enabled, true);
576 assert_eq!(config.provider, "openai");
577 assert_eq!(config.model, None);
578 assert_eq!(config.auto_execute, false);
579 }
580
581 #[test]
582 fn test_load_config_no_file() {
583 let _g = env_guard();
584 let temp = TempDir::new().unwrap();
585
586 unsafe {
588 env::set_var("HOME", temp.path());
589 }
590 let config = load_config(temp.path()).unwrap();
591 unsafe {
592 env::remove_var("HOME");
593 }
594
595 assert_eq!(config.provider, "openai");
597 assert_eq!(config.enabled, true);
598 }
599
600 #[test]
601 fn test_load_config_with_semantic_section() {
602 let _g = env_guard();
603 let temp = TempDir::new().unwrap();
604 let reflex_dir = temp.path().join(".reflex");
605 std::fs::create_dir_all(&reflex_dir).unwrap();
606 let config_path = reflex_dir.join("config.toml");
607
608 std::fs::write(
609 &config_path,
610 r#"
611[semantic]
612enabled = true
613provider = "anthropic"
614model = "claude-3-5-sonnet-20241022"
615auto_execute = true
616 "#,
617 )
618 .unwrap();
619
620 unsafe {
622 env::set_var("HOME", temp.path());
623 }
624 let config = load_config(temp.path()).unwrap();
625 unsafe {
626 env::remove_var("HOME");
627 }
628
629 assert_eq!(config.enabled, true);
630 assert_eq!(config.provider, "anthropic");
631 assert_eq!(config.model, Some("claude-3-5-sonnet-20241022".to_string()));
632 assert_eq!(config.auto_execute, true);
633 }
634
635 #[test]
636 fn test_load_config_without_semantic_section() {
637 let _g = env_guard();
638 let temp = TempDir::new().unwrap();
639 let reflex_dir = temp.path().join(".reflex");
640 std::fs::create_dir_all(&reflex_dir).unwrap();
641 let config_path = reflex_dir.join("config.toml");
642
643 std::fs::write(
644 &config_path,
645 r#"
646[index]
647languages = []
648 "#,
649 )
650 .unwrap();
651
652 unsafe {
654 env::set_var("HOME", temp.path());
655 }
656 let config = load_config(temp.path()).unwrap();
657 unsafe {
658 env::remove_var("HOME");
659 }
660
661 assert_eq!(config.provider, "openai");
663 }
664
665 #[test]
666 fn test_get_api_key_env_var() {
667 let _g = env_guard();
668 let temp = TempDir::new().unwrap();
669
670 unsafe {
672 env::set_var("HOME", temp.path());
673 env::set_var("OPENAI_API_KEY", "test-key-123");
674 }
675
676 let key = get_api_key("openai").unwrap();
677 assert_eq!(key, "test-key-123");
678
679 unsafe {
680 env::remove_var("OPENAI_API_KEY");
681 env::remove_var("HOME");
682 }
683 }
684
685 #[test]
686 fn test_get_api_key_missing() {
687 let _g = env_guard();
688 let temp = TempDir::new().unwrap();
689
690 unsafe {
692 env::set_var("HOME", temp.path());
693 env::remove_var("OPENROUTER_API_KEY");
694 env::remove_var("REFLEX_AI_API_KEY");
695 }
696
697 let result = get_api_key("openrouter");
698 assert!(result.is_err());
699 assert!(result.unwrap_err().to_string().contains("OPENROUTER_API_KEY"));
700
701 unsafe {
702 env::remove_var("HOME");
703 }
704 }
705
706 #[test]
707 fn test_get_api_key_unknown_provider() {
708 let _g = env_guard();
709 let result = get_api_key("unknown");
710 assert!(result.is_err());
711 assert!(result.unwrap_err().to_string().contains("Unknown provider"));
712 }
713
714 #[test]
715 fn test_env_override_provider() {
716 let _g = env_guard();
717 let temp = TempDir::new().unwrap();
718
719 unsafe {
720 env::set_var("HOME", temp.path());
721 env::set_var("REFLEX_PROVIDER", "openrouter");
722 }
723
724 let config = load_config(temp.path()).unwrap();
725
726 unsafe {
727 env::remove_var("REFLEX_PROVIDER");
728 env::remove_var("HOME");
729 }
730
731 assert_eq!(config.provider, "openrouter");
732 }
733
734 #[test]
735 fn test_env_override_model() {
736 let _g = env_guard();
737 let temp = TempDir::new().unwrap();
738
739 unsafe {
740 env::set_var("HOME", temp.path());
741 env::set_var("REFLEX_MODEL", "google/gemini-2.5-flash");
742 }
743
744 let config = load_config(temp.path()).unwrap();
745
746 unsafe {
747 env::remove_var("REFLEX_MODEL");
748 env::remove_var("HOME");
749 }
750
751 assert_eq!(config.model, Some("google/gemini-2.5-flash".to_string()));
752 assert_eq!(config.provider, "openai");
754 }
755
756 #[test]
757 fn test_get_api_key_generic_env_var() {
758 let _g = env_guard();
759 let temp = TempDir::new().unwrap();
760
761 unsafe {
762 env::set_var("HOME", temp.path());
763 env::remove_var("OPENROUTER_API_KEY");
764 env::set_var("REFLEX_AI_API_KEY", "generic-key-456");
765 }
766
767 let key = get_api_key("openrouter").unwrap();
768 assert_eq!(key, "generic-key-456");
769
770 unsafe {
771 env::remove_var("REFLEX_AI_API_KEY");
772 env::remove_var("HOME");
773 }
774 }
775
776 #[test]
777 fn test_get_api_key_openai_compatible_returns_empty_when_unset() {
778 let _g = env_guard();
779 let temp = TempDir::new().unwrap();
780
781 unsafe {
782 env::set_var("HOME", temp.path());
783 env::remove_var("OPENAI_COMPATIBLE_API_KEY");
784 env::remove_var("REFLEX_AI_API_KEY");
785 }
786
787 let key = get_api_key("openai-compatible").unwrap();
789 assert_eq!(key, "");
790
791 unsafe {
792 env::remove_var("HOME");
793 }
794 }
795
796 #[test]
797 fn test_get_provider_options_openai_compatible_from_config() {
798 let _g = env_guard();
799 let temp = TempDir::new().unwrap();
800 let reflex_dir = temp.path().join(".reflex");
801 std::fs::create_dir_all(&reflex_dir).unwrap();
802 let config_path = reflex_dir.join("config.toml");
803
804 std::fs::write(
805 &config_path,
806 r#"
807[credentials]
808openai_compatible_base_url = "http://localhost:1234/v1"
809openai_compatible_model = "qwen2.5-coder"
810 "#,
811 )
812 .unwrap();
813
814 unsafe {
815 env::set_var("HOME", temp.path());
816 env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
817 }
818
819 let opts = get_provider_options("openai-compatible");
820 let model = get_user_model("openai-compatible");
821
822 unsafe {
823 env::remove_var("HOME");
824 }
825
826 let opts = opts.expect("base_url should be discovered from config");
827 assert_eq!(
828 opts.get("base_url").map(|s| s.as_str()),
829 Some("http://localhost:1234/v1")
830 );
831 assert_eq!(model, Some("qwen2.5-coder".to_string()));
832 }
833
834 #[test]
835 fn test_get_provider_options_openai_compatible_from_env() {
836 let _g = env_guard();
837 let temp = TempDir::new().unwrap();
838
839 unsafe {
840 env::set_var("HOME", temp.path());
841 env::set_var("OPENAI_COMPATIBLE_BASE_URL", "http://localhost:11434/v1");
842 }
843
844 let opts = get_provider_options("openai-compatible");
845
846 unsafe {
847 env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
848 env::remove_var("HOME");
849 }
850
851 let opts = opts.expect("base_url should be discovered from env var");
852 assert_eq!(
853 opts.get("base_url").map(|s| s.as_str()),
854 Some("http://localhost:11434/v1")
855 );
856 }
857
858 fn config_with(provider: &str, project_model: Option<&str>) -> SemanticConfig {
859 SemanticConfig {
860 provider: provider.to_string(),
861 model: project_model.map(String::from),
862 ..SemanticConfig::default()
863 }
864 }
865
866 #[test]
867 fn resolve_model_prefers_override() {
868 let config = config_with("openai", Some("gpt-4o"));
869 let resolved = resolve_model(&config, Some("gpt-4o-2024-08-06"));
870 assert_eq!(resolved.as_deref(), Some("gpt-4o-2024-08-06"));
871 }
872
873 #[test]
874 fn resolve_model_falls_back_to_project_config() {
875 let config = config_with("openai", Some("gpt-4o"));
876 let resolved = resolve_model(&config, None);
877 assert_eq!(resolved.as_deref(), Some("gpt-4o"));
878 }
879
880 #[test]
881 fn resolve_model_returns_none_when_unset() {
882 let _g = env_guard();
883 let temp = TempDir::new().unwrap();
886 unsafe {
887 env::set_var("HOME", temp.path());
888 }
889
890 let config = config_with("openai", None);
891 let resolved = resolve_model(&config, None);
892
893 unsafe {
894 env::remove_var("HOME");
895 }
896
897 assert_eq!(resolved, None);
898 }
899
900 #[test]
901 fn resolve_model_for_openai_compatible_reads_user_config() {
902 let _g = env_guard();
903 let temp = TempDir::new().unwrap();
907 let reflex_dir = temp.path().join(".reflex");
908 std::fs::create_dir_all(&reflex_dir).unwrap();
909 std::fs::write(
910 reflex_dir.join("config.toml"),
911 r#"
912[credentials]
913openai_compatible_model = "gpt-oss:20b-cloud"
914 "#,
915 )
916 .unwrap();
917
918 unsafe {
919 env::set_var("HOME", temp.path());
920 }
921
922 let resolved = resolve_model_for("openai-compatible", None, None);
923
924 unsafe {
925 env::remove_var("HOME");
926 }
927
928 assert_eq!(resolved.as_deref(), Some("gpt-oss:20b-cloud"));
929 }
930
931 #[test]
932 fn resolve_model_for_override_beats_user_config() {
933 let _g = env_guard();
934 let temp = TempDir::new().unwrap();
935 let reflex_dir = temp.path().join(".reflex");
936 std::fs::create_dir_all(&reflex_dir).unwrap();
937 std::fs::write(
938 reflex_dir.join("config.toml"),
939 r#"
940[credentials]
941openrouter_model = "anthropic/claude-opus-4"
942 "#,
943 )
944 .unwrap();
945
946 unsafe {
947 env::set_var("HOME", temp.path());
948 }
949
950 let resolved = resolve_model_for("openrouter", None, Some("openai/gpt-4o"));
951
952 unsafe {
953 env::remove_var("HOME");
954 }
955
956 assert_eq!(resolved.as_deref(), Some("openai/gpt-4o"));
957 }
958}