Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 286 additions & 3 deletions crates/higgs-engine/src/chat_template.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use minijinja::value::Kwargs;
use minijinja::{Environment, Value};
use serde::Serialize;

Expand Down Expand Up @@ -171,9 +172,107 @@ impl ChatTemplateRenderer {
}
}

/// Custom tojson filter for minijinja (used by HF chat templates).
/// Normalise a tool-call JSON object so Qwen-Hermes-style chat templates
/// can render it without crashing on `tool_call.arguments|items`.
///
/// Two transformations are applied in place:
///
/// 1. **Flatten `function.{name,arguments}` to top level.** The `OpenAI`
/// request shape nests them under `function`; Qwen's
/// `chat_template.jinja` references `tool_call.name` and
/// `tool_call.arguments` directly. After this call, both shapes are
/// accessible.
/// 2. **Coerce `arguments` to a mapping.** `OpenAI` sends
/// `function.arguments` as a JSON-encoded string, but Qwen's template
/// iterates it via `|items`. A string that parses to a JSON object is
/// replaced by that object; anything that does not resolve to an object
/// (unparseable strings, or JSON that isn't an object) is coerced to an
/// empty object `{}` by [`normalize_arguments_value`] so the template
/// can't raise `cannot convert value into pairs`. The original string
/// does NOT survive when it isn't object-shaped.
///
/// Other fields (`id`, `type`, …) are preserved unchanged. Callers that
/// already supply the flat shape pay only the cost of a `serde_json::Value`
/// match.
pub fn normalize_tool_call_for_template(tc: &mut serde_json::Value) {
let Some(obj) = tc.as_object_mut() else {
return;
};

// Promote `function.name` / `function.arguments` to the top level.
if let Some(function) = obj.get("function").cloned() {
if let Some(func_obj) = function.as_object() {
if !obj.contains_key("name") {
if let Some(name) = func_obj.get("name") {
obj.insert("name".to_owned(), name.clone());
}
}
if !obj.contains_key("arguments") {
if let Some(arguments) = func_obj.get("arguments") {
obj.insert("arguments".to_owned(), arguments.clone());
}
}
}
}

// Normalize the top-level `arguments` (used by Qwen-flat templates).
if let Some(args) = obj.get_mut("arguments") {
normalize_arguments_value(args);
}

// Normalize the nested `function.arguments` too. Qwen's
// `chat_template.jinja` lines 107-108 rebind `tool_call` to
// `tool_call.function` when present, so if we only normalised the
// top-level copy the template still walks into a string and crashes
// at `|items`. Templates that don't rebind are unaffected.
if let Some(function) = obj.get_mut("function") {
if let Some(func_obj) = function.as_object_mut() {
if let Some(nested_args) = func_obj.get_mut("arguments") {
normalize_arguments_value(nested_args);
}
}
}
}

/// Coerce a `tool_call.arguments` (or `function.arguments`) value into
/// the mapping shape that `chat_template.jinja:120` requires.
///
/// 1. If it's a JSON-string, try to parse it back to a `Value`.
/// 2. If the result still isn't an object (null, bool, number, array,
/// or unparseable string), coerce to an empty object so the
/// template's `|items` doesn't raise. A warn is logged so the
/// pathological shape is visible.
fn normalize_arguments_value(args: &mut serde_json::Value) {
if let Some(s) = args.as_str() {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
*args = parsed;
}
}
if args.is_object() {
return;
}
let shape = match args {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
// `is_object()` already returned for this case above.
serde_json::Value::Object(_) => "object",
};
tracing::warn!(
shape,
"tool_call arguments not a mapping after normalization; coercing to empty object so the chat template can render"
);
*args = serde_json::Value::Object(serde_json::Map::new());
}

/// `tojson` filter. `_kwargs` absorbs keyword arguments HF chat templates pass
/// — notably `tojson(ensure_ascii=false)` (e.g. `MiniCPM5`). `serde_json` already
/// emits UTF-8, which matches `ensure_ascii=false`, so the kwarg is accepted and
/// ignored rather than failing the render with "too many arguments".
#[allow(clippy::needless_pass_by_value)]
fn tojson_filter(value: Value) -> Result<String, minijinja::Error> {
fn tojson_filter(value: Value, _kwargs: Kwargs) -> Result<String, minijinja::Error> {
let serialized = serde_json::to_string(&value).map_err(|e| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
Expand All @@ -185,7 +284,13 @@ fn tojson_filter(value: Value) -> Result<String, minijinja::Error> {
}

#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
#[allow(
clippy::panic,
clippy::unwrap_used,
clippy::expect_used,
clippy::shadow_unrelated,
clippy::shadow_reuse
)]
mod tests {
use super::*;

Expand Down Expand Up @@ -240,6 +345,20 @@ mod tests {
assert_eq!(result, r#""hello""#);
}

/// HF templates (e.g. `MiniCPM5` at `chat:6`) call `tojson(ensure_ascii=…)`.
/// The filter must accept the kwarg instead of failing with "too many
/// arguments"; the value is ignored since `serde_json` emits UTF-8.
#[test]
fn test_tojson_filter_accepts_ensure_ascii_kwarg() {
let env = tojson_env(r"{{ value | tojson(ensure_ascii=false) }}");
let tmpl = env.get_template("test").unwrap();
let result = tmpl
.render(minijinja::context! { value => "café" })
.unwrap();
// UTF-8 preserved (not \u-escaped), and the call did not error.
assert_eq!(result, "\"café\"");
}

#[test]
fn test_invalid_template_syntax_returns_error() {
assert!(ChatTemplateRenderer::new("{%- invalid syntax %}}}").is_err());
Expand Down Expand Up @@ -593,4 +712,168 @@ TOOLS:{{ tools | length }}
.unwrap();
assert!(ChatTemplateRenderer::try_from_model_dir(dir.path()).is_err());
}

// -----------------------------------------------------------------
// normalize_tool_call_for_template
// -----------------------------------------------------------------
//
// Invariants asserted, one test per shape we observed in production:
//
// 1. `OpenAI` shape (name/arguments nested under `function`,
// arguments as JSON-encoded STRING) → after normalize, top-level
// name and arguments-as-mapping. This is the case that crashed
// Qwen's `chat_template.jinja:120` with "cannot convert value
// into pairs".
// 2. Qwen-flat shape (top-level name/arguments, arguments already
// an object) → no-op, identity.
// 3. Non-JSON string in `function.arguments` → flattened but kept
// as string (template can decide what to do).
// 4. Non-object input (string, null, array) → no-op, can't panic.

fn parsed(s: &str) -> serde_json::Value {
serde_json::from_str(s).unwrap()
}

#[test]
fn normalize_openai_shape_to_qwen_flat() {
let mut tc = parsed(
r#"{
"id": "call_0",
"type": "function",
"function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" }
}"#,
);
normalize_tool_call_for_template(&mut tc);

// Top-level name and arguments are present.
assert_eq!(tc.get("name").and_then(|v| v.as_str()), Some("get_weather"));
// arguments is now an OBJECT, not a string.
let args = tc.get("arguments").unwrap();
assert!(
args.is_object(),
"expected arguments to be an object, got {args:?}"
);
assert_eq!(args.get("city").and_then(|v| v.as_str()), Some("Paris"));
// id and type preserved.
assert_eq!(tc.get("id").and_then(|v| v.as_str()), Some("call_0"));
assert_eq!(tc.get("type").and_then(|v| v.as_str()), Some("function"));
}

#[test]
fn normalize_qwen_flat_shape_is_noop() {
let original = parsed(r#"{ "name": "search", "arguments": { "q": "rust" } }"#);
let mut tc = original.clone();
normalize_tool_call_for_template(&mut tc);
assert_eq!(tc, original, "already-flat shape must be a no-op");
}

#[test]
fn normalize_unparseable_string_arguments_coerced_to_empty_object() {
// Unparseable string arguments are coerced to `{}` so the chat
// template's `|items` doesn't blow up. The model loses the
// pathological arguments, which is strictly better than the
// entire conversation 500-ing.
let mut tc = parsed(
r#"{
"function": { "name": "f", "arguments": "this is not json" }
}"#,
);
normalize_tool_call_for_template(&mut tc);
assert_eq!(tc.get("name").and_then(|v| v.as_str()), Some("f"));
assert_eq!(tc.get("arguments"), Some(&parsed("{}")));
}

#[test]
fn normalize_non_object_is_noop() {
let mut s = parsed(r#""not a tool call""#);
normalize_tool_call_for_template(&mut s);
assert_eq!(s, parsed(r#""not a tool call""#));

let mut n = parsed("null");
normalize_tool_call_for_template(&mut n);
assert_eq!(n, parsed("null"));

let mut a = parsed("[1, 2, 3]");
normalize_tool_call_for_template(&mut a);
assert_eq!(a, parsed("[1, 2, 3]"));
}

/// Qwen's `chat_template.jinja:107-108` rebinds `tool_call` to
/// `tool_call.function` when the latter is defined. If we only
/// normalised the hoisted top-level `arguments` and left
/// `function.arguments` as the original JSON-encoded string, the
/// rebinding would walk straight into a string and the template
/// would crash at `|items`. This test pins both paths.
#[test]
fn normalize_handles_qwen_rebind_to_function() {
let mut tc = parsed(
r#"{
"id": "call_0",
"type": "function",
"function": { "name": "f", "arguments": "{\"city\":\"London\"}" }
}"#,
);
normalize_tool_call_for_template(&mut tc);

// Top-level arguments — Qwen-flat templates see this.
let top_args = tc.get("arguments").unwrap();
assert!(
top_args.is_object(),
"top-level arguments must be a mapping"
);
assert_eq!(
top_args.get("city").and_then(|v| v.as_str()),
Some("London")
);

// Nested function.arguments — Qwen's standard template walks this
// after rebinding via `set tool_call = tool_call.function`.
let func_args = tc
.get("function")
.and_then(|f| f.get("arguments"))
.expect("function.arguments must still be present");
assert!(
func_args.is_object(),
"nested function.arguments must ALSO be a mapping, got {func_args:?}"
);
assert_eq!(
func_args.get("city").and_then(|v| v.as_str()),
Some("London")
);
}

/// Arguments shaped as something other than an object after normalization
/// must be coerced to an empty object so the chat template's
/// `tool_call.arguments|items` can render. Without this, Qwen's
/// `chat_template.jinja:120` raises `cannot convert value into pairs`
/// when prior conversation turns carried weird tool-call shapes.
#[test]
fn arguments_coerced_to_empty_object_when_not_mapping() {
// Null arguments → empty object.
let mut tc = parsed(r#"{ "name": "f", "arguments": null }"#);
normalize_tool_call_for_template(&mut tc);
assert_eq!(tc.get("arguments"), Some(&parsed("{}")));

// Array arguments → empty object.
let mut tc = parsed(r#"{ "name": "f", "arguments": [1, 2, 3] }"#);
normalize_tool_call_for_template(&mut tc);
assert_eq!(tc.get("arguments"), Some(&parsed("{}")));

// Number arguments → empty object.
let mut tc = parsed(r#"{ "name": "f", "arguments": 42 }"#);
normalize_tool_call_for_template(&mut tc);
assert_eq!(tc.get("arguments"), Some(&parsed("{}")));

// Unparseable string arguments → empty object (the model can't
// express what it wanted; better than a 500).
let mut tc = parsed(r#"{ "name": "f", "arguments": "this is not json" }"#);
normalize_tool_call_for_template(&mut tc);
assert_eq!(tc.get("arguments"), Some(&parsed("{}")));

// Valid-JSON-string-that-parses-to-array → coerced via the
// second pass (parse succeeds, result is still not an object).
let mut tc = parsed(r#"{ "name": "f", "arguments": "[1,2,3]" }"#);
normalize_tool_call_for_template(&mut tc);
assert_eq!(tc.get("arguments"), Some(&parsed("{}")));
}
}
Loading