From 1dfa8a464964f5c45ea23811356ed4d4c75082a8 Mon Sep 17 00:00:00 2001 From: Tomodad <175448937@qq.com> Date: Mon, 27 Apr 2026 18:51:57 +0800 Subject: [PATCH 1/3] fix(proxy): normalize OpenAI chat payloads Map Anthropic tool_choice values to OpenAI Chat formats and strip Anthropic cache_control fields so strict OpenAI-compatible providers accept Claude Code requests. Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/proxy/providers/transform.rs | 132 ++++++++++++++------- 1 file changed, 90 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 72e6b0f029..735caf34ee 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -88,14 +88,9 @@ pub fn anthropic_to_openai(body: Value) -> Result { // 单个字符串 messages.push(json!({"role": "system", "content": text})); } else if let Some(arr) = system.as_array() { - // 多个 system message — preserve cache_control for compatible proxies for msg in arr { if let Some(text) = msg.get("text").and_then(|t| t.as_str()) { - let mut sys_msg = json!({"role": "system", "content": text}); - if let Some(cc) = msg.get("cache_control") { - sys_msg["cache_control"] = cc.clone(); - } - messages.push(sys_msg); + messages.push(json!({"role": "system", "content": text})); } } } @@ -149,18 +144,14 @@ pub fn anthropic_to_openai(body: Value) -> Result { .iter() .filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool")) .map(|t| { - let mut tool = json!({ + json!({ "type": "function", "function": { "name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""), "description": t.get("description"), "parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({}))) } - }); - if let Some(cc) = t.get("cache_control") { - tool["cache_control"] = cc.clone(); - } - tool + }) }) .collect(); @@ -170,12 +161,37 @@ pub fn anthropic_to_openai(body: Value) -> Result { } if let Some(v) = body.get("tool_choice") { - result["tool_choice"] = v.clone(); + if let Some(tool_choice) = convert_tool_choice_to_openai(v) { + result["tool_choice"] = tool_choice; + } } Ok(result) } +fn convert_tool_choice_to_openai(value: &Value) -> Option { + if value.is_null() { + return None; + } + + if value.as_str().is_some() { + return Some(value.clone()); + } + + let obj = value.as_object()?; + match obj.get("type").and_then(|v| v.as_str()) { + Some("auto") => Some(json!("auto")), + Some("any") => Some(json!("required")), + Some("none") => Some(json!("none")), + Some("tool") => obj + .get("name") + .and_then(|name| name.as_str()) + .map(|name| json!({"type": "function", "function": {"name": name}})), + Some("function") => Some(value.clone()), + _ => None, + } +} + fn normalize_openai_system_messages(messages: &mut Vec) { let system_count = messages .iter() @@ -280,11 +296,7 @@ fn convert_message_to_openai( match block_type { "text" => { if let Some(text) = block.get("text").and_then(|t| t.as_str()) { - let mut part = json!({"type": "text", "text": text}); - if let Some(cc) = block.get("cache_control") { - part["cache_control"] = cc.clone(); - } - content_parts.push(part); + content_parts.push(json!({"type": "text", "text": text})); } } "image" => { @@ -346,14 +358,8 @@ fn convert_message_to_openai( if content_parts.is_empty() { msg["content"] = Value::Null; } else if content_parts.len() == 1 { - // When cache_control is present, keep array format to preserve it - let has_cache_control = content_parts[0].get("cache_control").is_some(); - if !has_cache_control { - if let Some(text) = content_parts[0].get("text") { - msg["content"] = text.clone(); - } else { - msg["content"] = json!(content_parts); - } + if let Some(text) = content_parts[0].get("text") { + msg["content"] = text.clone(); } else { msg["content"] = json!(content_parts); } @@ -627,7 +633,7 @@ mod tests { } #[test] - fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() { + fn test_anthropic_to_openai_drops_matching_system_cache_control_when_merging() { let input = json!({ "model": "claude-3-sonnet", "max_tokens": 1024, @@ -645,7 +651,7 @@ mod tests { result["messages"][0]["content"], "You are Claude Code.\nBe concise." ); - assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral"); + assert!(result["messages"][0].get("cache_control").is_none()); assert_eq!(result["messages"][1]["role"], "user"); } @@ -691,6 +697,58 @@ mod tests { assert!(result["messages"][0].get("cache_control").is_none()); } + #[test] + fn test_anthropic_to_openai_maps_anthropic_tool_choice() { + let base = json!({ + "model": "claude-3-opus", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let mut auto = base.clone(); + auto["tool_choice"] = json!({"type": "auto"}); + assert_eq!(anthropic_to_openai(auto).unwrap()["tool_choice"], "auto"); + + let mut any = base.clone(); + any["tool_choice"] = json!({"type": "any"}); + assert_eq!(anthropic_to_openai(any).unwrap()["tool_choice"], "required"); + + let mut none = base.clone(); + none["tool_choice"] = json!({"type": "none"}); + assert_eq!(anthropic_to_openai(none).unwrap()["tool_choice"], "none"); + + let mut tool = base.clone(); + tool["tool_choice"] = json!({"type": "tool", "name": "get_weather"}); + let result = anthropic_to_openai(tool).unwrap(); + assert_eq!(result["tool_choice"]["type"], "function"); + assert_eq!(result["tool_choice"]["function"]["name"], "get_weather"); + + let mut native = base.clone(); + native["tool_choice"] = json!({"type": "function", "function": {"name": "get_weather"}}); + assert_eq!( + anthropic_to_openai(native).unwrap()["tool_choice"], + json!({"type": "function", "function": {"name": "get_weather"}}) + ); + + let mut string = base.clone(); + string["tool_choice"] = json!("auto"); + assert_eq!(anthropic_to_openai(string).unwrap()["tool_choice"], "auto"); + + let mut null_choice = base.clone(); + null_choice["tool_choice"] = Value::Null; + assert!(anthropic_to_openai(null_choice) + .unwrap() + .get("tool_choice") + .is_none()); + + let mut unknown = base; + unknown["tool_choice"] = json!({"type": "unsupported"}); + assert!(anthropic_to_openai(unknown) + .unwrap() + .get("tool_choice") + .is_none()); + } + #[test] fn test_anthropic_to_openai_tool_use() { let input = json!({ @@ -814,7 +872,7 @@ mod tests { } #[test] - fn test_anthropic_to_openai_cache_control_preserved() { + fn test_anthropic_to_openai_drops_cache_control() { let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, @@ -836,19 +894,9 @@ mod tests { }); let result = anthropic_to_openai(input).unwrap(); - // System message cache_control preserved - assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral"); - // Text block cache_control preserved - assert_eq!( - result["messages"][1]["content"][0]["cache_control"]["type"], - "ephemeral" - ); - assert_eq!( - result["messages"][1]["content"][0]["cache_control"]["ttl"], - "5m" - ); - // Tool cache_control preserved - assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral"); + assert!(result["messages"][0].get("cache_control").is_none()); + assert_eq!(result["messages"][1]["content"], "Hello"); + assert!(result["tools"][0].get("cache_control").is_none()); } #[test] From f0e34f904f02d4d46f7de8f458488f44281ae242 Mon Sep 17 00:00:00 2001 From: Tomodad <175448937@qq.com> Date: Mon, 27 Apr 2026 21:41:18 +0800 Subject: [PATCH 2/3] fix(proxy): flatten OpenAI chat text blocks --- src-tauri/src/proxy/providers/transform.rs | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 735caf34ee..4c43fb1477 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -363,6 +363,16 @@ fn convert_message_to_openai( } else { msg["content"] = json!(content_parts); } + } else if content_parts + .iter() + .all(|part| part.get("type").and_then(|v| v.as_str()) == Some("text")) + { + let text = content_parts + .iter() + .filter_map(|part| part.get("text").and_then(|v| v.as_str())) + .collect::>() + .join("\n"); + msg["content"] = json!(text); } else { msg["content"] = json!(content_parts); } @@ -790,6 +800,27 @@ mod tests { assert_eq!(msg["content"], "Sunny, 25°C"); } + #[test] + fn test_anthropic_to_openai_flattens_text_only_content_blocks() { + let input = json!({ + "model": "moonshotai/kimi-k2.5", + "max_tokens": 1024, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "Caveat: local command output follows."}, + {"type": "text", "text": "Tell me the settings path."} + ] + }] + }); + + let result = anthropic_to_openai(input).unwrap(); + assert_eq!( + result["messages"][0]["content"], + "Caveat: local command output follows.\nTell me the settings path." + ); + } + #[test] fn test_openai_to_anthropic_simple() { let input = json!({ From 63de4fb972ac545e74756d5938b17b387defa9c8 Mon Sep 17 00:00:00 2001 From: Tomodad <175448937@qq.com> Date: Mon, 27 Apr 2026 23:17:32 +0800 Subject: [PATCH 3/3] fix(proxy): harden OpenAI tool streaming conversion --- src-tauri/src/proxy/providers/streaming.rs | 93 +++++++++++++++++++++- 1 file changed, 90 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index d6339e5d20..7d0255cb6c 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -108,6 +108,7 @@ pub fn create_anthropic_sse_stream( let mut has_sent_message_start = false; let mut current_non_tool_block_type: Option<&'static str> = None; let mut current_non_tool_block_index: Option = None; + let mut pending_leading_text = String::new(); let mut tool_blocks_by_index: HashMap = HashMap::new(); let mut open_tool_block_indices: HashSet = HashSet::new(); @@ -180,6 +181,7 @@ pub fn create_anthropic_sse_stream( // 处理 reasoning(thinking) if let Some(reasoning) = &choice.delta.reasoning { + pending_leading_text.clear(); if current_non_tool_block_type != Some("thinking") { if let Some(index) = current_non_tool_block_index.take() { let event = json!({ @@ -225,6 +227,14 @@ pub fn create_anthropic_sse_stream( // 处理文本内容 if let Some(content) = &choice.delta.content { if !content.is_empty() { + if current_non_tool_block_index.is_none() + && current_non_tool_block_type.is_none() + && content.trim().is_empty() + { + pending_leading_text.push_str(content); + continue; + } + if current_non_tool_block_type != Some("text") { if let Some(index) = current_non_tool_block_index.take() { let event = json!({ @@ -254,12 +264,20 @@ pub fn create_anthropic_sse_stream( } if let Some(index) = current_non_tool_block_index { + let text = if pending_leading_text.is_empty() { + content.clone() + } else { + let mut text = + std::mem::take(&mut pending_leading_text); + text.push_str(content); + text + }; let event = json!({ "type": "content_block_delta", "index": index, "delta": { "type": "text_delta", - "text": content + "text": text } }); let sse_data = format!("event: content_block_delta\ndata: {}\n\n", @@ -271,6 +289,7 @@ pub fn create_anthropic_sse_stream( // 处理工具调用 if let Some(tool_calls) = &choice.delta.tool_calls { + pending_leading_text.clear(); if let Some(index) = current_non_tool_block_index.take() { let event = json!({ "type": "content_block_stop", @@ -381,7 +400,8 @@ pub fn create_anthropic_sse_stream( "content_block": { "type": "tool_use", "id": id, - "name": name + "name": name, + "input": {} } }); let sse_data = format!("event: content_block_start\ndata: {}\n\n", @@ -473,7 +493,8 @@ pub fn create_anthropic_sse_stream( "content_block": { "type": "tool_use", "id": id, - "name": name + "name": name, + "input": {} } }); let sse_data = format!("event: content_block_start\ndata: {}\n\n", @@ -658,6 +679,12 @@ mod tests { event.pointer("/content_block/id").and_then(|v| v.as_str()), event.get("index").and_then(|v| v.as_u64()), ) { + assert!( + event + .pointer("/content_block/input") + .is_some_and(Value::is_object), + "tool_use content_block_start must include an empty input object" + ); tool_index_by_call.insert(call_id.to_string(), index); } } @@ -760,6 +787,12 @@ mod tests { .unwrap_or(""), "first_tool" ); + assert!( + starts[0] + .pointer("/content_block/input") + .is_some_and(Value::is_object), + "late-started tool_use content_block_start must include input" + ); let deltas: Vec<&str> = events .iter() @@ -778,6 +811,60 @@ mod tests { assert!(deltas.contains(&"1}")); } + #[tokio::test] + async fn test_streaming_drops_whitespace_only_text_before_tool_call() { + let input = concat!( + "data: {\"id\":\"chatcmpl_4\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"\\n\\n\"}}]}\n\n", + "data: {\"id\":\"chatcmpl_4\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\" \"}}]}\n\n", + "data: {\"id\":\"chatcmpl_4\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"search\",\"arguments\":\"{\\\"q\\\":\\\"x\\\"}\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_4\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":3}}\n\n", + "data: [DONE]\n\n" + ); + + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from( + input.as_bytes().to_vec(), + ))]); + let converted = create_anthropic_sse_stream(upstream); + let chunks: Vec<_> = converted.collect().await; + let merged = chunks + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect::(); + + let events: Vec = merged + .split("\n\n") + .filter_map(|block| { + let data = block + .lines() + .find_map(|line| strip_sse_field(line, "data"))?; + serde_json::from_str::(data).ok() + }) + .collect(); + + let text_starts = events + .iter() + .filter(|event| { + event.get("type").and_then(|v| v.as_str()) == Some("content_block_start") + && event + .pointer("/content_block/type") + .and_then(|v| v.as_str()) + == Some("text") + }) + .count(); + assert_eq!( + text_starts, 0, + "leading whitespace must not create a standalone text block before tool_use" + ); + + assert!(events.iter().any(|event| { + event.get("type").and_then(|v| v.as_str()) == Some("content_block_start") + && event + .pointer("/content_block/type") + .and_then(|v| v.as_str()) + == Some("tool_use") + })); + } + #[tokio::test] async fn test_streaming_chinese_split_across_chunks_no_replacement_chars() { // "你好" split across two TCP chunks inside a streaming text delta.