mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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![
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user