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 4, 17:30 UTC
[Model] Support Hy3 preview (#40681)
Signed-off-by: stevenkuang <stevenkuang@tencent.com> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>
stevenkuang committed
Apr 23, 2026 at 22:08 UTC
d0009ddb0b96e95bcfae6038e9b8673bd2263058
16 files changed
+2696
docs/models/supported_models.md
+1
@@ -419,6 +419,7 @@ th {
419
| `Grok1ForCausalLM` | Grok2 | `xai-org/grok-2` | ✅︎ | ✅︎ |
420
| `HunYuanDenseV1ForCausalLM` | Hunyuan Dense | `tencent/Hunyuan-7B-Instruct` | ✅︎ | ✅︎ |
421
| `HunYuanMoEV1ForCausalLM` | Hunyuan-A13B | `tencent/Hunyuan-A13B-Instruct`, `tencent/Hunyuan-A13B-Pretrain`, `tencent/Hunyuan-A13B-Instruct-FP8`, etc. | ✅︎ | ✅︎ |
422
+| `HYV3ForCausalLM` | HY3 | `tencent/Hy3-preview-Base`, `tencent/Hy3-preview` | ✅︎ | ✅︎ |
423
| `HyperCLOVAXForCausalLM` | HyperCLOVAX-SEED-Think-14B | `naver-hyperclovax/HyperCLOVAX-SEED-Think-14B` | ✅︎ | ✅︎ |
424
| `InternLMForCausalLM` | InternLM | `internlm/internlm-7b`, `internlm/internlm-chat-7b`, etc. | ✅︎ | ✅︎ |
425
| `InternLM2ForCausalLM` | InternLM2 | `internlm/internlm2-7b`, `internlm/internlm2-chat-7b`, etc. | ✅︎ | ✅︎ |
tests/models/registry.py
+5
@@ -324,6 +324,7 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
324
"HunYuanMoEV1ForCausalLM": _HfExamplesInfo(
325
"tencent/Hunyuan-A13B-Instruct", trust_remote_code=True
326
),
327
+ "HYV3ForCausalLM": _HfExamplesInfo("tencent/Hy3-preview", trust_remote_code=True),
328
"HyperCLOVAXForCausalLM": _HfExamplesInfo(
329
"naver-hyperclovax/HyperCLOVAX-SEED-Think-14B",
330
trust_remote_code=True,
@@ -1516,6 +1517,10 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1517
is_available_online=False,
1518
min_transformers_version="5.1.0",
1519
),
1520
+ "HYV3MTPModel": _HfExamplesInfo(
1521
+ "tencent/Hy3-preview",
1522
+ speculative_model="tencent/Hy3-preview",
1523
+ ),
1524
"LongCatFlashMTPModel": _HfExamplesInfo(
1525
"meituan-longcat/LongCat-Flash-Chat",
1526
trust_remote_code=True,
tests/reasoning/test_hy_v3_reasoning_parser.py
new
+243
@@ -0,0 +1,243 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import pytest
4
+
5
+from tests.reasoning.utils import run_reasoning_extraction
6
+from vllm.reasoning import ReasoningParser, ReasoningParserManager
7
+from vllm.tokenizers import get_tokenizer
8
+
9
+parser_name = "hy_v3"
10
+MODEL = "tencent/Hy3-preview"
11
+
12
+
13
+@pytest.fixture(scope="module")
14
+def hy_v3_tokenizer():
15
+ return get_tokenizer(tokenizer_name=MODEL)
16
+
17
+
18
+WITH_THINK = {
19
+ "output": "This is a reasoning section</think>This is the rest",
20
+ "reasoning": "This is a reasoning section",
21
+ "content": "This is the rest",
22
+ "is_reasoning_end": True,
23
+ "reasoning_effort": "high",
24
+}
25
+
26
+WITH_THINK_STREAM = {
27
+ "output": "This is a reasoning section</think>This is the rest",
28
+ "reasoning": "This is a reasoning section",
29
+ "content": "This is the rest",
30
+ "is_reasoning_end": True,
31
+ "reasoning_effort": "high",
32
+}
33
+
34
+WITHOUT_THINK = {
35
+ "output": "This is the rest",
36
+ "reasoning": None,
37
+ "content": "This is the rest",
38
+ "is_reasoning_end": True,
39
+ "reasoning_effort": "no_think",
40
+}
41
+
42
+WITHOUT_THINK_STREAM = {
43
+ "output": "This is the rest",
44
+ "reasoning": None,
45
+ "content": "This is the rest",
46
+ "is_reasoning_end": True,
47
+ "reasoning_effort": "no_think",
48
+}
49
+
50
+WITH_REASONING_EFFORT_NONE = {
51
+ "output": "This is the rest",
52
+ "reasoning": None,
53
+ "content": "This is the rest",
54
+ "is_reasoning_end": True,
55
+}
56
+
57
+WITH_REASONING_EFFORT_NONE_STREAM = {
58
+ "output": "This is the rest",
59
+ "reasoning": None,
60
+ "content": "This is the rest",
61
+ "is_reasoning_end": True,
62
+}
63
+
64
+COMPLETE_REASONING = {
65
+ "output": "This is a reasoning section</think>",
66
+ "reasoning": "This is a reasoning section",
67
+ "content": None,
68
+ "is_reasoning_end": True,
69
+ "reasoning_effort": "high",
70
+}
71
+MULTILINE_REASONING = {
72
+ "output": "This is a reasoning\nsection</think>This is the rest\nThat",
73
+ "reasoning": "This is a reasoning\nsection",
74
+ "content": "This is the rest\nThat",
75
+ "is_reasoning_end": True,
76
+ "reasoning_effort": "high",
77
+}
78
+ONLY_OPEN_TAG = {
79
+ "output": "This is a reasoning section",
80
+ "reasoning": "This is a reasoning section",
81
+ "content": None,
82
+ "is_reasoning_end": False,
83
+ "reasoning_effort": "high",
84
+}
85
+
86
+ONLY_OPEN_TAG_STREAM = {
87
+ "output": "This is a reasoning section",
88
+ "reasoning": "This is a reasoning section",
89
+ "content": None,
90
+ "is_reasoning_end": False,
91
+ "reasoning_effort": "high",
92
+}
93
+
94
+TEST_CASES = [
95
+ pytest.param(
96
+ False,
97
+ WITH_THINK,
98
+ id="with_think",
99
+ ),
100
+ pytest.param(
101
+ True,
102
+ WITH_THINK_STREAM,
103
+ id="with_think_stream",
104
+ ),
105
+ pytest.param(
106
+ False,
107
+ WITHOUT_THINK,
108
+ id="without_think",
109
+ ),
110
+ pytest.param(
111
+ True,
112
+ WITHOUT_THINK_STREAM,
113
+ id="without_think_stream",
114
+ ),
115
+ pytest.param(
116
+ False,
117
+ WITH_REASONING_EFFORT_NONE,
118
+ id="with_reasoning_effort_none",
119
+ ),
120
+ pytest.param(
121
+ True,
122
+ WITH_REASONING_EFFORT_NONE_STREAM,
123
+ id="with_reasoning_effort_none_stream",
124
+ ),
125
+ pytest.param(
126
+ False,
127
+ COMPLETE_REASONING,
128
+ id="complete_reasoning",
129
+ ),
130
+ pytest.param(
131
+ True,
132
+ COMPLETE_REASONING,
133
+ id="complete_reasoning_stream",
134
+ ),
135
+ pytest.param(
136
+ False,
137
+ MULTILINE_REASONING,
138
+ id="multiline_reasoning",
139
+ ),
140
+ pytest.param(
141
+ True,
142
+ MULTILINE_REASONING,
143
+ id="multiline_reasoning_stream",
144
+ ),
145
+ pytest.param(
146
+ False,
147
+ ONLY_OPEN_TAG,
148
+ id="only_open_tag",
149
+ ),
150
+ pytest.param(
151
+ True,
152
+ ONLY_OPEN_TAG_STREAM,
153
+ id="only_open_tag_stream",
154
+ ),
155
+]
156
+
157
+STILL_REASONING_PROMPT = """<|hy_begin▁of▁sentence|>
158
+You are a helpful assistant.
159
+<|reasoning_mode|>reasoning_effort:high<|hy_User|>
160
+What is the capital of France?<|hy_Assistant|>
161
+<think>The user is asking for the capital of"""
162
+
163
+DONE_REASONING_PROMPT = """<|hy_begin▁of▁sentence|>
164
+You are a helpful assistant.
165
+<|reasoning_mode|>reasoning_effort:high<|hy_User|>
166
+What is the capital of France?<|hy_Assistant|>
167
+<think>The user is asking for the capital of France.</think>
168
+The capital of France is Paris."""
169
+
170
+MULTI_TURN_STILL_REASONING_PROMPT = """<|hy_begin▁of▁sentence|>
171
+You are a helpful assistant.
172
+<|reasoning_mode|>reasoning_effort:high<|hy_User|>
173
+What is the capital of France?<|hy_Assistant|
174
+><think></think>The capital of France is Paris.<eos:6124c78e>
175
+<|hy_User|>What about Chile?<|hy_Assistant|>
176
+<think>The user is asking for the capital of"""
177
+
178
+MULTI_TURN_DONE_REASONING_PROMPT = """<|hy_begin▁of▁sentence|>
179
+You are a helpful assistant.
180
+<|reasoning_mode|>reasoning_effort:high<|hy_User|>
181
+What is the capital of France?<|hy_Assistant|
182
+><think></think>The capital of France is Paris.<eos:6124c78e>
183
+<|hy_User|>What about Chile?<|hy_Assistant|>
184
+<think>The user is asking for the capital of Chile.</think>
185
+The capital of Chile is Santiago."""
186
+
187
+REASONING_END_TEST_CASES = [
188
+ pytest.param(STILL_REASONING_PROMPT, False, id="still_reasoning"),
189
+ pytest.param(DONE_REASONING_PROMPT, True, id="done_reasoning"),
190
+ pytest.param(
191
+ MULTI_TURN_STILL_REASONING_PROMPT, False, id="multi_turn_still_reasoning"
192
+ ),
193
+ pytest.param(
194
+ MULTI_TURN_DONE_REASONING_PROMPT, True, id="multi_turn_done_reasoning"
195
+ ),
196
+]
197
+
198
+
199
+@pytest.mark.parametrize("streaming, param_dict", TEST_CASES)
200
+def test_reasoning(
201
+ streaming: bool,
202
+ param_dict: dict,
203
+ hy_v3_tokenizer,
204
+):
205
+ output = hy_v3_tokenizer.tokenize(param_dict["output"])
206
+ output_tokens: list[str] = [
207
+ hy_v3_tokenizer.convert_tokens_to_string([token]) for token in output
208
+ ]
209
+
210
+ parser_kwargs = {}
211
+ if "reasoning_effort" in param_dict:
212
+ parser_kwargs["chat_template_kwargs"] = {
213
+ "reasoning_effort": param_dict["reasoning_effort"]
214
+ }
215
+ parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
216
+ hy_v3_tokenizer,
217
+ **parser_kwargs,
218
+ )
219
+
220
+ reasoning, content = run_reasoning_extraction(
221
+ parser, output_tokens, streaming=streaming
222
+ )
223
+
224
+ assert reasoning == param_dict["reasoning"]
225
+ assert content == param_dict["content"]
226
+
227
+ output_ids = hy_v3_tokenizer.convert_tokens_to_ids(output)
228
+ is_reasoning_end = parser.is_reasoning_end(output_ids)
229
+ assert is_reasoning_end == param_dict["is_reasoning_end"]
230
+
231
+
232
+@pytest.mark.parametrize("prompt, is_reasoning_end", REASONING_END_TEST_CASES)
233
+def test_is_reasoning_end_full_prompt(
234
+ prompt: str, is_reasoning_end: bool, hy_v3_tokenizer
235
+):
236
+ parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
237
+ hy_v3_tokenizer,
238
+ chat_template_kwargs={"reasoning_effort": "high"},
239
+ )
240
+ tokens = hy_v3_tokenizer.tokenize(prompt)
241
+ token_ids = hy_v3_tokenizer.convert_tokens_to_ids(tokens)
242
+ check_is_reasoning_end = parser.is_reasoning_end(token_ids)
243
+ assert check_is_reasoning_end == is_reasoning_end
tests/tool_parsers/test_hy_v3_tool_parser.py
new
+274
@@ -0,0 +1,274 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+# ruff: noqa: E501
4
+"""Tests for the HYV3 tool call parser."""
5
+
6
+import json
7
+from unittest.mock import Mock
8
+
9
+import pytest
10
+
11
+from vllm.entrypoints.openai.chat_completion.protocol import (
12
+ ChatCompletionRequest,
13
+ ChatCompletionToolsParam,
14
+ FunctionDefinition,
15
+)
16
+from vllm.entrypoints.openai.engine.protocol import DeltaMessage
17
+from vllm.tokenizers import get_tokenizer
18
+from vllm.tool_parsers.hy_v3_tool_parser import HYV3ToolParser
19
+
20
+parser_name = "hy_v3"
21
+MODEL = "tencent/Hy3-preview"
22
+
23
+
24
+@pytest.fixture(scope="module")
25
+def hy_v3_tokenizer():
26
+ return get_tokenizer(tokenizer_name=MODEL)
27
+
28
+
29
+@pytest.fixture
30
+def hy_v3_tool_parser(hy_v3_tokenizer):
31
+ return HYV3ToolParser(hy_v3_tokenizer)
32
+
33
+
34
+@pytest.fixture
35
+def mock_request() -> ChatCompletionRequest:
36
+ request = Mock(spec=ChatCompletionRequest)
37
+ request.tools = [
38
+ ChatCompletionToolsParam(
39
+ function=FunctionDefinition(name="get_current_date", parameters={}),
40
+ ),
41
+ ChatCompletionToolsParam(
42
+ function=FunctionDefinition(
43
+ name="get_weather",
44
+ parameters={
45
+ "type": "object",
46
+ "properties": {
47
+ "city": {"type": "string"},
48
+ "date": {"type": "string"},
49
+ },
50
+ },
51
+ ),
52
+ ),
53
+ ]
54
+ request.tool_choice = "auto"
55
+ return request
56
+
57
+
58
+class TestHYV3ExtractToolCalls:
59
+ def test_no_tool_call(self, hy_v3_tool_parser, mock_request):
60
+ out = "This is a plain response."
61
+ r = hy_v3_tool_parser.extract_tool_calls(out, request=mock_request)
62
+ assert not r.tools_called
63
+ assert r.content == out
64
+
65
+ def test_zero_arg_inline(self, hy_v3_tool_parser, mock_request):
66
+ out = (
67
+ "<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>"
68
+ )
69
+ r = hy_v3_tool_parser.extract_tool_calls(out, request=mock_request)
70
+ assert r.tools_called
71
+ assert r.tool_calls[0].function.name == "get_current_date"
72
+ assert json.loads(r.tool_calls[0].function.arguments) == {}
73
+ assert r.content is None
74
+
75
+ def test_zero_arg_newline(self, hy_v3_tool_parser, mock_request):
76
+ out = "<tool_calls>\n<tool_call>get_current_date<tool_sep>\n</tool_call>\n</tool_calls>"
77
+ r = hy_v3_tool_parser.extract_tool_calls(out, request=mock_request)
78
+ assert r.tools_called
79
+ assert r.tool_calls[0].function.name == "get_current_date"
80
+
81
+ def test_args_same_line(self, hy_v3_tool_parser, mock_request):
82
+ out = (
83
+ "<tool_calls><tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>Beijing"
84
+ "</arg_value><arg_key>date</arg_key><arg_value>2026-03-30</arg_value></tool_call></tool_calls>"
85
+ )
86
+ r = hy_v3_tool_parser.extract_tool_calls(out, request=mock_request)
87
+ assert r.tools_called
88
+ assert json.loads(r.tool_calls[0].function.arguments) == {
89
+ "city": "Beijing",
90
+ "date": "2026-03-30",
91
+ }
92
+
93
+ def test_args_with_newlines(self, hy_v3_tool_parser, mock_request):
94
+ out = (
95
+ "<tool_calls>\n<tool_call>get_weather<tool_sep>\n<arg_key>city</arg_key>\n<arg_value>Beijing"
96
+ "</arg_value>\n<arg_key>date</arg_key>\n<arg_value>2026-03-30</arg_value>\n</tool_call>\n</tool_calls>"
97
+ )
98
+ r = hy_v3_tool_parser.extract_tool_calls(out, request=mock_request)
99
+ assert r.tools_called
100
+ assert json.loads(r.tool_calls[0].function.arguments) == {
101
+ "city": "Beijing",
102
+ "date": "2026-03-30",
103
+ }
104
+
105
+ def test_content_before(self, hy_v3_tool_parser, mock_request):
106
+ out = "Checking.<tool_calls>\n<tool_call>get_current_date<tool_sep>\n</tool_call>\n</tool_calls>"
107
+ r = hy_v3_tool_parser.extract_tool_calls(out, request=mock_request)
108
+ assert r.tools_called
109
+ assert r.content == "Checking."
110
+
111
+ def test_multiple(self, hy_v3_tool_parser, mock_request):
112
+ out = (
113
+ "<tool_calls>\n<tool_call>get_weather<tool_sep>\n<arg_key>city</arg_key>\n<arg_value>Beijing"
114
+ "</arg_value>\n<arg_key>date</arg_key>\n<arg_value>2026-03-30</arg_value>\n</tool_call>\n"
115
+ "<tool_call>get_weather<tool_sep>\n<arg_key>city</arg_key>\n<arg_value>Hangzhou</arg_value>\n"
116
+ "<arg_key>date</arg_key>\n<arg_value>2026-03-30</arg_value>\n</tool_call>\n</tool_calls>"
117
+ )
118
+ r = hy_v3_tool_parser.extract_tool_calls(out, request=mock_request)
119
+ assert len(r.tool_calls) == 2
120
+
121
+ def test_empty_content_none(self, hy_v3_tool_parser, mock_request):
122
+ out = "<tool_calls>\n<tool_call>get_current_date<tool_sep>\n</tool_call>\n</tool_calls>"
123
+ r = hy_v3_tool_parser.extract_tool_calls(out, request=mock_request)
124
+ assert r.content is None
125
+
126
+
127
+def _simulate_streaming(
128
+ parser: HYV3ToolParser,
129
+ deltas: list[str],
130
+ request: ChatCompletionRequest,
131
+) -> list[DeltaMessage | None]:
132
+ results: list[DeltaMessage | None] = []
133
+ previous_text = ""
134
+ previous_token_ids: list[int] = []
135
+ vocab = parser.vocab
136
+ for delta_text in deltas:
137
+ current_text = previous_text + delta_text
138
+ delta_token_ids = [tid for tok, tid in vocab.items() if tok in delta_text]
139
+ current_token_ids = previous_token_ids + delta_token_ids
140
+ result = parser.extract_tool_calls_streaming(
141
+ previous_text=previous_text,
142
+ current_text=current_text,
143
+ delta_text=delta_text,
144
+ previous_token_ids=previous_token_ids,
145
+ current_token_ids=current_token_ids,
146
+ delta_token_ids=delta_token_ids,
147
+ request=request,
148
+ )
149
+ results.append(result)
150
+ previous_text = current_text
151
+ previous_token_ids = current_token_ids
152
+ return results
153
+
154
+
155
+def _collect_streaming_tool_calls(results: list[DeltaMessage | None]) -> list[dict]:
156
+ tool_calls: dict[int, dict] = {}
157
+ for result in results:
158
+ if result is None or not result.tool_calls:
159
+ continue
160
+ for tc in result.tool_calls:
161
+ idx = tc.index
162
+ if idx not in tool_calls:
163
+ tool_calls[idx] = {
164
+ "name": tc.function.name or "",
165
+ "arguments": tc.function.arguments or "",
166
+ }
167
+ else:
168
+ if tc.function.name:
169
+ tool_calls[idx]["name"] += tc.function.name
170
+ if tc.function.arguments:
171
+ tool_calls[idx]["arguments"] += tc.function.arguments
172
+ return [tool_calls[i] for i in sorted(tool_calls.keys())]
173
+
174
+
175
+def _collect_streaming_content(results: list[DeltaMessage | None]) -> str:
176
+ parts = []
177
+ for result in results:
178
+ if result is not None and result.content:
179
+ parts.append(result.content)
180
+ return "".join(parts)
181
+
182
+
183
+class TestHYV3ExtractToolCallsStreaming:
184
+ def test_no_tool_call_streaming(self, hy_v3_tool_parser, mock_request):
185
+ deltas = ["This is ", "a plain ", "response."]
186
+ results = _simulate_streaming(hy_v3_tool_parser, deltas, mock_request)
187
+ content = _collect_streaming_content(results)
188
+ assert content == "This is a plain response."
189
+ assert len(_collect_streaming_tool_calls(results)) == 0
190
+
191
+ def test_zero_arg_streaming(self, hy_v3_tool_parser, mock_request):
192
+ deltas = [
193
+ "<tool_calls>",
194
+ "\n<tool_call>",
195
+ "get_current_date",
196
+ "<tool_sep>",
197
+ "\n</tool_call>",
198
+ "\n</tool_calls>",
199
+ ]
200
+ results = _simulate_streaming(hy_v3_tool_parser, deltas, mock_request)
201
+ tc = _collect_streaming_tool_calls(results)
202
+ assert len(tc) == 1
203
+ assert tc[0]["name"] == "get_current_date"
204
+ assert json.loads(tc[0]["arguments"]) == {}
205
+
206
+ def test_args_streaming(self, hy_v3_tool_parser, mock_request):
207
+ deltas = [
208
+ "<tool_calls>",
209
+ "\n<tool_call>",
210
+ "get_weather",
211
+ "<tool_sep>",
212
+ "\n<arg_key>city</arg_key>",
213
+ "\n<arg_value>Beijing</arg_value>",
214
+ "\n<arg_key>date</arg_key>",
215
+ "\n<arg_value>2026-03-30</arg_value>",
216
+ "\n</tool_call>",
217
+ "\n</tool_calls>",
218
+ ]
219
+ results = _simulate_streaming(hy_v3_tool_parser, deltas, mock_request)
220
+ tc = _collect_streaming_tool_calls(results)
221
+ assert len(tc) == 1 and tc[0]["name"] == "get_weather"
222
+ assert json.loads(tc[0]["arguments"]) == {
223
+ "city": "Beijing",
224
+ "date": "2026-03-30",
225
+ }
226
+
227
+ def test_content_before_streaming(self, hy_v3_tool_parser, mock_request):
228
+ deltas = [
229
+ "Checking.",
230
+ "<tool_calls>",
231
+ "\n<tool_call>",
232
+ "get_current_date",
233
+ "<tool_sep>",
234
+ "\n</tool_call>",
235
+ "\n</tool_calls>",
236
+ ]
237
+ results = _simulate_streaming(hy_v3_tool_parser, deltas, mock_request)
238
+ assert "Checking." in _collect_streaming_content(results)
239
+ tc = _collect_streaming_tool_calls(results)
240
+ assert len(tc) == 1 and tc[0]["name"] == "get_current_date"
241
+
242
+ def test_multiple_streaming(self, hy_v3_tool_parser, mock_request):
243
+ deltas = [
244
+ "<tool_calls>",
245
+ "\n<tool_call>",
246
+ "get_weather",
247
+ "<tool_sep>",
248
+ "\n<arg_key>city</arg_key>",
249
+ "\n<arg_value>Beijing</arg_value>",
250
+ "\n<arg_key>date</arg_key>",
251
+ "\n<arg_value>2026-03-30</arg_value>",
252
+ "\n</tool_call>",
253
+ "\n<tool_call>",
254
+ "get_weather",
255
+ "<tool_sep>",
256
+ "\n<arg_key>city</arg_key>",
257
+ "\n<arg_value>Hangzhou</arg_value>",
258
+ "\n<arg_key>date</arg_key>",
259
+ "\n<arg_value>2026-03-30</arg_value>",
260
+ "\n</tool_call>",
261
+ "\n</tool_calls>",
262
+ ]
263
+ results = _simulate_streaming(hy_v3_tool_parser, deltas, mock_request)
264
+ tc = _collect_streaming_tool_calls(results)
265
+ assert len(tc) == 2
266
+ assert json.loads(tc[0]["arguments"])["city"] == "Beijing"
267
+ assert json.loads(tc[1]["arguments"])["city"] == "Hangzhou"
268
+
269
+ def test_all_in_one_delta_streaming(self, hy_v3_tool_parser, mock_request):
270
+ out = "<tool_calls>\n<tool_call>get_current_date<tool_sep>\n</tool_call>\n</tool_calls>"
271
+ results = _simulate_streaming(hy_v3_tool_parser, [out], mock_request)
272
+ tc = _collect_streaming_tool_calls(results)
273
+ assert len(tc) == 1 and tc[0]["name"] == "get_current_date"
274
+ assert json.loads(tc[0]["arguments"]) == {}
vllm/config/speculative.py
+8
@@ -47,6 +47,7 @@ MTPModelTypes = Literal[
47
"mtp",
48
"pangu_ultra_moe_mtp",
49
"step3p5_mtp",
50
+ "hy_v3_mtp",
51
]
52
NgramGPUTypes = Literal["ngram_gpu"]
53
DFlashModelTypes = Literal["dflash"]
@@ -364,6 +365,13 @@ class SpeculativeConfig:
365
if initial_architecture == "MistralLarge3ForCausalLM":
366
hf_config.update({"architectures": ["EagleMistralLarge3ForCausalLM"]})
367
368
+ if hf_config.model_type == "hy_v3":
369
+ hf_config.model_type = "hy_v3_mtp"
370
+ n_predict = getattr(hf_config, "num_nextn_predict_layers", None)
371
+ hf_config.update(
372
+ {"n_predict": n_predict, "architectures": ["HYV3MTPModel"]}
373
+ )
374
+
375
return hf_config
376
377
def __post_init__(self):
vllm/model_executor/model_loader/weight_utils.py
+8
@@ -1562,6 +1562,11 @@ def maybe_remap_kv_scale_name(name: str, params_dict: dict) -> str | None:
1562
# NemotronH format: .mixer.{k,v}_proj.{k,v}_scale ->
1563
# .mixer.attn.{k,v}_scale
1564
(r"\.mixer\.[kv]_proj\.([kv])_scale$", r".mixer.attn.\1_scale"),
1565
+ # HYV3 format: .self_attn.q.scale -> .self_attn.attn.q_scale
1566
+ (r"\.self_attn\.q\.scale$", r".self_attn.attn.q_scale"),
1567
+ # HYV3 format: .self_attn.{k,v}_cache.scale ->
1568
+ # .self_attn.attn.{k,v}_scale
1569
+ (r"\.self_attn\.([kv])_cache\.scale$", r".self_attn.attn.\1_scale"),
1570
# Default format: .{k,v}_scale -> .attn.{k,v}_scale
1571
(r"\.([qkv])_scale$", r".attn.\1_scale"),
1572
(r"\.([qkv])_zero_point$", r".attn.\1_zero_point"),
@@ -1576,6 +1581,9 @@ def maybe_remap_kv_scale_name(name: str, params_dict: dict) -> str | None:
1581
".k_zero_point",
1582
".v_zero_point",
1583
".q_zero_point",
1584
+ ".q.scale",
1585
+ ".k_cache.scale",
1586
+ ".v_cache.scale",
1587
)
1588
):
1589
import regex as re
vllm/model_executor/models/hy_v3.py
new
+707
@@ -0,0 +1,707 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+# coding=utf-8
5
+# Copyright 2026 The HY team.
6
+# Copyright 2023 The vLLM team.
7
+# Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved.
8
+#
9
+# This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX
10
+# and OPT implementations in this library. It has been modified from its
11
+# original forms to accommodate minor architectural differences compared
12
+# to GPT-NeoX and OPT used by the Meta AI team that trained the model.
13
+#
14
+# Licensed under the Apache License, Version 2.0 (the "License");
15
+# you may not use this file except in compliance with the License.
16
+# You may obtain a copy of the License at
17
+#
18
+# http://www.apache.org/licenses/LICENSE-2.0
19
+#
20
+# Unless required by applicable law or agreed to in writing, software
21
+# distributed under the License is distributed on an "AS IS" BASIS,
22
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
23
+# See the License for the specific language governing permissions and
24
+# limitations under the License.
25
+"""Inference-only HY model compatible with HuggingFace weights."""
26
+
27
+import typing
28
+from collections.abc import Callable, Iterable
29
+from itertools import islice
30
+from typing import Any
31
+
32
+import torch
33
+from torch import nn
34
+from transformers import PretrainedConfig
35
+
36
+from vllm.compilation.decorators import support_torch_compile
37
+from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
38
+from vllm.distributed import (
39
+ get_ep_group,
40
+ get_pp_group,
41
+ get_tensor_model_parallel_world_size,
42
+)
43
+from vllm.logger import init_logger
44
+from vllm.model_executor.layers.activation import SiluAndMul
45
+from vllm.model_executor.layers.attention import Attention
46
+from vllm.model_executor.layers.fused_moe import FusedMoE, GateLinear
47
+from vllm.model_executor.layers.layernorm import RMSNorm
48
+from vllm.model_executor.layers.linear import (
49
+ MergedColumnParallelLinear,
50
+ QKVParallelLinear,
51
+ RowParallelLinear,
52
+)
53
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
54
+from vllm.model_executor.layers.quantization import QuantizationConfig
55
+from vllm.model_executor.layers.rotary_embedding import get_rope
56
+from vllm.model_executor.layers.vocab_parallel_embedding import (
57
+ ParallelLMHead,
58
+ VocabParallelEmbedding,
59
+)
60
+from vllm.model_executor.model_loader.weight_utils import (
61
+ default_weight_loader,
62
+ maybe_remap_kv_scale_name,
63
+)
64
+from vllm.sequence import IntermediateTensors
65
+from vllm.transformers_utils.configs.hy_v3 import HYV3Config
66
+
67
+from .interfaces import SupportsLoRA, SupportsPP
68
+from .utils import (
69
+ AutoWeightsLoader,
70
+ PPMissingLayer,
71
+ is_pp_missing_parameter,
72
+ make_empty_intermediate_tensors_factory,
73
+ make_layers,
74
+ maybe_prefix,
75
+)
76
+
77
+logger = init_logger(__name__)
78
+
79
+
80
+class HYV3FeedForward(nn.Module):
81
+ def __init__(
82
+ self,
83
+ hidden_size: int,
84
+ intermediate_size: int,
85
+ hidden_act: str,
86
+ quant_config: QuantizationConfig | None = None,
87
+ reduce_results: bool = True,
88
+ expert_gate: torch.nn.Linear | None = None,
89
+ prefix: str = "",
90
+ ) -> None:
91
+ super().__init__()
92
+ self.gate_up_proj = MergedColumnParallelLinear(
93
+ hidden_size,
94
+ [intermediate_size] * 2,
95
+ bias=False,
96
+ quant_config=quant_config,
97
+ prefix=f"{prefix}.gate_up_proj",
98
+ )
99
+ self.down_proj = RowParallelLinear(
100
+ intermediate_size,
101
+ hidden_size,
102
+ bias=False,
103
+ quant_config=quant_config,
104
+ reduce_results=reduce_results,
105
+ prefix=f"{prefix}.down_proj",
106
+ )
107
+ if hidden_act != "silu":
108
+ raise ValueError(
109
+ f"Unsupported activation: {hidden_act}. Only silu is supported for now."
110
+ )
111
+ self.act_fn = SiluAndMul()
112
+
113
+ def forward(self, x):
114
+ gate_up, _ = self.gate_up_proj(x)
115
+ out = self.act_fn(gate_up)
116
+ out, _ = self.down_proj(out)
117
+ return out
118
+
119
+
120
+class HYV3MoEFused(nn.Module):
121
+ def __init__(
122
+ self,
123
+ config: HYV3Config,
124
+ quant_config: QuantizationConfig | None = None,
125
+ prefix: str = "",
126
+ enable_eplb: bool = False,
127
+ ):
128
+ super().__init__()
129
+ self.tp_size = get_tensor_model_parallel_world_size()
130
+ self.ep_group = get_ep_group().device_group
131
+ self.ep_rank = get_ep_group().rank_in_group
132
+ self.ep_size = self.ep_group.size()
133
+ self.n_routed_experts = config.num_experts
134
+ if self.tp_size > config.num_experts:
135
+ raise ValueError(
136
+ f"Tensor parallel size {self.tp_size} is greater than "
137
+ f"the number of experts {config.num_experts}."
138
+ )
139
+ top_k = config.num_experts_per_tok
140
+ intermediate_size = config.expert_hidden_dim
141
+ router_scaling_factor = getattr(config, "router_scaling_factor", 1.0)
142
+ vllm_config = get_current_vllm_config()
143
+ eplb_config = vllm_config.parallel_config.eplb_config
144
+ self.enable_eplb = enable_eplb
145
+
146
+ self.n_logical_experts = self.n_routed_experts
147
+ self.n_redundant_experts = eplb_config.num_redundant_experts
148
+ self.n_physical_experts = self.n_logical_experts + self.n_redundant_experts
149
+ self.n_local_physical_experts = self.n_physical_experts // self.ep_size
150
+ self.physical_expert_start = self.ep_rank * self.n_local_physical_experts
151
+ self.physical_expert_end = (
152
+ self.physical_expert_start + self.n_local_physical_experts
153
+ )
154
+ self.gate = GateLinear(
155
+ config.hidden_size,
156
+ config.num_experts,
157
+ bias=False,
158
+ out_dtype=torch.float32,
159
+ params_dtype=torch.float32,
160
+ prefix=f"{prefix}.gate",
161
+ )
162
+
163
+ if config.num_shared_experts > 0:
164
+ self.shared_mlp = HYV3FeedForward(
165
+ hidden_size=config.hidden_size,
166
+ intermediate_size=config.expert_hidden_dim * config.num_shared_experts,
167
+ hidden_act=config.hidden_act,
168
+ quant_config=quant_config,
169
+ prefix=f"{prefix}",
170
+ reduce_results=False,
171
+ )
172
+ else:
173
+ self.shared_mlp = None
174
+
175
+ self.expert_bias = nn.Parameter(torch.empty(config.num_experts))
176
+ scoring_func = "sigmoid"
177
+ e_score_correction_bias = self.expert_bias
178
+
179
+ self.experts = FusedMoE(
180
+ num_experts=self.n_routed_experts,
181
+ top_k=top_k,
182
+ hidden_size=config.hidden_size,
183
+ intermediate_size=intermediate_size,
184
+ renormalize=config.route_norm,
185
+ quant_config=quant_config,
186
+ prefix=f"{prefix}.experts",
187
+ enable_eplb=self.enable_eplb,
188
+ num_redundant_experts=self.n_redundant_experts,
189
+ scoring_func=scoring_func,
190
+ use_grouped_topk=True,
191
+ num_expert_group=1,
192
+ topk_group=1,
193
+ routed_scaling_factor=router_scaling_factor,
194
+ e_score_correction_bias=e_score_correction_bias,
195
+ n_shared_experts=config.num_shared_experts,
196
+ shared_experts=self.shared_mlp,
197
+ )
198
+
199
+ def forward(
200
+ self,
201
+ hidden_states: torch.Tensor,
202
+ ) -> torch.Tensor:
203
+ orig_shape = hidden_states.shape
204
+ hidden_dim = hidden_states.shape[-1]
205
+ hidden_states = hidden_states.view(-1, hidden_dim)
206
+
207
+ # router_logits: (num_tokens, n_experts)
208
+ router_logits, _ = self.gate(hidden_states)
209
+
210
+ final_hidden_states = self.experts(
211
+ hidden_states=hidden_states, router_logits=router_logits
212
+ )
213
+ return final_hidden_states.view(orig_shape)
214
+
215
+
216
+class HYV3Attention(nn.Module):
217
+ def __init__(
218
+ self,
219
+ config: PretrainedConfig,
220
+ hidden_size: int,
221
+ num_heads: int,
222
+ num_kv_heads: int,
223
+ rope_parameters: dict[str, Any],
224
+ max_position_embeddings: int = 8192,
225
+ head_dim: int | None = None,
226
+ rms_norm_eps: float = 1e-5,
227
+ cache_config: CacheConfig | None = None,
228
+ quant_config: QuantizationConfig | None = None,
229
+ prefix: str = "",
230
+ dual_chunk_attention_config: dict[str, Any] | None = None,
231
+ ) -> None:
232
+ super().__init__()
233
+ self.hidden_size = hidden_size
234
+ tp_size = get_tensor_model_parallel_world_size()
235
+ self.total_num_heads = num_heads
236
+ assert self.total_num_heads % tp_size == 0
237
+ self.num_heads = self.total_num_heads // tp_size
238
+ self.total_num_kv_heads = num_kv_heads
239
+ if self.total_num_kv_heads >= tp_size:
240
+ # Number of KV heads is greater than TP size, so we partition
241
+ # the KV heads across multiple tensor parallel GPUs.
242
+ assert self.total_num_kv_heads % tp_size == 0
243
+ else:
244
+ # Number of KV heads is less than TP size, so we replicate
245
+ # the KV heads across multiple tensor parallel GPUs.
246
+ assert tp_size % self.total_num_kv_heads == 0
247
+ self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
248
+
249
+ if hasattr(config, "head_dim") and config.head_dim:
250
+ self.head_dim = config.head_dim
251
+ else:
252
+ self.head_dim = head_dim or (hidden_size // self.total_num_heads)
253
+ self.q_size = self.num_heads * self.head_dim
254
+ self.kv_size = self.num_kv_heads * self.head_dim
255
+ self.scaling = self.head_dim**-0.5
256
+ self.use_qk_norm = getattr(config, "qk_norm", False)
257
+ self.max_position_embeddings = max_position_embeddings
258
+
259
+ self.qkv_proj = QKVParallelLinear(
260
+ hidden_size,
261
+ self.head_dim,
262
+ self.total_num_heads,
263
+ self.total_num_kv_heads,
264
+ quant_config=quant_config,
265
+ bias=None,
266
+ prefix=f"{prefix}.qkv_proj",
267
+ )
268
+ self.o_proj = RowParallelLinear(
269
+ self.total_num_heads * self.head_dim,
270
+ hidden_size,
271
+ bias=False,
272
+ quant_config=quant_config,
273
+ prefix=f"{prefix}.o_proj",
274
+ )
275
+ self.rotary_emb = get_rope(
276
+ self.head_dim,
277
+ max_position=max_position_embeddings,
278
+ rope_parameters=rope_parameters,
279
+ is_neox_style=True,
280
+ )
281
+ self.attn = Attention(
282
+ self.num_heads,
283
+ self.head_dim,
284
+ self.scaling,
285
+ num_kv_heads=self.num_kv_heads,
286
+ cache_config=cache_config,
287
+ quant_config=quant_config,
288
+ prefix=f"{prefix}.attn",
289
+ )
290
+ if self.use_qk_norm:
291
+ self.q_norm = RMSNorm(self.head_dim, rms_norm_eps)
292
+ self.k_norm = RMSNorm(self.head_dim, rms_norm_eps)
293
+
294
+ def forward(
295
+ self,
296
+ positions: torch.Tensor,
297
+ hidden_states: torch.Tensor,
298
+ ) -> torch.Tensor:
299
+ qkv, _ = self.qkv_proj(hidden_states)
300
+ q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
301
+ output_shape = None
302
+ if self.use_qk_norm:
303
+ q_by_head = q.view(
304
+ *q.shape[:-1], q.shape[-1] // self.head_dim, self.head_dim
305
+ )
306
+ q_by_head = self.q_norm(q_by_head)
307
+ q = q_by_head.view(q.shape)
308
+
309
+ k_by_head = k.view(
310
+ *k.shape[:-1], k.shape[-1] // self.head_dim, self.head_dim
311
+ )
312
+ k_by_head = self.k_norm(k_by_head)
313
+ k = k_by_head.view(k.shape)
314
+ q, k = self.rotary_emb(positions, q, k)
315
+ attn_output = self.attn(q, k, v, output_shape)
316
+ attn_output = attn_output.view(q.shape[0], -1)
317
+ output, _ = self.o_proj(attn_output)
318
+ return output
319
+
320
+
321
+class HYV3DecoderLayer(nn.Module):
322
+ def __init__(
323
+ self,
324
+ config: PretrainedConfig,
325
+ cache_config: CacheConfig | None = None,
326
+ quant_config: QuantizationConfig | None = None,
327
+ prefix: str = "",
328
+ ) -> None:
329
+ super().__init__()
330
+ self.hidden_size = config.hidden_size
331
+ layer_idx = int(prefix.split(".")[-1])
332
+ max_position_embeddings = getattr(config, "max_position_embeddings", 8192)
333
+ self.self_attn = HYV3Attention(
334
+ config=config,
335
+ hidden_size=self.hidden_size,
336
+ num_heads=config.num_attention_heads,
337
+ num_kv_heads=config.num_key_value_heads,
338
+ rope_parameters=config.rope_parameters,
339
+ max_position_embeddings=max_position_embeddings,
340
+ head_dim=config.head_dim,
341
+ rms_norm_eps=config.rms_norm_eps,
342
+ cache_config=cache_config,
343
+ quant_config=quant_config,
344
+ prefix=f"{prefix}.self_attn",
345
+ )
346
+ self.input_layernorm = RMSNorm(config.hidden_size, config.rms_norm_eps)
347
+ self.post_attention_layernorm = RMSNorm(config.hidden_size, config.rms_norm_eps)
348
+ if not hasattr(config, "first_k_dense_replace"):
349
+ raise ValueError("first_k_dense_replace not exist,please check config")
350
+ if layer_idx < config.first_k_dense_replace:
351
+ self.mlp = HYV3FeedForward(
352
+ hidden_size=config.hidden_size,
353
+ intermediate_size=config.intermediate_size,
354
+ hidden_act=config.hidden_act,
355
+ quant_config=quant_config,
356
+ prefix=f"{prefix}.mlp",
357
+ )
358
+ self.block_type = "feedforward"
359
+ else:
360
+ self.mlp = HYV3MoEFused(
361
+ config=config, quant_config=quant_config, prefix=f"{prefix}.mlp"
362
+ )
363
+ self.block_type = "moe"
364
+
365
+ def forward(
366
+ self,
367
+ positions: torch.Tensor,
368
+ hidden_states: torch.Tensor,
369
+ residual: torch.Tensor | None,
370
+ idx: int = -1,
371
+ ) -> torch.Tensor:
372
+ if residual is None:
373
+ residual = hidden_states
374
+ hidden_states = self.input_layernorm(hidden_states)
375
+ else:
376
+ hidden_states, residual = self.input_layernorm(hidden_states, residual)
377
+
378
+ hidden_states = self.self_attn(
379
+ positions=positions,
380
+ hidden_states=hidden_states,
381
+ )
382
+
383
+ hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
384
+
385
+ hidden_states = self.mlp(hidden_states)
386
+
387
+ return hidden_states, residual
388
+
389
+
390
+@support_torch_compile
391
+class HYV3Model(nn.Module):
392
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
393
+ super().__init__()
394
+
395
+ config = vllm_config.model_config.hf_config
396
+ cache_config = vllm_config.cache_config
397
+ quant_config = vllm_config.quant_config
398
+
399
+ parallel_config = vllm_config.parallel_config
400
+ eplb_config = parallel_config.eplb_config
401
+ self.num_redundant_experts = eplb_config.num_redundant_experts
402
+
403
+ self.vocab_size = config.vocab_size
404
+ self.config = config
405
+ self.quant_config = quant_config
406
+
407
+ self.embed_tokens = VocabParallelEmbedding(
408
+ config.vocab_size,
409
+ config.hidden_size,
410
+ )
411
+
412
+ self.start_layer, self.end_layer, self.layers = make_layers(
413
+ config.num_hidden_layers,
414
+ lambda prefix: HYV3DecoderLayer(
415
+ config=config,
416
+ cache_config=cache_config,
417
+ quant_config=quant_config,
418
+ prefix=prefix,
419
+ ),
420
+ prefix=f"{prefix}.layers",
421
+ )
422
+ self.norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
423
+ self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
424
+ ["hidden_states", "residual"], config.hidden_size
425
+ )
426
+
427
+ # Set MoE hyperparameters
428
+ self.expert_weights = []
429
+ self.num_expert_groups = 1
430
+ self.moe_layers = []
431
+ example_layer = None
432
+ for layer in self.layers:
433
+ if isinstance(layer, PPMissingLayer):
434
+ continue
435
+
436
+ assert isinstance(layer, HYV3DecoderLayer)
437
+ if layer.block_type == "moe":
438
+ example_layer = layer.mlp
439
+ self.moe_layers.append(layer.mlp.experts)
440
+
441
+ if example_layer is None:
442
+ self.num_moe_layers = 0
443
+ raise RuntimeError("No MoE layer found in model.layers.")
444
+
445
+ self.num_moe_layers = len(self.moe_layers)
446
+ self.num_logical_experts = getattr(example_layer, "n_logical_experts", None)
447
+ self.num_physical_experts = getattr(example_layer, "n_physical_experts", None)
448
+ self.num_local_physical_experts = getattr(
449
+ example_layer, "n_local_physical_experts", None
450
+ )
451
+ self.num_routed_experts = getattr(example_layer, "n_routed_experts", None)
452
+ self.num_redundant_experts = getattr(example_layer, "n_redundant_experts", None)
453
+
454
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
455
+ return self.embed_tokens(input_ids)
456
+
457
+ def update_physical_experts_metadata(
458
+ self,
459
+ num_physical_experts: int,
460
+ num_local_physical_experts: int,
461
+ ) -> None:
462
+ assert self.num_local_physical_experts == num_local_physical_experts
463
+ self.num_physical_experts = num_physical_experts
464
+ self.num_local_physical_experts = num_local_physical_experts
465
+ self.num_redundant_experts = num_physical_experts - self.num_logical_experts
466
+ for layer in self.layers:
467
+ if isinstance(layer.mlp, HYV3MoEFused):
468
+ moe = layer.mlp
469
+ moe.n_local_physical_experts = num_local_physical_experts
470
+ moe.n_physical_experts = num_physical_experts
471
+ moe.n_redundant_experts = self.num_redundant_experts
472
+ moe.experts.update_expert_map()
473
+
474
+ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
475
+ # Params for weights, fp8 weight scales, fp8 activation scales
476
+ # (param_name, weight_name, expert_id, shard_id)
477
+ return FusedMoE.make_expert_params_mapping(
478
+ self,
479
+ ckpt_gate_proj_name="gate_proj",
480
+ ckpt_down_proj_name="down_proj",
481
+ ckpt_up_proj_name="up_proj",
482
+ num_experts=self.config.num_experts,
483
+ )
484
+
485
+ def forward(
486
+ self,
487
+ input_ids: torch.Tensor,
488
+ positions: torch.Tensor,
489
+ intermediate_tensors: IntermediateTensors | None = None,
490
+ inputs_embeds: torch.Tensor | None = None,
491
+ ) -> torch.Tensor | IntermediateTensors:
492
+ if get_pp_group().is_first_rank:
493
+ if inputs_embeds is not None:
494
+ hidden_states = inputs_embeds
495
+ else:
496
+ hidden_states = self.embed_input_ids(input_ids)
497
+ residual = None
498
+ else:
499
+ assert intermediate_tensors is not None
500
+ hidden_states = intermediate_tensors["hidden_states"]
501
+ residual = intermediate_tensors["residual"]
502
+
503
+ for idx, layer in enumerate(
504
+ islice(self.layers, self.start_layer, self.end_layer)
505
+ ):
506
+ hidden_states, residual = layer(positions, hidden_states, residual, idx=idx)
507
+ if not get_pp_group().is_last_rank:
508
+ return IntermediateTensors(
509
+ {"hidden_states": hidden_states, "residual": residual}
510
+ )
511
+
512
+ hidden_states = hidden_states + residual
513
+ residual = hidden_states
514
+
515
+ hidden_states = self.norm(hidden_states)
516
+
517
+ return hidden_states
518
+
519
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
520
+ stacked_params_mapping = [
521
+ # (param_name, shard_name, shard_id)
522
+ (".qkv_proj", ".q_proj", "q"),
523
+ (".qkv_proj", ".k_proj", "k"),
524
+ (".qkv_proj", ".v_proj", "v"),
525
+ (".gate_up_proj", ".gate_proj", 0),
526
+ (".gate_up_proj", ".up_proj", 1),
527
+ ]
528
+ params_dict = dict(self.named_parameters())
529
+ expert_params_mapping = self.get_expert_mapping()
530
+ loaded_params: set[str] = set()
531
+ for name, loaded_weight in weights:
532
+ if self.config.tie_word_embeddings and "lm_head.weight" in name:
533
+ continue
534
+ if self.quant_config is not None and (
535
+ scale_name := self.quant_config.get_cache_scale(name)
536
+ ):
537
+ param = params_dict[scale_name]
538
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
539
+ loaded_weight = (
540
+ loaded_weight if loaded_weight.dim() == 0 else loaded_weight[0]
541
+ )
542
+ weight_loader(param, loaded_weight)
543
+ loaded_params.add(scale_name)
544
+ continue
545
+ if "scale" in name:
546
+ # Remapping the name of FP8 kv-scale.
547
+ name = maybe_remap_kv_scale_name(name, params_dict)
548
+ if name is None:
549
+ continue
550
+ is_found = False
551
+ for param_name, weight_name, shard_id in stacked_params_mapping:
552
+ if weight_name not in name:
553
+ continue
554
+ if "mlp.experts" in name:
555
+ continue
556
+ name = name.replace(weight_name, param_name)
557
+ # Skip loading extra bias for GPTQ models.
558
+ if name.endswith(".bias") and name not in params_dict:
559
+ continue
560
+
561
+ # Skip layers on other devices.
562
+ if is_pp_missing_parameter(name, self):
563
+ continue
564
+
565
+ param = params_dict[name]
566
+ weight_loader = param.weight_loader
567
+ weight_loader(param, loaded_weight, shard_id)
568
+ loaded_params.add(name)
569
+ is_found = True
570
+ break
571
+ if is_found:
572
+ continue
573
+
574
+ if name.endswith(".bias") and name not in params_dict:
575
+ continue
576
+ is_expert_weight = False
577
+ for mapping in expert_params_mapping:
578
+ param_name, weight_name, expert_id, shard_id = mapping
579
+ if weight_name not in name:
580
+ continue
581
+ is_expert_weight = True
582
+ name_mapped = name.replace(weight_name, param_name)
583
+ # Skip layers on other devices.
584
+ if is_pp_missing_parameter(name_mapped, self):
585
+ continue
586
+
587
+ param = params_dict[name_mapped]
588
+ weight_loader = typing.cast(Callable[..., bool], param.weight_loader)
589
+ success = weight_loader(
590
+ param,
591
+ loaded_weight,
592
+ name_mapped,
593
+ shard_id=shard_id,
594
+ expert_id=expert_id,
595
+ return_success=True,
596
+ )
597
+ if success:
598
+ name = name_mapped
599
+ break
600
+ else:
601
+ if is_expert_weight:
602
+ # We've checked that this is an expert weight
603
+ # However it's not mapped locally to this rank
604
+ # So we simply skip it
605
+ continue
606
+ if name is None:
607
+ continue
608
+ if is_pp_missing_parameter(name, self):
609
+ continue
610
+ if "router.gate." in name:
611
+ name = name.replace("router.", "")
612
+
613
+ param = params_dict[name]
614
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
615
+ weight_loader(param, loaded_weight)
616
+ loaded_params.add(name)
617
+
618
+ return loaded_params
619
+
620
+
621
+def get_spec_layer_idx_from_weight_name(
622
+ config: PretrainedConfig, weight_name: str
623
+) -> int | None:
624
+ # HYV3MTP is enabled only when num_nextn_predict_layers is greater than 1
625
+ if (
626
+ hasattr(config, "num_nextn_predict_layers")
627
+ and config.num_nextn_predict_layers > 0
628
+ ):
629
+ layer_idx = config.num_hidden_layers
630
+ for i in range(config.num_nextn_predict_layers):
631
+ if weight_name.startswith(f"model.layers.{layer_idx + i}."):
632
+ return layer_idx + i
633
+ return None
634
+
635
+
636
+class HYV3ForCausalLM(nn.Module, SupportsPP, SupportsLoRA):
637
+ packed_modules_mapping = {
638
+ "qkv_proj": ["q_proj", "k_proj", "v_proj"],
639
+ "gate_up_proj": ["gate_proj", "up_proj"],
640
+ }
641
+
642
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
643
+ super().__init__()
644
+ config = vllm_config.model_config.hf_config
645
+ quant_config = vllm_config.quant_config
646
+ self.config = config
647
+ self.quant_config = quant_config
648
+
649
+ parallel_config = vllm_config.parallel_config
650
+ eplb_config = parallel_config.eplb_config
651
+ self.num_redundant_experts = eplb_config.num_redundant_experts
652
+
653
+ self.model = HYV3Model(
654
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
655
+ )
656
+ self.lm_head = ParallelLMHead(
657
+ config.vocab_size,
658
+ config.hidden_size,
659
+ quant_config=quant_config,
660
+ prefix=maybe_prefix(prefix, "lm_head"),
661
+ )
662
+ if self.config.tie_word_embeddings:
663
+ self.lm_head.weight = self.model.embed_tokens.weight
664
+ self.logits_processor = LogitsProcessor(config.vocab_size)
665
+ self.make_empty_intermediate_tensors = (
666
+ self.model.make_empty_intermediate_tensors
667
+ )
668
+
669
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
670
+ return self.model.embed_input_ids(input_ids)
671
+
672
+ def forward(
673
+ self,
674
+ input_ids: torch.Tensor,
675
+ positions: torch.Tensor,
676
+ intermediate_tensors: IntermediateTensors | None = None,
677
+ inputs_embeds: torch.Tensor | None = None,
678
+ ) -> torch.Tensor | IntermediateTensors:
679
+ hidden_states = self.model(
680
+ input_ids, positions, intermediate_tensors, inputs_embeds
681
+ )
682
+
683
+ return hidden_states
684
+
685
+ def compute_logits(
686
+ self,
687
+ hidden_states: torch.Tensor,
688
+ ) -> torch.Tensor | None:
689
+ logits = self.logits_processor(self.lm_head, hidden_states)
690
+ return logits
691
+
692
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
693
+ def _filter_weights(weights):
694
+ for name, weight in weights:
695
+ spec_layer = get_spec_layer_idx_from_weight_name(self.config, name)
696
+ if spec_layer is not None:
697
+ continue
698
+ yield name, weight
699
+
700
+ loader = AutoWeightsLoader(
701
+ self,
702
+ skip_prefixes=(["lm_head."] if self.config.tie_word_embeddings else None),
703
+ )
704
+ return loader.load_weights(_filter_weights(weights))
705
+
706
+ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
707
+ return self.model.get_expert_mapping()
vllm/model_executor/models/hy_v3_mtp.py
new
+470
@@ -0,0 +1,470 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+# coding=utf-8
5
+# Copyright 2026 The HY team.
6
+# Copyright 2023 The vLLM team.
7
+# Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved.
8
+#
9
+# This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX
10
+# and OPT implementations in this library. It has been modified from its
11
+# original forms to accommodate minor architectural differences compared
12
+# to GPT-NeoX and OPT used by the Meta AI team that trained the model.
13
+#
14
+# Licensed under the Apache License, Version 2.0 (the "License");
15
+# you may not use this file except in compliance with the License.
16
+# You may obtain a copy of the License at
17
+#
18
+# http://www.apache.org/licenses/LICENSE-2.0
19
+#
20
+# Unless required by applicable law or agreed to in writing, software
21
+# distributed under the License is distributed on an "AS IS" BASIS,
22
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
23
+# See the License for the specific language governing permissions and
24
+# limitations under the License.
25
+"""Inference-only HY V3 MTP model compatible with HuggingFace weights."""
26
+
27
+from collections.abc import Iterable
28
+
29
+import regex as re
30
+import torch
31
+from torch import nn
32
+from transformers import PretrainedConfig
33
+
34
+from vllm.config import CacheConfig, ModelConfig, VllmConfig
35
+from vllm.model_executor.layers.fused_moe import FusedMoE
36
+from vllm.model_executor.layers.layernorm import RMSNorm
37
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
38
+from vllm.model_executor.layers.quantization import QuantizationConfig
39
+from vllm.model_executor.layers.vocab_parallel_embedding import (
40
+ ParallelLMHead,
41
+ VocabParallelEmbedding,
42
+)
43
+from vllm.model_executor.model_loader.weight_utils import (
44
+ default_weight_loader,
45
+ maybe_remap_kv_scale_name,
46
+)
47
+from vllm.sequence import IntermediateTensors
48
+from vllm.v1.outputs import SamplerOutput
49
+from vllm.v1.sample.metadata import SamplingMetadata
50
+from vllm.v1.sample.sampler import Sampler
51
+
52
+from .hy_v3 import HYV3DecoderLayer, get_spec_layer_idx_from_weight_name
53
+from .utils import is_pp_missing_parameter, maybe_prefix
54
+
55
+
56
+def _is_moe(config: PretrainedConfig) -> bool:
57
+ return bool(
58
+ getattr(config, "num_experts", None)
59
+ and (
60
+ (isinstance(config.num_experts, int) and config.num_experts > 1)
61
+ or (isinstance(config.num_experts, list) and max(config.num_experts) > 1)
62
+ )
63
+ )
64
+
65
+
66
+def _get_cla_factor(config: PretrainedConfig) -> int:
67
+ if not getattr(config, "use_cla", False):
68
+ return 1
69
+ return getattr(config, "cla_share_factor", 1)
70
+
71
+
72
+class HYV3SharedHead(nn.Module):
73
+ def __init__(
74
+ self,
75
+ config: PretrainedConfig,
76
+ quant_config: QuantizationConfig | None = None,
77
+ ) -> None:
78
+ super().__init__()
79
+ self.head = ParallelLMHead(
80
+ config.vocab_size, config.hidden_size, quant_config=quant_config
81
+ )
82
+
83
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
84
+ return hidden_states
85
+
86
+
87
+class HYV3MultiTokenPredictorLayer(nn.Module):
88
+ def __init__(
89
+ self,
90
+ config: PretrainedConfig,
91
+ prefix: str,
92
+ model_config: ModelConfig,
93
+ cache_config: CacheConfig | None = None,
94
+ quant_config: QuantizationConfig | None = None,
95
+ ) -> None:
96
+ super().__init__()
97
+
98
+ self.enorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
99
+ self.hnorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
100
+ self.eh_proj = nn.Linear(config.hidden_size * 2, config.hidden_size, bias=False)
101
+ self.shared_head = HYV3SharedHead(config=config, quant_config=quant_config)
102
+ self.mtp_block = HYV3DecoderLayer(
103
+ config=config,
104
+ cache_config=cache_config,
105
+ quant_config=quant_config,
106
+ prefix=prefix,
107
+ )
108
+ # Final layernorm applied after transformer block, before logits
109
+ # projection (matches HF HYV3MTPDecoderLayer.final_layernorm)
110
+ self.final_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
111
+
112
+ def forward(
113
+ self,
114
+ input_ids: torch.Tensor,
115
+ positions: torch.Tensor,
116
+ previous_hidden_states: torch.Tensor,
117
+ inputs_embeds: torch.Tensor | None = None,
118
+ spec_step_index: int = 0,
119
+ ) -> torch.Tensor:
120
+ assert inputs_embeds is not None
121
+ # masking inputs at position 0, as not needed by MTP
122
+ inputs_embeds[positions == 0] = 0
123
+ inputs_embeds = self.enorm(inputs_embeds)
124
+ previous_hidden_states = self.hnorm(previous_hidden_states)
125
+
126
+ hidden_states = self.eh_proj(
127
+ torch.cat([inputs_embeds, previous_hidden_states], dim=-1)
128
+ )
129
+
130
+ # HYV3DecoderLayer returns (hidden_states, residual)
131
+ hidden_states, residual = self.mtp_block(
132
+ positions=positions, hidden_states=hidden_states, residual=None
133
+ )
134
+ hidden_states = residual + hidden_states
135
+ hidden_states = self.final_layernorm(hidden_states)
136
+ return hidden_states
137
+
138
+
139
+class HYV3MultiTokenPredictor(nn.Module):
140
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
141
+ super().__init__()
142
+ config = vllm_config.model_config.hf_config
143
+ self.mtp_start_layer_idx = config.num_hidden_layers
144
+ self.num_mtp_layers = config.num_nextn_predict_layers
145
+
146
+ # to map the exact layer index from weights
147
+ self.layers = torch.nn.ModuleDict(
148
+ {
149
+ str(idx): HYV3MultiTokenPredictorLayer(
150
+ config,
151
+ f"{prefix}.layers.{idx}",
152
+ model_config=vllm_config.model_config,
153
+ cache_config=vllm_config.cache_config,
154
+ quant_config=vllm_config.quant_config,
155
+ )
156
+ for idx in range(
157
+ self.mtp_start_layer_idx,
158
+ self.mtp_start_layer_idx + self.num_mtp_layers,
159
+ )
160
+ }
161
+ )
162
+
163
+ self.embed_tokens = VocabParallelEmbedding(
164
+ config.vocab_size,
165
+ config.hidden_size,
166
+ )
167
+
168
+ self.logits_processor = LogitsProcessor(config.vocab_size)
169
+
170
+ def forward(
171
+ self,
172
+ input_ids: torch.Tensor,
173
+ positions: torch.Tensor,
174
+ previous_hidden_states: torch.Tensor,
175
+ inputs_embeds: torch.Tensor | None = None,
176
+ spec_step_idx: int = 0,
177
+ ) -> torch.Tensor:
178
+ if inputs_embeds is None:
179
+ inputs_embeds = self.embed_tokens(input_ids)
180
+ current_step_idx = spec_step_idx % self.num_mtp_layers
181
+ return self.layers[str(self.mtp_start_layer_idx + current_step_idx)](
182
+ input_ids,
183
+ positions,
184
+ previous_hidden_states,
185
+ inputs_embeds,
186
+ current_step_idx,
187
+ )
188
+
189
+ def compute_logits(
190
+ self,
191
+ hidden_states: torch.Tensor,
192
+ spec_step_idx: int = 0,
193
+ ) -> torch.Tensor:
194
+ current_step_idx = spec_step_idx % self.num_mtp_layers
195
+ mtp_layer = self.layers[str(self.mtp_start_layer_idx + current_step_idx)]
196
+ logits = self.logits_processor(
197
+ mtp_layer.shared_head.head, mtp_layer.shared_head(hidden_states)
198
+ )
199
+ return logits
200
+
201
+
202
+class HYV3MTP(nn.Module):
203
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
204
+ super().__init__()
205
+ self.config = vllm_config.model_config.hf_config
206
+ self.quant_config = vllm_config.quant_config
207
+ self.model = HYV3MultiTokenPredictor(
208
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
209
+ )
210
+
211
+ self.sampler = Sampler()
212
+
213
+ def forward(
214
+ self,
215
+ input_ids: torch.Tensor,
216
+ positions: torch.Tensor,
217
+ hidden_states: torch.Tensor,
218
+ intermediate_tensors: IntermediateTensors | None = None,
219
+ inputs_embeds: torch.Tensor | None = None,
220
+ spec_step_idx: int = 0,
221
+ ) -> torch.Tensor:
222
+ hidden_states = self.model(
223
+ input_ids, positions, hidden_states, inputs_embeds, spec_step_idx
224
+ )
225
+ return hidden_states
226
+
227
+ def compute_logits(
228
+ self,
229
+ hidden_states: torch.Tensor,
230
+ spec_step_idx: int = 0,
231
+ ) -> torch.Tensor | None:
232
+ return self.model.compute_logits(hidden_states, spec_step_idx)
233
+
234
+ def sample(
235
+ self,
236
+ logits: torch.Tensor,
237
+ sampling_metadata: SamplingMetadata,
238
+ ) -> SamplerOutput | None:
239
+ next_tokens = self.sampler(logits, sampling_metadata)
240
+ return next_tokens
241
+
242
+ def _split_qkv_weight(self, qkv: torch.Tensor):
243
+ num_attention_heads = self.config.num_attention_heads
244
+ num_kv_heads = getattr(
245
+ self.config, "num_key_value_heads", self.config.num_attention_heads
246
+ )
247
+ num_key_value_groups = num_attention_heads // num_kv_heads
248
+ hidden_size = self.config.hidden_size
249
+
250
+ if hasattr(self.config, "head_dim"):
251
+ attention_head_dim = self.config.head_dim
252
+ elif hasattr(self.config, "attention_head_dim"):
253
+ attention_head_dim = self.config.attention_head_dim
254
+ else:
255
+ attention_head_dim = self.config.hidden_size // num_attention_heads
256
+
257
+ qkv = qkv.reshape(
258
+ num_kv_heads, num_key_value_groups + 2, attention_head_dim, hidden_size
259
+ )
260
+ q, k, v = torch.split(qkv, (num_key_value_groups, 1, 1), dim=1)
261
+ q = q.reshape(-1, hidden_size)
262
+ k = k.reshape(-1, hidden_size)
263
+ v = v.reshape(-1, hidden_size)
264
+ return torch.concat((q, k, v))
265
+
266
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
267
+ cla_factor = _get_cla_factor(self.config)
268
+ stacked_params_mapping = [
269
+ # (param_name, shard_name, shard_id)
270
+ (".qkv_proj", ".q_proj", "q"),
271
+ (".qkv_proj", ".k_proj", "k"),
272
+ (".qkv_proj", ".v_proj", "v"),
273
+ (".gate_up_proj", ".gate_proj", 0),
274
+ (".gate_up_proj", ".up_proj", 1),
275
+ ]
276
+
277
+ num_attention_heads = self.config.num_attention_heads
278
+ num_kv_heads = getattr(
279
+ self.config, "num_key_value_heads", self.config.num_attention_heads
280
+ )
281
+ split_params_mapping = [
282
+ (".gate_up_proj", ".gate_and_up_proj", 2, [(1, 1), (0, 1)], None),
283
+ (
284
+ ".qkv_proj",
285
+ ".qkv_proj",
286
+ num_attention_heads + num_kv_heads * 2,
287
+ [("q", num_attention_heads), ("k", num_kv_heads), ("v", num_kv_heads)],
288
+ self._split_qkv_weight,
289
+ ),
290
+ ]
291
+
292
+ if _is_moe(self.config):
293
+ expert_params_mapping = FusedMoE.make_expert_params_mapping(
294
+ self,
295
+ ckpt_gate_proj_name="gate_proj",
296
+ ckpt_down_proj_name="down_proj",
297
+ ckpt_up_proj_name="up_proj",
298
+ num_experts=self.config.num_experts,
299
+ )
300
+ else:
301
+ expert_params_mapping = {}
302
+
303
+ params_dict = dict(self.named_parameters())
304
+
305
+ # V3 shared weights mapping:
306
+ # - embed_tokens: from main model's model.embed_tokens.weight
307
+ # - lm_head: from main model's lm_head.weight → MTP shared_head.head
308
+ # (HF infer_mtp uses head_weight=self.lm_head.weight, not the
309
+ # checkpoint's model.layers.<N>.shared_head.weight)
310
+ # - No norm mapping (V3 MTP has no intermediate norm before lm_head)
311
+ mtp_start = self.config.num_hidden_layers
312
+ v3_shared_weights = {
313
+ "model.embed_tokens.weight": "model.embed_tokens.weight",
314
+ "lm_head.weight": f"model.layers.{mtp_start}.shared_head.head.weight",
315
+ }
316
+
317
+ for name, loaded_weight in weights:
318
+ # Intercept shared weights before any other processing
319
+ if name in v3_shared_weights:
320
+ target_name = v3_shared_weights[name]
321
+ if target_name in params_dict:
322
+ param = params_dict[target_name]
323
+ weight_loader = getattr(
324
+ param, "weight_loader", default_weight_loader
325
+ )
326
+ weight_loader(param, loaded_weight)
327
+ continue
328
+
329
+ if "rotary_emb.inv_freq" in name:
330
+ continue
331
+ if "gate_proj_bias" in name:
332
+ name = name.replace("gate_proj_bias", "gate_proj.bias")
333
+ if "up_proj_bias" in name:
334
+ name = name.replace("up_proj_bias", "up_proj.bias")
335
+ if "rotary_emb.cos_cached" in name or "rotary_emb.sin_cached" in name:
336
+ continue
337
+ if self.config.tie_word_embeddings and "lm_head.weight" in name:
338
+ continue
339
+ if self.quant_config is not None and (
340
+ scale_name := self.quant_config.get_cache_scale(name)
341
+ ):
342
+ param = params_dict[scale_name]
343
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
344
+ loaded_weight = loaded_weight[0]
345
+ weight_loader(param, loaded_weight)
346
+ continue
347
+ spec_layer = get_spec_layer_idx_from_weight_name(self.config, name)
348
+ if spec_layer is None:
349
+ continue
350
+ name = self._rewrite_spec_layer_name(spec_layer, name)
351
+ # Skip weights that _rewrite_spec_layer_name marked for skipping
352
+ if name == "__skip__":
353
+ continue
354
+ if "scale" in name:
355
+ name = maybe_remap_kv_scale_name(name, params_dict)
356
+ if name is None:
357
+ continue
358
+ is_found = False
359
+
360
+ for param_name, weight_name, shard_id in stacked_params_mapping:
361
+ if weight_name not in name:
362
+ continue
363
+ if "mlp.experts" in name:
364
+ continue
365
+ if weight_name == ".q_proj":
366
+ match = re.search(r"layers\.\d+", name)
367
+ if match:
368
+ layer_id = int(match.group(0).split(".")[-1])
369
+ if cla_factor > 1 and layer_id % cla_factor != 0:
370
+ continue
371
+ name = name.replace(weight_name, param_name)
372
+ if name.endswith(".bias") and name not in params_dict:
373
+ continue
374
+
375
+ if is_pp_missing_parameter(name, self):
376
+ continue
377
+
378
+ param = params_dict[name]
379
+ weight_loader = param.weight_loader
380
+ weight_loader(param, loaded_weight, shard_id)
381
+
382
+ is_found = True
383
+ break
384
+ if is_found:
385
+ continue
386
+
387
+ for param_name, weight_name, den, split_param, func in split_params_mapping:
388
+ if weight_name not in name:
389
+ continue
390
+ name = name.replace(weight_name, param_name)
391
+ if name.endswith(".bias") and name not in params_dict:
392
+ continue
393
+
394
+ if is_pp_missing_parameter(name, self):
395
+ continue
396
+
397
+ assert loaded_weight.shape[0] % den == 0
398
+ units = loaded_weight.shape[0] // den
399
+
400
+ param = params_dict[name]
401
+ weight_loader = param.weight_loader
402
+ offset = 0
403
+ for shard_id, num in split_param:
404
+ new_offset = offset + num * units
405
+ if func:
406
+ weight_loader(
407
+ param, func(loaded_weight)[offset:new_offset], shard_id
408
+ )
409
+ else:
410
+ weight_loader(param, loaded_weight[offset:new_offset], shard_id)
411
+ offset = new_offset
412
+
413
+ break
414
+ else:
415
+ if name.endswith(".bias") and name not in params_dict:
416
+ continue
417
+ for mapping in expert_params_mapping:
418
+ param_name, weight_name, expert_id, shard_id = mapping
419
+ if weight_name not in name:
420
+ continue
421
+ name = name.replace(weight_name, param_name)
422
+ if is_pp_missing_parameter(name, self):
423
+ continue
424
+ param = params_dict[name]
425
+ weight_loader = param.weight_loader
426
+ weight_loader(
427
+ param,
428
+ loaded_weight,
429
+ name,
430
+ shard_id=shard_id,
431
+ expert_id=expert_id,
432
+ )
433
+ break
434
+ else:
435
+ if is_pp_missing_parameter(name, self):
436
+ continue
437
+
438
+ if "mlp.gate.wg." in name:
439
+ name = name.replace("wg.", "")
440
+ # V3 checkpoint: mlp.router.gate -> mlp.gate
441
+ if "mlp.router.gate." in name:
442
+ name = name.replace("router.gate.", "gate.")
443
+
444
+ param = params_dict[name]
445
+ weight_loader = getattr(
446
+ param, "weight_loader", default_weight_loader
447
+ )
448
+ weight_loader(param, loaded_weight)
449
+
450
+ def _rewrite_spec_layer_name(self, spec_layer: int, name: str) -> str:
451
+ """Rewrite spec layer weight names to match vLLM module structure."""
452
+ # Skip embed_tokens (doesn't exist in V3 MTP checkpoint under spec
453
+ # layer) and shared_head (we use main model's lm_head instead)
454
+ if f"model.layers.{spec_layer}.embed_tokens" in name:
455
+ return "__skip__"
456
+ if f"model.layers.{spec_layer}.shared_head" in name:
457
+ return "__skip__"
458
+
459
+ spec_layer_weight_names = ["enorm", "hnorm", "eh_proj", "final_layernorm"]
460
+ spec_layer_weight = False
461
+ for weight_name in spec_layer_weight_names:
462
+ if weight_name in name:
463
+ spec_layer_weight = True
464
+ break
465
+ if not spec_layer_weight:
466
+ # Transformer block weights go under .mtp_block
467
+ name = name.replace(
468
+ f"model.layers.{spec_layer}.", f"model.layers.{spec_layer}.mtp_block."
469
+ )
470
+ return name
vllm/model_executor/models/registry.py
+2
@@ -133,6 +133,7 @@ _TEXT_GENERATION_MODELS = {
133
"Grok1ForCausalLM": ("grok1", "GrokForCausalLM"),
134
"HunYuanMoEV1ForCausalLM": ("hunyuan_v1", "HunYuanMoEV1ForCausalLM"),
135
"HunYuanDenseV1ForCausalLM": ("hunyuan_v1", "HunYuanDenseV1ForCausalLM"),
136
+ "HYV3ForCausalLM": ("hy_v3", "HYV3ForCausalLM"),
137
"HCXVisionForCausalLM": ("hyperclovax_vision", "HCXVisionForCausalLM"),
138
"HCXVisionV2ForCausalLM": ("hyperclovax_vision_v2", "HCXVisionV2ForCausalLM"),
139
"HyperCLOVAXForCausalLM": ("hyperclovax", "HyperCLOVAXForCausalLM"),
@@ -599,6 +600,7 @@ _SPECULATIVE_DECODING_MODELS = {
600
"Step3p5MTP": ("step3p5_mtp", "Step3p5MTP"),
601
"Qwen3_5MTP": ("qwen3_5_mtp", "Qwen3_5MTP"),
602
"Qwen3_5MoeMTP": ("qwen3_5_mtp", "Qwen3_5MoeMTP"),
603
+ "HYV3MTPModel": ("hy_v3_mtp", "HYV3MTP"),
604
# Temporarily disabled.
605
# # TODO(woosuk): Re-enable this once the MLP Speculator is supported in V1.
606
# "MLPSpeculatorPreTrainedModel": ("mlp_speculator", "MLPSpeculator"),
vllm/reasoning/__init__.py
+4
@@ -56,6 +56,10 @@ _REASONING_PARSERS_TO_REGISTER = {
56
"hunyuan_a13b_reasoning_parser",
57
"HunyuanA13BReasoningParser",
58
),
59
+ "hy_v3": (
60
+ "hy_v3_reasoning_parser",
61
+ "HYV3ReasoningParser",
62
+ ),
63
"kimi_k2": (
64
"kimi_k2_reasoning_parser",
65
"KimiK2ReasoningParser",
vllm/reasoning/hy_v3_reasoning_parser.py
new
+137
@@ -0,0 +1,137 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from collections.abc import Iterable, Sequence
5
+
6
+from vllm.entrypoints.openai.chat_completion.protocol import (
7
+ ChatCompletionRequest,
8
+)
9
+from vllm.entrypoints.openai.engine.protocol import DeltaMessage
10
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
11
+from vllm.logger import init_logger
12
+from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
13
+from vllm.reasoning.identity_reasoning_parser import IdentityReasoningParser
14
+from vllm.tokenizers import TokenizerLike
15
+
16
+logger = init_logger(__name__)
17
+
18
+
19
+class HYV3ReasoningParser(BaseThinkingReasoningParser):
20
+ """
21
+ HYV3 parser that delegates to either HYV3ReasoningParser or
22
+ IdentityReasoningParser based on `reasoning_effort`.
23
+
24
+ The HYV3 model uses <think>...</think> tokens to denote reasoning text.
25
+ This parser extracts the reasoning content from the model output.
26
+ """
27
+
28
+ def __init__(self, tokenizer: TokenizerLike, *args, **kwargs):
29
+ super().__init__(tokenizer, *args, **kwargs)
30
+
31
+ # First, If there is reasoning_effort in chat_kwargs,
32
+ # prioritize using chat_kwargs.reasoning_effort.
33
+ # If it's not present, use the "reasoning_effort" field
34
+ # at the outer level of the chat message.
35
+ # Otherwise, If both are empty, assign "no_think".
36
+
37
+ chat_kwargs = kwargs.pop("chat_template_kwargs", {}) or {}
38
+ reasoning_effort = chat_kwargs.pop("reasoning_effort", "no_think")
39
+
40
+ logger.debug("reasoning_effort for choosing parser: %s", reasoning_effort)
41
+
42
+ self._identity_parser: IdentityReasoningParser | None
43
+ if reasoning_effort == "no_think":
44
+ self._identity_parser = IdentityReasoningParser(tokenizer, *args, **kwargs)
45
+ else:
46
+ self._identity_parser = None
47
+
48
+ @property
49
+ def start_token(self) -> str:
50
+ """The token that starts reasoning content."""
51
+ return "<think>"
52
+
53
+ @property
54
+ def end_token(self) -> str:
55
+ """The token that ends reasoning content."""
56
+ return "</think>"
57
+
58
+ def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
59
+ if self._identity_parser is not None:
60
+ return self._identity_parser.is_reasoning_end(input_ids)
61
+
62
+ return super().is_reasoning_end(input_ids)
63
+
64
+ def is_reasoning_end_streaming(
65
+ self, input_ids: Sequence[int], delta_ids: Iterable[int]
66
+ ) -> bool:
67
+ if self._identity_parser is not None:
68
+ return self._identity_parser.is_reasoning_end_streaming(
69
+ input_ids, delta_ids
70
+ )
71
+
72
+ return super().is_reasoning_end_streaming(input_ids, delta_ids)
73
+
74
+ def extract_content_ids(self, input_ids: list[int]) -> list[int]:
75
+ if self._identity_parser is not None:
76
+ return self._identity_parser.extract_content_ids(input_ids)
77
+
78
+ return super().extract_content_ids(input_ids)
79
+
80
+ def extract_reasoning(
81
+ self, model_output: str, request: "ChatCompletionRequest | ResponsesRequest"
82
+ ) -> tuple[str | None, str | None]:
83
+ if self._identity_parser is not None:
84
+ return self._identity_parser.extract_reasoning(model_output, request)
85
+
86
+ return super().extract_reasoning(model_output, request)
87
+
88
+ def extract_reasoning_streaming(
89
+ self,
90
+ previous_text: str,
91
+ current_text: str,
92
+ delta_text: str,
93
+ previous_token_ids: Sequence[int],
94
+ current_token_ids: Sequence[int],
95
+ delta_token_ids: Sequence[int],
96
+ ) -> DeltaMessage | None:
97
+ if self._identity_parser is not None:
98
+ return self._identity_parser.extract_reasoning_streaming(
99
+ previous_text,
100
+ current_text,
101
+ delta_text,
102
+ previous_token_ids,
103
+ current_token_ids,
104
+ delta_token_ids,
105
+ )
106
+
107
+ ret = super().extract_reasoning_streaming(
108
+ previous_text,
109
+ current_text,
110
+ delta_text,
111
+ previous_token_ids,
112
+ current_token_ids,
113
+ delta_token_ids,
114
+ )
115
+ if (
116
+ ret is not None
117
+ and self.start_token_id not in previous_token_ids
118
+ and self.start_token_id not in delta_token_ids
119
+ ):
120
+ if self.end_token_id in delta_token_ids:
121
+ # end token in delta with more tokens,
122
+ # extract reasoning content and content
123
+ end_index = delta_text.find(self.end_token)
124
+ reasoning = delta_text[:end_index]
125
+ content = delta_text[end_index + len(self.end_token) :]
126
+ return DeltaMessage(
127
+ reasoning=reasoning,
128
+ content=content if content else None,
129
+ )
130
+ elif self.end_token_id in previous_token_ids:
131
+ # end token in previous, thinking content ends
132
+ return DeltaMessage(content=delta_text)
133
+ else:
134
+ # no end token in previous or delta, reasoning content continues
135
+ return DeltaMessage(reasoning=delta_text)
136
+
137
+ return ret
vllm/tool_parsers/__init__.py
+4
@@ -66,6 +66,10 @@ _TOOL_PARSERS_TO_REGISTER = {
66
"hunyuan_a13b_tool_parser",
67
"HunyuanA13BToolParser",
68
),
69
+ "hy_v3": (
70
+ "hy_v3_tool_parser",
71
+ "HYV3ToolParser",
72
+ ),
73
"internlm": (
74
"internlm2_tool_parser",
75
"Internlm2ToolParser",
vllm/tool_parsers/hy_v3_tool_parser.py
new
+645
@@ -0,0 +1,645 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import ast
5
+import json
6
+from collections.abc import Sequence
7
+from typing import Any
8
+
9
+import regex as re
10
+
11
+from vllm.entrypoints.chat_utils import make_tool_call_id
12
+from vllm.entrypoints.openai.chat_completion.protocol import (
13
+ ChatCompletionRequest,
14
+ ChatCompletionToolsParam,
15
+)
16
+from vllm.entrypoints.openai.engine.protocol import (
17
+ DeltaFunctionCall,
18
+ DeltaMessage,
19
+ DeltaToolCall,
20
+ ExtractedToolCallInformation,
21
+ FunctionCall,
22
+ ToolCall,
23
+)
24
+from vllm.logger import init_logger
25
+from vllm.tokenizers import TokenizerLike
26
+from vllm.tool_parsers.abstract_tool_parser import (
27
+ Tool,
28
+ ToolParser,
29
+)
30
+
31
+logger = init_logger(__name__)
32
+
33
+
34
+class HYV3ToolParser(ToolParser):
35
+ _TYPE_ALIASES: dict[str, str] = {
36
+ "str": "string",
37
+ "text": "string",
38
+ "varchar": "string",
39
+ "char": "string",
40
+ "enum": "string",
41
+ "bool": "boolean",
42
+ "binary": "boolean",
43
+ "int": "integer",
44
+ "float": "number",
45
+ "double": "number",
46
+ "list": "array",
47
+ "dict": "object",
48
+ "map": "object",
49
+ }
50
+
51
+ # Prefix-based wildcard matching for non-standard type names.
52
+ # Following the same approach as
53
+ # qwen3coder_tool_parser._convert_param_value which uses
54
+ # param_type.startswith("int"), startswith("uint"), etc.
55
+ _INTEGER_PREFIXES: tuple[str, ...] = (
56
+ "int",
57
+ "uint",
58
+ "long",
59
+ "short",
60
+ "unsigned",
61
+ )
62
+ _NUMBER_PREFIXES: tuple[str, ...] = ("num", "float")
63
+
64
+ @staticmethod
65
+ def _normalize_type(raw_type: str) -> str:
66
+ """Map non-standard type aliases to JSON Schema standard names.
67
+
68
+ First performs exact lookup in _TYPE_ALIASES. On miss, falls back
69
+ to prefix-based matching using startswith()
70
+ - int*/uint*/long*/short*/unsigned* → "integer"
71
+ - num*/float* → "number"
72
+ """
73
+ exact = HYV3ToolParser._TYPE_ALIASES.get(raw_type)
74
+ if exact is not None:
75
+ return exact
76
+ lower = raw_type.lower()
77
+ if any(lower.startswith(p) for p in HYV3ToolParser._INTEGER_PREFIXES):
78
+ return "integer"
79
+ if any(lower.startswith(p) for p in HYV3ToolParser._NUMBER_PREFIXES):
80
+ return "number"
81
+ return raw_type
82
+
83
+ @staticmethod
84
+ def _get_arg_schema(
85
+ function_name: str,
86
+ arg_key: str,
87
+ tools: list[ChatCompletionToolsParam] | None,
88
+ ) -> dict:
89
+ """Look up a specific argument's property schema from the tools list."""
90
+ if tools is None:
91
+ return {}
92
+ for tool in tools:
93
+ if tool.function.name == function_name:
94
+ if tool.function.parameters is None:
95
+ return {}
96
+ return tool.function.parameters.get("properties", {}).get(arg_key, {})
97
+ logger.warning("No tool named '%s'.", function_name)
98
+ return {}
99
+
100
+ @staticmethod
101
+ def _get_schema_options(arg_schema: dict) -> list[dict]:
102
+ """Normalize any property schema into a list of sub-schemas.
103
+ - has type (single type) → return [arg_schema]
104
+ - anyOf → return the anyOf list
105
+ - oneOf → return the oneOf list
106
+ - fallback → [{"type": "string"}]
107
+
108
+ Note: single ``type`` has the highest priority.
109
+ """
110
+ if "type" in arg_schema:
111
+ return [arg_schema]
112
+ if "anyOf" in arg_schema:
113
+ return arg_schema["anyOf"]
114
+ if "oneOf" in arg_schema:
115
+ return arg_schema["oneOf"]
116
+
117
+ return [{"type": "string"}]
118
+
119
+ @staticmethod
120
+ def _get_types(arg_schema: dict) -> set[str]:
121
+ """Extract normalized, non-null type set from a property schema."""
122
+ schemas = HYV3ToolParser._get_schema_options(arg_schema)
123
+ return {
124
+ HYV3ToolParser._normalize_type(s.get("type", "string")) for s in schemas
125
+ } - {"null"}
126
+
127
+ @staticmethod
128
+ def _is_only_string_type(
129
+ function_name: str,
130
+ arg_key: str,
131
+ tools: list[ChatCompletionToolsParam] | None,
132
+ ) -> bool:
133
+ """Return True if the parameter's type set is exactly {"string"}.
134
+
135
+ Only pure string types get partial value streaming; compound types
136
+ like anyOf(string | array) do not, since the partial value might
137
+ end up being a JSON array or object.
138
+ """
139
+ arg_schema = HYV3ToolParser._get_arg_schema(function_name, arg_key, tools)
140
+ types = HYV3ToolParser._get_types(arg_schema)
141
+ return types == {"string"}
142
+
143
+ @staticmethod
144
+ def _try_parse_bool(value: str) -> bool | None:
145
+ """Try to parse a string as bool; return None on failure."""
146
+ lower = value.lower()
147
+ if lower == "true":
148
+ return True
149
+ elif lower == "false":
150
+ return False
151
+ return None
152
+
153
+ @staticmethod
154
+ def _try_parse_int(value: str) -> int | None:
155
+ """Try to parse a string as int; return None on failure."""
156
+ try:
157
+ return int(value)
158
+ except (ValueError, TypeError):
159
+ return None
160
+
161
+ @staticmethod
162
+ def _try_parse_wildcard_number(value: str) -> int | float | None:
163
+ """Try to parse a string as a number (int or float).
164
+
165
+ Decision rule: if the string contains '.' or 'e'/'E' (scientific
166
+ notation), parse as float; otherwise parse as int.
167
+
168
+ Examples:
169
+ "5" → int(5)
170
+ "5.0" → float(5.0)
171
+ "5.3" → float(5.3)
172
+ "1e3" → float(1000.0)
173
+ "-3" → int(-3)
174
+
175
+ Return None on failure.
176
+ """
177
+ try:
178
+ if "." in value or "e" in value or "E" in value:
179
+ return float(value)
180
+ return int(value)
181
+ except (ValueError, TypeError):
182
+ return None
183
+
184
+ @staticmethod
185
+ def _deserialize(value: str) -> Any:
186
+ """Deserialize a string value using json.loads then ast.literal_eval."""
187
+ try:
188
+ return json.loads(value)
189
+ except Exception:
190
+ pass
191
+ try:
192
+ return ast.literal_eval(value)
193
+ except Exception:
194
+ pass
195
+ return value
196
+
197
+ @staticmethod
198
+ def _parse_value(
199
+ value: str,
200
+ function_name: str,
201
+ arg_key: str,
202
+ tools: list[ChatCompletionToolsParam] | None,
203
+ ) -> Any:
204
+ """Unified argument value parser with anyOf/oneOf support.
205
+
206
+ Fallthrough chain:
207
+ bool → int → number(wildcard_number)
208
+ → json.loads for array/object
209
+ → string → _deserialize
210
+ """
211
+ arg_schema = HYV3ToolParser._get_arg_schema(function_name, arg_key, tools)
212
+ types = HYV3ToolParser._get_types(arg_schema)
213
+
214
+ # 1. Try bool
215
+ if "boolean" in types:
216
+ result_bool = HYV3ToolParser._try_parse_bool(value)
217
+ if result_bool is not None:
218
+ return result_bool
219
+
220
+ # 2. Try int
221
+ if "integer" in types:
222
+ result_int = HYV3ToolParser._try_parse_int(value)
223
+ if result_int is not None:
224
+ return result_int
225
+
226
+ # 3. Try number (wildcard_number: int if no '.'/e/E, float otherwise)
227
+ if "number" in types:
228
+ result_number = HYV3ToolParser._try_parse_wildcard_number(value)
229
+ if result_number is not None:
230
+ return result_number
231
+
232
+ # 4. Try json.loads (covers array/object and other unlisted types)
233
+ if types - {"string", "boolean", "integer", "number"}:
234
+ try:
235
+ return json.loads(value)
236
+ except (json.JSONDecodeError, ValueError):
237
+ pass
238
+
239
+ # 5. String fallback
240
+ if "string" in types:
241
+ return value
242
+
243
+ # 6. Final fallback
244
+ return HYV3ToolParser._deserialize(value)
245
+
246
+ def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
247
+ super().__init__(tokenizer, tools)
248
+
249
+ self.current_tool_name_sent: bool = False
250
+ self.prev_tool_call_arr: list[dict] = []
251
+ self.current_tool_id: int = -1
252
+ self.streamed_args_for_tool: list[
253
+ str
254
+ ] = [] # map what has been streamed for each tool so far to a list
255
+
256
+ # Streaming state: send tool name first, then return arguments at once
257
+ self._streaming_tool_name: str | None = None # tool name being streamed
258
+
259
+ # State fields for incremental argument streaming
260
+ self._completed_args: dict = {} # closed {key: parsed_value}
261
+ self._current_arg_key: str | None = None # key being collected
262
+ self._current_arg_is_string: bool = False # is current arg pure string?
263
+ self._streamed_json_len: int = 0 # bytes of JSON already sent
264
+
265
+ self.tool_calls_start_token: str = "<tool_calls>"
266
+ self.tool_calls_end_token: str = "</tool_calls>"
267
+
268
+ self.tool_call_start_token: str = "<tool_call>"
269
+ self.tool_call_end_token: str = "</tool_call>"
270
+
271
+ self.tool_sep_token: str = "<tool_sep>"
272
+
273
+ self.arg_key_start_token: str = "<arg_key>"
274
+ self.arg_key_end_token: str = "</arg_key>"
275
+
276
+ self.arg_value_start_token: str = "<arg_value>"
277
+ self.arg_value_end_token: str = "</arg_value>"
278
+
279
+ self.tool_call_regex = re.compile(
280
+ rf"{self.tool_call_start_token}(.*?){self.tool_sep_token}"
281
+ rf"(.*?){self.tool_call_end_token}",
282
+ re.DOTALL,
283
+ )
284
+
285
+ self.tool_call_portion_regex = re.compile(
286
+ rf"{self.tool_call_start_token}(.*?){self.tool_sep_token}(.*)", re.DOTALL
287
+ )
288
+
289
+ self.func_args_regex = re.compile(
290
+ rf"{self.arg_key_start_token}(.*?){self.arg_key_end_token}\s*"
291
+ rf"{self.arg_value_start_token}(.*?){self.arg_value_end_token}",
292
+ re.DOTALL,
293
+ )
294
+
295
+ if not self.model_tokenizer:
296
+ raise ValueError(
297
+ "The model tokenizer must be passed to the ToolParser "
298
+ "constructor during construction."
299
+ )
300
+ self.tool_calls_start_token_id = self.vocab.get(self.tool_calls_start_token)
301
+ self.tool_calls_end_token_id = self.vocab.get(self.tool_calls_end_token)
302
+
303
+ self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
304
+ self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
305
+ self._buffer = ""
306
+
307
+ if (
308
+ self.tool_calls_start_token_id is None
309
+ or self.tool_calls_end_token_id is None
310
+ ):
311
+ raise RuntimeError(
312
+ "HYV3 Tool parser could not locate tool call "
313
+ "start/end tokens in the tokenizer!"
314
+ )
315
+
316
+ def _extract_tool_calls(
317
+ self,
318
+ model_output: str,
319
+ request: ChatCompletionRequest,
320
+ ) -> list[ToolCall]:
321
+ try:
322
+ function_call_tuples = []
323
+ # start_token{name}sep_token{args}end_token...
324
+ function_calls = self.tool_call_regex.findall(model_output)
325
+ if function_calls:
326
+ function_call_tuples.extend(function_calls)
327
+ remaining = model_output.split(self.tool_call_end_token)[-1]
328
+ function_calls = self.tool_call_portion_regex.findall(remaining)
329
+ function_call_tuples += function_calls
330
+ else:
331
+ function_calls = self.tool_call_portion_regex.findall(model_output)
332
+ if function_calls:
333
+ function_call_tuples.extend(function_calls)
334
+ tool_calls = []
335
+ for match in function_call_tuples:
336
+ function_name, function_args = match
337
+ function_name = function_name.strip()
338
+ function_args = function_args.strip()
339
+
340
+ arg_pairs = self.func_args_regex.findall(function_args)
341
+ arg_dict = {}
342
+ for key, value in arg_pairs:
343
+ parsed_value = HYV3ToolParser._parse_value(
344
+ value, function_name, key, request.tools
345
+ )
346
+ arg_dict[key] = parsed_value
347
+ tool_calls.append(
348
+ ToolCall(
349
+ type="function",
350
+ function=FunctionCall(
351
+ name=function_name,
352
+ arguments=json.dumps(arg_dict, ensure_ascii=False),
353
+ ),
354
+ )
355
+ )
356
+ return tool_calls
357
+ except Exception:
358
+ logger.exception("Error in extracting tool call from response.")
359
+ return []
360
+
361
+ def extract_tool_calls(
362
+ self,
363
+ model_output: str,
364
+ request: ChatCompletionRequest,
365
+ ) -> ExtractedToolCallInformation:
366
+ # sanity check; avoid unnecessary processing
367
+ if self.tool_calls_start_token not in model_output:
368
+ return ExtractedToolCallInformation(
369
+ tools_called=False, tool_calls=[], content=model_output
370
+ )
371
+ else:
372
+ try:
373
+ tool_calls = self._extract_tool_calls(model_output, request)
374
+
375
+ s_index = model_output.find(self.tool_calls_start_token)
376
+ content = model_output[:s_index] if s_index != -1 else model_output
377
+ return ExtractedToolCallInformation(
378
+ tools_called=True,
379
+ tool_calls=tool_calls,
380
+ content=content if content else None,
381
+ )
382
+
383
+ except Exception:
384
+ logger.exception("Error in extracting tool call from response.")
385
+ return ExtractedToolCallInformation(
386
+ tools_called=False, tool_calls=[], content=model_output
387
+ )
388
+
389
+ def _reset_streaming_tool_state(self):
390
+ """Reset the streaming state for a single tool call."""
391
+ self._streaming_tool_name = None
392
+ self._completed_args = {}
393
+ self._current_arg_key = None
394
+ self._current_arg_is_string = False
395
+ self._streamed_json_len = 0
396
+
397
+ def extract_tool_calls_streaming(
398
+ self,
399
+ previous_text: str,
400
+ current_text: str,
401
+ delta_text: str,
402
+ previous_token_ids: Sequence[int],
403
+ current_token_ids: Sequence[int],
404
+ delta_token_ids: Sequence[int],
405
+ request: ChatCompletionRequest,
406
+ ) -> DeltaMessage | None:
407
+ # Check whether current tokens contain the tool_calls start token
408
+ if self.tool_calls_start_token_id not in current_token_ids:
409
+ return DeltaMessage(content=delta_text)
410
+
411
+ # Encountered tool_calls start tag; extract preceding content and buffer
412
+ if self.tool_calls_start_token in delta_text:
413
+ text_parts = delta_text.split(self.tool_calls_start_token)
414
+ self._buffer += text_parts[-1]
415
+ if text_parts[0]:
416
+ return DeltaMessage(content=text_parts[0])
417
+ # Don't return None; continue processing buffer for complete content
418
+ else:
419
+ self._buffer += delta_text
420
+
421
+ # Encountered finish, extract valid arguments
422
+ if (
423
+ current_text.find(self.tool_call_end_token + self.tool_calls_end_token)
424
+ != -1
425
+ and self._buffer.find(self.tool_call_end_token) == -1
426
+ ):
427
+ self._buffer += self.tool_call_end_token + self.tool_calls_end_token
428
+
429
+ cur_text = self._buffer
430
+
431
+ # Haven't encountered tool_call start tag yet; keep buffering
432
+ start_idx = cur_text.find(self.tool_call_start_token)
433
+ if start_idx == -1 and self._streaming_tool_name is None:
434
+ self._buffer = ""
435
+ return None
436
+
437
+ # === Phase 1: Detect tool name (send when tool_sep_token is seen) ===
438
+ name_delta: DeltaMessage | None = None
439
+ if self._streaming_tool_name is None:
440
+ sep_idx = cur_text.find(self.tool_sep_token)
441
+ if sep_idx == -1:
442
+ # tool_sep not yet seen; keep buffering from tool_call_start
443
+ self._buffer = cur_text[start_idx:]
444
+ return None
445
+
446
+ # Extract tool name: between tool_call_start_token and tool_sep_token
447
+ name_start = start_idx + len(self.tool_call_start_token)
448
+ tool_name = cur_text[name_start:sep_idx].strip()
449
+ self._streaming_tool_name = tool_name
450
+
451
+ # Update buffer: keep only content after tool_sep (i.e. the args portion)
452
+ self._buffer = cur_text[sep_idx + len(self.tool_sep_token) :]
453
+
454
+ # Increment tool_id and send a chunk containing only the name
455
+ self.current_tool_id += 1
456
+ self._current_tool_call_id = make_tool_call_id()
457
+ name_delta = DeltaMessage(
458
+ tool_calls=[
459
+ DeltaToolCall(
460
+ index=self.current_tool_id,
461
+ id=self._current_tool_call_id,
462
+ type="function",
463
+ function=DeltaFunctionCall(
464
+ name=tool_name,
465
+ ),
466
+ )
467
+ ]
468
+ )
469
+
470
+ # Check if buffer already has complete arguments (all-in-one-delta)
471
+ if self.tool_call_end_token not in self._buffer:
472
+ return name_delta
473
+ # Buffer already has a complete tool call; continue to phase 2 below
474
+
475
+ # === Phase 2: Incremental argument streaming ===
476
+ return self._extract_streaming_incremental(name_delta, request)
477
+
478
+ def _make_args_delta(self, argument_diff: str) -> DeltaMessage:
479
+ """Build a DeltaMessage containing only an arguments diff."""
480
+ return DeltaMessage(
481
+ tool_calls=[
482
+ DeltaToolCall(
483
+ index=self.current_tool_id,
484
+ function=DeltaFunctionCall(arguments=argument_diff),
485
+ )
486
+ ]
487
+ )
488
+
489
+ def _extract_streaming_incremental(
490
+ self,
491
+ name_delta: DeltaMessage | None,
492
+ request: ChatCompletionRequest,
493
+ ) -> DeltaMessage | None:
494
+ """Incremental phase-2: scan tags in buffer, emit JSON diffs.
495
+
496
+ Strategy:
497
+ - Track completed args and emit each one as a JSON fragment.
498
+ - For string-typed args, stream the value character-by-character.
499
+ - Withhold the closing ``}`` until ``</tool_call>`` is seen.
500
+
501
+ We build JSON manually via fragments rather than using json.dumps
502
+ with a cursor, because json.dumps of partial-vs-full string values
503
+ produces incompatible prefixes (e.g. ``""}`` vs ``"Hello"}``).
504
+ """
505
+ buf = self._buffer
506
+ is_complete = self.tool_call_end_token in buf
507
+
508
+ if is_complete:
509
+ end_idx = buf.find(self.tool_call_end_token)
510
+ args_text = buf[:end_idx]
511
+ remaining = buf[end_idx + len(self.tool_call_end_token) :]
512
+ else:
513
+ args_text = buf
514
+ remaining = ""
515
+
516
+ # --- scan all fully closed kv pairs ---
517
+ arg_pairs = self.func_args_regex.findall(args_text)
518
+ for key, value in arg_pairs:
519
+ key = key.strip()
520
+ if key not in self._completed_args:
521
+ parsed_value = HYV3ToolParser._parse_value(
522
+ value, self._streaming_tool_name or "", key, request.tools
523
+ )
524
+ self._completed_args[key] = parsed_value
525
+
526
+ # --- detect partial (unclosed) kv at the tail ---
527
+ last_closed_end = 0
528
+ for m in self.func_args_regex.finditer(args_text):
529
+ last_closed_end = m.end()
530
+ tail = args_text[last_closed_end:]
531
+
532
+ partial_key: str | None = None
533
+ partial_value: str | None = None
534
+
535
+ ak_start = tail.find(self.arg_key_start_token)
536
+ if ak_start != -1:
537
+ ak_end = tail.find(
538
+ self.arg_key_end_token,
539
+ ak_start + len(self.arg_key_start_token),
540
+ )
541
+ if ak_end != -1:
542
+ partial_key = tail[
543
+ ak_start + len(self.arg_key_start_token) : ak_end
544
+ ].strip()
545
+ self._current_arg_key = partial_key
546
+ self._current_arg_is_string = HYV3ToolParser._is_only_string_type(
547
+ self._streaming_tool_name or "",
548
+ partial_key,
549
+ request.tools,
550
+ )
551
+
552
+ av_start = tail.find(self.arg_value_start_token, ak_end)
553
+ if av_start != -1:
554
+ val_content_start = av_start + len(self.arg_value_start_token)
555
+ if self._current_arg_is_string:
556
+ partial_value = tail[val_content_start:]
557
+ else:
558
+ # key not yet closed
559
+ self._current_arg_key = None
560
+ self._current_arg_is_string = False
561
+
562
+ # --- build the current JSON snapshot as a string ---
563
+ # We construct JSON manually so we can precisely control
564
+ # what gets sent incrementally.
565
+ snapshot_parts: list[str] = []
566
+ for k, v in self._completed_args.items():
567
+ k_json = json.dumps(k, ensure_ascii=False)
568
+ v_json = json.dumps(v, ensure_ascii=False)
569
+ snapshot_parts.append(f"{k_json}: {v_json}")
570
+
571
+ if partial_key is not None and partial_value is not None:
572
+ k_json = json.dumps(partial_key, ensure_ascii=False)
573
+ # For string partial value, we build the JSON string
574
+ # WITHOUT the closing quote, so the prefix stays stable
575
+ # as the value grows. The closing `"` and `}` will be
576
+ # sent when the value or tool_call closes.
577
+ escaped_val = (
578
+ partial_value.replace("\\", "\\\\")
579
+ .replace('"', '\\"')
580
+ .replace("\n", "\\n")
581
+ .replace("\r", "\\r")
582
+ .replace("\t", "\\t")
583
+ )
584
+ # Note: no closing " here – it's appended only on close
585
+ snapshot_parts.append(f'{k_json}: "{escaped_val}')
586
+
587
+ snapshot = "{" + ", ".join(snapshot_parts) + "}"
588
+
589
+ # --- compute diff ---
590
+ argument_diff: str | None = None
591
+
592
+ if is_complete:
593
+ # Tool call finished – send everything remaining.
594
+ # Build final snapshot with proper JSON (all values closed).
595
+ final_args = dict(self._completed_args)
596
+ final_json = json.dumps(final_args, ensure_ascii=False)
597
+ if self._streamed_json_len < len(final_json):
598
+ argument_diff = final_json[self._streamed_json_len :]
599
+ self._streamed_json_len = len(final_json)
600
+
601
+ # Record into prev_tool_call_arr
602
+ self.prev_tool_call_arr.append(
603
+ {
604
+ "name": self._streaming_tool_name,
605
+ "arguments": final_args,
606
+ }
607
+ )
608
+ self.streamed_args_for_tool.append(final_json)
609
+
610
+ self._reset_streaming_tool_state()
611
+ self._buffer = remaining
612
+ else:
613
+ # Still in progress – withhold the tail.
614
+ # For open strings: snapshot ends with ...partial_val}
615
+ # we withhold "}" (1 char) – the missing closing " will
616
+ # be sent when the value closes.
617
+ # For no open string: snapshot ends with ...value"}
618
+ # we withhold "}" (1 char).
619
+ end = len(snapshot) - 1 # exclude trailing "}"
620
+ if end > self._streamed_json_len:
621
+ argument_diff = snapshot[self._streamed_json_len : end]
622
+ self._streamed_json_len = end
623
+
624
+ # --- construct return DeltaMessage ---
625
+ if name_delta is not None and argument_diff:
626
+ nd_func = name_delta.tool_calls[0].function
627
+ return DeltaMessage(
628
+ tool_calls=[
629
+ DeltaToolCall(
630
+ index=self.current_tool_id,
631
+ id=self._current_tool_call_id,
632
+ type="function",
633
+ function=DeltaFunctionCall(
634
+ name=nd_func.name if nd_func else None,
635
+ arguments=argument_diff,
636
+ ),
637
+ )
638
+ ]
639
+ )
640
+ elif name_delta is not None:
641
+ return name_delta
642
+ elif argument_diff:
643
+ return self._make_args_delta(argument_diff)
644
+ else:
645
+ return None
vllm/transformers_utils/config.py
+1
@@ -94,6 +94,7 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
94
funaudiochat="FunAudioChatConfig",
95
granite4_vision="Granite4VisionConfig",
96
hunyuan_vl="HunYuanVLConfig",
97
+ hy_v3="HYV3Config",
98
isaac="IsaacConfig",
99
kimi_k2="DeepseekV3Config", # Kimi K2 uses same architecture as DeepSeek V3
100
kimi_linear="KimiLinearConfig",
vllm/transformers_utils/configs/__init__.py
+2
@@ -36,6 +36,7 @@ _CLASS_TO_MODULE: dict[str, str] = {
36
"HunYuanVLConfig": "vllm.transformers_utils.configs.hunyuan_vl",
37
"HunYuanVLTextConfig": "vllm.transformers_utils.configs.hunyuan_vl",
38
"HunYuanVLVisionConfig": "vllm.transformers_utils.configs.hunyuan_vl",
39
+ "HYV3Config": "vllm.transformers_utils.configs.hy_v3",
40
"HyperCLOVAXConfig": "vllm.transformers_utils.configs.hyperclovax",
41
"IsaacConfig": "vllm.transformers_utils.configs.isaac",
42
# RWConfig is for the original tiiuae/falcon-40b(-instruct) and
@@ -97,6 +98,7 @@ __all__ = [
98
"HunYuanVLConfig",
99
"HunYuanVLTextConfig",
100
"HunYuanVLVisionConfig",
101
+ "HYV3Config",
102
"HyperCLOVAXConfig",
103
"IsaacConfig",
104
"RWConfig",
vllm/transformers_utils/configs/hy_v3.py
new
+185
@@ -0,0 +1,185 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from typing import Any
4
+
5
+from transformers.configuration_utils import PretrainedConfig
6
+
7
+
8
+class HYV3Config(PretrainedConfig):
9
+ r"""
10
+ This is the configuration class to store the configuration of a [`HYV3Model`].
11
+ It is used to instantiate a HYV3 model (HY V3 MoE language model) according to
12
+ the specified arguments.
13
+
14
+ Configuration objects inherit from [`PretrainedConfig`] and can be used to
15
+ control the model outputs. Read the documentation from [`PretrainedConfig`]
16
+ for more information.
17
+
18
+ Args:
19
+ vocab_size (`int`, *optional*, defaults to 120832):
20
+ Vocabulary size of the model.
21
+ hidden_size (`int`, *optional*, defaults to 4096):
22
+ Dimension of the hidden representations.
23
+ intermediate_size (`int`, *optional*, defaults to 13312):
24
+ Dimension of the dense FFN intermediate representations.
25
+ num_hidden_layers (`int`, *optional*, defaults to 80):
26
+ Number of hidden layers in the Transformer decoder.
27
+ num_attention_heads (`int`, *optional*, defaults to 64):
28
+ Number of attention heads for each attention layer.
29
+ num_key_value_heads (`int`, *optional*, defaults to 8):
30
+ Number of key-value heads for grouped-query attention.
31
+ head_dim (`int`, *optional*, defaults to 128):
32
+ Dimension per attention head.
33
+ hidden_act (`str`, *optional*, defaults to `"silu"`):
34
+ Activation function used in FFN layers.
35
+ max_position_embeddings (`int`, *optional*, defaults to 131072):
36
+ Maximum sequence length supported by the model.
37
+ initializer_range (`float`, *optional*, defaults to 0.006):
38
+ Standard deviation of the truncated normal initializer for weight
39
+ initialization.
40
+ rms_norm_eps (`float`, *optional*, defaults to 1e-5):
41
+ Epsilon for RMS normalization layers.
42
+ use_cache (`bool`, *optional*, defaults to `True`):
43
+ Whether to use KV cache for decoding.
44
+ pad_token_id (`int`, *optional*):
45
+ Padding token id.
46
+ bos_token_id (`int`, *optional*):
47
+ Beginning-of-sequence token id.
48
+ eos_token_id (`int` or `List[int]`, *optional*):
49
+ End-of-sequence token id(s).
50
+ rope_parameters (`dict`, *optional*):
51
+ The parameters of the RoPE embeddings.
52
+ qk_norm (`bool`, *optional*, defaults to `True`):
53
+ Whether to apply RMSNorm to query and key states before attention.
54
+ tie_word_embeddings (`bool`, *optional*, defaults to `False`):
55
+ Whether to tie input and output embedding weights.
56
+ enable_attention_fp32_softmax (`bool`, *optional*, defaults to `False`):
57
+ Whether to upcast attention softmax to float32. Note: the eager attention
58
+ path always computes softmax in float32 regardless of this setting; this
59
+ flag is reserved for future use with custom attention backends.
60
+ enable_lm_head_fp32 (`bool`, *optional*, defaults to `True`):
61
+ Whether to upcast the LM head computation to float32.
62
+ num_experts (`int`, *optional*, defaults to 192):
63
+ Total number of MoE experts.
64
+ num_experts_per_tok (`int`, *optional*, defaults to 8):
65
+ Number of experts selected per token (top-k routing).
66
+ num_shared_experts (`int`, *optional*, defaults to 1):
67
+ Number of always-active shared experts combined into a single MLP.
68
+ expert_hidden_dim (`int`, *optional*, defaults to 1536):
69
+ Intermediate dimension of each individual MoE expert.
70
+ moe_router_enable_expert_bias (`bool`, *optional*, defaults to `True`):
71
+ Whether to use per-expert load-balancing bias in the router.
72
+ moe_router_use_sigmoid (`bool`, *optional*, defaults to `True`):
73
+ Whether to use sigmoid (instead of softmax) for router scoring.
74
+ route_norm (`bool`, *optional*, defaults to `True`):
75
+ Whether to normalize routing scores when using sigmoid routing.
76
+ router_scaling_factor (`float`, *optional*):
77
+ Optional multiplicative scaling factor applied to routing scores.
78
+ use_grouped_mm (`bool`, *optional*, defaults to `False`):
79
+ Whether to use grouped GEMM for expert computation (not yet implemented).
80
+ enable_moe_fp32_combine (`bool`, *optional*, defaults to `False`):
81
+ Whether to accumulate expert outputs in float32.
82
+ first_k_dense_replace (`int`, *optional*, defaults to 1):
83
+ Number of initial decoder layers that use a dense FFN instead of MoE.
84
+ output_router_logits (`bool`, *optional*, defaults to `False`):
85
+ Whether to output router logits from each MoE layer. Useful for computing
86
+ auxiliary load-balancing loss during training. Disabled by default to avoid
87
+ the memory overhead of storing per-layer router tensors during inference.
88
+
89
+ Example:
90
+ ```python
91
+ >>> from transformers import HYV3Config, HYV3Model
92
+
93
+ >>> config = HYV3Config()
94
+ >>> model = HYV3Model(config)
95
+ ```
96
+ """
97
+
98
+ model_type = "hy_v3"
99
+ keys_to_ignore_at_inference = ["past_key_values"]
100
+
101
+ def __init__(
102
+ self,
103
+ vocab_size=120832,
104
+ hidden_size=4096,
105
+ intermediate_size=13312,
106
+ num_hidden_layers=80,
107
+ num_attention_heads=64,
108
+ num_key_value_heads=8,
109
+ head_dim=128,
110
+ hidden_act="silu",
111
+ max_position_embeddings=131072,
112
+ initializer_range=0.006,
113
+ rms_norm_eps=1e-5,
114
+ use_cache=True,
115
+ pad_token_id=None,
116
+ bos_token_id=None,
117
+ eos_token_id=None,
118
+ rope_parameters: dict[str, Any] | None = None,
119
+ qk_norm=True,
120
+ tie_word_embeddings=False,
121
+ enable_attention_fp32_softmax=False,
122
+ enable_lm_head_fp32=True,
123
+ # MoE specific
124
+ num_experts=192,
125
+ num_experts_per_tok=8,
126
+ num_shared_experts=1,
127
+ expert_hidden_dim=1536,
128
+ moe_router_enable_expert_bias=True,
129
+ moe_router_use_sigmoid=True,
130
+ route_norm=True,
131
+ router_scaling_factor=None,
132
+ use_grouped_mm=False,
133
+ enable_moe_fp32_combine=False,
134
+ # Dense/MoE layer control
135
+ first_k_dense_replace=1,
136
+ output_router_logits=False,
137
+ **kwargs,
138
+ ):
139
+ self.vocab_size = vocab_size
140
+ self.hidden_size = hidden_size
141
+ self.intermediate_size = intermediate_size
142
+ self.num_hidden_layers = num_hidden_layers
143
+ self.num_attention_heads = num_attention_heads
144
+ self.num_key_value_heads = num_key_value_heads
145
+ self.head_dim = head_dim
146
+ self.hidden_act = hidden_act
147
+ self.max_position_embeddings = max_position_embeddings
148
+ self.initializer_range = initializer_range
149
+ self.rms_norm_eps = rms_norm_eps
150
+ self.use_cache = use_cache
151
+ rope_theta = kwargs.pop("rope_theta", 11158840.0)
152
+ if rope_parameters is None:
153
+ rope_parameters = {"rope_type": "default", "rope_theta": rope_theta}
154
+ self.rope_parameters = rope_parameters
155
+ self.qk_norm = qk_norm
156
+ self.tie_word_embeddings = tie_word_embeddings
157
+ self.enable_lm_head_fp32 = enable_lm_head_fp32
158
+ self.enable_attention_fp32_softmax = enable_attention_fp32_softmax
159
+
160
+ # MoE specific
161
+ self.num_experts = num_experts
162
+ self.num_experts_per_tok = num_experts_per_tok
163
+ self.num_shared_experts = num_shared_experts
164
+ self.expert_hidden_dim = expert_hidden_dim
165
+ self.moe_router_enable_expert_bias = moe_router_enable_expert_bias
166
+ self.moe_router_use_sigmoid = moe_router_use_sigmoid
167
+ self.route_norm = route_norm
168
+ self.use_grouped_mm = use_grouped_mm
169
+ self.router_scaling_factor = router_scaling_factor
170
+ self.enable_moe_fp32_combine = enable_moe_fp32_combine
171
+
172
+ # Dense/MoE layer control
173
+ self.first_k_dense_replace = first_k_dense_replace
174
+ self.output_router_logits = output_router_logits
175
+
176
+ if eos_token_id is not None and isinstance(eos_token_id, int):
177
+ eos_token_id = [eos_token_id]
178
+
179
+ super().__init__(
180
+ pad_token_id=pad_token_id,
181
+ bos_token_id=bos_token_id,
182
+ eos_token_id=eos_token_id,
183
+ tie_word_embeddings=tie_word_embeddings,
184
+ **kwargs,
185
+ )