[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,