fix: coerce empty tool-call arguments to {} in Codex Chat transform

No-argument tool calls produced empty argument strings that passed
through both the streaming response path and the request transform
verbatim. Strict OpenAI-compatible upstreams (e.g. Minimax) reject
`arguments: ""` with a 400 "invalid function arguments json string",
while lenient ones (OpenAI, Kimi) silently treat it as an empty object.

Add canonicalize_tool_arguments / canonicalize_tool_arguments_str helpers
that coerce empty/whitespace arguments to "{}" and apply them at the four
tool-call argument sites (streaming finalize + three request/response
transform paths). Leave function_call_output content untouched, where an
empty string is valid.
This commit is contained in:
Jason
2026-05-21 23:19:54 +08:00
parent 11edc96a15
commit f9db9913a0
3 changed files with 69 additions and 18 deletions
+61
View File
@@ -59,6 +59,34 @@ pub(crate) fn canonicalize_json_string_if_parseable(value: &str) -> String {
.unwrap_or_else(|_| value.to_string())
}
/// Normalize a tool-call `arguments` string into a valid JSON payload.
///
/// Identical to [`canonicalize_json_string_if_parseable`] except that an empty
/// (or whitespace-only) value is coerced to `"{}"` instead of being passed
/// through verbatim. A no-argument tool call must serialize as `"{}"`; strict
/// upstreams such as Minimax reject `arguments: ""` with a 400
/// `invalid function arguments json string` error, whereas lenient ones
/// (OpenAI, Kimi) silently treat it as an empty object.
pub(crate) fn canonicalize_tool_arguments_str(value: &str) -> String {
if value.trim().is_empty() {
return "{}".to_string();
}
canonicalize_json_string_if_parseable(value)
}
/// Normalize a tool-call `arguments` field from a Responses/Chat item.
///
/// Mirrors the inline `match` that several transform paths used to duplicate:
/// a string is canonicalized (with empty coerced to `"{}"`), a structured
/// value is serialized canonically, and a missing field defaults to `"{}"`.
pub(crate) fn canonicalize_tool_arguments(value: Option<&Value>) -> String {
match value {
Some(Value::String(s)) => canonicalize_tool_arguments_str(s),
Some(v) => canonical_json_string(v),
None => "{}".to_string(),
}
}
pub(crate) fn short_value_hash(value: Option<&Value>) -> String {
let Some(value) = value else {
return "absent".to_string();
@@ -126,4 +154,37 @@ mod tests {
"plain text"
);
}
#[test]
fn canonicalize_tool_arguments_str_coerces_empty_to_object() {
assert_eq!(canonicalize_tool_arguments_str(""), "{}");
assert_eq!(canonicalize_tool_arguments_str(" "), "{}");
assert_eq!(canonicalize_tool_arguments_str("\n\t"), "{}");
}
#[test]
fn canonicalize_tool_arguments_str_canonicalizes_valid_json() {
assert_eq!(
canonicalize_tool_arguments_str(r#"{ "b": 2, "a": 1 }"#),
r#"{"a":1,"b":2}"#
);
}
#[test]
fn canonicalize_tool_arguments_handles_field_variants() {
// Missing field -> empty object.
assert_eq!(canonicalize_tool_arguments(None), "{}");
// Empty string field -> empty object.
assert_eq!(canonicalize_tool_arguments(Some(&json!(""))), "{}");
// String field with JSON -> canonicalized.
assert_eq!(
canonicalize_tool_arguments(Some(&json!(r#"{"b":2,"a":1}"#))),
r#"{"a":1,"b":2}"#
);
// Structured (non-string) field -> canonical serialization.
assert_eq!(
canonicalize_tool_arguments(Some(&json!({"b": 2, "a": 1}))),
r#"{"a":1,"b":2}"#
);
}
}
@@ -9,7 +9,7 @@ use super::{
chat_usage_to_responses_usage, response_id_from_chat_id, response_status_from_finish_reason,
},
};
use crate::proxy::json_canonical::canonicalize_json_string_if_parseable;
use crate::proxy::json_canonical::canonicalize_tool_arguments_str;
use crate::proxy::sse::{strip_sse_field, take_sse_block};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
@@ -689,7 +689,7 @@ impl ChatToResponsesState {
let state = self.tools.get_mut(&key).expect("tool state exists");
let output_index = state.output_index.unwrap_or(0);
let arguments = canonicalize_json_string_if_parseable(&state.arguments);
let arguments = canonicalize_tool_arguments_str(&state.arguments);
let item = response_function_call_item(
&state.item_id,
"completed",
@@ -11,7 +11,9 @@ use super::codex_chat_common::{
use crate::provider::CodexChatReasoningConfig;
use crate::proxy::{
error::ProxyError,
json_canonical::{canonical_json_string, canonicalize_json_string_if_parseable},
json_canonical::{
canonical_json_string, canonicalize_json_string_if_parseable, canonicalize_tool_arguments,
},
};
use serde_json::{json, Value};
@@ -730,11 +732,7 @@ fn responses_function_call_to_chat_tool_call(item: &Value) -> Value {
.and_then(|v| v.as_str())
.unwrap_or("");
let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
let arguments = match item.get("arguments") {
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
Some(v) => canonical_json_string(v),
None => "{}".to_string(),
};
let arguments = canonicalize_tool_arguments(item.get("arguments"));
json!({
"id": call_id,
@@ -980,11 +978,7 @@ fn chat_tool_call_to_response_item(
.unwrap_or_else(|| format!("call_{index}"));
let function = tool_call.get("function").unwrap_or(&Value::Null);
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
let arguments = match function.get("arguments") {
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
Some(v) => canonical_json_string(v),
None => "{}".to_string(),
};
let arguments = canonicalize_tool_arguments(function.get("arguments"));
let item_id = format!("fc_{call_id}");
response_function_call_item(&item_id, "completed", &call_id, name, &arguments, reasoning)
@@ -1003,11 +997,7 @@ fn chat_legacy_function_call_to_response_item(
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("");
let arguments = match function_call.get("arguments") {
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
Some(v) => canonical_json_string(v),
None => "{}".to_string(),
};
let arguments = canonicalize_tool_arguments(function_call.get("arguments"));
let item_id = format!("fc_{call_id}");
response_function_call_item(&item_id, "completed", call_id, name, &arguments, reasoning)