//! 代理服务业务逻辑层 //! //! 提供代理服务器的启动、停止和配置管理 use crate::app_config::AppType; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::provider::Provider; use crate::proxy::server::ProxyServer; use crate::proxy::switch_lock::SwitchLockManager; use crate::proxy::types::*; use crate::services::provider::{ build_effective_settings_with_common_config, write_live_with_common_config, }; use serde_json::{json, Map, Value}; use std::str::FromStr; use std::sync::Arc; use tauri::Emitter; use tokio::sync::RwLock; /// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token) const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED"; /// 代理接管模式下需要从 Claude Live 配置中移除的"模型覆盖"字段。 /// /// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名, /// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管, /// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。 const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 10] = [ "ANTHROPIC_MODEL", "ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留 "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", "ANTHROPIC_SMALL_FAST_MODEL", // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型 "CLAUDE_CODE_SUBAGENT_MODEL", ]; const CLAUDE_TAKEOVER_HAIKU_MODEL: &str = "claude-haiku-4-5"; const CLAUDE_TAKEOVER_SONNET_MODEL: &str = "claude-sonnet-4-6"; const CLAUDE_TAKEOVER_OPUS_MODEL: &str = "claude-opus-4-8"; // 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。 const CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = "[1M]"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ClaudeTakeoverAuthPolicy { PreserveExistingOrAuthToken, ManagedAccount { keep_auth_token: bool }, } #[derive(Clone)] pub struct ProxyService { db: Arc, server: Arc>>, /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新 app_handle: Arc>>, switch_locks: SwitchLockManager, } #[derive(Debug, Clone, Copy, Default)] pub struct HotSwitchOutcome { pub logical_target_changed: bool, } impl ProxyService { pub fn new(db: Arc) -> Self { Self { db, server: Arc::new(RwLock::new(None)), app_handle: Arc::new(RwLock::new(None)), switch_locks: SwitchLockManager::new(), } } #[cfg(test)] fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) { Self::apply_claude_takeover_fields_with_policy( config, proxy_url, ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken, ); } fn apply_claude_takeover_fields_for_provider( config: &mut Value, proxy_url: &str, provider: &Provider, ) { let auth_policy = if provider.uses_managed_account_auth() { // Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留 // ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。 // Copilot 维持仅 API_KEY 占位,避免与 /login 管理的 key 冲突(#1049)。 ClaudeTakeoverAuthPolicy::ManagedAccount { keep_auth_token: !provider.is_github_copilot(), } } else { ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken }; // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。 let takeover_model_fields = if provider.uses_managed_account_auth() { Self::build_claude_takeover_model_fields(&provider.settings_config) } else { Self::build_claude_takeover_model_fields(config) }; Self::apply_claude_takeover_fields_with_policy_and_models( config, proxy_url, auth_policy, takeover_model_fields, ); } fn apply_claude_takeover_fields_with_policy( config: &mut Value, proxy_url: &str, auth_policy: ClaudeTakeoverAuthPolicy, ) { // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。 let takeover_model_fields = Self::build_claude_takeover_model_fields(config); Self::apply_claude_takeover_fields_with_policy_and_models( config, proxy_url, auth_policy, takeover_model_fields, ); } fn apply_claude_takeover_fields_with_policy_and_models( config: &mut Value, proxy_url: &str, auth_policy: ClaudeTakeoverAuthPolicy, takeover_model_fields: Vec<(&'static str, String)>, ) { if !config.is_object() { *config = json!({}); } let root = config .as_object_mut() .expect("Claude config should be normalized to an object"); let env = root.entry("env".to_string()).or_insert_with(|| json!({})); if !env.is_object() { *env = json!({}); } let env = env .as_object_mut() .expect("Claude env should be normalized to an object"); env.insert("ANTHROPIC_BASE_URL".to_string(), json!(proxy_url)); for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS { env.remove(key); } for (key, value) in takeover_model_fields { env.insert(key.to_string(), Value::String(value)); } let token_keys = [ "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", ]; match auth_policy { ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => { let mut replaced_any = false; for key in token_keys { if env.contains_key(key) { env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); replaced_any = true; } } if !replaced_any { env.insert( "ANTHROPIC_AUTH_TOKEN".to_string(), json!(PROXY_TOKEN_PLACEHOLDER), ); } } ClaudeTakeoverAuthPolicy::ManagedAccount { keep_auth_token } => { for key in token_keys { env.remove(key); } env.insert( "ANTHROPIC_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER), ); if keep_auth_token { // 无条件注入而非"已存在才保留":热切换路径传入的是 provider // settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。 env.insert( "ANTHROPIC_AUTH_TOKEN".to_string(), json!(PROXY_TOKEN_PLACEHOLDER), ); } } } } fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> { let Some(env) = config.get("env").and_then(Value::as_object) else { return Vec::new(); }; let default_model = Self::claude_env_string(env, "ANTHROPIC_MODEL"); let small_fast_model = Self::claude_env_string(env, "ANTHROPIC_SMALL_FAST_MODEL"); let haiku_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL") .or(small_fast_model) .or(default_model); let sonnet_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") .or(default_model) .or(small_fast_model); let opus_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_OPUS_MODEL") .or(default_model) .or(small_fast_model); let subagent_model = Self::claude_env_string(env, "CLAUDE_CODE_SUBAGENT_MODEL"); let mut fields = Vec::with_capacity(7); Self::push_claude_takeover_role_fields( &mut fields, env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", CLAUDE_TAKEOVER_HAIKU_MODEL, false, haiku_model, ); Self::push_claude_takeover_role_fields( &mut fields, env, "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", CLAUDE_TAKEOVER_SONNET_MODEL, true, sonnet_model, ); Self::push_claude_takeover_role_fields( &mut fields, env, "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", CLAUDE_TAKEOVER_OPUS_MODEL, true, opus_model, ); if let Some(subagent_model) = subagent_model { fields.push(("CLAUDE_CODE_SUBAGENT_MODEL", subagent_model.to_string())); } fields } fn push_claude_takeover_role_fields( fields: &mut Vec<(&'static str, String)>, env: &Map, model_key: &'static str, name_key: &'static str, takeover_model: &'static str, supports_one_m: bool, upstream_model: Option<&str>, ) { let Some(upstream_model) = upstream_model else { return; }; let mut client_model = takeover_model.to_string(); if supports_one_m && Self::has_claude_one_m_marker(upstream_model) { client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT); } fields.push((model_key, client_model)); let display_name = Self::claude_env_string(env, name_key) .map(str::to_string) .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model)); if !display_name.is_empty() { fields.push((name_key, display_name)); } } fn claude_env_string<'a>(env: &'a Map, key: &str) -> Option<&'a str> { env.get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) } fn has_claude_one_m_marker(model: &str) -> bool { model .trim_end() .to_ascii_lowercase() .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER) } fn strip_claude_one_m_marker(model: &str) -> String { crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model) .trim() .to_string() } fn claude_provider_with_effective_settings( &self, provider: &Provider, ) -> Result { let mut effective_provider = provider.clone(); effective_provider.settings_config = build_effective_settings_with_common_config( self.db.as_ref(), &AppType::Claude, provider, ) .map_err(|e| format!("构建 claude 有效配置失败: {e}"))?; Ok(effective_provider) } pub async fn sync_claude_live_from_provider_while_proxy_active( &self, provider: &Provider, ) -> Result<(), String> { let effective_provider = self.claude_provider_with_effective_settings(provider)?; let mut effective_settings = effective_provider.settings_config.clone(); let (proxy_url, _) = self.build_proxy_urls().await?; Self::apply_claude_takeover_fields_for_provider( &mut effective_settings, &proxy_url, &effective_provider, ); self.write_claude_live(&effective_settings)?; Ok(()) } pub async fn sync_codex_live_from_provider_while_proxy_active( &self, provider: &Provider, ) -> Result<(), String> { let existing_live = self.read_codex_live().ok(); let mut effective_settings = build_effective_settings_with_common_config( self.db.as_ref(), &AppType::Codex, provider, ) .map_err(|e| format!("构建 codex 有效配置失败: {e}"))?; if let Some(existing_live) = existing_live.as_ref() { Self::preserve_codex_mcp_servers_from_existing_config( &mut effective_settings, existing_live, )?; } let (_, proxy_codex_base_url) = self.build_proxy_urls().await?; if let Some(auth) = effective_settings .get_mut("auth") .and_then(|v| v.as_object_mut()) { auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); } else if let Some(root) = effective_settings.as_object_mut() { root.insert( "auth".to_string(), json!({ "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }), ); } let config_str = effective_settings .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); let updated_config = Self::apply_codex_proxy_toml_config_for_provider( config_str, &proxy_codex_base_url, Some(provider), ); effective_settings["config"] = json!(updated_config); Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider)); self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?; Ok(()) } fn get_current_provider_for_app(&self, app_type: &AppType) -> Result, String> { let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type) .map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))? else { return Ok(None); }; self.db .get_provider_by_id(¤t_id, app_type.as_str()) .map_err(|e| format!("读取 {app_type:?} 当前供应商失败: {e}")) } fn require_current_provider_for_app(&self, app_type: &AppType) -> Result { self.get_current_provider_for_app(app_type)? .ok_or_else(|| format!("{app_type:?} 当前供应商不存在,无法接管 Live 配置")) } /// 设置 AppHandle(在应用初始化时调用) pub fn set_app_handle(&self, handle: tauri::AppHandle) { futures::executor::block_on(async { *self.app_handle.write().await = Some(handle); }); } pub(crate) async fn lock_switch_for_app( &self, app_type: &str, ) -> tokio::sync::OwnedMutexGuard<()> { self.switch_locks.lock_for_app(app_type).await } /// 启动代理服务器 pub async fn start(&self) -> Result { // 1. 启动时自动设置 proxy_enabled = true let mut global_config = self .db .get_global_proxy_config() .await .map_err(|e| format!("获取全局代理配置失败: {e}"))?; if !global_config.proxy_enabled { global_config.proxy_enabled = true; self.db .update_global_proxy_config(global_config.clone()) .await .map_err(|e| format!("更新代理总开关失败: {e}"))?; } // 2. 获取配置 let config = self .db .get_proxy_config() .await .map_err(|e| format!("获取代理配置失败: {e}"))?; // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息 if let Some(server) = self.server.read().await.as_ref() { let status = server.get_status().await; return Ok(ProxyServerInfo { address: status.address, port: status.port, // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可 started_at: chrono::Utc::now().to_rfc3339(), }); } // 4. 创建并启动服务器 let app_handle = self.app_handle.read().await.clone(); let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle); let info = server .start() .await .map_err(|e| format!("启动代理服务器失败: {e}"))?; if let Err(e) = self .persist_ephemeral_listen_port_if_needed(&config, info.port) .await { let _ = server.stop().await; return Err(e); } // 5. 保存服务器实例 *self.server.write().await = Some(server); log::info!("代理服务器已启动: {}:{}", info.address, info.port); Ok(info) } async fn persist_ephemeral_listen_port_if_needed( &self, config: &ProxyConfig, actual_port: u16, ) -> Result<(), String> { if config.listen_port != 0 { return Ok(()); } let mut resolved_config = config.clone(); resolved_config.listen_port = actual_port; self.db .update_proxy_config(resolved_config) .await .map_err(|e| format!("保存动态代理端口失败: {e}")) } async fn start_before_takeover_if_ephemeral_port(&self) -> Result { let config = self .db .get_proxy_config() .await .map_err(|e| format!("获取代理配置失败: {e}"))?; if config.listen_port != 0 || self.is_running().await { return Ok(false); } self.start().await?; Ok(true) } /// 启动代理服务器(带 Live 配置接管) pub async fn start_with_takeover(&self) -> Result { // 1. 备份各应用的 Live 配置 self.backup_live_configs().await?; // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token) if let Err(e) = self.sync_live_to_providers().await { // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理 if let Err(clean_err) = self.db.delete_all_live_backups().await { log::warn!("清理 Live 备份失败: {clean_err}"); } return Err(e); } // 端口 0 需要先启动代理拿到 OS 分配的真实端口,否则接管 Live 配置会写出 :0。 let started_proxy_before_takeover = match self.start_before_takeover_if_ephemeral_port().await { Ok(started) => started, Err(e) => { if let Err(clean_err) = self.db.delete_all_live_backups().await { log::warn!("清理 Live 备份失败: {clean_err}"); } return Err(e); } }; // 3. 在写入接管配置之前先落盘接管标志: // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。 if let Err(e) = self.db.set_live_takeover_active(true).await { if let Err(clean_err) = self.db.delete_all_live_backups().await { log::warn!("清理 Live 备份失败: {clean_err}"); } if started_proxy_before_takeover { let _ = self.stop().await; } return Err(format!("设置接管状态失败: {e}")); } // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token) if let Err(e) = self.takeover_live_configs().await { // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。 log::error!("接管 Live 配置失败,尝试恢复原始配置: {e}"); match self.restore_live_configs().await { Ok(()) => { let _ = self.db.set_live_takeover_active(false).await; let _ = self.db.delete_all_live_backups().await; } Err(restore_err) => { log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}"); } } if started_proxy_before_takeover { let _ = self.stop().await; } return Err(e); } // 5. 启动代理服务器 match self.start().await { Ok(info) => Ok(info), Err(e) => { // 启动失败,恢复原始配置 log::error!("代理启动失败,尝试恢复原始配置: {e}"); match self.restore_live_configs().await { Ok(()) => { let _ = self.db.set_live_takeover_active(false).await; let _ = self.db.delete_all_live_backups().await; } Err(restore_err) => { log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}"); } } if started_proxy_before_takeover { let _ = self.stop().await; } Err(e) } } } /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理) pub async fn get_takeover_status(&self) -> Result { // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测 let claude_enabled = self .db .get_proxy_config_for_app("claude") .await .map(|c| c.enabled) .unwrap_or(false); let codex_enabled = self .db .get_proxy_config_for_app("codex") .await .map(|c| c.enabled) .unwrap_or(false); let gemini_enabled = self .db .get_proxy_config_for_app("gemini") .await .map(|c| c.enabled) .unwrap_or(false); // OpenCode and OpenClaw don't support proxy features, always return false let opencode_enabled = false; let openclaw_enabled = false; Ok(ProxyTakeoverStatus { claude: claude_enabled, codex: codex_enabled, gemini: gemini_enabled, opencode: opencode_enabled, openclaw: openclaw_enabled, }) } /// 为指定应用开启/关闭 Live 接管 /// /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置 /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务 pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> { let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let app_type_str = app.as_str(); let _guard = self.switch_locks.lock_for_app(app_type_str).await; if enabled { // 1) 代理服务未运行则自动启动 if !self.is_running().await { self.start().await?; } // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管 let current_config = self .db .get_proxy_config_for_app(app_type_str) .await .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; let mut restore_existing_backup_before_takeover = false; if current_config.enabled { let has_backup = match self.db.get_live_backup(app_type_str).await { Ok(v) => v.is_some(), Err(e) => { log::warn!("读取 {app_type_str} 备份失败(将继续重建接管): {e}"); false } }; let live_matches_current_proxy = match self.live_takeover_matches_current_proxy(&app).await { Ok(value) => value, Err(e) => { log::warn!("检测 {app_type_str} 接管配置失败(将继续重建接管): {e}"); false } }; // 必须 backup 存在,且 live 确实指向当前代理地址,才算真接管。 // 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后 // live 文件仍停留在普通供应商配置。 if has_backup && live_matches_current_proxy { return Ok(()); } restore_existing_backup_before_takeover = has_backup; log::warn!( "{app_type_str} 标记为已接管,但 backup={has_backup} live_matches_current_proxy={live_matches_current_proxy},正在重新接管并补齐 Live" ); } // 3) 备份 Live 配置(严格:目标 app 不存在则报错) if restore_existing_backup_before_takeover { self.restore_live_config_for_app_inner(&app).await?; } else { self.backup_live_config_strict(&app).await?; // 4) 同步 Live Token 到数据库(仅当前 app) if let Err(e) = self.sync_live_to_provider(&app).await { let _ = self.db.delete_live_backup(app_type_str).await; return Err(e); } } // 5) 写入接管配置(仅当前 app) if let Err(e) = self.takeover_live_config_strict(&app).await { log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}"); match self.restore_live_config_for_app_inner(&app).await { Ok(()) => { // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源 let _ = self.db.delete_live_backup(app_type_str).await; } Err(restore_err) => { log::error!( "{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}" ); } } return Err(e); } // 6) 设置 proxy_config.enabled = true let mut updated_config = self .db .get_proxy_config_for_app(app_type_str) .await .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; updated_config.enabled = true; self.db .update_proxy_config_for_app(updated_config) .await .map_err(|e| format!("设置 {app_type_str} enabled 状态失败: {e}"))?; // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) let _ = self.db.set_live_takeover_active(true).await; // 8) Warn if the current provider is official (risk of account ban via proxy) if let Ok(Some(current_id)) = crate::settings::get_effective_current_provider(&self.db, &app) { if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) { if provider.category.as_deref() == Some("official") { if let Some(handle) = self.app_handle.read().await.as_ref() { let _ = handle.emit( "proxy-official-warning", serde_json::json!({ "appType": app_type_str, "providerName": provider.name, }), ); } } } } return Ok(()); } // 关闭接管:检查 enabled 状态 let current_config = self .db .get_proxy_config_for_app(app_type_str) .await .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; if !current_config.enabled { return Ok(()); // 未接管,幂等返回 } // 1) 恢复 Live 配置 // // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。 // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()), // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。 self.restore_live_config_for_app_with_fallback_inner(&app) .await?; // 2) 删除该 app 的备份(避免长期存储敏感 Token) self.db .delete_live_backup(app_type_str) .await .map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?; // 3) 设置 proxy_config.enabled = false let mut updated_config = self .db .get_proxy_config_for_app(app_type_str) .await .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; updated_config.enabled = false; self.db .update_proxy_config_for_app(updated_config) .await .map_err(|e| format!("清除 {app_type_str} enabled 状态失败: {e}"))?; // 4) 清除该应用的健康状态(关闭代理时重置队列状态) self.db .clear_provider_health_for_app(app_type_str) .await .map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?; // 5) 若无其它接管,更新旧标志,并停止代理服务 // 检查是否还有其它 app 的 enabled = true let any_enabled = self .db .is_live_takeover_active() .await .map_err(|e| format!("检查接管状态失败: {e}"))?; if !any_enabled { let _ = self.db.set_live_takeover_active(false).await; if self.is_running().await { // 此时没有任何 app 处于接管状态,停止服务即可 let _ = self.stop().await; } } Ok(()) } /// 同步 Live 配置中的 Token 到数据库 /// /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。 /// 这样代理才能从数据库读取到正确的认证信息。 async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> { let live_config = match app_type { AppType::Claude => self.read_claude_live()?, AppType::Codex => self.read_codex_live()?, AppType::Gemini => self.read_gemini_live()?, _ => return Err("该应用不支持代理功能".to_string()), }; self.sync_live_config_to_provider(app_type, &live_config) .await } async fn sync_live_config_to_provider( &self, app_type: &AppType, live_config: &Value, ) -> Result<(), String> { match app_type { AppType::Claude => { let provider_id = crate::settings::get_effective_current_provider(&self.db, &AppType::Claude) .map_err(|e| format!("获取 Claude 当前供应商失败: {e}"))?; if let Some(provider_id) = provider_id { if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "claude") { if let Some(env) = live_config.get("env").and_then(|v| v.as_object()) { let token_pair = [ "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", ] .into_iter() .find_map(|key| { env.get(key) .and_then(|v| v.as_str()) .map(|s| (key, s.trim())) }) .filter(|(_, token)| { !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER }); if let Some((token_key, token)) = token_pair { let env_obj = provider .settings_config .get_mut("env") .and_then(|v| v.as_object_mut()); match env_obj { Some(obj) => { if token_key == "ANTHROPIC_AUTH_TOKEN" || token_key == "ANTHROPIC_API_KEY" { let mut updated = false; if obj.contains_key("ANTHROPIC_AUTH_TOKEN") { obj.insert( "ANTHROPIC_AUTH_TOKEN".to_string(), json!(token), ); updated = true; } if obj.contains_key("ANTHROPIC_API_KEY") { obj.insert( "ANTHROPIC_API_KEY".to_string(), json!(token), ); updated = true; } if !updated { obj.insert(token_key.to_string(), json!(token)); } } else { obj.insert(token_key.to_string(), json!(token)); } } None => { // 至少写入一份可用的 Token if provider.settings_config.is_null() { provider.settings_config = json!({}); } if let Some(root) = provider.settings_config.as_object_mut() { root.insert( "env".to_string(), json!({ token_key: token }), ); } else { log::warn!( "Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})" ); } } } if let Err(e) = self.db.update_provider_settings_config( "claude", &provider_id, &provider.settings_config, ) { log::warn!("同步 Claude Token 到数据库失败: {e}"); } else { log::info!( "已同步 Claude Token 到数据库 (provider: {provider_id})" ); } } } } } } AppType::Codex => { let provider_id = crate::settings::get_effective_current_provider(&self.db, &AppType::Codex) .map_err(|e| format!("获取 Codex 当前供应商失败: {e}"))?; if let Some(provider_id) = provider_id { if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "codex") { if let Some(token) = live_config .get("auth") .and_then(|v| v.get("OPENAI_API_KEY")) .and_then(|v| v.as_str()) .map(|s| s.trim()) .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER) { if let Some(auth_obj) = provider .settings_config .get_mut("auth") .and_then(|v| v.as_object_mut()) { auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token)); } else { if provider.settings_config.is_null() { provider.settings_config = json!({}); } if let Some(root) = provider.settings_config.as_object_mut() { root.insert( "auth".to_string(), json!({ "OPENAI_API_KEY": token }), ); } else { log::warn!( "Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})" ); } } if let Err(e) = self.db.update_provider_settings_config( "codex", &provider_id, &provider.settings_config, ) { log::warn!("同步 Codex Token 到数据库失败: {e}"); } else { log::info!("已同步 Codex Token 到数据库 (provider: {provider_id})"); } } } } } AppType::Gemini => { let provider_id = crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini) .map_err(|e| format!("获取 Gemini 当前供应商失败: {e}"))?; if let Some(provider_id) = provider_id { if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "gemini") { if let Some(token) = live_config .get("env") .and_then(|v| v.get("GEMINI_API_KEY")) .and_then(|v| v.as_str()) .map(|s| s.trim()) .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER) { if let Some(env_obj) = provider .settings_config .get_mut("env") .and_then(|v| v.as_object_mut()) { env_obj.insert("GEMINI_API_KEY".to_string(), json!(token)); } else { if provider.settings_config.is_null() { provider.settings_config = json!({}); } if let Some(root) = provider.settings_config.as_object_mut() { root.insert( "env".to_string(), json!({ "GEMINI_API_KEY": token }), ); } else { log::warn!( "Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})" ); } } if let Err(e) = self.db.update_provider_settings_config( "gemini", &provider_id, &provider.settings_config, ) { log::warn!("同步 Gemini Token 到数据库失败: {e}"); } else { log::info!( "已同步 Gemini Token 到数据库 (provider: {provider_id})" ); } } } } } _ => {} } Ok(()) } async fn sync_live_to_providers(&self) -> Result<(), String> { if let Ok(live_config) = self.read_claude_live() { self.sync_live_config_to_provider(&AppType::Claude, &live_config) .await?; } if let Ok(live_config) = self.read_codex_live() { self.sync_live_config_to_provider(&AppType::Codex, &live_config) .await?; } if let Ok(live_config) = self.read_gemini_live() { self.sync_live_config_to_provider(&AppType::Gemini, &live_config) .await?; } log::info!("Live 配置 Token 同步完成"); Ok(()) } /// 停止代理服务器 pub async fn stop(&self) -> Result<(), String> { if let Some(server) = self.server.write().await.take() { server .stop() .await .map_err(|e| format!("停止代理服务器失败: {e}"))?; // 停止时设置 proxy_enabled = false let mut global_config = self .db .get_global_proxy_config() .await .map_err(|e| format!("获取全局代理配置失败: {e}"))?; if global_config.proxy_enabled { global_config.proxy_enabled = false; if let Err(e) = self.db.update_global_proxy_config(global_config).await { log::warn!("更新代理总开关失败: {e}"); } } log::info!("代理服务器已停止"); Ok(()) } else { Err("代理服务器未运行".to_string()) } } /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用) /// /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。 pub async fn stop_with_restore(&self) -> Result<(), String> { // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑) if let Err(e) = self.stop().await { log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}"); } // 2. 恢复原始 Live 配置 self.restore_live_configs().await?; // 3. 清除 proxy_config 表中的接管状态(兼容旧版) self.db .set_live_takeover_active(false) .await .map_err(|e| format!("清除接管状态失败: {e}"))?; // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复) for app_type in ["claude", "codex", "gemini"] { if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await { if config.enabled { config.enabled = false; if let Err(e) = self.db.update_proxy_config_for_app(config).await { log::warn!("清除 {app_type} enabled 状态失败: {e}"); } } } } // 5. 删除备份 self.db .delete_all_live_backups() .await .map_err(|e| format!("删除备份失败: {e}"))?; // 6. 重置健康状态(让健康徽章恢复为正常) self.db .clear_all_provider_health() .await .map_err(|e| format!("重置健康状态失败: {e}"))?; // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用 log::info!("代理已停止,Live 配置已恢复"); Ok(()) } /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态) /// /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复 pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> { // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑) if let Err(e) = self.stop().await { log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}"); } // 2. 恢复原始 Live 配置 self.restore_live_configs().await?; // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版) // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复 if let Ok(mut config) = self.db.get_proxy_config().await { config.live_takeover_active = false; let _ = self.db.update_proxy_config(config).await; } // 4. 删除备份(Live 配置已恢复,备份不再需要) self.db .delete_all_live_backups() .await .map_err(|e| format!("删除备份失败: {e}"))?; // 5. 重置健康状态 self.db .clear_all_provider_health() .await .map_err(|e| format!("重置健康状态失败: {e}"))?; log::info!("代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)"); Ok(()) } /// 备份各应用的 Live 配置 async fn backup_live_configs(&self) -> Result<(), String> { // Claude if let Ok(config) = self.read_claude_live() { // 跳过已被代理接管的 Live:避免把代理占位符当作"原始 Live"存进备份槽。 // 否则下次 start_with_takeover 在异常历史状态下(Live 已是占位符)再次 // 调用本函数,会用代理配置覆盖一个原本正常的备份;之后 stop 恢复时 // 即便走到备份路径也会把代理占位符再写回 Live,永久卡在 127.0.0.1:15721。 if Self::live_has_proxy_placeholder_for_app(&AppType::Claude, &config) { log::warn!("claude Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live"); } else { let json_str = serde_json::to_string(&config) .map_err(|e| format!("序列化 Claude 配置失败: {e}"))?; self.db .save_live_backup("claude", &json_str) .await .map_err(|e| format!("备份 Claude 配置失败: {e}"))?; } } // Codex if let Ok(config) = self.read_codex_live() { if Self::live_has_proxy_placeholder_for_app(&AppType::Codex, &config) { log::warn!("codex Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live"); } else { let json_str = serde_json::to_string(&config) .map_err(|e| format!("序列化 Codex 配置失败: {e}"))?; self.db .save_live_backup("codex", &json_str) .await .map_err(|e| format!("备份 Codex 配置失败: {e}"))?; } } // Gemini if let Ok(config) = self.read_gemini_live() { if Self::live_has_proxy_placeholder_for_app(&AppType::Gemini, &config) { log::warn!("gemini Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live"); } else { let json_str = serde_json::to_string(&config) .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?; self.db .save_live_backup("gemini", &json_str) .await .map_err(|e| format!("备份 Gemini 配置失败: {e}"))?; } } log::info!("已备份所有应用的 Live 配置"); Ok(()) } /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误) async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> { let (app_type_str, config) = match app_type { AppType::Claude => ("claude", self.read_claude_live()?), AppType::Codex => ("codex", self.read_codex_live()?), AppType::Gemini => ("gemini", self.read_gemini_live()?), _ => return Err("该应用不支持代理功能".to_string()), }; // 跳过已被代理接管的 Live:避免把代理占位符当作"原始 Live"存进备份槽 // (见 backup_live_configs 中的注释)。 if Self::live_has_proxy_placeholder_for_app(app_type, &config) { log::warn!( "{app_type_str} Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live" ); return Ok(()); } let json_str = serde_json::to_string(&config) .map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?; self.db .save_live_backup(app_type_str, &json_str) .await .map_err(|e| format!("备份 {app_type_str} 配置失败: {e}"))?; Ok(()) } /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况) async fn build_proxy_urls(&self) -> Result<(String, String), String> { let config = self .db .get_proxy_config() .await .map_err(|e| format!("获取代理配置失败: {e}"))?; // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接; // 因此写回到各应用配置时,优先使用本机回环地址。 let connect_host = match config.listen_address.as_str() { "0.0.0.0" => "127.0.0.1".to_string(), "::" => "::1".to_string(), _ => config.listen_address.clone(), }; let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') { format!("[{connect_host}]") } else { connect_host }; let mut listen_port = config.listen_port; if let Some(server) = self.server.read().await.as_ref() { let status = server.get_status().await; if status.running { listen_port = status.port; } } if listen_port == 0 { return Err("代理监听端口为 0,但代理服务器尚未运行,无法生成接管地址".to_string()); } let proxy_origin = format!("http://{}:{}", connect_host_for_url, listen_port); let proxy_url = proxy_origin.clone(); let proxy_codex_base_url = format!("{}/v1", proxy_origin.trim_end_matches('/')); Ok((proxy_url, proxy_codex_base_url)) } /// 接管各应用的 Live 配置(写入代理地址) /// /// 代理服务器的路由已经根据 API 端点自动区分应用类型: /// - `/v1/messages` → Claude /// - `/v1/chat/completions`, `/v1/responses` → Codex /// - `/v1beta/*` → Gemini /// /// 因此不需要在 URL 中添加应用前缀。 async fn takeover_live_configs(&self) -> Result<(), String> { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) if let Ok(mut live_config) = self.read_claude_live() { let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?; let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?; Self::apply_claude_takeover_fields_for_provider( &mut live_config, &proxy_url, &claude_provider, ); self.write_claude_live(&live_config)?; log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token) if let Ok(mut live_config) = self.read_codex_live() { // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符) if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); } // 2. 修改 config.toml 中的 base_url let config_str = live_config .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); let codex_provider = self .get_current_provider_for_app(&AppType::Codex) .ok() .flatten(); let updated_config = Self::apply_codex_proxy_toml_config_for_provider( config_str, &proxy_codex_base_url, codex_provider.as_ref(), ); live_config["config"] = json!(updated_config); Self::attach_codex_model_catalog_from_provider( &mut live_config, codex_provider.as_ref(), ); self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); } // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) if let Ok(mut live_config) = self.read_gemini_live() { if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { env.insert("GOOGLE_GEMINI_BASE_URL".to_string(), json!(&proxy_url)); // 使用占位符,避免显示缺少 key 的警告 env.insert("GEMINI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); } else { live_config["env"] = json!({ "GOOGLE_GEMINI_BASE_URL": &proxy_url, "GEMINI_API_KEY": PROXY_TOKEN_PLACEHOLDER }); } self.write_gemini_live(&live_config)?; log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } Ok(()) } /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误) async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; match app_type { AppType::Claude => { let mut live_config = self.read_claude_live()?; let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?; let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?; Self::apply_claude_takeover_fields_for_provider( &mut live_config, &proxy_url, &claude_provider, ); self.write_claude_live(&live_config)?; log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } AppType::Codex => { let mut live_config = self.read_codex_live()?; if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); } let config_str = live_config .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?; let updated_config = Self::apply_codex_proxy_toml_config_for_provider( config_str, &proxy_codex_base_url, Some(&codex_provider), ); live_config["config"] = json!(updated_config); Self::attach_codex_model_catalog_from_provider( &mut live_config, Some(&codex_provider), ); self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?; log::info!("Codex Live 配置已接管,代理地址: {proxy_codex_base_url}"); } AppType::Gemini => { let mut live_config = self.read_gemini_live()?; if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { env.insert("GOOGLE_GEMINI_BASE_URL".to_string(), json!(&proxy_url)); env.insert("GEMINI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); } else { live_config["env"] = json!({ "GOOGLE_GEMINI_BASE_URL": &proxy_url, "GEMINI_API_KEY": PROXY_TOKEN_PLACEHOLDER }); } self.write_gemini_live(&live_config)?; log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } _ => return Err("该应用不支持代理功能".to_string()), } Ok(()) } /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过) async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; match app_type { AppType::Claude => { if let Ok(mut live_config) = self.read_claude_live() { let claude_provider = self .get_current_provider_for_app(&AppType::Claude) .ok() .flatten(); if let Some(provider) = claude_provider.as_ref() { let provider = self.claude_provider_with_effective_settings(provider)?; Self::apply_claude_takeover_fields_for_provider( &mut live_config, &proxy_url, &provider, ); } else { Self::apply_claude_takeover_fields_with_policy( &mut live_config, &proxy_url, ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken, ); } let _ = self.write_claude_live(&live_config); } } AppType::Codex => { if let Ok(mut live_config) = self.read_codex_live() { if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) { auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); } let config_str = live_config .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); let codex_provider = self .get_current_provider_for_app(&AppType::Codex) .ok() .flatten(); let updated_config = Self::apply_codex_proxy_toml_config_for_provider( config_str, &proxy_codex_base_url, codex_provider.as_ref(), ); live_config["config"] = json!(updated_config); Self::attach_codex_model_catalog_from_provider( &mut live_config, codex_provider.as_ref(), ); let _ = self.write_codex_takeover_live_for_provider( &live_config, codex_provider.as_ref(), ); } } AppType::Gemini => { if let Ok(mut live_config) = self.read_gemini_live() { if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) { env.insert("GOOGLE_GEMINI_BASE_URL".to_string(), json!(&proxy_url)); env.insert("GEMINI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); } else { live_config["env"] = json!({ "GOOGLE_GEMINI_BASE_URL": &proxy_url, "GEMINI_API_KEY": PROXY_TOKEN_PLACEHOLDER }); } let _ = self.write_gemini_live(&live_config); } } _ => {} } Ok(()) } async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> { match app_type { AppType::Claude => { if let Ok(Some(backup)) = self.db.get_live_backup("claude").await { let config: Value = serde_json::from_str(&backup.original_config) .map_err(|e| format!("解析 Claude 备份失败: {e}"))?; self.write_claude_live(&config)?; log::info!("Claude Live 配置已恢复"); } } AppType::Codex => { if let Ok(Some(backup)) = self.db.get_live_backup("codex").await { let config: Value = serde_json::from_str(&backup.original_config) .map_err(|e| format!("解析 Codex 备份失败: {e}"))?; self.write_codex_live(&config)?; log::info!("Codex Live 配置已恢复"); } } AppType::Gemini => { if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await { let config: Value = serde_json::from_str(&backup.original_config) .map_err(|e| format!("解析 Gemini 备份失败: {e}"))?; self.write_gemini_live(&config)?; log::info!("Gemini Live 配置已恢复"); } } _ => {} } Ok(()) } /// 恢复原始 Live 配置 async fn restore_live_configs(&self) -> Result<(), String> { let mut errors = Vec::new(); for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { if let Err(e) = self .restore_live_config_for_app_with_fallback(&app_type) .await { errors.push(e); } } if errors.is_empty() { Ok(()) } else { Err(errors.join(";")) } } async fn restore_live_config_for_app_with_fallback( &self, app_type: &AppType, ) -> Result<(), String> { let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await; self.restore_live_config_for_app_with_fallback_inner(app_type) .await } async fn restore_live_config_for_app_with_fallback_inner( &self, app_type: &AppType, ) -> Result<(), String> { let app_type_str = app_type.as_str(); // 1) 优先从 Live 备份恢复(这是"原始 Live"的唯一可靠来源) let backup = self .db .get_live_backup(app_type_str) .await .map_err(|e| format!("获取 {app_type_str} Live 备份失败: {e}"))?; if let Some(backup) = backup { let config: Value = serde_json::from_str(&backup.original_config) .map_err(|e| format!("解析 {app_type_str} 备份失败: {e}"))?; // 备份若是代理占位符(异常历史:上次 stop 失败导致 Live 留在了代理状态, // 下次接管时又被错误地备份成"原始 Live"),不能直接用 — 否则 stop 后 // Live 永远卡在 127.0.0.1:15721。落到下面的 SSOT 兜底重建。 if Self::live_has_proxy_placeholder_for_app(app_type, &config) { log::warn!( "{app_type_str} 备份本身已是代理占位符(异常历史状态),跳过备份,改走 SSOT 重建 Live" ); } else { self.write_live_config_for_app(app_type, &config)?; log::info!("{app_type_str} Live 配置已从备份恢复"); return Ok(()); } } // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景) if !self.detect_takeover_in_live_config_for_app(app_type) { return Ok(()); } // 2.1) 优先从 SSOT(当前供应商)重建 Live(比"清理字段"更可用) match self.restore_live_from_ssot_for_app(app_type) { Ok(true) => { log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)"); return Ok(()); } Ok(false) => { log::warn!( "{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符" ); } Err(e) => { log::error!( "{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}" ); } } // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态 self.cleanup_takeover_placeholders_in_live_for_app(app_type)?; log::info!("{app_type_str} Live 接管占位符已清理(无备份兜底)"); Ok(()) } fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> { match app_type { AppType::Claude => self.write_claude_live(config), AppType::Codex => self.write_codex_live(config), AppType::Gemini => self.write_gemini_live(config), _ => Err("该应用不支持代理功能".to_string()), } } pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool { match app_type { AppType::Claude => match self.read_claude_live() { Ok(config) => Self::is_claude_live_taken_over(&config), Err(_) => false, }, AppType::Codex => match self.read_codex_live() { Ok(config) => Self::is_codex_live_taken_over(&config), Err(_) => false, }, AppType::Gemini => match self.read_gemini_live() { Ok(config) => Self::is_gemini_live_taken_over(&config), Err(_) => false, }, _ => false, } } /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。 /// /// 返回值: /// - Ok(true):已成功写回 /// - Ok(false):缺少当前供应商/供应商不存在/供应商本身含占位符,无法写回 fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result { let current_id = crate::settings::get_effective_current_provider(&self.db, app_type) .map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?; let Some(current_id) = current_id else { return Ok(false); }; let providers = self .db .get_all_providers(app_type.as_str()) .map_err(|e| format!("读取 {app_type:?} 供应商列表失败: {e}"))?; let Some(provider) = providers.get(¤t_id) else { return Ok(false); }; // 供应商配置本身含接管占位符时不可写回(历史异常:接管期间 Live 被 // 误导入成了供应商)。写回只会把占位符固化进 Live;返回 Ok(false) // 让调用方落到"清理占位符"兜底。 if Self::live_has_proxy_placeholder_for_app(app_type, &provider.settings_config) { log::warn!( "{app_type:?} 当前供应商配置含代理接管占位符(疑似接管期间被导入的残留),跳过 SSOT 写回,改走占位符清理" ); return Ok(false); } write_live_with_common_config(self.db.as_ref(), app_type, provider) .map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?; Ok(true) } fn cleanup_takeover_placeholders_in_live_for_app( &self, app_type: &AppType, ) -> Result<(), String> { match app_type { AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(), AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(), AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(), _ => Ok(()), } } fn is_local_proxy_url(url: &str) -> bool { let url = url.trim(); if !url.starts_with("http://") { return false; } let rest = &url["http://".len()..]; rest.starts_with("127.0.0.1") || rest.starts_with("localhost") || rest.starts_with("0.0.0.0") || rest.starts_with("[::1]") || rest.starts_with("[::]") || rest.starts_with("::1") || rest.starts_with("::") } fn proxy_urls_match(actual: &str, expected: &str) -> bool { actual.trim().trim_end_matches('/') == expected.trim().trim_end_matches('/') } fn codex_config_has_base_url_matching( config_text: &str, predicate: impl Fn(&str) -> bool, ) -> bool { let Ok(doc) = toml::from_str::(config_text) else { return false; }; let active_provider = doc .get("model_provider") .and_then(|value| value.as_str()) .map(str::trim) .filter(|id| !id.is_empty()); if let Some(provider_id) = active_provider { if doc .get("model_providers") .and_then(|value| value.get(provider_id)) .and_then(|value| value.get("base_url")) .and_then(|value| value.as_str()) .is_some_and(&predicate) { return true; } } doc.get("base_url") .and_then(|value| value.as_str()) .is_some_and(predicate) } async fn live_takeover_matches_current_proxy( &self, app_type: &AppType, ) -> Result { let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?; match app_type { AppType::Claude => { let config = self.read_claude_live()?; let base_url_matches = config .get("env") .and_then(|value| value.get("ANTHROPIC_BASE_URL")) .and_then(|value| value.as_str()) .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url)); Ok(Self::is_claude_live_taken_over(&config) && base_url_matches) } AppType::Codex => { let config = self.read_codex_live()?; let base_url_matches = config .get("config") .and_then(|value| value.as_str()) .is_some_and(|config_text| { Self::codex_config_has_base_url_matching(config_text, |url| { Self::proxy_urls_match(url, &proxy_codex_base_url) }) }); Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches) } AppType::Gemini => { let config = self.read_gemini_live()?; let base_url_matches = config .get("env") .and_then(|value| value.get("GOOGLE_GEMINI_BASE_URL")) .and_then(|value| value.as_str()) .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url)); Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches) } _ => Ok(false), } } fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> { let mut config = self.read_claude_live()?; let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else { return Ok(()); }; for key in [ "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", ] { if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) { env.remove(key); } } if env .get("ANTHROPIC_BASE_URL") .and_then(|v| v.as_str()) .map(Self::is_local_proxy_url) .unwrap_or(false) { env.remove("ANTHROPIC_BASE_URL"); } self.write_claude_live(&config)?; Ok(()) } fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> { let mut config = self.read_codex_live()?; if let Some(auth) = config.get_mut("auth").and_then(|v| v.as_object_mut()) { if auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) { auth.remove("OPENAI_API_KEY"); } } if let Some(cfg_str) = config.get("config").and_then(|v| v.as_str()) { let updated = Self::remove_local_toml_base_url(cfg_str); let updated = crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| { token == PROXY_TOKEN_PLACEHOLDER }) .map_err(|e| format!("清理 Codex 接管占位符失败: {e}"))?; config["config"] = json!(updated); } self.write_codex_live(&config)?; Ok(()) } /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现) fn remove_local_toml_base_url(toml_str: &str) -> 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> { let mut config = self.read_gemini_live()?; let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else { return Ok(()); }; if env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) { env.remove("GEMINI_API_KEY"); } if env .get("GOOGLE_GEMINI_BASE_URL") .and_then(|v| v.as_str()) .map(Self::is_local_proxy_url) .unwrap_or(false) { env.remove("GOOGLE_GEMINI_BASE_URL"); } self.write_gemini_live(&config)?; Ok(()) } /// 检查是否处于 Live 接管模式 pub async fn is_takeover_active(&self) -> Result { let status = self.get_takeover_status().await?; Ok(status.claude || status.codex || status.gemini) } /// 从异常退出中恢复(启动时调用) /// /// 检测到 Live 备份残留时调用此方法。 /// 会恢复 Live 配置、清除接管标志、删除备份。 pub async fn recover_from_crash(&self) -> Result<(), String> { // 1. 恢复 Live 配置 self.restore_live_configs().await?; // 2. 清除接管标志 self.db .set_live_takeover_active(false) .await .map_err(|e| format!("清除接管状态失败: {e}"))?; // 3. 删除备份 self.db .delete_all_live_backups() .await .map_err(|e| format!("删除备份失败: {e}"))?; log::info!("已从异常退出中恢复 Live 配置"); Ok(()) } /// 检测 Live 配置是否处于"被接管"的残留状态 /// /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时, /// 启动流程可以据此触发恢复逻辑。 pub fn detect_takeover_in_live_configs(&self) -> bool { if let Ok(config) = self.read_claude_live() { if Self::is_claude_live_taken_over(&config) { return true; } } if let Ok(config) = self.read_codex_live() { if Self::is_codex_live_taken_over(&config) { return true; } } if let Ok(config) = self.read_gemini_live() { if Self::is_gemini_live_taken_over(&config) { return true; } } false } fn is_claude_live_taken_over(config: &Value) -> bool { let env = match config.get("env").and_then(|v| v.as_object()) { Some(env) => env, None => return false, }; for key in [ "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", ] { if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) { return true; } } false } fn codex_live_has_proxy_placeholder(config: &Value) -> bool { if config .get("auth") .and_then(|v| v.as_object()) .and_then(|auth| auth.get("OPENAI_API_KEY")) .and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) { return true; } config .get("config") .and_then(|v| v.as_str()) .and_then(crate::codex_config::extract_codex_experimental_bearer_token) .as_deref() == Some(PROXY_TOKEN_PLACEHOLDER) } fn is_codex_live_taken_over(config: &Value) -> bool { Self::codex_live_has_proxy_placeholder(config) } fn is_gemini_live_taken_over(config: &Value) -> bool { let env = match config.get("env").and_then(|v| v.as_object()) { Some(env) => env, None => return false, }; env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) } /// 判断给定的 Live/备份配置是否已被代理接管(包含占位符) /// /// 用途:检测"备份里存的其实是代理配置"这种异常历史状态。 /// 如果发现,备份不可信,备份路径不能写入(否则会把代理配置固化进备份槽), /// 恢复路径不能读取(否则会把代理占位符原样写回 Live,永久卡在代理地址)。 /// 两种情况下都应该走 SSOT 兜底重建 Live。 fn live_has_proxy_placeholder_for_app(app_type: &AppType, config: &Value) -> bool { match app_type { AppType::Claude => Self::is_claude_live_taken_over(config), AppType::Codex => Self::codex_live_has_proxy_placeholder(config), AppType::Gemini => Self::is_gemini_live_taken_over(config), _ => false, } } /// 从供应商配置更新 Live 备份(用于代理模式下的热切换) /// /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份, /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。 pub async fn update_live_backup_from_provider( &self, app_type: &str, provider: &Provider, ) -> Result<(), String> { let _guard = self.switch_locks.lock_for_app(app_type).await; self.update_live_backup_from_provider_inner(app_type, provider) .await } /// 仅供已持有 per-app 切换锁的调用方使用。 async fn update_live_backup_from_provider_inner( &self, app_type: &str, provider: &Provider, ) -> Result<(), String> { 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_value = self .db .get_live_backup(app_type) .await .map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))? .map(|backup| { serde_json::from_str::(&backup.original_config) .map_err(|e| format!("解析 {app_type} 现有备份失败: {e}")) }) .transpose()?; if let Some(existing_value) = existing_backup_value.as_ref() { Self::preserve_codex_mcp_servers_from_existing_config( &mut effective_settings, existing_value, )?; Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?; } // 统一会话开关:备份是接管释放时恢复 live 的来源,官方配置的 // 共享 custom 路由注入必须落在备份里,否则恢复后开关失效。 crate::codex_config::apply_codex_unified_session_bucket_to_settings( provider.category.as_deref(), &mut effective_settings, ) .map_err(|e| format!("注入统一会话路由失败: {e}"))?; } 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": {} }) }; serde_json::to_string(&env_backup) .map_err(|e| format!("序列化 Gemini 配置失败: {e}"))? } _ => return Err(format!("未知的应用类型: {app_type}")), }; self.db .save_live_backup(app_type, &backup_json) .await .map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?; log::info!("已更新 {app_type} Live 备份(热切换)"); Ok(()) } pub async fn hot_switch_provider( &self, app_type: &str, provider_id: &str, ) -> Result { let _guard = self.switch_locks.lock_for_app(app_type).await; self.hot_switch_provider_inner(app_type, provider_id).await } pub(crate) async fn hot_switch_provider_inner( &self, app_type: &str, provider_id: &str, ) -> Result { let app_type_enum = AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?; let provider = self .db .get_provider_by_id(provider_id, app_type) .map_err(|e| format!("读取供应商失败: {e}"))? .ok_or_else(|| format!("供应商不存在: {provider_id}"))?; // Defense-in-depth: block official providers during proxy takeover if provider.category.as_deref() == Some("official") { return Err( "代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)" .to_string(), ); } let logical_target_changed = crate::settings::get_effective_current_provider(&self.db, &app_type_enum) .map_err(|e| format!("读取当前供应商失败: {e}"))? .as_deref() != Some(provider_id); let has_backup = self .db .get_live_backup(app_type_enum.as_str()) .await .map_err(|e| format!("读取 {app_type} 备份失败: {e}"))? .is_some(); let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum); let should_sync_backup = has_backup || live_taken_over; self.db .set_current_provider(app_type_enum.as_str(), provider_id) .map_err(|e| format!("更新当前供应商失败: {e}"))?; crate::settings::set_current_provider(&app_type_enum, Some(provider_id)) .map_err(|e| format!("更新本地当前供应商失败: {e}"))?; if should_sync_backup { self.update_live_backup_from_provider_inner(app_type, &provider) .await?; if matches!(app_type_enum, AppType::Claude) { self.sync_claude_live_from_provider_while_proxy_active(&provider) .await?; } else if live_taken_over && matches!(app_type_enum, AppType::Codex) { self.sync_codex_live_from_provider_while_proxy_active(&provider) .await?; } } if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) { let effective_settings = build_effective_settings_with_common_config( self.db.as_ref(), &AppType::Codex, &provider, ) .map_err(|e| format!("构建 Codex 有效配置失败: {e}"))?; let auth = effective_settings .get("auth") .ok_or_else(|| "Codex 供应商缺少 auth 配置".to_string())?; let config_str = effective_settings.get("config").and_then(|v| v.as_str()); let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), ); crate::codex_config::write_codex_provider_live_with_catalog( &effective_settings, provider.category.as_deref(), auth, config_str, profile, ) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; } if let Some(server) = self.server.read().await.as_ref() { server .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name) .await; } Ok(HotSwitchOutcome { logical_target_changed, }) } #[cfg(test)] async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> { self.switch_locks.lock_for_app(app_type).await } fn preserve_codex_mcp_servers_from_existing_config( target_settings: &mut Value, existing_config: &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_config .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(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 MCP merge" ); } } None => { target_doc["mcp_servers"] = existing_mcp_servers.clone(); } } } target_obj.insert("config".to_string(), json!(target_doc.to_string())); Ok(()) } fn preserve_codex_oauth_auth_in_backup( target_settings: &mut Value, existing_backup: &Value, ) -> Result<(), String> { if !crate::settings::preserve_codex_official_auth_on_switch() { return Ok(()); } let Some(existing_auth) = existing_backup .get("auth") .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth)) .cloned() else { return Ok(()); }; let Some(target_obj) = target_settings.as_object_mut() else { return Ok(()); }; let provider_auth = target_obj.get("auth").cloned().unwrap_or_else(|| json!({})); if let Some(config_text) = target_obj.get("config").and_then(|value| value.as_str()) { let live_config = crate::codex_config::prepare_codex_provider_live_config( &provider_auth, config_text, ) .map_err(|e| format!("更新 Codex 备份配置失败: {e}"))?; target_obj.insert("config".to_string(), json!(live_config)); } target_obj.insert("auth".to_string(), existing_auth); Ok(()) } /// 代理模式下切换供应商(热切换,并按需刷新代理安全的 Live 显示字段) pub async fn switch_proxy_target( &self, app_type: &str, provider_id: &str, ) -> Result<(), String> { let outcome = self.hot_switch_provider(app_type, provider_id).await?; if outcome.logical_target_changed { log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}"); } else { log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}"); } Ok(()) } // ==================== Live 配置读写辅助方法 ==================== /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现) fn update_toml_base_url(toml_str: &str, new_url: &str) -> String { crate::codex_config::update_codex_toml_field(toml_str, "base_url", new_url) .unwrap_or_else(|_| toml_str.to_string()) } /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。 /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。 fn apply_codex_proxy_toml_config_for_provider( toml_str: &str, proxy_url: &str, provider: Option<&Provider>, ) -> String { let updated = Self::update_toml_base_url(toml_str, proxy_url); let mut updated = crate::codex_config::update_codex_toml_field(&updated, "wire_api", "responses") .unwrap_or(updated); if let Some(upstream_model) = provider.and_then(crate::proxy::providers::codex_provider_upstream_model) { updated = crate::codex_config::update_codex_toml_field(&updated, "model", &upstream_model) .unwrap_or(updated); } updated } fn attach_codex_model_catalog_from_provider( live_config: &mut Value, provider: Option<&Provider>, ) { let Some(provider) = provider else { return; }; let model_catalog = provider .settings_config .get("modelCatalog") .cloned() .unwrap_or_else(|| json!({ "models": [] })); if let Some(root) = live_config.as_object_mut() { root.insert("modelCatalog".to_string(), model_catalog); } } fn read_claude_live(&self) -> Result { let path = get_claude_settings_path(); if !path.exists() { return Err("Claude 配置文件不存在".to_string()); } let mut value: Value = read_json_file(&path).map_err(|e| format!("读取 Claude 配置失败: {e}"))?; if value.is_null() { value = json!({}); } if !value.is_object() { let kind = match &value { Value::Null => "null", Value::Bool(_) => "boolean", Value::Number(_) => "number", Value::String(_) => "string", Value::Array(_) => "array", Value::Object(_) => "object", }; return Err(format!( "Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}", path.display() )); } Ok(value) } fn write_claude_live(&self, config: &Value) -> Result<(), String> { let path = get_claude_settings_path(); let settings = crate::services::provider::sanitize_claude_settings_for_live(config); write_json_file(&path, &settings).map_err(|e| format!("写入 Claude 配置失败: {e}")) } fn read_codex_live(&self) -> Result { crate::codex_config::read_codex_live_settings() .map_err(|e| format!("读取 Codex Live 配置失败: {e}")) } fn write_codex_live(&self, config: &Value) -> Result<(), String> { self.write_codex_live_verbatim(config) } fn write_codex_live_for_provider( &self, config: &Value, provider: Option<&Provider>, ) -> Result<(), String> { let Some(provider) = provider else { if crate::settings::preserve_codex_official_auth_on_switch() { if let (Some(auth), Some(config_str)) = ( config.get("auth"), config.get("config").and_then(|v| v.as_str()), ) { if auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) { let live_config = crate::codex_config::prepare_codex_provider_live_config( auth, config_str, ) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; crate::codex_config::write_codex_live_config_atomic(Some(&live_config)) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; return Ok(()); } } } return self.write_codex_live_verbatim(config); }; let auth = config .get("auth") .ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?; let config_str = config.get("config").and_then(|v| v.as_str()); let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( provider.meta.as_ref().and_then(|m| m.api_format.as_deref()), ); crate::codex_config::write_codex_provider_live_with_catalog( config, provider.category.as_deref(), auth, config_str, profile, ) .map_err(|e| format!("写入 Codex 配置失败: {e}")) } fn codex_auth_has_proxy_placeholder(auth: &Value) -> bool { auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) } fn write_codex_takeover_live_for_provider( &self, config: &Value, provider: Option<&Provider>, ) -> Result<(), String> { if crate::settings::preserve_codex_official_auth_on_switch() { if let Some(auth) = config .get("auth") .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth)) { let config_str = config.get("config").and_then(|v| v.as_str()).unwrap_or(""); let profile = crate::codex_config::CodexCatalogToolProfile::from_api_format( provider.and_then(|p| p.meta.as_ref()?.api_format.as_deref()), ); let prepared_config = crate::codex_config::prepare_codex_live_config_text_with_optional_catalog( config, config_str, profile, ) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; let live_config = crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; crate::codex_config::write_codex_live_config_atomic(Some(&live_config)) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; return Ok(()); } } self.write_codex_live_for_provider(config, provider) } fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> { use crate::codex_config::{get_codex_auth_path, get_codex_config_path}; let auth = config.get("auth"); let config_str = config.get("config").and_then(|v| v.as_str()); // Decide the config.toml text ONCE, before splitting on auth. A stored // Codex backup comes in two shapes needing opposite handling: // - snapshot backup (`read_codex_live_settings`): no inline `modelCatalog`; // the config text already carries the live `model_catalog_json` pointer // → keep raw, or projection would strip it. // - provider-rebuilt backup (`update_live_backup_from_provider`): inline // `modelCatalog` (DB SSOT) with a pointer-less config text → project, // or the mapping is lost on restore. // The projection decision is orthogonal to auth: a provider-rebuilt backup // can pair an inline `modelCatalog` with empty/absent `auth.json` (the key // living in the config's `experimental_bearer_token`). Computing it up here // keeps every config-writing branch — write-auth, delete-auth, no-auth — // consistent instead of letting the empty-auth path skip projection. // Verbatim restore has no Provider in hand (we only have the stored // backup config), so the catalog tool profile can't be recovered here. // Default to ProxyChat: a restored native-direct backup keeps its inline // modelCatalog but would not get apply_patch re-stripped until the next // provider switch rewrites it via write_live_snapshot. Acceptable known // limitation (restore-of-deleted-provider-backup only). let prepared_cfg = config_str .map(|cfg| { crate::codex_config::prepare_codex_live_config_text_with_optional_catalog( config, cfg, crate::codex_config::CodexCatalogToolProfile::ProxyChat, ) }) .transpose() .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; match (auth, prepared_cfg.as_deref()) { (Some(auth), Some(cfg)) => { let auth_path = get_codex_auth_path(); if auth.as_object().is_some_and(|obj| obj.is_empty()) { let _ = crate::config::delete_file(&auth_path); let config_path = get_codex_config_path(); crate::config::write_text_file(&config_path, cfg) .map_err(|e| format!("写入 Codex config 失败: {e}"))?; } else { crate::codex_config::write_codex_live_atomic(auth, Some(cfg)) .map_err(|e| format!("写入 Codex 配置失败: {e}"))?; } } (Some(auth), None) => { let auth_path = get_codex_auth_path(); write_json_file(&auth_path, auth) .map_err(|e| format!("写入 Codex auth 失败: {e}"))?; } (None, Some(cfg)) => { let config_path = get_codex_config_path(); crate::config::write_text_file(&config_path, cfg) .map_err(|e| format!("写入 Codex config 失败: {e}"))?; } (None, None) => {} } Ok(()) } fn read_gemini_live(&self) -> Result { use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env}; let env_path = get_gemini_env_path(); if !env_path.exists() { return Err("Gemini .env 文件不存在".to_string()); } let env_map = read_gemini_env().map_err(|e| format!("读取 Gemini env 失败: {e}"))?; Ok(env_to_json(&env_map)) } fn write_gemini_live(&self, config: &Value) -> Result<(), String> { use crate::gemini_config::{json_to_env, write_gemini_env_atomic}; let env_map = json_to_env(config).map_err(|e| format!("转换 Gemini 配置失败: {e}"))?; write_gemini_env_atomic(&env_map).map_err(|e| format!("写入 Gemini env 失败: {e}"))?; Ok(()) } // ==================== 原有方法 ==================== /// 获取服务器状态 pub async fn get_status(&self) -> Result { if let Some(server) = self.server.read().await.as_ref() { Ok(server.get_status().await) } else { // 服务器未运行时返回默认状态 Ok(ProxyStatus { running: false, ..Default::default() }) } } /// 获取代理配置 pub async fn get_config(&self) -> Result { self.db .get_proxy_config() .await .map_err(|e| format!("获取代理配置失败: {e}")) } /// 更新代理配置 pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> { // 记录旧配置用于判定是否需要重启 let previous = self .db .get_proxy_config() .await .map_err(|e| format!("获取代理配置失败: {e}"))?; // 保存到数据库(保持 live_takeover_active 状态不变) let mut new_config = config.clone(); new_config.live_takeover_active = previous.live_takeover_active; self.db .update_proxy_config(new_config.clone()) .await .map_err(|e| format!("保存代理配置失败: {e}"))?; // 检查服务器当前状态 let mut server_guard = self.server.write().await; if server_guard.is_none() { return Ok(()); } // 判断是否需要重启(地址或端口变更) let require_restart = new_config.listen_address != previous.listen_address || new_config.listen_port != previous.listen_port; if require_restart { if let Some(server) = server_guard.take() { server .stop() .await .map_err(|e| format!("重启前停止代理服务器失败: {e}"))?; } let app_handle = self.app_handle.read().await.clone(); let new_server = ProxyServer::new(new_config.clone(), self.db.clone(), app_handle); let info = new_server .start() .await .map_err(|e| format!("重启代理服务器失败: {e}"))?; if let Err(e) = self .persist_ephemeral_listen_port_if_needed(&new_config, info.port) .await { let _ = new_server.stop().await; return Err(e); } *server_guard = Some(new_server); log::info!("代理配置已更新,服务器已自动重启应用最新配置"); // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口) drop(server_guard); if let Ok(takeover) = self.get_takeover_status().await { let mut updated_any = false; if takeover.claude { self.takeover_live_config_best_effort(&AppType::Claude) .await?; updated_any = true; } if takeover.codex { self.takeover_live_config_best_effort(&AppType::Codex) .await?; updated_any = true; } if takeover.gemini { self.takeover_live_config_best_effort(&AppType::Gemini) .await?; updated_any = true; } if updated_any { log::info!("已同步更新 Live 配置中的代理地址"); } } return Ok(()); } else if let Some(server) = server_guard.as_ref() { server.apply_runtime_config(&new_config).await; log::info!("代理配置已实时应用,无需重启代理服务器"); } Ok(()) } /// 检查服务器是否正在运行 pub async fn is_running(&self) -> bool { self.server.read().await.is_some() } /// 热更新熔断器配置 /// /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例 pub async fn update_circuit_breaker_configs( &self, config: crate::proxy::CircuitBreakerConfig, ) -> Result<(), String> { if let Some(server) = self.server.read().await.as_ref() { server.update_circuit_breaker_configs(config).await; log::info!("已热更新运行中的熔断器配置"); } else { log::debug!("代理服务器未运行,熔断器配置将在下次启动时生效"); } Ok(()) } /// 热更新指定应用的熔断器配置 pub async fn update_circuit_breaker_config_for_app( &self, app_type: &str, config: crate::proxy::CircuitBreakerConfig, ) -> Result<(), String> { if let Some(server) = self.server.read().await.as_ref() { server .update_circuit_breaker_config_for_app(app_type, config) .await; log::info!("已热更新 {app_type} 运行中的熔断器配置"); } else { log::debug!("{app_type} 熔断器配置将在下次代理启动时生效"); } Ok(()) } /// 重置指定 Provider 的熔断器 /// /// 如果代理服务器正在运行,立即重置内存中的熔断器状态 pub async fn reset_provider_circuit_breaker( &self, provider_id: &str, app_type: &str, ) -> Result<(), String> { if let Some(server) = self.server.read().await.as_ref() { server .reset_provider_circuit_breaker(provider_id, app_type) .await; log::info!("已重置 Provider {provider_id} (app: {app_type}) 的熔断器"); } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::provider::ProviderMeta; use serial_test::serial; use std::env; use tempfile::TempDir; struct TempHome { #[allow(dead_code)] dir: TempDir, original_home: Option, original_userprofile: Option, original_test_home: Option, } impl TempHome { fn new() -> Self { let dir = TempDir::new().expect("failed to create temp home"); let original_home = env::var("HOME").ok(); let original_userprofile = env::var("USERPROFILE").ok(); let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok(); env::set_var("HOME", dir.path()); env::set_var("USERPROFILE", dir.path()); env::set_var("CC_SWITCH_TEST_HOME", dir.path()); Self { dir, original_home, original_userprofile, original_test_home, } } } impl Drop for TempHome { fn drop(&mut self) { match &self.original_home { Some(value) => env::set_var("HOME", value), None => env::remove_var("HOME"), } match &self.original_userprofile { Some(value) => env::set_var("USERPROFILE", value), None => env::remove_var("USERPROFILE"), } match &self.original_test_home { Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value), None => env::remove_var("CC_SWITCH_TEST_HOME"), } } } fn assert_env_str(env: &Map, key: &str, expected: Option<&str>) { assert_eq!(env.get(key).and_then(|value| value.as_str()), expected); } async fn use_ephemeral_proxy_port(db: &Arc) { let mut proxy_config = db.get_proxy_config().await.expect("get test proxy config"); proxy_config.listen_port = 0; db.update_proxy_config(proxy_config) .await .expect("set test proxy config to an ephemeral port"); } async fn running_codex_base_url(service: &ProxyService) -> String { let status = service.get_status().await.expect("get proxy status"); format!("http://127.0.0.1:{}/v1", status.port) } fn seed_codex_model_template() { let codex_dir = crate::codex_config::get_codex_config_dir(); std::fs::create_dir_all(&codex_dir).expect("create codex dir"); std::fs::write( codex_dir.join("models_cache.json"), serde_json::to_string(&serde_json::json!({ "models": [{ "slug": "gpt-5.5", "display_name": "GPT-5.5", "model_messages": { "instructions_template": "t" }, "additional_speed_tiers": [], "context_window": 128000 }] })) .expect("serialize models_cache"), ) .expect("write models_cache.json"); } #[test] fn managed_account_claude_takeover_uses_api_key_placeholder() { let mut provider = Provider::with_id( "copilot".to_string(), "GitHub Copilot".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com", "ANTHROPIC_MODEL": "claude-haiku-4.5" } }), None, ); provider.meta = Some(ProviderMeta { provider_type: Some("github_copilot".to_string()), ..Default::default() }); let mut live_config = provider.settings_config.clone(); ProxyService::apply_claude_takeover_fields_for_provider( &mut live_config, "http://127.0.0.1:15721", &provider, ); let env = live_config .get("env") .and_then(|value| value.as_object()) .expect("env should exist"); assert_eq!( env.get("ANTHROPIC_API_KEY") .and_then(|value| value.as_str()), Some(PROXY_TOKEN_PLACEHOLDER) ); assert!( env.get("ANTHROPIC_AUTH_TOKEN").is_none(), "managed OAuth providers should avoid Claude Auth Token login semantics" ); } #[test] fn managed_account_claude_takeover_sources_copilot_models_from_provider() { let mut provider = Provider::with_id( "copilot".to_string(), "GitHub Copilot".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com", "ANTHROPIC_MODEL": "claude-sonnet-4.6", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4.5", "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4.6", "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-sonnet-4.6", "CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4.6[1M]" } }), None, ); provider.meta = Some(ProviderMeta { provider_type: Some("github_copilot".to_string()), ..Default::default() }); let mut live_config = json!({ "env": { "ANTHROPIC_BASE_URL": "https://stale.example.com", "ANTHROPIC_API_KEY": "stale-key", "ANTHROPIC_MODEL": "stale-model", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "stale-haiku", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "Stale Haiku", "ANTHROPIC_DEFAULT_SONNET_MODEL": "stale-sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet", "ANTHROPIC_DEFAULT_OPUS_MODEL": "stale-opus", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus", "CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent" } }); ProxyService::apply_claude_takeover_fields_for_provider( &mut live_config, "http://127.0.0.1:15721", &provider, ); let env = live_config .get("env") .and_then(|value| value.as_object()) .expect("env should exist"); assert_env_str(env, "ANTHROPIC_MODEL", None); assert_env_str( env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", Some("claude-haiku-4-5"), ); assert_env_str( env, "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", Some("claude-haiku-4.5"), ); assert_env_str( env, "ANTHROPIC_DEFAULT_SONNET_MODEL", Some("claude-sonnet-4-6"), ); assert_env_str( env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", Some("claude-sonnet-4.6"), ); assert_env_str(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", Some("claude-opus-4-8")); assert_env_str( env, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", Some("claude-sonnet-4.6"), ); assert_env_str( env, "CLAUDE_CODE_SUBAGENT_MODEL", Some("claude-sonnet-4.6[1M]"), ); assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None); } #[test] fn managed_account_claude_takeover_removes_stale_subagent_model_when_provider_omits_it() { let mut provider = Provider::with_id( "codex".to_string(), "Codex".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex", "ANTHROPIC_DEFAULT_SONNET_MODEL": "provider-sonnet" } }), None, ); provider.meta = Some(ProviderMeta { provider_type: Some("codex_oauth".to_string()), ..Default::default() }); let mut live_config = json!({ "env": { "ANTHROPIC_BASE_URL": "https://stale.example.com", "ANTHROPIC_API_KEY": "stale-key", "CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent" } }); ProxyService::apply_claude_takeover_fields_for_provider( &mut live_config, "http://127.0.0.1:15721", &provider, ); let env = live_config .get("env") .and_then(|value| value.as_object()) .expect("env should exist"); assert_env_str(env, "CLAUDE_CODE_SUBAGENT_MODEL", None); } #[test] fn managed_account_claude_takeover_sources_codex_models_from_provider() { let mut provider = Provider::with_id( "codex".to_string(), "Codex".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex", "ANTHROPIC_MODEL": "gpt-5.4", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4-mini", "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4", "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.4" } }), None, ); provider.meta = Some(ProviderMeta { provider_type: Some("codex_oauth".to_string()), ..Default::default() }); let mut live_config = json!({ "env": { "ANTHROPIC_BASE_URL": "https://stale.example.com", "ANTHROPIC_AUTH_TOKEN": "stale-token", "ANTHROPIC_MODEL": "stale-model", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "stale-haiku", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "Stale Haiku", "ANTHROPIC_DEFAULT_SONNET_MODEL": "stale-sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet", "ANTHROPIC_DEFAULT_OPUS_MODEL": "stale-opus", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Stale Opus" } }); ProxyService::apply_claude_takeover_fields_for_provider( &mut live_config, "http://127.0.0.1:15721", &provider, ); let env = live_config .get("env") .and_then(|value| value.as_object()) .expect("env should exist"); assert_env_str(env, "ANTHROPIC_MODEL", None); assert_env_str( env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", Some("claude-haiku-4-5"), ); assert_env_str( env, "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", Some("gpt-5.4-mini"), ); assert_env_str( env, "ANTHROPIC_DEFAULT_SONNET_MODEL", Some("claude-sonnet-4-6"), ); assert_env_str(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", Some("gpt-5.4")); assert_env_str(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", Some("claude-opus-4-8")); assert_env_str(env, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", Some("gpt-5.4")); assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); } #[test] fn managed_account_claude_takeover_codex_injects_auth_token_without_preexisting_key() { let mut provider = Provider::with_id( "codex".to_string(), "Codex".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex" } }), None, ); provider.meta = Some(ProviderMeta { provider_type: Some("codex_oauth".to_string()), ..Default::default() }); // 全新安装/热切换形态:传入的 env 没有任何 token 键。 let mut live_config = provider.settings_config.clone(); ProxyService::apply_claude_takeover_fields_for_provider( &mut live_config, "http://127.0.0.1:15721", &provider, ); let env = live_config .get("env") .and_then(|value| value.as_object()) .expect("env should exist"); assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); } #[test] fn managed_account_claude_takeover_codex_by_base_url_keeps_auth_token() { // 无 provider_type meta、仅凭 base_url 识别为受管 codex 的供应商, // 也必须保留 AUTH_TOKEN 占位符(与策略选择共用同一判定族)。 let provider = Provider::with_id( "codex-url-only".to_string(), "Codex (URL only)".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex" } }), None, ); assert!(provider.uses_managed_account_auth()); assert!(!provider.is_codex_oauth()); let mut live_config = provider.settings_config.clone(); ProxyService::apply_claude_takeover_fields_for_provider( &mut live_config, "http://127.0.0.1:15721", &provider, ); let env = live_config .get("env") .and_then(|value| value.as_object()) .expect("env should exist"); assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER)); } #[test] fn managed_account_claude_takeover_copilot_removes_stale_auth_token() { let mut provider = Provider::with_id( "copilot".to_string(), "GitHub Copilot".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com" } }), None, ); provider.meta = Some(ProviderMeta { provider_type: Some("github_copilot".to_string()), ..Default::default() }); let mut live_config = json!({ "env": { "ANTHROPIC_BASE_URL": "https://stale.example.com", "ANTHROPIC_AUTH_TOKEN": "stale-token" } }); ProxyService::apply_claude_takeover_fields_for_provider( &mut live_config, "http://127.0.0.1:15721", &provider, ); let env = live_config .get("env") .and_then(|value| value.as_object()) .expect("env should exist"); assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER)); assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None); } #[test] fn normal_claude_takeover_without_token_keeps_auth_token_fallback() { let mut live_config = json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.example.com", "ANTHROPIC_MODEL": "claude-haiku-4.5" } }); ProxyService::apply_claude_takeover_fields(&mut live_config, "http://127.0.0.1:15721"); assert_eq!( live_config .get("env") .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) .and_then(|value| value.as_str()), Some(PROXY_TOKEN_PLACEHOLDER) ); assert!( live_config .get("env") .and_then(|env| env.get("ANTHROPIC_API_KEY")) .is_none(), "non-managed providers should retain the legacy fallback behavior" ); } #[tokio::test] #[serial] async fn start_with_takeover_ephemeral_port_writes_actual_live_url() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); let db = Arc::new(Database::memory().expect("init db")); use_ephemeral_proxy_port(&db).await; let service = ProxyService::new(db.clone()); let provider = Provider::with_id( "p1".to_string(), "P1".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "provider-key", "ANTHROPIC_BASE_URL": "https://api.anthropic.com" } }), None, ); db.save_provider("claude", &provider) .expect("save provider"); db.set_current_provider("claude", "p1") .expect("set db current provider"); crate::settings::set_current_provider(&AppType::Claude, Some("p1")) .expect("set local current provider"); service .write_claude_live(&json!({ "env": { "ANTHROPIC_API_KEY": "live-key", "ANTHROPIC_BASE_URL": "https://api.anthropic.com" } })) .expect("seed claude live config"); let info = service .start_with_takeover() .await .expect("start proxy with takeover"); assert_ne!(info.port, 0, "OS should assign a concrete port"); let stored_config = db.get_proxy_config().await.expect("read proxy config"); assert_eq!( stored_config.listen_port, info.port, "resolved dynamic port should be persisted for DB-only proxy URL paths" ); let live = service.read_claude_live().expect("read taken-over live"); let base_url = live .get("env") .and_then(|env| env.get("ANTHROPIC_BASE_URL")) .and_then(|value| value.as_str()) .expect("taken-over base url"); assert_eq!(base_url, format!("http://127.0.0.1:{}", info.port)); assert!( !base_url.contains(":0"), "takeover must never write an unresolved :0 port" ); service .stop_with_restore() .await .expect("stop proxy and restore live config"); } #[test] #[serial] fn codex_custom_provider_live_write_preserves_oauth_auth_json() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { preserve_codex_official_auth_on_switch: true, ..Default::default() }) .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); let service = ProxyService::new(db); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); crate::codex_config::write_codex_live_atomic( &oauth_auth, Some( r#"model_provider = "openai" model = "gpt-5-codex" "#, ), ) .expect("seed live OAuth auth"); let mut provider = Provider::with_id( "rightcode".to_string(), "RightCode".to_string(), json!({ "auth": { "OPENAI_API_KEY": "rightcode-key" }, "config": r#"model_provider = "rightcode" model = "gpt-5-codex" [model_providers.rightcode] name = "RightCode" base_url = "https://rightcode.example/v1" wire_api = "responses" "# }), None, ); provider.category = Some("custom".to_string()); let takeover_settings = json!({ "auth": { "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }, "config": r#"model_provider = "rightcode" model = "gpt-5-codex" [model_providers.rightcode] name = "RightCode" base_url = "http://127.0.0.1:15721/v1" wire_api = "responses" "# }); service .write_codex_live_for_provider(&takeover_settings, Some(&provider)) .expect("write provider-driven Codex live config"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth, oauth_auth, "third-party Codex proxy writes must not overwrite ChatGPT OAuth login state" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); assert!( live_config.contains("experimental_bearer_token"), "proxy placeholder should move into config.toml instead of auth.json" ); assert!( live_config.contains(PROXY_TOKEN_PLACEHOLDER), "live config should carry the proxy placeholder token" ); crate::settings::update_settings(crate::settings::AppSettings::default()) .expect("reset settings"); } #[tokio::test] #[serial] async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { preserve_codex_official_auth_on_switch: true, ..Default::default() }) .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); let service = ProxyService::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); let deepseek_live_config = r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" experimental_bearer_token = "deepseek-key" "#; crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config)) .expect("seed live OAuth auth with DeepSeek config"); let mut provider = Provider::with_id( "deepseek".to_string(), "DeepSeek".to_string(), json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "# }), None, ); provider.category = Some("cn_official".to_string()); db.save_provider("codex", &provider) .expect("save DeepSeek provider"); db.set_current_provider("codex", "deepseek") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("deepseek")) .expect("set local current provider"); service .takeover_live_config_strict(&AppType::Codex) .await .expect("take over Codex live config"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth, oauth_auth, "Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); assert!( live_config.contains(PROXY_TOKEN_PLACEHOLDER), "takeover placeholder should move into config.toml" ); assert!( service.detect_takeover_in_live_config_for_app(&AppType::Codex), "Codex takeover detection should recognize config.toml placeholders" ); crate::settings::update_settings(crate::settings::AppSettings::default()) .expect("reset settings"); } #[tokio::test] #[serial] async fn codex_takeover_preserves_oauth_auth_json_even_when_provider_category_is_official() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { preserve_codex_official_auth_on_switch: true, ..Default::default() }) .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); let service = ProxyService::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); let deepseek_live_config = r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" experimental_bearer_token = "deepseek-key" "#; crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config)) .expect("seed live OAuth auth with DeepSeek config"); let mut provider = Provider::with_id( "deepseek".to_string(), "DeepSeek".to_string(), json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "# }), None, ); provider.category = Some("official".to_string()); db.save_provider("codex", &provider) .expect("save misclassified DeepSeek provider"); db.set_current_provider("codex", "deepseek") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("deepseek")) .expect("set local current provider"); service .takeover_live_config_strict(&AppType::Codex) .await .expect("take over Codex live config"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth, oauth_auth, "Codex takeover must not rewrite auth.json when preservation is enabled, even if provider category is stale or misclassified" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); assert!( live_config.contains(PROXY_TOKEN_PLACEHOLDER), "takeover placeholder should move into config.toml" ); crate::settings::update_settings(crate::settings::AppSettings::default()) .expect("reset settings"); } #[tokio::test] #[serial] async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { preserve_codex_official_auth_on_switch: true, ..Default::default() }) .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); use_ephemeral_proxy_port(&db).await; let service = ProxyService::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); let deepseek_live_config = r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" experimental_bearer_token = "deepseek-key" "#; crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config)) .expect("seed live OAuth auth with DeepSeek config"); let mut provider = Provider::with_id( "deepseek".to_string(), "DeepSeek".to_string(), json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "# }), None, ); provider.category = Some("official".to_string()); db.save_provider("codex", &provider) .expect("save misclassified DeepSeek provider"); db.set_current_provider("codex", "deepseek") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("deepseek")) .expect("set local current provider"); service .set_takeover_for_app("codex", true) .await .expect("enable Codex takeover"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth, oauth_auth, "the public takeover command path must not rewrite auth.json when preservation is enabled" ); service .set_takeover_for_app("codex", false) .await .expect("disable Codex takeover"); crate::settings::update_settings(crate::settings::AppSettings::default()) .expect("reset settings"); } #[tokio::test] #[serial] async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { preserve_codex_official_auth_on_switch: true, ..Default::default() }) .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); use_ephemeral_proxy_port(&db).await; let state = crate::store::AppState::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); let deepseek_live_config = r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" experimental_bearer_token = "deepseek-key" "#; crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config)) .expect("seed live OAuth auth with DeepSeek config"); let mut provider = Provider::with_id( "deepseek".to_string(), "DeepSeek".to_string(), json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "# }), None, ); provider.category = Some("official".to_string()); db.save_provider("codex", &provider) .expect("save misclassified DeepSeek provider"); db.set_current_provider("codex", "deepseek") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("deepseek")) .expect("set local current provider"); state .proxy_service .set_takeover_for_app("codex", true) .await .expect("enable Codex takeover"); crate::services::provider::ProviderService::sync_current_to_live(&state) .expect("sync current providers while Codex is taken over"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth, oauth_auth, "post-change provider sync must not rewrite Codex auth.json during takeover" ); let backup = db .get_live_backup("codex") .await .expect("get live backup") .expect("backup exists"); let backup_value: Value = serde_json::from_str(&backup.original_config).expect("parse backup"); assert_eq!( backup_value.get("auth"), Some(&oauth_auth), "provider-derived takeover backup should preserve official OAuth auth" ); assert!( backup_value .get("config") .and_then(|value| value.as_str()) .is_some_and(|config| config.contains("deepseek-key")), "provider token should be carried by config.toml in the restore backup" ); state .proxy_service .set_takeover_for_app("codex", false) .await .expect("disable Codex takeover"); let restored_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read restored auth"); assert_eq!( restored_auth, oauth_auth, "turning takeover off should restore the preserved official OAuth auth" ); crate::settings::update_settings(crate::settings::AppSettings::default()) .expect("reset settings"); } #[tokio::test] #[serial] async fn codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { preserve_codex_official_auth_on_switch: true, ..Default::default() }) .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); let state = crate::store::AppState::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); let deepseek_live_config = r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" experimental_bearer_token = "deepseek-key" "#; crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config)) .expect("seed live OAuth auth with DeepSeek config"); let mut provider = Provider::with_id( "deepseek".to_string(), "DeepSeek".to_string(), json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "# }), None, ); provider.category = Some("official".to_string()); db.save_provider("codex", &provider) .expect("save misclassified DeepSeek provider"); db.set_current_provider("codex", "deepseek") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("deepseek")) .expect("set local current provider"); state .proxy_service .backup_live_config_strict(&AppType::Codex) .await .expect("backup Codex live config"); state .proxy_service .takeover_live_config_strict(&AppType::Codex) .await .expect("take over Codex live config"); assert!( !db.get_proxy_config_for_app("codex") .await .expect("get Codex proxy config") .enabled, "this reproduces the activation window before set_takeover_for_app marks enabled=true" ); crate::services::provider::ProviderService::sync_current_to_live(&state) .expect("sync current providers during takeover activation"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth, oauth_auth, "activation-time provider sync must not rewrite Codex OAuth auth.json" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); assert!( live_config.contains(PROXY_TOKEN_PLACEHOLDER), "activation-time provider sync must keep the proxy bearer placeholder" ); assert!( live_config.contains("http://127.0.0.1"), "activation-time provider sync must keep the local proxy base_url" ); assert!( state .proxy_service .detect_takeover_in_live_config_for_app(&AppType::Codex), "Codex live config should still be detected as taken over" ); crate::settings::update_settings(crate::settings::AppSettings::default()) .expect("reset settings"); } #[tokio::test] #[serial] async fn codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_backup() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { preserve_codex_official_auth_on_switch: true, ..Default::default() }) .expect("enable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); use_ephemeral_proxy_port(&db).await; let service = ProxyService::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); let original_deepseek_config = r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" experimental_bearer_token = "deepseek-key" "#; let stale_live_config = r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" experimental_bearer_token = "PROXY_MANAGED" "#; crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(stale_live_config)) .expect("seed stale Codex live config"); let mut provider = Provider::with_id( "deepseek".to_string(), "DeepSeek".to_string(), json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "# }), None, ); provider.category = Some("official".to_string()); db.save_provider("codex", &provider) .expect("save misclassified DeepSeek provider"); db.set_current_provider("codex", "deepseek") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("deepseek")) .expect("set local current provider"); db.save_live_backup( "codex", &serde_json::to_string(&json!({ "auth": oauth_auth, "config": original_deepseek_config })) .expect("serialize original backup"), ) .await .expect("seed original live backup"); let mut proxy_config = db .get_proxy_config_for_app("codex") .await .expect("get Codex proxy config"); proxy_config.enabled = true; db.update_proxy_config_for_app(proxy_config) .await .expect("mark Codex takeover enabled"); service .set_takeover_for_app("codex", true) .await .expect("rebuild Codex takeover"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth, oauth_auth, "repairing stale takeover must restore the preserved OAuth auth from backup" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); let expected_base_url = running_codex_base_url(&service).await; assert!( live_config.contains(&expected_base_url), "stale enabled takeover must be rebuilt to the current proxy base_url" ); assert!( live_config.contains(PROXY_TOKEN_PLACEHOLDER), "rebuilt takeover should keep the proxy bearer placeholder" ); assert!( service .live_takeover_matches_current_proxy(&AppType::Codex) .await .expect("detect rebuilt Codex takeover"), "rebuilt Codex live config should match the active proxy address" ); let backup = db .get_live_backup("codex") .await .expect("get Codex live backup") .expect("backup exists"); let backup_value: Value = serde_json::from_str(&backup.original_config).expect("parse backup"); assert_eq!( backup_value.get("auth"), Some(&oauth_auth), "rebuilding stale takeover must not overwrite the original OAuth backup" ); assert!( backup_value .get("config") .and_then(|value| value.as_str()) .is_some_and(|config| config.contains("deepseek-key") && !config.contains("http://127.0.0.1")), "backup should remain the restorable DeepSeek config, not the proxy config" ); service .set_takeover_for_app("codex", false) .await .expect("disable Codex takeover"); crate::settings::update_settings(crate::settings::AppSettings::default()) .expect("reset settings"); } #[tokio::test] #[serial] async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { preserve_codex_official_auth_on_switch: false, ..Default::default() }) .expect("disable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); let service = ProxyService::new(db.clone()); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); let deepseek_live_config = r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "#; crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config)) .expect("seed live OAuth auth with DeepSeek config"); let mut provider = Provider::with_id( "deepseek".to_string(), "DeepSeek".to_string(), json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "# }), None, ); provider.category = Some("cn_official".to_string()); db.save_provider("codex", &provider) .expect("save DeepSeek provider"); db.set_current_provider("codex", "deepseek") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("deepseek")) .expect("set local current provider"); service .takeover_live_config_strict(&AppType::Codex) .await .expect("take over Codex live config"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth .get("OPENAI_API_KEY") .and_then(|value| value.as_str()), Some(PROXY_TOKEN_PLACEHOLDER), "disabled preservation should keep the legacy auth.json takeover placeholder" ); assert_eq!( live_auth .get("tokens") .and_then(|tokens| tokens.get("access_token")) .and_then(|value| value.as_str()), Some("oauth-access"), "the new config-only takeover branch must not run when preservation is disabled" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); assert!( !live_config.contains(PROXY_TOKEN_PLACEHOLDER), "disabled preservation should not move the takeover placeholder into config.toml" ); crate::settings::update_settings(crate::settings::AppSettings::default()) .expect("reset settings"); } #[test] #[serial] fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() { 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); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); crate::codex_config::write_codex_live_atomic( &oauth_auth, Some( r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "http://127.0.0.1:15721/v1" wire_api = "responses" experimental_bearer_token = "PROXY_MANAGED" "#, ), ) .expect("seed taken-over Codex live config"); assert!( service.detect_takeover_in_live_config_for_app(&AppType::Codex), "config.toml placeholder should be detected before cleanup" ); service .cleanup_codex_takeover_placeholders_in_live() .expect("cleanup Codex takeover placeholders"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth, oauth_auth, "cleanup should preserve ChatGPT OAuth auth" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); assert!( !live_config.contains(PROXY_TOKEN_PLACEHOLDER), "cleanup should remove config.toml proxy bearer placeholder" ); assert!( !live_config.contains("http://127.0.0.1:15721"), "cleanup should remove local proxy base_url" ); } #[test] #[serial] fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); crate::settings::update_settings(crate::settings::AppSettings { preserve_codex_official_auth_on_switch: false, ..Default::default() }) .expect("disable Codex official auth preservation"); let db = Arc::new(Database::memory().expect("init db")); let service = ProxyService::new(db); let oauth_auth = json!({ "auth_mode": "chatgpt", "tokens": { "id_token": "oauth-id", "access_token": "oauth-access" } }); crate::codex_config::write_codex_live_atomic( &oauth_auth, Some( r#"model_provider = "openai" model = "gpt-5-codex" "#, ), ) .expect("seed live OAuth auth"); let mut provider = Provider::with_id( "rightcode".to_string(), "RightCode".to_string(), json!({ "auth": { "OPENAI_API_KEY": "rightcode-key" }, "config": r#"model_provider = "rightcode" model = "gpt-5-codex" [model_providers.rightcode] name = "RightCode" base_url = "https://rightcode.example/v1" wire_api = "responses" "# }), None, ); provider.category = Some("custom".to_string()); let takeover_auth = json!({ "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }); let takeover_settings = json!({ "auth": takeover_auth, "config": r#"model_provider = "rightcode" model = "gpt-5-codex" [model_providers.rightcode] name = "RightCode" base_url = "http://127.0.0.1:15721/v1" wire_api = "responses" "# }); service .write_codex_live_for_provider(&takeover_settings, Some(&provider)) .expect("write provider-driven Codex live config"); let live_auth: Value = crate::config::read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read live auth"); assert_eq!( live_auth, json!({ "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }), "disabled preservation should let third-party switches overwrite auth.json" ); let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read live config"); assert!( !live_config.contains("experimental_bearer_token"), "provider token should stay in auth.json when preservation is disabled" ); crate::settings::update_settings(crate::settings::AppSettings::default()) .expect("reset settings"); } #[test] fn update_toml_base_url_updates_active_model_provider_base_url() { let input = r#" model_provider = "any" model = "gpt-5.1-codex" disable_response_storage = true [model_providers.any] name = "any" base_url = "https://anyrouter.top/v1" wire_api = "responses" requires_openai_auth = true "#; let new_url = "http://127.0.0.1:5000/v1"; let output = ProxyService::update_toml_base_url(input, new_url); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); 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("model_providers.any.base_url should exist"); assert_eq!(base_url, new_url); assert!( parsed.get("base_url").is_none(), "should not write top-level base_url" ); 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()) .expect("model_providers.any.wire_api should exist"); assert_eq!(wire_api, "responses"); } #[test] fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() { let input = r#" model_provider = "chat_only" model = "gpt-5.1-codex" [model_providers.chat_only] name = "Chat Only" base_url = "https://chat-only.example/v1" wire_api = "chat" "#; let proxy_url = "http://127.0.0.1:5000/v1"; let output = ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); let provider = parsed .get("model_providers") .and_then(|v| v.get("chat_only")) .expect("model_providers.chat_only should exist"); assert_eq!( provider.get("base_url").and_then(|v| v.as_str()), Some(proxy_url) ); assert_eq!( provider.get("wire_api").and_then(|v| v.as_str()), Some("responses") ); } #[test] fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() { let input = r#" model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" "#; let mut provider = Provider::with_id( "deepseek".to_string(), "DeepSeek".to_string(), json!({ "config": input }), None, ); provider.meta = Some(ProviderMeta { api_format: Some("openai_chat".to_string()), ..Default::default() }); let proxy_url = "http://127.0.0.1:5000/v1"; let output = ProxyService::apply_codex_proxy_toml_config_for_provider( input, proxy_url, Some(&provider), ); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); assert_eq!( parsed.get("model").and_then(|v| v.as_str()), Some("deepseek-v4-flash") ); assert_eq!( parsed .get("model_providers") .and_then(|v| v.get("deepseek")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()), Some(proxy_url) ); } #[test] fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() { let input = r#" model_provider = "responses" model = "upstream-responses-model" [model_providers.responses] name = "Responses" base_url = "https://responses.example/v1" wire_api = "responses" "#; let mut provider = Provider::with_id( "responses".to_string(), "Responses".to_string(), json!({ "config": input }), None, ); provider.meta = Some(ProviderMeta { api_format: Some("openai_responses".to_string()), ..Default::default() }); let output = ProxyService::apply_codex_proxy_toml_config_for_provider( input, "http://127.0.0.1:5000/v1", Some(&provider), ); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); assert_eq!( parsed.get("model").and_then(|v| v.as_str()), Some("upstream-responses-model") ); } #[test] fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() { let input = r#" model_provider = "responses" model = "gpt-5.4" [model_providers.responses] name = "Responses" base_url = "http://127.0.0.1:5000/v1" wire_api = "responses" "#; let mut provider = Provider::with_id( "responses".to_string(), "Responses".to_string(), json!({ "config": r#"model_provider = "responses" model = "upstream-responses-model" [model_providers.responses] name = "Responses" base_url = "https://responses.example/v1" wire_api = "responses" "# }), None, ); provider.meta = Some(ProviderMeta { api_format: Some("openai_responses".to_string()), ..Default::default() }); let output = ProxyService::apply_codex_proxy_toml_config_for_provider( input, "http://127.0.0.1:5000/v1", Some(&provider), ); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); assert_eq!( parsed.get("model").and_then(|v| v.as_str()), Some("upstream-responses-model") ); } #[test] fn update_toml_base_url_falls_back_to_top_level_base_url() { let input = r#" model = "gpt-5.1-codex" "#; let new_url = "http://127.0.0.1:5000/v1"; let output = ProxyService::update_toml_base_url(input, new_url); let parsed: toml::Value = toml::from_str(&output).expect("updated config should be valid TOML"); let base_url = parsed .get("base_url") .and_then(|v| v.as_str()) .expect("base_url should exist"); assert_eq!(base_url, new_url); } #[tokio::test] #[serial] async fn sync_claude_token_does_not_add_anthropic_api_key() { 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()); let provider = Provider::with_id( "p1".to_string(), "P1".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_AUTH_TOKEN": "stale" } }), None, ); db.save_provider("claude", &provider) .expect("save provider"); db.set_current_provider("claude", "p1") .expect("set current provider"); let live_config = json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "fresh" } }); service .sync_live_config_to_provider(&AppType::Claude, &live_config) .await .expect("sync"); let updated = db .get_provider_by_id("p1", "claude") .expect("get provider") .expect("provider exists"); let env = updated .settings_config .get("env") .and_then(|v| v.as_object()) .expect("env object"); assert_eq!( env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()), Some("fresh") ); assert!( !env.contains_key("ANTHROPIC_API_KEY"), "should not add ANTHROPIC_API_KEY when absent" ); } #[tokio::test] #[serial] async fn sync_claude_token_respects_existing_api_key_field() { 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()); let provider = Provider::with_id( "p1".to_string(), "P1".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "stale" } }), None, ); db.save_provider("claude", &provider) .expect("save provider"); db.set_current_provider("claude", "p1") .expect("set current provider"); let live_config = json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "fresh" } }); service .sync_live_config_to_provider(&AppType::Claude, &live_config) .await .expect("sync"); let updated = db .get_provider_by_id("p1", "claude") .expect("get provider") .expect("provider exists"); let env = updated .settings_config .get("env") .and_then(|v| v.as_object()) .expect("env object"); assert_eq!( env.get("ANTHROPIC_API_KEY").and_then(|v| v.as_str()), Some("fresh") ); assert!( !env.contains_key("ANTHROPIC_AUTH_TOKEN"), "should not add ANTHROPIC_AUTH_TOKEN when absent" ); } #[tokio::test] #[serial] async fn switch_proxy_target_updates_live_backup_when_taken_over() { 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()); let provider_a = Provider::with_id( "a".to_string(), "A".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }), None, ); let provider_b = Provider::with_id( "b".to_string(), "B".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }), None, ); db.save_provider("claude", &provider_a) .expect("save provider a"); db.save_provider("claude", &provider_b) .expect("save provider b"); db.set_current_provider("claude", "a") .expect("set current provider"); // 模拟"已接管"状态:存在 Live 备份(内容不重要,会被热切换更新) db.save_live_backup("claude", "{\"env\":{}}") .await .expect("seed live backup"); service .switch_proxy_target("claude", "b") .await .expect("switch proxy target"); // 断言:本地 settings 的 current provider 已同步 assert_eq!( crate::settings::get_current_provider(&AppType::Claude).as_deref(), Some("b") ); // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复) let backup = db .get_live_backup("claude") .await .expect("get live backup") .expect("backup exists"); let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize"); assert_eq!(backup.original_config, expected); } #[tokio::test] #[serial] async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() { 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()); let provider_a = Provider::with_id( "a".to_string(), "A".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "a-key", "ANTHROPIC_BASE_URL": "https://api.a.example", "ANTHROPIC_MODEL": "claude-old" }, "permissions": { "allow": ["Bash"] } }), None, ); let provider_b = Provider::with_id( "b".to_string(), "B".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "b-key", "ANTHROPIC_BASE_URL": "https://api.b.example", "ANTHROPIC_MODEL": "claude-new", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "DeepSeek V4 Flash", "ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1M]", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "DeepSeek V4 Pro", "ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]", "CLAUDE_CODE_SUBAGENT_MODEL": "deepseek-v4-pro[1M]" }, "permissions": { "allow": ["Read"] } }), None, ); db.save_provider("claude", &provider_a) .expect("save provider a"); db.save_provider("claude", &provider_b) .expect("save provider b"); db.set_current_provider("claude", "a") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Claude, Some("a")) .expect("set local current provider"); db.save_live_backup( "claude", &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"), ) .await .expect("seed live backup"); service .write_claude_live(&json!({ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721", "ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER, "ANTHROPIC_MODEL": "stale-model", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet", "CLAUDE_CODE_SUBAGENT_MODEL": "stale-subagent" }, "permissions": { "allow": ["Bash"] } })) .expect("seed taken-over live file"); service .hot_switch_provider("claude", "b") .await .expect("hot switch provider"); let live = service.read_claude_live().expect("read live config"); assert_eq!( live.get("permissions"), provider_b.settings_config.get("permissions"), "provider-derived live settings should be refreshed" ); assert_eq!( live.get("env") .and_then(|env| env.get("ANTHROPIC_API_KEY")) .and_then(|v| v.as_str()), Some(PROXY_TOKEN_PLACEHOLDER), "takeover token placeholder should be preserved" ); assert_eq!( live.get("env") .and_then(|env| env.get("ANTHROPIC_BASE_URL")) .and_then(|v| v.as_str()), Some("http://127.0.0.1:15721"), "takeover proxy URL should remain active" ); assert!( live.get("env") .and_then(|env| env.get("ANTHROPIC_MODEL")) .is_none(), "fallback model override should be removed in takeover mode" ); let live_env = live .get("env") .and_then(|env| env.as_object()) .expect("live env"); assert_eq!( live_env .get("ANTHROPIC_DEFAULT_HAIKU_MODEL") .and_then(|v| v.as_str()), Some("claude-haiku-4-5"), "takeover mode should expose a stable Haiku role model" ); assert_eq!( live_env .get("ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME") .and_then(|v| v.as_str()), Some("DeepSeek V4 Flash"), "model menu should show the current provider Haiku display name" ); assert_eq!( live_env .get("ANTHROPIC_DEFAULT_SONNET_MODEL") .and_then(|v| v.as_str()), Some("claude-sonnet-4-6[1M]"), "Sonnet role should carry the local 1M declaration for Claude Code" ); assert_eq!( live_env .get("ANTHROPIC_DEFAULT_SONNET_MODEL_NAME") .and_then(|v| v.as_str()), Some("DeepSeek V4 Pro"), "stale model display names should be replaced during hot switch" ); assert_eq!( live_env .get("ANTHROPIC_DEFAULT_OPUS_MODEL") .and_then(|v| v.as_str()), Some("claude-opus-4-8[1M]"), "Opus role should preserve the current provider 1M capability marker" ); assert_eq!( live_env .get("ANTHROPIC_DEFAULT_OPUS_MODEL_NAME") .and_then(|v| v.as_str()), Some("deepseek-v4-ultra"), "implicit display names should strip the local 1M marker" ); assert_eq!( live_env .get("CLAUDE_CODE_SUBAGENT_MODEL") .and_then(|v| v.as_str()), Some("deepseek-v4-pro[1M]"), "subagent model should follow the target provider during hot switch" ); let backup = db .get_live_backup("claude") .await .expect("get live backup") .expect("backup exists"); let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize"); assert_eq!(backup.original_config, expected); } #[tokio::test] #[serial] async fn hot_switch_provider_serializes_same_app_switches() { use tokio::time::{sleep, Duration}; 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()); let provider_a = Provider::with_id( "a".to_string(), "A".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }), None, ); let provider_b = Provider::with_id( "b".to_string(), "B".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }), None, ); let provider_c = Provider::with_id( "c".to_string(), "C".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "c-key" } }), None, ); db.save_provider("claude", &provider_a) .expect("save provider a"); db.save_provider("claude", &provider_b) .expect("save provider b"); db.save_provider("claude", &provider_c) .expect("save provider c"); db.set_current_provider("claude", "a") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Claude, Some("a")) .expect("set local current provider"); db.save_live_backup("claude", "{\"env\":{}}") .await .expect("seed live backup"); let guard = service.lock_switch_for_test("claude").await; let service_for_b = service.clone(); let service_for_c = service.clone(); let switch_b = tokio::spawn(async move { service_for_b .hot_switch_provider("claude", "b") .await .expect("switch to b") }); sleep(Duration::from_millis(20)).await; let switch_c = tokio::spawn(async move { service_for_c .hot_switch_provider("claude", "c") .await .expect("switch to c") }); sleep(Duration::from_millis(20)).await; drop(guard); let outcome_b = switch_b.await.expect("join switch b"); let outcome_c = switch_c.await.expect("join switch c"); assert!(outcome_b.logical_target_changed); assert!(outcome_c.logical_target_changed); assert_eq!( crate::settings::get_effective_current_provider(&db, &AppType::Claude) .expect("effective current"), Some("c".to_string()) ); assert_eq!( crate::settings::get_current_provider(&AppType::Claude).as_deref(), Some("c") ); assert_eq!( db.get_current_provider("claude").expect("db current"), Some("c".to_string()) ); let backup = db .get_live_backup("claude") .await .expect("get live backup") .expect("backup exists"); let expected = serde_json::to_string(&provider_c.settings_config).expect("serialize"); assert_eq!(backup.original_config, expected); } #[tokio::test] #[serial] async fn restore_waits_for_hot_switch_and_restores_latest_backup() { use tokio::time::{sleep, Duration}; 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()); let provider_a = Provider::with_id( "a".to_string(), "A".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }), None, ); let provider_b = Provider::with_id( "b".to_string(), "B".to_string(), json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }), None, ); db.save_provider("claude", &provider_a) .expect("save provider a"); db.save_provider("claude", &provider_b) .expect("save provider b"); db.set_current_provider("claude", "a") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Claude, Some("a")) .expect("set local current provider"); db.save_live_backup( "claude", &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"), ) .await .expect("seed live backup"); service .write_claude_live(&json!({ "env": { "ANTHROPIC_API_KEY": "stale" } })) .expect("seed live file"); let guard = service.lock_switch_for_test("claude").await; let service_for_switch = service.clone(); let service_for_restore = service.clone(); let switch_to_b = tokio::spawn(async move { service_for_switch .hot_switch_provider("claude", "b") .await .expect("switch to b") }); sleep(Duration::from_millis(20)).await; let restore = tokio::spawn(async move { service_for_restore .restore_live_config_for_app_with_fallback(&AppType::Claude) .await .expect("restore claude live") }); sleep(Duration::from_millis(20)).await; drop(guard); let outcome = switch_to_b.await.expect("join switch"); restore.await.expect("join restore"); assert!(outcome.logical_target_changed); assert_eq!( crate::settings::get_effective_current_provider(&db, &AppType::Claude) .expect("effective current"), Some("b".to_string()) ); let backup = db .get_live_backup("claude") .await .expect("get live backup") .expect("backup exists"); let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize"); assert_eq!(backup.original_config, expected); assert_eq!( service.read_claude_live().expect("read live"), provider_b.settings_config ); } #[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" ); } #[tokio::test] #[serial] async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() { 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()); let provider_a = Provider::with_id( "a".to_string(), "RightCode".to_string(), json!({ "auth": { "OPENAI_API_KEY": "rightcode-key" }, "config": r#"model_provider = "rightcode" model = "gpt-5.4" [model_providers.rightcode] name = "RightCode" base_url = "https://rightcode.example/v1" wire_api = "responses" requires_openai_auth = true "# }), None, ); let provider_b = Provider::with_id( "b".to_string(), "AiHubMix".to_string(), json!({ "auth": { "OPENAI_API_KEY": "aihubmix-key" }, "config": r#"model_provider = "aihubmix" model = "gpt-5.4" [model_providers.aihubmix] name = "AiHubMix" base_url = "https://aihubmix.example/v1" wire_api = "responses" requires_openai_auth = true "# }), None, ); db.save_provider("codex", &provider_a) .expect("save provider a"); db.save_provider("codex", &provider_b) .expect("save provider b"); db.set_current_provider("codex", "a") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("a")) .expect("set local current provider"); db.save_live_backup( "codex", &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"), ) .await .expect("seed live backup"); service .write_codex_live(&json!({ "auth": { "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }, "config": r#"model_provider = "rightcode" model = "gpt-5.4" [model_providers.rightcode] name = "RightCode" base_url = "http://127.0.0.1:15721/v1" wire_api = "responses" requires_openai_auth = true "# })) .expect("seed taken-over Codex live config"); service .hot_switch_provider("codex", "b") .await .expect("hot switch Codex provider"); 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 backup_config = stored .get("config") .and_then(|v| v.as_str()) .expect("backup config string"); let parsed_backup: toml::Value = toml::from_str(backup_config).expect("parse backup config"); assert_eq!( parsed_backup.get("model_provider").and_then(|v| v.as_str()), Some("aihubmix"), "provider-derived restore backup should preserve the provider's model_provider" ); let backup_model_providers = parsed_backup .get("model_providers") .and_then(|v| v.as_table()) .expect("backup model_providers"); assert!(backup_model_providers.get("custom").is_none()); assert_eq!( backup_model_providers .get("aihubmix") .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()), Some("https://aihubmix.example/v1"), "provider id should point at the hot-switched provider endpoint" ); let live = service.read_codex_live().expect("read Codex live config"); let live_config = live .get("config") .and_then(|v| v.as_str()) .expect("live config string"); let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config"); assert_eq!( parsed_live.get("model_provider").and_then(|v| v.as_str()), Some("aihubmix"), "hot-switched Codex live config should expose the selected provider" ); assert_eq!( parsed_live .get("model_providers") .and_then(|v| v.get("aihubmix")) .and_then(|v| v.get("name")) .and_then(|v| v.as_str()), Some("AiHubMix"), "Codex app provider label should follow the selected provider" ); assert_eq!( parsed_live .get("model_providers") .and_then(|v| v.get("aihubmix")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()), Some("http://127.0.0.1:15721/v1"), "taken-over live config should stay pointed at the local proxy" ); service .restore_live_config_for_app_with_fallback(&AppType::Codex) .await .expect("restore Codex live config"); let live = service.read_codex_live().expect("read Codex live config"); let live_config = live .get("config") .and_then(|v| v.as_str()) .expect("live config string"); let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config"); assert_eq!( parsed_live.get("model_provider").and_then(|v| v.as_str()), Some("aihubmix"), "restored Codex live config should preserve the provider's model_provider" ); assert_eq!( live.get("auth") .and_then(|auth| auth.get("OPENAI_API_KEY")) .and_then(|v| v.as_str()), Some("aihubmix-key"), "restore should still use the hot-switched provider auth" ); } #[tokio::test] #[serial] async fn hot_switch_codex_chat_provider_updates_live_provider_display() { 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()); let provider_a = Provider::with_id( "a".to_string(), "Responses".to_string(), json!({ "auth": { "OPENAI_API_KEY": "responses-key" }, "config": r#"model_provider = "stable" model = "responses-model" [model_providers.stable] name = "Stable" base_url = "https://responses.example/v1" wire_api = "responses" requires_openai_auth = true "# }), None, ); let mut provider_b = Provider::with_id( "b".to_string(), "DeepSeek".to_string(), json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": r#"model_provider = "deepseek" model = "deepseek-v4-flash" [model_providers.deepseek] name = "DeepSeek" base_url = "https://api.deepseek.com/v1" wire_api = "responses" requires_openai_auth = true "# }), None, ); provider_b.meta = Some(ProviderMeta { api_format: Some("openai_chat".to_string()), ..Default::default() }); db.save_provider("codex", &provider_a) .expect("save provider a"); db.save_provider("codex", &provider_b) .expect("save provider b"); db.set_current_provider("codex", "a") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("a")) .expect("set local current provider"); db.save_live_backup( "codex", &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"), ) .await .expect("seed live backup"); service .write_codex_live(&json!({ "auth": { "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER }, "config": r#"model_provider = "stable" model = "responses-model" [model_providers.stable] name = "Stable" base_url = "http://127.0.0.1:15721/v1" wire_api = "responses" requires_openai_auth = true "# })) .expect("seed taken-over Codex live config"); service .hot_switch_provider("codex", "b") .await .expect("hot switch Codex provider"); let live = service.read_codex_live().expect("read Codex live config"); let live_config = live .get("config") .and_then(|v| v.as_str()) .expect("live config string"); let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config"); assert_eq!( parsed_live.get("model_provider").and_then(|v| v.as_str()), Some("deepseek") ); assert_eq!( parsed_live .get("model_providers") .and_then(|v| v.get("deepseek")) .and_then(|v| v.get("name")) .and_then(|v| v.as_str()), Some("DeepSeek") ); assert_eq!( parsed_live .get("model_providers") .and_then(|v| v.get("deepseek")) .and_then(|v| v.get("base_url")) .and_then(|v| v.as_str()), Some("http://127.0.0.1:15721/v1") ); assert_eq!( parsed_live.get("model").and_then(|v| v.as_str()), Some("deepseek-v4-flash") ); assert_eq!( live.get("auth") .and_then(|auth| auth.get("OPENAI_API_KEY")) .and_then(|v| v.as_str()), Some(PROXY_TOKEN_PLACEHOLDER) ); } #[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" ); } #[tokio::test] #[serial] async fn provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); seed_codex_model_template(); let db = Arc::new(Database::memory().expect("init db")); let state = crate::store::AppState::new(db.clone()); db.set_config_snippet( "codex", Some( r#"[mcp_servers.shared] command = "shared-command" "# .to_string(), ), ) .expect("set common config snippet"); let proxy_config = ProxyConfig { listen_port: 0, ..Default::default() }; db.update_proxy_config(proxy_config) .await .expect("set test proxy config"); state .proxy_service .start() .await .expect("start proxy server"); let config_a = r#"model_provider = "provider-a" model = "model-a" [model_providers.provider-a] name = "ProviderA" base_url = "https://provider-a.example/v1" wire_api = "responses" requires_openai_auth = true "#; let config_b = r#"model_provider = "provider-b" model = "model-b" [model_providers.provider-b] name = "ProviderB" base_url = "https://provider-b.example/v1" wire_api = "responses" requires_openai_auth = true "#; let provider_a = Provider::with_id( "a".to_string(), "ProviderA".to_string(), serde_json::json!({ "auth": { "OPENAI_API_KEY": "key-a" }, "config": config_a, "modelCatalog": { "models": [{ "model": "model-a" }] } }), None, ); let mut provider_b = Provider::with_id( "b".to_string(), "ProviderB".to_string(), serde_json::json!({ "auth": { "OPENAI_API_KEY": "key-b" }, "config": config_b, "modelCatalog": { "models": [{ "model": "model-b" }] } }), None, ); provider_b.meta = Some(ProviderMeta { common_config_enabled: Some(true), ..Default::default() }); db.save_provider("codex", &provider_a) .expect("save provider a"); db.save_provider("codex", &provider_b) .expect("save provider b"); db.set_current_provider("codex", "a") .expect("set current provider a"); crate::settings::set_current_provider(&AppType::Codex, Some("a")) .expect("set local current provider a"); state .proxy_service .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a)) .expect("seed live codex config"); assert!( !state .proxy_service .detect_takeover_in_live_config_for_app(&AppType::Codex), "seeded live config should not be proxy-taken-over" ); db.save_live_backup( "codex", &serde_json::to_string(&provider_a.settings_config).expect("serialize backup"), ) .await .expect("seed restored backup"); crate::services::provider::ProviderService::switch(&state, AppType::Codex, "b") .expect("provider switch to provider b"); state.proxy_service.stop().await.expect("stop proxy server"); let catalog_path = crate::codex_config::get_codex_model_catalog_path(); assert!( catalog_path.exists(), "cc-switch-model-catalog.json must be created on provider switch" ); let catalog_text = std::fs::read_to_string(&catalog_path).expect("read catalog json"); let catalog: serde_json::Value = serde_json::from_str(&catalog_text).expect("parse catalog json"); let slugs: Vec<&str> = catalog .get("models") .and_then(|m| m.as_array()) .map(|arr| { arr.iter() .filter_map(|e| e.get("slug").and_then(|s| s.as_str())) .collect() }) .unwrap_or_default(); assert!( slugs.contains(&"model-b"), "catalog must contain provider B's model after switch; got: {slugs:?}" ); assert!( !slugs.contains(&"model-a"), "catalog must not contain stale provider A model after switch; got: {slugs:?}" ); let config_path = crate::codex_config::get_codex_config_path(); let config_text = std::fs::read_to_string(&config_path).expect("read config.toml"); assert!( config_text.contains("model_catalog_json"), "config.toml must reference model_catalog_json after switch" ); assert!( config_text.contains("[mcp_servers.shared]"), "config.toml must keep common config after switch" ); assert!( config_text.contains(r#"command = "shared-command""#), "config.toml must include common config content after switch" ); } #[tokio::test] #[serial] async fn provider_switch_with_restored_codex_backup_propagates_catalog_write_errors() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); seed_codex_model_template(); let db = Arc::new(Database::memory().expect("init db")); let state = crate::store::AppState::new(db.clone()); let proxy_config = ProxyConfig { listen_port: 0, ..Default::default() }; db.update_proxy_config(proxy_config) .await .expect("set test proxy config"); state .proxy_service .start() .await .expect("start proxy server"); let config_a = r#"model_provider = "provider-a" model = "model-a" [model_providers.provider-a] name = "ProviderA" base_url = "https://provider-a.example/v1" wire_api = "responses" requires_openai_auth = true "#; let config_b = r#"model_provider = "provider-b" model = "model-b" [model_providers.provider-b] name = "ProviderB" base_url = "https://provider-b.example/v1" wire_api = "responses" requires_openai_auth = true "#; let provider_a = Provider::with_id( "a".to_string(), "ProviderA".to_string(), serde_json::json!({ "auth": { "OPENAI_API_KEY": "key-a" }, "config": config_a, "modelCatalog": { "models": [{ "model": "model-a" }] } }), None, ); let provider_b = Provider::with_id( "b".to_string(), "ProviderB".to_string(), serde_json::json!({ "auth": { "OPENAI_API_KEY": "key-b" }, "config": config_b, "modelCatalog": { "models": [{ "model": "model-b" }] } }), None, ); db.save_provider("codex", &provider_a) .expect("save provider a"); db.save_provider("codex", &provider_b) .expect("save provider b"); db.set_current_provider("codex", "a") .expect("set current provider a"); crate::settings::set_current_provider(&AppType::Codex, Some("a")) .expect("set local current provider a"); state .proxy_service .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a)) .expect("seed live codex config"); assert!( !state .proxy_service .detect_takeover_in_live_config_for_app(&AppType::Codex), "seeded live config should not be proxy-taken-over" ); db.save_live_backup( "codex", &serde_json::to_string(&provider_a.settings_config).expect("serialize backup"), ) .await .expect("seed restored backup"); let catalog_path = crate::codex_config::get_codex_model_catalog_path(); if catalog_path.exists() { std::fs::remove_file(&catalog_path).expect("remove catalog file"); } std::fs::create_dir_all(&catalog_path).expect("turn catalog path into directory"); let err = crate::services::provider::ProviderService::switch(&state, AppType::Codex, "b") .expect_err("provider switch should fail when catalog cannot be written"); state.proxy_service.stop().await.expect("stop proxy server"); let message = err.to_string(); assert!( message.contains("写入 Codex 配置失败") || message.contains("原子替换失败"), "switch should surface catalog write failure, got: {message}" ); } /// Regression: turning proxy takeover off restores Live from the backup. The /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`, /// never an inline `modelCatalog`). The restore must NOT route the config /// through catalog projection, which would see no specs and strip the /// `model_catalog_json` pointer — silently dropping the user's Codex model /// mapping from Live even though the DB SSOT still holds it. #[tokio::test] #[serial] async fn codex_restore_from_backup_preserves_model_catalog_pointer() { 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()); // Pre-takeover Live state: config.toml points at the cc-switch generated // catalog file, and that file exists on disk (takeover never touches it). let catalog_path = crate::codex_config::get_codex_model_catalog_path(); if let Some(parent) = catalog_path.parent() { std::fs::create_dir_all(parent).expect("create codex dir"); } std::fs::write( &catalog_path, r#"{"models":[{"slug":"deepseek-v4-flash"}]}"#, ) .expect("seed generated catalog file"); let pointer = catalog_path.to_string_lossy().replace('\\', "/"); let backup_config = format!( "model_provider = \"custom\"\n\ model = \"deepseek-v4-flash\"\n\ model_catalog_json = \"{pointer}\"\n\n\ [model_providers.custom]\n\ name = \"DeepSeek\"\n\ base_url = \"https://api.deepseek.example/v1\"\n\ wire_api = \"responses\"\n" ); let backup_json = serde_json::to_string(&json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": backup_config, })) .expect("serialize backup"); db.save_live_backup("codex", &backup_json) .await .expect("seed live backup"); // Turning takeover off restores Live from this backup. service .restore_live_config_for_app_with_fallback(&AppType::Codex) .await .expect("restore codex live from backup"); let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read restored config.toml"); assert!( restored.contains("model_catalog_json"), "restore must preserve the model_catalog_json pointer, got:\n{restored}" ); assert!( restored.contains(pointer.as_str()), "restored pointer must still reference the cc-switch generated catalog file" ); } /// Regression: a hot-switch during takeover rebuilds the backup from the DB /// provider (`update_live_backup_from_provider`), so the backup carries an /// inline `modelCatalog` (DB SSOT) but a `config.toml` text WITHOUT a /// `model_catalog_json` pointer. Restoring that backup must project the /// inline catalog — (re)generating both the catalog file and the pointer — /// or the Codex model mapping vanishes from Live after takeover-off. #[tokio::test] #[serial] async fn codex_restore_from_backup_projects_inline_model_catalog() { 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()); // Catalog projection needs a model template; seed `models_cache.json` // with the template slug so we don't depend on the `codex` CLI. let codex_dir = crate::codex_config::get_codex_config_dir(); std::fs::create_dir_all(&codex_dir).expect("create codex dir"); std::fs::write( codex_dir.join("models_cache.json"), r#"{"models":[{"slug":"gpt-5.5"}]}"#, ) .expect("seed models_cache template"); // Provider-rebuilt backup shape: inline modelCatalog, pointer-less config. let backup_json = serde_json::to_string(&json!({ "auth": { "OPENAI_API_KEY": "deepseek-key" }, "config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.custom]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.example/v1\"\nwire_api = \"responses\"\n", "modelCatalog": { "models": [ { "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", "contextWindow": 1_000_000 } ] } })) .expect("serialize backup"); db.save_live_backup("codex", &backup_json) .await .expect("seed live backup"); service .restore_live_config_for_app_with_fallback(&AppType::Codex) .await .expect("restore codex live from backup"); let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read restored config.toml"); let catalog_path = crate::codex_config::get_codex_model_catalog_path(); assert!( restored.contains("model_catalog_json"), "restore must (re)generate the model_catalog_json pointer from inline catalog, got:\n{restored}" ); assert!( catalog_path.exists(), "restore must generate the cc-switch catalog file on disk" ); let catalog: Value = serde_json::from_str( &std::fs::read_to_string(&catalog_path).expect("read generated catalog"), ) .expect("parse generated catalog"); let slugs: Vec<&str> = catalog .get("models") .and_then(|m| m.as_array()) .expect("catalog models") .iter() .filter_map(|m| m.get("slug").and_then(|s| s.as_str())) .collect(); assert!( slugs.contains(&"deepseek-v4-flash"), "generated catalog must contain the inline model, got slugs: {slugs:?}" ); } /// Regression: a provider-rebuilt backup can pair an inline `modelCatalog` /// with EMPTY `auth.json` (`{}`) — the bearer-token / Mobile-compat shape /// where the API key lives in the config's `experimental_bearer_token`. The /// empty-auth restore branch deletes `auth.json` and writes config raw; it /// must still project the inline catalog (decision is orthogonal to auth), or /// the model mapping vanishes on takeover-off for this provider shape. #[tokio::test] #[serial] async fn codex_restore_empty_auth_backup_still_projects_inline_catalog() { 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()); let codex_dir = crate::codex_config::get_codex_config_dir(); std::fs::create_dir_all(&codex_dir).expect("create codex dir"); std::fs::write( codex_dir.join("models_cache.json"), r#"{"models":[{"slug":"gpt-5.5"}]}"#, ) .expect("seed models_cache template"); // Empty auth.json + key carried in config.toml's experimental_bearer_token, // plus the inline modelCatalog (DB SSOT). let backup_json = serde_json::to_string(&json!({ "auth": {}, "config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.custom]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.example/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"sk-deepseek\"\n", "modelCatalog": { "models": [ { "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash" } ] } })) .expect("serialize backup"); db.save_live_backup("codex", &backup_json) .await .expect("seed live backup"); service .restore_live_config_for_app_with_fallback(&AppType::Codex) .await .expect("restore codex live from backup"); let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read restored config.toml"); assert!( restored.contains("model_catalog_json"), "empty-auth restore must still project the inline catalog pointer, got:\n{restored}" ); assert!( crate::codex_config::get_codex_model_catalog_path().exists(), "empty-auth restore must generate the cc-switch catalog file" ); assert!( !crate::codex_config::get_codex_auth_path().exists(), "empty-auth restore must delete auth.json rather than write an empty one" ); } /// Regression: when the backup row itself contains the proxy placeholder /// (a corrupted state where previous start/stop cycles saved the proxy /// config as the "original Live"), restore must NOT write it back to Live. /// It should fall through to the SSOT (current provider) path and rebuild /// Live from the provider DB instead. #[tokio::test] #[serial] async fn restore_falls_through_to_ssot_when_backup_is_proxy_placeholder() { 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()); // Seed DB with a current provider that has a real API key let provider = Provider::with_id( "p1".to_string(), "P1".to_string(), json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.minimaxi.com/anthropic", "ANTHROPIC_API_KEY": "real-key-from-db" } }), None, ); db.save_provider("claude", &provider) .expect("save provider"); db.set_current_provider("claude", "p1") .expect("set current provider"); // Seed backup with proxy placeholder (the corrupted state) let corrupted_backup = serde_json::to_string(&json!({ "env": { "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER, "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" } })) .expect("serialize corrupted backup"); db.save_live_backup("claude", &corrupted_backup) .await .expect("seed corrupted backup"); // Seed Live with the same proxy placeholder (matches the corrupted state) service .write_claude_live(&json!({ "env": { "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER, "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" } })) .expect("seed taken-over live file"); // Restore: must NOT use the corrupted backup service .restore_live_config_for_app_with_fallback(&AppType::Claude) .await .expect("restore should succeed via SSOT"); // The backup should still be the corrupted one (we didn't touch it on this path) let backup_after = db .get_live_backup("claude") .await .expect("get backup") .expect("backup still exists"); assert_eq!( backup_after.original_config, corrupted_backup, "restore must NOT overwrite the corrupted backup" ); // Live should now reflect the SSOT (provider DB), NOT the proxy URL let restored_live = service.read_claude_live().expect("read live"); let restored_url = restored_live .get("env") .and_then(|env| env.get("ANTHROPIC_BASE_URL")) .and_then(|v| v.as_str()); assert_eq!( restored_url, Some("https://api.minimaxi.com/anthropic"), "Live must be rebuilt from SSOT, not from the corrupted backup" ); let restored_key = restored_live .get("env") .and_then(|env| env.get("ANTHROPIC_API_KEY")) .and_then(|v| v.as_str()); assert_eq!( restored_key, Some("real-key-from-db"), "Live must carry the real API key from the provider DB" ); assert_ne!( restored_live .get("env") .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) .and_then(|v| v.as_str()), Some(PROXY_TOKEN_PLACEHOLDER), "Live must not still carry the proxy placeholder" ); } /// Regression: when Live is already a proxy placeholder (a corrupted state /// where previous stop failed to restore), backup must NOT overwrite a /// previously-good backup with the proxy config. This prevents the bug /// where stop-then-start cycles permanently corrupt the backup. #[tokio::test] #[serial] async fn backup_skips_when_live_is_already_proxy_placeholder() { 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()); // Seed a GOOD backup (the "real" original Live) let good_backup = serde_json::to_string(&json!({ "env": { "ANTHROPIC_BASE_URL": "https://api.minimaxi.com/anthropic", "ANTHROPIC_AUTH_TOKEN": "real-token" } })) .expect("serialize good backup"); db.save_live_backup("claude", &good_backup) .await .expect("seed good backup"); // Seed Live with proxy placeholder (the corrupted state) service .write_claude_live(&json!({ "env": { "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER, "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" } })) .expect("seed taken-over live file"); // Call backup_live_config_strict: must skip service .backup_live_config_strict(&AppType::Claude) .await .expect("backup should succeed (no-op when live is placeholder)"); // The good backup must still be intact let backup_after = db .get_live_backup("claude") .await .expect("get backup") .expect("backup still exists"); assert_eq!( backup_after.original_config, good_backup, "must not overwrite a good backup with a proxy placeholder" ); } /// Regression: when ALL apps have Live=proxy-placeholder (worst-case /// corrupted state), the bulk `backup_live_configs` path used by /// `start_with_takeover` must skip every save — instead of overwriting /// good backups with the proxy config. #[tokio::test] #[serial] async fn bulk_backup_skips_all_when_live_is_proxy_placeholder() { 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()); // Seed good backups for all three apps let good_backup = serde_json::to_string(&json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "real-token" } })) .expect("serialize good backup"); db.save_live_backup("claude", &good_backup) .await .expect("seed claude backup"); let codex_good_backup = serde_json::to_string(&json!({ "auth": { "OPENAI_API_KEY": "real-codex-token" } })) .expect("serialize codex good backup"); db.save_live_backup("codex", &codex_good_backup) .await .expect("seed codex backup"); let gemini_good_backup = serde_json::to_string(&json!({ "env": { "GEMINI_API_KEY": "real-gemini-key" } })) .expect("serialize gemini good backup"); db.save_live_backup("gemini", &gemini_good_backup) .await .expect("seed gemini backup"); // Seed all three Live files with proxy placeholders service .write_claude_live(&json!({ "env": { "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER, "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" } })) .expect("seed claude live"); let codex_dir = crate::codex_config::get_codex_config_dir(); std::fs::create_dir_all(&codex_dir).expect("create codex dir"); std::fs::write( crate::codex_config::get_codex_config_path(), r#"model_provider = "custom" [model_providers.custom] name = "Custom" base_url = "http://127.0.0.1:15721/v1" wire_api = "chat" experimental_bearer_token = "PROXY_MANAGED" "#, ) .expect("seed codex config.toml"); std::fs::write( crate::codex_config::get_codex_auth_path(), r#"{"OPENAI_API_KEY":"PROXY_MANAGED"}"#, ) .expect("seed codex auth.json"); let gemini_env_path = crate::gemini_config::get_gemini_env_path(); if let Some(parent) = gemini_env_path.parent() { std::fs::create_dir_all(parent).expect("create gemini dir"); } std::fs::write(&gemini_env_path, "GEMINI_API_KEY=PROXY_MANAGED\n") .expect("seed gemini env"); // Call bulk backup: must skip all three apps service .backup_live_configs() .await .expect("bulk backup should succeed (no-op when all live are placeholders)"); // All three good backups must still be intact for (app_type, original) in [ ("claude", good_backup.as_str()), ("codex", codex_good_backup.as_str()), ("gemini", gemini_good_backup.as_str()), ] { let backup_after = db .get_live_backup(app_type) .await .expect("get backup") .expect("backup still exists"); assert_eq!( backup_after.original_config, original, "must not overwrite good backup for {app_type} with proxy placeholder" ); } } }