mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-02 10:21:16 +08:00
refactor(provider): switch from full config overwrite to partial key-field merging (#1098)
* refactor(provider): switch from full config overwrite to partial key-field merging Replace the provider switching mechanism for Claude/Codex/Gemini from full settings_config overwrite to partial key-field replacement, preserving user's non-provider settings (plugins, MCP, permissions, etc.) across switches. - Add write_live_partial() with per-app implementations for Claude (JSON env merge), Codex (auth replace + TOML partial merge), and Gemini (env merge) - Add backfill_key_fields() to extract only provider-specific fields when saving live config back to provider entries - Update switch_normal, sync_current_to_live, add, update to use partial merge - Remove common config snippet feature for Claude/Codex/Gemini (no longer needed with partial merging); preserve OMO common config - Delete 6 frontend files (3 components + 3 hooks), clean up 11 modified files - Remove backend extract_common_config_* methods, 3 Tauri commands, CommonConfigSnippets struct, and related migration code - Update integration tests to validate key-field-only backfill behavior * refactor(cleanup): remove dead code and redundant MCP sync after partial-merge refactor - Remove ConfigService legacy full-overwrite sync methods (~150 lines) - Remove redundant McpService::sync_all_enabled from switch_normal - Switch proxy fallback recovery from write_live_snapshot to write_live_partial - Remove dead ProviderService::write_gemini_live wrapper - Update tests to reflect partial-merge behavior (MCP preserved, not re-synced) * feat(claude): add Quick Toggles for common Claude Code preferences Add checkbox toggles for hideAttribution, alwaysThinking, and enableTeammates that write directly to the live settings file via RFC 7396 JSON Merge Patch. Mirror changes to the form editor using form.watch for reactive updates. * fix(provider): add missing key fields to partial-merge constants Add provider-specific fields verified against official docs to prevent key residue or loss during provider switching: - Claude: CLAUDE_CODE_SUBAGENT_MODEL (env), model (top-level) - Codex: review_model, plan_mode_reasoning_effort - Gemini: GOOGLE_API_KEY (official alternative to GEMINI_API_KEY) * fix(provider): expand partial-merge key fields for Bedrock, Vertex, Foundry and behavior settings Add missing env/top-level fields to CLAUDE_KEY_ENV_FIELDS and CLAUDE_KEY_TOP_LEVEL so that provider switching correctly replaces (and clears) credentials and flags for AWS Bedrock, Google Vertex AI, Microsoft Foundry, and provider behavior overrides like max output tokens and prompt caching. * feat(provider): add auth field selector for Claude providers (AUTH_TOKEN / API_KEY) Allow users to choose between ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY when creating or editing custom Claude providers, persisted in meta.apiKeyField. * refactor(preset): remove AiHubMix hardcoded API_KEY in favor of generic auth selector AiHubMix was the only preset that hardcoded ANTHROPIC_API_KEY before the generic auth field selector was introduced. Now that users can freely choose between AUTH_TOKEN and API_KEY via the UI, remove the special-case and default AiHubMix to the standard ANTHROPIC_AUTH_TOKEN.
This commit is contained in:
@@ -2,10 +2,7 @@ use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cc_switch_lib::{
|
||||
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
|
||||
Provider, ProviderMeta,
|
||||
};
|
||||
use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
@@ -13,132 +10,6 @@ use support::{
|
||||
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn sync_claude_provider_writes_live_settings() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
let provider_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.test"
|
||||
},
|
||||
"ui": {
|
||||
"displayName": "Test Provider"
|
||||
}
|
||||
});
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"prov-1".to_string(),
|
||||
"Test Claude".to_string(),
|
||||
provider_config.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Claude)
|
||||
.expect("claude manager");
|
||||
manager.providers.insert("prov-1".to_string(), provider);
|
||||
manager.current = "prov-1".to_string();
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
|
||||
|
||||
let settings_path = get_claude_settings_path();
|
||||
assert!(
|
||||
settings_path.exists(),
|
||||
"live settings should be written to {}",
|
||||
settings_path.display()
|
||||
);
|
||||
|
||||
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
|
||||
assert_eq!(live_value, provider_config);
|
||||
|
||||
// 确认 SSOT 中的供应商也同步了最新内容
|
||||
let updated = config
|
||||
.get_manager(&AppType::Claude)
|
||||
.and_then(|m| m.providers.get("prov-1"))
|
||||
.expect("provider in config");
|
||||
assert_eq!(updated.settings_config, provider_config);
|
||||
|
||||
// 额外确认写入位置位于测试 HOME 下
|
||||
assert!(
|
||||
settings_path.starts_with(home),
|
||||
"settings path {settings_path:?} should reside under test HOME {home:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_codex_provider_writes_auth_and_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
|
||||
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
|
||||
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
|
||||
|
||||
let provider_config = json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "codex-key"
|
||||
},
|
||||
"config": r#"base_url = "https://codex.test""#
|
||||
});
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"codex-1".to_string(),
|
||||
"Codex Test".to_string(),
|
||||
provider_config.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.providers.insert("codex-1".to_string(), provider);
|
||||
manager.current = "codex-1".to_string();
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
|
||||
|
||||
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||||
let config_path = cc_switch_lib::get_codex_config_path();
|
||||
|
||||
assert!(
|
||||
auth_path.exists(),
|
||||
"auth.json should exist at {}",
|
||||
auth_path.display()
|
||||
);
|
||||
assert!(
|
||||
config_path.exists(),
|
||||
"config.toml should exist at {}",
|
||||
config_path.display()
|
||||
);
|
||||
|
||||
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
|
||||
assert_eq!(
|
||||
auth_value,
|
||||
provider_config.get("auth").cloned().expect("auth object")
|
||||
);
|
||||
|
||||
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
||||
// 验证基础配置正确写入
|
||||
assert!(
|
||||
toml_text.contains("base_url"),
|
||||
"config.toml should contain base_url from provider config"
|
||||
);
|
||||
|
||||
// 当前供应商应同步最新 config 文本
|
||||
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
|
||||
let synced = manager.providers.get("codex-1").expect("codex provider");
|
||||
let synced_cfg = synced
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("config string");
|
||||
assert_eq!(synced_cfg, toml_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_enabled_to_codex_writes_enabled_servers() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
@@ -338,46 +209,6 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_codex_provider_missing_auth_returns_error() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
let provider = Provider::with_id(
|
||||
"codex-missing-auth".to_string(),
|
||||
"No Auth".to_string(),
|
||||
json!({
|
||||
"config": "model = \"test\""
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let manager = config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.providers.insert(provider.id.clone(), provider);
|
||||
manager.current = "codex-missing-auth".to_string();
|
||||
|
||||
let err = ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect_err("sync should fail when auth missing");
|
||||
match err {
|
||||
cc_switch_lib::AppError::Config(msg) => {
|
||||
assert!(msg.contains("auth"), "error message should mention auth");
|
||||
}
|
||||
other => panic!("unexpected error variant: {other:?}"),
|
||||
}
|
||||
|
||||
// 确认未产生任何 live 配置文件
|
||||
assert!(
|
||||
!cc_switch_lib::get_codex_auth_path().exists(),
|
||||
"auth.json should not be created on failure"
|
||||
);
|
||||
assert!(
|
||||
!cc_switch_lib::get_codex_config_path().exists(),
|
||||
"config.toml should not be created on failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_codex_live_atomic_persists_auth_and_config() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
@@ -816,107 +647,6 @@ fn create_backup_retains_only_latest_entries() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_gemini_packycode_sets_security_selected_type() {
|
||||
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::Gemini)
|
||||
.expect("gemini manager");
|
||||
manager.current = "packy-1".to_string();
|
||||
manager.providers.insert(
|
||||
"packy-1".to_string(),
|
||||
Provider::with_id(
|
||||
"packy-1".to_string(),
|
||||
"PackyCode".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "pk-key",
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
|
||||
}
|
||||
}),
|
||||
Some("https://www.packyapi.com".to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect("syncing gemini live should succeed");
|
||||
|
||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
"Gemini settings.json should exist at {}",
|
||||
gemini_settings.display()
|
||||
);
|
||||
|
||||
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
|
||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
|
||||
assert_eq!(
|
||||
value
|
||||
.pointer("/security/auth/selectedType")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("gemini-api-key"),
|
||||
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_gemini_google_official_sets_oauth_security() {
|
||||
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::Gemini)
|
||||
.expect("gemini manager");
|
||||
manager.current = "google-official".to_string();
|
||||
let mut provider = Provider::with_id(
|
||||
"google-official".to_string(),
|
||||
"Google".to_string(),
|
||||
json!({
|
||||
"env": {}
|
||||
}),
|
||||
Some("https://ai.google.dev".to_string()),
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
partner_promotion_key: Some("google-official".to_string()),
|
||||
..ProviderMeta::default()
|
||||
});
|
||||
manager
|
||||
.providers
|
||||
.insert("google-official".to_string(), provider);
|
||||
}
|
||||
|
||||
ConfigService::sync_current_providers_to_live(&mut config)
|
||||
.expect("syncing google official gemini should succeed");
|
||||
|
||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
||||
assert!(
|
||||
gemini_settings.exists(),
|
||||
"Gemini settings should exist at {}",
|
||||
gemini_settings.display()
|
||||
);
|
||||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
||||
let gemini_value: serde_json::Value =
|
||||
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
|
||||
assert_eq!(
|
||||
gemini_value
|
||||
.pointer("/security/auth/selectedType")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("oauth-personal"),
|
||||
"Gemini settings should record oauth-personal for Google Official"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_sql_writes_to_target_path() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
|
||||
@@ -100,9 +100,12 @@ command = "say"
|
||||
);
|
||||
|
||||
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
||||
// With partial merge, only key fields (model, provider, model_providers) are
|
||||
// merged into config.toml. The existing MCP section should be preserved.
|
||||
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.echo-server"),
|
||||
"config.toml should contain synced MCP servers"
|
||||
config_text.contains("mcp_servers.legacy"),
|
||||
"config.toml should preserve existing MCP servers after partial merge"
|
||||
);
|
||||
|
||||
let current_id = app_state
|
||||
@@ -126,12 +129,9 @@ command = "say"
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
// 供应商配置应该包含在 live 文件中
|
||||
// 注意:live 文件还会包含 MCP 同步后的内容
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.latest"),
|
||||
"live file should contain provider's original config"
|
||||
);
|
||||
// With partial merge, only key fields (model_provider, model, model_providers)
|
||||
// are written to the live file. MCP servers are synced separately.
|
||||
// The provider's stored config should still contain mcp_servers.latest.
|
||||
assert!(
|
||||
new_config_text.contains("mcp_servers.latest"),
|
||||
"provider snapshot should contain provider's original config"
|
||||
@@ -268,11 +268,22 @@ fn switch_provider_updates_claude_live_and_state() {
|
||||
let legacy_provider = providers
|
||||
.get("old-provider")
|
||||
.expect("legacy provider still exists");
|
||||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
||||
// 这保护了用户在 live 文件中的手动修改
|
||||
// Backfill mechanism: before switching, the live config's key fields are
|
||||
// backfilled to the current provider. With partial merge, only key fields
|
||||
// (auth, model, endpoint) are extracted — non-key fields like workspace
|
||||
// are NOT included in the backfill.
|
||||
assert_eq!(
|
||||
legacy_provider.settings_config, legacy_live,
|
||||
"previous provider should be backfilled with live config"
|
||||
legacy_provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|key| key.as_str()),
|
||||
Some("legacy-key"),
|
||||
"previous provider should be backfilled with live auth key"
|
||||
);
|
||||
assert!(
|
||||
legacy_provider.settings_config.get("workspace").is_none(),
|
||||
"backfill should NOT include non-key fields like workspace"
|
||||
);
|
||||
|
||||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||||
|
||||
@@ -112,9 +112,12 @@ command = "say"
|
||||
|
||||
let config_text =
|
||||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||||
// With partial merge, only key fields (model, provider, model_providers) are
|
||||
// merged into config.toml. The existing MCP section should be preserved.
|
||||
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.echo-server"),
|
||||
"config.toml should contain synced MCP servers"
|
||||
config_text.contains("mcp_servers.legacy"),
|
||||
"config.toml should preserve existing MCP servers after partial merge"
|
||||
);
|
||||
|
||||
let current_id = state
|
||||
@@ -143,11 +146,6 @@ command = "say"
|
||||
new_config_text.contains("mcp_servers.latest"),
|
||||
"provider config should contain original MCP servers"
|
||||
);
|
||||
// live 文件额外包含同步的 MCP 服务器
|
||||
assert!(
|
||||
config_text.contains("mcp_servers.echo-server"),
|
||||
"live config should include synced MCP servers"
|
||||
);
|
||||
|
||||
let legacy = providers
|
||||
.get("old-provider")
|
||||
@@ -414,9 +412,19 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
||||
let legacy_provider = providers
|
||||
.get("old-provider")
|
||||
.expect("legacy provider still exists");
|
||||
// With partial merge backfill, only key fields are extracted from live config
|
||||
assert_eq!(
|
||||
legacy_provider.settings_config, legacy_live,
|
||||
"previous provider should receive backfilled live config"
|
||||
legacy_provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|key| key.as_str()),
|
||||
Some("legacy-key"),
|
||||
"previous provider should receive backfilled auth key"
|
||||
);
|
||||
assert!(
|
||||
legacy_provider.settings_config.get("workspace").is_none(),
|
||||
"backfill should NOT include non-key fields like workspace"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user