Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion crates/codex-plus-core/src/relay_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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<String> {
let mut doc = parse_toml_document(config_text)?;
remove_provider_specific_common_keys(doc.as_table_mut());
Expand Down
111 changes: 111 additions & 0 deletions crates/codex-plus-core/tests/relay_switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down