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