mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Preserve common config during proxy takeover
Update takeover backup generation to rebuild effective provider settings with common config applied before saving restore snapshots. Keep Codex mcp_servers entries when hot-switching providers under takeover so restore does not drop live-only MCP config. Migrate legacy providers with inferred common-config usage to explicit commonConfigEnabled=true markers during startup and default imports, and cover the new behavior with proxy and provider regression tests.
This commit is contained in:
@@ -117,6 +117,8 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?;
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
|
||||
@@ -613,6 +613,25 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 5.1 Migrate legacy providers that relied on inferred common-config usage
|
||||
for app_type in [
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
] {
|
||||
if let Err(e) =
|
||||
crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed(
|
||||
&app_state,
|
||||
app_type.clone(),
|
||||
)
|
||||
{
|
||||
log::warn!(
|
||||
"✗ Failed to migrate legacy common-config usage for {}: {e}",
|
||||
app_type.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移旧的 app_config_dir 配置到 Store
|
||||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||||
log::warn!("迁移 app_config_dir 失败: {e}");
|
||||
|
||||
@@ -461,19 +461,18 @@ fn apply_common_config_to_settings(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config(
|
||||
pub(crate) fn build_effective_settings_with_common_config(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
) -> Result<Value, AppError> {
|
||||
let snippet = db.get_config_snippet(app_type.as_str())?;
|
||||
let mut effective_provider = provider.clone();
|
||||
let mut effective_settings = provider.settings_config.clone();
|
||||
|
||||
if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
if let Some(snippet_text) = snippet.as_deref() {
|
||||
match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text)
|
||||
{
|
||||
Ok(settings) => effective_provider.settings_config = settings,
|
||||
match apply_common_config_to_settings(app_type, &effective_settings, snippet_text) {
|
||||
Ok(settings) => effective_settings = settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to apply common config for {} provider '{}': {err}",
|
||||
@@ -485,6 +484,18 @@ pub(crate) fn write_live_with_common_config(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(effective_settings)
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let mut effective_provider = provider.clone();
|
||||
effective_provider.settings_config =
|
||||
build_effective_settings_with_common_config(db, app_type, provider)?;
|
||||
|
||||
write_live_snapshot(app_type, &effective_provider)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ pub use live::{
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::{
|
||||
normalize_provider_common_config_for_storage, strip_common_config_from_live_settings,
|
||||
sync_current_provider_for_app_to_live, write_live_with_common_config,
|
||||
build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,
|
||||
strip_common_config_from_live_settings, sync_current_provider_for_app_to_live,
|
||||
write_live_with_common_config,
|
||||
};
|
||||
|
||||
// Internal re-exports
|
||||
@@ -670,6 +671,25 @@ impl ProviderService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn migrate_legacy_common_config_usage_if_needed(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<(), AppError> {
|
||||
if app_type.is_additive_mode() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Self::migrate_legacy_common_config_usage(state, app_type, &snippet)
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
|
||||
+268
-16
@@ -8,7 +8,9 @@ use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::write_live_with_common_config;
|
||||
use crate::services::provider::{
|
||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -1528,21 +1530,37 @@ impl ProxyService {
|
||||
app_type: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), String> {
|
||||
let backup_json = match app_type {
|
||||
"claude" => {
|
||||
// Claude: settings_config 直接作为备份
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
|
||||
let mut effective_settings =
|
||||
build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)
|
||||
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
|
||||
|
||||
if matches!(app_type_enum, AppType::Codex) {
|
||||
let existing_backup = self
|
||||
.db
|
||||
.get_live_backup(app_type)
|
||||
.await
|
||||
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?;
|
||||
|
||||
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}"))?;
|
||||
Self::preserve_codex_mcp_servers_in_backup(
|
||||
&mut effective_settings,
|
||||
&existing_value,
|
||||
)?;
|
||||
}
|
||||
"codex" => {
|
||||
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
|
||||
serde_json::to_string(&provider.settings_config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
|
||||
}
|
||||
"gemini" => {
|
||||
// Gemini: 只提取 env 字段(与原始备份格式一致)
|
||||
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
|
||||
let env_backup = if let Some(env) = provider.settings_config.get("env") {
|
||||
}
|
||||
|
||||
let backup_json = match app_type_enum {
|
||||
AppType::Claude => serde_json::to_string(&effective_settings)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?,
|
||||
AppType::Codex => serde_json::to_string(&effective_settings)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?,
|
||||
AppType::Gemini => {
|
||||
// Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。
|
||||
let env_backup = if let Some(env) = effective_settings.get("env") {
|
||||
json!({ "env": env })
|
||||
} else {
|
||||
json!({ "env": {} })
|
||||
@@ -1550,7 +1568,9 @@ impl ProxyService {
|
||||
serde_json::to_string(&env_backup)
|
||||
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
|
||||
}
|
||||
_ => return Err(format!("未知的应用类型: {app_type}")),
|
||||
AppType::OpenCode | AppType::OpenClaw => {
|
||||
return Err(format!("未知的应用类型: {app_type}"));
|
||||
}
|
||||
};
|
||||
|
||||
self.db
|
||||
@@ -1562,6 +1582,47 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn preserve_codex_mcp_servers_in_backup(
|
||||
target_settings: &mut Value,
|
||||
existing_backup: &Value,
|
||||
) -> Result<(), String> {
|
||||
let target_obj = target_settings
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?;
|
||||
|
||||
let target_config = target_obj
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let mut target_doc = if target_config.trim().is_empty() {
|
||||
toml_edit::DocumentMut::new()
|
||||
} else {
|
||||
target_config
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))?
|
||||
};
|
||||
|
||||
let existing_config = existing_backup
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if existing_config.trim().is_empty() {
|
||||
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let existing_doc = existing_config
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?;
|
||||
|
||||
if let Some(mcp_servers) = existing_doc.get("mcp_servers") {
|
||||
target_doc["mcp_servers"] = mcp_servers.clone();
|
||||
}
|
||||
|
||||
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 代理模式下切换供应商(热切换,不写 Live)
|
||||
pub async fn switch_proxy_target(
|
||||
&self,
|
||||
@@ -1914,6 +1975,7 @@ impl ProxyService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::ProviderMeta;
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
@@ -2191,4 +2253,194 @@ model = "gpt-5.1-codex"
|
||||
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_applies_claude_common_config() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
db.set_config_snippet(
|
||||
"claude",
|
||||
Some(
|
||||
serde_json::json!({
|
||||
"includeCoAuthoredBy": false
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.expect("set common config snippet");
|
||||
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
"p1".to_string(),
|
||||
"P1".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "token",
|
||||
"ANTHROPIC_BASE_URL": "https://claude.example"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider("claude", &provider)
|
||||
.await
|
||||
.expect("update live backup");
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let stored: Value =
|
||||
serde_json::from_str(&backup.original_config).expect("parse backup json");
|
||||
|
||||
assert_eq!(
|
||||
stored.get("includeCoAuthoredBy").and_then(|v| v.as_bool()),
|
||||
Some(false),
|
||||
"common config should be applied into Claude restore backup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_applies_codex_common_config() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
db.set_config_snippet(
|
||||
"codex",
|
||||
Some("disable_response_storage = true\n".to_string()),
|
||||
)
|
||||
.expect("set common config snippet");
|
||||
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
"p1".to_string(),
|
||||
"P1".to_string(),
|
||||
json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "token"
|
||||
},
|
||||
"config": r#"model_provider = "any"
|
||||
model = "gpt-5"
|
||||
|
||||
[model_providers.any]
|
||||
base_url = "https://codex.example/v1"
|
||||
"#
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
common_config_enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider("codex", &provider)
|
||||
.await
|
||||
.expect("update live backup");
|
||||
|
||||
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 config = stored
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config string");
|
||||
|
||||
assert!(
|
||||
config.contains("disable_response_storage = true"),
|
||||
"common config should be applied into Codex restore backup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
|
||||
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());
|
||||
|
||||
db.save_live_backup(
|
||||
"codex",
|
||||
&serde_json::to_string(&json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "old-token"
|
||||
},
|
||||
"config": r#"model_provider = "any"
|
||||
model = "gpt-4"
|
||||
|
||||
[model_providers.any]
|
||||
base_url = "https://old.example/v1"
|
||||
|
||||
[mcp_servers.echo]
|
||||
command = "npx"
|
||||
args = ["echo-server"]
|
||||
"#
|
||||
}))
|
||||
.expect("serialize seed backup"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"p2".to_string(),
|
||||
"P2".to_string(),
|
||||
json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "new-token"
|
||||
},
|
||||
"config": r#"model_provider = "any"
|
||||
model = "gpt-5"
|
||||
|
||||
[model_providers.any]
|
||||
base_url = "https://new.example/v1"
|
||||
"#
|
||||
}),
|
||||
None,
|
||||
);
|
||||
|
||||
service
|
||||
.update_live_backup_from_provider("codex", &provider)
|
||||
.await
|
||||
.expect("update live backup");
|
||||
|
||||
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 config = stored
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config string");
|
||||
|
||||
assert!(
|
||||
config.contains("[mcp_servers.echo]"),
|
||||
"existing Codex MCP section should survive proxy hot-switch backup update"
|
||||
);
|
||||
assert!(
|
||||
config.contains("https://new.example/v1"),
|
||||
"provider-specific base_url should still update to the new provider"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,80 @@ fn sanitize_provider_name(name: &str) -> String {
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
{
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.current = "legacy-provider".to_string();
|
||||
manager.providers.insert(
|
||||
"legacy-provider".to_string(),
|
||||
Provider::with_id(
|
||||
"legacy-provider".to_string(),
|
||||
"Legacy".to_string(),
|
||||
json!({
|
||||
"includeCoAuthoredBy": false,
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "legacy-key"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let state = create_test_state_with_config(&config).expect("create test state");
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(
|
||||
AppType::Claude.as_str(),
|
||||
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
|
||||
)
|
||||
.expect("set common config snippet");
|
||||
|
||||
ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)
|
||||
.expect("migrate legacy common config");
|
||||
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Claude.as_str())
|
||||
.expect("get providers after migration");
|
||||
let provider = providers
|
||||
.get("legacy-provider")
|
||||
.expect("legacy provider exists");
|
||||
|
||||
assert_eq!(
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.common_config_enabled),
|
||||
Some(true),
|
||||
"historical provider should be explicitly marked as using common config"
|
||||
);
|
||||
assert!(
|
||||
provider
|
||||
.settings_config
|
||||
.get("includeCoAuthoredBy")
|
||||
.is_none(),
|
||||
"common config fields should be stripped from provider storage after migration"
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("legacy-key"),
|
||||
"provider-specific auth should remain untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_service_switch_codex_updates_live_and_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
Reference in New Issue
Block a user