diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 7a6707a2..27d9f754 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -532,7 +532,7 @@ fn launch_terminal_with_env( #[cfg(target_os = "macos")] { launch_macos_terminal(&config_file, &config_path_escaped)?; - return Ok(()); + Ok(()) } #[cfg(target_os = "linux")] @@ -583,8 +583,7 @@ fn escape_shell_path(path: &std::path::Path) -> String { /// 生成 bash 包装脚本,用于清理临时文件 fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String { format!( - "bash -c 'trap \"rm -f \\\"{}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{}\"; claude --settings \"{}\"; exec bash --norc --noprofile'", - config_path, escaped_path, escaped_path + "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'" ) } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index b10c1233..12cd64ff 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -59,3 +59,24 @@ pub async fn set_auto_launch(enabled: bool) -> Result { pub async fn get_auto_launch_status() -> Result { 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 { + 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 { + state + .db + .set_rectifier_config(&config) + .map_err(|e| e.to_string())?; + Ok(true) +} diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 70839165..57ecc720 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -163,4 +163,27 @@ impl Database { log::info!("已清除所有代理接管状态"); Ok(()) } + + // --- 整流器配置 --- + + /// 获取整流器配置 + /// + /// 返回整流器配置,如果不存在则返回默认值(全部启用) + pub fn get_rectifier_config(&self) -> Result { + 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) + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 37e75b0e..7d719f58 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -734,6 +734,8 @@ pub fn run() { commands::read_live_provider_settings, commands::get_settings, commands::save_settings, + commands::get_rectifier_config, + commands::set_rectifier_config, commands::restart_app, commands::check_for_updates, commands::is_portable_mode, diff --git a/src-tauri/src/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs index 310ce9bc..890a5138 100644 --- a/src-tauri/src/proxy/circuit_breaker.rs +++ b/src-tauri/src/proxy/circuit_breaker.rs @@ -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); loop { if current == 0 { diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 2ad82d9b..7758d4b0 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -7,8 +7,9 @@ use super::{ error::*, failover_switch::FailoverSwitchManager, provider_router::ProviderRouter, - providers::{get_adapter, ProviderAdapter}, - types::ProxyStatus, + providers::{get_adapter, ProviderAdapter, ProviderType}, + thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature}, + types::{ProxyStatus, RectifierConfig}, ProxyError, }; use crate::{app_config::AppType, provider::Provider}; @@ -90,6 +91,8 @@ pub struct RequestForwarder { app_handle: Option, /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) current_provider_id_at_start: String, + /// 整流器配置 + rectifier_config: RectifierConfig, /// 非流式请求超时(秒) non_streaming_timeout: std::time::Duration, } @@ -106,6 +109,7 @@ impl RequestForwarder { current_provider_id_at_start: String, _streaming_first_byte_timeout: u64, _streaming_idle_timeout: u64, + rectifier_config: RectifierConfig, ) -> Self { Self { router, @@ -114,6 +118,7 @@ impl RequestForwarder { failover_manager, app_handle, current_provider_id_at_start, + rectifier_config, non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout), } } @@ -130,7 +135,7 @@ impl RequestForwarder { &self, app_type: &AppType, endpoint: &str, - body: Value, + mut body: Value, headers: axum::http::HeaderMap, providers: Vec, ) -> Result { @@ -149,6 +154,9 @@ impl RequestForwarder { let mut last_provider = None; let mut attempted_providers = 0usize; + // 整流器重试标记:确保整流最多触发一次 + let mut rectifier_retried = false; + // 单 Provider 场景下跳过熔断器检查(故障转移关闭时) let bypass_circuit_breaker = providers.len() == 1; @@ -243,6 +251,205 @@ impl RequestForwarder { }); } 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 .router @@ -504,3 +711,11 @@ impl RequestForwarder { } } } + +/// 从 ProxyError 中提取错误消息 +fn extract_error_message(error: &ProxyError) -> Option { + match error { + ProxyError::UpstreamError { body, .. } => body.clone(), + _ => Some(error.to_string()), + } +} diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index c398d6fd..b4de0374 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -5,7 +5,10 @@ use crate::app_config::AppType; use crate::provider::Provider; use crate::proxy::{ - extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, + extract_session_id, + forwarder::RequestForwarder, + server::ProxyState, + types::{AppProxyConfig, RectifierConfig}, ProxyError, }; use axum::http::HeaderMap; @@ -54,6 +57,8 @@ pub struct RequestContext { pub app_type: AppType, /// Session ID(从客户端请求提取或新生成) pub session_id: String, + /// 整流器配置 + pub rectifier_config: RectifierConfig, } impl RequestContext { @@ -86,6 +91,9 @@ impl RequestContext { .await .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; + // 从数据库读取整流器配置 + let rectifier_config = state.db.get_rectifier_config().unwrap_or_default(); + let current_provider_id = crate::settings::get_current_provider(&app_type).unwrap_or_default(); @@ -147,6 +155,7 @@ impl RequestContext { app_type_str, app_type, session_id, + rectifier_config, }) } @@ -206,6 +215,7 @@ impl RequestContext { self.current_provider_id.clone(), first_byte_timeout, idle_timeout, + self.rectifier_config.clone(), ) } diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 8d99ab24..338b845f 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -21,6 +21,7 @@ pub mod response_handler; pub mod response_processor; pub(crate) mod server; pub mod session; +pub mod thinking_rectifier; pub(crate) mod types; pub mod usage; diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index a793100b..5f3a315f 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -151,6 +151,24 @@ impl ProviderRouter { 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) { let breakers = self.circuit_breakers.read().await; @@ -325,4 +343,55 @@ mod tests { 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); + } } diff --git a/src-tauri/src/proxy/thinking_rectifier.rs b/src-tauri/src/proxy/thinking_rectifier.rs new file mode 100644 index 00000000..827ce561 --- /dev/null +++ b/src-tauri/src/proxy/thinking_rectifier.rs @@ -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 = 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":"","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 + } +} diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 778fe46a..f4996392 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -191,3 +191,67 @@ pub struct AppProxyConfig { /// 计算错误率的最小请求数 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); + } +} diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index dcf9b6bb..f847c493 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -301,7 +301,11 @@ export function ProviderCard({
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
diff --git a/src/components/settings/RectifierConfigPanel.tsx b/src/components/settings/RectifierConfigPanel.tsx new file mode 100644 index 00000000..eeb6e10b --- /dev/null +++ b/src/components/settings/RectifierConfigPanel.tsx @@ -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({ + 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) => { + 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 ( +
+
+
+ +

+ {t("settings.advanced.rectifier.enabledDescription")} +

+
+ handleChange({ enabled: checked })} + /> +
+ +
+

+ {t("settings.advanced.rectifier.requestGroup")} +

+
+
+ +

+ {t("settings.advanced.rectifier.thinkingSignatureDescription")} +

+
+ + handleChange({ requestThinkingSignature: checked }) + } + /> +
+
+
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 6c67b404..f464f6a4 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -9,6 +9,7 @@ import { Database, Server, ChevronDown, + Zap, Globe, } from "lucide-react"; 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 { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; +import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; import { useTranslation } from "react-i18next"; @@ -550,6 +552,28 @@ export function SettingsPage({ /> + + + +
+ +
+

+ {t("settings.advanced.rectifier.title")} +

+

+ {t("settings.advanced.rectifier.description")} +

+
+
+
+ + + +
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6fdb5f8f..017d34fa 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -190,6 +190,16 @@ "data": { "title": "Data Management", "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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d1eeafc3..ba3549f1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -190,6 +190,16 @@ "data": { "title": "データ管理", "description": "設定のインポート/エクスポートとバックアップ/復元" + }, + "rectifier": { + "title": "整流器", + "description": "API リクエストの互換性問題を自動修正", + "enabled": "整流器を有効化", + "enabledDescription": "マスタースイッチ、オフにするとすべての整流機能が無効になります", + "requestGroup": "リクエスト整流", + "responseGroup": "レスポンス整流", + "thinkingSignature": "Thinking 署名整流", + "thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正" } }, "language": "言語", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9dfa91f1..ab68a021 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -190,6 +190,16 @@ "data": { "title": "数据管理", "description": "导入导出配置与备份恢复" + }, + "rectifier": { + "title": "整流器", + "description": "自动修复 API 请求中的兼容性问题", + "enabled": "启用整流器", + "enabledDescription": "总开关,关闭后所有整流功能将被禁用", + "requestGroup": "请求整流", + "responseGroup": "响应整流", + "thinkingSignature": "Thinking 签名整流", + "thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误" } }, "language": "界面语言", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index b6428a0f..1f1e6e0a 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -134,4 +134,17 @@ export const settingsApi = { > { return await invoke("get_tool_versions"); }, + + async getRectifierConfig(): Promise { + return await invoke("get_rectifier_config"); + }, + + async setRectifierConfig(config: RectifierConfig): Promise { + return await invoke("set_rectifier_config", { config }); + }, }; + +export interface RectifierConfig { + enabled: boolean; + requestThinkingSignature: boolean; +}