@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 2d481f8a94
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

[Bugfix][Rust Frontend] Stop unescaping XML-style tool-call parameter values (#45025)

Signed-off-by: Ting Sun <suntcrick@gmail.com>

Ting SUN committed Jun 11, 2026 at 10:05 UTC 2d481f8a946ee0521872af0f098674a8ee01ce4a
6 files changed +36 -120
rust/src/tool-parser/src/deepseek_dsml/deepseek_v32.rs
+2 -2
@@ -186,7 +186,7 @@ mod tests {
186 }
187
188 #[test]
189 - fn deepseek_v32_parse_complete_unescapes_literal_closing_tags_in_parameter_value() {
189 + fn deepseek_v32_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() {
190 let mut parser = DeepSeekV32ToolParser::new(&test_tools());
191 let output = parser
192 .parse_complete(&build_tool_call(
@@ -204,7 +204,7 @@ mod tests {
204 assert_eq!(
205 serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
206 json!({
207 - "location": "Hangzhou </|DSML|parameter></|DSML|invoke></|DSML|function_calls>",
207 + "location": "Hangzhou &lt;/|DSML|parameter&gt;&lt;/|DSML|invoke&gt;&lt;/|DSML|function_calls&gt;",
208 "date": "2026-05-08",
209 })
210 );
rust/src/tool-parser/src/deepseek_dsml/mod.rs
+2 -2
@@ -5,7 +5,7 @@ use winnow::stream::Partial;
5 use winnow::token::{literal, rest, take_until};
6
7 use super::parameters::ToolSchemas;
8 -use super::utils::{parse_buffered_event, safe_text_len, xml_unescape};
8 +use super::utils::{parse_buffered_event, safe_text_len};
9 use super::{Result, ToolCallDelta, ToolParserOutput};
10 use crate::Tool;
11
@@ -251,7 +251,7 @@ fn parse_parameter(input: &mut &str) -> ModalResult<DsmlParameter> {
251 is_string: string_attr.map(|value| value == "true"),
252 _: ws0,
253 _: ">",
254 - value: take_until(0.., PARAMETER_END).map(xml_unescape).map(|value| value.into_owned()),
254 + value: take_until(0.., PARAMETER_END).map(str::to_string),
255 _: literal(PARAMETER_END),
256 }}
257 .parse_next(input)
rust/src/tool-parser/src/glm_xml/mod.rs
+5 -5
@@ -5,7 +5,7 @@ use winnow::stream::Partial;
5 use winnow::token::{literal, rest, take_until, take_while};
6
7 use super::parameters::ToolSchemas;
8 -use super::utils::{parse_buffered_event, safe_text_len, xml_unescape};
8 +use super::utils::{parse_buffered_event, safe_text_len};
9 use super::{Result, ToolCallDelta, ToolParserOutput};
10 use crate::Tool;
11
@@ -238,12 +238,12 @@ fn parse_parameter(input: &mut &str) -> ModalResult<(String, String)> {
238 _: literal(ARG_KEY_END),
239 _: ws0,
240 _: literal(ARG_VALUE_START),
241 - take_until(0.., ARG_VALUE_END).map(str::trim).map(xml_unescape),
241 + take_until(0.., ARG_VALUE_END).map(str::trim),
242 _: literal(ARG_VALUE_END),
243 )
244 .parse_next(input)?;
245
246 - Ok((key.trim().to_string(), value.into_owned()))
246 + Ok((key.trim().to_string(), value.to_string()))
247 }
248
249 #[cfg(test)]
@@ -320,7 +320,7 @@ mod tests {
320 }
321
322 #[test]
323 - fn glm45_parse_complete_unescapes_literal_closing_tags_in_arg_value() {
323 + fn glm45_parse_complete_preserves_raw_closing_tag_text_in_arg_value() {
324 let mut parser = Glm45MoeToolParser::new(&test_tools());
325 let output = parser
326 .parse_complete(&glm45_tool_call(
@@ -335,7 +335,7 @@ mod tests {
335 assert_eq!(
336 serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
337 json!({
338 - "city": "Paris </arg_value></tool_call>",
338 + "city": "Paris &lt;/arg_value&gt;&lt;/tool_call&gt;",
339 "date": "2026-05-08",
340 })
341 );
rust/src/tool-parser/src/minimax_m2.rs
+22 -5
@@ -5,7 +5,7 @@ use winnow::stream::Partial;
5 use winnow::token::{literal, rest, take_until};
6
7 use super::parameters::ToolSchemas;
8 -use super::utils::{parse_buffered_event, safe_text_len, xml_unescape};
8 +use super::utils::{parse_buffered_event, safe_text_len};
9 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10 use crate::Tool;
11
@@ -213,12 +213,12 @@ fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
213 _: (ws1, literal("name=")),
214 attr_value,
215 _: literal(">"),
216 - take_until(0.., PARAMETER_END).map(xml_unescape),
216 + take_until(0.., PARAMETER_END),
217 _: literal(PARAMETER_END),
218 )
219 .parse_next(input)?;
220
221 - Ok((name.trim().to_string(), value.into_owned()))
221 + Ok((name.trim().to_string(), value.to_string()))
222 }
223
224 /// Parse a quoted or unquoted XML attribute value.
@@ -364,7 +364,24 @@ mod tests {
364 }
365
366 #[test]
367 - fn minimax_m2_parse_complete_unescapes_literal_closing_tags_in_parameter_value() {
367 + fn minimax_m2_parse_complete_preserves_raw_entities_in_parameter_value() {
368 + // The MiniMax-M2 chat template renders string parameter values RAW (no
369 + // XML escaping), so a value the user wants to be the literal text
370 + // "Tom &amp; Jerry &lt;3" is emitted verbatim. The parser must preserve
371 + // it; xml_unescape currently decodes it, corrupting the bytes.
372 + let mut parser = MinimaxM2ToolParser::new(&test_tools());
373 + let output = parser
374 + .parse_complete(&build_tool_block(&[(
375 + "get_weather",
376 + vec![("city", "Tom &amp; Jerry &lt;3")],
377 + )]))
378 + .unwrap();
379 + let args: Value = serde_json::from_str(&output.calls[0].arguments).unwrap();
380 + assert_eq!(args["city"], json!("Tom &amp; Jerry &lt;3"));
381 + }
382 +
383 + #[test]
384 + fn minimax_m2_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() {
385 let mut parser = MinimaxM2ToolParser::new(&test_tools());
386 let output = parser
387 .parse_complete(&build_tool_block(&[(
@@ -382,7 +399,7 @@ mod tests {
399 assert_eq!(
400 serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
401 json!({
385 - "city": "Seattle </parameter></invoke></minimax:tool_call>",
402 + "city": "Seattle &lt;/parameter&gt;&lt;/invoke&gt;&lt;/minimax:tool_call&gt;",
403 "days": 5,
404 })
405 );
rust/src/tool-parser/src/qwen_coder.rs
+5 -5
@@ -5,7 +5,7 @@ use winnow::stream::Partial;
5 use winnow::token::{literal, take_until};
6
7 use super::parameters::ToolSchemas;
8 -use super::utils::{parse_buffered_event, safe_text_len, xml_unescape};
8 +use super::utils::{parse_buffered_event, safe_text_len};
9 use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput};
10 use crate::Tool;
11
@@ -201,12 +201,12 @@ fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
201 _: literal(PARAMETER_START),
202 take_until(1.., ">"),
203 _: ">",
204 - take_until(0.., PARAMETER_END).map(trim_one_wrapping_newline).map(xml_unescape),
204 + take_until(0.., PARAMETER_END).map(trim_one_wrapping_newline),
205 _: literal(PARAMETER_END),
206 )
207 .parse_next(input)?;
208
209 - Ok((name.to_string(), value.into_owned()))
209 + Ok((name.to_string(), value.to_string()))
210 }
211
212 /// Parse a Qwen Coder tool-call body.
@@ -414,7 +414,7 @@ mod tests {
414 }
415
416 #[test]
417 - fn qwen_coder_parse_complete_unescapes_literal_closing_tags_in_parameter_value() {
417 + fn qwen_coder_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() {
418 let mut parser = Qwen3CoderToolParser::new(&test_tools());
419 let output = parser
420 .parse_complete(&build_tool_call(
@@ -433,7 +433,7 @@ mod tests {
433 assert_eq!(
434 serde_json::from_str::<Value>(&output.calls[0].arguments).unwrap(),
435 json!({
436 - "location": "杭州 </parameter></function></tool_call>",
436 + "location": "杭州 &lt;/parameter&gt;&lt;/function&gt;&lt;/tool_call&gt;",
437 "date": "2026-05-08",
438 })
439 );
rust/src/tool-parser/src/utils.rs
-101
@@ -1,7 +1,5 @@
1 //! Shared helpers for tool parsers.
2
3 -use std::borrow::Cow;
4 -
3 use winnow::error::{ContextError, ErrMode, ModalResult, Needed, StrContext, StrContextValue};
4 use winnow::stream::{Offset, Partial, Stream};
5
@@ -68,73 +66,6 @@ pub(super) fn safe_text_len(input: &mut Partial<&str>, marker: &str) -> ModalRes
66 Ok(emit_len)
67 }
68
71 -/// Decode XML/HTML entities in XML-style parameter values.
72 -pub(super) fn xml_unescape(value: &str) -> Cow<'_, str> {
73 - if !value.as_bytes().contains(&b'&') {
74 - return Cow::Borrowed(value);
75 - }
76 -
77 - let mut output: Option<String> = None;
78 - let mut copied_len = 0;
79 - let mut rest = value;
80 -
81 - while let Some(ampersand) = rest.find('&') {
82 - let before_ampersand = &rest[..ampersand];
83 - let after_ampersand = &rest[ampersand + '&'.len_utf8()..];
84 - if let Some(semicolon) = after_ampersand.find(';') {
85 - let entity = &after_ampersand[..semicolon];
86 - if let Some(decoded) = decode_xml_entity(entity) {
87 - match &mut output {
88 - Some(output) => output.push_str(before_ampersand),
89 - None => {
90 - let mut new_output = String::with_capacity(value.len());
91 - new_output.push_str(&value[..copied_len + ampersand]);
92 - output = Some(new_output);
93 - }
94 - }
95 - let output = output.as_mut().expect("output is initialized above");
96 - output.push(decoded);
97 - let consumed_len = ampersand + '&'.len_utf8() + semicolon + ';'.len_utf8();
98 - copied_len += consumed_len;
99 - rest = &rest[consumed_len..];
100 - continue;
101 - }
102 - }
103 -
104 - if let Some(output) = &mut output {
105 - output.push_str(before_ampersand);
106 - output.push('&');
107 - }
108 - let consumed_len = ampersand + '&'.len_utf8();
109 - copied_len += consumed_len;
110 - rest = after_ampersand;
111 - }
112 -
113 - if let Some(mut output) = output {
114 - output.push_str(rest);
115 - Cow::Owned(output)
116 - } else {
117 - Cow::Borrowed(value)
118 - }
119 -}
120 -
121 -fn decode_xml_entity(entity: &str) -> Option<char> {
122 - match entity {
123 - "amp" => Some('&'),
124 - "lt" => Some('<'),
125 - "gt" => Some('>'),
126 - "quot" => Some('"'),
127 - "apos" => Some('\''),
128 - entity if entity.starts_with("#x") || entity.starts_with("#X") => {
129 - u32::from_str_radix(&entity[2..], 16).ok().and_then(char::from_u32)
130 - }
131 - entity if entity.starts_with('#') => {
132 - entity[1..].parse::<u32>().ok().and_then(char::from_u32)
133 - }
134 - _ => None,
135 - }
136 -}
137 -
69 /// Streaming lexical state for a top-level JSON object.
70 #[derive(Debug, Clone, Default, PartialEq, Eq)]
71 pub(super) struct JsonObjectScanState {
@@ -340,7 +271,6 @@ pub(super) fn incomplete<T>() -> ModalResult<T> {
271
272 #[cfg(test)]
273 mod tests {
343 - use std::borrow::Cow;
274
275 use expect_test::expect;
276 use winnow::error::ErrMode;
@@ -348,7 +278,6 @@ mod tests {
278
279 use super::{
280 JsonObjectScanState, json_str, partial_prefix_len, safe_text_len, take_json_object,
351 - xml_unescape,
281 };
282
283 #[test]
@@ -406,36 +335,6 @@ mod tests {
335 assert!(matches!(error, ErrMode::Incomplete(_)));
336 }
337
409 - #[test]
410 - fn xml_unescape_decodes_common_entities() {
411 - assert_eq!(
412 - xml_unescape("&lt;tag attr=&quot;value&quot;&gt;Tom &amp; Jerry&apos;s&lt;/tag&gt;"),
413 - r#"<tag attr="value">Tom & Jerry's</tag>"#
414 - );
415 - }
416 -
417 - #[test]
418 - fn xml_unescape_decodes_numeric_entities() {
419 - assert_eq!(xml_unescape("&#60;tag&#x3E;&#x1F600;"), "<tag>😀");
420 - }
421 -
422 - #[test]
423 - fn xml_unescape_preserves_unknown_and_incomplete_entities() {
424 - let output = xml_unescape("Tom & Jerry &unknown; &amp");
425 -
426 - assert!(matches!(output, Cow::Borrowed(_)));
427 - assert_eq!(output, "Tom & Jerry &unknown; &amp");
428 - }
429 -
430 - #[test]
431 - fn xml_unescape_borrows_when_no_entity_is_present() {
432 - let input = "plain text";
433 - let output = xml_unescape(input);
434 -
435 - assert!(matches!(output, Cow::Borrowed(_)));
436 - assert_eq!(output, input);
437 - }
438 -
338 #[test]
339 fn take_json_object_consumes_simple_object() {
340 let mut state = JsonObjectScanState::default();