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