@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 782505ed8e
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 14, 05:30 UTC

[Model] Add reasoning_parser and tool_parser for Ernie45 thinking (#25027)

Signed-off-by: wangyafeng <wangyafeng@baidu.com>

CSWYF3634076 committed Oct 13, 2025 at 15:55 UTC 782505ed8eb4f1b27cccd009a8dc9b69f6ad6ebc
7 files changed +870
docs/features/reasoning_outputs.md
+2
@@ -11,6 +11,8 @@ vLLM currently supports the following reasoning models:
11 | Model Series | Parser Name | Structured Output Support | Tool Calling |
12 |--------------|-------------|------------------|-------------|
13 | [DeepSeek R1 series](https://huggingface.co/collections/deepseek-ai/deepseek-r1-678e1e131c0169c0bc89728d) | `deepseek_r1` | `json`, `regex` | ❌ |
14 +| [ERNIE-4.5-VL series](https://huggingface.co/baidu/ERNIE-4.5-VL-28B-A3B-PT) | `ernie45` | `json`, `regex` | ❌ |
15 +| [ERNIE-4.5-21B-A3B-Thinking](https://huggingface.co/baidu/ERNIE-4.5-21B-A3B-Thinking) | `ernie45` | `json`, `regex` | ✅ |
16 | [QwQ-32B](https://huggingface.co/Qwen/QwQ-32B) | `deepseek_r1` | `json`, `regex` | ✅ |
17 | [IBM Granite 3.2 language models](https://huggingface.co/collections/ibm-granite/granite-32-language-models-67b3bc8c13508f6d064cff9a) | `granite` | ❌ | ❌ |
18 | [Qwen3 series](https://huggingface.co/collections/Qwen/qwen3-67dd247413f0e2e4f653967f) | `qwen3` | `json`, `regex` | ✅ |
tests/reasoning/test_ernie45_reasoning_parser.py new
+124
@@ -0,0 +1,124 @@
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
6 +
7 +from tests.reasoning.utils import run_reasoning_extraction
8 +from vllm.reasoning import ReasoningParser, ReasoningParserManager
9 +
10 +parser_name = "ernie45"
11 +
12 +REASONING_MODEL_NAME = "baidu/ERNIE-4.5-21B-A3B-Thinking"
13 +
14 +
15 +@pytest.fixture(scope="module")
16 +def ernie45_tokenizer():
17 + return AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
18 +
19 +
20 +# 带 </think>,非stream
21 +WITH_THINK = {
22 + "output": "abc</think>def",
23 + "reasoning_content": "abc",
24 + "content": "def",
25 +}
26 +# 带 </think>,stream
27 +WITH_THINK_STREAM = {
28 + "output": "abc</think>def",
29 + "reasoning_content": "abc",
30 + "content": "def",
31 +}
32 +# without </think>, all is reasoning_content
33 +WITHOUT_THINK = {
34 + "output": "abc",
35 + "reasoning_content": "abc",
36 + "content": None,
37 +}
38 +# without </think>, all is reasoning_content
39 +WITHOUT_THINK_STREAM = {
40 + "output": "abc",
41 + "reasoning_content": "abc",
42 + "content": None,
43 +}
44 +
45 +COMPLETE_REASONING = {
46 + "output": "abc</think>",
47 + "reasoning_content": "abc",
48 + "content": None,
49 +}
50 +MULTILINE_REASONING = {
51 + "output": "abc\nABC</think>def\nDEF",
52 + "reasoning_content": "abc\nABC",
53 + "content": "def\nDEF",
54 +}
55 +
56 +TEST_CASES = [
57 + pytest.param(
58 + False,
59 + WITH_THINK,
60 + id="with_think",
61 + ),
62 + pytest.param(
63 + True,
64 + WITH_THINK_STREAM,
65 + id="with_think_stream",
66 + ),
67 + pytest.param(
68 + False,
69 + WITHOUT_THINK,
70 + id="without_think",
71 + ),
72 + pytest.param(
73 + True,
74 + WITHOUT_THINK_STREAM,
75 + id="without_think_stream",
76 + ),
77 + pytest.param(
78 + False,
79 + COMPLETE_REASONING,
80 + id="complete_reasoning",
81 + ),
82 + pytest.param(
83 + True,
84 + COMPLETE_REASONING,
85 + id="complete_reasoning_stream",
86 + ),
87 + pytest.param(
88 + False,
89 + MULTILINE_REASONING,
90 + id="multiline_reasoning",
91 + ),
92 + pytest.param(
93 + True,
94 + MULTILINE_REASONING,
95 + id="multiline_reasoning_stream",
96 + ),
97 +]
98 +
99 +
100 +@pytest.mark.parametrize("streaming, param_dict", TEST_CASES)
101 +def test_reasoning(
102 + streaming: bool,
103 + param_dict: dict,
104 + ernie45_tokenizer,
105 +):
106 + output = ernie45_tokenizer.tokenize(param_dict["output"])
107 + output_tokens: list[str] = []
108 + for token in output:
109 + one_token = ernie45_tokenizer.convert_tokens_to_string([token])
110 + if one_token:
111 + output_tokens.append(one_token)
112 +
113 + parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
114 + ernie45_tokenizer
115 + )
116 +
117 + reasoning, content = run_reasoning_extraction(
118 + parser, output_tokens, streaming=streaming
119 + )
120 +
121 + print()
122 +
123 + assert reasoning == param_dict["reasoning_content"]
124 + assert content == param_dict["content"]
tests/tool_use/test_ernie45_moe_tool_parser.py new
+359
@@ -0,0 +1,359 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# ruff: noqa: E501
4 +
5 +import json
6 +from collections.abc import Generator
7 +
8 +import pytest
9 +
10 +from vllm.entrypoints.openai.protocol import (
11 + ChatCompletionRequest,
12 + DeltaMessage,
13 + FunctionCall,
14 + ToolCall,
15 +)
16 +from vllm.entrypoints.openai.tool_parsers import Ernie45ToolParser
17 +from vllm.transformers_utils.detokenizer_utils import detokenize_incrementally
18 +from vllm.transformers_utils.tokenizer import AnyTokenizer, get_tokenizer
19 +
20 +# Use a common model that is likely to be available
21 +MODEL = "baidu/ERNIE-4.5-21B-A3B-Thinking"
22 +
23 +
24 +@pytest.fixture(scope="module")
25 +def ernie45_tokenizer():
26 + return get_tokenizer(tokenizer_name=MODEL, trust_remote_code=True)
27 +
28 +
29 +@pytest.fixture
30 +def ernie45_tool_parser(ernie45_tokenizer):
31 + return Ernie45ToolParser(ernie45_tokenizer)
32 +
33 +
34 +def assert_tool_calls(
35 + actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall]
36 +):
37 + assert len(actual_tool_calls) == len(expected_tool_calls)
38 +
39 + for actual_tool_call, expected_tool_call in zip(
40 + actual_tool_calls, expected_tool_calls
41 + ):
42 + assert isinstance(actual_tool_call.id, str)
43 + assert len(actual_tool_call.id) > 0
44 +
45 + assert actual_tool_call.type == "function"
46 + assert actual_tool_call.function.name == expected_tool_call.function.name
47 + # Compare arguments as JSON objects to handle formatting differences
48 + actual_args = json.loads(actual_tool_call.function.arguments)
49 + expected_args = json.loads(expected_tool_call.function.arguments)
50 + assert actual_args == expected_args
51 +
52 +
53 +def test_extract_tool_calls_no_tools(ernie45_tool_parser):
54 + model_output = "This is a test"
55 + extracted_tool_calls = ernie45_tool_parser.extract_tool_calls(
56 + model_output, request=None
57 + ) # type: ignore[arg-type]
58 + assert not extracted_tool_calls.tools_called
59 + assert extracted_tool_calls.tool_calls == []
60 + assert extracted_tool_calls.content == model_output
61 +
62 +
63 +@pytest.mark.parametrize(
64 + ids=[
65 + "single_tool_call",
66 + "multiple_tool_calls",
67 + "tool_call_with_content_before",
68 + ],
69 + argnames=["model_output", "expected_tool_calls", "expected_content"],
70 + argvalues=[
71 + (
72 + """<tool_call>
73 +{"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
74 +</tool_call>
75 +""",
76 + [
77 + ToolCall(
78 + function=FunctionCall(
79 + name="get_current_temperature",
80 + arguments=json.dumps(
81 + {
82 + "location": "Beijing",
83 + }
84 + ),
85 + )
86 + )
87 + ],
88 + None,
89 + ),
90 + (
91 + """<tool_call>
92 +{"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
93 +</tool_call>
94 +<tool_call>
95 +{"name": "get_temperature_unit", "arguments": {"location": "Guangzhou", "unit": "c"}}
96 +</tool_call>
97 +""",
98 + [
99 + ToolCall(
100 + function=FunctionCall(
101 + name="get_current_temperature",
102 + arguments=json.dumps(
103 + {
104 + "location": "Beijing",
105 + }
106 + ),
107 + )
108 + ),
109 + ToolCall(
110 + function=FunctionCall(
111 + name="get_temperature_unit",
112 + arguments=json.dumps(
113 + {
114 + "location": "Guangzhou",
115 + "unit": "c",
116 + }
117 + ),
118 + )
119 + ),
120 + ],
121 + None,
122 + ),
123 + (
124 + """I need to call two tools to handle these two issues separately.
125 +</think>
126 +
127 +<tool_call>
128 +{"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
129 +</tool_call>
130 +<tool_call>
131 +{"name": "get_temperature_unit", "arguments": {"location": "Guangzhou", "unit": "c"}}
132 +</tool_call>
133 +""",
134 + [
135 + ToolCall(
136 + function=FunctionCall(
137 + name="get_current_temperature",
138 + arguments=json.dumps(
139 + {
140 + "location": "Beijing",
141 + }
142 + ),
143 + )
144 + ),
145 + ToolCall(
146 + function=FunctionCall(
147 + name="get_temperature_unit",
148 + arguments=json.dumps(
149 + {
150 + "location": "Guangzhou",
151 + "unit": "c",
152 + }
153 + ),
154 + )
155 + ),
156 + ],
157 + "I need to call two tools to handle these two issues separately.\n</think>",
158 + ),
159 + ],
160 +)
161 +def test_extract_tool_calls(
162 + ernie45_tool_parser, model_output, expected_tool_calls, expected_content
163 +):
164 + extracted_tool_calls = ernie45_tool_parser.extract_tool_calls(
165 + model_output, request=None
166 + ) # type: ignore[arg-type]
167 + assert extracted_tool_calls.tools_called
168 +
169 + assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
170 +
171 + assert extracted_tool_calls.content == expected_content
172 +
173 +
174 +def stream_delta_message_generator(
175 + ernie45_tool_parser: Ernie45ToolParser,
176 + ernie45_tokenizer: AnyTokenizer,
177 + model_output: str,
178 + request: ChatCompletionRequest | None = None,
179 +) -> Generator[DeltaMessage, None, None]:
180 + all_token_ids = ernie45_tokenizer.encode(model_output, add_special_tokens=False)
181 +
182 + previous_text = ""
183 + previous_tokens = None
184 + prefix_offset = 0
185 + read_offset = 0
186 + for i, delta_token in enumerate(all_token_ids):
187 + delta_token_ids = [delta_token]
188 + previous_token_ids = all_token_ids[:i]
189 + current_token_ids = all_token_ids[: i + 1]
190 +
191 + (new_tokens, delta_text, new_prefix_offset, new_read_offset) = (
192 + detokenize_incrementally(
193 + tokenizer=ernie45_tokenizer,
194 + all_input_ids=current_token_ids,
195 + prev_tokens=previous_tokens,
196 + prefix_offset=prefix_offset,
197 + read_offset=read_offset,
198 + skip_special_tokens=False,
199 + spaces_between_special_tokens=True,
200 + )
201 + )
202 +
203 + current_text = previous_text + delta_text
204 +
205 + delta_message = ernie45_tool_parser.extract_tool_calls_streaming(
206 + previous_text,
207 + current_text,
208 + delta_text,
209 + previous_token_ids,
210 + current_token_ids,
211 + delta_token_ids,
212 + request=request,
213 + )
214 + if delta_message:
215 + yield delta_message
216 +
217 + previous_text = current_text
218 + previous_tokens = (
219 + previous_tokens + new_tokens if previous_tokens else new_tokens
220 + )
221 + prefix_offset = new_prefix_offset
222 + read_offset = new_read_offset
223 +
224 +
225 +@pytest.mark.parametrize(
226 + ids=[
227 + "single_tool_call",
228 + "multiple_tool_calls",
229 + "tool_call_with_content_before",
230 + ],
231 + argnames=["model_output", "expected_tool_calls", "expected_content"],
232 + argvalues=[
233 + (
234 + """<tool_call>
235 +{"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
236 +</tool_call>
237 +""",
238 + [
239 + ToolCall(
240 + function=FunctionCall(
241 + name="get_current_temperature",
242 + arguments=json.dumps(
243 + {
244 + "location": "Beijing",
245 + }
246 + ),
247 + )
248 + )
249 + ],
250 + None,
251 + ),
252 + (
253 + """<tool_call>
254 +{"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
255 +</tool_call>
256 +<tool_call>
257 +{"name": "get_temperature_unit", "arguments": {"location": "Guangzhou", "unit": "c"}}
258 +</tool_call>
259 +""",
260 + [
261 + ToolCall(
262 + function=FunctionCall(
263 + name="get_current_temperature",
264 + arguments=json.dumps(
265 + {
266 + "location": "Beijing",
267 + }
268 + ),
269 + )
270 + ),
271 + ToolCall(
272 + function=FunctionCall(
273 + name="get_temperature_unit",
274 + arguments=json.dumps(
275 + {
276 + "location": "Guangzhou",
277 + "unit": "c",
278 + }
279 + ),
280 + )
281 + ),
282 + ],
283 + None,
284 + ),
285 + (
286 + """I need to call two tools to handle these two issues separately.
287 +</think>
288 +
289 +<tool_call>
290 +{"name": "get_current_temperature", "arguments": {"location": "Beijing"}}
291 +</tool_call>
292 +<tool_call>
293 +{"name": "get_temperature_unit", "arguments": {"location": "Guangzhou", "unit": "c"}}
294 +</tool_call>
295 +""",
296 + [
297 + ToolCall(
298 + function=FunctionCall(
299 + name="get_current_temperature",
300 + arguments=json.dumps(
301 + {
302 + "location": "Beijing",
303 + }
304 + ),
305 + )
306 + ),
307 + ToolCall(
308 + function=FunctionCall(
309 + name="get_temperature_unit",
310 + arguments=json.dumps(
311 + {
312 + "location": "Guangzhou",
313 + "unit": "c",
314 + }
315 + ),
316 + )
317 + ),
318 + ],
319 + "I need to call two tools to handle these two issues separately.\n</think>",
320 + ),
321 + ],
322 +)
323 +def test_extract_tool_calls_streaming_incremental(
324 + ernie45_tool_parser,
325 + ernie45_tokenizer,
326 + model_output,
327 + expected_tool_calls,
328 + expected_content,
329 +):
330 + """Verify the Ernie45 Parser streaming behavior by verifying each chunk is as expected.""" # noqa: E501
331 + request = ChatCompletionRequest(model=MODEL, messages=[], tools=[])
332 +
333 + tool_calls_dict = {}
334 + for delta_message in stream_delta_message_generator(
335 + ernie45_tool_parser, ernie45_tokenizer, model_output, request
336 + ):
337 + if (
338 + delta_message.role is None
339 + and delta_message.content is None
340 + and delta_message.reasoning_content is None
341 + and len(delta_message.tool_calls) == 0
342 + ):
343 + continue
344 + tool_calls = delta_message.tool_calls
345 + for tool_call_chunk in tool_calls:
346 + index = tool_call_chunk.index
347 + if index not in tool_calls_dict:
348 + if tool_call_chunk.function.arguments is None:
349 + tool_call_chunk.function.arguments = ""
350 + tool_calls_dict[index] = tool_call_chunk
351 + else:
352 + tool_calls_dict[
353 + index
354 + ].function.arguments += tool_call_chunk.function.arguments
355 + actual_tool_calls = list(tool_calls_dict.values())
356 +
357 + assert len(actual_tool_calls) > 0
358 + # check tool call format
359 + assert_tool_calls(actual_tool_calls, expected_tool_calls)
vllm/entrypoints/openai/tool_parsers/__init__.py
+2
@@ -4,6 +4,7 @@
4 from .abstract_tool_parser import ToolParser, ToolParserManager
5 from .deepseekv3_tool_parser import DeepSeekV3ToolParser
6 from .deepseekv31_tool_parser import DeepSeekV31ToolParser
7 +from .ernie45_tool_parser import Ernie45ToolParser
8 from .glm4_moe_tool_parser import Glm4MoeModelToolParser
9 from .granite_20b_fc_tool_parser import Granite20bFCToolParser
10 from .granite_tool_parser import GraniteToolParser
@@ -42,6 +43,7 @@ __all__ = [
43 "Phi4MiniJsonToolParser",
44 "DeepSeekV3ToolParser",
45 "DeepSeekV31ToolParser",
46 + "Ernie45ToolParser",
47 "xLAMToolParser",
48 "MinimaxToolParser",
49 "KimiK2ToolParser",
vllm/entrypoints/openai/tool_parsers/ernie45_tool_parser.py new
+212
@@ -0,0 +1,212 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import json
5 +from collections.abc import Sequence
6 +
7 +import regex as re
8 +
9 +from vllm.entrypoints.openai.protocol import (
10 + ChatCompletionRequest,
11 + DeltaFunctionCall,
12 + DeltaMessage,
13 + DeltaToolCall,
14 + ExtractedToolCallInformation,
15 + FunctionCall,
16 + ToolCall,
17 +)
18 +from vllm.entrypoints.openai.tool_parsers.abstract_tool_parser import (
19 + ToolParser,
20 + ToolParserManager,
21 +)
22 +from vllm.logger import init_logger
23 +from vllm.transformers_utils.tokenizer import AnyTokenizer
24 +
25 +logger = init_logger(__name__)
26 +
27 +
28 +@ToolParserManager.register_module("ernie45")
29 +class Ernie45ToolParser(ToolParser):
30 + def __init__(self, tokenizer: AnyTokenizer):
31 + """
32 + Ernie thinking model format:
33 + abc\n</think>\n\n\n<tool_call>\ndef\n</tool_call>\n
34 + """
35 + super().__init__(tokenizer)
36 + self.current_tool_name_sent = False
37 + self.prev_tool_call_arr: list[dict] = []
38 + self.current_tool_id = -1
39 + self.streamed_args_for_tool: list[str] = []
40 + self.think_end_token = "</think>"
41 + self.response_start_token: str = "<response>"
42 + self.response_end_token: str = "</response>"
43 + self.tool_call_start_token = "<tool_call>"
44 + self.tool_call_end_token = "</tool_call>"
45 + self.tool_calls_start_token = self.tool_call_start_token
46 + self.newline_token: str = "<0x0A>"
47 +
48 + self.tool_call_regex = re.compile(
49 + r"<tool_call>\s*(?P<json>\{.*?\})\s*</tool_call>", re.DOTALL
50 + )
51 +
52 + if not self.model_tokenizer:
53 + raise ValueError(
54 + "The model tokenizer must be passed to the ToolParser "
55 + "constructor during construction."
56 + )
57 +
58 + self.think_end_token_id = self.vocab.get(self.think_end_token)
59 + self.response_start_token_id = self.vocab.get(self.response_start_token)
60 + self.response_end_token_id = self.vocab.get(self.response_end_token)
61 + self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
62 + self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
63 + self.newline_token_id = self.vocab.get(self.newline_token)
64 + self.parser_token_ids = [
65 + self.think_end_token_id,
66 + self.response_start_token_id,
67 + self.response_end_token_id,
68 + ]
69 +
70 + self._buffer = ""
71 +
72 + def extract_tool_calls(
73 + self,
74 + model_output: str,
75 + request: ChatCompletionRequest,
76 + ) -> ExtractedToolCallInformation:
77 + # sanity check; avoid unnecessary processing
78 + if self.tool_calls_start_token not in model_output:
79 + return ExtractedToolCallInformation(
80 + tools_called=False, tool_calls=[], content=model_output
81 + )
82 +
83 + else:
84 + try:
85 + tool_call_json_list = self.tool_call_regex.findall(model_output)
86 +
87 + tool_calls = []
88 + for tool_call_json in tool_call_json_list:
89 + tool_call_dict = json.loads(tool_call_json)
90 + args_str = json.dumps(
91 + tool_call_dict.get("arguments", {}), ensure_ascii=False
92 + )
93 + tool_calls.append(
94 + ToolCall(
95 + type="function",
96 + function=FunctionCall(
97 + name=tool_call_dict.get("name", ""),
98 + arguments=args_str,
99 + ),
100 + )
101 + )
102 +
103 + content = model_output[
104 + : model_output.find(self.tool_calls_start_token)
105 + ].rstrip("\n")
106 + return ExtractedToolCallInformation(
107 + tools_called=True,
108 + tool_calls=tool_calls,
109 + content=content if content else None,
110 + )
111 +
112 + except Exception:
113 + logger.exception("Error in extracting tool call from response.")
114 + return ExtractedToolCallInformation(
115 + tools_called=False, tool_calls=[], content=model_output
116 + )
117 +
118 + def extract_tool_calls_streaming(
119 + self,
120 + previous_text: str,
121 + current_text: str,
122 + delta_text: str,
123 + previous_token_ids: Sequence[int],
124 + current_token_ids: Sequence[int],
125 + delta_token_ids: Sequence[int],
126 + request: ChatCompletionRequest,
127 + ) -> DeltaMessage | None:
128 + self._buffer += delta_text
129 + cur_text = self._buffer
130 + start_idx = cur_text.find(self.tool_call_start_token)
131 + if start_idx == -1:
132 + self._buffer = ""
133 + # At least one toolcall has been completed
134 + if self.current_tool_id > 0:
135 + cur_text = ""
136 + if self.current_tool_id == -1 and all(
137 + token_id == self.newline_token_id for token_id in previous_token_ids
138 + ):
139 + cur_text = cur_text.strip("\n")
140 +
141 + # handle <response> </response> when tool_call is not triggered
142 + # cur_text === delta_text
143 + content = cur_text
144 + if self.response_start_token_id in delta_token_ids:
145 + content = content.lstrip("\n")
146 + response_start_idx = content.find(self.response_start_token)
147 + content = content[response_start_idx + len(self.response_start_token) :]
148 + # if have </response>, remove it
149 + response_end_idx = content.rfind(self.response_end_token)
150 + if response_end_idx != -1:
151 + content = content[:response_end_idx]
152 + elif self.response_end_token_id in delta_token_ids:
153 + response_end_idx = content.rfind(self.response_end_token)
154 + content = content[:response_end_idx]
155 + # remove \n after </think> or <response> or </response>
156 + if (
157 + len(previous_token_ids) > 0
158 + and previous_token_ids[-1] in self.parser_token_ids
159 + ) and (
160 + len(delta_token_ids) > 0 and delta_token_ids[0] == self.newline_token_id
161 + ):
162 + content = content.lstrip("\n")
163 +
164 + return DeltaMessage(content=content if content else None)
165 + logger.debug("cur_text = %s", cur_text)
166 + end_idx = cur_text.find(self.tool_call_end_token)
167 + if end_idx != -1:
168 + if self.current_tool_id == -1:
169 + self.current_tool_id = 0
170 + self.prev_tool_call_arr = []
171 + self.streamed_args_for_tool = []
172 + while len(self.prev_tool_call_arr) <= self.current_tool_id:
173 + self.prev_tool_call_arr.append({})
174 + while len(self.streamed_args_for_tool) <= self.current_tool_id:
175 + self.streamed_args_for_tool.append("")
176 +
177 + extracted_tool_calls = self.extract_tool_calls(
178 + cur_text[: end_idx + len(self.tool_call_end_token)], request
179 + )
180 +
181 + if len(extracted_tool_calls.tool_calls) == 0:
182 + logger.warning("Failed to extract any tool calls.")
183 + return None
184 + tool_call = extracted_tool_calls.tool_calls[0]
185 + self.prev_tool_call_arr[self.current_tool_id] = {
186 + "name": tool_call.function.name,
187 + "arguments": json.loads(tool_call.function.arguments),
188 + }
189 + self.streamed_args_for_tool[self.current_tool_id] = (
190 + tool_call.function.arguments
191 + )
192 + delta = DeltaMessage(
193 + content=extracted_tool_calls.content,
194 + tool_calls=[
195 + DeltaToolCall(
196 + index=self.current_tool_id,
197 + id=tool_call.id,
198 + type=tool_call.type,
199 + function=DeltaFunctionCall(
200 + name=tool_call.function.name,
201 + arguments=tool_call.function.arguments,
202 + ),
203 + )
204 + ],
205 + )
206 + self.current_tool_id += 1
207 + self._buffer = cur_text[end_idx + len(self.tool_call_end_token) :]
208 + return delta
209 +
210 + self._buffer = cur_text[start_idx:]
211 + content = cur_text[:start_idx].rstrip("\n")
212 + return DeltaMessage(content=content if content else None)
vllm/reasoning/__init__.py
+2
@@ -4,6 +4,7 @@
4 from .abs_reasoning_parsers import ReasoningParser, ReasoningParserManager
5 from .basic_parsers import BaseThinkingReasoningParser
6 from .deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParser
7 +from .ernie45_reasoning_parser import Ernie45ReasoningParser
8 from .glm4_moe_reasoning_parser import Glm4MoeModelReasoningParser
9 from .gptoss_reasoning_parser import GptOssReasoningParser
10 from .granite_reasoning_parser import GraniteReasoningParser
@@ -19,6 +20,7 @@ __all__ = [
20 "BaseThinkingReasoningParser",
21 "ReasoningParserManager",
22 "DeepSeekR1ReasoningParser",
23 + "Ernie45ReasoningParser",
24 "GraniteReasoningParser",
25 "HunyuanA13BReasoningParser",
26 "Qwen3ReasoningParser",
vllm/reasoning/ernie45_reasoning_parser.py new
+169
@@ -0,0 +1,169 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from collections.abc import Sequence
5 +
6 +from transformers import PreTrainedTokenizerBase
7 +
8 +from vllm.entrypoints.openai.protocol import ChatCompletionRequest, DeltaMessage
9 +from vllm.logger import init_logger
10 +from vllm.reasoning import ReasoningParserManager
11 +from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
12 +
13 +logger = init_logger(__name__)
14 +
15 +
16 +@ReasoningParserManager.register_module("ernie45")
17 +class Ernie45ReasoningParser(BaseThinkingReasoningParser):
18 + """
19 + Reasoning parser for Ernie45 thinking model.
20 + The Ernie45 thinking model ouput format is
21 + abc\n</think>\n\n<response>\ndef\n</response>\n
22 + or abc\n</think>\ndef
23 + """
24 +
25 + response_start_token: str = "<response>"
26 + response_end_token: str = "</response>"
27 + newline_token: str = "<0x0A>"
28 +
29 + @property
30 + def start_token(self) -> str:
31 + """The token that starts reasoning content."""
32 + return "<think>"
33 +
34 + @property
35 + def end_token(self) -> str:
36 + """The token that ends reasoning content."""
37 + return "</think>"
38 +
39 + def __init__(self, tokenizer: PreTrainedTokenizerBase):
40 + super().__init__(tokenizer)
41 +
42 + if not self.model_tokenizer:
43 + raise ValueError(
44 + "The model tokenizer must be passed to the ReasoningParser "
45 + "constructor during construction."
46 + )
47 +
48 + self.start_token_id = self.vocab.get(self.start_token)
49 + self.end_token_id = self.vocab.get(self.end_token)
50 + self.response_start_token_id = self.vocab.get(self.response_start_token)
51 + self.response_end_token_id = self.vocab.get(self.response_end_token)
52 + self.newline_token_id = self.vocab.get(self.newline_token)
53 +
54 + self.parser_token_ids = [self.end_token_id, self.response_end_token_id]
55 +
56 + if self.start_token_id is None or self.end_token_id is None:
57 + raise RuntimeError(
58 + "Ernie45 reasoning parser could not locate think start/end "
59 + "tokens in the tokenizer!"
60 + )
61 +
62 + def extract_reasoning_content_streaming(
63 + self,
64 + previous_text: str,
65 + current_text: str,
66 + delta_text: str,
67 + previous_token_ids: Sequence[int],
68 + current_token_ids: Sequence[int],
69 + delta_token_ids: Sequence[int],
70 + ) -> DeltaMessage | None:
71 + """
72 + Extract reasoning content from a delta message.
73 + Handles streaming output where previous + delta = current.
74 + Uses token IDs for faster processing.
75 + The Ernie45 thinking model ouput format is
76 + abc\n</think>\n\n<response>\ndef\n</response>\n
77 + or abc\n</think>\ndef
78 + - 'abc' goes to reasoning_content
79 + - 'def' goes to content
80 + """
81 + # Skip single special tokens
82 + if len(delta_token_ids) == 1 and (
83 + delta_token_ids[0]
84 + in [
85 + self.start_token_id,
86 + self.end_token_id,
87 + self.response_start_token_id,
88 + self.response_end_token_id,
89 + ]
90 + ):
91 + return None
92 +
93 + # No <think> in previous or delta, also need to check for </think>.
94 + # Because the model may have generated </think> without <think>
95 + if self.end_token_id in delta_token_ids:
96 + # </think> in delta with more tokens,
97 + # extract reasoning content and content
98 + think_end_index = delta_text.find(self.end_token)
99 + reasoning_content = delta_text[:think_end_index]
100 + content = delta_text[think_end_index + len(self.end_token) :]
101 + content = content.lstrip("\n")
102 + response_start_idx = content.find(self.response_start_token)
103 + response_end_idx = content.rfind(self.response_end_token)
104 + if response_start_idx != -1:
105 + content = content[response_start_idx + len(self.response_start_token) :]
106 + if response_end_idx != -1:
107 + content = content[:response_end_idx]
108 + return DeltaMessage(
109 + reasoning_content=reasoning_content,
110 + content=content if content else None,
111 + )
112 + elif self.end_token_id in previous_token_ids:
113 + # </think> in previous, thinking content ends
114 + content = delta_text
115 + if self.response_start_token_id in delta_token_ids:
116 + content = content.lstrip("\n")
117 + response_start_idx = content.find(self.response_start_token)
118 + content = content[response_start_idx + len(self.response_start_token) :]
119 + # if have </response>, remove it
120 + response_end_idx = content.rfind(self.response_end_token)
121 + if response_end_idx != -1:
122 + content = content[:response_end_idx]
123 + elif self.response_end_token_id in delta_token_ids:
124 + response_end_idx = content.rfind(self.response_end_token)
125 + content = content[:response_end_idx]
126 + # remove \n after </think> or </response>
127 + if previous_token_ids[-1] in self.parser_token_ids and (
128 + len(delta_token_ids) > 0 and delta_token_ids[0] == self.newline_token_id
129 + ):
130 + content = content.lstrip("\n")
131 + # remove \n after </think>\n
132 + if (
133 + len(previous_token_ids) > 1
134 + and previous_token_ids[-2] == self.end_token_id
135 + ) and (
136 + len(delta_token_ids) > 0 and delta_token_ids[0] == self.newline_token_id
137 + ):
138 + content = content.lstrip("\n")
139 +
140 + return DeltaMessage(content=content if content else None)
141 + else:
142 + # no </think> in previous or delta, reasoning content continues
143 + return DeltaMessage(reasoning_content=delta_text)
144 +
145 + def extract_reasoning_content(
146 + self, model_output: str, request: ChatCompletionRequest
147 + ) -> tuple[str | None, str | None]:
148 + """
149 + Extract reasoning content from the model output.
150 + The Ernie45 thinking model ouput format is
151 + abc\n</think>\n\n\n<response>\ndef\n</response>\n
152 + or abc\n</think>\ndef
153 + - 'abc' goes to reasoning_content
154 + - 'def' goes to content
155 + Returns:
156 + tuple[Optional[str], Optional[str]]: reasoning content and content
157 + """
158 + reasoning_content, content = super().extract_reasoning_content(
159 + model_output, request
160 + )
161 + if content:
162 + start_idx = content.find(self.response_start_token)
163 + end_idx = content.rfind(self.response_end_token)
164 + # Simultaneously existing and in the correct order
165 + if start_idx != -1 and end_idx != -1 and start_idx < end_idx:
166 + content = content[start_idx + len(self.response_start_token) : end_idx]
167 + final_content = content or None
168 +
169 + return reasoning_content, final_content