mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-16 17:59:32 +08:00
af8f907467
Concurrent failover switches for the same app could cause is_current, local settings, and Live backup to point at different providers. - Add SwitchLockManager with per-app mutexes (different apps still parallel) - Unify scattered switch logic into ProxyService::hot_switch_provider - Fix TOCTOU in set_current_provider via mutate_settings - Add logical_target_changed to skip redundant UI refreshes - Add tests for serialization and restore-waits-for-switch scenarios
2718 lines
101 KiB
Rust
2718 lines
101 KiB
Rust
//! 代理服务业务逻辑层
|
||
//!
|
||
//! 提供代理服务器的启动、停止和配置管理
|
||
|
||
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, Value};
|
||
use std::str::FromStr;
|
||
use std::sync::Arc;
|
||
use tokio::sync::RwLock;
|
||
|
||
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
|
||
const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||
|
||
/// 代理接管模式下需要从 Claude Live 配置中移除的"模型覆盖"字段。
|
||
///
|
||
/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,
|
||
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
|
||
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [
|
||
"ANTHROPIC_MODEL",
|
||
"ANTHROPIC_REASONING_MODEL",
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||
// Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
|
||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||
];
|
||
|
||
#[derive(Clone)]
|
||
pub struct ProxyService {
|
||
db: Arc<Database>,
|
||
server: Arc<RwLock<Option<ProxyServer>>>,
|
||
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
|
||
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
|
||
switch_locks: SwitchLockManager,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, Default)]
|
||
pub struct HotSwitchOutcome {
|
||
pub logical_target_changed: bool,
|
||
}
|
||
|
||
impl ProxyService {
|
||
pub fn new(db: Arc<Database>) -> Self {
|
||
Self {
|
||
db,
|
||
server: Arc::new(RwLock::new(None)),
|
||
app_handle: Arc::new(RwLock::new(None)),
|
||
switch_locks: SwitchLockManager::new(),
|
||
}
|
||
}
|
||
|
||
/// 清理接管模式下 Claude Live 配置中的模型覆盖字段。
|
||
///
|
||
/// 这可以避免"接管开启后切换供应商仍使用旧模型"的问题。
|
||
/// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。
|
||
pub fn cleanup_claude_model_overrides_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(());
|
||
};
|
||
|
||
let mut changed = false;
|
||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||
if env.remove(key).is_some() {
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if changed {
|
||
self.write_claude_live(&config)?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 设置 AppHandle(在应用初始化时调用)
|
||
pub fn set_app_handle(&self, handle: tauri::AppHandle) {
|
||
futures::executor::block_on(async {
|
||
*self.app_handle.write().await = Some(handle);
|
||
});
|
||
}
|
||
|
||
/// 启动代理服务器
|
||
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
|
||
// 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}"))?;
|
||
|
||
// 5. 保存服务器实例
|
||
*self.server.write().await = Some(server);
|
||
|
||
log::info!("代理服务器已启动: {}:{}", info.address, info.port);
|
||
Ok(info)
|
||
}
|
||
|
||
/// 启动代理服务器(带 Live 配置接管)
|
||
pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {
|
||
// 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);
|
||
}
|
||
|
||
// 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}");
|
||
}
|
||
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}");
|
||
}
|
||
}
|
||
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}");
|
||
}
|
||
}
|
||
Err(e)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
|
||
pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
|
||
// 从 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();
|
||
|
||
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}"))?;
|
||
|
||
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_taken_over = self.detect_takeover_in_live_config_for_app(&app);
|
||
|
||
if has_backup || live_taken_over {
|
||
return Ok(());
|
||
}
|
||
|
||
log::warn!(
|
||
"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份"
|
||
);
|
||
}
|
||
|
||
// 3) 备份 Live 配置(严格:目标 app 不存在则报错)
|
||
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(&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;
|
||
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 配置
|
||
self.restore_live_config_for_app(&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()?,
|
||
AppType::OpenCode => {
|
||
// OpenCode doesn't support proxy features
|
||
return Err("OpenCode 不支持代理功能".to_string());
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw doesn't support proxy features
|
||
return Err("OpenClaw 不支持代理功能".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})"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AppType::OpenCode => {
|
||
// OpenCode doesn't support proxy features, skip silently
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw doesn't support proxy features, skip silently
|
||
}
|
||
}
|
||
|
||
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() {
|
||
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() {
|
||
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() {
|
||
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()?),
|
||
AppType::OpenCode => {
|
||
// OpenCode doesn't support proxy features
|
||
return Err("OpenCode 不支持代理功能".to_string());
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw doesn't support proxy features
|
||
return Err("OpenClaw 不支持代理功能".to_string());
|
||
}
|
||
};
|
||
|
||
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 proxy_origin = format!("http://{}:{}", connect_host_for_url, config.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() {
|
||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||
env.remove(key);
|
||
}
|
||
// 仅覆盖已存在的 Token 字段,避免新增字段导致用户困惑;
|
||
// 若完全没有 Token 字段,则写入 ANTHROPIC_AUTH_TOKEN 占位符用于避免客户端警告。
|
||
let token_keys = [
|
||
"ANTHROPIC_AUTH_TOKEN",
|
||
"ANTHROPIC_API_KEY",
|
||
"OPENROUTER_API_KEY",
|
||
"OPENAI_API_KEY",
|
||
];
|
||
|
||
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),
|
||
);
|
||
}
|
||
} else {
|
||
live_config["env"] = json!({
|
||
"ANTHROPIC_BASE_URL": &proxy_url,
|
||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER
|
||
});
|
||
}
|
||
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 updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);
|
||
live_config["config"] = json!(updated_config);
|
||
|
||
self.write_codex_live(&live_config)?;
|
||
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()?;
|
||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||
env.remove(key);
|
||
}
|
||
|
||
let token_keys = [
|
||
"ANTHROPIC_AUTH_TOKEN",
|
||
"ANTHROPIC_API_KEY",
|
||
"OPENROUTER_API_KEY",
|
||
"OPENAI_API_KEY",
|
||
];
|
||
|
||
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),
|
||
);
|
||
}
|
||
} else {
|
||
live_config["env"] = json!({
|
||
"ANTHROPIC_BASE_URL": &proxy_url,
|
||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER
|
||
});
|
||
}
|
||
|
||
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 updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);
|
||
live_config["config"] = json!(updated_config);
|
||
|
||
self.write_codex_live(&live_config)?;
|
||
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}");
|
||
}
|
||
AppType::OpenCode => {
|
||
// OpenCode doesn't support proxy features
|
||
return Err("OpenCode 不支持代理功能".to_string());
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw doesn't support proxy features
|
||
return Err("OpenClaw 不支持代理功能".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() {
|
||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
||
// 关键:接管模式下移除模型覆盖字段,避免切换供应商后仍用旧模型名发起请求
|
||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||
env.remove(key);
|
||
}
|
||
|
||
let token_keys = [
|
||
"ANTHROPIC_AUTH_TOKEN",
|
||
"ANTHROPIC_API_KEY",
|
||
"OPENROUTER_API_KEY",
|
||
"OPENAI_API_KEY",
|
||
];
|
||
|
||
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),
|
||
);
|
||
}
|
||
} else {
|
||
live_config["env"] = json!({
|
||
"ANTHROPIC_BASE_URL": &proxy_url,
|
||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER
|
||
});
|
||
}
|
||
|
||
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 updated_config =
|
||
Self::update_toml_base_url(config_str, &proxy_codex_base_url);
|
||
live_config["config"] = json!(updated_config);
|
||
|
||
let _ = self.write_codex_live(&live_config);
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
AppType::OpenCode => {
|
||
// OpenCode doesn't support proxy features, skip silently
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw doesn't support proxy features, skip silently
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
|
||
async fn restore_live_config_for_app(&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_inner(app_type).await
|
||
}
|
||
|
||
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 配置已恢复");
|
||
}
|
||
}
|
||
AppType::OpenCode => {
|
||
// OpenCode doesn't support proxy features, skip silently
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw doesn't support proxy features, skip silently
|
||
}
|
||
}
|
||
|
||
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}"))?;
|
||
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),
|
||
AppType::OpenCode => {
|
||
// OpenCode doesn't support proxy features
|
||
Err("OpenCode 不支持代理功能".to_string())
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw doesn't support proxy features
|
||
Err("OpenClaw 不支持代理功能".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,
|
||
},
|
||
AppType::OpenCode => {
|
||
// OpenCode doesn't support proxy takeover
|
||
false
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw doesn't support proxy takeover
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。
|
||
///
|
||
/// 返回值:
|
||
/// - Ok(true):已成功写回
|
||
/// - Ok(false):缺少当前供应商/供应商不存在,无法写回
|
||
fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {
|
||
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);
|
||
};
|
||
|
||
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(),
|
||
AppType::OpenCode => {
|
||
// OpenCode doesn't support proxy features
|
||
Ok(())
|
||
}
|
||
AppType::OpenClaw => {
|
||
// OpenClaw doesn't support proxy features
|
||
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 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);
|
||
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<bool, String> {
|
||
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 is_codex_live_taken_over(config: &Value) -> bool {
|
||
let auth = match config.get("auth").and_then(|v| v.as_object()) {
|
||
Some(auth) => auth,
|
||
None => return false,
|
||
};
|
||
auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)
|
||
}
|
||
|
||
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 备份(用于代理模式下的热切换)
|
||
///
|
||
/// 与 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 = self
|
||
.db
|
||
.get_live_backup(app_type)
|
||
.await
|
||
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?;
|
||
|
||
if let Some(existing_backup) = existing_backup {
|
||
let existing_value: Value = serde_json::from_str(&existing_backup.original_config)
|
||
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?;
|
||
Self::preserve_codex_mcp_servers_in_backup(
|
||
&mut effective_settings,
|
||
&existing_value,
|
||
)?;
|
||
}
|
||
}
|
||
|
||
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}"))?
|
||
}
|
||
AppType::OpenCode | AppType::OpenClaw => {
|
||
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<HotSwitchOutcome, String> {
|
||
let _guard = self.switch_locks.lock_for_app(app_type).await;
|
||
|
||
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}"))?;
|
||
|
||
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) {
|
||
if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
|
||
log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {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_in_backup(
|
||
target_settings: &mut Value,
|
||
existing_backup: &Value,
|
||
) -> Result<(), String> {
|
||
let target_obj = target_settings
|
||
.as_object_mut()
|
||
.ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?;
|
||
|
||
let target_config = target_obj
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
let mut target_doc = if target_config.trim().is_empty() {
|
||
toml_edit::DocumentMut::new()
|
||
} else {
|
||
target_config
|
||
.parse::<toml_edit::DocumentMut>()
|
||
.map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))?
|
||
};
|
||
|
||
let existing_config = existing_backup
|
||
.get("config")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
if existing_config.trim().is_empty() {
|
||
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
|
||
return Ok(());
|
||
}
|
||
|
||
let existing_doc = existing_config
|
||
.parse::<toml_edit::DocumentMut>()
|
||
.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 backup MCP merge"
|
||
);
|
||
}
|
||
}
|
||
None => {
|
||
target_doc["mcp_servers"] = existing_mcp_servers.clone();
|
||
}
|
||
}
|
||
}
|
||
|
||
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
|
||
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())
|
||
}
|
||
|
||
fn read_claude_live(&self) -> Result<Value, String> {
|
||
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<Value, String> {
|
||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
||
|
||
let auth_path = get_codex_auth_path();
|
||
if !auth_path.exists() {
|
||
return Err("Codex auth.json 不存在".to_string());
|
||
}
|
||
|
||
let auth: Value =
|
||
read_json_file(&auth_path).map_err(|e| format!("读取 Codex auth 失败: {e}"))?;
|
||
|
||
let config_path = get_codex_config_path();
|
||
let config_str = if config_path.exists() {
|
||
std::fs::read_to_string(&config_path)
|
||
.map_err(|e| format!("读取 Codex config 失败: {e}"))?
|
||
} else {
|
||
String::new()
|
||
};
|
||
|
||
Ok(json!({
|
||
"auth": auth,
|
||
"config": config_str
|
||
}))
|
||
}
|
||
|
||
fn write_codex_live(&self, config: &Value) -> Result<(), String> {
|
||
use crate::codex_config::{
|
||
get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,
|
||
};
|
||
|
||
let auth = config.get("auth");
|
||
let config_str = config.get("config").and_then(|v| v.as_str());
|
||
|
||
match (auth, config_str) {
|
||
(Some(auth), Some(cfg)) => 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<Value, String> {
|
||
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<ProxyStatus, String> {
|
||
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<ProxyConfig, String> {
|
||
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, self.db.clone(), app_handle);
|
||
new_server
|
||
.start()
|
||
.await
|
||
.map_err(|e| format!("重启代理服务器失败: {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(())
|
||
}
|
||
|
||
/// 重置指定 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<String>,
|
||
original_userprofile: Option<String>,
|
||
}
|
||
|
||
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();
|
||
|
||
env::set_var("HOME", dir.path());
|
||
env::set_var("USERPROFILE", dir.path());
|
||
|
||
Self {
|
||
dir,
|
||
original_home,
|
||
original_userprofile,
|
||
}
|
||
}
|
||
}
|
||
|
||
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"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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 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_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 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"
|
||
);
|
||
}
|
||
}
|