mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
f9db9913a0
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.
191 lines
6.3 KiB
Rust
191 lines
6.3 KiB
Rust
//! Stable JSON helpers for cache-sensitive request bodies.
|
|
|
|
use serde_json::Value;
|
|
use sha2::{Digest, Sha256};
|
|
|
|
pub(crate) fn canonicalize_value(value: Value) -> Value {
|
|
match value {
|
|
Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_value).collect()),
|
|
Value::Object(map) => {
|
|
let mut entries = map.into_iter().collect::<Vec<_>>();
|
|
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
|
|
|
let mut sorted = serde_json::Map::new();
|
|
for (key, value) in entries {
|
|
sorted.insert(key, canonicalize_value(value));
|
|
}
|
|
Value::Object(sorted)
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn canonical_json_string(value: &Value) -> String {
|
|
match value {
|
|
Value::Null => "null".to_string(),
|
|
Value::Bool(value) => value.to_string(),
|
|
Value::Number(value) => value.to_string(),
|
|
Value::String(value) => serde_json::to_string(value)
|
|
.expect("serializing a JSON string for canonical output should not fail"),
|
|
Value::Array(values) => {
|
|
let parts = values.iter().map(canonical_json_string).collect::<Vec<_>>();
|
|
format!("[{}]", parts.join(","))
|
|
}
|
|
Value::Object(map) => {
|
|
let mut entries = map.iter().collect::<Vec<_>>();
|
|
entries.sort_by_key(|(left, _)| *left);
|
|
let parts = entries
|
|
.into_iter()
|
|
.map(|(key, value)| {
|
|
let key = serde_json::to_string(key).expect(
|
|
"serializing a JSON object key for canonical output should not fail",
|
|
);
|
|
format!("{key}:{}", canonical_json_string(value))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
format!("{{{}}}", parts.join(","))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn canonicalize_json_string_if_parseable(value: &str) -> String {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return value.to_string();
|
|
}
|
|
|
|
serde_json::from_str::<Value>(trimmed)
|
|
.map(|parsed| canonical_json_string(&parsed))
|
|
.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();
|
|
};
|
|
short_sha256_hex(canonical_json_string(value).as_bytes())
|
|
}
|
|
|
|
pub(crate) fn short_sha256_hex(bytes: &[u8]) -> String {
|
|
let digest = Sha256::digest(bytes);
|
|
digest
|
|
.iter()
|
|
.take(8)
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect::<String>()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn canonical_json_string_sorts_nested_object_keys() {
|
|
let left = json!({
|
|
"b": 2,
|
|
"a": {
|
|
"d": true,
|
|
"c": [3, {"z": 1, "y": 2}]
|
|
}
|
|
});
|
|
let right = json!({
|
|
"a": {
|
|
"c": [3, {"y": 2, "z": 1}],
|
|
"d": true
|
|
},
|
|
"b": 2
|
|
});
|
|
|
|
assert_eq!(canonical_json_string(&left), canonical_json_string(&right));
|
|
assert_eq!(
|
|
short_value_hash(Some(&left)),
|
|
short_value_hash(Some(&right))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalize_value_sorts_map_storage_order() {
|
|
let value = canonicalize_value(json!({"b": 2, "a": 1}));
|
|
|
|
assert_eq!(serde_json::to_string(&value).unwrap(), r#"{"a":1,"b":2}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalize_json_string_if_parseable_sorts_keys_and_removes_whitespace() {
|
|
assert_eq!(
|
|
canonicalize_json_string_if_parseable(r#"{ "b": 2, "a": 1 }"#),
|
|
r#"{"a":1,"b":2}"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalize_json_string_if_parseable_preserves_plain_text() {
|
|
assert_eq!(
|
|
canonicalize_json_string_if_parseable("plain text"),
|
|
"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}"#
|
|
);
|
|
}
|
|
}
|