feat: add OpenAI Responses API format conversion (api_format = "openai_responses")

Support Anthropic ↔ OpenAI Responses API format conversion alongside existing
Chat Completions conversion. The Responses API uses a flat input/output structure
with lifted function_call/function_call_output items and named SSE lifecycle events.
This commit is contained in:
Jason
2026-03-05 09:30:37 +08:00
Unverified
parent fb8996d19c
commit a30e2096bb
14 changed files with 1081 additions and 66 deletions
+7 -1
View File
@@ -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
};
+27 -9
View File
@@ -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<axum::response::Response, ProxyError> {
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<Item = Result<Bytes, std::io::Error>> + 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
})?;
+71 -47
View File
@@ -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<serde_json::Value, ProxyError> {
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<serde_json::Value, ProxyError> {
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)
}
}
}
+3 -1
View File
@@ -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;
@@ -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<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut message_id: Option<String> = None;
let mut current_model: Option<String> = 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<String> = None;
let mut data_parts: Vec<String> = 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;
}
}
}
}
}
+1 -1
View File
@@ -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") {
@@ -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<Value, ProxyError> {
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::<Vec<_>>()
.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<Value> = 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<Vec<Value>, 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<Value, ProxyError> {
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::<Vec<_>>()
.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");
}
}
@@ -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 (需转换)",
})}
</SelectItem>
<SelectItem value="openai_responses">
{t("providerForm.apiFormatOpenAIResponses", {
defaultValue: "OpenAI Responses API (需转换)",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
+2 -1
View File
@@ -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[] = [
+2 -1
View File
@@ -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(
+2
View File
@@ -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",
+2
View File
@@ -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 モデル",
+2
View File
@@ -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 默认模型",
+4 -2
View File
@@ -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 {