diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index db229776b..e7c21471d 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -323,7 +323,7 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { let mut search_paths: Vec = 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 ]; diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 109e972dd..972e95e32 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..9d46d5f33 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; @@ -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)? 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..ac91bd589 --- /dev/null +++ b/src-tauri/src/proxy/thinking_budget_rectifier.rs @@ -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("") + ); + 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); + } +} diff --git a/src-tauri/src/proxy/thinking_rectifier.rs b/src-tauri/src/proxy/thinking_rectifier.rs index 827ce5618..24c729633 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,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); + } } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 82ad47cb6..d4f4ca442 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -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] diff --git a/src/components/settings/RectifierConfigPanel.tsx b/src/components/settings/RectifierConfigPanel.tsx index eeb6e10b4..d3ddde912 100644 --- a/src/components/settings/RectifierConfigPanel.tsx +++ b/src/components/settings/RectifierConfigPanel.tsx @@ -8,8 +8,9 @@ import { settingsApi, type RectifierConfig } from "@/lib/api/settings"; export function RectifierConfigPanel() { const { t } = useTranslation(); const [config, setConfig] = useState({ - enabled: true, - requestThinkingSignature: true, + enabled: false, + requestThinkingSignature: false, + requestThinkingBudget: false, }); 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 05489ed9f..5f1182a93 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 or less than max_tokens), and the request explicitly enables thinking (type=enabled), automatically sets thinking budget to 32000 and max_tokens to 64000 if needed, then retries once" }, "logConfig": { "title": "Log Management", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8675570cd..a24ead122 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 以上、max_tokens 未満)を返し、かつリクエストで thinking(type=enabled)が明示的に有効な場合、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に設定して 1 回リトライします" }, "logConfig": { "title": "ログ管理", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 350ff83e6..69216b874 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、需小于 max_tokens)时,在请求显式开启 thinking(type=enabled)时自动将预算设为 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 {