| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import pytest |
| 5 | from transformers import AutoTokenizer, PreTrainedTokenizerBase |
| 6 | |
| 7 | from tests.reasoning.utils import run_reasoning_extraction |
| 8 | from vllm.reasoning import ReasoningParser, ReasoningParserManager |
| 9 | |
| 10 | parser_name = "minimax_m2" |
| 11 | end_token = "</think>" |
| 12 | |
| 13 | # MiniMax M2 model path |
| 14 | REASONING_MODEL_NAME = "MiniMaxAI/MiniMax-M2" |
| 15 | |
| 16 | |
| 17 | @pytest.fixture(scope="module") |
| 18 | def minimax_m2_tokenizer() -> PreTrainedTokenizerBase: |
| 19 | return AutoTokenizer.from_pretrained(REASONING_MODEL_NAME) |
| 20 | |
| 21 | |
| 22 | # ============================================================================= |
| 23 | # MiniMax M2 specific behavior: |
| 24 | # - Model does NOT generate <think> start token |
| 25 | # - Model only generates </think> end token |
| 26 | # - All content before </think> is reasoning |
| 27 | # - All content after </think> is the actual response (content) |
| 28 | # ============================================================================= |
| 29 | |
| 30 | # Case: reasoning + end token + content (typical case) |
| 31 | SIMPLE_REASONING = { |
| 32 | "output": "This is a reasoning section</think>This is the rest", |
| 33 | "reasoning": "This is a reasoning section", |
| 34 | "content": "This is the rest", |
| 35 | "is_reasoning_end": True, |
| 36 | } |
| 37 | |
| 38 | # Case: reasoning + end token only (no content after) |
| 39 | COMPLETE_REASONING = { |
| 40 | "output": "This is a reasoning section</think>", |
| 41 | "reasoning": "This is a reasoning section", |
| 42 | "content": None, |
| 43 | "is_reasoning_end": True, |
| 44 | } |
| 45 | |
| 46 | # Case: no end token yet (streaming in progress, all is reasoning) |
| 47 | NO_END_TOKEN = { |
| 48 | "output": "This is reasoning in progress", |
| 49 | "reasoning": "This is reasoning in progress", |
| 50 | "content": None, |
| 51 | "is_reasoning_end": False, |
| 52 | } |
| 53 | |
| 54 | # Case: multiple lines of reasoning |
| 55 | MULTIPLE_LINES = { |
| 56 | "output": "First line\nSecond line</think>Response first line\nResponse second", |
| 57 | "reasoning": "First line\nSecond line", |
| 58 | "content": "Response first line\nResponse second", |
| 59 | "is_reasoning_end": True, |
| 60 | } |
| 61 | |
| 62 | # Case: only end token streaming (reasoning is None because it's just the token) |
| 63 | SHORTEST_REASONING_STREAMING = { |
| 64 | "output": "</think>This is the response", |
| 65 | "reasoning": None, |
| 66 | "content": "This is the response", |
| 67 | "is_reasoning_end": True, |
| 68 | } |
| 69 | |
| 70 | # Case: empty streaming |
| 71 | EMPTY_STREAMING = { |
| 72 | "output": "", |
| 73 | "reasoning": None, |
| 74 | "content": None, |
| 75 | "is_reasoning_end": False, |
| 76 | } |
| 77 | |
| 78 | # Case: long reasoning with special characters |
| 79 | SPECIAL_CHARS = { |
| 80 | "output": "Let me think... 1+1=2, right?</think>Yes, 1+1=2.", |
| 81 | "reasoning": "Let me think... 1+1=2, right?", |
| 82 | "content": "Yes, 1+1=2.", |
| 83 | "is_reasoning_end": True, |
| 84 | } |
| 85 | |
| 86 | # Case: reasoning with code blocks |
| 87 | CODE_IN_REASONING = { |
| 88 | "output": "```python\nprint('hello')\n```</think>Here is the code.", |
| 89 | "reasoning": "```python\nprint('hello')\n```", |
| 90 | "content": "Here is the code.", |
| 91 | "is_reasoning_end": True, |
| 92 | } |
| 93 | |
| 94 | TEST_CASES = [ |
| 95 | # Core cases: no start token (MiniMax M2 actual behavior) |
| 96 | pytest.param( |
| 97 | False, |
| 98 | SIMPLE_REASONING, |
| 99 | id="simple_reasoning", |
| 100 | ), |
| 101 | pytest.param( |
| 102 | True, |
| 103 | SIMPLE_REASONING, |
| 104 | id="simple_reasoning_streaming", |
| 105 | ), |
| 106 | pytest.param( |
| 107 | False, |
| 108 | COMPLETE_REASONING, |
| 109 | id="complete_reasoning", |
| 110 | ), |
| 111 | pytest.param( |
| 112 | True, |
| 113 | COMPLETE_REASONING, |
| 114 | id="complete_reasoning_streaming", |
| 115 | ), |
| 116 | pytest.param( |
| 117 | False, |
| 118 | NO_END_TOKEN, |
| 119 | id="no_end_token", |
| 120 | ), |
| 121 | pytest.param( |
| 122 | True, |
| 123 | NO_END_TOKEN, |
| 124 | id="no_end_token_streaming", |
| 125 | ), |
| 126 | pytest.param( |
| 127 | False, |
| 128 | MULTIPLE_LINES, |
| 129 | id="multiple_lines", |
| 130 | ), |
| 131 | pytest.param( |
| 132 | True, |
| 133 | MULTIPLE_LINES, |
| 134 | id="multiple_lines_streaming", |
| 135 | ), |
| 136 | pytest.param( |
| 137 | True, |
| 138 | SHORTEST_REASONING_STREAMING, |
| 139 | id="shortest_reasoning_streaming", |
| 140 | ), |
| 141 | pytest.param( |
| 142 | True, |
| 143 | EMPTY_STREAMING, |
| 144 | id="empty_streaming", |
| 145 | ), |
| 146 | pytest.param( |
| 147 | False, |
| 148 | SPECIAL_CHARS, |
| 149 | id="special_chars", |
| 150 | ), |
| 151 | pytest.param( |
| 152 | True, |
| 153 | SPECIAL_CHARS, |
| 154 | id="special_chars_streaming", |
| 155 | ), |
| 156 | pytest.param( |
| 157 | False, |
| 158 | CODE_IN_REASONING, |
| 159 | id="code_in_reasoning", |
| 160 | ), |
| 161 | pytest.param( |
| 162 | True, |
| 163 | CODE_IN_REASONING, |
| 164 | id="code_in_reasoning_streaming", |
| 165 | ), |
| 166 | ] |
| 167 | |
| 168 | |
| 169 | @pytest.mark.parametrize("streaming, param_dict", TEST_CASES) |
| 170 | def test_reasoning( |
| 171 | streaming: bool, |
| 172 | param_dict: dict, |
| 173 | minimax_m2_tokenizer, |
| 174 | ): |
| 175 | output = minimax_m2_tokenizer.tokenize(param_dict["output"]) |
| 176 | # decode everything to tokens |
| 177 | output_tokens: list[str] = [ |
| 178 | minimax_m2_tokenizer.convert_tokens_to_string([token]) for token in output |
| 179 | ] |
| 180 | parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)( |
| 181 | minimax_m2_tokenizer |
| 182 | ) |
| 183 | |
| 184 | reasoning, content = run_reasoning_extraction( |
| 185 | parser, output_tokens, streaming=streaming |
| 186 | ) |
| 187 | |
| 188 | assert reasoning == param_dict["reasoning"] |
| 189 | assert content == param_dict["content"] |
| 190 | |
| 191 | # Test is_reasoning_end |
| 192 | output_ids = minimax_m2_tokenizer.convert_tokens_to_ids(output) |
| 193 | is_reasoning_end = parser.is_reasoning_end(output_ids) |
| 194 | assert is_reasoning_end == param_dict["is_reasoning_end"] |
| 195 | |
| 196 | # Test extract_content |
| 197 | if param_dict["content"] is not None: |
| 198 | content_ids = parser.extract_content_ids(output_ids) |
| 199 | assert content_ids == minimax_m2_tokenizer.convert_tokens_to_ids( |
| 200 | minimax_m2_tokenizer.tokenize(param_dict["content"]) |
| 201 | ) |
| 202 | else: |
| 203 | content_ids = parser.extract_content_ids(output) |
| 204 | assert content_ids == [] |