mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 19:22:15 +08:00
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
This commit is contained in:
@@ -9,6 +9,7 @@ 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 {
|
||||
@@ -135,3 +136,335 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user