use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, home_dir, sanitize_provider_name, write_json_file, write_text_file,
};
use crate::error::AppError;
use serde_json::Value;
use std::fs;
use std::path::Path;
use toml_edit::DocumentMut;
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
"amazon-bedrock",
"openai",
"ollama",
"lmstudio",
"oss",
"ollama-chat",
];
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
home_dir().expect("无法获取用户主目录").join(".codex")
}
pub fn get_codex_auth_path() -> PathBuf {
get_codex_config_dir().join("auth.json")
}
pub fn get_codex_config_path() -> PathBuf {
get_codex_config_dir().join("config.toml")
}
pub fn get_codex_provider_paths(
provider_id: &str,
provider_name: Option<&str>,
) -> (PathBuf, PathBuf) {
let base_name = provider_name
.map(sanitize_provider_name)
.unwrap_or_else(|| sanitize_provider_name(provider_id));
let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
(auth_path, config_path)
}
pub fn delete_codex_provider_config(
provider_id: &str,
provider_name: &str,
) -> Result<(), AppError> {
let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
delete_file(&auth_path).ok();
delete_file(&config_path).ok();
Ok(())
}
pub fn write_codex_live_atomic(
auth: &Value,
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
write_codex_live_atomic_optional_auth(Some(auth), config_text_opt)
}
pub fn write_codex_live_atomic_optional_auth(
auth: Option<&Value>,
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
if let Some(parent) = auth_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let old_auth = if auth_path.exists() {
Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
} else {
None
};
let _old_config = if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
} else {
None
};
let cfg_text = match config_text_opt {
Some(s) => s.to_string(),
None => String::new(),
};
if !cfg_text.trim().is_empty() {
toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
}
if let Some(auth) = auth {
write_json_file(&auth_path, auth)?;
} else {
delete_file(&auth_path)?;
}
if let Err(e) = write_text_file(&config_path, &cfg_text) {
if let Some(bytes) = old_auth {
let _ = atomic_write(&auth_path, &bytes);
} else {
let _ = delete_file(&auth_path);
}
return Err(e);
}
Ok(())
}
pub fn read_codex_config_text() -> Result<String, AppError> {
let path = get_codex_config_path();
if path.exists() {
std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
} else {
Ok(String::new())
}
}
pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
if text.trim().is_empty() {
return Ok(());
}
toml::from_str::<toml::Table>(text)
.map(|_| ())
.map_err(|e| AppError::toml(Path::new("config.toml"), e))
}
pub fn strip_codex_provider_config_text(config_toml: &str) -> Result<String, AppError> {
let config_toml = config_toml.trim();
if config_toml.is_empty() {
return Ok(String::new());
}
let mut doc = config_toml
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
let root = doc.as_table_mut();
root.remove("model");
root.remove("model_provider");
root.remove("base_url");
root.remove("model_providers");
let mut cleaned = String::new();
let mut blank_run = 0usize;
for line in doc.to_string().lines() {
if line.trim().is_empty() {
blank_run += 1;
if blank_run <= 1 {
cleaned.push('\n');
}
continue;
}
blank_run = 0;
cleaned.push_str(line);
cleaned.push('\n');
}
Ok(cleaned.trim().to_string())
}
pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
let s = read_codex_config_text()?;
validate_config_toml(&s)?;
Ok(s)
}
fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
doc.get("model_provider")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
}
fn is_custom_codex_model_provider_id(id: &str) -> bool {
let id = id.trim();
!id.is_empty()
&& !CODEX_RESERVED_MODEL_PROVIDER_IDS
.iter()
.any(|reserved| reserved.eq_ignore_ascii_case(id))
}
fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
let doc = config_text.parse::<DocumentMut>().ok()?;
let provider_id = active_codex_model_provider_id(&doc)?;
if is_custom_codex_model_provider_id(&provider_id) {
Some(provider_id)
} else {
None
}
}
fn codex_model_provider_id_with_table_from_config(
config_text: &str,
) -> Result<Option<String>, AppError> {
if config_text.trim().is_empty() {
return Ok(None);
}
let doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
let Some(provider_id) = active_codex_model_provider_id(&doc) else {
return Ok(None);
};
let has_provider_table = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|table| table.get(provider_id.as_str()))
.is_some();
Ok(has_provider_table.then_some(provider_id))
}
fn normalize_codex_live_config_model_provider_with_anchors<'a>(
config_text: &str,
anchor_config_texts: impl IntoIterator<Item = &'a str>,
) -> Result<String, AppError> {
if config_text.trim().is_empty() {
return Ok(config_text.to_string());
}
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
return Ok(config_text.to_string());
};
let has_source_provider_table = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|table| table.get(source_provider_id.as_str()))
.is_some();
if !has_source_provider_table {
return Ok(config_text.to_string());
}
let stable_provider_id = anchor_config_texts
.into_iter()
.find_map(stable_codex_model_provider_id_from_config)
.or_else(|| {
is_custom_codex_model_provider_id(&source_provider_id)
.then(|| source_provider_id.clone())
})
.unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
if stable_provider_id == source_provider_id {
return Ok(config_text.to_string());
}
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
{
let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
return Ok(config_text.to_string());
};
model_providers[stable_provider_id.as_str()] = provider_table;
}
rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
Ok(doc.to_string())
}
fn rewrite_codex_profile_model_provider_refs(
doc: &mut DocumentMut,
source_provider_id: &str,
stable_provider_id: &str,
) {
let Some(profiles) = doc
.get_mut("profiles")
.and_then(|item| item.as_table_like_mut())
else {
return;
};
let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
for profile_key in profile_keys {
let Some(profile_table) = profiles
.get_mut(&profile_key)
.and_then(|item| item.as_table_like_mut())
else {
continue;
};
let references_source = profile_table
.get("model_provider")
.and_then(|item| item.as_str())
== Some(source_provider_id);
if references_source {
profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
}
}
}
pub fn normalize_codex_settings_config_model_provider(
settings: &mut Value,
anchor_config_text: Option<&str>,
) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
else {
return Ok(());
};
let current_config_text = read_codex_config_text().ok();
let anchors = anchor_config_text
.into_iter()
.chain(current_config_text.as_deref());
let normalized =
normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(normalized));
}
Ok(())
}
fn restore_codex_backfill_model_provider_id(
config_text: &str,
template_config_text: &str,
) -> Result<String, AppError> {
let Some(template_provider_id) =
codex_model_provider_id_with_table_from_config(template_config_text)?
else {
return Ok(config_text.to_string());
};
if config_text.trim().is_empty() {
return Ok(config_text.to_string());
}
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
return Ok(config_text.to_string());
};
if live_provider_id == template_provider_id {
return Ok(config_text.to_string());
}
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
{
let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
return Ok(config_text.to_string());
};
model_providers[template_provider_id.as_str()] = provider_table;
} else {
return Ok(config_text.to_string());
}
rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
Ok(doc.to_string())
}
pub fn restore_codex_settings_config_model_provider_for_backfill(
settings: &mut Value,
template_settings: &Value,
) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
else {
return Ok(());
};
let Some(template_config_text) = template_settings
.get("config")
.and_then(|value| value.as_str())
else {
return Ok(());
};
let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?;
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(restored));
}
Ok(())
}
pub fn write_codex_live_atomic_with_stable_provider(
auth: &Value,
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
write_codex_live_atomic_optional_auth_with_stable_provider(Some(auth), config_text_opt)
}
pub fn write_codex_live_atomic_optional_auth_with_stable_provider(
auth: Option<&Value>,
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
match config_text_opt {
Some(config_text) => {
let mut settings = serde_json::Map::new();
settings.insert("config".to_string(), Value::String(config_text.to_string()));
let mut settings = Value::Object(settings);
normalize_codex_settings_config_model_provider(&mut settings, None)?;
let config_text = settings
.get("config")
.and_then(|value| value.as_str())
.unwrap_or(config_text);
write_codex_live_atomic_optional_auth(auth, Some(config_text))
}
None => write_codex_live_atomic_optional_auth(auth, None),
}
}
pub fn clean_codex_provider_key(raw: &str) -> String {
let mut key: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
while key.starts_with('_') {
key.remove(0);
}
while key.ends_with('_') {
key.pop();
}
if key.is_empty() {
"custom".to_string()
} else {
key
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_live_config_preserves_current_custom_model_provider_id() {
let current = r#"model_provider = "rightcode"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
"#;
let target = r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
[mcp_servers.context7]
command = "npx"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode")
);
let model_providers = parsed
.get("model_providers")
.and_then(|v| v.as_table())
.expect("model_providers should exist");
assert!(
model_providers.get("aihubmix").is_none(),
"source provider id should not remain in live config"
);
let stable_provider = model_providers
.get("rightcode")
.expect("stable provider table should exist");
assert_eq!(
stable_provider.get("base_url").and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1")
);
assert!(
parsed.get("mcp_servers").is_some(),
"unrelated config should be preserved"
);
}
#[test]
fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
let current = r#"model_provider = "openai""#;
let target = r#"model_provider = "aihubmix"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("aihubmix")
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("aihubmix"))
.is_some(),
"target provider id should be kept when there is no reusable live custom id"
);
}
#[test]
fn normalize_live_config_leaves_official_empty_config_unchanged() {
let current = r#"model_provider = "rightcode"
[model_providers.rightcode]
base_url = "https://rightcode.example/v1"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
assert_eq!(result, "");
}
#[test]
fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
let current = r#"model_provider = "session_anchor"
[model_providers.session_anchor]
name = "Session Anchor"
base_url = "https://anchor.example/v1"
wire_api = "responses"
"#;
let target = r#"model_provider = "vendor_alpha"
model = "gpt-5.4"
profile = "work"
[model_providers.vendor_alpha]
name = "Vendor Alpha"
base_url = "https://alpha.example/v1"
wire_api = "responses"
[profiles.work]
model_provider = "vendor_alpha"
model = "gpt-5.4"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("session_anchor")
);
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("work"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("session_anchor"),
"profile override matching the rewritten provider should stay valid"
);
}
#[test]
fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
let current = r#"model_provider = "session_anchor"
[model_providers.session_anchor]
name = "Session Anchor"
base_url = "https://anchor.example/v1"
wire_api = "responses"
"#;
let target = r#"model_provider = "vendor_alpha"
model = "gpt-5.4"
[model_providers.vendor_alpha]
name = "Vendor Alpha"
base_url = "https://alpha.example/v1"
wire_api = "responses"
[model_providers.local_profile]
name = "Local Profile"
base_url = "http://localhost:11434/v1"
wire_api = "responses"
[profiles.local]
model_provider = "local_profile"
model = "local-model"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("local"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("local_profile"),
"unrelated profile provider references should be preserved"
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("local_profile"))
.is_some(),
"unrelated provider tables should also remain available"
);
}
#[test]
fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
let anchor = r#"model_provider = "session_anchor"
[model_providers.session_anchor]
name = "Session Anchor"
base_url = "https://anchor.example/v1"
wire_api = "responses"
"#;
let first_target = r#"model_provider = "vendor_alpha"
[model_providers.vendor_alpha]
name = "Vendor Alpha"
base_url = "https://alpha.example/v1"
wire_api = "responses"
"#;
let second_target = r#"model_provider = "vendor_beta"
[model_providers.vendor_beta]
name = "Vendor Beta"
base_url = "https://beta.example/v1"
wire_api = "responses"
"#;
let first =
normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
.unwrap();
let second = normalize_codex_live_config_model_provider_with_anchors(
second_target,
Some(first.as_str()),
)
.unwrap();
let parsed: toml::Value = toml::from_str(&second).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("session_anchor"),
"stable provider id should not drift across repeated switches"
);
assert_eq!(
parsed
.get("model_providers")
.and_then(|v| v.get("session_anchor"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://beta.example/v1")
);
}
#[test]
fn restore_backfill_config_rewrites_live_id_to_template_provider_id() {
let mut settings = serde_json::json!({
"config": r#"model_provider = "session_anchor"
model = "gpt-5.4"
profile = "work"
[model_providers.session_anchor]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
[profiles.work]
model_provider = "session_anchor"
model = "gpt-5.4"
"#
});
let template = serde_json::json!({
"config": r#"model_provider = "aihubmix"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
"#
});
restore_codex_settings_config_model_provider_for_backfill(&mut settings, &template)
.unwrap();
let config = settings.get("config").and_then(Value::as_str).unwrap();
let parsed: toml::Value = toml::from_str(config).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("aihubmix")
);
assert!(parsed
.get("model_providers")
.and_then(|v| v.get("aihubmix"))
.is_some());
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("work"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("aihubmix")
);
}
}