diff --git a/tests/parser/engine/test_minimax_m2.py b/tests/parser/engine/test_minimax_m2.py index 9b2574dec4..4a3f5cbdf4 100644 --- a/tests/parser/engine/test_minimax_m2.py +++ b/tests/parser/engine/test_minimax_m2.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import dataclasses import json import pytest @@ -15,11 +16,14 @@ from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionToolsParam, FunctionDefinition, ) +from vllm.parser.engine.parser_engine import ParserEngine from vllm.parser.minimax_m2 import ( THINK_END, TOOL_CALL_END, TOOL_CALL_START, MinimaxM2Parser, + _minimax_m2_arg_value_extender, + minimax_m2_config, ) @@ -238,6 +242,123 @@ class TestStreaming: assert collect_tool_arguments(results) == "" +WRITE_CALL_HEAD = ( + '\n\n' + '/repo/app.py\n' + '644\n' + '' +) +WRITE_CALL_TAIL = "\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_stops_at_split_end_tag(self): + extend = _minimax_m2_arg_value_extender('') + assert extend is not None + deltas = ['a"\n', ""] + assert [extend(d) for d in deltas] == ['a\\"\\n', "x") + assert extend is not None + assert [extend(d) for d in ["< parameter", " name ="]] == ["< parameter", None] + + @pytest.mark.parametrize( + "raw_args", + [ + 'x', + '12' + '3' + ), + 'x', + ], + ids=[ + "closed_value", + "open_name", + "repeated_key_not_last", + "earlier_unmatched_opener", + "bare_name_opens_quote", + ], + ) + def test_no_extender_unless_open_value_is_stable(self, raw_args): + assert _minimax_m2_arg_value_extender(raw_args) is None + + class TestReasoning: def test_extract_reasoning_without_start_token(self, parser, mock_request): reasoning, content = parser.extract_reasoning( diff --git a/vllm/parser/minimax_m2.py b/vllm/parser/minimax_m2.py index c96f852b9d..d804148ec7 100644 --- a/vllm/parser/minimax_m2.py +++ b/vllm/parser/minimax_m2.py @@ -22,6 +22,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, @@ -57,37 +58,85 @@ _PARTIAL_PARAM_RE = re.compile( ) -def _minimax_m2_arg_converter(raw_args: str, partial: bool) -> str: +# 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+name\s*=") + + +def _param_name(match: re.Match) -> str: + return ( + match.group("dq_name") + or match.group("sq_name") + or match.group("bare_name") + or "" + ).strip() + + +def _minimax_m2_parse_args( + raw_args: str, partial: bool +) -> tuple[dict[str, object], re.Match | None]: + """Return the arguments and the match of the open trailing value, if any.""" params: dict[str, object] = {} for match in _PARAM_RE.finditer(raw_args): - name = ( - match.group("dq_name") - or match.group("sq_name") - or match.group("bare_name") - or "" - ).strip() + name = _param_name(match) if not name: continue # Keep the value verbatim (like the glm47 converter): it is inline # between `>` and ``, so surrounding whitespace is data. params[name] = match.group("value") + open_match = None if partial: remaining = _PARAM_RE.sub("", raw_args) match = _PARTIAL_PARAM_RE.search(remaining) if match: - name = ( - match.group("dq_name") - or match.group("sq_name") - or match.group("bare_name") - or "" - ).strip() + name = _param_name(match) if name: # Verbatim, same as the complete-match loop above. params[name] = match.group("value") + open_match = match - return json.dumps(params, ensure_ascii=False) + return params, open_match + + +def _minimax_m2_arg_converter(raw_args: str, partial: bool) -> str: + return json.dumps(_minimax_m2_parse_args(raw_args, partial)[0], ensure_ascii=False) + + +def _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 _minimax_m2_arg_value_extender(raw_args: str) -> ArgValueExtender | None: + # The open value runs to the end of raw_args and is the last JSON value, + # so a delta appends its JSON-escaped text unless it closes the value. + params, match = _minimax_m2_parse_args(raw_args, partial=True) + if match is None or next(reversed(params)) != _param_name(match): + return None + # Later text could still re-parse the opener: an earlier unmatched one, or + # a bare name that starts a quote, may close into a different name. + bare_name = match.group("bare_name") + if _PARAM_VALUE_END_RE.search(match.string, 0, match.start()) or ( + bare_name is not None and bare_name[0] in "\"'" + ): + return None + + window = _value_end_window(match.group("value")) + + def extend(delta: str) -> str | None: + nonlocal window + text = window + delta + if _PARAM_VALUE_END_RE.search(text): + return None + window = _value_end_window(text) + return json.dumps(delta, ensure_ascii=False)[1:-1] + + return extend @functools.cache @@ -191,6 +240,7 @@ def minimax_m2_config() -> ParserEngineConfig: }, }, arg_converter=_minimax_m2_arg_converter, + arg_value_extender=_minimax_m2_arg_value_extender, stream_arg_deltas=True, tool_args_json=False, validate_tool_names=True,