Stream Codex custom tools with native input events

Custom (freeform) Codex tools are bridged through Chat Completions as
JSON `{"input": "..."}` functions, but the Chat->Responses stream still
re-emitted them via `response.function_call_arguments.*`, leaking the
JSON wrapper and using event types the Codex client does not route for
freeform tools.

Emit `response.custom_tool_call_input.delta`/`.done` (with the unwrapped
input text) for custom tool calls instead, suppressing the intermediate
function-argument deltas since a partial `{"input":` fragment cannot be
safely unwrapped mid-stream. Custom tool call items now use a `ctc_`
item-id prefix (matching the input events' item_id) consistently across
the streaming and non-streaming paths via a shared
response_tool_call_item_id_from_chat_name helper.

Covered by new streaming and non-streaming custom-tool tests.
This commit is contained in:
Jason
2026-06-01 17:01:00 +08:00
parent 5968336364
commit d66030bee6
2 changed files with 104 additions and 19 deletions
@@ -5,8 +5,9 @@ use super::{
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, response_tool_call_item_from_chat_name,
chat_usage_to_responses_usage, custom_tool_input_from_chat_arguments,
response_id_from_chat_id, response_status_from_finish_reason,
response_tool_call_item_from_chat_name, response_tool_call_item_id_from_chat_name,
CodexToolContext,
},
};
@@ -420,6 +421,7 @@ impl ChatToResponsesState {
let mut output_index = None;
let mut item_id = String::new();
let mut pending_arguments = String::new();
let current_name: String;
{
let state = self.tools.entry(chat_index).or_default();
@@ -446,8 +448,10 @@ impl ChatToResponsesState {
output_index = state.output_index;
item_id = state.item_id.clone();
}
current_name = state.name.clone();
}
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&current_name);
let mut events = Vec::new();
if should_add {
@@ -463,7 +467,12 @@ impl ChatToResponsesState {
state.name = "unknown_tool".to_string();
}
state.output_index = Some(assigned);
state.item_id = format!("fc_{}", state.call_id);
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
state.item_id = response_tool_call_item_id_from_chat_name(
&state.call_id,
&state.name,
&self.tool_context,
);
item_id = state.item_id.clone();
let item = response_tool_call_item_from_chat_name(
@@ -485,7 +494,7 @@ impl ChatToResponsesState {
}),
));
if !pending_arguments.is_empty() {
if !pending_arguments.is_empty() && !is_custom_tool {
events.push(sse_event(
"response.function_call_arguments.delta",
json!({
@@ -496,7 +505,7 @@ impl ChatToResponsesState {
}),
));
}
} else if !args_delta.is_empty() {
} else if !args_delta.is_empty() && !is_custom_tool {
if let Some(output_index) = output_index {
events.push(sse_event(
"response.function_call_arguments.delta",
@@ -679,7 +688,11 @@ impl ChatToResponsesState {
state.name = "unknown_tool".to_string();
}
state.output_index = Some(assigned);
state.item_id = format!("fc_{}", state.call_id);
state.item_id = response_tool_call_item_id_from_chat_name(
&state.call_id,
&state.name,
&self.tool_context,
);
let item = response_tool_call_item_from_chat_name(
&state.item_id,
"in_progress",
@@ -708,6 +721,7 @@ impl ChatToResponsesState {
};
let output_index = state.output_index.unwrap_or(0);
let arguments = canonicalize_tool_arguments_str(&state.arguments);
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
let item = response_tool_call_item_from_chat_name(
&state.item_id,
"completed",
@@ -720,15 +734,39 @@ impl ChatToResponsesState {
state.done = true;
self.output_items.push((output_index, item.clone()));
events.push(sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": state.item_id,
"output_index": output_index,
"arguments": arguments
}),
));
if is_custom_tool {
let input = custom_tool_input_from_chat_arguments(&arguments);
if !input.is_empty() {
events.push(sse_event(
"response.custom_tool_call_input.delta",
json!({
"type": "response.custom_tool_call_input.delta",
"item_id": state.item_id,
"output_index": output_index,
"delta": input.clone()
}),
));
}
events.push(sse_event(
"response.custom_tool_call_input.done",
json!({
"type": "response.custom_tool_call_input.done",
"item_id": state.item_id,
"output_index": output_index,
"input": input
}),
));
} else {
events.push(sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": state.item_id,
"output_index": output_index,
"arguments": arguments
}),
));
}
events.push(sse_event(
"response.output_item.done",
json!({
@@ -1038,6 +1076,35 @@ mod tests {
assert!(output.contains("\"call_id\":\"call_1\""));
}
#[tokio::test]
async fn restores_custom_tool_input_stream_events() {
let request = json!({
"model": "gpt-5.4",
"tools": [{ "type": "custom", "name": "exec" }]
});
let context =
super::super::transform_codex_chat::build_codex_tool_context_from_request(&request);
let output = collect_with_context(
vec![
"data: {\"id\":\"chatcmpl_custom\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_custom\",\"type\":\"function\",\"function\":{\"name\":\"exec\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_custom\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"input\\\":\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_custom\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"ls -la\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
],
context,
)
.await;
assert!(output.contains("event: response.custom_tool_call_input.delta"));
assert!(output.contains("event: response.custom_tool_call_input.done"));
assert!(!output.contains("event: response.function_call_arguments.delta"));
assert!(!output.contains("event: response.function_call_arguments.done"));
assert!(output.contains("\"id\":\"ctc_call_custom\""));
assert!(output.contains("\"type\":\"custom_tool_call\""));
assert!(output.contains("\"name\":\"exec\""));
assert!(output.contains("\"input\":\"ls -la\""));
}
#[tokio::test]
async fn canonicalizes_streamed_tool_call_arguments_on_done_events() {
let output = collect(vec![
@@ -73,6 +73,11 @@ impl CodexToolContext {
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
@@ -1351,7 +1356,7 @@ fn chat_tool_call_to_response_item(
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
let arguments = canonicalize_tool_arguments(function.get("arguments"));
let item_id = format!("fc_{call_id}");
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",
@@ -1379,7 +1384,7 @@ fn chat_legacy_function_call_to_response_item(
.unwrap_or("");
let arguments = canonicalize_tool_arguments(function_call.get("arguments"));
let item_id = format!("fc_{call_id}");
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",
@@ -1391,6 +1396,18 @@ fn chat_legacy_function_call_to_response_item(
)
}
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,
@@ -1448,7 +1465,7 @@ fn response_custom_tool_call_item(
arguments: &str,
reasoning: Option<&str>,
) -> Value {
let input = parse_custom_tool_input(arguments);
let input = custom_tool_input_from_chat_arguments(arguments);
let mut item = json!({
"id": item_id,
"type": "custom_tool_call",
@@ -1471,7 +1488,7 @@ fn parse_tool_arguments_object(arguments: &str) -> Value {
.unwrap_or_else(|| json!({ "query": arguments }))
}
fn parse_custom_tool_input(arguments: &str) -> String {
pub(crate) fn custom_tool_input_from_chat_arguments(arguments: &str) -> String {
if arguments.trim().is_empty() {
return String::new();
}
@@ -2631,6 +2648,7 @@ mod tests {
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!(