mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +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:
@@ -352,6 +352,49 @@ impl FromStr for AppType {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用配置片段(按应用分治)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CommonConfigSnippets {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub codex: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gemini: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub openclaw: Option<String>,
|
||||
}
|
||||
|
||||
impl CommonConfigSnippets {
|
||||
/// 获取指定应用的通用配置片段
|
||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||
match app {
|
||||
AppType::Claude => self.claude.as_ref(),
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置指定应用的通用配置片段
|
||||
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
||||
match app {
|
||||
AppType::Claude => self.claude = snippet,
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
AppType::OpenClaw => self.openclaw = snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 多应用配置结构(向后兼容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MultiAppConfig {
|
||||
@@ -369,6 +412,12 @@ pub struct MultiAppConfig {
|
||||
/// Claude Skills 配置
|
||||
#[serde(default)]
|
||||
pub skills: SkillStore,
|
||||
/// 通用配置片段(按应用分治)
|
||||
#[serde(default)]
|
||||
pub common_config_snippets: CommonConfigSnippets,
|
||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_common_config_snippet: Option<String>,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
@@ -390,6 +439,8 @@ impl Default for MultiAppConfig {
|
||||
mcp: McpRoot::default(),
|
||||
prompts: PromptRoot::default(),
|
||||
skills: SkillStore::default(),
|
||||
common_config_snippets: CommonConfigSnippets::default(),
|
||||
claude_common_config_snippet: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +534,15 @@ impl MultiAppConfig {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
||||
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
||||
log::info!(
|
||||
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
||||
);
|
||||
config.common_config_snippets.claude = Some(old_claude_snippet);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated {
|
||||
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
||||
config.save()?;
|
||||
|
||||
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||
use crate::settings;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
@@ -15,6 +16,18 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
fn invalid_json_format_error(error: serde_json::Error) -> String {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
match lang.as_str() {
|
||||
"en" => format!("Invalid JSON format: {error}"),
|
||||
"ja" => format!("JSON形式が無効です: {error}"),
|
||||
_ => format!("无效的 JSON 格式: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
@@ -150,3 +163,126 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_common_config_snippet(
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
state
|
||||
.db
|
||||
.get_config_snippet("claude")
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_claude_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||
}
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snippet)
|
||||
};
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet("claude", value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_common_config_snippet(
|
||||
app_type: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
state
|
||||
.db
|
||||
.get_config_snippet(&app_type)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_common_config_snippet(
|
||||
app_type: String,
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
match app_type.as_str() {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snippet)
|
||||
};
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(&app_type, value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if app_type == "omo"
|
||||
&& state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo")
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some()
|
||||
{
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state.inner(),
|
||||
&crate::services::omo::STANDARD,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if app_type == "omo-slim"
|
||||
&& state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo-slim")
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some()
|
||||
{
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state.inner(),
|
||||
&crate::services::omo::SLIM,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_common_config_snippet(
|
||||
appType: String,
|
||||
settingsConfig: Option<String>,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<String, String> {
|
||||
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
|
||||
|
||||
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app,
|
||||
&settings,
|
||||
)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
|
||||
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -97,7 +97,27 @@ pub fn switch_provider(
|
||||
}
|
||||
|
||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
let imported = ProviderService::import_default_config(state, app_type)?;
|
||||
let imported = ProviderService::import_default_config(state, app_type.clone())?;
|
||||
|
||||
if imported {
|
||||
// Extract common config snippet (mirrors old startup logic in lib.rs)
|
||||
if state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
{
|
||||
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
let _ = state
|
||||
.db
|
||||
.set_config_snippet(app_type.as_str(), Some(snippet));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
@@ -167,12 +187,6 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
|
||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
|
||||
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_api_endpoints(
|
||||
urls: Vec<String>,
|
||||
|
||||
@@ -38,6 +38,31 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- 通用配置片段 (Common Config Snippet) ---
|
||||
|
||||
/// 获取通用配置片段
|
||||
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||
self.get_setting(&format!("common_config_{app_type}"))
|
||||
}
|
||||
|
||||
/// 设置通用配置片段
|
||||
pub fn set_config_snippet(
|
||||
&self,
|
||||
app_type: &str,
|
||||
snippet: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let key = format!("common_config_{app_type}");
|
||||
if let Some(value) = snippet {
|
||||
self.set_setting(&key, &value)
|
||||
} else {
|
||||
// 如果为 None 则删除
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- 全局出站代理 ---
|
||||
|
||||
/// 全局代理 URL 的存储键名
|
||||
|
||||
@@ -58,6 +58,9 @@ impl Database {
|
||||
// 4. 迁移 Skills
|
||||
Self::migrate_skills(tx, config)?;
|
||||
|
||||
// 5. 迁移 Common Config
|
||||
Self::migrate_common_config(tx, config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -209,4 +212,34 @@ impl Database {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 迁移通用配置片段
|
||||
fn migrate_common_config(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
config: &MultiAppConfig,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(snippet) = &config.common_config_snippets.claude {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_claude", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
if let Some(snippet) = &config.common_config_snippets.codex {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_codex", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
if let Some(snippet) = &config.common_config_snippets.gemini {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_gemini", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +513,8 @@ fn schema_dry_run_does_not_write_to_disk() {
|
||||
mcp: Default::default(),
|
||||
prompts: Default::default(),
|
||||
skills: Default::default(),
|
||||
common_config_snippets: Default::default(),
|
||||
claude_common_config_snippet: None,
|
||||
};
|
||||
|
||||
// Dry-run should succeed without any file I/O errors
|
||||
@@ -561,6 +563,8 @@ fn dry_run_validates_schema_compatibility() {
|
||||
mcp: Default::default(),
|
||||
prompts: Default::default(),
|
||||
skills: Default::default(),
|
||||
common_config_snippets: Default::default(),
|
||||
claude_common_config_snippet: None,
|
||||
};
|
||||
|
||||
// Dry-run should validate the full migration path
|
||||
|
||||
@@ -823,8 +823,12 @@ pub fn run() {
|
||||
commands::get_skills_migration_result,
|
||||
commands::get_app_config_path,
|
||||
commands::open_app_config_folder,
|
||||
commands::get_claude_common_config_snippet,
|
||||
commands::set_claude_common_config_snippet,
|
||||
commands::get_common_config_snippet,
|
||||
commands::set_common_config_snippet,
|
||||
commands::extract_common_config_snippet,
|
||||
commands::read_live_provider_settings,
|
||||
commands::patch_claude_live_settings,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::get_rectifier_config,
|
||||
|
||||
@@ -235,11 +235,6 @@ pub struct ProviderMeta {
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
/// Claude 认证字段名(仅 Claude 供应商使用)
|
||||
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
|
||||
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
|
||||
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_field: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
|
||||
@@ -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