From 4fe765ea9aa7c5dd0e45b69c374ba3aa3cb868f4 Mon Sep 17 00:00:00 2001 From: leiming2333 Date: Sat, 3 Oct 2026 17:21:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(relay-switch):=20=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=89=8D=E5=9B=9E=E5=A1=AB=E4=B8=8D=E5=86=8D=E9=87=87=E7=BA=B3?= =?UTF-8?q?=20cc-switch=20=E5=86=99=E5=85=A5=E7=9A=84=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E9=85=8D=E7=BD=AE=20(issue=20#1497?= =?UTF-8?q?=20#783=20#975)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cc-switch 与 Codex++ 的默认模板占用同一个 provider id(custom)。 cc-switch 接管 ~/.codex 后,live config.toml 变成另一套供应商的投影, 此时用户回 Codex++ 切换供应商,backfill 会把外部供应商的 base_url / API Key 身份 / 模型 / cc-switch catalog 指针整段固化进上一个 Codex++ 供应商;用户切回该供应商时上游、密钥、模型全部错乱,表现为切不回模型。 判定条件(三条同时满足才跳过回填,手工编辑不受影响): - live 与模板的活跃 provider id 相同; - 双方 base_url 均非空且不相等; - live auth.json 的 OPENAI_API_KEY 与模板 API Key 均非空且不相等。 协议代理/聚合模式写 live 时复用 profile 自己的 Key,仅改 base_url 或 仅换 Key 的手工编辑、以及换了 provider id 的形态均不满足全部条件, 仍走原有回填路径。 --- crates/codex-plus-core/src/relay_config.rs | 67 ++++++++++- crates/codex-plus-core/tests/relay_switch.rs | 111 +++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 2d0549caa..452e730aa 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1120,6 +1120,21 @@ pub fn backfill_relay_profile_from_home_with_common( let template_auth = profile.auth_contents.clone(); let template_api_key = relay_profile_api_key(profile); let template_base_url = relay_profile_base_url(profile); + let live_auth = read_optional_text(&home.join("auth.json"))?; + if live_config_is_foreign_provider( + &live_config, + &live_auth, + &template_config, + &template_base_url, + &template_api_key, + ) { + // cc-switch 等外部管理器已把 live 换成另一套供应商。整段回填会把外部 + // 供应商的 base_url / 模型 / catalog 指针固化进本 profile(issue #1497 + // #783),用户切回该供应商时上游与模型全部错乱,表现为"切不回模型"。 + // 保持模板原样,交给本次切换直接覆盖 live;手工编辑场景不受影响 + // (见 live_config_is_foreign_provider 的判定条件)。 + return Ok(()); + } profile.config_contents = if profile.use_common_config { strip_common_config_from_config(&live_config, common_config_contents)? } else { @@ -1146,7 +1161,6 @@ pub fn backfill_relay_profile_from_home_with_common( profile.config_contents = move_model_providers_before_profiles(&ensure_trailing_newline(doc.to_string())); } - let live_auth = read_optional_text(&home.join("auth.json"))?; restore_profile_credentials_after_backfill( profile, &template_auth, @@ -1168,6 +1182,57 @@ pub fn backfill_relay_profile_from_home_with_common( Ok(()) } +/// live config.toml 是否已被 cc-switch 等外部管理器写成**另一个供应商**的投影。 +/// +/// 判定条件(三条同时满足才视为外部接管,尽量不误伤手工编辑): +/// 1. live 与本 profile 模板的活跃 provider id 相同——外部工具占用了同一个 +/// provider 位。cc-switch 的默认模板与 Codex++ 的托管位同名(都是 +/// `model_provider = "custom"`),这是双方冲突的根源(issue #1497); +/// 2. 双方都带非空 base_url 且不相等——Codex++ 自己的 apply / 切回永远写 +/// 模板里的 base_url(协议代理改写有专门的还原分支),不会产生这种差异; +/// 3. live auth.json 的 OPENAI_API_KEY 与模板 API Key 都非空且不相等—— +/// base_url 与密钥两个身份锚点同时漂移,只可能是另一套供应商写进来的; +/// 协议代理/聚合模式写 live 时复用 profile 自己的 Key,因此不会误伤。 +/// +/// 只改 base_url、只换 Key 或换了 provider id(手工编辑的 `manual_a` 形态) +/// 不满足全部条件,仍走原有回填,行为不变。 +fn live_config_is_foreign_provider( + live_config: &str, + live_auth: &str, + template_config: &str, + template_base_url: &str, + template_api_key: &str, +) -> bool { + let (Ok(live_doc), Ok(template_doc)) = ( + parse_toml_document(live_config), + parse_toml_document(template_config), + ) else { + return false; + }; + let (Some(live_provider_id), Some(template_provider_id)) = ( + active_provider_id(&live_doc), + active_provider_id(&template_doc), + ) else { + return false; + }; + if live_provider_id != template_provider_id { + return false; + } + let Some(live_base_url) = provider_string_from_config(live_config, "base_url") else { + return false; + }; + if template_base_url.trim().is_empty() || live_base_url.trim() == template_base_url.trim() { + return false; + } + let (Some(live_api_key), true) = ( + codex_auth_api_key(live_auth), + !template_api_key.trim().is_empty(), + ) else { + return false; + }; + live_api_key.trim() != template_api_key.trim() +} + pub fn extract_common_config_from_config(config_text: &str) -> anyhow::Result { let mut doc = parse_toml_document(config_text)?; remove_provider_specific_common_keys(doc.as_table_mut()); diff --git a/crates/codex-plus-core/tests/relay_switch.rs b/crates/codex-plus-core/tests/relay_switch.rs index 66a9fdbda..c3fd8950c 100644 --- a/crates/codex-plus-core/tests/relay_switch.rs +++ b/crates/codex-plus-core/tests/relay_switch.rs @@ -793,6 +793,117 @@ fn switch_to_aggregate_rejects_corrupt_auth_json() { assert_eq!(live, corrupt, "损坏的 auth.json 不能被静默覆盖"); } +/// 回归(issue #1497 / #783 / #975,cc-switch 共存):cc-switch 接管 ~/.codex 后, +/// live config.toml 是它写入的另一套供应商——provider id 与 Codex++ 托管位同名 +/// (双方默认模板都是 "custom"),但 base_url、API Key、模型、catalog 指针全变了。 +/// 此时用户回 Codex++ 切换供应商,切换前的 backfill 不能把这套外部供应商内容 +/// 固化进上一个 Codex++ 供应商,否则用户切回该供应商时上游、密钥、模型全部 +/// 错乱,表现为"切不回模型"。 +#[test] +fn switch_does_not_backfill_foreign_provider_written_by_cc_switch() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("codex"); + std::fs::create_dir_all(&home).unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + let profiles = vec![ + pure_profile("a", "https://a.example/v1", "sk-a"), + pure_profile("b", "https://b.example/v1", "sk-b"), + ]; + store + .save(&BackendSettings { + active_relay_id: "a".to_string(), + relay_profiles: profiles.clone(), + ..BackendSettings::default() + }) + .unwrap(); + + // 1) Codex++ 先把供应商 a 写进 live + switch_relay_profile_in_home( + &store, + &home, + BackendSettings { + active_relay_id: "a".to_string(), + relay_profiles: profiles.clone(), + ..BackendSettings::default() + }, + "", + ) + .unwrap(); + + // 2) cc-switch 接管 live:同 provider id "custom",另一套供应商身份 + std::fs::write( + home.join("config.toml"), + r#"model = "gpt-5.6-sol" +model_provider = "custom" +model_catalog_json = "cc-switch-model-catalog.json" + +[model_providers.custom] +name = "custom" +wire_api = "responses" +requires_openai_auth = true +base_url = "https://ccswitch.example/v1" +"#, + ) + .unwrap(); + std::fs::write( + home.join("auth.json"), + r#"{"OPENAI_API_KEY":"sk-ccswitch"}"#, + ) + .unwrap(); + + // 3) 用户回 Codex++ 切到供应商 b + switch_relay_profile_in_home( + &store, + &home, + BackendSettings { + active_relay_id: "b".to_string(), + relay_profiles: profiles.clone(), + ..BackendSettings::default() + }, + "a", + ) + .unwrap(); + + // 4) 供应商 a 不得被 cc-switch 的内容污染 + let stored = store.load().unwrap(); + let a = stored + .relay_profiles + .iter() + .find(|profile| profile.id == "a") + .unwrap(); + assert!( + a.config_contents.contains("https://a.example/v1"), + "供应商 a 丢失了自己的 base_url:{}", + a.config_contents + ); + assert!( + !a.config_contents.contains("ccswitch.example"), + "供应商 a 被写入 cc-switch 的 base_url:{}", + a.config_contents + ); + assert!( + !a.config_contents.contains("cc-switch-model-catalog.json"), + "供应商 a 被写入 cc-switch 的 catalog 指针:{}", + a.config_contents + ); + assert!( + a.auth_contents.contains("sk-a"), + "供应商 a 的 API Key 不应被 cc-switch 覆盖:{}", + a.auth_contents + ); + + // 5) live 应当是供应商 b 的完整投影,且不带 cc-switch 的 catalog 指针 + let live = std::fs::read_to_string(home.join("config.toml")).unwrap(); + assert!( + live.contains("https://b.example/v1"), + "live 应该是供应商 b 的:{live}" + ); + assert!( + !live.contains("cc-switch-model-catalog.json"), + "cc-switch 的 catalog 指针应被接管:{live}" + ); +} + fn pure_profile(id: &str, base_url: &str, key: &str) -> RelayProfile { RelayProfile { id: id.to_string(),