mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 14:35:22 +08:00
92930461b7
CI cargo fmt --check failed on transform_codex_chat.rs (the wrapping introduced upstream did not match rustfmt 1.95.0). Apply cargo fmt so the chat_legacy_function_call_to_response_item call uses the expected layout.
3299 lines
113 KiB
Rust
3299 lines
113 KiB
Rust
//! Codex Responses ↔ OpenAI Chat Completions conversion.
|
||
//!
|
||
//! This module is used when the Codex client talks to CC Switch through the
|
||
//! Responses API, while the selected upstream provider only exposes an
|
||
//! OpenAI-compatible Chat Completions endpoint.
|
||
|
||
use super::codex_chat_common::{
|
||
append_reasoning_content, extract_reasoning_field_text, extract_reasoning_summary_text,
|
||
response_function_call_item, 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<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
pub(crate) struct CodexToolContext {
|
||
chat_tools: Vec<Value>,
|
||
seen_chat_names: HashSet<String>,
|
||
chat_name_to_spec: HashMap<String, CodexToolSpec>,
|
||
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))
|
||
}
|
||
|
||
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<Value, ProxyError> {
|
||
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<Value, ProxyError> {
|
||
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<bool> {
|
||
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<Value>) -> Vec<Value> {
|
||
let mut system_chunks: Vec<String> = Vec::new();
|
||
let mut rest: Vec<Value> = 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<Value> = 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::<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>,
|
||
tool_context: &CodexToolContext,
|
||
) -> Result<(), ProxyError> {
|
||
let mut pending_tool_calls = Vec::new();
|
||
let mut pending_reasoning: Option<String> = None;
|
||
let mut last_assistant_index: Option<usize> = None;
|
||
|
||
match input {
|
||
Value::String(text) => {
|
||
messages.push(json!({
|
||
"role": "user",
|
||
"content": text
|
||
}));
|
||
}
|
||
Value::Array(items) => {
|
||
for item in items {
|
||
append_responses_item_as_chat_message(
|
||
item,
|
||
messages,
|
||
&mut pending_tool_calls,
|
||
&mut pending_reasoning,
|
||
&mut last_assistant_index,
|
||
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<Value>,
|
||
pending_tool_calls: &mut Vec<Value>,
|
||
pending_reasoning: &mut Option<String>,
|
||
last_assistant_index: &mut Option<usize>,
|
||
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<Value>,
|
||
pending_tool_calls: &mut Vec<Value>,
|
||
pending_reasoning: &mut Option<String>,
|
||
last_assistant_index: &mut Option<usize>,
|
||
) {
|
||
if pending_tool_calls.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let mut message = json!({
|
||
"role": "assistant",
|
||
"content": null,
|
||
"tool_calls": std::mem::take(pending_tool_calls)
|
||
});
|
||
attach_pending_reasoning_to_assistant(&mut message, pending_reasoning);
|
||
*last_assistant_index = Some(messages.len());
|
||
messages.push(message);
|
||
}
|
||
|
||
fn responses_message_item_to_chat_message(
|
||
item: &Value,
|
||
pending_reasoning: &mut Option<String>,
|
||
) -> Value {
|
||
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user");
|
||
let chat_role = responses_role_to_chat_role(role);
|
||
let content = item
|
||
.get("content")
|
||
.map(|value| responses_content_to_chat_content(chat_role, value))
|
||
.unwrap_or(Value::Null);
|
||
|
||
let mut message = json!({
|
||
"role": chat_role,
|
||
"content": content
|
||
});
|
||
|
||
if chat_role == "assistant" {
|
||
append_pending_reasoning(pending_reasoning, responses_message_reasoning_text(item));
|
||
attach_pending_reasoning_to_assistant(&mut message, pending_reasoning);
|
||
} else if pending_reasoning.is_some() {
|
||
pending_reasoning.take();
|
||
}
|
||
|
||
message
|
||
}
|
||
|
||
fn responses_role_to_chat_role(role: &str) -> &'static str {
|
||
match role {
|
||
"system" | "developer" => "system",
|
||
"assistant" => "assistant",
|
||
"tool" => "tool",
|
||
"user" | "latest_reminder" => "user",
|
||
_ => "user",
|
||
}
|
||
}
|
||
|
||
fn update_last_assistant_index(
|
||
messages: &[Value],
|
||
message: &Value,
|
||
last_assistant_index: &mut Option<usize>,
|
||
) {
|
||
match message.get("role").and_then(|v| v.as_str()) {
|
||
Some("assistant") => {
|
||
*last_assistant_index = Some(messages.len());
|
||
}
|
||
Some("tool") => {}
|
||
_ => {
|
||
*last_assistant_index = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn append_pending_reasoning(pending_reasoning: &mut Option<String>, reasoning: Option<String>) {
|
||
let Some(reasoning) = reasoning else {
|
||
return;
|
||
};
|
||
let reasoning = reasoning.trim();
|
||
if reasoning.is_empty() {
|
||
return;
|
||
}
|
||
|
||
match pending_reasoning {
|
||
Some(existing) if !existing.is_empty() => {
|
||
existing.push_str("\n\n");
|
||
existing.push_str(reasoning);
|
||
}
|
||
_ => {
|
||
*pending_reasoning = Some(reasoning.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
fn append_unique_pending_reasoning(
|
||
pending_reasoning: &mut Option<String>,
|
||
reasoning: Option<String>,
|
||
) {
|
||
let Some(reasoning) = reasoning else {
|
||
return;
|
||
};
|
||
let reasoning = reasoning.trim();
|
||
if reasoning.is_empty() {
|
||
return;
|
||
}
|
||
|
||
match pending_reasoning {
|
||
Some(existing) if existing.contains(reasoning) => {}
|
||
Some(existing) if !existing.is_empty() => {
|
||
existing.push_str("\n\n");
|
||
existing.push_str(reasoning);
|
||
}
|
||
_ => {
|
||
*pending_reasoning = Some(reasoning.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
fn attach_pending_reasoning_to_assistant(
|
||
message: &mut Value,
|
||
pending_reasoning: &mut Option<String>,
|
||
) {
|
||
let Some(reasoning) = pending_reasoning.take() else {
|
||
return;
|
||
};
|
||
if reasoning.trim().is_empty() {
|
||
return;
|
||
}
|
||
|
||
if let Some(obj) = message.as_object_mut() {
|
||
append_reasoning_content(obj, &reasoning);
|
||
}
|
||
}
|
||
|
||
/// 在所有 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<usize>,
|
||
reasoning: &Option<String>,
|
||
) -> bool {
|
||
let Some(reasoning) = reasoning
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|s| !s.is_empty())
|
||
else {
|
||
return true;
|
||
};
|
||
let Some(index) = last_assistant_index else {
|
||
return false;
|
||
};
|
||
let Some(message) = messages.get_mut(index) else {
|
||
return false;
|
||
};
|
||
if message.get("role").and_then(|v| v.as_str()) != Some("assistant") {
|
||
return false;
|
||
}
|
||
|
||
if let Some(obj) = message.as_object_mut() {
|
||
append_reasoning_content(obj, reasoning);
|
||
return true;
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
fn responses_message_reasoning_text(item: &Value) -> Option<String> {
|
||
responses_item_reasoning_text(item)
|
||
}
|
||
|
||
fn responses_item_reasoning_text(item: &Value) -> Option<String> {
|
||
extract_reasoning_field_text(item)
|
||
}
|
||
|
||
fn responses_reasoning_item_text(item: &Value) -> Option<String> {
|
||
extract_reasoning_summary_text(item)
|
||
}
|
||
|
||
fn responses_content_to_chat_content(_role: &str, content: &Value) -> Value {
|
||
if content.is_null() || content.is_string() {
|
||
return content.clone();
|
||
}
|
||
|
||
let Some(parts) = content.as_array() else {
|
||
return content.clone();
|
||
};
|
||
|
||
let mut chat_parts: Vec<Value> = Vec::new();
|
||
let mut has_non_text_part = false;
|
||
|
||
for part in parts {
|
||
let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||
match part_type {
|
||
"input_text" | "output_text" | "text" => {
|
||
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
|
||
if !text.is_empty() {
|
||
chat_parts.push(json!({
|
||
"type": "text",
|
||
"text": text
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
"refusal" => {
|
||
if let Some(text) = part.get("refusal").and_then(|v| v.as_str()) {
|
||
if !text.is_empty() {
|
||
chat_parts.push(json!({
|
||
"type": "text",
|
||
"text": text
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
"input_image" => {
|
||
if let Some(image_url) = part.get("image_url") {
|
||
let image_url = if image_url.is_object() {
|
||
image_url.clone()
|
||
} else {
|
||
json!({ "url": image_url.as_str().unwrap_or_default() })
|
||
};
|
||
chat_parts.push(json!({
|
||
"type": "image_url",
|
||
"image_url": image_url
|
||
}));
|
||
has_non_text_part = true;
|
||
}
|
||
}
|
||
"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::<Vec<_>>()
|
||
.join("\n"),
|
||
);
|
||
}
|
||
|
||
Value::Array(chat_parts)
|
||
}
|
||
|
||
fn responses_input_file_to_chat_file(part: &Value) -> Option<Value> {
|
||
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<String> {
|
||
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<Value> {
|
||
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<Value, ProxyError> {
|
||
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<Value, ProxyError> {
|
||
let choices = body
|
||
.get("choices")
|
||
.and_then(|v| v.as_array())
|
||
.ok_or_else(|| ProxyError::TransformError("No choices in chat response".to_string()))?;
|
||
let choice = choices
|
||
.first()
|
||
.ok_or_else(|| ProxyError::TransformError("Empty choices in chat response".to_string()))?;
|
||
let message = choice
|
||
.get("message")
|
||
.ok_or_else(|| ProxyError::TransformError("No message in chat choice".to_string()))?;
|
||
|
||
let response_id = response_id_from_chat_id(body.get("id").and_then(|v| v.as_str()));
|
||
let model = body.get("model").and_then(|v| v.as_str()).unwrap_or("");
|
||
let created_at = body.get("created").and_then(|v| v.as_u64()).unwrap_or(0);
|
||
let finish_reason = choice.get("finish_reason").and_then(|v| v.as_str());
|
||
|
||
let reasoning = chat_reasoning_text(message);
|
||
let mut output = Vec::new();
|
||
if let Some(reasoning_item) =
|
||
chat_reasoning_to_response_output_item(reasoning.as_deref(), &response_id)
|
||
{
|
||
output.push(reasoning_item);
|
||
}
|
||
if let Some(message_item) = chat_message_to_response_output_item(message, &response_id) {
|
||
output.push(message_item);
|
||
}
|
||
output.extend(chat_tool_calls_to_response_output_items(
|
||
message,
|
||
reasoning.as_deref(),
|
||
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<Value> {
|
||
let reasoning = reasoning?;
|
||
if reasoning.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(json!({
|
||
"id": format!("rs_{response_id}"),
|
||
"type": "reasoning",
|
||
"summary": [{
|
||
"type": "summary_text",
|
||
"text": reasoning
|
||
}]
|
||
}))
|
||
}
|
||
|
||
fn chat_reasoning_text(message: &Value) -> Option<String> {
|
||
if let Some(reasoning) = extract_reasoning_field_text(message) {
|
||
return Some(reasoning);
|
||
}
|
||
|
||
if let Some(content) = message.get("content").and_then(|v| v.as_str()) {
|
||
if let Some((reasoning, _answer)) = split_leading_think_block(content) {
|
||
if !reasoning.is_empty() {
|
||
return Some(reasoning);
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn chat_message_to_response_output_item(message: &Value, response_id: &str) -> Option<Value> {
|
||
let mut content = Vec::new();
|
||
|
||
if let Some(text) = message.get("content").and_then(|v| v.as_str()) {
|
||
let text = split_leading_think_block(text)
|
||
.map(|(_reasoning, answer)| answer)
|
||
.unwrap_or_else(|| text.to_string());
|
||
if !text.is_empty() {
|
||
content.push(json!({
|
||
"type": "output_text",
|
||
"text": text,
|
||
"annotations": []
|
||
}));
|
||
}
|
||
} else if let Some(parts) = message.get("content").and_then(|v| v.as_array()) {
|
||
for part in parts {
|
||
let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||
match part_type {
|
||
"text" | "output_text" => {
|
||
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
|
||
if !text.is_empty() {
|
||
content.push(json!({
|
||
"type": "output_text",
|
||
"text": text,
|
||
"annotations": []
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
"refusal" => {
|
||
if let Some(text) = part.get("refusal").and_then(|v| v.as_str()) {
|
||
if !text.is_empty() {
|
||
content.push(json!({
|
||
"type": "refusal",
|
||
"refusal": text
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(refusal) = message.get("refusal").and_then(|v| v.as_str()) {
|
||
if !refusal.is_empty() {
|
||
content.push(json!({
|
||
"type": "refusal",
|
||
"refusal": refusal
|
||
}));
|
||
}
|
||
}
|
||
|
||
if content.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(json!({
|
||
"id": format!("{response_id}_msg"),
|
||
"type": "message",
|
||
"status": "completed",
|
||
"role": "assistant",
|
||
"content": content
|
||
}))
|
||
}
|
||
|
||
fn chat_tool_calls_to_response_output_items(
|
||
message: &Value,
|
||
reasoning: Option<&str>,
|
||
tool_context: &CodexToolContext,
|
||
) -> 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() {
|
||
// 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<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("");
|
||
|
||
// 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::<Value>(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::<Value>(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
|
||
});
|
||
|
||
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")
|
||
.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 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",
|
||
}
|
||
}
|
||
|
||
/// 把 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::<Vec<_>>();
|
||
|
||
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}
|
||
}
|
||
});
|
||
|
||
let result = chat_completion_to_response(input).unwrap();
|
||
|
||
assert_eq!(result["id"], "resp_chatcmpl_1");
|
||
assert_eq!(result["status"], "completed");
|
||
assert_eq!(result["output"][0]["type"], "reasoning");
|
||
assert_eq!(
|
||
result["output"][0]["summary"][0]["text"],
|
||
"I should check the weather before answering."
|
||
);
|
||
assert_eq!(result["output"][1]["type"], "message");
|
||
assert_eq!(result["output"][1]["content"][0]["text"], "Let me check.");
|
||
assert_eq!(result["output"][2]["type"], "function_call");
|
||
assert_eq!(result["output"][2]["call_id"], "call_1");
|
||
assert_eq!(
|
||
result["output"][2]["reasoning_content"],
|
||
"I should check the weather before answering."
|
||
);
|
||
assert_eq!(result["usage"]["input_tokens"], 10);
|
||
assert_eq!(result["usage"]["output_tokens"], 5);
|
||
assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 3);
|
||
}
|
||
|
||
#[test]
|
||
fn chat_response_to_responses_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": "<think>\nI should answer with pong.\n</think>\n\npong"
|
||
},
|
||
"finish_reason": "stop"
|
||
}],
|
||
"usage": {
|
||
"prompt_tokens": 10,
|
||
"completion_tokens": 20,
|
||
"total_tokens": 30,
|
||
"completion_tokens_details": {"reasoning_tokens": 18}
|
||
}
|
||
});
|
||
|
||
let result = chat_completion_to_response(input).unwrap();
|
||
|
||
assert_eq!(result["output"][0]["type"], "reasoning");
|
||
assert_eq!(
|
||
result["output"][0]["summary"][0]["text"],
|
||
"I should answer with pong."
|
||
);
|
||
assert_eq!(result["output"][1]["type"], "message");
|
||
assert_eq!(result["output"][1]["content"][0]["text"], "pong");
|
||
assert_eq!(
|
||
result["usage"]["output_tokens_details"]["reasoning_tokens"],
|
||
18
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn chat_response_length_maps_to_incomplete_response() {
|
||
let input = json!({
|
||
"id": "chatcmpl_2",
|
||
"model": "gpt-5.4",
|
||
"choices": [{
|
||
"message": {"role": "assistant", "content": "partial"},
|
||
"finish_reason": "length"
|
||
}]
|
||
});
|
||
|
||
let result = chat_completion_to_response(input).unwrap();
|
||
|
||
assert_eq!(result["status"], "incomplete");
|
||
assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens");
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|