mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
Restore Codex tool plugins over Chat Completions third-party proxy
When proxying Codex Responses requests through the Chat Completions format (api_format=openai_chat), the request transform only forwarded plain `function` tools and silently dropped `tool_search`, `namespace` (MCP), and `custom` tools, so third-party APIs never saw the official Codex plugins and could not call them. Introduce CodexToolContext, built from the original Responses request, which flattens all four tool kinds into Chat functions and keeps a chat_name -> CodexToolSpec map. The response path (streaming and non-streaming) looks names up in this map to restore the original tool_search_call / custom_tool_call / namespaced function_call items instead of reparsing the flattened name. Long namespaced names are truncated with a sha256 suffix to fit the 64-char Chat tool-name limit. Covered by new round-trip tests for all four tool kinds across both streaming and non-streaming paths.
This commit is contained in:
@@ -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<ActiveConnectionGuard>,
|
||||
tool_context: transform_codex_chat::CodexToolContext,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
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)
|
||||
|
||||
@@ -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<String> {
|
||||
item.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
|
||||
@@ -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<Value>,
|
||||
finish_reason: Option<String>,
|
||||
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<Bytes> {
|
||||
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<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + 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<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
tool_context: CodexToolContext,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = 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<Result<Bytes, std::io::Error>> = 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<Bytes> = 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::<Bytes, std::io::Error>(std::io::Error::other(
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
|
||||
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<Value, ProxyError> {
|
||||
@@ -46,6 +254,7 @@ pub fn responses_to_chat_completions_with_reasoning(
|
||||
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();
|
||||
@@ -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<Value> = 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<Value>,
|
||||
tool_context: &CodexToolContext,
|
||||
) -> Result<(), ProxyError> {
|
||||
let mut pending_tool_calls = Vec::new();
|
||||
let mut pending_reasoning: Option<String> = 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<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));
|
||||
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<Value> {
|
||||
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_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 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<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
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<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())
|
||||
@@ -825,6 +1187,7 @@ pub fn chat_completion_to_response(body: Value) -> Result<Value, ProxyError> {
|
||||
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<Value> {
|
||||
let mut output = Vec::new();
|
||||
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for (index, tool_call) in tool_calls.iter().enumerate() {
|
||||
output.push(chat_tool_call_to_response_item(tool_call, index, 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::<Value>(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::<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 {
|
||||
@@ -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::<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_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!({
|
||||
|
||||
Reference in New Issue
Block a user