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