mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
})?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 モデル",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user