//! 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 { 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 = 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::>() .join("\n\n"), other => other.as_str().unwrap_or_default().to_string(), } } fn append_responses_input_as_chat_messages( input: &Value, messages: &mut Vec, ) -> Result<(), ProxyError> { let mut pending_tool_calls = Vec::new(); let mut pending_reasoning: Option = None; let mut last_assistant_index: Option = 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, pending_tool_calls: &mut Vec, pending_reasoning: &mut Option, last_assistant_index: &mut Option, ) -> 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, pending_tool_calls: &mut Vec, pending_reasoning: &mut Option, last_assistant_index: &mut Option, ) { 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, ) -> 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, ) { 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, reasoning: Option) { 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, reasoning: Option, ) { 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, ) { 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, reasoning: &Option, ) -> 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 { responses_item_reasoning_text(item) } fn responses_item_reasoning_text(item: &Value) -> Option { extract_reasoning_field_text(item) } fn responses_reasoning_item_text(item: &Value) -> Option { 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 = 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::>() .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 { 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 { 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 { 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 { 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 { 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 { 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": "\nI should answer with pong.\n\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"); } }