[Rust Frontend] Fix GLM-5.2 chat template rendering parity (#51426)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Woosuk Kwon <woosuk@inferact.ai>

Woosuk Kwon committed Aug 17, 2026 at 20:16 UTC e0e5a7fb2808504ba86c94f7b379e38496002fd0
5 files changed +106 -6
rust/Cargo.lock
+4 -4
@@ -2402,9 +2402,9 @@ dependencies = [
2402
2403 [[package]]
2404 name = "minijinja"
2405 -version = "2.22.0"
2405 +version = "2.24.0"
2406 source = "registry+https://github.com/rust-lang/crates.io-index"
2407 -checksum = "ef84a52be188a1d4124bd717903fdde96ca4705f2b56adfe2d91fcc57fcb6987"
2407 +checksum = "86886cf6dbf4e614b19c9a1eec9775f021869d7eadde0fc73921a81b90c9b4c9"
2408 dependencies = [
2409 "indexmap 2.13.0",
2410 "memo-map",
@@ -2414,9 +2414,9 @@ dependencies = [
2414
2415 [[package]]
2416 name = "minijinja-contrib"
2417 -version = "2.22.0"
2417 +version = "2.24.0"
2418 source = "registry+https://github.com/rust-lang/crates.io-index"
2419 -checksum = "fd6e279dc925840c2d9ebc1e9f85410611d01292561297463ba3e516c3ad94dd"
2419 +checksum = "bd3e5f077bc2379f0f7d911e7cfdd921114ed99fc884533dca502944cb355b11"
2420 dependencies = [
2421 "minijinja",
2422 "serde",
rust/Cargo.toml
+2 -2
@@ -61,8 +61,8 @@ itertools = "0.14.0"
61 libc = "0.2.177"
62 llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "15adba5e025d8636ba4a334fb379b1371f6196a1", default-features = false, features = ["native-tls"] }
63 mimalloc = "0.1.52"
64 -minijinja = { version = "2.22", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
65 -minijinja-contrib = { version = "2.22", features = ["pycompat"] }
64 +minijinja = { version = "2.24", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
65 +minijinja-contrib = { version = "2.24", features = ["pycompat"] }
66 native-tls-vendored = { package = "native-tls", version = "0.2.18", features = ["vendored"] }
67 ndarray = { version = "0.17", features = ["serde"] }
68 openai-harmony = { package = "oss-harmony", git = "https://github.com/oss-harmony/harmony", tag = "v0.0.11", default-features = false }
rust/src/chat/src/renderer/hf/mod.rs
+66
@@ -290,6 +290,7 @@ struct TemplateToolDefinition {
290 name: String,
291 description: Option<String>,
292 parameters: JsonValue,
293 + #[serde(skip_serializing_if = "Option::is_none")]
294 strict: Option<bool>,
295 }
296
@@ -1280,6 +1281,71 @@ mod tests {
1281 assert_eq!(rendered, "get_weather|city");
1282 }
1283
1284 + #[test]
1285 + fn chat_template_preserves_openai_tool_field_order() {
1286 + let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
1287 + let tools = vec![ChatTool {
1288 + name: "get_weather".to_string(),
1289 + description: Some("Get weather".to_string()),
1290 + parameters: serde_json::json!({"type": "object"}),
1291 + strict: None,
1292 + }];
1293 + request.tool_context = crate::request::ResolvedToolContext::new(
1294 + &request.messages,
1295 + tools,
1296 + Some(ChatToolChoice::Auto),
1297 + true,
1298 + )
1299 + .expect("tool context should resolve");
1300 +
1301 + let rendered = render(
1302 + Some("{% for key, value in tools[0].function.items() %}{{ key }}|{% endfor %}"),
1303 + &request,
1304 + )
1305 + .unwrap();
1306 +
1307 + assert_eq!(rendered, "name|description|parameters|");
1308 + }
1309 +
1310 + #[test]
1311 + fn chat_template_preserves_python_optional_tool_fields() {
1312 + let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
1313 + let tools = vec![
1314 + ChatTool {
1315 + name: "without_strict".to_string(),
1316 + description: None,
1317 + parameters: Value::Null,
1318 + strict: None,
1319 + },
1320 + ChatTool {
1321 + name: "with_strict".to_string(),
1322 + description: Some("description".to_string()),
1323 + parameters: serde_json::json!({"type": "object"}),
1324 + strict: Some(false),
1325 + },
1326 + ];
1327 + request.tool_context = crate::request::ResolvedToolContext::new(
1328 + &request.messages,
1329 + tools,
1330 + Some(ChatToolChoice::Auto),
1331 + true,
1332 + )
1333 + .expect("tool context should resolve");
1334 +
1335 + let rendered = render(
1336 + Some(
1337 + "{% for tool in tools %}{% for key, value in tool.function.items() %}{{ key }}={{ value|tojson }}|{% endfor %};{% endfor %}",
1338 + ),
1339 + &request,
1340 + )
1341 + .unwrap();
1342 +
1343 + assert_eq!(
1344 + rendered,
1345 + "name=\"without_strict\"|description=null|parameters=null|;name=\"with_strict\"|description=\"description\"|parameters={\"type\": \"object\"}|strict=false|;"
1346 + );
1347 + }
1348 +
1349 #[test]
1350 fn chat_template_exposes_assistant_tool_calls_and_tool_messages() {
1351 let request = sample_request(vec![
rust/src/chat/src/renderer/hf/template.rs
+20
@@ -152,6 +152,26 @@ mod tests {
152 assert_eq!(result, "[]");
153 }
154
155 + #[test]
156 + fn test_midchain_dotted_integer_lookup() {
157 + let template = CompiledChatTemplate::new(
158 + "{{ values.0.name }}".to_string(),
159 + ChatTemplateContentFormatOption::Auto,
160 + )
161 + .unwrap();
162 + let mut kwargs = HashMap::new();
163 + kwargs.insert("values".to_string(), serde_json::json!([{"name": "first"}]));
164 +
165 + let result = template
166 + .apply(TemplateContext {
167 + template_kwargs: Some(&kwargs),
168 + ..Default::default()
169 + })
170 + .unwrap();
171 +
172 + assert_eq!(result, "first");
173 + }
174 +
175 #[test]
176 fn test_chat_template_state_invalid_template() {
177 let result = CompiledChatTemplate::new(
rust/src/chat/tests/roundtrip.rs
+14
@@ -185,6 +185,19 @@ impl RoundtripCase {
185 }
186 }
187
188 + /// GLM-5.2 XML-like argument format with `<think>` reasoning tags.
189 + fn glm52() -> Self {
190 + Self {
191 + model_id: "zai-org/GLM-5.2-FP8",
192 + assistant_stop_suffix: "",
193 + tool_call_parser: ParserSelection::Auto,
194 + reasoning_parser: ParserSelection::Auto,
195 + thinking_behavior: ThinkingBehavior::Toggleable { default: true },
196 + json_fmt: compact_json_fmt(),
197 + sort_json_keys: false,
198 + }
199 + }
200 +
201 /// Gemma4 channel reasoning with custom function-call arguments.
202 fn gemma4() -> Self {
203 Self {
@@ -324,6 +337,7 @@ roundtrip_tests! {
337 deepseek_v32 => [tool_call_mix],
338 glm45 => [reasoning_and_content, tool_call_mix],
339 glm47 => [reasoning_and_content, tool_call_mix],
340 + glm52 => [reasoning_and_content, tool_call_mix],
341 seed_oss => [reasoning_and_content, tool_call_mix],
342 step3p5 => [reasoning_and_content],
343 nemotron_v3 => [reasoning_and_content],