diff --git a/tests/parser/engine/test_qwen3.py b/tests/parser/engine/test_qwen3.py index fda047d422..be4b33e386 100644 --- a/tests/parser/engine/test_qwen3.py +++ b/tests/parser/engine/test_qwen3.py @@ -6,6 +6,7 @@ These validate that the engine-driven parser correctly handles Qwen3 XML-style tool calls. """ +import dataclasses import json from unittest.mock import MagicMock @@ -808,6 +809,136 @@ class TestArgConverter: assert result == {"city": "Tokyo", "expr": "x<5"} +WRITE_CALL_HEAD = ( + "\n\n" + "\n/repo/app.py\n\n" + "\n644\n\n" + "\n" +) +WRITE_CALL_TAIL = "\n\n\n" +# Escapes, non-ASCII, blank lines and tag-like text that must stay inside +# the value, including prefixes of the closing and next-parameter tags. +CONTENT_LINE = 'if a < b > c: print("x\\y") #

int: + calls = 0 + + def counting_converter(raw_args: str, partial: bool) -> str: + nonlocal calls + calls += partial + return converter(raw_args, partial) + + parser = ParserEngine( + mock_tokenizer, + parser_engine_config=dataclasses.replace( + base, arg_converter=counting_converter + ), + ) + text = WRITE_CALL_HEAD + CONTENT_LINE * lines + WRITE_CALL_TAIL + simulate_tool_streaming( + parser, + mock_request, + [text[i : i + 4] for i in range(0, len(text), 4)], + ) + return calls + + assert streaming_conversions(lines=20) == streaming_conversions(lines=200) + + def test_extender_follows_newline_trimming_and_split_end(self): + from vllm.parser.qwen3 import _qwen3_arg_value_extender + + extend = _qwen3_arg_value_extender("") + assert extend is not None + deltas = ["\n", 'a"\n', "b", "\n", ""] + expected = ["", 'a\\"', "\\nb", "", "\\nx") + assert extend is not None + assert [extend(d) for d in ["< para", "meter ="]] == ["< para", None] + + @pytest.mark.parametrize( + "raw_args", + [ + "x", + "123", + ], + ids=["closed_value", "open_name", "repeated_key_not_last"], + ) + def test_no_extender_unless_open_value_is_last(self, raw_args): + from vllm.parser.qwen3 import _qwen3_arg_value_extender + + assert _qwen3_arg_value_extender(raw_args) is None + + class TestSchemaAwareTypeCoercion: """Verify that _fix_arg_types corrects miscoerced values using the tool schema.""" diff --git a/vllm/parser/mimo.py b/vllm/parser/mimo.py index b1c37dd746..3704907efe 100644 --- a/vllm/parser/mimo.py +++ b/vllm/parser/mimo.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """MiMo's compact XML parameters contain verbatim string values.""" +import functools import json from dataclasses import replace @@ -10,6 +11,7 @@ from vllm.parser.qwen3 import ( _PARTIAL_PARAM_RE, CHATML_TURN_BOUNDARIES, Qwen3Parser, + _param_value_extender, qwen3_config, ) @@ -37,6 +39,9 @@ class MiMoParser(Qwen3Parser): turn_boundary_tokens=CHATML_TURN_BOUNDARIES, ), arg_converter=_mimo_arg_converter, + arg_value_extender=functools.partial( + _param_value_extender, trim_newlines=False + ), ), ) super().__init__(tokenizer, tools, **kwargs) diff --git a/vllm/parser/qwen3.py b/vllm/parser/qwen3.py index d9a71f2a46..577e01edf6 100644 --- a/vllm/parser/qwen3.py +++ b/vllm/parser/qwen3.py @@ -25,6 +25,7 @@ import regex as re from vllm.parser.engine.events import EventType from vllm.parser.engine.parser_engine import ParserEngine from vllm.parser.engine.parser_engine_config import ( + ArgValueExtender, ParserEngineConfig, ParserState, Transition, @@ -86,6 +87,64 @@ def _qwen3_arg_converter(raw_args: str, partial: bool) -> str: return json.dumps(params, ensure_ascii=False) +# What ends an open value in ``_PARAM_RE``: a closing tag or the next opener. +_PARAM_VALUE_END_RE = re.compile(r"<\s*/\s*parameter\s*>|<\s*parameter\s*=") + + +def _param_value_end_window(text: str) -> str: + """Return the suffix of *text* that may still grow into a value end.""" + start = text.rfind("<") + if start == -1: + return "" + tail = text[start:] + return tail if _PARAM_VALUE_END_RE.fullmatch(tail, partial=True) else "" + + +def _param_value_extender( + raw_args: str, trim_newlines: bool +) -> ArgValueExtender | None: + """Extend the open trailing ```` value of *raw_args*. + + The extender streams exactly what the converter would for the same text. + With *trim_newlines* it follows ``_trim_wrapping_newlines``: it drops a + leading newline of the value and holds back a trailing one. + """ + match = _PARTIAL_PARAM_RE.search(_PARAM_RE.sub("", raw_args)) + if match is None: + return None + name, value = match.group(1), match.group(2) + # A repeated key keeps its first position, so its value is not last. + if any(m.group(1) == name for m in _PARAM_RE.finditer(raw_args)): + return None + + window = _param_value_end_window(value) + started = bool(value) + held = "\n" if trim_newlines and value.endswith("\n") and value != "\n" else "" + + def extend(delta: str) -> str | None: + nonlocal window, started, held + text = window + delta + if _PARAM_VALUE_END_RE.search(text): + return None + window = _param_value_end_window(text) + if trim_newlines: + text = held + delta + if not started and text: + started = True + text = text.removeprefix("\n") + held = "\n" if text.endswith("\n") else "" + text = text[: len(text) - len(held)] + else: + text = delta + return json.dumps(text, ensure_ascii=False)[1:-1] + + return extend + + +def _qwen3_arg_value_extender(raw_args: str) -> ArgValueExtender | None: + return _param_value_extender(raw_args, trim_newlines=True) + + @functools.cache def qwen3_config( thinking: bool = True, @@ -223,6 +282,7 @@ def qwen3_config( ), }, arg_converter=_qwen3_arg_converter, + arg_value_extender=_qwen3_arg_value_extender, stream_arg_deltas=True, strip_trailing_reasoning_whitespace=False, tool_args_json=False,