mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
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:
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user