| 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 | from transformers import PreTrainedTokenizerBase |
| 8 | |
| 9 | from vllm.entrypoints.generate.base.protocol import DeltaMessage |
| 10 | from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser |
| 11 | |
| 12 | if TYPE_CHECKING: |
| 13 | from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest |
| 14 | from vllm.entrypoints.openai.responses.protocol import ResponsesRequest |
| 15 | |
| 16 | |
| 17 | class Ernie45ReasoningParser(BaseThinkingReasoningParser): |
| 18 | """Reasoning parser for Ernie45 thinking model. |
| 19 | The Ernie45 thinking model output format is |
| 20 | abc\n</think>\n\n<response>\ndef\n</response>\n |
| 21 | or abc\n</think>\ndef |
| 22 | """ |
| 23 | |
| 24 | response_start_token: str = "<response>" |
| 25 | response_end_token: str = "</response>" |
| 26 | newline_token: str = "<0x0A>" |
| 27 | |
| 28 | @property |
| 29 | def start_token(self) -> str: |
| 30 | """The token that starts reasoning content.""" |
| 31 | return "<think>" |
| 32 | |
| 33 | @property |
| 34 | def end_token(self) -> str: |
| 35 | """The token that ends reasoning content.""" |
| 36 | return "</think>" |
| 37 | |
| 38 | def __init__(self, tokenizer: PreTrainedTokenizerBase, *args, **kwargs): |
| 39 | super().__init__(tokenizer, *args, **kwargs) |
| 40 | |
| 41 | if not self.model_tokenizer: |
| 42 | raise ValueError( |
| 43 | "The model tokenizer must be passed to the ReasoningParser " |
| 44 | "constructor during construction." |
| 45 | ) |
| 46 | |
| 47 | self.response_start_token_id = self.vocab.get(self.response_start_token) |
| 48 | self.response_end_token_id = self.vocab.get(self.response_end_token) |
| 49 | self.newline_token_id = self.vocab.get(self.newline_token) |
| 50 | |
| 51 | self.parser_token_ids = [self.end_token_id, self.response_end_token_id] |
| 52 | |
| 53 | def extract_reasoning_streaming( |
| 54 | self, |
| 55 | previous_text: str, |
| 56 | current_text: str, |
| 57 | delta_text: str, |
| 58 | previous_token_ids: Sequence[int], |
| 59 | current_token_ids: Sequence[int], |
| 60 | delta_token_ids: Sequence[int], |
| 61 | ) -> DeltaMessage | None: |
| 62 | """Extract reasoning content from a delta message. |
| 63 | Handles streaming output where previous + delta = current. |
| 64 | Uses token IDs for faster processing. |
| 65 | The Ernie45 thinking model output format is |
| 66 | abc\n</think>\n\n<response>\ndef\n</response>\n |
| 67 | or abc\n</think>\ndef |
| 68 | - 'abc' goes to reasoning |
| 69 | - 'def' goes to content |
| 70 | """ |
| 71 | # Skip single special tokens |
| 72 | if len(delta_token_ids) == 1 and ( |
| 73 | delta_token_ids[0] |
| 74 | in [ |
| 75 | self.start_token_id, |
| 76 | self.end_token_id, |
| 77 | self.response_start_token_id, |
| 78 | self.response_end_token_id, |
| 79 | ] |
| 80 | ): |
| 81 | return None |
| 82 | |
| 83 | # No <think> in previous or delta, also need to check for </think>. |
| 84 | # Because the model may have generated </think> without <think> |
| 85 | if self.end_token_id in delta_token_ids: |
| 86 | # </think> in delta with more tokens, |
| 87 | # extract reasoning content and content |
| 88 | think_end_index = delta_text.find(self.end_token) |
| 89 | reasoning = delta_text[:think_end_index] |
| 90 | content = delta_text[think_end_index + len(self.end_token) :] |
| 91 | content = content.lstrip("\n") |
| 92 | response_start_idx = content.find(self.response_start_token) |
| 93 | response_end_idx = content.rfind(self.response_end_token) |
| 94 | if response_start_idx != -1: |
| 95 | content = content[response_start_idx + len(self.response_start_token) :] |
| 96 | if response_end_idx != -1: |
| 97 | content = content[:response_end_idx] |
| 98 | return DeltaMessage( |
| 99 | reasoning=reasoning, |
| 100 | content=content if content else None, |
| 101 | ) |
| 102 | elif self.end_token_id in previous_token_ids: |
| 103 | # </think> in previous, thinking content ends |
| 104 | content = delta_text |
| 105 | if self.response_start_token_id in delta_token_ids: |
| 106 | content = content.lstrip("\n") |
| 107 | response_start_idx = content.find(self.response_start_token) |
| 108 | content = content[response_start_idx + len(self.response_start_token) :] |
| 109 | # if have </response>, remove it |
| 110 | response_end_idx = content.rfind(self.response_end_token) |
| 111 | if response_end_idx != -1: |
| 112 | content = content[:response_end_idx] |
| 113 | elif self.response_end_token_id in delta_token_ids: |
| 114 | response_end_idx = content.rfind(self.response_end_token) |
| 115 | if response_end_idx != -1: |
| 116 | content = content[:response_end_idx] |
| 117 | # remove \n after </think> or </response> |
| 118 | if previous_token_ids[-1] in self.parser_token_ids and ( |
| 119 | len(delta_token_ids) > 0 and delta_token_ids[0] == self.newline_token_id |
| 120 | ): |
| 121 | content = content.lstrip("\n") |
| 122 | # remove \n after </think>\n |
| 123 | if ( |
| 124 | len(previous_token_ids) > 1 |
| 125 | and previous_token_ids[-2] == self.end_token_id |
| 126 | ) and ( |
| 127 | len(delta_token_ids) > 0 and delta_token_ids[0] == self.newline_token_id |
| 128 | ): |
| 129 | content = content.lstrip("\n") |
| 130 | |
| 131 | return DeltaMessage(content=content if content else None) |
| 132 | else: |
| 133 | # no </think> in previous or delta, reasoning content continues |
| 134 | return DeltaMessage(reasoning=delta_text) |
| 135 | |
| 136 | def extract_reasoning( |
| 137 | self, model_output: str, request: "ChatCompletionRequest | ResponsesRequest" |
| 138 | ) -> tuple[str | None, str | None]: |
| 139 | """Extract reasoning content from the model output. |
| 140 | The Ernie45 thinking model output format is |
| 141 | abc\n</think>\n\n\n<response>\ndef\n</response>\n |
| 142 | or abc\n</think>\ndef |
| 143 | - 'abc' goes to reasoning |
| 144 | - 'def' goes to content |
| 145 | Returns: |
| 146 | tuple[Optional[str], Optional[str]]: reasoning content and content |
| 147 | """ |
| 148 | reasoning, content = super().extract_reasoning(model_output, request) |
| 149 | if content: |
| 150 | start_idx = content.find(self.response_start_token) |
| 151 | end_idx = content.rfind(self.response_end_token) |
| 152 | # Simultaneously existing and in the correct order |
| 153 | if start_idx != -1 and end_idx != -1 and start_idx < end_idx: |
| 154 | content = content[start_idx + len(self.response_start_token) : end_idx] |
| 155 | final_content = content or None |
| 156 | |
| 157 | return reasoning, final_content |