mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
revert: restore full config overwrite + Common Config Snippet (revert 992dda5c)
Revert the partial key-field merging refactoring introduced in992dda5c, along with two dependent commits (24fa8a18,87604b18) that referenced the now-removed ClaudeQuickToggles component. The whitelist-based partial merge approach had critical issues: - Non-whitelisted custom fields were lost during provider switching - Backfill permanently stripped non-key fields from the database - Whitelist required constant maintenance to track upstream changes This restores the proven "full config overwrite + Common Config Snippet" architecture where each provider stores its complete configuration and shared settings are managed via a separate snippet mechanism. Reverted commits: -24fa8a18: context-aware JSON editor hint + hide quick toggles -87604b18: hide ClaudeQuickToggles when creating -992dda5c: partial key-field merging refactoring Restored: - Full config snapshot write (write_live_snapshot) for Claude/Codex/Gemini - Full config backfill (settings_config = live_config) - Common Config Snippet UI and backend commands - 6 frontend components/hooks for common config editing - configApi barrel export and DB snippet methods Removed: - ClaudeQuickToggles component - write_live_partial / backfill_key_fields / patch_claude_live - All KEY_FIELDS constants
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
|
||||
use crate::app_config::{AppType, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -78,4 +82,150 @@ impl ConfigService {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 同步当前供应商到对应的 live 配置。
|
||||
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
||||
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_current_provider_for_app(
|
||||
config: &mut MultiAppConfig,
|
||||
app_type: &AppType,
|
||||
) -> Result<(), AppError> {
|
||||
let (current_id, provider) = {
|
||||
let manager = match config.get_manager(app_type) {
|
||||
Some(manager) => manager,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if manager.current.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_id = manager.current.clone();
|
||||
let provider = match manager.providers.get(¤t_id) {
|
||||
Some(provider) => provider.clone(),
|
||||
None => {
|
||||
log::warn!(
|
||||
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
(current_id, provider)
|
||||
};
|
||||
|
||||
match app_type {
|
||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
// OpenCode providers are managed directly in the config file
|
||||
}
|
||||
AppType::OpenClaw => {
|
||||
// OpenClaw uses additive mode, no live sync needed
|
||||
// OpenClaw providers are managed directly in the config file
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_codex_live(
|
||||
config: &mut MultiAppConfig,
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
||||
})?;
|
||||
let auth = settings.get("auth").ok_or_else(|| {
|
||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
||||
})?;
|
||||
if !auth.is_object() {
|
||||
return Err(AppError::Config(format!(
|
||||
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
|
||||
)));
|
||||
}
|
||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
||||
|
||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
if let Some(obj) = target.settings_config.as_object_mut() {
|
||||
obj.insert(
|
||||
"config".to_string(),
|
||||
serde_json::Value::String(cfg_text_after),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_claude_live(
|
||||
config: &mut MultiAppConfig,
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
use crate::config::{read_json_file, write_json_file};
|
||||
|
||||
let settings_path = crate::config::get_claude_settings_path();
|
||||
if let Some(parent) = settings_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
||||
write_json_file(&settings_path, &settings)?;
|
||||
|
||||
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
target.settings_config = live_after;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_gemini_live(
|
||||
config: &mut MultiAppConfig,
|
||||
provider_id: &str,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{env_to_json, read_gemini_env};
|
||||
|
||||
ProviderService::write_gemini_live(provider)?;
|
||||
|
||||
// 读回实际写入的内容并更新到配置中(包含 settings.json)
|
||||
let live_after_env = read_gemini_env()?;
|
||||
let settings_path = crate::gemini_config::get_gemini_settings_path();
|
||||
let live_after_config = if settings_path.exists() {
|
||||
crate::config::read_json_file(&settings_path)?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let mut live_after = env_to_json(&live_after_env);
|
||||
if let Some(obj) = live_after.as_object_mut() {
|
||||
obj.insert("config".to_string(), live_after_config);
|
||||
}
|
||||
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
||||
target.settings_config = live_after;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,439 +237,6 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Key fields definitions for partial merge
|
||||
// ============================================================================
|
||||
|
||||
/// Claude env-level key fields that belong to the provider.
|
||||
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||
const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[
|
||||
// --- API auth & endpoint ---
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
// --- Model selection ---
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
// --- AWS Bedrock ---
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AWS_REGION",
|
||||
"AWS_PROFILE",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION",
|
||||
// --- Google Vertex AI ---
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"ANTHROPIC_VERTEX_PROJECT_ID",
|
||||
"CLOUD_ML_REGION",
|
||||
// --- Microsoft Foundry ---
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
// --- Provider behavior ---
|
||||
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
||||
"API_TIMEOUT_MS",
|
||||
"DISABLE_PROMPT_CACHING",
|
||||
];
|
||||
|
||||
/// Claude top-level key fields (legacy + modern format).
|
||||
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||
const CLAUDE_KEY_TOP_LEVEL: &[&str] = &[
|
||||
"apiBaseUrl", // legacy
|
||||
"primaryModel", // legacy
|
||||
"smallFastModel", // legacy
|
||||
"model", // modern
|
||||
"apiKey", // Bedrock API Key auth
|
||||
];
|
||||
|
||||
/// Codex TOML key fields.
|
||||
/// When adding a new field here, also update backfill_codex_key_fields().
|
||||
const CODEX_KEY_TOP_LEVEL: &[&str] = &[
|
||||
"model_provider",
|
||||
"model",
|
||||
"model_reasoning_effort",
|
||||
"review_model",
|
||||
"plan_mode_reasoning_effort",
|
||||
];
|
||||
|
||||
/// Gemini env-level key fields.
|
||||
/// When adding a new field here, also update backfill_gemini_key_fields().
|
||||
const GEMINI_KEY_ENV_FIELDS: &[&str] = &[
|
||||
"GOOGLE_GEMINI_BASE_URL",
|
||||
"GEMINI_API_KEY",
|
||||
"GEMINI_MODEL",
|
||||
"GOOGLE_API_KEY",
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Partial merge: write only key fields to live config
|
||||
// ============================================================================
|
||||
|
||||
/// Write only provider-specific key fields to live configuration,
|
||||
/// preserving all other user settings in the live file.
|
||||
///
|
||||
/// Used for switch-mode apps (Claude, Codex, Gemini) during:
|
||||
/// - `switch_normal()` — switching providers
|
||||
/// - `sync_current_to_live()` — startup sync
|
||||
/// - `add()` / `update()` when the provider is current
|
||||
pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => write_claude_live_partial(provider),
|
||||
AppType::Codex => write_codex_live_partial(provider),
|
||||
AppType::Gemini => write_gemini_live_partial(provider),
|
||||
// Additive mode apps still use full snapshot
|
||||
AppType::OpenCode | AppType::OpenClaw => write_live_snapshot(app_type, provider),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json.
|
||||
/// Used for user-level preferences (attribution, thinking, etc.) that are
|
||||
/// independent of the active provider.
|
||||
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||
let path = get_claude_settings_path();
|
||||
let mut live = if path.exists() {
|
||||
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
json_merge_patch(&mut live, &patch);
|
||||
let settings = sanitize_claude_settings_for_live(&live);
|
||||
write_json_file(&path, &settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites.
|
||||
fn json_merge_patch(target: &mut Value, patch: &Value) {
|
||||
if let Some(patch_obj) = patch.as_object() {
|
||||
if !target.is_object() {
|
||||
*target = json!({});
|
||||
}
|
||||
let target_obj = target.as_object_mut().unwrap();
|
||||
for (key, value) in patch_obj {
|
||||
if value.is_null() {
|
||||
target_obj.remove(key);
|
||||
} else if value.is_object() {
|
||||
let entry = target_obj.entry(key.clone()).or_insert(json!({}));
|
||||
json_merge_patch(entry, value);
|
||||
// Clean up empty container objects
|
||||
if entry.as_object().is_some_and(|o| o.is_empty()) {
|
||||
target_obj.remove(key);
|
||||
}
|
||||
} else {
|
||||
target_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude: merge only key env and top-level fields into live settings.json
|
||||
fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||
let path = get_claude_settings_path();
|
||||
|
||||
// 1. Read existing live config (start from empty if file doesn't exist)
|
||||
let mut live = if path.exists() {
|
||||
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
|
||||
// 2. Ensure live.env exists as an object
|
||||
if !live.get("env").is_some_and(|v| v.is_object()) {
|
||||
live.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("env".into(), json!({}));
|
||||
}
|
||||
|
||||
// 3. Clear key env fields from live, then write from provider
|
||||
let live_env = live.get_mut("env").unwrap().as_object_mut().unwrap();
|
||||
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||
live_env.remove(*key);
|
||||
}
|
||||
|
||||
if let Some(provider_env) = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||
if let Some(value) = provider_env.get(*key) {
|
||||
live_env.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle top-level legacy key fields
|
||||
let live_obj = live.as_object_mut().unwrap();
|
||||
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||
live_obj.remove(*key);
|
||||
}
|
||||
if let Some(provider_obj) = provider.settings_config.as_object() {
|
||||
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||
if let Some(value) = provider_obj.get(*key) {
|
||||
live_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Sanitize and write
|
||||
let settings = sanitize_claude_settings_for_live(&live);
|
||||
write_json_file(&path, &settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Codex: replace auth.json entirely, partially merge config.toml key fields
|
||||
fn write_codex_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||
let obj = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
||||
|
||||
// auth.json is entirely provider-specific, replace it wholesale
|
||||
let auth = obj
|
||||
.get("auth")
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||
|
||||
let provider_config_str = obj.get("config").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Read existing config.toml (or start from empty)
|
||||
let config_path = get_codex_config_path();
|
||||
let existing_toml = if config_path.exists() {
|
||||
std::fs::read_to_string(&config_path).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Parse both existing and provider TOML
|
||||
let mut live_doc = existing_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap_or_else(|_| toml_edit::DocumentMut::new());
|
||||
|
||||
// Remove key fields from live doc
|
||||
let live_root = live_doc.as_table_mut();
|
||||
for key in CODEX_KEY_TOP_LEVEL {
|
||||
live_root.remove(key);
|
||||
}
|
||||
live_root.remove("model_providers");
|
||||
|
||||
// Parse provider TOML and extract key fields
|
||||
if !provider_config_str.is_empty() {
|
||||
if let Ok(provider_doc) = provider_config_str.parse::<toml_edit::DocumentMut>() {
|
||||
let provider_root = provider_doc.as_table();
|
||||
|
||||
// Copy key top-level fields from provider
|
||||
for key in CODEX_KEY_TOP_LEVEL {
|
||||
if let Some(item) = provider_root.get(key) {
|
||||
live_root.insert(key, item.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy model_providers table from provider
|
||||
if let Some(mp) = provider_root.get("model_providers") {
|
||||
live_root.insert("model_providers", mp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write using atomic write
|
||||
crate::codex_config::write_codex_live_atomic(auth, Some(&live_doc.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gemini: merge only key env fields, preserve settings.json (MCP etc.)
|
||||
fn write_gemini_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, write_gemini_env_atomic};
|
||||
|
||||
let auth_type = detect_gemini_auth_type(provider);
|
||||
|
||||
// 1. Read existing env from live .env file
|
||||
let mut env_map = if get_gemini_env_path().exists() {
|
||||
read_gemini_env().unwrap_or_default()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// 2. Remove key fields from existing env
|
||||
for key in GEMINI_KEY_ENV_FIELDS {
|
||||
env_map.remove(*key);
|
||||
}
|
||||
|
||||
// 3. Extract key fields from provider and merge
|
||||
if let Some(provider_env) = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
for key in GEMINI_KEY_ENV_FIELDS {
|
||||
if let Some(value) = provider_env.get(*key).and_then(|v| v.as_str()) {
|
||||
if !value.is_empty() {
|
||||
env_map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle auth type specific behavior
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => {
|
||||
// Google official uses OAuth, clear all env
|
||||
env_map.clear();
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||
// Validate and write env
|
||||
crate::gemini_config::validate_gemini_settings_strict(&provider.settings_config)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Handle settings.json (same as write_gemini_live — preserve existing MCP etc.)
|
||||
use crate::gemini_config::get_gemini_settings_path;
|
||||
let settings_path = get_gemini_settings_path();
|
||||
|
||||
if let Some(config_value) = provider.settings_config.get("config") {
|
||||
if config_value.is_object() {
|
||||
let mut merged = if settings_path.exists() {
|
||||
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
if let (Some(merged_obj), Some(config_obj)) =
|
||||
(merged.as_object_mut(), config_value.as_object())
|
||||
{
|
||||
for (k, v) in config_obj {
|
||||
merged_obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
write_json_file(&settings_path, &merged)?;
|
||||
} else if !config_value.is_null() {
|
||||
return Err(AppError::localized(
|
||||
"gemini.validation.invalid_config",
|
||||
"Gemini 配置格式错误: config 必须是对象或 null",
|
||||
"Gemini config invalid: config must be an object or null",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Set security flag based on auth type
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
|
||||
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||
crate::gemini_config::write_packycode_settings()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Backfill: extract only key fields from live config
|
||||
// ============================================================================
|
||||
|
||||
/// Extract only provider-specific key fields from a live config value.
|
||||
///
|
||||
/// Used during backfill to ensure the provider's `settings_config` converges
|
||||
/// to containing only key fields over time.
|
||||
pub(crate) fn backfill_key_fields(app_type: &AppType, live_config: &Value) -> Value {
|
||||
match app_type {
|
||||
AppType::Claude => backfill_claude_key_fields(live_config),
|
||||
AppType::Codex => backfill_codex_key_fields(live_config),
|
||||
AppType::Gemini => backfill_gemini_key_fields(live_config),
|
||||
// Additive mode: return full config (no backfill needed)
|
||||
_ => live_config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn backfill_claude_key_fields(live: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// Extract key env fields
|
||||
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||
if let Some(value) = live_env.get(*key) {
|
||||
env_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if !env_obj.is_empty() {
|
||||
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract key top-level fields
|
||||
if let Some(live_obj) = live.as_object() {
|
||||
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||
if let Some(value) = live_obj.get(*key) {
|
||||
result_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn backfill_codex_key_fields(live: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// auth is entirely provider-specific — keep it as-is
|
||||
if let Some(auth) = live.get("auth") {
|
||||
result_obj.insert("auth".to_string(), auth.clone());
|
||||
}
|
||||
|
||||
// Extract key TOML fields from config string
|
||||
if let Some(config_str) = live.get("config").and_then(|v| v.as_str()) {
|
||||
if let Ok(doc) = config_str.parse::<toml_edit::DocumentMut>() {
|
||||
let mut new_doc = toml_edit::DocumentMut::new();
|
||||
let new_root = new_doc.as_table_mut();
|
||||
|
||||
// Copy key top-level fields
|
||||
for key in CODEX_KEY_TOP_LEVEL {
|
||||
if let Some(item) = doc.as_table().get(key) {
|
||||
new_root.insert(key, item.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy model_providers table
|
||||
if let Some(mp) = doc.as_table().get("model_providers") {
|
||||
new_root.insert("model_providers", mp.clone());
|
||||
}
|
||||
|
||||
let toml_str = new_doc.to_string();
|
||||
if !toml_str.trim().is_empty() {
|
||||
result_obj.insert("config".to_string(), Value::String(toml_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn backfill_gemini_key_fields(live: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// Extract key env fields
|
||||
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||
let mut env_obj = serde_json::Map::new();
|
||||
for key in GEMINI_KEY_ENV_FIELDS {
|
||||
if let Some(value) = live_env.get(*key) {
|
||||
env_obj.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if !env_obj.is_empty() {
|
||||
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Sync all providers to live configuration (for additive mode apps)
|
||||
///
|
||||
/// Writes all providers from the database to the live configuration file.
|
||||
@@ -719,7 +286,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_partial(&app_type, provider)?;
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
}
|
||||
// Note: get_effective_current_provider already validates existence,
|
||||
// so providers.get() should always succeed here
|
||||
|
||||
@@ -27,12 +27,11 @@ pub use live::{
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||
pub(crate) use live::write_live_partial;
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
|
||||
// Internal re-exports
|
||||
use live::{
|
||||
backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live,
|
||||
write_live_snapshot,
|
||||
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
|
||||
};
|
||||
use usage::validate_usage_script;
|
||||
|
||||
@@ -85,6 +84,47 @@ mod tests {
|
||||
assert_eq!(api_key, "token");
|
||||
assert_eq!(base_url, "https://claude.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
|
||||
let config_toml = r#"model_provider = "azure"
|
||||
model = "gpt-4"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.azure]
|
||||
name = "Azure OpenAI"
|
||||
base_url = "https://azure.example/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[mcp_servers.my_server]
|
||||
base_url = "http://localhost:8080"
|
||||
"#;
|
||||
|
||||
let settings = json!({ "config": config_toml });
|
||||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
||||
.expect("extract_codex_common_config should succeed");
|
||||
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model_provider")),
|
||||
"should remove top-level model_provider"
|
||||
);
|
||||
assert!(
|
||||
!extracted
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("model =")),
|
||||
"should remove top-level model"
|
||||
);
|
||||
assert!(
|
||||
!extracted.contains("[model_providers"),
|
||||
"should remove entire model_providers table"
|
||||
);
|
||||
assert!(
|
||||
extracted.contains("http://localhost:8080"),
|
||||
"should keep mcp_servers.* base_url"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderService {
|
||||
@@ -152,7 +192,7 @@ impl ProviderService {
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
write_live_partial(&app_type, &provider)?;
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
@@ -233,7 +273,7 @@ impl ProviderService {
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||
} else {
|
||||
write_live_partial(&app_type, &provider)?;
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
}
|
||||
@@ -520,9 +560,7 @@ impl ProviderService {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
// Only extract key fields from live config for backfill
|
||||
current_provider.settings_config =
|
||||
backfill_key_fields(&app_type, &live_config);
|
||||
current_provider.settings_config = live_config;
|
||||
if let Err(e) =
|
||||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||
{
|
||||
@@ -546,8 +584,11 @@ impl ProviderService {
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
}
|
||||
|
||||
// Sync to live (partial merge: only key fields, preserving user settings)
|
||||
write_live_partial(&app_type, provider)?;
|
||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -557,6 +598,222 @@ impl ProviderService {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
/// Extract common config snippet from current provider
|
||||
///
|
||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
||||
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
|
||||
pub fn extract_common_config_snippet(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<String, AppError> {
|
||||
// Get current provider
|
||||
let current_id = Self::current(state, app_type.clone())?;
|
||||
if current_id.is_empty() {
|
||||
return Err(AppError::Message("No current provider".to_string()));
|
||||
}
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers
|
||||
.get(¤t_id)
|
||||
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config snippet from a config value (e.g. editor content).
|
||||
pub fn extract_common_config_snippet_from_settings(
|
||||
app_type: AppType,
|
||||
settings_config: &Value,
|
||||
) -> Result<String, AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract common config for Claude (JSON format)
|
||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Fields to exclude from common config
|
||||
const ENV_EXCLUDES: &[&str] = &[
|
||||
// Auth
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
// Models (5 fields)
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
// Endpoint
|
||||
"ANTHROPIC_BASE_URL",
|
||||
];
|
||||
|
||||
const TOP_LEVEL_EXCLUDES: &[&str] = &[
|
||||
"apiBaseUrl",
|
||||
// Legacy model fields
|
||||
"primaryModel",
|
||||
"smallFastModel",
|
||||
];
|
||||
|
||||
// Remove env fields
|
||||
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
for key in ENV_EXCLUDES {
|
||||
env.remove(*key);
|
||||
}
|
||||
// If env is empty after removal, remove the env object itself
|
||||
if env.is_empty() {
|
||||
config.as_object_mut().map(|obj| obj.remove("env"));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove top-level fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
for key in TOP_LEVEL_EXCLUDES {
|
||||
obj.remove(*key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if result is empty
|
||||
if config.as_object().is_none_or(|obj| obj.is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for Codex (TOML format)
|
||||
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// Codex config is stored as { "auth": {...}, "config": "toml string" }
|
||||
let config_toml = settings
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if config_toml.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let mut doc = config_toml
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
|
||||
|
||||
// Remove provider-specific fields.
|
||||
let root = doc.as_table_mut();
|
||||
root.remove("model");
|
||||
root.remove("model_provider");
|
||||
// Legacy/alt formats might use a top-level base_url.
|
||||
root.remove("base_url");
|
||||
|
||||
// Remove entire model_providers table (provider-specific configuration)
|
||||
root.remove("model_providers");
|
||||
|
||||
// Clean up multiple empty lines (keep at most one blank line).
|
||||
let mut cleaned = String::new();
|
||||
let mut blank_run = 0usize;
|
||||
for line in doc.to_string().lines() {
|
||||
if line.trim().is_empty() {
|
||||
blank_run += 1;
|
||||
if blank_run <= 1 {
|
||||
cleaned.push('\n');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
blank_run = 0;
|
||||
cleaned.push_str(line);
|
||||
cleaned.push('\n');
|
||||
}
|
||||
|
||||
Ok(cleaned.trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract common config for Gemini (JSON format)
|
||||
///
|
||||
/// Extracts `.env` values while excluding provider-specific credentials:
|
||||
/// - GOOGLE_GEMINI_BASE_URL
|
||||
/// - GEMINI_API_KEY
|
||||
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let env = settings.get("env").and_then(|v| v.as_object());
|
||||
|
||||
let mut snippet = serde_json::Map::new();
|
||||
if let Some(env) = env {
|
||||
for (key, value) in env {
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||
continue;
|
||||
}
|
||||
let Value::String(v) = value else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = v.trim();
|
||||
if !trimmed.is_empty() {
|
||||
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snippet.is_empty() {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&Value::Object(snippet))
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenCode (JSON format)
|
||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenCode uses a different config structure with npm, options, models
|
||||
// For common config, we exclude provider-specific fields like apiKey
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Remove provider-specific fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
||||
options.remove("apiKey");
|
||||
options.remove("baseURL");
|
||||
}
|
||||
// Keep npm and models as they might be common
|
||||
}
|
||||
|
||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenClaw (JSON format)
|
||||
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
|
||||
// For common config, we exclude provider-specific fields like apiKey
|
||||
let mut config = settings.clone();
|
||||
|
||||
// Remove provider-specific fields
|
||||
if let Some(obj) = config.as_object_mut() {
|
||||
obj.remove("apiKey");
|
||||
obj.remove("baseUrl");
|
||||
// Keep api and models as they might be common
|
||||
}
|
||||
|
||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
||||
return Ok("{}".to_string());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
///
|
||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||
@@ -569,11 +826,6 @@ impl ProviderService {
|
||||
read_live_settings(app_type)
|
||||
}
|
||||
|
||||
/// Patch Claude live settings directly (user-level preferences)
|
||||
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||
live::patch_claude_live(patch)
|
||||
}
|
||||
|
||||
/// Get custom endpoints list (re-export)
|
||||
pub fn get_custom_endpoints(
|
||||
state: &AppState,
|
||||
@@ -669,6 +921,10 @@ impl ProviderService {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
write_gemini_live(provider)
|
||||
}
|
||||
|
||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::database::Database;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::types::*;
|
||||
use crate::services::provider::write_live_partial;
|
||||
use crate::services::provider::write_live_snapshot;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -1266,7 +1266,7 @@ impl ProxyService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
write_live_partial(app_type, provider)
|
||||
write_live_snapshot(app_type, provider)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
|
||||
Reference in New Issue
Block a user