[Hy4] support Hy4-preview model (#54160)
Signed-off-by: chengvjiang <chengvjiang@tencent.com> Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai> Signed-off-by: Bugen Zhao <i@bugenzhao.com> Signed-off-by: Jee Jee Li <pandaleefree@gmail.com> Co-authored-by: chengvjiang <chengvjiang@tencent.com> Co-authored-by: Jee Jee Li <jeejeelee@inferact.ai> Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: russellfeng <russellfeng@tencent.com> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>
Cheng Jiang committed
Aug 29, 2026 at 12:07 UTC
b2f685834a6456197e7033966fdef52a23f1abcd
47 files changed
+6260
-217
docs/models/supported_models.md
+2
-1
@@ -405,7 +405,8 @@ th {
405
| `GraniteMoeSWAForCausalLM` | Granite MoE SWA | `ibm-granite/granite-swash-3b-a600m` | ✅︎ | ✅︎ |
406
| `GraniteSWAForCausalLM` | Granite SWA | `ibm-granite/granite-swash-2b` | ✅︎ | ✅︎ |
407
| `HrmTextForCausalLM` | HRM-Text | `sapientinc/HRM-Text-1B`, etc. | | |
408
-| `HYV3ForCausalLM` | HY3 | `tencent/Hy3-preview-Base`, `tencent/Hy3-preview` | ✅︎ | ✅︎ |
408
+| `HYV3ForCausalLM` | HY3 | `tencent/Hy3-preview-Base`, `tencent/Hy3-preview`, `tencent/Hy3`, `tencent/Hy3-FP8` | ✅︎ | ✅︎ |
409
+| `HYV4ForCausalLM` | HY4 | `tencent/Hy4-preview`, `tencent/Hy4-preview-FP8` | ✅︎ | ✅︎ |
410
| `HyperCLOVAXForCausalLM` | HyperCLOVAX-SEED-Think-14B | `naver-hyperclovax/HyperCLOVAX-SEED-Think-14B` | ✅︎ | ✅︎ |
411
| `InternLM2ForCausalLM` | InternLM2 | `internlm/internlm2-7b`, `internlm/internlm2-chat-7b`, etc. | ✅︎ | ✅︎ |
412
| `InternLM3ForCausalLM` | InternLM3 | `internlm/internlm3-8b-instruct`, etc. | ✅︎ | ✅︎ |
rust/src/chat/src/lib.rs
+2
-2
@@ -405,7 +405,7 @@ mod tests {
405
)
406
.unwrap_err();
407
408
- expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
408
+ expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, hy_v4, inkling, internlm, kimi_k2, kimi_k3, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml, seed_oss)"].assert_eq(&error.to_report_string());
409
}
410
411
#[test]
@@ -416,6 +416,6 @@ mod tests {
416
)
417
.unwrap_err();
418
419
- expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, hy_v3, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
419
+ expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, hy_v3, hy_v4, inkling, kimi, kimi_k2, kimi_k3, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
420
}
421
}
rust/src/chat/src/parser/reasoning/mod.rs
+3
@@ -26,6 +26,7 @@ pub mod names {
26
pub const INKLING: &str = "inkling";
27
pub const GLM45: &str = "glm45";
28
pub const HY_V3: &str = "hy_v3";
29
+ pub const HY_V4: &str = "hy_v4";
30
pub const KIMI: &str = "kimi";
31
pub const KIMI_K2: &str = "kimi_k2";
32
pub const KIMI_K3: &str = "kimi_k3";
@@ -69,6 +70,7 @@ impl ReasoningParserFactory {
70
.register_unified_dummy(names::INKLING)
71
.register_parser::<Glm45ReasoningParser>(names::GLM45)
72
.register_unified_dummy(names::HY_V3)
73
+ .register_unified_dummy(names::HY_V4)
74
.register_parser::<KimiReasoningParser>(names::KIMI)
75
.register_parser::<KimiK2ReasoningParser>(names::KIMI_K2)
76
.register_unified_dummy(names::KIMI_K3)
@@ -94,6 +96,7 @@ impl ReasoningParserFactory {
96
.register_pattern("glm-4.6", names::GLM45)
97
.register_pattern("glm-4.5", names::GLM45)
98
.register_pattern("hy3", names::HY_V3)
99
+ .register_pattern("hy4", names::HY_V4)
100
.register_pattern("kimi-k2", names::KIMI_K2)
101
.register_pattern("kimi", names::KIMI)
102
// step3p5 patterns must precede `step3`: substring matching would
rust/src/chat/src/parser/tool/mod.rs
+3
@@ -29,6 +29,7 @@ pub mod names {
29
pub const GRANITE4: &str = "granite4";
30
pub const HERMES: &str = "hermes";
31
pub const HY_V3: &str = "hy_v3";
32
+ pub const HY_V4: &str = "hy_v4";
33
// Matches the Python CLI name `--tool-call-parser internlm`, which Python
34
// also routes to `Internlm2ToolParser` despite the version-agnostic name.
35
pub const INTERNLM: &str = "internlm";
@@ -77,6 +78,7 @@ impl ToolParserFactory {
78
.register_parser::<Granite4ToolParser>(names::GRANITE4)
79
.register_parser::<HermesToolParser>(names::HERMES)
80
.register_unified_dummy(names::HY_V3)
81
+ .register_unified_dummy(names::HY_V4)
82
.register_parser::<Internlm2ToolParser>(names::INTERNLM)
83
.register_parser::<KimiK2ToolParser>(names::KIMI_K2)
84
.register_unified_dummy(names::KIMI_K3)
@@ -100,6 +102,7 @@ impl ToolParserFactory {
102
.register_pattern("qwen3", names::QWEN3_XML)
103
.register_pattern("hermes", names::HERMES)
104
.register_pattern("hy3", names::HY_V3)
105
+ .register_pattern("hy4", names::HY_V4)
106
// Narrow to `internlm2` substring so it matches `internlm2-chat-7b`
107
// and `internlm2_5-7b-chat` but NOT `internlm-chat-7b` (InternLM v1,
108
// routes to Llama), `internlm3-*` (also Llama-architecture per
rust/src/chat/src/parser/tool/tests.rs
+4
@@ -183,6 +183,10 @@ fn factory_new_resolves_default_patterns() {
183
factory.resolve_name_for_model("tencent/Hy3-preview"),
184
Some(names::HY_V3)
185
);
186
+ assert_eq!(
187
+ factory.resolve_name_for_model("tencent/Hy4-preview"),
188
+ Some(names::HY_V4)
189
+ );
190
assert_eq!(
191
factory.resolve_name_for_model("MiniMax/MiniMax-M3-Text"),
192
Some(names::MINIMAX_M3)
rust/src/chat/src/parser/unified.rs
+34
-2
@@ -6,8 +6,8 @@
6
use std::sync::LazyLock;
7
8
pub use vllm_parser::unified::{
9
- Gemma4UnifiedParser, HyV3UnifiedParser, InklingUnifiedParser, KimiK3UnifiedParser,
10
- UnifiedParser,
9
+ Gemma4UnifiedParser, HyV3UnifiedParser, HyV4UnifiedParser, InklingUnifiedParser,
10
+ KimiK3UnifiedParser, UnifiedParser,
11
};
12
use vllm_tokenizer::DynTokenizer;
13
@@ -18,6 +18,7 @@ use crate::request::ChatTool;
18
pub mod names {
19
pub const GEMMA4: &str = "gemma4";
20
pub const HY_V3: &str = "hy_v3";
21
+ pub const HY_V4: &str = "hy_v4";
22
pub const INKLING: &str = "inkling";
23
pub const KIMI_K3: &str = "kimi_k3";
24
}
@@ -44,6 +45,7 @@ impl UnifiedParserFactory {
45
46
factory.register_parser::<Gemma4UnifiedParser>(names::GEMMA4);
47
factory.register_parser::<HyV3UnifiedParser>(names::HY_V3);
48
+ factory.register_parser::<HyV4UnifiedParser>(names::HY_V4);
49
factory.register_parser::<InklingUnifiedParser>(names::INKLING);
50
factory.register_parser::<KimiK3UnifiedParser>(names::KIMI_K3);
51
@@ -51,6 +53,7 @@ impl UnifiedParserFactory {
53
.register_pattern("gemma-4", names::GEMMA4)
54
.register_pattern("gemma4", names::GEMMA4)
55
.register_pattern("hy3", names::HY_V3)
56
+ .register_pattern("hy4", names::HY_V4)
57
.register_pattern("inkling", names::INKLING)
58
.register_pattern("kimi-k3", names::KIMI_K3)
59
.register_pattern("kimi_k3", names::KIMI_K3);
@@ -147,4 +150,33 @@ mod tests {
150
);
151
factory.create(names::KIMI_K3, &[], Arc::new(tokenizer)).unwrap();
152
}
153
+
154
+ #[test]
155
+ fn factory_registers_hy_v4() {
156
+ let factory = UnifiedParserFactory::new();
157
+ let tokenizer = [
158
+ "<think:opensource>",
159
+ "</think:opensource>",
160
+ "<tool_calls:opensource>",
161
+ "</tool_calls:opensource>",
162
+ "<tool_call:opensource>",
163
+ "</tool_call:opensource>",
164
+ "<arg_key:opensource>",
165
+ "</arg_key:opensource>",
166
+ "<arg_value:opensource>",
167
+ "</arg_value:opensource>",
168
+ ]
169
+ .into_iter()
170
+ .enumerate()
171
+ .fold(TestTokenizer::new(), |tokenizer, (index, token)| {
172
+ tokenizer.with_regular_token(token, 1000 + index as u32)
173
+ });
174
+
175
+ assert!(factory.contains(names::HY_V4));
176
+ assert_eq!(
177
+ factory.resolve_name_for_model("tencent/Hy4-preview"),
178
+ Some(names::HY_V4)
179
+ );
180
+ factory.create(names::HY_V4, &[], Arc::new(tokenizer)).unwrap();
181
+ }
182
}
rust/src/parser/src/reasoning/hy.rs
renamed
+7
-7
@@ -5,13 +5,13 @@ use vllm_tokenizer::DynTokenizer;
5
6
use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningError, ReasoningParser, Result};
7
8
-/// Internal HY3 reasoning stage used by the unified HY3 parser.
9
-pub(crate) struct HyV3ReasoningParser {
8
+/// Internal HY reasoning stage used by the unified HY parsers.
9
+pub(crate) struct HyReasoningParser {
10
inner: DelimitedReasoningParser,
11
}
12
13
-impl HyV3ReasoningParser {
14
- /// Create a HY3 reasoning parser for the tokenizer-specific marker suffix.
13
+impl HyReasoningParser {
14
+ /// Create a HY reasoning parser for the tokenizer-specific marker suffix.
15
pub(crate) fn new(tokenizer: DynTokenizer, suffix: &str) -> Result<Self> {
16
Ok(Self {
17
inner: DelimitedReasoningParser::new(
@@ -24,15 +24,15 @@ impl HyV3ReasoningParser {
24
}
25
}
26
27
-impl ReasoningParser for HyV3ReasoningParser {
28
- // Suffix discovery belongs to `HyV3UnifiedParser` so its reasoning and
27
+impl ReasoningParser for HyReasoningParser {
28
+ // Suffix discovery belongs to the unified HY parser so its reasoning and
29
// tool delimiters always use the same tokenizer-derived value.
30
fn create(_tokenizer: DynTokenizer) -> Result<Box<dyn ReasoningParser>>
31
where
32
Self: Sized + 'static,
33
{
34
Err(ReasoningError::DummyUnifiedParser {
35
- name: "hy_v3".to_string(),
35
+ name: "hy".to_string(),
36
})
37
}
38
rust/src/parser/src/reasoning/mod.rs
+2
-2
@@ -20,7 +20,7 @@
20
mod cohere_cmd;
21
mod deepseek_r1;
22
mod delimited;
23
-mod hy_v3;
23
+mod hy;
24
mod kimi;
25
mod minimax_m3;
26
mod qwen3;
@@ -33,7 +33,7 @@ use vllm_tokenizer::DynTokenizer;
33
pub use self::cohere_cmd::CohereCmdReasoningParser;
34
pub use self::deepseek_r1::DeepSeekR1ReasoningParser;
35
pub(crate) use self::delimited::{DelimitedReasoningParser, last_reasoning_boundary};
36
-pub(crate) use self::hy_v3::HyV3ReasoningParser;
36
+pub(crate) use self::hy::HyReasoningParser;
37
pub use self::kimi::KimiReasoningParser;
38
pub use self::minimax_m3::MiniMaxM3ReasoningParser;
39
pub use self::qwen3::Qwen3ReasoningParser;
rust/src/parser/src/tool/hy.rs
renamed
+250
-118
@@ -11,33 +11,56 @@ use winnow::prelude::*;
11
use winnow::stream::Partial;
12
use winnow::token::{literal, rest, take_until};
13
14
-use self::structural_tag::HyV3StructuralTagBuilder;
14
+use self::structural_tag::HyStructuralTagBuilder;
15
use super::parameters::ToolSchemas;
16
use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker};
17
use super::{Result, ToolCallDelta, ToolParser, ToolParserError, ToolParserOutput};
18
use crate::tool::{StructuralTagBuilder, Tool};
19
20
+/// Wire-level HY tool-call dialect.
21
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22
+pub(crate) enum HyDialect {
23
+ V3,
24
+ V4,
25
+}
26
+
27
+impl HyDialect {
28
+ fn separator(self) -> &'static str {
29
+ match self {
30
+ Self::V3 => "\n",
31
+ Self::V4 => "",
32
+ }
33
+ }
34
+
35
+ fn label(self) -> &'static str {
36
+ match self {
37
+ Self::V3 => "HY3",
38
+ Self::V4 => "HY4",
39
+ }
40
+ }
41
+}
42
+
43
#[derive(Debug, Clone, PartialEq, Eq)]
21
-pub(crate) struct HyV3ToolMarkers {
44
+pub(crate) struct HyToolMarkers {
45
tool_calls_start: String,
46
tool_calls_end: String,
47
tool_call_start: String,
48
tool_call_end: String,
26
- tool_sep: String,
49
+ tool_sep: Option<String>,
50
arg_key_start: String,
51
arg_key_end: String,
52
arg_value_start: String,
53
arg_value_end: String,
54
}
55
33
-impl HyV3ToolMarkers {
34
- pub(crate) fn new(suffix: &str) -> Self {
56
+impl HyToolMarkers {
57
+ pub(crate) fn new(suffix: &str, dialect: HyDialect) -> Self {
58
Self {
59
tool_calls_start: format!("<tool_calls{suffix}>"),
60
tool_calls_end: format!("</tool_calls{suffix}>"),
61
tool_call_start: format!("<tool_call{suffix}>"),
62
tool_call_end: format!("</tool_call{suffix}>"),
40
- tool_sep: format!("<tool_sep{suffix}>"),
63
+ tool_sep: (dialect == HyDialect::V3).then(|| format!("<tool_sep{suffix}>")),
64
arg_key_start: format!("<arg_key{suffix}>"),
65
arg_key_end: format!("</arg_key{suffix}>"),
66
arg_value_start: format!("<arg_value{suffix}>"),
@@ -47,31 +70,32 @@ impl HyV3ToolMarkers {
70
71
pub(crate) fn iter(&self) -> impl Iterator<Item = &str> {
72
[
50
- self.tool_calls_start.as_str(),
51
- self.tool_calls_end.as_str(),
52
- self.tool_call_start.as_str(),
53
- self.tool_call_end.as_str(),
54
- self.tool_sep.as_str(),
55
- self.arg_key_start.as_str(),
56
- self.arg_key_end.as_str(),
57
- self.arg_value_start.as_str(),
58
- self.arg_value_end.as_str(),
73
+ Some(self.tool_calls_start.as_str()),
74
+ Some(self.tool_calls_end.as_str()),
75
+ Some(self.tool_call_start.as_str()),
76
+ Some(self.tool_call_end.as_str()),
77
+ self.tool_sep.as_deref(),
78
+ Some(self.arg_key_start.as_str()),
79
+ Some(self.arg_key_end.as_str()),
80
+ Some(self.arg_value_start.as_str()),
81
+ Some(self.arg_value_end.as_str()),
82
]
83
.into_iter()
84
+ .flatten()
85
}
86
}
87
64
-type HyV3Input<'i> = Partial<&'i str>;
88
+type HyInput<'i> = Partial<&'i str>;
89
90
#[derive(Debug, Clone, PartialEq, Eq)]
67
-enum HyV3Mode {
91
+enum HyMode {
92
Text,
93
ToolBlock { tool_call_end_scan: MarkerScanState },
94
Done,
95
}
96
97
#[derive(Debug, Clone, PartialEq, Eq)]
74
-enum HyV3Event {
98
+enum HyEvent {
99
Text {
100
len: usize,
101
},
@@ -84,7 +108,7 @@ enum HyV3Event {
108
IgnoredRest,
109
}
110
87
-/// Tool parser for HY3 XML-style tool calls.
111
+/// Tool parser for HY XML-style tool calls.
112
///
113
/// Example tool call content:
114
///
@@ -97,43 +121,45 @@ enum HyV3Event {
121
/// ```
122
///
123
/// Arguments are emitted only after a full `<tool_call>` block is parsed.
100
-/// HY3 marker tokens are added-vocabulary tokens rather than tokenizer special
124
+/// HY marker tokens are added-vocabulary tokens rather than tokenizer special
125
/// tokens, so the default `preserve_special_tokens() == false` is sufficient.
102
-pub(crate) struct HyV3ToolParser {
126
+pub(crate) struct HyToolParser {
127
buffer: String,
104
- mode: HyV3Mode,
128
+ mode: HyMode,
129
emitted_tool_count: usize,
130
tool_parameters: ToolSchemas,
107
- markers: Arc<HyV3ToolMarkers>,
108
- structural_tag_builder: HyV3StructuralTagBuilder,
131
+ dialect: HyDialect,
132
+ markers: Arc<HyToolMarkers>,
133
+ structural_tag_builder: HyStructuralTagBuilder,
134
}
135
111
-impl HyV3ToolParser {
112
- /// Create a HY3 tool parser.
113
- pub(crate) fn new(tools: &[Tool], suffix: &str) -> Self {
114
- let markers = Arc::new(HyV3ToolMarkers::new(suffix));
136
+impl HyToolParser {
137
+ /// Create a HY tool parser for one wire dialect.
138
+ pub(crate) fn new(tools: &[Tool], suffix: &str, dialect: HyDialect) -> Self {
139
+ let markers = Arc::new(HyToolMarkers::new(suffix, dialect));
140
Self {
141
buffer: String::new(),
117
- mode: HyV3Mode::Text,
142
+ mode: HyMode::Text,
143
emitted_tool_count: 0,
144
tool_parameters: ToolSchemas::from_tools(tools),
145
+ dialect,
146
markers: Arc::clone(&markers),
121
- structural_tag_builder: HyV3StructuralTagBuilder::new(markers),
147
+ structural_tag_builder: HyStructuralTagBuilder::new(markers, dialect),
148
}
149
}
150
125
- /// Apply one parsed HY3 event to parser state and output.
126
- fn apply_event(&mut self, event: HyV3Event, output: &mut ToolParserOutput) -> Result<()> {
151
+ /// Apply one parsed HY event to parser state and output.
152
+ fn apply_event(&mut self, event: HyEvent, output: &mut ToolParserOutput) -> Result<()> {
153
match event {
128
- HyV3Event::Text { len: consumed_len } => {
154
+ HyEvent::Text { len: consumed_len } => {
155
output.push_text(&self.buffer[..consumed_len]);
156
}
131
- HyV3Event::ToolBlockStart => {
132
- self.mode = HyV3Mode::ToolBlock {
157
+ HyEvent::ToolBlockStart => {
158
+ self.mode = HyMode::ToolBlock {
159
tool_call_end_scan: MarkerScanState::default(),
160
};
161
}
136
- HyV3Event::ToolCall { name, raw_params } => {
162
+ HyEvent::ToolCall { name, raw_params } => {
163
let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params);
164
let arguments = serde_json::to_string(&arguments)
165
.map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?;
@@ -145,22 +171,22 @@ impl HyV3ToolParser {
171
});
172
self.emitted_tool_count += 1;
173
}
148
- HyV3Event::ToolBlockEnd => self.mode = HyV3Mode::Done,
149
- HyV3Event::IgnoredRest => {}
174
+ HyEvent::ToolBlockEnd => self.mode = HyMode::Done,
175
+ HyEvent::IgnoredRest => {}
176
}
177
Ok(())
178
}
179
}
180
155
-impl ToolParser for HyV3ToolParser {
156
- // Suffix discovery belongs to `HyV3UnifiedParser` so its reasoning and
181
+impl ToolParser for HyToolParser {
182
+ // Suffix discovery belongs to the unified HY parser so its reasoning and
183
// tool delimiters always use the same tokenizer-derived value.
184
fn create(_tools: &[Tool]) -> Result<Box<dyn ToolParser>>
185
where
186
Self: Sized + 'static,
187
{
188
Err(ToolParserError::DummyUnifiedParser {
163
- name: "hy_v3".to_string(),
189
+ name: "hy".to_string(),
190
})
191
}
192
@@ -172,7 +198,7 @@ impl ToolParser for HyV3ToolParser {
198
self.buffer.push_str(chunk);
199
200
while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
175
- parse_next_hy_v3_event(input, &mut self.mode, &self.markers)
201
+ parse_next_hy_event(input, &mut self.mode, &self.markers, self.dialect)
202
})? {
203
self.apply_event(event, output)?;
204
self.buffer.drain(..consumed_len);
@@ -184,113 +210,145 @@ impl ToolParser for HyV3ToolParser {
210
fn finish(&mut self) -> Result<ToolParserOutput> {
211
let mut output = ToolParserOutput::default();
212
match self.mode {
187
- HyV3Mode::Text => output.push_text(&self.buffer),
188
- HyV3Mode::ToolBlock { .. } => return Err(parsing_failed!("incomplete HY3 tool call")),
189
- HyV3Mode::Done => {}
213
+ HyMode::Text => output.push_text(&self.buffer),
214
+ HyMode::ToolBlock { .. } => {
215
+ return Err(parsing_failed!(
216
+ "incomplete {} tool call",
217
+ self.dialect.label()
218
+ ));
219
+ }
220
+ HyMode::Done => {}
221
}
222
let _ = self.reset();
223
Ok(output)
224
}
225
226
fn reset(&mut self) -> String {
196
- self.mode = HyV3Mode::Text;
227
+ self.mode = HyMode::Text;
228
self.emitted_tool_count = 0;
229
std::mem::take(&mut self.buffer)
230
}
231
}
232
202
-/// Parse a HY3 event for the current parser mode.
203
-fn parse_next_hy_v3_event(
204
- input: &mut HyV3Input<'_>,
205
- mode: &mut HyV3Mode,
206
- markers: &HyV3ToolMarkers,
207
-) -> ModalResult<HyV3Event> {
233
+/// Parse a HY event for the current parser mode.
234
+fn parse_next_hy_event(
235
+ input: &mut HyInput<'_>,
236
+ mode: &mut HyMode,
237
+ markers: &HyToolMarkers,
238
+ dialect: HyDialect,
239
+) -> ModalResult<HyEvent> {
240
match mode {
209
- HyV3Mode::Text => parse_text_event(input, markers),
210
- HyV3Mode::ToolBlock { tool_call_end_scan } => {
211
- parse_tool_block_event(input, tool_call_end_scan, markers)
241
+ HyMode::Text => parse_text_event(input, markers),
242
+ HyMode::ToolBlock { tool_call_end_scan } => {
243
+ parse_tool_block_event(input, tool_call_end_scan, markers, dialect)
244
}
213
- HyV3Mode::Done => ignored_rest_event(input),
245
+ HyMode::Done => ignored_rest_event(input),
246
}
247
}
248
217
-/// Parse a text-mode HY3 event.
218
-fn parse_text_event(
219
- input: &mut HyV3Input<'_>,
220
- markers: &HyV3ToolMarkers,
221
-) -> ModalResult<HyV3Event> {
249
+/// Parse a text-mode HY event.
250
+fn parse_text_event(input: &mut HyInput<'_>, markers: &HyToolMarkers) -> ModalResult<HyEvent> {
251
alt((
223
- |input: &mut HyV3Input<'_>| tool_block_start_event(input, markers),
224
- |input: &mut HyV3Input<'_>| safe_text_event(input, markers),
252
+ |input: &mut HyInput<'_>| tool_block_start_event(input, markers),
253
+ |input: &mut HyInput<'_>| safe_text_event(input, markers),
254
))
255
.parse_next(input)
256
}
257
229
-/// Parse a HY3 tool-block start marker.
258
+/// Parse a HY tool-block start marker.
259
fn tool_block_start_event(
231
- input: &mut HyV3Input<'_>,
232
- markers: &HyV3ToolMarkers,
233
-) -> ModalResult<HyV3Event> {
260
+ input: &mut HyInput<'_>,
261
+ markers: &HyToolMarkers,
262
+) -> ModalResult<HyEvent> {
263
literal(markers.tool_calls_start.as_str())
235
- .value(HyV3Event::ToolBlockStart)
264
+ .value(HyEvent::ToolBlockStart)
265
.parse_next(input)
266
}
267
239
-/// Parse a safe text run before the next HY3 marker.
240
-fn safe_text_event(input: &mut HyV3Input<'_>, markers: &HyV3ToolMarkers) -> ModalResult<HyV3Event> {
241
- safe_text_len(input, &markers.tool_calls_start).map(|len| HyV3Event::Text { len })
268
+/// Parse a safe text run before the next HY marker.
269
+fn safe_text_event(input: &mut HyInput<'_>, markers: &HyToolMarkers) -> ModalResult<HyEvent> {
270
+ safe_text_len(input, &markers.tool_calls_start).map(|len| HyEvent::Text { len })
271
}
272
244
-/// Parse one event inside a HY3 tool block.
273
+/// Parse one event inside a HY tool block.
274
fn parse_tool_block_event(
246
- input: &mut HyV3Input<'_>,
275
+ input: &mut HyInput<'_>,
276
tool_call_end_scan: &mut MarkerScanState,
248
- markers: &HyV3ToolMarkers,
249
-) -> ModalResult<HyV3Event> {
277
+ markers: &HyToolMarkers,
278
+ dialect: HyDialect,
279
+) -> ModalResult<HyEvent> {
280
alt((
251
- |input: &mut HyV3Input<'_>| tool_block_end_event(input, markers),
252
- |input: &mut HyV3Input<'_>| tool_call_event(input, tool_call_end_scan, markers),
281
+ |input: &mut HyInput<'_>| tool_block_end_event(input, markers),
282
+ |input: &mut HyInput<'_>| tool_call_event(input, tool_call_end_scan, markers, dialect),
283
))
284
.parse_next(input)
285
}
286
257
-/// Parse a HY3 tool-block end marker.
258
-fn tool_block_end_event(
259
- input: &mut HyV3Input<'_>,
260
- markers: &HyV3ToolMarkers,
261
-) -> ModalResult<HyV3Event> {
287
+/// Parse a HY tool-block end marker.
288
+fn tool_block_end_event(input: &mut HyInput<'_>, markers: &HyToolMarkers) -> ModalResult<HyEvent> {
289
(ws0, literal(markers.tool_calls_end.as_str()))
263
- .value(HyV3Event::ToolBlockEnd)
290
+ .value(HyEvent::ToolBlockEnd)
291
.parse_next(input)
292
}
293
267
-/// Parse a complete HY3 tool-call block.
294
+/// Parse a complete HY tool-call block.
295
fn tool_call_event(
269
- input: &mut HyV3Input<'_>,
296
+ input: &mut HyInput<'_>,
297
tool_call_end_scan: &mut MarkerScanState,
271
- markers: &HyV3ToolMarkers,
272
-) -> ModalResult<HyV3Event> {
273
- let (name, body) = seq!(
298
+ markers: &HyToolMarkers,
299
+ dialect: HyDialect,
300
+) -> ModalResult<HyEvent> {
301
+ let (body,) = seq!(
302
_: ws0,
303
_: literal(markers.tool_call_start.as_str()),
276
- take_until(0.., markers.tool_sep.as_str()),
277
- _: literal(markers.tool_sep.as_str()),
304
take_until_marker(markers.tool_call_end.as_str(), tool_call_end_scan),
305
_: literal(markers.tool_call_end.as_str()),
306
)
307
.parse_next(input)?;
282
- let raw_params = parse_tool_call_params(body, markers)?;
308
+ let mut body_input = body;
309
+ let (name, params) = parse_tool_call_body(&mut body_input, markers, dialect)?;
310
+ let raw_params = parse_tool_call_params(params, markers)?;
311
284
- Ok(HyV3Event::ToolCall {
312
+ Ok(HyEvent::ToolCall {
313
name: name.trim().to_string(),
314
raw_params,
315
})
316
}
317
290
-/// Parse all parameter blocks inside a complete HY3 tool call.
318
+/// Parse a complete HY tool-call body according to its dialect.
319
+fn parse_tool_call_body<'i>(
320
+ input: &mut &'i str,
321
+ markers: &HyToolMarkers,
322
+ dialect: HyDialect,
323
+) -> ModalResult<(&'i str, &'i str)> {
324
+ match dialect {
325
+ HyDialect::V3 => {
326
+ let tool_sep = markers.tool_sep.as_deref().expect("HY3 has a tool separator");
327
+ terminated(
328
+ seq!(
329
+ take_until(0.., tool_sep),
330
+ _: literal(tool_sep),
331
+ rest,
332
+ ),
333
+ eof,
334
+ )
335
+ .parse_next(input)
336
+ }
337
+ HyDialect::V4 => terminated(
338
+ alt((
339
+ seq!(take_until(0.., markers.arg_key_start.as_str()), rest,),
340
+ rest.map(|name| (name, "")),
341
+ )),
342
+ eof,
343
+ )
344
+ .parse_next(input),
345
+ }
346
+}
347
+
348
+/// Parse all parameter blocks inside a complete HY tool call.
349
fn parse_tool_call_params(
350
tool_call_body: &str,
293
- markers: &HyV3ToolMarkers,
351
+ markers: &HyToolMarkers,
352
) -> ModalResult<Vec<(String, String)>> {
353
let mut input = tool_call_body;
354
delimited(
@@ -304,8 +362,8 @@ fn parse_tool_call_params(
362
.parse_next(&mut input)
363
}
364
307
-/// Parse a HY3 argument key/value block.
308
-fn parameter(input: &mut &str, markers: &HyV3ToolMarkers) -> ModalResult<(String, String)> {
365
+/// Parse a HY argument key/value block.
366
+fn parameter(input: &mut &str, markers: &HyToolMarkers) -> ModalResult<(String, String)> {
367
let (name, value) = seq!(
368
_: literal(markers.arg_key_start.as_str()),
369
take_until(0.., markers.arg_key_end.as_str()),
@@ -320,9 +378,9 @@ fn parameter(input: &mut &str, markers: &HyV3ToolMarkers) -> ModalResult<(String
378
Ok((name.trim().to_string(), value.to_string()))
379
}
380
323
-/// Parse ignored rest after the HY3 tool block ends.
324
-fn ignored_rest_event(input: &mut HyV3Input<'_>) -> ModalResult<HyV3Event> {
325
- rest.value(HyV3Event::IgnoredRest).parse_next(input)
381
+/// Parse ignored rest after the HY tool block ends.
382
+fn ignored_rest_event(input: &mut HyInput<'_>) -> ModalResult<HyEvent> {
383
+ rest.value(HyEvent::IgnoredRest).parse_next(input)
384
}
385
386
#[cfg(test)]
@@ -331,7 +389,7 @@ mod tests {
389
use serde_json::{Value, json};
390
use thiserror_ext::AsReport;
391
334
- use super::{HyV3ToolParser, ToolParser};
392
+ use super::{HyDialect, HyToolParser, ToolParser};
393
use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools};
394
use crate::tool::{ToolParserOutput, ToolParserTestExt as _};
395
@@ -354,14 +412,14 @@ mod tests {
412
413
#[test]
414
fn hy_v3_does_not_preserve_special_tokens() {
357
- let parser = HyV3ToolParser::new(&test_tools(), "");
415
+ let parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
416
417
assert!(!parser.preserve_special_tokens());
418
}
419
420
#[test]
421
fn hy_v3_parse_complete_without_tool_call_keeps_text() {
364
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
422
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
423
let output = parser.parse_complete("This is a plain response.").unwrap();
424
425
assert_eq!(output.normal_text(), "This is a plain response.");
@@ -370,7 +428,7 @@ mod tests {
428
429
#[test]
430
fn hy_v3_parse_complete_extracts_zero_arg_inline_tool_call() {
373
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
431
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
432
let output = parser
433
.parse_complete(
434
"<tool_calls><tool_call>get_current_date<tool_sep></tool_call></tool_calls>",
@@ -385,7 +443,7 @@ mod tests {
443
444
#[test]
445
fn hy_v3_parse_complete_extracts_zero_arg_newline_tool_call() {
388
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
446
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
447
let output = parser
448
.parse_complete(
449
"<tool_calls>\n<tool_call>get_current_date<tool_sep>\n</tool_call>\n</tool_calls>",
@@ -398,7 +456,7 @@ mod tests {
456
457
#[test]
458
fn hy_v3_parse_complete_extracts_arguments_on_same_line() {
401
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
459
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
460
let output = parser
461
.parse_complete(
462
"<tool_calls><tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>Beijing</arg_value><arg_key>date</arg_key><arg_value>2026-03-30</arg_value></tool_call></tool_calls>",
@@ -413,7 +471,7 @@ mod tests {
471
472
#[test]
473
fn hy_v3_parse_complete_extracts_arguments_with_newlines() {
416
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
474
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
475
let output = parser
476
.parse_complete(&build_tool_calls(&[build_tool_call(
477
"get_weather",
@@ -429,7 +487,7 @@ mod tests {
487
488
#[test]
489
fn hy_v3_parse_complete_preserves_prefix_and_ignores_trailing_text() {
432
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
490
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
491
let output = parser
492
.parse_complete(&format!(
493
"Checking.{} trailing text",
@@ -443,7 +501,7 @@ mod tests {
501
502
#[test]
503
fn hy_v3_parse_complete_extracts_multiple_tool_calls_in_one_block() {
446
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
504
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
505
let output = parser
506
.parse_complete(&build_tool_calls(&[
507
build_tool_call(
@@ -486,7 +544,7 @@ mod tests {
544
545
#[test]
546
fn hy_v3_parse_complete_converts_schema_types() {
489
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
547
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
548
let output = parser
549
.parse_complete(&build_tool_calls(&[build_tool_call(
550
"convert",
@@ -512,7 +570,7 @@ mod tests {
570
571
#[test]
572
fn hy_v3_streaming_without_tool_call_emits_text_incrementally() {
515
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
573
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
574
let mut output = ToolParserOutput::default();
575
576
output.append(parser.parse_chunk("This is ").unwrap());
@@ -526,7 +584,7 @@ mod tests {
584
585
#[test]
586
fn hy_v3_streaming_extracts_zero_arg_tool_call() {
529
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
587
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
588
let chunks = [
589
"<tool_calls>",
590
"\n<tool_call>",
@@ -545,7 +603,7 @@ mod tests {
603
604
#[test]
605
fn hy_v3_streaming_extracts_arguments() {
548
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
606
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
607
let chunks = [
608
"<tool_calls>",
609
"\n<tool_call>",
@@ -571,7 +629,7 @@ mod tests {
629
630
#[test]
631
fn hy_v3_streaming_preserves_prefix_text() {
574
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
632
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
633
let chunks = [
634
"Checking.",
635
"<tool_calls>",
@@ -601,7 +659,7 @@ mod tests {
659
),
660
]);
661
let chunks = split_by_chars(&input, 9);
604
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
662
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
663
664
let output = collect_stream(&mut parser, &chunks);
665
@@ -617,7 +675,7 @@ mod tests {
675
build_tool_calls(&[build_tool_call("get_weather", &[("city", "Beijing")])])
676
);
677
let chunks = split_by_chars(&input, 5);
620
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
678
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
679
680
let output = collect_stream(&mut parser, &chunks);
681
@@ -628,7 +686,7 @@ mod tests {
686
687
#[test]
688
fn hy_v3_streaming_does_not_emit_incomplete_tool_call() {
631
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
689
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
690
let mut output = ToolParserOutput::default();
691
692
parser
@@ -644,7 +702,7 @@ mod tests {
702
703
#[test]
704
fn hy_v3_finish_fails_incomplete_tool_call() {
647
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
705
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
706
parser.parse_chunk("<tool_calls><tool_call>get_weather<tool_sep>").unwrap();
707
708
let error = parser.finish().unwrap_err();
@@ -655,7 +713,7 @@ mod tests {
713
714
#[test]
715
fn hy_v3_malformed_tool_call_fails_fast() {
658
- let mut parser = HyV3ToolParser::new(&test_tools(), "");
716
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V3);
717
let error = parser
718
.parse_complete(
719
"<tool_calls><tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>Beijing</tool_call></tool_calls>",
@@ -664,4 +722,78 @@ mod tests {
722
723
assert!(error.to_report_string().starts_with("tool parser parsing failed:"));
724
}
725
+
726
+ #[test]
727
+ fn hy_v4_parse_complete_extracts_compact_calls_with_and_without_arguments() {
728
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V4);
729
+ let output = parser
730
+ .parse_complete(
731
+ "<tool_calls><tool_call>get_weather<arg_key>city</arg_key><arg_value>Beijing</arg_value><arg_key>date</arg_key><arg_value>2026-03-30</arg_value></tool_call><tool_call>get_current_date</tool_call></tool_calls>",
732
+ )
733
+ .unwrap();
734
+
735
+ expect![[r#"
736
+ ToolParserOutput {
737
+ events: [
738
+ ToolCall(
739
+ ToolCallDelta {
740
+ tool_index: 0,
741
+ name: Some(
742
+ "get_weather",
743
+ ),
744
+ arguments: "{\"city\":\"Beijing\",\"date\":\"2026-03-30\"}",
745
+ },
746
+ ),
747
+ ToolCall(
748
+ ToolCallDelta {
749
+ tool_index: 1,
750
+ name: Some(
751
+ "get_current_date",
752
+ ),
753
+ arguments: "{}",
754
+ },
755
+ ),
756
+ ],
757
+ }
758
+ "#]]
759
+ .assert_debug_eq(&output);
760
+ }
761
+
762
+ #[test]
763
+ fn hy_v4_streaming_handles_markers_split_across_chunks() {
764
+ let input = "prefix<tool_calls><tool_call>get_weather<arg_key>city</arg_key><arg_value>上海</arg_value></tool_call><tool_call>get_current_date</tool_call></tool_calls>";
765
+ let chunks = split_by_chars(input, 7);
766
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V4);
767
+
768
+ let output = collect_stream(&mut parser, &chunks);
769
+
770
+ assert_eq!(output.normal_text(), "prefix");
771
+ assert_eq!(output.calls().len(), 2);
772
+ assert_eq!(parsed_arguments(&output, 0), json!({ "city": "上海" }));
773
+ assert_eq!(output.calls()[1].name.as_deref(), Some("get_current_date"));
774
+ assert_eq!(parsed_arguments(&output, 1), json!({}));
775
+ }
776
+
777
+ #[test]
778
+ fn hy_v4_finish_reports_its_dialect_for_incomplete_calls() {
779
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V4);
780
+ parser.parse_chunk("<tool_calls><tool_call>get_weather").unwrap();
781
+
782
+ let error = parser.finish().unwrap_err();
783
+
784
+ expect!["tool parser parsing failed: incomplete HY4 tool call"]
785
+ .assert_eq(&error.to_report_string());
786
+ }
787
+
788
+ #[test]
789
+ fn hy_v4_malformed_argument_pair_fails_fast() {
790
+ let mut parser = HyToolParser::new(&test_tools(), "", HyDialect::V4);
791
+ let error = parser
792
+ .parse_complete(
793
+ "<tool_calls><tool_call>get_weather<arg_key>city</arg_key>Beijing</tool_call></tool_calls>",
794
+ )
795
+ .unwrap_err();
796
+
797
+ assert!(error.to_report_string().starts_with("tool parser parsing failed:"));
798
+ }
799
}
rust/src/parser/src/tool/hy/structural_tag.rs
renamed
+86
-23
@@ -1,7 +1,7 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-//! Structural-tag grammar for HY3 XML-style tool calls.
4
+//! Structural-tag grammar for HY XML-style tool calls.
5
6
use std::collections::HashSet;
7
use std::sync::Arc;
@@ -12,31 +12,40 @@ use xgrammar_structural_tag::builders::{StructuralTagBuilder, StructuralTagConte
12
use xgrammar_structural_tag::format::{Format, StructuralTag, TagFormat};
13
use xgrammar_structural_tag::tool::{BuilderToolChoice, FunctionToolParam};
14
15
-use super::HyV3ToolMarkers;
15
+use super::{HyDialect, HyToolMarkers};
16
17
-/// HY3 structural-tag builder using tokenizer-specific structural markers.
17
+/// HY structural-tag builder using tokenizer-specific structural markers.
18
#[derive(Debug, Clone)]
19
-pub(super) struct HyV3StructuralTagBuilder {
20
- markers: Arc<HyV3ToolMarkers>,
19
+pub(super) struct HyStructuralTagBuilder {
20
+ markers: Arc<HyToolMarkers>,
21
+ dialect: HyDialect,
22
}
23
23
-impl HyV3StructuralTagBuilder {
24
- pub(super) fn new(markers: Arc<HyV3ToolMarkers>) -> Self {
25
- Self { markers }
24
+impl HyStructuralTagBuilder {
25
+ pub(super) fn new(markers: Arc<HyToolMarkers>, dialect: HyDialect) -> Self {
26
+ Self { markers, dialect }
27
}
28
29
fn argument_pair(&self, key: &str) -> Format {
30
let excludes = self.markers.iter().collect::<Vec<_>>();
30
- Format::sequence(vec![
31
+ let separator = self.dialect.separator();
32
+ let mut elements = vec![
33
Format::const_string(&self.markers.arg_key_start),
34
Format::const_string(key),
35
Format::const_string(&self.markers.arg_key_end),
34
- Format::const_string("\n"),
36
+ ];
37
+ if !separator.is_empty() {
38
+ elements.push(Format::const_string(separator));
39
+ }
40
+ elements.extend([
41
Format::const_string(&self.markers.arg_value_start),
42
Format::any_text_excluding(&excludes),
43
Format::const_string(&self.markers.arg_value_end),
38
- Format::const_string("\n"),
39
- ])
44
+ ]);
45
+ if !separator.is_empty() {
46
+ elements.push(Format::const_string(separator));
47
+ }
48
+ Format::sequence(elements)
49
}
50
51
fn tool_call(&self, tool: &FunctionToolParam) -> TagFormat {
@@ -56,14 +65,21 @@ impl HyV3StructuralTagBuilder {
65
}
66
67
let content = if elements.is_empty() {
59
- Format::any_text()
68
+ match self.dialect {
69
+ HyDialect::V3 => Format::any_text(),
70
+ HyDialect::V4 => Format::const_string(""),
71
+ }
72
} else {
73
Format::sequence(elements)
74
};
75
+ let tool_sep = self.markers.tool_sep.as_deref().unwrap_or_default();
76
TagFormat::new(
77
format!(
65
- "{}{}{}\n",
66
- self.markers.tool_call_start, tool.function.name, self.markers.tool_sep
78
+ "{}{}{}{}",
79
+ self.markers.tool_call_start,
80
+ tool.function.name,
81
+ tool_sep,
82
+ self.dialect.separator(),
83
),
84
content,
85
self.markers.tool_call_end.clone(),
@@ -72,15 +88,16 @@ impl HyV3StructuralTagBuilder {
88
89
fn tool_calls(&self, tools: &[FunctionToolParam], choice: BuilderToolChoice) -> Format {
90
let mut calls = tools.iter().map(|tool| self.tool_call(tool)).collect::<Vec<_>>();
75
- let begin = format!("{}\n", self.markers.tool_calls_start);
76
- let end = format!("\n{}", self.markers.tool_calls_end);
91
+ let separator = self.dialect.separator();
92
+ let begin = format!("{}{separator}", self.markers.tool_calls_start);
93
+ let end = format!("{separator}{}", self.markers.tool_calls_end);
94
95
match choice {
96
BuilderToolChoice::Auto if calls.is_empty() => Format::any_text(),
97
BuilderToolChoice::Auto => {
98
let outer = TagFormat::new(
99
begin,
83
- Format::tags_with_separator(calls, "\n", true, false),
100
+ Format::tags_with_separator(calls, separator, true, false),
101
end,
102
);
103
Format::triggered_tags(&[&self.markers.tool_calls_start], vec![outer])
@@ -92,14 +109,14 @@ impl HyV3StructuralTagBuilder {
109
]),
110
BuilderToolChoice::Required => Format::sequence(vec![
111
Format::const_string(begin),
95
- Format::tags_with_separator(calls, "\n", true, false),
112
+ Format::tags_with_separator(calls, separator, true, false),
113
Format::const_string(end),
114
]),
115
}
116
}
117
}
118
102
-impl StructuralTagBuilder for HyV3StructuralTagBuilder {
119
+impl StructuralTagBuilder for HyStructuralTagBuilder {
120
fn build(&self, ctx: StructuralTagContext<'_>) -> Result<StructuralTag> {
121
Ok(StructuralTag::new(
122
self.tool_calls(ctx.function_tools, ctx.tool_choice),
@@ -152,8 +169,8 @@ mod tests {
169
FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
170
};
171
155
- use super::HyV3StructuralTagBuilder;
156
- use crate::tool::HyV3ToolMarkers;
172
+ use super::HyStructuralTagBuilder;
173
+ use crate::tool::{HyDialect, HyToolMarkers};
174
175
fn tool(name: &str, parameters: Value) -> ToolParam {
176
ToolParam::Function(FunctionToolParam::new(
@@ -165,9 +182,18 @@ mod tests {
182
suffix: &str,
183
tools: &[ToolParam],
184
choice: ToolChoice,
185
+ ) -> xgrammar_structural_tag::format::StructuralTag {
186
+ build_for_dialect(HyDialect::V3, suffix, tools, choice)
187
+ }
188
+
189
+ fn build_for_dialect(
190
+ dialect: HyDialect,
191
+ suffix: &str,
192
+ tools: &[ToolParam],
193
+ choice: ToolChoice,
194
) -> xgrammar_structural_tag::format::StructuralTag {
195
build_structural_tag(
170
- HyV3StructuralTagBuilder::new(Arc::new(HyV3ToolMarkers::new(suffix))),
196
+ HyStructuralTagBuilder::new(Arc::new(HyToolMarkers::new(suffix, dialect)), dialect),
197
tools,
198
choice,
199
StructuralTagOptions::default().with_reasoning(false),
@@ -196,6 +222,25 @@ mod tests {
222
expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls:opensource>\n"},{"type":"tags_with_separator","tags":[{"begin":"<tool_call:opensource>get_weather<tool_sep:opensource>\n","content":{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"city"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<tool_sep:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"]},{"type":"const_string","value":"</arg_value:opensource>"},{"type":"const_string","value":"\n"}]},{"type":"star","content":{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"days"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"\n"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<tool_sep:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"]},{"type":"const_string","value":"</arg_value:opensource>"},{"type":"const_string","value":"\n"}]}}]},"end":"</tool_call:opensource>"}],"separator":"\n","at_least_one":true,"stop_after_first":false},{"type":"const_string","value":"\n</tool_calls:opensource>"}]}}"#]].assert_eq(&tag.to_json_string().unwrap());
223
}
224
225
+ #[test]
226
+ fn required_uses_suffixed_compact_hy4_skeleton_and_bounded_values() {
227
+ let tag = build_for_dialect(
228
+ HyDialect::V4,
229
+ ":opensource",
230
+ &[tool(
231
+ "get_weather",
232
+ json!({
233
+ "type": "object",
234
+ "properties": { "city": { "type": "string" } },
235
+ "required": ["city"]
236
+ }),
237
+ )],
238
+ ToolChoice::required(),
239
+ );
240
+
241
+ expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls:opensource>"},{"type":"tags_with_separator","tags":[{"begin":"<tool_call:opensource>get_weather","content":{"type":"sequence","elements":[{"type":"sequence","elements":[{"type":"const_string","value":"<arg_key:opensource>"},{"type":"const_string","value":"city"},{"type":"const_string","value":"</arg_key:opensource>"},{"type":"const_string","value":"<arg_value:opensource>"},{"type":"any_text","excludes":["<tool_calls:opensource>","</tool_calls:opensource>","<tool_call:opensource>","</tool_call:opensource>","<arg_key:opensource>","</arg_key:opensource>","<arg_value:opensource>","</arg_value:opensource>"]},{"type":"const_string","value":"</arg_value:opensource>"}]}]},"end":"</tool_call:opensource>"}],"separator":"","at_least_one":true,"stop_after_first":false},{"type":"const_string","value":"</tool_calls:opensource>"}]}}"#]].assert_eq(&tag.to_json_string().unwrap());
242
+ }
243
+
244
#[test]
245
fn optional_only_schema_keeps_declared_key_alternatives() {
246
let tag = build(
@@ -235,6 +280,24 @@ mod tests {
280
expect![[r#"{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"const_string","value":"<tool_calls>\n"},{"type":"tag","begin":"<tool_call>lookup<tool_sep>\n","content":{"type":"any_text","excludes":[]},"end":"</tool_call>"},{"type":"const_string","value":"\n</tool_calls>"}]}}"#]].assert_eq(&forced);
281
}
282
283
+ #[test]
284
+ fn hy_v4_zero_argument_tool_has_empty_compact_body() {
285
+ let tag = build_for_dialect(
286
+ HyDialect::V4,
287
+ "",
288
+ &[tool(
289
+ "get_current_date",
290
+ json!({ "type": "object", "properties": {} }),
291
+ )],
292
+ ToolChoice::function("get_current_date"),
293
+ );
294
+ let value = serde_json::to_value(tag).unwrap();
295
+
296
+ expect![[r#"{"type":"tag","begin":"<tool_call>get_current_date","content":{"type":"const_string","value":""},"end":"</tool_call>"}"#]].assert_eq(
297
+ &serde_json::to_string(&value["format"]["elements"][1]).unwrap(),
298
+ );
299
+ }
300
+
301
#[test]
302
fn missing_required_property_is_still_emitted() {
303
let tag = build(
rust/src/parser/src/tool/mod.rs
+2
-2
@@ -8,7 +8,7 @@ pub(crate) mod error;
8
mod deepseek_dsml;
9
pub(crate) mod deepseek_json;
10
mod glm_xml;
11
-mod hy_v3;
11
+mod hy;
12
pub(crate) mod json;
13
mod kimi_k2;
14
mod minimax_m2;
@@ -24,7 +24,7 @@ pub use deepseek_dsml::{DeepSeekV4ToolParser, DeepSeekV32ToolParser};
24
pub use deepseek_json::{DeepSeekV3ToolParser, DeepSeekV31ToolParser};
25
pub use error::{Result, ToolParserError};
26
pub use glm_xml::{Glm45MoeToolParser, Glm47MoeToolParser};
27
-pub(crate) use hy_v3::{HyV3ToolMarkers, HyV3ToolParser};
27
+pub(crate) use hy::{HyDialect, HyToolMarkers, HyToolParser};
28
pub use json::{
29
Granite4ToolParser, HermesToolParser, Internlm2ToolParser, Llama3JsonToolParser,
30
MistralToolParser, Phi4MiniJsonToolParser, Qwen3XmlToolParser,
rust/src/parser/src/unified/hy/hy_v3.rs
renamed
+9
-39
@@ -1,20 +1,12 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-use vllm_tokenizer::{DynTokenizer, Tokenizer};
4
+use vllm_tokenizer::DynTokenizer;
5
6
-use super::{CombinedParser, Result, UnifiedParser, UnifiedParserOutput, token_id};
7
-use crate::reasoning::HyV3ReasoningParser;
8
-use crate::tool::{HyV3ToolMarkers, HyV3ToolParser, StructuralTagBuilder, Tool};
9
-
10
-const HY_V3_MARKER_STEMS: &[&str] = &[
11
- "think",
12
- "tool_calls",
13
- "tool_call",
14
- "tool_sep",
15
- "arg_key",
16
- "arg_value",
17
-];
6
+use super::detect_hy_token_suffix;
7
+use crate::reasoning::HyReasoningParser;
8
+use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, StructuralTagBuilder, Tool};
9
+use crate::unified::{CombinedParser, Result, UnifiedParser, UnifiedParserOutput, token_id};
10
11
/// Unified reasoning and tool parser for HY3 output.
12
pub struct HyV3UnifiedParser {
@@ -24,14 +16,14 @@ pub struct HyV3UnifiedParser {
16
impl HyV3UnifiedParser {
17
/// Create a HY3 parser using the suffix encoded in tokenizer added tokens.
18
pub fn new(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Self> {
27
- let suffix = detect_token_suffix(tokenizer.as_ref());
28
- let markers = HyV3ToolMarkers::new(&suffix);
19
+ let suffix = detect_hy_token_suffix(tokenizer.as_ref());
20
+ let markers = HyToolMarkers::new(&suffix, HyDialect::V3);
21
for marker in markers.iter() {
22
token_id(tokenizer.as_ref(), marker)?;
23
}
24
33
- let reasoning = HyV3ReasoningParser::new(tokenizer, &suffix)?;
34
- let tool = HyV3ToolParser::new(tools, &suffix);
25
+ let reasoning = HyReasoningParser::new(tokenizer, &suffix)?;
26
+ let tool = HyToolParser::new(tools, &suffix, HyDialect::V3);
27
Ok(Self {
28
inner: CombinedParser::new(Some(Box::new(reasoning)), Some(Box::new(tool))),
29
})
@@ -75,28 +67,6 @@ impl UnifiedParser for HyV3UnifiedParser {
67
}
68
}
69
78
-/// Detect the HY3 structural-token suffix from tokenizer added vocabulary.
79
-fn detect_token_suffix(tokenizer: &dyn Tokenizer) -> String {
80
- tokenizer
81
- .added_vocab()
82
- .iter()
83
- .filter_map(|(token, id)| marker_suffix(token).map(|suffix| (*id, suffix)))
84
- .min_by_key(|(id, _)| *id)
85
- .map(|(_, suffix)| suffix.to_string())
86
- .unwrap_or_default()
87
-}
88
-
89
-/// Extract the suffix from one opening or closing HY3 structural token.
90
-fn marker_suffix(token: &str) -> Option<&str> {
91
- let body = token.strip_prefix('<')?.strip_suffix('>')?;
92
- let body = body.strip_prefix('/').unwrap_or(body);
93
-
94
- HY_V3_MARKER_STEMS.iter().find_map(|stem| {
95
- let suffix = body.strip_prefix(stem)?;
96
- (suffix.is_empty() || suffix.starts_with(':')).then_some(suffix)
97
- })
98
-}
99
-
70
#[cfg(test)]
71
mod tests {
72
use std::sync::Arc;
rust/src/parser/src/unified/hy/hy_v4.rs
new
+194
@@ -0,0 +1,194 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use vllm_tokenizer::DynTokenizer;
5
+
6
+use super::detect_hy_token_suffix;
7
+use crate::reasoning::HyReasoningParser;
8
+use crate::tool::{HyDialect, HyToolMarkers, HyToolParser, StructuralTagBuilder, Tool};
9
+use crate::unified::{CombinedParser, Result, UnifiedParser, UnifiedParserOutput, token_id};
10
+
11
+/// Unified reasoning and tool parser for HY4 output.
12
+pub struct HyV4UnifiedParser {
13
+ inner: CombinedParser,
14
+}
15
+
16
+impl HyV4UnifiedParser {
17
+ /// Create a HY4 parser using the suffix encoded in tokenizer added tokens.
18
+ pub fn new(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Self> {
19
+ let suffix = detect_hy_token_suffix(tokenizer.as_ref());
20
+ let markers = HyToolMarkers::new(&suffix, HyDialect::V4);
21
+ for marker in markers.iter() {
22
+ token_id(tokenizer.as_ref(), marker)?;
23
+ }
24
+
25
+ let reasoning = HyReasoningParser::new(tokenizer, &suffix)?;
26
+ let tool = HyToolParser::new(tools, &suffix, HyDialect::V4);
27
+ Ok(Self {
28
+ inner: CombinedParser::new(Some(Box::new(reasoning)), Some(Box::new(tool))),
29
+ })
30
+ }
31
+}
32
+
33
+impl UnifiedParser for HyV4UnifiedParser {
34
+ fn create(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Box<dyn UnifiedParser>>
35
+ where
36
+ Self: Sized + 'static,
37
+ {
38
+ Self::new(tools, tokenizer).map(|parser| Box::new(parser) as Box<dyn UnifiedParser>)
39
+ }
40
+
41
+ fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
42
+ self.inner.initialize(prompt_token_ids)
43
+ }
44
+
45
+ fn preserve_special_tokens(&self) -> bool {
46
+ self.inner.preserve_special_tokens()
47
+ }
48
+
49
+ fn structural_tag_builder(&self) -> Option<&dyn StructuralTagBuilder> {
50
+ self.inner.structural_tag_builder()
51
+ }
52
+
53
+ fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
54
+ self.inner.tool_call_id(tool_index)
55
+ }
56
+
57
+ fn parse_into(&mut self, delta: &str, output: &mut UnifiedParserOutput) -> Result<()> {
58
+ self.inner.parse_into(delta, output)
59
+ }
60
+
61
+ fn finish(&mut self) -> Result<UnifiedParserOutput> {
62
+ self.inner.finish()
63
+ }
64
+
65
+ fn reset(&mut self) -> String {
66
+ self.inner.reset()
67
+ }
68
+}
69
+
70
+#[cfg(test)]
71
+mod tests {
72
+ use std::sync::Arc;
73
+
74
+ use serde_json::json;
75
+ use vllm_tokenizer::{Tokenizer, test_utils::TestTokenizer};
76
+ use xgrammar_structural_tag::builders::StructuralTagOptions;
77
+ use xgrammar_structural_tag::{
78
+ FunctionDefinition, FunctionToolParam, ToolChoice, ToolParam, build_structural_tag,
79
+ };
80
+
81
+ use super::{HyV4UnifiedParser, UnifiedParser};
82
+ use crate::tool::Tool;
83
+ use crate::unified::{UnifiedParserEvent, UnifiedParserOutput};
84
+
85
+ fn tokenizer() -> TestTokenizer {
86
+ [
87
+ "<think:opensource>",
88
+ "</think:opensource>",
89
+ "<tool_calls:opensource>",
90
+ "</tool_calls:opensource>",
91
+ "<tool_call:opensource>",
92
+ "</tool_call:opensource>",
93
+ "<arg_key:opensource>",
94
+ "</arg_key:opensource>",
95
+ "<arg_value:opensource>",
96
+ "</arg_value:opensource>",
97
+ ]
98
+ .into_iter()
99
+ .enumerate()
100
+ .fold(TestTokenizer::new(), |tokenizer, (index, token)| {
101
+ tokenizer.with_regular_token(token, 1000 + index as u32)
102
+ })
103
+ }
104
+
105
+ fn tools() -> Vec<Tool> {
106
+ vec![
107
+ Tool {
108
+ name: "get_weather".to_string(),
109
+ description: None,
110
+ parameters: json!({
111
+ "type": "object",
112
+ "properties": { "city": { "type": "string" } },
113
+ }),
114
+ strict: None,
115
+ },
116
+ Tool {
117
+ name: "get_current_date".to_string(),
118
+ description: None,
119
+ parameters: json!({ "type": "object", "properties": {} }),
120
+ strict: None,
121
+ },
122
+ ]
123
+ }
124
+
125
+ #[test]
126
+ fn parses_suffixed_reasoning_and_compact_tool_calls_as_one_stream() {
127
+ let tokenizer = Arc::new(tokenizer());
128
+ let think_start_id = tokenizer.token_to_id("<think:opensource>").unwrap();
129
+ let mut parser = HyV4UnifiedParser::new(&tools(), tokenizer).unwrap();
130
+ parser.initialize(&[think_start_id]).unwrap();
131
+
132
+ let chunks = [
133
+ "reasoning</think:open",
134
+ "source>answer<tool_calls:opensource><tool_call:opensource>get_weather",
135
+ "<arg_key:opensource>city</arg_key:opensource>",
136
+ "<arg_value:opensource>Beijing</arg_value:opensource>",
137
+ "</tool_call:opensource><tool_call:opensource>get_current_date",
138
+ "</tool_call:opensource></tool_calls:opensource>",
139
+ ];
140
+ let mut output = UnifiedParserOutput::default();
141
+ for chunk in chunks {
142
+ parser.parse_into(chunk, &mut output).unwrap();
143
+ }
144
+ output.append(parser.finish().unwrap());
145
+
146
+ assert_eq!(
147
+ output.events,
148
+ vec![
149
+ UnifiedParserEvent::Reasoning("reasoning".to_string()),
150
+ UnifiedParserEvent::Text("answer".to_string()),
151
+ UnifiedParserEvent::ToolCall(crate::tool::ToolCallDelta {
152
+ tool_index: 0,
153
+ name: Some("get_weather".to_string()),
154
+ arguments: r#"{"city":"Beijing"}"#.to_string(),
155
+ }),
156
+ UnifiedParserEvent::ToolCall(crate::tool::ToolCallDelta {
157
+ tool_index: 1,
158
+ name: Some("get_current_date".to_string()),
159
+ arguments: "{}".to_string(),
160
+ }),
161
+ ]
162
+ );
163
+ }
164
+
165
+ #[test]
166
+ fn structural_tag_uses_compact_hy4_skeleton() {
167
+ let parser = HyV4UnifiedParser::new(&tools(), Arc::new(tokenizer())).unwrap();
168
+ let structural_tools = [ToolParam::Function(FunctionToolParam::new(
169
+ FunctionDefinition::new("get_weather").with_parameters(json!({
170
+ "type": "object",
171
+ "properties": { "city": { "type": "string" } },
172
+ "required": ["city"]
173
+ })),
174
+ ))];
175
+
176
+ let tag = build_structural_tag(
177
+ parser.structural_tag_builder().unwrap(),
178
+ &structural_tools,
179
+ ToolChoice::required(),
180
+ StructuralTagOptions::default().with_reasoning(false),
181
+ )
182
+ .unwrap()
183
+ .to_json_string()
184
+ .unwrap();
185
+
186
+ assert!(tag.contains("<tool_calls:opensource>"));
187
+ assert!(tag.contains("<tool_call:opensource>get_weather"));
188
+ assert!(tag.contains("<arg_key:opensource>"));
189
+ assert!(tag.contains("</tool_call:opensource>"));
190
+ assert!(tag.contains("</tool_calls:opensource>"));
191
+ assert!(!tag.contains("tool_sep"));
192
+ assert!(!tag.contains(r#"\n"#));
193
+ }
194
+}
rust/src/parser/src/unified/hy/mod.rs
new
+39
@@ -0,0 +1,39 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+mod hy_v3;
5
+mod hy_v4;
6
+
7
+pub use hy_v3::HyV3UnifiedParser;
8
+pub use hy_v4::HyV4UnifiedParser;
9
+
10
+const HY_MARKER_STEMS: &[&str] = &[
11
+ "think",
12
+ "tool_calls",
13
+ "tool_call",
14
+ "tool_sep",
15
+ "arg_key",
16
+ "arg_value",
17
+];
18
+
19
+/// Detect the HY structural-token suffix from tokenizer added vocabulary.
20
+fn detect_hy_token_suffix(tokenizer: &dyn vllm_tokenizer::Tokenizer) -> String {
21
+ tokenizer
22
+ .added_vocab()
23
+ .iter()
24
+ .filter_map(|(token, id)| hy_marker_suffix(token).map(|suffix| (*id, suffix)))
25
+ .min_by_key(|(id, _)| *id)
26
+ .map(|(_, suffix)| suffix.to_string())
27
+ .unwrap_or_default()
28
+}
29
+
30
+/// Extract the suffix from one opening or closing HY structural token.
31
+fn hy_marker_suffix(token: &str) -> Option<&str> {
32
+ let body = token.strip_prefix('<')?.strip_suffix('>')?;
33
+ let body = body.strip_prefix('/').unwrap_or(body);
34
+
35
+ HY_MARKER_STEMS.iter().find_map(|stem| {
36
+ let suffix = body.strip_prefix(stem)?;
37
+ (suffix.is_empty() || suffix.starts_with(':')).then_some(suffix)
38
+ })
39
+}
rust/src/parser/src/unified/mod.rs
+2
-2
@@ -5,13 +5,13 @@
5
6
mod combined;
7
mod gemma4;
8
-mod hy_v3;
8
+mod hy;
9
mod inkling;
10
mod kimi_k3;
11
12
pub use combined::CombinedParser;
13
pub use gemma4::Gemma4UnifiedParser;
14
-pub use hy_v3::HyV3UnifiedParser;
14
+pub use hy::{HyV3UnifiedParser, HyV4UnifiedParser};
15
pub use inkling::InklingUnifiedParser;
16
pub use kimi_k3::{KimiK3StructuralTagBuilder, KimiK3UnifiedParser};
17
use thiserror::Error;
tests/models/registry.py
+7
@@ -331,6 +331,8 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
331
"HunYuanDenseV1ForCausalLM": _HfExamplesInfo("tencent/Hunyuan-7B-Instruct"),
332
"HunYuanMoEV1ForCausalLM": _HfExamplesInfo("tencent/Hunyuan-A13B-Instruct"),
333
"HYV3ForCausalLM": _HfExamplesInfo("tencent/Hy3-preview", trust_remote_code=True),
334
+ # Internal checkpoint, not publicly available yet.
335
+ "HYV4ForCausalLM": _HfExamplesInfo("tencent/Hy4-preview", trust_remote_code=True),
336
"HyperCLOVAXForCausalLM": _HfExamplesInfo(
337
"naver-hyperclovax/HyperCLOVAX-SEED-Think-14B",
338
min_transformers_version="5.9.0",
@@ -1723,6 +1725,11 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1725
"tencent/Hy3-preview",
1726
speculative_model="tencent/Hy3-preview",
1727
),
1728
+ # Internal checkpoint, not publicly available yet.
1729
+ "HYV4MTPModel": _HfExamplesInfo(
1730
+ "tencent/Hy4-preview",
1731
+ speculative_model="tencent/Hy4-preview",
1732
+ ),
1733
"InklingMTPModel": _HfExamplesInfo(
1734
"thinkingmachines/Inkling-NVFP4",
1735
speculative_model="thinkingmachines/Inkling-NVFP4",
tests/models/test_registry.py
+5
@@ -59,6 +59,11 @@ def test_registry_imports(model_arch):
59
):
60
pytest.skip("Dots3 NOTE is only supported on CUDA")
61
62
+ if model_arch in ("HYV4ForCausalLM", "HYV4MTPModel") and not (
63
+ current_platform.is_cuda()
64
+ ):
65
+ pytest.skip("HY V4 is only supported on CUDA")
66
+
67
# Ensure all model classes can be imported successfully
68
model_cls = ModelRegistry._try_load_model_cls(model_arch)
69
assert model_cls is not None
tests/models/utils.py
+5
@@ -552,6 +552,11 @@ def dummy_hf_overrides(
552
553
text_config.update(update_dict)
554
555
+ # Keep per-layer metadata consistent with the reduced layer count.
556
+ # HY V4 uses this to decide which sparse-attention indexers are shared.
557
+ if indexer_types := getattr(text_config, "indexer_types", None):
558
+ text_config.update({"indexer_types": indexer_types[:num_hidden_layers]})
559
+
560
# Update n_layers and moe configs for Moondream3 model
561
if model_arch in ("Moondream3ForCausalLM", "HfMoondream"):
562
text_config.update(
vllm/config/speculative.py
+8
@@ -57,6 +57,7 @@ MTPModelTypes = Literal[
57
"pangu_ultra_moe_mtp",
58
"step3p5_mtp",
59
"hy_v3_mtp",
60
+ "hy_v4_mtp",
61
"gemma4_mtp",
62
"inkling_mtp",
63
]
@@ -917,6 +918,13 @@ class SpeculativeConfig:
918
{"n_predict": n_predict, "architectures": ["HYV3MTPModel"]}
919
)
920
921
+ if hf_config.model_type == "hy_v4":
922
+ hf_config.model_type = "hy_v4_mtp"
923
+ n_predict = getattr(hf_config, "num_nextn_predict_layers", None)
924
+ hf_config.update(
925
+ {"n_predict": n_predict, "architectures": ["HYV4MTPModel"]}
926
+ )
927
+
928
if hf_config.model_type in ("inkling_mm_model", "inkling_model"):
929
mtp_config = getattr(hf_config, "mtp_config", None) or {}
930
hf_config = getattr(hf_config, "text_config", hf_config)
vllm/config/vllm.py
+2
@@ -79,6 +79,8 @@ DEFAULT_BREAKABLE_CUDAGRAPH_ARCHITECTURES = frozenset(
79
"DeepseekV4ForCausalLM",
80
"DeepSeekV4MTPModel",
81
"GlmMoeDsaForCausalLM",
82
+ "HYV4ForCausalLM",
83
+ "HYV4MTPModel",
84
"InklingForCausalLM",
85
"InklingForConditionalGeneration",
86
"KimiK3ForConditionalGeneration",
vllm/envs.py
+10
@@ -321,6 +321,7 @@ if TYPE_CHECKING:
321
VLLM_GPU_NIC_PCIE_MAPPING: str = ""
322
VLLM_NIC_SELECTION_VARS: str = ""
323
VLLM_PREFIX_CACHE_RETENTION_INTERVAL: int | None = None
324
+ VLLM_ENABLE_HPC_OPS: bool = False
325
326
327
def get_default_cache_root():
@@ -2155,6 +2156,15 @@ environment_variables: dict[str, Callable[[], Any]] = {
2156
# Each entry is VAR_NAME or VAR_NAME:<suffix> (suffix appended to
2157
# RDMA device name). Must be set together with VLLM_GPU_NIC_PCIE_MAPPING.
2158
"VLLM_NIC_SELECTION_VARS": lambda: os.getenv("VLLM_NIC_SELECTION_VARS", ""),
2159
+ # If set to 1, enable the HPC fused kernels (requires the hpc package
2160
+ # (.so) and an sm100/sm103 device). Covers:
2161
+ # * the HY V4 iHC ops -- each of the eager HYV4HCPreLayer /
2162
+ # HYV4HCPostLayer / HYV4HCHeadLayer bodies becomes one kernel launch;
2163
+ # * the gated-MLA output gating (attn_out * sigmoid(gate projection)),
2164
+ # fused into gated_mla_gemm; elementwise gating only.
2165
+ # Each op additionally checks its own shape / dtype constraints and falls
2166
+ # back to the eager path when they do not hold.
2167
+ "VLLM_ENABLE_HPC_OPS": lambda: bool(int(os.getenv("VLLM_ENABLE_HPC_OPS", "0"))),
2168
}
2169
2170
vllm/model_executor/layers/fused_moe/experts/trtllm_fp8_moe.py
+20
-5
@@ -79,12 +79,27 @@ class TrtLlmFp8ExpertsBase:
79
activation_key,
80
activation_format,
81
)
82
- if not supported or moe_config.num_experts <= 2048:
82
+ if not supported:
83
return supported, reason
84
- return False, (
85
- "FlashInfer TRTLLM routing supports at most 2048 experts, "
86
- f"but got {moe_config.num_experts}"
87
- )
84
+ # FlashInfer accepts gemm1_alpha/beta/clamp_limit only for MXFP8; the
85
+ # DeepSeek-FP8 block-scale kernel rejects them and the per-tensor kernel
86
+ # has no clamp at all, so a model-requested SwiGLU clamp cannot be
87
+ # honored on those paths.
88
+ if (
89
+ moe_config.swiglu_limit is not None
90
+ or moe_config.swiglu_alpha is not None
91
+ or moe_config.swiglu_beta is not None
92
+ ) and (weight_key, activation_key) != (kMxfp8Static, kMxfp8Dynamic):
93
+ return False, (
94
+ "the TRTLLM FP8 kernels apply the SwiGLU alpha/beta/clamp "
95
+ "parameters only for MXFP8 weights"
96
+ )
97
+ if moe_config.num_experts > 2048:
98
+ return False, (
99
+ "FlashInfer TRTLLM routing supports at most 2048 experts, "
100
+ f"but got {moe_config.num_experts}"
101
+ )
102
+ return True, None
103
104
def __init__(
105
self,
vllm/model_executor/layers/hpc/__init__.py
+14
@@ -1,11 +1,25 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from vllm.model_executor.layers.hpc.gated_mla import (
5
+ hpc_gated_mla_gemm,
6
+ hpc_gated_mla_supported,
7
+)
8
+from vllm.model_executor.layers.hpc.hpc_ihc import (
9
+ HpcIHCHead,
10
+ HpcIHCPost,
11
+ HpcIHCPre,
12
+)
13
from vllm.model_executor.layers.hpc.hpc_module import HpcModule
14
from vllm.model_executor.layers.hpc.rope_norm import HpcRopeNorm, QkNormPolicy
15
16
__all__ = [
17
+ "HpcIHCHead",
18
+ "HpcIHCPost",
19
+ "HpcIHCPre",
20
"HpcModule",
21
"HpcRopeNorm",
22
"QkNormPolicy",
23
+ "hpc_gated_mla_gemm",
24
+ "hpc_gated_mla_supported",
25
]
vllm/model_executor/layers/hpc/gated_mla.py
new
+113
@@ -0,0 +1,113 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Enablement gate for the HPC fused gated-MLA GEMM.
4
+
5
+``hpc.gated_mla_gemm`` fuses the MLA output gating -- the gate projection, the
6
+sigmoid and the elementwise product -- into a single kernel launch, replacing
7
+the eager ``attn_out * sigmoid(hidden_states @ weight.T)`` sequence.
8
+
9
+Kernel constraints:
10
+ - Requires VLLM_ENABLE_HPC_OPS=1
11
+ - Requires the hpc package (.so) built for the current arch
12
+ - Only sm100 / sm103 (compute capability 100, 103)
13
+ - All three operands must be bfloat16 and contiguous
14
+ - The gate width must be a multiple of 256
15
+ - Only elementwise gating (headwise has no fused form)
16
+"""
17
+
18
+import torch
19
+
20
+from vllm import envs
21
+from vllm.logger import init_logger
22
+from vllm.utils.hpc import has_hpc
23
+
24
+logger = init_logger(__name__)
25
+
26
+# hpc.gated_mla_gemm fuses `attn_out * sigmoid(x @ weight.T)` into one launch.
27
+# It is built for sm100/sm103 and needs all three operands bf16 + contiguous,
28
+# with the gate width a multiple of 256.
29
+_HPC_GATED_GEMM_N_ALIGN = 256
30
+_HPC_GATED_GEMM_CAPABILITIES: frozenset[int] = frozenset({100, 103})
31
+
32
+
33
+def hpc_gated_mla_supported(
34
+ gating_type: str | None, attn_output_gate: torch.nn.Module | None
35
+) -> bool:
36
+ """Gate for running the MLA output gating through hpc.gated_mla_gemm."""
37
+ if not envs.VLLM_ENABLE_HPC_OPS:
38
+ return False
39
+
40
+ if attn_output_gate is None:
41
+ return False
42
+
43
+ if not has_hpc():
44
+ logger.warning_once(
45
+ "HPC gated MLA disabled: 'hpc' package is not installed. "
46
+ "Please install the HPC library (.so) to enable the fused kernel."
47
+ )
48
+ return False
49
+
50
+ from vllm.platforms import current_platform
51
+
52
+ if not current_platform.is_cuda():
53
+ logger.warning_once("HPC gated MLA disabled: only CUDA is supported.")
54
+ return False
55
+
56
+ capability = current_platform.get_device_capability()
57
+ if capability is None or capability.to_int() not in _HPC_GATED_GEMM_CAPABILITIES:
58
+ logger.warning_once(
59
+ "HPC gated MLA disabled: compute capability %s not in %s.",
60
+ capability,
61
+ _HPC_GATED_GEMM_CAPABILITIES,
62
+ )
63
+ return False
64
+
65
+ # Headwise gating broadcasts one scalar per head over v_head_dim; the
66
+ # kernel only implements the elementwise product.
67
+ if gating_type != "elementwise":
68
+ logger.warning_once(
69
+ "HPC gated MLA disabled: gating_type '%s' has no fused form.",
70
+ gating_type,
71
+ )
72
+ return False
73
+
74
+ weight = getattr(attn_output_gate, "weight", None)
75
+ if weight is None or weight.dtype != torch.bfloat16:
76
+ logger.warning_once(
77
+ "HPC gated MLA disabled: gate weight dtype is %s, the kernel needs "
78
+ "bfloat16 (keep the layer out of quantization).",
79
+ None if weight is None else weight.dtype,
80
+ )
81
+ return False
82
+
83
+ if weight.shape[0] % _HPC_GATED_GEMM_N_ALIGN != 0:
84
+ logger.warning_once(
85
+ "HPC gated MLA disabled: gate width %d is not a multiple of %d.",
86
+ weight.shape[0],
87
+ _HPC_GATED_GEMM_N_ALIGN,
88
+ )
89
+ return False
90
+
91
+ logger.info_once("HPC gated MLA enabled by set VLLM_ENABLE_HPC_OPS.")
92
+ return True
93
+
94
+
95
+def hpc_gated_mla_gemm(
96
+ x: torch.Tensor,
97
+ weight: torch.Tensor,
98
+ atten_output: torch.Tensor,
99
+) -> torch.Tensor:
100
+ """Fused ``atten_output * sigmoid(x @ weight.T)``.
101
+
102
+ Args:
103
+ x: ``[m, k]`` bfloat16, contiguous.
104
+ weight: ``[n, k]`` bfloat16, contiguous and row-major (the kernel
105
+ transposes it). ``n`` must be a multiple of 256.
106
+ atten_output: ``[m, n]`` bfloat16, contiguous.
107
+
108
+ Returns:
109
+ ``[m, n]`` bfloat16.
110
+ """
111
+ from hpc.gemm import gated_mla_gemm
112
+
113
+ return gated_mla_gemm(x, weight, atten_output)
vllm/model_executor/layers/hpc/hpc_ihc.py
new
+261
@@ -0,0 +1,261 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""HPC fused iHC (independent Hyper-Connections) kernels for HY V4.
4
+
5
+Replaces the eager HYV4HCPreLayer / HYV4HCPostLayer / HYV4HCHeadLayer bodies
6
+with single-kernel HPC implementations:
7
+
8
+ pre : rms square sum + w projection + sigmoid gates + hc-dim weighted sum
9
+ post : y[n, i, :] = H_post[n, i] * x[n, :] + residual[n, i, :]
10
+ head : same as pre but hc_mult projection rows and no H_post output
11
+
12
+The eager path issues 20 / 5 / 15 kernels for pre / post / head; each HPC op is
13
+a single kernel.
14
+
15
+Constraints:
16
+ - Requires VLLM_ENABLE_HPC_OPS=1
17
+ - Requires the hpc package (.so) built for the current arch
18
+ - Only sm100 / sm103 (compute capability 100, 103)
19
+ - Only hc_mult == 4 and hidden_size in {4096, 6144}
20
+ - x / residual must be bfloat16; hc_fn weights stay float32 (the checkpoint
21
+ keeps them out of fp8 quantization via modules_to_not_convert)
22
+
23
+The fused modules own no parameters: they hold references to the weights of the
24
+eager layer they replace, bound in ``process_weights_after_loading`` (invoked
25
+generically for every ``HpcModule`` by the model loader).
26
+
27
+NOTE: The reference implementation also offers a cross-layer post+pre fusion
28
+(``HpcIHCPostPre``) that folds one segment's post into the next segment's pre.
29
+It requires reworking the decoder-layer forward scheduling and is not ported
30
+here. TODO: add it once the decoder dataflow is restructured for it.
31
+"""
32
+
33
+import torch
34
+
35
+from vllm import envs
36
+from vllm.logger import init_logger
37
+from vllm.model_executor.layers.hpc.hpc_module import HpcModule
38
+from vllm.utils.hpc import has_hpc
39
+
40
+logger = init_logger(__name__)
41
+
42
+# Shapes the HPC kernels are instantiated for. Other shapes launch nothing,
43
+# so gate on this rather than relying on a silent no-op.
44
+_SUPPORTED_HIDDEN_SIZES: frozenset[int] = frozenset({4096, 6144})
45
+_SUPPORTED_HC_MULTS: frozenset[int] = frozenset({4})
46
+_SUPPORTED_CAPABILITIES: frozenset[int] = frozenset({100, 103})
47
+
48
+
49
+def _ihc_supported(hc_mult: int, hidden_size: int) -> bool:
50
+ """Shared gate for all three iHC ops."""
51
+ if not envs.VLLM_ENABLE_HPC_OPS:
52
+ return False
53
+
54
+ if not has_hpc():
55
+ logger.warning_once(
56
+ "HPC iHC disabled: 'hpc' package is not installed. "
57
+ "Please install the HPC library (.so) to enable fused kernels."
58
+ )
59
+ return False
60
+
61
+ from vllm.platforms import current_platform
62
+
63
+ if not current_platform.is_cuda():
64
+ logger.warning_once("HPC iHC disabled: only CUDA is supported.")
65
+ return False
66
+
67
+ capability = current_platform.get_device_capability()
68
+ if capability is None or capability.to_int() not in _SUPPORTED_CAPABILITIES:
69
+ logger.warning_once(
70
+ "HPC iHC disabled: compute capability %s not in %s.",
71
+ capability,
72
+ _SUPPORTED_CAPABILITIES,
73
+ )
74
+ return False
75
+
76
+ if hc_mult not in _SUPPORTED_HC_MULTS:
77
+ logger.warning_once(
78
+ "HPC iHC disabled: hc_mult=%d not in %s.", hc_mult, _SUPPORTED_HC_MULTS
79
+ )
80
+ return False
81
+
82
+ if hidden_size not in _SUPPORTED_HIDDEN_SIZES:
83
+ logger.warning_once(
84
+ "HPC iHC disabled: hidden_size=%d not in %s.",
85
+ hidden_size,
86
+ _SUPPORTED_HIDDEN_SIZES,
87
+ )
88
+ return False
89
+
90
+ if envs.VLLM_BATCH_INVARIANT:
91
+ # Batch-invariant mode relies on torch's deterministic reductions; the
92
+ # HPC kernel reduces in a different order, so keep the two regimes
93
+ # apart.
94
+ logger.warning_once(
95
+ "HPC iHC disabled: not supported under VLLM_BATCH_INVARIANT."
96
+ )
97
+ return False
98
+
99
+ logger.info_once("HPC iHC enabled by set VLLM_ENABLE_HPC_OPS.")
100
+ return True
101
+
102
+
103
+class HpcIHCPre(HpcModule):
104
+ """Fused iHC pre block.
105
+
106
+ Computes, in one kernel:
107
+ x_flat = x.flatten(1)
108
+ r = rsqrt(x_flat.square().mean(-1) + rms_norm_eps)
109
+ mixes = (x_flat @ w.T) * r
110
+ H_pre = sigmoid(mixes[:, :hc] * hc_scale[0] + hc_base[:hc]) + hc_eps
111
+ H_post = magnitude * sigmoid(
112
+ mixes[:, hc:] * hc_scale[1] + hc_base[hc:]) + hc_eps
113
+ y = sum_i H_pre[:, i] * x[:, i, :]
114
+
115
+ Args:
116
+ hc_mult: HC expand ratio.
117
+ hidden_size: Model hidden dimension.
118
+ magnitude: H_post multiplier (config.hc_magnitude).
119
+ hc_eps: Epsilon added to both gates (config.hc_eps).
120
+ norm_eps: Epsilon inside the rsqrt (config.rms_norm_eps).
121
+ fallback_op: The eager HYV4HCPreLayer to source weights from.
122
+ norm_owner: Optional RMSNorm that immediately follows this pre block.
123
+ When given, its weight/eps are folded into the kernel so a single
124
+ launch covers pre + RMSNorm (the caller then skips the separate
125
+ layernorm). None keeps the original pre-only behaviour.
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ hc_mult: int,
131
+ hidden_size: int,
132
+ magnitude: float,
133
+ hc_eps: float,
134
+ norm_eps: float,
135
+ fallback_op: torch.nn.Module,
136
+ norm_owner: torch.nn.Module | None = None,
137
+ ) -> None:
138
+ super().__init__()
139
+ self.hc_mult = hc_mult
140
+ self.hidden_size = hidden_size
141
+ self.magnitude = magnitude
142
+ self.hc_eps = hc_eps
143
+ self.norm_eps = norm_eps
144
+ # Stash the owner outside nn.Module's attribute machinery: callers pass
145
+ # their own `self` here, and registering that as a submodule would make
146
+ # the module tree cyclic (state_dict() then hits RecursionError).
147
+ object.__setattr__(self, "_fallback_op", fallback_op)
148
+ # Same anti-cycle idiom for the fused RMSNorm owner (a sibling module).
149
+ object.__setattr__(self, "_norm_owner", norm_owner)
150
+ self.weight = None
151
+ self.hc_scale = None
152
+ self.hc_base = None
153
+ self.rms_weight = None
154
+ self.rms_eps = 0.0
155
+
156
+ @classmethod
157
+ def support(cls, hc_mult: int, hidden_size: int) -> bool:
158
+ return _ihc_supported(hc_mult, hidden_size)
159
+
160
+ def process_weights_after_loading(self, model=None):
161
+ # hc_fn is a ReplicatedLinear whose weight is [2 * hc_mult, hc_mult * d].
162
+ # It stays float32 -- the HPC kernel consumes fp32 weights directly, so
163
+ # there is no conversion (and no precision loss) here.
164
+ owner = self._fallback_op
165
+ self.weight = owner.hc_fn.weight
166
+ self.hc_scale = owner.hc_scale
167
+ self.hc_base = owner.hc_base
168
+ if self._norm_owner is not None:
169
+ self.rms_weight = self._norm_owner.weight
170
+ self.rms_eps = self._norm_owner.variance_epsilon
171
+
172
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
173
+ import hpc
174
+
175
+ return hpc.fuse_ihc_pre(
176
+ x,
177
+ self.weight,
178
+ self.hc_scale,
179
+ self.hc_base,
180
+ self.norm_eps,
181
+ self.hc_eps,
182
+ self.magnitude,
183
+ self.rms_weight,
184
+ self.rms_eps,
185
+ )
186
+
187
+
188
+class HpcIHCPost(HpcModule):
189
+ """Fused iHC post block: H_post gating plus multi-channel residual add.
190
+
191
+ Unlike mHC there is no comb matrix, so each output channel only needs its
192
+ own residual channel and the whole thing is one fused multiply-add per
193
+ element.
194
+ """
195
+
196
+ def __init__(self, hc_mult: int, hidden_size: int) -> None:
197
+ super().__init__()
198
+ self.hc_mult = hc_mult
199
+ self.hidden_size = hidden_size
200
+
201
+ @classmethod
202
+ def support(cls, hc_mult: int, hidden_size: int) -> bool:
203
+ return _ihc_supported(hc_mult, hidden_size)
204
+
205
+ def forward(
206
+ self, x: torch.Tensor, residual: torch.Tensor, H_post: torch.Tensor
207
+ ) -> torch.Tensor:
208
+ import hpc
209
+
210
+ return hpc.fuse_ihc_post(x, residual, H_post)
211
+
212
+
213
+class HpcIHCHead(HpcModule):
214
+ """Fused iHC head block: merge the hc channels back into one hidden state.
215
+
216
+ Same structure as HpcIHCPre but the projection emits only hc_mult gate
217
+ logits and there is no H_post output. Called once per forward, after the
218
+ last decoder layer.
219
+ """
220
+
221
+ def __init__(
222
+ self,
223
+ hc_mult: int,
224
+ hidden_size: int,
225
+ hc_eps: float,
226
+ norm_eps: float,
227
+ fallback_op: torch.nn.Module,
228
+ ) -> None:
229
+ super().__init__()
230
+ self.hc_mult = hc_mult
231
+ self.hidden_size = hidden_size
232
+ self.hc_eps = hc_eps
233
+ self.norm_eps = norm_eps
234
+ # See HpcIHCPre: keep the owner out of the module tree.
235
+ object.__setattr__(self, "_fallback_op", fallback_op)
236
+ self.weight = None
237
+ self.hc_scale = None
238
+ self.hc_base = None
239
+
240
+ @classmethod
241
+ def support(cls, hc_mult: int, hidden_size: int) -> bool:
242
+ return _ihc_supported(hc_mult, hidden_size)
243
+
244
+ def process_weights_after_loading(self, model=None):
245
+ # hc_head_fn weight is [hc_mult, hc_mult * d], float32.
246
+ owner = self._fallback_op
247
+ self.weight = owner.hc_head_fn.weight
248
+ self.hc_scale = owner.hc_head_scale
249
+ self.hc_base = owner.hc_head_base
250
+
251
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
252
+ import hpc
253
+
254
+ return hpc.fuse_ihc_head(
255
+ x,
256
+ self.weight,
257
+ self.hc_scale,
258
+ self.hc_base,
259
+ self.norm_eps,
260
+ self.hc_eps,
261
+ )
vllm/model_executor/layers/logits_processor.py
+7
-1
@@ -15,6 +15,7 @@ from vllm.distributed import (
15
)
16
from vllm.logger import init_logger
17
from vllm.model_executor.custom_op import PluggableLayer
18
+from vllm.model_executor.layers.linear import UnquantizedLinearMethod
19
from vllm.model_executor.layers.vocab_parallel_embedding import (
20
UnquantizedEmbeddingMethod,
21
VocabParallelEmbedding,
@@ -144,7 +145,12 @@ class LogitsProcessor(PluggableLayer):
145
lm_head, hidden_states, bias=embedding_bias
146
)
147
147
- if not isinstance(lm_head.quant_method, UnquantizedEmbeddingMethod):
148
+ # A quant config that excludes lm_head hands out UnquantizedLinearMethod
149
+ # rather than UnquantizedEmbeddingMethod, so accept both: either way the
150
+ # weight is plain and `lm_head.weight` can be cast directly.
151
+ if not isinstance(
152
+ lm_head.quant_method, (UnquantizedEmbeddingMethod, UnquantizedLinearMethod)
153
+ ):
154
raise ValueError(
155
"A head_dtype different from the model dtype is only "
156
"supported for an unquantized lm_head."
vllm/model_executor/models/deepseek_v2.py
+10
-2
@@ -874,12 +874,20 @@ def _try_load_fp8_indexer_wk(
874
# We have both weight and scale: dequantize FP8 to BF16.
875
weight_fp8, scale_inv = entry["weight"], entry["scale"]
876
del buf[layer_prefix]
877
+ if scale_inv.dtype in (torch.uint8, torch.float8_e8m0fnu):
878
+ # MXFP8 checkpoints store power-of-two E8M0 scales, decode them to float.
879
+ scale_inv = scale_inv.view(torch.float8_e8m0fnu).to(torch.float32)
880
if scale_inv.ndim == 1:
881
# Per-channel scale: one scale per row of [out, in]
882
group_shape = GroupShape(1, weight_fp8.shape[1])
883
else:
881
- block_size = weight_fp8.shape[1] // scale_inv.shape[1]
882
- group_shape = GroupShape(block_size, block_size)
884
+ # Derive the block size independently per dim so that per-channel
885
+ # ([out, 1]), MX ([out, in / 32]) and 2D block ([out / 128, in / 128])
886
+ # scale layouts are all handled.
887
+ group_shape = GroupShape(
888
+ weight_fp8.shape[0] // scale_inv.shape[0],
889
+ weight_fp8.shape[1] // scale_inv.shape[1],
890
+ )
891
weight_bf16 = scaled_dequantize(
892
weight_fp8,
893
scale_inv,
vllm/model_executor/models/registry.py
+2
@@ -128,6 +128,7 @@ _TEXT_GENERATION_MODELS = {
128
"GraniteSWAForCausalLM": ("granite", "GraniteForCausalLM"),
129
"HrmTextForCausalLM": ("hrm_text", "HrmTextForCausalLM"),
130
"HYV3ForCausalLM": ("hy_v3", "HYV3ForCausalLM"),
131
+ "HYV4ForCausalLM": ("vllm.models.hy_v4", "HYV4ForCausalLM"),
132
"HCXVisionV2ForCausalLM": ("hyperclovax_vision_v2", "HCXVisionV2ForCausalLM"),
133
"HyperCLOVAXForCausalLM": ("hyperclovax", "HyperCLOVAXForCausalLM"),
134
"InternLM2ForCausalLM": ("internlm2", "InternLM2ForCausalLM"),
@@ -663,6 +664,7 @@ _SPECULATIVE_DECODING_MODELS = {
664
"Qwen3_5MoeMTP": ("qwen3_5_mtp", "Qwen3_5MoeMTP"),
665
"InternS2MobiusMTP": ("interns2_mobius", "InternS2MobiusMTP"),
666
"HYV3MTPModel": ("hy_v3_mtp", "HYV3MTP"),
667
+ "HYV4MTPModel": ("vllm.models.hy_v4", "HYV4MTP"),
668
"KimiK3MTPModel": ("vllm.models.kimi_k3", "KimiK3MTP"),
669
# Temporarily disabled.
670
# # TODO(woosuk): Re-enable this once the MLP Speculator is supported in V1.
vllm/models/hy_v4/__init__.py
new
+36
@@ -0,0 +1,36 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""HY V4 (``hy_v4``) model — hardware-isolated entry point.
4
+
5
+HY V4 combines three architectural pieces:
6
+
7
+- **iHC** (independent Hyper-Connections): the single residual stream is
8
+ replaced by ``hc_mult`` parallel residual channels, gated per sub-block.
9
+- **MLA + lightning indexer**: multi-head latent attention with an optional
10
+ DSA-style sparse top-k selection, plus an output gate and a learnable sink.
11
+- **MoE**: sigmoid-routed experts with a clamped SwiGLU and shared experts.
12
+
13
+The package is organized like `vllm.models.deepseek_v32`: this module is the
14
+only public entry point and dispatches on the current platform, so registry
15
+entries never reach into a platform subpackage.
16
+
17
+Only NVIDIA is supported for now. The port also drops the reference
18
+implementation's HPC/TPCP fusion paths, which depend on infrastructure that
19
+does not exist in this tree.
20
+"""
21
+
22
+from vllm.platforms import current_platform
23
+
24
+if current_platform.is_rocm():
25
+ raise NotImplementedError("hy_v4 does not yet support ROCm.")
26
+elif current_platform.is_xpu():
27
+ raise NotImplementedError("hy_v4 does not yet support XPU.")
28
+else:
29
+ # Covers Blackwell (sm100) and all other CUDA devices.
30
+ from .nvidia.model import HYV4ForCausalLM
31
+ from .nvidia.mtp import HYV4MTP
32
+
33
+__all__ = [
34
+ "HYV4ForCausalLM",
35
+ "HYV4MTP",
36
+]
vllm/models/hy_v4/nvidia/__init__.py
new
+2
@@ -0,0 +1,2 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/models/hy_v4/nvidia/attention.py
new
+765
@@ -0,0 +1,765 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""MLA attention and lightning indexer for HY V4 (NVIDIA).
4
+
5
+NOTE: The reference implementation also offers a fused MLA preprocessing
6
+wrapper, which is not available here, so this port keeps the eager MLA path
7
+with an FP8 indexer cache. TODO: restore the fused preprocessing once the
8
+kernel lands. The HPC gated-MLA GEMM *is* wired up: it replaces the eager
9
+output gating when the hpc package is installed, ``VLLM_ENABLE_HPC_OPS=1``
10
+and the elementwise/dtype/alignment constraints hold.
11
+
12
+The per-head learnable sink is supported through `.flashmla_sparse`, which
13
+subclasses the platform's sparse MLA backend to forward ``attn_sink``.
14
+"""
15
+
16
+from typing import cast
17
+
18
+import regex as re
19
+import torch
20
+from torch import nn
21
+from transformers import DeepseekV2Config, DeepseekV3Config, PretrainedConfig
22
+
23
+from vllm.compilation.breakable_cudagraph import eager_break_during_capture
24
+from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
25
+from vllm.config.cache import CacheDType
26
+from vllm.distributed import get_tensor_model_parallel_world_size
27
+from vllm.logger import init_logger
28
+from vllm.model_executor.layers.attention import MLAAttention
29
+from vllm.model_executor.layers.hpc import hpc_gated_mla_gemm, hpc_gated_mla_supported
30
+from vllm.model_executor.layers.layernorm import LayerNorm, RMSNorm
31
+from vllm.model_executor.layers.linear import (
32
+ ColumnParallelLinear,
33
+ MergedColumnParallelLinear,
34
+ ReplicatedLinear,
35
+ RowParallelLinear,
36
+)
37
+from vllm.model_executor.layers.quantization import QuantizationConfig
38
+from vllm.model_executor.layers.quantization.utils.fp8_utils import (
39
+ per_token_group_quant_fp8,
40
+)
41
+from vllm.model_executor.layers.rotary_embedding import get_rope
42
+from vllm.model_executor.layers.sparse_attn_indexer import SparseAttnIndexer
43
+from vllm.model_executor.models.deepseek_v2 import DeepseekV32IndexerCache
44
+from vllm.platforms import current_platform
45
+from vllm.v1.attention.backend import AttentionBackend, AttentionType
46
+from vllm.v1.attention.selector import get_attn_backend
47
+
48
+logger = init_logger(__name__)
49
+
50
+_SPARSE_LAYER_TYPES = ("sparse_attention", "sparse", "deepseek_sparse_attention")
51
+_WEIGHT_LAYER_INDEX_RE = re.compile(r"(?:^|\.)layers\.(\d+)(?:\.|$)")
52
+
53
+
54
+def compute_skip_topk_layers(config: PretrainedConfig) -> set[int]:
55
+ """Return the backbone layers that reuse a previous layer's top-k indices.
56
+
57
+ A "shared" indexer layer performs sparse attention with the indices computed
58
+ by the closest preceding "full" indexer layer, so it does not build its own
59
+ indexer and its checkpoint indexer weights must be skipped.
60
+
61
+ Args:
62
+ config: The model config.
63
+
64
+ Returns:
65
+ The set of layer indices that share another layer's top-k indices.
66
+
67
+ Raises:
68
+ ValueError: If ``indexer_types`` has the wrong length or an unknown
69
+ entry, or if ``index_topk_freq`` is not a positive integer.
70
+ """
71
+ if not hasattr(config, "index_topk"):
72
+ return set()
73
+
74
+ num_hidden_layers = config.num_hidden_layers
75
+ indexer_types = getattr(config, "indexer_types", None)
76
+ if indexer_types is not None:
77
+ if len(indexer_types) != num_hidden_layers:
78
+ raise ValueError(
79
+ "indexer_types must contain one entry per hidden layer: "
80
+ f"expected {num_hidden_layers}, got {len(indexer_types)}."
81
+ )
82
+ invalid_types = sorted(set(indexer_types) - {"full", "shared"})
83
+ if invalid_types:
84
+ raise ValueError(
85
+ f"indexer_types only supports 'full' and 'shared', got {invalid_types}."
86
+ )
87
+ return {
88
+ layer_idx
89
+ for layer_idx, indexer_type in enumerate(indexer_types)
90
+ if indexer_type == "shared"
91
+ }
92
+
93
+ freq = getattr(config, "index_topk_freq", 1)
94
+ if not isinstance(freq, int) or freq <= 0:
95
+ raise ValueError(f"index_topk_freq must be a positive integer, got {freq!r}.")
96
+ pattern = getattr(config, "index_topk_pattern", None)
97
+ offset = getattr(config, "index_skip_topk_offset", 2)
98
+ skip_layers: set[int] = set()
99
+ for layer_idx in range(num_hidden_layers):
100
+ if pattern is None:
101
+ if max(layer_idx - offset + 1, 0) % freq != 0:
102
+ skip_layers.add(layer_idx)
103
+ elif 0 <= layer_idx < len(pattern) and pattern[layer_idx] == "S":
104
+ skip_layers.add(layer_idx)
105
+ return skip_layers
106
+
107
+
108
+def is_skip_topk_indexer_weight(weight_name: str, skip_topk_layers: set[int]) -> bool:
109
+ """Return whether an indexer weight belongs to a top-k sharing layer.
110
+
111
+ Args:
112
+ weight_name: Checkpoint weight name.
113
+ skip_topk_layers: Result of `compute_skip_topk_layers`.
114
+
115
+ Returns:
116
+ True when the weight is an indexer weight of a layer that has no
117
+ indexer module and therefore must be dropped.
118
+ """
119
+ if ".indexer." not in weight_name or not skip_topk_layers:
120
+ return False
121
+ match = _WEIGHT_LAYER_INDEX_RE.search(weight_name)
122
+ return match is not None and int(match.group(1)) in skip_topk_layers
123
+
124
+
125
+class Indexer(nn.Module):
126
+ """Lightning indexer selecting the top-k tokens for sparse MLA."""
127
+
128
+ def __init__(
129
+ self,
130
+ vllm_config: VllmConfig,
131
+ config: DeepseekV2Config | DeepseekV3Config,
132
+ hidden_size: int,
133
+ q_lora_rank: int,
134
+ quant_config: QuantizationConfig | None,
135
+ cache_config: CacheConfig | None,
136
+ topk_indices_buffer: torch.Tensor | None,
137
+ prefix: str = "",
138
+ ):
139
+ super().__init__()
140
+ self.vllm_config = vllm_config
141
+ self.config = config
142
+ self.quant_config = quant_config
143
+ self.topk_tokens = config.index_topk
144
+ self.n_head = config.index_n_heads
145
+ self.head_dim = config.index_head_dim
146
+ self.rope_dim = config.qk_rope_head_dim
147
+ self.q_lora_rank = q_lora_rank
148
+
149
+ # No tensor parallelism, just replicated.
150
+ self.wq_b = ReplicatedLinear(
151
+ self.q_lora_rank,
152
+ self.head_dim * self.n_head,
153
+ bias=False,
154
+ quant_config=quant_config,
155
+ prefix=f"{prefix}.wq_b",
156
+ )
157
+ # Fused wk + weights_proj: single GEMM producing [head_dim + n_head].
158
+ # FP8 wk weights are upcast to BF16 while loading to keep the fusion.
159
+ self.wk_weights_proj = MergedColumnParallelLinear(
160
+ hidden_size,
161
+ [self.head_dim, self.n_head],
162
+ bias=False,
163
+ quant_config=None,
164
+ disable_tp=True,
165
+ prefix=f"{prefix}.wk_weights_proj",
166
+ )
167
+ self.k_norm = LayerNorm(self.head_dim, eps=1e-6)
168
+ self.softmax_scale = self.head_dim**-0.5
169
+
170
+ self.scale_fmt = "ue8m0"
171
+ self.quant_block_size = 128
172
+ self.topk_indices_buffer = topk_indices_buffer
173
+
174
+ # FP8 naive cache: values in fp8 plus one fp32 scale per
175
+ # ``quant_block_size`` elements.
176
+ assert cache_config is not None, "HYV4 indexer requires cache_config"
177
+ self.k_cache = DeepseekV32IndexerCache(
178
+ head_dim=self.head_dim + self.head_dim // self.quant_block_size * 4,
179
+ dtype=torch.uint8,
180
+ prefix=f"{prefix}.k_cache",
181
+ cache_config=cache_config,
182
+ )
183
+ self.max_model_len = vllm_config.model_config.max_model_len
184
+ self.prefix = prefix
185
+
186
+ from vllm.v1.attention.backends.mla.indexer import get_max_prefill_buffer_size
187
+
188
+ self.max_total_seq_len = get_max_prefill_buffer_size(vllm_config)
189
+ self.indexer_op = SparseAttnIndexer(
190
+ self.k_cache,
191
+ self.quant_block_size,
192
+ self.scale_fmt,
193
+ self.topk_tokens,
194
+ self.head_dim,
195
+ self.max_model_len,
196
+ self.max_total_seq_len,
197
+ self.topk_indices_buffer,
198
+ )
199
+
200
+ def forward(
201
+ self,
202
+ hidden_states: torch.Tensor,
203
+ qr: torch.Tensor,
204
+ positions: torch.Tensor,
205
+ rotary_emb: nn.Module,
206
+ ) -> torch.Tensor:
207
+ hidden_states, q_quant, k, weights = self.prepare_inputs(
208
+ hidden_states, qr, positions, rotary_emb
209
+ )
210
+ return self.indexer_op(hidden_states, q_quant, k, weights)
211
+
212
+ def prepare_inputs(
213
+ self,
214
+ hidden_states: torch.Tensor,
215
+ qr: torch.Tensor,
216
+ positions: torch.Tensor,
217
+ rotary_emb: nn.Module,
218
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
219
+ """Build the quantized query, key and per-head weights of the indexer."""
220
+ q, _ = self.wq_b(qr)
221
+ q = q.view(-1, self.n_head, self.head_dim)
222
+ # Checkpoint (PTM) layout: pe occupies the LAST rope_dim dims.
223
+ q_nope, q_pe = torch.split(
224
+ q, [self.head_dim - self.rope_dim, self.rope_dim], dim=-1
225
+ )
226
+
227
+ kw, _ = self.wk_weights_proj(hidden_states)
228
+ k = kw[:, : self.head_dim]
229
+ weights = kw[:, self.head_dim :]
230
+
231
+ k = self.k_norm(k)
232
+ k_nope, k_pe = torch.split(
233
+ k, [self.head_dim - self.rope_dim, self.rope_dim], dim=-1
234
+ )
235
+
236
+ q_pe, k_pe = rotary_emb(positions, q_pe, k_pe.unsqueeze(1))
237
+ # RoPE (NeoX) can introduce extra leading dims, so flatten back to the
238
+ # token-major shapes.
239
+ q_pe = q_pe.reshape(-1, self.n_head, self.rope_dim)
240
+ k_pe = k_pe.reshape(-1, 1, self.rope_dim)
241
+
242
+ # Reassemble with the original physical layout: no_pe first, pe last.
243
+ q = torch.cat([q_nope, q_pe], dim=-1)
244
+ # ``k_pe`` is [num_tokens, 1, rope_dim] (MQA).
245
+ k = torch.cat([k_nope, k_pe.squeeze(-2)], dim=-1)
246
+
247
+ # Only q is quantized here; k quantization is fused with cache insertion.
248
+ q = q.view(-1, self.head_dim)
249
+ q_fp8, q_scale = per_token_group_quant_fp8(
250
+ q,
251
+ self.quant_block_size,
252
+ column_major_scales=False,
253
+ use_ue8m0=self.scale_fmt is not None,
254
+ )
255
+ q_fp8 = q_fp8.view(-1, self.n_head, self.head_dim)
256
+ q_scale = q_scale.view(-1, self.n_head, 1)
257
+
258
+ weights = (
259
+ weights.unsqueeze(-1) * q_scale * self.softmax_scale * self.n_head**-0.5
260
+ )
261
+ weights = weights.squeeze(-1)
262
+
263
+ return hidden_states, q_fp8, k, weights
264
+
265
+
266
+class HYV4MLAAttention(nn.Module):
267
+ """Multi-head latent attention with optional sparse lightning indexer.
268
+
269
+ Main reference: the DeepSeek-V2 paper and the FlashInfer implementation
270
+ (https://arxiv.org/abs/2405.04434). HY V4 additionally supports an output
271
+ gate (``gated_mla``) and a per-head learnable attention sink.
272
+
273
+ The sink is applied by binding the sink-capable backend from
274
+ `.flashmla_sparse`; if no backend on this platform can consume sinks, the
275
+ weight is still loaded but the bias is disabled with a warning.
276
+ """
277
+
278
+ def __init__(
279
+ self,
280
+ vllm_config: VllmConfig,
281
+ config: PretrainedConfig,
282
+ hidden_size: int,
283
+ num_heads: int,
284
+ qk_nope_head_dim: int,
285
+ qk_rope_head_dim: int,
286
+ v_head_dim: int,
287
+ q_lora_rank: int | None,
288
+ kv_lora_rank: int,
289
+ max_position_embeddings: int = 8192,
290
+ cache_config: CacheConfig | None = None,
291
+ quant_config: QuantizationConfig | None = None,
292
+ prefix: str = "",
293
+ topk_indices_buffer: torch.Tensor | None = None,
294
+ layer_idx: int = 0,
295
+ ) -> None:
296
+ super().__init__()
297
+ self.config = config
298
+ self.hidden_size = hidden_size
299
+ self.qk_nope_head_dim = qk_nope_head_dim
300
+ self.qk_rope_head_dim = qk_rope_head_dim
301
+ self.qk_head_dim = qk_nope_head_dim + qk_rope_head_dim
302
+ self.v_head_dim = v_head_dim
303
+ self.layer_idx = layer_idx
304
+ self.q_lora_rank = q_lora_rank
305
+ self.kv_lora_rank = kv_lora_rank
306
+
307
+ self.num_heads = num_heads
308
+ tp_size = get_tensor_model_parallel_world_size()
309
+ assert num_heads % tp_size == 0
310
+ self.num_local_heads = num_heads // tp_size
311
+
312
+ self.layer_id = int(prefix.split(".")[-2])
313
+ layer_types = getattr(config, "layer_types", None)
314
+ requested_sparse = (
315
+ hasattr(config, "index_topk")
316
+ and layer_types is not None
317
+ and self.layer_id < len(layer_types)
318
+ and layer_types[self.layer_id] in _SPARSE_LAYER_TYPES
319
+ )
320
+ # Only actual sparse layers may share another layer's top-k indices.
321
+ self.skip_topk = requested_sparse and self.layer_id in compute_skip_topk_layers(
322
+ config
323
+ )
324
+ # The skip pattern only governs backbone layers. MTP/nextn layers
325
+ # (layer_id >= num_hidden_layers) always build a full indexer: they
326
+ # compute indices at draft step 0 and toggle at runtime.
327
+ num_hidden_layers = getattr(config, "num_hidden_layers", None)
328
+ is_mtp_layer = (
329
+ num_hidden_layers is not None and self.layer_id >= num_hidden_layers
330
+ )
331
+ self.create_indexer = requested_sparse and (not self.skip_topk or is_mtp_layer)
332
+ self.is_sparse = requested_sparse
333
+
334
+ # Do not silently degrade sparse layers into dense attention. Probe the
335
+ # sparse MLA backend directly and fail fast with the real error.
336
+ kv_cache_dtype = cache_config.cache_dtype if cache_config else "auto"
337
+ if self.is_sparse:
338
+ try:
339
+ get_attn_backend(
340
+ head_size=self.kv_lora_rank + self.qk_rope_head_dim,
341
+ dtype=torch.get_default_dtype(),
342
+ kv_cache_dtype=kv_cache_dtype,
343
+ use_mla=True,
344
+ has_sink=False,
345
+ use_sparse=True,
346
+ num_heads=self.num_local_heads,
347
+ )
348
+ except Exception as exc:
349
+ raise RuntimeError(
350
+ "HYV4 sparse attention was requested, but no valid sparse MLA "
351
+ "backend is available for current runtime/config. "
352
+ "Refusing to fall back to dense attention."
353
+ ) from exc
354
+
355
+ self.scaling = self.qk_head_dim**-0.5
356
+ self.max_position_embeddings = max_position_embeddings
357
+ self.fused_qkv_a_proj = None
358
+ self.kv_a_proj_with_mqa = None
359
+ if self.q_lora_rank is not None:
360
+ # ``q_a_proj`` and ``kv_a_proj_with_mqa`` read the same
361
+ # ``hidden_states`` and are both TP-replicated, so they run as one
362
+ # GEMM. The checkpoint keeps them separate; `load_weights` merges
363
+ # them through ``stacked_params_mapping``.
364
+ self.fused_qkv_a_proj = MergedColumnParallelLinear(
365
+ self.hidden_size,
366
+ [self.q_lora_rank, self.kv_lora_rank + self.qk_rope_head_dim],
367
+ bias=False,
368
+ quant_config=quant_config,
369
+ prefix=f"{prefix}.fused_qkv_a_proj",
370
+ disable_tp=True,
371
+ )
372
+ else:
373
+ self.kv_a_proj_with_mqa = ReplicatedLinear(
374
+ self.hidden_size,
375
+ self.kv_lora_rank + self.qk_rope_head_dim,
376
+ bias=False,
377
+ quant_config=quant_config,
378
+ prefix=f"{prefix}.kv_a_proj_with_mqa",
379
+ )
380
+
381
+ self.q_a_layernorm = None
382
+ self.q_b_proj = None
383
+ self.q_proj = None
384
+ if self.q_lora_rank is not None:
385
+ self.q_a_layernorm = RMSNorm(self.q_lora_rank, eps=config.rms_norm_eps)
386
+ self.q_b_proj = ColumnParallelLinear(
387
+ self.q_lora_rank,
388
+ self.num_heads * self.qk_head_dim,
389
+ bias=False,
390
+ quant_config=quant_config,
391
+ prefix=f"{prefix}.q_b_proj",
392
+ )
393
+ else:
394
+ self.q_proj = ColumnParallelLinear(
395
+ self.hidden_size,
396
+ self.num_heads * self.qk_head_dim,
397
+ bias=False,
398
+ quant_config=quant_config,
399
+ prefix=f"{prefix}.q_proj",
400
+ )
401
+ self.kv_a_layernorm = RMSNorm(self.kv_lora_rank, eps=config.rms_norm_eps)
402
+ self.kv_b_proj = ColumnParallelLinear(
403
+ self.kv_lora_rank,
404
+ self.num_heads * (self.qk_nope_head_dim + self.v_head_dim),
405
+ bias=False,
406
+ quant_config=quant_config,
407
+ prefix=f"{prefix}.kv_b_proj",
408
+ )
409
+ self.o_proj = RowParallelLinear(
410
+ self.num_heads * self.v_head_dim,
411
+ self.hidden_size,
412
+ bias=False,
413
+ quant_config=quant_config,
414
+ prefix=f"{prefix}.o_proj",
415
+ )
416
+ self.rotary_emb = get_rope(
417
+ qk_rope_head_dim,
418
+ max_position=max_position_embeddings,
419
+ rope_parameters=config.rope_parameters,
420
+ is_neox_style=False,
421
+ )
422
+ self.indexer_rope_emb: nn.Module | None
423
+ self.indexer: Indexer | None
424
+ if self.create_indexer:
425
+ # The checkpoint stores indexer q_pe/k_pe in interleaved
426
+ # (Megatron/PTM) layout, so the indexer must use interleaved RoPE
427
+ # (is_neox_style=False) like the main attention path. Using NeoX
428
+ # here loses the relative-position dependence and corrupts the DSA
429
+ # top-k selection.
430
+ self.indexer_rope_emb = get_rope(
431
+ qk_rope_head_dim,
432
+ max_position=max_position_embeddings,
433
+ rope_parameters=config.rope_parameters,
434
+ is_neox_style=False,
435
+ )
436
+ # The indexer projects its queries from the MLA q_lora activations,
437
+ # so a sparse layer requires a query down-projection.
438
+ assert q_lora_rank is not None, (
439
+ "HYV4 sparse attention requires q_lora_rank to be set"
440
+ )
441
+ self.indexer = Indexer(
442
+ vllm_config,
443
+ config,
444
+ hidden_size,
445
+ q_lora_rank,
446
+ quant_config,
447
+ cache_config,
448
+ topk_indices_buffer,
449
+ f"{prefix}.indexer",
450
+ )
451
+ else:
452
+ self.indexer_rope_emb = None
453
+ self.indexer = None
454
+
455
+ self.gated_mla = bool(getattr(config, "gated_mla", False))
456
+ self.linear_gate: ColumnParallelLinear | None
457
+ if self.gated_mla:
458
+ if config.gating_type == "headwise":
459
+ self.gate_projection_size_per_head = 1
460
+ elif config.gating_type == "elementwise":
461
+ self.gate_projection_size_per_head = self.v_head_dim
462
+ else:
463
+ raise ValueError(f"Unknown gating type: {config.gating_type}")
464
+ self.linear_gate = ColumnParallelLinear(
465
+ self.hidden_size,
466
+ self.num_heads * self.gate_projection_size_per_head,
467
+ bias=False,
468
+ quant_config=quant_config,
469
+ prefix=f"{prefix}.linear_gate",
470
+ )
471
+ self.use_hpc_gated_mla = hpc_gated_mla_supported(
472
+ config.gating_type, self.linear_gate
473
+ )
474
+ else:
475
+ self.linear_gate = None
476
+ self.use_hpc_gated_mla = False
477
+ self.prefix = prefix
478
+
479
+ # Per-head learnable attention sink. Created BEFORE ``MLAAttention`` so
480
+ # it can be forwarded as the ``sinks`` impl kwarg. The parameter always
481
+ # holds the local TP shard.
482
+ self.learnable_sink = bool(getattr(config, "learnable_sink", False))
483
+ sinks = None
484
+ sink_backend: type[AttentionBackend] | None = None
485
+ if self.learnable_sink:
486
+ sink_backend = self._resolve_sink_backend(kv_cache_dtype)
487
+ enable_sink = sink_backend is not None
488
+ self.learnable_sink_param = nn.Parameter(
489
+ torch.empty(
490
+ self.num_local_heads,
491
+ # The kernels require fp32 sinks; the disabled path keeps
492
+ # the checkpoint dtype since the value is never consumed.
493
+ dtype=torch.float32 if enable_sink else torch.bfloat16,
494
+ )
495
+ )
496
+ if enable_sink:
497
+ sinks = self.learnable_sink_param
498
+ self._force_sparse_mqa()
499
+
500
+ extra_impl_args = {} if sinks is None else {"sinks": sinks}
501
+ self.mla_attn = MLAAttention(
502
+ num_heads=self.num_local_heads,
503
+ scale=self.scaling,
504
+ qk_nope_head_dim=self.qk_nope_head_dim,
505
+ qk_rope_head_dim=self.qk_rope_head_dim,
506
+ v_head_dim=self.v_head_dim,
507
+ q_lora_rank=self.q_lora_rank,
508
+ kv_lora_rank=self.kv_lora_rank,
509
+ cache_config=cache_config,
510
+ quant_config=quant_config,
511
+ prefix=f"{prefix}.attn",
512
+ kv_b_proj=self.kv_b_proj,
513
+ use_sparse=self.is_sparse,
514
+ indexer=self.indexer,
515
+ topk_indices_buffer=topk_indices_buffer,
516
+ attn_backend=sink_backend,
517
+ **extra_impl_args,
518
+ )
519
+
520
+ @property
521
+ def topk_indices_buffer(self) -> torch.Tensor | None:
522
+ """The sparse top-k index buffer this layer reads from.
523
+
524
+ The real consumer is ``mla_attn.impl``, an ``AttentionImpl`` rather than
525
+ an ``nn.Module``, so a plain ``named_modules()`` walk cannot reach it.
526
+ The MTP proposer rebinds the draft layers onto the target model's buffer
527
+ through such a walk, so expose it here: without this the draft attention
528
+ would keep reading its own buffer while the indices it needs are written
529
+ to the target's.
530
+ """
531
+ return getattr(self.mla_attn.impl, "topk_indices_buffer", None)
532
+
533
+ @topk_indices_buffer.setter
534
+ def topk_indices_buffer(self, buffer: torch.Tensor) -> None:
535
+ self.mla_attn.impl.topk_indices_buffer = buffer # type: ignore[attr-defined]
536
+
537
+ def _resolve_sink_backend(
538
+ self, kv_cache_dtype: str
539
+ ) -> type[AttentionBackend] | None:
540
+ """Return an MLA backend that can apply this layer's learnable sink.
541
+
542
+ The sink is part of the architecture, so a backend that cannot apply it
543
+ changes the model's output. Resolution order:
544
+
545
+ 1. If the backend the selector would pick already advertises
546
+ `supports_sink`, keep it — this also honours an explicit
547
+ ``--attention-backend`` choice.
548
+ 2. Otherwise fall back to the sink-capable ``FLASHMLA_SPARSE`` subclass
549
+ in `.flashmla_sparse`, whose kernels accept ``attn_sink``, provided
550
+ it validates against the current runtime configuration.
551
+ 3. Otherwise give up on the bias rather than failing the load.
552
+
553
+ Args:
554
+ kv_cache_dtype: The layer's KV cache dtype string.
555
+
556
+ Returns:
557
+ The backend class to bind, or None when no sink-capable backend is
558
+ available; the caller then loads the sink weight but disables the
559
+ bias.
560
+ """
561
+ head_size = self.kv_lora_rank + self.qk_rope_head_dim
562
+ dtype = torch.get_default_dtype()
563
+ try:
564
+ selected_cls = get_attn_backend(
565
+ head_size=head_size,
566
+ dtype=dtype,
567
+ kv_cache_dtype=kv_cache_dtype,
568
+ use_mla=True,
569
+ use_sparse=self.is_sparse,
570
+ num_heads=self.num_local_heads,
571
+ )
572
+ except Exception as exc:
573
+ # Stringify before logging: warning_once dedupes on the arguments,
574
+ # and a fresh exception object per layer would defeat it.
575
+ logger.warning_once(
576
+ "HYV4 failed to select an MLA backend for the learnable sink "
577
+ "(%s); the sink parameter is loaded but the sink bias is "
578
+ "disabled.",
579
+ str(exc),
580
+ )
581
+ return None
582
+
583
+ if selected_cls.supports_sink():
584
+ return selected_cls
585
+
586
+ from .flashmla_sparse import HYV4FlashMLASparseBackend
587
+
588
+ # Mirror how the selector derives the configuration-dependent inputs so
589
+ # this check accepts exactly what the backend would accept at runtime.
590
+ capability = current_platform.get_device_capability()
591
+ if capability is None:
592
+ logger.warning_once(
593
+ "HYV4 learnable sink is unavailable: the device compute "
594
+ "capability is unknown. The sink parameter is loaded but the "
595
+ "sink bias is disabled."
596
+ )
597
+ return None
598
+ cache_config = get_current_vllm_config().cache_config
599
+ block_size = (
600
+ cache_config.block_size
601
+ if cache_config is not None and cache_config.user_specified_block_size
602
+ else None
603
+ )
604
+ invalid_reasons = HYV4FlashMLASparseBackend.validate_configuration(
605
+ head_size=head_size,
606
+ dtype=dtype,
607
+ kv_cache_dtype=cast(CacheDType, kv_cache_dtype),
608
+ block_size=block_size,
609
+ use_mla=True,
610
+ has_sink=True,
611
+ use_sparse=self.is_sparse,
612
+ use_mm_prefix=False,
613
+ use_per_head_quant_scales=False,
614
+ device_capability=capability,
615
+ attn_type=AttentionType.DECODER,
616
+ )
617
+ if invalid_reasons:
618
+ logger.warning_once(
619
+ "HYV4 learnable sink is unavailable: the selected backend %s "
620
+ "cannot apply sinks and the sink-capable FLASHMLA_SPARSE path "
621
+ "is invalid here (%s). The sink parameter is loaded but the "
622
+ "sink bias is disabled.",
623
+ selected_cls.get_name(),
624
+ ", ".join(invalid_reasons),
625
+ )
626
+ return None
627
+
628
+ logger.info_once(
629
+ "HYV4 learnable sink enabled: using the sink-capable "
630
+ "FLASHMLA_SPARSE impl instead of %s, which cannot apply sinks.",
631
+ selected_cls.get_name(),
632
+ )
633
+ return HYV4FlashMLASparseBackend
634
+
635
+ def _force_sparse_mqa(self) -> None:
636
+ """Keep every token on the sink-capable sparse MQA path.
637
+
638
+ ``_resolve_sink_backend`` only binds the backend that serves decode.
639
+ ``MLAAttention`` additionally routes short prefills to a separate dense
640
+ MLA prefill backend, and none of those accept ``attn_sink``, so prefill
641
+ would silently drop the sink while decode applies it. Such a partially
642
+ applied sink is not the trained architecture and corrupts the output, so
643
+ opt out of the dense split instead.
644
+
645
+ Prefills up to ``index_topk`` keep every token inside the sparse top-k,
646
+ making the sparse path numerically equivalent to the dense one apart from
647
+ also applying the sink.
648
+ """
649
+ attention_config = get_current_vllm_config().attention_config
650
+ if attention_config.sparse_mla_force_mqa:
651
+ return
652
+ attention_config.sparse_mla_force_mqa = True
653
+ logger.info_once(
654
+ "HYV4 learnable sink enabled: forcing sparse MQA for prefill too, "
655
+ "as the dense MLA prefill backends cannot apply sinks."
656
+ )
657
+
658
+ def forward(
659
+ self,
660
+ positions: torch.Tensor,
661
+ hidden_states: torch.Tensor,
662
+ llama_4_scaling: torch.Tensor | None = None,
663
+ ) -> torch.Tensor:
664
+ q_c = None
665
+ if self.q_lora_rank is not None:
666
+ assert self.fused_qkv_a_proj is not None
667
+ assert self.q_a_layernorm is not None
668
+ assert self.q_b_proj is not None
669
+ qkv_lora = self.fused_qkv_a_proj(hidden_states)[0]
670
+ q_c, kv_lora = qkv_lora.split(
671
+ [self.q_lora_rank, self.kv_lora_rank + self.qk_rope_head_dim],
672
+ dim=-1,
673
+ )
674
+ q_c = self.q_a_layernorm(q_c)
675
+ q = self.q_b_proj(q_c)[0]
676
+ else:
677
+ assert self.q_proj is not None
678
+ assert self.kv_a_proj_with_mqa is not None
679
+ q = self.q_proj(hidden_states)[0]
680
+ kv_lora = self.kv_a_proj_with_mqa(hidden_states)[0]
681
+
682
+ kv_c, k_pe = kv_lora.split([self.kv_lora_rank, self.qk_rope_head_dim], dim=-1)
683
+ kv_c_normed = self.kv_a_layernorm(kv_c)
684
+
685
+ q = q.view(-1, self.num_local_heads, self.qk_head_dim)
686
+ # Add a head dim of 1 to k_pe.
687
+ k_pe = k_pe.unsqueeze(1)
688
+ q[..., self.qk_nope_head_dim :], k_pe = self.rotary_emb(
689
+ positions, q[..., self.qk_nope_head_dim :], k_pe
690
+ )
691
+
692
+ if llama_4_scaling is not None:
693
+ q *= llama_4_scaling
694
+
695
+ output_shape = (
696
+ hidden_states.shape[0],
697
+ self.num_local_heads * self.v_head_dim,
698
+ )
699
+ # Single coarse eager break covering the indexer and MLA attention, as
700
+ # the breakable cudagraph contract requires: everything that reads
701
+ # per-batch metadata runs in one eager segment, so no tensor has to stay
702
+ # alive across a capture-segment boundary.
703
+ attn_out = torch.empty(
704
+ output_shape, dtype=hidden_states.dtype, device=hidden_states.device
705
+ )
706
+ self._indexer_and_attn(
707
+ hidden_states, q_c, positions, q, kv_c_normed, k_pe, attn_out
708
+ )
709
+
710
+ if self.gated_mla and self.linear_gate is not None:
711
+ if self.use_hpc_gated_mla:
712
+ # Projection, sigmoid and the product in one launch. The gate
713
+ # is column-parallel and unbiased, so its local weight shard
714
+ # maps straight onto the local attn_out columns.
715
+ assert hidden_states.is_contiguous() and attn_out.is_contiguous()
716
+ attn_out = hpc_gated_mla_gemm(
717
+ hidden_states,
718
+ self.linear_gate.weight,
719
+ attn_out,
720
+ )
721
+ else:
722
+ gate_score = self.linear_gate(hidden_states)[0]
723
+ if self.config.gating_type == "headwise":
724
+ gate_score = gate_score.unsqueeze(-1)
725
+ attn_out = attn_out.reshape(
726
+ *attn_out.shape[:-1], -1, self.v_head_dim
727
+ )
728
+ attn_out = attn_out * torch.sigmoid(gate_score)
729
+ attn_out = attn_out.reshape(*attn_out.shape[:-2], -1)
730
+ else:
731
+ attn_out = attn_out * torch.sigmoid(gate_score)
732
+
733
+ out, _ = self.o_proj(attn_out)
734
+ return out
735
+
736
+ @eager_break_during_capture
737
+ def _indexer_and_attn(
738
+ self,
739
+ hidden_states: torch.Tensor,
740
+ q_c: torch.Tensor | None,
741
+ positions: torch.Tensor,
742
+ q: torch.Tensor,
743
+ kv_c_normed: torch.Tensor,
744
+ k_pe: torch.Tensor,
745
+ out: torch.Tensor, # [num_tokens, heads * v_head_dim], written in place
746
+ ) -> None:
747
+ """Run the lightning indexer and MLA attention in one eager segment.
748
+
749
+ Both read per-batch attention metadata, so under the breakable cudagraph
750
+ they must not be captured. Keeping them in a single break (instead of one
751
+ break each) also means the attention inputs never have to survive a
752
+ capture-segment boundary. The nested ``sparse_attn_indexer`` and
753
+ ``unified_mla_attention_with_output`` breaks short-circuit here, since
754
+ the capture is no longer active inside an eager segment.
755
+ """
756
+ if self.indexer is not None and self.is_sparse and not self.skip_topk:
757
+ self.indexer(hidden_states, q_c, positions, self.indexer_rope_emb)
758
+ out.copy_(
759
+ self.mla_attn(
760
+ q,
761
+ kv_c_normed,
762
+ k_pe,
763
+ output_shape=out.shape,
764
+ )
765
+ )
vllm/models/hy_v4/nvidia/flashmla_sparse.py
new
+283
@@ -0,0 +1,283 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Sink-capable FlashMLA sparse backend for HY V4 (NVIDIA).
4
+
5
+HY V4 adds a per-head learnable attention sink on top of sparse MLA. The
6
+vendored FlashMLA kernels already accept an ``attn_sink`` argument, but vLLM's
7
+shared ``FLASHMLA_SPARSE`` backend neither advertises sink support nor forwards
8
+the tensor, so the bias would be silently dropped.
9
+
10
+This module supplies the missing wiring inside the model package, mirroring how
11
+`vllm.models.deepseek_v4.nvidia.flashinfer_sparse` hands ``sinks`` to the
12
+FlashInfer sparse MLA kernels: subclass the platform backend, declare
13
+`supports_sink`, and thread the sink into every kernel call.
14
+
15
+The subclass intentionally keeps the inherited ``get_name()``
16
+(``"FLASHMLA_SPARSE"``). Several shared code paths key off that exact string —
17
+`_canonicalize_sparse_mla_kv_cache_dtype` promotes a quantized KV cache to
18
+``fp8_ds_mla`` for it, and `FlashMLASparseImpl` asserts that layout — so a new
19
+name would silently change KV cache behaviour. Only``supports_sink`` and the
20
+two kernel wrappers differ from the parent.
21
+"""
22
+
23
+from typing import TYPE_CHECKING
24
+
25
+import torch
26
+
27
+from vllm.logger import init_logger
28
+from vllm.v1.attention.backends.mla.flashmla_sparse import (
29
+ FlashMLASparseBackend,
30
+ FlashMLASparseImpl,
31
+ FlashMLASparseMetadata,
32
+)
33
+from vllm.v1.attention.ops.flashmla import (
34
+ flash_mla_sparse_fwd,
35
+ flash_mla_with_kvcache,
36
+)
37
+
38
+if TYPE_CHECKING:
39
+ from vllm.model_executor.models.deepseek_v2 import Indexer
40
+
41
+logger = init_logger(__name__)
42
+
43
+
44
+class HYV4FlashMLASparseImpl(FlashMLASparseImpl):
45
+ """FlashMLA sparse impl that applies HY V4's per-head learnable sink.
46
+
47
+ The sink enters as the ``sinks`` impl kwarg of
48
+ `vllm.model_executor.layers.attention.MLAAttention` and is consumed by the
49
+ FlashMLA kernels, which fold it into the softmax denominator:
50
+ ``out *= exp(lse) / (exp(lse) + exp(sink))``.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ num_heads: int,
56
+ head_size: int,
57
+ scale: float,
58
+ num_kv_heads: int,
59
+ alibi_slopes: list[float] | None,
60
+ sliding_window: int | None,
61
+ kv_cache_dtype: str,
62
+ logits_soft_cap: float | None,
63
+ attn_type: str,
64
+ kv_sharing_target_layer_name: str | None,
65
+ # MLA Specific Arguments
66
+ topk_indices_buffer: torch.Tensor | None = None,
67
+ indexer: "Indexer | None" = None,
68
+ **mla_args,
69
+ ) -> None:
70
+ # ``SparseMLACommonImpl`` takes explicit keyword arguments only, so the
71
+ # sink has to be removed before the base classes see``mla_args``.
72
+ sinks: torch.Tensor | None = mla_args.pop("sinks", None)
73
+ super().__init__(
74
+ num_heads,
75
+ head_size,
76
+ scale,
77
+ num_kv_heads,
78
+ alibi_slopes,
79
+ sliding_window,
80
+ kv_cache_dtype,
81
+ logits_soft_cap,
82
+ attn_type,
83
+ kv_sharing_target_layer_name,
84
+ topk_indices_buffer=topk_indices_buffer,
85
+ indexer=indexer,
86
+ **mla_args,
87
+ )
88
+ self._validate_sinks(sinks, num_heads)
89
+ self.sinks = sinks
90
+
91
+ @staticmethod
92
+ def _validate_sinks(sinks: torch.Tensor | None, num_heads: int) -> None:
93
+ """Reject sink tensors the FlashMLA kernels cannot consume.
94
+
95
+ Args:
96
+ sinks: Candidate sink tensor, or None when the layer has no sink.
97
+ num_heads: Local (TP-sharded) query head count of this layer.
98
+
99
+ Raises:
100
+ ValueError: If the dtype is not float32 or the shape is not
101
+ ``(num_heads,)``.
102
+ """
103
+ if sinks is None:
104
+ return
105
+ if sinks.dtype != torch.float32:
106
+ raise ValueError(
107
+ "HYV4 FlashMLA sparse attention sinks must have dtype "
108
+ f"torch.float32, but got {sinks.dtype}."
109
+ )
110
+ if sinks.ndim != 1 or sinks.shape[0] != num_heads:
111
+ raise ValueError(
112
+ "HYV4 FlashMLA sparse attention sinks must have shape "
113
+ f"({num_heads},), but got {tuple(sinks.shape)}."
114
+ )
115
+
116
+ def _sinks_for_query(
117
+ self,
118
+ q: torch.Tensor,
119
+ head_dim: int,
120
+ kernel_heads: int,
121
+ ) -> torch.Tensor | None:
122
+ """Return the sink laid out for the kernel's query head count.
123
+
124
+ Args:
125
+ q: Query tensor, before any head padding.
126
+ head_dim: Axis of ``q`` holding the query heads.
127
+ kernel_heads: Head count the kernel is invoked with, which may
128
+ exceed the query head count because of padding.
129
+
130
+ Returns:
131
+ The sink tensor padded to ``kernel_heads`` with ``-inf`` (a no-op
132
+ sink) for the padded lanes, or None when the layer has no sink.
133
+
134
+ Raises:
135
+ ValueError: If the sink and query head layouts disagree, or if they
136
+ live on different devices.
137
+ """
138
+ sinks = self.sinks
139
+ if sinks is None:
140
+ return None
141
+
142
+ query_heads = q.shape[head_dim]
143
+ if sinks.shape[0] != query_heads:
144
+ raise ValueError(
145
+ "HYV4 FlashMLA sparse attention sink head count must match the "
146
+ f"runtime query layout: sinks={sinks.shape[0]}, "
147
+ f"query_heads={query_heads}. The sink must use the same "
148
+ "unpadded head layout as the query."
149
+ )
150
+ if sinks.device != q.device:
151
+ raise ValueError(
152
+ "HYV4 FlashMLA sparse attention sinks and query must be on the "
153
+ f"same device, but got sinks={sinks.device}, query={q.device}."
154
+ )
155
+ if kernel_heads < query_heads:
156
+ raise ValueError(
157
+ "HYV4 FlashMLA sparse kernel head count cannot be smaller than "
158
+ f"the runtime query layout: query_heads={query_heads}, "
159
+ f"kernel_heads={kernel_heads}."
160
+ )
161
+ if kernel_heads == query_heads:
162
+ return sinks
163
+
164
+ # Mirror the query padding the kernels require. Reading ``sinks`` here
165
+ # (rather than caching a padded copy at construction time) keeps the
166
+ # values correct for weights loaded after the module is built, and the
167
+ # allocation plus copy are captured in the CUDA graph.
168
+ padded_sinks = sinks.new_full((kernel_heads,), float("-inf"))
169
+ padded_sinks[:query_heads] = sinks
170
+ return padded_sinks
171
+
172
+ def _fp8_flash_mla_kernel(
173
+ self,
174
+ q: torch.Tensor,
175
+ kv_c_and_k_pe_cache: torch.Tensor,
176
+ topk_indices: torch.Tensor,
177
+ kernel_metadata: FlashMLASparseMetadata.FP8KernelMetadata,
178
+ ) -> tuple[torch.Tensor, torch.Tensor]:
179
+ # q shape: (batch, seq_len, num_heads, head_dim)
180
+ actual_num_heads = q.size(2)
181
+ padded_num_heads = self.fp8_decode_padded_heads
182
+ attn_sink = self._sinks_for_query(q, head_dim=2, kernel_heads=padded_num_heads)
183
+
184
+ # Pad query if needed (kernel only supports h_q = 64 or 128)
185
+ if actual_num_heads < padded_num_heads:
186
+ logger.warning_once(
187
+ f"Padding num_heads from {actual_num_heads} to "
188
+ f"{padded_num_heads} for FP8 sparse decode kernel"
189
+ )
190
+ q_padded = q.new_zeros((q.size(0), q.size(1), padded_num_heads, q.size(3)))
191
+ q_padded[:, :, :actual_num_heads, :] = q
192
+ q = q_padded
193
+
194
+ out, lse = flash_mla_with_kvcache(
195
+ q=q,
196
+ k_cache=kv_c_and_k_pe_cache.view(torch.uint8).unsqueeze(-2),
197
+ block_table=kernel_metadata.dummy_block_table,
198
+ head_dim_v=512,
199
+ cache_seqlens=kernel_metadata.cache_lens,
200
+ tile_scheduler_metadata=kernel_metadata.scheduler_metadata,
201
+ is_fp8_kvcache=True,
202
+ indices=topk_indices,
203
+ softmax_scale=self.softmax_scale,
204
+ attn_sink=attn_sink,
205
+ )
206
+
207
+ # Slice output and lse back to actual head count if we padded
208
+ if actual_num_heads < padded_num_heads:
209
+ out = out[:, :, :actual_num_heads, :]
210
+ lse = lse[:, :actual_num_heads, :]
211
+
212
+ return out, lse
213
+
214
+ def _bf16_flash_mla_kernel(
215
+ self,
216
+ q: torch.Tensor,
217
+ kv_c_and_k_pe_cache: torch.Tensor,
218
+ topk_indices: torch.Tensor,
219
+ topk_length: torch.Tensor | None = None,
220
+ actual_num_heads: int | None = None,
221
+ ) -> tuple[torch.Tensor, torch.Tensor]:
222
+ num_tokens = q.shape[0]
223
+ kv_c_and_k_pe_cache = kv_c_and_k_pe_cache.view(
224
+ -1, 1, kv_c_and_k_pe_cache.shape[-1]
225
+ )
226
+
227
+ # NOTE(Chen): kernel requires num_local_head to be a multiple of
228
+ # 64 on hopper and 128 on blackwell. Pad from q's head count, not
229
+ # self.num_heads: under DCP the heads are all-gathered before this.
230
+ if actual_num_heads is None:
231
+ actual_num_heads = q.shape[1]
232
+ padded_num_heads = (
233
+ (actual_num_heads + self.prefill_padding - 1)
234
+ // self.prefill_padding
235
+ * self.prefill_padding
236
+ )
237
+ # q_concat_buffer may already include the kernel-required padding.
238
+ # The sink remains defined only for the real query heads.
239
+ attn_sink = self._sinks_for_query(
240
+ q[:, :actual_num_heads], head_dim=1, kernel_heads=padded_num_heads
241
+ )
242
+
243
+ if q.shape[1] < padded_num_heads:
244
+ logger.warning_once(
245
+ f"Padding num_heads from {actual_num_heads} to "
246
+ f"{padded_num_heads} for BF16 sparse prefill kernel"
247
+ )
248
+ # Zero (not new_empty) the padded lanes: topk_indices is shared by
249
+ # all heads, so the kernel reduces across the head group and NaNs
250
+ # from uninitialized memory would leak into the real heads.
251
+ q_padded = q.new_zeros((q.shape[0], padded_num_heads, q.shape[2]))
252
+ q_padded[:, :actual_num_heads, :] = q
253
+ q = q_padded
254
+
255
+ topk_indices = topk_indices.view(num_tokens, 1, -1)
256
+ output, _, lse = flash_mla_sparse_fwd(
257
+ q,
258
+ kv_c_and_k_pe_cache,
259
+ topk_indices,
260
+ self.softmax_scale,
261
+ attn_sink=attn_sink,
262
+ topk_length=topk_length,
263
+ )
264
+
265
+ output = output[:, :actual_num_heads, :]
266
+ lse = lse[:, :actual_num_heads]
267
+ return output, lse
268
+
269
+
270
+class HYV4FlashMLASparseBackend(FlashMLASparseBackend):
271
+ """``FLASHMLA_SPARSE`` with attention-sink support for HY V4.
272
+
273
+ Keeps the parent's name, metadata and builder; only the impl class and the
274
+ sink capability differ. See the module docstring for why the name is reused.
275
+ """
276
+
277
+ @staticmethod
278
+ def get_impl_cls() -> type[HYV4FlashMLASparseImpl]:
279
+ return HYV4FlashMLASparseImpl
280
+
281
+ @classmethod
282
+ def supports_sink(cls) -> bool:
283
+ return True
vllm/models/hy_v4/nvidia/hc.py
new
+370
@@ -0,0 +1,370 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""iHC (independent Hyper-Connections) layers for HY V4 (NVIDIA).
4
+
5
+iHC replaces the single residual stream of a standard transformer with
6
+``hc_mult`` parallel residual channels. Each decoder sub-block reduces the
7
+channels to one hidden state (``HYV4HCPreLayer``), runs the sub-block, then
8
+scatters the result back over the channels (``HYV4HCPostLayer``). The final
9
+``HYV4HCHeadLayer`` merges the channels before the model's output norm.
10
+
11
+NOTE: Each of the three steps has an optional single-kernel HPC replacement
12
+(``HpcIHCPre`` / ``HpcIHCPost`` / ``HpcIHCHead``). They are only constructed
13
+when the hpc package is installed, ``VLLM_ENABLE_HPC_OPS=1`` and the shape /
14
+device constraints hold; otherwise the eager path below runs unchanged.
15
+TODO: port the cross-layer post+pre fusion (``HpcIHCPostPre``) as well; it
16
+requires restructuring the decoder-layer forward scheduling.
17
+"""
18
+
19
+import torch
20
+from torch import nn
21
+from transformers import PretrainedConfig
22
+
23
+from vllm.model_executor.layers.hpc import HpcIHCHead, HpcIHCPost, HpcIHCPre
24
+from vllm.model_executor.layers.linear import ReplicatedLinear
25
+
26
+
27
+class HYV4HCPreLayer(nn.Module):
28
+ """iHC pre-processing layer (2D-activation adaptation).
29
+
30
+ Steps:
31
+ 1. RMS-normalize the flattened ``[num_tokens, hc * d]`` input.
32
+ 2. Project to the pre/post gating logits.
33
+ 3. Turn the logits into sigmoid gates.
34
+ 4. Reduce over the channel dim with the pre gates.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ config: PretrainedConfig,
40
+ hidden_dim: int,
41
+ hc_mult: int = 4,
42
+ magnitude: float = 2.0,
43
+ init_std: float = 6e-3,
44
+ base_noise_std: float = 0.0,
45
+ hc_eps: float = 1e-6,
46
+ layernorm_epsilon: float = 1e-5,
47
+ prefix: str = "",
48
+ ):
49
+ super().__init__()
50
+ self.config = config
51
+ self.hidden_dim = hidden_dim
52
+ self.hc_mult = hc_mult
53
+ self.magnitude = magnitude
54
+ self.hc_eps = hc_eps
55
+ self.layernorm_epsilon = layernorm_epsilon
56
+ hc_in_dim = hc_mult * hidden_dim
57
+ mix_hc = 2 * hc_mult # pre + post only, no comb
58
+
59
+ self.hc_fn = ReplicatedLinear(
60
+ input_size=hc_in_dim,
61
+ output_size=mix_hc,
62
+ params_dtype=torch.float32,
63
+ bias=False,
64
+ prefix=f"{prefix}.hc_fn",
65
+ )
66
+ self.hc_scale = nn.Parameter(torch.empty(2, dtype=torch.float32))
67
+ self.hc_base = nn.Parameter(torch.empty(mix_hc, dtype=torch.float32))
68
+
69
+ self.reset_parameters(init_std, base_noise_std)
70
+
71
+ # Optional single-kernel HPC replacement for the whole forward below.
72
+ # ``norm_owner`` is left unset: the RMSNorm that follows the pre block
73
+ # lives in the decoder layer and is not folded in yet.
74
+ self.hpc_op: HpcIHCPre | None = None
75
+ if HpcIHCPre.support(hc_mult, hidden_dim):
76
+ self.hpc_op = HpcIHCPre(
77
+ hc_mult=hc_mult,
78
+ hidden_size=hidden_dim,
79
+ magnitude=magnitude,
80
+ hc_eps=hc_eps,
81
+ norm_eps=layernorm_epsilon,
82
+ fallback_op=self,
83
+ )
84
+
85
+ def reset_parameters(self, init_std: float, base_noise_std: float = 0.0) -> None:
86
+ """Initialize the gate scale and per-channel gate bias."""
87
+ del init_std # hc_fn is initialized by ReplicatedLinear
88
+ nn.init.constant_(self.hc_scale, 0.01)
89
+ with torch.no_grad():
90
+ self.hc_base[: self.hc_mult].fill_(
91
+ -torch.log(torch.tensor(self.hc_mult - 1.0, dtype=self.hc_base.dtype))
92
+ )
93
+ self.hc_base[self.hc_mult : 2 * self.hc_mult].fill_(0.0)
94
+ if base_noise_std > 0.0:
95
+ self.hc_base.add_(torch.randn_like(self.hc_base) * base_noise_std)
96
+
97
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
98
+ """Reduce the iHC channels and emit the post gates.
99
+
100
+ Args:
101
+ x: Input of shape ``[num_tokens, hc, d]``.
102
+
103
+ Returns:
104
+ A tuple of the pre-gated reduction ``[num_tokens, d]`` and the post
105
+ gates ``[num_tokens, hc]`` consumed by `HYV4HCPostLayer`.
106
+ """
107
+ if self.hpc_op is not None:
108
+ return self.hpc_op(x)
109
+
110
+ shape = x.size() # [num_tokens, hc, d]
111
+ hc = self.hc_mult
112
+ hc_eps = self.hc_eps
113
+
114
+ x_flat = x.flatten(1).float() # [num_tokens, hc*d]
115
+
116
+ rsqrt = torch.rsqrt(
117
+ x_flat.square().mean(-1, keepdim=True) + self.layernorm_epsilon
118
+ )
119
+ mixes = self.hc_fn(x_flat)[0] * rsqrt # [num_tokens, 2*hc]
120
+
121
+ pre_raw = mixes[..., :hc]
122
+ post_raw = mixes[..., hc : 2 * hc]
123
+
124
+ pre = (
125
+ torch.sigmoid(
126
+ pre_raw * self.hc_scale[0].float() + self.hc_base[:hc].float()
127
+ )
128
+ + hc_eps
129
+ )
130
+ post = (
131
+ self.magnitude
132
+ * torch.sigmoid(
133
+ post_raw * self.hc_scale[1].float() + self.hc_base[hc : 2 * hc].float()
134
+ )
135
+ + hc_eps
136
+ )
137
+
138
+ y = torch.sum(pre.unsqueeze(-1) * x.reshape(shape), dim=1) # [num_tokens, d]
139
+ return y.to(x.dtype), post
140
+
141
+
142
+class HYV4HCPostLayer(nn.Module):
143
+ """iHC post-processing layer (2D-activation adaptation).
144
+
145
+ Applies post-gating to the sub-block output and adds the multi-channel
146
+ residual (no comb mixing)::
147
+
148
+ y[n, i, d] = post[n, i] * x[n, d] + residual[n, i, d]
149
+ """
150
+
151
+ def __init__(self, config: PretrainedConfig):
152
+ super().__init__()
153
+ self.config = config
154
+
155
+ # Optional single-kernel HPC replacement for the whole forward below.
156
+ # Only constructed under enable_ihc (see HYV4HCLayer), so hc_mult is
157
+ # present; getattr keeps this robust if that ever changes.
158
+ self.hpc_op: HpcIHCPost | None = None
159
+ hc_mult = getattr(config, "hc_mult", 0)
160
+ if HpcIHCPost.support(hc_mult, config.hidden_size):
161
+ self.hpc_op = HpcIHCPost(hc_mult=hc_mult, hidden_size=config.hidden_size)
162
+
163
+ def forward(
164
+ self, x: torch.Tensor, residual: torch.Tensor, post: torch.Tensor
165
+ ) -> torch.Tensor:
166
+ """Scatter the sub-block output back onto the iHC channels.
167
+
168
+ Args:
169
+ x: Attention/MLP output of shape ``[num_tokens, d]``.
170
+ residual: Multi-channel residual ``[num_tokens, hc, d]``.
171
+ post: Post gates ``[num_tokens, hc]`` from `HYV4HCPreLayer`.
172
+
173
+ Returns:
174
+ The updated residual channels ``[num_tokens, hc, d]``.
175
+ """
176
+ if self.hpc_op is not None:
177
+ return self.hpc_op(x, residual, post)
178
+
179
+ dtype = x.dtype
180
+ x = x.float()
181
+ residual = residual.float()
182
+ post = post.float()
183
+
184
+ post_gated = post.unsqueeze(-1) * x.unsqueeze(-2) # [num_tokens, hc, d]
185
+ y = post_gated + residual
186
+ return y.to(dtype)
187
+
188
+
189
+class HYV4HCHeadLayer(nn.Module):
190
+ """iHC head layer (2D-activation adaptation).
191
+
192
+ Merges the iHC channels back into a single hidden state before the final
193
+ layer norm, using an RMS-normed projection plus sigmoid-gated reduction.
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ config: PretrainedConfig,
199
+ hidden_size: int,
200
+ hc_mult: int = 4,
201
+ hc_eps: float = 1e-6,
202
+ init_std: float = 6e-3,
203
+ base_noise_std: float = 0.0,
204
+ prefix: str = "",
205
+ ):
206
+ super().__init__()
207
+ self.config = config
208
+ self.hidden_size = hidden_size
209
+ self.hc_mult = hc_mult
210
+ self.hc_eps = hc_eps
211
+ self.hc_head_fn = ReplicatedLinear(
212
+ input_size=hc_mult * hidden_size,
213
+ output_size=hc_mult,
214
+ params_dtype=torch.float32,
215
+ bias=False,
216
+ prefix=f"{prefix}.hc_head_fn",
217
+ )
218
+ self.hc_head_base = nn.Parameter(torch.empty(hc_mult, dtype=torch.float32))
219
+ self.hc_head_scale = nn.Parameter(torch.empty(1, dtype=torch.float32))
220
+
221
+ self.reset_parameters(init_std, base_noise_std)
222
+
223
+ # Optional single-kernel HPC replacement for the whole forward below.
224
+ self.hpc_op: HpcIHCHead | None = None
225
+ if HpcIHCHead.support(hc_mult, hidden_size):
226
+ self.hpc_op = HpcIHCHead(
227
+ hc_mult=hc_mult,
228
+ hidden_size=hidden_size,
229
+ hc_eps=hc_eps,
230
+ norm_eps=config.rms_norm_eps,
231
+ fallback_op=self,
232
+ )
233
+
234
+ def reset_parameters(
235
+ self, init_std: float = 6e-3, base_noise_std: float = 0.0
236
+ ) -> None:
237
+ """Initialize the head gate scale and per-channel gate bias."""
238
+ del init_std # hc_head_fn is initialized by ReplicatedLinear
239
+ nn.init.constant_(self.hc_head_scale, 0.01)
240
+ with torch.no_grad():
241
+ self.hc_head_base.fill_(
242
+ -torch.log(
243
+ torch.tensor(self.hc_mult - 1.0, dtype=self.hc_head_base.dtype)
244
+ )
245
+ )
246
+ if base_noise_std > 0.0:
247
+ self.hc_head_base.add_(
248
+ torch.randn_like(self.hc_head_base) * base_noise_std
249
+ )
250
+
251
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
252
+ """Merge the iHC channels into a single hidden state.
253
+
254
+ Args:
255
+ x: Input of shape ``[num_tokens, hc, d]``.
256
+
257
+ Returns:
258
+ The merged hidden state ``[num_tokens, d]``.
259
+ """
260
+ if self.hpc_op is not None:
261
+ return self.hpc_op(x)
262
+
263
+ shape, x_dtype = x.size(), x.dtype
264
+
265
+ x = x.flatten(1).float() # [num_tokens, hc*d]
266
+ rsqrt = torch.rsqrt(
267
+ x.square().mean(-1, keepdim=True) + self.config.rms_norm_eps
268
+ )
269
+ mixes = self.hc_head_fn(x)[0] * rsqrt # [num_tokens, hc]
270
+ pre = (
271
+ torch.sigmoid(
272
+ mixes * self.hc_head_scale.float() + self.hc_head_base.float()
273
+ )
274
+ + self.hc_eps
275
+ )
276
+ y = torch.sum(pre.unsqueeze(-1) * x.reshape(shape), dim=1) # [num_tokens, d]
277
+ return y.to(x_dtype)
278
+
279
+
280
+class HYV4HCLayer(nn.Module):
281
+ """Wrapper owning one iHC boundary (pre + post) of a decoder sub-block."""
282
+
283
+ def __init__(
284
+ self,
285
+ config: PretrainedConfig,
286
+ layer_idx: int,
287
+ init_std: float = 6e-3,
288
+ base_noise_std: float = 0.0,
289
+ prefix: str = "",
290
+ ):
291
+ super().__init__()
292
+ self.config = config
293
+ self.layer_idx = layer_idx
294
+ self.enable_ihc = getattr(config, "enable_ihc", False)
295
+ if self.enable_ihc:
296
+ self.hc_pre = HYV4HCPreLayer(
297
+ config,
298
+ config.hidden_size,
299
+ config.hc_mult,
300
+ config.hc_magnitude,
301
+ init_std,
302
+ base_noise_std,
303
+ config.hc_eps,
304
+ config.rms_norm_eps,
305
+ prefix=f"{prefix}.hc_pre",
306
+ )
307
+ self.hc_post = HYV4HCPostLayer(config)
308
+
309
+ def prepare_input(self, hidden_states: torch.Tensor) -> torch.Tensor:
310
+ """Normalize the sub-block input to 3D when iHC is enabled."""
311
+ if not self.enable_ihc:
312
+ return hidden_states
313
+ return self._prepare_input_to_3d(hidden_states)
314
+
315
+ def _prepare_input_to_3d(self, hidden_states: torch.Tensor) -> torch.Tensor:
316
+ """Reshape the iHC input to ``[num_tokens, hc, h]``.
317
+
318
+ Accepted inputs are ``[num_tokens, hc, h]`` (no-op), ``[num_tokens, h]``
319
+ (broadcast over the channels) and ``[num_tokens, hc * h]`` (reshape).
320
+ """
321
+ if hidden_states.dim() == 3:
322
+ return hidden_states
323
+ if hidden_states.dim() != 2:
324
+ raise RuntimeError(
325
+ f"HC expects a 2D/3D tensor, got shape={tuple(hidden_states.shape)}"
326
+ )
327
+
328
+ d0, d1 = hidden_states.shape
329
+ h = self.config.hidden_size
330
+ hc = self.config.hc_mult
331
+
332
+ if d1 == h:
333
+ return hidden_states.unsqueeze(1).repeat(1, hc, 1)
334
+
335
+ expected = hc * h
336
+ if d1 == expected:
337
+ return hidden_states.reshape(d0, hc, h)
338
+
339
+ raise RuntimeError(
340
+ f"HC expects last dim to be hidden_size ({h})"
341
+ f"or hc_mult*hidden_size ({expected}), got {d1}. "
342
+ f"hc_mult={hc}, hidden_size={h}."
343
+ )
344
+
345
+ def pre(
346
+ self, hidden_states: torch.Tensor
347
+ ) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor]:
348
+ """Reduce the iHC channels and produce the post gates.
349
+
350
+ Returns:
351
+ A tuple of the reduced hidden states ``[num_tokens, d]``, the post
352
+ gates ``[num_tokens, hc]`` (``None`` when iHC is disabled) and the
353
+ residual (the untouched input).
354
+ """
355
+ if not self.enable_ihc:
356
+ return hidden_states, None, hidden_states
357
+ reduced, post_gates = self.hc_pre(hidden_states)
358
+ return reduced, post_gates, hidden_states
359
+
360
+ def post(
361
+ self,
362
+ output_with_bias: torch.Tensor,
363
+ residual: torch.Tensor,
364
+ post_gates: torch.Tensor | None,
365
+ ) -> torch.Tensor:
366
+ """Apply post-gating and add the residual."""
367
+ if not self.enable_ihc:
368
+ return output_with_bias + residual
369
+ assert post_gates is not None
370
+ return self.hc_post(output_with_bias, residual, post_gates)
vllm/models/hy_v4/nvidia/model.py
new
+716
@@ -0,0 +1,716 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+# Adapted from
5
+# Copyright 2023 The vLLM team.
6
+# Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved.
7
+#
8
+# This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX
9
+# and OPT implementations in this library. It has been modified from its
10
+# original forms to accommodate minor architectural differences compared
11
+# to GPT-NeoX and OPT used by the Meta AI team that trained the model.
12
+#
13
+# Licensed under the Apache License, Version 2.0 (the "License");
14
+# you may not use this file except in compliance with the License.
15
+# You may obtain a copy of the License at
16
+#
17
+# http://www.apache.org/licenses/LICENSE-2.0
18
+#
19
+# Unless required by applicable law or agreed to in writing, software
20
+# distributed under the License is distributed on an "AS IS" BASIS,
21
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22
+# See the License for the specific language governing permissions and
23
+# limitations under the License.
24
+"""Inference-only HY V4 model compatible with HuggingFace weights (NVIDIA)."""
25
+
26
+import typing
27
+from collections.abc import Callable, Iterable, MutableSequence, Sequence
28
+from itertools import islice
29
+
30
+import regex as re
31
+import torch
32
+from torch import nn
33
+from transformers import PretrainedConfig
34
+
35
+from vllm.config import CacheConfig, VllmConfig
36
+from vllm.distributed import (
37
+ get_pp_group,
38
+ get_tensor_model_parallel_rank,
39
+ get_tensor_model_parallel_world_size,
40
+)
41
+from vllm.logger import init_logger
42
+from vllm.model_executor.layers.fused_moe import fused_moe_make_expert_params_mapping
43
+from vllm.model_executor.layers.layernorm import RMSNorm
44
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
45
+from vllm.model_executor.layers.quantization import QuantizationConfig
46
+from vllm.model_executor.layers.vocab_parallel_embedding import (
47
+ ParallelLMHead,
48
+ VocabParallelEmbedding,
49
+)
50
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
51
+from vllm.model_executor.models.deepseek_v2 import _try_load_fp8_indexer_wk
52
+from vllm.model_executor.models.interfaces import SupportsLoRA, SupportsPP
53
+from vllm.model_executor.models.utils import (
54
+ AutoWeightsLoader,
55
+ PPMissingLayer,
56
+ WeightsMapper,
57
+ get_pp_missing_layer_names,
58
+ is_pp_missing_parameter,
59
+ make_empty_intermediate_tensors_factory,
60
+ make_layers,
61
+ maybe_prefix,
62
+)
63
+from vllm.platforms import current_platform
64
+from vllm.sequence import IntermediateTensors
65
+
66
+from .attention import (
67
+ HYV4MLAAttention,
68
+ compute_skip_topk_layers,
69
+ is_skip_topk_indexer_weight,
70
+)
71
+from .hc import HYV4HCHeadLayer, HYV4HCLayer
72
+from .moe import HYV4FeedForward, HYV4MoEFused
73
+
74
+logger = init_logger(__name__)
75
+
76
+
77
+def _normalize_hyv4_config(config: PretrainedConfig) -> PretrainedConfig:
78
+ """Populate the aliases consumed by the shared MoE implementation."""
79
+ config.router_scaling_factor = config.routed_scaling_factor
80
+ config.num_experts = config.n_routed_experts
81
+ config.expert_hidden_dim = config.moe_intermediate_size
82
+ config.num_shared_experts = config.n_shared_experts
83
+ config.route_norm = config.norm_topk_prob
84
+ return config
85
+
86
+
87
+class HYV4DecoderLayer(nn.Module):
88
+ """One HY V4 decoder layer: MLA attention plus a dense or MoE MLP.
89
+
90
+ When``config.enable_ihc`` is set the layer runs on ``hc_mult`` residual
91
+ channels and each sub-block is wrapped by an `HYV4HCLayer` boundary;
92
+ otherwise it uses the standard single-stream residual.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ config: PretrainedConfig,
98
+ vllm_config: VllmConfig,
99
+ cache_config: CacheConfig | None = None,
100
+ quant_config: QuantizationConfig | None = None,
101
+ prefix: str = "",
102
+ topk_indices_buffer: torch.Tensor | None = None,
103
+ ) -> None:
104
+ super().__init__()
105
+ self.hidden_size = config.hidden_size
106
+ self.config = config
107
+ self.enable_ihc = getattr(config, "enable_ihc", False)
108
+ layer_idx = int(prefix.split(".")[-1])
109
+ self.layer_idx = layer_idx
110
+
111
+ max_position_embeddings = getattr(config, "max_position_embeddings", 8192)
112
+ self.self_attn = HYV4MLAAttention(
113
+ vllm_config=vllm_config,
114
+ config=config,
115
+ hidden_size=self.hidden_size,
116
+ num_heads=config.num_attention_heads,
117
+ qk_nope_head_dim=config.qk_nope_head_dim,
118
+ qk_rope_head_dim=config.qk_rope_head_dim,
119
+ v_head_dim=config.v_head_dim,
120
+ q_lora_rank=config.q_lora_rank,
121
+ kv_lora_rank=config.kv_lora_rank,
122
+ max_position_embeddings=max_position_embeddings,
123
+ cache_config=cache_config,
124
+ quant_config=quant_config,
125
+ prefix=f"{prefix}.self_attn",
126
+ layer_idx=layer_idx,
127
+ topk_indices_buffer=topk_indices_buffer,
128
+ )
129
+ self.input_layernorm = RMSNorm(config.hidden_size, config.rms_norm_eps)
130
+ if config.mlp_layer_types[layer_idx] == "dense":
131
+ self.mlp = HYV4FeedForward(
132
+ hidden_size=config.hidden_size,
133
+ intermediate_size=config.intermediate_size,
134
+ hidden_act=config.hidden_act,
135
+ quant_config=quant_config,
136
+ prefix=f"{prefix}.mlp",
137
+ )
138
+ self.block_type = "feedforward"
139
+ else:
140
+ self.mlp = HYV4MoEFused(
141
+ config=config, quant_config=quant_config, prefix=f"{prefix}.mlp"
142
+ )
143
+ self.block_type = "moe"
144
+ self.post_attention_layernorm = RMSNorm(config.hidden_size, config.rms_norm_eps)
145
+ self.hc_attn_layer = HYV4HCLayer(
146
+ config, layer_idx, prefix=f"{prefix}.hc_attn_layer"
147
+ )
148
+ self.hc_mlp_layer = HYV4HCLayer(
149
+ config, layer_idx, prefix=f"{prefix}.hc_mlp_layer"
150
+ )
151
+
152
+ def forward(
153
+ self,
154
+ positions: torch.Tensor,
155
+ hidden_states: torch.Tensor,
156
+ residual: torch.Tensor | None,
157
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
158
+ if self.enable_ihc:
159
+ return self._forward_ihc(positions, hidden_states)
160
+ return self._forward_normal(positions, hidden_states, residual)
161
+
162
+ def _forward_normal(
163
+ self,
164
+ positions: torch.Tensor,
165
+ hidden_states: torch.Tensor,
166
+ residual: torch.Tensor | None,
167
+ ) -> tuple[torch.Tensor, torch.Tensor]:
168
+ """Standard single-residual-stream forward (iHC disabled)."""
169
+ if residual is not None:
170
+ hidden_states = hidden_states + residual
171
+ residual = hidden_states
172
+ hidden_states = self.input_layernorm(hidden_states)
173
+
174
+ hidden_states = self.self_attn(
175
+ positions=positions,
176
+ hidden_states=hidden_states,
177
+ )
178
+
179
+ hidden_states = hidden_states + residual
180
+ residual = hidden_states
181
+ hidden_states = self.post_attention_layernorm(hidden_states)
182
+
183
+ hidden_states = self.mlp(hidden_states)
184
+
185
+ return hidden_states, residual
186
+
187
+ def _forward_ihc(
188
+ self,
189
+ positions: torch.Tensor,
190
+ hidden_states: torch.Tensor,
191
+ ) -> tuple[torch.Tensor, None]:
192
+ """iHC forward: each sub-block reduces and re-scatters the channels."""
193
+ hidden_states = self.hc_attn_layer.prepare_input(hidden_states)
194
+ hidden_states, post_gates, residual = self.hc_attn_layer.pre(hidden_states)
195
+ hidden_states = self.input_layernorm(hidden_states)
196
+ hidden_states = self.self_attn(
197
+ positions=positions,
198
+ hidden_states=hidden_states,
199
+ )
200
+ hidden_states = self.hc_attn_layer.post(hidden_states, residual, post_gates)
201
+
202
+ hidden_states = self.hc_mlp_layer.prepare_input(hidden_states)
203
+ hidden_states, post_gates, residual = self.hc_mlp_layer.pre(hidden_states)
204
+ hidden_states = self.post_attention_layernorm(hidden_states)
205
+ hidden_states = self.mlp(hidden_states)
206
+ hidden_states = self.hc_mlp_layer.post(hidden_states, residual, post_gates)
207
+
208
+ # Under iHC the residual is carried inside hidden_states.
209
+ return hidden_states, None
210
+
211
+
212
+class HYV4Model(nn.Module):
213
+ """HY V4 backbone."""
214
+
215
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
216
+ super().__init__()
217
+
218
+ config = _normalize_hyv4_config(vllm_config.model_config.hf_config)
219
+ cache_config = vllm_config.cache_config
220
+ quant_config = vllm_config.quant_config
221
+ parallel_config = vllm_config.parallel_config
222
+ eplb_config = parallel_config.eplb_config
223
+ self.num_redundant_experts = eplb_config.num_redundant_experts
224
+ self.device = current_platform.device_type
225
+ self.vocab_size = config.vocab_size
226
+ self.is_sparse = hasattr(config, "index_topk")
227
+ if self.is_sparse:
228
+ self.topk_indices_buffer = torch.empty(
229
+ vllm_config.scheduler_config.max_num_batched_tokens,
230
+ config.index_topk,
231
+ dtype=torch.int32,
232
+ device=self.device,
233
+ )
234
+ else:
235
+ self.topk_indices_buffer = None
236
+ self.config = config
237
+ self.quant_config = quant_config
238
+ self.enable_ihc = getattr(config, "enable_ihc", False)
239
+
240
+ if get_pp_group().is_first_rank or (
241
+ self.config.tie_word_embeddings and get_pp_group().is_last_rank
242
+ ):
243
+ self.embed_tokens = VocabParallelEmbedding(
244
+ config.vocab_size,
245
+ config.hidden_size,
246
+ )
247
+ else:
248
+ self.embed_tokens = PPMissingLayer()
249
+ self.start_layer, self.end_layer, self.layers = make_layers(
250
+ config.num_hidden_layers,
251
+ lambda prefix: HYV4DecoderLayer(
252
+ config=config,
253
+ vllm_config=vllm_config,
254
+ cache_config=cache_config,
255
+ quant_config=quant_config,
256
+ prefix=prefix,
257
+ topk_indices_buffer=self.topk_indices_buffer,
258
+ ),
259
+ prefix=f"{prefix}.layers",
260
+ )
261
+ if get_pp_group().is_last_rank:
262
+ self.norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
263
+ else:
264
+ self.norm = PPMissingLayer()
265
+ if self.enable_ihc:
266
+ # iHC head layer: merge the residual channels into one stream.
267
+ if get_pp_group().is_last_rank:
268
+ self.hc_head = HYV4HCHeadLayer(
269
+ config,
270
+ hidden_size=config.hidden_size,
271
+ hc_mult=config.hc_mult,
272
+ hc_eps=config.hc_eps,
273
+ prefix=f"{prefix}.hc_head",
274
+ )
275
+ else:
276
+ self.hc_head = PPMissingLayer()
277
+ self.make_empty_intermediate_tensors = (
278
+ make_empty_intermediate_tensors_factory(
279
+ ["hidden_states"], config.hc_mult * config.hidden_size
280
+ )
281
+ )
282
+ else:
283
+ self.make_empty_intermediate_tensors = (
284
+ make_empty_intermediate_tensors_factory(
285
+ ["hidden_states", "residual"], config.hidden_size
286
+ )
287
+ )
288
+
289
+ # MoE hyperparameters (consumed by EPLB).
290
+ self.expert_weights: MutableSequence[Sequence[torch.Tensor]] = []
291
+ self.num_expert_groups = 1
292
+ self.moe_layers: list[nn.Module] = []
293
+ example_layer: HYV4MoEFused | None = None
294
+ for layer in self.layers:
295
+ if isinstance(layer, PPMissingLayer):
296
+ continue
297
+
298
+ assert isinstance(layer, HYV4DecoderLayer)
299
+ if layer.block_type == "moe":
300
+ assert isinstance(layer.mlp, HYV4MoEFused)
301
+ example_layer = layer.mlp
302
+ self.moe_layers.append(layer.mlp.experts)
303
+
304
+ if example_layer is None:
305
+ self.num_moe_layers = 0
306
+ self.num_logical_experts = 0
307
+ self.num_physical_experts = 0
308
+ self.num_local_physical_experts = 0
309
+ self.num_routed_experts = 0
310
+ self.num_redundant_experts = 0
311
+ return
312
+
313
+ self.num_moe_layers = len(self.moe_layers)
314
+ self.num_logical_experts = example_layer.n_logical_experts
315
+ self.num_physical_experts = example_layer.n_physical_experts
316
+ self.num_local_physical_experts = example_layer.n_local_physical_experts
317
+ self.num_routed_experts = example_layer.n_routed_experts
318
+ self.num_redundant_experts = example_layer.n_redundant_experts
319
+
320
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
321
+ return self.embed_tokens(input_ids)
322
+
323
+ def update_physical_experts_metadata(
324
+ self,
325
+ num_physical_experts: int,
326
+ num_local_physical_experts: int,
327
+ ) -> None:
328
+ assert self.num_local_physical_experts == num_local_physical_experts
329
+ self.num_physical_experts = num_physical_experts
330
+ self.num_local_physical_experts = num_local_physical_experts
331
+ self.num_redundant_experts = num_physical_experts - self.num_logical_experts
332
+ for layer in self.layers:
333
+ if isinstance(layer, PPMissingLayer):
334
+ continue
335
+ if isinstance(layer.mlp, HYV4MoEFused):
336
+ moe = layer.mlp
337
+ moe.n_local_physical_experts = num_local_physical_experts
338
+ moe.n_physical_experts = num_physical_experts
339
+ moe.n_redundant_experts = self.num_redundant_experts
340
+ moe.experts.update_expert_map()
341
+
342
+ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
343
+ # (param_name, weight_name, expert_id, shard_id) for weights, fp8
344
+ # weight scales and fp8 activation scales.
345
+ if not hasattr(self, "_cached_expert_params_mapping"):
346
+ self._cached_expert_params_mapping = fused_moe_make_expert_params_mapping(
347
+ self,
348
+ ckpt_gate_proj_name="gate_proj",
349
+ ckpt_down_proj_name="down_proj",
350
+ ckpt_up_proj_name="up_proj",
351
+ num_experts=self.config.num_experts,
352
+ num_redundant_experts=self.num_redundant_experts,
353
+ )
354
+ return self._cached_expert_params_mapping
355
+
356
+ def forward(
357
+ self,
358
+ input_ids: torch.Tensor,
359
+ positions: torch.Tensor,
360
+ intermediate_tensors: IntermediateTensors | None = None,
361
+ inputs_embeds: torch.Tensor | None = None,
362
+ ) -> torch.Tensor | IntermediateTensors:
363
+ if get_pp_group().is_first_rank:
364
+ if inputs_embeds is not None:
365
+ hidden_states = inputs_embeds
366
+ else:
367
+ hidden_states = self.embed_input_ids(input_ids)
368
+ residual = None
369
+ else:
370
+ assert intermediate_tensors is not None
371
+ hidden_states = intermediate_tensors["hidden_states"]
372
+ # In iHC mode the flattened [num_tokens, hc*h] tensor from the
373
+ # previous PP stage is reshaped back to 3D by the first layer's
374
+ # prepare_input, and residual is unused.
375
+ residual = None if self.enable_ihc else intermediate_tensors["residual"]
376
+
377
+ for layer in islice(self.layers, self.start_layer, self.end_layer):
378
+ hidden_states, residual = layer(positions, hidden_states, residual)
379
+
380
+ if not get_pp_group().is_last_rank:
381
+ if self.enable_ihc:
382
+ # hidden_states is [num_tokens, hc, h]; flatten the channel dim
383
+ # for PP transfer (matches the 2D receive buffer).
384
+ return IntermediateTensors({"hidden_states": hidden_states.flatten(1)})
385
+ return IntermediateTensors(
386
+ {"hidden_states": hidden_states, "residual": residual}
387
+ )
388
+ if self.enable_ihc:
389
+ hidden_states = self.hc_head(hidden_states)
390
+ else:
391
+ hidden_states = hidden_states + residual
392
+
393
+ return self.norm(hidden_states)
394
+
395
+ def load_fused_expert_weights(
396
+ self,
397
+ name: str,
398
+ params_dict: dict,
399
+ loaded_weight: torch.Tensor,
400
+ shard_id: str,
401
+ num_experts: int,
402
+ ) -> bool:
403
+ param = params_dict[name]
404
+ weight_loader = typing.cast(Callable[..., bool], param.weight_loader)
405
+ loaded_local_expert = False
406
+ for expert_id in range(num_experts):
407
+ curr_expert_weight = loaded_weight[expert_id]
408
+ success = weight_loader(
409
+ param,
410
+ curr_expert_weight,
411
+ name,
412
+ shard_id,
413
+ expert_id,
414
+ return_success=True,
415
+ )
416
+ if success:
417
+ loaded_local_expert = True
418
+
419
+ return loaded_local_expert
420
+
421
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
422
+ # FP8 indexer wk dequant buffer (weight and scale arrive separately).
423
+ pending_wk_fp8: dict[str, dict[str, torch.Tensor]] = {}
424
+ pp_missing_layer_names = get_pp_missing_layer_names(self)
425
+ skip_topk_layers = compute_skip_topk_layers(self.config)
426
+
427
+ # Must not be cached on `self`: `process_weights_after_loading` swaps in
428
+ # kernel-specific expert weights via `replace_parameter`, and a cache
429
+ # outliving this call would pin the pre-shuffle storage (OOM on large
430
+ # MoE) and make a later reload target orphaned tensors.
431
+ params_dict = dict(self.named_parameters())
432
+ # Split per-expert mapping (V3 style): experts.0.gate_proj.weight
433
+ split_expert_params_mapping = self.get_expert_mapping()
434
+ loaded_params: set[str] = set()
435
+
436
+ # Sink weights are sharded like the q/k/v linears.
437
+ sink_tp_size = get_tensor_model_parallel_world_size()
438
+ sink_tp_rank = get_tensor_model_parallel_rank()
439
+ n_local_head = self.config.num_attention_heads // sink_tp_size
440
+ head_rank_start = n_local_head * sink_tp_rank
441
+ head_rank_end = n_local_head * (sink_tp_rank + 1)
442
+ base_layer = (
443
+ "base_layer." if any(".base_layer." in name for name in params_dict) else ""
444
+ )
445
+ # Fused expert mapping: experts.gate_up_proj (all experts in one tensor).
446
+ # The packed weights are owned by the RoutedExperts submodule, so the
447
+ # targets are experts.routed_experts.[base_layer.]w{13,2}_weight.
448
+ fused_expert_prefix = f".experts.routed_experts.{base_layer}"
449
+ fused_expert_params_mapping = [
450
+ (f"{fused_expert_prefix}w13_weight", ".experts.gate_up_proj", 0, "w1"),
451
+ (f"{fused_expert_prefix}w2_weight", ".experts.down_proj", 0, "w2"),
452
+ ]
453
+ num_experts = getattr(self.config, "num_experts", 0)
454
+
455
+ def _should_skip_missing_param(param_name: str) -> bool:
456
+ # Sparse checkpoints may contain indexer weights for layers that
457
+ # fall back to dense MLA on unsupported GPUs.
458
+ return ".indexer." in param_name
459
+
460
+ def _is_split_expert_weight(weight_name: str) -> bool:
461
+ """Whether this weight is in split (per-expert) format."""
462
+ # Split format: mlp.experts.<id>.gate_proj.weight
463
+ return bool(re.search(r"\.experts\.\d+\.", weight_name))
464
+
465
+ def _is_fused_expert_weight(weight_name: str) -> bool:
466
+ """Whether this weight is in fused (all-experts-packed) format."""
467
+ return ".experts.gate_up_proj" in weight_name or (
468
+ ".experts.down_proj" in weight_name
469
+ and not _is_split_expert_weight(weight_name)
470
+ )
471
+
472
+ for name, loaded_weight in weights:
473
+ if is_skip_topk_indexer_weight(name, skip_topk_layers):
474
+ continue
475
+ if _try_load_fp8_indexer_wk(
476
+ name,
477
+ loaded_weight,
478
+ pending_wk_fp8,
479
+ params_dict,
480
+ loaded_params,
481
+ pp_missing_layer_names,
482
+ ):
483
+ continue
484
+ if self.config.tie_word_embeddings and "lm_head.weight" in name:
485
+ continue
486
+ # Names and stacked shard ids are normalized upstream by
487
+ # AutoWeightsLoader, through quant_config.get_cache_scale_mapper()
488
+ # and hf_to_vllm_mapper.
489
+ stacked_shard_id = getattr(loaded_weight, "shard_id", None)
490
+ if ".indexer.wk." in name:
491
+ name = name.replace(".indexer.wk.", ".indexer.wk_weights_proj.")
492
+ stacked_shard_id = 0
493
+ # Skip loading extra bias for GPTQ models.
494
+ if name.endswith(".bias") and name not in params_dict:
495
+ continue
496
+
497
+ # Determine per-weight whether this is fused or split format.
498
+ is_fused_expert = _is_fused_expert_weight(name)
499
+ expert_params_mapping = (
500
+ fused_expert_params_mapping
501
+ if is_fused_expert
502
+ else split_expert_params_mapping
503
+ )
504
+
505
+ is_expert_weight = False
506
+ loaded_expert_param_names: set[str] = set()
507
+ for mapping in expert_params_mapping:
508
+ param_name, weight_name, expert_id, shard_id = mapping
509
+ if weight_name not in name:
510
+ continue
511
+
512
+ # This is an expert weight and must not be attempted as any
513
+ # other kind of weight later on.
514
+ is_expert_weight = True
515
+
516
+ # Do not modify `name`: the loop may continue past this point.
517
+ name_mapped = name.replace(weight_name, param_name)
518
+ if is_pp_missing_parameter(name_mapped, self):
519
+ continue
520
+ if is_fused_expert:
521
+ if "experts.gate_up_proj" in name:
522
+ chunks = loaded_weight.chunk(2, dim=-2)
523
+ success_w1 = self.load_fused_expert_weights(
524
+ name_mapped, params_dict, chunks[0], "w1", num_experts
525
+ )
526
+ success_w3 = self.load_fused_expert_weights(
527
+ name_mapped, params_dict, chunks[1], "w3", num_experts
528
+ )
529
+ success = success_w1 and success_w3
530
+ else:
531
+ success = self.load_fused_expert_weights(
532
+ name_mapped,
533
+ params_dict,
534
+ loaded_weight,
535
+ shard_id,
536
+ num_experts,
537
+ )
538
+ if success:
539
+ name = name_mapped
540
+ break
541
+ else:
542
+ # Split per-expert format (V3 style).
543
+ if name_mapped not in params_dict:
544
+ if _should_skip_missing_param(name_mapped):
545
+ continue
546
+ logger.warning_once(
547
+ "Skipping unknown checkpoint weight: %s",
548
+ name_mapped,
549
+ )
550
+ continue
551
+ param = params_dict[name_mapped]
552
+ # Ask the weight loader whether it succeeded, otherwise we
553
+ # may skip experts that have other available replicas.
554
+ weight_loader: Callable[..., typing.Any] = param.weight_loader
555
+ success = weight_loader(
556
+ param,
557
+ loaded_weight,
558
+ name_mapped,
559
+ shard_id=shard_id,
560
+ expert_id=expert_id,
561
+ return_success=True,
562
+ )
563
+ if success:
564
+ if not is_fused_expert:
565
+ loaded_expert_param_names.add(name_mapped)
566
+ continue
567
+ name = name_mapped
568
+ break
569
+ else:
570
+ if loaded_expert_param_names:
571
+ loaded_params.update(loaded_expert_param_names)
572
+ continue
573
+ if "learnable_sink_param" in name:
574
+ if is_pp_missing_parameter(name, self):
575
+ continue
576
+ narrow_weight = loaded_weight[head_rank_start:head_rank_end]
577
+ n = narrow_weight.shape[0]
578
+ with torch.no_grad():
579
+ params_dict[name][:n].copy_(narrow_weight)
580
+ else:
581
+ if is_expert_weight:
582
+ # An expert weight that is not mapped to this rank.
583
+ continue
584
+ if "gate.e_score_correction_bias" in name:
585
+ name = name.replace(
586
+ "gate.e_score_correction_bias", "expert_bias"
587
+ )
588
+ if "router.gate." in name:
589
+ name = name.replace("router.", "")
590
+ if "hc_fn" in name:
591
+ name = name.replace("hc_fn", "hc_fn.weight")
592
+ if "hc_head_fn" in name:
593
+ name = name.replace("hc_head_fn", "hc_head_fn.weight")
594
+
595
+ if is_pp_missing_parameter(name, self):
596
+ continue
597
+
598
+ if name not in params_dict:
599
+ if _should_skip_missing_param(name):
600
+ continue
601
+ logger.warning_once(
602
+ "Skipping unknown checkpoint weight: %s",
603
+ name,
604
+ )
605
+ continue
606
+
607
+ param = params_dict[name]
608
+ weight_loader = getattr(
609
+ param, "weight_loader", default_weight_loader
610
+ )
611
+ if stacked_shard_id is None:
612
+ weight_loader(param, loaded_weight)
613
+ else:
614
+ weight_loader(param, loaded_weight, stacked_shard_id)
615
+ loaded_params.add(name)
616
+
617
+ return loaded_params
618
+
619
+
620
+class HYV4ForCausalLM(nn.Module, SupportsPP, SupportsLoRA):
621
+ hf_to_vllm_mapper = WeightsMapper(
622
+ orig_to_new_stacked={
623
+ ".q_a_proj": (".fused_qkv_a_proj", 0),
624
+ ".kv_a_proj_with_mqa": (".fused_qkv_a_proj", 1),
625
+ ".mlp.gate_proj": (".mlp.gate_up_proj", 0),
626
+ ".mlp.up_proj": (".mlp.gate_up_proj", 1),
627
+ ".shared_experts.gate_proj": (".shared_experts.gate_up_proj", 0),
628
+ ".shared_experts.up_proj": (".shared_experts.gate_up_proj", 1),
629
+ ".indexer.weights_proj.": (".indexer.wk_weights_proj.", 1),
630
+ }
631
+ )
632
+ packed_modules_mapping = {
633
+ "gate_up_proj": ["gate_proj", "up_proj"],
634
+ # MLA runs both latent down-projections as one GEMM.
635
+ "fused_qkv_a_proj": ["q_a_proj", "kv_a_proj_with_mqa"],
636
+ # The indexer fuses wk and weights_proj into one GEMM.
637
+ "wk_weights_proj": ["wk", "weights_proj"],
638
+ }
639
+
640
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
641
+ super().__init__()
642
+ config = vllm_config.model_config.hf_config
643
+ quant_config = vllm_config.quant_config
644
+ self.config = config
645
+ self.quant_config = quant_config
646
+ if quant_config is not None:
647
+ quant_config.packed_modules_mapping = self.packed_modules_mapping
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 = HYV4Model(
654
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
655
+ )
656
+ if get_pp_group().is_last_rank:
657
+ # The head stays in the model dtype; ``enable_lm_head_fp32`` is
658
+ # surfaced as ``head_dtype`` on the config (see HYV4Config), which
659
+ # makes LogitsProcessor accumulate the projection straight into
660
+ # fp32 instead of materializing an fp32 copy of the weight.
661
+ self.lm_head = ParallelLMHead(
662
+ config.vocab_size,
663
+ config.hidden_size,
664
+ quant_config=quant_config,
665
+ prefix=maybe_prefix(prefix, "lm_head"),
666
+ )
667
+ # With tie_word_embeddings, embed_tokens is kept on the last rank
668
+ # (see HYV4Model.__init__) so the weight can be shared here.
669
+ if self.config.tie_word_embeddings:
670
+ self.lm_head.weight = self.model.embed_tokens.weight
671
+ else:
672
+ self.lm_head = PPMissingLayer()
673
+ self.logits_processor = LogitsProcessor(config.vocab_size)
674
+ self.make_empty_intermediate_tensors = (
675
+ self.model.make_empty_intermediate_tensors
676
+ )
677
+
678
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
679
+ return self.model.embed_input_ids(input_ids)
680
+
681
+ def forward(
682
+ self,
683
+ input_ids: torch.Tensor,
684
+ positions: torch.Tensor,
685
+ intermediate_tensors: IntermediateTensors | None = None,
686
+ inputs_embeds: torch.Tensor | None = None,
687
+ ) -> torch.Tensor | IntermediateTensors:
688
+ return self.model(input_ids, positions, intermediate_tensors, inputs_embeds)
689
+
690
+ def compute_logits(
691
+ self,
692
+ hidden_states: torch.Tensor,
693
+ ) -> torch.Tensor | None:
694
+ logits = self.logits_processor(self.lm_head, hidden_states)
695
+
696
+ if getattr(self.config, "soft_logits_capping", False):
697
+ soft_cap = self.config.soft_logits_capping_logits
698
+ logits = soft_cap * torch.nn.functional.tanh(logits / soft_cap)
699
+
700
+ return logits
701
+
702
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
703
+ # MTP layers are loaded by the MTP head, not by the target model. They
704
+ # appear as model.mtp_layers.<i>.* and as the layer ids past the backbone.
705
+ mtp_start = self.config.num_hidden_layers
706
+ drop_prefixes: dict[str, str | None] = {"model.mtp_layers.": None}
707
+ for i in range(getattr(self.config, "num_nextn_predict_layers", 0)):
708
+ drop_prefixes[f"model.layers.{mtp_start + i}."] = None
709
+ if self.config.tie_word_embeddings:
710
+ drop_prefixes["lm_head."] = None
711
+ drop_mapper = WeightsMapper(orig_to_new_prefix=drop_prefixes)
712
+ loader = AutoWeightsLoader(self)
713
+ return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper | drop_mapper)
714
+
715
+ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
716
+ return self.model.get_expert_mapping()
vllm/models/hy_v4/nvidia/moe.py
new
+190
@@ -0,0 +1,190 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Dense FFN and MoE blocks for HY V4 (NVIDIA)."""
4
+
5
+import torch
6
+from torch import nn
7
+from transformers import PretrainedConfig
8
+
9
+from vllm.config import get_current_vllm_config
10
+from vllm.distributed import get_ep_group, get_tensor_model_parallel_world_size
11
+from vllm.logger import init_logger
12
+from vllm.model_executor.layers.activation import SiluAndMul
13
+from vllm.model_executor.layers.fused_moe import FusedMoEFactory, GateLinear
14
+from vllm.model_executor.layers.linear import (
15
+ MergedColumnParallelLinear,
16
+ RowParallelLinear,
17
+)
18
+from vllm.model_executor.layers.quantization import QuantizationConfig
19
+
20
+logger = init_logger(__name__)
21
+
22
+
23
+class HYV4FeedForward(nn.Module):
24
+ """Dense SwiGLU feed-forward block.
25
+
26
+ Used both for the dense decoder layers and for the MoE shared experts.
27
+ The routed-expert SwiGLU clamp does not apply here, matching the reference
28
+ implementation.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ hidden_size: int,
34
+ intermediate_size: int,
35
+ hidden_act: str,
36
+ quant_config: QuantizationConfig | None = None,
37
+ reduce_results: bool = True,
38
+ prefix: str = "",
39
+ ) -> None:
40
+ super().__init__()
41
+ if hidden_act != "silu":
42
+ raise ValueError(
43
+ f"Unsupported activation: {hidden_act}. Only silu is supported for now."
44
+ )
45
+ self.gate_up_proj = MergedColumnParallelLinear(
46
+ hidden_size,
47
+ [intermediate_size] * 2,
48
+ bias=False,
49
+ quant_config=quant_config,
50
+ prefix=f"{prefix}.gate_up_proj",
51
+ )
52
+ self.down_proj = RowParallelLinear(
53
+ intermediate_size,
54
+ hidden_size,
55
+ bias=False,
56
+ quant_config=quant_config,
57
+ reduce_results=reduce_results,
58
+ prefix=f"{prefix}.down_proj",
59
+ )
60
+ self.act_fn = SiluAndMul()
61
+
62
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
63
+ gate_up, _ = self.gate_up_proj(x)
64
+ out = self.act_fn(gate_up)
65
+ out, _ = self.down_proj(out)
66
+ return out
67
+
68
+
69
+class HYV4MoEFused(nn.Module):
70
+ """HY V4 MoE layer with optional SwiGLU clamp support.
71
+
72
+ When ``config.swiglu_limit > 0`` the routed experts use a clamped SwiGLU::
73
+
74
+ gate = clamp(gate, max=limit)
75
+ up = clamp(up, -limit, limit)
76
+ output = silu(gate) * up
77
+
78
+ Dense layers and shared experts are NOT clamped. The clamp is forwarded to
79
+ ``FusedMoEFactory`` as ``swiglu_limit`` so it travels through the quant
80
+ config to every expert backend instead of mutating global state.
81
+
82
+ NOTE: The reference implementation additionally supports HPC gate kernels,
83
+ TPCP/MLPSP sharded shared experts and a side-stream overlap for the shared
84
+ experts. Those depend on infrastructure that is absent here, so this port
85
+ keeps only the standard path. TODO: restore once available.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ config: PretrainedConfig,
91
+ quant_config: QuantizationConfig | None = None,
92
+ prefix: str = "",
93
+ enable_eplb: bool = False,
94
+ ):
95
+ super().__init__()
96
+ self.tp_size = get_tensor_model_parallel_world_size()
97
+ self.ep_group = get_ep_group().device_group
98
+ self.ep_rank = get_ep_group().rank_in_group
99
+ self.ep_size = self.ep_group.size()
100
+ self.n_routed_experts = config.num_experts
101
+ if self.tp_size > config.num_experts:
102
+ raise ValueError(
103
+ f"Tensor parallel size {self.tp_size} is greater than "
104
+ f"the number of experts {config.num_experts}."
105
+ )
106
+ top_k = config.num_experts_per_tok
107
+ intermediate_size = config.expert_hidden_dim
108
+ router_scaling_factor = getattr(config, "router_scaling_factor", 1.0)
109
+ vllm_config = get_current_vllm_config()
110
+ eplb_config = vllm_config.parallel_config.eplb_config
111
+ self.enable_eplb = enable_eplb
112
+
113
+ self.n_logical_experts = self.n_routed_experts
114
+ self.n_redundant_experts = eplb_config.num_redundant_experts
115
+ self.n_physical_experts = self.n_logical_experts + self.n_redundant_experts
116
+ self.n_local_physical_experts = self.n_physical_experts // self.ep_size
117
+ self.physical_expert_start = self.ep_rank * self.n_local_physical_experts
118
+ self.physical_expert_end = (
119
+ self.physical_expert_start + self.n_local_physical_experts
120
+ )
121
+
122
+ self.gate = GateLinear(
123
+ config.hidden_size,
124
+ config.num_experts,
125
+ bias=False,
126
+ out_dtype=torch.float32,
127
+ params_dtype=torch.float32,
128
+ prefix=f"{prefix}.gate",
129
+ )
130
+
131
+ self.shared_experts: HYV4FeedForward | None
132
+ if config.num_shared_experts > 0:
133
+ self.shared_experts = HYV4FeedForward(
134
+ hidden_size=config.hidden_size,
135
+ intermediate_size=config.expert_hidden_dim * config.num_shared_experts,
136
+ hidden_act=config.hidden_act,
137
+ quant_config=quant_config,
138
+ prefix=f"{prefix}.shared_experts",
139
+ reduce_results=False,
140
+ )
141
+ else:
142
+ self.shared_experts = None
143
+
144
+ self.expert_bias = nn.Parameter(
145
+ torch.empty(config.num_experts, dtype=torch.float32)
146
+ )
147
+
148
+ raw_swiglu_limit = getattr(config, "swiglu_limit", 0)
149
+ use_swiglu_clamp = bool(raw_swiglu_limit) and float(raw_swiglu_limit) > 0
150
+ moe_swiglu_limit = float(raw_swiglu_limit) if use_swiglu_clamp else None
151
+
152
+ self.experts = FusedMoEFactory(
153
+ num_experts=self.n_routed_experts,
154
+ top_k=top_k,
155
+ hidden_size=config.hidden_size,
156
+ intermediate_size=intermediate_size,
157
+ renormalize=config.route_norm,
158
+ quant_config=quant_config,
159
+ prefix=f"{prefix}.experts",
160
+ enable_eplb=self.enable_eplb,
161
+ num_redundant_experts=self.n_redundant_experts,
162
+ scoring_func="sigmoid",
163
+ use_grouped_topk=True,
164
+ num_expert_group=1,
165
+ topk_group=1,
166
+ routed_scaling_factor=router_scaling_factor,
167
+ e_score_correction_bias=self.expert_bias,
168
+ shared_experts=self.shared_experts,
169
+ swiglu_limit=moe_swiglu_limit,
170
+ )
171
+ self.prefix = prefix
172
+ if use_swiglu_clamp:
173
+ logger.debug_once(
174
+ "HYV4MoEFused: swiglu_limit=%.1f enabled for routed experts",
175
+ moe_swiglu_limit,
176
+ )
177
+
178
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
179
+ orig_shape = hidden_states.shape
180
+ hidden_dim = hidden_states.shape[-1]
181
+ hidden_states = hidden_states.view(-1, hidden_dim)
182
+
183
+ # router_logits: (num_tokens, n_experts); the gate stores fp32 weights
184
+ # and emits fp32 logits, so no explicit upcast is needed here.
185
+ router_logits, _ = self.gate(hidden_states)
186
+
187
+ final_hidden_states = self.experts(
188
+ hidden_states=hidden_states, router_logits=router_logits
189
+ )
190
+ return final_hidden_states.view(orig_shape)
vllm/models/hy_v4/nvidia/mtp.py
new
+937
@@ -0,0 +1,937 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Multi-token prediction (MTP) head for HY V4 (NVIDIA)."""
4
+
5
+import copy
6
+import typing
7
+from collections.abc import Callable, Iterable
8
+
9
+import torch
10
+from torch import nn
11
+from transformers import PretrainedConfig
12
+
13
+from vllm.config import CacheConfig, ModelConfig, VllmConfig
14
+from vllm.distributed import (
15
+ get_tensor_model_parallel_rank,
16
+ get_tensor_model_parallel_world_size,
17
+)
18
+from vllm.logger import init_logger
19
+from vllm.model_executor.layers.fused_moe import fused_moe_make_expert_params_mapping
20
+from vllm.model_executor.layers.layernorm import RMSNorm
21
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
22
+from vllm.model_executor.layers.quantization import QuantizationConfig
23
+from vllm.model_executor.layers.vocab_parallel_embedding import (
24
+ ParallelLMHead,
25
+ VocabParallelEmbedding,
26
+)
27
+from vllm.model_executor.model_loader.weight_utils import (
28
+ default_weight_loader,
29
+ maybe_remap_kv_scale_name,
30
+)
31
+from vllm.model_executor.models.deepseek_v2 import _try_load_fp8_indexer_wk
32
+from vllm.model_executor.models.utils import (
33
+ get_pp_missing_layer_names,
34
+ is_pp_missing_parameter,
35
+ maybe_prefix,
36
+)
37
+from vllm.sequence import IntermediateTensors
38
+from vllm.v1.outputs import SamplerOutput
39
+from vllm.v1.sample.metadata import SamplingMetadata
40
+from vllm.v1.sample.sampler import Sampler
41
+
42
+from .model import HYV4DecoderLayer, _normalize_hyv4_config
43
+
44
+logger = init_logger(__name__)
45
+
46
+_MTP_SCALE_ONLY_PARAM_SUBSTRINGS = (
47
+ "_scale",
48
+ "_weight_scale",
49
+ "_weight_scale_inv",
50
+ "_input_scale",
51
+)
52
+
53
+# Names of the quant-config attribute that stores the unquantized-module
54
+# prefix list, in the order we look them up. Different QuantizationConfig
55
+# subclasses expose the list under different names:
56
+# * ``ignored_layers`` -- Fp8Config, GPTQ / AWQ configs.
57
+# * ``exclude_modules`` -- ModelOpt* configs (Fp8, NvFp4, MxFp8, Mixed).
58
+_MTP_QUANT_EXCLUSION_ATTRS = ("ignored_layers", "exclude_modules")
59
+
60
+
61
+def _get_spec_layer_idx_from_weight_name(
62
+ config: PretrainedConfig, weight_name: str
63
+) -> int | None:
64
+ """Return the MTP layer index a checkpoint weight belongs to, or None.
65
+
66
+ Compatible with``num_nextn_predict_layers`` of 1 and 2 for a single MTP
67
+ head, matching how the HY V4 checkpoints are exported.
68
+ """
69
+ if getattr(config, "num_nextn_predict_layers", 0) < 1:
70
+ return None
71
+ layer_idx = config.num_hidden_layers
72
+ num_mtp_layers = max(config.num_nextn_predict_layers - 1, 1)
73
+ for i in range(num_mtp_layers):
74
+ if weight_name.startswith(f"model.layers.{layer_idx + i}."):
75
+ return layer_idx + i
76
+ return None
77
+
78
+
79
+def _should_skip_missing_mtp_scale_param(
80
+ quant_config: QuantizationConfig | None,
81
+ name: str,
82
+) -> bool:
83
+ """Whether an unmatched scale parameter can be silently ignored."""
84
+ return quant_config is None and any(
85
+ scale_name in name for scale_name in _MTP_SCALE_ONLY_PARAM_SUBSTRINGS
86
+ )
87
+
88
+
89
+def _resolve_fused_expert_param(
90
+ param_base: str,
91
+ ckpt_suffix: str,
92
+ params_dict: dict,
93
+) -> str | None:
94
+ """Map a fused expert checkpoint suffix onto a draft parameter name.
95
+
96
+ Fused checkpoints pack every expert into one tensor and name the companion
97
+ scales by suffixing the projection (``experts.gate_up_proj_scale_inv``),
98
+ while the draft model owns ``experts.routed_experts.w13_weight`` plus a
99
+ separate ``..._scale_inv`` parameter. Dropping the suffix would push the
100
+ scale into the weight parameter and leave the scale at its sentinel init.
101
+
102
+ Args:
103
+ param_base: Draft parameter holding the packed weight.
104
+ ckpt_suffix: Checkpoint text following the projection name.
105
+ params_dict: The draft model's named parameters.
106
+
107
+ Returns:
108
+ The matching draft parameter name, or None when there is none.
109
+ """
110
+ if not ckpt_suffix:
111
+ return param_base if param_base in params_dict else None
112
+ tail = ckpt_suffix.lstrip(".").removeprefix("weight_")
113
+ for candidate in (
114
+ f"{param_base}_{tail}",
115
+ f"{param_base}_scale_inv",
116
+ f"{param_base}_scale",
117
+ ):
118
+ if candidate in params_dict:
119
+ return candidate
120
+ return None
121
+
122
+
123
+def _prepare_mtp_fp8_expert_scale(
124
+ quant_config: QuantizationConfig | None,
125
+ name: str,
126
+ loaded_weight: torch.Tensor,
127
+) -> tuple[str, torch.Tensor]:
128
+ """Normalize block-wise FP8 expert scale names and dtypes."""
129
+ from vllm.model_executor.layers.quantization.fp8 import Fp8Config
130
+
131
+ if (
132
+ isinstance(quant_config, Fp8Config)
133
+ and quant_config.weight_block_size is not None
134
+ and ".mlp.experts." in name
135
+ and name.endswith(".scale")
136
+ ):
137
+ name = name[: -len(".scale")] + ".weight_scale_inv"
138
+ if (
139
+ getattr(quant_config, "is_scale_e8m0", False)
140
+ and loaded_weight.dtype == torch.uint8
141
+ ):
142
+ # UE8M0 scale exponents are stored as raw uint8 bytes: reinterpret
143
+ # the bits instead of numerically converting them.
144
+ loaded_weight = loaded_weight.view(torch.float8_e8m0fnu)
145
+ return name, loaded_weight
146
+
147
+
148
+def _create_mtp_quant_config(
149
+ hf_config: PretrainedConfig,
150
+ backbone_quant_config: QuantizationConfig | None = None,
151
+) -> QuantizationConfig | None:
152
+ """Create the quantization config for the MTP layers.
153
+
154
+ The MTP quantization algorithm is given by the ``mtp_quant_algo`` field in
155
+ ``config.json``, independently of the backbone's quantization. Supported
156
+ values are ``"FP8"``, ``"NONE"`` (inherit the backbone) and ``"BF16"`` /
157
+ ``"FP16"`` (unquantized). A missing or ``"NONE"`` value falls back to the
158
+ backbone config.
159
+
160
+ Args:
161
+ hf_config: The draft model's HF config.
162
+ backbone_quant_config: The target model's quantization config.
163
+
164
+ Returns:
165
+ The quantization config to use for the MTP layers, or None when the MTP
166
+ layers are unquantized.
167
+ """
168
+ mtp_quant_algo = getattr(hf_config, "mtp_quant_algo", None)
169
+
170
+ if mtp_quant_algo is None or mtp_quant_algo.upper() == "NONE":
171
+ logger.info_once(
172
+ "No explicit MTP quant algo (mtp_quant_algo=%s). "
173
+ "Using the backbone's quant config.",
174
+ mtp_quant_algo,
175
+ )
176
+ return backbone_quant_config
177
+
178
+ mtp_quant_algo = mtp_quant_algo.upper()
179
+
180
+ if mtp_quant_algo in ("BF16", "FP16"):
181
+ logger.info_once(
182
+ "MTP layers are unquantized (mtp_quant_algo=%s).", mtp_quant_algo
183
+ )
184
+ return None
185
+
186
+ if mtp_quant_algo == "FP8":
187
+ from vllm.model_executor.layers.quantization.fp8 import Fp8Config
188
+
189
+ logger.info_once("MTP uses FP8 quantization (mtp_quant_algo=FP8).")
190
+ hf_quant_config = getattr(hf_config, "quantization_config", None) or {}
191
+ weight_block_size = hf_quant_config.get("weight_block_size")
192
+ activation_scheme = hf_quant_config.get("activation_scheme", "dynamic")
193
+ if weight_block_size is not None:
194
+ activation_scheme = "dynamic"
195
+ ignored_layers = hf_quant_config.get("ignored_layers") or hf_quant_config.get(
196
+ "modules_to_not_convert"
197
+ )
198
+
199
+ fp8_config = Fp8Config(
200
+ is_checkpoint_fp8_serialized=True,
201
+ activation_scheme=activation_scheme,
202
+ ignored_layers=ignored_layers or [],
203
+ weight_block_size=weight_block_size,
204
+ )
205
+ if hf_quant_config.get("scale_fmt") == "ue8m0":
206
+ # Fp8LinearMethod reads this through getattr, so it is not a
207
+ # declared Fp8Config field.
208
+ setattr(fp8_config, "is_scale_e8m0", True) # noqa: B010
209
+ return fp8_config
210
+
211
+ logger.warning(
212
+ "Unknown mtp_quant_algo=%s, falling back to the backbone config.",
213
+ mtp_quant_algo,
214
+ )
215
+ return backbone_quant_config
216
+
217
+
218
+def _remap_mtp_quant_exclusions(
219
+ quant_config: QuantizationConfig | None,
220
+ mtp_start_layer_idx: int,
221
+ num_mtp_layers: int,
222
+) -> QuantizationConfig | None:
223
+ """Translate checkpoint-named MTP quant exclusions to draft prefixes.
224
+
225
+ ``modules_to_not_convert`` / ``exclude_modules`` name MTP modules the way
226
+ the checkpoint does (``model.mtp_layers.0.self_attn.linear_gate``), while
227
+ the draft model builds them under ``model.layers.<num_hidden_layers + i>``.
228
+ ``is_layer_skipped`` compares prefixes for exact equality, so without this
229
+ translation an excluded MTP module gets a quant method even though its
230
+ checkpoint weight is BF16 with no ``weight_scale`` / ``weight_scale_inv``
231
+ companion: the scale then keeps its ``finfo(float32).min`` sentinel and the
232
+ layer silently produces garbage.
233
+
234
+ The list lives under a different attribute per config class, hence the
235
+ lookup over ``_MTP_QUANT_EXCLUSION_ATTRS``.
236
+
237
+ Args:
238
+ quant_config: The MTP quantization config to widen.
239
+ mtp_start_layer_idx: Index of the first MTP layer in the draft model.
240
+ num_mtp_layers: Number of MTP layers.
241
+
242
+ Returns:
243
+ A shallow copy with the translated exclusions, or the input unchanged.
244
+ """
245
+ if quant_config is None:
246
+ return None
247
+
248
+ # Different QuantizationConfig subclasses expose the exclusion list under
249
+ # different attribute names; only the one that is actually populated on
250
+ # the backbone config governs which draft modules stay unquantized. For
251
+ # ModelOpt checkpoints (``quant_method: modelopt`` and friends) this is
252
+ # ``exclude_modules``; for the legacy fp8 path it is ``ignored_layers``.
253
+ exclusion_attr: str | None = None
254
+ patterns: list[str] | None = None
255
+ for attr in _MTP_QUANT_EXCLUSION_ATTRS:
256
+ value = getattr(quant_config, attr, None)
257
+ if value:
258
+ exclusion_attr = attr
259
+ patterns = list(value)
260
+ break
261
+ if not patterns or exclusion_attr is None:
262
+ return quant_config
263
+
264
+ extra: list[str] = []
265
+ for offset in range(num_mtp_layers):
266
+ source_prefix = f"model.mtp_layers.{offset}."
267
+ draft_prefix = f"model.layers.{mtp_start_layer_idx + offset}."
268
+ for pattern in patterns:
269
+ if pattern.startswith(source_prefix):
270
+ extra.append(draft_prefix + pattern[len(source_prefix) :])
271
+ # ModelOpt exclusion patterns may end with a ``*`` wildcard; the
272
+ # remap must preserve that suffix so ``is_layer_skipped`` still
273
+ # matches the draft module prefixes.
274
+ elif pattern.rstrip("*").startswith(source_prefix):
275
+ stripped = pattern.rstrip("*")
276
+ trailing_stars = pattern[len(stripped) :]
277
+ extra.append(
278
+ draft_prefix + stripped[len(source_prefix) :] + trailing_stars
279
+ )
280
+ if not extra:
281
+ return quant_config
282
+
283
+ # The backbone shares this object with the target model; copy before
284
+ # widening the exclusion list. The attribute is only present on the
285
+ # concrete configs that support exclusions, hence the getattr/setattr pair.
286
+ quant_config = copy.copy(quant_config)
287
+ setattr(quant_config, exclusion_attr, patterns + extra) # noqa: B010
288
+ logger.info_once(
289
+ "HYV4 MTP: mapped %d checkpoint-named quant exclusions (%s) onto the "
290
+ "draft module prefixes: %s",
291
+ len(extra),
292
+ exclusion_attr,
293
+ ", ".join(extra),
294
+ )
295
+ return quant_config
296
+
297
+
298
+class HYV4SharedHead(nn.Module):
299
+ """Holds the draft LM head shared with the target model."""
300
+
301
+ def __init__(
302
+ self,
303
+ config: PretrainedConfig,
304
+ quant_config: QuantizationConfig | None = None,
305
+ ) -> None:
306
+ super().__init__()
307
+ # Use the same ``lm_head`` prefix as the backbone so that quant-config
308
+ # exclusion lists (e.g. ModelOpt's ``exclude_modules: ["lm_head", ...]``)
309
+ # also apply here: an unquantized checkpoint lm_head must not gain a
310
+ # spurious ``weight_scale`` parameter on the draft side, which would
311
+ # otherwise stay at its FP-min sentinel until the proposer swaps the
312
+ # whole ParallelLMHead for the target model's ``lm_head``.
313
+ self.head = ParallelLMHead(
314
+ config.vocab_size,
315
+ config.hidden_size,
316
+ quant_config=quant_config,
317
+ prefix="lm_head",
318
+ )
319
+
320
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
321
+ return hidden_states
322
+
323
+
324
+class HYV4MultiTokenPredictorLayer(nn.Module):
325
+ """A single MTP draft block."""
326
+
327
+ def __init__(
328
+ self,
329
+ config: PretrainedConfig,
330
+ prefix: str,
331
+ vllm_config: VllmConfig,
332
+ model_config: ModelConfig,
333
+ cache_config: CacheConfig | None = None,
334
+ quant_config: QuantizationConfig | None = None,
335
+ topk_indices_buffer: torch.Tensor | None = None,
336
+ ) -> None:
337
+ super().__init__()
338
+ del model_config # kept for signature parity with the other MTP heads
339
+ self.enorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
340
+ self.hnorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
341
+ self.eh_proj = nn.Linear(config.hidden_size * 2, config.hidden_size, bias=False)
342
+ self.shared_head = HYV4SharedHead(config=config, quant_config=quant_config)
343
+
344
+ # HYV4DecoderLayer indexes layer_types/mlp_layer_types by the numeric
345
+ # layer id parsed from the prefix. MTP layers live after the backbone
346
+ # layers, so extend the per-layer config lists to cover those ids.
347
+ layer_idx = int(prefix.rsplit(".", 1)[-1])
348
+ mtp_config = copy.deepcopy(config)
349
+ # MTP checkpoints do not carry iHC pre/post weights for mtp_layers, so
350
+ # the draft blocks are built without iHC to match their structure.
351
+ mtp_config.enable_ihc = False
352
+ mtp_config.layer_types = _extend_layer_types(
353
+ getattr(mtp_config, "layer_types", None), layer_idx, "full_attention"
354
+ )
355
+ mtp_config.mlp_layer_types = _extend_layer_types(
356
+ getattr(mtp_config, "mlp_layer_types", None), layer_idx, "sparse"
357
+ )
358
+
359
+ self.mtp_block = HYV4DecoderLayer(
360
+ config=mtp_config,
361
+ vllm_config=vllm_config,
362
+ cache_config=cache_config,
363
+ quant_config=quant_config,
364
+ prefix=prefix,
365
+ topk_indices_buffer=topk_indices_buffer,
366
+ )
367
+ self.final_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
368
+
369
+ def forward(
370
+ self,
371
+ input_ids: torch.Tensor,
372
+ positions: torch.Tensor,
373
+ previous_hidden_states: torch.Tensor,
374
+ inputs_embeds: torch.Tensor | None = None,
375
+ ) -> torch.Tensor:
376
+ del input_ids # embeddings are resolved by the caller
377
+ assert inputs_embeds is not None
378
+ inputs_embeds = self.enorm(inputs_embeds)
379
+ previous_hidden_states = self.hnorm(previous_hidden_states)
380
+ hidden_states = self.eh_proj(
381
+ torch.cat([inputs_embeds, previous_hidden_states], dim=-1)
382
+ )
383
+ hidden_states, residual = self.mtp_block(
384
+ positions=positions,
385
+ hidden_states=hidden_states,
386
+ residual=None,
387
+ )
388
+ hidden_states, _ = self.final_layernorm(hidden_states, residual)
389
+ return hidden_states
390
+
391
+
392
+def _extend_layer_types(
393
+ layer_types: list[str] | None, layer_idx: int, fallback: str
394
+) -> list[str] | None:
395
+ """Pad a per-layer config list so``layer_idx`` is addressable."""
396
+ if layer_types is None or len(layer_types) > layer_idx:
397
+ return layer_types
398
+ fill = layer_types[-1] if layer_types else fallback
399
+ return list(layer_types) + [fill] * (layer_idx + 1 - len(layer_types))
400
+
401
+
402
+class HYV4MultiTokenPredictor(nn.Module):
403
+ """Owns the MTP draft blocks and their shared embedding / logits path."""
404
+
405
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
406
+ super().__init__()
407
+ target_config = vllm_config.model_config.hf_config
408
+ speculative_config = vllm_config.speculative_config
409
+ assert speculative_config is not None, (
410
+ "HYV4MTP can only be built as a speculative draft model"
411
+ )
412
+ draft_model_config = speculative_config.draft_model_config
413
+ assert draft_model_config is not None
414
+ config = _normalize_hyv4_config(draft_model_config.hf_config)
415
+ self.mtp_start_layer_idx = target_config.num_hidden_layers
416
+ self.num_mtp_layers = max(
417
+ getattr(target_config, "num_nextn_predict_layers", 1), 1
418
+ )
419
+ self.quant_config = _remap_mtp_quant_exclusions(
420
+ _create_mtp_quant_config(config, vllm_config.quant_config),
421
+ self.mtp_start_layer_idx,
422
+ self.num_mtp_layers,
423
+ )
424
+ if self.quant_config is not None:
425
+ self.quant_config.packed_modules_mapping = HYV4MTP.packed_modules_mapping
426
+
427
+ # Sparse DSA draft blocks must share the target model's top-k buffer.
428
+ # The proposer injects it through HYV4MTP.set_topk_indices_buffer()
429
+ # before any draft forward.
430
+ self.topk_indices_buffer: torch.Tensor | None = None
431
+ if hasattr(config, "index_topk"):
432
+ from vllm.platforms import current_platform
433
+
434
+ self.topk_indices_buffer = torch.empty(
435
+ vllm_config.scheduler_config.max_num_batched_tokens,
436
+ config.index_topk,
437
+ dtype=torch.int32,
438
+ device=current_platform.device_type,
439
+ )
440
+
441
+ self.layers = nn.ModuleDict(
442
+ {
443
+ str(idx): HYV4MultiTokenPredictorLayer(
444
+ config,
445
+ f"{prefix}.layers.{idx}",
446
+ vllm_config=vllm_config,
447
+ model_config=draft_model_config,
448
+ cache_config=vllm_config.cache_config,
449
+ quant_config=self.quant_config,
450
+ topk_indices_buffer=self.topk_indices_buffer,
451
+ )
452
+ for idx in range(
453
+ self.mtp_start_layer_idx,
454
+ self.mtp_start_layer_idx + self.num_mtp_layers,
455
+ )
456
+ }
457
+ )
458
+ self.requires_topk_indices_buffer = any(
459
+ layer.mtp_block.self_attn.is_sparse for layer in self.layers.values()
460
+ )
461
+
462
+ self.embed_tokens = VocabParallelEmbedding(
463
+ config.vocab_size,
464
+ config.hidden_size,
465
+ )
466
+ self.logits_processor = LogitsProcessor(config.vocab_size)
467
+ self.spec_step_idx: int = 0
468
+
469
+ def set_skip_topk(self, skip: bool) -> None:
470
+ """Toggle the draft indexer for ``index_share_for_mtp_iteration``.
471
+
472
+ The proposer clears the flag for draft step 0 so the MTP layer builds
473
+ its own top-k indices, then sets it for steps 1+ so they reuse what
474
+ step 0 wrote into the shared buffer instead of re-running the indexer.
475
+ """
476
+ for layer in self.layers.values():
477
+ layer.mtp_block.self_attn.skip_topk = skip
478
+
479
+ def compact_topk_indices(self, slot_ids: torch.Tensor) -> None:
480
+ """Move the top-k rows at ``slot_ids`` to the front of the buffer.
481
+
482
+ Step 0 writes one row per query token of the multi-token batch, while
483
+ steps 1+ decode a single token per request and index the buffer from 0.
484
+ Without this gather they would read another token's rows.
485
+ """
486
+ num_slots = slot_ids.numel()
487
+ if self.topk_indices_buffer is None or num_slots == 0:
488
+ return
489
+ self.topk_indices_buffer[:num_slots] = self.topk_indices_buffer[slot_ids]
490
+
491
+ def forward(
492
+ self,
493
+ input_ids: torch.Tensor,
494
+ positions: torch.Tensor,
495
+ previous_hidden_states: torch.Tensor,
496
+ inputs_embeds: torch.Tensor | None = None,
497
+ ) -> torch.Tensor:
498
+ if inputs_embeds is None:
499
+ inputs_embeds = self.embed_tokens(input_ids)
500
+ inputs_embeds = torch.where((positions == 0).unsqueeze(-1), 0, inputs_embeds)
501
+
502
+ current_step_idx = self.spec_step_idx % self.num_mtp_layers
503
+ return self.layers[str(self.mtp_start_layer_idx + current_step_idx)](
504
+ input_ids,
505
+ positions,
506
+ previous_hidden_states,
507
+ inputs_embeds,
508
+ )
509
+
510
+ def compute_logits(
511
+ self,
512
+ hidden_states: torch.Tensor,
513
+ ) -> torch.Tensor:
514
+ current_step_idx = self.spec_step_idx % self.num_mtp_layers
515
+ mtp_layer = self.layers[str(self.mtp_start_layer_idx + current_step_idx)]
516
+ lm_head = mtp_layer.shared_head.head
517
+ proj_input = mtp_layer.shared_head(hidden_states)
518
+ return self.logits_processor(lm_head, proj_input)
519
+
520
+
521
+class HYV4MTP(nn.Module):
522
+ """HY V4 MTP draft head.
523
+
524
+ Not a pipeline-parallel stage: the draft head always runs on a single rank,
525
+ matching `DeepseekV32MTP` / `KimiK3MTP` / `HYV3MTP`.
526
+ """
527
+
528
+ packed_modules_mapping = {
529
+ "gate_up_proj": ["gate_proj", "up_proj"],
530
+ # MLA runs both latent down-projections as one GEMM.
531
+ "fused_qkv_a_proj": ["q_a_proj", "kv_a_proj_with_mqa"],
532
+ # The indexer fuses wk and weights_proj into one GEMM.
533
+ "wk_weights_proj": ["wk", "weights_proj"],
534
+ }
535
+
536
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
537
+ super().__init__()
538
+ self.config = vllm_config.model_config.hf_config
539
+ self.model = HYV4MultiTokenPredictor(
540
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
541
+ )
542
+ self.quant_config = self.model.quant_config
543
+ self.sampler = Sampler()
544
+
545
+ def set_topk_indices_buffer(self, topk_indices_buffer: torch.Tensor) -> None:
546
+ """Share the target sparse-index buffer with every draft consumer.
547
+
548
+ Proposers that walk ``named_modules()`` instead of calling this reach
549
+ the same consumers via ``HYV4MLAAttention.topk_indices_buffer``.
550
+ """
551
+ self.model.topk_indices_buffer = topk_indices_buffer
552
+ for layer in self.model.layers.values():
553
+ self_attn = layer.mtp_block.self_attn
554
+ if not self_attn.is_sparse:
555
+ continue
556
+
557
+ indexer = self_attn.indexer
558
+ assert indexer is not None, "Sparse HYV4 MTP attention requires an indexer"
559
+ indexer.topk_indices_buffer = topk_indices_buffer
560
+ indexer.indexer_op.topk_indices_buffer = topk_indices_buffer
561
+
562
+ attn_impl = self_attn.mla_attn.impl
563
+ assert hasattr(attn_impl, "topk_indices_buffer"), (
564
+ "Sparse HYV4 MTP attention backend requires a top-k indices buffer"
565
+ )
566
+ attn_impl.topk_indices_buffer = topk_indices_buffer
567
+
568
+ def forward(
569
+ self,
570
+ input_ids: torch.Tensor,
571
+ positions: torch.Tensor,
572
+ hidden_states: torch.Tensor,
573
+ intermediate_tensors: IntermediateTensors | None = None,
574
+ inputs_embeds: torch.Tensor | None = None,
575
+ spec_step_idx: int = 0,
576
+ ) -> torch.Tensor:
577
+ del intermediate_tensors # the MTP head is single-stage
578
+ if (
579
+ self.model.requires_topk_indices_buffer
580
+ and self.model.topk_indices_buffer is None
581
+ ):
582
+ raise RuntimeError(
583
+ "HYV4 sparse MTP requires the target model's top-k indices buffer. "
584
+ "The proposer must call HYV4MTP.set_topk_indices_buffer() before "
585
+ "the first draft forward."
586
+ )
587
+ self.model.spec_step_idx = spec_step_idx
588
+ return self.model(input_ids, positions, hidden_states, inputs_embeds)
589
+
590
+ def compute_logits(
591
+ self,
592
+ hidden_states: torch.Tensor,
593
+ spec_step_idx: int = 0,
594
+ ) -> torch.Tensor | None:
595
+ self.model.spec_step_idx = spec_step_idx
596
+ return self.model.compute_logits(hidden_states)
597
+
598
+ def sample(
599
+ self,
600
+ logits: torch.Tensor,
601
+ sampling_metadata: SamplingMetadata,
602
+ ) -> SamplerOutput | None:
603
+ return self.sampler(logits, sampling_metadata)
604
+
605
+ def _rewrite_spec_layer_name(self, spec_layer: int, name: str) -> str:
606
+ if f"model.layers.{spec_layer}.embed_tokens" in name:
607
+ return "__skip__"
608
+ if f"model.layers.{spec_layer}.shared_head" in name:
609
+ return "__skip__"
610
+
611
+ spec_layer_weight_names = ["enorm", "hnorm", "eh_proj", "final_layernorm"]
612
+ spec_layer_weight = any(
613
+ weight_name in name for weight_name in spec_layer_weight_names
614
+ )
615
+ if not spec_layer_weight:
616
+ name = name.replace(
617
+ f"model.layers.{spec_layer}.",
618
+ f"model.layers.{spec_layer}.mtp_block.",
619
+ )
620
+ return name
621
+
622
+ def _load_fused_expert_weights(
623
+ self,
624
+ name: str,
625
+ params_dict: dict,
626
+ loaded_weight: torch.Tensor,
627
+ shard_id: str,
628
+ num_experts: int,
629
+ ) -> bool:
630
+ if name not in params_dict:
631
+ return False
632
+ param = params_dict[name]
633
+ weight_loader = typing.cast(Callable[..., bool], param.weight_loader)
634
+ loaded_local_expert = False
635
+ for expert_id in range(num_experts):
636
+ curr_expert_weight = loaded_weight[expert_id]
637
+ success = weight_loader(
638
+ param,
639
+ curr_expert_weight,
640
+ name,
641
+ shard_id,
642
+ expert_id,
643
+ return_success=True,
644
+ )
645
+ if success:
646
+ loaded_local_expert = True
647
+ return loaded_local_expert
648
+
649
+ def _load_expert_weight(
650
+ self,
651
+ name: str,
652
+ loaded_weight: torch.Tensor,
653
+ params_dict: dict,
654
+ loaded_params: set[str],
655
+ split_expert_params_mapping: list[tuple[str, str, int, str]],
656
+ fused_expert_param_names: dict[tuple[str, str], str],
657
+ num_experts: int,
658
+ ) -> bool:
659
+ """Load one routed-expert weight in either checkpoint layout.
660
+
661
+ Args:
662
+ name: Weight name already rewritten to draft-module naming.
663
+ loaded_weight: The checkpoint tensor.
664
+ params_dict: The draft model's named parameters.
665
+ loaded_params: Set updated with the parameters that received a value.
666
+ split_expert_params_mapping: Mapping for the per-expert layout.
667
+ fused_expert_param_names: ``(mlp_prefix, tag) -> param name`` for the
668
+ all-experts-packed layout.
669
+ num_experts: Total number of routed experts.
670
+
671
+ Returns:
672
+ True when the weight was consumed (even if this rank holds none of
673
+ the addressed experts).
674
+ """
675
+ base = name.split(".experts.")[0]
676
+ for ckpt_proj, tag in (
677
+ (".experts.gate_up_proj", "w13_weight"),
678
+ (".experts.down_proj", "w2_weight"),
679
+ ):
680
+ if ckpt_proj not in name:
681
+ continue
682
+ param_base = fused_expert_param_names.get((base, tag))
683
+ if param_base is None:
684
+ return False
685
+ # Keep the checkpoint suffix (e.g. `_scale_inv`) so block-scale
686
+ # tensors land in the scale parameter, not in the weight.
687
+ target = _resolve_fused_expert_param(
688
+ param_base, name.split(ckpt_proj, 1)[1], params_dict
689
+ )
690
+ if target is None:
691
+ return False
692
+ if tag == "w13_weight":
693
+ chunks = loaded_weight.chunk(2, dim=-2)
694
+ loaded_w1 = self._load_fused_expert_weights(
695
+ target, params_dict, chunks[0], "w1", num_experts
696
+ )
697
+ loaded_w3 = self._load_fused_expert_weights(
698
+ target, params_dict, chunks[1], "w3", num_experts
699
+ )
700
+ loaded = loaded_w1 and loaded_w3
701
+ else:
702
+ loaded = self._load_fused_expert_weights(
703
+ target, params_dict, loaded_weight, "w2", num_experts
704
+ )
705
+ if loaded:
706
+ loaded_params.add(target)
707
+ # The weight belongs to the experts either way; never fall through.
708
+ return True
709
+
710
+ consumed = False
711
+ for param_name, weight_name, expert_id, shard_id in split_expert_params_mapping:
712
+ if weight_name not in name:
713
+ continue
714
+ consumed = True
715
+ name_mapped = name.replace(weight_name, param_name)
716
+ if name_mapped not in params_dict:
717
+ continue
718
+ param = params_dict[name_mapped]
719
+ weight_loader = typing.cast(Callable[..., bool], param.weight_loader)
720
+ if weight_loader(
721
+ param,
722
+ loaded_weight,
723
+ name_mapped,
724
+ shard_id=shard_id,
725
+ expert_id=expert_id,
726
+ return_success=True,
727
+ ):
728
+ loaded_params.add(name_mapped)
729
+ return consumed
730
+
731
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
732
+ params_dict = dict(self.named_parameters())
733
+ pp_missing_layer_names = get_pp_missing_layer_names(self)
734
+ loaded_params: set[str] = set()
735
+
736
+ mtp_start = self.config.num_hidden_layers
737
+ shared_weights = {
738
+ "model.embed_tokens.weight": "model.embed_tokens.weight",
739
+ "lm_head.weight": f"model.layers.{mtp_start}.shared_head.head.weight",
740
+ }
741
+
742
+ num_experts = getattr(self.config, "n_routed_experts", 0)
743
+ sink_tp_size = get_tensor_model_parallel_world_size()
744
+ sink_tp_rank = get_tensor_model_parallel_rank()
745
+ n_local_head = self.config.num_attention_heads // sink_tp_size
746
+ head_rank_start = n_local_head * sink_tp_rank
747
+ head_rank_end = n_local_head * (sink_tp_rank + 1)
748
+
749
+ # Routed-expert weights come in two checkpoint layouts:
750
+ # split: mlp.experts.<id>.gate_proj.weight
751
+ # fused: mlp.experts.gate_up_proj (all experts in one tensor)
752
+ # The split layout is resolved through the shared EPLB helper so the
753
+ # target param names stay in sync with the RoutedExperts module layout;
754
+ # the fused layout is resolved from the live parameter names.
755
+ split_expert_params_mapping = fused_moe_make_expert_params_mapping(
756
+ self,
757
+ ckpt_gate_proj_name="gate_proj",
758
+ ckpt_down_proj_name="down_proj",
759
+ ckpt_up_proj_name="up_proj",
760
+ num_experts=num_experts,
761
+ )
762
+ fused_expert_param_names: dict[tuple[str, str], str] = {}
763
+ for param_name in params_dict:
764
+ for tag in ("w13_weight", "w2_weight"):
765
+ if param_name.endswith(tag) and ".experts." in param_name:
766
+ base = param_name.split(".experts.")[0]
767
+ fused_expert_param_names[base, tag] = param_name
768
+ stacked_mapping = [
769
+ (".gate_up_proj", ".gate_proj", 0),
770
+ (".gate_up_proj", ".up_proj", 1),
771
+ # MLA runs both latent down-projections as one GEMM.
772
+ (".fused_qkv_a_proj", ".q_a_proj", 0),
773
+ (".fused_qkv_a_proj", ".kv_a_proj_with_mqa", 1),
774
+ ]
775
+ # Sparse DSA draft blocks build an Indexer whose wk / weights_proj are
776
+ # fused into a single MergedColumnParallelLinear (wk_weights_proj).
777
+ indexer_stacked_mapping = [
778
+ (".wk_weights_proj", ".wk", 0),
779
+ (".wk_weights_proj", ".weights_proj", 1),
780
+ ]
781
+ # FP8 indexer wk dequant buffer (weight and scale arrive separately).
782
+ pending_wk_fp8: dict[str, dict[str, torch.Tensor]] = {}
783
+
784
+ for name, loaded_weight in weights:
785
+ if name in shared_weights:
786
+ target_name = shared_weights[name]
787
+ if target_name in params_dict:
788
+ param = params_dict[target_name]
789
+ weight_loader = getattr(
790
+ param, "weight_loader", default_weight_loader
791
+ )
792
+ weight_loader(param, loaded_weight)
793
+ loaded_params.add(target_name)
794
+ continue
795
+
796
+ spec_layer = None
797
+ if name.startswith("model.mtp_layers."):
798
+ parts = name.split(".")
799
+ if len(parts) > 3 and parts[2].isdigit():
800
+ spec_layer = mtp_start + int(parts[2])
801
+ name = name.replace(
802
+ f"model.mtp_layers.{parts[2]}.",
803
+ f"model.layers.{spec_layer}.",
804
+ )
805
+ else:
806
+ spec_layer = _get_spec_layer_idx_from_weight_name(self.config, name)
807
+
808
+ if spec_layer is None:
809
+ continue
810
+
811
+ name = self._rewrite_spec_layer_name(spec_layer, name)
812
+ if name == "__skip__":
813
+ continue
814
+ name, loaded_weight = _prepare_mtp_fp8_expert_scale(
815
+ self.quant_config, name, loaded_weight
816
+ )
817
+
818
+ if "mlp.gate.e_score_correction_bias" in name:
819
+ name = name.replace("gate.e_score_correction_bias", "expert_bias")
820
+
821
+ is_loaded = False
822
+ for param_name, weight_name, shard_id in stacked_mapping:
823
+ if weight_name not in name or ".experts." in name:
824
+ continue
825
+ name_mapped = name.replace(weight_name, param_name)
826
+ if name_mapped not in params_dict:
827
+ if is_pp_missing_parameter(name_mapped, self):
828
+ is_loaded = True
829
+ break
830
+ param = params_dict[name_mapped]
831
+ param.weight_loader(param, loaded_weight, shard_id)
832
+ loaded_params.add(name_mapped)
833
+ is_loaded = True
834
+ break
835
+ if is_loaded:
836
+ continue
837
+
838
+ # FP8 indexer wk: dequantize to BF16 and load into the fused
839
+ # wk_weights_proj. PP-aware (skips layers not held by this rank).
840
+ if _try_load_fp8_indexer_wk(
841
+ name,
842
+ loaded_weight,
843
+ pending_wk_fp8,
844
+ params_dict,
845
+ loaded_params,
846
+ pp_missing_layer_names,
847
+ ):
848
+ continue
849
+
850
+ # BF16 indexer wk / weights_proj: merge into the fused param.
851
+ is_loaded = False
852
+ for param_name, weight_name, shard_id in indexer_stacked_mapping:
853
+ if weight_name not in name or "wk_weights" in name:
854
+ continue
855
+ name_mapped = name.replace(weight_name, param_name)
856
+ if name_mapped not in params_dict:
857
+ if is_pp_missing_parameter(name_mapped, self):
858
+ is_loaded = True
859
+ break
860
+ param = params_dict[name_mapped]
861
+ param.weight_loader(param, loaded_weight, shard_id)
862
+ loaded_params.add(name_mapped)
863
+ is_loaded = True
864
+ break
865
+ if is_loaded:
866
+ continue
867
+
868
+ is_loaded = False
869
+ if ".experts." in name:
870
+ is_loaded = self._load_expert_weight(
871
+ name,
872
+ loaded_weight,
873
+ params_dict,
874
+ loaded_params,
875
+ split_expert_params_mapping,
876
+ fused_expert_param_names,
877
+ num_experts,
878
+ )
879
+ if is_loaded:
880
+ continue
881
+
882
+ if "learnable_sink_param" in name:
883
+ if name in params_dict:
884
+ narrow_weight = loaded_weight[head_rank_start:head_rank_end]
885
+ n = narrow_weight.shape[0]
886
+ with torch.no_grad():
887
+ params_dict[name][:n].copy_(narrow_weight)
888
+ loaded_params.add(name)
889
+ continue
890
+
891
+ remapped_name = maybe_remap_kv_scale_name(name, params_dict)
892
+ if remapped_name is None:
893
+ continue
894
+ name = remapped_name
895
+ if name not in params_dict:
896
+ if is_pp_missing_parameter(name, self):
897
+ continue
898
+ if _should_skip_missing_mtp_scale_param(self.quant_config, name):
899
+ continue
900
+ logger.warning_once("Skipping unknown MTP weight: %s", name)
901
+ continue
902
+
903
+ param = params_dict[name]
904
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
905
+ weight_loader(param, loaded_weight)
906
+ loaded_params.add(name)
907
+
908
+ logger.info_once("HYV4 MTP draft model loaded: %d params", len(loaded_params))
909
+ unassigned_all = sorted(set(params_dict) - loaded_params)
910
+ # KVCacheScaleParameter (``k_scale`` / ``v_scale`` / ``q_scale`` /
911
+ # ``prob_scale``) is created by ``BaseKVCacheMethod.create_weights`` for
912
+ # every Attention layer that runs on an fp8-family quant method, and
913
+ # its -1.0 sentinel is replaced by the runtime default (1.0) in
914
+ # ``process_weights_after_loading`` -- which runs *after* this method.
915
+ # HY V4 checkpoints intentionally omit these (``kv_cache_quant_algo:
916
+ # null``); silence them only when the parameter is actually the
917
+ # sentinel type so a genuine mismatch on a same-named tensor still
918
+ # reaches the warning.
919
+ from vllm.model_executor.layers.quantization.kv_cache import (
920
+ KVCacheScaleParameter,
921
+ )
922
+
923
+ unassigned = [
924
+ name
925
+ for name in unassigned_all
926
+ if not isinstance(params_dict.get(name), KVCacheScaleParameter)
927
+ ]
928
+ if unassigned:
929
+ # A draft parameter with no checkpoint source keeps its sentinel
930
+ # init value (FP8 scales start at finfo(float32).min), which
931
+ # silently destroys draft quality instead of failing the load.
932
+ logger.warning(
933
+ "HYV4 MTP draft model: %d parameters received no checkpoint value: %s",
934
+ len(unassigned),
935
+ ", ".join(unassigned),
936
+ )
937
+ return loaded_params
vllm/parser/harmony.py
+2
-1
@@ -451,9 +451,10 @@ def get_harmony_structural_tag(
451
builtin_tools: list[BuiltinToolParam],
452
tool_choice: SimplifiedToolChoice,
453
reasoning: bool,
454
+ token_suffix: str = "",
455
) -> StructuralTag:
456
# reasoning always enabled for Harmony
456
- del reasoning
457
+ del reasoning, token_suffix
458
459
if builtin_tools:
460
# Fallback for built-in tools
vllm/reasoning/__init__.py
+4
@@ -84,6 +84,10 @@ _REASONING_PARSERS_TO_REGISTER = {
84
"hy_v3_reasoning_parser",
85
"HYV3ReasoningParser",
86
),
87
+ "hy_v4": (
88
+ "hy_v4_reasoning_parser",
89
+ "HYV4ReasoningParser",
90
+ ),
91
"kimi_k2": (
92
"kimi_k2_reasoning_parser",
93
"KimiK2ReasoningParser",
vllm/reasoning/hy_v4_reasoning_parser.py
new
+324
@@ -0,0 +1,324 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from __future__ import annotations
5
+
6
+from collections.abc import Iterable, Sequence
7
+from typing import TypedDict
8
+
9
+import regex as re
10
+
11
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
12
+from vllm.entrypoints.openai.engine.protocol import DeltaMessage
13
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
14
+from vllm.logger import init_logger
15
+from vllm.reasoning import ReasoningParser
16
+from vllm.tokenizers import TokenizerLike
17
+
18
+logger = init_logger(__name__)
19
+
20
+# Must match the chat template's own default for ``reasoning_effort``.
21
+DEFAULT_REASONING_EFFORT = "high"
22
+
23
+
24
+class ReasoningDelta(TypedDict):
25
+ """One streaming reasoning delta. Either/both fields may be None."""
26
+
27
+ reasoning: str | None
28
+ content: str | None
29
+
30
+
31
+# NOTE: mirrored in ``vllm.tool_parsers.hy_v4_tool_parser`` (same pattern as
32
+# ``gemma4_utils``) so neither package depends on the other.
33
+def detect_token_suffix(tokenizer: TokenizerLike) -> str:
34
+ """Detect the per-checkpoint suffix used by Hunyuan structural tokens.
35
+
36
+ Args:
37
+ tokenizer: Tokenizer of the served checkpoint.
38
+
39
+ Returns:
40
+ The suffix including its leading colon (e.g. ``":6124c78e"``), or ``""``
41
+ when the checkpoint uses unsuffixed tokens.
42
+
43
+ Raises:
44
+ RuntimeError: The tokenizer declares the structural tokens through
45
+ ``model_specific_special_tokens``, which transformers 5 no longer
46
+ round-trips.
47
+ """
48
+
49
+ import transformers
50
+
51
+ if int(transformers.__version__.split(".")[0]) >= 5:
52
+ init_kwargs = getattr(tokenizer, "init_kwargs", None) or {}
53
+ think_begin_as_special = init_kwargs.get(
54
+ "model_specific_special_tokens", {}
55
+ ).get("think_begin_token", "")
56
+ if think_begin_as_special:
57
+ raise RuntimeError(
58
+ "This checkpoint declares HYV4 structural tokens (think_begin_token"
59
+ "/toolcalls_begin_token/argkey_begin_token) in "
60
+ "tokenizer_config.json, which transformers 5 no longer supports. "
61
+ "Remove those fields and keep the tokens in the tokenizer's own "
62
+ "token definitions so the suffix can be read from the vocab."
63
+ )
64
+
65
+ structural_token_re = re.compile(
66
+ r"<(?:think|tool_calls|tool_call|arg_key|arg_value)(:[^\s>]+)?>"
67
+ )
68
+ for token in tokenizer.get_vocab():
69
+ match = structural_token_re.fullmatch(token)
70
+ if match:
71
+ return match.group(1) or ""
72
+
73
+ return ""
74
+
75
+
76
+class HYV4ReasoningExtractor:
77
+ """Reasoning extraction for HYV4, on plain data.
78
+
79
+ ``thinking`` selects the mode:
80
+ - ``True``: parse ``<think>...</think>``; the start token is injected at the
81
+ end of the prompt and is therefore usually absent from the output.
82
+ - ``False``: ``no_think`` -- the whole output is content, no reasoning.
83
+ """
84
+
85
+ def __init__(self, vocab: dict[str, int], token_suffix: str, thinking: bool = True):
86
+ self.thinking = thinking
87
+ self.start_token: str = f"<think{token_suffix}>"
88
+ self.end_token: str = f"</think{token_suffix}>"
89
+
90
+ start_token_id = vocab.get(self.start_token)
91
+ end_token_id = vocab.get(self.end_token)
92
+ if thinking and (start_token_id is None or end_token_id is None):
93
+ raise RuntimeError(
94
+ "HYV4 reasoning extractor could not locate think "
95
+ "start/end tokens in the tokenizer!"
96
+ )
97
+ # In no_think mode the ids are unused (kept for symmetry / debugging).
98
+ self.start_token_id: int | None = start_token_id
99
+ self.end_token_id: int | None = end_token_id
100
+
101
+ def has_reasoning_ended(self, token_ids: Sequence[int]) -> bool:
102
+ """Idempotent STATE query: has ``</think>`` been emitted yet?
103
+
104
+ Monotonic -- once the end token is present this stays True. Use for
105
+ "has reasoning finished, given everything so far". (no_think is always
106
+ ended.) Contrast with ``reasoning_ended_in_delta`` (edge detector).
107
+ """
108
+ if not self.thinking:
109
+ return True
110
+ for i in range(len(token_ids) - 1, -1, -1):
111
+ if token_ids[i] == self.start_token_id:
112
+ return False
113
+ if token_ids[i] == self.end_token_id:
114
+ return True
115
+ return False
116
+
117
+ def reasoning_ended_in_delta(
118
+ self, token_ids: Sequence[int], delta_ids: Iterable[int]
119
+ ) -> bool:
120
+ """Edge detector (NOT idempotent): did reasoning end *in this* step?
121
+
122
+ True only on the step whose ``delta_ids`` contains ``</think>``. Callers
123
+ use it once behind their own "already ended" latch, so it need not be
124
+ monotonic; it only checks the new delta (cheaper than scanning the whole
125
+ sequence). Contrast with ``has_reasoning_ended`` (cumulative state).
126
+ """
127
+ if not self.thinking:
128
+ return True
129
+ end_token_id = self.end_token_id
130
+ if end_token_id is None:
131
+ return False
132
+ return end_token_id in delta_ids
133
+
134
+ def extract_content_ids(self, input_ids: list[int]) -> list[int]:
135
+ """Return the token ids after ``</think>`` (all of them in no_think)."""
136
+ if not self.thinking:
137
+ return input_ids
138
+ head = input_ids[: max(0, len(input_ids) - 1)]
139
+ if self.end_token_id not in head:
140
+ return []
141
+ return input_ids[input_ids.index(self.end_token_id) + 1 :]
142
+
143
+ def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
144
+ """Count reasoning tokens (0 in no_think).
145
+
146
+ The ``<think>`` start token is injected at the END of the prompt, so it
147
+ is normally absent from ``token_ids``. Only skip a leading start token
148
+ when it actually appears first (legacy / no prompt-injection); otherwise
149
+ count every token up to ``</think>``.
150
+ """
151
+ if not self.thinking:
152
+ return 0
153
+ start = 1 if (token_ids and token_ids[0] == self.start_token_id) else 0
154
+ try:
155
+ return token_ids.index(self.end_token_id, start) - start
156
+ except ValueError:
157
+ # No end token -- reasoning was truncated; everything counts.
158
+ return len(token_ids) - start
159
+
160
+ def extract_reasoning(self, model_output: str) -> tuple[str | None, str | None]:
161
+ """Split a complete output into ``(reasoning, content)``.
162
+
163
+ In no_think mode reasoning is None and the whole output is content. The
164
+ start token may be absent (injected in the prompt), so reasoning is
165
+ assumed to begin at the start of the output.
166
+ """
167
+ if not self.thinking:
168
+ return None, model_output
169
+
170
+ parts = model_output.partition(self.start_token)
171
+ model_output = parts[2] if parts[1] else parts[0]
172
+
173
+ if self.end_token not in model_output:
174
+ return model_output, None
175
+ reasoning, _, content = model_output.partition(self.end_token)
176
+ return reasoning, content or None
177
+
178
+ def extract_reasoning_streaming(
179
+ self,
180
+ previous_text: str,
181
+ current_text: str,
182
+ delta_text: str,
183
+ previous_token_ids: Sequence[int],
184
+ current_token_ids: Sequence[int],
185
+ delta_token_ids: Sequence[int],
186
+ ) -> ReasoningDelta | None:
187
+ """Classify a streaming delta as reasoning and/or content.
188
+
189
+ In no_think mode every delta is content. Otherwise: same logic as vLLM's
190
+ generic thinking parser, except the final branch (no start token seen
191
+ anywhere) treats output as reasoning-then-content, because HYV4 injects
192
+ ``<think>`` in the prompt (absent from the output).
193
+ """
194
+ if not self.thinking:
195
+ if not delta_text:
196
+ return None
197
+ return ReasoningDelta(reasoning=None, content=delta_text)
198
+
199
+ # Skip a delta that is a single lone start/end special token.
200
+ if len(delta_token_ids) == 1 and delta_token_ids[0] in (
201
+ self.start_token_id,
202
+ self.end_token_id,
203
+ ):
204
+ return None
205
+
206
+ if self.start_token_id in previous_token_ids:
207
+ if self.end_token_id in delta_token_ids:
208
+ end_index = delta_text.find(self.end_token)
209
+ reasoning = delta_text[:end_index]
210
+ content = delta_text[end_index + len(self.end_token) :]
211
+ return ReasoningDelta(reasoning=reasoning, content=content or None)
212
+ elif self.end_token_id in previous_token_ids:
213
+ return ReasoningDelta(reasoning=None, content=delta_text)
214
+ else:
215
+ return ReasoningDelta(reasoning=delta_text, content=None)
216
+ elif self.start_token_id in delta_token_ids:
217
+ if self.end_token_id in delta_token_ids:
218
+ start_index = delta_text.find(self.start_token)
219
+ end_index = delta_text.find(self.end_token)
220
+ reasoning = delta_text[start_index + len(self.start_token) : end_index]
221
+ content = delta_text[end_index + len(self.end_token) :]
222
+ return ReasoningDelta(reasoning=reasoning, content=content or None)
223
+ else:
224
+ return ReasoningDelta(reasoning=delta_text, content=None)
225
+ else:
226
+ # HYV4: <think> was injected at the end of the prompt, so it never
227
+ # appears in the output. Treat the stream as reasoning until
228
+ # </think>, then content.
229
+ if self.end_token_id in delta_token_ids:
230
+ end_index = delta_text.find(self.end_token)
231
+ reasoning = delta_text[:end_index]
232
+ content = delta_text[end_index + len(self.end_token) :]
233
+ return ReasoningDelta(reasoning=reasoning, content=content or None)
234
+ elif self.end_token_id in previous_token_ids:
235
+ return ReasoningDelta(reasoning=None, content=delta_text)
236
+ else:
237
+ return ReasoningDelta(reasoning=delta_text, content=None)
238
+
239
+
240
+def build_reasoning_extractor(
241
+ tokenizer: TokenizerLike, *, thinking: bool = True
242
+) -> HYV4ReasoningExtractor:
243
+ return HYV4ReasoningExtractor(
244
+ tokenizer.get_vocab(), detect_token_suffix(tokenizer), thinking
245
+ )
246
+
247
+
248
+class HYV4ReasoningParser(ReasoningParser):
249
+ """vLLM adapter around :class:`HYV4ReasoningExtractor`.
250
+
251
+ Decides ``thinking`` from the request's ``reasoning_effort`` and converts
252
+ the extractor's ``ReasoningDelta`` to ``DeltaMessage``.
253
+ """
254
+
255
+ def __init__(self, tokenizer: TokenizerLike, *args, **kwargs):
256
+ super().__init__(tokenizer, *args, **kwargs)
257
+
258
+ # Decide thinking vs no_think from the request; the extractor handles
259
+ # both modes internally. Prefer chat_template_kwargs.reasoning_effort,
260
+ # then the top-level reasoning_effort. The default must match the chat
261
+ # template, which sets reasoning_effort='high' when the request omits
262
+ # it -- defaulting to no_think here would return the whole
263
+ # chain-of-thought (and a stray ``</think:SUF>``) as content.
264
+ chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
265
+ reasoning_effort = chat_kwargs.get("reasoning_effort", None)
266
+ if not reasoning_effort:
267
+ reasoning_effort = (
268
+ kwargs.get("reasoning_effort") or DEFAULT_REASONING_EFFORT
269
+ )
270
+ logger.debug("reasoning_effort for choosing parser: %s", reasoning_effort)
271
+ thinking = reasoning_effort != "no_think"
272
+
273
+ self._extractor = build_reasoning_extractor(tokenizer, thinking=thinking)
274
+
275
+ # Reported regardless of the per-request thinking mode: ``ReasoningConfig``
276
+ # is engine-wide and needs the checkpoint's delimiters to derive the token
277
+ # ids used for thinking budget enforcement.
278
+ @property
279
+ def reasoning_start_str(self) -> str:
280
+ return self._extractor.start_token
281
+
282
+ @property
283
+ def reasoning_end_str(self) -> str:
284
+ return self._extractor.end_token
285
+
286
+ def is_reasoning_end(self, input_ids) -> bool:
287
+ return self._extractor.has_reasoning_ended(input_ids)
288
+
289
+ def is_reasoning_end_streaming(self, input_ids, delta_ids) -> bool:
290
+ return self._extractor.reasoning_ended_in_delta(input_ids, delta_ids)
291
+
292
+ def extract_content_ids(self, input_ids) -> list[int]:
293
+ return self._extractor.extract_content_ids(input_ids)
294
+
295
+ def count_reasoning_tokens(self, token_ids) -> int:
296
+ return self._extractor.count_reasoning_tokens(token_ids)
297
+
298
+ def extract_reasoning(
299
+ self,
300
+ model_output: str,
301
+ request: ChatCompletionRequest | ResponsesRequest,
302
+ ) -> tuple[str | None, str | None]:
303
+ return self._extractor.extract_reasoning(model_output)
304
+
305
+ def extract_reasoning_streaming(
306
+ self,
307
+ previous_text: str,
308
+ current_text: str,
309
+ delta_text: str,
310
+ previous_token_ids,
311
+ current_token_ids,
312
+ delta_token_ids,
313
+ ) -> DeltaMessage | None:
314
+ delta = self._extractor.extract_reasoning_streaming(
315
+ previous_text,
316
+ current_text,
317
+ delta_text,
318
+ previous_token_ids,
319
+ current_token_ids,
320
+ delta_token_ids,
321
+ )
322
+ if delta is None:
323
+ return None
324
+ return DeltaMessage(reasoning=delta["reasoning"], content=delta["content"])
vllm/tool_parsers/__init__.py
+4
@@ -94,6 +94,10 @@ _TOOL_PARSERS_TO_REGISTER = {
94
"hy_v3_tool_parser",
95
"HYV3ToolParser",
96
),
97
+ "hy_v4": (
98
+ "hy_v4_tool_parser",
99
+ "HYV4ToolParser",
100
+ ),
101
"internlm": (
102
"internlm2_tool_parser",
103
"Internlm2ToolParser",
vllm/tool_parsers/hy_v4_tool_parser.py
new
+1166
@@ -0,0 +1,1166 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from __future__ import annotations
5
+
6
+import ast
7
+import json
8
+from typing import Any, TypedDict
9
+
10
+import regex as re
11
+
12
+import vllm.envs as envs
13
+from vllm.entrypoints.chat_utils import make_tool_call_id
14
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
15
+from vllm.entrypoints.openai.engine.protocol import (
16
+ DeltaFunctionCall,
17
+ DeltaMessage,
18
+ DeltaToolCall,
19
+ ExtractedToolCallInformation,
20
+ FunctionCall,
21
+ ToolCall,
22
+)
23
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
24
+from vllm.logger import init_logger
25
+from vllm.tokenizers import TokenizerLike
26
+from vllm.tool_parsers.abstract_tool_parser import ToolParser
27
+from vllm.tool_parsers.utils import partial_tag_overlap
28
+
29
+logger = init_logger(__name__)
30
+
31
+# =============================================================================
32
+# Plain-dict contract with the vLLM-side wrapper
33
+# =============================================================================
34
+
35
+
36
+class ToolSchema(TypedDict):
37
+ """A tool as seen by this parser (built by the wrapper from ``request.tools``)."""
38
+
39
+ name: str
40
+ parameters: dict[str, Any] | None # JSON Schema, or None
41
+
42
+
43
+class ToolCallDict(TypedDict):
44
+ """One extracted tool call. ``arguments`` is a JSON-encoded string."""
45
+
46
+ name: str
47
+ arguments: str
48
+
49
+
50
+class ExtractResult(TypedDict):
51
+ """Return of ``extract_tool_calls`` (non-streaming)."""
52
+
53
+ tools_called: bool
54
+ content: str | None
55
+ tool_calls: list[ToolCallDict]
56
+
57
+
58
+class StreamToolCall(TypedDict):
59
+ """One streaming tool-call delta.
60
+
61
+ ``new`` is True only on the chunk that first emits this tool's name -- the
62
+ wrapper mints an id + sets type on that chunk only. ``arguments`` is the
63
+ incremental JSON diff (or None).
64
+ """
65
+
66
+ index: int
67
+ new: bool
68
+ name: str | None
69
+ arguments: str | None
70
+
71
+
72
+class StreamDelta(TypedDict):
73
+ """Return of ``extract_tool_calls_streaming``.
74
+
75
+ A single engine delta can carry content plus every tool call that was
76
+ drained from the buffer (batched decode), so ``tool_calls`` is not limited
77
+ to one entry.
78
+ """
79
+
80
+ content: str | None
81
+ tool_calls: list[StreamToolCall]
82
+
83
+
84
+# NOTE: mirrored in ``vllm.reasoning.hy_v4_reasoning_parser`` (same pattern as
85
+# ``gemma4_utils``) so neither package depends on the other.
86
+def detect_token_suffix(tokenizer: TokenizerLike) -> str:
87
+ """Detect the per-checkpoint suffix used by Hunyuan structural tokens.
88
+
89
+ Args:
90
+ tokenizer: Tokenizer of the served checkpoint.
91
+
92
+ Returns:
93
+ The suffix including its leading colon (e.g. ``":6124c78e"``), or ``""``
94
+ when the checkpoint uses unsuffixed tokens.
95
+
96
+ Raises:
97
+ RuntimeError: The tokenizer declares the structural tokens through
98
+ ``model_specific_special_tokens``, which transformers 5 no longer
99
+ round-trips.
100
+ """
101
+
102
+ import transformers
103
+
104
+ if int(transformers.__version__.split(".")[0]) >= 5:
105
+ init_kwargs = getattr(tokenizer, "init_kwargs", None) or {}
106
+ think_begin_as_special = init_kwargs.get(
107
+ "model_specific_special_tokens", {}
108
+ ).get("think_begin_token", "")
109
+ if think_begin_as_special:
110
+ raise RuntimeError(
111
+ "This checkpoint declares HYV4 structural tokens (think_begin_token"
112
+ "/toolcalls_begin_token/argkey_begin_token) in "
113
+ "tokenizer_config.json, which transformers 5 no longer supports. "
114
+ "Remove those fields and keep the tokens in the tokenizer's own "
115
+ "token definitions so the suffix can be read from the vocab."
116
+ )
117
+
118
+ structural_token_re = re.compile(
119
+ r"<(?:think|tool_calls|tool_call|arg_key|arg_value)(:[^\s>]+)?>"
120
+ )
121
+ for token in tokenizer.get_vocab():
122
+ match = structural_token_re.fullmatch(token)
123
+ if match:
124
+ return match.group(1) or ""
125
+
126
+ return ""
127
+
128
+
129
+# =============================================================================
130
+# Argument-value parsing utilities (stateless, pure functions)
131
+#
132
+# These map the model's raw ``<arg_value>`` text to typed Python values using
133
+# the tool's JSON Schema. They do not touch any parser state, so they live at
134
+# module level instead of tangling up HYV4ToolExtractor.
135
+# =============================================================================
136
+
137
+_TYPE_ALIASES: dict[str, str] = {
138
+ "str": "string",
139
+ "text": "string",
140
+ "varchar": "string",
141
+ "char": "string",
142
+ "enum": "string",
143
+ "bool": "boolean",
144
+ "binary": "boolean",
145
+ "int": "integer",
146
+ "float": "number",
147
+ "double": "number",
148
+ "list": "array",
149
+ "dict": "object",
150
+ "map": "object",
151
+}
152
+
153
+# Prefix-based wildcard matching for non-standard type names.
154
+# Following the same approach as qwen3coder_tool_parser._convert_param_value
155
+# which uses param_type.startswith("int"), startswith("uint"), etc.
156
+_INTEGER_PREFIXES: tuple[str, ...] = ("int", "uint", "long", "short", "unsigned")
157
+_NUMBER_PREFIXES: tuple[str, ...] = ("num", "float")
158
+
159
+
160
+def _normalize_type(raw_type: str) -> str:
161
+ """Map non-standard type aliases to JSON Schema standard names.
162
+
163
+ First performs exact lookup in _TYPE_ALIASES. On miss, falls back to
164
+ prefix-based matching using startswith()
165
+ - int*/uint*/long*/short*/unsigned* → "integer"
166
+ - num*/float* → "number"
167
+ """
168
+ exact = _TYPE_ALIASES.get(raw_type)
169
+ if exact is not None:
170
+ return exact
171
+ lower = raw_type.lower()
172
+ if any(lower.startswith(p) for p in _INTEGER_PREFIXES):
173
+ return "integer"
174
+ if any(lower.startswith(p) for p in _NUMBER_PREFIXES):
175
+ return "number"
176
+ return raw_type
177
+
178
+
179
+def _get_arg_schema(
180
+ function_name: str,
181
+ arg_key: str,
182
+ tools: list[ToolSchema] | None,
183
+) -> dict[str, Any]:
184
+ """Look up a specific argument's property schema from the tools list."""
185
+ if tools is None:
186
+ return {}
187
+ for tool in tools:
188
+ if tool.get("name") == function_name:
189
+ parameters = tool.get("parameters")
190
+ if parameters is None:
191
+ return {}
192
+ return parameters.get("properties", {}).get(arg_key, {})
193
+ logger.warning_once("No tool named '%s'.", function_name)
194
+ return {}
195
+
196
+
197
+def _get_schema_options(arg_schema: dict[str, Any]) -> list[dict[str, Any]]:
198
+ """Normalize any property schema into a list of sub-schemas.
199
+ - has type (single type) → return [arg_schema]
200
+ - anyOf → return the anyOf list
201
+ - oneOf → return the oneOf list
202
+ - fallback → [{"type": "string"}]
203
+
204
+ Note: single ``type`` has the highest priority.
205
+ """
206
+ if "type" in arg_schema:
207
+ type_val = arg_schema["type"]
208
+ # JSON Schema allows "type" to be an array to represent union types,
209
+ # e.g. "type": ["string", "object"].
210
+ # Expand it into an anyOf-equivalent format:
211
+ # [{"type": "string"}, {"type": "object"}]
212
+ # so that _get_types / _parse_value can handle it uniformly later.
213
+ if isinstance(type_val, list):
214
+ return [{"type": t} for t in type_val]
215
+ return [arg_schema]
216
+ if "anyOf" in arg_schema:
217
+ return arg_schema["anyOf"]
218
+ if "oneOf" in arg_schema:
219
+ return arg_schema["oneOf"]
220
+
221
+ return [{"type": "string"}]
222
+
223
+
224
+def _get_types(arg_schema: dict[str, Any]) -> set[str]:
225
+ """Extract normalized, non-null type set from a property schema."""
226
+ schemas = _get_schema_options(arg_schema)
227
+ return {_normalize_type(s.get("type", "string")) for s in schemas} - {"null"}
228
+
229
+
230
+def _is_only_string_type(
231
+ function_name: str,
232
+ arg_key: str,
233
+ tools: list[ToolSchema] | None,
234
+) -> bool:
235
+ """Return True if the parameter's type set is exactly {"string"}.
236
+
237
+ Only pure string types get partial value streaming; compound types like
238
+ anyOf(string | array) do not, since the partial value might end up being a
239
+ JSON array or object.
240
+ """
241
+ types = _get_types(_get_arg_schema(function_name, arg_key, tools))
242
+ return types == {"string"}
243
+
244
+
245
+def _try_parse_bool(value: str) -> bool | None:
246
+ """Try to parse a string as bool; return None on failure."""
247
+ lower = value.lower()
248
+ if lower == "true":
249
+ return True
250
+ elif lower == "false":
251
+ return False
252
+ return None
253
+
254
+
255
+def _try_parse_int(value: str) -> int | None:
256
+ """Try to parse a string as int; return None on failure."""
257
+ try:
258
+ return int(value)
259
+ except (ValueError, TypeError):
260
+ return None
261
+
262
+
263
+def _try_parse_wildcard_number(value: str) -> int | float | None:
264
+ """Try to parse a string as a number (int or float).
265
+
266
+ Decision rule: if the string contains '.' or 'e'/'E' (scientific notation),
267
+ parse as float; otherwise parse as int.
268
+
269
+ Examples: "5" → 5, "5.0" → 5.0, "1e3" → 1000.0, "-3" → -3.
270
+ Return None on failure.
271
+ """
272
+ try:
273
+ if "." in value or "e" in value or "E" in value:
274
+ return float(value)
275
+ return int(value)
276
+ except (ValueError, TypeError):
277
+ return None
278
+
279
+
280
+def _deserialize(value: str) -> Any:
281
+ """Deserialize a string value using json.loads then ast.literal_eval."""
282
+ try:
283
+ return json.loads(value)
284
+ except Exception:
285
+ pass
286
+ try:
287
+ return ast.literal_eval(value)
288
+ except Exception:
289
+ pass
290
+ return value
291
+
292
+
293
+def _parse_value(
294
+ value: str,
295
+ function_name: str,
296
+ arg_key: str,
297
+ tools: list[ToolSchema] | None,
298
+) -> Any:
299
+ """Unified argument value parser with anyOf/oneOf support.
300
+
301
+ Fallthrough chain:
302
+ bool → int → number(wildcard_number)
303
+ → json.loads for array/object → string → _deserialize
304
+ """
305
+ types = _get_types(_get_arg_schema(function_name, arg_key, tools))
306
+
307
+ # 1. Try bool
308
+ if "boolean" in types:
309
+ result_bool = _try_parse_bool(value)
310
+ if result_bool is not None:
311
+ return result_bool
312
+
313
+ # 2. Try int
314
+ if "integer" in types:
315
+ result_int = _try_parse_int(value)
316
+ if result_int is not None:
317
+ return result_int
318
+
319
+ # 3. Try number (wildcard_number: int if no '.'/e/E, float otherwise)
320
+ if "number" in types:
321
+ result_number = _try_parse_wildcard_number(value)
322
+ if result_number is not None:
323
+ return result_number
324
+
325
+ # 4. Try json.loads (covers array/object and other unlisted types)
326
+ if types - {"string", "boolean", "integer", "number"}:
327
+ try:
328
+ return json.loads(value)
329
+ except (json.JSONDecodeError, ValueError):
330
+ pass
331
+
332
+ # 5. String fallback
333
+ if "string" in types:
334
+ return value
335
+
336
+ # 6. Final fallback
337
+ return _deserialize(value)
338
+
339
+
340
+# =============================================================================
341
+# Stateful tool-call parser
342
+# =============================================================================
343
+
344
+
345
+class HYV4ToolExtractor:
346
+ """Pure tool-call parsing logic for HYV4. Returns plain dicts; no vLLM types.
347
+
348
+ Holds only parser state: the structural token strings/ids, the compiled
349
+ regexes, and the streaming incremental state. Stateless argument parsing is
350
+ delegated to the module-level utilities above.
351
+
352
+ Streaming has two entry paths sharing one incremental argument parser,
353
+ selected by the ``guided`` flag of :meth:`extract_tool_calls_streaming`:
354
+ normal / auto tool choice detects markers on atomic token ids so ordinary
355
+ ``<`` in content is never withheld, while structural-tag guided decoding
356
+ detects them on the string level because the grammar may split a marker
357
+ across sub-word tokens.
358
+ """
359
+
360
+ def __init__(self, vocab: dict[str, int], token_suffix: str, strict: bool):
361
+ self._strict = strict
362
+
363
+ # Kept so the wrapper can build a matching structural tag for
364
+ # constrained decoding without re-scanning the tokenizer vocab.
365
+ self.token_suffix: str = token_suffix
366
+
367
+ # Running state read by the vLLM serving layer (mirrored by the wrapper).
368
+ self.prev_tool_call_arr: list[dict] = []
369
+ self.current_tool_id: int = -1
370
+ self.streamed_args_for_tool: list[str] = []
371
+
372
+ # Streaming state: send tool name first, then return arguments at once
373
+ self._streaming_tool_name: str | None = None # tool name being streamed
374
+
375
+ # State fields for incremental argument streaming
376
+ self._completed_args: dict = {} # closed {key: parsed_value}
377
+ self._current_arg_key: str | None = None # key being collected
378
+ self._current_arg_is_string: bool = False # is current arg pure string?
379
+ self._streamed_json_len: int = 0 # bytes of JSON already sent
380
+
381
+ self.tool_calls_start_token: str = f"<tool_calls{token_suffix}>"
382
+ self.tool_calls_end_token: str = f"</tool_calls{token_suffix}>"
383
+
384
+ self.tool_call_start_token: str = f"<tool_call{token_suffix}>"
385
+ self.tool_call_end_token: str = f"</tool_call{token_suffix}>"
386
+
387
+ self.arg_key_start_token: str = f"<arg_key{token_suffix}>"
388
+ self.arg_key_end_token: str = f"</arg_key{token_suffix}>"
389
+
390
+ self.arg_value_start_token: str = f"<arg_value{token_suffix}>"
391
+ self.arg_value_end_token: str = f"</arg_value{token_suffix}>"
392
+
393
+ logger.debug(
394
+ "HYV4ToolExtractor structural tokens: %s ... %s, suffix=%r, strict=%s",
395
+ self.tool_calls_start_token,
396
+ self.tool_calls_end_token,
397
+ token_suffix,
398
+ strict,
399
+ )
400
+
401
+ # The suffix comes from the tokenizer vocab, so it must not be treated
402
+ # as a pattern.
403
+ self.tool_call_regex = re.compile(
404
+ rf"{re.escape(self.tool_call_start_token)}(.*?)"
405
+ rf"{re.escape(self.tool_call_end_token)}",
406
+ re.DOTALL,
407
+ )
408
+
409
+ self.func_args_regex = re.compile(
410
+ rf"{re.escape(self.arg_key_start_token)}(.*?)"
411
+ rf"{re.escape(self.arg_key_end_token)}"
412
+ rf"{re.escape(self.arg_value_start_token)}(.*?)"
413
+ rf"{re.escape(self.arg_value_end_token)}",
414
+ re.DOTALL,
415
+ )
416
+
417
+ self.tool_calls_start_token_id = vocab.get(self.tool_calls_start_token)
418
+ self.tool_calls_end_token_id = vocab.get(self.tool_calls_end_token)
419
+
420
+ self.tool_call_start_token_id = vocab.get(self.tool_call_start_token)
421
+ self.tool_call_end_token_id = vocab.get(self.tool_call_end_token)
422
+ self._buffer = ""
423
+
424
+ if (
425
+ self.tool_calls_start_token_id is None
426
+ or self.tool_calls_end_token_id is None
427
+ ):
428
+ raise RuntimeError(
429
+ "HYV4 tool extractor could not locate tool call "
430
+ "start/end tokens in the tokenizer!"
431
+ )
432
+
433
+ def _extract_tool_calls(
434
+ self,
435
+ model_output: str,
436
+ tools: list[ToolSchema] | None,
437
+ ) -> list[ToolCallDict]:
438
+ try:
439
+ tool_calls: list[ToolCallDict] = []
440
+ function_calls = self.tool_call_regex.findall(model_output)
441
+ for tool_call_body in function_calls:
442
+ arg_start = tool_call_body.find(self.arg_key_start_token)
443
+ if arg_start == -1:
444
+ function_name = tool_call_body.strip()
445
+ function_args = ""
446
+ else:
447
+ function_name = tool_call_body[:arg_start].strip()
448
+ function_args = tool_call_body[arg_start:].strip()
449
+
450
+ arg_pairs = self.func_args_regex.findall(function_args)
451
+ arg_dict = {}
452
+ for key, value in arg_pairs:
453
+ arg_dict[key] = _parse_value(value, function_name, key, tools)
454
+ tool_calls.append(
455
+ ToolCallDict(
456
+ name=function_name,
457
+ arguments=json.dumps(arg_dict, ensure_ascii=False),
458
+ )
459
+ )
460
+ return tool_calls
461
+ except Exception:
462
+ logger.exception("Error in extracting tool call from response.")
463
+ return []
464
+
465
+ def _extract_tool_calls_strict(
466
+ self,
467
+ model_output: str,
468
+ tools: list[ToolSchema] | None,
469
+ ) -> list[ToolCallDict]:
470
+ start_idx = model_output.find(self.tool_calls_start_token)
471
+ end_idx = model_output.find(self.tool_calls_end_token)
472
+ if start_idx == -1 or end_idx == -1 or end_idx < start_idx:
473
+ raise ValueError("Malformed tool block wrapper.")
474
+
475
+ tool_block = model_output[
476
+ start_idx + len(self.tool_calls_start_token) : end_idx
477
+ ]
478
+ if tool_block.count(self.tool_call_start_token) != tool_block.count(
479
+ self.tool_call_end_token
480
+ ):
481
+ raise ValueError("Malformed tool_call wrapper count.")
482
+
483
+ tool_calls: list[ToolCallDict] = []
484
+ function_calls = self.tool_call_regex.findall(tool_block)
485
+ if tool_block.count(self.tool_call_start_token) != len(function_calls):
486
+ raise ValueError("Malformed tool_call body.")
487
+
488
+ for tool_call_body in function_calls:
489
+ arg_start = tool_call_body.find(self.arg_key_start_token)
490
+ if arg_start == -1:
491
+ function_name = tool_call_body.strip()
492
+ function_args = ""
493
+ else:
494
+ function_name = tool_call_body[:arg_start].strip()
495
+ function_args = tool_call_body[arg_start:].strip()
496
+
497
+ if not function_name:
498
+ raise ValueError("Empty function name in tool call.")
499
+
500
+ if function_args.count(self.arg_key_start_token) != function_args.count(
501
+ self.arg_key_end_token
502
+ ) or function_args.count(self.arg_value_start_token) != function_args.count(
503
+ self.arg_value_end_token
504
+ ):
505
+ raise ValueError("Malformed argument tag count.")
506
+
507
+ arg_pairs = self.func_args_regex.findall(function_args)
508
+ remainder = self.func_args_regex.sub("", function_args)
509
+ if remainder.strip():
510
+ raise ValueError("Unparsed argument payload remains.")
511
+
512
+ arg_dict = {}
513
+ for key, value in arg_pairs:
514
+ arg_dict[key] = _parse_value(value, function_name, key, tools)
515
+
516
+ tool_calls.append(
517
+ ToolCallDict(
518
+ name=function_name,
519
+ arguments=json.dumps(arg_dict, ensure_ascii=False),
520
+ )
521
+ )
522
+
523
+ if not tool_calls:
524
+ raise ValueError("No valid tool calls extracted.")
525
+
526
+ return tool_calls
527
+
528
+ def extract_tool_calls(
529
+ self,
530
+ model_output: str,
531
+ tools: list[ToolSchema] | None,
532
+ ) -> ExtractResult:
533
+ # sanity check; avoid unnecessary processing
534
+ if self.tool_calls_start_token not in model_output:
535
+ return ExtractResult(
536
+ tools_called=False, content=model_output, tool_calls=[]
537
+ )
538
+ else:
539
+ try:
540
+ if self._strict:
541
+ tool_calls = self._extract_tool_calls_strict(model_output, tools)
542
+ else:
543
+ tool_calls = self._extract_tool_calls(model_output, tools)
544
+ if not tool_calls:
545
+ raise ValueError("No valid tool calls extracted.")
546
+
547
+ s_index = model_output.find(self.tool_calls_start_token)
548
+ content = model_output[:s_index] if s_index != -1 else model_output
549
+ return ExtractResult(
550
+ tools_called=True,
551
+ content=content if content else None,
552
+ tool_calls=tool_calls,
553
+ )
554
+
555
+ except Exception:
556
+ logger.warning(
557
+ "HYV4ToolExtractor detected malformed tool output; "
558
+ "falling back to raw content."
559
+ )
560
+ return ExtractResult(
561
+ tools_called=False,
562
+ content=model_output,
563
+ tool_calls=[],
564
+ )
565
+
566
+ def _reset_streaming_tool_state(self):
567
+ """Reset the streaming state for a single tool call."""
568
+ self._streaming_tool_name = None
569
+ self._completed_args = {}
570
+ self._current_arg_key = None
571
+ self._current_arg_is_string = False
572
+ self._streamed_json_len = 0
573
+
574
+ def extract_tool_calls_streaming(
575
+ self,
576
+ previous_text: str,
577
+ current_text: str,
578
+ delta_text: str,
579
+ previous_token_ids,
580
+ current_token_ids,
581
+ delta_token_ids,
582
+ tools: list[ToolSchema] | None,
583
+ *,
584
+ guided: bool = False,
585
+ ) -> StreamDelta | None:
586
+ """Consume one engine delta and emit content / tool-call deltas.
587
+
588
+ Two entry paths share the same incremental argument parser:
589
+ - ``guided=False`` (normal / auto tool choice): the structural markers
590
+ arrive as atomic tokenizer tokens, so detection is done on token ids
591
+ and ordinary ``<`` in model content is never withheld.
592
+ - ``guided=True`` (structural-tag constrained decoding): the grammar
593
+ only constrains strings, so ``<tool_calls...>`` may be split across
594
+ sub-word tokens and must be detected on the string level.
595
+
596
+ Args:
597
+ previous_text: Text decoded before this delta.
598
+ current_text: Full text decoded so far.
599
+ delta_text: Text decoded in this delta.
600
+ previous_token_ids: Token ids before this delta.
601
+ current_token_ids: Token ids decoded so far.
602
+ delta_token_ids: Token ids in this delta.
603
+ tools: Plain tool schemas used for argument typing.
604
+ guided: Whether guided decoding may split markers into sub-word
605
+ tokens.
606
+
607
+ Returns:
608
+ A streaming delta carrying content and/or the tool calls drained
609
+ from the buffer, or None when nothing can be emitted yet.
610
+ """
611
+ content_delta: str | None = None
612
+ tool_calls: list[StreamToolCall] = []
613
+
614
+ if guided:
615
+ # Guided path: a structural-tag grammar constrains strings, not
616
+ # token ids, so the atomic ``tool_calls_start_token_id`` never
617
+ # appears in the token stream and an id-based check would
618
+ # misclassify every tool delta as plain content.
619
+ marker = self.tool_calls_start_token
620
+ marker_pos = current_text.find(marker)
621
+
622
+ if marker_pos == -1:
623
+ # Marker not seen yet. The tail of ``current_text`` might still
624
+ # be a partial prefix of the marker split across deltas; hold
625
+ # that tail back and only stream the safe portion as content.
626
+ safe_end = len(current_text) - partial_tag_overlap(current_text, marker)
627
+ # Emit only the newly-arrived, safe content (delta relative to
628
+ # what was previously streamed).
629
+ content = current_text[len(previous_text) : safe_end]
630
+ return StreamDelta(
631
+ content=content if content else None,
632
+ tool_calls=[],
633
+ )
634
+
635
+ # Marker present. Stream any content that precedes it (only the
636
+ # part we have not already emitted), then buffer the tool payload.
637
+ #
638
+ # The payload is everything after the marker in the *full* text. We
639
+ # keep the buffer in sync with it by only appending the portion that
640
+ # arrived in this delta, mirroring the incremental ``+=`` semantics
641
+ # while remaining correct when the marker itself was split across
642
+ # sub-word tokens.
643
+ content_end = marker_pos
644
+ content = current_text[len(previous_text) : content_end]
645
+
646
+ payload_start = marker_pos + len(marker)
647
+ # How much of the payload had already been buffered before this
648
+ # delta.
649
+ prev_payload_len = max(len(previous_text) - payload_start, 0)
650
+ new_payload = current_text[payload_start + prev_payload_len :]
651
+ self._buffer += new_payload
652
+
653
+ if content:
654
+ content_delta = content
655
+ else:
656
+ # Atomic-token path: normal / auto generation emits the HYV4
657
+ # structural markers as single tokenizer tokens. Pass ordinary
658
+ # content through exactly as decoded, including comparison
659
+ # operators such as ``<``.
660
+ if self.tool_calls_start_token_id not in current_token_ids:
661
+ return StreamDelta(content=delta_text, tool_calls=[])
662
+
663
+ if self.tool_calls_start_token in delta_text:
664
+ text_parts = delta_text.split(self.tool_calls_start_token)
665
+ self._buffer += text_parts[-1]
666
+ if text_parts[0]:
667
+ content_delta = text_parts[0]
668
+ else:
669
+ self._buffer += delta_text
670
+
671
+ # Encountered finish, extract valid arguments
672
+ if (
673
+ current_text.find(self.tool_call_end_token + self.tool_calls_end_token)
674
+ != -1
675
+ and self._buffer.find(self.tool_call_end_token) == -1
676
+ ):
677
+ self._buffer += self.tool_call_end_token + self.tool_calls_end_token
678
+
679
+ # Drain every complete tool call that is already buffered so a single
680
+ # engine delta can carry content + N tool calls (batched decode).
681
+ while True:
682
+ cur_text = self._buffer
683
+
684
+ # Haven't encountered tool_call start tag yet; drop the inter-tag
685
+ # padding but keep any tail that may be a partial tool_call start
686
+ # tag (guided decoding can split it across deltas).
687
+ start_idx = cur_text.find(self.tool_call_start_token)
688
+ if start_idx == -1 and self._streaming_tool_name is None:
689
+ keep = partial_tag_overlap(cur_text, self.tool_call_start_token)
690
+ self._buffer = cur_text[len(cur_text) - keep :] if keep else ""
691
+ break
692
+
693
+ # === Phase 1: Detect tool name (send when first arg_key is seen) =
694
+ # ``name_new`` mirrors the old ``name_delta is not None``: it marks
695
+ # the chunk that first emits this tool's name, which is the only
696
+ # chunk the wrapper mints an id + type for.
697
+ name_new = False
698
+ pending_name: str | None = None
699
+ if self._streaming_tool_name is None:
700
+ arg_idx = cur_text.find(self.arg_key_start_token, start_idx)
701
+ end_idx = cur_text.find(self.tool_call_end_token, start_idx)
702
+ if arg_idx == -1 and end_idx == -1:
703
+ # tool name not yet complete; keep buffering from
704
+ # tool_call_start
705
+ self._buffer = cur_text[start_idx:]
706
+ break
707
+
708
+ name_start = start_idx + len(self.tool_call_start_token)
709
+ name_end = arg_idx if arg_idx != -1 else end_idx
710
+
711
+ tool_name = cur_text[name_start:name_end].strip()
712
+ self._streaming_tool_name = tool_name
713
+
714
+ if arg_idx != -1:
715
+ self._buffer = cur_text[arg_idx:]
716
+ else:
717
+ self._buffer = cur_text[end_idx:]
718
+
719
+ # Increment tool_id and mark that a name chunk should be emitted
720
+ self.current_tool_id += 1
721
+ name_new = True
722
+ pending_name = tool_name
723
+
724
+ # Check if buffer already has complete arguments
725
+ # (all-in-one-delta); otherwise only the name is ready now.
726
+ if self.tool_call_end_token not in self._buffer:
727
+ tool_calls.append(
728
+ StreamToolCall(
729
+ index=self.current_tool_id,
730
+ new=True,
731
+ name=tool_name,
732
+ arguments=None,
733
+ )
734
+ )
735
+ break
736
+ # Buffer already has a complete tool call; fall through to
737
+ # phase 2 below.
738
+
739
+ # === Phase 2: Incremental argument streaming ===
740
+ result = self._extract_streaming_incremental(name_new, pending_name, tools)
741
+ if result:
742
+ tool_calls.extend(result["tool_calls"])
743
+
744
+ # Continue only after the current tool call has completed and the
745
+ # remaining structural payload starts with another tool call.
746
+ # A literal ``<tool_call>`` may legitimately appear inside an open
747
+ # string argument; in that case the current tool name is still set
748
+ # and the buffer has not been consumed, so continuing would spin on
749
+ # the same buffer forever.
750
+ if self._streaming_tool_name is None and self._buffer.startswith(
751
+ self.tool_call_start_token
752
+ ):
753
+ continue
754
+ break
755
+
756
+ if content_delta is not None or tool_calls:
757
+ return StreamDelta(content=content_delta, tool_calls=tool_calls)
758
+ return None
759
+
760
+ def _make_args_delta(self, argument_diff: str) -> StreamDelta:
761
+ """Build an args-only streaming delta (no id/type -> new=False)."""
762
+ return StreamDelta(
763
+ content=None,
764
+ tool_calls=[
765
+ StreamToolCall(
766
+ index=self.current_tool_id,
767
+ new=False,
768
+ name=None,
769
+ arguments=argument_diff,
770
+ )
771
+ ],
772
+ )
773
+
774
+ def _extract_streaming_incremental(
775
+ self,
776
+ name_new: bool,
777
+ pending_name: str | None,
778
+ tools: list[ToolSchema] | None,
779
+ ) -> StreamDelta | None:
780
+ """Incremental phase-2: scan tags in buffer, emit JSON diffs.
781
+
782
+ Strategy:
783
+ - Track completed args and emit each one as a JSON fragment.
784
+ - For string-typed args, stream the value character-by-character.
785
+ - Withhold the closing ``}`` until ``</tool_call>`` is seen.
786
+
787
+ We build JSON manually via fragments rather than using json.dumps
788
+ with a cursor, because json.dumps of partial-vs-full string values
789
+ produces incompatible prefixes (e.g. ``""}`` vs ``"Hello"}``).
790
+ """
791
+ buf = self._buffer
792
+ is_complete = self.tool_call_end_token in buf
793
+
794
+ if is_complete:
795
+ end_idx = buf.find(self.tool_call_end_token)
796
+ args_text = buf[:end_idx]
797
+ remaining = buf[end_idx + len(self.tool_call_end_token) :]
798
+ else:
799
+ args_text = buf
800
+ remaining = ""
801
+
802
+ # --- scan all fully closed kv pairs ---
803
+ arg_pairs = self.func_args_regex.findall(args_text)
804
+ for key, value in arg_pairs:
805
+ key = key.strip()
806
+ if key not in self._completed_args:
807
+ self._completed_args[key] = _parse_value(
808
+ value, self._streaming_tool_name or "", key, tools
809
+ )
810
+
811
+ # --- detect partial (unclosed) kv at the tail ---
812
+ last_closed_end = 0
813
+ for m in self.func_args_regex.finditer(args_text):
814
+ last_closed_end = m.end()
815
+ tail = args_text[last_closed_end:]
816
+
817
+ partial_key: str | None = None
818
+ partial_value: str | None = None
819
+
820
+ ak_start = tail.find(self.arg_key_start_token)
821
+ if ak_start != -1:
822
+ ak_end = tail.find(
823
+ self.arg_key_end_token,
824
+ ak_start + len(self.arg_key_start_token),
825
+ )
826
+ if ak_end != -1:
827
+ partial_key = tail[
828
+ ak_start + len(self.arg_key_start_token) : ak_end
829
+ ].strip()
830
+ self._current_arg_key = partial_key
831
+ self._current_arg_is_string = _is_only_string_type(
832
+ self._streaming_tool_name or "",
833
+ partial_key,
834
+ tools,
835
+ )
836
+
837
+ av_start = tail.find(self.arg_value_start_token, ak_end)
838
+ if av_start != -1:
839
+ val_content_start = av_start + len(self.arg_value_start_token)
840
+ if self._current_arg_is_string:
841
+ raw_value = tail[val_content_start:]
842
+ # Guided decoding can split ``</arg_value>`` across
843
+ # deltas; hold back a partial closing tag so it is not
844
+ # streamed as part of the value.
845
+ keep = len(raw_value) - partial_tag_overlap(
846
+ raw_value, self.arg_value_end_token
847
+ )
848
+ partial_value = raw_value[:keep]
849
+ else:
850
+ # key not yet closed
851
+ self._current_arg_key = None
852
+ self._current_arg_is_string = False
853
+
854
+ # --- build the current JSON snapshot as a string ---
855
+ # We construct JSON manually so we can precisely control
856
+ # what gets sent incrementally.
857
+ snapshot_parts: list[str] = []
858
+ for k, v in self._completed_args.items():
859
+ k_json = json.dumps(k, ensure_ascii=False)
860
+ v_json = json.dumps(v, ensure_ascii=False)
861
+ snapshot_parts.append(f"{k_json}: {v_json}")
862
+
863
+ if partial_key is not None and partial_value is not None:
864
+ k_json = json.dumps(partial_key, ensure_ascii=False)
865
+ # For string partial value, we build the JSON string
866
+ # WITHOUT the closing quote, so the prefix stays stable
867
+ # as the value grows. The closing `"` and `}` will be
868
+ # sent when the value or tool_call closes.
869
+ escaped_val = json.dumps(partial_value, ensure_ascii=False)[1:-1]
870
+ # Note: no closing " here - it's appended only on close
871
+ snapshot_parts.append(f'{k_json}: "{escaped_val}')
872
+
873
+ snapshot = "{" + ", ".join(snapshot_parts) + "}"
874
+
875
+ # --- compute diff ---
876
+ argument_diff: str | None = None
877
+
878
+ if is_complete:
879
+ # Tool call finished - send everything remaining.
880
+ # Build final snapshot with proper JSON (all values closed).
881
+ final_args = dict(self._completed_args)
882
+ final_json = json.dumps(final_args, ensure_ascii=False)
883
+ if self._streamed_json_len < len(final_json):
884
+ argument_diff = final_json[self._streamed_json_len :]
885
+ self._streamed_json_len = len(final_json)
886
+
887
+ # Record into prev_tool_call_arr
888
+ self.prev_tool_call_arr.append(
889
+ {
890
+ "name": self._streaming_tool_name,
891
+ "arguments": final_args,
892
+ }
893
+ )
894
+ self.streamed_args_for_tool.append(final_json)
895
+
896
+ self._reset_streaming_tool_state()
897
+ self._buffer = remaining
898
+ else:
899
+ # Still in progress - withhold the tail.
900
+ # For open strings: snapshot ends with ...partial_val}
901
+ # we withhold "}" (1 char) - the missing closing " will
902
+ # be sent when the value closes.
903
+ # For no open string: snapshot ends with ...value"}
904
+ # we withhold "}" (1 char).
905
+ end = len(snapshot) - 1 # exclude trailing "}"
906
+ if end > self._streamed_json_len:
907
+ argument_diff = snapshot[self._streamed_json_len : end]
908
+ self._streamed_json_len = end
909
+
910
+ # --- construct return dict ---
911
+ if name_new and argument_diff:
912
+ return StreamDelta(
913
+ content=None,
914
+ tool_calls=[
915
+ StreamToolCall(
916
+ index=self.current_tool_id,
917
+ new=True,
918
+ name=pending_name,
919
+ arguments=argument_diff,
920
+ )
921
+ ],
922
+ )
923
+ elif name_new:
924
+ return StreamDelta(
925
+ content=None,
926
+ tool_calls=[
927
+ StreamToolCall(
928
+ index=self.current_tool_id,
929
+ new=True,
930
+ name=pending_name,
931
+ arguments=None,
932
+ )
933
+ ],
934
+ )
935
+ elif argument_diff:
936
+ return self._make_args_delta(argument_diff)
937
+ else:
938
+ return None
939
+
940
+
941
+def build_tool_extractor(
942
+ tokenizer: TokenizerLike, *, strict: bool
943
+) -> HYV4ToolExtractor:
944
+ return HYV4ToolExtractor(
945
+ tokenizer.get_vocab(), detect_token_suffix(tokenizer), strict
946
+ )
947
+
948
+
949
+def _tools_to_plain(tools) -> list[ToolSchema] | None:
950
+ """Convert tool-schema objects into plain dicts for the extractor."""
951
+ if not tools:
952
+ return None
953
+ return [
954
+ ToolSchema(name=t.function.name, parameters=t.function.parameters)
955
+ for t in tools
956
+ ]
957
+
958
+
959
+class HYV4ToolParser(ToolParser):
960
+ """vLLM adapter around :class:`HYV4ToolExtractor`.
961
+
962
+ Converts the extractor's plain dicts to vLLM protocol objects, mints
963
+ tool-call ids, and mirrors ``prev_tool_call_arr`` /
964
+ ``streamed_args_for_tool`` so the serving layer sees live state.
965
+ """
966
+
967
+ # HYV4 emits its own <tool_calls>/<tool_call>/<arg_key>/<arg_value>
968
+ # structural tokens, not a plain JSON array. The generic required/named
969
+ # JSON streaming path (streaming.extract_required_tool_call_streaming)
970
+ # cannot parse this format and produces misaligned multi-tool deltas in
971
+ # streaming mode. Fall back to our own (auto-path) parser for
972
+ # required/named too, so streaming and non-streaming stay consistent.
973
+ supports_required_and_named: bool = False
974
+ structural_tag_model: str = "hy_v4"
975
+
976
+ def __init__(self, tokenizer: TokenizerLike, tools=None):
977
+ super().__init__(tokenizer, tools)
978
+
979
+ # Tools are supplied at construction (``tool_parser(tokenizer,
980
+ # request.tools)``); keep a plain-dict copy for the extractor.
981
+ self._plain_tools = _tools_to_plain(tools)
982
+ # Malformed tag structure is rejected and returned as raw content
983
+ # instead of a half-parsed tool call.
984
+ self._extractor = build_tool_extractor(tokenizer, strict=True)
985
+ self._mirror_state()
986
+
987
+ def get_structural_tag(
988
+ self,
989
+ request: ChatCompletionRequest | ResponsesRequest,
990
+ *,
991
+ reasoning: bool = False,
992
+ ):
993
+ """Build a structural tag matching HYV4's tool tokens.
994
+
995
+ Overridden only to pass the per-checkpoint ``token_suffix`` through to
996
+ the builder; everything else follows the base implementation.
997
+
998
+ Named (forced) tool choice is constrained with the same structural tag:
999
+ because ``supports_required_and_named`` is False, required *and* named
1000
+ requests are parsed by the HYV4 extractor, which only recognizes the HYV4
1001
+ structural tokens -- the default JSON guided-decoding path would make
1002
+ the model emit JSON the extractor silently drops.
1003
+
1004
+ Args:
1005
+ request: The request being adjusted.
1006
+ reasoning: Whether the grammar also covers the reasoning phase.
1007
+
1008
+ Returns:
1009
+ The structural tag, or None when structural tagging does not apply.
1010
+ """
1011
+ if not envs.VLLM_ENFORCE_STRICT_TOOL_CALLING:
1012
+ return None
1013
+
1014
+ # Only constrain required / forced tool choice with the structural tag.
1015
+ # For "auto", tool calling is optional and most requests never emit a
1016
+ # tool call, so applying the grammar to every auto request would make
1017
+ # the whole stream pay the output inflation and the per-token
1018
+ # bitmask/accept overhead for a minority benefit. Skip it here so auto
1019
+ # falls through to free generation; the HYV4 parser still reads back
1020
+ # any tool calls the model emits.
1021
+ tool_choice = request.tool_choice
1022
+ if (
1023
+ tool_choice is None
1024
+ or tool_choice == "auto"
1025
+ or getattr(tool_choice, "mode", None) == "auto"
1026
+ ):
1027
+ return None
1028
+
1029
+ from vllm.tool_parsers.structural_tag_registry import get_model_structural_tag
1030
+
1031
+ try:
1032
+ return get_model_structural_tag(
1033
+ model=self.structural_tag_model,
1034
+ tools=request.tools,
1035
+ tool_choice=request.tool_choice,
1036
+ reasoning=reasoning,
1037
+ token_suffix=self._extractor.token_suffix,
1038
+ )
1039
+ except Exception:
1040
+ logger.warning(
1041
+ "HYV4ToolParser failed to build a structural tag; falling back "
1042
+ "to the default guided-decoding path.",
1043
+ exc_info=True,
1044
+ )
1045
+ return None
1046
+
1047
+ def _mirror_state(self) -> None:
1048
+ # Alias the SAME list objects the extractor appends to, so the serving
1049
+ # layer reads live state. ``current_tool_id`` is an int, so re-read it.
1050
+ self.prev_tool_call_arr = self._extractor.prev_tool_call_arr
1051
+ self.streamed_args_for_tool = self._extractor.streamed_args_for_tool
1052
+ self.current_tool_id = self._extractor.current_tool_id
1053
+
1054
+ @staticmethod
1055
+ def _is_guided(request: ChatCompletionRequest) -> bool:
1056
+ """Whether a structural tag constrains this request's decoding.
1057
+
1058
+ ``get_structural_tag`` only applies to required / named tool choice and
1059
+ the serving layer records the result on the request. A structural tag
1060
+ constrains strings rather than token ids, so the ``<tool_calls>`` marker
1061
+ can arrive split across sub-word tokens and must be detected on the
1062
+ string level.
1063
+
1064
+ Args:
1065
+ request: The request being streamed.
1066
+
1067
+ Returns:
1068
+ True when the streaming parser must use the string-marker path.
1069
+ """
1070
+ structured_outputs = getattr(request, "structured_outputs", None)
1071
+ return (
1072
+ structured_outputs is not None
1073
+ and structured_outputs.structural_tag is not None
1074
+ )
1075
+
1076
+ def _tools(self, request: ChatCompletionRequest) -> list[ToolSchema] | None:
1077
+ # Prefer the tools passed at construction; fall back to the request.
1078
+ if self._plain_tools is not None:
1079
+ return self._plain_tools
1080
+ return _tools_to_plain(getattr(request, "tools", None))
1081
+
1082
+ def extract_tool_calls(
1083
+ self,
1084
+ model_output: str,
1085
+ request: ChatCompletionRequest,
1086
+ ) -> ExtractedToolCallInformation:
1087
+ result = self._extractor.extract_tool_calls(model_output, self._tools(request))
1088
+ self._mirror_state()
1089
+ return ExtractedToolCallInformation(
1090
+ tools_called=result["tools_called"],
1091
+ content=result["content"],
1092
+ tool_calls=[
1093
+ ToolCall(
1094
+ type="function",
1095
+ function=FunctionCall(
1096
+ name=tc["name"],
1097
+ arguments=tc["arguments"],
1098
+ ),
1099
+ )
1100
+ for tc in result["tool_calls"]
1101
+ ],
1102
+ )
1103
+
1104
+ def extract_tool_calls_streaming(
1105
+ self,
1106
+ previous_text: str,
1107
+ current_text: str,
1108
+ delta_text: str,
1109
+ previous_token_ids,
1110
+ current_token_ids,
1111
+ delta_token_ids,
1112
+ request: ChatCompletionRequest,
1113
+ ) -> DeltaMessage | None:
1114
+ delta = self._extractor.extract_tool_calls_streaming(
1115
+ previous_text,
1116
+ current_text,
1117
+ delta_text,
1118
+ previous_token_ids,
1119
+ current_token_ids,
1120
+ delta_token_ids,
1121
+ self._tools(request),
1122
+ guided=self._is_guided(request),
1123
+ )
1124
+ self._mirror_state()
1125
+ if delta is None:
1126
+ return None
1127
+
1128
+ tool_calls: list[DeltaToolCall] = []
1129
+ for tc in delta.get("tool_calls", []):
1130
+ # Only set the fields that are present, leaving the rest UNSET (not
1131
+ # None). Streaming serialization uses exclude_unset, so an explicit
1132
+ # ``arguments=None`` would emit ``"arguments": null`` and break
1133
+ # clients that concatenate argument deltas. Matches hy_v3.
1134
+ fn_kwargs: dict[str, str] = {}
1135
+ name = tc.get("name")
1136
+ if name is not None:
1137
+ fn_kwargs["name"] = name
1138
+ arguments = tc.get("arguments")
1139
+ if arguments is not None:
1140
+ fn_kwargs["arguments"] = arguments
1141
+ function = DeltaFunctionCall(**fn_kwargs)
1142
+ if tc.get("new"):
1143
+ # First chunk for this tool: mint an id + set type here only.
1144
+ tool_calls.append(
1145
+ DeltaToolCall(
1146
+ index=tc["index"],
1147
+ id=make_tool_call_id(),
1148
+ type="function",
1149
+ function=function,
1150
+ )
1151
+ )
1152
+ else:
1153
+ tool_calls.append(DeltaToolCall(index=tc["index"], function=function))
1154
+
1155
+ # A single engine delta can carry content plus N drained tool calls.
1156
+ # Only set the fields that are present so streaming serialization
1157
+ # (exclude_unset) never emits ``content: null`` on a tool-call chunk.
1158
+ content = delta.get("content")
1159
+ kwargs: dict[str, Any] = {}
1160
+ if content is not None:
1161
+ kwargs["content"] = content
1162
+ if tool_calls:
1163
+ kwargs["tool_calls"] = tool_calls
1164
+ if not kwargs:
1165
+ return None
1166
+ return DeltaMessage(**kwargs)
vllm/tool_parsers/structural_tag_registry.py
+158
-5
@@ -49,6 +49,7 @@ StructuralTagBuilder: TypeAlias = Callable[
49
list[BuiltinToolParam],
50
SimplifiedToolChoice,
51
bool,
52
+ str,
53
],
54
StructuralTag,
55
]
@@ -70,7 +71,7 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
71
"deepseek_v4",
72
}
73
)
73
-VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "kimi_k3"})
74
+VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes", "hy_v4", "kimi_k3"})
75
SUPPORTED_STRUCTURAL_TAG_MODELS = (
76
XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
77
)
@@ -78,7 +79,9 @@ SUPPORTED_STRUCTURAL_TAG_MODELS = (
79
_VLLM_STRUCTURAL_TAG_REGISTRY: dict[str, StructuralTagBuilder] = {}
80
81
81
-def register_vllm_structural_tag(model: str):
82
+def register_vllm_structural_tag(
83
+ model: str,
84
+) -> Callable[[StructuralTagBuilder], StructuralTagBuilder]:
85
"""Register a vLLM-owned structural tag builder."""
86
87
def decorator(func: StructuralTagBuilder) -> StructuralTagBuilder:
@@ -104,6 +107,7 @@ def get_model_structural_tag(
107
tools: Sequence[ChatCompletionToolsParam | ResponsesTool] | None,
108
tool_choice: ToolChoice,
109
reasoning: bool,
110
+ token_suffix: str = "",
111
) -> StructuralTag | None:
112
"""Build a structural tag with xgrammar's builtin model templates."""
113
@@ -126,12 +130,19 @@ def get_model_structural_tag(
130
builtin_tools,
131
simplified_tool_choice,
132
reasoning,
133
+ token_suffix,
134
)
135
136
if model not in XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS:
137
supported = sorted(SUPPORTED_STRUCTURAL_TAG_MODELS)
138
raise ValueError(f"Unknown format type: {model}, supported types: {supported}")
139
140
+ if token_suffix:
141
+ raise ValueError(
142
+ f"Structural tag model {model!r} is an xgrammar builtin with fixed "
143
+ f"tokens and cannot apply token_suffix={token_suffix!r}"
144
+ )
145
+
146
return get_xgrammar_model_structural_tag(
147
model=model,
148
tools=dumped_tools,
@@ -244,8 +255,9 @@ def get_hermes_structural_tag(
255
builtin_tools: list[BuiltinToolParam],
256
tool_choice: SimplifiedToolChoice,
257
reasoning: bool,
258
+ token_suffix: str = "",
259
) -> StructuralTag:
248
- del builtin_tools, reasoning
260
+ del builtin_tools, reasoning, token_suffix
261
262
tool_call_trigger = "<tool_call>"
263
@@ -293,8 +305,9 @@ def get_minimax_structural_tag(
305
builtin_tools: list[BuiltinToolParam],
306
tool_choice: SimplifiedToolChoice,
307
reasoning: bool,
308
+ token_suffix: str = "",
309
) -> StructuralTag:
297
- del builtin_tools, reasoning
310
+ del builtin_tools, reasoning, token_suffix
311
312
tool_call_begin = "<minimax:tool_call>\n"
313
tool_call_end = "</minimax:tool_call>"
@@ -589,8 +602,9 @@ def get_kimi_k3_structural_tag(
602
builtin_tools: list[BuiltinToolParam],
603
tool_choice: SimplifiedToolChoice,
604
reasoning: bool,
605
+ token_suffix: str = "",
606
) -> StructuralTag:
593
- del builtin_tools, reasoning
607
+ del builtin_tools, reasoning, token_suffix
608
609
trailer = OptionalFormat(content=ConstStringFormat(value=_K3_MESSAGE_CLOSE))
610
@@ -611,3 +625,142 @@ def get_kimi_k3_structural_tag(
625
return StructuralTag(
626
format=SequenceFormat(elements=[*_k3_response_prefix(), tools_part, trailer])
627
)
628
+
629
+
630
+# ---------------------------------------------------------------------------
631
+# HYV4 (<tool_calls>/<tool_call>/<arg_key>/<arg_value> structural tokens)
632
+# ---------------------------------------------------------------------------
633
+# HYV4 assistant output after the reasoning gate (``</think:SUF>``):
634
+# <tool_calls:SUF>
635
+# <tool_call:SUF>NAME
636
+# <arg_key:SUF>K</arg_key:SUF><arg_value:SUF>V</arg_value:SUF>
637
+# </tool_call:SUF>
638
+# </tool_calls:SUF>
639
+# Adjacent <tool_call> blocks have no separator, and argument values are emitted
640
+# verbatim (no surrounding quotes), so the value body cannot be described by
641
+# JSONSchemaFormat. Only the skeleton (tool names, argument keys, tag order) is
642
+# constrained; each <arg_value> body stays free text.
643
+
644
+
645
+def _hy_v4_argument_keys(parameters: dict[str, Any]) -> tuple[list[str], list[str]]:
646
+ """Split a tool's argument keys into ``(required, optional)``.
647
+
648
+ Both lists keep declaration order. Required keys missing from
649
+ ``properties`` are still treated as required.
650
+ """
651
+ properties = parameters.get("properties") or {}
652
+ required = parameters.get("required") or []
653
+ required_set = set(required)
654
+ required_keys = [k for k in properties if k in required_set]
655
+ required_keys += [k for k in required if k not in properties]
656
+ optional_keys = [k for k in properties if k not in required_set]
657
+ return required_keys, optional_keys
658
+
659
+
660
+@register_vllm_structural_tag("hy_v4")
661
+def get_hy_v4_structural_tag(
662
+ tools: list[FunctionToolParam],
663
+ builtin_tools: list[BuiltinToolParam],
664
+ tool_choice: SimplifiedToolChoice,
665
+ reasoning: bool,
666
+ token_suffix: str = "",
667
+) -> StructuralTag:
668
+ """Build HYV4 <tool_calls>/<tool_call>/<arg_key>/<arg_value> structural tags.
669
+
670
+ Args:
671
+ tools: Normalized function tools the model may call.
672
+ builtin_tools: Unused; HYV4 has no builtin tools.
673
+ tool_choice: Simplified tool choice.
674
+ reasoning: Whether the grammar also covers the reasoning phase.
675
+ token_suffix: Per-checkpoint structural-token suffix including the
676
+ leading colon (e.g. ``":6124c78e"``), or ``""`` when the checkpoint
677
+ uses unsuffixed tokens. The HYV4 tool parser reads it off the
678
+ tokenizer vocab and passes it to ``get_model_structural_tag``.
679
+ """
680
+ del builtin_tools
681
+
682
+ think_end = f"</think{token_suffix}>"
683
+ tool_calls_begin = f"<tool_calls{token_suffix}>"
684
+ tool_calls_end = f"</tool_calls{token_suffix}>"
685
+ tool_call_begin = f"<tool_call{token_suffix}>"
686
+ tool_call_end = f"</tool_call{token_suffix}>"
687
+ arg_key_begin = f"<arg_key{token_suffix}>"
688
+ arg_key_end = f"</arg_key{token_suffix}>"
689
+ arg_value_begin = f"<arg_value{token_suffix}>"
690
+ arg_value_end = f"</arg_value{token_suffix}>"
691
+
692
+ def arg_pair(key: str) -> SequenceFormat:
693
+ return SequenceFormat(
694
+ elements=[
695
+ ConstStringFormat(value=arg_key_begin),
696
+ ConstStringFormat(value=key),
697
+ ConstStringFormat(value=arg_key_end),
698
+ ConstStringFormat(value=arg_value_begin),
699
+ AnyTextFormat(),
700
+ ConstStringFormat(value=arg_value_end),
701
+ ]
702
+ )
703
+
704
+ def single_tool_call(tool: FunctionToolParam) -> TagFormat:
705
+ function = tool.function
706
+ parameters = (
707
+ function.parameters if isinstance(function.parameters, dict) else {}
708
+ )
709
+ required_keys, optional_keys = _hy_v4_argument_keys(parameters)
710
+ elements: list[Any] = [arg_pair(k) for k in required_keys]
711
+ if optional_keys:
712
+ # Optional keys may appear in any order, or not at all.
713
+ optional_pairs = [arg_pair(k) for k in optional_keys]
714
+ elements.append(
715
+ StarFormat(
716
+ content=optional_pairs[0]
717
+ if len(optional_pairs) == 1
718
+ else OrFormat(elements=optional_pairs)
719
+ )
720
+ )
721
+ content: Any = (
722
+ SequenceFormat(elements=elements) if elements else AnyTextFormat()
723
+ )
724
+ return TagFormat(
725
+ begin=tool_call_begin + function.name,
726
+ content=content,
727
+ end=tool_call_end,
728
+ )
729
+
730
+ tags = [single_tool_call(tool) for tool in tools]
731
+
732
+ if tool_choice == "auto":
733
+ block = TagFormat(
734
+ begin=tool_calls_begin,
735
+ content=TagsWithSeparatorFormat(
736
+ tags=tags,
737
+ separator="",
738
+ at_least_one=True,
739
+ ),
740
+ end=tool_calls_end,
741
+ )
742
+ suffix_tag: Any = TriggeredTagsFormat(triggers=[tool_calls_begin], tags=[block])
743
+ else:
744
+ # HYV4 places tool_call blocks back-to-back with no separator. "forced"
745
+ # has already been filtered down to the single named tool and must emit
746
+ # exactly one block.
747
+ suffix_tag = SequenceFormat(
748
+ elements=[
749
+ ConstStringFormat(value=tool_calls_begin),
750
+ TagsWithSeparatorFormat(
751
+ tags=tags,
752
+ separator="",
753
+ at_least_one=True,
754
+ stop_after_first=tool_choice == "forced",
755
+ ),
756
+ ConstStringFormat(value=tool_calls_end),
757
+ ]
758
+ )
759
+
760
+ if not reasoning:
761
+ return StructuralTag(format=suffix_tag)
762
+
763
+ # The reasoning phase is constrained too, so the tag must explicitly skip
764
+ # the ``<think>...</think:SUF>`` prefix.
765
+ prefix_tag = TagFormat(begin="", content=AnyTextFormat(), end=think_end)
766
+ return StructuralTag(format=SequenceFormat(elements=[prefix_tag, suffix_tag]))
vllm/transformers_utils/config.py
+1
@@ -91,6 +91,7 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
91
granite4_vision="Granite4VisionConfig",
92
hyperclovax="HyperCLOVAXConfig",
93
hy_v3="HYV3Config",
94
+ hy_v4="HYV4Config",
95
isaac="IsaacConfig",
96
kimi_k2="DeepseekV3Config", # Kimi K2 uses same architecture as DeepSeek V3
97
kimi_linear="KimiLinearConfig",
vllm/transformers_utils/configs/__init__.py
+2
@@ -41,6 +41,7 @@ _CLASS_TO_MODULE: dict[str, str] = {
41
"FunAudioChatAudioEncoderConfig": "vllm.transformers_utils.configs.funaudiochat",
42
"Granite4VisionConfig": "vllm.transformers_utils.configs.granite4_vision",
43
"HYV3Config": "vllm.transformers_utils.configs.hy_v3",
44
+ "HYV4Config": "vllm.transformers_utils.configs.hy_v4",
45
"HyperCLOVAXConfig": "vllm.transformers_utils.configs.hyperclovax",
46
"IsaacConfig": "vllm.transformers_utils.configs.isaac",
47
# RWConfig is for the original tiiuae/falcon-40b(-instruct) and
@@ -130,6 +131,7 @@ __all__ = [
131
"FunAudioChatAudioEncoderConfig",
132
"Granite4VisionConfig",
133
"HYV3Config",
134
+ "HYV4Config",
135
"HyperCLOVAXConfig",
136
"IsaacConfig",
137
"RWConfig",
vllm/transformers_utils/configs/hy_v4.py
new
+172
@@ -0,0 +1,172 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from huggingface_hub.dataclasses import strict
4
+from transformers.configuration_utils import PreTrainedConfig
5
+from transformers.modeling_rope_utils import RopeParameters
6
+
7
+
8
+@strict
9
+class HYV4Config(PreTrainedConfig):
10
+ r"""Configuration class for the HY V4 (`HYV4ForCausalLM`) model.
11
+
12
+ Kept field-for-field in sync with
13
+ `transformers.models.hy_v4.configuration_hy_v4.HYV4Config` so that a
14
+ checkpoint resolves to the same values through either class. Registering it
15
+ in vLLM's `_CONFIG_REGISTRY` lets the architecture load without
16
+ `trust_remote_code` and without an `auto_map` module next to the weights.
17
+
18
+ HYV4 is a mixture-of-experts causal language model using Multi-head Latent
19
+ Attention (MLA), DeepSeek-style sparse attention (DSA), gated MLA,
20
+ learnable attention sinks, and independent Hyper-Connections (iHC).
21
+
22
+ The `layer_types`, `num_nextn_predict_layers` and `mtp_loss_factor` fields
23
+ below are vLLM-only additions: upstream leaves them to `**kwargs`, but
24
+ vLLM reads them directly (sparse-attention selection and MTP speculative
25
+ decoding), so they are declared to give checkpoints that omit them a
26
+ working default instead of a `None`.
27
+
28
+ Args:
29
+ mlp_layer_types: Per-layer MLP kind, `"dense"` or `"sparse"`. Defaults
30
+ to one dense layer followed by sparse MoE layers.
31
+ layer_types: Per-layer attention kind, e.g. `"full_attention"` or
32
+ `"deepseek_sparse_attention"`.
33
+ indexer_types: Per-layer DSA indexer kind, `"full"` or `"shared"`. A
34
+ shared layer reuses the most recent full indexer in the same
35
+ forward request.
36
+ index_topk: Maximum number of key positions selected by each DSA query.
37
+ index_head_dim: Hidden dimension of each DSA indexer head.
38
+ index_n_heads: Number of DSA indexer heads.
39
+ enable_lm_head_fp32: Whether the language-model head emits float32
40
+ logits. Surfaced as ``head_dtype`` so `LogitsProcessor` accumulates
41
+ the projection into fp32 while the weight stays in the model dtype.
42
+ enable_ihc: Whether independent Hyper-Connections are enabled.
43
+ hc_mult: Number of hidden-state channels maintained by iHC.
44
+ hc_magnitude: Scale applied to the iHC post-gating branch.
45
+ hc_eps: Numerical epsilon added to iHC sigmoid gates.
46
+ gated_mla: Whether to gate the MLA output.
47
+ gating_type: MLA gate granularity, `"elementwise"` or `"headwise"`.
48
+ learnable_sink: Whether to add a learned per-head attention sink.
49
+ learnable_sink_init: Initial value of each learned attention-sink
50
+ logit.
51
+ swiglu_limit: Magnitude of the routed-expert SwiGLU clamp. Values at or
52
+ below zero disable the clamp.
53
+ num_nextn_predict_layers: Number of MTP layers in the checkpoint.
54
+ mtp_loss_factor: Training-time MTP loss weight (unused at inference).
55
+ """
56
+
57
+ model_type = "hy_v4"
58
+ keys_to_ignore_at_inference = ["past_key_values"]
59
+ attribute_map = {
60
+ "num_local_experts": "n_routed_experts",
61
+ }
62
+ base_model_tp_plan = {
63
+ "layers.*.self_attn.q_b_proj": "colwise",
64
+ "layers.*.self_attn.kv_a_proj_with_mqa": "mla_kv_a_proj",
65
+ "layers.*.self_attn.kv_b_proj": "colwise",
66
+ "layers.*.self_attn.o_proj": "rowwise",
67
+ "layers.*.self_attn.linear_gate": "colwise",
68
+ "layers.*.mlp.experts.gate_up_proj": "packed_colwise",
69
+ "layers.*.mlp.experts.down_proj": "rowwise",
70
+ "layers.*.mlp.experts": "moe_tp_experts",
71
+ "layers.*.mlp.shared_experts.gate_proj": "colwise",
72
+ "layers.*.mlp.shared_experts.up_proj": "colwise",
73
+ "layers.*.mlp.shared_experts.down_proj": "rowwise",
74
+ "layers.*.mlp.gate_proj": "colwise",
75
+ "layers.*.mlp.up_proj": "colwise",
76
+ "layers.*.mlp.down_proj": "rowwise",
77
+ }
78
+ base_model_pp_plan = {
79
+ "embed_tokens": (["input_ids"], ["inputs_embeds"]),
80
+ "layers": (["hidden_states", "attention_mask"], ["hidden_states"]),
81
+ "norm": (["hidden_states"], ["hidden_states"]),
82
+ }
83
+
84
+ vocab_size: int = 120832
85
+ hidden_size: int = 2816
86
+ intermediate_size: int = 6912
87
+ moe_intermediate_size: int = 768
88
+ num_hidden_layers: int = 34
89
+ num_attention_heads: int = 32
90
+ num_key_value_heads: int = 32
91
+ head_dim: int = 256
92
+ hidden_act: str = "silu"
93
+ max_position_embeddings: int = 262144
94
+ initializer_range: float = 0.006
95
+ rms_norm_eps: float = 1e-5
96
+ use_cache: bool = True
97
+ pad_token_id: int | None = 120002
98
+ bos_token_id: int | None = 120000
99
+ eos_token_id: int | list[int] | None = 120025
100
+ tie_word_embeddings: bool = False
101
+ attention_bias: bool = False
102
+ attention_dropout: float = 0.0
103
+ n_routed_experts: int = 256
104
+ n_shared_experts: int = 1
105
+ num_experts_per_tok: int = 8
106
+ routed_scaling_factor: float = 2.827
107
+ norm_topk_prob: bool = True
108
+ q_lora_rank: int = 1536
109
+ kv_lora_rank: int = 512
110
+ qk_nope_head_dim: int = 192
111
+ qk_rope_head_dim: int = 64
112
+ v_head_dim: int = 256
113
+ mlp_layer_types: list[str] | None = None
114
+ layer_types: list[str] | None = None
115
+ index_topk: int = 2048
116
+ index_head_dim: int = 128
117
+ index_n_heads: int = 16
118
+ indexer_types: list[str] | None = None
119
+ enable_lm_head_fp32: bool = True
120
+ enable_ihc: bool = True
121
+ hc_mult: int = 4
122
+ hc_magnitude: float = 2.0
123
+ hc_eps: float = 1e-6
124
+ gated_mla: bool = True
125
+ gating_type: str = "elementwise"
126
+ learnable_sink: bool = True
127
+ learnable_sink_init: float = 0.0
128
+ swiglu_limit: float = 10.0
129
+ rope_parameters: RopeParameters | dict | None = None
130
+ num_nextn_predict_layers: int = 1
131
+ mtp_loss_factor: float = 0.1
132
+
133
+ def __post_init__(self, **kwargs):
134
+ self.qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
135
+ # RoPE applies only to the rope slice, so point `head_dim` at it: the
136
+ # inherited rotary embedding reads `config.head_dim` and then computes
137
+ # the right frequencies with no override.
138
+ self.head_dim = self.qk_rope_head_dim
139
+
140
+ if self.mlp_layer_types is None:
141
+ self.mlp_layer_types = ["dense"] * min(1, self.num_hidden_layers) + [
142
+ "sparse"
143
+ ] * max(self.num_hidden_layers - 1, 0)
144
+ if self.indexer_types is None:
145
+ self.indexer_types = [
146
+ "full" if layer_idx == 0 or (layer_idx - 1) % 4 == 0 else "shared"
147
+ for layer_idx in range(self.num_hidden_layers)
148
+ ]
149
+ # `PreTrainedConfig` validates `layer_types` against a fixed allow-list
150
+ # in which "deepseek_sparse_attention" is the only accepted spelling of
151
+ # a DSA layer. The model treats "sparse_attention" and "sparse" as
152
+ # equivalent, so fold them in before the base class validates.
153
+ if self.layer_types is not None:
154
+ self.layer_types = [
155
+ "deepseek_sparse_attention"
156
+ if lt in ("sparse_attention", "sparse")
157
+ else lt
158
+ for lt in self.layer_types
159
+ ]
160
+
161
+ # `ModelConfig.head_dtype` reads this off the HF config, so an fp32 head
162
+ # needs no model-side dtype juggling: LogitsProcessor picks the
163
+ # `torch.mm(out_dtype=float32)` path and leaves the weight in the model
164
+ # dtype. Only fill it in when the checkpoint (or --hf-overrides) has not
165
+ # already pinned a value.
166
+ if self.enable_lm_head_fp32 and getattr(self, "head_dtype", None) is None:
167
+ self.head_dtype = "float32"
168
+
169
+ super().__post_init__(**kwargs)
170
+
171
+
172
+__all__ = ["HYV4Config"]
vllm/transformers_utils/model_arch_config_convertor.py
+2
@@ -332,6 +332,8 @@ class ModelArchConfigConvertorBase:
332
"bailing_hybrid",
333
"bailing_hybrid_mtp",
334
"bailing_hybrid_v3_mtp",
335
+ "hy_v4",
336
+ "hy_v4_mtp",
337
):
338
# check is deepseek_v4 model
339
if hasattr(self.hf_text_config, "compress_ratios"):
vllm/v1/worker/gpu/spec_decode/speculator.py
+23
-5
@@ -30,6 +30,19 @@ from vllm.v1.worker.utils import AttentionGroup
30
logger = init_logger(__name__)
31
32
33
+def _target_feeds_hc_residual(vllm_config: VllmConfig) -> bool:
34
+ """Whether the target replaces the drafter's input with its HC residual.
35
+
36
+ Keyed on the same hook the model runner calls to perform that swap. It is
37
+ resolved from the target model class because speculators are built before
38
+ the target model is instantiated.
39
+ """
40
+ from vllm.model_executor.model_loader.utils import get_model_cls
41
+
42
+ target_cls = get_model_cls(vllm_config.model_config)
43
+ return hasattr(target_cls, "get_mtp_target_hidden_states")
44
+
45
+
46
class BaseSpeculator(ABC):
47
@abstractmethod
48
def init_cudagraph_manager(self, cudagraph_mode: CUDAGraphMode) -> None:
@@ -90,11 +103,16 @@ class DraftModelSpeculator(BaseSpeculator):
103
# the draft model's hidden size can be different from the target model's
104
# hidden size (e.g., Llama 3.3 70B).
105
self.hidden_size = self.draft_model_config.get_hidden_size()
93
- # Widen for HC-multiplexed residuals (e.g. DeepSeek V4 feeds the MTP
94
- # draft the target's pre-hc_head (T, hc_mult * hidden_size) residual).
95
- # Non-HC models default to hc_mult=1 and are unaffected.
96
- hc_mult = getattr(self.draft_model_config.hf_config, "hc_mult", 1)
97
- self.hidden_size = self.hidden_size * hc_mult
106
+ # Widen for HC-multiplexed residuals: a target that implements
107
+ # get_mtp_target_hidden_states() (e.g. DeepSeek V4) hands the drafter
108
+ # its pre-hc_head (T, hc_mult * hidden_size) residual in place of the
109
+ # collapsed hidden states, so the drafter's buffers must match. Key off
110
+ # that hook rather than hc_mult alone -- HY V4 runs iHC in its backbone
111
+ # (hc_mult=4) but its MTP head consumes the collapsed states, so
112
+ # widening it feeds propose() a 4x-too-wide buffer.
113
+ if _target_feeds_hc_residual(vllm_config):
114
+ hc_mult = getattr(self.draft_model_config.hf_config, "hc_mult", 1)
115
+ self.hidden_size = self.hidden_size * hc_mult
116
self.vocab_size = self.draft_model_config.get_vocab_size()
117
self.dtype = vllm_config.model_config.dtype
118
self.use_fp64_gumbel = vllm_config.model_config.use_fp64_gumbel