refactor(codex): stop force-rewriting user's model_provider field in live config

The one-time history migration already consolidates legacy provider IDs
into a single bucket; there is no need to normalize on every write.
This preserves user-chosen provider identities through the full
write/backup/restore cycle.
This commit is contained in:
Jason
2026-05-28 11:39:54 +08:00
parent 6b0dd3c4e9
commit a2ac21d0a6
3 changed files with 63 additions and 401 deletions
+52 -385
View File
@@ -183,244 +183,15 @@ pub(crate) fn stable_codex_model_provider_id_from_config(config_text: &str) -> O
}
}
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(config_text: &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());
}
if !is_custom_codex_model_provider_id(&source_provider_id) {
return Ok(config_text.to_string());
}
let stable_provider_id = 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));
}
}
}
/// Keep Codex's active `model_provider` stable across CC Switch provider changes.
///
/// Codex stores and filters resume history by `model_provider`, so switching between
/// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move.
/// CC Switch-managed third-party providers share one stable bucket while official
/// built-in providers such as `openai` keep their original identity.
pub fn normalize_codex_settings_config_model_provider(
settings: &mut Value,
) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
else {
return Ok(());
};
let normalized = normalize_codex_live_config_model_provider(&config_text)?;
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())
}
/// Convert a Codex live config that was normalized for history stability back
/// to the provider-specific id used by the stored provider template.
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(())
}
/// Atomically write Codex live config after normalizing provider-specific ids.
///
/// Use this for provider-driven live writes. Keep `write_codex_live_atomic` available
/// for exact restore/backup paths that must preserve the config text byte-for-byte.
pub fn write_codex_live_atomic_with_stable_provider(
auth: &Value,
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
match config_text_opt {
Some(config_text) => {
let config_text = normalize_codex_config_for_live_provider(config_text)?;
write_codex_live_atomic(auth, Some(&config_text))
}
None => write_codex_live_atomic(auth, None),
}
}
fn normalize_codex_config_for_live_provider(config_text: &str) -> Result<String, AppError> {
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)?;
Ok(settings
.get("config")
.and_then(|value| value.as_str())
.unwrap_or(config_text)
.to_string())
}
/// Write only Codex `config.toml` for provider switching.
///
/// Codex login state lives in `auth.json`; provider routing, endpoint, model,
/// and provider-scoped bearer tokens live in `config.toml`. Provider switches
/// should not overwrite the user's ChatGPT login cache.
pub fn write_codex_live_config_atomic_with_stable_provider(
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
pub fn write_codex_live_config_atomic(config_text_opt: Option<&str>) -> Result<(), AppError> {
let config_path = get_codex_config_path();
let cfg_text = match config_text_opt {
Some(config_text) => normalize_codex_config_for_live_provider(config_text)?,
Some(config_text) => config_text.to_string(),
None => String::new(),
};
@@ -885,7 +656,7 @@ pub fn write_codex_live_with_catalog(
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text))
.transpose()?;
write_codex_live_atomic_with_stable_provider(auth, prepared_config.as_deref())
write_codex_live_atomic(auth, prepared_config.as_deref())
}
pub fn write_codex_provider_live_with_catalog(
@@ -1037,10 +808,10 @@ pub fn write_codex_live_for_provider(
config_text: Option<&str>,
) -> Result<(), AppError> {
if category == Some("official") && codex_auth_has_login_material(auth) {
write_codex_live_atomic_with_stable_provider(auth, config_text)
write_codex_live_atomic(auth, config_text)
} else {
let live_config = prepare_codex_provider_live_config(auth, config_text.unwrap_or(""))?;
write_codex_live_config_atomic_with_stable_provider(Some(&live_config))
write_codex_live_config_atomic(Some(&live_config))
}
}
@@ -1110,7 +881,6 @@ pub fn restore_codex_settings_for_backfill(
template_settings: &Value,
restore_provider_token: bool,
) -> Result<(), AppError> {
restore_codex_settings_config_model_provider_for_backfill(settings, template_settings)?;
if restore_provider_token {
restore_codex_provider_token_for_backfill(settings, template_settings)?;
}
@@ -1237,84 +1007,6 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn normalize_live_config_uses_custom_for_third_party_model_provider_id() {
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(target).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("custom")
);
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("custom")
.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_custom_for_custom_provider_even_without_anchor() {
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(target).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("custom")
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("custom"))
.is_some(),
"third-party provider id should be normalized to custom"
);
}
#[test]
fn normalize_live_config_leaves_official_empty_config_unchanged() {
let result = normalize_codex_live_config_model_provider("").unwrap();
assert_eq!(result, "");
}
#[test]
fn prepare_provider_live_config_rejects_key_without_config() {
let err = prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), "")
@@ -1418,8 +1110,8 @@ model = "gpt-5"
}
#[test]
fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
let target = r#"model_provider = "vendor_alpha"
fn prepare_provider_live_config_preserves_custom_provider_id() {
let input = r#"model_provider = "vendor_alpha"
model = "gpt-5.4"
profile = "work"
@@ -1433,12 +1125,29 @@ model_provider = "vendor_alpha"
model = "gpt-5.4"
"#;
let result = normalize_codex_live_config_model_provider(target).unwrap();
let result =
prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), input)
.expect("prepare live config");
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("custom")
Some("vendor_alpha")
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("custom"))
.is_none(),
"provider writes should not force custom provider ids"
);
assert_eq!(
parsed
.get("model_providers")
.and_then(|v| v.get("vendor_alpha"))
.and_then(|v| v.get("experimental_bearer_token"))
.and_then(|v| v.as_str()),
Some("sk-test")
);
assert_eq!(
parsed
@@ -1446,90 +1155,48 @@ model = "gpt-5.4"
.and_then(|v| v.get("work"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("custom"),
"profile override matching the rewritten provider should stay valid"
Some("vendor_alpha"),
"profile provider references should be preserved"
);
}
#[test]
fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
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(target).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_custom_across_repeated_switches() {
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"
fn backfill_preserves_live_model_provider_id() {
let mut live_settings = json!({
"auth": {},
"config": r#"model_provider = "vendor_beta"
[model_providers.vendor_beta]
name = "Vendor Beta"
base_url = "https://beta.example/v1"
wire_api = "responses"
"#;
"#,
});
let template_settings = json!({
"auth": {},
"config": r#"model_provider = "custom"
let first = normalize_codex_live_config_model_provider(first_target).unwrap();
let second = normalize_codex_live_config_model_provider(second_target).unwrap();
let first_parsed: toml::Value = toml::from_str(&first).unwrap();
let parsed: toml::Value = toml::from_str(&second).unwrap();
[model_providers.custom]
name = "Custom"
base_url = "https://custom.example/v1"
wire_api = "responses"
"#,
});
restore_codex_settings_for_backfill(&mut live_settings, &template_settings, false).unwrap();
let config = live_settings.get("config").and_then(Value::as_str).unwrap();
let parsed: toml::Value = toml::from_str(config).unwrap();
assert_eq!(
first_parsed.get("model_provider").and_then(|v| v.as_str()),
Some("custom")
);
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("custom"),
"stable provider id should not drift across repeated switches"
Some("vendor_beta")
);
assert_eq!(
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("custom"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://beta.example/v1")
.and_then(|v| v.get("vendor_beta"))
.is_some(),
"backfill should not rewrite user-selected provider tables"
);
}
+2 -2
View File
@@ -187,8 +187,8 @@ impl ConfigService {
&provider.settings_config,
restore_provider_token,
)?;
// 必须同时写回 auth 和 config: backfill 会恢复 stored provider id
// 并把 live 的 experimental_bearer_token 移到 restored.auth.OPENAI_API_KEY。
// 必须同时写回 auth 和 config: backfill 会把 live 的
// experimental_bearer_token 移到 restored.auth.OPENAI_API_KEY。
if let Some(restored_obj) = restored.as_object() {
if let Some(auth_value) = restored_obj.get("auth") {
obj.insert("auth".to_string(), auth_value.clone());
+9 -14
View File
@@ -1774,11 +1774,6 @@ impl ProxyService {
existing_value,
)?;
}
crate::codex_config::normalize_codex_settings_config_model_provider(
&mut effective_settings,
)
.map_err(|e| format!("归一化 Codex restore backup 失败: {e}"))?;
}
let backup_json = match app_type_enum {
@@ -3566,7 +3561,7 @@ base_url = "https://new.example/v1"
#[tokio::test]
#[serial]
async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {
async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
@@ -3663,21 +3658,21 @@ requires_openai_auth = true
toml::from_str(backup_config).expect("parse backup config");
assert_eq!(
parsed_backup.get("model_provider").and_then(|v| v.as_str()),
Some("custom"),
"provider-derived restore backup should retain stable Codex model_provider"
Some("aihubmix"),
"provider-derived restore backup should preserve the provider's model_provider"
);
let backup_model_providers = parsed_backup
.get("model_providers")
.and_then(|v| v.as_table())
.expect("backup model_providers");
assert!(backup_model_providers.get("aihubmix").is_none());
assert!(backup_model_providers.get("custom").is_none());
assert_eq!(
backup_model_providers
.get("custom")
.get("aihubmix")
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1"),
"stable provider id should point at the hot-switched provider endpoint"
"provider id should point at the hot-switched provider endpoint"
);
service
@@ -3693,8 +3688,8 @@ requires_openai_auth = true
let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config");
assert_eq!(
parsed_live.get("model_provider").and_then(|v| v.as_str()),
Some("custom"),
"restored Codex live config should not switch history buckets"
Some("aihubmix"),
"restored Codex live config should preserve the provider's model_provider"
);
assert_eq!(
live.get("auth")
@@ -3802,7 +3797,7 @@ requires_openai_auth = true
assert_eq!(
parsed_live.get("model_provider").and_then(|v| v.as_str()),
Some("custom")
Some("stable")
);
assert_eq!(
parsed_live.get("model").and_then(|v| v.as_str()),