fix(proxy): thinking rectifiers

This commit is contained in:
YoVinchen
2026-02-12 22:17:17 +08:00
parent 579df5ad99
commit 07d00e726e
10 changed files with 203 additions and 132 deletions

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)

View File

@@ -262,6 +262,7 @@ impl RequestForwarder {
provider_type,
ProviderType::Claude | ProviderType::ClaudeAuth
);
let mut signature_rectifier_non_retryable_client_error = false;
if is_anthropic_provider {
let error_message = extract_error_message(&e);
@@ -300,8 +301,9 @@ impl RequestForwarder {
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
if !rectified.applied {
log::warn!(
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget 整流路径"
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回"
);
signature_rectifier_non_retryable_client_error = true;
} else {
log::info!(
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
@@ -497,11 +499,10 @@ impl RequestForwarder {
}
log::info!(
"[{}] [RECT-010] thinking budget 整流器触发, type_changed={}, budget_changed={}, max_tokens_changed={}",
"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}",
app_type_str,
budget_rectified.type_changed,
budget_rectified.budget_changed,
budget_rectified.max_tokens_changed
budget_rectified.before,
budget_rectified.after
);
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
@@ -616,6 +617,28 @@ impl RequestForwarder {
}
}
if signature_rectifier_non_retryable_client_error {
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 _ = self
.router

View File

@@ -16,16 +16,25 @@ const MAX_TOKENS_VALUE: u64 = 64000;
const MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1;
/// Budget 整流结果
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BudgetRectifySnapshot {
/// max_tokens
pub max_tokens: Option<u64>,
/// thinking.type
pub thinking_type: Option<String>,
/// thinking.budget_tokens
pub thinking_budget_tokens: Option<u64>,
}
/// Budget 整流结果
#[derive(Debug, Clone, Default, PartialEq, Eq)]
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,
/// 整流前快照
pub before: BudgetRectifySnapshot,
/// 整流后快照
pub after: BudgetRectifySnapshot,
}
/// 检测是否需要触发 thinking budget 整流器
@@ -49,27 +58,14 @@ pub fn should_rectify_thinking_budget(
};
let lower = msg.to_lowercase();
// 覆盖常见上游文案变体:
// - budget_tokens >= 1024 约束
// - budget_tokens 与 max_tokens 关系约束
// 与 CCH 对齐:仅在包含 budget_tokens + thinking + 1024 约束时触发
let has_budget_tokens_reference =
lower.contains("budget_tokens") || lower.contains("budget tokens");
let has_thinking_reference = lower.contains("thinking");
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 {
if has_budget_tokens_reference && has_thinking_reference && has_1024_constraint {
return true;
}
@@ -83,51 +79,63 @@ pub fn should_rectify_thinking_budget(
/// - `thinking.budget_tokens = 32000`
/// - 如果 `max_tokens < 32001`,设为 `64000`
pub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult {
let mut result = BudgetRectifyResult::default();
let before = snapshot_budget(body);
// 仅允许对显式 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;
// 与 CCH 对齐adaptive 请求不改写
if before.thinking_type.as_deref() == Some("adaptive") {
return BudgetRectifyResult {
applied: false,
before: before.clone(),
after: before,
};
}
if current_type != Some("enabled") {
log::warn!(
"[RECT-BUD-003] budget 整流命中但 thinking.type 不是 enabled当前: {}),跳过",
current_type.unwrap_or("<missing>")
);
return result;
// 与 CCH 对齐:缺少/非法 thinking 时自动创建后再整流
if !body.get("thinking").is_some_and(Value::is_object) {
body["thinking"] = Value::Object(serde_json::Map::new());
}
let Some(thinking) = body.get_mut("thinking").and_then(|t| t.as_object_mut()) else {
return result;
return BudgetRectifyResult {
applied: false,
before: before.clone(),
after: before,
};
};
// 设置 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;
}
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
thinking.insert(
"budget_tokens".to_string(),
Value::Number(MAX_THINKING_BUDGET.into()),
);
// 确保 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 {
if before.max_tokens.is_none() || before.max_tokens < Some(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 整流命中但请求已满足约束,跳过重试");
let after = snapshot_budget(body);
BudgetRectifyResult {
applied: before != after,
before,
after,
}
}
fn snapshot_budget(body: &Value) -> BudgetRectifySnapshot {
let max_tokens = body.get("max_tokens").and_then(|v| v.as_u64());
let thinking = body.get("thinking").and_then(|t| t.as_object());
let thinking_type = thinking
.and_then(|t| t.get("type"))
.and_then(|v| v.as_str())
.map(ToString::to_string);
let thinking_budget_tokens = thinking
.and_then(|t| t.get("budget_tokens"))
.and_then(|v| v.as_u64());
BudgetRectifySnapshot {
max_tokens,
thinking_type,
thinking_budget_tokens,
}
result
}
#[cfg(test)]
@@ -171,7 +179,7 @@ mod tests {
#[test]
fn test_detect_budget_tokens_max_tokens_error() {
assert!(should_rectify_thinking_budget(
assert!(!should_rectify_thinking_budget(
Some("budget_tokens must be less than max_tokens"),
&enabled_config()
));
@@ -179,7 +187,7 @@ mod tests {
#[test]
fn test_detect_budget_tokens_1024_error() {
assert!(should_rectify_thinking_budget(
assert!(!should_rectify_thinking_budget(
Some("budget_tokens: value must be at least 1024"),
&enabled_config()
));
@@ -231,8 +239,15 @@ mod tests {
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert!(result.budget_changed);
assert!(result.max_tokens_changed);
assert_eq!(result.before.thinking_type.as_deref(), Some("enabled"));
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(result.before.thinking_budget_tokens, Some(512));
assert_eq!(
result.after.thinking_budget_tokens,
Some(MAX_THINKING_BUDGET)
);
assert_eq!(result.before.max_tokens, Some(1024));
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
@@ -249,7 +264,7 @@ mod tests {
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert!(!result.type_changed);
assert_eq!(result.before, result.after);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["budget_tokens"], 512);
assert_eq!(body["max_tokens"], 1024);
@@ -266,7 +281,8 @@ mod tests {
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert!(!result.max_tokens_changed);
assert_eq!(result.before.max_tokens, Some(100000));
assert_eq!(result.after.max_tokens, Some(100000));
assert_eq!(body["max_tokens"], 100000);
}
@@ -279,9 +295,17 @@ mod tests {
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert!(body.get("thinking").is_none());
assert_eq!(body["max_tokens"], 1024);
assert!(result.applied);
assert_eq!(result.before.thinking_type, None);
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(
result.after.thinking_budget_tokens,
Some(MAX_THINKING_BUDGET)
);
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
@@ -294,12 +318,13 @@ mod tests {
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert!(result.max_tokens_changed);
assert_eq!(result.before.max_tokens, None);
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_skips_non_enabled_type() {
fn test_rectify_budget_normalizes_non_enabled_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "disabled", "budget_tokens": 512 },
@@ -308,10 +333,12 @@ mod tests {
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);
assert!(result.applied);
assert_eq!(result.before.thinking_type.as_deref(), Some("disabled"));
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
@@ -325,8 +352,7 @@ mod tests {
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert!(!result.budget_changed);
assert!(!result.max_tokens_changed);
assert_eq!(result.before, result.after);
assert_eq!(body["thinking"]["budget_tokens"], 32000);
assert_eq!(body["max_tokens"], 64001);
}

View File

@@ -89,15 +89,11 @@ pub fn should_rectify_thinking_signature(
return true;
}
// 场景7: 非法请求(需携带签名/思考结构线索,避免过宽命中
let invalid_request_like = lower.contains("非法请求")
// 场景7: 非法请求(与 CCH 对齐,按 invalid request 统一兜底
if 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 {
|| lower.contains("invalid request")
{
return true;
}
@@ -114,15 +110,6 @@ 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,
@@ -496,7 +483,7 @@ mod tests {
#[test]
fn test_detect_invalid_request() {
// 场景7: 非法请求(包含签名/思考结构线索才触发)
// 场景7: 非法请求(与 CCH 对齐,统一触发)
assert!(should_rectify_thinking_signature(
Some("非法请求thinking signature 不合法"),
&enabled_config()
@@ -505,7 +492,7 @@ mod tests {
Some("illegal request: tool_use block mismatch"),
&enabled_config()
));
assert!(!should_rectify_thinking_signature(
assert!(should_rectify_thinking_signature(
Some("invalid request: malformed JSON"),
&enabled_config()
));
@@ -523,7 +510,7 @@ mod tests {
// ==================== adaptive thinking type 测试 ====================
#[test]
fn test_rectify_skips_when_adaptive_thinking_type() {
fn test_rectify_keeps_adaptive_when_no_legacy_blocks() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
@@ -577,7 +564,7 @@ mod tests {
#[test]
fn test_rectify_removes_top_level_thinking_adaptive() {
// 与 CCH 对齐adaptive 模式下整流器直接跳过,不删除顶层 thinking
// 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除adaptive 不删除
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
@@ -598,6 +585,31 @@ mod tests {
assert_eq!(body["thinking"]["type"], "adaptive");
}
#[test]
fn test_rectify_adaptive_still_cleans_legacy_signature_blocks() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "t", "signature": "sig_thinking" },
{ "type": "text", "text": "hello", "signature": "sig_text" }
]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "text");
assert!(content[0].get("signature").is_none());
assert_eq!(body["thinking"]["type"], "adaptive");
}
// ==================== normalize_thinking_type 测试 ====================
#[test]

View File

@@ -195,28 +195,24 @@ pub struct AppProxyConfig {
/// 整流器配置
///
/// 存储在 settings 表中
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RectifierConfig {
/// 总开关:是否启用整流器(默认关闭
#[serde(default = "default_false")]
/// 总开关:是否启用整流器(默认开启
#[serde(default = "default_true")]
pub enabled: bool,
/// 请求整流:启用 thinking 签名整流器(默认关闭
/// 请求整流:启用 thinking 签名整流器(默认开启
///
/// 处理错误Invalid 'signature' in 'thinking' block
#[serde(default = "default_false")]
#[serde(default = "default_true")]
pub request_thinking_signature: bool,
/// 请求整流:启用 thinking budget 整流器(默认关闭
/// 请求整流:启用 thinking budget 整流器(默认开启
///
/// 处理错误budget_tokens + thinking 相关约束
#[serde(default = "default_false")]
#[serde(default = "default_true")]
pub request_thinking_budget: bool,
}
fn default_false() -> bool {
false
}
fn default_true() -> bool {
true
}
@@ -225,6 +221,16 @@ fn default_log_level() -> String {
"info".to_string()
}
impl Default for RectifierConfig {
fn default() -> Self {
Self {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
}
/// 日志配置
///
/// 存储在 settings 表的 log_config 字段中JSON 格式)
@@ -270,28 +276,28 @@ mod tests {
use super::*;
#[test]
fn test_rectifier_config_default_disabled() {
// 验证 RectifierConfig::default() 返回全关闭状态
fn test_rectifier_config_default_enabled() {
// 验证 RectifierConfig::default() 返回全开启状态
let config = RectifierConfig::default();
assert!(!config.enabled, "整流器总开关默认应为 false");
assert!(config.enabled, "整流器总开关默认应为 true");
assert!(
!config.request_thinking_signature,
"thinking 签名整流器默认应为 false"
config.request_thinking_signature,
"thinking 签名整流器默认应为 true"
);
assert!(
!config.request_thinking_budget,
"thinking budget 整流器默认应为 false"
config.request_thinking_budget,
"thinking budget 整流器默认应为 true"
);
}
#[test]
fn test_rectifier_config_serde_default() {
// 验证反序列化缺字段时使用默认值 false
// 验证反序列化缺字段时使用默认值 true
let json = "{}";
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert!(!config.request_thinking_signature);
assert!(!config.request_thinking_budget);
assert!(config.enabled);
assert!(config.request_thinking_signature);
assert!(config.request_thinking_budget);
}
#[test]
@@ -307,12 +313,12 @@ mod tests {
#[test]
fn test_rectifier_config_serde_partial_fields() {
// 验证只设置部分字段时,缺失字段使用默认值 false
// 验证只设置部分字段时,缺失字段使用默认值 true
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);
assert!(config.request_thinking_budget);
}
#[test]

View File

@@ -141,7 +141,11 @@ function App() {
// Fallback from sessions view when switching to an app without session support
useEffect(() => {
if (currentView === "sessions" && activeApp !== "claude" && activeApp !== "codex") {
if (
currentView === "sessions" &&
activeApp !== "claude" &&
activeApp !== "codex"
) {
setCurrentView("providers");
}
}, [activeApp, currentView]);

View File

@@ -8,9 +8,9 @@ import { settingsApi, type RectifierConfig } from "@/lib/api/settings";
export function RectifierConfigPanel() {
const { t } = useTranslation();
const [config, setConfig] = useState<RectifierConfig>({
enabled: false,
requestThinkingSignature: false,
requestThinkingBudget: false,
enabled: true,
requestThinkingSignature: true,
requestThinkingBudget: true,
});
const [isLoading, setIsLoading] = useState(true);

View File

@@ -216,7 +216,7 @@
"thinkingSignature": "Thinking Signature Rectification",
"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"
"thinkingBudgetDescription": "When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024), automatically normalizes thinking to enabled, sets thinking budget to 32000, and raises max_tokens to 64000 if needed, then retries once"
},
"logConfig": {
"title": "Log Management",

View File

@@ -216,7 +216,7 @@
"thinkingSignature": "Thinking 署名整流",
"thinkingSignatureDescription": "Anthropic タイプのプロバイダーが thinking 署名の非互換性や不正なリクエストエラーを返した場合、互換性のない thinking 関連ブロックを自動削除し、同じプロバイダーで 1 回リトライします",
"thinkingBudget": "Thinking Budget 整流",
"thinkingBudgetDescription": "Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上、max_tokens 未満)を返し、かつリクエストで thinkingtype=enabled)が明示的に有効な場合、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に設定して 1 回リトライします"
"thinkingBudgetDescription": "Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上)を返した場合、thinkingenabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします"
},
"logConfig": {
"title": "ログ管理",

View File

@@ -216,7 +216,7 @@
"thinkingSignature": "Thinking 签名整流",
"thinkingSignatureDescription": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次",
"thinkingBudget": "Thinking Budget 整流",
"thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024、需小于 max_tokens在请求显式开启 thinkingtype=enabled)时自动将预算设为 32000在需要时将 max_tokens 设为 64000然后重试一次"
"thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024)时,自动将 thinking 规范为 enabled 并将 budget 设为 32000同时在需要时将 max_tokens 设为 64000然后重试一次"
},
"logConfig": {
"title": "日志管理",