mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user