diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 755ae49de..1e041b6b8 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -789,7 +789,13 @@ impl RequestForwarder { let effective_endpoint = if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" { - "/v1/chat/completions" + // 根据 api_format 选择目标端点 + let api_format = super::providers::get_claude_api_format(provider); + if api_format == "openai_responses" { + "/v1/responses" + } else { + "/v1/chat/completions" + } } else { endpoint }; diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 6cc9840b0..3bfbac0ea 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -13,7 +13,11 @@ use super::{ CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG, }, handler_context::RequestContext, - providers::{get_adapter, streaming::create_anthropic_sse_stream, transform}, + providers::{ + get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream, + streaming_responses::create_anthropic_sse_stream_from_responses, transform, + transform_responses, + }, response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector}, server::ProxyState, types::*, @@ -22,6 +26,7 @@ use super::{ }; use crate::app_config::AppType; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use bytes::Bytes; use serde_json::{json, Value}; // ============================================================================ @@ -107,7 +112,7 @@ pub async fn handle_messages( /// Claude 格式转换处理(独有逻辑) /// -/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用) +/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换 async fn handle_claude_transform( response: reqwest::Response, ctx: &RequestContext, @@ -116,11 +121,18 @@ async fn handle_claude_transform( is_stream: bool, ) -> Result { let status = response.status(); + let api_format = get_claude_api_format(&ctx.provider); if is_stream { - // 流式响应转换 (OpenAI SSE → Anthropic SSE) + // 根据 api_format 选择流式转换器 let stream = response.bytes_stream(); - let sse_stream = create_anthropic_sse_stream(stream); + let sse_stream: Box< + dyn futures::Stream> + Send + Unpin, + > = if api_format == "openai_responses" { + Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream))) + } else { + Box::new(Box::pin(create_anthropic_sse_stream(stream))) + }; // 创建使用量收集器 let usage_collector = { @@ -186,7 +198,7 @@ async fn handle_claude_transform( return Ok((headers, body).into_response()); } - // 非流式响应转换 (OpenAI → Anthropic) + // 非流式响应转换 (OpenAI/Responses → Anthropic) let response_headers = response.headers().clone(); let body_bytes = response.bytes().await.map_err(|e| { @@ -196,12 +208,18 @@ async fn handle_claude_transform( let body_str = String::from_utf8_lossy(&body_bytes); - let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { - log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}"); - ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}")) + let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { + log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}"); + ProxyError::TransformError(format!("Failed to parse upstream response: {e}")) })?; - let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| { + // 根据 api_format 选择非流式转换器 + let anthropic_response = if api_format == "openai_responses" { + transform_responses::responses_to_anthropic(upstream_response) + } else { + transform::openai_to_anthropic(upstream_response) + } + .map_err(|e| { log::error!("[Claude] 转换响应失败: {e}"); e })?; diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 82f365d3b..bc85ee15b 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -1,10 +1,11 @@ //! Claude (Anthropic) Provider Adapter //! -//! 支持透传模式和 OpenAI Chat Completions 格式转换模式 +//! 支持透传模式和 OpenAI 格式转换模式 //! //! ## API 格式 //! - **anthropic** (默认): Anthropic Messages API 格式,直接透传 //! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换 +//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换 //! //! ## 认证模式 //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) @@ -16,6 +17,54 @@ use crate::provider::Provider; use crate::proxy::error::ProxyError; use reqwest::RequestBuilder; +/// 获取 Claude 供应商的 API 格式 +/// +/// 供 handler/forwarder 外部使用的公开函数。 +/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic" +pub fn get_claude_api_format(provider: &Provider) -> &'static str { + // 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config) + if let Some(meta) = provider.meta.as_ref() { + if let Some(api_format) = meta.api_format.as_deref() { + return match api_format { + "openai_chat" => "openai_chat", + "openai_responses" => "openai_responses", + _ => "anthropic", + }; + } + } + + // 2) Backward compatibility: legacy settings_config.api_format + if let Some(api_format) = provider + .settings_config + .get("api_format") + .and_then(|v| v.as_str()) + { + return match api_format { + "openai_chat" => "openai_chat", + "openai_responses" => "openai_responses", + _ => "anthropic", + }; + } + + // 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string) + let raw = provider.settings_config.get("openrouter_compat_mode"); + let enabled = match raw { + Some(serde_json::Value::Bool(v)) => *v, + Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0, + Some(serde_json::Value::String(value)) => { + let normalized = value.trim().to_lowercase(); + normalized == "true" || normalized == "1" + } + _ => false, + }; + + if enabled { + "openai_chat" + } else { + "anthropic" + } +} + /// Claude 适配器 pub struct ClaudeAdapter; @@ -57,48 +106,9 @@ impl ClaudeAdapter { /// 从 provider.meta.api_format 读取格式设置: /// - "anthropic" (默认): Anthropic Messages API 格式,直接透传 /// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + /// - "openai_responses": OpenAI Responses API 格式,需要格式转换 fn get_api_format(&self, provider: &Provider) -> &'static str { - // 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config) - if let Some(meta) = provider.meta.as_ref() { - if let Some(api_format) = meta.api_format.as_deref() { - return if api_format == "openai_chat" { - "openai_chat" - } else { - "anthropic" - }; - } - } - - // 2) Backward compatibility: legacy settings_config.api_format - if let Some(api_format) = provider - .settings_config - .get("api_format") - .and_then(|v| v.as_str()) - { - return if api_format == "openai_chat" { - "openai_chat" - } else { - "anthropic" - }; - } - - // 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string) - let raw = provider.settings_config.get("openrouter_compat_mode"); - let enabled = match raw { - Some(serde_json::Value::Bool(v)) => *v, - Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0, - Some(serde_json::Value::String(value)) => { - let normalized = value.trim().to_lowercase(); - normalized == "true" || normalized == "1" - } - _ => false, - }; - - if enabled { - "openai_chat" - } else { - "anthropic" - } + get_claude_api_format(provider) } /// 检测是否为仅 Bearer 认证模式 @@ -301,20 +311,34 @@ impl ProviderAdapter for ClaudeAdapter { fn needs_transform(&self, provider: &Provider) -> bool { // 根据 api_format 配置决定是否需要格式转换 // - "anthropic" (默认): 直接透传,无需转换 - // - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换 - self.get_api_format(provider) == "openai_chat" + // - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换 + // - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换 + matches!( + self.get_api_format(provider), + "openai_chat" | "openai_responses" + ) } fn transform_request( &self, body: serde_json::Value, - _provider: &Provider, + provider: &Provider, ) -> Result { - super::transform::anthropic_to_openai(body) + match self.get_api_format(provider) { + "openai_responses" => super::transform_responses::anthropic_to_responses(body), + _ => super::transform::anthropic_to_openai(body), + } } fn transform_response(&self, body: serde_json::Value) -> Result { - super::transform::openai_to_anthropic(body) + // 响应格式通过检测 "output" vs "choices" 字段区分 + // "output" 字段 → Responses API 格式 + // "choices" 字段 → Chat Completions 格式 + if body.get("output").is_some() { + super::transform_responses::responses_to_anthropic(body) + } else { + super::transform::openai_to_anthropic(body) + } } } diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index a4bb3de76..a71762034 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -18,7 +18,9 @@ mod codex; mod gemini; pub mod models; pub mod streaming; +pub mod streaming_responses; pub mod transform; +pub mod transform_responses; use crate::app_config::AppType; use crate::provider::Provider; @@ -27,7 +29,7 @@ use serde::{Deserialize, Serialize}; // 公开导出 pub use adapter::ProviderAdapter; pub use auth::{AuthInfo, AuthStrategy}; -pub use claude::ClaudeAdapter; +pub use claude::{get_claude_api_format, ClaudeAdapter}; pub use codex::CodexAdapter; pub use gemini::GeminiAdapter; diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs new file mode 100644 index 000000000..22e3b3762 --- /dev/null +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -0,0 +1,316 @@ +//! OpenAI Responses API 流式转换模块 +//! +//! 实现 Responses API SSE → Anthropic SSE 格式转换。 +//! +//! Responses API 使用命名事件 (named events) 的生命周期模型: +//! response.created → output_item.added → content_part.added → +//! output_text.delta → content_part.done → output_item.done → response.completed +//! +//! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。 + +use bytes::Bytes; +use futures::stream::{Stream, StreamExt}; +use serde_json::{json, Value}; + +/// 创建从 Responses API SSE 到 Anthropic SSE 的转换流 +/// +/// 状态机跟踪: message_id, current_model, content_index, has_sent_message_start +/// SSE 解析支持 named events (event: + data: 行) +pub fn create_anthropic_sse_stream_from_responses( + stream: impl Stream> + Send + 'static, +) -> impl Stream> + Send { + async_stream::stream! { + let mut buffer = String::new(); + let mut message_id: Option = None; + let mut current_model: Option = None; + let mut content_index: u32 = 0; + let mut has_sent_message_start = false; + + tokio::pin!(stream); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + buffer.push_str(&text); + + // SSE 事件由 \n\n 分隔 + while let Some(pos) = buffer.find("\n\n") { + let block = buffer[..pos].to_string(); + buffer = buffer[pos + 2..].to_string(); + + if block.trim().is_empty() { + continue; + } + + // 解析 SSE 块:提取 event: 和 data: 行 + let mut event_type: Option = None; + let mut data_parts: Vec = Vec::new(); + + for line in block.lines() { + if let Some(evt) = line.strip_prefix("event: ") { + event_type = Some(evt.trim().to_string()); + } else if let Some(d) = line.strip_prefix("data: ") { + data_parts.push(d.to_string()); + } + } + + if data_parts.is_empty() { + continue; + } + + let data_str = data_parts.join("\n"); + let event_name = event_type.as_deref().unwrap_or(""); + + // 解析 JSON 数据 + let data: Value = match serde_json::from_str(&data_str) { + Ok(v) => v, + Err(_) => continue, + }; + + log::debug!("[Claude/Responses] <<< SSE event: {event_name}"); + + match event_name { + // ================================================ + // response.created → message_start + // ================================================ + "response.created" => { + if let Some(id) = data.get("id").and_then(|i| i.as_str()) { + message_id = Some(id.to_string()); + } + if let Some(model) = data.get("model").and_then(|m| m.as_str()) { + current_model = Some(model.to_string()); + } + + has_sent_message_start = true; + let 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(&event).unwrap_or_default()); + log::debug!("[Claude/Responses] >>> Anthropic SSE: message_start"); + yield Ok(Bytes::from(sse)); + } + + // ================================================ + // response.content_part.added → content_block_start (text) + // ================================================ + "response.content_part.added" => { + // 确保 message_start 已发送 + 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; + } + + if let Some(part) = data.get("part") { + if part.get("type").and_then(|t| t.as_str()) == Some("output_text") { + let event = json!({ + "type": "content_block_start", + "index": content_index, + "content_block": { + "type": "text", + "text": "" + } + }); + let sse = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + } + } + } + + // ================================================ + // response.output_text.delta → content_block_delta (text_delta) + // ================================================ + "response.output_text.delta" => { + if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) { + let event = json!({ + "type": "content_block_delta", + "index": content_index, + "delta": { + "type": "text_delta", + "text": delta + } + }); + let sse = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + } + } + + // ================================================ + // response.content_part.done → content_block_stop + // ================================================ + "response.content_part.done" => { + let event = json!({ + "type": "content_block_stop", + "index": content_index + }); + let sse = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + content_index += 1; + } + + // ================================================ + // response.output_item.added (function_call) → content_block_start (tool_use) + // ================================================ + "response.output_item.added" => { + if let Some(item) = data.get("item") { + let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if item_type == "function_call" { + // 确保 message_start 已发送 + 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 call_id = item.get("call_id").and_then(|i| i.as_str()).unwrap_or(""); + let name = item.get("name").and_then(|n| n.as_str()).unwrap_or(""); + + let event = json!({ + "type": "content_block_start", + "index": content_index, + "content_block": { + "type": "tool_use", + "id": call_id, + "name": name + } + }); + let sse = format!("event: content_block_start\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + } + // message type output_item.added is handled via content_part.added + } + } + + // ================================================ + // response.function_call_arguments.delta → content_block_delta (input_json_delta) + // ================================================ + "response.function_call_arguments.delta" => { + if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) { + let event = json!({ + "type": "content_block_delta", + "index": content_index, + "delta": { + "type": "input_json_delta", + "partial_json": delta + } + }); + let sse = format!("event: content_block_delta\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + } + } + + // ================================================ + // response.function_call_arguments.done → content_block_stop + // ================================================ + "response.function_call_arguments.done" => { + let event = json!({ + "type": "content_block_stop", + "index": content_index + }); + let sse = format!("event: content_block_stop\ndata: {}\n\n", + serde_json::to_string(&event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + content_index += 1; + } + + // ================================================ + // response.completed → message_delta + message_stop + // ================================================ + "response.completed" => { + let stop_reason = data.get("status") + .and_then(|s| s.as_str()) + .map(|s| match s { + "completed" => "end_turn", + "incomplete" => "max_tokens", + _ => "end_turn", + }); + + let usage_json = data.get("usage").map(|u| json!({ + "input_tokens": u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0), + "output_tokens": u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0) + })); + + // Emit message_delta (with usage + stop_reason) + let delta_event = json!({ + "type": "message_delta", + "delta": { + "stop_reason": stop_reason, + "stop_sequence": null + }, + "usage": usage_json + }); + let sse = format!("event: message_delta\ndata: {}\n\n", + serde_json::to_string(&delta_event).unwrap_or_default()); + log::debug!("[Claude/Responses] >>> Anthropic SSE: message_delta"); + yield Ok(Bytes::from(sse)); + + // Emit message_stop + let stop_event = json!({"type": "message_stop"}); + let stop_sse = format!("event: message_stop\ndata: {}\n\n", + serde_json::to_string(&stop_event).unwrap_or_default()); + log::debug!("[Claude/Responses] >>> Anthropic SSE: message_stop"); + yield Ok(Bytes::from(stop_sse)); + } + + // Ignore other events (response.in_progress, output_item.done, etc.) + _ => {} + } + } + } + Err(e) => { + log::error!("Responses stream error: {e}"); + let error_event = json!({ + "type": "error", + "error": { + "type": "stream_error", + "message": format!("Stream error: {e}") + } + }); + let sse = format!("event: error\ndata: {}\n\n", + serde_json::to_string(&error_event).unwrap_or_default()); + yield Ok(Bytes::from(sse)); + break; + } + } + } + } +} diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 414debcf8..2758f8ea4 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -210,7 +210,7 @@ fn convert_message_to_openai( } /// 清理 JSON schema(移除不支持的 format) -fn clean_schema(mut schema: Value) -> Value { +pub fn clean_schema(mut schema: Value) -> Value { if let Some(obj) = schema.as_object_mut() { // 移除 "format": "uri" if obj.get("format").and_then(|v| v.as_str()) == Some("uri") { diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs new file mode 100644 index 000000000..e04450131 --- /dev/null +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -0,0 +1,632 @@ +//! OpenAI Responses API 格式转换模块 +//! +//! 实现 Anthropic Messages ↔ OpenAI Responses API 格式转换。 +//! Responses API 是 OpenAI 2025 年推出的新一代 API,采用扁平化的 input/output 结构。 +//! +//! 与 Chat Completions 的主要差异: +//! - tool_use/tool_result 从 message content 中"提升"为顶层 input item +//! - system prompt 使用 `instructions` 字段而非 system role message +//! - usage 字段命名与 Anthropic 一致 (input_tokens/output_tokens) + +use crate::proxy::error::ProxyError; +use serde_json::{json, Value}; + +/// Anthropic 请求 → OpenAI Responses 请求 +pub fn anthropic_to_responses(body: Value) -> Result { + let mut result = json!({}); + + // NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。 + if let Some(model) = body.get("model").and_then(|m| m.as_str()) { + result["model"] = json!(model); + } + + // system → instructions (Responses API 使用 instructions 字段) + if let Some(system) = body.get("system") { + let instructions = if let Some(text) = system.as_str() { + text.to_string() + } else if let Some(arr) = system.as_array() { + arr.iter() + .filter_map(|msg| msg.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n\n") + } else { + String::new() + }; + if !instructions.is_empty() { + result["instructions"] = json!(instructions); + } + } + + // messages → input + if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) { + let input = convert_messages_to_input(msgs)?; + result["input"] = json!(input); + } + + // max_tokens → max_output_tokens + if let Some(v) = body.get("max_tokens") { + result["max_output_tokens"] = v.clone(); + } + + // 直接透传的参数 + if let Some(v) = body.get("temperature") { + result["temperature"] = v.clone(); + } + if let Some(v) = body.get("top_p") { + result["top_p"] = v.clone(); + } + if let Some(v) = body.get("stream") { + result["stream"] = v.clone(); + } + + // stop_sequences → 丢弃 (Responses API 不支持) + + // 转换 tools (过滤 BatchTool) + 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(); + + if !response_tools.is_empty() { + result["tools"] = json!(response_tools); + } + } + + if let Some(v) = body.get("tool_choice") { + result["tool_choice"] = v.clone(); + } + + Ok(result) +} + +/// 将 Anthropic messages 数组转换为 Responses API input 数组 +/// +/// 核心转换逻辑: +/// - user/assistant 的 text 内容 → 对应 role 的 message item +/// - tool_use 从 assistant message 中"提升"为独立的 function_call item +/// - tool_result 从 user message 中"提升"为独立的 function_call_output item +/// - thinking blocks → 丢弃 +fn convert_messages_to_input(messages: &[Value]) -> Result, ProxyError> { + let mut input = Vec::new(); + + for msg in messages { + let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user"); + let content = msg.get("content"); + + match content { + // 字符串内容 + Some(Value::String(text)) => { + let content_type = if role == "assistant" { + "output_text" + } else { + "input_text" + }; + input.push(json!({ + "role": role, + "content": [{ "type": content_type, "text": text }] + })); + } + + // 数组内容(多模态/工具调用) + Some(Value::Array(blocks)) => { + let mut message_content = Vec::new(); + + for block in blocks { + let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match block_type { + "text" => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + let content_type = if role == "assistant" { + "output_text" + } else { + "input_text" + }; + message_content.push(json!({ "type": content_type, "text": text })); + } + } + + "image" => { + if let Some(source) = block.get("source") { + let media_type = source + .get("media_type") + .and_then(|m| m.as_str()) + .unwrap_or("image/png"); + let data = + source.get("data").and_then(|d| d.as_str()).unwrap_or(""); + message_content.push(json!({ + "type": "input_image", + "image_url": format!("data:{media_type};base64,{data}") + })); + } + } + + "tool_use" => { + // 先刷新已累积的消息内容 + if !message_content.is_empty() { + input.push(json!({ + "role": role, + "content": message_content.clone() + })); + message_content.clear(); + } + + // 提升为独立的 function_call item + let id = block.get("id").and_then(|i| i.as_str()).unwrap_or(""); + let name = block.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let arguments = block.get("input").cloned().unwrap_or(json!({})); + + input.push(json!({ + "type": "function_call", + "call_id": id, + "name": name, + "arguments": serde_json::to_string(&arguments).unwrap_or_default() + })); + } + + "tool_result" => { + // 先刷新已累积的消息内容 + if !message_content.is_empty() { + input.push(json!({ + "role": role, + "content": message_content.clone() + })); + message_content.clear(); + } + + // 提升为独立的 function_call_output item + let call_id = block + .get("tool_use_id") + .and_then(|i| i.as_str()) + .unwrap_or(""); + let output = match block.get("content") { + Some(Value::String(s)) => s.clone(), + Some(v) => serde_json::to_string(v).unwrap_or_default(), + None => String::new(), + }; + + input.push(json!({ + "type": "function_call_output", + "call_id": call_id, + "output": output + })); + } + + "thinking" => { + // 丢弃 thinking blocks(与 openai_chat 一致) + } + + _ => {} + } + } + + // 刷新剩余的消息内容 + if !message_content.is_empty() { + input.push(json!({ + "role": role, + "content": message_content + })); + } + } + + _ => { + // 无内容或 null + input.push(json!({ "role": role })); + } + } + } + + Ok(input) +} + +/// OpenAI Responses 响应 → Anthropic 响应 +pub fn responses_to_anthropic(body: Value) -> Result { + let output = body + .get("output") + .and_then(|o| o.as_array()) + .ok_or_else(|| ProxyError::TransformError("No output in response".to_string()))?; + + let mut content = Vec::new(); + + for item in output { + let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match item_type { + "message" => { + if let Some(msg_content) = item.get("content").and_then(|c| c.as_array()) { + for block in msg_content { + let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if block_type == "output_text" { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + if !text.is_empty() { + content.push(json!({"type": "text", "text": text})); + } + } + } + } + } + } + + "function_call" => { + let call_id = item.get("call_id").and_then(|i| i.as_str()).unwrap_or(""); + let name = item.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let args_str = item + .get("arguments") + .and_then(|a| a.as_str()) + .unwrap_or("{}"); + let input: Value = serde_json::from_str(args_str).unwrap_or(json!({})); + + content.push(json!({ + "type": "tool_use", + "id": call_id, + "name": name, + "input": input + })); + } + + "reasoning" => { + // 映射 reasoning summary → thinking block + if let Some(summary) = item.get("summary").and_then(|s| s.as_array()) { + let thinking_text: String = summary + .iter() + .filter_map(|s| { + if s.get("type").and_then(|t| t.as_str()) == Some("summary_text") { + s.get("text").and_then(|t| t.as_str()) + } else { + None + } + }) + .collect::>() + .join(""); + + if !thinking_text.is_empty() { + content.push(json!({ + "type": "thinking", + "thinking": thinking_text + })); + } + } + } + + _ => {} + } + } + + // status → stop_reason + let stop_reason = body + .get("status") + .and_then(|s| s.as_str()) + .map(|s| match s { + "completed" => "end_turn", + "incomplete" => "max_tokens", + _ => "end_turn", + }); + + // Usage — Responses API 使用与 Anthropic 相同的字段名 + let usage = body.get("usage").cloned().unwrap_or(json!({})); + let input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + let output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + + let result = json!({ + "id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""), + "type": "message", + "role": "assistant", + "content": content, + "model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""), + "stop_reason": stop_reason, + "stop_sequence": null, + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens + } + }); + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_anthropic_to_responses_simple() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input).unwrap(); + assert_eq!(result["model"], "gpt-4o"); + assert_eq!(result["max_output_tokens"], 1024); + assert_eq!(result["input"][0]["role"], "user"); + assert_eq!(result["input"][0]["content"][0]["type"], "input_text"); + assert_eq!(result["input"][0]["content"][0]["text"], "Hello"); + // stop_sequences should not appear + assert!(result.get("stop_sequences").is_none()); + } + + #[test] + fn test_anthropic_to_responses_with_system_string() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "system": "You are a helpful assistant.", + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input).unwrap(); + assert_eq!(result["instructions"], "You are a helpful assistant."); + // system should not appear in input + assert_eq!(result["input"].as_array().unwrap().len(), 1); + } + + #[test] + fn test_anthropic_to_responses_with_system_array() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "system": [ + {"type": "text", "text": "Part 1"}, + {"type": "text", "text": "Part 2"} + ], + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input).unwrap(); + assert_eq!(result["instructions"], "Part 1\n\nPart 2"); + } + + #[test] + fn test_anthropic_to_responses_with_tools() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Weather?"}], + "tools": [{ + "name": "get_weather", + "description": "Get weather info", + "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}} + }] + }); + + let result = anthropic_to_responses(input).unwrap(); + assert_eq!(result["tools"][0]["type"], "function"); + assert_eq!(result["tools"][0]["name"], "get_weather"); + assert!(result["tools"][0].get("parameters").is_some()); + // input_schema should not appear + assert!(result["tools"][0].get("input_schema").is_none()); + } + + #[test] + fn test_anthropic_to_responses_tool_use_lifting() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me check"}, + {"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}} + ] + }] + }); + + let result = anthropic_to_responses(input).unwrap(); + let input_arr = result["input"].as_array().unwrap(); + + // Should produce: assistant message (text) + function_call item + assert_eq!(input_arr.len(), 2); + + // First: assistant message with output_text + assert_eq!(input_arr[0]["role"], "assistant"); + assert_eq!(input_arr[0]["content"][0]["type"], "output_text"); + assert_eq!(input_arr[0]["content"][0]["text"], "Let me check"); + + // Second: function_call item (lifted from message) + assert_eq!(input_arr[1]["type"], "function_call"); + assert_eq!(input_arr[1]["call_id"], "call_123"); + assert_eq!(input_arr[1]["name"], "get_weather"); + } + + #[test] + fn test_anthropic_to_responses_tool_result_lifting() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{ + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"} + ] + }] + }); + + let result = anthropic_to_responses(input).unwrap(); + let input_arr = result["input"].as_array().unwrap(); + + // Should produce: function_call_output item (lifted) + assert_eq!(input_arr.len(), 1); + assert_eq!(input_arr[0]["type"], "function_call_output"); + assert_eq!(input_arr[0]["call_id"], "call_123"); + assert_eq!(input_arr[0]["output"], "Sunny, 25°C"); + } + + #[test] + fn test_anthropic_to_responses_thinking_discarded() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me think..."}, + {"type": "text", "text": "The answer is 42"} + ] + }] + }); + + let result = anthropic_to_responses(input).unwrap(); + let input_arr = result["input"].as_array().unwrap(); + + // thinking should be discarded, only text remains + assert_eq!(input_arr.len(), 1); + assert_eq!(input_arr[0]["content"][0]["type"], "output_text"); + assert_eq!(input_arr[0]["content"][0]["text"], "The answer is 42"); + } + + #[test] + fn test_anthropic_to_responses_image() { + let input = json!({ + "model": "gpt-4o", + "max_tokens": 1024, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": "What is this?"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc123"}} + ] + }] + }); + + let result = anthropic_to_responses(input).unwrap(); + let content = result["input"][0]["content"].as_array().unwrap(); + + assert_eq!(content[0]["type"], "input_text"); + assert_eq!(content[1]["type"], "input_image"); + assert_eq!(content[1]["image_url"], "data:image/png;base64,abc123"); + } + + #[test] + fn test_responses_to_anthropic_simple() { + let input = json!({ + "id": "resp_123", + "object": "response", + "status": "completed", + "model": "gpt-4o", + "output": [{ + "type": "message", + "id": "msg_123", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!"}] + }], + "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} + }); + + let result = responses_to_anthropic(input).unwrap(); + assert_eq!(result["id"], "resp_123"); + assert_eq!(result["type"], "message"); + assert_eq!(result["content"][0]["type"], "text"); + assert_eq!(result["content"][0]["text"], "Hello!"); + assert_eq!(result["stop_reason"], "end_turn"); + assert_eq!(result["usage"]["input_tokens"], 10); + assert_eq!(result["usage"]["output_tokens"], 5); + } + + #[test] + fn test_responses_to_anthropic_with_function_call() { + let input = json!({ + "id": "resp_123", + "object": "response", + "status": "completed", + "model": "gpt-4o", + "output": [{ + "type": "function_call", + "id": "fc_123", + "call_id": "call_123", + "name": "get_weather", + "arguments": "{\"location\": \"Tokyo\"}", + "status": "completed" + }], + "usage": {"input_tokens": 10, "output_tokens": 15} + }); + + let result = responses_to_anthropic(input).unwrap(); + assert_eq!(result["content"][0]["type"], "tool_use"); + assert_eq!(result["content"][0]["id"], "call_123"); + assert_eq!(result["content"][0]["name"], "get_weather"); + assert_eq!(result["content"][0]["input"]["location"], "Tokyo"); + } + + #[test] + fn test_responses_to_anthropic_with_reasoning() { + let input = json!({ + "id": "resp_123", + "object": "response", + "status": "completed", + "model": "gpt-4o", + "output": [ + { + "type": "reasoning", + "id": "rs_123", + "summary": [ + {"type": "summary_text", "text": "Thinking about the problem..."} + ] + }, + { + "type": "message", + "id": "msg_123", + "role": "assistant", + "content": [{"type": "output_text", "text": "The answer is 42"}] + } + ], + "usage": {"input_tokens": 10, "output_tokens": 20} + }); + + let result = responses_to_anthropic(input).unwrap(); + // Should have thinking + text + assert_eq!(result["content"][0]["type"], "thinking"); + assert_eq!( + result["content"][0]["thinking"], + "Thinking about the problem..." + ); + assert_eq!(result["content"][1]["type"], "text"); + assert_eq!(result["content"][1]["text"], "The answer is 42"); + } + + #[test] + fn test_responses_to_anthropic_incomplete_status() { + let input = json!({ + "id": "resp_123", + "status": "incomplete", + "model": "gpt-4o", + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "Partial..."}] + }], + "usage": {"input_tokens": 10, "output_tokens": 4096} + }); + + let result = responses_to_anthropic(input).unwrap(); + assert_eq!(result["stop_reason"], "max_tokens"); + } + + #[test] + fn test_model_passthrough() { + let input = json!({ + "model": "o3-mini", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = anthropic_to_responses(input).unwrap(); + assert_eq!(result["model"], "o3-mini"); + } +} diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 21149c919..c9430c09e 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -164,9 +164,11 @@ export function ClaudeFormFields({ onChange={onBaseUrlChange} placeholder={t("providerForm.apiEndpointPlaceholder")} hint={ - apiFormat === "openai_chat" - ? t("providerForm.apiHintOAI") - : t("providerForm.apiHint") + apiFormat === "openai_responses" + ? t("providerForm.apiHintResponses") + : apiFormat === "openai_chat" + ? t("providerForm.apiHintOAI") + : t("providerForm.apiHint") } onManageClick={() => onEndpointModalToggle(true)} /> @@ -209,6 +211,11 @@ export function ClaudeFormFields({ defaultValue: "OpenAI Chat Completions (需转换)", })} + + {t("providerForm.apiFormatOpenAIResponses", { + defaultValue: "OpenAI Responses API (需转换)", + })} +

diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index c658f03f7..3caae2c52 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -47,7 +47,8 @@ export interface ProviderPreset { // Claude API 格式(仅 Claude 供应商使用) // - "anthropic" (默认): Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 - apiFormat?: "anthropic" | "openai_chat"; + // - "openai_responses": OpenAI Responses API 格式,需要格式转换 + apiFormat?: "anthropic" | "openai_chat" | "openai_responses"; } export const providerPresets: ProviderPreset[] = [ diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index d6b3cd178..d79cabbfc 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -152,7 +152,8 @@ export function useProviderActions(activeApp: AppId) { if ( activeApp === "claude" && provider.category !== "official" && - provider.meta?.apiFormat === "openai_chat" + (provider.meta?.apiFormat === "openai_chat" || + provider.meta?.apiFormat === "openai_responses") ) { // OpenAI Chat 格式供应商:显示代理提示 toast.info( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b434a149a..fa3faddba 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -697,6 +697,8 @@ "apiFormatHint": "Select the input format for the provider's API", "apiFormatAnthropic": "Anthropic Messages (Native)", "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)", + "apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)", + "apiHintResponses": "💡 Fill in OpenAI Responses API compatible service endpoint, avoid trailing slash", "anthropicDefaultHaikuModel": "Default Haiku Model", "anthropicDefaultSonnetModel": "Default Sonnet Model", "anthropicDefaultOpusModel": "Default Opus Model", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bd765e12c..f1cfb23b0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -697,6 +697,8 @@ "apiFormatHint": "プロバイダー API の入力フォーマットを選択", "apiFormatAnthropic": "Anthropic Messages(ネイティブ)", "apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)", + "apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)", + "apiHintResponses": "💡 OpenAI Responses API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください", "anthropicDefaultHaikuModel": "既定 Haiku モデル", "anthropicDefaultSonnetModel": "既定 Sonnet モデル", "anthropicDefaultOpusModel": "既定 Opus モデル", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9cb2e0d40..78af1756b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -697,6 +697,8 @@ "apiFormatHint": "选择供应商 API 的输入格式", "apiFormatAnthropic": "Anthropic Messages (原生)", "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)", + "apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)", + "apiHintResponses": "💡 填写兼容 OpenAI Responses API 的服务端点地址,不要以斜杠结尾", "anthropicDefaultHaikuModel": "Haiku 默认模型", "anthropicDefaultSonnetModel": "Sonnet 默认模型", "anthropicDefaultOpusModel": "Opus 默认模型", diff --git a/src/types.ts b/src/types.ts index 72d25e08f..0e8667084 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,7 +145,8 @@ export interface ProviderMeta { // Claude API 格式(仅 Claude 供应商使用) // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 - apiFormat?: "anthropic" | "openai_chat"; + // - "openai_responses": OpenAI Responses API 格式,需要格式转换 + apiFormat?: "anthropic" | "openai_chat" | "openai_responses"; } // Skill 同步方式 @@ -154,7 +155,8 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy"; // Claude API 格式类型 // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 -export type ClaudeApiFormat = "anthropic" | "openai_chat"; +// - "openai_responses": OpenAI Responses API 格式,需要格式转换 +export type ClaudeApiFormat = "anthropic" | "openai_chat" | "openai_responses"; // 主页面显示的应用配置 export interface VisibleApps {