diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs index c7f63a14e..81615c2cf 100644 --- a/src-tauri/src/proxy/providers/streaming_responses.rs +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -8,7 +8,10 @@ //! //! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。 -use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason}; +use super::transform_responses::{ + build_anthropic_usage_from_responses, map_responses_stop_reason, + sanitize_anthropic_tool_use_input_json, +}; use crate::proxy::sse::{strip_sse_field, take_sse_block}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; @@ -112,6 +115,8 @@ pub fn create_anthropic_sse_stream_from_responses = None; let mut current_text_index: Option = None; let mut tool_index_by_item_id: HashMap = HashMap::new(); + let mut tool_name_by_index: HashMap = HashMap::new(); + let mut tool_args_by_index: HashMap = HashMap::new(); let mut last_tool_index: Option = None; tokio::pin!(stream); @@ -413,12 +418,15 @@ pub fn create_anthropic_sse_stream_from_responses(Bytes::from( + input.as_bytes().to_vec(), + ))]); + let converted = create_anthropic_sse_stream_from_responses(upstream); + let chunks: Vec<_> = converted.collect().await; + + let merged = chunks + .into_iter() + .map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string()) + .collect::(); + + assert!(merged.contains("\"name\":\"Read\"")); + assert!(merged.contains("\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0}")); + assert!(!merged.contains("\\\"pages\\\":\\\"\\\"")); + } + + #[tokio::test] + async fn test_streaming_read_tool_duplicate_start_preserves_buffered_args() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_read\",\"model\":\"gpt-5.5\"}}\n\n", + "event: response.output_item.added\n", + "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_read\",\"type\":\"function_call\",\"call_id\":\"call_read\",\"name\":\"Read\"}}\n\n", + "event: response.function_call_arguments.delta\n", + "data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_read\",\"delta\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0,\\\"pages\\\":\\\"\\\"}\"}\n\n", + "event: response.output_item.added\n", + "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_read\",\"type\":\"function_call\",\"call_id\":\"call_read\",\"name\":\"Read\"}}\n\n", + "event: response.function_call_arguments.done\n", + "data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_read\"}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n" + ); + + let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from( + input.as_bytes().to_vec(), + ))]); + let converted = create_anthropic_sse_stream_from_responses(upstream); + let chunks: Vec<_> = converted.collect().await; + + let merged = chunks + .into_iter() + .map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string()) + .collect::(); + + assert_eq!(merged.matches("event: content_block_start").count(), 1); + assert_eq!(merged.matches("event: content_block_stop").count(), 1); + assert!(merged.contains("\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0}")); + assert!(!merged.contains("\\\"pages\\\":\\\"\\\"")); + } + #[tokio::test] async fn test_streaming_conversion_interleaved_tool_deltas_by_item_id() { let input = concat!( diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs index e2ec627dd..308b3c964 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -11,6 +11,35 @@ use crate::proxy::error::ProxyError; use serde_json::{json, Value}; +pub(crate) fn sanitize_anthropic_tool_use_input(name: &str, input: Value) -> Value { + if name != "Read" { + return input; + } + + match input { + Value::Object(mut object) => { + if matches!(object.get("pages"), Some(Value::String(value)) if value.is_empty()) { + object.remove("pages"); + } + Value::Object(object) + } + other => other, + } +} + +pub(crate) fn sanitize_anthropic_tool_use_input_json(name: &str, raw: &str) -> String { + if name != "Read" || raw.is_empty() { + return raw.to_string(); + } + + let Ok(input) = serde_json::from_str::(raw) else { + return raw.to_string(); + }; + + serde_json::to_string(&sanitize_anthropic_tool_use_input(name, input)) + .unwrap_or_else(|_| raw.to_string()) +} + /// Anthropic 请求 → OpenAI Responses 请求 /// /// `cache_key`: optional prompt_cache_key to inject for improved cache routing @@ -514,6 +543,7 @@ pub fn responses_to_anthropic(body: Value) -> Result { .and_then(|a| a.as_str()) .unwrap_or("{}"); let input: Value = serde_json::from_str(args_str).unwrap_or(json!({})); + let input = sanitize_anthropic_tool_use_input(name, input); content.push(json!({ "type": "tool_use", @@ -905,6 +935,56 @@ mod tests { assert_eq!(result["stop_reason"], "tool_use"); } + #[test] + fn test_responses_to_anthropic_read_drops_empty_pages() { + let input = json!({ + "id": "resp_read", + "object": "response", + "status": "completed", + "model": "gpt-5.5", + "output": [{ + "type": "function_call", + "id": "fc_read", + "call_id": "call_read", + "name": "Read", + "arguments": "{\"file_path\":\"/tmp/demo.py\",\"limit\":2000,\"offset\":0,\"pages\":\"\"}", + "status": "completed" + }] + }); + + let result = responses_to_anthropic(input).unwrap(); + let tool_input = &result["content"][0]["input"]; + + assert_eq!(result["content"][0]["type"], "tool_use"); + assert_eq!(result["content"][0]["name"], "Read"); + assert_eq!(tool_input["file_path"], "/tmp/demo.py"); + assert_eq!(tool_input["limit"], 2000); + assert_eq!(tool_input["offset"], 0); + assert!(tool_input.get("pages").is_none()); + } + + #[test] + fn test_responses_to_anthropic_preserves_empty_strings_for_other_tools() { + let input = json!({ + "id": "resp_other", + "object": "response", + "status": "completed", + "model": "gpt-5.5", + "output": [{ + "type": "function_call", + "id": "fc_other", + "call_id": "call_other", + "name": "search", + "arguments": "{\"query\":\"\"}", + "status": "completed" + }] + }); + + let result = responses_to_anthropic(input).unwrap(); + + assert_eq!(result["content"][0]["input"]["query"], ""); + } + #[test] fn test_responses_to_anthropic_with_refusal_block() { let input = json!({