Add Chat Completions routing for Codex providers

- Add a Codex API format selector and routing badge for Chat Completions providers.
- Convert Codex Responses requests to upstream Chat Completions when routing is required.
- Convert Chat Completions JSON and SSE responses back to Responses format.
- Keep generated Codex wire_api values on Responses for Codex compatibility.
- Add i18n labels, provider metadata handling, and focused conversion tests.
This commit is contained in:
Jason
2026-05-19 10:23:48 +08:00
parent 61e68d754c
commit 1c82b8a3fa
19 changed files with 2230 additions and 35 deletions
@@ -0,0 +1,712 @@
//! 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 crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
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();
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)?;
}
}
Value::Object(_) => {
append_responses_item_as_chat_message(input, messages, &mut pending_tool_calls)?;
}
_ => {}
}
flush_pending_tool_calls(messages, &mut pending_tool_calls);
Ok(())
}
fn append_responses_item_as_chat_message(
item: &Value,
messages: &mut Vec<Value>,
pending_tool_calls: &mut Vec<Value>,
) -> Result<(), ProxyError> {
let item_type = item.get("type").and_then(|v| v.as_str());
match item_type {
Some("function_call") => {
pending_tool_calls.push(responses_function_call_to_chat_tool_call(item));
}
Some("function_call_output") => {
flush_pending_tool_calls(messages, pending_tool_calls);
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)) => s.clone(),
Some(v) => canonical_json_string(v),
None => String::new(),
};
messages.push(json!({
"role": "tool",
"tool_call_id": call_id,
"content": output
}));
}
Some("reasoning") => {
// Reasoning items are Responses-specific context. Chat-only providers
// cannot consume encrypted reasoning state, so omit it.
}
Some("message") | None => {
flush_pending_tool_calls(messages, pending_tool_calls);
if item.get("role").is_some() || item.get("content").is_some() {
messages.push(responses_message_item_to_chat_message(item));
}
}
_ => {
flush_pending_tool_calls(messages, pending_tool_calls);
if item.get("role").is_some() || item.get("content").is_some() {
messages.push(responses_message_item_to_chat_message(item));
}
}
}
Ok(())
}
fn flush_pending_tool_calls(messages: &mut Vec<Value>, pending_tool_calls: &mut Vec<Value>) {
if pending_tool_calls.is_empty() {
return;
}
messages.push(json!({
"role": "assistant",
"content": null,
"tool_calls": std::mem::take(pending_tool_calls)
}));
}
fn responses_message_item_to_chat_message(item: &Value) -> Value {
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user");
let content = item
.get("content")
.map(|value| responses_content_to_chat_content(role, value))
.unwrap_or(Value::Null);
json!({
"role": role,
"content": content
})
}
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)) => s.clone(),
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 mut output = Vec::new();
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));
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_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()) {
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) -> 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));
}
} else if let Some(function_call) = message.get("function_call") {
output.push(chat_legacy_function_call_to_response_item(function_call));
}
output
}
fn chat_tool_call_to_response_item(tool_call: &Value, index: usize) -> 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)) => s.clone(),
Some(v) => canonical_json_string(v),
None => "{}".to_string(),
};
json!({
"id": format!("fc_{call_id}"),
"type": "function_call",
"status": "completed",
"call_id": call_id,
"name": name,
"arguments": arguments
})
}
fn chat_legacy_function_call_to_response_item(function_call: &Value) -> 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)) => s.clone(),
Some(v) => canonical_json_string(v),
None => "{}".to_string(),
};
json!({
"id": format!("fc_{call_id}"),
"type": "function_call",
"status": "completed",
"call_id": call_id,
"name": name,
"arguments": arguments
})
}
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 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",
"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"], "message");
assert_eq!(result["output"][0]["content"][0]["text"], "Let me check.");
assert_eq!(result["output"][1]["type"], "function_call");
assert_eq!(result["output"][1]["call_id"], "call_1");
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_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");
}
}