Files
CC-Switch/src-tauri/src/codex_config.rs
T
Jason 3568c98f57 fix: make Codex TOML base_url editing section-aware
Rewrite setCodexBaseUrl/extractCodexBaseUrl to understand TOML section
boundaries, ensuring base_url is written into the correct
[model_providers.<name>] section instead of being appended to file end.

- Add section-aware TOML helpers in providerConfigUtils.ts
- Extract shared update_codex_toml_field/remove_codex_toml_base_url_if
  in codex_config.rs, deduplicate proxy.rs TOML editing logic
- Replace scattered inline base_url regexes with extractCodexBaseUrl()
- Add comprehensive tests for both Rust and TypeScript implementations
2026-03-12 22:20:59 +08:00

471 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// unused imports removed
use std::path::PathBuf;
use crate::config::{
atomic_write, delete_file, get_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;
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
get_home_dir().join(".codex")
}
/// 获取 Codex auth.json 路径
pub fn get_codex_auth_path() -> PathBuf {
get_codex_config_dir().join("auth.json")
}
/// 获取 Codex config.toml 路径
pub fn get_codex_config_path() -> PathBuf {
get_codex_config_dir().join("config.toml")
}
/// 获取 Codex 供应商配置文件路径
#[allow(dead_code)]
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)
}
/// 删除 Codex 供应商配置文件
#[allow(dead_code)]
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(())
}
/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
pub fn write_codex_live_atomic(
auth: &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))?;
}
// 第一步:写 auth.json
write_json_file(&auth_path, auth)?;
// 第二步:写 config.toml(失败则回滚 auth.json
if let Err(e) = write_text_file(&config_path, &cfg_text) {
// 回滚 auth.json
if let Some(bytes) = old_auth {
let _ = atomic_write(&auth_path, &bytes);
} else {
let _ = delete_file(&auth_path);
}
return Err(e);
}
Ok(())
}
/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
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())
}
}
/// 对非空的 TOML 文本进行语法校验
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))
}
/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
let s = read_codex_config_text()?;
validate_config_toml(&s)?;
Ok(s)
}
/// Update a field in Codex config.toml using toml_edit (syntax-preserving).
///
/// Supported fields:
/// - `"base_url"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists,
/// otherwise falls back to top-level `base_url`.
/// - `"model"`: writes to top-level `model` field.
///
/// Empty value removes the field.
pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result<String, String> {
let mut doc = toml_str
.parse::<DocumentMut>()
.map_err(|e| format!("TOML parse error: {e}"))?;
let trimmed = value.trim();
match field {
"base_url" => {
let model_provider = doc
.get("model_provider")
.and_then(|item| item.as_str())
.map(str::to_string);
if let Some(provider_key) = model_provider {
// Ensure [model_providers] table exists
if doc.get("model_providers").is_none() {
doc["model_providers"] = toml_edit::table();
}
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
// Ensure [model_providers.<provider_key>] table exists
if !model_providers.contains_key(&provider_key) {
model_providers[&provider_key] = toml_edit::table();
}
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
if trimmed.is_empty() {
provider_table.remove("base_url");
} else {
provider_table["base_url"] = toml_edit::value(trimmed);
}
return Ok(doc.to_string());
}
}
}
// Fallback: no model_provider or structure mismatch → top-level base_url
if trimmed.is_empty() {
doc.as_table_mut().remove("base_url");
} else {
doc["base_url"] = toml_edit::value(trimmed);
}
}
"model" => {
if trimmed.is_empty() {
doc.as_table_mut().remove("model");
} else {
doc["model"] = toml_edit::value(trimmed);
}
}
_ => return Err(format!("unsupported field: {field}")),
}
Ok(doc.to_string())
}
/// Remove `base_url` from the active model_provider section only if it matches `predicate`.
/// Also removes top-level `base_url` if it matches.
/// Used by proxy cleanup to strip local proxy URLs without touching user-configured URLs.
pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> bool) -> String {
let mut doc = match toml_str.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(_) => return toml_str.to_string(),
};
let model_provider = doc
.get("model_provider")
.and_then(|item| item.as_str())
.map(str::to_string);
if let Some(provider_key) = model_provider {
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|v| v.as_table_mut())
{
if let Some(provider_table) = model_providers
.get_mut(provider_key.as_str())
.and_then(|v| v.as_table_mut())
{
let should_remove = provider_table
.get("base_url")
.and_then(|item| item.as_str())
.map(&predicate)
.unwrap_or(false);
if should_remove {
provider_table.remove("base_url");
}
}
}
}
// Fallback: also clean up top-level base_url if it matches
let should_remove_root = doc
.get("base_url")
.and_then(|item| item.as_str())
.map(&predicate)
.unwrap_or(false);
if should_remove_root {
doc.as_table_mut().remove("base_url");
}
doc.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_url_writes_into_correct_model_provider_section() {
let input = r#"model_provider = "any"
model = "gpt-5.1-codex"
[model_providers.any]
name = "any"
wire_api = "responses"
"#;
let result = update_codex_toml_field(input, "base_url", "https://example.com/v1").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
let base_url = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str())
.expect("base_url should be in model_providers.any");
assert_eq!(base_url, "https://example.com/v1");
// Should NOT have top-level base_url
assert!(parsed.get("base_url").is_none());
// wire_api preserved
let wire_api = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.and_then(|v| v.get("wire_api"))
.and_then(|v| v.as_str());
assert_eq!(wire_api, Some("responses"));
}
#[test]
fn base_url_creates_section_when_missing() {
let input = r#"model_provider = "custom"
model = "gpt-4"
"#;
let result = update_codex_toml_field(input, "base_url", "https://custom.api/v1").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
let base_url = parsed
.get("model_providers")
.and_then(|v| v.get("custom"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str())
.expect("should create section and set base_url");
assert_eq!(base_url, "https://custom.api/v1");
}
#[test]
fn base_url_falls_back_to_top_level_without_model_provider() {
let input = r#"model = "gpt-4"
"#;
let result = update_codex_toml_field(input, "base_url", "https://fallback.api/v1").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
let base_url = parsed
.get("base_url")
.and_then(|v| v.as_str())
.expect("should set top-level base_url");
assert_eq!(base_url, "https://fallback.api/v1");
}
#[test]
fn clearing_base_url_removes_only_from_correct_section() {
let input = r#"model_provider = "any"
[model_providers.any]
name = "any"
base_url = "https://old.api/v1"
wire_api = "responses"
[mcp_servers.context7]
command = "npx"
"#;
let result = update_codex_toml_field(input, "base_url", "").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
// base_url removed from model_providers.any
let any_section = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.expect("model_providers.any should exist");
assert!(any_section.get("base_url").is_none());
// wire_api preserved
assert_eq!(
any_section.get("wire_api").and_then(|v| v.as_str()),
Some("responses")
);
// mcp_servers untouched
assert!(parsed.get("mcp_servers").is_some());
}
#[test]
fn model_field_operates_on_top_level() {
let input = r#"model_provider = "any"
model = "gpt-4"
[model_providers.any]
name = "any"
"#;
let result = update_codex_toml_field(input, "model", "gpt-5").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(parsed.get("model").and_then(|v| v.as_str()), Some("gpt-5"));
// Clear model
let result2 = update_codex_toml_field(&result, "model", "").unwrap();
let parsed2: toml::Value = toml::from_str(&result2).unwrap();
assert!(parsed2.get("model").is_none());
}
#[test]
fn preserves_comments_and_whitespace() {
let input = r#"# My Codex config
model_provider = "any"
model = "gpt-4"
# Provider section
[model_providers.any]
name = "any"
base_url = "https://old.api/v1"
"#;
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
// Comments should be preserved
assert!(result.contains("# My Codex config"));
assert!(result.contains("# Provider section"));
}
#[test]
fn does_not_misplace_when_profiles_section_follows() {
let input = r#"model_provider = "any"
[model_providers.any]
name = "any"
base_url = "https://old.api/v1"
[profiles.default]
model = "gpt-4"
"#;
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
// base_url in correct section
let base_url = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str());
assert_eq!(base_url, Some("https://new.api/v1"));
// profiles section untouched
let profile_model = parsed
.get("profiles")
.and_then(|v| v.get("default"))
.and_then(|v| v.get("model"))
.and_then(|v| v.as_str());
assert_eq!(profile_model, Some("gpt-4"));
}
#[test]
fn remove_base_url_if_predicate() {
let input = r#"model_provider = "any"
[model_providers.any]
name = "any"
base_url = "http://127.0.0.1:5000/v1"
wire_api = "responses"
"#;
let result =
remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1"));
let parsed: toml::Value = toml::from_str(&result).unwrap();
let any_section = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.unwrap();
assert!(any_section.get("base_url").is_none());
assert_eq!(
any_section.get("wire_api").and_then(|v| v.as_str()),
Some("responses")
);
}
#[test]
fn remove_base_url_if_keeps_non_matching() {
let input = r#"model_provider = "any"
[model_providers.any]
base_url = "https://production.api/v1"
"#;
let result =
remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1"));
let parsed: toml::Value = toml::from_str(&result).unwrap();
let base_url = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str());
assert_eq!(base_url, Some("https://production.api/v1"));
}
}