修复 Codex 切换供应商后历史记录变化 (#2349)

* Keep Codex history stable across provider switches

* Restore template Codex provider id when backfilling live config

Backfill writes the current Codex live config back to the previous
provider's stored template after a switch. Because the live file now
carries a normalized stable model_provider id, the previous provider's
template would lose its own provider-specific id (and any matching
[profiles.*] references) on every subsequent switch.

Reverse the normalization at backfill time by rewriting model_provider,
the active model_providers section, and matching profile references back
to the template's original id.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
SaladDay
2026-04-30 09:54:25 +08:00
committed by GitHub
parent eb304232b3
commit a1e6c3b65d
6 changed files with 1043 additions and 29 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ impl ConfigService {
}
let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
crate::codex_config::write_codex_live_atomic_with_stable_provider(auth, cfg_text)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
+47 -22
View File
@@ -8,7 +8,9 @@ use serde_json::{json, Value};
use toml_edit::{DocumentMut, Item, TableLike};
use crate::app_config::AppType;
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
use crate::codex_config::{
get_codex_auth_path, get_codex_config_path, write_codex_live_atomic_with_stable_provider,
};
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::error::AppError;
@@ -528,29 +530,55 @@ pub(crate) fn strip_common_config_from_live_settings(
app_type.as_str(),
provider.id
);
return live_settings;
return restore_live_settings_for_provider_backfill(app_type, provider, live_settings);
}
};
if !provider_uses_common_config(app_type, provider, snippet.as_deref()) {
return live_settings;
}
let Some(snippet_text) = snippet.as_deref() else {
return live_settings;
let backfill_settings = if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
match snippet.as_deref() {
Some(snippet_text) => {
match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {
Ok(settings) => settings,
Err(err) => {
log::warn!(
"Failed to strip common config for {} provider '{}': {err}",
app_type.as_str(),
provider.id
);
live_settings
}
}
}
None => live_settings,
}
} else {
live_settings
};
match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {
Ok(settings) => settings,
Err(err) => {
log::warn!(
"Failed to strip common config for {} provider '{}': {err}",
app_type.as_str(),
provider.id
);
live_settings
}
restore_live_settings_for_provider_backfill(app_type, provider, backfill_settings)
}
fn restore_live_settings_for_provider_backfill(
app_type: &AppType,
provider: &Provider,
live_settings: Value,
) -> Value {
if !matches!(app_type, AppType::Codex) {
return live_settings;
}
let mut settings = live_settings;
if let Err(err) = crate::codex_config::restore_codex_settings_config_model_provider_for_backfill(
&mut settings,
&provider.settings_config,
) {
log::warn!(
"Failed to restore Codex provider id while backfilling '{}': {err}",
provider.id
);
}
settings
}
pub(crate) fn normalize_provider_common_config_for_storage(
@@ -683,10 +711,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
})?;
let auth_path = get_codex_auth_path();
write_json_file(&auth_path, auth)?;
let config_path = get_codex_config_path();
std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
write_codex_live_atomic_with_stable_provider(auth, Some(config_str))?;
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
+162 -6
View File
@@ -1476,20 +1476,33 @@ impl ProxyService {
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
if matches!(app_type_enum, AppType::Codex) {
let existing_backup = self
let existing_backup_value = self
.db
.get_live_backup(app_type)
.await
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?;
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?
.map(|backup| {
serde_json::from_str::<Value>(&backup.original_config)
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))
})
.transpose()?;
if let Some(existing_backup) = existing_backup {
let existing_value: Value = serde_json::from_str(&existing_backup.original_config)
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?;
if let Some(existing_value) = existing_backup_value.as_ref() {
Self::preserve_codex_mcp_servers_in_backup(
&mut effective_settings,
&existing_value,
existing_value,
)?;
}
let anchor_config_text = existing_backup_value
.as_ref()
.and_then(|value| value.get("config"))
.and_then(|value| value.as_str());
crate::codex_config::normalize_codex_settings_config_model_provider(
&mut effective_settings,
anchor_config_text,
)
.map_err(|e| format!("归一化 Codex restore backup 失败: {e}"))?;
}
let backup_json = match app_type_enum {
@@ -1749,6 +1762,8 @@ impl ProxyService {
let auth = config.get("auth");
let config_str = config.get("config").and_then(|v| v.as_str());
// Proxy restore writes saved live backups verbatim. Provider-driven writes go
// through write_live_with_common_config(), which normalizes Codex provider ids.
match (auth, config_str) {
(Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?,
@@ -2693,6 +2708,147 @@ base_url = "https://new.example/v1"
);
}
#[tokio::test]
#[serial]
async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let provider_a = Provider::with_id(
"a".to_string(),
"RightCode".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "rightcode-key"
},
"config": r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
);
let provider_b = Provider::with_id(
"b".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "aihubmix-key"
},
"config": 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
"#
}),
None,
);
db.save_provider("codex", &provider_a)
.expect("save provider a");
db.save_provider("codex", &provider_b)
.expect("save provider b");
db.set_current_provider("codex", "a")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Codex, Some("a"))
.expect("set local current provider");
db.save_live_backup(
"codex",
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
)
.await
.expect("seed live backup");
service
.write_codex_live(&json!({
"auth": {
"OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER
},
"config": r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "http://127.0.0.1:15721/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}))
.expect("seed taken-over Codex live config");
service
.hot_switch_provider("codex", "b")
.await
.expect("hot switch Codex provider");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let backup_config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("backup config string");
let parsed_backup: toml::Value =
toml::from_str(backup_config).expect("parse backup config");
assert_eq!(
parsed_backup.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode"),
"provider-derived restore backup should retain stable Codex 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_eq!(
backup_model_providers
.get("rightcode")
.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"
);
service
.restore_live_config_for_app_with_fallback(&AppType::Codex)
.await
.expect("restore Codex live config");
let live = service.read_codex_live().expect("read Codex live config");
let live_config = live
.get("config")
.and_then(|v| v.as_str())
.expect("live config string");
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("rightcode"),
"restored Codex live config should not switch history buckets"
);
assert_eq!(
live.get("auth")
.and_then(|auth| auth.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str()),
Some("aihubmix-key"),
"restore should still use the hot-switched provider auth"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {