Feat/usage model extraction (#455)

* feat(proxy): extract model name from API response for accurate usage tracking

- Add model field extraction in TokenUsage parsing for Claude, OpenAI, and Codex
- Prioritize response model over request model in usage logging
- Update model extractors to use parsed usage.model first
- Add tests for model extraction in stream and non-stream responses

* feat(proxy): implement streaming timeout control with validation

- Add first byte timeout (0 or 1-180s) for streaming requests
- Add idle timeout (0 or 60-600s) for streaming data gaps
- Add non-streaming timeout (0 or 60-1800s) for total request
- Implement timeout logic in response processor
- Add 1800s global timeout fallback when disabled
- Add database schema migration for timeout fields
- Add i18n translations for timeout settings

* feat(proxy): add model mapping module for provider-based model substitution

- Add model_mapper.rs with ModelMapping struct to extract model configs from Provider
- Support ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL, and default models for haiku/sonnet/opus
- Implement thinking mode detection for reasoning model priority
- Include comprehensive unit tests for all mapping scenarios

* fix(proxy): bypass circuit breaker for single provider scenario

When failover is disabled (single provider), circuit breaker open state
would block all requests causing poor UX. Now bypasses circuit breaker
check in this scenario. Also integrates model mapping into request flow.

* feat(ui): add reasoning model field to Claude provider form

Add ANTHROPIC_REASONING_MODEL configuration field for Claude providers,
allowing users to specify a dedicated model for thinking/reasoning tasks.

* feat(proxy): add openrouter_compat_mode for optional format conversion

Add configurable OpenRouter compatibility mode that enables Anthropic to
OpenAI format conversion. When enabled, rewrites endpoint to /v1/chat/completions
and transforms request/response formats. Defaults to enabled for OpenRouter.

* feat(ui): add OpenRouter compatibility mode toggle

Add UI toggle for OpenRouter providers to enable/disable compatibility
mode which uses OpenAI Chat Completions format with SSE conversion.

* feat(stream-check): use provider-configured model for health checks

Extract model from provider's settings_config (ANTHROPIC_MODEL, GEMINI_MODEL,
or Codex config.toml) instead of always using default test models.

* refactor(ui): remove timeout settings from AutoFailoverConfigPanel

Remove streaming/non-streaming timeout configuration from failover panel
as these settings have been moved to a dedicated location.

* refactor(database): migrate proxy_config to per-app three-row structure

Replace singleton proxy_config table with app_type primary key structure,
allowing independent proxy settings for Claude, Codex, and Gemini.
Add GlobalProxyConfig queries and per-app config management in DAO layer.

* feat(proxy): add GlobalProxyConfig and AppProxyConfig types

Add new type definitions for the refactored proxy configuration:
- GlobalProxyConfig: shared settings (enabled, address, port, logging)
- AppProxyConfig: per-app settings (failover, timeouts, circuit breaker)

* refactor(proxy): update service layer for per-app config structure

Adapt proxy service, handler context, and provider router to use
the new per-app configuration model. Read enabled/timeout settings
from proxy_config table instead of settings table.

* feat(commands): add global and per-app proxy config commands

Add new Tauri commands for the refactored proxy configuration:
- get_global_proxy_config / update_global_proxy_config
- get_proxy_config_for_app / update_proxy_config_for_app
Update startup restore logic to read from proxy_config table.

* feat(api): add frontend API and Query hooks for proxy config

Add TypeScript wrappers and TanStack Query hooks for:
- Global proxy config (address, port, logging)
- Per-app proxy config (failover, timeouts, circuit breaker)
- Proxy takeover status management

* refactor(ui): redesign proxy panel with inline config controls

Replace ProxySettingsDialog with inline controls in ProxyPanel.
Add per-app takeover switches and global address/port settings.
Simplify AutoFailoverConfigPanel by removing timeout settings.

* feat(i18n): add proxy takeover translations and update types

Add i18n strings for proxy takeover status in zh/en/ja.
Update TypeScript types for GlobalProxyConfig and AppProxyConfig.

* refactor(proxy): load circuit breaker config per-app instead of globally

Extract app_type from router key and read circuit breaker settings
from the corresponding proxy_config row for each application.
This commit is contained in:
YoVinchen
2025-12-25 10:40:11 +08:00
committed by GitHub
Unverified
parent 7d495aa772
commit e6f18ba801
37 changed files with 2813 additions and 1095 deletions
+22 -10
View File
@@ -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())
}
+61 -11
View File
@@ -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
}
+267 -61
View File
@@ -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,
+18 -4
View File
@@ -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(
+284 -215
View File
@@ -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,124 @@ 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()))?;
// 初始化三行数据(每应用不同默认值)
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'))
)",
"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()))?;
// 尝试添加 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
)",
"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()))?;
// 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
)",
"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 INDEX IF NOT EXISTS idx_request_logs_provider
ON proxy_request_logs(provider_id, app_type)",
"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_created_at
ON proxy_request_logs(created_at)",
"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_model
ON proxy_request_logs(model)",
"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_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 +210,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 +227,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 +403,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 +439,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 +472,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 +488,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(())
}
+23 -16
View File
@@ -596,7 +596,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,
@@ -656,6 +656,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,
@@ -845,22 +850,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!("启动时无需恢复代理状态");
@@ -882,7 +885,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}");
}
}
+54 -16
View File
@@ -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!(
+24 -4
View File
@@ -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| {
+35 -8
View File
@@ -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,
}
}
}
+4
View File
@@ -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();
+1
View File
@@ -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;
+264
View File
@@ -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()));
}
}
+64 -48
View File
@@ -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(&current_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());
+29 -8
View File
@@ -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));
}
}
+69 -14
View File
@@ -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;
}
}
}
+69 -1
View File
@@ -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,
}
+87 -3
View File
@@ -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()));
}
}
+97 -53
View File
@@ -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;
+48 -3
View File
@@ -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)]
+1 -3
View File
@@ -641,9 +641,7 @@ function App() {
</header>
<main className="flex-1 pb-12 animate-fade-in ">
<div className="pb-12">
{renderContent()}
</div>
<div className="pb-12">{renderContent()}</div>
</main>
<AddProviderDialog
+6 -1
View File
@@ -51,7 +51,12 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button type="button" variant="outline" size="icon" onClick={onClose}>
<Button
type="button"
variant="outline"
size="icon"
onClick={onClose}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
@@ -39,12 +40,14 @@ interface ClaudeFormFieldsProps {
// Model Selector
shouldShowModelSelector: boolean;
claudeModel: string;
reasoningModel: string;
defaultHaikuModel: string;
defaultSonnetModel: string;
defaultOpusModel: string;
onModelChange: (
field:
| "ANTHROPIC_MODEL"
| "ANTHROPIC_REASONING_MODEL"
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
@@ -53,6 +56,11 @@ interface ClaudeFormFieldsProps {
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];
// OpenRouter Compat
showOpenRouterCompatToggle: boolean;
openRouterCompatEnabled: boolean;
onOpenRouterCompatChange: (enabled: boolean) => void;
}
export function ClaudeFormFields({
@@ -77,11 +85,15 @@ export function ClaudeFormFields({
onCustomEndpointsChange,
shouldShowModelSelector,
claudeModel,
reasoningModel,
defaultHaikuModel,
defaultSonnetModel,
defaultOpusModel,
onModelChange,
speedTestEndpoints,
showOpenRouterCompatToggle,
openRouterCompatEnabled,
onOpenRouterCompatChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
@@ -162,6 +174,28 @@ export function ClaudeFormFields({
/>
)}
{showOpenRouterCompatToggle && (
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("providerForm.openrouterCompatMode", {
defaultValue: "OpenRouter 兼容模式",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("providerForm.openrouterCompatModeHint", {
defaultValue:
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
})}
</p>
</div>
<Switch
checked={openRouterCompatEnabled}
onCheckedChange={onOpenRouterCompatChange}
/>
</div>
)}
{/* 模型选择器 */}
{shouldShowModelSelector && (
<div className="space-y-3">
@@ -185,6 +219,27 @@ export function ClaudeFormFields({
/>
</div>
{/* 推理模型 */}
<div className="space-y-2">
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel", {
defaultValue: "推理模型 (Thinking)",
})}
</FormLabel>
<Input
id="reasoningModel"
type="text"
value={reasoningModel}
onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
}
placeholder={t("providerForm.reasoningModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
</div>
{/* 默认 Haiku */}
<div className="space-y-2">
<FormLabel htmlFor="claudeDefaultHaikuModel">
@@ -162,6 +162,8 @@ export function ProviderForm({
mode: "onSubmit",
});
const settingsConfigValue = form.watch("settingsConfig");
// 使用 API Key hook
const {
apiKey,
@@ -187,9 +189,10 @@ export function ProviderForm({
},
});
// 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型)
// 使用 Model hook(新:主模型 + 推理模型 + Haiku/Sonnet/Opus 默认模型)
const {
claudeModel,
reasoningModel,
defaultHaikuModel,
defaultSonnetModel,
defaultOpusModel,
@@ -199,6 +202,53 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
const isOpenRouterProvider = useMemo(() => {
if (appId !== "claude") return false;
const normalized = baseUrl.trim().toLowerCase();
if (normalized.includes("openrouter.ai")) {
return true;
}
try {
const config = JSON.parse(settingsConfigValue || "{}");
const envUrl = config?.env?.ANTHROPIC_BASE_URL;
return typeof envUrl === "string" && envUrl.includes("openrouter.ai");
} catch {
return false;
}
}, [appId, baseUrl, settingsConfigValue]);
const openRouterCompatEnabled = useMemo(() => {
if (!isOpenRouterProvider) return false;
try {
const config = JSON.parse(settingsConfigValue || "{}");
const raw = config?.openrouter_compat_mode;
if (typeof raw === "boolean") return raw;
if (typeof raw === "number") return raw !== 0;
if (typeof raw === "string") {
const normalized = raw.trim().toLowerCase();
return normalized === "true" || normalized === "1";
}
} catch {
// ignore
}
return true;
}, [isOpenRouterProvider, settingsConfigValue]);
const handleOpenRouterCompatChange = useCallback(
(enabled: boolean) => {
try {
const currentConfig = JSON.parse(
form.getValues("settingsConfig") || "{}",
);
currentConfig.openrouter_compat_mode = enabled;
form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2));
} catch {
// ignore
}
},
[form],
);
// 使用 Codex 配置 hook (仅 Codex 模式)
const {
codexAuth,
@@ -789,11 +839,15 @@ export function ProviderForm({
}
shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel}
reasoningModel={reasoningModel}
defaultHaikuModel={defaultHaikuModel}
defaultSonnetModel={defaultSonnetModel}
defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
showOpenRouterCompatToggle={isOpenRouterProvider}
openRouterCompatEnabled={openRouterCompatEnabled}
onOpenRouterCompatChange={handleOpenRouterCompatChange}
/>
)}
@@ -7,13 +7,14 @@ interface UseModelStateProps {
/**
* 管理模型选择状态
* 支持 ANTHROPIC_MODEL ANTHROPIC_SMALL_FAST_MODEL
* 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型
*/
export function useModelState({
settingsConfig,
onConfigChange,
}: UseModelStateProps) {
const [claudeModel, setClaudeModel] = useState("");
const [reasoningModel, setReasoningModel] = useState("");
const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
const [defaultOpusModel, setDefaultOpusModel] = useState("");
@@ -29,6 +30,10 @@ export function useModelState({
const env = cfg?.env || {};
const model =
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
const reasoning =
typeof env.ANTHROPIC_REASONING_MODEL === "string"
? env.ANTHROPIC_REASONING_MODEL
: "";
const small =
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
? env.ANTHROPIC_SMALL_FAST_MODEL
@@ -47,6 +52,7 @@ export function useModelState({
: model || small;
setClaudeModel(model || "");
setReasoningModel(reasoning || "");
setDefaultHaikuModel(haiku || "");
setDefaultSonnetModel(sonnet || "");
setDefaultOpusModel(opus || "");
@@ -59,12 +65,14 @@ export function useModelState({
(
field:
| "ANTHROPIC_MODEL"
| "ANTHROPIC_REASONING_MODEL"
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
value: string,
) => {
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
setDefaultHaikuModel(value);
if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL")
@@ -98,6 +106,8 @@ export function useModelState({
return {
claudeModel,
setClaudeModel,
reasoningModel,
setReasoningModel,
defaultHaikuModel,
setDefaultHaikuModel,
defaultSonnetModel,
+208 -132
View File
@@ -6,51 +6,67 @@ import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import {
useCircuitBreakerConfig,
useUpdateCircuitBreakerConfig,
} from "@/lib/query/failover";
import { useAppProxyConfig, useUpdateAppProxyConfig } from "@/lib/query/proxy";
export interface AutoFailoverConfigPanelProps {
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
appType: string;
disabled?: boolean;
}
export function AutoFailoverConfigPanel({
enabled = true,
onEnabledChange: _onEnabledChange,
}: AutoFailoverConfigPanelProps = {}) {
// Note: onEnabledChange is currently unused but kept in the interface
// for potential future use by parent components
void _onEnabledChange;
appType,
disabled = false,
}: AutoFailoverConfigPanelProps) {
const { t } = useTranslation();
const { data: config, isLoading, error } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
const { data: config, isLoading, error } = useAppProxyConfig(appType);
const updateConfig = useUpdateAppProxyConfig();
const [formData, setFormData] = useState({
failureThreshold: 5,
successThreshold: 2,
timeoutSeconds: 60,
errorRateThreshold: 0.5,
minRequests: 10,
autoFailoverEnabled: false,
maxRetries: 3,
streamingFirstByteTimeout: 30,
streamingIdleTimeout: 60,
nonStreamingTimeout: 300,
circuitFailureThreshold: 5,
circuitSuccessThreshold: 2,
circuitTimeoutSeconds: 60,
circuitErrorRateThreshold: 0.5,
circuitMinRequests: 10,
});
useEffect(() => {
if (config) {
setFormData({
...config,
autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
});
}
}, [config]);
const handleSave = async () => {
if (!config) return;
try {
await updateConfig.mutateAsync({
failureThreshold: formData.failureThreshold,
successThreshold: formData.successThreshold,
timeoutSeconds: formData.timeoutSeconds,
errorRateThreshold: formData.errorRateThreshold,
minRequests: formData.minRequests,
appType,
enabled: config.enabled,
autoFailoverEnabled: formData.autoFailoverEnabled,
maxRetries: formData.maxRetries,
streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
streamingIdleTimeout: formData.streamingIdleTimeout,
nonStreamingTimeout: formData.nonStreamingTimeout,
circuitFailureThreshold: formData.circuitFailureThreshold,
circuitSuccessThreshold: formData.circuitSuccessThreshold,
circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
circuitMinRequests: formData.circuitMinRequests,
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -66,7 +82,16 @@ export function AutoFailoverConfigPanel({
const handleReset = () => {
if (config) {
setFormData({
...config,
autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
});
}
};
@@ -79,16 +104,10 @@ export function AutoFailoverConfigPanel({
);
}
const isDisabled = disabled || updateConfig.isPending;
return (
<div className="border-0 rounded-none shadow-none bg-transparent">
{/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits.
Since we need it in the accordion header, and this component is inside the content, we can use a portal or
absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage
and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually.
Better yet, let's just render the content directly without the wrapping Card header/collapse logic
since the user requested "click to expand is detailed info, no need to fold again" (implying the accordion handles folding).
*/}
<div className="space-y-4">
{error && (
<Alert variant="destructive">
@@ -114,22 +133,48 @@ export function AutoFailoverConfigPanel({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="failureThreshold">
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
<Label htmlFor={`maxRetries-${appType}`}>
{t("proxy.autoFailover.maxRetries", "最大重试次数")}
</Label>
<Input
id="failureThreshold"
id={`maxRetries-${appType}`}
type="number"
min="1"
max="20"
value={formData.failureThreshold}
min="0"
max="10"
value={formData.maxRetries}
onChange={(e) =>
setFormData({
...formData,
failureThreshold: parseInt(e.target.value) || 5,
maxRetries: parseInt(e.target.value) || 3,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.maxRetriesHint",
"请求失败时的重试次数(0-10",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`failureThreshold-${appType}`}>
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
</Label>
<Input
id={`failureThreshold-${appType}`}
type="number"
min="1"
max="20"
value={formData.circuitFailureThreshold}
onChange={(e) =>
setFormData({
...formData,
circuitFailureThreshold: parseInt(e.target.value) || 5,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -138,59 +183,123 @@ export function AutoFailoverConfigPanel({
)}
</p>
</div>
</div>
</div>
{/* 超时配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.timeoutSettings", "超时配置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="timeoutSeconds">
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
<Label htmlFor={`streamingFirstByte-${appType}`}>
{t(
"proxy.autoFailover.streamingFirstByte",
"流式首字节超时(秒)",
)}
</Label>
<Input
id="timeoutSeconds"
id={`streamingFirstByte-${appType}`}
type="number"
min="10"
max="300"
value={formData.timeoutSeconds}
min="0"
max="180"
value={formData.streamingFirstByteTimeout}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
"proxy.autoFailover.streamingFirstByteHint",
"等待首个数据块的最大时间",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`streamingIdle-${appType}`}>
{t("proxy.autoFailover.streamingIdle", "流式静默超时(秒)")}
</Label>
<Input
id={`streamingIdle-${appType}`}
type="number"
min="0"
max="600"
value={formData.streamingIdleTimeout}
onChange={(e) =>
setFormData({
...formData,
streamingIdleTimeout: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.streamingIdleHint",
"数据块之间的最大间隔",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`nonStreaming-${appType}`}>
{t("proxy.autoFailover.nonStreaming", "非流式超时(秒)")}
</Label>
<Input
id={`nonStreaming-${appType}`}
type="number"
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) =>
setFormData({
...formData,
nonStreamingTimeout: parseInt(e.target.value) || 300,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.nonStreamingHint",
"非流式请求的总超时时间",
)}
</p>
</div>
</div>
</div>
{/* 熔断器高级配置 */}
{/* 熔断器配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="successThreshold">
<Label htmlFor={`successThreshold-${appType}`}>
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
</Label>
<Input
id="successThreshold"
id={`successThreshold-${appType}`}
type="number"
min="1"
max="10"
value={formData.successThreshold}
value={formData.circuitSuccessThreshold}
onChange={(e) =>
setFormData({
...formData,
successThreshold: parseInt(e.target.value) || 2,
circuitSuccessThreshold: parseInt(e.target.value) || 2,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -201,23 +310,50 @@ export function AutoFailoverConfigPanel({
</div>
<div className="space-y-2">
<Label htmlFor="errorRateThreshold">
<Label htmlFor={`timeoutSeconds-${appType}`}>
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
</Label>
<Input
id={`timeoutSeconds-${appType}`}
type="number"
min="10"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`errorRateThreshold-${appType}`}>
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
</Label>
<Input
id="errorRateThreshold"
id={`errorRateThreshold-${appType}`}
type="number"
min="0"
max="100"
step="5"
value={Math.round(formData.errorRateThreshold * 100)}
value={Math.round(formData.circuitErrorRateThreshold * 100)}
onChange={(e) =>
setFormData({
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
circuitErrorRateThreshold:
(parseInt(e.target.value) || 50) / 100,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -228,22 +364,22 @@ export function AutoFailoverConfigPanel({
</div>
<div className="space-y-2">
<Label htmlFor="minRequests">
<Label htmlFor={`minRequests-${appType}`}>
{t("proxy.autoFailover.minRequests", "最小请求数")}
</Label>
<Input
id="minRequests"
id={`minRequests-${appType}`}
type="number"
min="5"
max="100"
value={formData.minRequests}
value={formData.circuitMinRequests}
onChange={(e) =>
setFormData({
...formData,
minRequests: parseInt(e.target.value) || 10,
circuitMinRequests: parseInt(e.target.value) || 10,
})
}
disabled={!enabled}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
@@ -257,17 +393,10 @@ export function AutoFailoverConfigPanel({
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending || !enabled}
>
<Button variant="outline" onClick={handleReset} disabled={isDisabled}>
{t("common.reset", "重置")}
</Button>
<Button
onClick={handleSave}
disabled={updateConfig.isPending || !enabled}
>
<Button onClick={handleSave} disabled={isDisabled}>
{updateConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -281,59 +410,6 @@ export function AutoFailoverConfigPanel({
)}
</Button>
</div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium">
{t("proxy.autoFailover.explanationTitle", "工作原理")}
</h4>
<ul className="space-y-1 text-muted-foreground">
<li>
{" "}
<strong>
{t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
</strong>
{t(
"proxy.autoFailover.failureThresholdExplain",
"连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
</strong>
{t(
"proxy.autoFailover.timeoutExplain",
"熔断器打开后,等待此时间后尝试半开状态",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.successThresholdLabel", "恢复成功阈值")}
</strong>
{t(
"proxy.autoFailover.successThresholdExplain",
"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
</strong>
{t(
"proxy.autoFailover.errorRateExplain",
"错误率超过此值时,即使未达到失败阈值也会打开熔断器",
)}
</li>
</ul>
</div>
</div>
</div>
);
+261 -46
View File
@@ -1,26 +1,54 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import {
Activity,
Clock,
TrendingUp,
Server,
ListOrdered,
Settings,
Save,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner";
import { useFailoverQueue } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover";
import {
useProxyTakeoverStatus,
useSetProxyTakeoverForApp,
useGlobalProxyConfig,
useUpdateGlobalProxyConfig,
} from "@/lib/query/proxy";
import type { ProxyStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
export function ProxyPanel() {
const { t } = useTranslation();
const { status, isRunning } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
// 获取应用接管状态
const { data: takeoverStatus } = useProxyTakeoverStatus();
const setTakeoverForApp = useSetProxyTakeoverForApp();
// 获取全局代理配置
const { data: globalConfig } = useGlobalProxyConfig();
const updateGlobalConfig = useUpdateGlobalProxyConfig();
// 监听地址/端口的本地状态
const [listenAddress, setListenAddress] = useState("127.0.0.1");
const [listenPort, setListenPort] = useState(5000);
// 同步全局配置到本地状态
useEffect(() => {
if (globalConfig) {
setListenAddress(globalConfig.listenAddress);
setListenPort(globalConfig.listenPort);
}
}, [globalConfig]);
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
// 当前供应商始终优先,队列仅用于失败后的备用顺序
@@ -28,6 +56,69 @@ export function ProxyPanel() {
const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
try {
await setTakeoverForApp.mutateAsync({ appType, enabled });
toast.success(
enabled
? t("proxy.takeover.enabled", {
app: appType,
defaultValue: `${appType} 接管已启用`,
})
: t("proxy.takeover.disabled", {
app: appType,
defaultValue: `${appType} 接管已关闭`,
}),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.takeover.failed", {
defaultValue: "切换接管状态失败",
}),
);
}
};
const handleLoggingChange = async (enabled: boolean) => {
if (!globalConfig) return;
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
enableLogging: enabled,
});
toast.success(
enabled
? t("proxy.logging.enabled", { defaultValue: "日志记录已启用" })
: t("proxy.logging.disabled", { defaultValue: "日志记录已关闭" }),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.logging.failed", { defaultValue: "切换日志状态失败" }),
);
}
};
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress,
listenPort,
});
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.settings.configSaveFailed", { defaultValue: "保存配置失败" }),
);
}
};
const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
@@ -49,22 +140,11 @@ export function ProxyPanel() {
<div className="space-y-6">
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground">
{t("proxy.panel.serviceAddress", {
defaultValue: "服务地址",
})}
</p>
<Button
size="sm"
variant="ghost"
onClick={() => setShowSettings(true)}
className="h-7 gap-1.5 text-xs"
>
<Settings className="h-3.5 w-3.5" />
{t("common.settings")}
</Button>
</div>
<p className="text-xs text-muted-foreground mb-2">
{t("proxy.panel.serviceAddress", {
defaultValue: "服务地址",
})}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port}
@@ -87,6 +167,11 @@ export function ProxyPanel() {
{t("common.copy")}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
{t("proxy.settings.restartRequired", {
defaultValue: "修改监听地址/端口需要先停止代理服务",
})}
</p>
</div>
<div className="pt-3 border-t border-border space-y-2">
@@ -130,6 +215,63 @@ export function ProxyPanel() {
)}
</div>
{/* 应用接管开关 */}
<div className="pt-3 border-t border-border space-y-3">
<p className="text-xs text-muted-foreground">
{t("proxyConfig.appTakeover", {
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-3">
{(["claude", "codex", "gemini"] as const).map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
</div>
</div>
{/* 日志记录开关 */}
<div className="pt-3 border-t border-border">
<div className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</Label>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</p>
</div>
<Switch
checked={globalConfig?.enableLogging ?? true}
onCheckedChange={handleLoggingChange}
disabled={updateGlobalConfig.isPending}
/>
</div>
</div>
{/* 供应商队列 - 按应用类型分组展示 */}
{(claudeQueue.length > 0 ||
codexQueue.length > 0 ||
@@ -217,36 +359,109 @@ export function ProxyPanel() {
</div>
</div>
) : (
<div className="text-center py-10 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
<div className="space-y-6">
{/* 空白区域避免冲突 */}
<div className="h-4"></div>
{/* 基础设置 - 监听地址/端口 */}
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="listen-address">
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</Label>
<Input
id="listen-address"
value={listenAddress}
onChange={(e) => setListenAddress(e.target.value)}
placeholder="127.0.0.1"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="listen-port">
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</Label>
<Input
id="listen-port"
type="number"
value={listenPort}
onChange={(e) =>
setListenPort(parseInt(e.target.value) || 5000)
}
placeholder="5000"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenPort.description", {
defaultValue: "代理服务器监听的端口号(1024 ~ 65535",
})}
</p>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSaveBasicConfig}
disabled={updateGlobalConfig.isPending}
>
{updateGlobalConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", { defaultValue: "保存中..." })}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", { defaultValue: "保存" })}
</>
)}
</Button>
</div>
</div>
{/* 代理服务已停止提示 */}
<div className="text-center py-6 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
</div>
<p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", {
defaultValue: "代理服务已停止",
})}
</p>
<p className="text-sm text-muted-foreground">
{t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
})}
</p>
</div>
<p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", {
defaultValue: "代理服务已停止",
})}
</p>
<p className="text-sm text-muted-foreground mb-4">
{t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
})}
</p>
<Button
size="sm"
variant="outline"
onClick={() => setShowSettings(true)}
className="gap-1.5"
>
<Settings className="h-4 w-4" />
{t("proxy.panel.openSettings", {
defaultValue: "配置代理服务",
})}
</Button>
</div>
)}
</section>
<ProxySettingsDialog open={showSettings} onOpenChange={setShowSettings} />
</>
);
}
@@ -1,403 +0,0 @@
/**
* 代理服务设置对话框
*/
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useProxyConfig } from "@/hooks/useProxyConfig";
import { useEffect, useMemo } from "react";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { ProxyConfig } from "@/types/proxy";
// 表单数据类型(仅包含可编辑字段)
type ProxyConfigForm = Pick<
ProxyConfig,
| "listen_address"
| "listen_port"
| "max_retries"
| "request_timeout"
| "enable_logging"
>;
const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z
.number()
.min(
0,
t("proxy.settings.validation.timeoutNonNegative", {
defaultValue: "超时时间不能为负数",
}),
)
.max(
600,
t("proxy.settings.validation.timeoutMax", {
defaultValue: "超时时间最多600秒",
}),
)
.refine((value) => value === 0 || value >= 10, {
message: t("proxy.settings.validation.timeoutRange", {
defaultValue: "请输入 0 或 10-600 之间的数值",
}),
});
return z.object({
listen_address: z.string().regex(
/^(\d{1,3}\.){3}\d{1,3}$/,
t("proxy.settings.validation.addressInvalid", {
defaultValue: "请输入有效的IP地址",
}),
),
listen_port: z
.number()
.min(
1024,
t("proxy.settings.validation.portMin", {
defaultValue: "端口必须大于1024",
}),
)
.max(
65535,
t("proxy.settings.validation.portMax", {
defaultValue: "端口必须小于65535",
}),
),
max_retries: z
.number()
.min(
0,
t("proxy.settings.validation.retryMin", {
defaultValue: "重试次数不能为负",
}),
)
.max(
10,
t("proxy.settings.validation.retryMax", {
defaultValue: "重试次数不能超过10",
}),
),
request_timeout: requestTimeoutSchema,
enable_logging: z.boolean(),
});
};
interface ProxySettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProxySettingsDialog({
open,
onOpenChange,
}: ProxySettingsDialogProps) {
const { config, isLoading, updateConfig, isUpdating } = useProxyConfig();
const { t } = useTranslation();
const schema = useMemo(() => createProxyConfigSchema(t), [t]);
const closePanel = () => onOpenChange(false);
const form = useForm<ProxyConfigForm>({
resolver: zodResolver(schema),
defaultValues: {
listen_address: "127.0.0.1",
listen_port: 5000,
max_retries: 3,
request_timeout: 300,
enable_logging: true,
},
});
// 当配置加载完成后更新表单
useEffect(() => {
if (config) {
form.reset({
listen_address: config.listen_address,
listen_port: config.listen_port,
max_retries: config.max_retries,
request_timeout: config.request_timeout,
enable_logging: config.enable_logging,
});
}
}, [config, form]);
const onSubmit = async (data: ProxyConfigForm) => {
try {
await updateConfig(data);
closePanel();
} catch (error) {
console.error("Save config failed:", error);
}
};
const formId = "proxy-settings-form";
return (
<FullScreenPanel
isOpen={open}
title={t("proxy.settings.title", { defaultValue: "代理服务设置" })}
onClose={closePanel}
footer={
<>
<Button
type="button"
variant="outline"
onClick={closePanel}
disabled={isUpdating}
>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="submit"
form={formId}
disabled={isUpdating || isLoading}
>
{isUpdating
? t("common.saving", { defaultValue: "保存中..." })
: t("proxy.settings.actions.save", { defaultValue: "保存配置" })}
</Button>
</>
}
>
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("proxy.settings.description", {
defaultValue:
"配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
})}
</p>
<Alert className="border-emerald-500/40 bg-emerald-500/10">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{t("proxy.settings.alert.autoApply", {
defaultValue:
"保存后将自动同步到正在运行的代理服务,无需手动重启。",
})}
</AlertDescription>
</Alert>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="listen_address"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"proxy.settings.fields.listenAddress.placeholder",
{ defaultValue: "127.0.0.1" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listen_port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.listenPort.placeholder",
{ defaultValue: "5000" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenPort.description", {
defaultValue:
"代理服务器监听的端口号(1024 ~ 65535",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.advanced.title", {
defaultValue: "高级参数",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.advanced.description", {
defaultValue: "控制请求的稳定性和日志记录。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="max_retries"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.maxRetries.label", {
defaultValue: "最大重试次数",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.maxRetries.placeholder",
{ defaultValue: "3" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.maxRetries.description", {
defaultValue: "请求失败时的重试次数(0 ~ 10)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="request_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.requestTimeout.label", {
defaultValue: "请求超时(秒)",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.requestTimeout.placeholder",
{ defaultValue: "0(不限)或 300" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.requestTimeout.description", {
defaultValue:
"单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="enable_logging"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</FormLabel>
<FormDescription>
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</section>
</form>
</Form>
</div>
</FullScreenPanel>
);
}
-1
View File
@@ -3,4 +3,3 @@
*/
export { ProxyPanel } from "./ProxyPanel";
export { ProxySettingsDialog } from "./ProxySettingsDialog";
+71 -29
View File
@@ -384,47 +384,89 @@ export function SettingsPage({
</div>
)}
{/* 故障转移队列管理 - 每个应用独立 */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent value="claude" className="mt-4">
{/* 故障转移设置 - 按应用分组 */}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent
value="claude"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="claude"
disabled={!isRunning}
/>
</TabsContent>
<TabsContent value="codex" className="mt-4">
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="claude"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="codex"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="codex"
disabled={!isRunning}
/>
</TabsContent>
<TabsContent value="gemini" className="mt-4">
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="codex"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="gemini"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="gemini"
disabled={!isRunning}
/>
</TabsContent>
</Tabs>
</div>
{/* 熔断器配置 - 全局共享 */}
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel />
</div>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="gemini"
disabled={!isRunning}
/>
</div>
</TabsContent>
</Tabs>
</div>
</AccordionContent>
</AccordionItem>
+87 -1
View File
@@ -981,6 +981,76 @@
}
},
"settings": {
"title": "Proxy Service Settings",
"description": "Configure local proxy server listening address, port and runtime parameters. Changes take effect immediately after saving.",
"alert": {
"autoApply": "Changes will be automatically synced to the running proxy service without manual restart."
},
"basic": {
"title": "Basic Settings",
"description": "Configure proxy service listening address and port."
},
"advanced": {
"title": "Advanced Parameters",
"description": "Control request stability and logging."
},
"timeout": {
"title": "Timeout Settings",
"description": "Configure timeout for streaming and non-streaming requests."
},
"fields": {
"listenAddress": {
"label": "Listen Address",
"placeholder": "127.0.0.1",
"description": "IP address the proxy server listens on (recommended: 127.0.0.1)"
},
"listenPort": {
"label": "Listen Port",
"placeholder": "5000",
"description": "Port number the proxy server listens on (1024 ~ 65535)"
},
"maxRetries": {
"label": "Max Retries",
"placeholder": "3",
"description": "Number of retries on request failure (0 ~ 10)"
},
"requestTimeout": {
"label": "Request Timeout (sec)",
"placeholder": "0 (unlimited) or 300",
"description": "Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)"
},
"enableLogging": {
"label": "Enable Logging",
"description": "Log all proxy requests for troubleshooting"
},
"streamingFirstByteTimeout": {
"label": "Streaming First Byte Timeout (sec)",
"description": "Maximum time to wait for the first data chunk"
},
"streamingIdleTimeout": {
"label": "Streaming Idle Timeout (sec)",
"description": "Maximum interval between data chunks"
},
"nonStreamingTimeout": {
"label": "Non-Streaming Timeout (sec)",
"description": "Total timeout for non-streaming requests"
}
},
"validation": {
"addressInvalid": "Please enter a valid IP address",
"portMin": "Port must be greater than 1024",
"portMax": "Port must be less than 65535",
"retryMin": "Retry count cannot be negative",
"retryMax": "Retry count cannot exceed 10",
"timeoutNonNegative": "Timeout cannot be negative",
"timeoutMax": "Timeout cannot exceed 600 seconds",
"timeoutRange": "Please enter 0 or a value between 10-600",
"streamingTimeoutMin": "Timeout must be at least 5 seconds",
"streamingTimeoutMax": "Timeout cannot exceed 300 seconds"
},
"actions": {
"save": "Save Configuration"
},
"toast": {
"saved": "Proxy configuration saved",
"saveFailed": "Save failed: {{error}}"
@@ -1013,7 +1083,7 @@
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
"timeout": "Recovery Wait Time (seconds)",
"timeoutHint": "Wait this long before trying to recover after circuit opens (recommended: 30-120)",
"circuitBreakerSettings": "Circuit Breaker Advanced Settings",
"circuitBreakerSettings": "Circuit Breaker Settings",
"successThreshold": "Recovery Success Threshold",
"successThresholdHint": "Close circuit breaker after this many successes in half-open state",
"errorRate": "Error Rate Threshold (%)",
@@ -1042,5 +1112,21 @@
"timeout": "Timeout (seconds)",
"maxRetries": "Max Retries",
"degradedThreshold": "Degraded Threshold (ms)"
},
"proxyConfig": {
"proxyEnabled": "Proxy Enabled",
"appTakeover": "Proxy Enabled",
"perAppConfig": "Per-App Config",
"circuitBreaker": "Circuit Breaker",
"circuitBreakerSettings": "Circuit Breaker Settings",
"failureThreshold": "Failure Threshold",
"successThreshold": "Success Threshold",
"recoveryTimeout": "Recovery Timeout",
"errorRateThreshold": "Error Rate Threshold",
"minRequests": "Min Requests",
"timeoutConfig": "Timeout Config",
"streamingFirstByte": "Streaming First Byte Timeout",
"streamingIdle": "Streaming Idle Timeout",
"nonStreaming": "Non-Streaming Timeout"
}
}
+87 -1
View File
@@ -981,6 +981,76 @@
}
},
"settings": {
"title": "プロキシサービス設定",
"description": "ローカルプロキシサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。",
"alert": {
"autoApply": "変更は実行中のプロキシサービスに自動的に同期され、手動での再起動は不要です。"
},
"basic": {
"title": "基本設定",
"description": "プロキシサービスのリッスンアドレスとポートを設定します。"
},
"advanced": {
"title": "詳細パラメータ",
"description": "リクエストの安定性とログ記録を制御します。"
},
"timeout": {
"title": "タイムアウト設定",
"description": "ストリーミングと非ストリーミングリクエストのタイムアウトを設定します。"
},
"fields": {
"listenAddress": {
"label": "リッスンアドレス",
"placeholder": "127.0.0.1",
"description": "プロキシサーバーがリッスンするIPアドレス(推奨: 127.0.0.1"
},
"listenPort": {
"label": "リッスンポート",
"placeholder": "5000",
"description": "プロキシサーバーがリッスンするポート番号(1024 ~ 65535"
},
"maxRetries": {
"label": "最大リトライ回数",
"placeholder": "3",
"description": "リクエスト失敗時のリトライ回数(0 ~ 10)"
},
"requestTimeout": {
"label": "リクエストタイムアウト(秒)",
"placeholder": "0(無制限)または 300",
"description": "単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)"
},
"enableLogging": {
"label": "ログ記録を有効化",
"description": "トラブルシューティングのためにすべてのプロキシリクエストを記録"
},
"streamingFirstByteTimeout": {
"label": "ストリーミング初回バイトタイムアウト(秒)",
"description": "最初のデータチャンクを待つ最大時間"
},
"streamingIdleTimeout": {
"label": "ストリーミングアイドルタイムアウト(秒)",
"description": "データチャンク間の最大間隔"
},
"nonStreamingTimeout": {
"label": "非ストリーミングタイムアウト(秒)",
"description": "非ストリーミングリクエストの総タイムアウト"
}
},
"validation": {
"addressInvalid": "有効なIPアドレスを入力してください",
"portMin": "ポートは1024より大きい必要があります",
"portMax": "ポートは65535より小さい必要があります",
"retryMin": "リトライ回数は負の値にできません",
"retryMax": "リトライ回数は10を超えることはできません",
"timeoutNonNegative": "タイムアウトは負の値にできません",
"timeoutMax": "タイムアウトは600秒を超えることはできません",
"timeoutRange": "0または10-600の間の値を入力してください",
"streamingTimeoutMin": "タイムアウトは少なくとも5秒必要です",
"streamingTimeoutMax": "タイムアウトは300秒を超えることはできません"
},
"actions": {
"save": "設定を保存"
},
"toast": {
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}"
@@ -1013,7 +1083,7 @@
"failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)",
"timeout": "回復待ち時間(秒)",
"timeoutHint": "サーキットが開いた後、回復を試みるまでの待ち時間(推奨: 30-120)",
"circuitBreakerSettings": "サーキットブレーカー詳細設定",
"circuitBreakerSettings": "サーキットブレーカー設定",
"successThreshold": "回復成功しきい値",
"successThresholdHint": "半開状態でこの回数成功するとサーキットブレーカーが閉じます",
"errorRate": "エラー率しきい値 (%)",
@@ -1042,5 +1112,21 @@
"timeout": "タイムアウト(秒)",
"maxRetries": "最大リトライ回数",
"degradedThreshold": "劣化しきい値(ミリ秒)"
},
"proxyConfig": {
"proxyEnabled": "プロキシ有効",
"appTakeover": "プロキシ有効",
"perAppConfig": "アプリ別設定",
"circuitBreaker": "サーキットブレーカー",
"circuitBreakerSettings": "サーキットブレーカー設定",
"failureThreshold": "失敗閾値",
"successThreshold": "回復閾値",
"recoveryTimeout": "回復待機時間",
"errorRateThreshold": "エラー率閾値",
"minRequests": "最小リクエスト数",
"timeoutConfig": "タイムアウト設定",
"streamingFirstByte": "ストリーミング初回バイトタイムアウト",
"streamingIdle": "ストリーミングアイドルタイムアウト",
"nonStreaming": "非ストリーミングタイムアウト"
}
}
+87 -1
View File
@@ -981,6 +981,76 @@
}
},
"settings": {
"title": "代理服务设置",
"description": "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
"alert": {
"autoApply": "保存后将自动同步到正在运行的代理服务,无需手动重启。"
},
"basic": {
"title": "基础设置",
"description": "配置代理服务监听的地址与端口。"
},
"advanced": {
"title": "高级参数",
"description": "控制请求的稳定性和日志记录。"
},
"timeout": {
"title": "超时设置",
"description": "配置流式和非流式请求的超时时间。"
},
"fields": {
"listenAddress": {
"label": "监听地址",
"placeholder": "127.0.0.1",
"description": "代理服务器监听的 IP 地址(推荐 127.0.0.1"
},
"listenPort": {
"label": "监听端口",
"placeholder": "5000",
"description": "代理服务器监听的端口号(1024 ~ 65535"
},
"maxRetries": {
"label": "最大重试次数",
"placeholder": "3",
"description": "请求失败时的重试次数(0 ~ 10)"
},
"requestTimeout": {
"label": "请求超时(秒)",
"placeholder": "0(不限)或 300",
"description": "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)"
},
"enableLogging": {
"label": "启用日志记录",
"description": "记录所有代理请求,便于排查问题"
},
"streamingFirstByteTimeout": {
"label": "流式首字超时(秒)",
"description": "等待首个数据块的最大时间"
},
"streamingIdleTimeout": {
"label": "流式静默超时(秒)",
"description": "数据块之间的最大间隔"
},
"nonStreamingTimeout": {
"label": "非流式超时(秒)",
"description": "非流式请求的总超时时间"
}
},
"validation": {
"addressInvalid": "请输入有效的IP地址",
"portMin": "端口必须大于1024",
"portMax": "端口必须小于65535",
"retryMin": "重试次数不能为负",
"retryMax": "重试次数不能超过10",
"timeoutNonNegative": "超时时间不能为负数",
"timeoutMax": "超时时间最多600秒",
"timeoutRange": "请输入 0 或 10-600 之间的数值",
"streamingTimeoutMin": "超时时间至少5秒",
"streamingTimeoutMax": "超时时间最多300秒"
},
"actions": {
"save": "保存配置"
},
"toast": {
"saved": "代理配置已保存",
"saveFailed": "保存失败: {{error}}"
@@ -1013,7 +1083,7 @@
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
"timeout": "恢复等待时间(秒)",
"timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
"circuitBreakerSettings": "熔断器高级设置",
"circuitBreakerSettings": "熔断器设置",
"successThreshold": "恢复成功阈值",
"successThresholdHint": "半开状态下成功多少次后关闭熔断器",
"errorRate": "错误率阈值 (%)",
@@ -1042,5 +1112,21 @@
"timeout": "超时时间(秒)",
"maxRetries": "最大重试次数",
"degradedThreshold": "降级阈值(毫秒)"
},
"proxyConfig": {
"proxyEnabled": "代理总开关",
"appTakeover": "代理启用",
"perAppConfig": "应用配置",
"circuitBreaker": "熔断器配置",
"circuitBreakerSettings": "熔断器设置",
"failureThreshold": "失败阈值",
"successThreshold": "恢复阈值",
"recoveryTimeout": "恢复等待时间",
"errorRateThreshold": "错误率阈值",
"minRequests": "最小请求数",
"timeoutConfig": "超时配置",
"streamingFirstByte": "流式首字节超时",
"streamingIdle": "流式静默超时",
"nonStreaming": "非流式超时"
}
}
+1
View File
@@ -5,6 +5,7 @@ export { mcpApi } from "./mcp";
export { promptsApi } from "./prompts";
export { usageApi } from "./usage";
export { vscodeApi } from "./vscode";
export { proxyApi } from "./proxy";
export * as configApi from "./config";
export type { ProviderSwitchEvent } from "./providers";
export type { Prompt } from "./prompts";
+95
View File
@@ -0,0 +1,95 @@
import { invoke } from "@tauri-apps/api/core";
import type {
ProxyConfig,
ProxyStatus,
ProxyServerInfo,
ProxyTakeoverStatus,
GlobalProxyConfig,
AppProxyConfig,
} from "@/types/proxy";
export const proxyApi = {
// ========== 代理服务器控制 API ==========
// 启动代理服务器
async startProxyServer(): Promise<ProxyServerInfo> {
return invoke("start_proxy_server");
},
// 停止代理服务器并恢复配置
async stopProxyWithRestore(): Promise<void> {
return invoke("stop_proxy_with_restore");
},
// 获取代理服务器状态
async getProxyStatus(): Promise<ProxyStatus> {
return invoke("get_proxy_status");
},
// 检查代理服务器是否正在运行
async isProxyRunning(): Promise<boolean> {
return invoke("is_proxy_running");
},
// 检查是否处于接管模式
async isLiveTakeoverActive(): Promise<boolean> {
return invoke("is_live_takeover_active");
},
// 代理模式下切换供应商
async switchProxyProvider(
appType: string,
providerId: string,
): Promise<void> {
return invoke("switch_proxy_provider", { appType, providerId });
},
// ========== 接管状态 API ==========
// 获取各应用接管状态
async getProxyTakeoverStatus(): Promise<ProxyTakeoverStatus> {
return invoke("get_proxy_takeover_status");
},
// 为指定应用开启/关闭接管
async setProxyTakeoverForApp(
appType: string,
enabled: boolean,
): Promise<void> {
return invoke("set_proxy_takeover_for_app", { appType, enabled });
},
// ========== Legacy 代理配置 API (兼容) ==========
// 获取代理配置(旧版 v2 兼容接口)
async getProxyConfig(): Promise<ProxyConfig> {
return invoke("get_proxy_config");
},
// 更新代理配置(旧版 v2 兼容接口)
async updateProxyConfig(config: ProxyConfig): Promise<void> {
return invoke("update_proxy_config", { config });
},
// ========== v3+ 全局/应用级配置 API ==========
// 获取全局代理配置
async getGlobalProxyConfig(): Promise<GlobalProxyConfig> {
return invoke("get_global_proxy_config");
},
// 更新全局代理配置
async updateGlobalProxyConfig(config: GlobalProxyConfig): Promise<void> {
return invoke("update_global_proxy_config", { config });
},
// 获取指定应用的代理配置
async getProxyConfigForApp(appType: string): Promise<AppProxyConfig> {
return invoke("get_proxy_config_for_app", { appType });
},
// 更新指定应用的代理配置
async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> {
return invoke("update_proxy_config_for_app", { config });
},
};
+1
View File
@@ -1,3 +1,4 @@
export * from "./queryClient";
export * from "./queries";
export * from "./mutations";
export * from "./proxy";
+239
View File
@@ -0,0 +1,239 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { proxyApi } from "@/lib/api/proxy";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { GlobalProxyConfig, AppProxyConfig } from "@/types/proxy";
// ========== 代理服务器状态 Hooks ==========
/**
* 获取代理服务器状态
*/
export function useProxyStatus() {
return useQuery({
queryKey: ["proxyStatus"],
queryFn: () => proxyApi.getProxyStatus(),
refetchInterval: 5000, // 每 5 秒刷新一次
});
}
/**
* 检查代理服务器是否运行
*/
export function useIsProxyRunning() {
return useQuery({
queryKey: ["proxyRunning"],
queryFn: () => proxyApi.isProxyRunning(),
refetchInterval: 2000,
});
}
/**
* 检查是否处于接管模式
*/
export function useIsLiveTakeoverActive() {
return useQuery({
queryKey: ["liveTakeoverActive"],
queryFn: () => proxyApi.isLiveTakeoverActive(),
refetchInterval: 2000,
});
}
/**
* 获取各应用接管状态
*/
export function useProxyTakeoverStatus() {
return useQuery({
queryKey: ["proxyTakeoverStatus"],
queryFn: () => proxyApi.getProxyTakeoverStatus(),
refetchInterval: 2000,
});
}
// ========== 代理服务器控制 Hooks ==========
/**
* 启动代理服务器
*/
export function useStartProxyServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => proxyApi.startProxyServer(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyRunning"] });
queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
},
});
}
/**
* 停止代理服务器
*/
export function useStopProxyServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => proxyApi.stopProxyWithRestore(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyRunning"] });
queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
},
});
}
/**
* 设置应用接管状态
*/
export function useSetProxyTakeoverForApp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
proxyApi.setProxyTakeoverForApp(appType, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] });
},
});
}
/**
* 代理模式下切换供应商
*/
export function useSwitchProxyProvider() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: ({
appType,
providerId,
}: {
appType: string;
providerId: string;
}) => proxyApi.switchProxyProvider(appType, providerId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({
queryKey: ["providers", variables.appType],
});
},
onError: (error: Error) => {
toast.error(t("proxy.switchFailed", { error: error.message }));
},
});
}
// ========== Legacy 代理配置 Hooks (兼容) ==========
/**
* 获取代理配置(旧版)
*/
export function useProxyConfig() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { data: config, isLoading } = useQuery({
queryKey: ["proxyConfig"],
queryFn: () => proxyApi.getProxyConfig(),
});
const updateMutation = useMutation({
mutationFn: proxyApi.updateProxyConfig,
onSuccess: () => {
toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
toast.error(
t("proxy.settings.toast.saveFailed", { error: error.message }),
);
},
});
return {
config,
isLoading,
updateConfig: updateMutation.mutateAsync,
isUpdating: updateMutation.isPending,
};
}
// ========== v3+ 全局/应用级配置 Hooks ==========
/**
* 获取全局代理配置
*/
export function useGlobalProxyConfig() {
return useQuery({
queryKey: ["globalProxyConfig"],
queryFn: () => proxyApi.getGlobalProxyConfig(),
});
}
/**
* 更新全局代理配置
*/
export function useUpdateGlobalProxyConfig() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (config: GlobalProxyConfig) =>
proxyApi.updateGlobalProxyConfig(config),
onSuccess: () => {
toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
queryClient.invalidateQueries({ queryKey: ["globalProxyConfig"] });
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
toast.error(
t("proxy.settings.toast.saveFailed", { error: error.message }),
);
},
});
}
/**
* 获取指定应用的代理配置
*/
export function useAppProxyConfig(appType: string) {
return useQuery({
queryKey: ["appProxyConfig", appType],
queryFn: () => proxyApi.getProxyConfigForApp(appType),
enabled: !!appType,
});
}
/**
* 更新指定应用的代理配置
*/
export function useUpdateAppProxyConfig() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (config: AppProxyConfig) =>
proxyApi.updateProxyConfigForApp(config),
onSuccess: (_, variables) => {
toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
queryClient.invalidateQueries({
queryKey: ["appProxyConfig", variables.appType],
});
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
queryClient.invalidateQueries({ queryKey: ["circuitBreakerConfig"] });
},
onError: (error: Error) => {
toast.error(
t("proxy.settings.toast.saveFailed", { error: error.message }),
);
},
});
}
+28
View File
@@ -5,6 +5,10 @@ export interface ProxyConfig {
request_timeout: number;
enable_logging: boolean;
live_takeover_active?: boolean;
// 超时配置
streaming_first_byte_timeout: number;
streaming_idle_timeout: number;
non_streaming_timeout: number;
}
export interface ProxyStatus {
@@ -105,3 +109,27 @@ export interface FailoverQueueItem {
providerName: string;
sortIndex?: number;
}
// 全局代理配置(统一字段,三行镜像)
export interface GlobalProxyConfig {
proxyEnabled: boolean;
listenAddress: string;
listenPort: number;
enableLogging: boolean;
}
// 应用级代理配置(每个 app 独立)
export interface AppProxyConfig {
appType: string;
enabled: boolean;
autoFailoverEnabled: boolean;
maxRetries: number;
streamingFirstByteTimeout: number;
streamingIdleTimeout: number;
nonStreamingTimeout: number;
circuitFailureThreshold: number;
circuitSuccessThreshold: number;
circuitTimeoutSeconds: number;
circuitErrorRateThreshold: number;
circuitMinRequests: number;
}