mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
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:
@@ -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
|
||||
];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user