Chat API: skip tool calls with missing function names (#4159)

* Chat API: skip tool calls with missing function names

Some providers send empty or absent function names in streaming
tool call deltas. Previously these produced invalid output items.

- Don't overwrite accumulated state.name with empty deltas
- Skip tool calls that never received a valid name (instead of
  falling back to 'unknown_tool')
- Apply the same defensive guard in finalize_tools and the
  non-streaming path

* Address review: defer empty-name skip to finalization

Require both call_id and name before triggering should_add,
instead of skipping eagerly when name is absent in the first
delta.  This handles providers that send id before name,
as suggested in the Codex review.

* Guard legacy function_call against empty name

Return Option<Value> from chat_legacy_function_call_to_response_item,
returning None when function_call.name is missing or empty. This covers
the legacy message.function_call path that the original guard missed.

* Remove unreachable unknown_tool fallback

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Huei Feng
2026-06-19 23:38:25 +08:00
committed by GitHub
parent 510aa250c5
commit 455556380b
2 changed files with 43 additions and 14 deletions
@@ -1398,6 +1398,14 @@ fn chat_tool_calls_to_response_output_items(
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
for (index, tool_call) in tool_calls.iter().enumerate() {
// Skip tool calls with missing function names (defensive: some models
// 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");
continue;
}
output.push(chat_tool_call_to_response_item(
tool_call,
index,
@@ -1406,11 +1414,13 @@ fn chat_tool_calls_to_response_output_items(
));
}
} else if let Some(function_call) = message.get("function_call") {
output.push(chat_legacy_function_call_to_response_item(
if let Some(item) = chat_legacy_function_call_to_response_item(
function_call,
reasoning,
tool_context,
));
) {
output.push(item);
}
}
output
@@ -1448,7 +1458,7 @@ fn chat_legacy_function_call_to_response_item(
function_call: &Value,
reasoning: Option<&str>,
tool_context: &CodexToolContext,
) -> Value {
) -> Option<Value> {
let call_id = function_call
.get("id")
.and_then(|v| v.as_str())
@@ -1458,10 +1468,18 @@ fn chat_legacy_function_call_to_response_item(
.get("name")
.and_then(|v| v.as_str())
.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");
return None;
}
let arguments = canonicalize_tool_arguments(function_call.get("arguments"));
let item_id = response_tool_call_item_id_from_chat_name(call_id, name, tool_context);
response_tool_call_item_from_chat_name(
Some(response_tool_call_item_from_chat_name(
&item_id,
"completed",
call_id,
@@ -1469,7 +1487,7 @@ fn chat_legacy_function_call_to_response_item(
&arguments,
reasoning,
tool_context,
)
))
}
pub(crate) fn response_tool_call_item_id_from_chat_name(