[Bugfix][Tool Parser] Preserve whitespace in parameter values (MiniMax M2, Qwen3, MiniCPM5 XML) (#48846)
Signed-off-by: mosya415 <263250241+mosya415@users.noreply.github.com> Signed-off-by: Ben Browning <56071+bbrowning@users.noreply.github.com> Co-authored-by: mosya415 <263250241+mosya415@users.noreply.github.com> Co-authored-by: Ben Browning <56071+bbrowning@users.noreply.github.com>
mosya415 committed
Jul 17, 2026 at 19:45 UTC
11d291511a35bfa2a9ecb8fd21ee1c48b3a78d6b
7 files changed
+164
-9
tests/parser/engine/test_qwen3.py
+2
-2
@@ -454,10 +454,10 @@ class TestStreaming:
454
455
args_after_partial_tag = collect_tool_arguments(results[:4])
456
assert "<param" not in args_after_partial_tag
457
- assert args_after_partial_tag == '{"query": "hello'
457
+ assert args_after_partial_tag == '{"query": "hello '
458
459
args_text = collect_tool_arguments(results)
460
- assert json.loads(args_text) == {"query": "hello", "limit": "10"}
460
+ assert json.loads(args_text) == {"query": "hello ", "limit": "10"}
461
462
def test_streaming_numeric_values(self, parser, mock_request):
463
chunks = [
tests/tool_parsers/test_minicpm5xml_tool_parser.py
+52
@@ -733,3 +733,55 @@ def test_extract_tool_calls_streaming_multiple(parser: ToolParser) -> None:
733
"nums": [7, 8, 9],
734
"exact": False,
735
}
736
+
737
+
738
+def make_tools_write() -> list[ChatCompletionToolsParam]:
739
+ return [
740
+ _tool(
741
+ "write_file",
742
+ {
743
+ "type": "object",
744
+ "properties": {"content": {"type": "string"}},
745
+ "required": ["content"],
746
+ },
747
+ )
748
+ ]
749
+
750
+
751
+class TestParameterWhitespace:
752
+ """CDATA is verbatim, so its whitespace must survive."""
753
+
754
+ def test_cdata_whitespace_preserved(self, parser: ToolParser) -> None:
755
+ request = make_request(make_tools_write())
756
+ text = (
757
+ '<function name="write_file">'
758
+ '<param name="content"><![CDATA[ def foo():\n pass\n]]></param>'
759
+ "</function>\n"
760
+ )
761
+
762
+ out = parser.extract_tool_calls(text, request)
763
+
764
+ assert len(out.tool_calls) == 1
765
+ args = json.loads(out.tool_calls[0].function.arguments)
766
+ assert args["content"] == " def foo():\n pass\n"
767
+
768
+ def test_cdata_whitespace_preserved_streaming(self, parser: ToolParser) -> None:
769
+ """Value split across chunks, i.e. the partial path."""
770
+ request = make_request(make_tools_write())
771
+ chunks = [
772
+ '<function name="write_file">',
773
+ '<param name="content"><![CDATA[ def foo():\n',
774
+ " pass\n]]></param></function>\n",
775
+ ]
776
+
777
+ reconstructor = run_tool_extraction_streaming(
778
+ parser,
779
+ chunks,
780
+ request,
781
+ assert_one_tool_per_delta=False,
782
+ )
783
+
784
+ assert len(reconstructor.tool_calls) == 1
785
+ assert json.loads(reconstructor.tool_calls[0].function.arguments) == {
786
+ "content": " def foo():\n pass\n"
787
+ }
tests/tool_parsers/test_minimax_m2_tool_parser.py
+34
@@ -567,3 +567,37 @@ class TestNoneStringPreservation:
567
assert len(tc) == 1
568
parsed = json.loads(tc[0]["arguments"])
569
assert parsed["value"] == "nil"
570
+
571
+
572
+class TestParameterWhitespace:
573
+ """Parameter values must preserve surrounding whitespace."""
574
+
575
+ def test_whitespace_preserved(self, parser):
576
+ results = _feed(
577
+ parser,
578
+ [
579
+ '<minimax:tool_call><invoke name="echo">'
580
+ '<parameter name="msg"> hi </parameter>'
581
+ "</invoke></minimax:tool_call>",
582
+ ],
583
+ )
584
+ tc = _collect_tool_calls(results)
585
+ assert len(tc) == 1
586
+ assert json.loads(tc[0]["arguments"]) == {"msg": " hi "}
587
+
588
+ def test_whitespace_preserved_across_chunks(self, parser):
589
+ """Value split before </parameter> arrives, i.e. the partial path."""
590
+ results = _feed(
591
+ parser,
592
+ [
593
+ '<minimax:tool_call><invoke name="write">'
594
+ '<parameter name="content"> def foo():\n',
595
+ " return 1\n",
596
+ "</parameter></invoke></minimax:tool_call>",
597
+ ],
598
+ )
599
+ tc = _collect_tool_calls(results)
600
+ assert len(tc) == 1
601
+ assert json.loads(tc[0]["arguments"]) == {
602
+ "content": " def foo():\n return 1\n"
603
+ }
tests/tool_parsers/test_qwen3coder_tool_parser.py
+55
@@ -1482,3 +1482,58 @@ def test_adjust_request_required_prefers_structural_tag(
1482
out = TestParser(MagicMock(), tools=sample_tools).adjust_request(req)
1483
assert out.structured_outputs is not None
1484
assert out.structured_outputs.structural_tag is not None
1485
+
1486
+
1487
+WRITE_FILE_TOOLS = [
1488
+ ChatCompletionToolsParam(
1489
+ type="function",
1490
+ function={
1491
+ "name": "write_file",
1492
+ "parameters": {
1493
+ "type": "object",
1494
+ "properties": {"content": {"type": "string"}},
1495
+ },
1496
+ },
1497
+ )
1498
+]
1499
+
1500
+WRITE_FILE_OUTPUT = (
1501
+ "<tool_call>\n<function=write_file>\n"
1502
+ "<parameter=content>\n"
1503
+ " def foo():\n return 1\n\n"
1504
+ "</parameter>\n</function>\n</tool_call>"
1505
+)
1506
+
1507
+EXPECTED_CONTENT = " def foo():\n return 1\n"
1508
+
1509
+
1510
+class TestParameterWhitespace:
1511
+ """Wrapping newlines are markup; the rest of the value is preserved."""
1512
+
1513
+ def test_whitespace_preserved(self, qwen3_tokenizer):
1514
+ parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=WRITE_FILE_TOOLS)
1515
+ request = ChatCompletionRequest(
1516
+ model=MODEL, messages=[], tools=WRITE_FILE_TOOLS
1517
+ )
1518
+
1519
+ extracted = parser.extract_tool_calls(WRITE_FILE_OUTPUT, request=request)
1520
+
1521
+ args = json.loads(extracted.tool_calls[0].function.arguments)
1522
+ assert args["content"] == EXPECTED_CONTENT
1523
+
1524
+ def test_whitespace_preserved_streaming(self, qwen3_tokenizer):
1525
+ """The partial path must not strip the value either."""
1526
+ parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=WRITE_FILE_TOOLS)
1527
+ request = ChatCompletionRequest(
1528
+ model=MODEL, messages=[], tools=WRITE_FILE_TOOLS
1529
+ )
1530
+
1531
+ streamed = ""
1532
+ for delta_message in stream_delta_message_generator(
1533
+ parser, qwen3_tokenizer, WRITE_FILE_OUTPUT, request
1534
+ ):
1535
+ for tool_call in delta_message.tool_calls or []:
1536
+ if tool_call.function and tool_call.function.arguments:
1537
+ streamed += tool_call.function.arguments
1538
+
1539
+ assert json.loads(streamed)["content"] == EXPECTED_CONTENT
vllm/parser/minimax_m2.py
+5
-2
@@ -69,7 +69,9 @@ def _minimax_m2_arg_converter(raw_args: str, partial: bool) -> str:
69
).strip()
70
if not name:
71
continue
72
- params[name] = match.group("value").strip()
72
+ # Keep the value verbatim (like the glm47 converter): it is inline
73
+ # between `>` and `</parameter>`, so surrounding whitespace is data.
74
+ params[name] = match.group("value")
75
76
if partial:
77
remaining = _PARAM_RE.sub("", raw_args)
@@ -82,7 +84,8 @@ def _minimax_m2_arg_converter(raw_args: str, partial: bool) -> str:
84
or ""
85
).strip()
86
if name:
85
- params[name] = match.group("value").strip()
87
+ # Verbatim, same as the complete-match loop above.
88
+ params[name] = match.group("value")
89
90
return json.dumps(params, ensure_ascii=False)
91
vllm/parser/qwen3.py
+11
-2
@@ -56,13 +56,22 @@ _PARAM_RE = re.compile(
56
_PARTIAL_PARAM_RE = re.compile(r"<\s*parameter\s*=\s*([^>]+)>(.*)$", re.DOTALL)
57
58
59
+def _trim_wrapping_newlines(value: str) -> str:
60
+ """Strip one leading and one trailing newline (the Qwen3 template markup)."""
61
+ if value.startswith("\n"):
62
+ value = value[1:]
63
+ if value.endswith("\n"):
64
+ value = value[:-1]
65
+ return value
66
+
67
+
68
def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
69
params: dict[str, object] = {}
70
71
for match in _PARAM_RE.finditer(raw_args):
72
name = match.group(1)
73
value = match.group(2)
65
- params[name] = value.strip()
74
+ params[name] = _trim_wrapping_newlines(value)
75
76
if partial:
77
remaining = _PARAM_RE.sub("", raw_args)
@@ -71,7 +80,7 @@ def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
80
name = m.group(1)
81
value = m.group(2)
82
if name:
74
- params[name] = value.strip()
83
+ params[name] = _trim_wrapping_newlines(value)
84
85
return json.dumps(params, ensure_ascii=False)
86
vllm/tool_parsers/minicpm5xml_tool_parser.py
+5
-3
@@ -360,7 +360,7 @@ def _parse_function_block(
360
if not key:
361
has_invalid_param = True
362
break
363
- val_text = (param.text or "").strip()
363
+ val_text = param.text or ""
364
if not _add_argument(
365
func_name or "",
366
key,
@@ -392,7 +392,8 @@ def _parse_function_block(
392
val_text = pm.group(2) or ""
393
if val_text.startswith("<![CDATA[") and val_text.endswith("]]>"):
394
val_text = val_text[len("<![CDATA[") : -len("]]>")]
395
- val_text = val_text.strip()
395
+ else:
396
+ val_text = val_text.strip()
397
if not _add_argument(
398
func_name or "",
399
key,
@@ -445,7 +446,8 @@ def _parse_partial_params(
446
val_text = pm.group(2) or ""
447
if val_text.startswith("<![CDATA[") and val_text.endswith("]]>"):
448
val_text = val_text[len("<![CDATA[") : -len("]]>")]
448
- val_text = val_text.strip()
449
+ else:
450
+ val_text = val_text.strip()
451
_add_argument(
452
func_name,
453
key,