//! 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, response_function_call_item_with_namespace, split_leading_think_block, }; use crate::provider::CodexChatReasoningConfig; use crate::proxy::{ error::ProxyError, json_canonical::{ canonical_json_string, canonicalize_json_string_if_parseable, canonicalize_tool_arguments, short_sha256_hex, }, }; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; 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", ]; const TOOL_SEARCH_PROXY_NAME: &str = "tool_search"; const CUSTOM_TOOL_INPUT_FIELD: &str = "input"; const CHAT_TOOL_NAME_MAX_LEN: usize = 64; const CUSTOM_TOOL_INPUT_DESCRIPTION: &str = "Raw string input for the original custom tool. Preserve formatting exactly and follow the original tool definition embedded in the description."; const CUSTOM_TOOL_PRESERVED_METADATA_HEADING: &str = "Original tool definition:"; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CodexToolKind { Function, Namespace, Custom, ToolSearch, } #[derive(Debug, Clone)] pub(crate) struct CodexToolSpec { pub(crate) kind: CodexToolKind, pub(crate) name: String, pub(crate) namespace: Option, } #[derive(Debug, Clone, Default)] pub(crate) struct CodexToolContext { chat_tools: Vec, seen_chat_names: HashSet, chat_name_to_spec: HashMap, namespace_name_to_chat_name: HashMap<(String, String), String>, } impl CodexToolContext { pub(crate) fn chat_tools(&self) -> &[Value] { &self.chat_tools } pub(crate) fn lookup_chat_name(&self, chat_name: &str) -> Option<&CodexToolSpec> { self.chat_name_to_spec.get(chat_name) } pub(crate) fn is_custom_tool_chat_name(&self, chat_name: &str) -> bool { self.lookup_chat_name(chat_name) .is_some_and(|spec| matches!(&spec.kind, CodexToolKind::Custom)) } pub(crate) fn chat_name_for_response_function( &self, name: &str, namespace: Option<&str>, ) -> String { if let Some(namespace) = namespace.filter(|value| !value.is_empty()) { if let Some(chat_name) = self .namespace_name_to_chat_name .get(&(namespace.to_string(), name.to_string())) { return chat_name.clone(); } return flatten_namespace_tool_name(namespace, name); } name.to_string() } fn add_chat_tool(&mut self, chat_name: String, spec: CodexToolSpec, chat_tool: Value) { if chat_name.trim().is_empty() || self.seen_chat_names.contains(&chat_name) { return; } self.seen_chat_names.insert(chat_name.clone()); if let Some(namespace) = spec.namespace.as_ref() { self.namespace_name_to_chat_name .insert((namespace.clone(), spec.name.clone()), chat_name.clone()); } self.chat_name_to_spec.insert(chat_name, spec); self.chat_tools.push(chat_tool); } fn add_function_tool(&mut self, tool: &Value, namespace: Option<&str>) { let Some(original_name) = responses_tool_name(tool) else { return; }; let chat_name = namespace .map(|namespace| flatten_namespace_tool_name(namespace, &original_name)) .unwrap_or_else(|| original_name.clone()); let Some(chat_tool) = responses_function_tool_to_chat_tool(tool, &chat_name) else { return; }; let spec = CodexToolSpec { kind: if namespace.is_some() { CodexToolKind::Namespace } else { CodexToolKind::Function }, name: original_name, namespace: namespace.map(ToString::to_string), }; self.add_chat_tool(chat_name, spec, chat_tool); } fn add_custom_tool(&mut self, tool: &Value) { let Some(name) = responses_tool_name(tool) else { return; }; let description = json!(responses_custom_tool_description(tool)); let chat_tool = json!({ "type": "function", "function": { "name": name, "description": description, "parameters": { "type": "object", "properties": { CUSTOM_TOOL_INPUT_FIELD: { "type": "string", "description": CUSTOM_TOOL_INPUT_DESCRIPTION } }, "required": [CUSTOM_TOOL_INPUT_FIELD] } } }); let spec = CodexToolSpec { kind: CodexToolKind::Custom, name: name.clone(), namespace: None, }; self.add_chat_tool(name, spec, chat_tool); } fn add_tool_search_tool(&mut self) { let chat_tool = json!({ "type": "function", "function": { "name": TOOL_SEARCH_PROXY_NAME, "description": "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Search query for tools or connectors to load." }, "limit": { "type": "integer", "description": "Maximum number of tool groups to return." } }, "required": ["query"] } } }); let spec = CodexToolSpec { kind: CodexToolKind::ToolSearch, name: TOOL_SEARCH_PROXY_NAME.to_string(), namespace: None, }; self.add_chat_tool(TOOL_SEARCH_PROXY_NAME.to_string(), spec, chat_tool); } fn add_namespace_tool(&mut self, namespace_tool: &Value) { let Some(namespace) = namespace_tool.get("name").and_then(|v| v.as_str()) else { return; }; let Some(children) = namespace_tool .get("tools") .or_else(|| namespace_tool.get("children")) .and_then(|v| v.as_array()) else { return; }; for child in children { if child.get("type").and_then(|v| v.as_str()) == Some("function") { self.add_function_tool(child, Some(namespace)); } } } fn add_response_tool(&mut self, tool: &Value) { match tool { Value::String(name) => { self.add_custom_tool(&json!({ "type": "custom", "name": name })); } Value::Object(_) => match tool.get("type").and_then(|v| v.as_str()) { Some("function") => self.add_function_tool(tool, None), Some("custom") => self.add_custom_tool(tool), Some("tool_search") => self.add_tool_search_tool(), Some("namespace") => self.add_namespace_tool(tool), _ => {} }, _ => {} } } } pub(crate) fn build_codex_tool_context_from_request(body: &Value) -> CodexToolContext { let mut context = CodexToolContext::default(); if let Some(tools) = body.get("tools").and_then(|v| v.as_array()) { for tool in tools { context.add_response_tool(tool); } } if let Some(input) = body.get("input") { collect_tool_search_output_tools(input, &mut context); } context } /// Convert an OpenAI Responses request into an OpenAI Chat Completions request. #[allow(dead_code)] pub fn responses_to_chat_completions(body: Value) -> Result { responses_to_chat_completions_with_reasoning(body, None) } /// Convert an OpenAI Responses request into an OpenAI Chat Completions request, /// using provider-declared Codex Chat reasoning capabilities when available. pub fn responses_to_chat_completions_with_reasoning( body: Value, reasoning_config: Option<&CodexChatReasoningConfig>, ) -> Result { let mut result = json!({}); let tool_context = build_codex_tool_context_from_request(&body); 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, &tool_context)?; } let messages = collapse_system_messages_to_head(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(); } } apply_reasoning_options(&mut result, &body, model, reasoning_config); let tools = tool_context.chat_tools(); 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, &tool_context); } for key in EXTRA_CHAT_PASSTHROUGH_FIELDS { if let Some(value) = body.get(*key) { result[*key] = value.clone(); } } // Strict OpenAI-compatible upstreams (vLLM, enterprise gateways) reject // requests that carry tool_choice or parallel_tool_calls without a non-empty // tools array. Drop both fields when tools ended up absent or empty after // conversion to avoid 503/400 from such providers. let has_tools = result .get("tools") .is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty())); if !has_tools { if let Some(obj) = result.as_object_mut() { obj.remove("tool_choice"); obj.remove("parallel_tool_calls"); } } // OpenAI 兼容上游在流式下默认不在 SSE 里返回 usage,必须显式声明 // include_usage 才会在末尾吐 usage chunk。Codex CLI 用 Responses 协议、 // 自身不带 stream_options,缺这一注入会导致 kimi/MiniMax 等第三方流式请求的 // token/成本/缓存命中率全部漏记(input/output/cache 全为 0)。 // 与 Claude→openai_chat 路径共用同一 helper,保证两个客户端方向一致。 super::transform::inject_openai_stream_include_usage(&mut result); Ok(result) } fn apply_reasoning_options( result: &mut Value, body: &Value, model: &str, config: Option<&CodexChatReasoningConfig>, ) { let Some(config) = config else { if super::transform::supports_reasoning_effort(model) { if let Some(effort) = body.pointer("/reasoning/effort") { result["reasoning_effort"] = effort.clone(); } } return; }; let supports_effort = config.supports_effort.unwrap_or(false); let supports_thinking = config.supports_thinking.unwrap_or(false) || supports_effort; let Some(reasoning_enabled) = reasoning_requested(body) else { return; }; if supports_thinking { match config .thinking_param .as_deref() .unwrap_or("thinking") .trim() .to_ascii_lowercase() .as_str() { "thinking" => { result["thinking"] = json!({ "type": if reasoning_enabled { "enabled" } else { "disabled" } }); } "enable_thinking" => { result["enable_thinking"] = json!(reasoning_enabled); } "reasoning_split" => { result["reasoning_split"] = json!(reasoning_enabled); } _ => {} } } // effort_param 在 early return 之前算出:reasoning.effort 形态的「显式关闭」分支要用到。 let effort_param = config .effort_param .as_deref() .unwrap_or("reasoning_effort") .trim() .to_ascii_lowercase(); if !reasoning_enabled { // OpenRouter 原生 reasoning.effort 支持显式 "none"(语义:彻底关闭推理)。 // 上游显式发 effort=none/off/disabled(或 reasoning=null)时 reasoning_enabled 为 false, // 直接 return 会丢失关闭意图——OpenRouter 部分模型默认开思考,不带字段无法关闭, // 造成行为与成本偏差;故对该形态忠实转发 {"reasoning":{"effort":"none"}}。 // 顶层 reasoning_effort 平台的枚举不含 none,仍走上方 thinking 关闭路径、不发 effort。 // 注意:完全不带 reasoning 字段时 reasoning_requested 返回 None 已提前 return, // 不会走到这里,故只有上游「显式」表达关闭才透传 none。 if effort_param == "reasoning.effort" { result["reasoning"] = json!({ "effort": "none" }); } return; } if !supports_effort { return; } let Some(effort) = body.pointer("/reasoning/effort").and_then(|v| v.as_str()) else { return; }; let Some(mapped) = map_reasoning_effort(effort, config.effort_value_mode.as_deref()) else { return; }; match effort_param.as_str() { // OpenAI 风格顶层字段(DeepSeek 官方、OpenAI o-series 等)。 "reasoning_effort" => { result["reasoning_effort"] = json!(mapped); } // OpenRouter 原生归一化对象:reasoning.effort 会被 OpenRouter 翻译成各底层模型 // (OpenAI/Grok/Gemini/Anthropic)的正确推理参数,覆盖面比顶层 OpenAI 别名更全。 // 本转换从空对象构造、不残留原始 reasoning 对象,故不会出现 reasoning 与 // reasoning_effort 并存触发 400 的情况(参见 openclaw#24119)。 "reasoning.effort" => { result["reasoning"] = json!({ "effort": mapped }); } _ => {} } } fn reasoning_requested(body: &Value) -> Option { if let Some(effort) = body.pointer("/reasoning/effort").and_then(|v| v.as_str()) { return Some(!matches!( effort.trim().to_ascii_lowercase().as_str(), "none" | "off" | "disabled" )); } body.get("reasoning").map(|value| !value.is_null()) } fn map_reasoning_effort(effort: &str, mode: Option<&str>) -> Option<&'static str> { let effort = effort.trim().to_ascii_lowercase(); if matches!(effort.as_str(), "none" | "off" | "disabled") { return None; } match mode.unwrap_or("passthrough") { "deepseek" => match effort.as_str() { "max" | "xhigh" => Some("max"), _ => Some("high"), }, "low_high" => match effort.as_str() { "minimal" | "low" => Some("low"), _ => Some("high"), }, // OpenRouter effort 枚举为 xhigh|high|medium|low|minimal(无 max)。max 是 // Codex / 部分模型的扩展档位,对 OpenRouter 非法,会触发 // `400 reasoning_effort: Invalid option`(见 openclaw#77350);钳到最高合法档 // xhigh,其余合法值透传,未知值丢弃以免被上游拒绝。 "openrouter" => match effort.as_str() { "max" | "xhigh" => Some("xhigh"), "high" => Some("high"), "medium" => Some("medium"), "low" => Some("low"), "minimal" => Some("minimal"), _ => None, }, _ => match effort.as_str() { "minimal" => Some("minimal"), "low" => Some("low"), "medium" => Some("medium"), "high" => Some("high"), "xhigh" => Some("xhigh"), "max" => Some("max"), _ => None, }, } } /// MiniMax 严格要求 messages 中只能首条出现 `role=system`, /// 否则返回 `invalid params, chat content has invalid message role: system (2013)`。 /// 把所有 system 消息合并到首位,避免中间 system(如 Codex 的 `developer` 指令)触发该约束; /// 该重排对 OpenAI / DeepSeek 等宽松兼容层也是无损的。 fn collapse_system_messages_to_head(messages: Vec) -> Vec { let mut system_chunks: Vec = Vec::new(); let mut rest: Vec = Vec::with_capacity(messages.len()); for msg in messages { if msg.get("role").and_then(|v| v.as_str()) == Some("system") { if let Some(text) = msg.get("content").and_then(|v| v.as_str()) { let trimmed = text.trim(); if !trimmed.is_empty() { system_chunks.push(text.to_string()); } continue; } } rest.push(msg); } let mut out: Vec = Vec::with_capacity(rest.len() + 1); if !system_chunks.is_empty() { out.push(json!({ "role": "system", "content": system_chunks.join("\n\n") })); } out.extend(rest); out } 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, tool_context: &CodexToolContext, ) -> 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, tool_context, )?; } } Value::Object(_) => { append_responses_item_as_chat_message( input, messages, &mut pending_tool_calls, &mut pending_reasoning, &mut last_assistant_index, tool_context, )?; } _ => {} } flush_pending_tool_calls( messages, &mut pending_tool_calls, &mut pending_reasoning, &mut last_assistant_index, ); backfill_tool_call_reasoning_placeholders(messages); 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, tool_context: &CodexToolContext, ) -> 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, tool_context, )); } Some("custom_tool_call") => { append_unique_pending_reasoning(pending_reasoning, responses_item_reasoning_text(item)); pending_tool_calls.push(responses_custom_tool_call_to_chat_tool_call(item)); } Some("tool_search_call") => { append_unique_pending_reasoning(pending_reasoning, responses_item_reasoning_text(item)); pending_tool_calls.push(responses_tool_search_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("custom_tool_call_output") | Some("tool_search_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 = canonical_json_string(item); 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("input_text" | "input_image" | "input_file" | "input_audio") => { flush_pending_tool_calls( messages, pending_tool_calls, pending_reasoning, last_assistant_index, ); let role = item .get("role") .and_then(|v| v.as_str()) .map(responses_role_to_chat_role) .unwrap_or("user"); let message = json!({ "role": role, "content": responses_content_to_chat_content(role, &Value::Array(vec![item.clone()])) }); if role == "assistant" { let mut message = message; attach_pending_reasoning_to_assistant(&mut message, pending_reasoning); update_last_assistant_index(messages, &message, last_assistant_index); messages.push(message); return Ok(()); } else if pending_reasoning.is_some() { pending_reasoning.take(); } update_last_assistant_index(messages, &message, last_assistant_index); messages.push(message); } 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); } } /// 在所有 input 处理完毕后,对仍缺 `reasoning_content` 的 assistant tool-call 消息补占位。 /// 必须作为管线末端的最终兜底执行:真实 reasoning 可能以尾随 `reasoning` item 的形式经 /// `attach_reasoning_to_last_assistant` 回填,过早注入占位会被 `append_reasoning_content` /// 追加而污染真实思考。 fn backfill_tool_call_reasoning_placeholders(messages: &mut [Value]) { for message in messages.iter_mut() { let is_assistant_tool_call = message.get("role").and_then(|value| value.as_str()) == Some("assistant") && message .get("tool_calls") .and_then(|value| value.as_array()) .is_some_and(|calls| !calls.is_empty()); if is_assistant_tool_call { ensure_tool_call_reasoning_content(message); } } } /// kimi/Moonshot、DeepSeek 等 thinking 模型要求每条带 `tool_calls` 的 assistant /// 消息都必须携带非空 `reasoning_content`。跨轮历史恢复 miss(如代理重启丢失内存缓存、 /// call_id 歧义无法恢复、上游某轮未产出思考)时,这里补一个占位,避免上游返回 /// `reasoning_content is missing in assistant tool call message`。 /// 与 `transform::anthropic_to_openai_with_reasoning_content` 的占位行为保持对称。 fn ensure_tool_call_reasoning_content(message: &mut Value) { let Some(obj) = message.as_object_mut() else { return; }; let has_reasoning = obj .get("reasoning_content") .and_then(|value| value.as_str()) .is_some_and(|text| !text.trim().is_empty()); if !has_reasoning { obj.insert( "reasoning_content".to_string(), Value::String("tool call".to_string()), ); } } 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; } } "input_file" => { if let Some(file) = responses_input_file_to_chat_file(part) { chat_parts.push(json!({ "type": "file", "file": file })); has_non_text_part = true; } } "input_audio" => { if let Some(input_audio) = part.get("input_audio") { chat_parts.push(json!({ "type": "input_audio", "input_audio": input_audio.clone() })); 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_input_file_to_chat_file(part: &Value) -> Option { let mut file = serde_json::Map::new(); let has_supported_file_ref = part.get("file_id").is_some() || part.get("file_data").is_some(); if !has_supported_file_ref { return None; } for key in ["file_id", "file_data", "filename"] { if let Some(value) = part.get(key) { file.insert(key.to_string(), value.clone()); } } Some(Value::Object(file)) } fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContext) { match value { Value::Array(items) => { for item in items { collect_tool_search_output_tools(item, context); } } Value::Object(obj) => { if obj.get("type").and_then(|v| v.as_str()) == Some("tool_search_output") { if let Some(tools) = obj.get("tools").and_then(|v| v.as_array()) { for tool in tools { context.add_response_tool(tool); } } } for value in obj.values() { collect_tool_search_output_tools(value, context); } } _ => {} } } fn flatten_namespace_tool_name(namespace: &str, name: &str) -> String { let full_name = format!("{namespace}__{name}"); if full_name.len() <= CHAT_TOOL_NAME_MAX_LEN { return full_name; } let hash = short_sha256_hex(full_name.as_bytes()); let suffix = format!("__{hash}"); let prefix_len = CHAT_TOOL_NAME_MAX_LEN.saturating_sub(suffix.len()); let mut prefix = String::new(); for ch in full_name.chars() { if prefix.len() + ch.len_utf8() > prefix_len { break; } prefix.push(ch); } format!("{prefix}{suffix}") } fn responses_tool_name(tool: &Value) -> Option { tool.get("function") .and_then(|function| function.get("name")) .or_else(|| tool.get("name")) .and_then(|v| v.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) } fn responses_custom_tool_description(tool: &Value) -> String { let mut description = String::new(); description.push_str(CUSTOM_TOOL_PRESERVED_METADATA_HEADING); description.push_str("\n```json\n"); description.push_str(&serialize_tool_definition_for_description(tool)); description.push_str("\n```"); description } fn serialize_tool_definition_for_description(tool: &Value) -> String { // Keep the embedded definition compact to reduce tool-description token // overhead for chat-only upstreams, while remaining stable across map // storage order. canonical_json_string(tool) } fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option { if tool.get("type").and_then(|v| v.as_str()) != Some("function") { return None; } if let Some(function) = tool.get("function") { let mut chat_tool = json!({ "type": "function", "function": function.clone() }); if let Some(obj) = chat_tool .get_mut("function") .and_then(|value| value.as_object_mut()) { obj.insert("name".to_string(), json!(chat_name)); if let Some(strict) = tool.get("strict").cloned() { obj.entry("strict".to_string()).or_insert(strict); } } return Some(chat_tool); } let mut function = json!({ "name": chat_name, "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_function_call_to_chat_tool_call( item: &Value, tool_context: &CodexToolContext, ) -> 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 namespace = item.get("namespace").and_then(|v| v.as_str()); let chat_name = tool_context.chat_name_for_response_function(name, namespace); let arguments = canonicalize_tool_arguments(item.get("arguments")); json!({ "id": call_id, "type": "function", "function": { "name": chat_name, "arguments": arguments } }) } fn responses_custom_tool_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 input = item.get("input").cloned().unwrap_or_else(|| json!("")); json!({ "id": call_id, "type": "function", "function": { "name": name, "arguments": canonical_json_string(&json!({ CUSTOM_TOOL_INPUT_FIELD: input })) } }) } fn responses_tool_search_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 arguments = item .get("arguments") .map(canonical_json_string) .unwrap_or_else(|| "{}".to_string()); json!({ "id": call_id, "type": "function", "function": { "name": TOOL_SEARCH_PROXY_NAME, "arguments": arguments } }) } fn responses_tool_choice_to_chat(tool_choice: &Value, tool_context: &CodexToolContext) -> Value { match tool_choice { Value::Object(obj) if obj.get("type").and_then(|v| v.as_str()) == Some("function") => { let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or(""); let namespace = obj.get("namespace").and_then(|v| v.as_str()); let chat_name = tool_context.chat_name_for_response_function(name, namespace); json!({ "type": "function", "function": { "name": chat_name } }) } Value::Object(obj) if obj.get("type").and_then(|v| v.as_str()) == Some("tool_search") => { json!({ "type": "function", "function": { "name": TOOL_SEARCH_PROXY_NAME } }) } Value::Object(obj) if obj.get("type").and_then(|v| v.as_str()) == Some("custom") => { let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or(""); json!({ "type": "function", "function": { "name": name } }) } _ => tool_choice.clone(), } } /// Convert a non-streaming Chat Completions response into a Responses response. #[allow(dead_code)] pub fn chat_completion_to_response(body: Value) -> Result { chat_completion_to_response_with_context(body, &CodexToolContext::default()) } /// Convert a non-streaming Chat Completions response into a Responses response, /// restoring Codex-specific tool names using the original Responses request. pub(crate) fn chat_completion_to_response_with_context( body: Value, tool_context: &CodexToolContext, ) -> 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(), tool_context, )); 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>, tool_context: &CodexToolContext, ) -> 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() { // Skip tool calls with missing function names (defensive: some models // may generate tool calls without providing a valid name) let function = tool_call.get("function").unwrap_or(&Value::Null); let name = function.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name.is_empty() { log::warn!("[Codex] Skipping tool call with missing name"); continue; } output.push(chat_tool_call_to_response_item( tool_call, index, reasoning, tool_context, )); } } else if let Some(function_call) = message.get("function_call") { if let Some(item) = chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context) { output.push(item); } } output } fn chat_tool_call_to_response_item( tool_call: &Value, index: usize, reasoning: Option<&str>, tool_context: &CodexToolContext, ) -> 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 = canonicalize_tool_arguments(function.get("arguments")); let item_id = response_tool_call_item_id_from_chat_name(&call_id, name, tool_context); response_tool_call_item_from_chat_name( &item_id, "completed", &call_id, name, &arguments, reasoning, tool_context, ) } fn chat_legacy_function_call_to_response_item( function_call: &Value, reasoning: Option<&str>, tool_context: &CodexToolContext, ) -> Option { 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(""); // Skip legacy function calls with missing names (defensive: some models // may generate function_call without providing a valid name) if name.is_empty() { log::warn!("[Codex] Skipping legacy function_call with missing name"); return None; } let arguments = canonicalize_tool_arguments(function_call.get("arguments")); let item_id = response_tool_call_item_id_from_chat_name(call_id, name, tool_context); Some(response_tool_call_item_from_chat_name( &item_id, "completed", call_id, name, &arguments, reasoning, tool_context, )) } pub(crate) fn response_tool_call_item_id_from_chat_name( call_id: &str, chat_name: &str, tool_context: &CodexToolContext, ) -> String { if tool_context.is_custom_tool_chat_name(chat_name) { format!("ctc_{call_id}") } else { format!("fc_{call_id}") } } pub(crate) fn response_tool_call_item_from_chat_name( item_id: &str, status: &str, call_id: &str, chat_name: &str, arguments: &str, reasoning: Option<&str>, tool_context: &CodexToolContext, ) -> Value { match tool_context.lookup_chat_name(chat_name) { Some(spec) if spec.kind == CodexToolKind::ToolSearch => { response_tool_search_call_item(call_id, status, arguments, reasoning) } Some(spec) if spec.kind == CodexToolKind::Custom => response_custom_tool_call_item( item_id, status, call_id, &spec.name, arguments, reasoning, ), Some(spec) => response_function_call_item_with_namespace( item_id, status, call_id, &spec.name, spec.namespace.as_deref(), arguments, reasoning, ), None => { response_function_call_item(item_id, status, call_id, chat_name, arguments, reasoning) } } } fn response_tool_search_call_item( call_id: &str, status: &str, arguments: &str, reasoning: Option<&str>, ) -> Value { let parsed_arguments = parse_tool_arguments_object(arguments); let mut item = json!({ "type": "tool_search_call", "call_id": call_id, "status": status, "execution": "client", "arguments": parsed_arguments }); super::codex_chat_common::attach_optional_reasoning_content_field(&mut item, reasoning); item } fn response_custom_tool_call_item( item_id: &str, status: &str, call_id: &str, name: &str, arguments: &str, reasoning: Option<&str>, ) -> Value { let input = custom_tool_input_from_chat_arguments(arguments); let mut item = json!({ "id": item_id, "type": "custom_tool_call", "status": status, "call_id": call_id, "name": name, "input": input }); super::codex_chat_common::attach_optional_reasoning_content_field(&mut item, reasoning); item } fn parse_tool_arguments_object(arguments: &str) -> Value { if arguments.trim().is_empty() { return json!({}); } serde_json::from_str::(arguments) .ok() .filter(|value| value.is_object()) .unwrap_or_else(|| json!({ "query": arguments })) } pub(crate) fn custom_tool_input_from_chat_arguments(arguments: &str) -> String { if arguments.trim().is_empty() { return String::new(); } match serde_json::from_str::(arguments) { Ok(Value::Object(obj)) => obj .get(CUSTOM_TOOL_INPUT_FIELD) .and_then(|value| value.as_str()) .unwrap_or(arguments) .to_string(), _ => arguments.to_string(), } } 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, "output_tokens_details": { "reasoning_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 }); let cached = usage .pointer("/prompt_tokens_details/cached_tokens") .or_else(|| usage.pointer("/input_tokens_details/cached_tokens")) .and_then(|v| v.as_u64()) .unwrap_or(0); let cache_write = usage .pointer("/prompt_tokens_details/cache_write_tokens") .or_else(|| usage.pointer("/input_tokens_details/cache_write_tokens")) .and_then(|v| v.as_u64()) .or_else(|| { usage .get("cache_creation_input_tokens") .and_then(|v| v.as_u64()) }) .unwrap_or(0); if cached > 0 || cache_write > 0 { result["input_tokens_details"] = json!({ "cached_tokens": cached, "cache_write_tokens": cache_write }); } if let Some(details) = usage .get("completion_tokens_details") .filter(|v| v.is_object()) { let mut details = details.clone(); if details.get("reasoning_tokens").is_none() { details["reasoning_tokens"] = json!(0); } result["output_tokens_details"] = details; } else { result["output_tokens_details"] = json!({ "reasoning_tokens": 0 }); } if let Some(cache_read) = usage.get("cache_read_input_tokens") { result["cache_read_input_tokens"] = cache_read.clone(); } if cache_write > 0 { result["cache_creation_input_tokens"] = json!(cache_write); } 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", } } /// 把 Chat Completions 上游的错误体规整成 OpenAI Responses API 风格的错误对象。 /// /// 兼容三类输入: /// 1. 标准 OpenAI 形式 `{"error": {"message": "...", "type": "...", "code": ...}}` /// 2. MiniMax 等非标形式(如 `{"base_resp": {"status_code": 2013, "status_msg": "..."}}`) /// 3. 顶层只有 `message` / `detail` / 裸字符串的最小错误 /// /// 输出统一为 `{"error": {"message", "type", "code", "param"}}`,与 OpenAI Responses /// API 错误响应一致;Codex 客户端的错误处理只识别这个形状。 pub fn chat_error_to_response_error(body: Option<&Value>) -> Value { let Some(value) = body else { return json!({ "error": { "message": "Upstream returned an empty error response", "type": "upstream_error", "code": serde_json::Value::Null, "param": serde_json::Value::Null, } }); }; if let Some(text) = value.as_str() { return json!({ "error": { "message": text, "type": "upstream_error", "code": serde_json::Value::Null, "param": serde_json::Value::Null, } }); } let source = value.get("error").unwrap_or(value); let message = source .get("message") .or_else(|| source.get("detail")) .or_else(|| source.get("status_msg")) .or_else(|| source.pointer("/base_resp/status_msg")) .and_then(|v| v.as_str()) .map(ToString::to_string) .or_else(|| source.as_str().map(ToString::to_string)) .unwrap_or_else(|| { // 没法从字段提取出文本,就把整个 JSON 序列化回去,方便用户排查。 serde_json::to_string(source).unwrap_or_else(|_| "Upstream error".to_string()) }); let error_type = source .get("type") .and_then(|v| v.as_str()) .map(ToString::to_string) .unwrap_or_else(|| "upstream_error".to_string()); let code = source .get("code") .cloned() .or_else(|| source.pointer("/base_resp/status_code").cloned()) .unwrap_or(serde_json::Value::Null); let param = source .get("param") .cloned() .unwrap_or(serde_json::Value::Null); json!({ "error": { "message": message, "type": error_type, "code": code, "param": param, } }) } #[cfg(test)] mod tests { use super::*; #[test] fn responses_request_with_stream_injects_include_usage() { let input = json!({ "model": "kimi-k2.6", "input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}], "stream": true }); let result = responses_to_chat_completions(input).unwrap(); assert_eq!(result["stream"], true); assert_eq!(result["stream_options"]["include_usage"], true); } #[test] fn responses_request_without_stream_omits_stream_options() { let input = json!({ "model": "kimi-k2.6", "input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}] }); let result = responses_to_chat_completions(input).unwrap(); assert!(result.get("stream_options").is_none()); } #[test] fn responses_request_merges_include_usage_into_existing_stream_options() { let input = json!({ "model": "kimi-k2.6", "input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}], "stream": true, "stream_options": {"continuous_usage_stats": true} }); let result = responses_to_chat_completions(input).unwrap(); // 既补上 include_usage,又保留客户端原有的 stream_options 字段。 assert_eq!(result["stream_options"]["include_usage"], true); assert_eq!(result["stream_options"]["continuous_usage_stats"], true); } #[test] fn responses_request_maps_input_file_content_parts() { let input = json!({ "model": "gpt-5.4", "input": [{ "role": "user", "content": [ {"type": "input_text", "text": "Summarize this."}, { "type": "input_file", "file_id": "file_123", "file_url": "https://example.com/spec.pdf", "filename": "spec.pdf" }, { "type": "input_audio", "input_audio": { "data": "UklGRg==", "format": "wav" } } ] }] }); let result = responses_to_chat_completions(input).unwrap(); let content = result["messages"][0]["content"].as_array().unwrap(); assert_eq!(content[0]["type"], "text"); assert_eq!(content[1]["type"], "file"); assert_eq!(content[1]["file"]["file_id"], "file_123"); assert!(content[1]["file"].get("file_url").is_none()); assert_eq!(content[1]["file"]["filename"], "spec.pdf"); assert_eq!(content[2]["type"], "input_audio"); assert_eq!(content[2]["input_audio"]["format"], "wav"); } #[test] fn responses_request_does_not_emit_chat_file_for_url_only_input_file() { let input = json!({ "model": "gpt-5.4", "input": [{ "role": "user", "content": [ {"type": "input_text", "text": "Summarize this URL file."}, { "type": "input_file", "file_url": "https://example.com/spec.pdf" } ] }] }); let result = responses_to_chat_completions(input).unwrap(); assert_eq!(result["messages"][0]["content"], "Summarize this URL file."); } #[test] fn responses_request_maps_top_level_input_file_item() { let input = json!({ "model": "gpt-5.4", "input": [ { "type": "input_file", "file_id": "file_top", "filename": "top.pdf" } ] }); let result = responses_to_chat_completions(input).unwrap(); let content = result["messages"][0]["content"].as_array().unwrap(); assert_eq!(result["messages"][0]["role"], "user"); assert_eq!(content[0]["type"], "file"); assert_eq!(content[0]["file"]["file_id"], "file_top"); assert_eq!(content[0]["file"]["filename"], "top.pdf"); } #[test] fn top_level_user_content_part_clears_pending_reasoning() { let input = json!({ "model": "gpt-5.4", "input": [ { "type": "reasoning", "summary": [{"text": "stale reasoning"}] }, { "type": "input_text", "text": "Please run the tool." }, { "type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}" } ], "tools": [{ "type": "function", "name": "lookup", "parameters": {"type": "object"} }] }); let result = responses_to_chat_completions(input).unwrap(); let messages = result["messages"].as_array().unwrap(); assert_eq!(messages[0]["role"], "user"); assert_eq!(messages[0]["content"], "Please run the tool."); assert_eq!(messages[1]["role"], "assistant"); assert_eq!(messages[1]["reasoning_content"], "tool call"); } #[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_exposes_tool_search_and_loaded_namespace_tools() { let input = json!({ "model": "gpt-5.4", "tools": [{"type": "tool_search"}], "input": [ { "type": "tool_search_call", "call_id": "call_tool_search_1", "status": "completed", "execution": "client", "arguments": {"query": "Gmail search emails", "limit": 5} }, { "type": "tool_search_output", "call_id": "call_tool_search_1", "status": "completed", "execution": "client", "tools": [{ "type": "namespace", "name": "mcp__codex_apps__gmail", "description": "Find and reference emails from your inbox.", "tools": [{ "type": "function", "name": "_search_emails", "description": "Search Gmail for emails matching a query.", "strict": false, "parameters": { "type": "object", "properties": { "query": {"type": "string"}, "max_results": {"type": "integer"} }, "required": ["query"] } }] }] }, { "type": "message", "role": "user", "content": "Search unread inbox mail." } ] }); let result = responses_to_chat_completions(input).unwrap(); let tools = result["tools"].as_array().unwrap(); let tool_names = tools .iter() .filter_map(|tool| tool.pointer("/function/name").and_then(|v| v.as_str())) .collect::>(); assert!(tool_names.contains(&"tool_search")); assert!(tool_names.contains(&"mcp__codex_apps__gmail___search_emails")); assert_eq!( result["messages"][0]["tool_calls"][0]["function"]["name"], "tool_search" ); assert_eq!(result["messages"][1]["role"], "tool"); assert_eq!(result["messages"][1]["tool_call_id"], "call_tool_search_1"); assert!(result["messages"][1]["content"] .as_str() .unwrap() .contains("mcp__codex_apps__gmail")); } #[test] fn responses_request_to_chat_maps_custom_tool_and_choice() { let input = json!({ "model": "gpt-5.4", "tools": [{ "type": "custom", "name": "apply_patch", "description": "Apply a patch to files." }], "tool_choice": {"type": "custom", "name": "apply_patch"}, "input": [{ "type": "custom_tool_call", "id": "ctc_1", "call_id": "call_patch", "name": "apply_patch", "input": "*** Begin Patch\n*** End Patch" }] }); let result = responses_to_chat_completions(input).unwrap(); assert_eq!(result["tools"][0]["function"]["name"], "apply_patch"); assert_eq!( result["tools"][0]["function"]["parameters"]["required"][0], "input" ); assert_eq!(result["tool_choice"]["function"]["name"], "apply_patch"); assert_eq!( result["messages"][0]["tool_calls"][0]["function"]["arguments"], r#"{"input":"*** Begin Patch\n*** End Patch"}"# ); } #[test] fn responses_request_to_chat_preserves_custom_tool_metadata_in_description() { let input = json!({ "model": "gpt-5.4", "tools": [{ "type": "custom", "name": "apply_patch", "description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.", "format": { "type": "grammar", "syntax": "lark", "definition": "start: begin_patch hunk+ end_patch" } }] }); let result = responses_to_chat_completions(input).unwrap(); let description = result["tools"][0]["function"]["description"] .as_str() .unwrap(); assert!(description.starts_with("Original tool definition:")); assert!(!description.contains("Original Codex tool definition")); assert!(description.contains("\"type\":\"custom\"")); assert!(description.contains("\"format\":")); assert!(description.contains("\"syntax\":\"lark\"")); } #[test] fn responses_request_to_chat_uses_provider_reasoning_effort_for_deepseek_model() { let input = json!({ "model": "deepseek-v4-pro", "input": "hello", "reasoning": {"effort": "xhigh"} }); let config = CodexChatReasoningConfig { supports_thinking: Some(true), supports_effort: Some(true), thinking_param: Some("thinking".to_string()), effort_param: Some("reasoning_effort".to_string()), effort_value_mode: Some("deepseek".to_string()), output_format: Some("reasoning_content".to_string()), }; let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); assert_eq!(result["thinking"]["type"], "enabled"); assert_eq!(result["reasoning_effort"], "max"); } #[test] fn responses_request_to_chat_maps_openrouter_to_native_reasoning_object() { // OpenRouter 平台形态:原生 reasoning:{effort} 对象 + "openrouter" 值映射 // (与 infer_aggregator_platform_config 推断出的配置保持一致)。 let config = CodexChatReasoningConfig { supports_thinking: Some(false), supports_effort: Some(true), thinking_param: Some("none".to_string()), effort_param: Some("reasoning.effort".to_string()), effort_value_mode: Some("openrouter".to_string()), output_format: Some("auto".to_string()), }; // max 不在 OpenRouter 枚举内(见 openclaw#77350),必须钳成 xhigh, // 且写进原生 reasoning 对象,而非顶层 reasoning_effort 别名。 let input = json!({ "model": "deepseek/deepseek-chat-v3.1", "input": "hello", "reasoning": {"effort": "max"} }); let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); assert_eq!(result["reasoning"]["effort"], "xhigh"); assert!(result.get("reasoning_effort").is_none()); // thinking_param=none:即使 supports_effort 把 supports_thinking 带成 true, // 也不写任何 thinking 字段(OpenRouter 不认 thinking:{type})。 assert!(result.get("thinking").is_none()); // 合法档位原样透传。 let input_high = json!({ "model": "deepseek/deepseek-chat-v3.1", "input": "hello", "reasoning": {"effort": "high"} }); let result_high = responses_to_chat_completions_with_reasoning(input_high, Some(&config)).unwrap(); assert_eq!(result_high["reasoning"]["effort"], "high"); assert!(result_high.get("reasoning_effort").is_none()); } #[test] fn responses_request_to_chat_passes_explicit_none_through_for_openrouter() { // OpenRouter 原生 reasoning 对象支持显式关闭:effort=none 应忠实转发为 // {"reasoning":{"effort":"none"}},而非被吞掉——否则默认开思考的模型无法关闭, // 带来行为与成本偏差。 let config = CodexChatReasoningConfig { supports_thinking: Some(false), supports_effort: Some(true), thinking_param: Some("none".to_string()), effort_param: Some("reasoning.effort".to_string()), effort_value_mode: Some("openrouter".to_string()), output_format: Some("auto".to_string()), }; let input = json!({ "model": "openai/gpt-5", "input": "hello", "reasoning": {"effort": "none"} }); let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); assert_eq!(result["reasoning"]["effort"], "none"); // none 不是 OpenAI 顶层 reasoning_effort 的合法枚举,不写顶层别名;也不写 thinking。 assert!(result.get("reasoning_effort").is_none()); assert!(result.get("thinking").is_none()); } #[test] fn responses_request_to_chat_drops_explicit_none_for_top_level_effort_provider() { // 对照:顶层 reasoning_effort 平台(DeepSeek/OpenAI 风格)的 effort 枚举不含 none, // 显式 none 不应透传成 reasoning_effort:"none"(会被上游拒),仅走 thinking 关闭路径。 // 锁定「none 透传仅限 reasoning.effort 形态」的边界,防止回归。 let config = CodexChatReasoningConfig { supports_thinking: Some(true), supports_effort: Some(true), thinking_param: Some("thinking".to_string()), effort_param: Some("reasoning_effort".to_string()), effort_value_mode: Some("deepseek".to_string()), output_format: Some("reasoning_content".to_string()), }; let input = json!({ "model": "deepseek-v4-pro", "input": "hello", "reasoning": {"effort": "none"} }); let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); // thinking 关闭信号照发;但不写 reasoning_effort,也不写原生 reasoning 对象。 assert_eq!(result["thinking"]["type"], "disabled"); assert!(result.get("reasoning_effort").is_none()); assert!(result.get("reasoning").is_none()); } #[test] fn responses_request_to_chat_maps_thinking_only_provider_without_effort() { let input = json!({ "model": "kimi-k2.6", "input": "hello", "reasoning": {"effort": "high"} }); let config = CodexChatReasoningConfig { supports_thinking: Some(true), supports_effort: Some(false), thinking_param: Some("thinking".to_string()), effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), }; let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); assert_eq!(result["thinking"]["type"], "enabled"); assert!(result.get("reasoning_effort").is_none()); } #[test] fn responses_request_to_chat_maps_enable_thinking_provider() { let input = json!({ "model": "qwen3-max", "input": "hello", "reasoning": {"effort": "medium"} }); let config = CodexChatReasoningConfig { supports_thinking: Some(true), supports_effort: Some(false), thinking_param: Some("enable_thinking".to_string()), effort_param: Some("none".to_string()), effort_value_mode: None, output_format: Some("reasoning_content".to_string()), }; let result = responses_to_chat_completions_with_reasoning(input, Some(&config)).unwrap(); assert_eq!(result["enable_thinking"], true); assert!(result.get("reasoning_effort").is_none()); } #[test] fn chat_response_to_responses_extracts_reasoning_details() { let input = json!({ "id": "chatcmpl_minimax", "object": "chat.completion", "created": 123, "model": "MiniMax-M2.7", "choices": [{ "message": { "role": "assistant", "reasoning_details": [ {"type": "reasoning_text", "text": "Need to inspect the code."} ], "content": "Done" }, "finish_reason": "stop" }] }); let result = chat_completion_to_response(input).unwrap(); assert_eq!(result["output"][0]["type"], "reasoning"); assert_eq!( result["output"][0]["summary"][0]["text"], "Need to inspect the code." ); assert_eq!(result["output"][1]["content"][0]["text"], "Done"); } #[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_merges_mid_stream_system_into_head() { let input = json!({ "model": "MiniMax-M2.7", "instructions": "You are Codex.", "input": [ {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "Permissions block"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "AGENTS.md"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "你好"}]}, {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "Collaboration Mode: Default"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "你好"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "你好"}]} ] }); let result = responses_to_chat_completions(input).unwrap(); let messages = result["messages"].as_array().unwrap(); for (idx, msg) in messages.iter().enumerate() { let role = msg.get("role").and_then(|v| v.as_str()).unwrap(); if idx == 0 { assert_eq!(role, "system", "first message must be system"); } else { assert_ne!( role, "system", "no system role allowed past index 0 (got at {idx})" ); } } let head_content = messages[0]["content"].as_str().unwrap(); assert!(head_content.contains("You are Codex.")); assert!(head_content.contains("Permissions block")); assert!(head_content.contains("Collaboration Mode: Default")); } #[test] fn collapse_system_messages_preserves_non_system_order() { let input = vec![ json!({"role": "system", "content": "S1"}), json!({"role": "user", "content": "U1"}), json!({"role": "assistant", "content": "A1"}), json!({"role": "system", "content": "S2"}), json!({"role": "user", "content": "U2"}), ]; let out = collapse_system_messages_to_head(input); assert_eq!(out.len(), 4); assert_eq!(out[0]["role"], "system"); assert_eq!(out[0]["content"], "S1\n\nS2"); assert_eq!(out[1]["content"], "U1"); assert_eq!(out[2]["content"], "A1"); assert_eq!(out[3]["content"], "U2"); } #[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_injects_placeholder_reasoning_for_bare_tool_call() { // 历史恢复 miss 时,带 tool_calls 的 assistant 消息没有任何可用 reasoning, // 必须补占位,否则 kimi/Moonshot thinking 模型会拒绝整个请求。 let input = json!({ "model": "kimi-k2-thinking", "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" } ] }); 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"], "tool call"); 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, "cache_write_tokens": 2} } }); 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); assert_eq!( result["usage"]["input_tokens_details"]["cache_write_tokens"], 2 ); } #[test] fn chat_response_to_responses_restores_loaded_namespace_tool_call() { let request = json!({ "model": "gpt-5.4", "tools": [{"type": "tool_search"}], "input": [{ "type": "tool_search_output", "call_id": "call_tool_search_1", "status": "completed", "execution": "client", "tools": [{ "type": "namespace", "name": "mcp__codex_apps__gmail", "description": "Find and reference emails from your inbox.", "tools": [{ "type": "function", "name": "_search_emails", "description": "Search Gmail for emails matching a query.", "parameters": { "type": "object", "properties": { "query": {"type": "string"}, "label_ids": {"type": "array", "items": {"type": "string"}}, "max_results": {"type": "integer"} } } }] }] }] }); let context = build_codex_tool_context_from_request(&request); let chat = json!({ "id": "chatcmpl_gmail", "object": "chat.completion", "created": 123, "model": "gpt-5.4", "choices": [{ "message": { "role": "assistant", "tool_calls": [{ "id": "call_gmail", "type": "function", "function": { "name": "mcp__codex_apps__gmail___search_emails", "arguments": "{\"query\":\"-in:spam -in:trash\",\"label_ids\":[\"UNREAD\"],\"max_results\":5}" } }] }, "finish_reason": "tool_calls" }] }); let result = chat_completion_to_response_with_context(chat, &context).unwrap(); assert_eq!(result["output"][0]["type"], "function_call"); assert_eq!(result["output"][0]["call_id"], "call_gmail"); assert_eq!(result["output"][0]["namespace"], "mcp__codex_apps__gmail"); assert_eq!(result["output"][0]["name"], "_search_emails"); assert_eq!( result["output"][0]["arguments"], r#"{"label_ids":["UNREAD"],"max_results":5,"query":"-in:spam -in:trash"}"# ); } #[test] fn chat_response_to_responses_restores_tool_search_call() { let request = json!({ "model": "gpt-5.4", "tools": [{"type": "tool_search"}], "input": "Find tools." }); let context = build_codex_tool_context_from_request(&request); let chat = json!({ "id": "chatcmpl_tool_search", "object": "chat.completion", "created": 123, "model": "gpt-5.4", "choices": [{ "message": { "role": "assistant", "tool_calls": [{ "id": "call_tool_search_1", "type": "function", "function": { "name": "tool_search", "arguments": "{\"query\":\"Gmail search emails\",\"limit\":10}" } }] }, "finish_reason": "tool_calls" }] }); let result = chat_completion_to_response_with_context(chat, &context).unwrap(); assert_eq!(result["output"][0]["type"], "tool_search_call"); assert_eq!(result["output"][0]["call_id"], "call_tool_search_1"); assert_eq!(result["output"][0]["execution"], "client"); assert_eq!( result["output"][0]["arguments"]["query"], "Gmail search emails" ); assert_eq!(result["output"][0]["arguments"]["limit"], 10); } #[test] fn chat_response_to_responses_restores_custom_tool_call() { let request = json!({ "model": "gpt-5.4", "tools": [{"type": "custom", "name": "apply_patch"}], "input": "Patch it." }); let context = build_codex_tool_context_from_request(&request); let chat = json!({ "id": "chatcmpl_custom", "object": "chat.completion", "created": 123, "model": "gpt-5.4", "choices": [{ "message": { "role": "assistant", "tool_calls": [{ "id": "call_patch", "type": "function", "function": { "name": "apply_patch", "arguments": "{\"input\":\"*** Begin Patch\\n*** End Patch\"}" } }] }, "finish_reason": "tool_calls" }] }); let result = chat_completion_to_response_with_context(chat, &context).unwrap(); assert_eq!(result["output"][0]["type"], "custom_tool_call"); assert_eq!(result["output"][0]["id"], "ctc_call_patch"); assert_eq!(result["output"][0]["call_id"], "call_patch"); assert_eq!(result["output"][0]["name"], "apply_patch"); assert_eq!( result["output"][0]["input"], "*** Begin Patch\n*** End Patch" ); } #[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"); } #[test] fn chat_error_to_response_error_normalizes_standard_openai_shape() { let input = json!({ "error": { "message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key", "param": "api_key" } }); let result = chat_error_to_response_error(Some(&input)); assert_eq!(result["error"]["message"], "Invalid API key"); assert_eq!(result["error"]["type"], "invalid_request_error"); assert_eq!(result["error"]["code"], "invalid_api_key"); assert_eq!(result["error"]["param"], "api_key"); } #[test] fn chat_error_to_response_error_normalizes_minimax_base_resp() { // MiniMax 把错误塞在 base_resp 里,code 是数字而不是字符串 let input = json!({ "base_resp": { "status_code": 2013, "status_msg": "invalid params, chat content has invalid message role: system" } }); let result = chat_error_to_response_error(Some(&input)); assert_eq!( result["error"]["message"], "invalid params, chat content has invalid message role: system" ); assert_eq!(result["error"]["code"], 2013); // type 没有显式给出,应该回落到 upstream_error assert_eq!(result["error"]["type"], "upstream_error"); } #[test] fn chat_error_to_response_error_handles_plain_text_body() { let input = json!("Upstream timeout"); let result = chat_error_to_response_error(Some(&input)); assert_eq!(result["error"]["message"], "Upstream timeout"); assert_eq!(result["error"]["type"], "upstream_error"); assert!(result["error"]["code"].is_null()); assert!(result["error"]["param"].is_null()); } #[test] fn chat_error_to_response_error_handles_missing_body() { let result = chat_error_to_response_error(None); assert_eq!( result["error"]["message"], "Upstream returned an empty error response" ); assert_eq!(result["error"]["type"], "upstream_error"); } #[test] fn chat_error_to_response_error_falls_back_to_detail_field() { // 部分中转把错误塞在顶层 detail 字段(OpenAI 兼容层常见) let input = json!({ "detail": "rate limit exceeded" }); let result = chat_error_to_response_error(Some(&input)); assert_eq!(result["error"]["message"], "rate limit exceeded"); assert_eq!(result["error"]["type"], "upstream_error"); } // Regression tests for tool_choice without tools guard // https://github.com/farion1231/cc-switch/issues/3557 #[test] fn responses_request_to_chat_drops_tool_choice_when_no_tools() { // When tools is absent from the request, tool_choice must be dropped // to avoid 503/400 from strict OpenAI-compatible upstreams. let input = json!({ "model": "qwen3-7-max", "tool_choice": "auto", "input": "hi" }); let result = responses_to_chat_completions(input).unwrap(); assert!( result.get("tool_choice").is_none(), "tool_choice should be dropped when tools is absent" ); assert!(result.get("tools").is_none(), "tools should be absent"); assert_eq!(result["model"], "qwen3-7-max"); } #[test] fn responses_request_to_chat_drops_tool_choice_when_tools_empty_array() { // When tools is an empty array, tool_choice must be dropped. let input = json!({ "model": "gpt-5.4", "tools": [], "tool_choice": "auto", "input": "hi" }); let result = responses_to_chat_completions(input).unwrap(); assert!( result.get("tool_choice").is_none(), "tool_choice should be dropped when tools is empty" ); assert!( result.get("tools").is_none(), "tools should be absent when input tools was empty" ); } #[test] fn responses_request_to_chat_drops_parallel_tool_calls_when_no_tools() { // parallel_tool_calls must also be dropped when tools is absent, // as it is part of EXTRA_CHAT_PASSTHROUGH_FIELDS. let input = json!({ "model": "gpt-5.4", "tool_choice": "auto", "parallel_tool_calls": true, "input": "hi" }); let result = responses_to_chat_completions(input).unwrap(); assert!( result.get("tool_choice").is_none(), "tool_choice should be dropped" ); assert!( result.get("parallel_tool_calls").is_none(), "parallel_tool_calls should be dropped" ); assert!(result.get("tools").is_none(), "tools should be absent"); } #[test] fn responses_request_to_chat_drops_tool_choice_when_all_tools_filtered() { // When all tools are filtered out (e.g., missing name), tool_choice must be dropped. let input = json!({ "model": "gpt-5.4", "tools": [ {"type": "function"} ], "tool_choice": "auto", "input": "hi" }); let result = responses_to_chat_completions(input).unwrap(); assert!( result.get("tool_choice").is_none(), "tool_choice should be dropped when all tools filtered" ); assert!( result.get("tools").is_none(), "tools should be absent when all filtered" ); } #[test] fn responses_request_to_chat_keeps_tool_choice_when_tools_present() { // When tools is present and non-empty, tool_choice must be preserved. let input = json!({ "model": "gpt-5.4", "tools": [{ "type": "function", "name": "get_weather", "description": "Get weather", "parameters": {"type": "object"} }], "tool_choice": "auto", "parallel_tool_calls": true, "input": "hi" }); let result = responses_to_chat_completions(input).unwrap(); assert!( result.get("tool_choice").is_some(), "tool_choice should be kept when tools present" ); assert_eq!(result["tool_choice"], "auto"); assert!( result.get("parallel_tool_calls").is_some(), "parallel_tool_calls should be kept" ); assert_eq!(result["parallel_tool_calls"], true); assert!( result .get("tools") .is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty())), "tools should be present" ); assert_eq!(result["tools"][0]["function"]["name"], "get_weather"); } #[test] fn responses_request_to_chat_keeps_tool_choice_function_when_tools_present() { // When tools is present, function-type tool_choice must be preserved and mapped. let input = json!({ "model": "gpt-5.4", "tools": [{ "type": "function", "name": "get_weather", "description": "Get weather", "parameters": {"type": "object"} }], "tool_choice": {"type": "function", "name": "get_weather"}, "input": "hi" }); let result = responses_to_chat_completions(input).unwrap(); assert!( result.get("tool_choice").is_some(), "tool_choice should be kept" ); assert_eq!(result["tool_choice"]["type"], "function"); assert_eq!(result["tool_choice"]["function"]["name"], "get_weather"); } #[test] fn responses_request_to_chat_no_tool_choice_no_tools_stays_clean() { // When neither tool_choice nor tools are present, the output should be clean. let input = json!({ "model": "gpt-5.4", "input": "hi" }); let result = responses_to_chat_completions(input).unwrap(); assert!( result.get("tool_choice").is_none(), "tool_choice should be absent" ); assert!(result.get("tools").is_none(), "tools should be absent"); assert!( result.get("parallel_tool_calls").is_none(), "parallel_tool_calls should be absent" ); } #[test] fn responses_request_to_chat_tool_choice_none_dropped_when_no_tools() { // Even tool_choice: "none" should be dropped when tools is absent, // because strict upstreams reject the combination regardless of value. let input = json!({ "model": "gpt-5.4", "tool_choice": "none", "input": "hi" }); let result = responses_to_chat_completions(input).unwrap(); assert!( result.get("tool_choice").is_none(), "tool_choice 'none' should be dropped when no tools" ); } #[test] fn responses_request_to_chat_tool_search_output_provides_tools_keeps_tool_choice() { // When tool_search_output in input provides tools, tool_choice should be kept. let input = json!({ "model": "gpt-5.4", "tool_choice": "auto", "input": [{ "type": "tool_search_output", "call_id": "call_ts_1", "status": "completed", "execution": "client", "tools": [{ "type": "function", "name": "search_docs", "description": "Search documentation.", "parameters": { "type": "object", "properties": { "query": {"type": "string"} } } }] }] }); let result = responses_to_chat_completions(input).unwrap(); assert!( result.get("tool_choice").is_some(), "tool_choice should be kept when tool_search_output provides tools" ); assert_eq!(result["tool_choice"], "auto"); assert!( result .get("tools") .is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty())), "tools should be present from tool_search_output" ); assert_eq!(result["tools"][0]["function"]["name"], "search_docs"); } }