use crate::path_utils::safe_parent_for_creation;
use anyhow::{Context, Result, anyhow};
use rust_i18n::t;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use toml::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderLlmanConfigs {
pub override_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub name: String,
pub base_url: String,
#[serde(default = "default_wire_api")]
pub wire_api: String,
pub env_key: String,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llman_configs: Option<ProviderLlmanConfigs>,
#[serde(default, flatten)]
pub extra: HashMap<String, Value>,
}
fn default_wire_api() -> String {
"responses".to_string()
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub model_providers: HashMap<String, ProviderConfig>,
}
impl Config {
pub fn load() -> Result<Self> {
let path = Self::config_file_path()?;
Self::load_from_path(&path)
}
pub fn load_from_path(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(path)
.with_context(|| t!("codex.error.load_config_failed", path = path.display()))?;
toml::from_str(&content)
.with_context(|| t!("codex.error.parse_config_failed", path = path.display()))
}
pub fn provider_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.model_providers.keys().cloned().collect();
names.sort();
names
}
pub fn get_provider(&self, name: &str) -> Option<&ProviderConfig> {
self.model_providers.get(name)
}
pub fn is_empty(&self) -> bool {
self.model_providers.is_empty()
}
pub fn add_provider(&mut self, key: String, provider: ProviderConfig) {
self.model_providers.insert(key, provider);
}
pub fn config_file_path() -> Result<PathBuf> {
Ok(crate::config::resolve_config_dir(None)?.join("codex.toml"))
}
pub fn save(&self) -> Result<()> {
let path = Self::config_file_path()?;
self.save_to_path(&path)
}
pub fn save_to_path(&self, path: &Path) -> Result<()> {
if let Some(parent) = safe_parent_for_creation(path) {
fs::create_dir_all(parent).with_context(|| {
t!(
"codex.error.create_config_dir_failed",
path = parent.display()
)
})?;
}
let content = toml::to_string_pretty(self)
.with_context(|| t!("codex.error.serialize_config_failed"))?;
crate::fs_utils::atomic_write_with_mode(path, content.as_bytes(), Some(0o600))
.with_context(|| t!("codex.error.write_config_failed", path = path.display()))?;
Ok(())
}
}
pub fn provider_to_codex_table(provider: &ProviderConfig, effective_name: &str) -> Value {
let mut table = toml::map::Map::new();
let mut extra_keys: Vec<&String> = provider.extra.keys().collect();
extra_keys.sort();
for key in extra_keys {
let Some(value) = provider.extra.get(key) else {
continue;
};
if key == "env" || key == "llman_configs" {
continue;
}
table.insert(key.clone(), value.clone());
}
table.insert("name".into(), Value::String(effective_name.to_string()));
table.insert("base_url".into(), Value::String(provider.base_url.clone()));
table.insert("wire_api".into(), Value::String(provider.wire_api.clone()));
table.insert("env_key".into(), Value::String(provider.env_key.clone()));
Value::Table(table)
}
pub fn upsert_to_codex_config(provider_key: &str, provider: &ProviderConfig) -> Result<bool> {
let effective_name = match provider
.llman_configs
.as_ref()
.and_then(|cfg| cfg.override_name.as_deref())
{
Some(override_name) => {
let override_name = override_name.trim();
if override_name.is_empty() {
return Err(anyhow!(t!(
"codex.error.override_name_blank",
name = provider_key
)));
}
override_name.to_string()
}
None => provider_key.to_string(),
};
let codex_config_path = codex_config_path()?;
let mut doc: Value = if codex_config_path.exists() {
let content = fs::read_to_string(&codex_config_path)
.with_context(|| t!("codex.error.read_codex_config_failed"))?;
toml::from_str(&content).with_context(|| t!("codex.error.parse_codex_config_failed"))?
} else {
Value::Table(toml::map::Map::new())
};
let root = doc
.as_table_mut()
.ok_or_else(|| anyhow!(t!("codex.error.codex_config_not_table")))?;
let new_provider_table = provider_to_codex_table(provider, &effective_name);
let current_model_provider = root
.get("model_provider")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let current_provider_entry = root
.get("model_providers")
.and_then(|v| v.as_table())
.and_then(|t| t.get(&effective_name));
if current_model_provider.as_deref() == Some(&effective_name)
&& current_provider_entry == Some(&new_provider_table)
{
return Ok(false);
}
root.insert(
"model_provider".into(),
Value::String(effective_name.to_string()),
);
let providers = root
.entry("model_providers")
.or_insert_with(|| Value::Table(toml::map::Map::new()));
if let Some(providers_table) = providers.as_table_mut() {
providers_table.insert(effective_name.to_string(), new_provider_table);
}
if let Some(parent) = codex_config_path.parent() {
fs::create_dir_all(parent).with_context(|| t!("codex.error.create_codex_dir_failed"))?;
}
let output = toml::to_string_pretty(&doc)
.with_context(|| t!("codex.error.serialize_codex_config_failed"))?;
crate::fs_utils::atomic_write_with_mode(&codex_config_path, output.as_bytes(), Some(0o600))
.with_context(|| t!("codex.error.write_codex_config_failed"))?;
Ok(true)
}
fn codex_config_path() -> Result<PathBuf> {
let home = crate::config::home_dir().context(t!("codex.error.home_dir_failed"))?;
Ok(home.join(".codex").join("config.toml"))
}
pub fn mask_secret(value: &str) -> String {
if value.len() <= 8 {
"*".repeat(value.len())
} else {
format!("{}...{}", &value[..4], &value[value.len() - 4..])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::TestProcess;
use std::fs;
use tempfile::TempDir;
#[test]
fn provider_to_codex_table_includes_extra_excludes_env_and_llman_configs_and_overrides_name() {
let provider = ProviderConfig {
name: "b".to_string(),
base_url: "https://api.example.com/v1".to_string(),
wire_api: "responses".to_string(),
env_key: "CODEX_API_KEY".to_string(),
env: [("CODEX_API_KEY".to_string(), "sk-test".to_string())]
.into_iter()
.collect(),
llman_configs: Some(ProviderLlmanConfigs {
override_name: Some("a".to_string()),
}),
extra: [
("request_max_retries".to_string(), Value::Integer(9999)),
("some_flag".to_string(), Value::Boolean(true)),
]
.into_iter()
.collect(),
};
let table = provider_to_codex_table(&provider, "a");
let t = table.as_table().expect("provider table");
assert_eq!(t.get("name").and_then(|v| v.as_str()), Some("a"));
assert_eq!(
t.get("base_url").and_then(|v| v.as_str()),
Some("https://api.example.com/v1")
);
assert_eq!(
t.get("wire_api").and_then(|v| v.as_str()),
Some("responses")
);
assert_eq!(
t.get("env_key").and_then(|v| v.as_str()),
Some("CODEX_API_KEY")
);
assert_eq!(
t.get("request_max_retries").and_then(|v| v.as_integer()),
Some(9999)
);
assert_eq!(t.get("some_flag").and_then(|v| v.as_bool()), Some(true));
assert!(t.get("env").is_none());
assert!(t.get("llman_configs").is_none());
}
#[test]
fn config_save_roundtrips_provider_extra_fields() {
let temp = TempDir::new().expect("temp dir");
let config_path = temp.path().join("codex.toml");
fs::write(
&config_path,
r#"
[model_providers.b]
name = "b"
base_url = "https://api.example.com/v1"
wire_api = "responses"
env_key = "CODEX_API_KEY"
request_max_retries = 9999
[model_providers.b.env]
CODEX_API_KEY = "sk-test"
[model_providers.b.llman_configs]
override_name = "a"
"#,
)
.expect("write config");
let config = Config::load_from_path(&config_path).expect("load config");
let provider = config.get_provider("b").expect("provider b");
assert_eq!(
provider
.extra
.get("request_max_retries")
.and_then(|v| v.as_integer()),
Some(9999)
);
assert_eq!(
provider
.llman_configs
.as_ref()
.and_then(|c| c.override_name.as_deref()),
Some("a")
);
let saved_path = temp.path().join("codex.saved.toml");
config.save_to_path(&saved_path).expect("save config");
let config2 = Config::load_from_path(&saved_path).expect("load saved config");
let provider2 = config2.get_provider("b").expect("provider b");
assert_eq!(
provider2
.extra
.get("request_max_retries")
.and_then(|v| v.as_integer()),
Some(9999)
);
assert_eq!(
provider2
.llman_configs
.as_ref()
.and_then(|c| c.override_name.as_deref()),
Some("a")
);
}
#[test]
fn upsert_to_codex_config_uses_override_name_and_is_idempotent() {
let temp = TempDir::new().expect("temp dir");
let mut proc = TestProcess::new();
proc.set_var("HOME", temp.path());
let provider = ProviderConfig {
name: "b".to_string(),
base_url: "https://api.example.com/v1".to_string(),
wire_api: "responses".to_string(),
env_key: "CODEX_API_KEY".to_string(),
env: HashMap::new(),
llman_configs: Some(ProviderLlmanConfigs {
override_name: Some("a".to_string()),
}),
extra: [("request_max_retries".to_string(), Value::Integer(9999))]
.into_iter()
.collect(),
};
let wrote = upsert_to_codex_config("b", &provider).expect("upsert");
assert!(wrote);
let codex_config_path = temp.path().join(".codex").join("config.toml");
let content1 = fs::read_to_string(&codex_config_path).expect("read config");
let v: Value = toml::from_str(&content1).expect("parse toml");
let root = v.as_table().expect("root table");
assert_eq!(
root.get("model_provider").and_then(|v| v.as_str()),
Some("a")
);
let providers = root
.get("model_providers")
.and_then(|v| v.as_table())
.expect("model_providers");
assert!(providers.get("b").is_none());
let provider_a = providers.get("a").and_then(|v| v.as_table()).expect("a");
assert_eq!(provider_a.get("name").and_then(|v| v.as_str()), Some("a"));
assert_eq!(
provider_a
.get("request_max_retries")
.and_then(|v| v.as_integer()),
Some(9999)
);
assert!(provider_a.get("env").is_none());
assert!(provider_a.get("llman_configs").is_none());
let wrote2 = upsert_to_codex_config("b", &provider).expect("second upsert");
assert!(!wrote2);
let content2 = fs::read_to_string(&codex_config_path).expect("read config 2");
assert_eq!(content1, content2);
}
}