diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 109e972dd..8056a01cb 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -168,7 +168,7 @@ impl Database { /// 获取整流器配置 /// - /// 返回整流器配置,如果不存在则返回默认值(全部启用) + /// 返回整流器配置,如果不存在则返回默认值(全部开启) pub fn get_rectifier_config(&self) -> Result { match self.get_setting("rectifier_config")? { Some(json) => serde_json::from_str(&json) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 2c741b8a6..c26ceabf4 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -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; @@ -258,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); @@ -293,12 +298,185 @@ 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;若 budget 也未命中则按客户端错误返回" + ); + signature_rectifier_non_retryable_client_error = true; + } 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 +499,13 @@ impl RequestForwarder { } log::info!( - "[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields", + "[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}", app_type_str, - rectified.removed_thinking_blocks, - rectified.removed_redacted_thinking_blocks, - rectified.removed_signature_fields + budget_rectified.before, + budget_rectified.after ); - // 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展) - let _ = std::mem::replace(&mut rectifier_retried, true); + let _ = std::mem::replace(&mut budget_rectifier_retried, true); // 使用同一供应商重试(不计入熔断器) match self @@ -337,8 +513,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 +525,6 @@ impl RequestForwarder { ) .await; - // 更新当前应用类型使用的 provider { let mut current_providers = self.current_providers.write().await; @@ -360,7 +534,6 @@ impl RequestForwarder { ); } - // 更新成功统计 { let mut status = self.status.write().await; status.success_requests += 1; @@ -370,14 +543,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 +567,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 +580,6 @@ impl RequestForwarder { }; if is_provider_error { - // Provider 问题:记录失败到熔断器 let _ = self .router .record_result( @@ -424,7 +591,6 @@ impl RequestForwarder { ) .await; } else { - // 客户端问题:仅释放 permit,不记录熔断器 self.router .release_permit_neutral( &provider.id, @@ -451,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 @@ -575,6 +763,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)? diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 338b845f6..fd6e0ba88 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -21,6 +21,7 @@ pub mod response_handler; pub mod response_processor; pub(crate) mod server; pub mod session; +pub mod thinking_budget_rectifier; pub mod thinking_rectifier; pub(crate) mod types; pub mod usage; diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs index a0eca7d1e..4f0f94d81 100644 --- a/src-tauri/src/proxy/model_mapper.rs +++ b/src-tauri/src/proxy/model_mapper.rs @@ -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(); diff --git a/src-tauri/src/proxy/thinking_budget_rectifier.rs b/src-tauri/src/proxy/thinking_budget_rectifier.rs new file mode 100644 index 000000000..aa0836878 --- /dev/null +++ b/src-tauri/src/proxy/thinking_budget_rectifier.rs @@ -0,0 +1,359 @@ +//! 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, PartialEq, Eq)] +pub struct BudgetRectifySnapshot { + /// max_tokens + pub max_tokens: Option, + /// thinking.type + pub thinking_type: Option, + /// thinking.budget_tokens + pub thinking_budget_tokens: Option, +} + +/// Budget 整流结果 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BudgetRectifyResult { + /// 是否应用了整流 + pub applied: bool, + /// 整流前快照 + pub before: BudgetRectifySnapshot, + /// 整流后快照 + pub after: BudgetRectifySnapshot, +} + +/// 检测是否需要触发 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(); + + // 与 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("1024") && lower.contains("input should be")); + if has_budget_tokens_reference && 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 before = snapshot_budget(body); + + // 与 CCH 对齐:adaptive 请求不改写 + if before.thinking_type.as_deref() == Some("adaptive") { + return BudgetRectifyResult { + applied: false, + before: before.clone(), + after: before, + }; + } + + // 与 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 BudgetRectifyResult { + applied: false, + before: before.clone(), + after: before, + }; + }; + + thinking.insert("type".to_string(), Value::String("enabled".to_string())); + thinking.insert( + "budget_tokens".to_string(), + Value::Number(MAX_THINKING_BUDGET.into()), + ); + + if before.max_tokens.is_none() || before.max_tokens < Some(MIN_MAX_TOKENS_FOR_BUDGET) { + body["max_tokens"] = Value::Number(MAX_TOKENS_VALUE.into()); + } + + 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, + } +} + +#[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_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); + } + + #[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_eq!(result.before, result.after); + 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_eq!(result.before.max_tokens, Some(100000)); + assert_eq!(result.after.max_tokens, Some(100000)); + 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_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] + 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_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_normalizes_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!(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] + 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_eq!(result.before, result.after); + assert_eq!(body["thinking"]["budget_tokens"], 32000); + assert_eq!(body["max_tokens"], 64001); + } +} diff --git a/src-tauri/src/proxy/thinking_rectifier.rs b/src-tauri/src/proxy/thinking_rectifier.rs index 827ce5618..ce71503c8 100644 --- a/src-tauri/src/proxy/thinking_rectifier.rs +++ b/src-tauri/src/proxy/thinking_rectifier.rs @@ -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,28 @@ 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: 非法请求(与 CCH 对齐,按 invalid request 统一兜底) + if lower.contains("非法请求") + || lower.contains("illegal request") + || lower.contains("invalid request") + { + return true; + } + false } @@ -159,11 +183,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 +228,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 +242,7 @@ mod tests { RectifierConfig { enabled: true, request_thinking_signature: true, + request_thinking_budget: true, } } @@ -218,6 +250,7 @@ mod tests { RectifierConfig { enabled: true, request_thinking_signature: false, + request_thinking_budget: false, } } @@ -225,6 +258,7 @@ mod tests { RectifierConfig { enabled: false, request_thinking_signature: true, + request_thinking_budget: true, } } @@ -264,6 +298,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 +460,230 @@ 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: 非法请求(与 CCH 对齐,统一触发) + 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_keeps_adaptive_when_no_legacy_blocks() { + 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() { + // 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除,adaptive 不删除 + 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"); + } + + #[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] + 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); + } } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 82ad47cb6..cf8dd7c8c 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -195,17 +195,22 @@ pub struct AppProxyConfig { /// 整流器配置 /// /// 存储在 settings 表中 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RectifierConfig { - /// 总开关:是否启用整流器 - #[serde(default)] + /// 总开关:是否启用整流器(默认开启) + #[serde(default = "default_true")] pub enabled: bool, - /// 请求整流:启用 thinking 签名整流器 + /// 请求整流:启用 thinking 签名整流器(默认开启) /// /// 处理错误:Invalid 'signature' in 'thinking' block - #[serde(default)] + #[serde(default = "default_true")] pub request_thinking_signature: bool, + /// 请求整流:启用 thinking budget 整流器(默认开启) + /// + /// 处理错误:budget_tokens + thinking 相关约束 + #[serde(default = "default_true")] + pub request_thinking_budget: bool, } fn default_true() -> bool { @@ -216,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 格式) @@ -261,32 +276,49 @@ 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 整流器默认应为 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.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() { + // 验证只设置部分字段时,缺失字段使用默认值 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); } #[test] diff --git a/src/components/settings/RectifierConfigPanel.tsx b/src/components/settings/RectifierConfigPanel.tsx index eeb6e10b4..be75a2282 100644 --- a/src/components/settings/RectifierConfigPanel.tsx +++ b/src/components/settings/RectifierConfigPanel.tsx @@ -10,6 +10,7 @@ export function RectifierConfigPanel() { const [config, setConfig] = useState({ enabled: true, requestThinkingSignature: true, + requestThinkingBudget: true, }); const [isLoading, setIsLoading] = useState(true); @@ -69,6 +70,21 @@ export function RectifierConfigPanel() { } /> +
+
+ +

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

+
+ + handleChange({ requestThinkingBudget: checked }) + } + /> +
); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d5bd4632a..c697a891b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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), 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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 908d45f4f..d2fdfa44e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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 以上)を返した場合、thinking を enabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします" }, "logConfig": { "title": "ログ管理", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e325e9bbe..cbe8eb0ee 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -214,7 +214,9 @@ "requestGroup": "请求整流", "responseGroup": "响应整流", "thinkingSignature": "Thinking 签名整流", - "thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误" + "thinkingSignatureDescription": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次", + "thinkingBudget": "Thinking Budget 整流", + "thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024)时,自动将 thinking 规范为 enabled 并将 budget 设为 32000,同时在需要时将 max_tokens 设为 64000,然后重试一次" }, "logConfig": { "title": "日志管理", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 43daeed37..cd8de3666 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -155,6 +155,7 @@ export const settingsApi = { export interface RectifierConfig { enabled: boolean; requestThinkingSignature: boolean; + requestThinkingBudget: boolean; } export interface LogConfig {