From d66030bee61c4c2d4f50b8050087cd06d6d640d7 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 1 Jun 2026 17:01:00 +0800 Subject: [PATCH] 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. --- .../proxy/providers/streaming_codex_chat.rs | 97 ++++++++++++++++--- .../proxy/providers/transform_codex_chat.rs | 26 ++++- 2 files changed, 104 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index 2ae06e5dd..1146d095d 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -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(¤t_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![ diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index 72166ee39..a3568dce5 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -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!(