diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 8aac400f9..23304ebbc 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -145,6 +145,36 @@ pub async fn set_rectifier_config( Ok(true) } +/// 获取优化器配置 +#[tauri::command] +pub async fn get_optimizer_config( + state: tauri::State<'_, crate::AppState>, +) -> Result { + state.db.get_optimizer_config().map_err(|e| e.to_string()) +} + +/// 设置优化器配置 +#[tauri::command] +pub async fn set_optimizer_config( + state: tauri::State<'_, crate::AppState>, + config: crate::proxy::types::OptimizerConfig, +) -> Result { + // Validate cache_ttl: only allow known values + match config.cache_ttl.as_str() { + "5m" | "1h" => {} + other => { + return Err(format!( + "Invalid cache_ttl value: '{other}'. Allowed values: '5m', '1h'" + )) + } + } + state + .db + .set_optimizer_config(&config) + .map_err(|e| e.to_string())?; + Ok(true) +} + /// 获取日志配置 #[tauri::command] pub async fn get_log_config( diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index e39aa7b31..6f15b2585 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -187,6 +187,29 @@ impl Database { self.set_setting("rectifier_config", &json) } + // --- 优化器配置 --- + + /// 获取优化器配置 + /// + /// 返回优化器配置,如果不存在则返回默认值(默认关闭) + pub fn get_optimizer_config(&self) -> Result { + match self.get_setting("optimizer_config")? { + Some(json) => serde_json::from_str(&json) + .map_err(|e| AppError::Database(format!("解析优化器配置失败: {e}"))), + None => Ok(crate::proxy::types::OptimizerConfig::default()), + } + } + + /// 更新优化器配置 + pub fn set_optimizer_config( + &self, + config: &crate::proxy::types::OptimizerConfig, + ) -> Result<(), AppError> { + let json = serde_json::to_string(config) + .map_err(|e| AppError::Database(format!("序列化优化器配置失败: {e}")))?; + self.set_setting("optimizer_config", &json) + } + // --- 日志配置 --- /// 获取日志配置 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 65739696d..5715d7392 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -886,6 +886,8 @@ pub fn run() { commands::save_settings, commands::get_rectifier_config, commands::set_rectifier_config, + commands::get_optimizer_config, + commands::set_optimizer_config, commands::get_log_config, commands::set_log_config, commands::restart_app, diff --git a/src-tauri/src/proxy/cache_injector.rs b/src-tauri/src/proxy/cache_injector.rs new file mode 100644 index 000000000..d7e43b172 --- /dev/null +++ b/src-tauri/src/proxy/cache_injector.rs @@ -0,0 +1,374 @@ +//! Cache 断点注入器 +//! +//! 在请求转发前自动注入 cache_control 标记,启用 Bedrock Prompt Caching + +use super::types::OptimizerConfig; +use serde_json::{json, Value}; + +/// 在请求体关键位置注入 cache_control 断点 +pub fn inject(body: &mut Value, config: &OptimizerConfig) { + if !config.cache_injection { + return; + } + + let existing = count_existing(body); + + // 升级已有断点的 TTL + upgrade_existing_ttl(body, &config.cache_ttl); + + let mut budget = 4_usize.saturating_sub(existing); + if budget == 0 { + if existing > 0 { + log::info!( + "[OPT] cache: ttl-upgrade({existing}->{},existing={existing})", + config.cache_ttl + ); + } else { + log::info!("[OPT] cache: no-op(existing={existing})"); + } + return; + } + + let mut injected = Vec::new(); + + // (a) tools 末尾 + if budget > 0 { + if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) { + if let Some(last) = tools.last_mut() { + if last.get("cache_control").is_none() { + if let Some(o) = last.as_object_mut() { + o.insert( + "cache_control".to_string(), + make_cache_control(&config.cache_ttl), + ); + } + budget -= 1; + injected.push("tools"); + } + } + } + } + + // (b) system 末尾 + if budget > 0 { + // 字符串 system → 转为数组 + if body.get("system").and_then(|s| s.as_str()).is_some() { + let text = body["system"].as_str().unwrap().to_string(); + body["system"] = json!([{"type": "text", "text": text}]); + } + + if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) { + if let Some(last) = system.last_mut() { + if last.get("cache_control").is_none() { + if let Some(o) = last.as_object_mut() { + o.insert( + "cache_control".to_string(), + make_cache_control(&config.cache_ttl), + ); + } + budget -= 1; + injected.push("system"); + } + } + } + } + + // (c) 最后一条 assistant 消息的最后一个非 thinking block + if budget > 0 { + if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) { + if let Some(assistant_msg) = messages + .iter_mut() + .rev() + .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant")) + { + if let Some(content) = assistant_msg + .get_mut("content") + .and_then(|c| c.as_array_mut()) + { + // 逆序找最后一个非 thinking/redacted_thinking block + if let Some(block) = content.iter_mut().rev().find(|b| { + let bt = b.get("type").and_then(|t| t.as_str()).unwrap_or(""); + bt != "thinking" && bt != "redacted_thinking" + }) { + if block.get("cache_control").is_none() { + if let Some(o) = block.as_object_mut() { + o.insert( + "cache_control".to_string(), + make_cache_control(&config.cache_ttl), + ); + } + injected.push("msgs"); + } + } + } + } + } + } + + log::info!( + "[OPT] cache: {}bp({},{},pre={existing})", + injected.len(), + injected.join("+"), + config.cache_ttl, + ); +} + +fn make_cache_control(ttl: &str) -> Value { + if ttl == "5m" { + json!({"type": "ephemeral"}) + } else { + json!({"type": "ephemeral", "ttl": ttl}) + } +} + +fn count_existing(body: &Value) -> usize { + let mut count = 0; + + if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) { + count += tools + .iter() + .filter(|t| t.get("cache_control").is_some()) + .count(); + } + + if let Some(system) = body.get("system").and_then(|s| s.as_array()) { + count += system + .iter() + .filter(|b| b.get("cache_control").is_some()) + .count(); + } + + if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) { + for msg in messages { + if let Some(content) = msg.get("content").and_then(|c| c.as_array()) { + count += content + .iter() + .filter(|b| b.get("cache_control").is_some()) + .count(); + } + } + } + + count +} + +fn upgrade_existing_ttl(body: &mut Value, ttl: &str) { + let upgrade = |val: &mut Value| { + if let Some(cc) = val.get_mut("cache_control").and_then(|c| c.as_object_mut()) { + if ttl == "5m" { + cc.remove("ttl"); + } else { + cc.insert("ttl".to_string(), json!(ttl)); + } + } + }; + + if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) { + for tool in tools.iter_mut() { + upgrade(tool); + } + } + + if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) { + for block in system.iter_mut() { + upgrade(block); + } + } + + if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) { + for msg in messages.iter_mut() { + if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) { + for block in content.iter_mut() { + upgrade(block); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn default_config() -> OptimizerConfig { + OptimizerConfig { + enabled: true, + thinking_optimizer: true, + cache_injection: true, + cache_ttl: "1h".to_string(), + } + } + + #[test] + fn test_empty_body_no_injection() { + let mut body = json!({"model": "test", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]}); + let original = body.clone(); + inject(&mut body, &default_config()); + // No tools, no system, no assistant → no injection + assert_eq!(body, original); + } + + #[test] + fn test_inject_three_breakpoints() { + let mut body = json!({ + "model": "test", + "tools": [{"name": "tool1"}, {"name": "tool2"}], + "system": [{"type": "text", "text": "sys prompt"}], + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + {"role": "assistant", "content": [ + {"type": "text", "text": "hello"} + ]} + ] + }); + + inject(&mut body, &default_config()); + + // tools last element + assert!(body["tools"][1].get("cache_control").is_some()); + assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h"); + // system last element + assert!(body["system"][0].get("cache_control").is_some()); + // assistant last non-thinking block + assert!(body["messages"][1]["content"][0] + .get("cache_control") + .is_some()); + } + + #[test] + fn test_existing_four_breakpoints_only_upgrades_ttl() { + let mut body = json!({ + "model": "test", + "tools": [ + {"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "5m"}}, + {"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "5m"}} + ], + "system": [ + {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "5m"}} + ], + "messages": [ + {"role": "assistant", "content": [ + {"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "5m"}} + ]} + ] + }); + + inject(&mut body, &default_config()); + + // All TTLs upgraded to 1h, no new breakpoints + assert_eq!(body["tools"][0]["cache_control"]["ttl"], "1h"); + assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h"); + assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h"); + assert_eq!( + body["messages"][0]["content"][0]["cache_control"]["ttl"], + "1h" + ); + } + + #[test] + fn test_existing_two_injects_two_more() { + let mut body = json!({ + "model": "test", + "tools": [ + {"name": "t1", "cache_control": {"type": "ephemeral"}}, + {"name": "t2", "cache_control": {"type": "ephemeral"}} + ], + "system": [{"type": "text", "text": "sys"}], + "messages": [ + {"role": "assistant", "content": [{"type": "text", "text": "ok"}]} + ] + }); + + inject(&mut body, &default_config()); + + // budget = 4 - 2 = 2, inject system + msgs + assert!(body["system"][0].get("cache_control").is_some()); + assert!(body["messages"][0]["content"][0] + .get("cache_control") + .is_some()); + } + + #[test] + fn test_system_string_converted_to_array() { + let mut body = json!({ + "model": "test", + "system": "You are a helpful assistant", + "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + }); + + inject(&mut body, &default_config()); + + assert!(body["system"].is_array()); + let sys = body["system"].as_array().unwrap(); + assert_eq!(sys.len(), 1); + assert_eq!(sys[0]["type"], "text"); + assert_eq!(sys[0]["text"], "You are a helpful assistant"); + assert!(sys[0].get("cache_control").is_some()); + } + + #[test] + fn test_ttl_5m_no_ttl_field() { + let config = OptimizerConfig { + cache_ttl: "5m".to_string(), + ..default_config() + }; + let mut body = json!({ + "model": "test", + "tools": [{"name": "tool1"}], + "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + }); + + inject(&mut body, &config); + + let cc = &body["tools"][0]["cache_control"]; + assert_eq!(cc["type"], "ephemeral"); + assert!(cc.get("ttl").is_none() || cc["ttl"].is_null()); + } + + #[test] + fn test_disabled_no_change() { + let config = OptimizerConfig { + cache_injection: false, + ..default_config() + }; + let mut body = json!({ + "model": "test", + "tools": [{"name": "tool1"}], + "system": [{"type": "text", "text": "sys"}], + "messages": [{"role": "assistant", "content": [{"type": "text", "text": "ok"}]}] + }); + let original = body.clone(); + + inject(&mut body, &config); + + assert_eq!(body, original); + } + + #[test] + fn test_skip_thinking_blocks_in_assistant() { + let mut body = json!({ + "model": "test", + "messages": [ + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "hmm"}, + {"type": "text", "text": "result"}, + {"type": "redacted_thinking", "data": "xxx"} + ]} + ] + }); + + inject(&mut body, &default_config()); + + // Should inject on "text" block (last non-thinking), not on thinking/redacted_thinking + assert!(body["messages"][0]["content"][1] + .get("cache_control") + .is_some()); + assert!(body["messages"][0]["content"][0] + .get("cache_control") + .is_none()); + assert!(body["messages"][0]["content"][2] + .get("cache_control") + .is_none()); + } +} diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index c26ceabf4..755ae49de 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -12,7 +12,7 @@ use super::{ thinking_rectifier::{ normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, }, - types::{ProxyStatus, RectifierConfig}, + types::{OptimizerConfig, ProxyStatus, RectifierConfig}, ProxyError, }; use crate::{app_config::AppType, provider::Provider}; @@ -97,6 +97,8 @@ pub struct RequestForwarder { current_provider_id_at_start: String, /// 整流器配置 rectifier_config: RectifierConfig, + /// 优化器配置 + optimizer_config: OptimizerConfig, /// 非流式请求超时(秒) non_streaming_timeout: std::time::Duration, } @@ -114,6 +116,7 @@ impl RequestForwarder { _streaming_first_byte_timeout: u64, _streaming_idle_timeout: u64, rectifier_config: RectifierConfig, + optimizer_config: OptimizerConfig, ) -> Self { Self { router, @@ -123,6 +126,7 @@ impl RequestForwarder { app_handle, current_provider_id_at_start, rectifier_config, + optimizer_config, non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout), } } @@ -139,7 +143,7 @@ impl RequestForwarder { &self, app_type: &AppType, endpoint: &str, - mut body: Value, + body: Value, headers: axum::http::HeaderMap, providers: Vec, ) -> Result { @@ -183,6 +187,22 @@ impl RequestForwarder { continue; } + // PRE-SEND 优化器:每个 provider 独立决定是否优化 + // clone body 以避免 Bedrock 优化字段泄漏到非 Bedrock provider(failover 场景) + let mut provider_body = + if self.optimizer_config.enabled && is_bedrock_provider(provider) { + let mut b = body.clone(); + if self.optimizer_config.thinking_optimizer { + super::thinking_optimizer::optimize(&mut b, &self.optimizer_config); + } + if self.optimizer_config.cache_injection { + super::cache_injector::inject(&mut b, &self.optimizer_config); + } + b + } else { + body.clone() + }; + attempted_providers += 1; // 更新状态中的当前Provider信息 @@ -196,7 +216,13 @@ impl RequestForwarder { // 转发请求(每个 Provider 只尝试一次,重试由客户端控制) match self - .forward(provider, endpoint, &body, &headers, adapter.as_ref()) + .forward( + provider, + endpoint, + &provider_body, + &headers, + adapter.as_ref(), + ) .await { Ok(response) => { @@ -296,7 +322,7 @@ impl RequestForwarder { } // 首次触发:整流请求体 - let rectified = rectify_anthropic_request(&mut body); + let rectified = rectify_anthropic_request(&mut provider_body); // 整流未生效:继续尝试 budget 整流路径,避免误判后短路 if !rectified.applied { @@ -318,7 +344,13 @@ impl RequestForwarder { // 使用同一供应商重试(不计入熔断器) match self - .forward(provider, endpoint, &body, &headers, adapter.as_ref()) + .forward( + provider, + endpoint, + &provider_body, + &headers, + adapter.as_ref(), + ) .await { Ok(response) => { @@ -472,7 +504,7 @@ impl RequestForwarder { }); } - let budget_rectified = rectify_thinking_budget(&mut body); + let budget_rectified = rectify_thinking_budget(&mut provider_body); if !budget_rectified.applied { log::warn!( "[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试" @@ -509,7 +541,13 @@ impl RequestForwarder { // 使用同一供应商重试(不计入熔断器) match self - .forward(provider, endpoint, &body, &headers, adapter.as_ref()) + .forward( + provider, + endpoint, + &provider_body, + &headers, + adapter.as_ref(), + ) .await { Ok(response) => { @@ -927,3 +965,14 @@ fn extract_error_message(error: &ProxyError) -> Option { _ => Some(error.to_string()), } } + +/// 检测 Provider 是否为 Bedrock(通过 CLAUDE_CODE_USE_BEDROCK 环境变量判断) +fn is_bedrock_provider(provider: &Provider) -> bool { + provider + .settings_config + .get("env") + .and_then(|e| e.get("CLAUDE_CODE_USE_BEDROCK")) + .and_then(|v| v.as_str()) + .map(|v| v == "1") + .unwrap_or(false) +} diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index b4de0374a..4f36434db 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -8,7 +8,7 @@ use crate::proxy::{ extract_session_id, forwarder::RequestForwarder, server::ProxyState, - types::{AppProxyConfig, RectifierConfig}, + types::{AppProxyConfig, OptimizerConfig, RectifierConfig}, ProxyError, }; use axum::http::HeaderMap; @@ -59,6 +59,8 @@ pub struct RequestContext { pub session_id: String, /// 整流器配置 pub rectifier_config: RectifierConfig, + /// 优化器配置 + pub optimizer_config: OptimizerConfig, } impl RequestContext { @@ -93,6 +95,7 @@ impl RequestContext { // 从数据库读取整流器配置 let rectifier_config = state.db.get_rectifier_config().unwrap_or_default(); + let optimizer_config = state.db.get_optimizer_config().unwrap_or_default(); let current_provider_id = crate::settings::get_current_provider(&app_type).unwrap_or_default(); @@ -156,6 +159,7 @@ impl RequestContext { app_type, session_id, rectifier_config, + optimizer_config, }) } @@ -216,6 +220,7 @@ impl RequestContext { first_byte_timeout, idle_timeout, self.rectifier_config.clone(), + self.optimizer_config.clone(), ) } diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index fd6e0ba88..5378823a6 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -3,6 +3,7 @@ //! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传 pub mod body_filter; +pub mod cache_injector; pub mod circuit_breaker; pub mod error; pub mod error_mapper; @@ -22,6 +23,7 @@ pub mod response_processor; pub(crate) mod server; pub mod session; pub mod thinking_budget_rectifier; +pub mod thinking_optimizer; pub mod thinking_rectifier; pub(crate) mod types; pub mod usage; diff --git a/src-tauri/src/proxy/thinking_optimizer.rs b/src-tauri/src/proxy/thinking_optimizer.rs new file mode 100644 index 000000000..c9b770c0b --- /dev/null +++ b/src-tauri/src/proxy/thinking_optimizer.rs @@ -0,0 +1,274 @@ +//! Thinking 优化器 + +use super::types::OptimizerConfig; +use serde_json::{json, Value}; + +/// 根据模型类型自动优化 thinking 配置 +/// +/// 三路径分发: +/// - skip: haiku 模型直接跳过 +/// - adaptive: opus-4-6 / sonnet-4-6 使用 adaptive thinking +/// - legacy: 其他模型注入 enabled thinking + budget_tokens +pub fn optimize(body: &mut Value, config: &OptimizerConfig) { + if !config.thinking_optimizer { + return; + } + + let model = match body.get("model").and_then(|m| m.as_str()) { + Some(m) => m.to_lowercase(), + None => return, + }; + + if model.contains("haiku") { + log::info!("[OPT] thinking: skip(haiku)"); + return; + } + + if model.contains("opus-4-6") || model.contains("sonnet-4-6") { + log::info!("[OPT] thinking: adaptive({})", model); + body["thinking"] = json!({"type": "adaptive"}); + body["output_config"] = json!({"effort": "max"}); + append_beta(body, "context-1m-2025-08-07"); + return; + } + + // legacy path + log::info!("[OPT] thinking: legacy({})", model); + + let max_tokens = body + .get("max_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(16384); + + let budget_target = max_tokens.saturating_sub(1); + + let thinking_type = body + .get("thinking") + .and_then(|t| t.get("type")) + .and_then(|t| t.as_str()) + .map(|s| s.to_string()); + + match thinking_type.as_deref() { + None | Some("disabled") => { + body["thinking"] = json!({ + "type": "enabled", + "budget_tokens": budget_target + }); + append_beta(body, "interleaved-thinking-2025-05-14"); + } + Some("enabled") => { + let current_budget = body + .get("thinking") + .and_then(|t| t.get("budget_tokens")) + .and_then(|b| b.as_u64()) + .unwrap_or(0); + if current_budget < budget_target { + body["thinking"]["budget_tokens"] = json!(budget_target); + } + append_beta(body, "interleaved-thinking-2025-05-14"); + } + _ => { + append_beta(body, "interleaved-thinking-2025-05-14"); + } + } +} + +/// 追加 beta 标识到 anthropic_beta 数组(去重) +fn append_beta(body: &mut Value, beta: &str) { + match body.get("anthropic_beta") { + Some(Value::Array(arr)) => { + if arr.iter().any(|v| v.as_str() == Some(beta)) { + return; + } + body["anthropic_beta"] + .as_array_mut() + .unwrap() + .push(json!(beta)); + } + Some(Value::Null) | None => { + body["anthropic_beta"] = json!([beta]); + } + _ => { + body["anthropic_beta"] = json!([beta]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn enabled_config() -> OptimizerConfig { + OptimizerConfig { + enabled: true, + thinking_optimizer: true, + cache_injection: true, + cache_ttl: "1h".to_string(), + } + } + + fn disabled_config() -> OptimizerConfig { + OptimizerConfig { + enabled: true, + thinking_optimizer: false, + cache_injection: true, + cache_ttl: "1h".to_string(), + } + } + + #[test] + fn test_adaptive_opus_4_6() { + let mut body = json!({ + "model": "anthropic.claude-opus-4-6-20250514-v1:0", + "max_tokens": 16384, + "thinking": {"type": "enabled", "budget_tokens": 8000}, + "messages": [{"role": "user", "content": "hello"}] + }); + + optimize(&mut body, &enabled_config()); + + assert_eq!(body["thinking"]["type"], "adaptive"); + assert!(body["thinking"].get("budget_tokens").is_none()); + assert_eq!(body["output_config"]["effort"], "max"); + let betas = body["anthropic_beta"].as_array().unwrap(); + assert!(betas.iter().any(|v| v == "context-1m-2025-08-07")); + } + + #[test] + fn test_adaptive_sonnet_4_6() { + let mut body = json!({ + "model": "anthropic.claude-sonnet-4-6-20250514-v1:0", + "max_tokens": 16384, + "messages": [{"role": "user", "content": "hello"}] + }); + + optimize(&mut body, &enabled_config()); + + assert_eq!(body["thinking"]["type"], "adaptive"); + assert!(body["thinking"].get("budget_tokens").is_none()); + assert_eq!(body["output_config"]["effort"], "max"); + let betas = body["anthropic_beta"].as_array().unwrap(); + assert!(betas.iter().any(|v| v == "context-1m-2025-08-07")); + } + + #[test] + fn test_legacy_sonnet_4_5_thinking_null() { + let mut body = json!({ + "model": "anthropic.claude-sonnet-4-5-20250514-v1:0", + "max_tokens": 16384, + "messages": [{"role": "user", "content": "hello"}] + }); + + optimize(&mut body, &enabled_config()); + + assert_eq!(body["thinking"]["type"], "enabled"); + assert_eq!(body["thinking"]["budget_tokens"], 16383); + let betas = body["anthropic_beta"].as_array().unwrap(); + assert!(betas.iter().any(|v| v == "interleaved-thinking-2025-05-14")); + } + + #[test] + fn test_legacy_budget_too_small_upgraded() { + let mut body = json!({ + "model": "anthropic.claude-sonnet-4-5-20250514-v1:0", + "max_tokens": 16384, + "thinking": {"type": "enabled", "budget_tokens": 1024}, + "messages": [{"role": "user", "content": "hello"}] + }); + + optimize(&mut body, &enabled_config()); + + assert_eq!(body["thinking"]["type"], "enabled"); + assert_eq!(body["thinking"]["budget_tokens"], 16383); + } + + #[test] + fn test_skip_haiku() { + let mut body = json!({ + "model": "anthropic.claude-haiku-4-5-20250514-v1:0", + "max_tokens": 8192, + "messages": [{"role": "user", "content": "hello"}] + }); + let original = body.clone(); + + optimize(&mut body, &enabled_config()); + + assert_eq!(body, original); + } + + #[test] + fn test_thinking_optimizer_disabled() { + let mut body = json!({ + "model": "anthropic.claude-opus-4-6-20250514-v1:0", + "max_tokens": 16384, + "messages": [{"role": "user", "content": "hello"}] + }); + let original = body.clone(); + + optimize(&mut body, &disabled_config()); + + assert_eq!(body, original); + } + + #[test] + fn test_adaptive_dedup_beta() { + let mut body = json!({ + "model": "anthropic.claude-opus-4-6-20250514-v1:0", + "max_tokens": 16384, + "anthropic_beta": ["context-1m-2025-08-07"], + "messages": [{"role": "user", "content": "hello"}] + }); + + optimize(&mut body, &enabled_config()); + + let betas = body["anthropic_beta"].as_array().unwrap(); + let count = betas + .iter() + .filter(|v| v == &&json!("context-1m-2025-08-07")) + .count(); + assert_eq!(count, 1); + } + + #[test] + fn test_legacy_disabled_thinking_injected() { + let mut body = json!({ + "model": "anthropic.claude-sonnet-4-5-20250514-v1:0", + "max_tokens": 8192, + "thinking": {"type": "disabled"}, + "messages": [{"role": "user", "content": "hello"}] + }); + + optimize(&mut body, &enabled_config()); + + assert_eq!(body["thinking"]["type"], "enabled"); + assert_eq!(body["thinking"]["budget_tokens"], 8191); + } + + #[test] + fn test_legacy_default_max_tokens() { + let mut body = json!({ + "model": "anthropic.claude-sonnet-4-5-20250514-v1:0", + "messages": [{"role": "user", "content": "hello"}] + }); + + optimize(&mut body, &enabled_config()); + + assert_eq!(body["thinking"]["type"], "enabled"); + assert_eq!(body["thinking"]["budget_tokens"], 16383); + } + + #[test] + fn test_append_beta_null_field() { + let mut body = json!({ + "model": "anthropic.claude-opus-4-6-20250514-v1:0", + "anthropic_beta": null, + "messages": [{"role": "user", "content": "hello"}] + }); + + optimize(&mut body, &enabled_config()); + + let betas = body["anthropic_beta"].as_array().unwrap(); + assert!(betas.iter().any(|v| v == "context-1m-2025-08-07")); + } +} diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 5118c2bbe..6f5c19fe1 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -233,6 +233,42 @@ impl Default for RectifierConfig { } } +/// 请求优化器配置 +/// +/// 存储在 settings 表中,key = "optimizer_config" +/// 仅对 Bedrock provider 生效(CLAUDE_CODE_USE_BEDROCK = "1") +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OptimizerConfig { + /// 总开关(默认关闭,用户需手动启用) + #[serde(default)] + pub enabled: bool, + /// Thinking 优化子开关(总开关开启后默认生效) + #[serde(default = "default_true")] + pub thinking_optimizer: bool, + /// Cache 注入子开关(总开关开启后默认生效) + #[serde(default = "default_true")] + pub cache_injection: bool, + /// Cache TTL: "5m" | "1h"(默认 "1h") + #[serde(default = "default_cache_ttl")] + pub cache_ttl: String, +} + +fn default_cache_ttl() -> String { + "1h".to_string() +} + +impl Default for OptimizerConfig { + fn default() -> Self { + Self { + enabled: false, + thinking_optimizer: true, + cache_injection: true, + cache_ttl: "1h".to_string(), + } + } +} + /// 日志配置 /// /// 存储在 settings 表的 log_config 字段中(JSON 格式) diff --git a/src/components/settings/RectifierConfigPanel.tsx b/src/components/settings/RectifierConfigPanel.tsx index be75a2282..6b72c029d 100644 --- a/src/components/settings/RectifierConfigPanel.tsx +++ b/src/components/settings/RectifierConfigPanel.tsx @@ -3,7 +3,11 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { settingsApi, type RectifierConfig } from "@/lib/api/settings"; +import { + settingsApi, + type RectifierConfig, + type OptimizerConfig, +} from "@/lib/api/settings"; export function RectifierConfigPanel() { const { t } = useTranslation(); @@ -12,6 +16,12 @@ export function RectifierConfigPanel() { requestThinkingSignature: true, requestThinkingBudget: true, }); + const [optimizerConfig, setOptimizerConfig] = useState({ + enabled: false, + thinkingOptimizer: true, + cacheInjection: true, + cacheTtl: "1h", + }); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -20,6 +30,10 @@ export function RectifierConfigPanel() { .then(setConfig) .catch((e) => console.error("Failed to load rectifier config:", e)) .finally(() => setIsLoading(false)); + settingsApi + .getOptimizerConfig() + .then(setOptimizerConfig) + .catch((e) => console.error("Failed to load optimizer config:", e)); }, []); const handleChange = async (updates: Partial) => { @@ -34,6 +48,18 @@ export function RectifierConfigPanel() { } }; + const handleOptimizerChange = async (updates: Partial) => { + const newConfig = { ...optimizerConfig, ...updates }; + setOptimizerConfig(newConfig); + try { + await settingsApi.setOptimizerConfig(newConfig); + } catch (e) { + console.error("Failed to save optimizer config:", e); + toast.error(String(e)); + setOptimizerConfig(optimizerConfig); + } + }; + if (isLoading) return null; return ( @@ -86,6 +112,94 @@ export function RectifierConfigPanel() { /> + +
+
+

+ {t("settings.advanced.optimizer.title")} +

+

+ {t("settings.advanced.optimizer.description")} +

+
+ +
+
+
+ +
+ + handleOptimizerChange({ enabled: checked }) + } + /> +
+ +
+
+
+ +

+ {t( + "settings.advanced.optimizer.thinkingOptimizerDescription", + )} +

+
+ + handleOptimizerChange({ thinkingOptimizer: checked }) + } + /> +
+ +
+
+ +

+ {t("settings.advanced.optimizer.cacheInjectionDescription")} +

+
+ + handleOptimizerChange({ cacheInjection: checked }) + } + /> +
+ + {optimizerConfig.cacheInjection && ( +
+
+ +
+ +
+ )} +
+
+
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f725f2cdf..91d736a05 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -257,6 +257,18 @@ "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" }, + "optimizer": { + "title": "Bedrock Request Optimizer", + "description": "Automatically optimize Thinking and Cache configuration before sending requests (only applies to Bedrock providers)", + "enabled": "Enable Optimizer", + "thinkingOptimizer": "Thinking Optimization", + "thinkingOptimizerDescription": "Automatically enable Adaptive Thinking for Opus/Sonnet, and inject Extended Thinking for legacy models", + "cacheInjection": "Cache Injection", + "cacheInjectionDescription": "Automatically inject cache breakpoints at key positions in requests to reduce duplicate token billing", + "cacheTtl": "Cache TTL", + "cacheTtl5m": "5 minutes", + "cacheTtl1h": "1 hour" + }, "logConfig": { "title": "Log Management", "description": "Control log output level", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0440fb4aa..c995cd0fc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -257,6 +257,18 @@ "thinkingBudget": "Thinking Budget 整流", "thinkingBudgetDescription": "Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上)を返した場合、thinking を enabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします" }, + "optimizer": { + "title": "Bedrock リクエストオプティマイザー", + "description": "リクエスト送信前に Thinking と Cache の設定を自動最適化(Bedrock プロバイダーのみ有効)", + "enabled": "オプティマイザーを有効化", + "thinkingOptimizer": "Thinking 最適化", + "thinkingOptimizerDescription": "Opus/Sonnet に Adaptive Thinking を自動的に有効化し、レガシーモデルに Extended Thinking を注入", + "cacheInjection": "Cache 注入", + "cacheInjectionDescription": "リクエストの重要な位置に Cache ブレークポイントを自動注入し、重複トークンの課金を削減", + "cacheTtl": "Cache TTL", + "cacheTtl5m": "5 分", + "cacheTtl1h": "1 時間" + }, "logConfig": { "title": "ログ管理", "description": "ログ出力レベルを制御", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ad99f1dba..c9a9ad8a2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -257,6 +257,18 @@ "thinkingBudget": "Thinking Budget 整流", "thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024)时,自动将 thinking 规范为 enabled 并将 budget 设为 32000,同时在需要时将 max_tokens 设为 64000,然后重试一次" }, + "optimizer": { + "title": "Bedrock 请求优化器", + "description": "在请求发送前自动优化 Thinking 和 Cache 配置(仅 Bedrock 供应商生效)", + "enabled": "启用优化器", + "thinkingOptimizer": "Thinking 优化", + "thinkingOptimizerDescription": "自动为 Opus/Sonnet 启用 Adaptive Thinking,为旧模型注入 Extended Thinking", + "cacheInjection": "Cache 注入", + "cacheInjectionDescription": "自动在请求关键位置注入 Cache 断点,减少重复 token 计费", + "cacheTtl": "Cache TTL", + "cacheTtl5m": "5 分钟", + "cacheTtl1h": "1 小时" + }, "logConfig": { "title": "日志管理", "description": "控制日志输出级别", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index e02e2a188..4d42c3533 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -196,6 +196,14 @@ export const settingsApi = { return await invoke("set_rectifier_config", { config }); }, + async getOptimizerConfig(): Promise { + return await invoke("get_optimizer_config"); + }, + + async setOptimizerConfig(config: OptimizerConfig): Promise { + return await invoke("set_optimizer_config", { config }); + }, + async getLogConfig(): Promise { return await invoke("get_log_config"); }, @@ -211,6 +219,13 @@ export interface RectifierConfig { requestThinkingBudget: boolean; } +export interface OptimizerConfig { + enabled: boolean; + thinkingOptimizer: boolean; + cacheInjection: boolean; + cacheTtl: string; +} + export interface LogConfig { enabled: boolean; level: "error" | "warn" | "info" | "debug" | "trace";