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