mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(proxy): drop empty pages from Read tool input (#2472)
* fix(proxy): drop empty pages from Read tool input * fix(proxy): preserve Read args across duplicate tool starts
This commit is contained in:
@@ -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<E: std::error::Error + Send +
|
||||
let mut fallback_open_index: Option<u32> = None;
|
||||
let mut current_text_index: Option<u32> = None;
|
||||
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
|
||||
let mut tool_name_by_index: HashMap<u32, String> = HashMap::new();
|
||||
let mut tool_args_by_index: HashMap<u32, String> = HashMap::new();
|
||||
let mut last_tool_index: Option<u32> = None;
|
||||
|
||||
tokio::pin!(stream);
|
||||
@@ -413,12 +418,15 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
{
|
||||
tool_index_by_item_id.insert(item_id.to_string(), index);
|
||||
}
|
||||
tool_name_by_index.insert(index, name.to_string());
|
||||
last_tool_index = Some(index);
|
||||
|
||||
if open_indices.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tool_args_by_index.insert(index, String::new());
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
@@ -482,6 +490,14 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
open_indices.insert(index);
|
||||
}
|
||||
|
||||
if tool_name_by_index.get(&index).map(String::as_str) == Some("Read") {
|
||||
tool_args_by_index
|
||||
.entry(index)
|
||||
.or_default()
|
||||
.push_str(delta);
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
@@ -515,6 +531,32 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
if tool_name_by_index.get(&index).map(String::as_str) == Some("Read") {
|
||||
let raw = data
|
||||
.get("arguments")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
tool_args_by_index
|
||||
.get(&index)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let sanitized = sanitize_anthropic_tool_use_input_json("Read", &raw);
|
||||
if !sanitized.is_empty() {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": sanitized
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
@@ -525,6 +567,8 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
if let Some(item_id) = item_id {
|
||||
tool_index_by_item_id.remove(item_id);
|
||||
}
|
||||
tool_name_by_index.remove(&index);
|
||||
tool_args_by_index.remove(&index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,6 +870,71 @@ mod tests {
|
||||
assert!(merged.contains("\"type\":\"message_stop\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_read_tool_drops_empty_pages() {
|
||||
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.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_read\",\"arguments\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0,\\\"pages\\\":\\\"\\\"}\"}\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::<String>();
|
||||
|
||||
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::<String>();
|
||||
|
||||
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!(
|
||||
|
||||
@@ -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::<Value>(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<Value, ProxyError> {
|
||||
.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!({
|
||||
|
||||
Reference in New Issue
Block a user