diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 37b536dbd..3f1031373 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -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, AppError> { - if config_text.trim().is_empty() { - return Ok(None); - } - - let doc = config_text - .parse::() - .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 { - if config_text.trim().is_empty() { - return Ok(config_text.to_string()); - } - - let mut doc = config_text - .parse::() - .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 = 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 { - 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::() - .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 { - 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" ); } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 62c1f1ae3..bb0f19190 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -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()); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index fc9fa6551..60dc25e60 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -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()),