Skip to main content

reflex/semantic/
config.rs

1//! Configuration for semantic query feature
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::env;
7use std::path::{Path, PathBuf};
8
9/// Locate the user's home directory.
10///
11/// `dirs::home_dir()` queries `SHGetKnownFolderPath(FOLDERID_Profile)` on
12/// Windows and therefore ignores `HOME` / `USERPROFILE` env vars. That makes
13/// it impossible to redirect to a temp directory in tests. Honour those env
14/// vars (and `REFLEX_HOME` for an explicit override) before falling back to
15/// the OS-native lookup so test code can point us at a temp directory on
16/// every platform.
17fn 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/// Semantic query configuration
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SemanticConfig {
31    /// Enable semantic query feature
32    #[serde(default = "default_enabled")]
33    pub enabled: bool,
34
35    /// LLM provider (openai, anthropic, openrouter)
36    #[serde(default = "default_provider")]
37    pub provider: String,
38
39    /// Optional model override (uses provider default if None)
40    #[serde(default)]
41    pub model: Option<String>,
42
43    /// Auto-execute generated commands without confirmation
44    #[serde(default)]
45    pub auto_execute: bool,
46
47    /// Enable agentic mode (multi-step reasoning with context gathering)
48    #[serde(default = "default_agentic_enabled")]
49    pub agentic_enabled: bool,
50
51    /// Maximum iterations for query refinement in agentic mode
52    #[serde(default = "default_max_iterations")]
53    pub max_iterations: usize,
54
55    /// Maximum tool calls per context gathering phase
56    #[serde(default = "default_max_tools")]
57    pub max_tools_per_phase: usize,
58
59    /// Enable result evaluation in agentic mode
60    #[serde(default = "default_evaluation_enabled")]
61    pub evaluation_enabled: bool,
62
63    /// Evaluation strictness (0.0-1.0, higher is stricter)
64    #[serde(default = "default_strictness")]
65    pub evaluation_strictness: f32,
66
67    /// LLM request timeout in seconds (default: 30)
68    #[serde(default = "default_timeout_seconds")]
69    pub timeout_seconds: u64,
70}
71
72fn default_enabled() -> bool {
73    true
74}
75
76fn default_provider() -> String {
77    "openai".to_string()
78}
79
80fn default_agentic_enabled() -> bool {
81    false // Disabled by default, opt-in for experimental feature
82}
83
84fn default_max_iterations() -> usize {
85    2
86}
87
88fn default_max_tools() -> usize {
89    5
90}
91
92fn default_evaluation_enabled() -> bool {
93    true
94}
95
96fn default_strictness() -> f32 {
97    0.5
98}
99
100fn default_timeout_seconds() -> u64 {
101    30
102}
103
104impl Default for SemanticConfig {
105    fn default() -> Self {
106        Self {
107            enabled: true,
108            provider: "openai".to_string(),
109            model: None,
110            auto_execute: false,
111            agentic_enabled: false,
112            max_iterations: 2,
113            max_tools_per_phase: 5,
114            evaluation_enabled: true,
115            evaluation_strictness: 0.5,
116            timeout_seconds: 30,
117        }
118    }
119}
120
121/// Apply environment variable overrides to a semantic config.
122///
123/// Supports:
124/// - `REFLEX_PROVIDER` — overrides the provider (e.g., "openrouter", "anthropic", "openai")
125/// - `REFLEX_MODEL` — overrides the model
126///
127/// This enables CI/headless usage where there's no ~/.reflex/config.toml.
128fn apply_env_overrides(mut config: SemanticConfig) -> SemanticConfig {
129    if let Ok(provider) = env::var("REFLEX_PROVIDER")
130        && !provider.is_empty()
131    {
132        log::debug!(
133            "Overriding provider from REFLEX_PROVIDER env var: {}",
134            provider
135        );
136        config.provider = provider;
137    }
138
139    if let Ok(model) = env::var("REFLEX_MODEL")
140        && !model.is_empty()
141    {
142        log::debug!("Overriding model from REFLEX_MODEL env var: {}", model);
143        config.model = Some(model);
144    }
145
146    if let Ok(val) = env::var("REFLEX_LLM_TIMEOUT_SECONDS") {
147        match val.trim().parse::<u64>() {
148            Ok(secs) if secs > 0 => {
149                log::debug!(
150                    "Overriding LLM timeout from REFLEX_LLM_TIMEOUT_SECONDS: {}s",
151                    secs
152                );
153                config.timeout_seconds = secs;
154            }
155            _ => log::warn!(
156                "REFLEX_LLM_TIMEOUT_SECONDS is invalid (must be a positive integer): {}",
157                val
158            ),
159        }
160    }
161
162    config
163}
164
165/// Load semantic config from ~/.reflex/config.toml
166///
167/// Semantic configuration is ALWAYS user-level (not project-level).
168/// Falls back to defaults if file doesn't exist or [semantic] section is missing.
169/// Environment variables `REFLEX_PROVIDER` and `REFLEX_MODEL` override config file values.
170///
171/// Note: The cache_dir parameter is ignored - kept for API compatibility but will be removed in future.
172pub fn load_config(_cache_dir: &Path) -> Result<SemanticConfig> {
173    // Semantic config is always in user home directory, not project directory
174    let home = match user_home_dir() {
175        Some(h) => h,
176        None => {
177            log::debug!("Could not determine home directory, using defaults");
178            return Ok(apply_env_overrides(SemanticConfig::default()));
179        }
180    };
181
182    let config_path = home.join(".reflex").join("config.toml");
183
184    if !config_path.exists() {
185        log::debug!("No ~/.reflex/config.toml found, using default semantic config");
186        return Ok(apply_env_overrides(SemanticConfig::default()));
187    }
188
189    let config_str =
190        std::fs::read_to_string(&config_path).context("Failed to read ~/.reflex/config.toml")?;
191
192    let toml_value: toml::Value =
193        toml::from_str(&config_str).context("Failed to parse ~/.reflex/config.toml")?;
194
195    // REF-90: Warn about unknown top-level sections
196    let known_sections = ["semantic", "credentials", "index", "search", "performance"];
197    if let Some(table) = toml_value.as_table() {
198        for key in table.keys() {
199            if !known_sections.contains(&key.as_str()) {
200                eprintln!(
201                    "[warn] ~/.reflex/config.toml: unknown section '[{}]' — ignored",
202                    key
203                );
204            }
205        }
206    }
207
208    // REF-90: Warn about unknown keys within the [semantic] section
209    let known_semantic_keys = ["provider", "model", "auto_execute"];
210    if let Some(toml::Value::Table(sem_table)) = toml_value.get("semantic") {
211        for key in sem_table.keys() {
212            if !known_semantic_keys.contains(&key.as_str()) {
213                eprintln!(
214                    "[warn] ~/.reflex/config.toml: unknown key '[semantic].{}' — ignored",
215                    key
216                );
217            }
218        }
219    }
220
221    // Extract [semantic] section
222    if let Some(semantic_table) = toml_value.get("semantic") {
223        let config: SemanticConfig = semantic_table
224            .clone()
225            .try_into()
226            .context("Failed to parse [semantic] section in ~/.reflex/config.toml")?;
227        log::debug!(
228            "Loaded semantic config from ~/.reflex/config.toml: provider={}",
229            config.provider
230        );
231        Ok(apply_env_overrides(config))
232    } else {
233        log::debug!("No [semantic] section in ~/.reflex/config.toml, using defaults");
234        Ok(apply_env_overrides(SemanticConfig::default()))
235    }
236}
237
238/// User configuration structure for ~/.reflex/config.toml
239#[derive(Debug, Clone, Serialize, Deserialize)]
240struct UserConfig {
241    #[serde(default)]
242    credentials: Option<Credentials>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246struct Credentials {
247    #[serde(default)]
248    openai_api_key: Option<String>,
249    #[serde(default)]
250    anthropic_api_key: Option<String>,
251    #[serde(default)]
252    openrouter_api_key: Option<String>,
253    #[serde(default)]
254    openai_compatible_api_key: Option<String>,
255    #[serde(default)]
256    openai_model: Option<String>,
257    #[serde(default)]
258    anthropic_model: Option<String>,
259    #[serde(default)]
260    openrouter_model: Option<String>,
261    #[serde(default)]
262    openai_compatible_model: Option<String>,
263    #[serde(default)]
264    openrouter_sort: Option<String>,
265    #[serde(default)]
266    openai_compatible_base_url: Option<String>,
267}
268
269/// Load user configuration from ~/.reflex/config.toml
270fn load_user_config() -> Result<Option<UserConfig>> {
271    let home = match user_home_dir() {
272        Some(h) => h,
273        None => {
274            log::debug!("Could not determine home directory");
275            return Ok(None);
276        }
277    };
278
279    let config_path = home.join(".reflex").join("config.toml");
280
281    if !config_path.exists() {
282        log::debug!("No user config found at ~/.reflex/config.toml");
283        return Ok(None);
284    }
285
286    let config_str =
287        std::fs::read_to_string(&config_path).context("Failed to read ~/.reflex/config.toml")?;
288
289    let config: UserConfig =
290        toml::from_str(&config_str).context("Failed to parse ~/.reflex/config.toml")?;
291
292    Ok(Some(config))
293}
294
295/// Get API key for a provider
296///
297/// Checks in priority order:
298/// 1. ~/.reflex/config.toml (user config file)
299/// 2. REFLEX_AI_API_KEY environment variable (generic, provider-agnostic)
300/// 3. {PROVIDER}_API_KEY environment variable (e.g., OPENAI_API_KEY)
301/// 4. Error if not found
302pub fn get_api_key(provider: &str) -> Result<String> {
303    let provider_lc = provider.to_lowercase();
304    let is_openai_compatible =
305        provider_lc == "openai-compatible" || provider_lc == "openai_compatible";
306
307    // First check user config file
308    if let Ok(Some(user_config)) = load_user_config()
309        && let Some(credentials) = &user_config.credentials
310    {
311        // Get the appropriate key based on provider
312        let key = match provider_lc.as_str() {
313            "openai" => credentials.openai_api_key.as_ref(),
314            "anthropic" => credentials.anthropic_api_key.as_ref(),
315            "openrouter" => credentials.openrouter_api_key.as_ref(),
316            "openai-compatible" | "openai_compatible" => {
317                credentials.openai_compatible_api_key.as_ref()
318            }
319            _ => None,
320        };
321
322        if let Some(api_key) = key {
323            log::debug!("Using {} API key from ~/.reflex/config.toml", provider);
324            return Ok(api_key.clone());
325        }
326    }
327
328    // Check generic REFLEX_AI_API_KEY env var (provider-agnostic, useful for CI)
329    if let Ok(key) = env::var("REFLEX_AI_API_KEY")
330        && !key.is_empty()
331    {
332        log::debug!(
333            "Using API key from REFLEX_AI_API_KEY env var for provider '{}'",
334            provider
335        );
336        return Ok(key);
337    }
338
339    // Fall back to provider-specific environment variables
340    let env_var = match provider_lc.as_str() {
341        "openai" => "OPENAI_API_KEY",
342        "anthropic" => "ANTHROPIC_API_KEY",
343        "openrouter" => "OPENROUTER_API_KEY",
344        "openai-compatible" | "openai_compatible" => "OPENAI_COMPATIBLE_API_KEY",
345        _ => anyhow::bail!("Unknown provider: {}", provider),
346    };
347
348    if let Ok(key) = env::var(env_var) {
349        return Ok(key);
350    }
351
352    // openai-compatible can run keyless against local servers — return empty
353    // string instead of erroring. Caller is responsible for ensuring base_url
354    // is configured separately.
355    if is_openai_compatible {
356        log::debug!(
357            "No API key configured for openai-compatible; sending requests without auth header"
358        );
359        return Ok(String::new());
360    }
361
362    Err(anyhow::anyhow!(
363        "API key not found for provider '{}'.\n\
364         \n\
365         Either:\n\
366         1. Run 'rfx llm config' to set up your API key interactively\n\
367         2. Set REFLEX_AI_API_KEY (works with any provider)\n\
368         3. Set the {} environment variable\n\
369         \n\
370         Example: export REFLEX_AI_API_KEY=sk-...",
371        provider,
372        env_var
373    ))
374}
375
376/// Check if any API key is configured for any supported provider
377///
378/// Checks in priority order:
379/// 1. ~/.reflex/config.toml (credentials section)
380/// 2. REFLEX_AI_API_KEY environment variable (generic)
381/// 3. Provider-specific environment variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY)
382///
383/// Returns true if at least one API key is found for any provider.
384pub fn is_any_api_key_configured() -> bool {
385    // Check user config file first
386    if let Ok(Some(user_config)) = load_user_config()
387        && let Some(credentials) = &user_config.credentials
388    {
389        // Check if any provider has an API key in the config file
390        if credentials.openai_api_key.is_some()
391                || credentials.anthropic_api_key.is_some()
392                || credentials.openrouter_api_key.is_some()
393                || credentials.openai_compatible_api_key.is_some()
394                // openai-compatible can run keyless — a configured base_url
395                // counts as "configured" even without an API key.
396                || credentials.openai_compatible_base_url.is_some()
397        {
398            log::debug!("Found provider credential in ~/.reflex/config.toml");
399            return true;
400        }
401    }
402
403    // Check generic REFLEX_AI_API_KEY
404    if let Ok(key) = env::var("REFLEX_AI_API_KEY")
405        && !key.is_empty()
406    {
407        log::debug!("Found REFLEX_AI_API_KEY env var");
408        return true;
409    }
410
411    // Check provider-specific environment variables
412    let env_vars = [
413        "OPENAI_API_KEY",
414        "ANTHROPIC_API_KEY",
415        "OPENROUTER_API_KEY",
416        "OPENAI_COMPATIBLE_API_KEY",
417        "OPENAI_COMPATIBLE_BASE_URL",
418    ];
419
420    for env_var in &env_vars {
421        if env::var(env_var).is_ok() {
422            log::debug!("Found {} environment variable", env_var);
423            return true;
424        }
425    }
426
427    log::debug!("No provider credentials found in config or environment variables");
428    false
429}
430
431/// Get the preferred model for a provider from user config
432///
433/// Returns None if no model is configured for this provider.
434/// The caller should use provider defaults if None is returned.
435pub fn get_user_model(provider: &str) -> Option<String> {
436    if let Ok(Some(user_config)) = load_user_config()
437        && let Some(credentials) = &user_config.credentials
438    {
439        let model = match provider.to_lowercase().as_str() {
440            "openai" => credentials.openai_model.as_ref(),
441            "anthropic" => credentials.anthropic_model.as_ref(),
442            "openrouter" => credentials.openrouter_model.as_ref(),
443            "openai-compatible" | "openai_compatible" => {
444                credentials.openai_compatible_model.as_ref()
445            }
446            _ => None,
447        };
448
449        if let Some(model_name) = model {
450            log::debug!(
451                "Using {} model from ~/.reflex/config.toml: {}",
452                provider,
453                model_name
454            );
455            return Some(model_name.clone());
456        }
457    }
458
459    // Fall back to OPENAI_COMPATIBLE_MODEL env var for the openai-compatible provider
460    let provider_lc = provider.to_lowercase();
461    if (provider_lc == "openai-compatible" || provider_lc == "openai_compatible")
462        && let Ok(model) = env::var("OPENAI_COMPATIBLE_MODEL")
463        && !model.is_empty()
464    {
465        log::debug!(
466            "Using openai-compatible model from OPENAI_COMPATIBLE_MODEL env var: {}",
467            model
468        );
469        return Some(model);
470    }
471
472    None
473}
474
475/// Resolve the effective model for an LLM call.
476///
477/// Precedence:
478///   1. Explicit override (CLI flag, `--model`, `/model` command arg, etc.)
479///   2. `[semantic] model` from `~/.reflex/config.toml` (also receives
480///      `REFLEX_MODEL` env var via `apply_env_overrides`)
481///   3. `[credentials] {provider}_model` via `get_user_model`
482///   4. `None` — caller's provider constructor applies its own default
483///
484/// Returning `None` lets each provider keep its own built-in default
485/// (e.g. OpenAI → `gpt-4o-mini`). The openai-compatible provider has no
486/// default and will error if `None` is returned, which is the correct
487/// behavior for self-hosted endpoints — the fix is to configure a model.
488pub fn resolve_model(config: &SemanticConfig, override_model: Option<&str>) -> Option<String> {
489    resolve_model_for(&config.provider, config.model.as_deref(), override_model)
490}
491
492/// Same as [`resolve_model`] but takes provider/project-model separately.
493///
494/// Use when the caller has resolved a provider that may not match
495/// `semantic_config.provider` — e.g. `pulse/narrate.rs` auto-detects a
496/// provider with a working API key when the configured one has none.
497pub fn resolve_model_for(
498    provider: &str,
499    project_model: Option<&str>,
500    override_model: Option<&str>,
501) -> Option<String> {
502    override_model
503        .map(String::from)
504        .or_else(|| project_model.map(String::from))
505        .or_else(|| get_user_model(provider))
506}
507
508/// Save user's provider/model preference to ~/.reflex/config.toml
509///
510/// Updates the [credentials] section with the new model for the specified provider.
511/// Creates the config file and directory if they don't exist.
512pub fn save_user_provider(provider: &str, model: Option<&str>) -> Result<()> {
513    let home = user_home_dir().context("Cannot find home directory")?;
514    let config_dir = home.join(".reflex");
515    let config_path = config_dir.join("config.toml");
516
517    // Create directory if needed
518    std::fs::create_dir_all(&config_dir).context("Failed to create ~/.reflex directory")?;
519
520    // Read existing config or create empty
521    let mut config: toml::Value = if config_path.exists() {
522        let content = std::fs::read_to_string(&config_path)
523            .context("Failed to read ~/.reflex/config.toml")?;
524        toml::from_str(&content).context("Failed to parse ~/.reflex/config.toml")?
525    } else {
526        toml::Value::Table(toml::map::Map::new())
527    };
528
529    // Ensure [credentials] section exists
530    let credentials = config
531        .as_table_mut()
532        .context("Config root is not a table")?
533        .entry("credentials")
534        .or_insert(toml::Value::Table(toml::map::Map::new()))
535        .as_table_mut()
536        .context("[credentials] is not a table")?;
537
538    // Set model for this provider (if provided)
539    if let Some(m) = model {
540        let key = format!("{}_model", provider.to_lowercase());
541        credentials.insert(key, toml::Value::String(m.to_string()));
542        log::info!("Saved {} model: {}", provider, m);
543    }
544
545    // Write back to file
546    let toml_str = toml::to_string_pretty(&config).context("Failed to serialize config to TOML")?;
547    std::fs::write(&config_path, toml_str).context("Failed to write ~/.reflex/config.toml")?;
548
549    Ok(())
550}
551
552/// Get provider-specific options from user config
553///
554/// Returns `Some(HashMap)` for providers that need extra settings (e.g., OpenRouter sort strategy).
555/// Returns `None` for providers with no additional options.
556pub fn get_provider_options(provider: &str) -> Option<HashMap<String, String>> {
557    let provider_lc = provider.to_lowercase();
558
559    match provider_lc.as_str() {
560        "openrouter" => {
561            if let Ok(Some(user_config)) = load_user_config()
562                && let Some(credentials) = &user_config.credentials
563                && let Some(sort) = &credentials.openrouter_sort
564            {
565                let mut opts = HashMap::new();
566                opts.insert("sort".to_string(), sort.clone());
567                return Some(opts);
568            }
569            None
570        }
571        "openai-compatible" | "openai_compatible" => {
572            // base_url priority: config file → OPENAI_COMPATIBLE_BASE_URL env var
573            let base_url = load_user_config()
574                .ok()
575                .flatten()
576                .and_then(|cfg| cfg.credentials)
577                .and_then(|c| c.openai_compatible_base_url)
578                .or_else(|| env::var("OPENAI_COMPATIBLE_BASE_URL").ok())
579                .filter(|s| !s.is_empty());
580
581            base_url.map(|url| {
582                let mut opts = HashMap::new();
583                opts.insert("base_url".to_string(), url);
584                opts
585            })
586        }
587        _ => None,
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use std::sync::{Mutex, MutexGuard};
595    use tempfile::TempDir;
596
597    /// Tests in this module manipulate process-wide environment variables
598    /// (`HOME`, `OPENAI_API_KEY`, etc.). Cargo runs tests in parallel by
599    /// default, which causes races: one test's `env::remove_var("HOME")`
600    /// executes mid-flight while another test is reading config from a
601    /// `HOME`-rooted path. Acquire this mutex at the start of every test
602    /// that touches env state to serialize them. Tests that don't touch
603    /// env state can omit it.
604    static ENV_LOCK: Mutex<()> = Mutex::new(());
605
606    /// Acquire the env-state lock for the duration of a test. Drops on
607    /// scope exit, restoring parallelism. Robust to poisoning from a
608    /// panicking test (recover instead of propagating).
609    fn env_guard() -> MutexGuard<'static, ()> {
610        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
611    }
612
613    /// Point `dirs::home_dir()` at the given path. On Unix that means
614    /// `HOME`; on Windows the resolver reads `USERPROFILE` instead, so we
615    /// must set the platform-appropriate variable for the override to take
616    /// effect.
617    fn set_home(path: &std::path::Path) {
618        unsafe {
619            env::set_var("HOME", path);
620            if cfg!(windows) {
621                env::set_var("USERPROFILE", path);
622            }
623        }
624    }
625
626    /// Reset the home override applied by [`set_home`].
627    fn unset_home() {
628        unsafe {
629            env::remove_var("HOME");
630            if cfg!(windows) {
631                env::remove_var("USERPROFILE");
632            }
633        }
634    }
635
636    #[test]
637    fn test_default_config() {
638        let config = SemanticConfig::default();
639        assert!(config.enabled);
640        assert_eq!(config.provider, "openai");
641        assert_eq!(config.model, None);
642        assert!(!config.auto_execute);
643    }
644
645    #[test]
646    fn test_load_config_no_file() {
647        let _g = env_guard();
648        let temp = TempDir::new().unwrap();
649
650        // Set HOME to temp directory to avoid loading user's config
651        unsafe {
652            env::set_var("HOME", temp.path());
653        }
654        let config = load_config(temp.path()).unwrap();
655        unsafe {
656            env::remove_var("HOME");
657        }
658
659        // Should return defaults
660        assert_eq!(config.provider, "openai");
661        assert!(config.enabled);
662    }
663
664    #[test]
665    fn test_load_config_with_semantic_section() {
666        let _g = env_guard();
667        let temp = TempDir::new().unwrap();
668        let reflex_dir = temp.path().join(".reflex");
669        std::fs::create_dir_all(&reflex_dir).unwrap();
670        let config_path = reflex_dir.join("config.toml");
671
672        std::fs::write(
673            &config_path,
674            r#"
675[semantic]
676enabled = true
677provider = "anthropic"
678model = "claude-3-5-sonnet-20241022"
679auto_execute = true
680            "#,
681        )
682        .unwrap();
683
684        // Set HOME to temp directory to load test config
685        set_home(temp.path());
686        let config = load_config(temp.path()).unwrap();
687        unset_home();
688
689        assert!(config.enabled);
690        assert_eq!(config.provider, "anthropic");
691        assert_eq!(config.model, Some("claude-3-5-sonnet-20241022".to_string()));
692        assert!(config.auto_execute);
693    }
694
695    #[test]
696    fn test_load_config_without_semantic_section() {
697        let _g = env_guard();
698        let temp = TempDir::new().unwrap();
699        let reflex_dir = temp.path().join(".reflex");
700        std::fs::create_dir_all(&reflex_dir).unwrap();
701        let config_path = reflex_dir.join("config.toml");
702
703        std::fs::write(
704            &config_path,
705            r#"
706[index]
707languages = []
708            "#,
709        )
710        .unwrap();
711
712        // Set HOME to temp directory to load test config
713        unsafe {
714            env::set_var("HOME", temp.path());
715        }
716        let config = load_config(temp.path()).unwrap();
717        unsafe {
718            env::remove_var("HOME");
719        }
720
721        // Should return defaults
722        assert_eq!(config.provider, "openai");
723    }
724
725    #[test]
726    fn test_get_api_key_env_var() {
727        let _g = env_guard();
728        let temp = TempDir::new().unwrap();
729
730        // Set HOME to temp directory to avoid loading user's config
731        unsafe {
732            env::set_var("HOME", temp.path());
733            env::set_var("OPENAI_API_KEY", "test-key-123");
734        }
735
736        let key = get_api_key("openai").unwrap();
737        assert_eq!(key, "test-key-123");
738
739        unsafe {
740            env::remove_var("OPENAI_API_KEY");
741            env::remove_var("HOME");
742        }
743    }
744
745    #[test]
746    fn test_get_api_key_missing() {
747        let _g = env_guard();
748        let temp = TempDir::new().unwrap();
749
750        // Set HOME to temp directory to avoid loading user's config
751        unsafe {
752            env::set_var("HOME", temp.path());
753            env::remove_var("OPENROUTER_API_KEY");
754            env::remove_var("REFLEX_AI_API_KEY");
755        }
756
757        let result = get_api_key("openrouter");
758        assert!(result.is_err());
759        assert!(
760            result
761                .unwrap_err()
762                .to_string()
763                .contains("OPENROUTER_API_KEY")
764        );
765
766        unsafe {
767            env::remove_var("HOME");
768        }
769    }
770
771    #[test]
772    fn test_get_api_key_unknown_provider() {
773        let _g = env_guard();
774        let result = get_api_key("unknown");
775        assert!(result.is_err());
776        assert!(result.unwrap_err().to_string().contains("Unknown provider"));
777    }
778
779    #[test]
780    fn test_env_override_provider() {
781        let _g = env_guard();
782        let temp = TempDir::new().unwrap();
783
784        unsafe {
785            env::set_var("HOME", temp.path());
786            env::set_var("REFLEX_PROVIDER", "openrouter");
787        }
788
789        let config = load_config(temp.path()).unwrap();
790
791        unsafe {
792            env::remove_var("REFLEX_PROVIDER");
793            env::remove_var("HOME");
794        }
795
796        assert_eq!(config.provider, "openrouter");
797    }
798
799    #[test]
800    fn test_env_override_model() {
801        let _g = env_guard();
802        let temp = TempDir::new().unwrap();
803
804        unsafe {
805            env::set_var("HOME", temp.path());
806            env::set_var("REFLEX_MODEL", "google/gemini-2.5-flash");
807        }
808
809        let config = load_config(temp.path()).unwrap();
810
811        unsafe {
812            env::remove_var("REFLEX_MODEL");
813            env::remove_var("HOME");
814        }
815
816        assert_eq!(config.model, Some("google/gemini-2.5-flash".to_string()));
817        // Provider should remain the default since we didn't override it
818        assert_eq!(config.provider, "openai");
819    }
820
821    #[test]
822    fn test_get_api_key_generic_env_var() {
823        let _g = env_guard();
824        let temp = TempDir::new().unwrap();
825
826        unsafe {
827            env::set_var("HOME", temp.path());
828            env::remove_var("OPENROUTER_API_KEY");
829            env::set_var("REFLEX_AI_API_KEY", "generic-key-456");
830        }
831
832        let key = get_api_key("openrouter").unwrap();
833        assert_eq!(key, "generic-key-456");
834
835        unsafe {
836            env::remove_var("REFLEX_AI_API_KEY");
837            env::remove_var("HOME");
838        }
839    }
840
841    #[test]
842    fn test_get_api_key_openai_compatible_returns_empty_when_unset() {
843        let _g = env_guard();
844        let temp = TempDir::new().unwrap();
845
846        unsafe {
847            env::set_var("HOME", temp.path());
848            env::remove_var("OPENAI_COMPATIBLE_API_KEY");
849            env::remove_var("REFLEX_AI_API_KEY");
850        }
851
852        // For openai-compatible, missing key is OK (local servers don't require auth)
853        let key = get_api_key("openai-compatible").unwrap();
854        assert_eq!(key, "");
855
856        unsafe {
857            env::remove_var("HOME");
858        }
859    }
860
861    #[test]
862    fn test_get_provider_options_openai_compatible_from_config() {
863        let _g = env_guard();
864        let temp = TempDir::new().unwrap();
865        let reflex_dir = temp.path().join(".reflex");
866        std::fs::create_dir_all(&reflex_dir).unwrap();
867        let config_path = reflex_dir.join("config.toml");
868
869        std::fs::write(
870            &config_path,
871            r#"
872[credentials]
873openai_compatible_base_url = "http://localhost:1234/v1"
874openai_compatible_model = "qwen2.5-coder"
875            "#,
876        )
877        .unwrap();
878
879        unsafe {
880            env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
881        }
882        set_home(temp.path());
883
884        let opts = get_provider_options("openai-compatible");
885        let model = get_user_model("openai-compatible");
886
887        unset_home();
888
889        let opts = opts.expect("base_url should be discovered from config");
890        assert_eq!(
891            opts.get("base_url").map(|s| s.as_str()),
892            Some("http://localhost:1234/v1")
893        );
894        assert_eq!(model, Some("qwen2.5-coder".to_string()));
895    }
896
897    #[test]
898    fn test_get_provider_options_openai_compatible_from_env() {
899        let _g = env_guard();
900        let temp = TempDir::new().unwrap();
901
902        unsafe {
903            env::set_var("HOME", temp.path());
904            env::set_var("OPENAI_COMPATIBLE_BASE_URL", "http://localhost:11434/v1");
905        }
906
907        let opts = get_provider_options("openai-compatible");
908
909        unsafe {
910            env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
911            env::remove_var("HOME");
912        }
913
914        let opts = opts.expect("base_url should be discovered from env var");
915        assert_eq!(
916            opts.get("base_url").map(|s| s.as_str()),
917            Some("http://localhost:11434/v1")
918        );
919    }
920
921    fn config_with(provider: &str, project_model: Option<&str>) -> SemanticConfig {
922        SemanticConfig {
923            provider: provider.to_string(),
924            model: project_model.map(String::from),
925            ..SemanticConfig::default()
926        }
927    }
928
929    #[test]
930    fn resolve_model_prefers_override() {
931        let config = config_with("openai", Some("gpt-4o"));
932        let resolved = resolve_model(&config, Some("gpt-4o-2024-08-06"));
933        assert_eq!(resolved.as_deref(), Some("gpt-4o-2024-08-06"));
934    }
935
936    #[test]
937    fn resolve_model_falls_back_to_project_config() {
938        let config = config_with("openai", Some("gpt-4o"));
939        let resolved = resolve_model(&config, None);
940        assert_eq!(resolved.as_deref(), Some("gpt-4o"));
941    }
942
943    #[test]
944    fn resolve_model_returns_none_when_unset() {
945        let _g = env_guard();
946        // No override, no [semantic] model, no [credentials] entry — caller
947        // is expected to fall back to the provider's own default.
948        let temp = TempDir::new().unwrap();
949        unsafe {
950            env::set_var("HOME", temp.path());
951        }
952
953        let config = config_with("openai", None);
954        let resolved = resolve_model(&config, None);
955
956        unsafe {
957            env::remove_var("HOME");
958        }
959
960        assert_eq!(resolved, None);
961    }
962
963    #[test]
964    fn resolve_model_for_openai_compatible_reads_user_config() {
965        let _g = env_guard();
966        // The actual bug repro at the unit level: model lives in
967        // ~/.reflex/config.toml [credentials] openai_compatible_model and
968        // resolve_model_for must surface it when override + project are None.
969        let temp = TempDir::new().unwrap();
970        let reflex_dir = temp.path().join(".reflex");
971        std::fs::create_dir_all(&reflex_dir).unwrap();
972        std::fs::write(
973            reflex_dir.join("config.toml"),
974            r#"
975[credentials]
976openai_compatible_model = "gpt-oss:20b-cloud"
977            "#,
978        )
979        .unwrap();
980
981        set_home(temp.path());
982
983        let resolved = resolve_model_for("openai-compatible", None, None);
984
985        unset_home();
986
987        assert_eq!(resolved.as_deref(), Some("gpt-oss:20b-cloud"));
988    }
989
990    #[test]
991    fn resolve_model_for_override_beats_user_config() {
992        let _g = env_guard();
993        let temp = TempDir::new().unwrap();
994        let reflex_dir = temp.path().join(".reflex");
995        std::fs::create_dir_all(&reflex_dir).unwrap();
996        std::fs::write(
997            reflex_dir.join("config.toml"),
998            r#"
999[credentials]
1000openrouter_model = "anthropic/claude-opus-4"
1001            "#,
1002        )
1003        .unwrap();
1004
1005        unsafe {
1006            env::set_var("HOME", temp.path());
1007        }
1008
1009        let resolved = resolve_model_for("openrouter", None, Some("openai/gpt-4o"));
1010
1011        unsafe {
1012            env::remove_var("HOME");
1013        }
1014
1015        assert_eq!(resolved.as_deref(), Some("openai/gpt-4o"));
1016    }
1017}