From 28580daf33286d238d07c8001f5d1bfa0f7f534a Mon Sep 17 00:00:00 2001 From: linzj Date: Mon, 13 Apr 2026 09:39:17 +0800 Subject: [PATCH] Add Anthropic web-search bridge for Responses providers Bridge Anthropic web_search tools, history replay, non-streaming responses, and SSE streaming onto OpenAI Responses web_search_call handling. Preserve usage accounting, keep non-query action metadata round-trippable across request replay and streaming, and tolerate Codex OAuth max_uses while still failing fast for generic Responses providers. Scope: this change applies to every Claude provider configured with api_format=openai_responses, not just codex_oauth, because the transform and streaming handlers are selected by API format. --- .../proxy/providers/streaming_responses.rs | 576 ++++++++++++- .../proxy/providers/transform_responses.rs | 788 +++++++++++++++++- 2 files changed, 1330 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs index 2c4b306e27..523f181c7a 100644 --- a/src-tauri/src/proxy/providers/streaming_responses.rs +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -62,6 +62,157 @@ fn tool_item_key_from_event(data: &Value) -> Option { None } +#[inline] +fn output_item_from_event(data: &Value) -> &Value { + data.get("item").unwrap_or(data) +} + +#[inline] +fn output_item_identifier(data: &Value, item: &Value) -> Option { + item.get("id") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + .or_else(|| { + data.get("item_id") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + }) + .or_else(|| { + data.get("output_index") + .and_then(|v| v.as_u64()) + .map(|idx| format!("out:{idx}")) + }) +} + +#[inline] +fn allocate_index_for_key( + key: Option, + next_content_index: &mut u32, + index_by_key: &mut HashMap, +) -> u32 { + if let Some(key) = key { + if let Some(existing) = index_by_key.get(&key).copied() { + existing + } else { + let assigned = *next_content_index; + *next_content_index += 1; + index_by_key.insert(key, assigned); + assigned + } + } else { + let assigned = *next_content_index; + *next_content_index += 1; + assigned + } +} + +fn extract_web_search_result_content(item: &Value) -> Value { + let sources = item + .pointer("/action/sources") + .and_then(Value::as_array) + .or_else(|| item.get("results").and_then(Value::as_array)); + + Value::Array( + sources + .into_iter() + .flatten() + .filter_map(|source| { + let url = source.get("url").and_then(Value::as_str)?; + Some(json!({ + "type": "web_search_result", + "url": url, + "title": source.get("title").cloned().unwrap_or_else(|| json!("")) + })) + }) + .collect(), + ) +} + +#[inline] +fn normalized_web_search_action_fields(item: &Value) -> serde_json::Map { + let mut action = serde_json::Map::new(); + let source = item + .get("action") + .filter(|action| action.is_object()) + .or_else(|| item.get("input").filter(|input| input.is_object())) + .unwrap_or(item); + + if let Some(action_type) = source + .get("type") + .and_then(Value::as_str) + .filter(|action_type| !action_type.is_empty()) + { + action.insert("type".to_string(), json!(action_type)); + } + if let Some(query) = source + .get("query") + .and_then(Value::as_str) + .filter(|query| !query.is_empty()) + { + action.insert("query".to_string(), json!(query)); + } + if let Some(queries) = source + .get("queries") + .and_then(Value::as_array) + .filter(|queries| { + queries + .iter() + .any(|query| query.as_str().is_some_and(|query| !query.trim().is_empty())) + }) + { + action.insert("queries".to_string(), Value::Array(queries.clone())); + } + if let Some(url) = source + .get("url") + .and_then(Value::as_str) + .filter(|url| !url.is_empty()) + { + action.insert("url".to_string(), json!(url)); + } + if let Some(pattern) = source + .get("pattern") + .and_then(Value::as_str) + .filter(|pattern| !pattern.is_empty()) + { + action.insert("pattern".to_string(), json!(pattern)); + } + + action +} + +fn web_search_action_delta_json(item: &Value) -> Option { + let action = Value::Object(normalized_web_search_action_fields(item)); + if action.as_object().is_some_and(|action| !action.is_empty()) { + serde_json::to_string(&action).ok() + } else { + None + } +} + +#[inline] +fn web_search_call_status(item: &Value) -> &str { + item.get("status") + .and_then(Value::as_str) + .unwrap_or("completed") +} + +fn extract_web_search_error_content(item: &Value) -> Value { + let error_code = item + .pointer("/error/code") + .and_then(Value::as_str) + .or_else(|| item.pointer("/error/type").and_then(Value::as_str)) + .or_else(|| { + item.pointer("/incomplete_details/reason") + .and_then(Value::as_str) + }) + .unwrap_or("unavailable"); + + json!({ + "type": "web_search_tool_result_error", + "error_code": error_code + }) +} + /// Resolve content index for a text/refusal content part event. /// /// Uses `content_part_key` to look up or assign a stable index, falling back to @@ -113,6 +264,9 @@ pub fn create_anthropic_sse_stream_from_responses = None; let mut tool_index_by_item_id: HashMap = HashMap::new(); let mut last_tool_index: Option = None; + let mut emitted_web_search_tool_use_keys: HashSet = HashSet::new(); + let mut counted_web_search_result_keys: HashSet = HashSet::new(); + let mut web_search_requests: u64 = 0; tokio::pin!(stream); @@ -432,6 +586,94 @@ pub fn create_anthropic_sse_stream_from_responses 0 { + usage["server_tool_use"] = json!({ + "web_search_requests": web_search_requests + }); + } + usage }); // Emit message_delta (with usage + stop_reason) @@ -717,8 +965,131 @@ pub fn create_anthropic_sse_stream_from_responses {} + "response.output_item.done" => { + let item = output_item_from_event(&data); + if item.get("type").and_then(|v| v.as_str()) == Some("web_search_call") { + let status = web_search_call_status(item); + if !has_sent_message_start { + let start_event = json!({ + "type": "message_start", + "message": { + "id": message_id.clone().unwrap_or_default(), + "type": "message", + "role": "assistant", + "model": current_model.clone().unwrap_or_default(), + "usage": { "input_tokens": 0, "output_tokens": 0 } + } + }); + let sse = format!("event: message_start\ndata: {}\n\n", + serde_json::to_string(&start_event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + has_sent_message_start = true; + } + + let block_id = item + .get("id") + .and_then(|v| v.as_str()) + .or_else(|| data.get("item_id").and_then(|v| v.as_str())) + .unwrap_or(""); + let item_id = output_item_identifier(&data, item); + let tool_use_key = item_id + .clone() + .map(|id| format!("web_search_tool_use:{id}")); + let should_emit_tool_use = tool_use_key + .as_ref() + .map(|key| !emitted_web_search_tool_use_keys.contains(key)) + .unwrap_or(true); + + if should_emit_tool_use { + let index = allocate_index_for_key( + tool_use_key.clone(), + &mut next_content_index, + &mut index_by_key, + ); + let start_event = json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "server_tool_use", + "id": block_id, + "name": "web_search" + } + }); + let start_sse = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&start_event).unwrap_or_default()); + yield Ok(Bytes::from(start_sse)); + + if let Some(action_json) = web_search_action_delta_json(item) { + let delta_event = json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "input_json_delta", + "partial_json": action_json + } + }); + let delta_sse = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&delta_event).unwrap_or_default()); + yield Ok(Bytes::from(delta_sse)); + } + + let stop_event = json!({ + "type": "content_block_stop", + "index": index + }); + let stop_sse = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&stop_event).unwrap_or_default()); + yield Ok(Bytes::from(stop_sse)); + + if let Some(key) = tool_use_key.clone() { + emitted_web_search_tool_use_keys.insert(key); + } + } + + let result_content = match status { + "completed" => { + let result_counter_key = item_id + .clone() + .unwrap_or_else(|| format!("id:{block_id}")); + if counted_web_search_result_keys.insert(result_counter_key) { + web_search_requests += 1; + } + Some(extract_web_search_result_content(item)) + } + "failed" => Some(extract_web_search_error_content(item)), + _ => None, + }; + + if let Some(result_content) = result_content { + let result_index = allocate_index_for_key( + item_id.map(|id| format!("web_search_result:{id}")), + &mut next_content_index, + &mut index_by_key, + ); + let start_event = json!({ + "type": "content_block_start", + "index": result_index, + "content_block": { + "type": "web_search_tool_result", + "tool_use_id": block_id, + "content": result_content + } + }); + let start_sse = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&start_event).unwrap_or_default()); + yield Ok(Bytes::from(start_sse)); + + let stop_event = json!({ + "type": "content_block_stop", + "index": result_index + }); + let stop_sse = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&stop_event).unwrap_or_default()); + yield Ok(Bytes::from(stop_sse)); + } + } + } + "response.in_progress" => {} // Any other unknown/future events — silently skip. _ => {} @@ -823,6 +1194,205 @@ mod tests { assert!(merged.contains("\"type\":\"message_stop\"")); } + #[tokio::test] + async fn test_streaming_conversion_with_web_search_call() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws\",\"model\":\"gpt-5-codex\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", + "event: response.output_item.added\n", + "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"ws_1\",\"type\":\"web_search_call\",\"action\":{\"query\":\"OpenAI latest\"}}}\n\n", + "event: response.output_item.done\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ws_1\",\"type\":\"web_search_call\",\"action\":{\"query\":\"OpenAI latest\",\"sources\":[{\"url\":\"https://openai.com\",\"title\":\"OpenAI\"}]}}}\n\n", + "event: response.content_part.added\n", + "data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":1,\"content_index\":0}\n\n", + "event: response.output_text.delta\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"Here is what I found.\",\"output_index\":1,\"content_index\":0}\n\n", + "event: response.output_text.done\n", + "data: {\"type\":\"response.output_text.done\",\"output_index\":1,\"content_index\":0}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":8}}}\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!(merged.contains("\"type\":\"server_tool_use\"")); + assert!(merged.contains("\"name\":\"web_search\"")); + assert!(merged.contains("\"type\":\"input_json_delta\"")); + assert!(merged.contains("\\\"query\\\":\\\"OpenAI latest\\\"")); + assert!(merged.contains("\"type\":\"web_search_tool_result\"")); + assert!(merged.contains("\"tool_use_id\":\"ws_1\"")); + assert!(merged.contains("\"url\":\"https://openai.com\"")); + assert!(merged.contains("\"web_search_requests\":1")); + assert!(merged.contains("\"stop_reason\":\"end_turn\"")); + assert!(!merged.contains("\"stop_reason\":\"tool_use\"")); + } + + #[tokio::test] + async fn test_streaming_conversion_with_web_search_query_only_on_done() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_done\",\"model\":\"gpt-5-codex\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", + "event: response.output_item.added\n", + "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"ws_1\",\"type\":\"web_search_call\"}}\n\n", + "event: response.output_item.done\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ws_1\",\"type\":\"web_search_call\",\"action\":{\"query\":\"OpenAI latest\",\"sources\":[{\"url\":\"https://openai.com\",\"title\":\"OpenAI\"}]}}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":8}}}\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("\"type\":\"server_tool_use\"").count(), 1); + assert!(merged.contains("\\\"query\\\":\\\"OpenAI latest\\\"")); + assert!(merged.contains("\"type\":\"web_search_tool_result\"")); + } + + #[tokio::test] + async fn test_streaming_conversion_with_non_query_web_search_action() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_open\",\"model\":\"gpt-5-codex\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", + "event: response.output_item.added\n", + "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"ws_open_1\",\"type\":\"web_search_call\"}}\n\n", + "event: response.output_item.done\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ws_open_1\",\"type\":\"web_search_call\",\"action\":{\"type\":\"open_page\",\"sources\":[{\"url\":\"https://openai.com/research\",\"title\":\"Research\"}]}}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":8}}}\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("\"type\":\"server_tool_use\"").count(), 1); + assert!(merged.contains("\"type\":\"input_json_delta\"")); + assert!(merged.contains("\\\"type\\\":\\\"open_page\\\"")); + assert!(merged.contains("\"type\":\"web_search_tool_result\"")); + assert!(merged.contains("\"tool_use_id\":\"ws_open_1\"")); + assert!(merged.contains("\"url\":\"https://openai.com/research\"")); + } + + #[tokio::test] + async fn test_streaming_conversion_does_not_duplicate_web_search_tool_use_between_added_and_done( + ) { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_dup\",\"model\":\"gpt-5-codex\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", + "event: response.output_item.added\n", + "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"ws_dup_1\",\"type\":\"web_search_call\",\"action\":{\"query\":\"OpenAI latest\"}}}\n\n", + "event: response.output_item.done\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ws_dup_1\",\"type\":\"web_search_call\",\"action\":{\"query\":\"OpenAI latest\",\"sources\":[{\"url\":\"https://openai.com\",\"title\":\"OpenAI\"}]}}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":8}}}\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("\"type\":\"server_tool_use\"").count(), 1); + assert_eq!(merged.matches("\"type\":\"input_json_delta\"").count(), 1); + assert_eq!( + merged + .matches("\"type\":\"web_search_tool_result\"") + .count(), + 1 + ); + assert!(merged.contains("\"tool_use_id\":\"ws_dup_1\"")); + } + + #[tokio::test] + async fn test_streaming_conversion_with_failed_web_search_call_does_not_count_usage() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_failed\",\"model\":\"gpt-5-codex\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", + "event: response.output_item.done\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ws_failed_1\",\"type\":\"web_search_call\",\"status\":\"failed\",\"action\":{\"query\":\"OpenAI latest\"}}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":2}}}\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!(merged.contains("\"type\":\"server_tool_use\"")); + assert!(merged.contains("\\\"query\\\":\\\"OpenAI latest\\\"")); + assert!(merged.contains("\"type\":\"web_search_tool_result\"")); + assert!(merged.contains("\"type\":\"web_search_tool_result_error\"")); + assert!(merged.contains("\"error_code\":\"unavailable\"")); + assert!(!merged.contains("\"web_search_requests\":")); + } + + #[tokio::test] + async fn test_streaming_conversion_with_failed_non_query_web_search_preserves_action() { + let input = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_failed_open\",\"model\":\"gpt-5-codex\",\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n", + "event: response.output_item.done\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ws_failed_open_1\",\"type\":\"web_search_call\",\"status\":\"failed\",\"action\":{\"type\":\"find_in_page\",\"url\":\"https://openai.com/research\",\"pattern\":\"GPT-5\"}}}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":2}}}\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!(merged.contains("\"type\":\"server_tool_use\"")); + assert!(merged.contains("\\\"type\\\":\\\"find_in_page\\\"")); + assert!(merged.contains("\\\"url\\\":\\\"https://openai.com/research\\\"")); + assert!(merged.contains("\\\"pattern\\\":\\\"GPT-5\\\"")); + assert!(merged.contains("\"type\":\"web_search_tool_result_error\"")); + assert!(!merged.contains("\"web_search_requests\":")); + } + #[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 a61bf7f9bb..f7b9d4a4e6 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -10,6 +10,260 @@ use crate::proxy::error::ProxyError; use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; + +fn is_anthropic_web_search_tool(tool: &Value) -> bool { + matches!( + ( + tool.get("name").and_then(Value::as_str), + tool.get("type").and_then(Value::as_str), + ), + (Some("web_search"), Some(tool_type)) if tool_type.starts_with("web_search_") + ) +} + +fn ensure_include(result: &mut Value, item: &str) { + if let Some(obj) = result.as_object_mut() { + let entry = obj + .entry("include".to_string()) + .or_insert_with(|| json!([])); + if !entry.is_array() { + *entry = json!([]); + } + + if let Some(includes) = entry.as_array_mut() { + if !includes.iter().any(|v| v.as_str() == Some(item)) { + includes.push(json!(item)); + } + } + } +} + +fn normalized_non_empty_string_array(value: Option<&Value>) -> Vec { + match value { + Some(Value::Array(items)) => items + .iter() + .filter_map(|item| item.as_str().map(str::trim)) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .collect(), + _ => Vec::new(), + } +} + +fn map_anthropic_tool_to_responses( + tool: &Value, + allow_unsupported_max_uses: bool, +) -> Result, ProxyError> { + if tool.get("type").and_then(Value::as_str) == Some("BatchTool") { + return Ok(None); + } + + if is_anthropic_web_search_tool(tool) { + let allowed_domains = normalized_non_empty_string_array(tool.get("allowed_domains")); + let blocked_domains = normalized_non_empty_string_array(tool.get("blocked_domains")); + + if !allowed_domains.is_empty() && !blocked_domains.is_empty() { + return Err(ProxyError::TransformError( + "Cannot specify both allowed_domains and blocked_domains in the same request" + .to_string(), + )); + } + + if !blocked_domains.is_empty() { + return Err(ProxyError::TransformError( + "Anthropic blocked_domains has no direct OpenAI web_search equivalent".to_string(), + )); + } + + if tool.get("max_uses").is_some() { + if allow_unsupported_max_uses { + log::warn!( + "Anthropic web_search max_uses has no direct Codex OAuth Responses equivalent; ignoring it" + ); + } else { + return Err(ProxyError::TransformError( + "Anthropic web_search max_uses has no direct OpenAI web_search equivalent" + .to_string(), + )); + } + } + + let mut mapped = serde_json::Map::new(); + mapped.insert("type".to_string(), json!("web_search")); + + if !allowed_domains.is_empty() { + mapped.insert( + "filters".to_string(), + json!({ "allowed_domains": allowed_domains }), + ); + } + + if let Some(location) = tool.get("user_location").cloned() { + mapped.insert("user_location".to_string(), location); + } + + return Ok(Some(Value::Object(mapped))); + } + + Ok(Some(json!({ + "type": "function", + "name": tool.get("name").and_then(|n| n.as_str()).unwrap_or(""), + "description": tool.get("description"), + "parameters": super::transform::clean_schema( + tool.get("input_schema").cloned().unwrap_or(json!({})) + ) + }))) +} + +fn extract_web_search_sources_from_response_item(item: &Value) -> Vec { + let sources = item + .pointer("/action/sources") + .and_then(Value::as_array) + .or_else(|| item.get("results").and_then(Value::as_array)); + + sources + .into_iter() + .flatten() + .filter_map(|source| { + let url = source.get("url").and_then(Value::as_str)?; + let mut mapped = serde_json::Map::new(); + mapped.insert("type".to_string(), json!("web_search_result")); + mapped.insert("url".to_string(), json!(url)); + mapped.insert( + "title".to_string(), + source.get("title").cloned().unwrap_or_else(|| json!("")), + ); + Some(Value::Object(mapped)) + }) + .collect() +} + +fn extract_web_search_sources_from_anthropic_content(content: &Value) -> Vec { + content + .as_array() + .into_iter() + .flatten() + .filter_map(|block| { + let url = block.get("url").and_then(Value::as_str)?; + let mut mapped = serde_json::Map::new(); + mapped.insert("url".to_string(), json!(url)); + mapped.insert( + "title".to_string(), + block.get("title").cloned().unwrap_or_else(|| json!("")), + ); + Some(Value::Object(mapped)) + }) + .collect() +} + +fn extract_anthropic_web_search_error_code(content: &Value) -> Option { + match content { + Value::Object(obj) + if obj.get("type").and_then(Value::as_str) == Some("web_search_tool_result_error") => + { + obj.get("error_code") + .and_then(Value::as_str) + .map(ToString::to_string) + } + _ => None, + } +} + +fn normalized_web_search_action_fields(value: &Value) -> serde_json::Map { + let mut action = serde_json::Map::new(); + let source = value + .get("action") + .filter(|action| action.is_object()) + .or_else(|| value.get("input").filter(|input| input.is_object())) + .unwrap_or(value); + + if let Some(action_type) = source + .get("type") + .and_then(Value::as_str) + .filter(|action_type| !action_type.is_empty()) + { + action.insert("type".to_string(), json!(action_type)); + } + + if let Some(query) = source + .get("query") + .and_then(Value::as_str) + .filter(|query| !query.is_empty()) + { + action.insert("query".to_string(), json!(query)); + } + + if let Some(queries) = source + .get("queries") + .and_then(Value::as_array) + .filter(|queries| { + queries + .iter() + .any(|query| query.as_str().is_some_and(|query| !query.trim().is_empty())) + }) + { + action.insert("queries".to_string(), Value::Array(queries.clone())); + } + + if let Some(url) = source + .get("url") + .and_then(Value::as_str) + .filter(|url| !url.is_empty()) + { + action.insert("url".to_string(), json!(url)); + } + + if let Some(pattern) = source + .get("pattern") + .and_then(Value::as_str) + .filter(|pattern| !pattern.is_empty()) + { + action.insert("pattern".to_string(), json!(pattern)); + } + + action +} + +fn build_responses_web_search_action(action_source: Option<&Value>, sources: Vec) -> Value { + let mut action = action_source + .map(normalized_web_search_action_fields) + .unwrap_or_default(); + + if !sources.is_empty() { + action.insert("sources".to_string(), Value::Array(sources)); + } + + Value::Object(action) +} + +fn build_anthropic_web_search_input(item: &Value) -> Value { + Value::Object(normalized_web_search_action_fields(item)) +} + +fn response_web_search_status(item: &Value) -> &str { + item.get("status") + .and_then(Value::as_str) + .unwrap_or("completed") +} + +fn extract_response_web_search_error_code(item: &Value) -> Option { + item.pointer("/error/code") + .and_then(Value::as_str) + .or_else(|| item.pointer("/error/type").and_then(Value::as_str)) + .or_else(|| { + item.pointer("/incomplete_details/reason") + .and_then(Value::as_str) + }) + .map(ToString::to_string) +} + +fn build_anthropic_web_search_error_content(error_code: Option<&str>) -> Value { + json!({ + "type": "web_search_tool_result_error", + "error_code": error_code.unwrap_or("unavailable") + }) +} /// Anthropic 请求 → OpenAI Responses 请求 /// @@ -67,6 +321,9 @@ pub fn anthropic_to_responses( if let Some(v) = body.get("stream") { result["stream"] = v.clone(); } + if let Some(v) = body.get("include").filter(|v| v.is_array()) { + result["include"] = v.clone(); + } // Map Anthropic thinking → OpenAI Responses reasoning.effort if let Some(model_name) = body.get("model").and_then(|m| m.as_str()) { @@ -80,29 +337,34 @@ pub fn anthropic_to_responses( // stop_sequences → 丢弃 (Responses API 不支持) // 转换 tools (过滤 BatchTool) + let mut web_search_tool_names: HashSet = HashSet::new(); + let mut has_web_search = false; if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) { - let response_tools: Vec = tools - .iter() - .filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool")) - .map(|t| { - json!({ - "type": "function", - "name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""), - "description": t.get("description"), - "parameters": super::transform::clean_schema( - t.get("input_schema").cloned().unwrap_or(json!({})) - ) - }) - }) - .collect(); + let mut response_tools = Vec::new(); + for tool in tools { + if is_anthropic_web_search_tool(tool) { + has_web_search = true; + if let Some(name) = tool.get("name").and_then(Value::as_str) { + web_search_tool_names.insert(name.to_string()); + } + } + + if let Some(mapped) = map_anthropic_tool_to_responses(tool, is_codex_oauth)? { + response_tools.push(mapped); + } + } if !response_tools.is_empty() { result["tools"] = json!(response_tools); } } + if has_web_search { + ensure_include(&mut result, "web_search_call.action.sources"); + } + if let Some(v) = body.get("tool_choice") { - result["tool_choice"] = map_tool_choice_to_responses(v); + result["tool_choice"] = map_tool_choice_to_responses(v, &web_search_tool_names); } // Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints @@ -129,20 +391,7 @@ pub fn anthropic_to_responses( // SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false) if is_codex_oauth { result["store"] = json!(false); - - const REASONING_MARKER: &str = "reasoning.encrypted_content"; - let mut includes: Vec = body - .get("include") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - if !includes - .iter() - .any(|v| v.as_str() == Some(REASONING_MARKER)) - { - includes.push(json!(REASONING_MARKER)); - } - result["include"] = json!(includes); + ensure_include(&mut result, "reasoning.encrypted_content"); if let Some(obj) = result.as_object_mut() { // —— 删除 ChatGPT 反代不接受的字段 —— @@ -166,7 +415,10 @@ pub fn anthropic_to_responses( Ok(result) } -fn map_tool_choice_to_responses(tool_choice: &Value) -> Value { +fn map_tool_choice_to_responses( + tool_choice: &Value, + web_search_tool_names: &HashSet, +) -> Value { match tool_choice { Value::String(_) => tool_choice.clone(), Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) { @@ -177,6 +429,12 @@ fn map_tool_choice_to_responses(tool_choice: &Value) -> Value { // Anthropic forced tool -> Responses function tool selector Some("tool") => { let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if web_search_tool_names.contains(name) { + log::warn!( + "Anthropic forced web_search tool_choice has no direct Responses equivalent; falling back to auto" + ); + return json!("auto"); + } json!({ "type": "function", "name": name @@ -300,6 +558,7 @@ fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyErro // 数组内容(多模态/工具调用) Some(Value::Array(blocks)) => { let mut message_content = Vec::new(); + let mut pending_web_searches: HashMap = HashMap::new(); for block in blocks { let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); @@ -384,6 +643,58 @@ fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyErro })); } + "server_tool_use" => { + if block.get("name").and_then(Value::as_str) == Some("web_search") { + let id = block + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + if !id.is_empty() { + pending_web_searches.insert(id, block.clone()); + } + } + } + + "web_search_tool_result" => { + if !message_content.is_empty() { + input.push(json!({ + "role": role, + "content": message_content.clone() + })); + message_content.clear(); + } + + let call_id = block + .get("tool_use_id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let pending = pending_web_searches.remove(&call_id); + let error_code = extract_anthropic_web_search_error_code( + block.get("content").unwrap_or(&Value::Null), + ); + let status = if error_code.is_some() { + "failed" + } else { + "completed" + }; + let sources = if error_code.is_some() { + Vec::new() + } else { + extract_web_search_sources_from_anthropic_content( + block.get("content").unwrap_or(&Value::Null), + ) + }; + + input.push(json!({ + "type": "web_search_call", + "id": call_id, + "status": status, + "action": build_responses_web_search_action(pending.as_ref(), sources) + })); + } + "thinking" => { // 丢弃 thinking blocks(与 openai_chat 一致) } @@ -399,6 +710,15 @@ fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyErro "content": message_content })); } + + for (call_id, pending) in pending_web_searches { + input.push(json!({ + "type": "web_search_call", + "id": call_id, + "status": "in_progress", + "action": build_responses_web_search_action(Some(&pending), Vec::new()) + })); + } } _ => { @@ -421,6 +741,7 @@ pub fn responses_to_anthropic(body: Value) -> Result { let mut content = Vec::new(); let mut has_tool_use = false; + let mut web_search_requests = 0_u64; for item in output { let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); @@ -464,6 +785,39 @@ pub fn responses_to_anthropic(body: Value) -> Result { has_tool_use = true; } + "web_search_call" => { + let id = item.get("id").and_then(Value::as_str).unwrap_or(""); + let status = response_web_search_status(item); + + content.push(json!({ + "type": "server_tool_use", + "id": id, + "name": "web_search", + "input": build_anthropic_web_search_input(item) + })); + + match status { + "completed" => { + content.push(json!({ + "type": "web_search_tool_result", + "tool_use_id": id, + "content": extract_web_search_sources_from_response_item(item) + })); + web_search_requests += 1; + } + "failed" => { + content.push(json!({ + "type": "web_search_tool_result", + "tool_use_id": id, + "content": build_anthropic_web_search_error_content( + extract_response_web_search_error_code(item).as_deref() + ) + })); + } + _ => {} + } + } + "reasoning" => { // 映射 reasoning summary → thinking block if let Some(summary) = item.get("summary").and_then(|s| s.as_array()) { @@ -500,7 +854,12 @@ pub fn responses_to_anthropic(body: Value) -> Result { .and_then(|r| r.as_str()), ); - let usage_json = build_anthropic_usage_from_responses(body.get("usage")); + let mut usage_json = build_anthropic_usage_from_responses(body.get("usage")); + if web_search_requests > 0 { + usage_json["server_tool_use"] = json!({ + "web_search_requests": web_search_requests + }); + } let result = json!({ "id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""), @@ -590,6 +949,135 @@ mod tests { assert!(result["tools"][0].get("input_schema").is_none()); } + #[test] + fn test_anthropic_to_responses_maps_web_search_tool_and_include() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Search this"}], + "tools": [{ + "type": "web_search_20250305", + "name": "web_search", + "allowed_domains": ["openai.com"], + "user_location": {"type": "approximate", "country": "US"} + }] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + assert_eq!(result["tools"][0]["type"], "web_search"); + assert_eq!( + result["tools"][0]["filters"]["allowed_domains"], + json!(["openai.com"]) + ); + assert_eq!( + result["tools"][0]["user_location"], + json!({"type": "approximate", "country": "US"}) + ); + + let includes = result["include"] + .as_array() + .expect("include should be array"); + assert!(includes + .iter() + .any(|v| v.as_str() == Some("web_search_call.action.sources"))); + assert!(!includes + .iter() + .any(|v| v.as_str() == Some("web_search_call.results"))); + assert!(includes + .iter() + .any(|v| v.as_str() == Some("reasoning.encrypted_content"))); + } + + #[test] + fn test_anthropic_to_responses_rejects_blocked_domains_web_search() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Search this"}], + "tools": [{ + "type": "web_search_20250305", + "name": "web_search", + "blocked_domains": ["example.com"] + }] + }); + + let err = anthropic_to_responses(input, None, false).unwrap_err(); + assert!( + matches!(err, ProxyError::TransformError(message) if message.contains("blocked_domains")) + ); + } + + #[test] + fn test_anthropic_to_responses_rejects_mixed_allowed_and_blocked_domains_web_search() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Search this"}], + "tools": [{ + "type": "web_search_20250305", + "name": "web_search", + "allowed_domains": ["openai.com"], + "blocked_domains": ["example.com"] + }] + }); + + let err = anthropic_to_responses(input, None, false).unwrap_err(); + assert!( + matches!(err, ProxyError::TransformError(message) if message.contains("Cannot specify both allowed_domains and blocked_domains")) + ); + } + + #[test] + fn test_anthropic_to_responses_omits_empty_allowed_domains_web_search() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Search this"}], + "tools": [{ + "type": "web_search_20250305", + "name": "web_search", + "allowed_domains": [] + }] + }); + + let result = anthropic_to_responses(input, None, false).unwrap(); + assert_eq!(result["tools"][0]["type"], "web_search"); + assert!(result["tools"][0].get("filters").is_none()); + } + + #[test] + fn test_anthropic_to_responses_rejects_max_uses_web_search() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Search this"}], + "tools": [{ + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 1 + }] + }); + + let err = anthropic_to_responses(input, None, false).unwrap_err(); + assert!(matches!(err, ProxyError::TransformError(message) if message.contains("max_uses"))); + } + + #[test] + fn test_anthropic_to_responses_codex_oauth_ignores_max_uses_web_search() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "tools": [{ + "type": "web_search_20250305", + "name": "web_search", + "max_uses": 5 + }] + }); + + let result = anthropic_to_responses(input, None, true).unwrap(); + assert_eq!(result["tools"][0]["type"], "web_search"); + } + #[test] fn test_anthropic_to_responses_tool_choice_any_to_required() { let input = json!({ @@ -617,6 +1105,23 @@ mod tests { assert_eq!(result["tool_choice"]["name"], "get_weather"); } + #[test] + fn test_anthropic_to_responses_web_search_tool_choice_falls_back_to_auto() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Search"}], + "tools": [{ + "type": "web_search_20250305", + "name": "web_search" + }], + "tool_choice": {"type": "tool", "name": "web_search"} + }); + + let result = anthropic_to_responses(input, None, false).unwrap(); + assert_eq!(result["tool_choice"], "auto"); + } + #[test] fn test_anthropic_to_responses_tool_use_lifting() { let input = json!({ @@ -671,6 +1176,121 @@ mod tests { assert_eq!(input_arr[0]["output"], "Sunny, 25°C"); } + #[test] + fn test_anthropic_to_responses_web_search_history_round_trip() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "ws_1", + "name": "web_search", + "input": {"query": "OpenAI latest"} + }, + { + "type": "web_search_tool_result", + "tool_use_id": "ws_1", + "content": [ + {"type": "web_search_result", "url": "https://openai.com", "title": "OpenAI"} + ] + }, + {"type": "text", "text": "Here is what I found."} + ] + }] + }); + + let result = anthropic_to_responses(input, None, false).unwrap(); + let items = result["input"].as_array().expect("input should be array"); + + assert_eq!(items[0]["type"], "web_search_call"); + assert_eq!(items[0]["id"], "ws_1"); + assert_eq!(items[0]["action"]["query"], "OpenAI latest"); + assert_eq!( + items[0]["action"]["sources"][0]["url"], + "https://openai.com" + ); + assert_eq!(items[1]["role"], "assistant"); + assert_eq!(items[1]["content"][0]["text"], "Here is what I found."); + } + + #[test] + fn test_anthropic_to_responses_web_search_error_history_preserved() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "ws_err_1", + "name": "web_search", + "input": {"query": "OpenAI latest"} + }, + { + "type": "web_search_tool_result", + "tool_use_id": "ws_err_1", + "content": { + "type": "web_search_tool_result_error", + "error_code": "max_uses_exceeded" + } + } + ] + }] + }); + + let result = anthropic_to_responses(input, None, false).unwrap(); + let items = result["input"].as_array().expect("input should be array"); + + assert_eq!(items[0]["type"], "web_search_call"); + assert_eq!(items[0]["id"], "ws_err_1"); + assert_eq!(items[0]["status"], "failed"); + assert_eq!(items[0]["action"]["query"], "OpenAI latest"); + assert!(items[0]["action"].get("sources").is_none()); + } + + #[test] + fn test_anthropic_to_responses_failed_non_query_web_search_history_preserved() { + let input = json!({ + "model": "gpt-5-codex", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "ws_open_err_1", + "name": "web_search", + "input": { + "type": "open_page", + "url": "https://openai.com/research" + } + }, + { + "type": "web_search_tool_result", + "tool_use_id": "ws_open_err_1", + "content": { + "type": "web_search_tool_result_error", + "error_code": "unavailable" + } + } + ] + }] + }); + + let result = anthropic_to_responses(input, None, false).unwrap(); + let items = result["input"].as_array().expect("input should be array"); + + assert_eq!(items[0]["type"], "web_search_call"); + assert_eq!(items[0]["id"], "ws_open_err_1"); + assert_eq!(items[0]["status"], "failed"); + assert_eq!(items[0]["action"]["type"], "open_page"); + assert_eq!(items[0]["action"]["url"], "https://openai.com/research"); + } + #[test] fn test_anthropic_to_responses_thinking_discarded() { let input = json!({ @@ -768,6 +1388,112 @@ mod tests { assert_eq!(result["stop_reason"], "tool_use"); } + #[test] + fn test_responses_to_anthropic_with_web_search_call() { + let input = json!({ + "id": "resp_ws", + "status": "completed", + "model": "gpt-5-codex", + "output": [ + { + "type": "web_search_call", + "id": "ws_1", + "action": { + "query": "OpenAI latest", + "sources": [ + {"url": "https://openai.com", "title": "OpenAI"} + ] + } + }, + { + "type": "message", + "content": [{"type": "output_text", "text": "Here is what I found."}] + } + ], + "usage": {"input_tokens": 10, "output_tokens": 20} + }); + + let result = responses_to_anthropic(input).unwrap(); + assert_eq!(result["content"][0]["type"], "server_tool_use"); + assert_eq!(result["content"][0]["id"], "ws_1"); + assert_eq!(result["content"][0]["input"]["query"], "OpenAI latest"); + assert_eq!(result["content"][1]["type"], "web_search_tool_result"); + assert_eq!(result["content"][1]["tool_use_id"], "ws_1"); + assert_eq!( + result["content"][1]["content"][0]["url"], + "https://openai.com" + ); + assert_eq!(result["content"][2]["type"], "text"); + assert_eq!(result["stop_reason"], "end_turn"); + assert_eq!( + result["usage"]["server_tool_use"]["web_search_requests"], + json!(1) + ); + } + + #[test] + fn test_responses_to_anthropic_with_failed_web_search_call() { + let input = json!({ + "id": "resp_ws_failed", + "status": "completed", + "model": "gpt-5-codex", + "output": [{ + "type": "web_search_call", + "id": "ws_failed_1", + "status": "failed", + "action": { + "query": "OpenAI latest" + } + }], + "usage": {"input_tokens": 10, "output_tokens": 2} + }); + + let result = responses_to_anthropic(input).unwrap(); + assert_eq!(result["content"][0]["type"], "server_tool_use"); + assert_eq!(result["content"][0]["id"], "ws_failed_1"); + assert_eq!(result["content"][0]["input"]["query"], "OpenAI latest"); + assert_eq!(result["content"][1]["type"], "web_search_tool_result"); + assert_eq!(result["content"][1]["tool_use_id"], "ws_failed_1"); + assert_eq!( + result["content"][1]["content"]["type"], + "web_search_tool_result_error" + ); + assert_eq!(result["content"][1]["content"]["error_code"], "unavailable"); + assert!(result["usage"].get("server_tool_use").is_none()); + } + + #[test] + fn test_responses_to_anthropic_preserves_non_query_web_search_action_input() { + let input = json!({ + "id": "resp_ws_open_failed", + "status": "completed", + "model": "gpt-5-codex", + "output": [{ + "type": "web_search_call", + "id": "ws_open_failed_1", + "status": "failed", + "action": { + "type": "open_page", + "url": "https://openai.com/research" + } + }], + "usage": {"input_tokens": 10, "output_tokens": 2} + }); + + let result = responses_to_anthropic(input).unwrap(); + assert_eq!(result["content"][0]["type"], "server_tool_use"); + assert_eq!(result["content"][0]["input"]["type"], "open_page"); + assert_eq!( + result["content"][0]["input"]["url"], + "https://openai.com/research" + ); + assert_eq!(result["content"][1]["type"], "web_search_tool_result"); + assert_eq!( + result["content"][1]["content"]["type"], + "web_search_tool_result_error" + ); + } + #[test] fn test_responses_to_anthropic_with_refusal_block() { let input = json!({