diff --git a/src-tauri/src/proxy/json_canonical.rs b/src-tauri/src/proxy/json_canonical.rs index 2305a12a9..258a4fc57 100644 --- a/src-tauri/src/proxy/json_canonical.rs +++ b/src-tauri/src/proxy/json_canonical.rs @@ -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}"# + ); + } } diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index 83d53e457..6406993b7 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -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", diff --git a/src-tauri/src/proxy/providers/transform_codex_chat.rs b/src-tauri/src/proxy/providers/transform_codex_chat.rs index 53cdf11ae..2060aae3e 100644 --- a/src-tauri/src/proxy/providers/transform_codex_chat.rs +++ b/src-tauri/src/proxy/providers/transform_codex_chat.rs @@ -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)