mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
Merge upstream/main into main
Resolved conflicts in src-tauri/src/lib.rs: - Kept both: open_provider_terminal (my feature) and universal provider commands (upstream) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,36 @@
|
||||
use crate::error::AppError;
|
||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
||||
|
||||
/// 获取 macOS 上的 .app bundle 路径
|
||||
/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
let path_str = exe_path.to_string_lossy();
|
||||
// 查找 .app/Contents/MacOS/ 模式
|
||||
if let Some(app_pos) = path_str.find(".app/Contents/MacOS/") {
|
||||
let app_bundle_end = app_pos + 4; // ".app" 的结束位置
|
||||
Some(std::path::PathBuf::from(&path_str[..app_bundle_end]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化 AutoLaunch 实例
|
||||
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
||||
let app_name = "CC Switch";
|
||||
let app_path =
|
||||
let exe_path =
|
||||
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
|
||||
|
||||
// macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端
|
||||
#[cfg(target_os = "macos")]
|
||||
let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let app_path = exe_path;
|
||||
|
||||
// 使用 AutoLaunchBuilder 消除平台差异
|
||||
// Windows/Linux: new() 接受 3 参数
|
||||
// macOS: new() 接受 4 参数(含 hidden 参数)
|
||||
// Builder 模式自动处理这些差异
|
||||
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径
|
||||
// Windows/Linux: 使用注册表/XDG autostart
|
||||
let auto_launch = AutoLaunchBuilder::new()
|
||||
.set_app_name(app_name)
|
||||
.set_app_path(&app_path.to_string_lossy())
|
||||
@@ -47,3 +67,49 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
|
||||
.is_enabled()
|
||||
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_get_macos_app_bundle_path_valid() {
|
||||
let exe_path =
|
||||
std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
|
||||
let result = get_macos_app_bundle_path(exe_path);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(std::path::PathBuf::from("/Applications/CC Switch.app"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_get_macos_app_bundle_path_with_spaces() {
|
||||
let exe_path =
|
||||
std::path::Path::new("/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch");
|
||||
let result = get_macos_app_bundle_path(exe_path);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(std::path::PathBuf::from("/Users/test/My Apps/CC Switch.app"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_get_macos_app_bundle_path_not_in_bundle() {
|
||||
let exe_path = std::path::Path::new("/usr/local/bin/cc-switch");
|
||||
let result = get_macos_app_bundle_path(exe_path);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_get_macos_app_bundle_path_dev_build() {
|
||||
// 开发环境下的路径通常不在 .app bundle 内
|
||||
let exe_path = std::path::Path::new("/Users/dev/project/target/debug/cc-switch");
|
||||
let result = get_macos_app_bundle_path(exe_path);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,21 +56,21 @@ pub async fn remove_from_failover_queue(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定应用的自动故障转移开关状态
|
||||
/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取)
|
||||
#[tauri::command]
|
||||
pub async fn get_auto_failover_enabled(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<bool, String> {
|
||||
let key = format!("auto_failover_enabled_{app_type}");
|
||||
state
|
||||
.db
|
||||
.get_setting(&key)
|
||||
.map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭
|
||||
.get_proxy_config_for_app(&app_type)
|
||||
.await
|
||||
.map(|config| config.auto_failover_enabled)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置指定应用的自动故障转移开关状态
|
||||
/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表)
|
||||
///
|
||||
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
||||
#[tauri::command]
|
||||
@@ -79,12 +79,24 @@ pub async fn set_auto_failover_enabled(
|
||||
app_type: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let key = format!("auto_failover_enabled_{app_type}");
|
||||
let value = if enabled { "true" } else { "false" };
|
||||
|
||||
log::info!(
|
||||
"[Failover] Setting auto_failover_enabled: key='{key}', value='{value}', app_type='{app_type}'"
|
||||
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
||||
);
|
||||
|
||||
state.db.set_setting(&key, value).map_err(|e| e.to_string())
|
||||
// 读取当前配置
|
||||
let mut config = state
|
||||
.db
|
||||
.get_proxy_config_for_app(&app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 更新 auto_failover_enabled 字段
|
||||
config.auto_failover_enabled = enabled;
|
||||
|
||||
// 写回数据库
|
||||
state
|
||||
.db
|
||||
.update_proxy_config_for_app(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -229,3 +229,97 @@ pub fn update_providers_sort_order(
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 统一供应商(Universal Provider)命令
|
||||
// ============================================================================
|
||||
|
||||
use crate::provider::UniversalProvider;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// 统一供应商同步完成事件的 payload
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct UniversalProviderSyncedEvent {
|
||||
/// 操作类型: "upsert" | "delete" | "sync"
|
||||
pub action: String,
|
||||
/// 统一供应商 ID
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// 发送统一供应商同步事件,通知前端刷新供应商列表
|
||||
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
||||
let _ = app.emit(
|
||||
"universal-provider-synced",
|
||||
UniversalProviderSyncedEvent {
|
||||
action: action.to_string(),
|
||||
id: id.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取所有统一供应商
|
||||
#[tauri::command]
|
||||
pub fn get_universal_providers(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HashMap<String, UniversalProvider>, String> {
|
||||
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取单个统一供应商
|
||||
#[tauri::command]
|
||||
pub fn get_universal_provider(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Option<UniversalProvider>, String> {
|
||||
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 添加或更新统一供应商
|
||||
#[tauri::command]
|
||||
pub fn upsert_universal_provider(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
provider: UniversalProvider,
|
||||
) -> Result<bool, String> {
|
||||
let id = provider.id.clone();
|
||||
let result =
|
||||
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
|
||||
|
||||
// 发送事件通知前端刷新
|
||||
emit_universal_provider_synced(&app, "upsert", &id);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 删除统一供应商
|
||||
#[tauri::command]
|
||||
pub fn delete_universal_provider(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let result =
|
||||
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
|
||||
|
||||
// 发送事件通知前端刷新
|
||||
emit_universal_provider_synced(&app, "delete", &id);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 同步统一供应商到各应用(手动触发)
|
||||
#[tauri::command]
|
||||
pub fn sync_universal_provider(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let result =
|
||||
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
|
||||
|
||||
// 发送事件通知前端刷新
|
||||
emit_universal_provider_synced(&app, "sync", &id);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,63 @@ pub async fn update_proxy_config(
|
||||
state.proxy_service.update_config(&config).await
|
||||
}
|
||||
|
||||
// ==================== Global & Per-App Config ====================
|
||||
|
||||
/// 获取全局代理配置
|
||||
///
|
||||
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
|
||||
#[tauri::command]
|
||||
pub async fn get_global_proxy_config(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<GlobalProxyConfig, String> {
|
||||
let db = &state.db;
|
||||
db.get_global_proxy_config()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新全局代理配置
|
||||
///
|
||||
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini)
|
||||
#[tauri::command]
|
||||
pub async fn update_global_proxy_config(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config: GlobalProxyConfig,
|
||||
) -> Result<(), String> {
|
||||
let db = &state.db;
|
||||
db.update_global_proxy_config(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定应用的代理配置
|
||||
///
|
||||
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
|
||||
#[tauri::command]
|
||||
pub async fn get_proxy_config_for_app(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<AppProxyConfig, String> {
|
||||
let db = &state.db;
|
||||
db.get_proxy_config_for_app(&app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新指定应用的代理配置
|
||||
///
|
||||
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
|
||||
#[tauri::command]
|
||||
pub async fn update_proxy_config_for_app(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config: AppProxyConfig,
|
||||
) -> Result<(), String> {
|
||||
let db = &state.db;
|
||||
db.update_proxy_config_for_app(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 检查代理服务器是否正在运行
|
||||
#[tauri::command]
|
||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||
@@ -126,19 +183,12 @@ pub async fn reset_circuit_breaker(
|
||||
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
||||
.await?;
|
||||
|
||||
// 3. 检查是否应该切回优先级更高的供应商
|
||||
let failover_key = format!("auto_failover_enabled_{app_type}");
|
||||
let auto_failover_enabled = match db.get_setting(&failover_key) {
|
||||
Ok(Some(value)) => value == "true",
|
||||
Ok(None) => {
|
||||
log::debug!(
|
||||
"[{app_type}] Failover setting '{failover_key}' not found, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
|
||||
let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await {
|
||||
Ok(config) => config.auto_failover_enabled,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled"
|
||||
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod universal_providers;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
|
||||
@@ -8,62 +8,66 @@ use crate::proxy::types::*;
|
||||
use super::super::{lock_conn, Database};
|
||||
|
||||
impl Database {
|
||||
// ==================== Proxy Config ====================
|
||||
// ==================== Global Proxy Config ====================
|
||||
|
||||
/// 获取代理配置
|
||||
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
|
||||
// 在一个作用域内获取锁并查询,确保锁在await之前释放
|
||||
/// 获取全局代理配置(统一字段)
|
||||
///
|
||||
/// 从 claude 行读取(三行镜像一致)
|
||||
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
|
||||
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT listen_address, listen_port, max_retries,
|
||||
request_timeout, enable_logging, live_takeover_active
|
||||
FROM proxy_config WHERE id = 1",
|
||||
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
|
||||
FROM proxy_config WHERE app_type = 'claude'",
|
||||
[],
|
||||
|row| {
|
||||
Ok(ProxyConfig {
|
||||
listen_address: row.get(0)?,
|
||||
listen_port: row.get::<_, i32>(1)? as u16,
|
||||
max_retries: row.get::<_, i32>(2)? as u8,
|
||||
request_timeout: row.get::<_, i32>(3)? as u64,
|
||||
enable_logging: row.get::<_, i32>(4)? != 0,
|
||||
live_takeover_active: row.get::<_, i32>(5).unwrap_or(0) != 0,
|
||||
Ok(GlobalProxyConfig {
|
||||
proxy_enabled: row.get::<_, i32>(0)? != 0,
|
||||
listen_address: row.get(1)?,
|
||||
listen_port: row.get::<_, i32>(2)? as u16,
|
||||
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
}; // conn锁在这里释放
|
||||
};
|
||||
// conn 已在 block 结束时释放
|
||||
|
||||
match result {
|
||||
Ok(config) => Ok(config),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
// 如果不存在,插入默认配置
|
||||
let default_config = ProxyConfig::default();
|
||||
self.update_proxy_config(default_config.clone()).await?;
|
||||
Ok(default_config)
|
||||
// 如果不存在,创建默认配置
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok(GlobalProxyConfig {
|
||||
proxy_enabled: false,
|
||||
listen_address: "127.0.0.1".to_string(),
|
||||
listen_port: 5000,
|
||||
enable_logging: true,
|
||||
})
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新代理配置
|
||||
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
||||
/// 更新全局代理配置(镜像写三行)
|
||||
pub async fn update_global_proxy_config(
|
||||
&self,
|
||||
config: GlobalProxyConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_config
|
||||
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
||||
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
|
||||
datetime('now'))",
|
||||
"UPDATE proxy_config SET
|
||||
proxy_enabled = ?1,
|
||||
listen_address = ?2,
|
||||
listen_port = ?3,
|
||||
enable_logging = ?4,
|
||||
updated_at = datetime('now')",
|
||||
rusqlite::params![
|
||||
0, // 已移除自动启用逻辑,保留列但固定为 0
|
||||
if config.proxy_enabled { 1 } else { 0 },
|
||||
config.listen_address,
|
||||
config.listen_port as i32,
|
||||
config.max_retries as i32,
|
||||
config.request_timeout as i32,
|
||||
if config.enable_logging { 1 } else { 0 },
|
||||
if config.live_takeover_active { 1 } else { 0 },
|
||||
"claude", // 兼容旧字段,写入默认值
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -71,27 +75,214 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置 Live 接管状态(仅更新 proxy_config 表,兼容旧逻辑)
|
||||
///
|
||||
/// 注意:此方法不会清除 settings 表中的 proxy_takeover_* 状态。
|
||||
/// settings 表的状态由 set_proxy_takeover_enabled 单独管理,用于跨重启保持状态。
|
||||
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
|
||||
// 仅更新 proxy_config 表(兼容旧版本)
|
||||
/// 获取应用级代理配置
|
||||
pub async fn get_proxy_config_for_app(
|
||||
&self,
|
||||
app_type: &str,
|
||||
) -> Result<AppProxyConfig, AppError> {
|
||||
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||
let app_type_owned = app_type.to_string();
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT app_type, enabled, auto_failover_enabled,
|
||||
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
FROM proxy_config WHERE app_type = ?1",
|
||||
[app_type],
|
||||
|row| {
|
||||
Ok(AppProxyConfig {
|
||||
app_type: row.get(0)?,
|
||||
enabled: row.get::<_, i32>(1)? != 0,
|
||||
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
|
||||
max_retries: row.get::<_, i32>(3)? as u32,
|
||||
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
|
||||
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
|
||||
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
|
||||
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
|
||||
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
|
||||
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
|
||||
circuit_error_rate_threshold: row.get(10)?,
|
||||
circuit_min_requests: row.get::<_, i32>(11)? as u32,
|
||||
})
|
||||
},
|
||||
)
|
||||
};
|
||||
// conn 已在 block 结束时释放
|
||||
|
||||
match result {
|
||||
Ok(config) => Ok(config),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
// 如果不存在,创建默认配置
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok(AppProxyConfig {
|
||||
app_type: app_type_owned,
|
||||
enabled: false,
|
||||
auto_failover_enabled: false,
|
||||
max_retries: 3,
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 300,
|
||||
circuit_failure_threshold: 5,
|
||||
circuit_success_threshold: 2,
|
||||
circuit_timeout_seconds: 60,
|
||||
circuit_error_rate_threshold: 0.5,
|
||||
circuit_min_requests: 10,
|
||||
})
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新应用级代理配置
|
||||
pub async fn update_proxy_config_for_app(
|
||||
&self,
|
||||
config: AppProxyConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
|
||||
rusqlite::params![if active { 1 } else { 0 }],
|
||||
"UPDATE proxy_config SET
|
||||
enabled = ?2,
|
||||
auto_failover_enabled = ?3,
|
||||
max_retries = ?4,
|
||||
streaming_first_byte_timeout = ?5,
|
||||
streaming_idle_timeout = ?6,
|
||||
non_streaming_timeout = ?7,
|
||||
circuit_failure_threshold = ?8,
|
||||
circuit_success_threshold = ?9,
|
||||
circuit_timeout_seconds = ?10,
|
||||
circuit_error_rate_threshold = ?11,
|
||||
circuit_min_requests = ?12,
|
||||
updated_at = datetime('now')
|
||||
WHERE app_type = ?1",
|
||||
rusqlite::params![
|
||||
config.app_type,
|
||||
if config.enabled { 1 } else { 0 },
|
||||
if config.auto_failover_enabled { 1 } else { 0 },
|
||||
config.max_retries as i32,
|
||||
config.streaming_first_byte_timeout as i32,
|
||||
config.streaming_idle_timeout as i32,
|
||||
config.non_streaming_timeout as i32,
|
||||
config.circuit_failure_threshold as i32,
|
||||
config.circuit_success_threshold as i32,
|
||||
config.circuit_timeout_seconds as i32,
|
||||
config.circuit_error_rate_threshold,
|
||||
config.circuit_min_requests as i32,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化 proxy_config 表的三行数据
|
||||
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
for app_type in &["claude", "codex", "gemini"] {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
|
||||
[app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Legacy Proxy Config (兼容旧代码) ====================
|
||||
|
||||
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
|
||||
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
|
||||
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT listen_address, listen_port, max_retries,
|
||||
enable_logging,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||||
FROM proxy_config WHERE app_type = 'claude'",
|
||||
[],
|
||||
|row| {
|
||||
Ok(ProxyConfig {
|
||||
listen_address: row.get(0)?,
|
||||
listen_port: row.get::<_, i32>(1)? as u16,
|
||||
max_retries: row.get::<_, i32>(2)? as u8,
|
||||
request_timeout: 300, // 废弃字段,返回默认值
|
||||
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||
live_takeover_active: false, // 废弃字段
|
||||
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
|
||||
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
|
||||
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
|
||||
})
|
||||
},
|
||||
)
|
||||
};
|
||||
// conn 已在 block 结束时释放
|
||||
|
||||
match result {
|
||||
Ok(config) => Ok(config),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
// 如果不存在,初始化默认配置
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok(ProxyConfig::default())
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
|
||||
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 更新所有三行的公共字段
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET
|
||||
listen_address = ?1,
|
||||
listen_port = ?2,
|
||||
max_retries = ?3,
|
||||
enable_logging = ?4,
|
||||
streaming_first_byte_timeout = ?5,
|
||||
streaming_idle_timeout = ?6,
|
||||
non_streaming_timeout = ?7,
|
||||
updated_at = datetime('now')",
|
||||
rusqlite::params![
|
||||
config.listen_address,
|
||||
config.listen_port as i32,
|
||||
config.max_retries as i32,
|
||||
if config.enable_logging { 1 } else { 0 },
|
||||
config.streaming_first_byte_timeout as i32,
|
||||
config.streaming_idle_timeout as i32,
|
||||
config.non_streaming_timeout as i32,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
|
||||
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
|
||||
// 不再使用此字段,由 enabled 字段替代
|
||||
// 保留空实现以兼容旧代码
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查是否处于 Live 接管模式
|
||||
///
|
||||
/// v3.8.0+:以 settings 表中的 `proxy_takeover_{app_type}` 为真实来源
|
||||
/// 检查是否有任一 app 的 enabled = true
|
||||
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
|
||||
self.has_any_proxy_takeover()
|
||||
let conn = lock_conn!(self.conn);
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
// ==================== Provider Health ====================
|
||||
@@ -270,19 +461,22 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Circuit Breaker Config ====================
|
||||
// ==================== Circuit Breaker Config (Legacy Compatibility) ====================
|
||||
|
||||
/// 获取熔断器配置
|
||||
/// 获取熔断器配置(兼容旧接口,从 claude 行读取)
|
||||
///
|
||||
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
|
||||
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
|
||||
pub async fn get_circuit_breaker_config(
|
||||
&self,
|
||||
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let config = conn
|
||||
.query_row(
|
||||
"SELECT failure_threshold, success_threshold, timeout_seconds,
|
||||
error_rate_threshold, min_requests
|
||||
FROM circuit_breaker_config WHERE id = 1",
|
||||
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
FROM proxy_config WHERE app_type = 'claude'",
|
||||
[],
|
||||
|row| {
|
||||
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||
@@ -294,27 +488,39 @@ impl Database {
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
};
|
||||
// conn 已在 block 结束时释放
|
||||
|
||||
Ok(config)
|
||||
match result {
|
||||
Ok(config) => Ok(config),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
// 如果不存在,初始化默认配置
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新熔断器配置
|
||||
/// 更新熔断器配置(兼容旧接口,更新所有三行)
|
||||
///
|
||||
/// 熔断器配置已合并到 proxy_config 表
|
||||
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
|
||||
pub async fn update_circuit_breaker_config(
|
||||
&self,
|
||||
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 更新所有三行的熔断器配置
|
||||
conn.execute(
|
||||
"UPDATE circuit_breaker_config
|
||||
SET failure_threshold = ?1,
|
||||
success_threshold = ?2,
|
||||
timeout_seconds = ?3,
|
||||
error_rate_threshold = ?4,
|
||||
min_requests = ?5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1",
|
||||
"UPDATE proxy_config SET
|
||||
circuit_failure_threshold = ?1,
|
||||
circuit_success_threshold = ?2,
|
||||
circuit_timeout_seconds = ?3,
|
||||
circuit_error_rate_threshold = ?4,
|
||||
circuit_min_requests = ?5,
|
||||
updated_at = datetime('now')",
|
||||
rusqlite::params![
|
||||
config.failure_threshold as i32,
|
||||
config.success_threshold as i32,
|
||||
|
||||
@@ -63,11 +63,13 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 代理接管状态管理 ---
|
||||
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||
|
||||
/// 获取指定应用的代理接管状态
|
||||
///
|
||||
/// 使用 settings 表存储各应用的接管状态,key 格式: `proxy_takeover_{app_type}`
|
||||
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
|
||||
/// 此方法仅用于数据库迁移时读取旧数据
|
||||
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
|
||||
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
|
||||
let key = format!("proxy_takeover_{app_type}");
|
||||
match self.get_setting(&key)? {
|
||||
@@ -78,8 +80,11 @@ impl Database {
|
||||
|
||||
/// 设置指定应用的代理接管状态
|
||||
///
|
||||
/// - `true` = 开启代理接管
|
||||
/// - `false` = 关闭代理接管
|
||||
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
|
||||
#[deprecated(
|
||||
since = "3.9.0",
|
||||
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
|
||||
)]
|
||||
pub fn set_proxy_takeover_enabled(
|
||||
&self,
|
||||
app_type: &str,
|
||||
@@ -91,6 +96,9 @@ impl Database {
|
||||
}
|
||||
|
||||
/// 检查是否有任一应用开启了代理接管
|
||||
///
|
||||
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
|
||||
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
|
||||
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let count: i64 = conn
|
||||
@@ -104,6 +112,12 @@ impl Database {
|
||||
}
|
||||
|
||||
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false)
|
||||
///
|
||||
/// **已废弃**: settings 表不再用于存储代理状态
|
||||
#[deprecated(
|
||||
since = "3.9.0",
|
||||
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
|
||||
)]
|
||||
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! 统一供应商 (Universal Provider) DAO
|
||||
//!
|
||||
//! 提供统一供应商的 CRUD 操作。
|
||||
|
||||
use crate::database::{lock_conn, to_json_string, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::UniversalProvider;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 统一供应商的 Settings Key
|
||||
const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers";
|
||||
|
||||
impl Database {
|
||||
/// 获取所有统一供应商
|
||||
pub fn get_all_universal_providers(&self) -> Result<HashMap<String, UniversalProvider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let result: Option<String> = stmt
|
||||
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
|
||||
.ok();
|
||||
|
||||
match result {
|
||||
Some(json) => {
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}")))
|
||||
}
|
||||
None => Ok(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单个统一供应商
|
||||
pub fn get_universal_provider(&self, id: &str) -> Result<Option<UniversalProvider>, AppError> {
|
||||
let providers = self.get_all_universal_providers()?;
|
||||
Ok(providers.get(id).cloned())
|
||||
}
|
||||
|
||||
/// 保存统一供应商(添加或更新)
|
||||
pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> {
|
||||
let mut providers = self.get_all_universal_providers()?;
|
||||
providers.insert(provider.id.clone(), provider.clone());
|
||||
self.save_all_universal_providers(&providers)
|
||||
}
|
||||
|
||||
/// 删除统一供应商
|
||||
pub fn delete_universal_provider(&self, id: &str) -> Result<bool, AppError> {
|
||||
let mut providers = self.get_all_universal_providers()?;
|
||||
let existed = providers.remove(id).is_some();
|
||||
if existed {
|
||||
self.save_all_universal_providers(&providers)?;
|
||||
}
|
||||
Ok(existed)
|
||||
}
|
||||
|
||||
/// 保存所有统一供应商(内部方法)
|
||||
fn save_all_universal_providers(
|
||||
&self,
|
||||
providers: &HashMap<String, UniversalProvider>,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let json = to_json_string(providers)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
[UNIVERSAL_PROVIDERS_KEY, &json],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+303
-228
@@ -55,47 +55,28 @@ impl Database {
|
||||
// 3. MCP Servers 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
description TEXT,
|
||||
homepage TEXT,
|
||||
docs TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 4. Prompts 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS prompts (
|
||||
id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
PRIMARY KEY (id, app_type)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS prompts (
|
||||
id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,
|
||||
description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,
|
||||
PRIMARY KEY (id, app_type)
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 5. Skills 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS skills (
|
||||
directory TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (directory, app_type)
|
||||
)",
|
||||
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
|
||||
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -103,169 +84,130 @@ impl Database {
|
||||
// 6. Skill Repos 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS skill_repos (
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (owner, name)
|
||||
)",
|
||||
owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 7. Settings 表 (通用配置)
|
||||
// 7. Settings 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 8. Proxy Config 表 (代理服务器配置)
|
||||
// 代理配置表(单例)
|
||||
// 8. Proxy Config 表(三行结构,app_type 主键)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 初始化三行数据(每应用不同默认值)
|
||||
//
|
||||
// 兼容旧数据库:
|
||||
// - 老版本 proxy_config 是单例表(没有 app_type 列),此时不能执行三行 seed insert;
|
||||
// - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。
|
||||
if Self::has_column(conn, "proxy_config", "app_type")? {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 9. Provider Health 表
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
|
||||
provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT,
|
||||
last_error TEXT, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider_id, app_type),
|
||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 10. Proxy Request Logs 表
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
|
||||
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
|
||||
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS proxy_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
request_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
target_app TEXT NOT NULL DEFAULT 'claude',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 尝试添加 target_app 列(如果表已存在但缺少该列)
|
||||
// 忽略 "duplicate column name" 错误
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
|
||||
[],
|
||||
);
|
||||
|
||||
// 9. Provider Health 表 (Provider健康状态)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS provider_health (
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
is_healthy INTEGER NOT NULL DEFAULT 1,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
last_success_at TEXT,
|
||||
last_failure_at TEXT,
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider_id, app_type),
|
||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 10. Proxy Request Logs 表 (详细请求日志)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
latency_ms INTEGER NOT NULL,
|
||||
first_token_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
status_code INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
session_id TEXT,
|
||||
provider_type TEXT,
|
||||
is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
||||
created_at INTEGER NOT NULL
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_provider
|
||||
ON proxy_request_logs(provider_id, app_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at
|
||||
ON proxy_request_logs(created_at)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_model
|
||||
ON proxy_request_logs(model)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_session
|
||||
ON proxy_request_logs(session_id)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_status
|
||||
ON proxy_request_logs(status_code)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 11. Model Pricing 表 (模型定价)
|
||||
// 11. Model Pricing 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
input_cost_per_million TEXT NOT NULL,
|
||||
output_cost_per_million TEXT NOT NULL,
|
||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||
)",
|
||||
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 12. Stream Check Logs 表 (流式健康检查日志)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS stream_check_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
response_time_ms INTEGER,
|
||||
http_status INTEGER,
|
||||
model_used TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
tested_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
// 12. Stream Check Logs 表
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,
|
||||
response_time_ms INTEGER, http_status INTEGER, model_used TEXT,
|
||||
retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
|
||||
@@ -274,35 +216,13 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 13. Circuit Breaker Config 表 (熔断器配置)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
failure_threshold INTEGER NOT NULL DEFAULT 5,
|
||||
success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
||||
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||
min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 插入默认熔断器配置
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
// 注意:circuit_breaker_config 已合并到 proxy_config 表中
|
||||
|
||||
// 16. Proxy Live Backup 表 (Live 配置备份)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS proxy_live_backup (
|
||||
app_type TEXT PRIMARY KEY,
|
||||
original_config TEXT NOT NULL,
|
||||
backed_up_at TEXT NOT NULL
|
||||
)",
|
||||
app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -313,6 +233,20 @@ impl Database {
|
||||
[],
|
||||
);
|
||||
|
||||
// 尝试添加超时配置列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
|
||||
[],
|
||||
);
|
||||
|
||||
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
@@ -475,6 +409,28 @@ impl Database {
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 添加代理超时配置字段
|
||||
if Self::table_exists(conn, "proxy_config")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"streaming_first_byte_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 30",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"streaming_idle_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 60",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"non_streaming_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 300",
|
||||
)?;
|
||||
}
|
||||
|
||||
// 删除旧的 failover_queue 表(如果存在)
|
||||
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
|
||||
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
|
||||
@@ -489,35 +445,18 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
|
||||
|
||||
// proxy_request_logs 表(包含所有字段)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
latency_ms INTEGER NOT NULL,
|
||||
first_token_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
status_code INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
session_id TEXT,
|
||||
provider_type TEXT,
|
||||
is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
||||
created_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
// proxy_request_logs 表
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
|
||||
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
|
||||
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
|
||||
)", [])?;
|
||||
|
||||
// 为已存在的表添加新字段
|
||||
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
|
||||
@@ -539,13 +478,11 @@ impl Database {
|
||||
// model_pricing 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
input_cost_per_million TEXT NOT NULL,
|
||||
output_cost_per_million TEXT NOT NULL,
|
||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||
)",
|
||||
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
@@ -557,6 +494,144 @@ impl Database {
|
||||
// 重构 skills 表(添加 app_type 字段)
|
||||
Self::migrate_skills_table(conn)?;
|
||||
|
||||
// 重构 proxy_config 为三行结构(每应用独立配置)
|
||||
Self::migrate_proxy_config_to_per_app(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 proxy_config 迁移为三行结构(每应用独立配置)
|
||||
fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {
|
||||
// 检查是否已经是新表结构(幂等性)
|
||||
if !Self::table_exists(conn, "proxy_config")? {
|
||||
// 表不存在,跳过迁移(新安装)
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if Self::has_column(conn, "proxy_config", "app_type")? {
|
||||
// 已经是三行结构,跳过迁移
|
||||
log::info!("proxy_config 已经是三行结构,跳过迁移");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 读取旧配置
|
||||
let old_config = conn
|
||||
.query_row(
|
||||
"SELECT listen_address, listen_port, max_retries, enable_logging,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||||
FROM proxy_config WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i32>(1)?,
|
||||
row.get::<_, i32>(2)?,
|
||||
row.get::<_, i32>(3)?,
|
||||
row.get::<_, i32>(4).unwrap_or(30),
|
||||
row.get::<_, i32>(5).unwrap_or(60),
|
||||
row.get::<_, i32>(6).unwrap_or(300),
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300));
|
||||
|
||||
let old_cb = conn.query_row(
|
||||
"SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests
|
||||
FROM circuit_breaker_config WHERE id = 1", [],
|
||||
|row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,
|
||||
row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))
|
||||
).unwrap_or((5, 2, 60, 0.5, 10));
|
||||
|
||||
let get_bool = |key: &str| -> bool {
|
||||
conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
let apps = [
|
||||
(
|
||||
"claude",
|
||||
get_bool("proxy_takeover_claude"),
|
||||
get_bool("auto_failover_enabled_claude"),
|
||||
6,
|
||||
45,
|
||||
90,
|
||||
8,
|
||||
3,
|
||||
90,
|
||||
0.6,
|
||||
15,
|
||||
),
|
||||
(
|
||||
"codex",
|
||||
get_bool("proxy_takeover_codex"),
|
||||
get_bool("auto_failover_enabled_codex"),
|
||||
3,
|
||||
old_config.4,
|
||||
old_config.5,
|
||||
old_cb.0,
|
||||
old_cb.1,
|
||||
old_cb.2,
|
||||
old_cb.3,
|
||||
old_cb.4,
|
||||
),
|
||||
(
|
||||
"gemini",
|
||||
get_bool("proxy_takeover_gemini"),
|
||||
get_bool("auto_failover_enabled_gemini"),
|
||||
5,
|
||||
old_config.4,
|
||||
old_config.5,
|
||||
old_cb.0,
|
||||
old_cb.1,
|
||||
old_cb.2,
|
||||
old_cb.3,
|
||||
old_cb.4,
|
||||
),
|
||||
];
|
||||
|
||||
// 创建新表
|
||||
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
|
||||
conn.execute("CREATE TABLE proxy_config_new (
|
||||
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", [])?;
|
||||
|
||||
// 插入三行配置
|
||||
for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,
|
||||
enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,
|
||||
non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
rusqlite::params![app, old_config.0, old_config.1, old_config.3,
|
||||
if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },
|
||||
retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]
|
||||
).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?;
|
||||
}
|
||||
|
||||
// 替换表并清理
|
||||
conn.execute("DROP TABLE IF EXISTS proxy_config", [])?;
|
||||
conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?;
|
||||
conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?;
|
||||
conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?;
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'",
|
||||
[],
|
||||
)?;
|
||||
|
||||
log::info!("proxy_config 已迁移为三行结构");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+29
-16
@@ -597,7 +597,7 @@ pub fn run() {
|
||||
commands::upsert_mcp_server_in_config,
|
||||
commands::delete_mcp_server_in_config,
|
||||
commands::set_mcp_enabled,
|
||||
// v3.7.0: Unified MCP management
|
||||
// Unified MCP management
|
||||
commands::get_mcp_servers,
|
||||
commands::upsert_mcp_server,
|
||||
commands::delete_mcp_server,
|
||||
@@ -657,6 +657,11 @@ pub fn run() {
|
||||
commands::get_proxy_status,
|
||||
commands::get_proxy_config,
|
||||
commands::update_proxy_config,
|
||||
// Global & Per-App Config
|
||||
commands::get_global_proxy_config,
|
||||
commands::update_global_proxy_config,
|
||||
commands::get_proxy_config_for_app,
|
||||
commands::update_proxy_config_for_app,
|
||||
commands::is_proxy_running,
|
||||
commands::is_live_takeover_active,
|
||||
commands::switch_proxy_provider,
|
||||
@@ -692,6 +697,12 @@ pub fn run() {
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
commands::open_provider_terminal,
|
||||
// Universal Provider management
|
||||
commands::get_universal_providers,
|
||||
commands::get_universal_provider,
|
||||
commands::upsert_universal_provider,
|
||||
commands::delete_universal_provider,
|
||||
commands::sync_universal_provider,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
@@ -848,22 +859,20 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||
// 启动时恢复代理状态
|
||||
// ============================================================
|
||||
|
||||
/// 启动时根据 settings 表中的代理状态自动恢复代理服务
|
||||
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务
|
||||
///
|
||||
/// 检查 `proxy_takeover_claude`、`proxy_takeover_codex`、`proxy_takeover_gemini` 的值,
|
||||
/// 如果有任一应用的状态为 `true`,则自动启动代理服务并接管对应应用的 Live 配置。
|
||||
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
|
||||
/// 则自动启动代理服务并接管对应应用的 Live 配置。
|
||||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
// 收集需要恢复接管的应用列表
|
||||
let apps_to_restore: Vec<&str> = ["claude", "codex", "gemini"]
|
||||
.iter()
|
||||
.filter(|app_type| {
|
||||
state
|
||||
.db
|
||||
.get_proxy_takeover_enabled(app_type)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||
let mut apps_to_restore = Vec::new();
|
||||
for app_type in ["claude", "codex", "gemini"] {
|
||||
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
|
||||
if config.enabled {
|
||||
apps_to_restore.push(app_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if apps_to_restore.is_empty() {
|
||||
log::debug!("启动时无需恢复代理状态");
|
||||
@@ -885,7 +894,11 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
Err(e) => {
|
||||
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
|
||||
// 失败时清除该应用的状态,避免下次启动再次尝试
|
||||
if let Err(clear_err) = state.db.set_proxy_takeover_enabled(app_type, false) {
|
||||
if let Err(clear_err) = state
|
||||
.proxy_service
|
||||
.set_takeover_for_app(app_type, false)
|
||||
.await
|
||||
{
|
||||
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,9 +359,14 @@ pub fn sync_single_server_to_codex(
|
||||
let mut doc = if config_path.exists() {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
||||
// 尝试解析现有配置,如果失败则创建新文档(容错处理)
|
||||
match content.parse::<toml_edit::DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
|
||||
toml_edit::DocumentMut::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toml_edit::DocumentMut::new()
|
||||
};
|
||||
@@ -409,9 +414,14 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
let mut doc = content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
|
||||
// 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容)
|
||||
let mut doc = match content.parse::<toml_edit::DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// 从正确的位置删除:[mcp_servers]
|
||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||
|
||||
@@ -173,3 +173,286 @@ impl ProviderManager {
|
||||
&self.providers
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 统一供应商(Universal Provider)- 跨应用共享配置
|
||||
// ============================================================================
|
||||
|
||||
/// 统一供应商的应用启用状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UniversalProviderApps {
|
||||
#[serde(default)]
|
||||
pub claude: bool,
|
||||
#[serde(default)]
|
||||
pub codex: bool,
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
}
|
||||
|
||||
/// Claude 模型配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ClaudeModelConfig {
|
||||
/// 主模型
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Haiku 默认模型
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "haikuModel")]
|
||||
pub haiku_model: Option<String>,
|
||||
/// Sonnet 默认模型
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "sonnetModel")]
|
||||
pub sonnet_model: Option<String>,
|
||||
/// Opus 默认模型
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "opusModel")]
|
||||
pub opus_model: Option<String>,
|
||||
}
|
||||
|
||||
/// Codex 模型配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CodexModelConfig {
|
||||
/// 模型名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 推理强度
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "reasoningEffort")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
/// Gemini 模型配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct GeminiModelConfig {
|
||||
/// 模型名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// 各应用的模型配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UniversalProviderModels {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub claude: Option<ClaudeModelConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub codex: Option<CodexModelConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gemini: Option<GeminiModelConfig>,
|
||||
}
|
||||
|
||||
/// 统一供应商(跨应用共享配置)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UniversalProvider {
|
||||
/// 唯一标识
|
||||
pub id: String,
|
||||
/// 供应商名称
|
||||
pub name: String,
|
||||
/// 供应商类型(如 "newapi", "custom")
|
||||
#[serde(rename = "providerType")]
|
||||
pub provider_type: String,
|
||||
/// 应用启用状态
|
||||
pub apps: UniversalProviderApps,
|
||||
/// API 基础地址
|
||||
#[serde(rename = "baseUrl")]
|
||||
pub base_url: String,
|
||||
/// API 密钥
|
||||
#[serde(rename = "apiKey")]
|
||||
pub api_key: String,
|
||||
/// 各应用的模型配置
|
||||
#[serde(default)]
|
||||
pub models: UniversalProviderModels,
|
||||
/// 网站链接
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "websiteUrl")]
|
||||
pub website_url: Option<String>,
|
||||
/// 备注信息
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
/// 图标名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// 图标颜色
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "iconColor")]
|
||||
pub icon_color: Option<String>,
|
||||
/// 元数据
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<ProviderMeta>,
|
||||
/// 创建时间戳
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: Option<i64>,
|
||||
/// 排序索引
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "sortIndex")]
|
||||
pub sort_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl UniversalProvider {
|
||||
/// 创建新的统一供应商
|
||||
pub fn new(
|
||||
id: String,
|
||||
name: String,
|
||||
provider_type: String,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
provider_type,
|
||||
apps: UniversalProviderApps::default(),
|
||||
base_url,
|
||||
api_key,
|
||||
models: UniversalProviderModels::default(),
|
||||
website_url: None,
|
||||
notes: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
meta: None,
|
||||
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
||||
sort_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 Claude 供应商配置
|
||||
pub fn to_claude_provider(&self) -> Option<Provider> {
|
||||
if !self.apps.claude {
|
||||
return None;
|
||||
}
|
||||
|
||||
let models = self.models.claude.as_ref();
|
||||
let model = models
|
||||
.and_then(|m| m.model.clone())
|
||||
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
|
||||
let haiku = models
|
||||
.and_then(|m| m.haiku_model.clone())
|
||||
.unwrap_or_else(|| model.clone());
|
||||
let sonnet = models
|
||||
.and_then(|m| m.sonnet_model.clone())
|
||||
.unwrap_or_else(|| model.clone());
|
||||
let opus = models
|
||||
.and_then(|m| m.opus_model.clone())
|
||||
.unwrap_or_else(|| model.clone());
|
||||
|
||||
let settings_config = serde_json::json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": self.base_url,
|
||||
"ANTHROPIC_AUTH_TOKEN": self.api_key,
|
||||
"ANTHROPIC_MODEL": model,
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku,
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": sonnet,
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": opus,
|
||||
}
|
||||
});
|
||||
|
||||
Some(Provider {
|
||||
id: format!("universal-claude-{}", self.id),
|
||||
name: self.name.clone(),
|
||||
settings_config,
|
||||
website_url: self.website_url.clone(),
|
||||
category: Some("aggregator".to_string()),
|
||||
created_at: self.created_at,
|
||||
sort_index: self.sort_index,
|
||||
notes: self.notes.clone(),
|
||||
meta: self.meta.clone(),
|
||||
icon: self.icon.clone(),
|
||||
icon_color: self.icon_color.clone(),
|
||||
in_failover_queue: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成 Codex 供应商配置
|
||||
pub fn to_codex_provider(&self) -> Option<Provider> {
|
||||
if !self.apps.codex {
|
||||
return None;
|
||||
}
|
||||
|
||||
let models = self.models.codex.as_ref();
|
||||
let model = models
|
||||
.and_then(|m| m.model.clone())
|
||||
.unwrap_or_else(|| "gpt-4o".to_string());
|
||||
let reasoning_effort = models
|
||||
.and_then(|m| m.reasoning_effort.clone())
|
||||
.unwrap_or_else(|| "high".to_string());
|
||||
|
||||
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API)
|
||||
let codex_base_url = if self.base_url.ends_with("/v1") {
|
||||
self.base_url.clone()
|
||||
} else {
|
||||
format!("{}/v1", self.base_url.trim_end_matches('/'))
|
||||
};
|
||||
|
||||
// 生成 Codex 的 config.toml 内容
|
||||
let config_toml = format!(
|
||||
r#"model_provider = "newapi"
|
||||
model = "{}"
|
||||
model_reasoning_effort = "{}"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.newapi]
|
||||
name = "NewAPI"
|
||||
base_url = "{}"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true"#,
|
||||
model, reasoning_effort, codex_base_url
|
||||
);
|
||||
|
||||
let settings_config = serde_json::json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": self.api_key
|
||||
},
|
||||
"config": config_toml
|
||||
});
|
||||
|
||||
Some(Provider {
|
||||
id: format!("universal-codex-{}", self.id),
|
||||
name: self.name.clone(),
|
||||
settings_config,
|
||||
website_url: self.website_url.clone(),
|
||||
category: Some("aggregator".to_string()),
|
||||
created_at: self.created_at,
|
||||
sort_index: self.sort_index,
|
||||
notes: self.notes.clone(),
|
||||
meta: self.meta.clone(),
|
||||
icon: self.icon.clone(),
|
||||
icon_color: self.icon_color.clone(),
|
||||
in_failover_queue: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成 Gemini 供应商配置
|
||||
pub fn to_gemini_provider(&self) -> Option<Provider> {
|
||||
if !self.apps.gemini {
|
||||
return None;
|
||||
}
|
||||
|
||||
let models = self.models.gemini.as_ref();
|
||||
let model = models
|
||||
.and_then(|m| m.model.clone())
|
||||
.unwrap_or_else(|| "gemini-2.5-pro".to_string());
|
||||
|
||||
let settings_config = serde_json::json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_BASE_URL": self.base_url,
|
||||
"GEMINI_API_KEY": self.api_key,
|
||||
"GEMINI_MODEL": model,
|
||||
}
|
||||
});
|
||||
|
||||
Some(Provider {
|
||||
id: format!("universal-gemini-{}", self.id),
|
||||
name: self.name.clone(),
|
||||
settings_config,
|
||||
website_url: self.website_url.clone(),
|
||||
category: Some("aggregator".to_string()),
|
||||
created_at: self.created_at,
|
||||
sort_index: self.sort_index,
|
||||
notes: self.notes.clone(),
|
||||
meta: self.meta.clone(),
|
||||
icon: self.icon.clone(),
|
||||
icon_color: self.icon_color.clone(),
|
||||
in_failover_queue: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub struct RequestForwarder {
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
/// AppHandle,用于发射事件和更新托盘
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 请求开始时的“当前供应商 ID”(用于判断是否需要同步 UI/托盘)
|
||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||
current_provider_id_at_start: String,
|
||||
}
|
||||
|
||||
@@ -47,17 +47,27 @@ impl RequestForwarder {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
router: Arc<ProviderRouter>,
|
||||
timeout_secs: u64,
|
||||
non_streaming_timeout: u64,
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
current_provider_id_at_start: String,
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
) -> Self {
|
||||
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
|
||||
// 参考 Claude Code Hub 的 undici 全局超时设计
|
||||
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
let mut client_builder = Client::builder();
|
||||
if timeout_secs > 0 {
|
||||
client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
|
||||
if non_streaming_timeout > 0 {
|
||||
// 使用配置的非流式超时
|
||||
client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout));
|
||||
} else {
|
||||
// 禁用超时时使用全局超时作为保底
|
||||
client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS));
|
||||
}
|
||||
|
||||
let client = client_builder
|
||||
@@ -166,14 +176,24 @@ impl RequestForwarder {
|
||||
let mut last_provider = None;
|
||||
let mut attempted_providers = 0usize;
|
||||
|
||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||
let bypass_circuit_breaker = providers.len() == 1;
|
||||
|
||||
// 依次尝试每个供应商
|
||||
for provider in providers.iter() {
|
||||
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
|
||||
let permit = self
|
||||
.router
|
||||
.allow_provider_request(&provider.id, app_type_str)
|
||||
.await;
|
||||
if !permit.allowed {
|
||||
// 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求
|
||||
let (allowed, used_half_open_permit) = if bypass_circuit_breaker {
|
||||
(true, false)
|
||||
} else {
|
||||
let permit = self
|
||||
.router
|
||||
.allow_provider_request(&provider.id, app_type_str)
|
||||
.await;
|
||||
(permit.allowed, permit.used_half_open_permit)
|
||||
};
|
||||
|
||||
if !allowed {
|
||||
log::debug!(
|
||||
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
|
||||
app_type_str,
|
||||
@@ -182,8 +202,6 @@ impl RequestForwarder {
|
||||
continue;
|
||||
}
|
||||
|
||||
let used_half_open_permit = permit.used_half_open_permit;
|
||||
|
||||
attempted_providers += 1;
|
||||
|
||||
log::info!(
|
||||
@@ -412,12 +430,19 @@ impl RequestForwarder {
|
||||
let base_url = adapter.extract_base_url(provider)?;
|
||||
log::info!("[{}] base_url: {}", adapter.name(), base_url);
|
||||
|
||||
// 使用适配器构建 URL
|
||||
let url = adapter.build_url(&base_url, endpoint);
|
||||
|
||||
// 检查是否需要格式转换
|
||||
let needs_transform = adapter.needs_transform(provider);
|
||||
|
||||
let effective_endpoint =
|
||||
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
|
||||
"/v1/chat/completions"
|
||||
} else {
|
||||
endpoint
|
||||
};
|
||||
|
||||
// 使用适配器构建 URL
|
||||
let url = adapter.build_url(&base_url, effective_endpoint);
|
||||
|
||||
// 记录原始请求 JSON
|
||||
log::info!(
|
||||
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
|
||||
@@ -425,10 +450,23 @@ impl RequestForwarder {
|
||||
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
|
||||
);
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
if let Some(ref mapped) = mapped_model {
|
||||
log::info!(
|
||||
"[{}] >>> 模型映射后的请求 JSON:\n{}",
|
||||
adapter.name(),
|
||||
serde_json::to_string_pretty(&mapped_body).unwrap_or_default()
|
||||
);
|
||||
log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped);
|
||||
}
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if needs_transform {
|
||||
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
|
||||
let transformed = adapter.transform_request(body.clone(), provider)?;
|
||||
let transformed = adapter.transform_request(mapped_body, provider)?;
|
||||
log::info!(
|
||||
"[{}] >>> 转换后的请求 JSON:\n{}",
|
||||
adapter.name(),
|
||||
@@ -436,7 +474,7 @@ impl RequestForwarder {
|
||||
);
|
||||
transformed
|
||||
} else {
|
||||
body.clone()
|
||||
mapped_body
|
||||
};
|
||||
|
||||
log::info!(
|
||||
|
||||
@@ -31,13 +31,26 @@ pub struct UsageParserConfig {
|
||||
// 模型提取器实现
|
||||
// ============================================================================
|
||||
|
||||
/// Claude 流式响应模型提取(直接使用请求模型)
|
||||
fn claude_model_extractor(_events: &[Value], request_model: &str) -> String {
|
||||
/// Claude 流式响应模型提取(优先使用 usage.model)
|
||||
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
|
||||
if let Some(model) = usage.model {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
request_model.to_string()
|
||||
}
|
||||
|
||||
/// OpenAI Chat Completions 流式响应模型提取
|
||||
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model)
|
||||
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
|
||||
if let Some(model) = usage.model {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
// 回退:从事件中直接提取
|
||||
events
|
||||
.iter()
|
||||
.find_map(|e| e.get("model")?.as_str())
|
||||
@@ -45,8 +58,15 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Codex Responses API 流式响应模型提取
|
||||
/// Codex Responses API 流式响应模型提取(优先使用 usage.model)
|
||||
fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_codex_stream_events(events) {
|
||||
if let Some(model) = usage.model {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
// 回退:从 response.completed 事件中提取
|
||||
events
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
|
||||
@@ -5,23 +5,32 @@
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::{
|
||||
forwarder::RequestForwarder, server::ProxyState, types::ProxyConfig, ProxyError,
|
||||
forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
/// 流式超时配置
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StreamingTimeoutConfig {
|
||||
/// 首字节超时(秒),0 表示禁用
|
||||
pub first_byte_timeout: u64,
|
||||
/// 静默期超时(秒),0 表示禁用
|
||||
pub idle_timeout: u64,
|
||||
}
|
||||
|
||||
/// 请求上下文
|
||||
///
|
||||
/// 贯穿整个请求生命周期,包含:
|
||||
/// - 计时信息
|
||||
/// - 代理配置
|
||||
/// - 应用级代理配置(per-app)
|
||||
/// - 选中的 Provider 列表(用于故障转移)
|
||||
/// - 请求模型名称
|
||||
/// - 日志标签
|
||||
pub struct RequestContext {
|
||||
/// 请求开始时间
|
||||
pub start_time: Instant,
|
||||
/// 代理配置快照
|
||||
pub config: ProxyConfig,
|
||||
/// 应用级代理配置(per-app,包含重试次数和超时配置)
|
||||
pub app_config: AppProxyConfig,
|
||||
/// 选中的 Provider(故障转移链的第一个)
|
||||
pub provider: Provider,
|
||||
/// 完整的 Provider 列表(用于故障转移)
|
||||
@@ -62,7 +71,14 @@ impl RequestContext {
|
||||
app_type_str: &'static str,
|
||||
) -> Result<Self, ProxyError> {
|
||||
let start_time = Instant::now();
|
||||
let config = state.config.read().await.clone();
|
||||
|
||||
// 从数据库读取应用级代理配置(per-app)
|
||||
let app_config = state
|
||||
.db
|
||||
.get_proxy_config_for_app(app_type_str)
|
||||
.await
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
|
||||
@@ -96,7 +112,7 @@ impl RequestContext {
|
||||
|
||||
Ok(Self {
|
||||
start_time,
|
||||
config,
|
||||
app_config,
|
||||
provider,
|
||||
providers,
|
||||
current_provider_id,
|
||||
@@ -135,13 +151,15 @@ impl RequestContext {
|
||||
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
|
||||
RequestForwarder::new(
|
||||
state.provider_router.clone(),
|
||||
self.config.request_timeout,
|
||||
self.config.max_retries,
|
||||
self.app_config.non_streaming_timeout as u64,
|
||||
self.app_config.max_retries as u8,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
state.failover_manager.clone(),
|
||||
state.app_handle.clone(),
|
||||
self.current_provider_id.clone(),
|
||||
self.app_config.streaming_first_byte_timeout as u64,
|
||||
self.app_config.streaming_idle_timeout as u64,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,4 +175,13 @@ impl RequestContext {
|
||||
pub fn latency_ms(&self) -> u64 {
|
||||
self.start_time.elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// 获取流式超时配置
|
||||
#[inline]
|
||||
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
|
||||
StreamingTimeoutConfig {
|
||||
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
|
||||
idle_timeout: self.app_config.streaming_idle_timeout as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,10 +170,14 @@ async fn handle_claude_transform(
|
||||
})
|
||||
};
|
||||
|
||||
// 获取流式超时配置
|
||||
let timeout_config = ctx.streaming_timeout_config();
|
||||
|
||||
let logged_stream = create_logged_passthrough_stream(
|
||||
sse_stream,
|
||||
"Claude/OpenRouter",
|
||||
Some(usage_collector),
|
||||
timeout_config,
|
||||
);
|
||||
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod handler_config;
|
||||
pub mod handler_context;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
pub mod providers;
|
||||
pub mod response_handler;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! 模型映射模块
|
||||
//!
|
||||
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
|
||||
|
||||
use crate::provider::Provider;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 模型映射配置
|
||||
pub struct ModelMapping {
|
||||
pub haiku_model: Option<String>,
|
||||
pub sonnet_model: Option<String>,
|
||||
pub opus_model: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub reasoning_model: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelMapping {
|
||||
/// 从 Provider 配置中提取模型映射
|
||||
pub fn from_provider(provider: &Provider) -> Self {
|
||||
let env = provider.settings_config.get("env");
|
||||
|
||||
Self {
|
||||
haiku_model: env
|
||||
.and_then(|e| e.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
sonnet_model: env
|
||||
.and_then(|e| e.get("ANTHROPIC_DEFAULT_SONNET_MODEL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
opus_model: env
|
||||
.and_then(|e| e.get("ANTHROPIC_DEFAULT_OPUS_MODEL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
default_model: env
|
||||
.and_then(|e| e.get("ANTHROPIC_MODEL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
reasoning_model: env
|
||||
.and_then(|e| e.get("ANTHROPIC_REASONING_MODEL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否配置了任何模型映射
|
||||
pub fn has_mapping(&self) -> bool {
|
||||
self.haiku_model.is_some()
|
||||
|| self.sonnet_model.is_some()
|
||||
|| self.opus_model.is_some()
|
||||
|| self.default_model.is_some()
|
||||
}
|
||||
|
||||
/// 根据原始模型名称获取映射后的模型
|
||||
pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {
|
||||
let model_lower = original_model.to_lowercase();
|
||||
|
||||
// 1. thinking 模式优先使用推理模型
|
||||
if has_thinking {
|
||||
if let Some(ref m) = self.reasoning_model {
|
||||
return m.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 按模型类型匹配
|
||||
if model_lower.contains("haiku") {
|
||||
if let Some(ref m) = self.haiku_model {
|
||||
return m.clone();
|
||||
}
|
||||
}
|
||||
if model_lower.contains("opus") {
|
||||
if let Some(ref m) = self.opus_model {
|
||||
return m.clone();
|
||||
}
|
||||
}
|
||||
if model_lower.contains("sonnet") {
|
||||
if let Some(ref m) = self.sonnet_model {
|
||||
return m.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 默认模型
|
||||
if let Some(ref m) = self.default_model {
|
||||
return m.clone();
|
||||
}
|
||||
|
||||
// 4. 无映射,保持原样
|
||||
original_model.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测请求是否启用了 thinking 模式
|
||||
pub fn has_thinking_enabled(body: &Value) -> bool {
|
||||
body.get("thinking")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|o| o.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled")
|
||||
}
|
||||
|
||||
/// 对请求体应用模型映射
|
||||
///
|
||||
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
|
||||
pub fn apply_model_mapping(
|
||||
mut body: Value,
|
||||
provider: &Provider,
|
||||
) -> (Value, Option<String>, Option<String>) {
|
||||
let mapping = ModelMapping::from_provider(provider);
|
||||
|
||||
// 如果没有配置映射,直接返回
|
||||
if !mapping.has_mapping() {
|
||||
let original = body.get("model").and_then(|m| m.as_str()).map(String::from);
|
||||
return (body, original, None);
|
||||
}
|
||||
|
||||
// 提取原始模型名
|
||||
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
|
||||
|
||||
if let Some(ref original) = original_model {
|
||||
let has_thinking = has_thinking_enabled(&body);
|
||||
let mapped = mapping.map_model(original, has_thinking);
|
||||
|
||||
if mapped != *original {
|
||||
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
|
||||
body["model"] = serde_json::json!(mapped);
|
||||
return (body, Some(original.clone()), Some(mapped));
|
||||
}
|
||||
}
|
||||
|
||||
(body, original_model, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn create_provider_with_mapping() -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
settings_config: json!({
|
||||
"env": {
|
||||
"ANTHROPIC_MODEL": "default-model",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
|
||||
"ANTHROPIC_REASONING_MODEL": "reasoning-model"
|
||||
}
|
||||
}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_provider_without_mapping() -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
settings_config: json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sonnet_mapping() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({"model": "claude-sonnet-4-5-20250929"});
|
||||
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(original, Some("claude-sonnet-4-5-20250929".to_string()));
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_haiku_mapping() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({"model": "claude-haiku-4-5"});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "haiku-mapped");
|
||||
assert_eq!(mapped, Some("haiku-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_opus_mapping() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({"model": "claude-opus-4-5"});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "opus-mapped");
|
||||
assert_eq!(mapped, Some("opus-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_mode() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "enabled"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "reasoning-model");
|
||||
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_disabled() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "disabled"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_model_uses_default() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({"model": "some-unknown-model"});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "default-model");
|
||||
assert_eq!(mapped, Some("default-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_mapping_configured() {
|
||||
let provider = create_provider_without_mapping();
|
||||
let body = json!({"model": "claude-sonnet-4-5"});
|
||||
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
|
||||
assert!(mapped.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({"model": "Claude-SONNET-4-5"});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -35,25 +35,16 @@ impl ProviderRouter {
|
||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
// 检查该应用的自动故障转移开关是否开启
|
||||
let failover_key = format!("auto_failover_enabled_{app_type}");
|
||||
let auto_failover_enabled = match self.db.get_setting(&failover_key) {
|
||||
Ok(Some(value)) => {
|
||||
let enabled = value == "true";
|
||||
log::info!(
|
||||
"[{app_type}] Failover setting '{failover_key}' = '{value}', enabled: {enabled}"
|
||||
);
|
||||
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
|
||||
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(config) => {
|
||||
let enabled = config.auto_failover_enabled;
|
||||
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
|
||||
enabled
|
||||
}
|
||||
Ok(None) => {
|
||||
log::warn!(
|
||||
"[{app_type}] Failover setting '{failover_key}' not found in database, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled"
|
||||
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
@@ -91,29 +82,19 @@ impl ProviderRouter {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 故障转移关闭:仅使用当前供应商
|
||||
log::info!("[{app_type}] Failover disabled, using current provider only");
|
||||
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
|
||||
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
|
||||
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
|
||||
|
||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||
let circuit_key = format!("{}:{}", app_type, current.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
log::info!(
|
||||
"[{}] Current provider available: {} ({})",
|
||||
app_type,
|
||||
current.name,
|
||||
current.id
|
||||
);
|
||||
result.push(current);
|
||||
} else {
|
||||
log::warn!(
|
||||
"[{}] Current provider {} circuit breaker open",
|
||||
app_type,
|
||||
current.name
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"[{}] Current provider: {} ({})",
|
||||
app_type,
|
||||
current.name,
|
||||
current.id
|
||||
);
|
||||
result.push(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,9 +137,16 @@ impl ProviderRouter {
|
||||
success: bool,
|
||||
error_msg: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
// 1. 获取熔断器配置(用于更新健康状态和判断是否禁用)
|
||||
let config = self.db.get_circuit_breaker_config().await.ok();
|
||||
let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5);
|
||||
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
|
||||
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(app_config) => app_config.circuit_failure_threshold,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to load circuit config for {app_type}, using default threshold: {e}"
|
||||
);
|
||||
5 // 默认值
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 更新熔断器状态
|
||||
let circuit_key = format!("{app_type}:{provider_id}");
|
||||
@@ -255,12 +243,34 @@ impl ProviderRouter {
|
||||
return breaker.clone();
|
||||
}
|
||||
|
||||
// 从数据库加载配置
|
||||
let config = self
|
||||
.db
|
||||
.get_circuit_breaker_config()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// 从 key 中提取 app_type (格式: "app_type:provider_id")
|
||||
let app_type = key.split(':').next().unwrap_or("claude");
|
||||
|
||||
// 按应用独立读取熔断器配置
|
||||
let config = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(app_config) => {
|
||||
log::debug!(
|
||||
"Loading circuit breaker config for {key} (app={app_type}): \
|
||||
failure_threshold={}, success_threshold={}, timeout={}s",
|
||||
app_config.circuit_failure_threshold,
|
||||
app_config.circuit_success_threshold,
|
||||
app_config.circuit_timeout_seconds
|
||||
);
|
||||
crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||
failure_threshold: app_config.circuit_failure_threshold,
|
||||
success_threshold: app_config.circuit_success_threshold,
|
||||
timeout_seconds: app_config.circuit_timeout_seconds as u64,
|
||||
error_rate_threshold: app_config.circuit_error_rate_threshold,
|
||||
min_requests: app_config.circuit_min_requests,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
|
||||
);
|
||||
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
|
||||
|
||||
@@ -325,8 +335,11 @@ mod tests {
|
||||
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
db.set_setting("auto_failover_enabled_claude", "true")
|
||||
.unwrap();
|
||||
|
||||
// 启用自动故障转移(使用新的 proxy_config API)
|
||||
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||
config.auto_failover_enabled = true;
|
||||
db.update_proxy_config_for_app(config).await.unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
let providers = router.select_providers("claude").await.unwrap();
|
||||
@@ -359,8 +372,11 @@ mod tests {
|
||||
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
db.set_setting("auto_failover_enabled_claude", "true")
|
||||
.unwrap();
|
||||
|
||||
// 启用自动故障转移(使用新的 proxy_config API)
|
||||
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||
config.auto_failover_enabled = true;
|
||||
db.update_proxy_config_for_app(config).await.unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
|
||||
|
||||
@@ -48,6 +48,24 @@ impl ClaudeAdapter {
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测 OpenRouter 是否启用兼容模式
|
||||
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
|
||||
if !self.is_openrouter(provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
match raw {
|
||||
Some(serde_json::Value::Bool(enabled)) => *enabled,
|
||||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||
Some(serde_json::Value::String(value)) => {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测是否为仅 Bearer 认证模式
|
||||
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
||||
// 检查 settings_config 中的 auth_mode
|
||||
@@ -197,11 +215,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||||
//
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可恢复下面这段分支:
|
||||
//
|
||||
// if base_url.contains("openrouter.ai") {
|
||||
// return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
// }
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
format!(
|
||||
"{}/{}",
|
||||
@@ -235,8 +249,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// Anthropic ↔ OpenAI 的格式转换。
|
||||
//
|
||||
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
||||
// self.is_openrouter(_provider)
|
||||
false
|
||||
self.is_openrouter_compat_enabled(_provider)
|
||||
}
|
||||
|
||||
fn transform_request(
|
||||
@@ -430,6 +443,14 @@ mod tests {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
}
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&openrouter_provider));
|
||||
assert!(adapter.needs_transform(&openrouter_provider));
|
||||
|
||||
let openrouter_disabled = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
},
|
||||
"openrouter_compat_mode": false
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&openrouter_disabled));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
//! 统一处理流式和非流式 API 响应
|
||||
|
||||
use super::{
|
||||
handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState,
|
||||
usage::parser::TokenUsage, ProxyError,
|
||||
handler_config::UsageParserConfig,
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
server::ProxyState,
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use axum::response::Response;
|
||||
use bytes::Bytes;
|
||||
@@ -17,6 +20,7 @@ use std::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -60,8 +64,12 @@ pub async fn handle_streaming(
|
||||
// 创建使用量收集器
|
||||
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
|
||||
|
||||
// 创建带日志的透传流
|
||||
let logged_stream = create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector));
|
||||
// 获取流式超时配置
|
||||
let timeout_config = ctx.streaming_timeout_config();
|
||||
|
||||
// 创建带日志和超时的透传流
|
||||
let logged_stream =
|
||||
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
builder.body(body).unwrap()
|
||||
@@ -93,12 +101,16 @@ pub async fn handle_non_streaming(
|
||||
|
||||
// 解析使用量
|
||||
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
||||
let model = json_value
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or(&ctx.request_model);
|
||||
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
||||
let model = if let Some(ref m) = usage.model {
|
||||
m.clone()
|
||||
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
|
||||
m.to_string()
|
||||
} else {
|
||||
ctx.request_model.clone()
|
||||
};
|
||||
|
||||
spawn_log_usage(state, ctx, usage, model, status.as_u16(), false);
|
||||
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[{}] 未能解析 usage 信息,跳过记录",
|
||||
@@ -344,21 +356,60 @@ async fn log_usage_internal(
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带日志记录的透传流
|
||||
/// 创建带日志记录和超时控制的透传流
|
||||
pub fn create_logged_passthrough_stream(
|
||||
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
|
||||
tag: &'static str,
|
||||
usage_collector: Option<SseUsageCollector>,
|
||||
timeout_config: StreamingTimeoutConfig,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut collector = usage_collector;
|
||||
let mut is_first_chunk = true;
|
||||
|
||||
// 超时配置
|
||||
let first_byte_timeout = if timeout_config.first_byte_timeout > 0 {
|
||||
Some(Duration::from_secs(timeout_config.first_byte_timeout))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let idle_timeout = if timeout_config.idle_timeout > 0 {
|
||||
Some(Duration::from_secs(timeout_config.idle_timeout))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
loop {
|
||||
// 选择超时时间:首字节超时或静默期超时
|
||||
let timeout_duration = if is_first_chunk {
|
||||
first_byte_timeout
|
||||
} else {
|
||||
idle_timeout
|
||||
};
|
||||
|
||||
let chunk_result = match timeout_duration {
|
||||
Some(duration) => {
|
||||
match tokio::time::timeout(duration, stream.next()).await {
|
||||
Ok(Some(chunk)) => Some(chunk),
|
||||
Ok(None) => None, // 流结束
|
||||
Err(_) => {
|
||||
// 超时
|
||||
let timeout_type = if is_first_chunk { "首字节" } else { "静默期" };
|
||||
log::error!("[{tag}] 流式响应{}超时 ({}秒)", timeout_type, duration.as_secs());
|
||||
yield Err(std::io::Error::other(format!("流式响应{timeout_type}超时")));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => stream.next().await, // 无超时限制
|
||||
};
|
||||
|
||||
match chunk_result {
|
||||
Some(Ok(bytes)) => {
|
||||
is_first_chunk = false;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
|
||||
@@ -394,11 +445,15 @@ pub fn create_logged_passthrough_stream(
|
||||
|
||||
yield Ok(bytes);
|
||||
}
|
||||
Err(e) => {
|
||||
Some(Err(e)) => {
|
||||
log::error!("[{tag}] 流错误: {e}");
|
||||
yield Err(std::io::Error::other(e.to_string()));
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
// 流正常结束
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,34 @@ pub struct ProxyConfig {
|
||||
pub listen_port: u16,
|
||||
/// 最大重试次数
|
||||
pub max_retries: u8,
|
||||
/// 请求超时时间(秒)
|
||||
/// 请求超时时间(秒)- 已废弃,保留兼容
|
||||
pub request_timeout: u64,
|
||||
/// 是否启用日志
|
||||
pub enable_logging: bool,
|
||||
/// 是否正在接管 Live 配置
|
||||
#[serde(default)]
|
||||
pub live_takeover_active: bool,
|
||||
/// 流式首字超时(秒)- 等待首个数据块的最大时间
|
||||
#[serde(default = "default_streaming_first_byte_timeout")]
|
||||
pub streaming_first_byte_timeout: u64,
|
||||
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
|
||||
#[serde(default = "default_streaming_idle_timeout")]
|
||||
pub streaming_idle_timeout: u64,
|
||||
/// 非流式总超时(秒)- 非流式请求的总超时时间
|
||||
#[serde(default = "default_non_streaming_timeout")]
|
||||
pub non_streaming_timeout: u64,
|
||||
}
|
||||
|
||||
fn default_streaming_first_byte_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_streaming_idle_timeout() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_non_streaming_timeout() -> u64 {
|
||||
600
|
||||
}
|
||||
|
||||
impl Default for ProxyConfig {
|
||||
@@ -27,6 +48,9 @@ impl Default for ProxyConfig {
|
||||
request_timeout: 300,
|
||||
enable_logging: true,
|
||||
live_takeover_active: false,
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 600,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,3 +147,47 @@ pub struct LiveBackup {
|
||||
/// 备份时间
|
||||
pub backed_up_at: String,
|
||||
}
|
||||
|
||||
/// 全局代理配置(统一字段,三行镜像)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GlobalProxyConfig {
|
||||
/// 代理总开关
|
||||
pub proxy_enabled: bool,
|
||||
/// 监听地址
|
||||
pub listen_address: String,
|
||||
/// 监听端口
|
||||
pub listen_port: u16,
|
||||
/// 是否启用日志
|
||||
pub enable_logging: bool,
|
||||
}
|
||||
|
||||
/// 应用级代理配置(每个 app 独立)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppProxyConfig {
|
||||
/// 应用类型 (claude/codex/gemini)
|
||||
pub app_type: String,
|
||||
/// 该 app 代理启用开关
|
||||
pub enabled: bool,
|
||||
/// 该 app 自动故障转移开关
|
||||
pub auto_failover_enabled: bool,
|
||||
/// 最大重试次数
|
||||
pub max_retries: u32,
|
||||
/// 流式首字超时(秒)
|
||||
pub streaming_first_byte_timeout: u32,
|
||||
/// 流式静默超时(秒)
|
||||
pub streaming_idle_timeout: u32,
|
||||
/// 非流式总超时(秒)
|
||||
pub non_streaming_timeout: u32,
|
||||
/// 熔断失败阈值
|
||||
pub circuit_failure_threshold: u32,
|
||||
/// 熔断恢复阈值
|
||||
pub circuit_success_threshold: u32,
|
||||
/// 熔断恢复等待时间(秒)
|
||||
pub circuit_timeout_seconds: u32,
|
||||
/// 错误率阈值
|
||||
pub circuit_error_rate_threshold: f64,
|
||||
/// 计算错误率的最小请求数
|
||||
pub circuit_min_requests: u32,
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ impl TokenUsage {
|
||||
/// 从 Claude API 非流式响应解析
|
||||
pub fn from_claude_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
// 提取响应中的模型名称
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(Self {
|
||||
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
|
||||
output_tokens: usage.get("output_tokens")?.as_u64()? as u32,
|
||||
@@ -45,7 +51,7 @@ impl TokenUsage {
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model: None,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,11 +59,20 @@ impl TokenUsage {
|
||||
#[allow(dead_code)]
|
||||
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
|
||||
let mut usage = Self::default();
|
||||
let mut model: Option<String> = None;
|
||||
|
||||
for event in events {
|
||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||
match event_type {
|
||||
"message_start" => {
|
||||
// 从 message_start 提取模型名称
|
||||
if model.is_none() {
|
||||
if let Some(message) = event.get("message") {
|
||||
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
|
||||
model = Some(m.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
|
||||
// 从 message_start 获取 input_tokens(原生 Claude API)
|
||||
if let Some(input) =
|
||||
@@ -102,6 +117,7 @@ impl TokenUsage {
|
||||
}
|
||||
|
||||
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
||||
usage.model = model;
|
||||
Some(usage)
|
||||
} else {
|
||||
None
|
||||
@@ -141,6 +157,12 @@ impl TokenUsage {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 提取响应中的模型名称
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(Self {
|
||||
input_tokens: input_tokens? as u32,
|
||||
output_tokens: output_tokens? as u32,
|
||||
@@ -152,7 +174,7 @@ impl TokenUsage {
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model: None,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -222,12 +244,18 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// 提取响应中的模型名称
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(Self {
|
||||
input_tokens: prompt_tokens as u32,
|
||||
output_tokens: completion_tokens as u32,
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -322,6 +350,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_claude_response_parsing() {
|
||||
let response = json!({
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
@@ -335,10 +364,60 @@ mod tests {
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 10);
|
||||
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_response_parsing_no_model() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 20,
|
||||
"cache_creation_input_tokens": 10
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_claude_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 10);
|
||||
assert_eq!(usage.model, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_stream_parsing() {
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"cache_read_input_tokens": 20,
|
||||
"cache_creation_input_tokens": 10
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"output_tokens": 50
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 10);
|
||||
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_stream_parsing_no_model() {
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "message_start",
|
||||
@@ -363,6 +442,7 @@ mod tests {
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 10);
|
||||
assert_eq!(usage.model, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -481,6 +561,7 @@ mod tests {
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
@@ -502,6 +583,7 @@ mod tests {
|
||||
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 150);
|
||||
assert_eq!(usage.output_tokens, 75);
|
||||
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -512,6 +594,7 @@ mod tests {
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"usage": {
|
||||
"input_tokens": 200,
|
||||
"cache_read_input_tokens": 50
|
||||
@@ -530,5 +613,6 @@ mod tests {
|
||||
assert_eq!(usage.input_tokens, 200);
|
||||
assert_eq!(usage.output_tokens, 100);
|
||||
assert_eq!(usage.cache_read_tokens, 50);
|
||||
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,9 @@ impl ConfigService {
|
||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
crate::mcp::sync_enabled_to_codex(config)?;
|
||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
||||
|
||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
||||
|
||||
@@ -690,3 +690,140 @@ pub struct ProviderSortUpdate {
|
||||
#[serde(rename = "sortIndex")]
|
||||
pub sort_index: usize,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 统一供应商(Universal Provider)服务方法
|
||||
// ============================================================================
|
||||
|
||||
use crate::provider::UniversalProvider;
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl ProviderService {
|
||||
/// 获取所有统一供应商
|
||||
pub fn list_universal(
|
||||
state: &AppState,
|
||||
) -> Result<HashMap<String, UniversalProvider>, AppError> {
|
||||
state.db.get_all_universal_providers()
|
||||
}
|
||||
|
||||
/// 获取单个统一供应商
|
||||
pub fn get_universal(
|
||||
state: &AppState,
|
||||
id: &str,
|
||||
) -> Result<Option<UniversalProvider>, AppError> {
|
||||
state.db.get_universal_provider(id)
|
||||
}
|
||||
|
||||
/// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps)
|
||||
pub fn upsert_universal(
|
||||
state: &AppState,
|
||||
provider: UniversalProvider,
|
||||
) -> Result<bool, AppError> {
|
||||
// 保存统一供应商
|
||||
state.db.save_universal_provider(&provider)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 删除统一供应商
|
||||
pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {
|
||||
// 获取统一供应商(用于删除生成的子供应商)
|
||||
let provider = state.db.get_universal_provider(id)?;
|
||||
|
||||
// 删除统一供应商
|
||||
state.db.delete_universal_provider(id)?;
|
||||
|
||||
// 删除生成的子供应商
|
||||
if let Some(p) = provider {
|
||||
if p.apps.claude {
|
||||
let claude_id = format!("universal-claude-{}", id);
|
||||
let _ = state.db.delete_provider("claude", &claude_id);
|
||||
}
|
||||
if p.apps.codex {
|
||||
let codex_id = format!("universal-codex-{}", id);
|
||||
let _ = state.db.delete_provider("codex", &codex_id);
|
||||
}
|
||||
if p.apps.gemini {
|
||||
let gemini_id = format!("universal-gemini-{}", id);
|
||||
let _ = state.db.delete_provider("gemini", &gemini_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 同步统一供应商到各应用
|
||||
pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {
|
||||
let provider = state
|
||||
.db
|
||||
.get_universal_provider(id)?
|
||||
.ok_or_else(|| AppError::Message(format!("统一供应商 {} 不存在", id)))?;
|
||||
|
||||
// 同步到 Claude
|
||||
if let Some(mut claude_provider) = provider.to_claude_provider() {
|
||||
// 合并已有配置
|
||||
if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? {
|
||||
let mut merged = existing.settings_config.clone();
|
||||
Self::merge_json(&mut merged, &claude_provider.settings_config);
|
||||
claude_provider.settings_config = merged;
|
||||
}
|
||||
state.db.save_provider("claude", &claude_provider)?;
|
||||
} else {
|
||||
// 如果禁用了 Claude,删除对应的子供应商
|
||||
let claude_id = format!("universal-claude-{}", id);
|
||||
let _ = state.db.delete_provider("claude", &claude_id);
|
||||
}
|
||||
|
||||
// 同步到 Codex
|
||||
if let Some(mut codex_provider) = provider.to_codex_provider() {
|
||||
// 合并已有配置
|
||||
if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? {
|
||||
let mut merged = existing.settings_config.clone();
|
||||
Self::merge_json(&mut merged, &codex_provider.settings_config);
|
||||
codex_provider.settings_config = merged;
|
||||
}
|
||||
state.db.save_provider("codex", &codex_provider)?;
|
||||
} else {
|
||||
let codex_id = format!("universal-codex-{}", id);
|
||||
let _ = state.db.delete_provider("codex", &codex_id);
|
||||
}
|
||||
|
||||
// 同步到 Gemini
|
||||
if let Some(mut gemini_provider) = provider.to_gemini_provider() {
|
||||
// 合并已有配置
|
||||
if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? {
|
||||
let mut merged = existing.settings_config.clone();
|
||||
Self::merge_json(&mut merged, &gemini_provider.settings_config);
|
||||
gemini_provider.settings_config = merged;
|
||||
}
|
||||
state.db.save_provider("gemini", &gemini_provider)?;
|
||||
} else {
|
||||
let gemini_id = format!("universal-gemini-{}", id);
|
||||
let _ = state.db.delete_provider("gemini", &gemini_id);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 递归合并 JSON:base 为底,patch 覆盖同名字段
|
||||
fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {
|
||||
use serde_json::Value;
|
||||
|
||||
match (base, patch) {
|
||||
(Value::Object(base_map), Value::Object(patch_map)) => {
|
||||
for (k, v_patch) in patch_map {
|
||||
match base_map.get_mut(k) {
|
||||
Some(v_base) => Self::merge_json(v_base, v_patch),
|
||||
None => {
|
||||
base_map.insert(k.clone(), v_patch.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 其它类型:直接覆盖
|
||||
(base_val, patch_val) => {
|
||||
*base_val = patch_val.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,22 @@ impl ProxyService {
|
||||
|
||||
/// 启动代理服务器
|
||||
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
|
||||
// 1. 获取配置
|
||||
// 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()
|
||||
@@ -115,14 +130,7 @@ impl ProxyService {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 5. 设置 settings 表中所有应用的接管状态(用于重启后自动恢复)
|
||||
for app in ["claude", "codex", "gemini"] {
|
||||
if let Err(e) = self.db.set_proxy_takeover_enabled(app, true) {
|
||||
log::warn!("设置 {app} 接管状态失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 启动代理服务器
|
||||
// 5. 启动代理服务器
|
||||
match self.start().await {
|
||||
Ok(info) => Ok(info),
|
||||
Err(e) => {
|
||||
@@ -132,8 +140,6 @@ impl ProxyService {
|
||||
Ok(()) => {
|
||||
let _ = self.db.set_live_takeover_active(false).await;
|
||||
let _ = self.db.delete_all_live_backups().await;
|
||||
// 清除 settings 状态
|
||||
let _ = self.db.clear_all_proxy_takeover();
|
||||
}
|
||||
Err(restore_err) => {
|
||||
log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}");
|
||||
@@ -146,29 +152,30 @@ impl ProxyService {
|
||||
|
||||
/// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
|
||||
pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
|
||||
let claude = self
|
||||
// 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测
|
||||
let claude_enabled = self
|
||||
.db
|
||||
.get_live_backup("claude")
|
||||
.get_proxy_config_for_app("claude")
|
||||
.await
|
||||
.map_err(|e| format!("获取 Claude 接管状态失败: {e}"))?
|
||||
.is_some();
|
||||
let codex = self
|
||||
.map(|c| c.enabled)
|
||||
.unwrap_or(false);
|
||||
let codex_enabled = self
|
||||
.db
|
||||
.get_live_backup("codex")
|
||||
.get_proxy_config_for_app("codex")
|
||||
.await
|
||||
.map_err(|e| format!("获取 Codex 接管状态失败: {e}"))?
|
||||
.is_some();
|
||||
let gemini = self
|
||||
.map(|c| c.enabled)
|
||||
.unwrap_or(false);
|
||||
let gemini_enabled = self
|
||||
.db
|
||||
.get_live_backup("gemini")
|
||||
.get_proxy_config_for_app("gemini")
|
||||
.await
|
||||
.map_err(|e| format!("获取 Gemini 接管状态失败: {e}"))?
|
||||
.is_some();
|
||||
.map(|c| c.enabled)
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(ProxyTakeoverStatus {
|
||||
claude,
|
||||
codex,
|
||||
gemini,
|
||||
claude: claude_enabled,
|
||||
codex: codex_enabled,
|
||||
gemini: gemini_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,13 +194,13 @@ impl ProxyService {
|
||||
}
|
||||
|
||||
// 2) 已接管则直接返回(幂等)
|
||||
if self
|
||||
let current_config = self
|
||||
.db
|
||||
.get_live_backup(app_type_str)
|
||||
.get_proxy_config_for_app(app_type_str)
|
||||
.await
|
||||
.map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
|
||||
.is_some()
|
||||
{
|
||||
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||
|
||||
if current_config.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -223,25 +230,32 @@ impl ProxyService {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 6) 设置 settings 表中的接管状态
|
||||
// 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
|
||||
.set_proxy_takeover_enabled(app_type_str, true)
|
||||
.map_err(|e| format!("设置 {app_type_str} 接管状态失败: {e}"))?;
|
||||
.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(());
|
||||
}
|
||||
|
||||
// 关闭接管:无备份则视为未接管(幂等)
|
||||
let has_backup = self
|
||||
// 关闭接管:检查 enabled 状态
|
||||
let current_config = self
|
||||
.db
|
||||
.get_live_backup(app_type_str)
|
||||
.get_proxy_config_for_app(app_type_str)
|
||||
.await
|
||||
.map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
|
||||
.is_some();
|
||||
if !has_backup {
|
||||
return Ok(());
|
||||
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||
|
||||
if !current_config.enabled {
|
||||
return Ok(()); // 未接管,幂等返回
|
||||
}
|
||||
|
||||
// 1) 恢复 Live 配置
|
||||
@@ -253,10 +267,17 @@ impl ProxyService {
|
||||
.await
|
||||
.map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?;
|
||||
|
||||
// 3) 清除 settings 表中该应用的接管状态
|
||||
// 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
|
||||
.set_proxy_takeover_enabled(app_type_str, false)
|
||||
.map_err(|e| format!("清除 {app_type_str} 接管状态失败: {e}"))?;
|
||||
.update_proxy_config_for_app(updated_config)
|
||||
.await
|
||||
.map_err(|e| format!("清除 {app_type_str} enabled 状态失败: {e}"))?;
|
||||
|
||||
// 4) 清除该应用的健康状态(关闭代理时重置队列状态)
|
||||
self.db
|
||||
@@ -265,12 +286,14 @@ impl ProxyService {
|
||||
.map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?;
|
||||
|
||||
// 5) 若无其它接管,更新旧标志,并停止代理服务
|
||||
let has_any_backup = self
|
||||
// 检查是否还有其它 app 的 enabled = true
|
||||
let any_enabled = self
|
||||
.db
|
||||
.has_any_live_backup()
|
||||
.is_live_takeover_active()
|
||||
.await
|
||||
.map_err(|e| format!("检查 Live 备份失败: {e}"))?;
|
||||
if !has_any_backup {
|
||||
.map_err(|e| format!("检查接管状态失败: {e}"))?;
|
||||
|
||||
if !any_enabled {
|
||||
let _ = self.db.set_live_takeover_active(false).await;
|
||||
|
||||
if self.is_running().await {
|
||||
@@ -502,6 +525,20 @@ impl ProxyService {
|
||||
.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 {
|
||||
@@ -527,10 +564,17 @@ impl ProxyService {
|
||||
.await
|
||||
.map_err(|e| format!("清除接管状态失败: {e}"))?;
|
||||
|
||||
// 4. 清除 settings 表中的代理状态(用户手动关闭,不需要下次自动恢复)
|
||||
self.db
|
||||
.clear_all_proxy_takeover()
|
||||
.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
|
||||
@@ -562,7 +606,7 @@ impl ProxyService {
|
||||
self.restore_live_configs().await?;
|
||||
|
||||
// 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)
|
||||
// 注意:仅更新 proxy_config 表,不清除 settings 表中的 proxy_takeover_* 状态
|
||||
// 注意:保留 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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
|
||||
|
||||
use futures::StreamExt;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -141,15 +142,17 @@ impl StreamCheckService {
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
||||
|
||||
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||
|
||||
let result = match app_type {
|
||||
AppType::Claude => {
|
||||
Self::check_claude_stream(&client, &base_url, &auth, &config.claude_model).await
|
||||
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
}
|
||||
AppType::Codex => {
|
||||
Self::check_codex_stream(&client, &base_url, &auth, &config.codex_model).await
|
||||
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
}
|
||||
AppType::Gemini => {
|
||||
Self::check_gemini_stream(&client, &base_url, &auth, &config.gemini_model).await
|
||||
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
|
||||
}
|
||||
};
|
||||
|
||||
@@ -379,6 +382,48 @@ impl StreamCheckService {
|
||||
AppError::Message(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_test_model(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
config: &StreamCheckConfig,
|
||||
) -> String {
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
|
||||
.unwrap_or_else(|| config.claude_model.clone()),
|
||||
AppType::Codex => {
|
||||
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
|
||||
}
|
||||
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
||||
.unwrap_or_else(|| config.gemini_model.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||
provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|env| env.get(key))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn extract_codex_model(provider: &Provider) -> Option<String> {
|
||||
let config_text = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|value| value.as_str())?;
|
||||
if config_text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?;
|
||||
re.captures(config_text)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user