mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +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 =
|
let effective_endpoint =
|
||||||
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
|
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 {
|
} else {
|
||||||
endpoint
|
endpoint
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ use super::{
|
|||||||
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||||
},
|
},
|
||||||
handler_context::RequestContext,
|
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},
|
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||||||
server::ProxyState,
|
server::ProxyState,
|
||||||
types::*,
|
types::*,
|
||||||
@@ -22,6 +26,7 @@ use super::{
|
|||||||
};
|
};
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||||
|
use bytes::Bytes;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -107,7 +112,7 @@ pub async fn handle_messages(
|
|||||||
|
|
||||||
/// Claude 格式转换处理(独有逻辑)
|
/// Claude 格式转换处理(独有逻辑)
|
||||||
///
|
///
|
||||||
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
|
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||||||
async fn handle_claude_transform(
|
async fn handle_claude_transform(
|
||||||
response: reqwest::Response,
|
response: reqwest::Response,
|
||||||
ctx: &RequestContext,
|
ctx: &RequestContext,
|
||||||
@@ -116,11 +121,18 @@ async fn handle_claude_transform(
|
|||||||
is_stream: bool,
|
is_stream: bool,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
let api_format = get_claude_api_format(&ctx.provider);
|
||||||
|
|
||||||
if is_stream {
|
if is_stream {
|
||||||
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
// 根据 api_format 选择流式转换器
|
||||||
let stream = response.bytes_stream();
|
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 = {
|
let usage_collector = {
|
||||||
@@ -186,7 +198,7 @@ async fn handle_claude_transform(
|
|||||||
return Ok((headers, body).into_response());
|
return Ok((headers, body).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非流式响应转换 (OpenAI → Anthropic)
|
// 非流式响应转换 (OpenAI/Responses → Anthropic)
|
||||||
let response_headers = response.headers().clone();
|
let response_headers = response.headers().clone();
|
||||||
|
|
||||||
let body_bytes = response.bytes().await.map_err(|e| {
|
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 body_str = String::from_utf8_lossy(&body_bytes);
|
||||||
|
|
||||||
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||||
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
|
||||||
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
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}");
|
log::error!("[Claude] 转换响应失败: {e}");
|
||||||
e
|
e
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
//! Claude (Anthropic) Provider Adapter
|
//! Claude (Anthropic) Provider Adapter
|
||||||
//!
|
//!
|
||||||
//! 支持透传模式和 OpenAI Chat Completions 格式转换模式
|
//! 支持透传模式和 OpenAI 格式转换模式
|
||||||
//!
|
//!
|
||||||
//! ## API 格式
|
//! ## API 格式
|
||||||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||||
|
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
|
||||||
//!
|
//!
|
||||||
//! ## 认证模式
|
//! ## 认证模式
|
||||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||||
@@ -16,6 +17,54 @@ use crate::provider::Provider;
|
|||||||
use crate::proxy::error::ProxyError;
|
use crate::proxy::error::ProxyError;
|
||||||
use reqwest::RequestBuilder;
|
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 适配器
|
/// Claude 适配器
|
||||||
pub struct ClaudeAdapter;
|
pub struct ClaudeAdapter;
|
||||||
|
|
||||||
@@ -57,48 +106,9 @@ impl ClaudeAdapter {
|
|||||||
/// 从 provider.meta.api_format 读取格式设置:
|
/// 从 provider.meta.api_format 读取格式设置:
|
||||||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||||
|
/// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
get_claude_api_format(provider)
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检测是否为仅 Bearer 认证模式
|
/// 检测是否为仅 Bearer 认证模式
|
||||||
@@ -301,20 +311,34 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||||
// 根据 api_format 配置决定是否需要格式转换
|
// 根据 api_format 配置决定是否需要格式转换
|
||||||
// - "anthropic" (默认): 直接透传,无需转换
|
// - "anthropic" (默认): 直接透传,无需转换
|
||||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
|
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||||
self.get_api_format(provider) == "openai_chat"
|
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
|
||||||
|
matches!(
|
||||||
|
self.get_api_format(provider),
|
||||||
|
"openai_chat" | "openai_responses"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_request(
|
fn transform_request(
|
||||||
&self,
|
&self,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
_provider: &Provider,
|
provider: &Provider,
|
||||||
) -> Result<serde_json::Value, ProxyError> {
|
) -> 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> {
|
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;
|
mod gemini;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod streaming;
|
pub mod streaming;
|
||||||
|
pub mod streaming_responses;
|
||||||
pub mod transform;
|
pub mod transform;
|
||||||
|
pub mod transform_responses;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -27,7 +29,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
// 公开导出
|
// 公开导出
|
||||||
pub use adapter::ProviderAdapter;
|
pub use adapter::ProviderAdapter;
|
||||||
pub use auth::{AuthInfo, AuthStrategy};
|
pub use auth::{AuthInfo, AuthStrategy};
|
||||||
pub use claude::ClaudeAdapter;
|
pub use claude::{get_claude_api_format, ClaudeAdapter};
|
||||||
pub use codex::CodexAdapter;
|
pub use codex::CodexAdapter;
|
||||||
pub use gemini::GeminiAdapter;
|
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)
|
/// 清理 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() {
|
if let Some(obj) = schema.as_object_mut() {
|
||||||
// 移除 "format": "uri"
|
// 移除 "format": "uri"
|
||||||
if obj.get("format").and_then(|v| v.as_str()) == Some("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}
|
onChange={onBaseUrlChange}
|
||||||
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
placeholder={t("providerForm.apiEndpointPlaceholder")}
|
||||||
hint={
|
hint={
|
||||||
apiFormat === "openai_chat"
|
apiFormat === "openai_responses"
|
||||||
? t("providerForm.apiHintOAI")
|
? t("providerForm.apiHintResponses")
|
||||||
: t("providerForm.apiHint")
|
: apiFormat === "openai_chat"
|
||||||
|
? t("providerForm.apiHintOAI")
|
||||||
|
: t("providerForm.apiHint")
|
||||||
}
|
}
|
||||||
onManageClick={() => onEndpointModalToggle(true)}
|
onManageClick={() => onEndpointModalToggle(true)}
|
||||||
/>
|
/>
|
||||||
@@ -209,6 +211,11 @@ export function ClaudeFormFields({
|
|||||||
defaultValue: "OpenAI Chat Completions (需转换)",
|
defaultValue: "OpenAI Chat Completions (需转换)",
|
||||||
})}
|
})}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="openai_responses">
|
||||||
|
{t("providerForm.apiFormatOpenAIResponses", {
|
||||||
|
defaultValue: "OpenAI Responses API (需转换)",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export interface ProviderPreset {
|
|||||||
// Claude API 格式(仅 Claude 供应商使用)
|
// Claude API 格式(仅 Claude 供应商使用)
|
||||||
// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||||
apiFormat?: "anthropic" | "openai_chat";
|
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||||
|
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const providerPresets: ProviderPreset[] = [
|
export const providerPresets: ProviderPreset[] = [
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ export function useProviderActions(activeApp: AppId) {
|
|||||||
if (
|
if (
|
||||||
activeApp === "claude" &&
|
activeApp === "claude" &&
|
||||||
provider.category !== "official" &&
|
provider.category !== "official" &&
|
||||||
provider.meta?.apiFormat === "openai_chat"
|
(provider.meta?.apiFormat === "openai_chat" ||
|
||||||
|
provider.meta?.apiFormat === "openai_responses")
|
||||||
) {
|
) {
|
||||||
// OpenAI Chat 格式供应商:显示代理提示
|
// OpenAI Chat 格式供应商:显示代理提示
|
||||||
toast.info(
|
toast.info(
|
||||||
|
|||||||
@@ -697,6 +697,8 @@
|
|||||||
"apiFormatHint": "Select the input format for the provider's API",
|
"apiFormatHint": "Select the input format for the provider's API",
|
||||||
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
||||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
|
"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",
|
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
||||||
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
||||||
"anthropicDefaultOpusModel": "Default Opus Model",
|
"anthropicDefaultOpusModel": "Default Opus Model",
|
||||||
|
|||||||
@@ -697,6 +697,8 @@
|
|||||||
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
||||||
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
||||||
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
|
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
|
||||||
|
"apiFormatOpenAIResponses": "OpenAI Responses API(プロキシが必要)",
|
||||||
|
"apiHintResponses": "💡 OpenAI Responses API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
|
||||||
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
||||||
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
|
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
|
||||||
"anthropicDefaultOpusModel": "既定 Opus モデル",
|
"anthropicDefaultOpusModel": "既定 Opus モデル",
|
||||||
|
|||||||
@@ -697,6 +697,8 @@
|
|||||||
"apiFormatHint": "选择供应商 API 的输入格式",
|
"apiFormatHint": "选择供应商 API 的输入格式",
|
||||||
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
||||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
|
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
|
||||||
|
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)",
|
||||||
|
"apiHintResponses": "💡 填写兼容 OpenAI Responses API 的服务端点地址,不要以斜杠结尾",
|
||||||
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
||||||
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
||||||
"anthropicDefaultOpusModel": "Opus 默认模型",
|
"anthropicDefaultOpusModel": "Opus 默认模型",
|
||||||
|
|||||||
+4
-2
@@ -145,7 +145,8 @@ export interface ProviderMeta {
|
|||||||
// Claude API 格式(仅 Claude 供应商使用)
|
// Claude API 格式(仅 Claude 供应商使用)
|
||||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||||
apiFormat?: "anthropic" | "openai_chat";
|
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||||
|
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill 同步方式
|
// Skill 同步方式
|
||||||
@@ -154,7 +155,8 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy";
|
|||||||
// Claude API 格式类型
|
// Claude API 格式类型
|
||||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
// - "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 {
|
export interface VisibleApps {
|
||||||
|
|||||||
Reference in New Issue
Block a user