1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::env;
7use std::path::{Path, PathBuf};
8
9fn user_home_dir() -> Option<PathBuf> {
18 for var in ["REFLEX_HOME", "HOME", "USERPROFILE"] {
19 if let Some(val) = env::var_os(var)
20 && !val.is_empty()
21 {
22 return Some(PathBuf::from(val));
23 }
24 }
25 dirs::home_dir()
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct McpConfig {
31 #[serde(default = "default_structural_tools")]
35 pub enable_structural_tools: bool,
36}
37
38fn default_structural_tools() -> bool {
39 true
40}
41
42impl Default for McpConfig {
43 fn default() -> Self {
44 Self {
45 enable_structural_tools: true,
46 }
47 }
48}
49
50pub fn load_mcp_config() -> McpConfig {
53 let home = match user_home_dir() {
54 Some(h) => h,
55 None => return McpConfig::default(),
56 };
57
58 let config_path = home.join(".reflex").join("config.toml");
59 if !config_path.exists() {
60 return McpConfig::default();
61 }
62
63 let config_str = match std::fs::read_to_string(&config_path) {
64 Ok(s) => s,
65 Err(_) => return McpConfig::default(),
66 };
67
68 let toml_value: toml::Value = match toml::from_str(&config_str) {
69 Ok(v) => v,
70 Err(_) => return McpConfig::default(),
71 };
72
73 let known_mcp_keys = ["enable_structural_tools"];
75 if let Some(toml::Value::Table(mcp_table)) = toml_value.get("mcp") {
76 for key in mcp_table.keys() {
77 if !known_mcp_keys.contains(&key.as_str()) {
78 eprintln!(
79 "[warn] ~/.reflex/config.toml: unknown key '[mcp].{}' — ignored",
80 key
81 );
82 }
83 }
84 }
85
86 if let Some(mcp_table) = toml_value.get("mcp") {
87 mcp_table.clone().try_into().unwrap_or_default()
88 } else {
89 McpConfig::default()
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SemanticConfig {
96 #[serde(default = "default_enabled")]
98 pub enabled: bool,
99
100 #[serde(default = "default_provider")]
102 pub provider: String,
103
104 #[serde(default)]
106 pub model: Option<String>,
107
108 #[serde(default)]
110 pub auto_execute: bool,
111
112 #[serde(default = "default_agentic_enabled")]
114 pub agentic_enabled: bool,
115
116 #[serde(default = "default_max_iterations")]
118 pub max_iterations: usize,
119
120 #[serde(default = "default_max_tools")]
122 pub max_tools_per_phase: usize,
123
124 #[serde(default = "default_evaluation_enabled")]
126 pub evaluation_enabled: bool,
127
128 #[serde(default = "default_strictness")]
130 pub evaluation_strictness: f32,
131
132 #[serde(default = "default_timeout_seconds")]
134 pub timeout_seconds: u64,
135}
136
137fn default_enabled() -> bool {
138 true
139}
140
141fn default_provider() -> String {
142 "openai".to_string()
143}
144
145fn default_agentic_enabled() -> bool {
146 false }
148
149fn default_max_iterations() -> usize {
150 2
151}
152
153fn default_max_tools() -> usize {
154 5
155}
156
157fn default_evaluation_enabled() -> bool {
158 true
159}
160
161fn default_strictness() -> f32 {
162 0.5
163}
164
165fn default_timeout_seconds() -> u64 {
166 30
167}
168
169impl Default for SemanticConfig {
170 fn default() -> Self {
171 Self {
172 enabled: true,
173 provider: "openai".to_string(),
174 model: None,
175 auto_execute: false,
176 agentic_enabled: false,
177 max_iterations: 2,
178 max_tools_per_phase: 5,
179 evaluation_enabled: true,
180 evaluation_strictness: 0.5,
181 timeout_seconds: 30,
182 }
183 }
184}
185
186fn apply_env_overrides(mut config: SemanticConfig) -> SemanticConfig {
194 if let Ok(provider) = env::var("REFLEX_PROVIDER")
195 && !provider.is_empty()
196 {
197 log::debug!(
198 "Overriding provider from REFLEX_PROVIDER env var: {}",
199 provider
200 );
201 config.provider = provider;
202 }
203
204 if let Ok(model) = env::var("REFLEX_MODEL")
205 && !model.is_empty()
206 {
207 log::debug!("Overriding model from REFLEX_MODEL env var: {}", model);
208 config.model = Some(model);
209 }
210
211 if let Ok(val) = env::var("REFLEX_LLM_TIMEOUT_SECONDS") {
212 match val.trim().parse::<u64>() {
213 Ok(secs) if secs > 0 => {
214 log::debug!(
215 "Overriding LLM timeout from REFLEX_LLM_TIMEOUT_SECONDS: {}s",
216 secs
217 );
218 config.timeout_seconds = secs;
219 }
220 _ => log::warn!(
221 "REFLEX_LLM_TIMEOUT_SECONDS is invalid (must be a positive integer): {}",
222 val
223 ),
224 }
225 }
226
227 config
228}
229
230pub fn load_config(_cache_dir: &Path) -> Result<SemanticConfig> {
238 let home = match user_home_dir() {
240 Some(h) => h,
241 None => {
242 log::debug!("Could not determine home directory, using defaults");
243 return Ok(apply_env_overrides(SemanticConfig::default()));
244 }
245 };
246
247 let config_path = home.join(".reflex").join("config.toml");
248
249 if !config_path.exists() {
250 log::debug!("No ~/.reflex/config.toml found, using default semantic config");
251 return Ok(apply_env_overrides(SemanticConfig::default()));
252 }
253
254 let config_str =
255 std::fs::read_to_string(&config_path).context("Failed to read ~/.reflex/config.toml")?;
256
257 let toml_value: toml::Value =
258 toml::from_str(&config_str).context("Failed to parse ~/.reflex/config.toml")?;
259
260 let known_sections = [
262 "semantic",
263 "credentials",
264 "index",
265 "search",
266 "performance",
267 "mcp",
268 ];
269 if let Some(table) = toml_value.as_table() {
270 for key in table.keys() {
271 if !known_sections.contains(&key.as_str()) {
272 eprintln!(
273 "[warn] ~/.reflex/config.toml: unknown section '[{}]' — ignored",
274 key
275 );
276 }
277 }
278 }
279
280 let known_semantic_keys = ["provider", "model", "auto_execute"];
282 if let Some(toml::Value::Table(sem_table)) = toml_value.get("semantic") {
283 for key in sem_table.keys() {
284 if !known_semantic_keys.contains(&key.as_str()) {
285 eprintln!(
286 "[warn] ~/.reflex/config.toml: unknown key '[semantic].{}' — ignored",
287 key
288 );
289 }
290 }
291 }
292
293 if let Some(semantic_table) = toml_value.get("semantic") {
295 let config: SemanticConfig = semantic_table
296 .clone()
297 .try_into()
298 .context("Failed to parse [semantic] section in ~/.reflex/config.toml")?;
299 log::debug!(
300 "Loaded semantic config from ~/.reflex/config.toml: provider={}",
301 config.provider
302 );
303 Ok(apply_env_overrides(config))
304 } else {
305 log::debug!("No [semantic] section in ~/.reflex/config.toml, using defaults");
306 Ok(apply_env_overrides(SemanticConfig::default()))
307 }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
312struct UserConfig {
313 #[serde(default)]
314 credentials: Option<Credentials>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318struct Credentials {
319 #[serde(default)]
320 openai_api_key: Option<String>,
321 #[serde(default)]
322 anthropic_api_key: Option<String>,
323 #[serde(default)]
324 openrouter_api_key: Option<String>,
325 #[serde(default)]
326 openai_compatible_api_key: Option<String>,
327 #[serde(default)]
328 openai_model: Option<String>,
329 #[serde(default)]
330 anthropic_model: Option<String>,
331 #[serde(default)]
332 openrouter_model: Option<String>,
333 #[serde(default)]
334 openai_compatible_model: Option<String>,
335 #[serde(default)]
336 openrouter_sort: Option<String>,
337 #[serde(default)]
338 openai_compatible_base_url: Option<String>,
339}
340
341fn load_user_config() -> Result<Option<UserConfig>> {
343 let home = match user_home_dir() {
344 Some(h) => h,
345 None => {
346 log::debug!("Could not determine home directory");
347 return Ok(None);
348 }
349 };
350
351 let config_path = home.join(".reflex").join("config.toml");
352
353 if !config_path.exists() {
354 log::debug!("No user config found at ~/.reflex/config.toml");
355 return Ok(None);
356 }
357
358 let config_str =
359 std::fs::read_to_string(&config_path).context("Failed to read ~/.reflex/config.toml")?;
360
361 let config: UserConfig =
362 toml::from_str(&config_str).context("Failed to parse ~/.reflex/config.toml")?;
363
364 Ok(Some(config))
365}
366
367pub fn get_api_key(provider: &str) -> Result<String> {
375 let provider_lc = provider.to_lowercase();
376 let is_openai_compatible =
377 provider_lc == "openai-compatible" || provider_lc == "openai_compatible";
378
379 if let Ok(Some(user_config)) = load_user_config()
381 && let Some(credentials) = &user_config.credentials
382 {
383 let key = match provider_lc.as_str() {
385 "openai" => credentials.openai_api_key.as_ref(),
386 "anthropic" => credentials.anthropic_api_key.as_ref(),
387 "openrouter" => credentials.openrouter_api_key.as_ref(),
388 "openai-compatible" | "openai_compatible" => {
389 credentials.openai_compatible_api_key.as_ref()
390 }
391 _ => None,
392 };
393
394 if let Some(api_key) = key {
395 log::debug!("Using {} API key from ~/.reflex/config.toml", provider);
396 return Ok(api_key.clone());
397 }
398 }
399
400 if let Ok(key) = env::var("REFLEX_AI_API_KEY")
402 && !key.is_empty()
403 {
404 log::debug!(
405 "Using API key from REFLEX_AI_API_KEY env var for provider '{}'",
406 provider
407 );
408 return Ok(key);
409 }
410
411 let env_var = match provider_lc.as_str() {
413 "openai" => "OPENAI_API_KEY",
414 "anthropic" => "ANTHROPIC_API_KEY",
415 "openrouter" => "OPENROUTER_API_KEY",
416 "openai-compatible" | "openai_compatible" => "OPENAI_COMPATIBLE_API_KEY",
417 _ => anyhow::bail!("Unknown provider: {}", provider),
418 };
419
420 if let Ok(key) = env::var(env_var) {
421 return Ok(key);
422 }
423
424 if is_openai_compatible {
428 log::debug!(
429 "No API key configured for openai-compatible; sending requests without auth header"
430 );
431 return Ok(String::new());
432 }
433
434 Err(anyhow::anyhow!(
435 "API key not found for provider '{}'.\n\
436 \n\
437 Either:\n\
438 1. Run 'rfx llm config' to set up your API key interactively\n\
439 2. Set REFLEX_AI_API_KEY (works with any provider)\n\
440 3. Set the {} environment variable\n\
441 \n\
442 Example: export REFLEX_AI_API_KEY=sk-...",
443 provider,
444 env_var
445 ))
446}
447
448pub fn is_any_api_key_configured() -> bool {
457 if let Ok(Some(user_config)) = load_user_config()
459 && let Some(credentials) = &user_config.credentials
460 {
461 if credentials.openai_api_key.is_some()
463 || credentials.anthropic_api_key.is_some()
464 || credentials.openrouter_api_key.is_some()
465 || credentials.openai_compatible_api_key.is_some()
466 || credentials.openai_compatible_base_url.is_some()
469 {
470 log::debug!("Found provider credential in ~/.reflex/config.toml");
471 return true;
472 }
473 }
474
475 if let Ok(key) = env::var("REFLEX_AI_API_KEY")
477 && !key.is_empty()
478 {
479 log::debug!("Found REFLEX_AI_API_KEY env var");
480 return true;
481 }
482
483 let env_vars = [
485 "OPENAI_API_KEY",
486 "ANTHROPIC_API_KEY",
487 "OPENROUTER_API_KEY",
488 "OPENAI_COMPATIBLE_API_KEY",
489 "OPENAI_COMPATIBLE_BASE_URL",
490 ];
491
492 for env_var in &env_vars {
493 if env::var(env_var).is_ok() {
494 log::debug!("Found {} environment variable", env_var);
495 return true;
496 }
497 }
498
499 log::debug!("No provider credentials found in config or environment variables");
500 false
501}
502
503pub fn get_user_model(provider: &str) -> Option<String> {
508 if let Ok(Some(user_config)) = load_user_config()
509 && let Some(credentials) = &user_config.credentials
510 {
511 let model = match provider.to_lowercase().as_str() {
512 "openai" => credentials.openai_model.as_ref(),
513 "anthropic" => credentials.anthropic_model.as_ref(),
514 "openrouter" => credentials.openrouter_model.as_ref(),
515 "openai-compatible" | "openai_compatible" => {
516 credentials.openai_compatible_model.as_ref()
517 }
518 _ => None,
519 };
520
521 if let Some(model_name) = model {
522 log::debug!(
523 "Using {} model from ~/.reflex/config.toml: {}",
524 provider,
525 model_name
526 );
527 return Some(model_name.clone());
528 }
529 }
530
531 let provider_lc = provider.to_lowercase();
533 if (provider_lc == "openai-compatible" || provider_lc == "openai_compatible")
534 && let Ok(model) = env::var("OPENAI_COMPATIBLE_MODEL")
535 && !model.is_empty()
536 {
537 log::debug!(
538 "Using openai-compatible model from OPENAI_COMPATIBLE_MODEL env var: {}",
539 model
540 );
541 return Some(model);
542 }
543
544 None
545}
546
547pub fn resolve_model(config: &SemanticConfig, override_model: Option<&str>) -> Option<String> {
561 resolve_model_for(&config.provider, config.model.as_deref(), override_model)
562}
563
564pub fn resolve_model_for(
570 provider: &str,
571 project_model: Option<&str>,
572 override_model: Option<&str>,
573) -> Option<String> {
574 override_model
575 .map(String::from)
576 .or_else(|| project_model.map(String::from))
577 .or_else(|| get_user_model(provider))
578}
579
580pub fn save_user_provider(provider: &str, model: Option<&str>) -> Result<()> {
585 let home = user_home_dir().context("Cannot find home directory")?;
586 let config_dir = home.join(".reflex");
587 let config_path = config_dir.join("config.toml");
588
589 std::fs::create_dir_all(&config_dir).context("Failed to create ~/.reflex directory")?;
591
592 let mut config: toml::Value = if config_path.exists() {
594 let content = std::fs::read_to_string(&config_path)
595 .context("Failed to read ~/.reflex/config.toml")?;
596 toml::from_str(&content).context("Failed to parse ~/.reflex/config.toml")?
597 } else {
598 toml::Value::Table(toml::map::Map::new())
599 };
600
601 let credentials = config
603 .as_table_mut()
604 .context("Config root is not a table")?
605 .entry("credentials")
606 .or_insert(toml::Value::Table(toml::map::Map::new()))
607 .as_table_mut()
608 .context("[credentials] is not a table")?;
609
610 if let Some(m) = model {
612 let key = format!("{}_model", provider.to_lowercase());
613 credentials.insert(key, toml::Value::String(m.to_string()));
614 log::info!("Saved {} model: {}", provider, m);
615 }
616
617 let toml_str = toml::to_string_pretty(&config).context("Failed to serialize config to TOML")?;
619 std::fs::write(&config_path, toml_str).context("Failed to write ~/.reflex/config.toml")?;
620
621 Ok(())
622}
623
624pub fn get_provider_options(provider: &str) -> Option<HashMap<String, String>> {
629 let provider_lc = provider.to_lowercase();
630
631 match provider_lc.as_str() {
632 "openrouter" => {
633 if let Ok(Some(user_config)) = load_user_config()
634 && let Some(credentials) = &user_config.credentials
635 && let Some(sort) = &credentials.openrouter_sort
636 {
637 let mut opts = HashMap::new();
638 opts.insert("sort".to_string(), sort.clone());
639 return Some(opts);
640 }
641 None
642 }
643 "openai-compatible" | "openai_compatible" => {
644 let base_url = load_user_config()
646 .ok()
647 .flatten()
648 .and_then(|cfg| cfg.credentials)
649 .and_then(|c| c.openai_compatible_base_url)
650 .or_else(|| env::var("OPENAI_COMPATIBLE_BASE_URL").ok())
651 .filter(|s| !s.is_empty());
652
653 base_url.map(|url| {
654 let mut opts = HashMap::new();
655 opts.insert("base_url".to_string(), url);
656 opts
657 })
658 }
659 _ => None,
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use std::sync::{Mutex, MutexGuard};
667 use tempfile::TempDir;
668
669 static ENV_LOCK: Mutex<()> = Mutex::new(());
677
678 fn env_guard() -> MutexGuard<'static, ()> {
682 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
683 }
684
685 fn set_home(path: &std::path::Path) {
690 unsafe {
691 env::set_var("HOME", path);
692 if cfg!(windows) {
693 env::set_var("USERPROFILE", path);
694 }
695 }
696 }
697
698 fn unset_home() {
700 unsafe {
701 env::remove_var("HOME");
702 if cfg!(windows) {
703 env::remove_var("USERPROFILE");
704 }
705 }
706 }
707
708 #[test]
709 fn test_default_config() {
710 let config = SemanticConfig::default();
711 assert!(config.enabled);
712 assert_eq!(config.provider, "openai");
713 assert_eq!(config.model, None);
714 assert!(!config.auto_execute);
715 }
716
717 #[test]
718 fn test_load_config_no_file() {
719 let _g = env_guard();
720 let temp = TempDir::new().unwrap();
721
722 unsafe {
724 env::set_var("HOME", temp.path());
725 }
726 let config = load_config(temp.path()).unwrap();
727 unsafe {
728 env::remove_var("HOME");
729 }
730
731 assert_eq!(config.provider, "openai");
733 assert!(config.enabled);
734 }
735
736 #[test]
737 fn test_load_config_with_semantic_section() {
738 let _g = env_guard();
739 let temp = TempDir::new().unwrap();
740 let reflex_dir = temp.path().join(".reflex");
741 std::fs::create_dir_all(&reflex_dir).unwrap();
742 let config_path = reflex_dir.join("config.toml");
743
744 std::fs::write(
745 &config_path,
746 r#"
747[semantic]
748enabled = true
749provider = "anthropic"
750model = "claude-3-5-sonnet-20241022"
751auto_execute = true
752 "#,
753 )
754 .unwrap();
755
756 set_home(temp.path());
758 let config = load_config(temp.path()).unwrap();
759 unset_home();
760
761 assert!(config.enabled);
762 assert_eq!(config.provider, "anthropic");
763 assert_eq!(config.model, Some("claude-3-5-sonnet-20241022".to_string()));
764 assert!(config.auto_execute);
765 }
766
767 #[test]
768 fn test_load_config_without_semantic_section() {
769 let _g = env_guard();
770 let temp = TempDir::new().unwrap();
771 let reflex_dir = temp.path().join(".reflex");
772 std::fs::create_dir_all(&reflex_dir).unwrap();
773 let config_path = reflex_dir.join("config.toml");
774
775 std::fs::write(
776 &config_path,
777 r#"
778[index]
779languages = []
780 "#,
781 )
782 .unwrap();
783
784 unsafe {
786 env::set_var("HOME", temp.path());
787 }
788 let config = load_config(temp.path()).unwrap();
789 unsafe {
790 env::remove_var("HOME");
791 }
792
793 assert_eq!(config.provider, "openai");
795 }
796
797 #[test]
798 fn test_get_api_key_env_var() {
799 let _g = env_guard();
800 let temp = TempDir::new().unwrap();
801
802 unsafe {
804 env::set_var("HOME", temp.path());
805 env::set_var("OPENAI_API_KEY", "test-key-123");
806 }
807
808 let key = get_api_key("openai").unwrap();
809 assert_eq!(key, "test-key-123");
810
811 unsafe {
812 env::remove_var("OPENAI_API_KEY");
813 env::remove_var("HOME");
814 }
815 }
816
817 #[test]
818 fn test_get_api_key_missing() {
819 let _g = env_guard();
820 let temp = TempDir::new().unwrap();
821
822 unsafe {
824 env::set_var("HOME", temp.path());
825 env::remove_var("OPENROUTER_API_KEY");
826 env::remove_var("REFLEX_AI_API_KEY");
827 }
828
829 let result = get_api_key("openrouter");
830 assert!(result.is_err());
831 assert!(
832 result
833 .unwrap_err()
834 .to_string()
835 .contains("OPENROUTER_API_KEY")
836 );
837
838 unsafe {
839 env::remove_var("HOME");
840 }
841 }
842
843 #[test]
844 fn test_get_api_key_unknown_provider() {
845 let _g = env_guard();
846 let result = get_api_key("unknown");
847 assert!(result.is_err());
848 assert!(result.unwrap_err().to_string().contains("Unknown provider"));
849 }
850
851 #[test]
852 fn test_env_override_provider() {
853 let _g = env_guard();
854 let temp = TempDir::new().unwrap();
855
856 unsafe {
857 env::set_var("HOME", temp.path());
858 env::set_var("REFLEX_PROVIDER", "openrouter");
859 }
860
861 let config = load_config(temp.path()).unwrap();
862
863 unsafe {
864 env::remove_var("REFLEX_PROVIDER");
865 env::remove_var("HOME");
866 }
867
868 assert_eq!(config.provider, "openrouter");
869 }
870
871 #[test]
872 fn test_env_override_model() {
873 let _g = env_guard();
874 let temp = TempDir::new().unwrap();
875
876 unsafe {
877 env::set_var("HOME", temp.path());
878 env::set_var("REFLEX_MODEL", "google/gemini-2.5-flash");
879 }
880
881 let config = load_config(temp.path()).unwrap();
882
883 unsafe {
884 env::remove_var("REFLEX_MODEL");
885 env::remove_var("HOME");
886 }
887
888 assert_eq!(config.model, Some("google/gemini-2.5-flash".to_string()));
889 assert_eq!(config.provider, "openai");
891 }
892
893 #[test]
894 fn test_get_api_key_generic_env_var() {
895 let _g = env_guard();
896 let temp = TempDir::new().unwrap();
897
898 unsafe {
899 env::set_var("HOME", temp.path());
900 env::remove_var("OPENROUTER_API_KEY");
901 env::set_var("REFLEX_AI_API_KEY", "generic-key-456");
902 }
903
904 let key = get_api_key("openrouter").unwrap();
905 assert_eq!(key, "generic-key-456");
906
907 unsafe {
908 env::remove_var("REFLEX_AI_API_KEY");
909 env::remove_var("HOME");
910 }
911 }
912
913 #[test]
914 fn test_get_api_key_openai_compatible_returns_empty_when_unset() {
915 let _g = env_guard();
916 let temp = TempDir::new().unwrap();
917
918 unsafe {
919 env::set_var("HOME", temp.path());
920 env::remove_var("OPENAI_COMPATIBLE_API_KEY");
921 env::remove_var("REFLEX_AI_API_KEY");
922 }
923
924 let key = get_api_key("openai-compatible").unwrap();
926 assert_eq!(key, "");
927
928 unsafe {
929 env::remove_var("HOME");
930 }
931 }
932
933 #[test]
934 fn test_get_provider_options_openai_compatible_from_config() {
935 let _g = env_guard();
936 let temp = TempDir::new().unwrap();
937 let reflex_dir = temp.path().join(".reflex");
938 std::fs::create_dir_all(&reflex_dir).unwrap();
939 let config_path = reflex_dir.join("config.toml");
940
941 std::fs::write(
942 &config_path,
943 r#"
944[credentials]
945openai_compatible_base_url = "http://localhost:1234/v1"
946openai_compatible_model = "qwen2.5-coder"
947 "#,
948 )
949 .unwrap();
950
951 unsafe {
952 env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
953 }
954 set_home(temp.path());
955
956 let opts = get_provider_options("openai-compatible");
957 let model = get_user_model("openai-compatible");
958
959 unset_home();
960
961 let opts = opts.expect("base_url should be discovered from config");
962 assert_eq!(
963 opts.get("base_url").map(|s| s.as_str()),
964 Some("http://localhost:1234/v1")
965 );
966 assert_eq!(model, Some("qwen2.5-coder".to_string()));
967 }
968
969 #[test]
970 fn test_get_provider_options_openai_compatible_from_env() {
971 let _g = env_guard();
972 let temp = TempDir::new().unwrap();
973
974 unsafe {
975 env::set_var("HOME", temp.path());
976 env::set_var("OPENAI_COMPATIBLE_BASE_URL", "http://localhost:11434/v1");
977 }
978
979 let opts = get_provider_options("openai-compatible");
980
981 unsafe {
982 env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
983 env::remove_var("HOME");
984 }
985
986 let opts = opts.expect("base_url should be discovered from env var");
987 assert_eq!(
988 opts.get("base_url").map(|s| s.as_str()),
989 Some("http://localhost:11434/v1")
990 );
991 }
992
993 fn config_with(provider: &str, project_model: Option<&str>) -> SemanticConfig {
994 SemanticConfig {
995 provider: provider.to_string(),
996 model: project_model.map(String::from),
997 ..SemanticConfig::default()
998 }
999 }
1000
1001 #[test]
1002 fn resolve_model_prefers_override() {
1003 let config = config_with("openai", Some("gpt-4o"));
1004 let resolved = resolve_model(&config, Some("gpt-4o-2024-08-06"));
1005 assert_eq!(resolved.as_deref(), Some("gpt-4o-2024-08-06"));
1006 }
1007
1008 #[test]
1009 fn resolve_model_falls_back_to_project_config() {
1010 let config = config_with("openai", Some("gpt-4o"));
1011 let resolved = resolve_model(&config, None);
1012 assert_eq!(resolved.as_deref(), Some("gpt-4o"));
1013 }
1014
1015 #[test]
1016 fn resolve_model_returns_none_when_unset() {
1017 let _g = env_guard();
1018 let temp = TempDir::new().unwrap();
1021 unsafe {
1022 env::set_var("HOME", temp.path());
1023 }
1024
1025 let config = config_with("openai", None);
1026 let resolved = resolve_model(&config, None);
1027
1028 unsafe {
1029 env::remove_var("HOME");
1030 }
1031
1032 assert_eq!(resolved, None);
1033 }
1034
1035 #[test]
1036 fn resolve_model_for_openai_compatible_reads_user_config() {
1037 let _g = env_guard();
1038 let temp = TempDir::new().unwrap();
1042 let reflex_dir = temp.path().join(".reflex");
1043 std::fs::create_dir_all(&reflex_dir).unwrap();
1044 std::fs::write(
1045 reflex_dir.join("config.toml"),
1046 r#"
1047[credentials]
1048openai_compatible_model = "gpt-oss:20b-cloud"
1049 "#,
1050 )
1051 .unwrap();
1052
1053 set_home(temp.path());
1054
1055 let resolved = resolve_model_for("openai-compatible", None, None);
1056
1057 unset_home();
1058
1059 assert_eq!(resolved.as_deref(), Some("gpt-oss:20b-cloud"));
1060 }
1061
1062 #[test]
1063 fn resolve_model_for_override_beats_user_config() {
1064 let _g = env_guard();
1065 let temp = TempDir::new().unwrap();
1066 let reflex_dir = temp.path().join(".reflex");
1067 std::fs::create_dir_all(&reflex_dir).unwrap();
1068 std::fs::write(
1069 reflex_dir.join("config.toml"),
1070 r#"
1071[credentials]
1072openrouter_model = "anthropic/claude-opus-4"
1073 "#,
1074 )
1075 .unwrap();
1076
1077 unsafe {
1078 env::set_var("HOME", temp.path());
1079 }
1080
1081 let resolved = resolve_model_for("openrouter", None, Some("openai/gpt-4o"));
1082
1083 unsafe {
1084 env::remove_var("HOME");
1085 }
1086
1087 assert_eq!(resolved.as_deref(), Some("openai/gpt-4o"));
1088 }
1089}