mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-14 00:09:58 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9af769895 | |||
| b5fa2f92a3 | |||
| 1b3ee19f62 | |||
| f32d7494b9 | |||
| 6d6b948ae8 | |||
| 67556b8cbc | |||
| 86d96f8e52 | |||
| 0f8533ea98 | |||
| 8506522e26 | |||
| b19a0ef705 | |||
| c2b8fc655e | |||
| 4630831a5a | |||
| 5953d9db0d | |||
| d14c786b83 | |||
| 172191b592 |
@@ -532,7 +532,7 @@ fn launch_terminal_with_env(
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -583,8 +583,7 @@ fn escape_shell_path(path: &std::path::Path) -> String {
|
|||||||
/// 生成 bash 包装脚本,用于清理临时文件
|
/// 生成 bash 包装脚本,用于清理临时文件
|
||||||
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"bash -c 'trap \"rm -f \\\"{}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{}\"; claude --settings \"{}\"; exec bash --norc --noprofile'",
|
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
|
||||||
config_path, escaped_path, escaped_path
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,3 +59,24 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
|||||||
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
pub async fn get_auto_launch_status() -> Result<bool, String> {
|
||||||
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取整流器配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_rectifier_config(
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
) -> Result<crate::proxy::types::RectifierConfig, String> {
|
||||||
|
state.db.get_rectifier_config().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置整流器配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_rectifier_config(
|
||||||
|
state: tauri::State<'_, crate::AppState>,
|
||||||
|
config: crate::proxy::types::RectifierConfig,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_rectifier_config(&config)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -163,4 +163,27 @@ impl Database {
|
|||||||
log::info!("已清除所有代理接管状态");
|
log::info!("已清除所有代理接管状态");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 整流器配置 ---
|
||||||
|
|
||||||
|
/// 获取整流器配置
|
||||||
|
///
|
||||||
|
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
||||||
|
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
||||||
|
match self.get_setting("rectifier_config")? {
|
||||||
|
Some(json) => serde_json::from_str(&json)
|
||||||
|
.map_err(|e| AppError::Database(format!("解析整流器配置失败: {e}"))),
|
||||||
|
None => Ok(crate::proxy::types::RectifierConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新整流器配置
|
||||||
|
pub fn set_rectifier_config(
|
||||||
|
&self,
|
||||||
|
config: &crate::proxy::types::RectifierConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let json = serde_json::to_string(config)
|
||||||
|
.map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?;
|
||||||
|
self.set_setting("rectifier_config", &json)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -734,6 +734,8 @@ pub fn run() {
|
|||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
|
commands::get_rectifier_config,
|
||||||
|
commands::set_rectifier_config,
|
||||||
commands::restart_app,
|
commands::restart_app,
|
||||||
commands::check_for_updates,
|
commands::check_for_updates,
|
||||||
commands::is_portable_mode,
|
commands::is_portable_mode,
|
||||||
|
|||||||
@@ -319,7 +319,11 @@ impl CircuitBreaker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn release_half_open_permit(&self) {
|
/// 仅释放 HalfOpen permit,不影响健康统计
|
||||||
|
///
|
||||||
|
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||||
|
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||||
|
pub fn release_half_open_permit(&self) {
|
||||||
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||||
loop {
|
loop {
|
||||||
if current == 0 {
|
if current == 0 {
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ use super::{
|
|||||||
error::*,
|
error::*,
|
||||||
failover_switch::FailoverSwitchManager,
|
failover_switch::FailoverSwitchManager,
|
||||||
provider_router::ProviderRouter,
|
provider_router::ProviderRouter,
|
||||||
providers::{get_adapter, ProviderAdapter},
|
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||||
types::ProxyStatus,
|
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||||
|
types::{ProxyStatus, RectifierConfig},
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use crate::{app_config::AppType, provider::Provider};
|
use crate::{app_config::AppType, provider::Provider};
|
||||||
@@ -90,6 +91,8 @@ pub struct RequestForwarder {
|
|||||||
app_handle: Option<tauri::AppHandle>,
|
app_handle: Option<tauri::AppHandle>,
|
||||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
|
/// 整流器配置
|
||||||
|
rectifier_config: RectifierConfig,
|
||||||
/// 非流式请求超时(秒)
|
/// 非流式请求超时(秒)
|
||||||
non_streaming_timeout: std::time::Duration,
|
non_streaming_timeout: std::time::Duration,
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,7 @@ impl RequestForwarder {
|
|||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
_streaming_first_byte_timeout: u64,
|
_streaming_first_byte_timeout: u64,
|
||||||
_streaming_idle_timeout: u64,
|
_streaming_idle_timeout: u64,
|
||||||
|
rectifier_config: RectifierConfig,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
router,
|
router,
|
||||||
@@ -114,6 +118,7 @@ impl RequestForwarder {
|
|||||||
failover_manager,
|
failover_manager,
|
||||||
app_handle,
|
app_handle,
|
||||||
current_provider_id_at_start,
|
current_provider_id_at_start,
|
||||||
|
rectifier_config,
|
||||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +135,7 @@ impl RequestForwarder {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
body: Value,
|
mut body: Value,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
providers: Vec<Provider>,
|
providers: Vec<Provider>,
|
||||||
) -> Result<ForwardResult, ForwardError> {
|
) -> Result<ForwardResult, ForwardError> {
|
||||||
@@ -149,6 +154,9 @@ impl RequestForwarder {
|
|||||||
let mut last_provider = None;
|
let mut last_provider = None;
|
||||||
let mut attempted_providers = 0usize;
|
let mut attempted_providers = 0usize;
|
||||||
|
|
||||||
|
// 整流器重试标记:确保整流最多触发一次
|
||||||
|
let mut rectifier_retried = false;
|
||||||
|
|
||||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||||
let bypass_circuit_breaker = providers.len() == 1;
|
let bypass_circuit_breaker = providers.len() == 1;
|
||||||
|
|
||||||
@@ -243,6 +251,205 @@ impl RequestForwarder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商)
|
||||||
|
let provider_type = ProviderType::from_app_type_and_config(app_type, provider);
|
||||||
|
let is_anthropic_provider = matches!(
|
||||||
|
provider_type,
|
||||||
|
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||||
|
);
|
||||||
|
|
||||||
|
if is_anthropic_provider {
|
||||||
|
let error_message = extract_error_message(&e);
|
||||||
|
if should_rectify_thinking_signature(
|
||||||
|
error_message.as_deref(),
|
||||||
|
&self.rectifier_config,
|
||||||
|
) {
|
||||||
|
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||||
|
if rectifier_retried {
|
||||||
|
log::warn!("[{app_type_str}] [RECT-005] 整流器已触发过,不再重试");
|
||||||
|
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||||
|
self.router
|
||||||
|
.release_permit_neutral(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(e.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次触发:整流请求体
|
||||||
|
let rectified = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
// 整流未生效:直接返回错误(不可重试客户端错误)
|
||||||
|
if !rectified.applied {
|
||||||
|
log::warn!(
|
||||||
|
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
||||||
|
);
|
||||||
|
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||||
|
self.router
|
||||||
|
.release_permit_neutral(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(e.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||||
|
app_type_str,
|
||||||
|
rectified.removed_thinking_blocks,
|
||||||
|
rectified.removed_redacted_thinking_blocks,
|
||||||
|
rectified.removed_signature_fields
|
||||||
|
);
|
||||||
|
|
||||||
|
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||||
|
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||||
|
|
||||||
|
// 使用同一供应商重试(不计入熔断器)
|
||||||
|
match self
|
||||||
|
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||||
|
// 记录成功
|
||||||
|
let _ = self
|
||||||
|
.router
|
||||||
|
.record_result(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 更新当前应用类型使用的 provider
|
||||||
|
{
|
||||||
|
let mut current_providers =
|
||||||
|
self.current_providers.write().await;
|
||||||
|
current_providers.insert(
|
||||||
|
app_type_str.to_string(),
|
||||||
|
(provider.id.clone(), provider.name.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成功统计
|
||||||
|
{
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.success_requests += 1;
|
||||||
|
status.last_error = None;
|
||||||
|
let should_switch =
|
||||||
|
self.current_provider_id_at_start.as_str()
|
||||||
|
!= provider.id.as_str();
|
||||||
|
if should_switch {
|
||||||
|
status.failover_count += 1;
|
||||||
|
|
||||||
|
// 异步触发供应商切换,更新 UI/托盘
|
||||||
|
let fm = self.failover_manager.clone();
|
||||||
|
let ah = self.app_handle.clone();
|
||||||
|
let pid = provider.id.clone();
|
||||||
|
let pname = provider.name.clone();
|
||||||
|
let at = app_type_str.to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = fm
|
||||||
|
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(ForwardResult {
|
||||||
|
response,
|
||||||
|
provider: provider.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(retry_err) => {
|
||||||
|
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||||
|
log::warn!(
|
||||||
|
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||||
|
let is_provider_error = match &retry_err {
|
||||||
|
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
ProxyError::UpstreamError { status, .. } => *status >= 500,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_provider_error {
|
||||||
|
// Provider 问题:记录失败到熔断器
|
||||||
|
let _ = self
|
||||||
|
.router
|
||||||
|
.record_result(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
false,
|
||||||
|
Some(retry_err.to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
// 客户端问题:仅释放 permit,不记录熔断器
|
||||||
|
self.router
|
||||||
|
.release_permit_neutral(
|
||||||
|
&provider.id,
|
||||||
|
app_type_str,
|
||||||
|
used_half_open_permit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut status = self.status.write().await;
|
||||||
|
status.failed_requests += 1;
|
||||||
|
status.last_error = Some(retry_err.to_string());
|
||||||
|
if status.total_requests > 0 {
|
||||||
|
status.success_rate = (status.success_requests as f32
|
||||||
|
/ status.total_requests as f32)
|
||||||
|
* 100.0;
|
||||||
|
}
|
||||||
|
return Err(ForwardError {
|
||||||
|
error: retry_err,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 失败:记录失败并更新熔断器
|
// 失败:记录失败并更新熔断器
|
||||||
let _ = self
|
let _ = self
|
||||||
.router
|
.router
|
||||||
@@ -504,3 +711,11 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 ProxyError 中提取错误消息
|
||||||
|
fn extract_error_message(error: &ProxyError) -> Option<String> {
|
||||||
|
match error {
|
||||||
|
ProxyError::UpstreamError { body, .. } => body.clone(),
|
||||||
|
_ => Some(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::{
|
use crate::proxy::{
|
||||||
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
|
extract_session_id,
|
||||||
|
forwarder::RequestForwarder,
|
||||||
|
server::ProxyState,
|
||||||
|
types::{AppProxyConfig, RectifierConfig},
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
@@ -54,6 +57,8 @@ pub struct RequestContext {
|
|||||||
pub app_type: AppType,
|
pub app_type: AppType,
|
||||||
/// Session ID(从客户端请求提取或新生成)
|
/// Session ID(从客户端请求提取或新生成)
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
|
/// 整流器配置
|
||||||
|
pub rectifier_config: RectifierConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestContext {
|
impl RequestContext {
|
||||||
@@ -86,6 +91,9 @@ impl RequestContext {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
// 从数据库读取整流器配置
|
||||||
|
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||||
|
|
||||||
let current_provider_id =
|
let current_provider_id =
|
||||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||||
|
|
||||||
@@ -147,6 +155,7 @@ impl RequestContext {
|
|||||||
app_type_str,
|
app_type_str,
|
||||||
app_type,
|
app_type,
|
||||||
session_id,
|
session_id,
|
||||||
|
rectifier_config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +215,7 @@ impl RequestContext {
|
|||||||
self.current_provider_id.clone(),
|
self.current_provider_id.clone(),
|
||||||
first_byte_timeout,
|
first_byte_timeout,
|
||||||
idle_timeout,
|
idle_timeout,
|
||||||
|
self.rectifier_config.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod response_handler;
|
|||||||
pub mod response_processor;
|
pub mod response_processor;
|
||||||
pub(crate) mod server;
|
pub(crate) mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod thinking_rectifier;
|
||||||
pub(crate) mod types;
|
pub(crate) mod types;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,24 @@ impl ProviderRouter {
|
|||||||
self.reset_circuit_breaker(&circuit_key).await;
|
self.reset_circuit_breaker(&circuit_key).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅释放 HalfOpen permit,不影响健康统计(neutral 接口)
|
||||||
|
///
|
||||||
|
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||||
|
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||||
|
pub async fn release_permit_neutral(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
used_half_open_permit: bool,
|
||||||
|
) {
|
||||||
|
if !used_half_open_permit {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let circuit_key = format!("{app_type}:{provider_id}");
|
||||||
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
breaker.release_half_open_permit();
|
||||||
|
}
|
||||||
|
|
||||||
/// 更新所有熔断器的配置(热更新)
|
/// 更新所有熔断器的配置(热更新)
|
||||||
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
||||||
let breakers = self.circuit_breakers.read().await;
|
let breakers = self.circuit_breakers.read().await;
|
||||||
@@ -325,4 +343,55 @@ mod tests {
|
|||||||
|
|
||||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_release_permit_neutral_frees_half_open_slot() {
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
|
||||||
|
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||||
|
failure_threshold: 1,
|
||||||
|
timeout_seconds: 0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
|
||||||
|
// 启用自动故障转移
|
||||||
|
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());
|
||||||
|
|
||||||
|
// 触发熔断:1 次失败
|
||||||
|
router
|
||||||
|
.record_result("a", "claude", false, false, Some("fail".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 第一次请求:获取 HalfOpen 探测名额
|
||||||
|
let first = router.allow_provider_request("a", "claude").await;
|
||||||
|
assert!(first.allowed);
|
||||||
|
assert!(first.used_half_open_permit);
|
||||||
|
|
||||||
|
// 第二次请求应被拒绝(名额已被占用)
|
||||||
|
let second = router.allow_provider_request("a", "claude").await;
|
||||||
|
assert!(!second.allowed);
|
||||||
|
|
||||||
|
// 使用 release_permit_neutral 释放名额(不影响健康统计)
|
||||||
|
router
|
||||||
|
.release_permit_neutral("a", "claude", first.used_half_open_permit)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 第三次请求应被允许(名额已释放)
|
||||||
|
let third = router.allow_provider_request("a", "claude").await;
|
||||||
|
assert!(third.allowed);
|
||||||
|
assert!(third.used_half_open_permit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
//! Thinking Signature 整流器
|
||||||
|
//!
|
||||||
|
//! 用于自动修复 Anthropic API 中因签名校验失败导致的请求错误。
|
||||||
|
//! 当上游 API 返回签名相关错误时,系统会自动移除有问题的签名字段并重试请求。
|
||||||
|
|
||||||
|
use super::types::RectifierConfig;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// 整流结果
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RectifyResult {
|
||||||
|
/// 是否应用了整流
|
||||||
|
pub applied: bool,
|
||||||
|
/// 移除的 thinking block 数量
|
||||||
|
pub removed_thinking_blocks: usize,
|
||||||
|
/// 移除的 redacted_thinking block 数量
|
||||||
|
pub removed_redacted_thinking_blocks: usize,
|
||||||
|
/// 移除的 signature 字段数量
|
||||||
|
pub removed_signature_fields: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测是否需要触发 thinking 签名整流器
|
||||||
|
///
|
||||||
|
/// 返回 `true` 表示需要触发整流器,`false` 表示不需要。
|
||||||
|
/// 会检查配置开关。
|
||||||
|
pub fn should_rectify_thinking_signature(
|
||||||
|
error_message: Option<&str>,
|
||||||
|
config: &RectifierConfig,
|
||||||
|
) -> bool {
|
||||||
|
// 检查总开关
|
||||||
|
if !config.enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 检查子开关
|
||||||
|
if !config.request_thinking_signature {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测错误类型
|
||||||
|
let Some(msg) = error_message else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let lower = msg.to_lowercase();
|
||||||
|
|
||||||
|
// 场景1: thinking block 中的签名无效
|
||||||
|
// 错误示例: "Invalid 'signature' in 'thinking' block"
|
||||||
|
if lower.contains("invalid")
|
||||||
|
&& lower.contains("signature")
|
||||||
|
&& lower.contains("thinking")
|
||||||
|
&& lower.contains("block")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景2: assistant 消息必须以 thinking block 开头
|
||||||
|
// 错误示例: "must start with a thinking block"
|
||||||
|
if lower.contains("must start with a thinking block") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景3: expected thinking or redacted_thinking, found tool_use
|
||||||
|
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
|
||||||
|
if lower.contains("expected")
|
||||||
|
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||||
|
&& lower.contains("found")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景4: signature 字段必需但缺失
|
||||||
|
// 错误示例: "signature: Field required"
|
||||||
|
if lower.contains("signature") && lower.contains("field required") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对 Anthropic 请求体做最小侵入整流
|
||||||
|
///
|
||||||
|
/// - 移除 messages[*].content 中的 thinking/redacted_thinking block
|
||||||
|
/// - 移除非 thinking block 上遗留的 signature 字段
|
||||||
|
/// - 特定条件下删除顶层 thinking 字段
|
||||||
|
///
|
||||||
|
/// 注意:该函数会原地修改 body 对象
|
||||||
|
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
|
||||||
|
let mut result = RectifyResult::default();
|
||||||
|
|
||||||
|
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return result,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 遍历所有消息
|
||||||
|
for msg in messages.iter_mut() {
|
||||||
|
let content = match msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut new_content = Vec::with_capacity(content.len());
|
||||||
|
let mut content_modified = false;
|
||||||
|
|
||||||
|
for block in content.iter() {
|
||||||
|
let block_type = block.get("type").and_then(|t| t.as_str());
|
||||||
|
|
||||||
|
match block_type {
|
||||||
|
Some("thinking") => {
|
||||||
|
result.removed_thinking_blocks += 1;
|
||||||
|
content_modified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some("redacted_thinking") => {
|
||||||
|
result.removed_redacted_thinking_blocks += 1;
|
||||||
|
content_modified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除非 thinking block 上的 signature 字段
|
||||||
|
if block.get("signature").is_some() {
|
||||||
|
let mut block_clone = block.clone();
|
||||||
|
if let Some(obj) = block_clone.as_object_mut() {
|
||||||
|
obj.remove("signature");
|
||||||
|
result.removed_signature_fields += 1;
|
||||||
|
content_modified = true;
|
||||||
|
new_content.push(Value::Object(obj.clone()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new_content.push(block.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_modified {
|
||||||
|
result.applied = true;
|
||||||
|
*content = new_content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底处理:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking 开头
|
||||||
|
let messages_snapshot: Vec<Value> = body
|
||||||
|
.get("messages")
|
||||||
|
.and_then(|m| m.as_array())
|
||||||
|
.map(|a| a.to_vec())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if should_remove_top_level_thinking(body, &messages_snapshot) {
|
||||||
|
if let Some(obj) = body.as_object_mut() {
|
||||||
|
obj.remove("thinking");
|
||||||
|
result.applied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否需要删除顶层 thinking 字段
|
||||||
|
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||||
|
// 检查 thinking 是否启用
|
||||||
|
let thinking_enabled = body
|
||||||
|
.get("thinking")
|
||||||
|
.and_then(|t| t.get("type"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
== Some("enabled");
|
||||||
|
|
||||||
|
if !thinking_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到最后一条 assistant 消息
|
||||||
|
let last_assistant = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"));
|
||||||
|
|
||||||
|
let last_assistant_content = match last_assistant
|
||||||
|
.and_then(|m| m.get("content"))
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
{
|
||||||
|
Some(c) if !c.is_empty() => c,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查首块是否为 thinking/redacted_thinking
|
||||||
|
let first_block_type = last_assistant_content
|
||||||
|
.first()
|
||||||
|
.and_then(|b| b.get("type"))
|
||||||
|
.and_then(|t| t.as_str());
|
||||||
|
|
||||||
|
let missing_thinking_prefix =
|
||||||
|
first_block_type != Some("thinking") && first_block_type != Some("redacted_thinking");
|
||||||
|
|
||||||
|
if !missing_thinking_prefix {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否存在 tool_use
|
||||||
|
last_assistant_content
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn enabled_config() -> RectifierConfig {
|
||||||
|
RectifierConfig {
|
||||||
|
enabled: true,
|
||||||
|
request_thinking_signature: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disabled_config() -> RectifierConfig {
|
||||||
|
RectifierConfig {
|
||||||
|
enabled: true,
|
||||||
|
request_thinking_signature: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn master_disabled_config() -> RectifierConfig {
|
||||||
|
RectifierConfig {
|
||||||
|
enabled: false,
|
||||||
|
request_thinking_signature: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== should_rectify_thinking_signature 测试 ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_invalid_signature() {
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("messages.1.content.0: Invalid `signature` in `thinking` block"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_invalid_signature_no_backticks() {
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("Messages.1.Content.0: invalid signature in thinking block"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_invalid_signature_nested_json() {
|
||||||
|
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
|
||||||
|
let nested_error = r#"{"error":{"message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some(nested_error),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_thinking_expected() {
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`."),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_must_start_with_thinking() {
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("a final `assistant` message must start with a thinking block"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_trigger_for_unrelated_error() {
|
||||||
|
assert!(!should_rectify_thinking_signature(
|
||||||
|
Some("Request timeout"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
assert!(!should_rectify_thinking_signature(
|
||||||
|
Some("Connection refused"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
assert!(!should_rectify_thinking_signature(None, &enabled_config()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_signature_field_required() {
|
||||||
|
// 场景4: signature 字段缺失
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some("***.***.***.***.***.signature: Field required"),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
// 嵌套 JSON 格式
|
||||||
|
let nested_error = r#"{"error":{"type":"<nil>","message":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"***.***.***.***.***.signature: Field required\"},\"request_id\":\"req_xxx\"}"}}"#;
|
||||||
|
assert!(should_rectify_thinking_signature(
|
||||||
|
Some(nested_error),
|
||||||
|
&enabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_disabled_config() {
|
||||||
|
// 即使错误匹配,配置关闭时也不触发
|
||||||
|
assert!(!should_rectify_thinking_signature(
|
||||||
|
Some("Invalid `signature` in `thinking` block"),
|
||||||
|
&disabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_master_disabled() {
|
||||||
|
// 总开关关闭时,即使子开关开启也不触发
|
||||||
|
assert!(!should_rectify_thinking_signature(
|
||||||
|
Some("Invalid `signature` in `thinking` block"),
|
||||||
|
&master_disabled_config()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== rectify_anthropic_request 测试 ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_removes_thinking_blocks() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "claude-test",
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{ "type": "thinking", "thinking": "t", "signature": "sig" },
|
||||||
|
{ "type": "text", "text": "hello", "signature": "sig_text" },
|
||||||
|
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {}, "signature": "sig_tool" },
|
||||||
|
{ "type": "redacted_thinking", "data": "r", "signature": "sig_redacted" }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
assert!(result.applied);
|
||||||
|
assert_eq!(result.removed_thinking_blocks, 1);
|
||||||
|
assert_eq!(result.removed_redacted_thinking_blocks, 1);
|
||||||
|
assert_eq!(result.removed_signature_fields, 2);
|
||||||
|
|
||||||
|
let content = body["messages"][0]["content"].as_array().unwrap();
|
||||||
|
assert_eq!(content.len(), 2);
|
||||||
|
assert_eq!(content[0]["type"], "text");
|
||||||
|
assert!(content[0].get("signature").is_none());
|
||||||
|
assert_eq!(content[1]["type"], "tool_use");
|
||||||
|
assert!(content[1].get("signature").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_removes_top_level_thinking() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "claude-test",
|
||||||
|
"thinking": { "type": "enabled", "budget_tokens": 1024 },
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
|
||||||
|
]
|
||||||
|
}, {
|
||||||
|
"role": "user",
|
||||||
|
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
assert!(result.applied);
|
||||||
|
assert!(body.get("thinking").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_no_change_when_no_issues() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "claude-test",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{ "type": "text", "text": "hello" }]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
assert!(!result.applied);
|
||||||
|
assert_eq!(result.removed_thinking_blocks, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_no_messages() {
|
||||||
|
let mut body = json!({ "model": "claude-test" });
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
assert!(!result.applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectify_preserves_thinking_when_prefix_exists() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "claude-test",
|
||||||
|
"thinking": { "type": "enabled" },
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{ "type": "thinking", "thinking": "some thought" },
|
||||||
|
{ "type": "tool_use", "id": "toolu_1", "name": "Test", "input": {} }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = rectify_anthropic_request(&mut body);
|
||||||
|
|
||||||
|
// thinking block 被移除,但顶层 thinking 不应被移除(因为原本有 thinking 前缀)
|
||||||
|
assert!(result.applied);
|
||||||
|
assert_eq!(result.removed_thinking_blocks, 1);
|
||||||
|
// 注意:由于 thinking block 被移除后,首块变成了 tool_use,
|
||||||
|
// 此时会触发删除顶层 thinking 的逻辑
|
||||||
|
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,3 +191,67 @@ pub struct AppProxyConfig {
|
|||||||
/// 计算错误率的最小请求数
|
/// 计算错误率的最小请求数
|
||||||
pub circuit_min_requests: u32,
|
pub circuit_min_requests: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 整流器配置
|
||||||
|
///
|
||||||
|
/// 存储在 settings 表中
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RectifierConfig {
|
||||||
|
/// 总开关:是否启用整流器
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 请求整流:启用 thinking 签名整流器
|
||||||
|
///
|
||||||
|
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub request_thinking_signature: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RectifierConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
request_thinking_signature: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectifier_config_default_enabled() {
|
||||||
|
// 验证 RectifierConfig::default() 返回全启用状态
|
||||||
|
// 防止回归:#[derive(Default)] 会使 bool 默认为 false
|
||||||
|
let config = RectifierConfig::default();
|
||||||
|
assert!(config.enabled, "整流器总开关默认应为 true");
|
||||||
|
assert!(
|
||||||
|
config.request_thinking_signature,
|
||||||
|
"thinking 签名整流器默认应为 true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectifier_config_serde_default() {
|
||||||
|
// 验证反序列化缺字段时使用 default_true
|
||||||
|
let json = "{}";
|
||||||
|
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert!(config.request_thinking_signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rectifier_config_serde_explicit_false() {
|
||||||
|
// 验证显式设置 false 时正确反序列化
|
||||||
|
let json = r#"{"enabled": false, "requestThinkingSignature": false}"#;
|
||||||
|
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!config.enabled);
|
||||||
|
assert!(!config.request_thinking_signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -301,7 +301,11 @@ export function ProviderCard({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative flex items-center ml-auto min-w-0 gap-3"
|
className="relative flex items-center ml-auto min-w-0 gap-3"
|
||||||
style={{ "--actions-width": `${actionsWidth || 320}px` } as React.CSSProperties}
|
style={
|
||||||
|
{
|
||||||
|
"--actions-width": `${actionsWidth || 320}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
|
||||||
|
|
||||||
|
export function RectifierConfigPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [config, setConfig] = useState<RectifierConfig>({
|
||||||
|
enabled: true,
|
||||||
|
requestThinkingSignature: true,
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
settingsApi
|
||||||
|
.getRectifierConfig()
|
||||||
|
.then(setConfig)
|
||||||
|
.catch((e) => console.error("Failed to load rectifier config:", e))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = async (updates: Partial<RectifierConfig>) => {
|
||||||
|
const newConfig = { ...config, ...updates };
|
||||||
|
setConfig(newConfig);
|
||||||
|
try {
|
||||||
|
await settingsApi.setRectifierConfig(newConfig);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save rectifier config:", e);
|
||||||
|
toast.error(String(e));
|
||||||
|
setConfig(config);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>{t("settings.advanced.rectifier.enabled")}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.advanced.rectifier.enabledDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.enabled}
|
||||||
|
onCheckedChange={(checked) => handleChange({ enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("settings.advanced.rectifier.requestGroup")}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center justify-between pl-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>{t("settings.advanced.rectifier.thinkingSignature")}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.advanced.rectifier.thinkingSignatureDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.requestThinkingSignature}
|
||||||
|
disabled={!config.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange({ requestThinkingSignature: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Server,
|
Server,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Zap,
|
||||||
Globe,
|
Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
@@ -42,6 +43,7 @@ import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
|||||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
||||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||||
|
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
import { useImportExport } from "@/hooks/useImportExport";
|
import { useImportExport } from "@/hooks/useImportExport";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -550,6 +552,28 @@ export function SettingsPage({
|
|||||||
/>
|
/>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem
|
||||||
|
value="rectifier"
|
||||||
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="h-5 w-5 text-purple-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.rectifier.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.rectifier.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<RectifierConfigPanel />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
|
|||||||
@@ -190,6 +190,16 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"title": "Data Management",
|
"title": "Data Management",
|
||||||
"description": "Import/export configurations and backup/restore"
|
"description": "Import/export configurations and backup/restore"
|
||||||
|
},
|
||||||
|
"rectifier": {
|
||||||
|
"title": "Rectifier",
|
||||||
|
"description": "Automatically fix API request compatibility issues",
|
||||||
|
"enabled": "Enable Rectifier",
|
||||||
|
"enabledDescription": "Master switch, all rectification features will be disabled when turned off",
|
||||||
|
"requestGroup": "Request Rectification",
|
||||||
|
"responseGroup": "Response Rectification",
|
||||||
|
"thinkingSignature": "Thinking Signature Rectification",
|
||||||
|
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
|||||||
@@ -190,6 +190,16 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"title": "データ管理",
|
"title": "データ管理",
|
||||||
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
||||||
|
},
|
||||||
|
"rectifier": {
|
||||||
|
"title": "整流器",
|
||||||
|
"description": "API リクエストの互換性問題を自動修正",
|
||||||
|
"enabled": "整流器を有効化",
|
||||||
|
"enabledDescription": "マスタースイッチ、オフにするとすべての整流機能が無効になります",
|
||||||
|
"requestGroup": "リクエスト整流",
|
||||||
|
"responseGroup": "レスポンス整流",
|
||||||
|
"thinkingSignature": "Thinking 署名整流",
|
||||||
|
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
|
|||||||
@@ -190,6 +190,16 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"title": "数据管理",
|
"title": "数据管理",
|
||||||
"description": "导入导出配置与备份恢复"
|
"description": "导入导出配置与备份恢复"
|
||||||
|
},
|
||||||
|
"rectifier": {
|
||||||
|
"title": "整流器",
|
||||||
|
"description": "自动修复 API 请求中的兼容性问题",
|
||||||
|
"enabled": "启用整流器",
|
||||||
|
"enabledDescription": "总开关,关闭后所有整流功能将被禁用",
|
||||||
|
"requestGroup": "请求整流",
|
||||||
|
"responseGroup": "响应整流",
|
||||||
|
"thinkingSignature": "Thinking 签名整流",
|
||||||
|
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"language": "界面语言",
|
"language": "界面语言",
|
||||||
|
|||||||
@@ -134,4 +134,17 @@ export const settingsApi = {
|
|||||||
> {
|
> {
|
||||||
return await invoke("get_tool_versions");
|
return await invoke("get_tool_versions");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getRectifierConfig(): Promise<RectifierConfig> {
|
||||||
|
return await invoke("get_rectifier_config");
|
||||||
|
},
|
||||||
|
|
||||||
|
async setRectifierConfig(config: RectifierConfig): Promise<boolean> {
|
||||||
|
return await invoke("set_rectifier_config", { config });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface RectifierConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
requestThinkingSignature: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user