From 25136871846787fdf51f51fbfc9ea1f18c7f8184 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 08:34:10 +0800 Subject: [PATCH] feat: add Copilot optimizer to reduce premium interaction consumption Implement request classification, tool result merging, compact detection, deterministic request IDs, and warmup downgrade for Copilot proxy. The root cause was x-initiator being hardcoded to "user", making Copilot count every API request (including tool callbacks and agent continuations) as a separate premium interaction. The optimizer dynamically classifies requests as "user" or "agent" based on message content analysis. Closes #1813 --- src-tauri/src/commands/settings.rs | 24 + src-tauri/src/database/dao/settings.rs | 25 + src-tauri/src/lib.rs | 2 + src-tauri/src/proxy/copilot_optimizer.rs | 901 +++++++++++++++++++++++ src-tauri/src/proxy/forwarder.rs | 85 ++- src-tauri/src/proxy/handler_context.rs | 7 +- src-tauri/src/proxy/mod.rs | 1 + src-tauri/src/proxy/types.rs | 48 ++ src-tauri/src/tray.rs | 9 +- 9 files changed, 1093 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/proxy/copilot_optimizer.rs diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 3d07e9514..ef6f7b395 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -247,6 +247,30 @@ pub async fn set_optimizer_config( Ok(true) } +/// 获取 Copilot 优化器配置 +#[tauri::command] +pub async fn get_copilot_optimizer_config( + state: tauri::State<'_, crate::AppState>, +) -> Result { + state + .db + .get_copilot_optimizer_config() + .map_err(|e| e.to_string()) +} + +/// 设置 Copilot 优化器配置 +#[tauri::command] +pub async fn set_copilot_optimizer_config( + state: tauri::State<'_, crate::AppState>, + config: crate::proxy::types::CopilotOptimizerConfig, +) -> Result { + state + .db + .set_copilot_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 bca48bcbf..4ee5431d6 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -270,6 +270,31 @@ impl Database { self.set_setting("optimizer_config", &json) } + // --- Copilot 优化器配置 --- + + /// 获取 Copilot 优化器配置 + /// + /// 返回配置,如果不存在则返回默认值(默认开启) + pub fn get_copilot_optimizer_config( + &self, + ) -> Result { + match self.get_setting("copilot_optimizer_config")? { + Some(json) => serde_json::from_str(&json) + .map_err(|e| AppError::Database(format!("解析 Copilot 优化器配置失败: {e}"))), + None => Ok(crate::proxy::types::CopilotOptimizerConfig::default()), + } + } + + /// 更新 Copilot 优化器配置 + pub fn set_copilot_optimizer_config( + &self, + config: &crate::proxy::types::CopilotOptimizerConfig, + ) -> Result<(), AppError> { + let json = serde_json::to_string(config) + .map_err(|e| AppError::Database(format!("序列化 Copilot 优化器配置失败: {e}")))?; + self.set_setting("copilot_optimizer_config", &json) + } + // --- 日志配置 --- /// 获取日志配置 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 43bc47757..4a2600b3b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -867,6 +867,8 @@ pub fn run() { commands::set_rectifier_config, commands::get_optimizer_config, commands::set_optimizer_config, + commands::get_copilot_optimizer_config, + commands::set_copilot_optimizer_config, commands::get_log_config, commands::set_log_config, commands::restart_app, diff --git a/src-tauri/src/proxy/copilot_optimizer.rs b/src-tauri/src/proxy/copilot_optimizer.rs new file mode 100644 index 000000000..0092e02a5 --- /dev/null +++ b/src-tauri/src/proxy/copilot_optimizer.rs @@ -0,0 +1,901 @@ +//! Copilot 请求优化器 +//! +//! 解决 GitHub Copilot 代理消耗量异常问题(Issue #1813)。 +//! +//! Copilot 使用 `x-initiator` 请求头区分「用户发起」和「agent 续写」: +//! - `user`:计为一次 premium interaction(扣额度) +//! - `agent`:视为上一次交互的延续(不额外扣费) +//! +//! 参考实现: https://github.com/caozhiyuan/copilot-api + +use serde_json::Value; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// 请求分类结果 +#[derive(Debug, Clone)] +pub struct CopilotClassification { + /// "user" 或 "agent" — 映射到 x-initiator 请求头 + pub initiator: &'static str, + /// 是否为 warmup/探针请求(可降级到小模型) + pub is_warmup: bool, + /// 是否为上下文压缩请求 + pub is_compact: bool, +} + +/// 分类 Anthropic 格式的请求体,决定 Copilot 请求头。 +/// +/// 分类算法(只检查最后一条消息,与参考实现 caozhiyuan/copilot-api 对齐): +/// 1. 无消息 → "user"(安全默认,首次请求) +/// 2. 最后消息 role=user: +/// - content 中存在非 tool_result 类型 block → "user" +/// - content 全部是 tool_result → "agent" +/// - 匹配 compact 模式 → "agent" +/// 3. 最后消息 role 非 user → "user"(安全默认) +/// +/// Warmup 检测(与参考实现对齐): +/// - 请求头中有 `anthropic-beta` + 无 tools + 非 compact → warmup +/// +/// `compact_detection`:是否启用 compact 检测。为 false 时跳过, +/// 确保 `CopilotOptimizerConfig.compact_detection` 开关真正生效。 +pub fn classify_request( + body: &Value, + has_anthropic_beta: bool, + compact_detection: bool, +) -> CopilotClassification { + let is_compact = compact_detection && is_compact_request(body); + + let messages = match body.get("messages").and_then(|m| m.as_array()) { + Some(msgs) if !msgs.is_empty() => msgs, + _ => { + return CopilotClassification { + initiator: "user", + is_warmup: is_warmup_request(body, has_anthropic_beta, false), + is_compact: false, + } + } + }; + + let last_msg = &messages[messages.len() - 1]; + let role = last_msg.get("role").and_then(|r| r.as_str()).unwrap_or(""); + + // 只有 role=user 的消息需要细分 + if role != "user" { + return CopilotClassification { + initiator: "user", + is_warmup: false, + is_compact, + }; + } + + // 参考实现的判定逻辑(Messages API 路径): + // 如果 content 是数组,检查是否有非 tool_result 的 block + // 有 → "user",全是 tool_result → "agent" + // 如果 content 是字符串 → "user" + let is_user_initiated = match last_msg.get("content") { + Some(content) if content.is_array() => { + let blocks = content.as_array().unwrap(); + // 存在非 tool_result block → 用户发起 + blocks + .iter() + .any(|block| block.get("type").and_then(|t| t.as_str()) != Some("tool_result")) + } + Some(content) if content.is_string() => true, + _ => false, + }; + + let initiator = if !is_user_initiated || is_compact { + "agent" + } else { + "user" + }; + + CopilotClassification { + initiator, + is_warmup: initiator == "user" && is_warmup_request(body, has_anthropic_beta, is_compact), + is_compact, + } +} + +/// 检测是否为 warmup/探针请求(适合降级到小模型)。 +/// +/// 与参考实现对齐,三个条件同时满足: +/// 1. 请求头有 `anthropic-beta`(Claude Code warmup 探针的标志) +/// 2. 无 tools 定义 +/// 3. 非 compact 请求 +fn is_warmup_request(body: &Value, has_anthropic_beta: bool, is_compact: bool) -> bool { + if !has_anthropic_beta || is_compact { + return false; + } + // 无工具定义 + !matches!(body.get("tools"), Some(tools) if tools.is_array() && !tools.as_array().unwrap().is_empty()) +} + +/// 检测是否为 Claude Code 上下文压缩/compact 请求。 +/// +/// 只匹配 Claude Code **内部生成**的机器特征,不匹配用户可能手动输入的通用短语, +/// 避免将真实用户请求误标为 agent。 +/// +/// 强特征来源: +/// 1. system prompt — Claude Code compact 模式会设置专用 system prompt,用户无法手动设置 +/// 2. "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools." — 机器指令 +/// 3. 同时包含 "Pending Tasks:" 和 "Current Work:" — Claude Code compact 的结构标记 +fn is_compact_request(body: &Value) -> bool { + // 信号 1: system prompt 以 Claude Code compact 专用前缀开头 + // 用户在 Claude Code 中无法直接控制 system prompt,这是最可靠的信号 + if let Some(system) = body.get("system") { + let system_text = if let Some(s) = system.as_str() { + s.to_string() + } else if let Some(arr) = system.as_array() { + arr.iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(" ") + } else { + String::new() + }; + + if system_text + .starts_with("You are a helpful AI assistant tasked with summarizing conversations") + { + return true; + } + } + + // 信号 2 & 3: 检查最后一条用户消息中的机器生成特征 + let messages = match body.get("messages").and_then(|m| m.as_array()) { + Some(msgs) => msgs, + None => return false, + }; + + if let Some(last_msg) = messages.last() { + if last_msg.get("role").and_then(|r| r.as_str()) != Some("user") { + return false; + } + + let text = extract_text_from_message(last_msg); + + // 信号 2: Claude Code compact 的机器指令(大小写敏感,精确匹配) + if text.contains("CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.") { + return true; + } + + // 信号 3: Claude Code compact 的结构标记(两个同时出现才算) + if text.contains("Pending Tasks:") && text.contains("Current Work:") { + return true; + } + } + + false +} + +/// 合并用户消息中的 tool_result 和 text block。 +/// +/// 与参考实现 `mergeToolResultForClaude` 对齐: +/// +/// **消息内部合并**(核心):在单条 user 消息内,将 text block 吸收进 tool_result block, +/// 使整条消息只剩 tool_result 类型 block。这样 Copilot 不会将其视为用户发起的交互。 +/// +/// 场景:Claude Code 在 skill 调用、edit hook、plan 提醒等场景下,会发送混合了 +/// tool_result + text 的用户消息。text block 的存在让 Copilot 将其计为 premium request。 +/// +/// **跨消息合并**(补充):连续的 tool_result-only 用户消息合并为一条。 +pub fn merge_tool_results(mut body: Value) -> Value { + let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) { + Some(msgs) if !msgs.is_empty() => msgs, + _ => return body, + }; + + // Phase 1: 消息内部合并 — 将 text block 吸收进 tool_result block + for msg in messages.iter_mut() { + if msg.get("role").and_then(|r| r.as_str()) != Some("user") { + continue; + } + let content = match msg.get("content").and_then(|c| c.as_array()) { + Some(blocks) => blocks, + None => continue, + }; + + // 分离 tool_result 和 text block + let mut tool_results: Vec = Vec::new(); + let mut text_blocks: Vec = Vec::new(); + let mut valid = true; + + for block in content { + match block.get("type").and_then(|t| t.as_str()) { + Some("tool_result") => tool_results.push(block.clone()), + Some("text") => text_blocks.push(block.clone()), + _ => { + // 存在其他类型 block → 跳过此消息 + valid = false; + break; + } + } + } + + // 必须同时有 tool_result 和 text 才需要合并 + if !valid || tool_results.is_empty() || text_blocks.is_empty() { + continue; + } + + // 合并策略(与参考实现对齐) + let merged = merge_blocks_into_tool_results(tool_results, text_blocks); + msg["content"] = Value::Array(merged); + } + + // Phase 2: 跨消息合并 — 连续的 tool_result-only 用户消息合并 + let messages = body["messages"].as_array().unwrap().clone(); + if messages.len() <= 1 { + return body; + } + + let mut merged_msgs: Vec = Vec::with_capacity(messages.len()); + let mut i = 0; + + while i < messages.len() { + if is_tool_result_only_message(&messages[i]) { + let mut combined_content: Vec = Vec::new(); + while i < messages.len() && is_tool_result_only_message(&messages[i]) { + if let Some(content) = messages[i].get("content").and_then(|c| c.as_array()) { + combined_content.extend(content.iter().cloned()); + } + i += 1; + } + if !combined_content.is_empty() { + merged_msgs.push(serde_json::json!({ + "role": "user", + "content": combined_content + })); + } + } else { + merged_msgs.push(messages[i].clone()); + i += 1; + } + } + + body["messages"] = Value::Array(merged_msgs); + body +} + +/// 基于最后一条用户消息内容生成确定性 Request ID。 +/// +/// 与参考实现对齐: +/// - 哈希输入: sessionId + lastUserContent(排除 tool_result 和 cache_control) +/// - 找不到用户内容时退化为随机 UUID +/// - 使用 UUID v4 格式 +pub fn deterministic_request_id(body: &Value, session_id: &str) -> String { + let last_user_content = find_last_user_content(body); + + match last_user_content { + Some(content) => { + let mut hasher = Sha256::new(); + hasher.update(session_id.as_bytes()); + hasher.update(content.as_bytes()); + let result = hasher.finalize(); + + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&result[..16]); + // UUID v4 版本位和变体位(与参考实现一致) + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + Uuid::from_bytes(bytes).to_string() + } + None => Uuid::new_v4().to_string(), + } +} + +// ─── 内部辅助 ───────────────────────────────── + +/// 查找最后一条 user 消息的非 tool_result 文本内容。 +/// +/// 与参考实现的 `findLastUserContent` 对齐: +/// - 从后往前遍历消息 +/// - 排除 tool_result block +/// - 排除 cache_control 字段 +fn find_last_user_content(body: &Value) -> Option { + let messages = body.get("messages").and_then(|m| m.as_array())?; + + for msg in messages.iter().rev() { + if msg.get("role").and_then(|r| r.as_str()) != Some("user") { + continue; + } + let content = msg.get("content")?; + + if let Some(s) = content.as_str() { + return Some(s.to_string()); + } + + if let Some(blocks) = content.as_array() { + // 过滤 tool_result,保留其他 block(去掉 cache_control) + let filtered: Vec = blocks + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) != Some("tool_result")) + .map(|b| { + let mut b = b.clone(); + if let Some(obj) = b.as_object_mut() { + obj.remove("cache_control"); + } + b + }) + .collect(); + + if !filtered.is_empty() { + return Some(serde_json::to_string(&filtered).unwrap_or_default()); + } + } + } + + None +} + +/// 将 text block 合并进 tool_result block。 +/// +/// 两种合并策略(与参考实现对齐): +/// - 数量相等:一一对应,text 追加到对应 tool_result 的 content 中 +/// - 数量不等:所有 text 追加到最后一个 tool_result 的 content 中 +fn merge_blocks_into_tool_results( + mut tool_results: Vec, + text_blocks: Vec, +) -> Vec { + if tool_results.len() == text_blocks.len() { + // 一一对应合并 + for (tr, tb) in tool_results.iter_mut().zip(text_blocks.iter()) { + append_text_to_tool_result(tr, tb); + } + } else { + // 所有 text 追加到最后一个 tool_result + if let Some(last_tr) = tool_results.last_mut() { + for tb in &text_blocks { + append_text_to_tool_result(last_tr, tb); + } + } + } + tool_results +} + +/// 将 text block 的内容追加到 tool_result 的 content 中 +fn append_text_to_tool_result(tool_result: &mut Value, text_block: &Value) { + let text = text_block + .get("text") + .and_then(|t| t.as_str()) + .unwrap_or(""); + if text.trim().is_empty() { + return; + } + + // tool_result 的 content 可以是字符串或数组 + match tool_result.get("content") { + Some(c) if c.is_string() => { + let existing = c.as_str().unwrap_or(""); + tool_result["content"] = Value::String(format!("{existing}\n{text}")); + } + Some(c) if c.is_array() => { + let arr = tool_result["content"].as_array_mut().unwrap(); + arr.push(serde_json::json!({"type": "text", "text": text})); + } + _ => { + // content 缺失或 null — 直接设置 + tool_result["content"] = Value::String(text.to_string()); + } + } +} + +/// 从消息中提取文本内容 +fn extract_text_from_message(msg: &Value) -> String { + match msg.get("content") { + Some(content) if content.is_string() => content.as_str().unwrap_or("").to_string(), + Some(content) if content.is_array() => { + let blocks = content.as_array().unwrap(); + blocks + .iter() + .filter_map(|block| { + if block.get("type").and_then(|t| t.as_str()) == Some("text") { + block.get("text").and_then(|t| t.as_str()) + } else { + None + } + }) + .collect::>() + .join(" ") + } + _ => String::new(), + } +} + +/// 判断消息是否为 tool_result-only 的用户消息 +fn is_tool_result_only_message(msg: &Value) -> bool { + if msg.get("role").and_then(|r| r.as_str()) != Some("user") { + return false; + } + match msg.get("content").and_then(|c| c.as_array()) { + Some(blocks) if !blocks.is_empty() => blocks + .iter() + .all(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")), + _ => false, + } +} + +// ─── 测试 ───────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // === classify_request 测试 === + + #[test] + fn test_classify_user_text_message() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "Hello, please help me write some code"} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + assert!(!result.is_compact); + } + + #[test] + fn test_classify_user_text_array_message() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "Please explain this code"} + ]} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + } + + #[test] + fn test_classify_tool_result_only() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "tools": [{"name": "Read", "description": "Read a file", "input_schema": {}}], + "messages": [ + {"role": "user", "content": "Read the file"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "I'll read that file."}, + {"type": "tool_use", "id": "toolu_123", "name": "Read", "input": {"path": "/tmp/test.rs"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_123", "content": "file contents here"} + ]} + ] + }); + let result = classify_request(&body, true, true); + assert_eq!(result.initiator, "agent"); + assert!(!result.is_warmup); + } + + #[test] + fn test_classify_tool_result_with_text_block() { + // 参考实现的关键场景:tool_result + text block + // 有非 tool_result block → 仍然是 "user" + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_123", "content": "file contents"}, + {"type": "text", "text": "Now please refactor this code"} + ]} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + } + + #[test] + fn test_classify_empty_messages() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + } + + #[test] + fn test_classify_no_messages() { + let body = json!({"model": "claude-sonnet-4-20250514"}); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + } + + #[test] + fn test_classify_compact_request_system_prompt() { + // compact 通过 system prompt 强特征检测 + let body = json!({ + "model": "claude-sonnet-4-20250514", + "system": "You are a helpful AI assistant tasked with summarizing conversations. Please create a summary.", + "messages": [ + {"role": "user", "content": "Here is the conversation history to summarize..."} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "agent"); + assert!(result.is_compact); + } + + #[test] + fn test_classify_compact_request_critical_marker() { + // compact 通过 CRITICAL 机器指令检测 + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Summarize the conversation."} + ]} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "agent"); + assert!(result.is_compact); + } + + #[test] + fn test_classify_compact_disabled_by_config() { + // compact_detection=false 时,即使内容匹配也不标记为 compact + let body = json!({ + "model": "claude-sonnet-4-20250514", + "system": "You are a helpful AI assistant tasked with summarizing conversations.", + "messages": [ + {"role": "user", "content": "Summarize"} + ] + }); + let result = classify_request(&body, false, false); // compact_detection=false + assert_eq!(result.initiator, "user"); // 不被标记为 agent + assert!(!result.is_compact); + } + + #[test] + fn test_no_false_positive_on_user_summarize_request() { + // P1 修复验证:用户手动输入 "summarize the conversation" 不应被误判为 compact + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "Please summarize the conversation so far into a concise summary."} + ] + }); + let result = classify_request(&body, false, true); + // 没有 system prompt 强特征,也没有 CRITICAL 指令 → 不是 compact → user + assert_eq!(result.initiator, "user"); + assert!(!result.is_compact); + } + + // === warmup 测试(与参考实现对齐) === + + #[test] + fn test_warmup_with_anthropic_beta_no_tools() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "Hello"} + ] + }); + // has_anthropic_beta=true, 无 tools → warmup + let result = classify_request(&body, true, true); + assert!(result.is_warmup); + } + + #[test] + fn test_not_warmup_without_anthropic_beta() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "Hello"} + ] + }); + // has_anthropic_beta=false → 不是 warmup + let result = classify_request(&body, false, true); + assert!(!result.is_warmup); + } + + #[test] + fn test_not_warmup_with_tools() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "tools": [{"name": "Read", "description": "Read a file", "input_schema": {}}], + "messages": [ + {"role": "user", "content": "Hello"} + ] + }); + // 有 tools → 不是 warmup(即使有 anthropic-beta) + let result = classify_request(&body, true, true); + assert!(!result.is_warmup); + } + + #[test] + fn test_not_warmup_when_agent() { + // tool_result → agent → 不判定 warmup + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_123", "content": "ok"} + ]} + ] + }); + let result = classify_request(&body, true, true); + assert_eq!(result.initiator, "agent"); + assert!(!result.is_warmup); + } + + // === merge_tool_results 测试 === + + #[test] + fn test_merge_intra_message_tool_result_text() { + // 核心场景:消息内部 tool_result + text → text 被吸收进 tool_result + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "file contents"}, + {"type": "text", "text": "skill output here"} + ]} + ] + }); + let result = merge_tool_results(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + // 应只剩 1 个 tool_result block(text 被吸收) + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "tool_result"); + // tool_result 的 content 应包含原始内容 + 吸收的 text + let tr_content = content[0]["content"].as_str().unwrap(); + assert!(tr_content.contains("file contents")); + assert!(tr_content.contains("skill output here")); + } + + #[test] + fn test_merge_intra_message_equal_count() { + // 数量相等:一一对应合并 + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "result1"}, + {"type": "text", "text": "text1"}, + {"type": "tool_result", "tool_use_id": "t2", "content": "result2"}, + {"type": "text", "text": "text2"} + ]} + ] + }); + let result = merge_tool_results(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + assert!(content[0]["content"].as_str().unwrap().contains("text1")); + assert!(content[1]["content"].as_str().unwrap().contains("text2")); + } + + #[test] + fn test_merge_intra_message_empty_text_ignored() { + // 空 text block 不追加内容 + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "result"}, + {"type": "text", "text": ""} + ]} + ] + }); + let result = merge_tool_results(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1); + // 空 text 不改变原始 content + assert_eq!(content[0]["content"], "result"); + } + + #[test] + fn test_merge_intra_skips_other_block_types() { + // 有非 tool_result/text 的 block → 跳过整条消息 + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "result"}, + {"type": "image", "source": {"data": "..."}}, + {"type": "text", "text": "caption"} + ]} + ] + }); + let result = merge_tool_results(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + // 未合并,保持原样 3 个 block + assert_eq!(content.len(), 3); + } + + #[test] + fn test_merge_cross_message_consecutive() { + // 跨消息合并:连续 tool_result-only 用户消息 + let body = json!({ + "messages": [ + {"role": "user", "content": "Read files"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "Read", "input": {}}, + {"type": "tool_use", "id": "t2", "name": "Read", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "file1"} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t2", "content": "file2"} + ]} + ] + }); + let result = merge_tool_results(body); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 3); + let merged_content = messages[2]["content"].as_array().unwrap(); + assert_eq!(merged_content.len(), 2); + } + + #[test] + fn test_merge_does_not_affect_normal_messages() { + let body = json!({ + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "How are you?"} + ] + }); + let result = merge_tool_results(body.clone()); + assert_eq!(result["messages"], body["messages"]); + } + + // === deterministic_request_id 测试 === + + #[test] + fn test_deterministic_id_stable() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hello"}] + }); + let id1 = deterministic_request_id(&body, "session1"); + let id2 = deterministic_request_id(&body, "session1"); + assert_eq!(id1, id2); + } + + #[test] + fn test_deterministic_id_varies_by_content() { + let body1 = json!({ + "messages": [{"role": "user", "content": "Hello"}] + }); + let body2 = json!({ + "messages": [{"role": "user", "content": "Goodbye"}] + }); + let id1 = deterministic_request_id(&body1, "session1"); + let id2 = deterministic_request_id(&body2, "session1"); + assert_ne!(id1, id2); + } + + #[test] + fn test_deterministic_id_varies_by_session() { + let body = json!({ + "messages": [{"role": "user", "content": "Hello"}] + }); + let id1 = deterministic_request_id(&body, "session1"); + let id2 = deterministic_request_id(&body, "session2"); + assert_ne!(id1, id2); + } + + #[test] + fn test_deterministic_id_ignores_tool_result() { + // tool_result 内容不同,但 user text 相同 → 相同 ID + let body1 = json!({ + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "version_A"} + ]}, + {"role": "user", "content": "do something"} + ] + }); + let body2 = json!({ + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "version_B"} + ]}, + {"role": "user", "content": "do something"} + ] + }); + let id1 = deterministic_request_id(&body1, "s"); + let id2 = deterministic_request_id(&body2, "s"); + assert_eq!(id1, id2); + } + + #[test] + fn test_deterministic_id_fallback_when_no_user_content() { + // 无用户消息 → 退化为随机 UUID(每次不同) + let body = json!({ + "messages": [ + {"role": "assistant", "content": "Hi"} + ] + }); + let id1 = deterministic_request_id(&body, "s"); + let id2 = deterministic_request_id(&body, "s"); + // 随机 UUID,每次应不同 + assert_ne!(id1, id2); + } + + #[test] + fn test_deterministic_id_is_valid_uuid() { + let body = json!({ + "messages": [{"role": "user", "content": "test"}] + }); + let id = deterministic_request_id(&body, "session"); + assert!(Uuid::parse_str(&id).is_ok()); + } + + // === compact 检测增强测试 === + + #[test] + fn test_compact_detection_system_prompt() { + let body = json!({ + "system": "You are a helpful AI assistant tasked with summarizing conversations. Please provide a concise summary.", + "messages": [ + {"role": "user", "content": "Here is the conversation to summarize..."} + ] + }); + assert!(is_compact_request(&body)); + } + + #[test] + fn test_compact_detection_critical_keyword() { + let body = json!({ + "messages": [ + {"role": "user", "content": "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Summarize this conversation."} + ] + }); + assert!(is_compact_request(&body)); + } + + #[test] + fn test_compact_detection_structural_markers() { + // Claude Code compact 特有的结构标记 + let body = json!({ + "messages": [ + {"role": "user", "content": "Summary of conversation:\n\nPending Tasks:\n- Fix bug\n\nCurrent Work:\n- Implementing feature"} + ] + }); + assert!(is_compact_request(&body)); + } + + #[test] + fn test_compact_no_false_positive_on_generic_summary() { + // 通用短语不应触发 compact 检测 + let body = json!({ + "messages": [ + {"role": "user", "content": "Your task is to create a detailed summary of the conversation so far."} + ] + }); + assert!(!is_compact_request(&body)); + } + + #[test] + fn test_compact_detection_negative() { + let body = json!({ + "messages": [ + {"role": "user", "content": "What is the weather today?"} + ] + }); + assert!(!is_compact_request(&body)); + } + + #[test] + fn test_compact_detection_system_array() { + let body = json!({ + "system": [ + {"type": "text", "text": "You are a helpful AI assistant tasked with summarizing conversations."} + ], + "messages": [ + {"role": "user", "content": "Summarize"} + ] + }); + assert!(is_compact_request(&body)); + } +} diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 635a8bc32..13516c383 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -14,7 +14,7 @@ use super::{ thinking_rectifier::{ normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, }, - types::{OptimizerConfig, ProxyStatus, RectifierConfig}, + types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig}, ProxyError, }; use crate::commands::CopilotAuthState; @@ -52,6 +52,8 @@ pub struct RequestForwarder { rectifier_config: RectifierConfig, /// 优化器配置 optimizer_config: OptimizerConfig, + /// Copilot 优化器配置 + copilot_optimizer_config: CopilotOptimizerConfig, /// 非流式请求超时(秒) non_streaming_timeout: std::time::Duration, } @@ -70,6 +72,7 @@ impl RequestForwarder { _streaming_idle_timeout: u64, rectifier_config: RectifierConfig, optimizer_config: OptimizerConfig, + copilot_optimizer_config: CopilotOptimizerConfig, ) -> Self { Self { router, @@ -80,6 +83,7 @@ impl RequestForwarder { current_provider_id_at_start, rectifier_config, optimizer_config, + copilot_optimizer_config, non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout), } } @@ -760,7 +764,7 @@ impl RequestForwarder { super::model_mapper::apply_model_mapping(body.clone(), provider); // 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口) - let mapped_body = normalize_thinking_type(mapped_body); + let mut mapped_body = normalize_thinking_type(mapped_body); // 确定有效端点 // GitHub Copilot API 使用 /chat/completions(无 /v1 前缀) @@ -771,6 +775,62 @@ impl RequestForwarder { == Some("github_copilot") || base_url.contains("githubcopilot.com"); + // --- Copilot 优化器:请求体优化 + 分类(在格式转换之前执行) --- + // 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move + let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled { + // 1. Tool result 合并 — 必须在分类之前执行 + // 合并将 [tool_result, text] 变为 [tool_result(含text)], + // 分类才能正确识别为 agent(全是 tool_result)而非 user(有 text block) + if self.copilot_optimizer_config.tool_result_merging { + mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body); + } + + // 2. 在合并后的 body 上进行分类 + let has_anthropic_beta = headers.contains_key("anthropic-beta"); + let classification = super::copilot_optimizer::classify_request( + &mapped_body, + has_anthropic_beta, + self.copilot_optimizer_config.compact_detection, + ); + + log::debug!( + "[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}", + classification.initiator, + classification.is_warmup, + classification.is_compact + ); + + // 3. Warmup 小模型降级 + if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup { + log::info!( + "[Copilot] Warmup 请求降级到模型: {}", + self.copilot_optimizer_config.warmup_model + ); + mapped_body["model"] = + serde_json::json!(&self.copilot_optimizer_config.warmup_model); + } + + // 预计算确定性 Request ID(在 body 被 move 之前) + // 使用 session_id 从 body.metadata.user_id 或请求头提取 + let session_id = body + .pointer("/metadata/user_id") + .and_then(|v| v.as_str()) + .or_else(|| headers.get("x-session-id").and_then(|v| v.to_str().ok())) + .unwrap_or(""); + let det_request_id = if self.copilot_optimizer_config.deterministic_request_id { + Some(super::copilot_optimizer::deterministic_request_id( + &mapped_body, + session_id, + )) + } else { + None + }; + + Some((classification, det_request_id)) + } else { + None + }; + // GitHub Copilot 动态 endpoint 路由 // 从 CopilotAuthManager 获取缓存的 API endpoint(支持企业版等非默认 endpoint) if is_copilot && !is_full_url { @@ -858,7 +918,7 @@ impl RequestForwarder { || should_force_identity_encoding(&effective_endpoint, &filtered_body, headers); // 获取认证头(提前准备,用于内联替换) - let auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) { + let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) { // GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token if auth.strategy == AuthStrategy::GitHubCopilot { if let Some(app_handle) = &self.app_handle { @@ -914,6 +974,25 @@ impl RequestForwarder { Vec::new() }; + // --- Copilot 优化器:动态 header 注入 --- + if let Some((ref classification, ref det_request_id)) = copilot_optimization { + for (name, value) in auth_headers.iter_mut() { + match name.as_str() { + "x-initiator" if self.copilot_optimizer_config.request_classification => { + *value = http::HeaderValue::from_static(classification.initiator); + } + "x-request-id" | "x-agent-task-id" => { + if let Some(ref det_id) = det_request_id { + if let Ok(hv) = http::HeaderValue::from_str(det_id) { + *value = hv; + } + } + } + _ => {} + } + } + } + // Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重) let copilot_fingerprint_headers: &[&str] = if is_copilot { &[ diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index 4f36434db..bad855a91 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, OptimizerConfig, RectifierConfig}, + types::{AppProxyConfig, CopilotOptimizerConfig, OptimizerConfig, RectifierConfig}, ProxyError, }; use axum::http::HeaderMap; @@ -61,6 +61,8 @@ pub struct RequestContext { pub rectifier_config: RectifierConfig, /// 优化器配置 pub optimizer_config: OptimizerConfig, + /// Copilot 优化器配置 + pub copilot_optimizer_config: CopilotOptimizerConfig, } impl RequestContext { @@ -96,6 +98,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 copilot_optimizer_config = state.db.get_copilot_optimizer_config().unwrap_or_default(); let current_provider_id = crate::settings::get_current_provider(&app_type).unwrap_or_default(); @@ -160,6 +163,7 @@ impl RequestContext { session_id, rectifier_config, optimizer_config, + copilot_optimizer_config, }) } @@ -221,6 +225,7 @@ impl RequestContext { idle_timeout, self.rectifier_config.clone(), self.optimizer_config.clone(), + self.copilot_optimizer_config.clone(), ) } diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 93d819ea1..a14818c5d 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -5,6 +5,7 @@ pub mod body_filter; pub mod cache_injector; pub mod circuit_breaker; +pub mod copilot_optimizer; pub mod error; pub mod error_mapper; pub(crate) mod failover_switch; diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 6f5c19fe1..08df9e6eb 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -269,6 +269,54 @@ impl Default for OptimizerConfig { } } +/// Copilot 优化器配置 +/// +/// 存储在 settings 表中,key = "copilot_optimizer_config" +/// 解决 Copilot 代理消耗量异常问题(Issue #1813) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CopilotOptimizerConfig { + /// 总开关(默认开启 — 对 Copilot 用户至关重要) + #[serde(default = "default_true")] + pub enabled: bool, + /// x-initiator 请求分类(默认开启,P0 优先级) + #[serde(default = "default_true")] + pub request_classification: bool, + /// Tool result 消息合并(默认开启,P1 优先级) + #[serde(default = "default_true")] + pub tool_result_merging: bool, + /// Compact 请求识别(默认开启,P2 优先级) + #[serde(default = "default_true")] + pub compact_detection: bool, + /// 确定性 Request ID(默认开启,P3 优先级) + #[serde(default = "default_true")] + pub deterministic_request_id: bool, + /// Warmup 小模型降级(默认关闭,P4 优先级,opt-in) + #[serde(default)] + pub warmup_downgrade: bool, + /// Warmup 降级使用的模型(默认 "gpt-4o-mini") + #[serde(default = "default_warmup_model")] + pub warmup_model: String, +} + +fn default_warmup_model() -> String { + "gpt-4o-mini".to_string() +} + +impl Default for CopilotOptimizerConfig { + fn default() -> Self { + Self { + enabled: true, + request_classification: true, + tool_result_merging: true, + compact_detection: true, + deterministic_request_id: true, + warmup_downgrade: false, + warmup_model: "gpt-4o-mini".to_string(), + } + } +} + /// 日志配置 /// /// 存储在 settings 表的 log_config 字段中(JSON 格式) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 72f8821ed..9f9b06c58 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -313,11 +313,10 @@ pub fn create_tray_menu( if providers.is_empty() { // 空供应商:显示禁用的菜单项 let label = format!("{} {}", section.header_label, tray_texts.no_providers_label); - let empty_item = - MenuItem::with_id(app, section.empty_id, &label, false, None::<&str>) - .map_err(|e| { - AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)) - })?; + let empty_item = MenuItem::with_id(app, section.empty_id, &label, false, None::<&str>) + .map_err(|e| { + AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)) + })?; menu_builder = menu_builder.item(&empty_item); } else { // 有供应商:构建子菜单