diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index ace793e4fb..d9f1c657cf 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -19,6 +19,7 @@ use super::{ thinking_rectifier::{ normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, }, + tool_strict_rectifier::{rectify_tool_strict, should_rectify_tool_strict}, types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig}, ProxyError, }; @@ -542,15 +543,104 @@ impl RequestForwarder { connection_guard: None, }); } - Err(e) => { - // 检测是否需要触发整流器(仅 Claude/ClaudeAuth 供应商) - let provider_type = ProviderType::from_app_type_and_config(app_type, provider); - let is_anthropic_provider = matches!( - provider_type, - ProviderType::Claude | ProviderType::ClaudeAuth - ); + Err(mut e) => { + // Anthropic-native requests include Claude providers and the + // Codex/GrokBuild Responses -> Anthropic bridge. + let is_anthropic_provider = + uses_anthropic_request_format(app_type, provider, endpoint); let mut signature_rectifier_non_retryable_client_error = false; + if should_retry_tool_strict(&self.rectifier_config, is_anthropic_provider, &e) { + let removed = rectify_tool_strict(&mut provider_body); + if removed > 0 { + log::info!( + "[{app_type_str}] [ToolStrict] Upstream rejected tool strict; retrying provider={} without {removed} strict field(s)", + provider.id + ); + + match self + .forward( + app_type, + &method, + provider, + endpoint, + &provider_body, + &headers, + &extensions, + adapter.as_ref(), + ) + .await + { + Ok((response, claude_api_format, outbound_model)) => { + log::info!( + "[{app_type_str}] [ToolStrict] Compatibility retry succeeded" + ); + self.record_success_result( + &provider.id, + app_type_str, + used_half_open_permit, + ) + .await; + + { + 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; + 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(), + claude_api_format, + outbound_model, + connection_guard: None, + }); + } + Err(retry_err) => { + log::warn!( + "[{app_type_str}] [ToolStrict] Compatibility retry still failed: {retry_err}" + ); + // Continue through the other rectifiers with the + // stripped request and the latest error. A request + // may need both this Bedrock compatibility fallback + // and an existing thinking/media repair. + e = retry_err; + } + } + } + } + if self.media_retry_should_trigger( adapter.name(), media_rectifier_retried, @@ -2719,6 +2809,22 @@ fn extract_error_message(error: &ProxyError) -> Option { } } +fn uses_anthropic_request_format(app_type: &AppType, provider: &Provider, endpoint: &str) -> bool { + matches!( + ProviderType::from_app_type_and_config(app_type, provider), + ProviderType::Claude | ProviderType::ClaudeAuth + ) || (matches!(app_type, AppType::Codex | AppType::GrokBuild) + && super::providers::should_convert_codex_responses_to_anthropic(provider, endpoint)) +} + +fn should_retry_tool_strict( + rectifier_config: &RectifierConfig, + is_anthropic_provider: bool, + error: &ProxyError, +) -> bool { + rectifier_config.enabled && is_anthropic_provider && should_rectify_tool_strict(error) +} + /// 检测 Provider 是否为 Bedrock(通过 CLAUDE_CODE_USE_BEDROCK 环境变量判断) fn is_bedrock_provider(provider: &Provider) -> bool { provider @@ -3617,6 +3723,63 @@ mod tests { } } + #[test] + fn anthropic_rectifiers_cover_codex_responses_bridge() { + let mut provider = test_provider_with_type(None); + provider.meta = Some(crate::provider::ProviderMeta { + api_format: Some("anthropic".to_string()), + ..Default::default() + }); + + assert!(uses_anthropic_request_format( + &AppType::Codex, + &provider, + "/responses" + )); + assert!(!uses_anthropic_request_format( + &AppType::Codex, + &provider, + "/chat/completions" + )); + + provider.meta.as_mut().unwrap().api_format = Some("openai_responses".to_string()); + assert!(!uses_anthropic_request_format( + &AppType::Codex, + &provider, + "/responses" + )); + } + + #[test] + fn tool_strict_retry_honors_rectifier_opt_out() { + let error = ProxyError::UpstreamError { + status: 400, + body: Some( + r#"{"error":{"message":"tools.0.custom.strict: Extra inputs are not permitted"}}"# + .to_string(), + ), + }; + + assert!(should_retry_tool_strict( + &RectifierConfig::default(), + true, + &error + )); + assert!(!should_retry_tool_strict( + &RectifierConfig { + enabled: false, + ..RectifierConfig::default() + }, + true, + &error + )); + assert!(!should_retry_tool_strict( + &RectifierConfig::default(), + false, + &error + )); + } + fn test_forwarder( non_streaming_timeout: Duration, streaming_first_byte_timeout: Duration, diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index d1dc858081..798015e8d1 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -32,6 +32,7 @@ pub mod thinking_budget_rectifier; pub mod thinking_optimizer; pub mod thinking_rectifier; pub(crate) mod tool_media; +pub mod tool_strict_rectifier; pub(crate) mod types; pub mod usage; diff --git a/src-tauri/src/proxy/providers/transform_codex_anthropic.rs b/src-tauri/src/proxy/providers/transform_codex_anthropic.rs index 18d247fef4..7c8dc4d96a 100644 --- a/src-tauri/src/proxy/providers/transform_codex_anthropic.rs +++ b/src-tauri/src/proxy/providers/transform_codex_anthropic.rs @@ -1683,7 +1683,7 @@ mod tests { "max_output_tokens": 100, "input": [{ "role": "user", "content": "hi" }], "tools": [ - { "type": "function", "name": "get_weather", "description": "d", "parameters": {"type": "object"} }, + { "type": "function", "name": "get_weather", "description": "d", "strict": true, "parameters": {"type": "object"} }, { "type": "web_search" }, { "type": "custom", "name": "apply_patch" } ] @@ -1693,6 +1693,7 @@ mod tests { assert_eq!(tools.len(), 2); assert_eq!(tools[0]["name"], "get_weather"); assert_eq!(tools[0]["input_schema"]["type"], "object"); + assert_eq!(tools[0]["strict"], true); assert!(tools[0].get("parameters").is_none()); assert_eq!(tools[1]["name"], "apply_patch"); } diff --git a/src-tauri/src/proxy/tool_strict_rectifier.rs b/src-tauri/src/proxy/tool_strict_rectifier.rs new file mode 100644 index 0000000000..b2211c80bb --- /dev/null +++ b/src-tauri/src/proxy/tool_strict_rectifier.rs @@ -0,0 +1,179 @@ +//! Tool `strict` compatibility rectifier. +//! +//! Some Anthropic-compatible gateways ultimately invoke Claude through AWS +//! Bedrock. Bedrock rejects tool-level `strict` for some Claude models even +//! though native Anthropic accepts it. Keep the field on the first attempt and +//! only remove it after the upstream reports the exact unsupported-field error. + +use super::ProxyError; +use serde_json::Value; + +/// Returns true when an upstream explicitly rejects tool-level `strict`. +pub fn should_rectify_tool_strict(error: &ProxyError) -> bool { + let ProxyError::UpstreamError { + status: 400, + body: Some(body), + } = error + else { + return false; + }; + + body.to_ascii_lowercase() + .contains(".strict: extra inputs are not permitted") +} + +/// Removes tool declaration `strict` fields from supported request shapes. +/// +/// Handles: +/// - OpenAI Responses / Anthropic tools: `tools[*].strict` +/// - OpenAI Chat tools: `tools[*].function.strict` +/// - Namespace and `tool_search_output` tool arrays nested under `tools` +/// +/// JSON Schema contents are not traversed, so a business property named +/// `strict` remains intact. +pub fn rectify_tool_strict(body: &mut Value) -> usize { + strip_nested_tool_arrays(body) +} + +fn strip_nested_tool_arrays(value: &mut Value) -> usize { + match value { + Value::Object(object) => { + let mut removed = 0; + for (key, child) in object { + if key == "tools" { + removed += strip_tool_array(child); + } else if !matches!(key.as_str(), "parameters" | "input_schema") { + removed += strip_nested_tool_arrays(child); + } + } + removed + } + Value::Array(values) => values.iter_mut().map(strip_nested_tool_arrays).sum(), + _ => 0, + } +} + +fn strip_tool_array(value: &mut Value) -> usize { + let Some(tools) = value.as_array_mut() else { + return 0; + }; + + tools + .iter_mut() + .map(|tool| { + let Some(object) = tool.as_object_mut() else { + return 0; + }; + + let mut removed = usize::from(object.remove("strict").is_some()); + if let Some(function) = object.get_mut("function").and_then(Value::as_object_mut) { + removed += usize::from(function.remove("strict").is_some()); + } + if let Some(nested_tools) = object.get_mut("tools") { + removed += strip_tool_array(nested_tools); + } + removed + }) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn upstream_error(status: u16, message: &str) -> ProxyError { + ProxyError::UpstreamError { + status, + body: Some(json!({ "error": { "message": message } }).to_string()), + } + } + + #[test] + fn detects_bedrock_tool_strict_rejection() { + for message in [ + "tools.0.custom.strict: Extra inputs are not permitted", + "***.***.***.strict: Extra inputs are not permitted", + ] { + assert!(should_rectify_tool_strict(&upstream_error(400, message))); + } + } + + #[test] + fn ignores_unrelated_or_non_400_errors() { + assert!(!should_rectify_tool_strict(&upstream_error( + 400, + "tools.0.input_schema: Extra inputs are not permitted" + ))); + assert!(!should_rectify_tool_strict(&upstream_error( + 422, + "tools.0.custom.strict: Extra inputs are not permitted" + ))); + } + + #[test] + fn strips_responses_chat_and_nested_tool_strict() { + let mut body = json!({ + "tools": [ + { + "type": "function", + "name": "top_level", + "strict": true, + "parameters": { + "type": "object", + "properties": { "strict": { "type": "boolean" } } + } + }, + { + "type": "function", + "function": { + "name": "chat_tool", + "strict": false, + "parameters": { "type": "object" } + } + }, + { + "type": "namespace", + "name": "nested", + "tools": [{ + "type": "function", + "name": "child", + "strict": true, + "parameters": { "type": "object" } + }] + } + ], + "input": [{ + "type": "tool_search_output", + "tools": [{ + "type": "function", + "name": "dynamic", + "strict": true, + "parameters": { "type": "object" } + }] + }] + }); + + assert_eq!(rectify_tool_strict(&mut body), 4); + assert!(body["tools"][0].get("strict").is_none()); + assert_eq!( + body["tools"][0]["parameters"]["properties"]["strict"]["type"], + "boolean" + ); + assert!(body["tools"][1]["function"].get("strict").is_none()); + assert!(body["tools"][2]["tools"][0].get("strict").is_none()); + assert!(body["input"][0]["tools"][0].get("strict").is_none()); + } + + #[test] + fn reports_no_change_when_tools_have_no_strict_field() { + let mut body = json!({ + "tools": [{ + "name": "plain", + "input_schema": { "type": "object", "properties": {} } + }] + }); + + assert_eq!(rectify_tool_strict(&mut body), 0); + } +}