From d8065cc628fcd373d00c4363d718095f19e78c9e Mon Sep 17 00:00:00 2001 From: htyvista Date: Sat, 29 Aug 2026 22:55:03 +0800 Subject: [PATCH] fix(proxy): preserve mid-conversation system messages for prefix cache (#6941) * fix(proxy): preserve mid-conversation system messages for prefix cache Remove normalize_openai_system_messages which merged all system messages to the head, breaking radix prefix cache on every turn when Claude Code injects metadata as a mid-conversation system message. Now system messages from the top-level field are placed first, and all messages (including mid-conversation system) are extended in their original order without any merging or reordering. Also add custom-protocol feature for standalone release builds. * fix(proxy): restore top-level system merge, add regression test Restore merging of top-level system array blocks into a single system message (cross-turn stable, does not affect prefix cache). Mid-conversation system messages from the messages array remain in place without merging or reordering. Add regression test to lock the new behavior: mid-conversation system messages stay at their original position. --- src-tauri/src/proxy/providers/transform.rs | 93 ++++++++++------------ 1 file changed, 40 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 6bd6c511d..7dbb9d63d 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -158,15 +158,20 @@ pub fn anthropic_to_openai_with_reasoning_content( messages.push(json!({"role": "system", "content": text})); } } else if let Some(arr) = system.as_array() { + // 顶层 system 数组合并为一条 system 消息(跨轮字节稳定,不影响前缀缓存) + let mut parts = Vec::new(); for msg in arr { if let Some(text) = msg.get("text").and_then(|t| t.as_str()) { let text = strip_leading_anthropic_billing_header(text); if text.is_empty() { continue; } - messages.push(json!({"role": "system", "content": text})); + parts.push(text.to_string()); } } + if !parts.is_empty() { + messages.push(json!({"role": "system", "content": parts.join("\n")})); + } } } @@ -180,7 +185,6 @@ pub fn anthropic_to_openai_with_reasoning_content( } } - normalize_openai_system_messages(&mut messages); result["messages"] = json!(messages); // 转换参数 — o-series 模型需要 max_completion_tokens @@ -305,57 +309,6 @@ fn map_tool_choice_to_chat(tool_choice: &Value) -> Value { } } -fn normalize_openai_system_messages(messages: &mut Vec) { - let system_count = messages - .iter() - .filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system")) - .count(); - - if system_count == 0 { - return; - } - - if system_count == 1 { - if let Some(index) = messages.iter().position(|message| { - message.get("role").and_then(|value| value.as_str()) == Some("system") - }) { - if index > 0 { - let message = messages.remove(index); - messages.insert(0, message); - } - } - return; - } - - let mut parts = Vec::new(); - messages.retain(|message| { - if message.get("role").and_then(|value| value.as_str()) != Some("system") { - return true; - } - - match message.get("content") { - Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()), - Some(Value::Array(content_parts)) => { - let text = content_parts - .iter() - .filter_map(|part| part.get("text").and_then(|value| value.as_str())) - .collect::>() - .join("\n"); - if !text.is_empty() { - parts.push(text); - } - } - _ => {} - } - - false - }); - - if !parts.is_empty() { - messages.insert(0, json!({"role": "system", "content": parts.join("\n")})); - } -} - /// 转换单条消息到 OpenAI 格式(可能产生多条消息) fn convert_message_to_openai( role: &str, @@ -988,6 +941,40 @@ mod tests { assert!(result["messages"][0].get("cache_control").is_none()); } + #[test] + fn test_anthropic_to_openai_preserves_mid_conversation_system_in_place() { + // Claude Code 会在对话中间注入 system 消息(如 ), + // 必须保持原位,不合并不上提,否则破坏前缀缓存。 + let input = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": "You are Claude Code.", + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "system", "content": "14963538 tokens left"}, + {"role": "user", "content": "Continue"} + ] + }); + + let result = anthropic_to_openai(input).unwrap(); + let messages = result["messages"].as_array().unwrap(); + + // 顶层 system 在最前面 + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[0]["content"], "You are Claude Code."); + + // 中途 system 保持原位(第 3 条,index=3),不被合并或上提 + assert_eq!(messages[3]["role"], "system"); + assert_eq!( + messages[3]["content"], + "14963538 tokens left" + ); + + // 总共 5 条消息,没有合并 + assert_eq!(messages.len(), 5); + } + #[test] fn test_anthropic_to_openai_strips_cache_control_from_conflicting_system() { let input = json!({