mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-07 03:34:20 +08:00
feat(proxy): add thinking signature rectifier for Claude API (#595)
* feat(proxy): add thinking signature rectifier for Claude API Add automatic request rectification when Anthropic API returns signature validation errors. This improves compatibility when switching between different Claude providers or when historical messages contain incompatible thinking block signatures. - Add thinking_rectifier.rs module with trigger detection and rectification - Integrate rectifier into forwarder error handling flow - Remove thinking/redacted_thinking blocks and signature fields on retry - Delete top-level thinking field when assistant message lacks thinking prefix * fix(proxy): complete rectifier retry path with failover switch and chain continuation - Add failover switch trigger on rectifier retry success when provider differs from start - Replace direct error return with error categorization on rectifier retry failure - Continue failover chain for retryable errors instead of terminating early * feat(proxy): add rectifier config with master switch - Add RectifierConfig struct with enabled and requestThinkingSignature fields - Update should_rectify_thinking_signature to check master switch first - Add tests for master switch functionality * feat(db): add rectifier config storage in settings table Store rectifier config as JSON in single key for extensibility * feat(commands): add get/set rectifier config commands * feat(ui): add rectifier config panel in advanced settings - Add RectifierConfigPanel component with master switch and thinking signature toggle - Add API wrapper for rectifier config - Add i18n translations for zh/en/ja * feat(proxy): integrate rectifier config into request forwarding - Load rectifier config from database in RequestContext - Pass config to RequestForwarder for runtime checking - Use should_rectify_thinking_signature with config parameter * test(proxy): add nested JSON error detection test for thinking rectifier * fix(proxy): resolve HalfOpen permit leak and RectifierConfig default values - Fix RectifierConfig::default() to return enabled=true (was false due to derive) - Add release_permit_neutral() for releasing permits without affecting health stats - Fix 3 permit leak points in rectifier retry branches - Add unit tests for default values and permit release * style(ui): format ProviderCard style attribute * fix(rectifier): add detection for signature field required error Add support for detecting "signature: Field required" error pattern in the thinking signature rectifier. This enables automatic request rectification when upstream API returns this specific validation error.
This commit is contained in:
@@ -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'"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
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!("已清除所有代理接管状态");
|
||||
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::get_settings,
|
||||
commands::save_settings,
|
||||
commands::get_rectifier_config,
|
||||
commands::set_rectifier_config,
|
||||
commands::restart_app,
|
||||
commands::check_for_updates,
|
||||
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);
|
||||
loop {
|
||||
if current == 0 {
|
||||
|
||||
@@ -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<tauri::AppHandle>,
|
||||
/// 请求开始时的"当前供应商 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<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
@@ -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<String> {
|
||||
match error {
|
||||
ProxyError::UpstreamError { body, .. } => body.clone(),
|
||||
_ => Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/// 整流器配置
|
||||
///
|
||||
/// 存储在 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
|
||||
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 时向左移动,为操作按钮腾出空间 */}
|
||||
<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,
|
||||
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({
|
||||
/>
|
||||
</AccordionContent>
|
||||
</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>
|
||||
|
||||
<div className="pt-4">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -190,6 +190,16 @@
|
||||
"data": {
|
||||
"title": "データ管理",
|
||||
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
||||
},
|
||||
"rectifier": {
|
||||
"title": "整流器",
|
||||
"description": "API リクエストの互換性問題を自動修正",
|
||||
"enabled": "整流器を有効化",
|
||||
"enabledDescription": "マスタースイッチ、オフにするとすべての整流機能が無効になります",
|
||||
"requestGroup": "リクエスト整流",
|
||||
"responseGroup": "レスポンス整流",
|
||||
"thinkingSignature": "Thinking 署名整流",
|
||||
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
|
||||
}
|
||||
},
|
||||
"language": "言語",
|
||||
|
||||
@@ -190,6 +190,16 @@
|
||||
"data": {
|
||||
"title": "数据管理",
|
||||
"description": "导入导出配置与备份恢复"
|
||||
},
|
||||
"rectifier": {
|
||||
"title": "整流器",
|
||||
"description": "自动修复 API 请求中的兼容性问题",
|
||||
"enabled": "启用整流器",
|
||||
"enabledDescription": "总开关,关闭后所有整流功能将被禁用",
|
||||
"requestGroup": "请求整流",
|
||||
"responseGroup": "响应整流",
|
||||
"thinkingSignature": "Thinking 签名整流",
|
||||
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
|
||||
}
|
||||
},
|
||||
"language": "界面语言",
|
||||
|
||||
@@ -134,4 +134,17 @@ export const settingsApi = {
|
||||
> {
|
||||
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