use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SemanticConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub auto_execute: bool,
#[serde(default = "default_agentic_enabled")]
pub agentic_enabled: bool,
#[serde(default = "default_max_iterations")]
pub max_iterations: usize,
#[serde(default = "default_max_tools")]
pub max_tools_per_phase: usize,
#[serde(default = "default_evaluation_enabled")]
pub evaluation_enabled: bool,
#[serde(default = "default_strictness")]
pub evaluation_strictness: f32,
#[serde(default = "default_timeout_seconds")]
pub timeout_seconds: u64,
}
fn default_enabled() -> bool {
true
}
fn default_provider() -> String {
"openai".to_string()
}
fn default_agentic_enabled() -> bool {
false }
fn default_max_iterations() -> usize {
2
}
fn default_max_tools() -> usize {
5
}
fn default_evaluation_enabled() -> bool {
true
}
fn default_strictness() -> f32 {
0.5
}
fn default_timeout_seconds() -> u64 {
30
}
impl Default for SemanticConfig {
fn default() -> Self {
Self {
enabled: true,
provider: "openai".to_string(),
model: None,
auto_execute: false,
agentic_enabled: false,
max_iterations: 2,
max_tools_per_phase: 5,
evaluation_enabled: true,
evaluation_strictness: 0.5,
timeout_seconds: 30,
}
}
}
fn apply_env_overrides(mut config: SemanticConfig) -> SemanticConfig {
if let Ok(provider) = env::var("REFLEX_PROVIDER") {
if !provider.is_empty() {
log::debug!(
"Overriding provider from REFLEX_PROVIDER env var: {}",
provider
);
config.provider = provider;
}
}
if let Ok(model) = env::var("REFLEX_MODEL") {
if !model.is_empty() {
log::debug!("Overriding model from REFLEX_MODEL env var: {}", model);
config.model = Some(model);
}
}
if let Ok(val) = env::var("REFLEX_LLM_TIMEOUT_SECONDS") {
match val.trim().parse::<u64>() {
Ok(secs) if secs > 0 => {
log::debug!(
"Overriding LLM timeout from REFLEX_LLM_TIMEOUT_SECONDS: {}s",
secs
);
config.timeout_seconds = secs;
}
_ => log::warn!(
"REFLEX_LLM_TIMEOUT_SECONDS is invalid (must be a positive integer): {}",
val
),
}
}
config
}
pub fn load_config(_cache_dir: &Path) -> Result<SemanticConfig> {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
log::debug!("Could not determine home directory, using defaults");
return Ok(apply_env_overrides(SemanticConfig::default()));
}
};
let config_path = home.join(".reflex").join("config.toml");
if !config_path.exists() {
log::debug!("No ~/.reflex/config.toml found, using default semantic config");
return Ok(apply_env_overrides(SemanticConfig::default()));
}
let config_str =
std::fs::read_to_string(&config_path).context("Failed to read ~/.reflex/config.toml")?;
let toml_value: toml::Value =
toml::from_str(&config_str).context("Failed to parse ~/.reflex/config.toml")?;
let known_sections = ["semantic", "credentials", "index", "search", "performance"];
if let Some(table) = toml_value.as_table() {
for key in table.keys() {
if !known_sections.contains(&key.as_str()) {
eprintln!(
"[warn] ~/.reflex/config.toml: unknown section '[{}]' — ignored",
key
);
}
}
}
let known_semantic_keys = ["provider", "model", "auto_execute"];
if let Some(toml::Value::Table(sem_table)) = toml_value.get("semantic") {
for key in sem_table.keys() {
if !known_semantic_keys.contains(&key.as_str()) {
eprintln!(
"[warn] ~/.reflex/config.toml: unknown key '[semantic].{}' — ignored",
key
);
}
}
}
if let Some(semantic_table) = toml_value.get("semantic") {
let config: SemanticConfig = semantic_table
.clone()
.try_into()
.context("Failed to parse [semantic] section in ~/.reflex/config.toml")?;
log::debug!(
"Loaded semantic config from ~/.reflex/config.toml: provider={}",
config.provider
);
Ok(apply_env_overrides(config))
} else {
log::debug!("No [semantic] section in ~/.reflex/config.toml, using defaults");
Ok(apply_env_overrides(SemanticConfig::default()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UserConfig {
#[serde(default)]
credentials: Option<Credentials>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Credentials {
#[serde(default)]
openai_api_key: Option<String>,
#[serde(default)]
anthropic_api_key: Option<String>,
#[serde(default)]
openrouter_api_key: Option<String>,
#[serde(default)]
openai_compatible_api_key: Option<String>,
#[serde(default)]
openai_model: Option<String>,
#[serde(default)]
anthropic_model: Option<String>,
#[serde(default)]
openrouter_model: Option<String>,
#[serde(default)]
openai_compatible_model: Option<String>,
#[serde(default)]
openrouter_sort: Option<String>,
#[serde(default)]
openai_compatible_base_url: Option<String>,
}
fn load_user_config() -> Result<Option<UserConfig>> {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
log::debug!("Could not determine home directory");
return Ok(None);
}
};
let config_path = home.join(".reflex").join("config.toml");
if !config_path.exists() {
log::debug!("No user config found at ~/.reflex/config.toml");
return Ok(None);
}
let config_str =
std::fs::read_to_string(&config_path).context("Failed to read ~/.reflex/config.toml")?;
let config: UserConfig =
toml::from_str(&config_str).context("Failed to parse ~/.reflex/config.toml")?;
Ok(Some(config))
}
pub fn get_api_key(provider: &str) -> Result<String> {
let provider_lc = provider.to_lowercase();
let is_openai_compatible =
provider_lc == "openai-compatible" || provider_lc == "openai_compatible";
if let Ok(Some(user_config)) = load_user_config() {
if let Some(credentials) = &user_config.credentials {
let key = match provider_lc.as_str() {
"openai" => credentials.openai_api_key.as_ref(),
"anthropic" => credentials.anthropic_api_key.as_ref(),
"openrouter" => credentials.openrouter_api_key.as_ref(),
"openai-compatible" | "openai_compatible" => {
credentials.openai_compatible_api_key.as_ref()
}
_ => None,
};
if let Some(api_key) = key {
log::debug!("Using {} API key from ~/.reflex/config.toml", provider);
return Ok(api_key.clone());
}
}
}
if let Ok(key) = env::var("REFLEX_AI_API_KEY") {
if !key.is_empty() {
log::debug!(
"Using API key from REFLEX_AI_API_KEY env var for provider '{}'",
provider
);
return Ok(key);
}
}
let env_var = match provider_lc.as_str() {
"openai" => "OPENAI_API_KEY",
"anthropic" => "ANTHROPIC_API_KEY",
"openrouter" => "OPENROUTER_API_KEY",
"openai-compatible" | "openai_compatible" => "OPENAI_COMPATIBLE_API_KEY",
_ => anyhow::bail!("Unknown provider: {}", provider),
};
if let Ok(key) = env::var(env_var) {
return Ok(key);
}
if is_openai_compatible {
log::debug!(
"No API key configured for openai-compatible; sending requests without auth header"
);
return Ok(String::new());
}
Err(anyhow::anyhow!(
"API key not found for provider '{}'.\n\
\n\
Either:\n\
1. Run 'rfx llm config' to set up your API key interactively\n\
2. Set REFLEX_AI_API_KEY (works with any provider)\n\
3. Set the {} environment variable\n\
\n\
Example: export REFLEX_AI_API_KEY=sk-...",
provider,
env_var
))
}
pub fn is_any_api_key_configured() -> bool {
if let Ok(Some(user_config)) = load_user_config() {
if let Some(credentials) = &user_config.credentials {
if credentials.openai_api_key.is_some()
|| credentials.anthropic_api_key.is_some()
|| credentials.openrouter_api_key.is_some()
|| credentials.openai_compatible_api_key.is_some()
|| credentials.openai_compatible_base_url.is_some()
{
log::debug!("Found provider credential in ~/.reflex/config.toml");
return true;
}
}
}
if let Ok(key) = env::var("REFLEX_AI_API_KEY") {
if !key.is_empty() {
log::debug!("Found REFLEX_AI_API_KEY env var");
return true;
}
}
let env_vars = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"OPENAI_COMPATIBLE_API_KEY",
"OPENAI_COMPATIBLE_BASE_URL",
];
for env_var in &env_vars {
if env::var(env_var).is_ok() {
log::debug!("Found {} environment variable", env_var);
return true;
}
}
log::debug!("No provider credentials found in config or environment variables");
false
}
pub fn get_user_model(provider: &str) -> Option<String> {
if let Ok(Some(user_config)) = load_user_config() {
if let Some(credentials) = &user_config.credentials {
let model = match provider.to_lowercase().as_str() {
"openai" => credentials.openai_model.as_ref(),
"anthropic" => credentials.anthropic_model.as_ref(),
"openrouter" => credentials.openrouter_model.as_ref(),
"openai-compatible" | "openai_compatible" => {
credentials.openai_compatible_model.as_ref()
}
_ => None,
};
if let Some(model_name) = model {
log::debug!(
"Using {} model from ~/.reflex/config.toml: {}",
provider,
model_name
);
return Some(model_name.clone());
}
}
}
let provider_lc = provider.to_lowercase();
if provider_lc == "openai-compatible" || provider_lc == "openai_compatible" {
if let Ok(model) = env::var("OPENAI_COMPATIBLE_MODEL") {
if !model.is_empty() {
log::debug!(
"Using openai-compatible model from OPENAI_COMPATIBLE_MODEL env var: {}",
model
);
return Some(model);
}
}
}
None
}
pub fn resolve_model(config: &SemanticConfig, override_model: Option<&str>) -> Option<String> {
resolve_model_for(&config.provider, config.model.as_deref(), override_model)
}
pub fn resolve_model_for(
provider: &str,
project_model: Option<&str>,
override_model: Option<&str>,
) -> Option<String> {
override_model
.map(String::from)
.or_else(|| project_model.map(String::from))
.or_else(|| get_user_model(provider))
}
pub fn save_user_provider(provider: &str, model: Option<&str>) -> Result<()> {
let home = dirs::home_dir().context("Cannot find home directory")?;
let config_dir = home.join(".reflex");
let config_path = config_dir.join("config.toml");
std::fs::create_dir_all(&config_dir).context("Failed to create ~/.reflex directory")?;
let mut config: toml::Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)
.context("Failed to read ~/.reflex/config.toml")?;
toml::from_str(&content).context("Failed to parse ~/.reflex/config.toml")?
} else {
toml::Value::Table(toml::map::Map::new())
};
let credentials = config
.as_table_mut()
.context("Config root is not a table")?
.entry("credentials")
.or_insert(toml::Value::Table(toml::map::Map::new()))
.as_table_mut()
.context("[credentials] is not a table")?;
if let Some(m) = model {
let key = format!("{}_model", provider.to_lowercase());
credentials.insert(key, toml::Value::String(m.to_string()));
log::info!("Saved {} model: {}", provider, m);
}
let toml_str = toml::to_string_pretty(&config).context("Failed to serialize config to TOML")?;
std::fs::write(&config_path, toml_str).context("Failed to write ~/.reflex/config.toml")?;
Ok(())
}
pub fn get_provider_options(provider: &str) -> Option<HashMap<String, String>> {
let provider_lc = provider.to_lowercase();
match provider_lc.as_str() {
"openrouter" => {
if let Ok(Some(user_config)) = load_user_config() {
if let Some(credentials) = &user_config.credentials {
if let Some(sort) = &credentials.openrouter_sort {
let mut opts = HashMap::new();
opts.insert("sort".to_string(), sort.clone());
return Some(opts);
}
}
}
None
}
"openai-compatible" | "openai_compatible" => {
let base_url = load_user_config()
.ok()
.flatten()
.and_then(|cfg| cfg.credentials)
.and_then(|c| c.openai_compatible_base_url)
.or_else(|| env::var("OPENAI_COMPATIBLE_BASE_URL").ok())
.filter(|s| !s.is_empty());
base_url.map(|url| {
let mut opts = HashMap::new();
opts.insert("base_url".to_string(), url);
opts
})
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn env_guard() -> MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn test_default_config() {
let config = SemanticConfig::default();
assert_eq!(config.enabled, true);
assert_eq!(config.provider, "openai");
assert_eq!(config.model, None);
assert_eq!(config.auto_execute, false);
}
#[test]
fn test_load_config_no_file() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
unsafe {
env::set_var("HOME", temp.path());
}
let config = load_config(temp.path()).unwrap();
unsafe {
env::remove_var("HOME");
}
assert_eq!(config.provider, "openai");
assert_eq!(config.enabled, true);
}
#[test]
fn test_load_config_with_semantic_section() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
let reflex_dir = temp.path().join(".reflex");
std::fs::create_dir_all(&reflex_dir).unwrap();
let config_path = reflex_dir.join("config.toml");
std::fs::write(
&config_path,
r#"
[semantic]
enabled = true
provider = "anthropic"
model = "claude-3-5-sonnet-20241022"
auto_execute = true
"#,
)
.unwrap();
unsafe {
env::set_var("HOME", temp.path());
}
let config = load_config(temp.path()).unwrap();
unsafe {
env::remove_var("HOME");
}
assert_eq!(config.enabled, true);
assert_eq!(config.provider, "anthropic");
assert_eq!(config.model, Some("claude-3-5-sonnet-20241022".to_string()));
assert_eq!(config.auto_execute, true);
}
#[test]
fn test_load_config_without_semantic_section() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
let reflex_dir = temp.path().join(".reflex");
std::fs::create_dir_all(&reflex_dir).unwrap();
let config_path = reflex_dir.join("config.toml");
std::fs::write(
&config_path,
r#"
[index]
languages = []
"#,
)
.unwrap();
unsafe {
env::set_var("HOME", temp.path());
}
let config = load_config(temp.path()).unwrap();
unsafe {
env::remove_var("HOME");
}
assert_eq!(config.provider, "openai");
}
#[test]
fn test_get_api_key_env_var() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
unsafe {
env::set_var("HOME", temp.path());
env::set_var("OPENAI_API_KEY", "test-key-123");
}
let key = get_api_key("openai").unwrap();
assert_eq!(key, "test-key-123");
unsafe {
env::remove_var("OPENAI_API_KEY");
env::remove_var("HOME");
}
}
#[test]
fn test_get_api_key_missing() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
unsafe {
env::set_var("HOME", temp.path());
env::remove_var("OPENROUTER_API_KEY");
env::remove_var("REFLEX_AI_API_KEY");
}
let result = get_api_key("openrouter");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("OPENROUTER_API_KEY")
);
unsafe {
env::remove_var("HOME");
}
}
#[test]
fn test_get_api_key_unknown_provider() {
let _g = env_guard();
let result = get_api_key("unknown");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unknown provider"));
}
#[test]
fn test_env_override_provider() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
unsafe {
env::set_var("HOME", temp.path());
env::set_var("REFLEX_PROVIDER", "openrouter");
}
let config = load_config(temp.path()).unwrap();
unsafe {
env::remove_var("REFLEX_PROVIDER");
env::remove_var("HOME");
}
assert_eq!(config.provider, "openrouter");
}
#[test]
fn test_env_override_model() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
unsafe {
env::set_var("HOME", temp.path());
env::set_var("REFLEX_MODEL", "google/gemini-2.5-flash");
}
let config = load_config(temp.path()).unwrap();
unsafe {
env::remove_var("REFLEX_MODEL");
env::remove_var("HOME");
}
assert_eq!(config.model, Some("google/gemini-2.5-flash".to_string()));
assert_eq!(config.provider, "openai");
}
#[test]
fn test_get_api_key_generic_env_var() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
unsafe {
env::set_var("HOME", temp.path());
env::remove_var("OPENROUTER_API_KEY");
env::set_var("REFLEX_AI_API_KEY", "generic-key-456");
}
let key = get_api_key("openrouter").unwrap();
assert_eq!(key, "generic-key-456");
unsafe {
env::remove_var("REFLEX_AI_API_KEY");
env::remove_var("HOME");
}
}
#[test]
fn test_get_api_key_openai_compatible_returns_empty_when_unset() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
unsafe {
env::set_var("HOME", temp.path());
env::remove_var("OPENAI_COMPATIBLE_API_KEY");
env::remove_var("REFLEX_AI_API_KEY");
}
let key = get_api_key("openai-compatible").unwrap();
assert_eq!(key, "");
unsafe {
env::remove_var("HOME");
}
}
#[test]
fn test_get_provider_options_openai_compatible_from_config() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
let reflex_dir = temp.path().join(".reflex");
std::fs::create_dir_all(&reflex_dir).unwrap();
let config_path = reflex_dir.join("config.toml");
std::fs::write(
&config_path,
r#"
[credentials]
openai_compatible_base_url = "http://localhost:1234/v1"
openai_compatible_model = "qwen2.5-coder"
"#,
)
.unwrap();
unsafe {
env::set_var("HOME", temp.path());
env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
}
let opts = get_provider_options("openai-compatible");
let model = get_user_model("openai-compatible");
unsafe {
env::remove_var("HOME");
}
let opts = opts.expect("base_url should be discovered from config");
assert_eq!(
opts.get("base_url").map(|s| s.as_str()),
Some("http://localhost:1234/v1")
);
assert_eq!(model, Some("qwen2.5-coder".to_string()));
}
#[test]
fn test_get_provider_options_openai_compatible_from_env() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
unsafe {
env::set_var("HOME", temp.path());
env::set_var("OPENAI_COMPATIBLE_BASE_URL", "http://localhost:11434/v1");
}
let opts = get_provider_options("openai-compatible");
unsafe {
env::remove_var("OPENAI_COMPATIBLE_BASE_URL");
env::remove_var("HOME");
}
let opts = opts.expect("base_url should be discovered from env var");
assert_eq!(
opts.get("base_url").map(|s| s.as_str()),
Some("http://localhost:11434/v1")
);
}
fn config_with(provider: &str, project_model: Option<&str>) -> SemanticConfig {
SemanticConfig {
provider: provider.to_string(),
model: project_model.map(String::from),
..SemanticConfig::default()
}
}
#[test]
fn resolve_model_prefers_override() {
let config = config_with("openai", Some("gpt-4o"));
let resolved = resolve_model(&config, Some("gpt-4o-2024-08-06"));
assert_eq!(resolved.as_deref(), Some("gpt-4o-2024-08-06"));
}
#[test]
fn resolve_model_falls_back_to_project_config() {
let config = config_with("openai", Some("gpt-4o"));
let resolved = resolve_model(&config, None);
assert_eq!(resolved.as_deref(), Some("gpt-4o"));
}
#[test]
fn resolve_model_returns_none_when_unset() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
unsafe {
env::set_var("HOME", temp.path());
}
let config = config_with("openai", None);
let resolved = resolve_model(&config, None);
unsafe {
env::remove_var("HOME");
}
assert_eq!(resolved, None);
}
#[test]
fn resolve_model_for_openai_compatible_reads_user_config() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
let reflex_dir = temp.path().join(".reflex");
std::fs::create_dir_all(&reflex_dir).unwrap();
std::fs::write(
reflex_dir.join("config.toml"),
r#"
[credentials]
openai_compatible_model = "gpt-oss:20b-cloud"
"#,
)
.unwrap();
unsafe {
env::set_var("HOME", temp.path());
}
let resolved = resolve_model_for("openai-compatible", None, None);
unsafe {
env::remove_var("HOME");
}
assert_eq!(resolved.as_deref(), Some("gpt-oss:20b-cloud"));
}
#[test]
fn resolve_model_for_override_beats_user_config() {
let _g = env_guard();
let temp = TempDir::new().unwrap();
let reflex_dir = temp.path().join(".reflex");
std::fs::create_dir_all(&reflex_dir).unwrap();
std::fs::write(
reflex_dir.join("config.toml"),
r#"
[credentials]
openrouter_model = "anthropic/claude-opus-4"
"#,
)
.unwrap();
unsafe {
env::set_var("HOME", temp.path());
}
let resolved = resolve_model_for("openrouter", None, Some("openai/gpt-4o"));
unsafe {
env::remove_var("HOME");
}
assert_eq!(resolved.as_deref(), Some("openai/gpt-4o"));
}
}