Files
CC-Switch/src-tauri/src/proxy/providers/transform_codex_chat.rs
T
Jason 22fbe6f11a - Optimize Codex Chat cache stability
- Stop deriving Codex session/cache identity from `previous_response_id`.
  - Canonicalize parseable JSON string payloads in Chat and Responses tool conversions.
  - Add regression coverage for cache-sensitive conversion behavior.
2026-05-25 22:20:33 +08:00

1374 lines
44 KiB
Rust

//! Codex Responses ↔ OpenAI Chat Completions conversion.
//!
//! This module is used when the Codex client talks to CC Switch through the
//! Responses API, while the selected upstream provider only exposes an
//! OpenAI-compatible Chat Completions endpoint.
use super::codex_chat_common::{
append_reasoning_content, extract_reasoning_field_text, extract_reasoning_summary_text,
response_function_call_item, split_leading_think_block,
};
use crate::proxy::{
error::ProxyError,
json_canonical::{canonical_json_string, canonicalize_json_string_if_parseable},
};
use serde_json::{json, Value};
const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[
"frequency_penalty",
"logit_bias",
"logprobs",
"metadata",
"n",
"parallel_tool_calls",
"presence_penalty",
"response_format",
"seed",
"service_tier",
"stop",
"stream_options",
"top_logprobs",
"user",
];
/// Convert an OpenAI Responses request into an OpenAI Chat Completions request.
pub fn responses_to_chat_completions(body: Value) -> Result<Value, ProxyError> {
let mut result = json!({});
if let Some(model) = body.get("model") {
result["model"] = model.clone();
}
let mut messages = Vec::new();
if let Some(instructions) = body.get("instructions") {
let instructions = instruction_text(instructions);
if !instructions.is_empty() {
messages.push(json!({
"role": "system",
"content": instructions
}));
}
}
if let Some(input) = body.get("input") {
append_responses_input_as_chat_messages(input, &mut messages)?;
}
result["messages"] = json!(messages);
let model = body.get("model").and_then(|v| v.as_str()).unwrap_or("");
if let Some(max_tokens) = body.get("max_output_tokens") {
if super::transform::is_openai_o_series(model) {
result["max_completion_tokens"] = max_tokens.clone();
} else {
result["max_tokens"] = max_tokens.clone();
}
}
if let Some(max_tokens) = body.get("max_tokens") {
result["max_tokens"] = max_tokens.clone();
}
if let Some(max_tokens) = body.get("max_completion_tokens") {
result["max_completion_tokens"] = max_tokens.clone();
}
for key in ["temperature", "top_p", "stream"] {
if let Some(value) = body.get(key) {
result[key] = value.clone();
}
}
if super::transform::supports_reasoning_effort(model) {
if let Some(effort) = body.pointer("/reasoning/effort") {
result["reasoning_effort"] = effort.clone();
}
}
if let Some(tools) = body.get("tools").and_then(|v| v.as_array()) {
let tools: Vec<Value> = tools
.iter()
.filter_map(responses_tool_to_chat_tool)
.collect();
if !tools.is_empty() {
result["tools"] = json!(tools);
}
}
if let Some(tool_choice) = body.get("tool_choice") {
result["tool_choice"] = responses_tool_choice_to_chat(tool_choice);
}
for key in EXTRA_CHAT_PASSTHROUGH_FIELDS {
if let Some(value) = body.get(*key) {
result[*key] = value.clone();
}
}
Ok(result)
}
fn instruction_text(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Array(parts) => parts
.iter()
.filter_map(|part| {
part.get("text")
.and_then(|v| v.as_str())
.or_else(|| part.as_str())
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n\n"),
other => other.as_str().unwrap_or_default().to_string(),
}
}
fn append_responses_input_as_chat_messages(
input: &Value,
messages: &mut Vec<Value>,
) -> Result<(), ProxyError> {
let mut pending_tool_calls = Vec::new();
let mut pending_reasoning: Option<String> = None;
let mut last_assistant_index: Option<usize> = None;
match input {
Value::String(text) => {
messages.push(json!({
"role": "user",
"content": text
}));
}
Value::Array(items) => {
for item in items {
append_responses_item_as_chat_message(
item,
messages,
&mut pending_tool_calls,
&mut pending_reasoning,
&mut last_assistant_index,
)?;
}
}
Value::Object(_) => {
append_responses_item_as_chat_message(
input,
messages,
&mut pending_tool_calls,
&mut pending_reasoning,
&mut last_assistant_index,
)?;
}
_ => {}
}
flush_pending_tool_calls(
messages,
&mut pending_tool_calls,
&mut pending_reasoning,
&mut last_assistant_index,
);
Ok(())
}
fn append_responses_item_as_chat_message(
item: &Value,
messages: &mut Vec<Value>,
pending_tool_calls: &mut Vec<Value>,
pending_reasoning: &mut Option<String>,
last_assistant_index: &mut Option<usize>,
) -> Result<(), ProxyError> {
let item_type = item.get("type").and_then(|v| v.as_str());
match item_type {
Some("function_call") => {
append_unique_pending_reasoning(pending_reasoning, responses_item_reasoning_text(item));
pending_tool_calls.push(responses_function_call_to_chat_tool_call(item));
}
Some("function_call_output") => {
flush_pending_tool_calls(
messages,
pending_tool_calls,
pending_reasoning,
last_assistant_index,
);
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
let output = match item.get("output") {
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
Some(v) => canonical_json_string(v),
None => String::new(),
};
messages.push(json!({
"role": "tool",
"tool_call_id": call_id,
"content": output
}));
}
Some("reasoning") => {
let reasoning = responses_reasoning_item_text(item);
let attached_to_previous = pending_tool_calls.is_empty()
&& attach_reasoning_to_last_assistant(messages, *last_assistant_index, &reasoning);
if !attached_to_previous {
append_pending_reasoning(pending_reasoning, reasoning);
}
}
Some("message") | None => {
flush_pending_tool_calls(
messages,
pending_tool_calls,
pending_reasoning,
last_assistant_index,
);
if item.get("role").is_some() || item.get("content").is_some() {
let message = responses_message_item_to_chat_message(item, pending_reasoning);
update_last_assistant_index(messages, &message, last_assistant_index);
messages.push(message);
}
}
_ => {
flush_pending_tool_calls(
messages,
pending_tool_calls,
pending_reasoning,
last_assistant_index,
);
if item.get("role").is_some() || item.get("content").is_some() {
let message = responses_message_item_to_chat_message(item, pending_reasoning);
update_last_assistant_index(messages, &message, last_assistant_index);
messages.push(message);
}
}
}
Ok(())
}
fn flush_pending_tool_calls(
messages: &mut Vec<Value>,
pending_tool_calls: &mut Vec<Value>,
pending_reasoning: &mut Option<String>,
last_assistant_index: &mut Option<usize>,
) {
if pending_tool_calls.is_empty() {
return;
}
let mut message = json!({
"role": "assistant",
"content": null,
"tool_calls": std::mem::take(pending_tool_calls)
});
attach_pending_reasoning_to_assistant(&mut message, pending_reasoning);
*last_assistant_index = Some(messages.len());
messages.push(message);
}
fn responses_message_item_to_chat_message(
item: &Value,
pending_reasoning: &mut Option<String>,
) -> Value {
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user");
let chat_role = responses_role_to_chat_role(role);
let content = item
.get("content")
.map(|value| responses_content_to_chat_content(chat_role, value))
.unwrap_or(Value::Null);
let mut message = json!({
"role": chat_role,
"content": content
});
if chat_role == "assistant" {
append_pending_reasoning(pending_reasoning, responses_message_reasoning_text(item));
attach_pending_reasoning_to_assistant(&mut message, pending_reasoning);
} else if pending_reasoning.is_some() {
pending_reasoning.take();
}
message
}
fn responses_role_to_chat_role(role: &str) -> &'static str {
match role {
"system" | "developer" => "system",
"assistant" => "assistant",
"tool" => "tool",
"user" | "latest_reminder" => "user",
_ => "user",
}
}
fn update_last_assistant_index(
messages: &[Value],
message: &Value,
last_assistant_index: &mut Option<usize>,
) {
match message.get("role").and_then(|v| v.as_str()) {
Some("assistant") => {
*last_assistant_index = Some(messages.len());
}
Some("tool") => {}
_ => {
*last_assistant_index = None;
}
}
}
fn append_pending_reasoning(pending_reasoning: &mut Option<String>, reasoning: Option<String>) {
let Some(reasoning) = reasoning else {
return;
};
let reasoning = reasoning.trim();
if reasoning.is_empty() {
return;
}
match pending_reasoning {
Some(existing) if !existing.is_empty() => {
existing.push_str("\n\n");
existing.push_str(reasoning);
}
_ => {
*pending_reasoning = Some(reasoning.to_string());
}
}
}
fn append_unique_pending_reasoning(
pending_reasoning: &mut Option<String>,
reasoning: Option<String>,
) {
let Some(reasoning) = reasoning else {
return;
};
let reasoning = reasoning.trim();
if reasoning.is_empty() {
return;
}
match pending_reasoning {
Some(existing) if existing.contains(reasoning) => {}
Some(existing) if !existing.is_empty() => {
existing.push_str("\n\n");
existing.push_str(reasoning);
}
_ => {
*pending_reasoning = Some(reasoning.to_string());
}
}
}
fn attach_pending_reasoning_to_assistant(
message: &mut Value,
pending_reasoning: &mut Option<String>,
) {
let Some(reasoning) = pending_reasoning.take() else {
return;
};
if reasoning.trim().is_empty() {
return;
}
if let Some(obj) = message.as_object_mut() {
append_reasoning_content(obj, &reasoning);
}
}
fn attach_reasoning_to_last_assistant(
messages: &mut [Value],
last_assistant_index: Option<usize>,
reasoning: &Option<String>,
) -> bool {
let Some(reasoning) = reasoning
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return true;
};
let Some(index) = last_assistant_index else {
return false;
};
let Some(message) = messages.get_mut(index) else {
return false;
};
if message.get("role").and_then(|v| v.as_str()) != Some("assistant") {
return false;
}
if let Some(obj) = message.as_object_mut() {
append_reasoning_content(obj, reasoning);
return true;
}
false
}
fn responses_message_reasoning_text(item: &Value) -> Option<String> {
responses_item_reasoning_text(item)
}
fn responses_item_reasoning_text(item: &Value) -> Option<String> {
extract_reasoning_field_text(item)
}
fn responses_reasoning_item_text(item: &Value) -> Option<String> {
extract_reasoning_summary_text(item)
}
fn responses_content_to_chat_content(_role: &str, content: &Value) -> Value {
if content.is_null() || content.is_string() {
return content.clone();
}
let Some(parts) = content.as_array() else {
return content.clone();
};
let mut chat_parts: Vec<Value> = Vec::new();
let mut has_non_text_part = false;
for part in parts {
let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
match part_type {
"input_text" | "output_text" | "text" => {
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
if !text.is_empty() {
chat_parts.push(json!({
"type": "text",
"text": text
}));
}
}
}
"refusal" => {
if let Some(text) = part.get("refusal").and_then(|v| v.as_str()) {
if !text.is_empty() {
chat_parts.push(json!({
"type": "text",
"text": text
}));
}
}
}
"input_image" => {
if let Some(image_url) = part.get("image_url") {
let image_url = if image_url.is_object() {
image_url.clone()
} else {
json!({ "url": image_url.as_str().unwrap_or_default() })
};
chat_parts.push(json!({
"type": "image_url",
"image_url": image_url
}));
has_non_text_part = true;
}
}
_ => {}
}
}
if !has_non_text_part {
return Value::String(
chat_parts
.iter()
.filter_map(|part| part.get("text").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join("\n"),
);
}
Value::Array(chat_parts)
}
fn responses_function_call_to_chat_tool_call(item: &Value) -> Value {
let call_id = item
.get("call_id")
.or_else(|| item.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("");
let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
let arguments = match item.get("arguments") {
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
Some(v) => canonical_json_string(v),
None => "{}".to_string(),
};
json!({
"id": call_id,
"type": "function",
"function": {
"name": name,
"arguments": arguments
}
})
}
fn responses_tool_to_chat_tool(tool: &Value) -> Option<Value> {
if tool.get("type").and_then(|v| v.as_str()) != Some("function") {
return None;
}
if tool.get("function").is_some() {
let mut chat_tool = tool.clone();
if let Some(strict) = tool.get("strict").cloned() {
if let Some(function) = chat_tool
.get_mut("function")
.and_then(|value| value.as_object_mut())
{
function.entry("strict".to_string()).or_insert(strict);
}
if let Some(obj) = chat_tool.as_object_mut() {
obj.remove("strict");
}
}
return Some(chat_tool);
}
let mut function = json!({
"name": tool.get("name").and_then(|v| v.as_str()).unwrap_or(""),
"description": tool.get("description").cloned().unwrap_or(Value::Null),
"parameters": tool.get("parameters").cloned().unwrap_or_else(|| json!({}))
});
if let Some(strict) = tool.get("strict") {
function["strict"] = strict.clone();
}
Some(json!({
"type": "function",
"function": function
}))
}
fn responses_tool_choice_to_chat(tool_choice: &Value) -> Value {
match tool_choice {
Value::Object(obj) if obj.get("type").and_then(|v| v.as_str()) == Some("function") => {
json!({
"type": "function",
"function": {
"name": obj.get("name").and_then(|v| v.as_str()).unwrap_or("")
}
})
}
_ => tool_choice.clone(),
}
}
/// Convert a non-streaming Chat Completions response into a Responses response.
pub fn chat_completion_to_response(body: Value) -> Result<Value, ProxyError> {
let choices = body
.get("choices")
.and_then(|v| v.as_array())
.ok_or_else(|| ProxyError::TransformError("No choices in chat response".to_string()))?;
let choice = choices
.first()
.ok_or_else(|| ProxyError::TransformError("Empty choices in chat response".to_string()))?;
let message = choice
.get("message")
.ok_or_else(|| ProxyError::TransformError("No message in chat choice".to_string()))?;
let response_id = response_id_from_chat_id(body.get("id").and_then(|v| v.as_str()));
let model = body.get("model").and_then(|v| v.as_str()).unwrap_or("");
let created_at = body.get("created").and_then(|v| v.as_u64()).unwrap_or(0);
let finish_reason = choice.get("finish_reason").and_then(|v| v.as_str());
let reasoning = chat_reasoning_text(message);
let mut output = Vec::new();
if let Some(reasoning_item) =
chat_reasoning_to_response_output_item(reasoning.as_deref(), &response_id)
{
output.push(reasoning_item);
}
if let Some(message_item) = chat_message_to_response_output_item(message, &response_id) {
output.push(message_item);
}
output.extend(chat_tool_calls_to_response_output_items(
message,
reasoning.as_deref(),
));
let mut response = json!({
"id": response_id,
"object": "response",
"created_at": created_at,
"status": response_status_from_finish_reason(finish_reason),
"model": model,
"output": output,
"usage": chat_usage_to_responses_usage(body.get("usage"))
});
if finish_reason == Some("length") {
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
}
Ok(response)
}
fn chat_reasoning_to_response_output_item(
reasoning: Option<&str>,
response_id: &str,
) -> Option<Value> {
let reasoning = reasoning?;
if reasoning.is_empty() {
return None;
}
Some(json!({
"id": format!("rs_{response_id}"),
"type": "reasoning",
"summary": [{
"type": "summary_text",
"text": reasoning
}]
}))
}
fn chat_reasoning_text(message: &Value) -> Option<String> {
if let Some(reasoning) = extract_reasoning_field_text(message) {
return Some(reasoning);
}
if let Some(content) = message.get("content").and_then(|v| v.as_str()) {
if let Some((reasoning, _answer)) = split_leading_think_block(content) {
if !reasoning.is_empty() {
return Some(reasoning);
}
}
}
None
}
fn chat_message_to_response_output_item(message: &Value, response_id: &str) -> Option<Value> {
let mut content = Vec::new();
if let Some(text) = message.get("content").and_then(|v| v.as_str()) {
let text = split_leading_think_block(text)
.map(|(_reasoning, answer)| answer)
.unwrap_or_else(|| text.to_string());
if !text.is_empty() {
content.push(json!({
"type": "output_text",
"text": text,
"annotations": []
}));
}
} else if let Some(parts) = message.get("content").and_then(|v| v.as_array()) {
for part in parts {
let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
match part_type {
"text" | "output_text" => {
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
if !text.is_empty() {
content.push(json!({
"type": "output_text",
"text": text,
"annotations": []
}));
}
}
}
"refusal" => {
if let Some(text) = part.get("refusal").and_then(|v| v.as_str()) {
if !text.is_empty() {
content.push(json!({
"type": "refusal",
"refusal": text
}));
}
}
}
_ => {}
}
}
}
if let Some(refusal) = message.get("refusal").and_then(|v| v.as_str()) {
if !refusal.is_empty() {
content.push(json!({
"type": "refusal",
"refusal": refusal
}));
}
}
if content.is_empty() {
return None;
}
Some(json!({
"id": format!("{response_id}_msg"),
"type": "message",
"status": "completed",
"role": "assistant",
"content": content
}))
}
fn chat_tool_calls_to_response_output_items(
message: &Value,
reasoning: Option<&str>,
) -> Vec<Value> {
let mut output = Vec::new();
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
for (index, tool_call) in tool_calls.iter().enumerate() {
output.push(chat_tool_call_to_response_item(tool_call, index, reasoning));
}
} else if let Some(function_call) = message.get("function_call") {
output.push(chat_legacy_function_call_to_response_item(
function_call,
reasoning,
));
}
output
}
fn chat_tool_call_to_response_item(
tool_call: &Value,
index: usize,
reasoning: Option<&str>,
) -> Value {
let call_id = tool_call
.get("id")
.and_then(|v| v.as_str())
.filter(|v| !v.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| format!("call_{index}"));
let function = tool_call.get("function").unwrap_or(&Value::Null);
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
let arguments = match function.get("arguments") {
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
Some(v) => canonical_json_string(v),
None => "{}".to_string(),
};
let item_id = format!("fc_{call_id}");
response_function_call_item(&item_id, "completed", &call_id, name, &arguments, reasoning)
}
fn chat_legacy_function_call_to_response_item(
function_call: &Value,
reasoning: Option<&str>,
) -> Value {
let call_id = function_call
.get("id")
.and_then(|v| v.as_str())
.filter(|v| !v.is_empty())
.unwrap_or("call_0");
let name = function_call
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
let arguments = match function_call.get("arguments") {
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
Some(v) => canonical_json_string(v),
None => "{}".to_string(),
};
let item_id = format!("fc_{call_id}");
response_function_call_item(&item_id, "completed", call_id, name, &arguments, reasoning)
}
pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value {
let Some(usage) = usage.filter(|value| value.is_object() && !value.is_null()) else {
return json!({
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0
});
};
let input_tokens = usage
.get("prompt_tokens")
.or_else(|| usage.get("input_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let output_tokens = usage
.get("completion_tokens")
.or_else(|| usage.get("output_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let total_tokens = usage
.get("total_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(input_tokens + output_tokens);
let mut result = json!({
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens
});
if let Some(cached) = usage
.pointer("/prompt_tokens_details/cached_tokens")
.or_else(|| usage.pointer("/input_tokens_details/cached_tokens"))
.and_then(|v| v.as_u64())
{
result["input_tokens_details"] = json!({ "cached_tokens": cached });
}
if let Some(details) = usage.get("completion_tokens_details") {
result["output_tokens_details"] = details.clone();
}
if let Some(cache_read) = usage.get("cache_read_input_tokens") {
result["cache_read_input_tokens"] = cache_read.clone();
}
if let Some(cache_creation) = usage.get("cache_creation_input_tokens") {
result["cache_creation_input_tokens"] = cache_creation.clone();
}
result
}
pub(crate) fn response_id_from_chat_id(id: Option<&str>) -> String {
let id = id.unwrap_or("ccswitch");
if id.starts_with("resp_") {
id.to_string()
} else {
format!("resp_{id}")
}
}
pub(crate) fn response_status_from_finish_reason(finish_reason: Option<&str>) -> &'static str {
match finish_reason {
Some("length") => "incomplete",
_ => "completed",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn responses_request_to_chat_maps_messages_tools_and_limits() {
let input = json!({
"model": "gpt-5.4",
"instructions": "You are concise.",
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "Weather?"},
{"type": "input_image", "image_url": "data:image/png;base64,abc"},
{"type": "input_text", "text": "Use Celsius."}
]
},
{
"type": "function_call",
"call_id": "call_1",
"name": "get_weather",
"arguments": "{\"city\":\"Tokyo\"}"
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "Sunny"
}
],
"tools": [{
"type": "function",
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object"},
"strict": true
}],
"tool_choice": {"type": "function", "name": "get_weather"},
"max_output_tokens": 100,
"reasoning": {"effort": "high"},
"stream": true
});
let result = responses_to_chat_completions(input).unwrap();
assert_eq!(result["model"], "gpt-5.4");
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(result["messages"][1]["role"], "user");
assert_eq!(result["messages"][1]["content"][0]["type"], "text");
assert_eq!(result["messages"][1]["content"][1]["type"], "image_url");
assert_eq!(result["messages"][1]["content"][2]["type"], "text");
assert_eq!(result["messages"][1]["content"][2]["text"], "Use Celsius.");
assert_eq!(result["messages"][2]["tool_calls"][0]["id"], "call_1");
assert_eq!(result["messages"][3]["role"], "tool");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
assert_eq!(result["tools"][0]["function"]["strict"], true);
assert_eq!(result["tool_choice"]["function"]["name"], "get_weather");
assert_eq!(result["max_tokens"], 100);
assert_eq!(result["reasoning_effort"], "high");
}
#[test]
fn responses_request_to_chat_normalizes_codex_internal_roles() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "message",
"role": "developer",
"content": [
{"type": "input_text", "text": "Follow project instructions."}
]
},
{
"type": "message",
"role": "latest_reminder",
"content": "Keep the reply brief."
},
{
"type": "message",
"role": "unknown_codex_role",
"content": "Fallback content."
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "Follow project instructions.");
assert_eq!(messages[1]["role"], "user");
assert_eq!(messages[1]["content"], "Keep the reply brief.");
assert_eq!(messages[2]["role"], "user");
assert_eq!(messages[2]["content"], "Fallback content.");
}
#[test]
fn responses_request_to_chat_passes_reasoning_content_back_to_assistant_message() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "reasoning",
"summary": [
{"type": "summary_text", "text": "Need to inspect the repo."}
]
},
{
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "I will check the files."}
]
},
{
"type": "message",
"role": "user",
"content": "Continue"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["content"], "I will check the files.");
assert_eq!(
messages[0]["reasoning_content"],
"Need to inspect the repo."
);
assert_eq!(messages[1]["role"], "user");
assert!(messages[1].get("reasoning_content").is_none());
}
#[test]
fn responses_request_to_chat_attaches_trailing_reasoning_to_previous_assistant() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "message",
"role": "assistant",
"content": "I checked the files."
},
{
"type": "reasoning",
"summary": [
{"type": "summary_text", "text": "The answer came from README."}
]
},
{
"type": "message",
"role": "user",
"content": "Continue"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["content"], "I checked the files.");
assert_eq!(
messages[0]["reasoning_content"],
"The answer came from README."
);
assert_eq!(messages[1]["role"], "user");
assert!(messages[1].get("reasoning_content").is_none());
}
#[test]
fn responses_request_to_chat_keeps_embedded_assistant_reasoning() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "message",
"role": "assistant",
"reasoning_content": "I need to preserve thinking history.",
"content": "Done."
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["content"], "Done.");
assert_eq!(
messages[0]["reasoning_content"],
"I need to preserve thinking history."
);
}
#[test]
fn responses_request_to_chat_attaches_reasoning_to_tool_call_message() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "reasoning",
"summary": "Need to read a file."
},
{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}"
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "Readme content"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["reasoning_content"], "Need to read a file.");
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
assert_eq!(messages[1]["role"], "tool");
}
#[test]
fn responses_request_to_chat_recovers_reasoning_from_function_call_item() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}",
"reasoning_content": "Need to read a file."
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "Readme content"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
assert_eq!(messages[0]["reasoning_content"], "Need to read a file.");
assert_eq!(messages[1]["role"], "tool");
}
#[test]
fn responses_request_to_chat_attaches_trailing_reasoning_to_tool_call_message() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}"
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "Readme content"
},
{
"type": "reasoning",
"summary": "Need to read a file."
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
assert_eq!(messages[0]["reasoning_content"], "Need to read a file.");
assert_eq!(messages[1]["role"], "tool");
}
#[test]
fn responses_request_to_chat_keeps_multiple_tool_calls_adjacent_to_outputs() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}"
},
{
"type": "function_call",
"call_id": "call_2",
"name": "list_files",
"arguments": "{\"path\":\"src\"}"
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "Readme content"
},
{
"type": "function_call_output",
"call_id": "call_2",
"output": ["main.rs", "lib.rs"]
},
{
"role": "user",
"content": "Continue"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 4);
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
assert_eq!(messages[0]["tool_calls"][1]["id"], "call_2");
assert_eq!(messages[1]["role"], "tool");
assert_eq!(messages[1]["tool_call_id"], "call_1");
assert_eq!(messages[2]["role"], "tool");
assert_eq!(messages[2]["tool_call_id"], "call_2");
assert_eq!(messages[2]["content"], "[\"main.rs\",\"lib.rs\"]");
assert_eq!(messages[3]["role"], "user");
}
#[test]
fn responses_request_to_chat_canonicalizes_json_string_tool_payloads() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": "{ \"b\": 2, \"a\": 1 }"
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "{ \"z\": true, \"a\": [2, 1] }"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(
messages[0]["tool_calls"][0]["function"]["arguments"],
r#"{"a":1,"b":2}"#
);
assert_eq!(messages[1]["content"], r#"{"a":[2,1],"z":true}"#);
}
#[test]
fn responses_request_to_chat_preserves_plain_text_tool_output() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": "not json"
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "plain text result"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(
messages[0]["tool_calls"][0]["function"]["arguments"],
"not json"
);
assert_eq!(messages[1]["content"], "plain text result");
}
#[test]
fn chat_response_to_responses_maps_text_tool_calls_and_usage() {
let input = json!({
"id": "chatcmpl_1",
"object": "chat.completion",
"created": 123,
"model": "gpt-5.4",
"choices": [{
"message": {
"role": "assistant",
"reasoning_content": "I should check the weather before answering.",
"content": "Let me check.",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Tokyo\"}"
}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
"prompt_tokens_details": {"cached_tokens": 3}
}
});
let result = chat_completion_to_response(input).unwrap();
assert_eq!(result["id"], "resp_chatcmpl_1");
assert_eq!(result["status"], "completed");
assert_eq!(result["output"][0]["type"], "reasoning");
assert_eq!(
result["output"][0]["summary"][0]["text"],
"I should check the weather before answering."
);
assert_eq!(result["output"][1]["type"], "message");
assert_eq!(result["output"][1]["content"][0]["text"], "Let me check.");
assert_eq!(result["output"][2]["type"], "function_call");
assert_eq!(result["output"][2]["call_id"], "call_1");
assert_eq!(
result["output"][2]["reasoning_content"],
"I should check the weather before answering."
);
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 3);
}
#[test]
fn chat_response_to_responses_canonicalizes_json_string_tool_arguments() {
let input = json!({
"id": "chatcmpl_args",
"object": "chat.completion",
"created": 123,
"model": "gpt-5.4",
"choices": [{
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "lookup",
"arguments": "{ \"b\": 2, \"a\": 1 }"
}
}]
},
"finish_reason": "tool_calls"
}]
});
let result = chat_completion_to_response(input).unwrap();
assert_eq!(result["output"][0]["type"], "function_call");
assert_eq!(result["output"][0]["arguments"], r#"{"a":1,"b":2}"#);
}
#[test]
fn chat_response_to_responses_splits_inline_think_content() {
let input = json!({
"id": "chatcmpl_think",
"object": "chat.completion",
"created": 123,
"model": "MiniMax-M2.7",
"choices": [{
"message": {
"role": "assistant",
"content": "<think>\nI should answer with pong.\n</think>\n\npong"
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
"completion_tokens_details": {"reasoning_tokens": 18}
}
});
let result = chat_completion_to_response(input).unwrap();
assert_eq!(result["output"][0]["type"], "reasoning");
assert_eq!(
result["output"][0]["summary"][0]["text"],
"I should answer with pong."
);
assert_eq!(result["output"][1]["type"], "message");
assert_eq!(result["output"][1]["content"][0]["text"], "pong");
assert_eq!(
result["usage"]["output_tokens_details"]["reasoning_tokens"],
18
);
}
#[test]
fn chat_response_length_maps_to_incomplete_response() {
let input = json!({
"id": "chatcmpl_2",
"model": "gpt-5.4",
"choices": [{
"message": {"role": "assistant", "content": "partial"},
"finish_reason": "length"
}]
});
let result = chat_completion_to_response(input).unwrap();
assert_eq!(result["status"], "incomplete");
assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens");
}
}