diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index e69a1ec30..b16bf2715 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -18,7 +18,7 @@ use super::{ providers::{ codex_chat_history::record_responses_sse_stream, get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream, - streaming_codex_chat::create_responses_sse_stream_from_chat, + streaming_codex_chat::create_responses_sse_stream_from_chat_with_context, streaming_gemini::create_anthropic_sse_stream_from_gemini, streaming_responses::create_anthropic_sse_stream_from_responses, transform, transform_codex_chat, transform_gemini, transform_responses, @@ -566,6 +566,7 @@ pub async fn handle_responses( .get("stream") .and_then(|v| v.as_bool()) .unwrap_or(false); + let codex_tool_context = transform_codex_chat::build_codex_tool_context_from_request(&body); let forwarder = ctx.create_forwarder(&state); let mut result = match forwarder @@ -601,6 +602,7 @@ pub async fn handle_responses( &state, is_stream, connection_guard, + codex_tool_context, ) .await; } @@ -641,6 +643,7 @@ pub async fn handle_responses_compact( .get("stream") .and_then(|v| v.as_bool()) .unwrap_or(false); + let codex_tool_context = transform_codex_chat::build_codex_tool_context_from_request(&body); let forwarder = ctx.create_forwarder(&state); let mut result = match forwarder @@ -676,6 +679,7 @@ pub async fn handle_responses_compact( &state, is_stream, connection_guard, + codex_tool_context, ) .await; } @@ -696,6 +700,7 @@ async fn handle_codex_chat_to_responses_transform( state: &ProxyState, is_stream: bool, connection_guard: Option, + tool_context: transform_codex_chat::CodexToolContext, ) -> Result { let status = response.status(); @@ -708,7 +713,7 @@ async fn handle_codex_chat_to_responses_transform( if is_stream || response.is_sse() { let stream = response.bytes_stream(); - let sse_stream = create_responses_sse_stream_from_chat(stream); + let sse_stream = create_responses_sse_stream_from_chat_with_context(stream, tool_context); let sse_stream = record_responses_sse_stream(sse_stream, state.codex_chat_history.clone()); let usage_collector = if usage_logging_enabled(state) { @@ -790,11 +795,14 @@ async fn handle_codex_chat_to_responses_transform( log::error!("[Codex] 解析 Chat 上游响应失败: {e}, body: {body_str}"); ProxyError::TransformError(format!("Failed to parse upstream chat response: {e}")) })?; - let responses_response = transform_codex_chat::chat_completion_to_response(chat_response) - .map_err(|e| { - log::error!("[Codex] Chat → Responses 响应转换失败: {e}"); - e - })?; + let responses_response = transform_codex_chat::chat_completion_to_response_with_context( + chat_response, + &tool_context, + ) + .map_err(|e| { + log::error!("[Codex] Chat → Responses 响应转换失败: {e}"); + e + })?; state .codex_chat_history .record_response(&responses_response) diff --git a/src-tauri/src/proxy/providers/codex_chat_common.rs b/src-tauri/src/proxy/providers/codex_chat_common.rs index 6983f97fa..7c7ab4bc4 100644 --- a/src-tauri/src/proxy/providers/codex_chat_common.rs +++ b/src-tauri/src/proxy/providers/codex_chat_common.rs @@ -170,6 +170,25 @@ pub(crate) fn response_function_call_item( item } +pub(crate) fn response_function_call_item_with_namespace( + item_id: &str, + status: &str, + call_id: &str, + name: &str, + namespace: Option<&str>, + arguments: &str, + reasoning: Option<&str>, +) -> Value { + let mut item = + response_function_call_item(item_id, status, call_id, name, arguments, reasoning); + if let Some(namespace) = namespace.filter(|value| !value.is_empty()) { + if let Some(obj) = item.as_object_mut() { + obj.insert("namespace".to_string(), json!(namespace)); + } + } + item +} + pub(crate) fn response_item_call_id(item: &Value) -> Option { item.get("call_id") .or_else(|| item.get("id")) diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index 5951419de..2ae06e5dd 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -2,11 +2,12 @@ use super::{ codex_chat_common::{ - extract_reasoning_field_text, response_function_call_item, split_leading_think_block, - strip_leading_think_open_tag, + extract_reasoning_field_text, split_leading_think_block, strip_leading_think_open_tag, }, transform_codex_chat::{ - chat_usage_to_responses_usage, response_id_from_chat_id, response_status_from_finish_reason, + chat_usage_to_responses_usage, response_id_from_chat_id, + response_status_from_finish_reason, response_tool_call_item_from_chat_name, + CodexToolContext, }, }; use crate::proxy::json_canonical::canonicalize_tool_arguments_str; @@ -75,6 +76,7 @@ struct ChatToResponsesState { output_items: Vec<(u32, Value)>, latest_usage: Option, finish_reason: Option, + tool_context: CodexToolContext, } impl Default for ChatToResponsesState { @@ -93,11 +95,19 @@ impl Default for ChatToResponsesState { output_items: Vec::new(), latest_usage: None, finish_reason: None, + tool_context: CodexToolContext::default(), } } } impl ChatToResponsesState { + fn with_tool_context(tool_context: CodexToolContext) -> Self { + Self { + tool_context, + ..Self::default() + } + } + fn handle_chat_chunk(&mut self, chunk: &Value) -> Vec { let mut events = Vec::new(); @@ -456,13 +466,14 @@ impl ChatToResponsesState { state.item_id = format!("fc_{}", state.call_id); item_id = state.item_id.clone(); - let item = response_function_call_item( + let item = response_tool_call_item_from_chat_name( &item_id, "in_progress", &state.call_id, &state.name, "", Some(&state.reasoning_content), + &self.tool_context, ); events.push(sse_event( @@ -669,13 +680,14 @@ impl ChatToResponsesState { } state.output_index = Some(assigned); state.item_id = format!("fc_{}", state.call_id); - let item = response_function_call_item( + let item = response_tool_call_item_from_chat_name( &state.item_id, "in_progress", &state.call_id, &state.name, "", Some(&state.reasoning_content), + &self.tool_context, ); add_event = Some(sse_event( "response.output_item.added", @@ -696,13 +708,14 @@ impl ChatToResponsesState { }; let output_index = state.output_index.unwrap_or(0); let arguments = canonicalize_tool_arguments_str(&state.arguments); - let item = response_function_call_item( + let item = response_tool_call_item_from_chat_name( &state.item_id, "completed", &state.call_id, &state.name, &arguments, Some(&state.reasoning_content), + &self.tool_context, ); state.done = true; self.output_items.push((output_index, item.clone())); @@ -810,13 +823,23 @@ fn leading_think_prefix_decision(buffer: &str) -> ThinkPrefixDecision { } /// Create a stream that converts Chat Completions SSE chunks into Responses SSE events. +#[allow(dead_code)] pub fn create_responses_sse_stream_from_chat( stream: impl Stream> + Send + 'static, +) -> impl Stream> + Send { + create_responses_sse_stream_from_chat_with_context(stream, CodexToolContext::default()) +} + +/// Create a stream that converts Chat Completions SSE chunks into Responses SSE +/// events while restoring Codex tool namespace/custom/tool_search metadata. +pub fn create_responses_sse_stream_from_chat_with_context( + stream: impl Stream> + Send + 'static, + tool_context: CodexToolContext, ) -> impl Stream> + Send { async_stream::stream! { let mut buffer = String::new(); let mut utf8_remainder: Vec = Vec::new(); - let mut state = ChatToResponsesState::default(); + let mut state = ChatToResponsesState::with_tool_context(tool_context); let mut stream_failed = false; tokio::pin!(stream); @@ -929,12 +952,16 @@ mod tests { use futures::{stream, StreamExt}; async fn collect(chunks: Vec<&str>) -> String { + collect_with_context(chunks, CodexToolContext::default()).await + } + + async fn collect_with_context(chunks: Vec<&str>, tool_context: CodexToolContext) -> String { let chunks: Vec> = chunks .into_iter() .map(|chunk| Ok(Bytes::copy_from_slice(chunk.as_bytes()))) .collect(); let upstream = stream::iter(chunks); - let converted = create_responses_sse_stream_from_chat(upstream); + let converted = create_responses_sse_stream_from_chat_with_context(upstream, tool_context); let bytes: Vec = converted.map(|item| item.unwrap()).collect().await; String::from_utf8(bytes.concat()).unwrap() } @@ -1039,6 +1066,68 @@ mod tests { assert!(output.contains("\"reasoning_content\":\"Need file.\"")); } + #[tokio::test] + async fn restores_namespace_on_streamed_tool_call_items() { + let request = json!({ + "model": "gpt-5.4", + "input": [{ + "type": "tool_search_output", + "call_id": "call_tool_search_1", + "tools": [{ + "type": "namespace", + "name": "mcp__codex_apps__gmail", + "tools": [{ + "type": "function", + "name": "_search_emails", + "description": "Search Gmail.", + "parameters": {"type": "object"} + }] + }] + }] + }); + let context = + super::super::transform_codex_chat::build_codex_tool_context_from_request(&request); + let output = collect_with_context( + vec![ + "data: {\"id\":\"chatcmpl_gmail\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_gmail\",\"type\":\"function\",\"function\":{\"name\":\"mcp__codex_apps__gmail___search_emails\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_gmail\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"query\\\":\\\"in:inbox\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n", + ], + context, + ) + .await; + + assert!(output.contains("\"type\":\"function_call\"")); + assert!(output.contains("\"namespace\":\"mcp__codex_apps__gmail\"")); + assert!(output.contains("\"name\":\"_search_emails\"")); + assert!(output.contains(r#""arguments":"{\"query\":\"in:inbox\"}""#)); + } + + #[tokio::test] + async fn restores_tool_search_on_streamed_tool_call_items() { + let request = json!({ + "model": "gpt-5.4", + "tools": [{"type": "tool_search"}], + "input": "Search for Gmail tools." + }); + let context = + super::super::transform_codex_chat::build_codex_tool_context_from_request(&request); + let output = collect_with_context( + vec![ + "data: {\"id\":\"chatcmpl_tool_search\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_tool_search_1\",\"type\":\"function\",\"function\":{\"name\":\"tool_search\"}}]}}]}\n\n", + "data: {\"id\":\"chatcmpl_tool_search\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"query\\\":\\\"Gmail search emails\\\",\\\"limit\\\":10}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n", + ], + context, + ) + .await; + + assert!(output.contains("\"type\":\"tool_search_call\"")); + assert!(output.contains("\"execution\":\"client\"")); + assert!(output.contains("\"call_id\":\"call_tool_search_1\"")); + assert!(output.contains("\"query\":\"Gmail search emails\"")); + } + #[tokio::test] async fn stream_error_emits_failed_without_completed() { let upstream = stream::iter(vec![Err::(std::io::Error::other( diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index 2060aae3e..72166ee39 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -6,16 +6,19 @@ use super::codex_chat_common::{ append_reasoning_content, extract_reasoning_field_text, extract_reasoning_summary_text, - response_function_call_item, split_leading_think_block, + 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", @@ -33,6 +36,211 @@ const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[ "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; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CodexToolKind { + Function, + Namespace, + Custom, + ToolSearch, +} + +#[derive(Debug, Clone)] +pub(crate) struct CodexToolSpec { + pub(crate) kind: CodexToolKind, + pub(crate) name: String, + pub(crate) namespace: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct CodexToolContext { + chat_tools: Vec, + seen_chat_names: HashSet, + chat_name_to_spec: HashMap, + namespace_name_to_chat_name: HashMap<(String, String), String>, +} + +impl CodexToolContext { + pub(crate) fn chat_tools(&self) -> &[Value] { + &self.chat_tools + } + + pub(crate) fn lookup_chat_name(&self, chat_name: &str) -> Option<&CodexToolSpec> { + self.chat_name_to_spec.get(chat_name) + } + + 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 = tool + .get("description") + .cloned() + .unwrap_or_else(|| json!("Custom Codex tool.")); + let chat_tool = json!({ + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": { + "type": "object", + "properties": { + CUSTOM_TOOL_INPUT_FIELD: { + "type": "string", + "description": "Input to pass to the custom Codex tool." + } + }, + "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 { @@ -46,6 +254,7 @@ pub fn responses_to_chat_completions_with_reasoning( reasoning_config: Option<&CodexChatReasoningConfig>, ) -> Result { let mut result = json!({}); + let tool_context = build_codex_tool_context_from_request(&body); if let Some(model) = body.get("model") { result["model"] = model.clone(); @@ -63,7 +272,7 @@ pub fn responses_to_chat_completions_with_reasoning( } if let Some(input) = body.get("input") { - append_responses_input_as_chat_messages(input, &mut messages)?; + append_responses_input_as_chat_messages(input, &mut messages, &tool_context)?; } let messages = collapse_system_messages_to_head(messages); result["messages"] = json!(messages); @@ -91,18 +300,13 @@ pub fn responses_to_chat_completions_with_reasoning( apply_reasoning_options(&mut result, &body, model, reasoning_config); - if let Some(tools) = body.get("tools").and_then(|v| v.as_array()) { - let tools: Vec = tools - .iter() - .filter_map(responses_tool_to_chat_tool) - .collect(); - if !tools.is_empty() { - result["tools"] = json!(tools); - } + 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); + result["tool_choice"] = responses_tool_choice_to_chat(tool_choice, &tool_context); } for key in EXTRA_CHAT_PASSTHROUGH_FIELDS { @@ -330,6 +534,7 @@ fn instruction_text(value: &Value) -> String { fn append_responses_input_as_chat_messages( input: &Value, messages: &mut Vec, + tool_context: &CodexToolContext, ) -> Result<(), ProxyError> { let mut pending_tool_calls = Vec::new(); let mut pending_reasoning: Option = None; @@ -350,6 +555,7 @@ fn append_responses_input_as_chat_messages( &mut pending_tool_calls, &mut pending_reasoning, &mut last_assistant_index, + tool_context, )?; } } @@ -360,6 +566,7 @@ fn append_responses_input_as_chat_messages( &mut pending_tool_calls, &mut pending_reasoning, &mut last_assistant_index, + tool_context, )?; } _ => {} @@ -381,12 +588,24 @@ fn append_responses_item_as_chat_message( pending_tool_calls: &mut Vec, pending_reasoning: &mut Option, last_assistant_index: &mut Option, + tool_context: &CodexToolContext, ) -> Result<(), ProxyError> { let item_type = item.get("type").and_then(|v| v.as_str()); match item_type { Some("function_call") => { append_unique_pending_reasoning(pending_reasoning, responses_item_reasoning_text(item)); - pending_tool_calls.push(responses_function_call_to_chat_tool_call(item)); + 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( @@ -407,6 +626,21 @@ fn append_responses_item_as_chat_message( "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() @@ -725,48 +959,82 @@ fn responses_content_to_chat_content(_role: &str, content: &Value) -> Value { Value::Array(chat_parts) } -fn responses_function_call_to_chat_tool_call(item: &Value) -> Value { - let call_id = item - .get("call_id") - .or_else(|| item.get("id")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let name = item.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let arguments = canonicalize_tool_arguments(item.get("arguments")); - - json!({ - "id": call_id, - "type": "function", - "function": { - "name": name, - "arguments": arguments +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 responses_tool_to_chat_tool(tool: &Value) -> Option { +fn flatten_namespace_tool_name(namespace: &str, name: &str) -> String { + let full_name = format!("{namespace}__{name}"); + if full_name.len() <= CHAT_TOOL_NAME_MAX_LEN { + return full_name; + } + + let hash = short_sha256_hex(full_name.as_bytes()); + let suffix = format!("__{hash}"); + let prefix_len = CHAT_TOOL_NAME_MAX_LEN.saturating_sub(suffix.len()); + let mut prefix = String::new(); + for ch in full_name.chars() { + if prefix.len() + ch.len_utf8() > prefix_len { + break; + } + prefix.push(ch); + } + format!("{prefix}{suffix}") +} + +fn responses_tool_name(tool: &Value) -> Option { + tool.get("function") + .and_then(|function| function.get("name")) + .or_else(|| tool.get("name")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option { if tool.get("type").and_then(|v| v.as_str()) != Some("function") { return None; } - if tool.get("function").is_some() { - let mut chat_tool = tool.clone(); - if let Some(strict) = tool.get("strict").cloned() { - if let Some(function) = chat_tool - .get_mut("function") - .and_then(|value| value.as_object_mut()) - { - function.entry("strict".to_string()).or_insert(strict); - } - if let Some(obj) = chat_tool.as_object_mut() { - obj.remove("strict"); + 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": tool.get("name").and_then(|v| v.as_str()).unwrap_or(""), + "name": chat_name, "description": tool.get("description").cloned().unwrap_or(Value::Null), "parameters": tool.get("parameters").cloned().unwrap_or_else(|| json!({})) }); @@ -780,13 +1048,97 @@ fn responses_tool_to_chat_tool(tool: &Value) -> Option { })) } -fn responses_tool_choice_to_chat(tool_choice: &Value) -> Value { +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": obj.get("name").and_then(|v| v.as_str()).unwrap_or("") + "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 } }) } @@ -795,7 +1147,17 @@ fn responses_tool_choice_to_chat(tool_choice: &Value) -> Value { } /// Convert a non-streaming Chat Completions response into a Responses response. +#[allow(dead_code)] pub fn chat_completion_to_response(body: Value) -> Result { + chat_completion_to_response_with_context(body, &CodexToolContext::default()) +} + +/// Convert a non-streaming Chat Completions response into a Responses response, +/// restoring Codex-specific tool names using the original Responses request. +pub(crate) fn chat_completion_to_response_with_context( + body: Value, + tool_context: &CodexToolContext, +) -> Result { let choices = body .get("choices") .and_then(|v| v.as_array()) @@ -825,6 +1187,7 @@ pub fn chat_completion_to_response(body: Value) -> Result { output.extend(chat_tool_calls_to_response_output_items( message, reasoning.as_deref(), + tool_context, )); let mut response = json!({ @@ -948,17 +1311,24 @@ fn chat_message_to_response_output_item(message: &Value, response_id: &str) -> O fn chat_tool_calls_to_response_output_items( message: &Value, reasoning: Option<&str>, + tool_context: &CodexToolContext, ) -> Vec { let mut output = Vec::new(); if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) { for (index, tool_call) in tool_calls.iter().enumerate() { - output.push(chat_tool_call_to_response_item(tool_call, index, reasoning)); + output.push(chat_tool_call_to_response_item( + tool_call, + index, + reasoning, + tool_context, + )); } } else if let Some(function_call) = message.get("function_call") { output.push(chat_legacy_function_call_to_response_item( function_call, reasoning, + tool_context, )); } @@ -969,6 +1339,7 @@ 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") @@ -981,12 +1352,21 @@ fn chat_tool_call_to_response_item( let arguments = canonicalize_tool_arguments(function.get("arguments")); let item_id = format!("fc_{call_id}"); - response_function_call_item(&item_id, "completed", &call_id, name, &arguments, reasoning) + 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, ) -> Value { let call_id = function_call .get("id") @@ -1000,7 +1380,109 @@ fn chat_legacy_function_call_to_response_item( let arguments = canonicalize_tool_arguments(function_call.get("arguments")); let item_id = format!("fc_{call_id}"); - response_function_call_item(&item_id, "completed", call_id, name, &arguments, reasoning) + response_tool_call_item_from_chat_name( + &item_id, + "completed", + call_id, + name, + &arguments, + reasoning, + tool_context, + ) +} + +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 = parse_custom_tool_input(arguments); + let mut item = json!({ + "id": item_id, + "type": "custom_tool_call", + "status": status, + "call_id": call_id, + "name": name, + "input": input + }); + super::codex_chat_common::attach_optional_reasoning_content_field(&mut item, reasoning); + item +} + +fn parse_tool_arguments_object(arguments: &str) -> Value { + if arguments.trim().is_empty() { + return json!({}); + } + serde_json::from_str::(arguments) + .ok() + .filter(|value| value.is_object()) + .unwrap_or_else(|| json!({ "query": arguments })) +} + +fn parse_custom_tool_input(arguments: &str) -> String { + if arguments.trim().is_empty() { + return String::new(); + } + match serde_json::from_str::(arguments) { + Ok(Value::Object(obj)) => obj + .get(CUSTOM_TOOL_INPUT_FIELD) + .and_then(|value| value.as_str()) + .unwrap_or(arguments) + .to_string(), + _ => arguments.to_string(), + } } pub(crate) fn chat_usage_to_responses_usage(usage: Option<&Value>) -> Value { @@ -1248,6 +1730,106 @@ mod tests { assert_eq!(result["reasoning_effort"], "high"); } + #[test] + fn responses_request_to_chat_exposes_tool_search_and_loaded_namespace_tools() { + let input = json!({ + "model": "gpt-5.4", + "tools": [{"type": "tool_search"}], + "input": [ + { + "type": "tool_search_call", + "call_id": "call_tool_search_1", + "status": "completed", + "execution": "client", + "arguments": {"query": "Gmail search emails", "limit": 5} + }, + { + "type": "tool_search_output", + "call_id": "call_tool_search_1", + "status": "completed", + "execution": "client", + "tools": [{ + "type": "namespace", + "name": "mcp__codex_apps__gmail", + "description": "Find and reference emails from your inbox.", + "tools": [{ + "type": "function", + "name": "_search_emails", + "description": "Search Gmail for emails matching a query.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "max_results": {"type": "integer"} + }, + "required": ["query"] + } + }] + }] + }, + { + "type": "message", + "role": "user", + "content": "Search unread inbox mail." + } + ] + }); + + let result = responses_to_chat_completions(input).unwrap(); + let tools = result["tools"].as_array().unwrap(); + let tool_names = tools + .iter() + .filter_map(|tool| tool.pointer("/function/name").and_then(|v| v.as_str())) + .collect::>(); + + assert!(tool_names.contains(&"tool_search")); + assert!(tool_names.contains(&"mcp__codex_apps__gmail___search_emails")); + assert_eq!( + result["messages"][0]["tool_calls"][0]["function"]["name"], + "tool_search" + ); + assert_eq!(result["messages"][1]["role"], "tool"); + assert_eq!(result["messages"][1]["tool_call_id"], "call_tool_search_1"); + assert!(result["messages"][1]["content"] + .as_str() + .unwrap() + .contains("mcp__codex_apps__gmail")); + } + + #[test] + fn responses_request_to_chat_maps_custom_tool_and_choice() { + let input = json!({ + "model": "gpt-5.4", + "tools": [{ + "type": "custom", + "name": "apply_patch", + "description": "Apply a patch to files." + }], + "tool_choice": {"type": "custom", "name": "apply_patch"}, + "input": [{ + "type": "custom_tool_call", + "id": "ctc_1", + "call_id": "call_patch", + "name": "apply_patch", + "input": "*** Begin Patch\n*** End Patch" + }] + }); + + let result = responses_to_chat_completions(input).unwrap(); + + assert_eq!(result["tools"][0]["function"]["name"], "apply_patch"); + assert_eq!( + result["tools"][0]["function"]["parameters"]["required"][0], + "input" + ); + assert_eq!(result["tool_choice"]["function"]["name"], "apply_patch"); + assert_eq!( + result["messages"][0]["tool_calls"][0]["function"]["arguments"], + r#"{"input":"*** Begin Patch\n*** End Patch"}"# + ); + } + #[test] fn responses_request_to_chat_uses_provider_reasoning_effort_for_deepseek_model() { let input = json!({ @@ -1912,6 +2494,151 @@ mod tests { 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]["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!({