fix(proxy): report dropped Chat tool calls instead of faking completion

When a third-party gateway returns tool calls without a function name,
the Chat -> Responses transform silently discarded them and still marked
the turn as `completed`. Codex then saw a successful turn with nothing
left to do and ended its agent loop without any error, turning a
diagnosable upstream failure into a silent stall.

- Emit `response.failed` (streaming) or a transform error (non-streaming)
  when every tool call in a turn was dropped and none remains usable.
  Gated on `status == "completed"` so that `finish_reason: length`
  truncation keeps its own `incomplete` semantics, matching the existing
  Anthropic streaming path.
- Log all three drop sites with structured, content-free fields (call_id
  presence, argument byte counts, finish reason) so the upstream defect
  can finally be diagnosed from real traffic.
- Treat whitespace-only function names as missing, and resolve tool keys
  conservatively when upstream omits the required `index` field.

Turns that still contain a valid tool call, text-only turns and truncated
turns are unaffected. Adds 13 tests.

Refs #4341
This commit is contained in:
Jason
2026-08-03 11:34:53 +08:00
parent eb356e15bd
commit 9db9c56fda
2 changed files with 450 additions and 19 deletions
@@ -80,6 +80,8 @@ struct ChatToResponsesState {
latest_usage: Option<Value>,
finish_reason: Option<String>,
tool_context: CodexToolContext,
/// 本回合因缺少合法函数名而被丢弃的工具调用数(见 `finalize_tools`)。
dropped_tool_calls: usize,
}
impl Default for ChatToResponsesState {
@@ -100,6 +102,7 @@ impl Default for ChatToResponsesState {
latest_usage: None,
finish_reason: None,
tool_context: CodexToolContext::default(),
dropped_tool_calls: 0,
}
}
}
@@ -332,8 +335,43 @@ impl ChatToResponsesState {
(!self.reasoning.text.trim().is_empty()).then(|| self.reasoning.text.trim().to_string())
}
/// 上游未下发 `index` 时的 key 解析。
///
/// `index` 在 OpenAI Chat Completions 协议里是必填字段,但部分第三方网关会省略。
/// 缺了它就无法从帧结构上区分「同一调用的 arguments 续帧」和「一个新调用」,
/// 所以这里只在**能确证是新调用**时才分配新 key:delta 带非空 `id`,且该 id 与
/// 所有已知调用都不同。其余情况一律归入最后一个已知 key(空 map 时为 0),保持
/// 既有行为——宁可两个并行调用坍缩成一个,也不能把一个调用的续帧炸成多个 item。
fn resolve_tool_key_without_index(&self, tool_call: &Value) -> usize {
let last_key = self.tools.keys().next_back().copied();
let Some(id) = tool_call
.get("id")
.and_then(|v| v.as_str())
.filter(|id| !id.is_empty())
else {
return last_key.unwrap_or(0);
};
if let Some((key, _)) = self.tools.iter().find(|(_, state)| state.call_id == id) {
return *key;
}
// 上游可以先发一个显式 `index: usize::MAX` 再发无 index 的新 id。这段代码
// 存在的理由就是应付畸形上游,所以不能用裸 `+1`debug 下 panic、release 下
// 回绕到 0 覆盖已有调用)。溢出时退回并入最后一个已知调用,与本函数
// "宁可坍缩也不炸开" 的取向一致。
match last_key {
Some(key) => key.checked_add(1).unwrap_or(key),
None => 0,
}
}
fn push_tool_call_delta(&mut self, tool_call: &Value, reasoning: Option<&str>) -> Vec<Bytes> {
let chat_index = tool_call.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let chat_index = match tool_call.get("index").and_then(|v| v.as_u64()) {
Some(index) => index as usize,
None => self.resolve_tool_key_without_index(tool_call),
};
let id_delta = tool_call
.get("id")
.and_then(|v| v.as_str())
@@ -483,6 +521,16 @@ impl ChatToResponsesState {
})
}
/// 本回合最终产出里是否至少有一个可被 Codex 识别的工具调用 item。
fn has_emitted_tool_call(&self) -> bool {
self.output_items.iter().any(|(_, item)| {
matches!(
item.get("type").and_then(|v| v.as_str()),
Some("function_call" | "custom_tool_call" | "tool_search_call")
)
})
}
fn finalize(&mut self) -> Vec<Bytes> {
if self.completed {
return Vec::new();
@@ -495,6 +543,27 @@ impl ChatToResponsesState {
events.extend(self.finalize_tools());
let status = response_status_from_finish_reason(self.finish_reason.as_deref());
// 丢弃过工具调用、且最终一个工具调用都没剩下时,Codex 会收到一个
// "status=completed 但 output 里没有任何工具调用" 的回合,agent loop 必然
// 静默收尾——这正是 #4341「答一句就停、零报错」的形态。此时如实报错,
// 而不是谎报成功。只要还剩下任何一个合法工具调用,Codex 本来就会继续,
// 判据不成立,行为保持不变。
//
// 🔴 只对本应 `completed` 的回合生效:`finish_reason=length`(含流提前断开后
// 合成的 length)有自己正当的终止解释,工具调用没拿到 name 是截断的后果而非
// 上游发了畸形数据——报成 tool_call_dropped 会给出错误的归因,而本修复的全部
// 意义就在于诊断信息的准确性。
if status == "completed" && self.dropped_tool_calls > 0 && !self.has_emitted_tool_call() {
let dropped = self.dropped_tool_calls;
let message = format!(
"Upstream returned {dropped} tool call(s) without a function name, \
leaving no usable tool call in this turn"
);
events.push(self.failed_event(message, Some("upstream_tool_call_dropped".to_string())));
return events;
}
let mut response = self.base_response(status, self.completed_output_items());
if status == "incomplete" {
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
@@ -545,16 +614,35 @@ impl ChatToResponsesState {
// Skip tool calls with missing names (defensive: some models generate
// tool call deltas without providing a valid function name)
// 纯空白名同样对应不到任何已发布工具,必须与空名同等对待——否则它会
// 伪装成"本回合还有工具调用",绕过下面 finalize 里的失败判据。
let has_bad_name = self
.tools
.get(&key)
.map(|state| state.name.is_empty())
.map(|state| state.name.trim().is_empty())
.unwrap_or(true);
if has_bad_name {
let (call_id_empty, args_bytes) = self
.tools
.get(&key)
.map(|state| (state.call_id.is_empty(), state.arguments.len()))
.unwrap_or((true, 0));
if let Some(state) = self.tools.get_mut(&key) {
state.done = true;
}
log::warn!("[Codex] Skipping streaming tool call with missing name");
self.dropped_tool_calls += 1;
// 只记结构信息:arguments 内容可能包含用户代码,且前端日志出口是
// allowlist 脱敏,新字段不进白名单就不会被处理,因此只输出字节数。
log::warn!(
"[Codex] dropped streaming tool call: model={} chat_index={} \
call_id_empty={} args_bytes={} finish_reason={} tools_total={}",
self.model,
key,
call_id_empty,
args_bytes,
self.finish_reason.as_deref().unwrap_or("<none>"),
self.tools.len()
);
continue;
}
@@ -1033,6 +1121,138 @@ mod tests {
assert!(!output.contains("call_missing"));
}
/// #4341:上游只给出畸形工具调用时,丢弃后本回合一个工具调用都不剩,
/// Codex 会把它当成正常完成而静默收尾。此时必须如实报错。
#[tokio::test]
async fn dropped_only_tool_call_emits_failed_without_completed() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_drop\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"content\":\"让我继续处理这个文件\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_drop\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_bad\",\"type\":\"function\",\"function\":{\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
assert!(output.contains("event: response.failed"));
assert!(output.contains("upstream_tool_call_dropped"));
assert!(!output.contains("event: response.completed"));
// 已经推给客户端的文本增量不受影响,用户仍能看到模型说了什么。
assert!(output.contains("让我继续处理这个文件"));
}
/// `finish_reason=length`token 截断)时工具调用往往只到一半就没了 name。
/// 这不是"上游发了畸形数据",而是截断——归因必须是 incomplete,不能报成
/// tool_call_dropped,否则诊断信息本身就是错的。
#[tokio::test]
async fn truncated_turn_stays_incomplete_instead_of_failed() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_trunc\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"content\":\"我来看看\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_trunc\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_cut\",\"type\":\"function\",\"function\":{\"arguments\":\"{\\\"pa\"}}]},\"finish_reason\":\"length\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
assert!(output.contains("event: response.completed"));
assert!(output.contains("\"status\":\"incomplete\""));
assert!(!output.contains("event: response.failed"));
}
/// 纯空白函数名对应不到任何已发布工具,必须与空名同等对待,
/// 否则它会伪装成"本回合还有工具调用"而绕过判据。
#[tokio::test]
async fn whitespace_only_tool_name_is_dropped() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_ws\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_ws\",\"type\":\"function\",\"function\":{\"name\":\" \",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
assert!(output.contains("event: response.failed"));
assert!(output.contains("upstream_tool_call_dropped"));
assert!(!output.contains("event: response.completed"));
}
/// 纯文本回合(从未出现过工具调用增量)不受判据影响。
#[tokio::test]
async fn text_only_turn_still_completes() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_text\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"content\":\"完成了\"},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
assert!(output.contains("event: response.completed"));
assert!(!output.contains("event: response.failed"));
}
/// 上游省略 `index` 时,两个 id 不同的调用不得坍缩成一个。
#[tokio::test]
async fn missing_index_with_distinct_ids_keeps_calls_separate() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_noidx\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"a.txt\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_noidx\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_b\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\\\"cmd\\\":\\\"ls\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0]["call_id"], "call_a");
assert_eq!(items[0]["name"], "read_file");
assert_eq!(items[0]["arguments"], r#"{"path":"a.txt"}"#);
assert_eq!(items[1]["call_id"], "call_b");
assert_eq!(items[1]["name"], "exec_command");
assert_eq!(items[1]["arguments"], r#"{"cmd":"ls"}"#);
}
/// 上游省略 `index` 时,不带 id 的 arguments 续帧必须归入同一个调用,
/// 不能被当成新调用炸成多个 item。
#[tokio::test]
async fn missing_index_argument_fragments_stay_in_one_call() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_frag\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_frag\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"function\":{\"arguments\":\"\\\"a.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["call_id"], "call_a");
assert_eq!(items[0]["arguments"], r#"{"path":"a.txt"}"#);
}
/// 上游省略 `index` 且重复下发同一个 id(部分网关每帧重复整个头部)时,
/// 不得被判成新调用。
#[tokio::test]
async fn missing_index_repeated_same_id_stays_in_one_call() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_rep\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_rep\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"\\\"a.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["call_id"], "call_a");
assert_eq!(items[0]["arguments"], r#"{"path":"a.txt"}"#);
}
#[tokio::test]
async fn finalization_keeps_non_contiguous_tool_index() {
let output = collect(vec![
@@ -1409,11 +1409,28 @@ pub(crate) fn chat_completion_to_response_with_context(
if let Some(message_item) = chat_message_to_response_output_item(message, &response_id) {
output.push(message_item);
}
output.extend(chat_tool_calls_to_response_output_items(
message,
reasoning.as_deref(),
tool_context,
));
let tool_calls =
chat_tool_calls_to_response_output_items(message, reasoning.as_deref(), tool_context);
// 丢弃过工具调用、且最终一个工具调用都没剩下时,Codex 会收到一个
// "status=completed 但 output 里没有任何工具调用" 的回合,agent loop 必然静默
// 收尾(#4341)。此时如实报错,而不是谎报成功。只要还剩下任何一个合法工具
// 调用,Codex 本来就会继续,判据不成立,行为保持不变。
//
// 🔴 与流式分支一致,只对本应 `completed` 的回合生效:`finish_reason=length`
// 是截断,工具调用缺 name 是截断的后果而非上游发了畸形数据,报成
// tool_call_dropped 会给出错误的归因。
if response_status_from_finish_reason(finish_reason) == "completed"
&& tool_calls.dropped > 0
&& tool_calls.items.is_empty()
{
return Err(ProxyError::TransformError(format!(
"Upstream returned {} tool call(s) without a function name, \
leaving no usable tool call in this turn",
tool_calls.dropped
)));
}
output.extend(tool_calls.items);
let mut response = json!({
"id": response_id,
@@ -1533,12 +1550,20 @@ fn chat_message_to_response_output_item(message: &Value, response_id: &str) -> O
}))
}
/// 非流式工具调用转换结果。`dropped` 记录因缺少合法函数名而被丢弃的条数,
/// 供调用方判断本回合是否已经不可能让 Codex 继续(见 #4341)。
struct ChatToolCallItems {
items: Vec<Value>,
dropped: usize,
}
fn chat_tool_calls_to_response_output_items(
message: &Value,
reasoning: Option<&str>,
tool_context: &CodexToolContext,
) -> Vec<Value> {
) -> ChatToolCallItems {
let mut output = Vec::new();
let mut dropped = 0usize;
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
for (index, tool_call) in tool_calls.iter().enumerate() {
@@ -1546,8 +1571,24 @@ fn chat_tool_calls_to_response_output_items(
// may generate tool calls without providing a valid name)
let function = tool_call.get("function").unwrap_or(&Value::Null);
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name.is_empty() {
log::warn!("[Codex] Skipping tool call with missing name");
// 纯空白名同样对应不到任何已发布工具,与空名同等对待。
if name.trim().is_empty() {
dropped += 1;
// 只记结构信息,不记 arguments 内容(可能包含用户代码)。
let call_id_empty = tool_call
.get("id")
.and_then(|v| v.as_str())
.is_none_or(str::is_empty);
let args_bytes = function
.get("arguments")
.and_then(|v| v.as_str())
.map(str::len)
.unwrap_or(0);
log::warn!(
"[Codex] dropped tool call: index={index} call_id_empty={call_id_empty} \
args_bytes={args_bytes} tools_total={}",
tool_calls.len()
);
continue;
}
output.push(chat_tool_call_to_response_item(
@@ -1558,14 +1599,16 @@ fn chat_tool_calls_to_response_output_items(
));
}
} else if let Some(function_call) = message.get("function_call") {
if let Some(item) =
chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context)
{
output.push(item);
match chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context) {
Some(item) => output.push(item),
None => dropped += 1,
}
}
output
ChatToolCallItems {
items: output,
dropped,
}
}
fn chat_tool_call_to_response_item(
@@ -1612,9 +1655,18 @@ fn chat_legacy_function_call_to_response_item(
.unwrap_or("");
// Skip legacy function calls with missing names (defensive: some models
// may generate function_call without providing a valid name)
if name.is_empty() {
log::warn!("[Codex] Skipping legacy function_call with missing name");
// may generate function_call without providing a valid name)
// 纯空白名同样对应不到任何已发布工具,与空名同等对待。
if name.trim().is_empty() {
// 只记结构信息,不记 arguments 内容(可能包含用户代码)。
let args_bytes = function_call
.get("arguments")
.and_then(|v| v.as_str())
.map(str::len)
.unwrap_or(0);
log::warn!(
"[Codex] dropped legacy function_call: call_id={call_id} args_bytes={args_bytes}"
);
return None;
}
@@ -3952,6 +4004,165 @@ mod tests {
);
}
/// #4341(非流式路径):丢弃后一个工具调用都不剩时,必须如实报错,
/// 而不是返回一个 Codex 会当成正常完成的空壳回合。
#[test]
fn chat_response_with_only_unnamed_tool_call_is_an_error() {
let chat = json!({
"id": "chatcmpl_drop",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"content": "让我继续处理这个文件",
"tool_calls": [{
"id": "call_bad",
"type": "function",
"function": {"arguments": "{}"}
}]
},
"finish_reason": "tool_calls"
}]
});
let err = chat_completion_to_response_with_context(chat, &CodexToolContext::default())
.unwrap_err();
assert!(matches!(err, ProxyError::TransformError(_)));
assert!(err.to_string().contains("without a function name"));
}
/// 只要还剩下一个合法工具调用,Codex 本来就会继续,行为保持不变。
#[test]
fn chat_response_keeps_valid_tool_call_beside_unnamed_one() {
let chat = json!({
"id": "chatcmpl_mixed",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"tool_calls": [
{"id": "call_bad", "type": "function", "function": {"arguments": "{}"}},
{
"id": "call_good",
"type": "function",
"function": {"name": "exec_command", "arguments": "{\"cmd\":\"ls\"}"}
}
]
},
"finish_reason": "tool_calls"
}]
});
let result =
chat_completion_to_response_with_context(chat, &CodexToolContext::default()).unwrap();
let output = result["output"].as_array().unwrap();
assert_eq!(output.len(), 1);
assert_eq!(output[0]["name"], "exec_command");
assert_eq!(output[0]["call_id"], "call_good");
assert_eq!(result["status"], "completed");
}
/// legacy `function_call` 形态同样受判据保护。
#[test]
fn chat_response_with_unnamed_legacy_function_call_is_an_error() {
let chat = json!({
"id": "chatcmpl_legacy",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"function_call": {"id": "call_legacy", "arguments": "{}"}
},
"finish_reason": "function_call"
}]
});
let err = chat_completion_to_response_with_context(chat, &CodexToolContext::default())
.unwrap_err();
assert!(matches!(err, ProxyError::TransformError(_)));
}
/// `finish_reason=length` 是截断,不是"上游发了畸形数据"——归因必须保持
/// incomplete,不能报成 tool_call_dropped。
#[test]
fn chat_response_truncated_stays_incomplete_instead_of_error() {
let chat = json!({
"id": "chatcmpl_trunc",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"content": "我来看看",
"tool_calls": [{
"id": "call_cut",
"type": "function",
"function": {"arguments": "{\"pa"}
}]
},
"finish_reason": "length"
}]
});
let result =
chat_completion_to_response_with_context(chat, &CodexToolContext::default()).unwrap();
assert_eq!(result["status"], "incomplete");
assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens");
}
/// 纯空白函数名必须与空名同等对待,否则会伪装成"本回合还有工具调用"。
#[test]
fn chat_response_whitespace_only_tool_name_is_an_error() {
let chat = json!({
"id": "chatcmpl_ws",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_ws",
"type": "function",
"function": {"name": " ", "arguments": "{}"}
}]
},
"finish_reason": "tool_calls"
}]
});
let err = chat_completion_to_response_with_context(chat, &CodexToolContext::default())
.unwrap_err();
assert!(matches!(err, ProxyError::TransformError(_)));
}
/// 纯文本回合(从未出现工具调用)不受判据影响。
#[test]
fn chat_response_text_only_still_completes() {
let chat = json!({
"id": "chatcmpl_text",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {"role": "assistant", "content": "完成了"},
"finish_reason": "stop"
}]
});
let result =
chat_completion_to_response_with_context(chat, &CodexToolContext::default()).unwrap();
assert_eq!(result["status"], "completed");
}
#[test]
fn chat_response_to_responses_canonicalizes_json_string_tool_arguments() {
let input = json!({