| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | from collections.abc import Sequence |
| 5 | from typing import TYPE_CHECKING |
| 6 | |
| 7 | import regex as re |
| 8 | from transformers import PreTrainedTokenizerBase |
| 9 | |
| 10 | from vllm.entrypoints.generate.base.protocol import DeltaMessage |
| 11 | from vllm.reasoning import ReasoningParser |
| 12 | |
| 13 | if TYPE_CHECKING: |
| 14 | from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest |
| 15 | from vllm.entrypoints.openai.responses.protocol import ResponsesRequest |
| 16 | |
| 17 | |
| 18 | class HunyuanA13BReasoningParser(ReasoningParser): |
| 19 | """Reasoning parser for Hunyuan A13B Model. |
| 20 | |
| 21 | HunyuanReasoningParser |
| 22 | |
| 23 | This class implements a reasoning parser specifically designed |
| 24 | for the Hunyuan A13B Model. It is responsible for parsing and |
| 25 | extracting structured reasoning and answer segments from model |
| 26 | outputs that follow a specific pattern. |
| 27 | |
| 28 | Key Features: |
| 29 | - For non-stream output , Recognizes and extracts reasoning ("think") |
| 30 | and answer ("answer") sections from text using regular expressions. |
| 31 | - For stream process, it requires a token id sequences to change the |
| 32 | reasoning state and other state so it maintains internal state to |
| 33 | manage parsing across multiple token. |
| 34 | |
| 35 | |
| 36 | think start: "<think>\n": [14023, 771, 397] |
| 37 | think ends: "\n</think>\n<answer>\n": [198, 524, 27963, 397, 27, 9399, 397] |
| 38 | response ends: "\n</answer>": [524, 9399, 29] |
| 39 | """ |
| 40 | |
| 41 | def __init__(self, tokenizer: PreTrainedTokenizerBase, *args, **kwargs): |
| 42 | super().__init__(tokenizer, *args, **kwargs) |
| 43 | self.think_start_expr = r"<think>\n" |
| 44 | self.think_end_expr = r"\n</think>\n" |
| 45 | |
| 46 | self.response_start_expr = r"\n</think>\n<answer>\n" |
| 47 | self.response_end_expr = r"\n</answer>" |
| 48 | |
| 49 | self.full_match_reasoning_regex = re.compile( |
| 50 | rf"(?:{self.think_start_expr}(.*?){self.response_start_expr})?(.*?){self.response_end_expr}", |
| 51 | re.DOTALL, |
| 52 | ) |
| 53 | |
| 54 | self.half_match_reasoning_regex = re.compile( |
| 55 | rf"{self.think_start_expr}(.*?){self.response_start_expr}(.*)", re.DOTALL |
| 56 | ) |
| 57 | |
| 58 | self.think_start_ids = [14023, 771, 397] |
| 59 | self.think_start_ids_fast = [14023, 771, 1363] |
| 60 | self.response_start_ids = [198, 524, 27963, 397, 27, 9399, 397] |
| 61 | self.response_start_ids_fast = [524, 27963, 397, 27, 9399, 397] |
| 62 | self.response_end_ids = [198, 524, 9399, 29] |
| 63 | self.fast_think_ids = [14023, 771, 1363, 524, 27963, 397, 27, 9399, 397] |
| 64 | |
| 65 | # when state change, send out all the buffered text in last state |
| 66 | self.buffered_text: list[str] = [] |
| 67 | self.buffered_ids: list[int] = [] |
| 68 | |
| 69 | self.current_state = "reasoning" |
| 70 | self.all_states = ["reasoning", "response"] |
| 71 | |
| 72 | self.current_state = "idle" |
| 73 | self.expected_sequence = self.think_start_ids |
| 74 | # this sequence only for the think start, it has two way to start. |
| 75 | self.expected_sequence_side = self.think_start_ids_fast |
| 76 | self.sequence_index = 0 |
| 77 | self.token_buffer: list[int] = [] |
| 78 | self.text_buffer = "" |
| 79 | |
| 80 | def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: |
| 81 | return self.current_state == "response" |
| 82 | |
| 83 | def extract_content_ids(self, input_ids: list[int]) -> list[int]: |
| 84 | # for hunyuan streaming reason parsing, the stream parse |
| 85 | # will call first, and the same token will be called in |
| 86 | # is_reasoning_end and extract_content_ids |
| 87 | # this id is not part of content, so just return [] here. |
| 88 | return [] |
| 89 | |
| 90 | def extract_reasoning( |
| 91 | self, model_output: str, request: "ChatCompletionRequest | ResponsesRequest" |
| 92 | ) -> tuple[str | None, str | None]: |
| 93 | """Extract the reasoning content & content sections, respectively. |
| 94 | If the sequence doesn't match what we expect, i.e., the model generates |
| 95 | something else, all content is considered non-reasoning content. |
| 96 | |
| 97 | Args: |
| 98 | model_output (str): Output of the model to be parsed. |
| 99 | request (ChatCompletionRequest): Request being processed. |
| 100 | |
| 101 | Returns: |
| 102 | tuple[Optional[str], Optional[str]]: Tuple pair containing the |
| 103 | reasoning content and non-reasoning content. |
| 104 | |
| 105 | """ |
| 106 | re_match = self.full_match_reasoning_regex.findall(model_output) |
| 107 | if re_match: |
| 108 | reasoning, response_content = re_match[0] |
| 109 | if len(reasoning) == 0: |
| 110 | reasoning = None |
| 111 | if len(response_content) == 0: |
| 112 | response_content = None |
| 113 | return reasoning, response_content |
| 114 | |
| 115 | fallback_regex = self.half_match_reasoning_regex |
| 116 | fallback_match = fallback_regex.findall(model_output) |
| 117 | if fallback_match: |
| 118 | reasoning, response_content = fallback_match[0] |
| 119 | |
| 120 | if response_content.endswith(self.response_end_expr): |
| 121 | response_content = response_content[: -len(self.response_end_expr)] |
| 122 | |
| 123 | if len(reasoning) == 0: |
| 124 | reasoning = None |
| 125 | if len(response_content) == 0: |
| 126 | response_content = None |
| 127 | |
| 128 | return reasoning, response_content |
| 129 | |
| 130 | return None, model_output |
| 131 | |
| 132 | def extract_reasoning_streaming( |
| 133 | self, |
| 134 | previous_text: str, |
| 135 | current_text: str, |
| 136 | delta_text: str, |
| 137 | previous_token_ids: Sequence[int], |
| 138 | current_token_ids: Sequence[int], |
| 139 | delta_token_ids: Sequence[int], |
| 140 | ) -> DeltaMessage | None: |
| 141 | """Extract content using token ID sequence state machine.""" |
| 142 | # Define sequences |
| 143 | think_start_sequence = self.think_start_ids |
| 144 | response_start_sequence = self.response_start_ids |
| 145 | response_end_sequence = self.response_end_ids |
| 146 | |
| 147 | assert len(delta_token_ids) == 1 |
| 148 | # Process each token in the delta |
| 149 | token = delta_token_ids[0] |
| 150 | |
| 151 | def check_token_with_sequence(token): |
| 152 | if self.current_state == "idle" or self.current_state == "think": |
| 153 | return ( |
| 154 | token == self.expected_sequence[self.sequence_index] |
| 155 | or token == self.expected_sequence_side[self.sequence_index] |
| 156 | ) |
| 157 | else: |
| 158 | return token == self.expected_sequence[self.sequence_index] |
| 159 | |
| 160 | def check_last_token(token): |
| 161 | if self.current_state == "idle" or self.current_state == "think": |
| 162 | # only return true if it's judge using a side sequence. |
| 163 | if ( |
| 164 | self.sequence_index - 1 < len(self.expected_sequence_side) |
| 165 | and token == self.expected_sequence_side[self.sequence_index - 1] |
| 166 | ): |
| 167 | return self.sequence_index == len(self.expected_sequence_side) |
| 168 | else: |
| 169 | return self.sequence_index == len(self.expected_sequence) |
| 170 | else: |
| 171 | return self.sequence_index == len(self.expected_sequence) |
| 172 | |
| 173 | # Check if token matches expected sequence |
| 174 | token_in_state_seq = check_token_with_sequence(token) |
| 175 | |
| 176 | if token_in_state_seq: |
| 177 | # Store matching token |
| 178 | self.token_buffer.append(token) |
| 179 | self.text_buffer += delta_text |
| 180 | self.sequence_index += 1 |
| 181 | ## state change from idle->think->response->idle |
| 182 | |
| 183 | # Check if sequence fully matched |
| 184 | if check_last_token(token): |
| 185 | # State transition |
| 186 | if self.current_state == "idle": |
| 187 | self.current_state = "think" |
| 188 | self.expected_sequence = response_start_sequence |
| 189 | self.expected_sequence_side = self.response_start_ids_fast |
| 190 | elif self.current_state == "think": |
| 191 | self.current_state = "response" |
| 192 | self.expected_sequence = response_end_sequence |
| 193 | elif self.current_state == "response": |
| 194 | self.current_state = "idle" |
| 195 | self.expected_sequence = think_start_sequence |
| 196 | self.expected_sequence_side = self.think_start_ids_fast |
| 197 | |
| 198 | # Reset matching state |
| 199 | self.sequence_index = 0 |
| 200 | self.token_buffer = [] |
| 201 | self.text_buffer = "" |
| 202 | # Do not send content for state transition texts. |
| 203 | else: |
| 204 | # Sequence broken - handle buffered content |
| 205 | if self.token_buffer and len(self.token_buffer) > 0: |
| 206 | # Send buffered tokens |
| 207 | buffered_content = self.text_buffer + delta_text |
| 208 | # Reset matching state |
| 209 | self.sequence_index = 0 |
| 210 | self.token_buffer = [] |
| 211 | self.text_buffer = "" |
| 212 | |
| 213 | # Return content based on current state |
| 214 | if self.current_state == "think": |
| 215 | return DeltaMessage(reasoning=buffered_content, content=None) |
| 216 | else: |
| 217 | return DeltaMessage(reasoning=None, content=buffered_content) |
| 218 | else: |
| 219 | # No buffered content, send normally |
| 220 | if self.current_state == "think": |
| 221 | return DeltaMessage(reasoning=delta_text, content=None) |
| 222 | else: |
| 223 | return DeltaMessage(reasoning=None, content=delta_text) |
| 224 | |
| 225 | # If no content to send in this delta |
| 226 | return None |