feat(proxy): align thinking rectifiers and resolve clippy warnings

- add thinking budget rectifier flow with single retry on anthropic budget errors

- align thinking signature rectification behavior with adaptive-safe handling

- expose requestThinkingBudget in settings/ui/i18n and default rectifier config to disabled

- fix clippy warnings in model_mapper format args and RectifierConfig default derive
This commit is contained in:
YoVinchen
2026-02-12 00:46:42 +08:00
parent e349012abc
commit 579df5ad99
13 changed files with 881 additions and 40 deletions
+1 -1
View File
@@ -323,7 +323,7 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
let mut search_paths: Vec<std::path::PathBuf> = vec![
home.join(".local/bin"), // Native install (official recommended)
home.join(".npm-global/bin"),
home.join("n/bin"), // n version manager
home.join("n/bin"), // n version manager
home.join(".volta/bin"), // Volta package manager
];
+1 -1
View File
@@ -168,7 +168,7 @@ impl Database {
/// 获取整流器配置
///
/// 返回整流器配置,如果不存在则返回默认值(全部启用
/// 返回整流器配置,如果不存在则返回默认值(全部关闭
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)
+190 -22
View File
@@ -8,7 +8,10 @@ use super::{
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter, ProviderType},
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
thinking_rectifier::{
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
},
types::{ProxyStatus, RectifierConfig},
ProxyError,
};
@@ -157,6 +160,7 @@ impl RequestForwarder {
// 整流器重试标记:确保整流最多触发一次
let mut rectifier_retried = false;
let mut budget_rectifier_retried = false;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
@@ -293,12 +297,184 @@ impl RequestForwarder {
// 首次触发:整流请求体
let rectified = rectify_anthropic_request(&mut body);
// 整流未生效:直接返回错误(不可重试客户端错误)
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget 整流路径"
);
} else {
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()),
});
}
}
}
}
}
// 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商)
if is_anthropic_provider {
let error_message = extract_error_message(&e);
if should_rectify_thinking_budget(
error_message.as_deref(),
&self.rectifier_config,
) {
// 已经重试过:直接返回错误(不可重试客户端错误)
if budget_rectifier_retried {
log::warn!(
"[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试"
);
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 budget_rectified = rectify_thinking_budget(&mut body);
if !budget_rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
);
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
self.router
.release_permit_neutral(
&provider.id,
@@ -321,15 +497,14 @@ impl RequestForwarder {
}
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
"[{}] [RECT-010] thinking budget 整流器触发, type_changed={}, budget_changed={}, max_tokens_changed={}",
app_type_str,
rectified.removed_thinking_blocks,
rectified.removed_redacted_thinking_blocks,
rectified.removed_signature_fields
budget_rectified.type_changed,
budget_rectified.budget_changed,
budget_rectified.max_tokens_changed
);
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
let _ = std::mem::replace(&mut rectifier_retried, true);
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
// 使用同一供应商重试(不计入熔断器)
match self
@@ -337,8 +512,7 @@ impl RequestForwarder {
.await
{
Ok(response) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
let _ = self
.router
.record_result(
@@ -350,7 +524,6 @@ impl RequestForwarder {
)
.await;
// 更新当前应用类型使用的 provider
{
let mut current_providers =
self.current_providers.write().await;
@@ -360,7 +533,6 @@ impl RequestForwarder {
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
@@ -370,14 +542,11 @@ impl RequestForwarder {
!= 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)
@@ -397,12 +566,10 @@ impl RequestForwarder {
});
}
Err(retry_err) => {
// 整流重试仍失败:区分错误类型决定是否记录熔断器
log::warn!(
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
);
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
let is_provider_error = match &retry_err {
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
true
@@ -412,7 +579,6 @@ impl RequestForwarder {
};
if is_provider_error {
// Provider 问题:记录失败到熔断器
let _ = self
.router
.record_result(
@@ -424,7 +590,6 @@ impl RequestForwarder {
)
.await;
} else {
// 客户端问题:仅释放 permit,不记录熔断器
self.router
.release_permit_neutral(
&provider.id,
@@ -575,6 +740,9 @@ impl RequestForwarder {
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mapped_body = normalize_thinking_type(mapped_body);
// 转换请求体(如果需要)
let request_body = if needs_transform {
adapter.transform_request(mapped_body, provider)?
+1
View File
@@ -21,6 +21,7 @@ pub mod response_handler;
pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub mod thinking_budget_rectifier;
pub mod thinking_rectifier;
pub(crate) mod types;
pub mod usage;
+36 -2
View File
@@ -97,11 +97,21 @@ impl ModelMapping {
/// 检测请求是否启用了 thinking 模式
pub fn has_thinking_enabled(body: &Value) -> bool {
body.get("thinking")
match body
.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled")
{
Some("enabled") | Some("adaptive") => true,
Some("disabled") | None => false,
Some(other) => {
log::warn!(
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
);
false
}
}
}
/// 对请求体应用模型映射
@@ -300,6 +310,30 @@ mod tests {
assert!(mapped.is_none());
}
#[test]
fn test_thinking_adaptive() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "adaptive"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_thinking_unknown_type() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "some_future_type"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_case_insensitive() {
let provider = create_provider_with_mapping();
@@ -0,0 +1,333 @@
//! Thinking Budget 整流器
//!
//! 用于自动修复 Anthropic API 中因 thinking budget 约束导致的请求错误。
//! 当上游 API 返回 budget_tokens 相关错误时,系统会自动调整 budget 参数并重试。
use super::types::RectifierConfig;
use serde_json::Value;
/// 最大 thinking budget tokens
const MAX_THINKING_BUDGET: u64 = 32000;
/// 最大 max_tokens 值
const MAX_TOKENS_VALUE: u64 = 64000;
/// max_tokens 必须大于 budget_tokens
const MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1;
/// Budget 整流结果
#[derive(Debug, Clone, Default)]
pub struct BudgetRectifyResult {
/// 是否应用了整流
pub applied: bool,
/// 是否修改了 thinking type
pub type_changed: bool,
/// 是否修改了 budget_tokens
pub budget_changed: bool,
/// 是否修改了 max_tokens
pub max_tokens_changed: bool,
}
/// 检测是否需要触发 thinking budget 整流器
///
/// 检测条件:error message 同时包含 `budget_tokens` + `thinking` 相关约束
pub fn should_rectify_thinking_budget(
error_message: Option<&str>,
config: &RectifierConfig,
) -> bool {
// 检查总开关
if !config.enabled {
return false;
}
// 检查子开关
if !config.request_thinking_budget {
return false;
}
let Some(msg) = error_message else {
return false;
};
let lower = msg.to_lowercase();
// 覆盖常见上游文案变体:
// - budget_tokens >= 1024 约束
// - budget_tokens 与 max_tokens 关系约束
let has_budget_tokens_reference =
lower.contains("budget_tokens") || lower.contains("budget tokens");
let has_1024_constraint = lower.contains("greater than or equal to 1024")
|| lower.contains(">= 1024")
|| lower.contains("at least 1024")
|| (lower.contains("1024") && lower.contains("input should be"));
let has_max_tokens_constraint = lower.contains("less than max_tokens")
|| (lower.contains("budget_tokens")
&& lower.contains("max_tokens")
&& (lower.contains("must be less than") || lower.contains("should be less than")));
let has_thinking_reference = lower.contains("thinking");
if has_budget_tokens_reference && (has_1024_constraint || has_max_tokens_constraint) {
return true;
}
// 兜底:部分网关会省略 budget_tokens 字段名,但保留 thinking + 1024 线索
if has_thinking_reference && has_1024_constraint {
return true;
}
false
}
/// 对请求体执行 budget 整流
///
/// 整流动作:
/// - `thinking.type = "enabled"`
/// - `thinking.budget_tokens = 32000`
/// - 如果 `max_tokens < 32001`,设为 `64000`
pub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult {
let mut result = BudgetRectifyResult::default();
// 仅允许对显式 thinking.type=enabled 的请求做 budget 整流,避免静默语义升级。
let Some(thinking_obj) = body.get("thinking").and_then(|t| t.as_object()) else {
log::warn!("[RECT-BUD-001] budget 整流命中但请求缺少 thinking 对象,跳过");
return result;
};
let current_type = thinking_obj.get("type").and_then(|t| t.as_str());
if current_type == Some("adaptive") {
log::warn!("[RECT-BUD-002] budget 整流命中但 thinking.type=adaptive,跳过");
return result;
}
if current_type != Some("enabled") {
log::warn!(
"[RECT-BUD-003] budget 整流命中但 thinking.type 不是 enabled(当前: {}),跳过",
current_type.unwrap_or("<missing>")
);
return result;
}
let Some(thinking) = body.get_mut("thinking").and_then(|t| t.as_object_mut()) else {
return result;
};
// 设置 budget_tokens = MAX_THINKING_BUDGET
let current_budget = thinking.get("budget_tokens").and_then(|v| v.as_u64());
if current_budget != Some(MAX_THINKING_BUDGET) {
thinking.insert(
"budget_tokens".to_string(),
Value::Number(MAX_THINKING_BUDGET.into()),
);
result.budget_changed = true;
}
// 确保 max_tokens >= MIN_MAX_TOKENS_FOR_BUDGET
let current_max_tokens = body.get("max_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
if current_max_tokens < MIN_MAX_TOKENS_FOR_BUDGET {
body["max_tokens"] = Value::Number(MAX_TOKENS_VALUE.into());
result.max_tokens_changed = true;
}
result.applied = result.type_changed || result.budget_changed || result.max_tokens_changed;
if !result.applied {
log::warn!("[RECT-BUD-004] budget 整流命中但请求已满足约束,跳过重试");
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn enabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
fn budget_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: false,
}
}
fn master_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
// ==================== should_rectify_thinking_budget 测试 ====================
#[test]
fn test_detect_budget_tokens_thinking_error() {
assert!(should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_max_tokens_error() {
assert!(should_rectify_thinking_budget(
Some("budget_tokens must be less than max_tokens"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_1024_error() {
assert!(should_rectify_thinking_budget(
Some("budget_tokens: value must be at least 1024"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_with_thinking_and_1024_error() {
assert!(should_rectify_thinking_budget(
Some("thinking budget_tokens must be >= 1024"),
&enabled_config()
));
}
#[test]
fn test_no_trigger_for_unrelated_error() {
assert!(!should_rectify_thinking_budget(
Some("Request timeout"),
&enabled_config()
));
assert!(!should_rectify_thinking_budget(None, &enabled_config()));
}
#[test]
fn test_disabled_budget_config() {
assert!(!should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&budget_disabled_config()
));
}
#[test]
fn test_master_disabled() {
assert!(!should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&master_disabled_config()
));
}
// ==================== rectify_thinking_budget 测试 ====================
#[test]
fn test_rectify_budget_basic() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert!(result.budget_changed);
assert!(result.max_tokens_changed);
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_skips_adaptive() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert!(!result.type_changed);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["budget_tokens"], 512);
assert_eq!(body["max_tokens"], 1024);
}
#[test]
fn test_rectify_budget_preserves_large_max_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 },
"max_tokens": 100000
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert!(!result.max_tokens_changed);
assert_eq!(body["max_tokens"], 100000);
}
#[test]
fn test_rectify_budget_creates_thinking_object_when_missing() {
let mut body = json!({
"model": "claude-test",
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert!(body.get("thinking").is_none());
assert_eq!(body["max_tokens"], 1024);
}
#[test]
fn test_rectify_budget_no_max_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 }
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert!(result.max_tokens_changed);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_skips_non_enabled_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "disabled", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "disabled");
assert_eq!(body["thinking"]["budget_tokens"], 512);
assert_eq!(body["max_tokens"], 1024);
}
#[test]
fn test_rectify_budget_no_change_when_already_valid() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 32000 },
"max_tokens": 64001
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert!(!result.budget_changed);
assert!(!result.max_tokens_changed);
assert_eq!(body["thinking"]["budget_tokens"], 32000);
assert_eq!(body["max_tokens"], 64001);
}
}
+259 -3
View File
@@ -59,10 +59,12 @@ pub fn should_rectify_thinking_signature(
}
// 场景3: expected thinking or redacted_thinking, found tool_use
// 与 CCH 对齐:要求明确包含 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")
&& lower.contains("tool_use")
{
return true;
}
@@ -73,6 +75,32 @@ pub fn should_rectify_thinking_signature(
return true;
}
// 场景5: signature 字段不被接受(第三方渠道)
// 错误示例: "xxx.signature: Extra inputs are not permitted"
if lower.contains("signature") && lower.contains("extra inputs are not permitted") {
return true;
}
// 场景6: thinking/redacted_thinking 块被修改
// 错误示例: "thinking or redacted_thinking blocks ... cannot be modified"
if (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("cannot be modified")
{
return true;
}
// 场景7: 非法请求(需携带签名/思考结构线索,避免过宽命中)
let invalid_request_like = lower.contains("非法请求")
|| lower.contains("illegal request")
|| lower.contains("invalid request");
let has_signature_or_thinking_clue = lower.contains("signature")
|| lower.contains("thinking")
|| lower.contains("redacted_thinking")
|| lower.contains("tool_use");
if invalid_request_like && has_signature_or_thinking_clue {
return true;
}
false
}
@@ -86,6 +114,15 @@ pub fn should_rectify_thinking_signature(
pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
let mut result = RectifyResult::default();
// 与 CCH 对齐:adaptive 模式下整流器直接跳过,不改写请求。
let thinking_type = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str());
if thinking_type == Some("adaptive") {
return result;
}
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
Some(m) => m,
None => return result,
@@ -159,11 +196,13 @@ pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
/// 判断是否需要删除顶层 thinking 字段
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
// 检查 thinking 是否启用
let thinking_enabled = body
let thinking_type = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
.and_then(|t| t.as_str());
// 与 CCH 对齐:仅 type=enabled 视为开启
let thinking_enabled = thinking_type == Some("enabled");
if !thinking_enabled {
return false;
@@ -202,6 +241,11 @@ fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
}
/// 与 CCH 对齐:请求前不做 thinking type 主动改写。
pub fn normalize_thinking_type(body: Value) -> Value {
body
}
#[cfg(test)]
mod tests {
use super::*;
@@ -211,6 +255,7 @@ mod tests {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
@@ -218,6 +263,7 @@ mod tests {
RectifierConfig {
enabled: true,
request_thinking_signature: false,
request_thinking_budget: false,
}
}
@@ -225,6 +271,7 @@ mod tests {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
@@ -264,6 +311,14 @@ mod tests {
));
}
#[test]
fn test_no_detect_thinking_expected_without_tool_use() {
assert!(!should_rectify_thinking_signature(
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`."),
&enabled_config()
));
}
#[test]
fn test_detect_must_start_with_thinking() {
assert!(should_rectify_thinking_signature(
@@ -418,4 +473,205 @@ mod tests {
// 此时会触发删除顶层 thinking 的逻辑
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
}
// ==================== 新增错误场景检测测试 ====================
#[test]
fn test_detect_signature_extra_inputs() {
// 场景5: signature 字段不被接受
assert!(should_rectify_thinking_signature(
Some("xxx.signature: Extra inputs are not permitted"),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_cannot_be_modified() {
// 场景6: thinking blocks cannot be modified
assert!(should_rectify_thinking_signature(
Some("thinking or redacted_thinking blocks in the response cannot be modified"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_request() {
// 场景7: 非法请求(包含签名/思考结构线索才触发)
assert!(should_rectify_thinking_signature(
Some("非法请求:thinking signature 不合法"),
&enabled_config()
));
assert!(should_rectify_thinking_signature(
Some("illegal request: tool_use block mismatch"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(
Some("invalid request: malformed JSON"),
&enabled_config()
));
}
#[test]
fn test_do_not_detect_thinking_type_tag_mismatch() {
// 与 CCH 对齐:adaptive tag mismatch 不触发签名整流器
assert!(!should_rectify_thinking_signature(
Some("Input tag 'adaptive' found using 'type' does not match expected tags"),
&enabled_config()
));
}
// ==================== adaptive thinking type 测试 ====================
#[test]
fn test_rectify_skips_when_adaptive_thinking_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
assert!(body["thinking"].get("budget_tokens").is_none());
}
#[test]
fn test_rectify_adaptive_preserves_existing_budget_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 5000 },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["budget_tokens"], 5000);
}
#[test]
fn test_rectify_does_not_change_enabled_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 1024 },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "enabled");
}
#[test]
fn test_rectify_removes_top_level_thinking_adaptive() {
// 与 CCH 对齐:adaptive 模式下整流器直接跳过,不删除顶层 thinking
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"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_eq!(body["thinking"]["type"], "adaptive");
}
// ==================== normalize_thinking_type 测试 ====================
#[test]
fn test_normalize_thinking_type_adaptive_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "adaptive");
assert!(result["thinking"].get("budget_tokens").is_none());
}
#[test]
fn test_normalize_thinking_type_enabled_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 2048 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 2048);
}
#[test]
fn test_normalize_thinking_type_disabled_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "disabled" }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "disabled");
}
#[test]
fn test_normalize_thinking_type_preserves_budget() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 5000 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "adaptive");
assert_eq!(result["thinking"]["budget_tokens"], 5000);
}
#[test]
fn test_normalize_thinking_type_no_thinking() {
let body = json!({
"model": "claude-test"
});
let result = normalize_thinking_type(body);
assert!(result.get("thinking").is_none());
}
#[test]
fn test_normalize_thinking_type_unknown_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "unexpected", "budget_tokens": 100 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "unexpected");
assert_eq!(result["thinking"]["budget_tokens"], 100);
}
}
+32 -6
View File
@@ -198,14 +198,23 @@ pub struct AppProxyConfig {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RectifierConfig {
/// 总开关:是否启用整流器
#[serde(default)]
/// 总开关:是否启用整流器(默认关闭)
#[serde(default = "default_false")]
pub enabled: bool,
/// 请求整流:启用 thinking 签名整流器
/// 请求整流:启用 thinking 签名整流器(默认关闭)
///
/// 处理错误:Invalid 'signature' in 'thinking' block
#[serde(default)]
#[serde(default = "default_false")]
pub request_thinking_signature: bool,
/// 请求整流:启用 thinking budget 整流器(默认关闭)
///
/// 处理错误:budget_tokens + thinking 相关约束
#[serde(default = "default_false")]
pub request_thinking_budget: bool,
}
fn default_false() -> bool {
false
}
fn default_true() -> bool {
@@ -262,13 +271,17 @@ mod tests {
#[test]
fn test_rectifier_config_default_disabled() {
// 验证 RectifierConfig::default() 返回全禁用状态
// 验证 RectifierConfig::default() 返回全关闭状态
let config = RectifierConfig::default();
assert!(!config.enabled, "整流器总开关默认应为 false");
assert!(
!config.request_thinking_signature,
"thinking 签名整流器默认应为 false"
);
assert!(
!config.request_thinking_budget,
"thinking budget 整流器默认应为 false"
);
}
#[test]
@@ -278,15 +291,28 @@ mod tests {
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert!(!config.request_thinking_signature);
assert!(!config.request_thinking_budget);
}
#[test]
fn test_rectifier_config_serde_explicit_true() {
// 验证显式设置 true 时正确反序列化
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
let json =
r#"{"enabled": true, "requestThinkingSignature": true, "requestThinkingBudget": true}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
assert!(config.request_thinking_budget);
}
#[test]
fn test_rectifier_config_serde_partial_fields() {
// 验证只设置部分字段时,缺失字段使用默认值 false
let json = r#"{"enabled": true, "requestThinkingSignature": false}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(!config.request_thinking_signature);
assert!(!config.request_thinking_budget);
}
#[test]
@@ -8,8 +8,9 @@ import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
export function RectifierConfigPanel() {
const { t } = useTranslation();
const [config, setConfig] = useState<RectifierConfig>({
enabled: true,
requestThinkingSignature: true,
enabled: false,
requestThinkingSignature: false,
requestThinkingBudget: false,
});
const [isLoading, setIsLoading] = useState(true);
@@ -69,6 +70,21 @@ export function RectifierConfigPanel() {
}
/>
</div>
<div className="flex items-center justify-between pl-4">
<div className="space-y-0.5">
<Label>{t("settings.advanced.rectifier.thinkingBudget")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.rectifier.thinkingBudgetDescription")}
</p>
</div>
<Switch
checked={config.requestThinkingBudget}
disabled={!config.enabled}
onCheckedChange={(checked) =>
handleChange({ requestThinkingBudget: checked })
}
/>
</div>
</div>
</div>
);
+3 -1
View File
@@ -214,7 +214,9 @@
"requestGroup": "Request Rectification",
"responseGroup": "Response Rectification",
"thinkingSignature": "Thinking Signature Rectification",
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
"thinkingSignatureDescription": "When an Anthropic-type provider returns thinking signature incompatibility or illegal request errors, automatically removes incompatible thinking-related blocks and retries once with the same provider",
"thinkingBudget": "Thinking Budget Rectification",
"thinkingBudgetDescription": "When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024 or less than max_tokens), and the request explicitly enables thinking (type=enabled), automatically sets thinking budget to 32000 and max_tokens to 64000 if needed, then retries once"
},
"logConfig": {
"title": "Log Management",
+3 -1
View File
@@ -214,7 +214,9 @@
"requestGroup": "リクエスト整流",
"responseGroup": "レスポンス整流",
"thinkingSignature": "Thinking 署名整流",
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
"thinkingSignatureDescription": "Anthropic タイプのプロバイダーが thinking 署名の非互換性や不正なリクエストエラーを返した場合、互換性のない thinking 関連ブロックを自動削除し、同じプロバイダーで 1 回リトライします",
"thinkingBudget": "Thinking Budget 整流",
"thinkingBudgetDescription": "Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上、max_tokens 未満)を返し、かつリクエストで thinkingtype=enabled)が明示的に有効な場合、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に設定して 1 回リトライします"
},
"logConfig": {
"title": "ログ管理",
+3 -1
View File
@@ -214,7 +214,9 @@
"requestGroup": "请求整流",
"responseGroup": "响应整流",
"thinkingSignature": "Thinking 签名整流",
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
"thinkingSignatureDescription": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次",
"thinkingBudget": "Thinking Budget 整流",
"thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024、需小于 max_tokens)时,在请求显式开启 thinkingtype=enabled)时自动将预算设为 32000,并在需要时将 max_tokens 设为 64000,然后重试一次"
},
"logConfig": {
"title": "日志管理",
+1
View File
@@ -155,6 +155,7 @@ export const settingsApi = {
export interface RectifierConfig {
enabled: boolean;
requestThinkingSignature: boolean;
requestThinkingBudget: boolean;
}
export interface LogConfig {