From e561084f626893b2e30ac6e1573d017fb2b8b7d8 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 12 Mar 2026 16:06:07 +0800 Subject: [PATCH 1/6] Preserve common config during proxy takeover Update takeover backup generation to rebuild effective provider settings with common config applied before saving restore snapshots. Keep Codex mcp_servers entries when hot-switching providers under takeover so restore does not drop live-only MCP config. Migrate legacy providers with inferred common-config usage to explicit commonConfigEnabled=true markers during startup and default imports, and cover the new behavior with proxy and provider regression tests. --- src-tauri/src/commands/provider.rs | 2 + src-tauri/src/lib.rs | 19 ++ src-tauri/src/services/provider/live.rs | 23 +- src-tauri/src/services/provider/mod.rs | 24 +- src-tauri/src/services/proxy.rs | 284 ++++++++++++++++++++++-- src-tauri/tests/provider_service.rs | 74 ++++++ 6 files changed, 402 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index ef9c1bb0e..c9f2b6ea3 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -117,6 +117,8 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result _ => {} } } + + ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?; } Ok(imported) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc02f4181..16550f49b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -613,6 +613,25 @@ pub fn run() { } } + // 5.1 Migrate legacy providers that relied on inferred common-config usage + for app_type in [ + crate::app_config::AppType::Claude, + crate::app_config::AppType::Codex, + crate::app_config::AppType::Gemini, + ] { + if let Err(e) = + crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed( + &app_state, + app_type.clone(), + ) + { + log::warn!( + "✗ Failed to migrate legacy common-config usage for {}: {e}", + app_type.as_str() + ); + } + } + // 迁移旧的 app_config_dir 配置到 Store if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) { log::warn!("迁移 app_config_dir 失败: {e}"); diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index d096d96ea..b4b3aa0fd 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -461,19 +461,18 @@ fn apply_common_config_to_settings( } } -pub(crate) fn write_live_with_common_config( +pub(crate) fn build_effective_settings_with_common_config( db: &Database, app_type: &AppType, provider: &Provider, -) -> Result<(), AppError> { +) -> Result { let snippet = db.get_config_snippet(app_type.as_str())?; - let mut effective_provider = provider.clone(); + let mut effective_settings = provider.settings_config.clone(); if provider_uses_common_config(app_type, provider, snippet.as_deref()) { if let Some(snippet_text) = snippet.as_deref() { - match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text) - { - Ok(settings) => effective_provider.settings_config = settings, + match apply_common_config_to_settings(app_type, &effective_settings, snippet_text) { + Ok(settings) => effective_settings = settings, Err(err) => { log::warn!( "Failed to apply common config for {} provider '{}': {err}", @@ -485,6 +484,18 @@ pub(crate) fn write_live_with_common_config( } } + Ok(effective_settings) +} + +pub(crate) fn write_live_with_common_config( + db: &Database, + app_type: &AppType, + provider: &Provider, +) -> Result<(), AppError> { + let mut effective_provider = provider.clone(); + effective_provider.settings_config = + build_effective_settings_with_common_config(db, app_type, provider)?; + write_live_snapshot(app_type, &effective_provider) } diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 1ecfd4e83..508c2a8d4 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -28,8 +28,9 @@ pub use live::{ // Internal re-exports (pub(crate)) pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::{ - normalize_provider_common_config_for_storage, strip_common_config_from_live_settings, - sync_current_provider_for_app_to_live, write_live_with_common_config, + build_effective_settings_with_common_config, normalize_provider_common_config_for_storage, + strip_common_config_from_live_settings, sync_current_provider_for_app_to_live, + write_live_with_common_config, }; // Internal re-exports @@ -670,6 +671,25 @@ impl ProviderService { Ok(()) } + pub fn migrate_legacy_common_config_usage_if_needed( + state: &AppState, + app_type: AppType, + ) -> Result<(), AppError> { + if app_type.is_additive_mode() { + return Ok(()); + } + + let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else { + return Ok(()); + }; + + if snippet.trim().is_empty() { + return Ok(()); + } + + Self::migrate_legacy_common_config_usage(state, app_type, &snippet) + } + /// Extract common config snippet from current provider /// /// Extracts the current provider's configuration and removes provider-specific fields diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index a3e5ff3f6..ccb755fc9 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -8,7 +8,9 @@ use crate::database::Database; use crate::provider::Provider; use crate::proxy::server::ProxyServer; use crate::proxy::types::*; -use crate::services::provider::write_live_with_common_config; +use crate::services::provider::{ + build_effective_settings_with_common_config, write_live_with_common_config, +}; use serde_json::{json, Value}; use std::str::FromStr; use std::sync::Arc; @@ -1528,21 +1530,37 @@ impl ProxyService { app_type: &str, provider: &Provider, ) -> Result<(), String> { - let backup_json = match app_type { - "claude" => { - // Claude: settings_config 直接作为备份 - serde_json::to_string(&provider.settings_config) - .map_err(|e| format!("序列化 Claude 配置失败: {e}"))? + let app_type_enum = + AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?; + let mut effective_settings = + build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider) + .map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?; + + if matches!(app_type_enum, AppType::Codex) { + let existing_backup = self + .db + .get_live_backup(app_type) + .await + .map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?; + + if let Some(existing_backup) = existing_backup { + let existing_value: Value = serde_json::from_str(&existing_backup.original_config) + .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?; + Self::preserve_codex_mcp_servers_in_backup( + &mut effective_settings, + &existing_value, + )?; } - "codex" => { - // Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用 - serde_json::to_string(&provider.settings_config) - .map_err(|e| format!("序列化 Codex 配置失败: {e}"))? - } - "gemini" => { - // Gemini: 只提取 env 字段(与原始备份格式一致) - // proxy.rs 的 read_gemini_live() 返回 {"env": {...}} - let env_backup = if let Some(env) = provider.settings_config.get("env") { + } + + let backup_json = match app_type_enum { + AppType::Claude => serde_json::to_string(&effective_settings) + .map_err(|e| format!("序列化 Claude 配置失败: {e}"))?, + AppType::Codex => serde_json::to_string(&effective_settings) + .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?, + AppType::Gemini => { + // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。 + let env_backup = if let Some(env) = effective_settings.get("env") { json!({ "env": env }) } else { json!({ "env": {} }) @@ -1550,7 +1568,9 @@ impl ProxyService { serde_json::to_string(&env_backup) .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))? } - _ => return Err(format!("未知的应用类型: {app_type}")), + AppType::OpenCode | AppType::OpenClaw => { + return Err(format!("未知的应用类型: {app_type}")); + } }; self.db @@ -1562,6 +1582,47 @@ impl ProxyService { Ok(()) } + fn preserve_codex_mcp_servers_in_backup( + target_settings: &mut Value, + existing_backup: &Value, + ) -> Result<(), String> { + let target_obj = target_settings + .as_object_mut() + .ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?; + + let target_config = target_obj + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut target_doc = if target_config.trim().is_empty() { + toml_edit::DocumentMut::new() + } else { + target_config + .parse::() + .map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))? + }; + + let existing_config = existing_backup + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if existing_config.trim().is_empty() { + target_obj.insert("config".to_string(), json!(target_doc.to_string())); + return Ok(()); + } + + let existing_doc = existing_config + .parse::() + .map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?; + + if let Some(mcp_servers) = existing_doc.get("mcp_servers") { + target_doc["mcp_servers"] = mcp_servers.clone(); + } + + target_obj.insert("config".to_string(), json!(target_doc.to_string())); + Ok(()) + } + /// 代理模式下切换供应商(热切换,不写 Live) pub async fn switch_proxy_target( &self, @@ -1914,6 +1975,7 @@ impl ProxyService { #[cfg(test)] mod tests { use super::*; + use crate::provider::ProviderMeta; use serial_test::serial; use std::env; use tempfile::TempDir; @@ -2191,4 +2253,194 @@ model = "gpt-5.1-codex" let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize"); assert_eq!(backup.original_config, expected); } + + #[tokio::test] + #[serial] + async fn update_live_backup_from_provider_applies_claude_common_config() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + db.set_config_snippet( + "claude", + Some( + serde_json::json!({ + "includeCoAuthoredBy": false + }) + .to_string(), + ), + ) + .expect("set common config snippet"); + + let service = ProxyService::new(db.clone()); + + let mut provider = Provider::with_id( + "p1".to_string(), + "P1".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token", + "ANTHROPIC_BASE_URL": "https://claude.example" + } + }), + None, + ); + provider.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + + service + .update_live_backup_from_provider("claude", &provider) + .await + .expect("update live backup"); + + let backup = db + .get_live_backup("claude") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + + assert_eq!( + stored.get("includeCoAuthoredBy").and_then(|v| v.as_bool()), + Some(false), + "common config should be applied into Claude restore backup" + ); + } + + #[tokio::test] + #[serial] + async fn update_live_backup_from_provider_applies_codex_common_config() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + db.set_config_snippet( + "codex", + Some("disable_response_storage = true\n".to_string()), + ) + .expect("set common config snippet"); + + let service = ProxyService::new(db.clone()); + + let mut provider = Provider::with_id( + "p1".to_string(), + "P1".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "token" + }, + "config": r#"model_provider = "any" +model = "gpt-5" + +[model_providers.any] +base_url = "https://codex.example/v1" +"# + }), + None, + ); + provider.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + + service + .update_live_backup_from_provider("codex", &provider) + .await + .expect("update live backup"); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + let config = stored + .get("config") + .and_then(|v| v.as_str()) + .expect("config string"); + + assert!( + config.contains("disable_response_storage = true"), + "common config should be applied into Codex restore backup" + ); + } + + #[tokio::test] + #[serial] + async fn update_live_backup_from_provider_preserves_codex_mcp_servers() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + + db.save_live_backup( + "codex", + &serde_json::to_string(&json!({ + "auth": { + "OPENAI_API_KEY": "old-token" + }, + "config": r#"model_provider = "any" +model = "gpt-4" + +[model_providers.any] +base_url = "https://old.example/v1" + +[mcp_servers.echo] +command = "npx" +args = ["echo-server"] +"# + })) + .expect("serialize seed backup"), + ) + .await + .expect("seed live backup"); + + let provider = Provider::with_id( + "p2".to_string(), + "P2".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "new-token" + }, + "config": r#"model_provider = "any" +model = "gpt-5" + +[model_providers.any] +base_url = "https://new.example/v1" +"# + }), + None, + ); + + service + .update_live_backup_from_provider("codex", &provider) + .await + .expect("update live backup"); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + let config = stored + .get("config") + .and_then(|v| v.as_str()) + .expect("config string"); + + assert!( + config.contains("[mcp_servers.echo]"), + "existing Codex MCP section should survive proxy hot-switch backup update" + ); + assert!( + config.contains("https://new.example/v1"), + "provider-specific base_url should still update to the new provider" + ); + } } diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 6288bddd0..903d86dfa 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -21,6 +21,80 @@ fn sanitize_provider_name(name: &str) -> String { .to_lowercase() } +#[test] +fn migrate_legacy_common_config_usage_marks_historical_provider_enabled() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "legacy-provider".to_string(); + manager.providers.insert( + "legacy-provider".to_string(), + Provider::with_id( + "legacy-provider".to_string(), + "Legacy".to_string(), + json!({ + "includeCoAuthoredBy": false, + "env": { + "ANTHROPIC_API_KEY": "legacy-key" + } + }), + None, + ), + ); + } + + let state = create_test_state_with_config(&config).expect("create test state"); + state + .db + .set_config_snippet( + AppType::Claude.as_str(), + Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()), + ) + .expect("set common config snippet"); + + ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude) + .expect("migrate legacy common config"); + + let providers = state + .db + .get_all_providers(AppType::Claude.as_str()) + .expect("get providers after migration"); + let provider = providers + .get("legacy-provider") + .expect("legacy provider exists"); + + assert_eq!( + provider + .meta + .as_ref() + .and_then(|meta| meta.common_config_enabled), + Some(true), + "historical provider should be explicitly marked as using common config" + ); + assert!( + provider + .settings_config + .get("includeCoAuthoredBy") + .is_none(), + "common config fields should be stripped from provider storage after migration" + ); + assert_eq!( + provider + .settings_config + .get("env") + .and_then(|v| v.get("ANTHROPIC_API_KEY")) + .and_then(|v| v.as_str()), + Some("legacy-key"), + "provider-specific auth should remain untouched" + ); +} + #[test] fn provider_service_switch_codex_updates_live_and_config() { let _guard = test_mutex().lock().expect("acquire test mutex"); From 7ca33ff901c2ca89d1bf17f64e9df2a10f929344 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 12 Mar 2026 17:16:22 +0800 Subject: [PATCH 2/6] fix: prevent common config loss during proxy takeover and stabilize snippet lifecycle - Make sync_current_provider_for_app takeover-aware: update restore backup instead of overwriting live config when proxy is active - Introduce explicit "cleared" flag for common config snippets to prevent auto-extraction from resurrecting user-cleared snippets - Reorder startup: extract snippets from clean live files before restoring proxy takeover state - Add one-time migration flag to skip legacy commonConfigEnabled migration on subsequent startups - Add regression tests for takeover backup preservation, explicit clear semantics, and migration flag roundtrip --- src-tauri/src/commands/config.rs | 23 ++-- src-tauri/src/commands/provider.rs | 8 +- src-tauri/src/database/dao/settings.rs | 60 +++++++++ src-tauri/src/lib.rs | 153 +++++++++++---------- src-tauri/src/services/provider/mod.rs | 40 ++++++ src-tauri/tests/provider_service.rs | 178 +++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 86 deletions(-) diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 0fa6e4a20..60c0f1c79 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -212,20 +212,22 @@ pub async fn set_claude_common_config_snippet( snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { + let is_cleared = snippet.trim().is_empty(); + if !snippet.trim().is_empty() { serde_json::from_str::(&snippet).map_err(invalid_json_format_error)?; } - let value = if snippet.trim().is_empty() { - None - } else { - Some(snippet) - }; + let value = if is_cleared { None } else { Some(snippet) }; state .db .set_config_snippet("claude", value) .map_err(|e| e.to_string())?; + state + .db + .set_config_snippet_cleared("claude", is_cleared) + .map_err(|e| e.to_string())?; Ok(()) } @@ -246,6 +248,7 @@ pub async fn set_common_config_snippet( snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { + let is_cleared = snippet.trim().is_empty(); let old_snippet = state .db .get_config_snippet(&app_type) @@ -253,11 +256,7 @@ pub async fn set_common_config_snippet( validate_common_config_snippet(&app_type, &snippet)?; - let value = if snippet.trim().is_empty() { - None - } else { - Some(snippet) - }; + let value = if is_cleared { None } else { Some(snippet) }; if matches!(app_type.as_str(), "claude" | "codex" | "gemini") { if let Some(legacy_snippet) = old_snippet @@ -278,6 +277,10 @@ pub async fn set_common_config_snippet( .db .set_config_snippet(&app_type, value) .map_err(|e| e.to_string())?; + state + .db + .set_config_snippet_cleared(&app_type, is_cleared) + .map_err(|e| e.to_string())?; if matches!(app_type.as_str(), "claude" | "codex" | "gemini") { let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index c9f2b6ea3..344b35ecc 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -103,16 +103,16 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result // 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() + .should_auto_extract_config_snippet(app_type.as_str())? { 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)); + let _ = state + .db + .set_config_snippet_cleared(app_type.as_str(), false); } _ => {} } diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 6f15b2585..bca48bcbf 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -7,6 +7,12 @@ use crate::error::AppError; use rusqlite::params; impl Database { + const LEGACY_COMMON_CONFIG_MIGRATED_KEY: &'static str = "common_config_legacy_migrated_v1"; + + fn config_snippet_cleared_key(app_type: &str) -> String { + format!("common_config_{app_type}_cleared") + } + /// 获取设置值 pub fn get_setting(&self, key: &str) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -45,6 +51,60 @@ impl Database { self.get_setting(&format!("common_config_{app_type}")) } + /// 检查通用配置片段是否被用户显式清空 + pub fn is_config_snippet_cleared(&self, app_type: &str) -> Result { + Ok(self + .get_setting(&Self::config_snippet_cleared_key(app_type))? + .as_deref() + == Some("true")) + } + + /// 设置通用配置片段是否被显式清空 + pub fn set_config_snippet_cleared( + &self, + app_type: &str, + cleared: bool, + ) -> Result<(), AppError> { + let key = Self::config_snippet_cleared_key(app_type); + if cleared { + self.set_setting(&key, "true") + } else { + 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(()) + } + } + + /// 当前是否允许从 live 配置自动抽取通用配置片段 + pub fn should_auto_extract_config_snippet(&self, app_type: &str) -> Result { + Ok(self.get_config_snippet(app_type)?.is_none() + && !self.is_config_snippet_cleared(app_type)?) + } + + /// 检查历史通用配置迁移是否已经执行过 + pub fn is_legacy_common_config_migrated(&self) -> Result { + Ok(self + .get_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY)? + .as_deref() + == Some("true")) + } + + /// 标记历史通用配置迁移已经执行完成 + pub fn set_legacy_common_config_migrated(&self, migrated: bool) -> Result<(), AppError> { + if migrated { + self.set_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY, "true") + } else { + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM settings WHERE key = ?1", + params![Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + } + /// 设置通用配置片段 pub fn set_config_snippet( &self, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 16550f49b..edbd8727e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -560,78 +560,6 @@ pub fn run() { } } - // 5. Auto-extract common config snippets from live files (when snippet is missing) - for app_type in crate::app_config::AppType::all() { - // Skip if snippet already exists - if app_state - .db - .get_config_snippet(app_type.as_str()) - .ok() - .flatten() - .is_some() - { - continue; - } - - // Try to read the live config file for this app type - let settings = - match crate::services::provider::ProviderService::read_live_settings( - app_type.clone(), - ) { - Ok(s) => s, - Err(_) => continue, // No live config file, skip silently - }; - - // Extract common config (strip provider-specific fields) - match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings( - app_type.clone(), - &settings, - ) { - Ok(snippet) if !snippet.is_empty() && snippet != "{}" => { - match app_state - .db - .set_config_snippet(app_type.as_str(), Some(snippet)) - { - Ok(()) => log::info!( - "✓ Auto-extracted common config snippet for {}", - app_type.as_str() - ), - Err(e) => log::warn!( - "✗ Failed to save config snippet for {}: {e}", - app_type.as_str() - ), - } - } - Ok(_) => log::debug!( - "○ Live config for {} has no extractable common fields", - app_type.as_str() - ), - Err(e) => log::warn!( - "✗ Failed to extract config snippet for {}: {e}", - app_type.as_str() - ), - } - } - - // 5.1 Migrate legacy providers that relied on inferred common-config usage - for app_type in [ - crate::app_config::AppType::Claude, - crate::app_config::AppType::Codex, - crate::app_config::AppType::Gemini, - ] { - if let Err(e) = - crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed( - &app_state, - app_type.clone(), - ) - { - log::warn!( - "✗ Failed to migrate legacy common-config usage for {}: {e}", - app_type.as_str() - ); - } - } - // 迁移旧的 app_config_dir 配置到 Store if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) { log::warn!("迁移 app_config_dir 失败: {e}"); @@ -816,6 +744,8 @@ pub fn run() { } } + initialize_common_config_snippets(&state); + // 检查 settings 表中的代理状态,自动恢复代理服务 restore_proxy_state_on_startup(&state).await; @@ -1324,6 +1254,85 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) { } } +fn initialize_common_config_snippets(state: &store::AppState) { + // Auto-extract common config snippets from clean live files when snippet is missing. + // This must run before proxy takeover is restored on startup, otherwise we'd read + // proxy-placeholder configs instead of the user's actual live settings. + for app_type in crate::app_config::AppType::all() { + if !state + .db + .should_auto_extract_config_snippet(app_type.as_str()) + .unwrap_or(false) + { + continue; + } + + let settings = match crate::services::provider::ProviderService::read_live_settings( + app_type.clone(), + ) { + Ok(s) => s, + Err(_) => continue, + }; + + match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings( + app_type.clone(), + &settings, + ) { + Ok(snippet) if !snippet.is_empty() && snippet != "{}" => { + match state.db.set_config_snippet(app_type.as_str(), Some(snippet)) { + Ok(()) => { + let _ = state.db.set_config_snippet_cleared(app_type.as_str(), false); + log::info!( + "✓ Auto-extracted common config snippet for {}", + app_type.as_str() + ); + } + Err(e) => log::warn!( + "✗ Failed to save config snippet for {}: {e}", + app_type.as_str() + ), + } + } + Ok(_) => log::debug!( + "○ Live config for {} has no extractable common fields", + app_type.as_str() + ), + Err(e) => log::warn!( + "✗ Failed to extract config snippet for {}: {e}", + app_type.as_str() + ), + } + } + + let should_run_legacy_migration = state + .db + .is_legacy_common_config_migrated() + .map(|done| !done) + .unwrap_or(true); + + if should_run_legacy_migration { + for app_type in [ + crate::app_config::AppType::Claude, + crate::app_config::AppType::Codex, + crate::app_config::AppType::Gemini, + ] { + if let Err(e) = crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed( + state, + app_type.clone(), + ) { + log::warn!( + "✗ Failed to migrate legacy common-config usage for {}: {e}", + app_type.as_str() + ); + } + } + + if let Err(e) = state.db.set_legacy_common_config_migrated(true) { + log::warn!("✗ Failed to persist legacy common-config migration flag: {e}"); + } + } +} + // ============================================================ // 迁移错误对话框辅助函数 // ============================================================ diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 508c2a8d4..bbae722ca 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -614,6 +614,46 @@ impl ProviderService { state: &AppState, app_type: AppType, ) -> Result<(), AppError> { + if app_type.is_additive_mode() { + return sync_current_provider_for_app_to_live(state, &app_type); + } + + let current_id = + match crate::settings::get_effective_current_provider(&state.db, &app_type)? { + Some(id) => id, + None => return Ok(()), + }; + + let providers = state.db.get_all_providers(app_type.as_str())?; + let Some(provider) = providers.get(¤t_id) else { + return Ok(()); + }; + + let takeover_enabled = + futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str())) + .map(|config| config.enabled) + .unwrap_or(false); + + let has_live_backup = + futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) + .ok() + .flatten() + .is_some(); + + let live_taken_over = state + .proxy_service + .detect_takeover_in_live_config_for_app(&app_type); + + if takeover_enabled && (has_live_backup || live_taken_over) { + futures::executor::block_on( + state + .proxy_service + .update_live_backup_from_provider(app_type.as_str(), provider), + ) + .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; + return Ok(()); + } + sync_current_provider_for_app_to_live(state, &app_type) } diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 903d86dfa..c4400c230 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -238,6 +238,184 @@ command = "say" ); } +#[test] +fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Claude) + .expect("claude manager"); + manager.current = "current-provider".to_string(); + + let mut provider = Provider::with_id( + "current-provider".to_string(), + "Current".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "real-token", + "ANTHROPIC_BASE_URL": "https://claude.example" + } + }), + None, + ); + provider.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + + manager + .providers + .insert("current-provider".to_string(), provider); + } + + let state = create_test_state_with_config(&config).expect("create test state"); + state + .db + .set_config_snippet( + AppType::Claude.as_str(), + Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()), + ) + .expect("set common config snippet"); + + let taken_over_live = json!({ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:5000", + "ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED" + } + }); + let settings_path = get_claude_settings_path(); + std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir"); + std::fs::write( + &settings_path, + serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"), + ) + .expect("write taken over live"); + + futures::executor::block_on(state.db.save_live_backup("claude", "{\"env\":{}}")) + .expect("seed live backup"); + + let mut proxy_config = futures::executor::block_on(state.db.get_proxy_config_for_app("claude")) + .expect("get proxy config"); + proxy_config.enabled = true; + futures::executor::block_on(state.db.update_proxy_config_for_app(proxy_config)) + .expect("enable takeover"); + + ProviderService::sync_current_provider_for_app(&state, AppType::Claude) + .expect("sync current provider should succeed"); + + let live_after: serde_json::Value = + read_json_file(&settings_path).expect("read live settings after sync"); + assert_eq!( + live_after, taken_over_live, + "sync should not overwrite live config while takeover is active" + ); + + let backup = futures::executor::block_on(state.db.get_live_backup("claude")) + .expect("get live backup") + .expect("backup exists"); + let backup_value: serde_json::Value = + serde_json::from_str(&backup.original_config).expect("parse backup value"); + + assert_eq!( + backup_value + .get("includeCoAuthoredBy") + .and_then(|v| v.as_bool()), + Some(false), + "restore backup should receive the updated effective config" + ); + assert_eq!( + backup_value + .get("env") + .and_then(|v| v.get("ANTHROPIC_AUTH_TOKEN")) + .and_then(|v| v.as_str()), + Some("real-token"), + "restore backup should preserve the provider token rather than proxy placeholder" + ); +} + +#[test] +fn explicitly_cleared_common_snippet_is_not_auto_extracted() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + state + .db + .set_config_snippet_cleared(AppType::Claude.as_str(), true) + .expect("mark snippet explicitly cleared"); + + assert!( + !state + .db + .should_auto_extract_config_snippet(AppType::Claude.as_str()) + .expect("check auto-extract eligibility"), + "explicitly cleared snippets should block auto-extraction" + ); + + state + .db + .set_config_snippet(AppType::Claude.as_str(), Some("{}".to_string())) + .expect("set snippet"); + state + .db + .set_config_snippet_cleared(AppType::Claude.as_str(), false) + .expect("clear explicit-empty marker"); + + assert!( + !state + .db + .should_auto_extract_config_snippet(AppType::Claude.as_str()) + .expect("check auto-extract after snippet saved"), + "existing snippets should also block auto-extraction" + ); +} + +#[test] +fn legacy_common_config_migration_flag_roundtrip() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + let state = create_test_state().expect("create test state"); + + assert!( + !state + .db + .is_legacy_common_config_migrated() + .expect("initial migration flag"), + "migration flag should default to false" + ); + + state + .db + .set_legacy_common_config_migrated(true) + .expect("set migration flag"); + assert!( + state + .db + .is_legacy_common_config_migrated() + .expect("read migration flag"), + "migration flag should persist once set" + ); + + state + .db + .set_legacy_common_config_migrated(false) + .expect("clear migration flag"); + assert!( + !state + .db + .is_legacy_common_config_migrated() + .expect("read migration flag after clear"), + "migration flag should be removable for tests/debugging" + ); +} + #[test] fn switch_packycode_gemini_updates_security_selected_type() { let _guard = test_mutex().lock().expect("acquire test mutex"); From 3568c98f574135dcb8393a37779fffe19d190a8f Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 12 Mar 2026 22:20:59 +0800 Subject: [PATCH 3/6] fix: make Codex TOML base_url editing section-aware Rewrite setCodexBaseUrl/extractCodexBaseUrl to understand TOML section boundaries, ensuring base_url is written into the correct [model_providers.] section instead of being appended to file end. - Add section-aware TOML helpers in providerConfigUtils.ts - Extract shared update_codex_toml_field/remove_codex_toml_base_url_if in codex_config.rs, deduplicate proxy.rs TOML editing logic - Replace scattered inline base_url regexes with extractCodexBaseUrl() - Add comprehensive tests for both Rust and TypeScript implementations --- src-tauri/src/codex_config.rs | 333 ++++++++++++++ src-tauri/src/services/proxy.rs | 103 +---- .../providers/AddProviderDialog.tsx | 9 +- src/components/providers/ProviderCard.tsx | 7 +- .../providers/forms/OpenClawFormFields.tsx | 4 +- .../forms/hooks/useCodexCommonConfig.ts | 8 +- .../forms/hooks/useOpenclawFormState.ts | 21 +- .../forms/hooks/useSpeedTestEndpoints.ts | 16 +- src/utils/providerConfigUtils.ts | 412 +++++++++++++++--- tests/utils/providerConfigUtils.codex.test.ts | 102 ++++- 10 files changed, 825 insertions(+), 190 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 642e48861..b84caab7a 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -9,6 +9,7 @@ use crate::error::AppError; use serde_json::Value; use std::fs; use std::path::Path; +use toml_edit::DocumentMut; /// 获取 Codex 配置目录路径 pub fn get_codex_config_dir() -> PathBuf { @@ -135,3 +136,335 @@ pub fn read_and_validate_codex_config_text() -> Result { validate_config_toml(&s)?; Ok(s) } + +/// Update a field in Codex config.toml using toml_edit (syntax-preserving). +/// +/// Supported fields: +/// - `"base_url"`: writes to `[model_providers.].base_url` if `model_provider` exists, +/// otherwise falls back to top-level `base_url`. +/// - `"model"`: writes to top-level `model` field. +/// +/// Empty value removes the field. +pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result { + let mut doc = toml_str + .parse::() + .map_err(|e| format!("TOML parse error: {e}"))?; + + let trimmed = value.trim(); + + match field { + "base_url" => { + let model_provider = doc + .get("model_provider") + .and_then(|item| item.as_str()) + .map(str::to_string); + + if let Some(provider_key) = model_provider { + // Ensure [model_providers] table exists + if doc.get("model_providers").is_none() { + doc["model_providers"] = toml_edit::table(); + } + + if let Some(model_providers) = doc["model_providers"].as_table_mut() { + // Ensure [model_providers.] table exists + if !model_providers.contains_key(&provider_key) { + model_providers[&provider_key] = toml_edit::table(); + } + + if let Some(provider_table) = model_providers[&provider_key].as_table_mut() { + if trimmed.is_empty() { + provider_table.remove("base_url"); + } else { + provider_table["base_url"] = toml_edit::value(trimmed); + } + return Ok(doc.to_string()); + } + } + } + + // Fallback: no model_provider or structure mismatch → top-level base_url + if trimmed.is_empty() { + doc.as_table_mut().remove("base_url"); + } else { + doc["base_url"] = toml_edit::value(trimmed); + } + } + "model" => { + if trimmed.is_empty() { + doc.as_table_mut().remove("model"); + } else { + doc["model"] = toml_edit::value(trimmed); + } + } + _ => return Err(format!("unsupported field: {field}")), + } + + Ok(doc.to_string()) +} + +/// Remove `base_url` from the active model_provider section only if it matches `predicate`. +/// Also removes top-level `base_url` if it matches. +/// Used by proxy cleanup to strip local proxy URLs without touching user-configured URLs. +pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> bool) -> String { + let mut doc = match toml_str.parse::() { + Ok(doc) => doc, + Err(_) => return toml_str.to_string(), + }; + + let model_provider = doc + .get("model_provider") + .and_then(|item| item.as_str()) + .map(str::to_string); + + if let Some(provider_key) = model_provider { + if let Some(model_providers) = doc + .get_mut("model_providers") + .and_then(|v| v.as_table_mut()) + { + if let Some(provider_table) = model_providers + .get_mut(provider_key.as_str()) + .and_then(|v| v.as_table_mut()) + { + let should_remove = provider_table + .get("base_url") + .and_then(|item| item.as_str()) + .map(&predicate) + .unwrap_or(false); + if should_remove { + provider_table.remove("base_url"); + } + } + } + } + + // Fallback: also clean up top-level base_url if it matches + let should_remove_root = doc + .get("base_url") + .and_then(|item| item.as_str()) + .map(&predicate) + .unwrap_or(false); + if should_remove_root { + doc.as_table_mut().remove("base_url"); + } + + doc.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_url_writes_into_correct_model_provider_section() { + let input = r#"model_provider = "any" +model = "gpt-5.1-codex" + +[model_providers.any] +name = "any" +wire_api = "responses" +"#; + + let result = update_codex_toml_field(input, "base_url", "https://example.com/v1").unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + let base_url = parsed + .get("model_providers") + .and_then(|v| v.get("any")) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()) + .expect("base_url should be in model_providers.any"); + assert_eq!(base_url, "https://example.com/v1"); + + // Should NOT have top-level base_url + assert!(parsed.get("base_url").is_none()); + + // wire_api preserved + let wire_api = parsed + .get("model_providers") + .and_then(|v| v.get("any")) + .and_then(|v| v.get("wire_api")) + .and_then(|v| v.as_str()); + assert_eq!(wire_api, Some("responses")); + } + + #[test] + fn base_url_creates_section_when_missing() { + let input = r#"model_provider = "custom" +model = "gpt-4" +"#; + + let result = update_codex_toml_field(input, "base_url", "https://custom.api/v1").unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + let base_url = parsed + .get("model_providers") + .and_then(|v| v.get("custom")) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()) + .expect("should create section and set base_url"); + assert_eq!(base_url, "https://custom.api/v1"); + } + + #[test] + fn base_url_falls_back_to_top_level_without_model_provider() { + let input = r#"model = "gpt-4" +"#; + + let result = update_codex_toml_field(input, "base_url", "https://fallback.api/v1").unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + let base_url = parsed + .get("base_url") + .and_then(|v| v.as_str()) + .expect("should set top-level base_url"); + assert_eq!(base_url, "https://fallback.api/v1"); + } + + #[test] + fn clearing_base_url_removes_only_from_correct_section() { + let input = r#"model_provider = "any" + +[model_providers.any] +name = "any" +base_url = "https://old.api/v1" +wire_api = "responses" + +[mcp_servers.context7] +command = "npx" +"#; + + let result = update_codex_toml_field(input, "base_url", "").unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + // base_url removed from model_providers.any + let any_section = parsed + .get("model_providers") + .and_then(|v| v.get("any")) + .expect("model_providers.any should exist"); + assert!(any_section.get("base_url").is_none()); + + // wire_api preserved + assert_eq!( + any_section.get("wire_api").and_then(|v| v.as_str()), + Some("responses") + ); + + // mcp_servers untouched + assert!(parsed.get("mcp_servers").is_some()); + } + + #[test] + fn model_field_operates_on_top_level() { + let input = r#"model_provider = "any" +model = "gpt-4" + +[model_providers.any] +name = "any" +"#; + + let result = update_codex_toml_field(input, "model", "gpt-5").unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + assert_eq!(parsed.get("model").and_then(|v| v.as_str()), Some("gpt-5")); + + // Clear model + let result2 = update_codex_toml_field(&result, "model", "").unwrap(); + let parsed2: toml::Value = toml::from_str(&result2).unwrap(); + assert!(parsed2.get("model").is_none()); + } + + #[test] + fn preserves_comments_and_whitespace() { + let input = r#"# My Codex config +model_provider = "any" +model = "gpt-4" + +# Provider section +[model_providers.any] +name = "any" +base_url = "https://old.api/v1" +"#; + + let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap(); + + // Comments should be preserved + assert!(result.contains("# My Codex config")); + assert!(result.contains("# Provider section")); + } + + #[test] + fn does_not_misplace_when_profiles_section_follows() { + let input = r#"model_provider = "any" + +[model_providers.any] +name = "any" +base_url = "https://old.api/v1" + +[profiles.default] +model = "gpt-4" +"#; + + let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap(); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + // base_url in correct section + let base_url = parsed + .get("model_providers") + .and_then(|v| v.get("any")) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()); + assert_eq!(base_url, Some("https://new.api/v1")); + + // profiles section untouched + let profile_model = parsed + .get("profiles") + .and_then(|v| v.get("default")) + .and_then(|v| v.get("model")) + .and_then(|v| v.as_str()); + assert_eq!(profile_model, Some("gpt-4")); + } + + #[test] + fn remove_base_url_if_predicate() { + let input = r#"model_provider = "any" + +[model_providers.any] +name = "any" +base_url = "http://127.0.0.1:5000/v1" +wire_api = "responses" +"#; + + let result = + remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1")); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + let any_section = parsed + .get("model_providers") + .and_then(|v| v.get("any")) + .unwrap(); + assert!(any_section.get("base_url").is_none()); + assert_eq!( + any_section.get("wire_api").and_then(|v| v.as_str()), + Some("responses") + ); + } + + #[test] + fn remove_base_url_if_keeps_non_matching() { + let input = r#"model_provider = "any" + +[model_providers.any] +base_url = "https://production.api/v1" +"#; + + let result = + remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1")); + let parsed: toml::Value = toml::from_str(&result).unwrap(); + + let base_url = parsed + .get("model_providers") + .and_then(|v| v.get("any")) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()); + assert_eq!(base_url, Some("https://production.api/v1")); + } +} diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index ccb755fc9..02ce4a588 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -19,7 +19,7 @@ use tokio::sync::RwLock; /// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token) const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED"; -/// 代理接管模式下需要从 Claude Live 配置中移除的“模型覆盖”字段。 +/// 代理接管模式下需要从 Claude Live 配置中移除的"模型覆盖"字段。 /// /// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段, /// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。 @@ -52,7 +52,7 @@ impl ProxyService { /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。 /// - /// 这可以避免“接管开启后切换供应商仍使用旧模型”的问题。 + /// 这可以避免"接管开启后切换供应商仍使用旧模型"的问题。 /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。 pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> { let mut config = self.read_claude_live()?; @@ -1162,7 +1162,7 @@ impl ProxyService { ) -> Result<(), String> { let app_type_str = app_type.as_str(); - // 1) 优先从 Live 备份恢复(这是“原始 Live”的唯一可靠来源) + // 1) 优先从 Live 备份恢复(这是"原始 Live"的唯一可靠来源) let backup = self .db .get_live_backup(app_type_str) @@ -1181,7 +1181,7 @@ impl ProxyService { return Ok(()); } - // 2.1) 优先从 SSOT(当前供应商)重建 Live(比“清理字段”更可用) + // 2.1) 优先从 SSOT(当前供应商)重建 Live(比"清理字段"更可用) match self.restore_live_from_ssot_for_app(app_type) { Ok(true) => { log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)"); @@ -1358,51 +1358,9 @@ impl ProxyService { Ok(()) } + /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现) fn remove_local_toml_base_url(toml_str: &str) -> String { - use toml_edit::DocumentMut; - - let mut doc = match toml_str.parse::() { - Ok(doc) => doc, - Err(_) => return toml_str.to_string(), - }; - - let model_provider = doc - .get("model_provider") - .and_then(|item| item.as_str()) - .map(str::to_string); - - if let Some(provider_key) = model_provider { - if let Some(model_providers) = doc - .get_mut("model_providers") - .and_then(|v| v.as_table_mut()) - { - if let Some(provider_table) = model_providers - .get_mut(provider_key.as_str()) - .and_then(|v| v.as_table_mut()) - { - let should_remove = provider_table - .get("base_url") - .and_then(|item| item.as_str()) - .map(Self::is_local_proxy_url) - .unwrap_or(false); - if should_remove { - provider_table.remove("base_url"); - } - } - } - } - - // 兜底:清理顶层 base_url(仅当它看起来像本地代理地址) - let should_remove_root = doc - .get("base_url") - .and_then(|item| item.as_str()) - .map(Self::is_local_proxy_url) - .unwrap_or(false); - if should_remove_root { - doc.as_table_mut().remove("base_url"); - } - - doc.to_string() + crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url) } fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> { @@ -1459,7 +1417,7 @@ impl ProxyService { Ok(()) } - /// 检测 Live 配置是否处于“被接管”的残留状态 + /// 检测 Live 配置是否处于"被接管"的残留状态 /// /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时, /// 启动流程可以据此触发恢复逻辑。 @@ -1675,49 +1633,10 @@ impl ProxyService { // ==================== Live 配置读写辅助方法 ==================== - /// 更新 TOML 字符串中的 base_url + /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现) fn update_toml_base_url(toml_str: &str, new_url: &str) -> String { - use toml_edit::DocumentMut; - - let mut doc = match toml_str.parse::() { - Ok(doc) => doc, - Err(_) => return toml_str.to_string(), - }; - - // Codex 的 config.toml 通常是: - // model_provider = "any" - // - // [model_providers.any] - // base_url = "https://.../v1" - // - // 所以接管时要“精准”修改当前 model_provider 对应的 model_providers..base_url, - // 避免写错位置导致 Codex 仍然走旧地址。 - let model_provider = doc - .get("model_provider") - .and_then(|item| item.as_str()) - .map(str::to_string); - - if let Some(provider_key) = model_provider { - if doc.get("model_providers").is_none() { - doc["model_providers"] = toml_edit::table(); - } - - if let Some(model_providers) = doc["model_providers"].as_table_mut() { - if !model_providers.contains_key(&provider_key) { - model_providers[&provider_key] = toml_edit::table(); - } - - if let Some(provider_table) = model_providers[&provider_key].as_table_mut() { - provider_table["base_url"] = toml_edit::value(new_url); - return doc.to_string(); - } - } - } - - // 兜底:如果没有 model_provider 或结构不符合预期,则退回修改顶层 base_url。 - doc["base_url"] = toml_edit::value(new_url); - - doc.to_string() + crate::codex_config::update_codex_toml_field(toml_str, "base_url", new_url) + .unwrap_or_else(|_| toml_str.to_string()) } fn read_claude_live(&self) -> Result { @@ -2228,7 +2147,7 @@ model = "gpt-5.1-codex" db.set_current_provider("claude", "a") .expect("set current provider"); - // 模拟“已接管”状态:存在 Live 备份(内容不重要,会被热切换更新) + // 模拟"已接管"状态:存在 Live 备份(内容不重要,会被热切换更新) db.save_live_backup("claude", "{\"env\":{}}") .await .expect("seed live backup"); diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index a40befdc4..f8fc8e5b5 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -17,6 +17,7 @@ import { UniversalProviderPanel } from "@/components/universal"; import { providerPresets } from "@/config/claudeProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets"; +import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; @@ -179,11 +180,9 @@ export function AddProviderDialog({ } else if (appId === "codex") { const config = parsedConfig.config as string | undefined; if (config) { - const baseUrlMatch = config.match( - /base_url\s*=\s*["']([^"']+)["']/, - ); - if (baseUrlMatch?.[1]) { - addUrl(baseUrlMatch[1]); + const extractedBaseUrl = extractCodexBaseUrl(config); + if (extractedBaseUrl) { + addUrl(extractedBaseUrl); } } } else if (appId === "gemini") { diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 96d8b4607..3301fbc91 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -13,6 +13,7 @@ import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; +import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; import { useProviderHealth } from "@/lib/query/failover"; import { useUsageQuery } from "@/lib/query/queries"; @@ -76,9 +77,9 @@ const extractApiUrl = (provider: Provider, fallbackText: string) => { const baseUrl = (config as Record)?.config; if (typeof baseUrl === "string" && baseUrl.includes("base_url")) { - const match = baseUrl.match(/base_url\s*=\s*['"]([^'"]+)['"]/); - if (match?.[1]) { - return match[1]; + const extractedBaseUrl = extractCodexBaseUrl(baseUrl); + if (extractedBaseUrl) { + return extractedBaseUrl; } } } diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx index 8c35e0df6..314f7024a 100644 --- a/src/components/providers/forms/OpenClawFormFields.tsx +++ b/src/components/providers/forms/OpenClawFormFields.tsx @@ -383,7 +383,9 @@ export function OpenClawFormFields({ className="flex items-center gap-1.5 cursor-pointer select-none" > { const current = model.input ?? ["text"]; const next = checked diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index fb65053d2..228ab419f 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -285,7 +285,13 @@ export function useCodexCommonConfig({ isUpdatingFromCommonConfig.current = false; }, 0); }, - [codexConfig, commonConfigSnippet, onConfigChange, parseCommonConfigSnippet, t], + [ + codexConfig, + commonConfigSnippet, + onConfigChange, + parseCommonConfigSnippet, + t, + ], ); // 处理通用配置片段变化 diff --git a/src/components/providers/forms/hooks/useOpenclawFormState.ts b/src/components/providers/forms/hooks/useOpenclawFormState.ts index b76525703..7f32424c5 100644 --- a/src/components/providers/forms/hooks/useOpenclawFormState.ts +++ b/src/components/providers/forms/hooks/useOpenclawFormState.ts @@ -171,18 +171,15 @@ export function useOpenclawFormState({ [updateOpenclawConfig], ); - const resetOpenclawState = useCallback( - (config?: OpenClawProviderConfig) => { - setOpenclawProviderKey(""); - setOpenclawBaseUrl(config?.baseUrl || ""); - setOpenclawApiKey(config?.apiKey || ""); - setOpenclawApi(config?.api || "openai-completions"); - setOpenclawModels(config?.models || []); - const ua = config?.headers ? "User-Agent" in config.headers : false; - setOpenclawUserAgent(ua); - }, - [], - ); + const resetOpenclawState = useCallback((config?: OpenClawProviderConfig) => { + setOpenclawProviderKey(""); + setOpenclawBaseUrl(config?.baseUrl || ""); + setOpenclawApiKey(config?.apiKey || ""); + setOpenclawApi(config?.api || "openai-completions"); + setOpenclawModels(config?.models || []); + const ua = config?.headers ? "User-Agent" in config.headers : false; + setOpenclawUserAgent(ua); + }, []); return { openclawProviderKey, diff --git a/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts b/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts index ab9f54b55..c503289c3 100644 --- a/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts +++ b/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts @@ -3,6 +3,7 @@ import type { AppId } from "@/lib/api"; import type { ProviderPreset } from "@/config/claudeProviderPresets"; import type { CodexProviderPreset } from "@/config/codexProviderPresets"; import type { ProviderMeta, EndpointCandidate } from "@/types"; +import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; type PresetEntry = { id: string; @@ -128,10 +129,9 @@ export function useSpeedTestEndpoints({ } | undefined; const configStr = initialCodexConfig?.config ?? ""; - // 从 TOML 中提取 base_url - const match = /base_url\s*=\s*["']([^"']+)["']/i.exec(configStr); - if (match?.[1]) { - add(match[1]); + const extractedBaseUrl = extractCodexBaseUrl(configStr); + if (extractedBaseUrl) { + add(extractedBaseUrl); } // 3. 预设中的 endpointCandidates @@ -141,11 +141,9 @@ export function useSpeedTestEndpoints({ const preset = entry.preset as CodexProviderPreset; // 添加预设自己的 baseUrl const presetConfig = preset.config || ""; - const presetMatch = /base_url\s*=\s*["']([^"']+)["']/i.exec( - presetConfig, - ); - if (presetMatch?.[1]) { - add(presetMatch[1]); + const presetBaseUrl = extractCodexBaseUrl(presetConfig); + if (presetBaseUrl) { + add(presetBaseUrl); } // 添加预设的候选端点 if (preset.endpointCandidates) { diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index b35dcabef..84f5e941c 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -1,7 +1,7 @@ // 供应商配置处理工具函数 import type { TemplateValueConfig } from "../config/claudeProviderPresets"; -import { normalizeQuotes, normalizeTomlText } from "@/utils/textNormalization"; +import { normalizeTomlText } from "@/utils/textNormalization"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; const isPlainObject = (value: unknown): value is Record => { @@ -414,17 +414,234 @@ export const hasTomlCommonConfigSnippet = ( // ========== Codex base_url utils ========== +const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/; +const TOML_BASE_URL_PATTERN = + /^\s*base_url\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; +const TOML_MODEL_PATTERN = /^\s*model\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; +const TOML_MODEL_PROVIDER_LINE_PATTERN = + /^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; +const TOML_MODEL_PROVIDER_PATTERN = + /^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/m; + +interface TomlSectionRange { + bodyEndIndex: number; + bodyStartIndex: number; +} + +interface TomlAssignmentMatch { + index: number; + sectionName?: string; + value: string; +} + +const finalizeTomlText = (lines: string[]): string => + lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\n+/, ""); + +const getTomlSectionRange = ( + lines: string[], + sectionName: string, +): TomlSectionRange | undefined => { + let headerLineIndex = -1; + + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(TOML_SECTION_HEADER_PATTERN); + if (!match) { + continue; + } + + if (headerLineIndex === -1) { + if (match[1] === sectionName) { + headerLineIndex = index; + } + continue; + } + + return { + bodyStartIndex: headerLineIndex + 1, + bodyEndIndex: index, + }; + } + + if (headerLineIndex === -1) { + return undefined; + } + + return { + bodyStartIndex: headerLineIndex + 1, + bodyEndIndex: lines.length, + }; +}; + +const getTopLevelEndIndex = (lines: string[]): number => { + const firstSectionIndex = lines.findIndex((line) => + TOML_SECTION_HEADER_PATTERN.test(line), + ); + return firstSectionIndex === -1 ? lines.length : firstSectionIndex; +}; + +const getTomlSectionInsertIndex = ( + lines: string[], + sectionRange: TomlSectionRange, +): number => { + let insertIndex = sectionRange.bodyEndIndex; + while ( + insertIndex > sectionRange.bodyStartIndex && + lines[insertIndex - 1].trim() === "" + ) { + insertIndex -= 1; + } + return insertIndex; +}; + +const getCodexModelProviderName = (configText: string): string | undefined => { + const match = configText.match(TOML_MODEL_PROVIDER_PATTERN); + const providerName = match?.[2]?.trim(); + return providerName || undefined; +}; + +const getCodexProviderSectionName = ( + configText: string, +): string | undefined => { + const providerName = getCodexModelProviderName(configText); + return providerName ? `model_providers.${providerName}` : undefined; +}; + +const findTomlAssignmentInRange = ( + lines: string[], + pattern: RegExp, + startIndex: number, + endIndex: number, + sectionName?: string, +): TomlAssignmentMatch | undefined => { + for (let index = startIndex; index < endIndex; index += 1) { + const match = lines[index].match(pattern); + if (match?.[2]) { + return { + index, + sectionName, + value: match[2], + }; + } + } + + return undefined; +}; + +const findTomlAssignments = ( + lines: string[], + pattern: RegExp, +): TomlAssignmentMatch[] => { + const assignments: TomlAssignmentMatch[] = []; + let currentSectionName: string | undefined; + + lines.forEach((line, index) => { + const sectionMatch = line.match(TOML_SECTION_HEADER_PATTERN); + if (sectionMatch) { + currentSectionName = sectionMatch[1]; + return; + } + + const match = line.match(pattern); + if (!match?.[2]) { + return; + } + + assignments.push({ + index, + sectionName: currentSectionName, + value: match[2], + }); + }); + + return assignments; +}; + +const isMcpServerSection = (sectionName?: string): boolean => + sectionName === "mcp_servers" || + sectionName?.startsWith("mcp_servers.") === true; + +const isOtherProviderSection = ( + sectionName: string | undefined, + targetSectionName: string | undefined, +): boolean => + Boolean( + sectionName && + sectionName !== targetSectionName && + (sectionName === "model_providers" || + sectionName.startsWith("model_providers.")), + ); + +const getRecoverableBaseUrlAssignments = ( + assignments: TomlAssignmentMatch[], + targetSectionName: string | undefined, +): TomlAssignmentMatch[] => + assignments.filter( + ({ sectionName }) => + sectionName !== targetSectionName && + !isMcpServerSection(sectionName) && + !isOtherProviderSection(sectionName, targetSectionName), + ); + +const getTopLevelModelProviderLineIndex = (lines: string[]): number => { + const topLevelEndIndex = getTopLevelEndIndex(lines); + + for (let index = 0; index < topLevelEndIndex; index += 1) { + if (TOML_MODEL_PROVIDER_LINE_PATTERN.test(lines[index])) { + return index; + } + } + + return -1; +}; + // 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号) export const extractCodexBaseUrl = ( configText: string | undefined | null, ): string | undefined => { try { const raw = typeof configText === "string" ? configText : ""; - // 归一化中文/全角引号,避免正则提取失败 - const text = normalizeQuotes(raw); + const text = normalizeTomlText(raw); if (!text) return undefined; - const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/); - return m && m[2] ? m[2] : undefined; + + const lines = text.split("\n"); + const targetSectionName = getCodexProviderSectionName(text); + + if (targetSectionName) { + const sectionRange = getTomlSectionRange(lines, targetSectionName); + if (sectionRange) { + const match = findTomlAssignmentInRange( + lines, + TOML_BASE_URL_PATTERN, + sectionRange.bodyStartIndex, + sectionRange.bodyEndIndex, + targetSectionName, + ); + if (match?.value) { + return match.value; + } + } + } + + const topLevelMatch = findTomlAssignmentInRange( + lines, + TOML_BASE_URL_PATTERN, + 0, + getTopLevelEndIndex(lines), + ); + if (topLevelMatch?.value) { + return topLevelMatch.value; + } + + const fallbackAssignments = getRecoverableBaseUrlAssignments( + findTomlAssignments(lines, TOML_BASE_URL_PATTERN), + targetSectionName, + ); + return fallbackAssignments.length === 1 + ? fallbackAssignments[0].value + : undefined; } catch { return undefined; } @@ -451,36 +668,107 @@ export const setCodexBaseUrl = ( baseUrl: string, ): string => { const trimmed = baseUrl.trim(); - // 归一化原文本中的引号(既能匹配,也能输出稳定格式) - const normalizedText = normalizeQuotes(configText); + const normalizedText = normalizeTomlText(configText); + const lines = normalizedText ? normalizedText.split("\n") : []; + const targetSectionName = getCodexProviderSectionName(normalizedText); + const allAssignments = findTomlAssignments(lines, TOML_BASE_URL_PATTERN); + const recoverableAssignments = getRecoverableBaseUrlAssignments( + allAssignments, + targetSectionName, + ); - // 允许清空:当 baseUrl 为空时,移除 base_url 行 if (!trimmed) { if (!normalizedText) return normalizedText; - const next = normalizedText - .split("\n") - .filter((line) => !/^\s*base_url\s*=/.test(line)) - .join("\n") - // 避免移除后留下过多空行 - .replace(/\n{3,}/g, "\n\n") - // 避免开头出现空行 - .replace(/^\n+/, ""); - return next; + + if (targetSectionName) { + const sectionRange = getTomlSectionRange(lines, targetSectionName); + const targetMatch = sectionRange + ? findTomlAssignmentInRange( + lines, + TOML_BASE_URL_PATTERN, + sectionRange.bodyStartIndex, + sectionRange.bodyEndIndex, + targetSectionName, + ) + : undefined; + + if (targetMatch) { + lines.splice(targetMatch.index, 1); + return finalizeTomlText(lines); + } + } + + if (recoverableAssignments.length === 1) { + lines.splice(recoverableAssignments[0].index, 1); + return finalizeTomlText(lines); + } + + return finalizeTomlText(lines); } const normalizedUrl = trimmed.replace(/\s+/g, ""); const replacementLine = `base_url = "${normalizedUrl}"`; - const pattern = /base_url\s*=\s*(["'])([^"']+)\1/; - if (pattern.test(normalizedText)) { - return normalizedText.replace(pattern, replacementLine); + if (targetSectionName) { + let targetSectionRange = getTomlSectionRange(lines, targetSectionName); + const targetMatch = targetSectionRange + ? findTomlAssignmentInRange( + lines, + TOML_BASE_URL_PATTERN, + targetSectionRange.bodyStartIndex, + targetSectionRange.bodyEndIndex, + targetSectionName, + ) + : undefined; + + if (targetMatch) { + lines[targetMatch.index] = replacementLine; + return finalizeTomlText(lines); + } + + if (recoverableAssignments.length === 1) { + lines.splice(recoverableAssignments[0].index, 1); + targetSectionRange = getTomlSectionRange(lines, targetSectionName); + } + + if (targetSectionRange) { + const insertIndex = getTomlSectionInsertIndex(lines, targetSectionRange); + lines.splice(insertIndex, 0, replacementLine); + return finalizeTomlText(lines); + } + + if (lines.length > 0 && lines[lines.length - 1].trim() !== "") { + lines.push(""); + } + lines.push(`[${targetSectionName}]`, replacementLine); + return finalizeTomlText(lines); } - const prefix = - normalizedText && !normalizedText.endsWith("\n") - ? `${normalizedText}\n` - : normalizedText; - return `${prefix}${replacementLine}\n`; + const topLevelEndIndex = getTopLevelEndIndex(lines); + const topLevelMatch = findTomlAssignmentInRange( + lines, + TOML_BASE_URL_PATTERN, + 0, + topLevelEndIndex, + ); + if (topLevelMatch) { + lines[topLevelMatch.index] = replacementLine; + return finalizeTomlText(lines); + } + + const modelProviderIndex = getTopLevelModelProviderLineIndex(lines); + if (modelProviderIndex !== -1) { + lines.splice(modelProviderIndex + 1, 0, replacementLine); + return finalizeTomlText(lines); + } + + if (lines.length === 0) { + return `${replacementLine}\n`; + } + + const insertIndex = topLevelEndIndex; + lines.splice(insertIndex, 0, replacementLine); + return finalizeTomlText(lines); }; // ========== Codex model name utils ========== @@ -491,13 +779,16 @@ export const extractCodexModelName = ( ): string | undefined => { try { const raw = typeof configText === "string" ? configText : ""; - // 归一化中文/全角引号,避免正则提取失败 - const text = normalizeQuotes(raw); + const text = normalizeTomlText(raw); if (!text) return undefined; - - // 匹配 model = "xxx" 或 model = 'xxx' - const m = text.match(/^model\s*=\s*(['"])([^'"]+)\1/m); - return m && m[2] ? m[2] : undefined; + const lines = text.split("\n"); + const topLevelMatch = findTomlAssignmentInRange( + lines, + TOML_MODEL_PATTERN, + 0, + getTopLevelEndIndex(lines), + ); + return topLevelMatch?.value; } catch { return undefined; } @@ -509,47 +800,40 @@ export const setCodexModelName = ( modelName: string, ): string => { const trimmed = modelName.trim(); - // 归一化原文本中的引号(既能匹配,也能输出稳定格式) - const normalizedText = normalizeQuotes(configText); + const normalizedText = normalizeTomlText(configText); + const lines = normalizedText ? normalizedText.split("\n") : []; + const topLevelEndIndex = getTopLevelEndIndex(lines); + const topLevelMatch = findTomlAssignmentInRange( + lines, + TOML_MODEL_PATTERN, + 0, + topLevelEndIndex, + ); - // 允许清空:当 modelName 为空时,移除 model 行 if (!trimmed) { if (!normalizedText) return normalizedText; - const next = normalizedText - .split("\n") - .filter((line) => !/^\s*model\s*=/.test(line)) - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .replace(/^\n+/, ""); - return next; + if (topLevelMatch) { + lines.splice(topLevelMatch.index, 1); + } + return finalizeTomlText(lines); } const replacementLine = `model = "${trimmed}"`; - const pattern = /^model\s*=\s*["']([^"']+)["']/m; - - if (pattern.test(normalizedText)) { - return normalizedText.replace(pattern, replacementLine); + if (topLevelMatch) { + lines[topLevelMatch.index] = replacementLine; + return finalizeTomlText(lines); } - // 如果不存在 model 字段,尝试在 model_provider 之后插入 - // 如果 model_provider 也不存在,则插入到开头 - const providerPattern = /^model_provider\s*=\s*["'][^"']+["']/m; - const match = normalizedText.match(providerPattern); - - if (match && match.index !== undefined) { - // 在 model_provider 行之后插入 - const endOfLine = normalizedText.indexOf("\n", match.index); - if (endOfLine !== -1) { - return ( - normalizedText.slice(0, endOfLine + 1) + - replacementLine + - "\n" + - normalizedText.slice(endOfLine + 1) - ); - } + const modelProviderIndex = getTopLevelModelProviderLineIndex(lines); + if (modelProviderIndex !== -1) { + lines.splice(modelProviderIndex + 1, 0, replacementLine); + return finalizeTomlText(lines); } - // 在文件开头插入 - const lines = normalizedText.split("\n"); - return `${replacementLine}\n${lines.join("\n")}`; + if (lines.length === 0) { + return `${replacementLine}\n`; + } + + lines.splice(topLevelEndIndex, 0, replacementLine); + return finalizeTomlText(lines); }; diff --git a/tests/utils/providerConfigUtils.codex.test.ts b/tests/utils/providerConfigUtils.codex.test.ts index 13dfa2ae7..28e741abc 100644 --- a/tests/utils/providerConfigUtils.codex.test.ts +++ b/tests/utils/providerConfigUtils.codex.test.ts @@ -22,17 +22,21 @@ describe("Codex TOML utils", () => { expect(extractCodexModelName(output)).toBe("gpt-5-codex"); }); - it("removes model line when set to empty", () => { + it("removes only the top-level model line when set to empty", () => { const input = [ 'model_provider = "openai"', 'base_url = "https://api.example.com/v1"', 'model = "gpt-5-codex"', "", + "[profiles.default]", + 'model = "profile-model"', + "", ].join("\n"); const output = setCodexModelName(input, ""); - expect(output).not.toMatch(/^\s*model\s*=/m); + expect(output).not.toMatch(/^model\s*=\s*"gpt-5-codex"$/m); + expect(output).toMatch(/^\[profiles\.default\]\nmodel = "profile-model"$/m); expect(extractCodexModelName(output)).toBeUndefined(); expect(extractCodexBaseUrl(output)).toBe("https://api.example.com/v1"); }); @@ -51,5 +55,97 @@ describe("Codex TOML utils", () => { const output2 = setCodexModelName(output1, " new-model \n"); expect(extractCodexModelName(output2)).toBe("new-model"); }); -}); + it("reads and writes base_url in the active provider section", () => { + const input = [ + 'model_provider = "custom"', + 'model = "gpt-5.4"', + "", + "[model_providers.custom]", + 'name = "custom"', + 'wire_api = "responses"', + "", + "[profiles.default]", + 'approval_policy = "never"', + "", + ].join("\n"); + + const output = setCodexBaseUrl(input, "https://api.example.com/v1"); + + expect(output).toContain( + '[model_providers.custom]\nname = "custom"\nwire_api = "responses"\nbase_url = "https://api.example.com/v1"', + ); + expect(extractCodexBaseUrl(output)).toBe("https://api.example.com/v1"); + }); + + it("recovers a single misplaced base_url from another section", () => { + const input = [ + 'model_provider = "custom"', + 'model = "gpt-5.4"', + "", + "[model_providers.custom]", + 'name = "custom"', + 'wire_api = "responses"', + "", + "[profiles.default]", + 'approval_policy = "never"', + 'base_url = "https://wrong.example/v1"', + "", + ].join("\n"); + + expect(extractCodexBaseUrl(input)).toBe("https://wrong.example/v1"); + + const output = setCodexBaseUrl(input, "https://fixed.example/v1"); + + expect(output).toContain( + '[model_providers.custom]\nname = "custom"\nwire_api = "responses"\nbase_url = "https://fixed.example/v1"', + ); + expect(output).not.toContain("https://wrong.example/v1"); + expect(output.match(/base_url\s*=/g)).toHaveLength(1); + }); + + it("does not treat mcp_servers base_url as provider base_url", () => { + const input = [ + 'model_provider = "azure"', + 'model = "gpt-4"', + "", + "[model_providers.azure]", + 'name = "Azure OpenAI"', + 'wire_api = "responses"', + "", + "[mcp_servers.my_server]", + 'base_url = "http://localhost:8080"', + "", + ].join("\n"); + + expect(extractCodexBaseUrl(input)).toBeUndefined(); + + const output = setCodexBaseUrl(input, "https://new.azure/v1"); + + expect(output).toContain( + '[model_providers.azure]\nname = "Azure OpenAI"\nwire_api = "responses"\nbase_url = "https://new.azure/v1"', + ); + expect(output).toContain( + '[mcp_servers.my_server]\nbase_url = "http://localhost:8080"', + ); + }); + + it("reads model only from the top-level config", () => { + const input = [ + 'model_provider = "custom"', + "", + "[profiles.default]", + 'model = "profile-model"', + "", + ].join("\n"); + + expect(extractCodexModelName(input)).toBeUndefined(); + }); + + it("handles single-quoted values", () => { + const input = "base_url = 'https://api.example.com/v1'\nmodel = 'gpt-5'\n"; + + expect(extractCodexBaseUrl(input)).toBe("https://api.example.com/v1"); + expect(extractCodexModelName(input)).toBe("gpt-5"); + }); +}); From 8e1204b1ee4dfd4d0709c8e1c50b2c4299fddb94 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 12 Mar 2026 23:15:40 +0800 Subject: [PATCH 4/6] fix: merge Codex MCP restore backup entries during takeover When proxy takeover is active, update_live_backup_from_provider rebuilds the Codex restore snapshot from the current provider and common-config state. We were then replacing the new mcp_servers table with the previous backup's table wholesale, which meant stopping takeover could restore stale MCP entries and silently discard MCP changes made through provider/common-config updates. Change the backup preservation step to merge MCP entries by server id instead of replacing the entire table. New provider/common-config MCP definitions now win on conflict, while backup-only servers are retained so live-only MCP state still survives a hot-switch. Add regression coverage for the existing preservation case and for the conflict case where both the old backup and the new provider define the same MCP server. --- src-tauri/src/services/proxy.rs | 115 +++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 02ce4a588..ed5895bb7 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1573,8 +1573,28 @@ impl ProxyService { .parse::() .map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?; - if let Some(mcp_servers) = existing_doc.get("mcp_servers") { - target_doc["mcp_servers"] = mcp_servers.clone(); + if let Some(existing_mcp_servers) = existing_doc.get("mcp_servers") { + match target_doc.get_mut("mcp_servers") { + Some(target_mcp_servers) => { + if let (Some(target_table), Some(existing_table)) = ( + target_mcp_servers.as_table_like_mut(), + existing_mcp_servers.as_table_like(), + ) { + for (server_id, server_item) in existing_table.iter() { + if target_table.get(server_id).is_none() { + target_table.insert(server_id, server_item.clone()); + } + } + } else { + log::warn!( + "Codex config contains a non-table mcp_servers section; skipping backup MCP merge" + ); + } + } + None => { + target_doc["mcp_servers"] = existing_mcp_servers.clone(); + } + } } target_obj.insert("config".to_string(), json!(target_doc.to_string())); @@ -2362,4 +2382,95 @@ base_url = "https://new.example/v1" "provider-specific base_url should still update to the new provider" ); } + + #[tokio::test] + #[serial] + async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload settings"); + + let db = Arc::new(Database::memory().expect("init db")); + let service = ProxyService::new(db.clone()); + + db.save_live_backup( + "codex", + &serde_json::to_string(&json!({ + "auth": { + "OPENAI_API_KEY": "old-token" + }, + "config": r#"[mcp_servers.shared] +command = "old-command" + +[mcp_servers.legacy] +command = "legacy-command" +"# + })) + .expect("serialize seed backup"), + ) + .await + .expect("seed live backup"); + + let provider = Provider::with_id( + "p2".to_string(), + "P2".to_string(), + json!({ + "auth": { + "OPENAI_API_KEY": "new-token" + }, + "config": r#"[mcp_servers.shared] +command = "new-command" + +[mcp_servers.latest] +command = "latest-command" +"# + }), + None, + ); + + service + .update_live_backup_from_provider("codex", &provider) + .await + .expect("update live backup"); + + let backup = db + .get_live_backup("codex") + .await + .expect("get live backup") + .expect("backup exists"); + let stored: Value = + serde_json::from_str(&backup.original_config).expect("parse backup json"); + let config = stored + .get("config") + .and_then(|v| v.as_str()) + .expect("config string"); + let parsed: toml::Value = toml::from_str(config).expect("parse merged codex config"); + + let mcp_servers = parsed + .get("mcp_servers") + .expect("mcp_servers should be present"); + assert_eq!( + mcp_servers + .get("shared") + .and_then(|v| v.get("command")) + .and_then(|v| v.as_str()), + Some("new-command"), + "new provider/common-config MCP definition should win on conflict" + ); + assert_eq!( + mcp_servers + .get("legacy") + .and_then(|v| v.get("command")) + .and_then(|v| v.as_str()), + Some("legacy-command"), + "backup-only MCP entries should still be preserved" + ); + assert_eq!( + mcp_servers + .get("latest") + .and_then(|v| v.get("command")) + .and_then(|v| v.as_str()), + Some("latest-command"), + "new MCP entries should remain in the restore backup" + ); + } } From 305c0f2e08e1bcbbc892969a4295bcb928591b03 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 12 Mar 2026 23:42:15 +0800 Subject: [PATCH 5/6] feat: improve empty state guidance for first-run experience Show detailed import instructions and conditionally display common config snippet hint for Claude/Codex/Gemini (not OpenCode/OpenClaw). --- src/components/providers/ProviderEmptyState.tsx | 12 +++++++++++- src/components/providers/ProviderList.tsx | 1 + src/i18n/locales/en.json | 3 ++- src/i18n/locales/ja.json | 3 ++- src/i18n/locales/zh.json | 3 ++- 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/providers/ProviderEmptyState.tsx b/src/components/providers/ProviderEmptyState.tsx index 8c8240747..a31cfa67e 100644 --- a/src/components/providers/ProviderEmptyState.tsx +++ b/src/components/providers/ProviderEmptyState.tsx @@ -1,17 +1,22 @@ import { Download, Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import type { AppId } from "@/lib/api/types"; interface ProviderEmptyStateProps { + appId: AppId; onCreate?: () => void; onImport?: () => void; } export function ProviderEmptyState({ + appId, onCreate, onImport, }: ProviderEmptyStateProps) { const { t } = useTranslation(); + const showSnippetHint = + appId === "claude" || appId === "codex" || appId === "gemini"; return (
@@ -19,9 +24,14 @@ export function ProviderEmptyState({

{t("provider.noProviders")}

-

+

{t("provider.noProvidersDescription")}

+ {showSnippetHint && ( +

+ {t("provider.noProvidersDescriptionSnippet")} +

+ )}
{onImport && (