Files
CC-Switch/src-tauri/src/proxy/providers/transform_responses.rs
T
Jason af679cda25 fix: map adaptive thinking to xhigh reasoning_effort instead of high
When thinking.type is "adaptive" (Claude's maximum thinking mode) and
output_config.effort is absent, resolve_reasoning_effort() incorrectly
mapped it to "high" instead of "xhigh" in OpenAI format conversions.
2026-04-10 22:40:29 +08:00

1339 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 请求
///
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
/// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。
/// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content`
/// 以便在无服务端状态下保持多轮 reasoning 上下文。
pub fn anthropic_to_responses(
body: Value,
cache_key: Option<&str>,
is_codex_oauth: bool,
) -> 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 (Responses API uses max_output_tokens for all models)
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();
}
// Map Anthropic thinking → OpenAI Responses reasoning.effort
if let Some(model_name) = body.get("model").and_then(|m| m.as_str()) {
if super::transform::supports_reasoning_effort(model_name) {
if let Some(effort) = super::transform::resolve_reasoning_effort(&body) {
result["reasoning"] = json!({ "effort": effort });
}
}
}
// 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"] = map_tool_choice_to_responses(v);
}
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
if let Some(key) = cache_key {
result["prompt_cache_key"] = json!(key);
}
// Codex OAuth (ChatGPT Plus/Pro 反代) 特殊协议约束:
// 整体依据:OpenAI 官方 codex-rs 的 `ResponsesApiRequest` 结构体
// (codex-rs/codex-api/src/common.rs) 是 ChatGPT 反代后端的协议契约。
// 任何不在该结构体里的字段都可能被 ChatGPT 后端以
// "Unsupported parameter: ..." 400 拒绝;任何在结构体里的必填字段
// 都需要在请求体里出现。
//
// 字段处理:
// - store: 必须显式为 falseChatGPT 消费级后端不允许服务端持久化)
// - include: 必须包含 "reasoning.encrypted_content"
// 否则多轮 reasoning 中间态会丢失(无服务端状态 + 无加密回传 = 上下文断链)
// - max_output_tokens / temperature / top_p: 必须删除
// (codex-rs 结构体根本没有这三个字段,OpenAI 自己的客户端不发它们)
// - instructions / tools / parallel_tool_calls: 必填字段,缺则兜底默认值
// cc-switch 的 transform 当前是"条件写入",可能产生缺失)
// - stream: 必须永远 truecodex-rs 硬编码 true,且 cc-switch 的
// SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false)
if is_codex_oauth {
result["store"] = json!(false);
const REASONING_MARKER: &str = "reasoning.encrypted_content";
let mut includes: Vec<Value> = body
.get("include")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if !includes
.iter()
.any(|v| v.as_str() == Some(REASONING_MARKER))
{
includes.push(json!(REASONING_MARKER));
}
result["include"] = json!(includes);
if let Some(obj) = result.as_object_mut() {
// —— 删除 ChatGPT 反代不接受的字段 ——
obj.remove("max_output_tokens");
obj.remove("temperature");
obj.remove("top_p");
// —— 兜底必填字段(or_insert:客户端送了什么就保留,否则注入默认值)——
obj.entry("instructions".to_string()).or_insert(json!(""));
obj.entry("tools".to_string()).or_insert(json!([]));
obj.entry("parallel_tool_calls".to_string())
.or_insert(json!(false));
// —— 强制覆盖 stream = true ——
// 即便客户端误传 stream:false 也要覆盖,因为 codex-rs 永远 true
// 且 cc-switch SSE 解析层只支持流式响应。
obj.insert("stream".to_string(), json!(true));
}
}
Ok(result)
}
fn map_tool_choice_to_responses(tool_choice: &Value) -> Value {
match tool_choice {
Value::String(_) => tool_choice.clone(),
Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) {
// Anthropic "any" means at least one tool call is required
Some("any") => json!("required"),
Some("auto") => json!("auto"),
Some("none") => json!("none"),
// Anthropic forced tool -> Responses function tool selector
Some("tool") => {
let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or("");
json!({
"type": "function",
"name": name
})
}
_ => tool_choice.clone(),
},
_ => tool_choice.clone(),
}
}
pub(crate) fn map_responses_stop_reason(
status: Option<&str>,
has_tool_use: bool,
incomplete_reason: Option<&str>,
) -> Option<&'static str> {
status.map(|s| match s {
"completed" => {
if has_tool_use {
"tool_use"
} else {
"end_turn"
}
}
"incomplete" => {
if matches!(
incomplete_reason,
Some("max_output_tokens") | Some("max_tokens")
) || incomplete_reason.is_none()
{
"max_tokens"
} else {
"end_turn"
}
}
_ => "end_turn",
})
}
/// Build Anthropic-style usage JSON from Responses API usage, including cache tokens.
///
/// Priority order:
/// 1. OpenAI nested details (`input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens`) as initial value
/// 2. Direct Anthropic-style fields (`cache_read_input_tokens`, `cache_creation_input_tokens`) override if present
pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Value {
let u = match usage {
Some(v) if !v.is_null() => v,
_ => {
return json!({
"input_tokens": 0,
"output_tokens": 0
})
}
};
let input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let mut result = json!({
"input_tokens": input,
"output_tokens": output
});
// Step 1: OpenAI nested details as fallback
// OpenAI Responses API: input_tokens_details.cached_tokens
if let Some(cached) = u
.pointer("/input_tokens_details/cached_tokens")
.and_then(|v| v.as_u64())
{
result["cache_read_input_tokens"] = json!(cached);
}
// OpenAI standard: prompt_tokens_details.cached_tokens
if let Some(cached) = u
.pointer("/prompt_tokens_details/cached_tokens")
.and_then(|v| v.as_u64())
{
if result.get("cache_read_input_tokens").is_none() {
result["cache_read_input_tokens"] = json!(cached);
}
}
// Step 2: Direct Anthropic-style fields override (authoritative if present)
if let Some(v) = u.get("cache_read_input_tokens") {
result["cache_read_input_tokens"] = v.clone();
}
if let Some(v) = u.get("cache_creation_input_tokens") {
result["cache_creation_input_tokens"] = v.clone();
}
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"
};
// OpenAI Responses API does not accept Anthropic cache_control
// under input[].content[].
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();
let mut has_tool_use = false;
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}));
}
}
} else if block_type == "refusal" {
if let Some(refusal) = block.get("refusal").and_then(|t| t.as_str()) {
if !refusal.is_empty() {
content.push(json!({"type": "text", "text": refusal}));
}
}
}
}
}
}
"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
}));
has_tool_use = true;
}
"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 = map_responses_stop_reason(
body.get("status").and_then(|s| s.as_str()),
has_tool_use,
body.pointer("/incomplete_details/reason")
.and_then(|r| r.as_str()),
);
let usage_json = build_anthropic_usage_from_responses(body.get("usage"));
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": usage_json
});
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, None, false).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, None, false).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, None, false).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, None, false).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_choice_any_to_required() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Weather?"}],
"tool_choice": {"type": "any"}
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["tool_choice"], "required");
}
#[test]
fn test_anthropic_to_responses_tool_choice_tool_to_function() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Weather?"}],
"tool_choice": {"type": "tool", "name": "get_weather"}
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["tool_choice"]["type"], "function");
assert_eq!(result["tool_choice"]["name"], "get_weather");
}
#[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, None, false).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, None, false).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, None, false).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, None, false).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");
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_responses_to_anthropic_with_refusal_block() {
let input = json!({
"id": "resp_123",
"status": "completed",
"model": "gpt-4o",
"output": [{
"type": "message",
"content": [{"type": "refusal", "refusal": "I can't help with that."}]
}],
"usage": {"input_tokens": 10, "output_tokens": 5}
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "I can't help with that.");
assert_eq!(result["stop_reason"], "end_turn");
}
#[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_responses_to_anthropic_incomplete_non_token_reason() {
let input = json!({
"id": "resp_123",
"status": "incomplete",
"incomplete_details": {"reason": "content_filter"},
"model": "gpt-4o",
"output": [{
"type": "message",
"content": [{"type": "output_text", "text": "Blocked"}]
}],
"usage": {"input_tokens": 10, "output_tokens": 1}
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["stop_reason"], "end_turn");
}
#[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, None, false).unwrap();
assert_eq!(result["model"], "o3-mini");
}
#[test]
fn test_anthropic_to_responses_with_cache_key() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap();
assert_eq!(result["prompt_cache_key"], "my-provider-id");
}
#[test]
fn test_anthropic_to_responses_strip_cache_control_on_tools() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather",
"input_schema": {"type": "object"},
"cache_control": {"type": "ephemeral"}
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(result["tools"][0].get("cache_control").is_none());
}
#[test]
fn test_anthropic_to_responses_strip_cache_control_on_text() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
]
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(result["input"][0]["content"][0]
.get("cache_control")
.is_none());
}
#[test]
fn test_responses_to_anthropic_with_cache_tokens() {
let input = json!({
"id": "resp_123",
"status": "completed",
"model": "gpt-4o",
"output": [{
"type": "message",
"content": [{"type": "output_text", "text": "Hello!"}]
}],
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"input_tokens_details": {
"cached_tokens": 80
}
}
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["usage"]["input_tokens"], 100);
assert_eq!(result["usage"]["output_tokens"], 50);
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
}
#[test]
fn test_responses_to_anthropic_with_direct_cache_fields() {
let input = json!({
"id": "resp_123",
"status": "completed",
"model": "gpt-4o",
"output": [{
"type": "message",
"content": [{"type": "output_text", "text": "Hello!"}]
}],
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_read_input_tokens": 60,
"cache_creation_input_tokens": 20
}
});
let result = responses_to_anthropic(input).unwrap();
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
}
#[test]
fn test_anthropic_to_responses_o_series_uses_max_output_tokens() {
// Responses API always uses max_output_tokens, even for o-series models
let input = json!({
"model": "o3-mini",
"max_tokens": 4096,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["max_output_tokens"], 4096);
assert!(result.get("max_completion_tokens").is_none());
}
#[test]
fn test_responses_output_config_max_sets_reasoning_xhigh() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "max"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
#[test]
fn test_responses_output_config_takes_priority_over_thinking() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "low"},
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
#[test]
fn test_responses_thinking_enabled_small_budget_sets_reasoning_low() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
#[test]
fn test_responses_thinking_enabled_medium_budget_sets_reasoning_medium() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 8000},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "medium");
}
#[test]
fn test_responses_thinking_enabled_large_budget_sets_reasoning_high() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 32000},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
#[test]
fn test_responses_thinking_adaptive_sets_reasoning_xhigh() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
#[test]
fn test_responses_non_reasoning_model_no_reasoning() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(result.get("reasoning").is_none());
}
// ==================== Codex OAuth (ChatGPT 反代) 协议约束 ====================
#[test]
fn test_anthropic_to_responses_codex_oauth_sets_store_and_include() {
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
// store 必须显式为 falseChatGPT 后端拒绝 true
assert_eq!(result["store"], json!(false));
// include 必须包含 reasoning.encrypted_content(无服务端状态下保持多轮 reasoning)
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
}
#[test]
fn test_anthropic_to_responses_non_codex_omits_store_and_include() {
// 回归护栏:is_codex_oauth=false 时,行为必须与今日字节级一致
// —— 不写 store、不写 includeOpenRouter / Azure / OpenAI 付费 API 路径不受影响
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(result.get("store").is_none());
assert!(result.get("include").is_none());
}
#[test]
fn test_anthropic_to_responses_codex_oauth_preserves_existing_include() {
// 客户端预置了 includeunion 保留原有项 + 添加 marker,不重复
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}],
"include": ["something.else", "reasoning.encrypted_content"]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let includes = result["include"]
.as_array()
.expect("include should be array");
// 原有项必须保留
assert!(includes
.iter()
.any(|v| v.as_str() == Some("something.else")));
// marker 必须存在
assert!(includes
.iter()
.any(|v| v.as_str() == Some("reasoning.encrypted_content")));
// 不重复:marker 只出现一次
let marker_count = includes
.iter()
.filter(|v| v.as_str() == Some("reasoning.encrypted_content"))
.count();
assert_eq!(marker_count, 1, "marker 不应被重复添加(idempotent 失败)");
}
#[test]
fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() {
// ChatGPT Plus/Pro 反代不接受 max_output_tokensOpenAI 官方 codex-rs 的
// ResponsesApiRequest 结构体里也没有这个字段),必须删除,否则服务端 400:
// "Unsupported parameter: max_output_tokens"
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert!(
result.get("max_output_tokens").is_none(),
"Codex OAuth 路径必须删除 max_output_tokens"
);
}
#[test]
fn test_anthropic_to_responses_non_codex_keeps_max_output_tokens() {
// 回归护栏:非 Codex OAuth 路径必须保留 max_output_tokens
// —— OpenAI 付费 Responses API / Azure 等仍然依赖这个字段
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["max_output_tokens"], json!(1024));
}
// ==================== 第二轮:P0 + P1 字段对齐 ====================
#[test]
fn test_codex_oauth_strips_temperature() {
// P0: ChatGPT 反代不接受 temperature
// 依据:OpenAI 官方 codex-rs 的 ResponsesApiRequest 结构体根本没有这个字段
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"temperature": 0.7,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert!(
result.get("temperature").is_none(),
"Codex OAuth 路径必须删除 temperature"
);
}
#[test]
fn test_codex_oauth_strips_top_p() {
// P0: ChatGPT 反代不接受 top_p
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"top_p": 0.9,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert!(
result.get("top_p").is_none(),
"Codex OAuth 路径必须删除 top_p"
);
}
#[test]
fn test_codex_oauth_defaults_required_fields_when_absent() {
// P1: 极简输入(无 system / 无 tools / 无 stream),断言四个必填字段都被注入默认值
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert_eq!(
result["instructions"],
json!(""),
"instructions 缺失时应兜底为空字符串"
);
assert_eq!(result["tools"], json!([]), "tools 缺失时应兜底为空数组");
assert_eq!(
result["parallel_tool_calls"],
json!(false),
"parallel_tool_calls 应兜底为 false"
);
assert_eq!(result["stream"], json!(true), "stream 应被强制设为 true");
}
#[test]
fn test_codex_oauth_preserves_existing_instructions_and_tools() {
// P1: 客户端送了 system 和 tools,应保留原值,不被默认值覆盖
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"system": "You are a helpful assistant",
"tools": [{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}}
}
}],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert_eq!(
result["instructions"],
json!("You are a helpful assistant"),
"client 已送的 instructions 必须保留"
);
let tools = result["tools"].as_array().expect("tools 应为数组");
assert_eq!(tools.len(), 1, "client 已送的 tools 必须保留");
assert_eq!(tools[0]["name"], json!("get_weather"));
}
#[test]
fn test_codex_oauth_forces_stream_true_even_when_client_sends_false() {
// 即使客户端误传 stream:false,也要强制覆盖为 true
// 依据:cc-switch SSE 解析层只支持流式响应
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"stream": false,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
assert_eq!(
result["stream"],
json!(true),
"Codex OAuth 路径下 stream 必须强制为 true"
);
}
#[test]
fn test_non_codex_keeps_temperature_and_top_p() {
// 回归护栏:非 Codex OAuth 路径必须保留 temperature/top_p
// —— 防止 P0 删除逻辑误扩散到 OpenRouter / Azure / 付费 OpenAI 路径
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"temperature": 0.7,
"top_p": 0.9,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["temperature"], json!(0.7));
assert_eq!(result["top_p"], json!(0.9));
}
#[test]
fn test_non_codex_does_not_inject_default_required_fields() {
// 回归护栏:非 Codex OAuth 路径不应被 P1 默认值污染
// —— OpenRouter / Azure / 付费 OpenAI 等保持原有"条件写入"语义
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert!(
result.get("parallel_tool_calls").is_none(),
"非 Codex OAuth 路径不应注入 parallel_tool_calls"
);
assert!(
result.get("stream").is_none(),
"非 Codex OAuth 路径不应注入 stream"
);
// instructions 和 tools 因为客户端没送,所以不应出现
assert!(
result.get("instructions").is_none(),
"非 Codex OAuth 路径下 instructions 在客户端未送时不应被注入"
);
assert!(
result.get("tools").is_none(),
"非 Codex OAuth 路径下 tools 在客户端未送时不应被注入"
);
}
}