[Model] Add K2-Horizon model support (#55063)
Bowen Tan committed
Sep 3, 2026 at 01:17 UTC
1f76efaa2195485b92cb04215aba6fb8f5fe523d
10 files changed
+2613
docs/models/supported_models.md
+1
@@ -414,6 +414,7 @@ th {
414
| `IQuestLoopCoderForCausalLM` | IQuestLoopCoderV1 | `IQuestLab/IQuest-Coder-V1-40B-Loop-Instruct`, etc. | | |
415
| `Jais2ForCausalLM` | Jais2 | `inceptionai/Jais-2-8B-Chat`, `inceptionai/Jais-2-70B-Chat`, etc. | | ✅︎ |
416
| `JambaForCausalLM` | Jamba | `ai21labs/AI21-Jamba-1.5-Large`, `ai21labs/AI21-Jamba-1.5-Mini`, `ai21labs/Jamba-v0.1`, etc. | ✅︎ | ✅︎ |
417
+| `K2HorizonForCausalLM` | K2-Horizon | `IFM/K2-Horizon-7B`, `IFM/K2-Horizon-375B` | | ✅︎ |
418
| `KimiLinearForCausalLM` | Kimi-Linear-48B-A3B-Base, Kimi-Linear-48B-A3B-Instruct | `moonshotai/Kimi-Linear-48B-A3B-Base`, `moonshotai/Kimi-Linear-48B-A3B-Instruct` | | ✅︎ |
419
| `Lfm2ForCausalLM` | LFM2 | `LiquidAI/LFM2-1.2B`, `LiquidAI/LFM2-700M`, `LiquidAI/LFM2-350M`, etc. | ✅︎ | ✅︎ |
420
| `Lfm2MoeForCausalLM` | LFM2MoE | `LiquidAI/LFM2-8B-A1B-preview`, etc. | ✅︎ | ✅︎ |
tests/models/registry.py
+5
@@ -364,6 +364,11 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
364
"random": "ai21labs/Jamba-tiny-random",
365
},
366
),
367
+ "K2HorizonForCausalLM": _HfExamplesInfo(
368
+ "IFM/K2-Horizon-36B",
369
+ trust_remote_code=True,
370
+ is_available_online=False,
371
+ ),
372
"KimiLinearForCausalLM": _HfExamplesInfo(
373
"moonshotai/Kimi-Linear-48B-A3B-Instruct", trust_remote_code=True
374
),
tests/reasoning/test_k2_horizon_reasoning_parser.py
new
+214
@@ -0,0 +1,214 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import string
5
+from collections.abc import Sequence
6
+
7
+import pytest
8
+
9
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
10
+from vllm.parser.parser_manager import ParserManager
11
+from vllm.reasoning import ReasoningParserManager
12
+from vllm.reasoning.k2_horizon_reasoning_parser import K2HorizonReasoningParser
13
+
14
+pytestmark = pytest.mark.skip_global_cleanup
15
+
16
+EFFORT_TOKENS = {
17
+ "high": ("<ifm|think>", "</ifm|think>"),
18
+ "medium": ("<ifm|think_fast>", "</ifm|think_fast>"),
19
+ "low": ("<ifm|think_faster>", "</ifm|think_faster>"),
20
+}
21
+TOOL_CALLS_START = "<ifm|tool_calls>"
22
+
23
+
24
+class K2Tokenizer:
25
+ def __init__(self) -> None:
26
+ special_tokens = [
27
+ token for token_pair in EFFORT_TOKENS.values() for token in token_pair
28
+ ] + [TOOL_CALLS_START]
29
+ tokens = special_tokens + list(string.printable)
30
+ self._vocab = {token: index for index, token in enumerate(tokens, start=1)}
31
+
32
+ def get_vocab(self) -> dict[str, int]:
33
+ return dict(self._vocab)
34
+
35
+ def encode(self, text: str) -> list[int]:
36
+ tokens: list[int] = []
37
+ position = 0
38
+ specials = sorted(EFFORT_TOKENS["high"] + (TOOL_CALLS_START,), key=len)
39
+ while position < len(text):
40
+ for special in specials:
41
+ if text.startswith(special, position):
42
+ tokens.append(self._vocab[special])
43
+ position += len(special)
44
+ break
45
+ else:
46
+ tokens.append(self._vocab[text[position]])
47
+ position += 1
48
+ return tokens
49
+
50
+ def decode(
51
+ self,
52
+ ids: Sequence[int] | int,
53
+ skip_special_tokens: bool = False,
54
+ ) -> str:
55
+ del skip_special_tokens
56
+ if isinstance(ids, int):
57
+ ids = [ids]
58
+ inverse_vocab = {token_id: token for token, token_id in self._vocab.items()}
59
+ return "".join(inverse_vocab[token_id] for token_id in ids)
60
+
61
+
62
+def _request(**kwargs) -> ChatCompletionRequest:
63
+ return ChatCompletionRequest(
64
+ model="k2-horizon",
65
+ messages=[{"role": "user", "content": "test"}],
66
+ **kwargs,
67
+ )
68
+
69
+
70
+@pytest.mark.parametrize("effort", ["high", "medium", "low"])
71
+def test_reasoning_effort_selects_tokens(effort: str):
72
+ parser = K2HorizonReasoningParser(
73
+ K2Tokenizer(),
74
+ chat_template_kwargs={"reasoning_effort": effort},
75
+ )
76
+
77
+ assert (parser.start_token, parser.end_token) == EFFORT_TOKENS[effort]
78
+
79
+
80
+def test_reasoning_effort_precedence_and_default():
81
+ tokenizer = K2Tokenizer()
82
+ request = _request(
83
+ reasoning_effort="medium",
84
+ chat_template_kwargs={"reasoning_effort": "low"},
85
+ )
86
+ resolved_kwargs = (
87
+ request.build_chat_params(None, "auto")
88
+ .with_defaults({"reasoning_effort": "high"})
89
+ .chat_template_kwargs
90
+ )
91
+
92
+ parser = K2HorizonReasoningParser(
93
+ tokenizer,
94
+ chat_template_kwargs=resolved_kwargs,
95
+ )
96
+ default_parser = K2HorizonReasoningParser(tokenizer)
97
+
98
+ assert parser.start_token == EFFORT_TOKENS["medium"][0]
99
+ assert default_parser.start_token == EFFORT_TOKENS["high"][0]
100
+
101
+
102
+@pytest.mark.parametrize("effort", ["none", "unknown", None])
103
+def test_invalid_reasoning_effort_raises(effort: str | None):
104
+ with pytest.raises(ValueError, match="Unsupported reasoning_effort"):
105
+ K2HorizonReasoningParser(
106
+ K2Tokenizer(),
107
+ chat_template_kwargs={"reasoning_effort": effort},
108
+ )
109
+
110
+
111
+def test_non_streaming_explicit_and_implicit_boundaries():
112
+ parser = K2HorizonReasoningParser(K2Tokenizer())
113
+ request = _request()
114
+
115
+ assert parser.extract_reasoning("<ifm|think>plan</ifm|think>answer", request) == (
116
+ "plan",
117
+ "answer",
118
+ )
119
+ assert parser.extract_reasoning(
120
+ f"plan{TOOL_CALLS_START}<ifm|tool_call>ping</ifm|tool_call>", request
121
+ ) == (
122
+ "plan",
123
+ f"{TOOL_CALLS_START}<ifm|tool_call>ping</ifm|tool_call>",
124
+ )
125
+
126
+
127
+@pytest.mark.parametrize("reasoning", ["plan", " ", "\n", " \n"])
128
+def test_character_split_reasoning_stream(reasoning: str):
129
+ parser = K2HorizonReasoningParser(K2Tokenizer())
130
+ reasoning_parts: list[str] = []
131
+ content_parts: list[str] = []
132
+
133
+ for char in f"{reasoning}</ifm|think>answer":
134
+ delta = parser.extract_reasoning_streaming(
135
+ previous_text="",
136
+ current_text="",
137
+ delta_text=char,
138
+ previous_token_ids=[],
139
+ current_token_ids=[],
140
+ delta_token_ids=[],
141
+ )
142
+ if delta is not None:
143
+ if delta.reasoning is not None:
144
+ reasoning_parts.append(delta.reasoning)
145
+ if delta.content is not None:
146
+ content_parts.append(delta.content)
147
+
148
+ assert "".join(reasoning_parts) == reasoning
149
+ assert "".join(content_parts) == "answer"
150
+
151
+
152
+def test_latest_boundary_controls_reasoning_state():
153
+ tokenizer = K2Tokenizer()
154
+ parser = K2HorizonReasoningParser(tokenizer)
155
+ vocab = tokenizer.get_vocab()
156
+
157
+ assert parser.is_reasoning_end([vocab["<ifm|think>"], vocab[TOOL_CALLS_START]])
158
+ assert not parser.is_reasoning_end([vocab[TOOL_CALLS_START], vocab["<ifm|think>"]])
159
+
160
+
161
+def test_composed_streaming_reasoning_to_tool_handoff():
162
+ tokenizer = K2Tokenizer()
163
+ tools = [
164
+ {
165
+ "type": "function",
166
+ "function": {
167
+ "name": "ping",
168
+ "parameters": {"type": "object", "properties": {}},
169
+ },
170
+ }
171
+ ]
172
+ request = _request(tools=tools, tool_choice="auto")
173
+ parser_cls = ParserManager.get_parser(
174
+ tool_parser_name="k2_horizon",
175
+ reasoning_parser_name="k2_horizon",
176
+ enable_auto_tools=True,
177
+ )
178
+ assert parser_cls is not None
179
+ parser = parser_cls(
180
+ tokenizer,
181
+ request.tools,
182
+ chat_template_kwargs={"reasoning_effort": "high"},
183
+ )
184
+ output = "plan<ifm|tool_calls><ifm|tool_call>ping</ifm|tool_call></ifm|tool_calls>"
185
+ reasoning_parts: list[str] = []
186
+ tool_names: list[str] = []
187
+
188
+ for index, char in enumerate(output):
189
+ delta = parser.parse_delta(
190
+ delta_text=char,
191
+ delta_token_ids=tokenizer.encode(char),
192
+ request=request,
193
+ prompt_token_ids=[] if index == 0 else None,
194
+ finished=index == len(output) - 1,
195
+ )
196
+ if delta is not None:
197
+ if delta.reasoning is not None:
198
+ reasoning_parts.append(delta.reasoning)
199
+ tool_names.extend(
200
+ tool_call.function.name
201
+ for tool_call in delta.tool_calls
202
+ if tool_call.function is not None
203
+ and tool_call.function.name is not None
204
+ )
205
+
206
+ assert "".join(reasoning_parts) == "plan"
207
+ assert tool_names == ["ping"]
208
+
209
+
210
+def test_reasoning_parser_registered():
211
+ assert (
212
+ ReasoningParserManager.get_reasoning_parser("k2_horizon")
213
+ is K2HorizonReasoningParser
214
+ )
tests/tool_parsers/test_k2_horizon_tool_parser.py
new
+318
@@ -0,0 +1,318 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import json
5
+from unittest.mock import MagicMock
6
+
7
+import pytest
8
+
9
+from vllm.entrypoints.generate.base.protocol import DeltaMessage
10
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
11
+from vllm.parser.parser_manager import ParserManager
12
+from vllm.tool_parsers import ToolParserManager
13
+from vllm.tool_parsers.k2_horizon_tool_parser import K2HorizonToolParser
14
+
15
+pytestmark = pytest.mark.skip_global_cleanup
16
+
17
+TOOLS = [
18
+ {
19
+ "type": "function",
20
+ "function": {
21
+ "name": "lookup",
22
+ "parameters": {
23
+ "type": "object",
24
+ "properties": {
25
+ "query": {"type": "string"},
26
+ "limit": {"type": "integer"},
27
+ "enabled": {"type": "boolean"},
28
+ },
29
+ },
30
+ },
31
+ },
32
+ {
33
+ "type": "function",
34
+ "function": {
35
+ "name": "ping",
36
+ "parameters": {"type": "object", "properties": {}},
37
+ },
38
+ },
39
+]
40
+
41
+
42
+@pytest.fixture
43
+def tokenizer():
44
+ tokenizer = MagicMock()
45
+ tokenizer.get_vocab.return_value = {}
46
+ return tokenizer
47
+
48
+
49
+def _request(
50
+ tool_format: str | None = None,
51
+ tool_choice="auto",
52
+) -> ChatCompletionRequest:
53
+ chat_template_kwargs = (
54
+ {"tool_call_format": tool_format} if tool_format is not None else None
55
+ )
56
+ return ChatCompletionRequest(
57
+ model="k2-horizon",
58
+ messages=[{"role": "user", "content": "test"}],
59
+ tools=TOOLS,
60
+ tool_choice=tool_choice,
61
+ chat_template_kwargs=chat_template_kwargs,
62
+ )
63
+
64
+
65
+def _group(*calls: str, prefix: str = "", suffix: str = "") -> str:
66
+ return prefix + "<ifm|tool_calls>" + "\n".join(calls) + "</ifm|tool_calls>" + suffix
67
+
68
+
69
+def _json_call(name: str, arguments: dict) -> str:
70
+ payload = json.dumps({"name": name, "arguments": arguments})
71
+ return f"<ifm|tool_call>{payload}</ifm|tool_call>"
72
+
73
+
74
+def _xml_call(
75
+ name: str,
76
+ arguments: list[tuple[str, str]],
77
+ *,
78
+ typed: bool = False,
79
+) -> str:
80
+ parts = [f"<ifm|tool_call>{name}"]
81
+ for key, value in arguments:
82
+ parts.append(f"<ifm|arg_key>{key}</ifm|arg_key>")
83
+ if typed:
84
+ arg_type = "integer" if key == "limit" else "string"
85
+ parts.append(f"<ifm|arg_type>{arg_type}</ifm|arg_type>")
86
+ parts.append(f"<ifm|arg_value>{value}</ifm|arg_value>")
87
+ parts.append("</ifm|tool_call>")
88
+ return "".join(parts)
89
+
90
+
91
+def _collect_stream(
92
+ parser: K2HorizonToolParser,
93
+ request: ChatCompletionRequest,
94
+ output: str,
95
+) -> tuple[str, list[tuple[str, dict]]]:
96
+ messages: list[DeltaMessage | None] = []
97
+ for char in output:
98
+ messages.append(
99
+ parser.extract_tool_calls_streaming(
100
+ previous_text="",
101
+ current_text="",
102
+ delta_text=char,
103
+ previous_token_ids=[],
104
+ current_token_ids=[],
105
+ delta_token_ids=[],
106
+ request=request,
107
+ )
108
+ )
109
+ messages.append(parser.finish_streaming())
110
+
111
+ content: list[str] = []
112
+ calls: list[tuple[str, dict]] = []
113
+ for message in messages:
114
+ if message is None:
115
+ continue
116
+ if message.content is not None:
117
+ content.append(message.content)
118
+ for tool_call in message.tool_calls:
119
+ assert tool_call.function is not None
120
+ assert tool_call.function.name is not None
121
+ assert tool_call.function.arguments is not None
122
+ calls.append(
123
+ (
124
+ tool_call.function.name,
125
+ json.loads(tool_call.function.arguments),
126
+ )
127
+ )
128
+ return "".join(content), calls
129
+
130
+
131
+def test_xml_default_parses_multiple_calls_and_coerces_schema(tokenizer):
132
+ parser = K2HorizonToolParser(tokenizer, _request().tools)
133
+ output = _group(
134
+ _xml_call("lookup", [("query", "123"), ("limit", "3")]),
135
+ _xml_call("ping", []),
136
+ prefix="Before ",
137
+ suffix=" after.",
138
+ )
139
+
140
+ result = parser.extract_tool_calls(output, _request())
141
+
142
+ assert parser.tool_format == "xml"
143
+ assert result.tools_called
144
+ assert result.content == "Before after."
145
+ assert [call.function.name for call in result.tool_calls] == ["lookup", "ping"]
146
+ assert json.loads(result.tool_calls[0].function.arguments) == {
147
+ "query": "123",
148
+ "limit": 3,
149
+ }
150
+
151
+
152
+@pytest.mark.parametrize(
153
+ ("tool_format", "call", "expected"),
154
+ [
155
+ ("json", _json_call("lookup", {"limit": "2"}), {"limit": 2}),
156
+ (
157
+ "xml_typed",
158
+ _xml_call("lookup", [("query", "weather"), ("limit", "2")], typed=True),
159
+ {"query": "weather", "limit": 2},
160
+ ),
161
+ ],
162
+)
163
+def test_request_level_format_overrides(tokenizer, tool_format, call, expected):
164
+ request = _request(tool_format)
165
+ parser = K2HorizonToolParser(tokenizer, request.tools)
166
+
167
+ result = parser.extract_tool_calls(_group(call), request)
168
+
169
+ assert result.tools_called
170
+ assert json.loads(result.tool_calls[0].function.arguments) == expected
171
+
172
+
173
+@pytest.mark.parametrize(
174
+ "tool_format",
175
+ ["yaml", "python", "xml_untyped", "", None, 1],
176
+)
177
+def test_invalid_request_format_rejected_before_generation(tokenizer, tool_format):
178
+ parser = K2HorizonToolParser(tokenizer, _request().tools)
179
+ request = _request().model_copy(
180
+ update={"chat_template_kwargs": {"tool_call_format": tool_format}}
181
+ )
182
+
183
+ with pytest.raises(ValueError, match="Unsupported tool_call_format"):
184
+ parser.adjust_request(request)
185
+
186
+
187
+@pytest.mark.parametrize(
188
+ ("tool_request", "call"),
189
+ [
190
+ (_request(), _json_call("ping", {})),
191
+ (_request("json"), _xml_call("ping", [])),
192
+ (_request("xml"), _xml_call("lookup", [("query", "x")], typed=True)),
193
+ (_request("xml_typed"), _xml_call("lookup", [("query", "x")])),
194
+ ],
195
+)
196
+def test_mismatched_formats_fail_closed(tokenizer, tool_request, call):
197
+ parser = K2HorizonToolParser(tokenizer, tool_request.tools)
198
+ output = _group(call)
199
+
200
+ result = parser.extract_tool_calls(output, tool_request)
201
+
202
+ assert not result.tools_called
203
+ assert result.content == output
204
+
205
+
206
+@pytest.mark.parametrize(
207
+ "tool_choice",
208
+ [
209
+ "auto",
210
+ "required",
211
+ {"type": "function", "function": {"name": "lookup"}},
212
+ ],
213
+)
214
+def test_auto_required_and_named_use_ifm_parser(tokenizer, tool_choice):
215
+ request = _request("json", tool_choice)
216
+ parser_cls = ParserManager.get_parser(
217
+ tool_parser_name="k2_horizon",
218
+ enable_auto_tools=True,
219
+ )
220
+ assert parser_cls is not None
221
+ parser = parser_cls(tokenizer, request.tools)
222
+
223
+ assert parser.adjust_request(request) is request
224
+ assert request.structured_outputs is None
225
+ _, content, tool_calls = parser.parse(
226
+ _group(_json_call("lookup", {"enabled": "true"})),
227
+ request,
228
+ enable_auto_tools=True,
229
+ )
230
+
231
+ assert content is None
232
+ assert tool_calls is not None
233
+ assert json.loads(tool_calls[0].arguments) == {"enabled": True}
234
+
235
+
236
+def test_unknown_or_wrong_named_tool_fails_closed(tokenizer):
237
+ named_request = _request(
238
+ "json", {"type": "function", "function": {"name": "lookup"}}
239
+ )
240
+ parser = K2HorizonToolParser(tokenizer, named_request.tools)
241
+
242
+ unknown = parser.extract_tool_calls(
243
+ _group(_json_call("missing", {})), _request("json")
244
+ )
245
+ wrong_named = parser.extract_tool_calls(
246
+ _group(_json_call("ping", {})), named_request
247
+ )
248
+
249
+ assert not unknown.tools_called
250
+ assert not wrong_named.tools_called
251
+
252
+
253
+@pytest.mark.parametrize(
254
+ "output",
255
+ [
256
+ "<ifm|tool_calls><ifm|tool_call>ping",
257
+ "<ifm|tool_calls><ifm|tool_call>ping</ifm|tool_call>",
258
+ _group("junk"),
259
+ ],
260
+)
261
+def test_malformed_and_incomplete_input_is_content(tokenizer, output):
262
+ request = _request()
263
+ parser = K2HorizonToolParser(tokenizer, request.tools)
264
+
265
+ result = parser.extract_tool_calls(output, request)
266
+
267
+ assert not result.tools_called
268
+ assert result.content == output
269
+
270
+
271
+@pytest.mark.parametrize(
272
+ ("tool_request", "call", "expected"),
273
+ [
274
+ (_request(), _xml_call("lookup", [("limit", "2")]), {"limit": 2}),
275
+ (_request("json"), _json_call("lookup", {"limit": "2"}), {"limit": 2}),
276
+ (
277
+ _request("xml_typed"),
278
+ _xml_call("lookup", [("limit", "2")], typed=True),
279
+ {"limit": 2},
280
+ ),
281
+ ],
282
+)
283
+def test_character_split_streaming(tokenizer, tool_request, call, expected):
284
+ parser = K2HorizonToolParser(tokenizer, tool_request.tools)
285
+ output = _group(call, prefix="Before", suffix="after")
286
+
287
+ content, calls = _collect_stream(parser, tool_request, output)
288
+
289
+ assert content == "Beforeafter"
290
+ assert calls == [("lookup", expected)]
291
+
292
+
293
+def test_streaming_flushes_incomplete_markup_as_content(tokenizer):
294
+ request = _request()
295
+ parser = K2HorizonToolParser(tokenizer, request.tools)
296
+ output = "Before<ifm|tool_calls><ifm|tool_call>ping"
297
+
298
+ content, calls = _collect_stream(parser, request, output)
299
+
300
+ assert content == output
301
+ assert calls == []
302
+
303
+
304
+def test_whitespace_only_surrounding_content_is_not_preserved(tokenizer):
305
+ request = _request()
306
+ parser = K2HorizonToolParser(tokenizer, request.tools)
307
+
308
+ result = parser.extract_tool_calls(
309
+ _group(_xml_call("ping", []), prefix=" \n", suffix="\t "), request
310
+ )
311
+
312
+ assert result.tools_called
313
+ assert result.content is None
314
+
315
+
316
+def test_tool_parser_registered():
317
+ assert ToolParserManager.get_tool_parser("k2_horizon") is K2HorizonToolParser
318
+ assert K2HorizonToolParser.supports_required_and_named is False
vllm/model_executor/models/k2_horizon.py
new
+1445
@@ -0,0 +1,1445 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+# Copyright 2026 MBZUAI-IFM.
5
+# Copyright 2024 The Qwen team.
6
+# Copyright 2023 The vLLM team.
7
+# Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved.
8
+#
9
+# This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX
10
+# and OPT implementations in this library. It has been modified from its
11
+# original forms to accommodate minor architectural differences compared
12
+# to GPT-NeoX and OPT used by the Meta AI team that trained the model.
13
+#
14
+# Licensed under the Apache License, Version 2.0 (the "License");
15
+# you may not use this file except in compliance with the License.
16
+# You may obtain a copy of the License at
17
+#
18
+# http://www.apache.org/licenses/LICENSE-2.0
19
+#
20
+# Unless required by applicable law or agreed to in writing, software
21
+# distributed under the License is distributed on an "AS IS" BASIS,
22
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
23
+# See the License for the specific language governing permissions and
24
+# limitations under the License.
25
+"""Inference-only K2Horizon model compatible with HuggingFace weights."""
26
+
27
+import math
28
+import typing
29
+from collections.abc import Callable, Iterable
30
+from itertools import islice
31
+from typing import Any
32
+
33
+import torch
34
+import torch.nn.functional as F
35
+from torch import nn
36
+
37
+from vllm import _custom_ops as ops
38
+from vllm.compilation.decorators import support_torch_compile
39
+from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
40
+from vllm.distributed import (
41
+ get_ep_group,
42
+ get_pp_group,
43
+ get_tensor_model_parallel_rank,
44
+ get_tensor_model_parallel_world_size,
45
+ tensor_model_parallel_all_gather,
46
+)
47
+from vllm.logger import init_logger
48
+from vllm.model_executor.layers.activation import SiluAndMul
49
+from vllm.model_executor.layers.attention import Attention
50
+from vllm.model_executor.layers.fused_moe import (
51
+ FusedMoEFactory,
52
+ fused_moe_make_expert_params_mapping,
53
+)
54
+from vllm.model_executor.layers.fused_moe.config import (
55
+ _get_config_dtype_str,
56
+)
57
+from vllm.model_executor.layers.fused_moe.fused_moe import (
58
+ _get_config_quant_dtype,
59
+ _prepare_expert_assignment,
60
+ dispatch_fused_moe_kernel,
61
+ try_get_optimal_moe_config,
62
+)
63
+from vllm.model_executor.layers.fused_moe.utils import (
64
+ moe_kernel_quantize_input,
65
+)
66
+from vllm.model_executor.layers.layernorm import RMSNorm
67
+from vllm.model_executor.layers.linear import (
68
+ ColumnParallelLinear,
69
+ MergedColumnParallelLinear,
70
+ QKVParallelLinear,
71
+ ReplicatedLinear,
72
+ RowParallelLinear,
73
+)
74
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
75
+from vllm.model_executor.layers.quantization import QuantizationConfig
76
+from vllm.model_executor.layers.rotary_embedding import get_rope
77
+from vllm.model_executor.layers.vocab_parallel_embedding import (
78
+ ParallelLMHead,
79
+ VocabParallelEmbedding,
80
+)
81
+from vllm.model_executor.model_loader.weight_utils import (
82
+ default_weight_loader,
83
+ maybe_remap_kv_scale_name,
84
+)
85
+from vllm.model_executor.models.interfaces import (
86
+ EagleModelMixin,
87
+ MixtureOfExperts,
88
+ SupportsEagle,
89
+ SupportsEagle3,
90
+ SupportsLoRA,
91
+ SupportsPP,
92
+)
93
+from vllm.model_executor.models.utils import (
94
+ AutoWeightsLoader,
95
+ PPMissingLayer,
96
+ extract_layer_index,
97
+ is_pp_missing_parameter,
98
+ make_empty_intermediate_tensors_factory,
99
+ make_layers,
100
+ maybe_prefix,
101
+ sequence_parallel_chunk,
102
+)
103
+from vllm.sequence import IntermediateTensors
104
+from vllm.triton_utils import tl
105
+
106
+logger = init_logger(__name__)
107
+
108
+
109
+def split_to_interleaved(x):
110
+ # Split halves: x0 x1 x2 x3 ... y0 y1 y2 y3 ...
111
+ # Interleaved: x0 y0 x1 y1 x2 y2 x3 y3 ...
112
+ return x.reshape(*x.shape[:-1], 2, -1).transpose(-1, -2).reshape(*x.shape[:-1], -1)
113
+
114
+
115
+def interleaved_to_split(x):
116
+ # Interleaved: x0 y0 x1 y1 x2 y2 x3 y3 ...
117
+ # Split halves: x0 x1 x2 x3 ... y0 y1 y2 y3 ...
118
+ return x.reshape(*x.shape[:-1], -1, 2).transpose(-1, -2).reshape(*x.shape[:-1], -1)
119
+
120
+
121
+def calc_router_weights(
122
+ router_logits: torch.Tensor,
123
+ e_score_correction_bias: torch.Tensor | None,
124
+ score_func: str,
125
+ top_k: int,
126
+ scaling_factor: float | None,
127
+ renormalize: bool | None = None,
128
+) -> tuple[torch.Tensor, torch.Tensor]:
129
+ if score_func == "softmax":
130
+ routing_scores = F.softmax(router_logits, dim=-1, dtype=torch.float32)
131
+ else:
132
+ assert score_func == "sigmoid", (
133
+ f"Unsupported router score function: {score_func}"
134
+ )
135
+ routing_scores = torch.sigmoid(router_logits.to(torch.float32))
136
+
137
+ selection_scores = routing_scores
138
+ if e_score_correction_bias is not None:
139
+ selection_scores = selection_scores + e_score_correction_bias.to(
140
+ selection_scores
141
+ )
142
+
143
+ selected_indices = torch.topk(selection_scores, top_k, dim=-1).indices
144
+ routing_weights = torch.gather(routing_scores, dim=-1, index=selected_indices)
145
+ if renormalize is None:
146
+ renormalize = top_k > 1
147
+ if renormalize:
148
+ routing_weights = routing_weights / routing_weights.sum(dim=-1, keepdim=True)
149
+ if scaling_factor is not None:
150
+ routing_weights = routing_weights * scaling_factor
151
+ return routing_weights.to(router_logits.dtype), selected_indices
152
+
153
+
154
+def fused_mova_impl(
155
+ config,
156
+ hidden_states: torch.Tensor,
157
+ w1: torch.Tensor,
158
+ topk_weights: torch.Tensor,
159
+ topk_ids: torch.Tensor,
160
+ use_fp8_w8a8: bool = False,
161
+ use_int8_w8a8: bool = False,
162
+ use_int8_w8a16: bool = False,
163
+ use_int4_w4a16: bool = False,
164
+ ocp_mx_scheme: str | None = None,
165
+ per_channel_quant: bool = False,
166
+ global_num_experts: int = -1,
167
+ expert_map: torch.Tensor | None = None,
168
+ w1_scale: torch.Tensor | None = None,
169
+ w1_zp: torch.Tensor | None = None,
170
+ a1_scale: torch.Tensor | None = None,
171
+ block_shape: list[int] | None = None,
172
+ w1_bias: torch.Tensor | None = None,
173
+) -> torch.Tensor:
174
+ if ocp_mx_scheme is not None:
175
+ raise NotImplementedError(
176
+ f"Using ocp_mx_scheme={ocp_mx_scheme} in functional fused_experts call is "
177
+ "deprecated. Please use OCP_MXQuantizationEmulationTritonExperts."
178
+ )
179
+
180
+ # Check constraints.
181
+ if use_int4_w4a16:
182
+ assert hidden_states.size(1) // 2 == w1.size(2), "Hidden size mismatch"
183
+ else:
184
+ assert hidden_states.size(1) == w1.size(2), (
185
+ f"Hidden size mismatch {hidden_states.size(1)} != {w1.size(2)}"
186
+ )
187
+
188
+ assert topk_weights.size() == topk_ids.size(), "topk shape mismatch"
189
+ assert hidden_states.is_contiguous(), "Hidden_states must be contiguous"
190
+ assert w1.stride(-1) == 1, "Stride of last dimension must be 1"
191
+ assert hidden_states.dtype in [torch.float32, torch.float16, torch.bfloat16]
192
+
193
+ num_tokens = hidden_states.size(0)
194
+ E, N, K = w1.size()
195
+
196
+ if global_num_experts == -1:
197
+ global_num_experts = E
198
+ top_k_num = topk_ids.size(1)
199
+ M = num_tokens
200
+
201
+ # Note: for use_int8_w8a16 or use_int4_w4a16, the activations are
202
+ # quantized prior to calling fused_experts.
203
+ quant_dtype = _get_config_quant_dtype(
204
+ use_fp8_w8a8=use_fp8_w8a8,
205
+ use_int8_w8a8=use_int8_w8a8,
206
+ )
207
+
208
+ intermediate_cache1 = torch.empty(
209
+ (M, top_k_num, N), device=hidden_states.device, dtype=hidden_states.dtype
210
+ )
211
+
212
+ if hidden_states.dtype == torch.bfloat16:
213
+ compute_type = tl.bfloat16
214
+ elif hidden_states.dtype == torch.float16:
215
+ compute_type = tl.float16
216
+ elif hidden_states.dtype == torch.float32:
217
+ compute_type = tl.float32
218
+ else:
219
+ raise ValueError(f"Unsupported compute_type: {hidden_states.dtype}")
220
+
221
+ out_hidden_states = torch.empty(
222
+ (M, N),
223
+ device=hidden_states.device,
224
+ dtype=hidden_states.dtype,
225
+ )
226
+
227
+ qhidden_states, a1q_scale = moe_kernel_quantize_input(
228
+ A=hidden_states,
229
+ A_scale=a1_scale,
230
+ quant_dtype=quant_dtype,
231
+ per_act_token_quant=per_channel_quant,
232
+ block_shape=block_shape,
233
+ )
234
+
235
+ sorted_token_ids, expert_ids, num_tokens_post_padded = _prepare_expert_assignment(
236
+ topk_ids,
237
+ config,
238
+ num_tokens,
239
+ top_k_num,
240
+ global_num_experts,
241
+ expert_map,
242
+ use_int8_w8a16=use_int8_w8a16,
243
+ use_int4_w4a16=use_int4_w4a16,
244
+ block_shape=block_shape,
245
+ ignore_invalid_experts=True,
246
+ )
247
+
248
+ dispatch_fused_moe_kernel(
249
+ qhidden_states,
250
+ w1,
251
+ intermediate_cache1,
252
+ a1q_scale,
253
+ w1_scale,
254
+ w1_zp,
255
+ topk_weights,
256
+ sorted_token_ids,
257
+ expert_ids,
258
+ num_tokens_post_padded,
259
+ False, # apply_router_weight_on_input,
260
+ top_k_num,
261
+ config,
262
+ compute_type=compute_type,
263
+ use_fp8_w8a8=use_fp8_w8a8,
264
+ use_int8_w8a8=use_int8_w8a8,
265
+ use_int8_w8a16=use_int8_w8a16,
266
+ use_int4_w4a16=use_int4_w4a16,
267
+ per_channel_quant=per_channel_quant,
268
+ block_shape=block_shape,
269
+ B_bias=w1_bias,
270
+ )
271
+
272
+ intermediate_cache1 = F.silu(intermediate_cache1)
273
+ intermediate_cache1 *= topk_weights[:, :, None].to(intermediate_cache1.dtype)
274
+
275
+ ops.moe_sum(intermediate_cache1, out_hidden_states)
276
+
277
+ return out_hidden_states
278
+
279
+
280
+class K2HorizonRMSNorm(RMSNorm):
281
+ def __init__(self, hidden_size: int, n_groups: int, eps: float = 1e-6):
282
+ assert hidden_size % n_groups == 0
283
+ super().__init__(hidden_size, eps=eps)
284
+ self.hidden_size = hidden_size
285
+ self.n_groups = n_groups
286
+ self.group_size = hidden_size // n_groups
287
+
288
+ def forward(self, x, residual=None):
289
+ if self.n_groups == 1:
290
+ return super().forward(x, residual)
291
+
292
+ if residual is not None:
293
+ x = x + residual
294
+ residual = x
295
+
296
+ orig_shape = x.shape
297
+ x_grouped = x.reshape(-1, self.n_groups, self.group_size).reshape(
298
+ -1, self.group_size
299
+ )
300
+ y_grouped = torch.empty_like(x_grouped)
301
+
302
+ # Reuse vLLM's RMSNorm CUDA kernel on each group.
303
+ ops.rms_norm(
304
+ y_grouped,
305
+ x_grouped,
306
+ torch.ones(self.group_size, dtype=x.dtype, device=x.device),
307
+ self.variance_epsilon,
308
+ )
309
+
310
+ y = y_grouped.reshape(orig_shape) * self.weight
311
+ return (y, residual) if residual is not None else y
312
+
313
+
314
+class K2HorizonMLP(nn.Module):
315
+ def __init__(
316
+ self,
317
+ hidden_size: int,
318
+ intermediate_size: int,
319
+ hidden_act: str,
320
+ quant_config: QuantizationConfig | None = None,
321
+ reduce_results: bool = True,
322
+ expert_gate: torch.nn.Linear | None = None,
323
+ prefix: str = "",
324
+ ) -> None:
325
+ super().__init__()
326
+ self.gate_up_proj = MergedColumnParallelLinear(
327
+ hidden_size,
328
+ [intermediate_size] * 2,
329
+ bias=False,
330
+ quant_config=quant_config,
331
+ prefix=f"{prefix}.gate_up_proj",
332
+ )
333
+ self.down_proj = RowParallelLinear(
334
+ intermediate_size,
335
+ hidden_size,
336
+ bias=False,
337
+ quant_config=quant_config,
338
+ reduce_results=reduce_results,
339
+ prefix=f"{prefix}.down_proj",
340
+ )
341
+ if hidden_act != "silu":
342
+ raise ValueError(
343
+ f"Unsupported activation: {hidden_act}. Only silu is supported for now."
344
+ )
345
+ self.act_fn = SiluAndMul()
346
+ self.expert_gate = expert_gate
347
+
348
+ def forward(self, x):
349
+ gate_up, _ = self.gate_up_proj(x)
350
+ out = self.act_fn(gate_up)
351
+ out, _ = self.down_proj(out)
352
+
353
+ if self.expert_gate is not None:
354
+ out = F.sigmoid(self.expert_gate(x)[0]) * out
355
+
356
+ return out
357
+
358
+
359
+class K2HorizonSparseMoeBlock(nn.Module):
360
+ def __init__(
361
+ self,
362
+ vllm_config: VllmConfig,
363
+ prefix: str = "",
364
+ ):
365
+ super().__init__()
366
+
367
+ config = vllm_config.model_config.hf_text_config
368
+ parallel_config = vllm_config.parallel_config
369
+ quant_config = vllm_config.quant_config
370
+
371
+ self.tp_size = get_tensor_model_parallel_world_size()
372
+
373
+ self.ep_group = get_ep_group().device_group
374
+ self.ep_rank = get_ep_group().rank_in_group
375
+ self.ep_size = self.ep_group.size()
376
+ self.n_routed_experts = config.num_experts
377
+
378
+ self.is_sequence_parallel = parallel_config.use_sequence_parallel_moe
379
+
380
+ if self.tp_size > config.num_experts:
381
+ raise ValueError(
382
+ f"Tensor parallel size {self.tp_size} is greater than "
383
+ f"the number of experts {config.num_experts}."
384
+ )
385
+
386
+ # Load balancing settings.
387
+ vllm_config = get_current_vllm_config()
388
+ eplb_config = vllm_config.parallel_config.eplb_config
389
+ self.enable_eplb = parallel_config.enable_eplb
390
+
391
+ self.n_logical_experts = self.n_routed_experts
392
+ self.n_redundant_experts = eplb_config.num_redundant_experts
393
+ self.n_physical_experts = self.n_logical_experts + self.n_redundant_experts
394
+ self.n_local_physical_experts = self.n_physical_experts // self.ep_size
395
+
396
+ self.physical_expert_start = self.ep_rank * self.n_local_physical_experts
397
+ self.physical_expert_end = (
398
+ self.physical_expert_start + self.n_local_physical_experts
399
+ )
400
+
401
+ self.gate = ReplicatedLinear(
402
+ config.hidden_size,
403
+ config.num_experts,
404
+ bias=config.moe_gate_bias,
405
+ skip_bias_add=True,
406
+ quant_config=quant_config,
407
+ prefix=f"{prefix}.gate",
408
+ )
409
+ if self.gate.bias is not None:
410
+ self.gate.bias = nn.Parameter(self.gate.bias.float(), requires_grad=False)
411
+
412
+ self.num_shared_experts = config.num_shared_experts
413
+ if config.num_shared_experts > 0:
414
+ self.shared_experts = K2HorizonMLP(
415
+ hidden_size=config.hidden_size,
416
+ intermediate_size=config.moe_intermediate_size
417
+ * config.num_shared_experts,
418
+ hidden_act=config.hidden_act,
419
+ quant_config=quant_config,
420
+ reduce_results=False,
421
+ expert_gate=None,
422
+ prefix=f"{prefix}.shared_experts",
423
+ )
424
+ else:
425
+ self.shared_experts = None
426
+
427
+ self.experts = FusedMoEFactory(
428
+ shared_experts=self.shared_experts,
429
+ gate=self.gate,
430
+ num_experts=self.n_routed_experts,
431
+ top_k=config.num_experts_per_tok,
432
+ hidden_size=config.hidden_size,
433
+ intermediate_size=config.moe_intermediate_size,
434
+ renormalize=config.norm_topk_prob,
435
+ quant_config=quant_config,
436
+ prefix=f"{prefix}.experts",
437
+ enable_eplb=self.enable_eplb,
438
+ num_redundant_experts=self.n_redundant_experts,
439
+ is_sequence_parallel=self.is_sequence_parallel,
440
+ scoring_func=config.router_score_func,
441
+ e_score_correction_bias=self.gate.bias,
442
+ routed_scaling_factor=config.router_scaling_factor,
443
+ )
444
+
445
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
446
+ assert hidden_states.dim() <= 2, (
447
+ "K2HorizonSparseMoeBlock only supports 1D or 2D inputs"
448
+ )
449
+ is_input_1d = hidden_states.dim() == 1
450
+ num_tokens, hidden_dim = hidden_states.shape
451
+ hidden_states = hidden_states.view(-1, hidden_dim)
452
+
453
+ if self.is_sequence_parallel:
454
+ hidden_states = sequence_parallel_chunk(hidden_states)
455
+
456
+ final_hidden_states = self.experts(
457
+ hidden_states=hidden_states, router_logits=hidden_states
458
+ )
459
+
460
+ if self.is_sequence_parallel:
461
+ final_hidden_states = tensor_model_parallel_all_gather(
462
+ final_hidden_states, 0
463
+ )
464
+ final_hidden_states = final_hidden_states[:num_tokens]
465
+
466
+ # return to 1d if input is 1d
467
+ return final_hidden_states.squeeze(0) if is_input_1d else final_hidden_states
468
+
469
+
470
+class K2HorizonAttention(nn.Module):
471
+ def __init__(
472
+ self,
473
+ hidden_size: int,
474
+ num_heads: int,
475
+ num_kv_heads: int,
476
+ rope_parameters: dict[str, Any],
477
+ rope_head_dim: int,
478
+ query_key_norm: bool,
479
+ max_position_embeddings: int = 8192,
480
+ head_dim: int | None = None,
481
+ rms_norm_eps: float = 1e-06,
482
+ qkv_bias: bool = False,
483
+ cache_config: CacheConfig | None = None,
484
+ quant_config: QuantizationConfig | None = None,
485
+ prefix: str = "",
486
+ dual_chunk_attention_config: dict[str, Any] | None = None,
487
+ gate_func: str | None = None,
488
+ ) -> None:
489
+ super().__init__()
490
+ self.hidden_size = hidden_size
491
+ tp_size = get_tensor_model_parallel_world_size()
492
+ self.total_num_heads = num_heads
493
+ assert self.total_num_heads % tp_size == 0
494
+ self.num_heads = self.total_num_heads // tp_size
495
+ self.total_num_kv_heads = num_kv_heads
496
+ if self.total_num_kv_heads >= tp_size:
497
+ # Number of KV heads is greater than TP size, so we partition
498
+ # the KV heads across multiple tensor parallel GPUs.
499
+ assert self.total_num_kv_heads % tp_size == 0
500
+ else:
501
+ # Number of KV heads is less than TP size, so we replicate
502
+ # the KV heads across multiple tensor parallel GPUs.
503
+ assert tp_size % self.total_num_kv_heads == 0
504
+ self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
505
+ self.head_dim = head_dim or (hidden_size // self.total_num_heads)
506
+ self.q_size = self.num_heads * self.head_dim
507
+ self.kv_size = self.num_kv_heads * self.head_dim
508
+ self.scaling = self.head_dim**-0.5
509
+ self.max_position_embeddings = max_position_embeddings
510
+ self.dual_chunk_attention_config = dual_chunk_attention_config
511
+
512
+ self.qkv_proj = QKVParallelLinear(
513
+ hidden_size,
514
+ self.head_dim,
515
+ self.total_num_heads,
516
+ self.total_num_kv_heads,
517
+ bias=qkv_bias,
518
+ quant_config=quant_config,
519
+ prefix=f"{prefix}.qkv_proj",
520
+ )
521
+
522
+ self.o_proj = RowParallelLinear(
523
+ self.total_num_heads * self.head_dim,
524
+ hidden_size,
525
+ bias=qkv_bias,
526
+ quant_config=quant_config,
527
+ prefix=f"{prefix}.o_proj",
528
+ )
529
+
530
+ self.rope_head_dim = rope_head_dim or self.head_dim
531
+ self.rotary_emb = get_rope(
532
+ self.rope_head_dim,
533
+ max_position=max_position_embeddings,
534
+ rope_parameters=rope_parameters,
535
+ dual_chunk_attention_config=dual_chunk_attention_config,
536
+ )
537
+ self.attn = Attention(
538
+ self.num_heads,
539
+ self.head_dim,
540
+ self.scaling,
541
+ num_kv_heads=self.num_kv_heads,
542
+ cache_config=cache_config,
543
+ quant_config=quant_config,
544
+ prefix=f"{prefix}.attn",
545
+ **{
546
+ "layer_idx": extract_layer_index(prefix),
547
+ "dual_chunk_attention_config": dual_chunk_attention_config,
548
+ }
549
+ if dual_chunk_attention_config
550
+ else {},
551
+ )
552
+
553
+ self.query_key_norm = query_key_norm
554
+ if self.query_key_norm:
555
+ self.q_norm = K2HorizonRMSNorm(
556
+ hidden_size=self.q_size, n_groups=self.num_heads, eps=rms_norm_eps
557
+ )
558
+ self.k_norm = K2HorizonRMSNorm(
559
+ hidden_size=self.kv_size, n_groups=self.num_kv_heads, eps=rms_norm_eps
560
+ )
561
+
562
+ self.gate_func = gate_func
563
+ if self.gate_func is not None:
564
+ self.gate_proj = ColumnParallelLinear(
565
+ hidden_size,
566
+ self.total_num_heads * self.head_dim,
567
+ bias=False,
568
+ quant_config=quant_config,
569
+ prefix=f"{prefix}.gate_proj",
570
+ )
571
+
572
+ self.tp_size = get_tensor_model_parallel_world_size()
573
+ self.tp_rank = get_tensor_model_parallel_rank()
574
+
575
+ def forward(
576
+ self,
577
+ positions: torch.Tensor,
578
+ hidden_states: torch.Tensor,
579
+ ) -> torch.Tensor:
580
+ qkv, _ = self.qkv_proj(hidden_states)
581
+ q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
582
+ if self.query_key_norm:
583
+ q = self.q_norm(q)
584
+ k = self.k_norm(k)
585
+
586
+ if self.rope_head_dim == self.head_dim:
587
+ q, k = self.rotary_emb(positions, q, k)
588
+ else:
589
+ q = q.reshape(*q.shape[:-1], self.num_heads, self.head_dim)
590
+ k = k.reshape(*k.shape[:-1], self.num_kv_heads, self.head_dim)
591
+
592
+ q_rope, q_nope = torch.split(
593
+ split_to_interleaved(q),
594
+ split_size_or_sections=[
595
+ self.rope_head_dim,
596
+ self.head_dim - self.rope_head_dim,
597
+ ],
598
+ dim=-1,
599
+ )
600
+ k_rope, k_nope = torch.split(
601
+ split_to_interleaved(k),
602
+ split_size_or_sections=[
603
+ self.rope_head_dim,
604
+ self.head_dim - self.rope_head_dim,
605
+ ],
606
+ dim=-1,
607
+ )
608
+
609
+ q_rope, k_rope = self.rotary_emb(
610
+ positions, interleaved_to_split(q_rope), interleaved_to_split(k_rope)
611
+ )
612
+
613
+ q = interleaved_to_split(
614
+ torch.cat([split_to_interleaved(q_rope), q_nope], dim=-1)
615
+ ).reshape(*q.shape[:-2], -1)
616
+
617
+ k = interleaved_to_split(
618
+ torch.cat([split_to_interleaved(k_rope), k_nope], dim=-1)
619
+ ).reshape(*k.shape[:-2], -1)
620
+
621
+ attn_output = self.attn(q, k, v)
622
+
623
+ if self.gate_func is not None:
624
+ gate, _ = self.gate_proj(hidden_states)
625
+ if self.gate_func == "silu":
626
+ gate = F.silu(gate)
627
+ else:
628
+ assert self.gate_func == "softplus"
629
+ gate = F.softplus(gate, beta=math.log(2))
630
+
631
+ attn_output = attn_output * gate
632
+
633
+ output, _ = self.o_proj(attn_output)
634
+ return output
635
+
636
+
637
+class K2HorizonMoVAAttention(nn.Module):
638
+ def __init__(
639
+ self,
640
+ vllm_config: VllmConfig,
641
+ hidden_size: int,
642
+ num_heads: int,
643
+ num_kv_heads: int,
644
+ rope_parameters: dict[str, Any],
645
+ rope_head_dim: int,
646
+ query_key_norm: bool,
647
+ num_experts: int,
648
+ num_experts_per_tok: int,
649
+ moe_gate_bias: bool,
650
+ router_score_func: str,
651
+ router_scaling_factor: float,
652
+ max_position_embeddings: int = 8192,
653
+ head_dim: int | None = None,
654
+ rms_norm_eps: float = 1e-06,
655
+ qkv_bias: bool = False,
656
+ cache_config: CacheConfig | None = None,
657
+ quant_config: QuantizationConfig | None = None,
658
+ prefix: str = "",
659
+ dual_chunk_attention_config: dict[str, Any] | None = None,
660
+ gate_func: str | None = None,
661
+ ) -> None:
662
+ super().__init__()
663
+ self.hidden_size = hidden_size
664
+ tp_size = get_tensor_model_parallel_world_size()
665
+ self.total_num_heads = num_heads
666
+ assert self.total_num_heads % tp_size == 0
667
+ self.num_heads = self.total_num_heads // tp_size
668
+ self.total_num_kv_heads = num_kv_heads
669
+ if self.total_num_kv_heads >= tp_size:
670
+ # Number of KV heads is greater than TP size, so we partition
671
+ # the KV heads across multiple tensor parallel GPUs.
672
+ assert self.total_num_kv_heads % tp_size == 0
673
+ else:
674
+ # Number of KV heads is less than TP size, so we replicate
675
+ # the KV heads across multiple tensor parallel GPUs.
676
+ assert tp_size % self.total_num_kv_heads == 0
677
+
678
+ self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
679
+ self.head_dim = head_dim or (hidden_size // self.total_num_heads)
680
+ self.q_size = self.num_heads * self.head_dim
681
+ self.kv_size = self.num_kv_heads * self.head_dim
682
+ self.scaling = self.head_dim**-0.5
683
+ self.max_position_embeddings = max_position_embeddings
684
+ self.dual_chunk_attention_config = dual_chunk_attention_config
685
+
686
+ self.num_experts = num_experts
687
+ self.num_experts_per_tok = num_experts_per_tok
688
+ self.router_score_func = router_score_func
689
+ self.router_scaling_factor = router_scaling_factor
690
+
691
+ self.qk_proj = MergedColumnParallelLinear(
692
+ hidden_size,
693
+ [self.total_num_heads * self.head_dim, self.kv_size * tp_size],
694
+ bias=qkv_bias,
695
+ quant_config=quant_config,
696
+ prefix=f"{prefix}.qk_proj",
697
+ )
698
+
699
+ self.v_router = ReplicatedLinear(
700
+ hidden_size,
701
+ num_experts,
702
+ bias=moe_gate_bias,
703
+ skip_bias_add=True,
704
+ quant_config=quant_config,
705
+ prefix=f"{prefix}.v_router",
706
+ )
707
+ if self.v_router.bias is not None:
708
+ self.v_router.bias = nn.Parameter(
709
+ self.v_router.bias.float(), requires_grad=False
710
+ )
711
+
712
+ self.v_experts = nn.ModuleList(
713
+ [
714
+ ColumnParallelLinear(
715
+ hidden_size,
716
+ self.kv_size * tp_size,
717
+ bias=False,
718
+ quant_config=quant_config,
719
+ prefix=f"{prefix}.v_experts.{value}",
720
+ )
721
+ for value in range(num_experts)
722
+ ]
723
+ )
724
+
725
+ self.o_proj = RowParallelLinear(
726
+ self.total_num_heads * self.head_dim,
727
+ hidden_size,
728
+ bias=qkv_bias,
729
+ quant_config=quant_config,
730
+ prefix=f"{prefix}.o_proj",
731
+ )
732
+
733
+ self.rope_head_dim = rope_head_dim or self.head_dim
734
+ self.rotary_emb = get_rope(
735
+ self.rope_head_dim,
736
+ max_position=max_position_embeddings,
737
+ rope_parameters=rope_parameters,
738
+ dual_chunk_attention_config=dual_chunk_attention_config,
739
+ )
740
+ self.attn = Attention(
741
+ self.num_heads,
742
+ self.head_dim,
743
+ self.scaling,
744
+ num_kv_heads=self.num_kv_heads,
745
+ cache_config=cache_config,
746
+ quant_config=quant_config,
747
+ prefix=f"{prefix}.attn",
748
+ **{
749
+ "layer_idx": extract_layer_index(prefix),
750
+ "dual_chunk_attention_config": dual_chunk_attention_config,
751
+ }
752
+ if dual_chunk_attention_config
753
+ else {},
754
+ )
755
+
756
+ self.query_key_norm = query_key_norm
757
+ if self.query_key_norm:
758
+ self.q_norm = K2HorizonRMSNorm(
759
+ hidden_size=self.q_size, n_groups=self.num_heads, eps=rms_norm_eps
760
+ )
761
+ self.k_norm = K2HorizonRMSNorm(
762
+ hidden_size=self.kv_size, n_groups=self.num_kv_heads, eps=rms_norm_eps
763
+ )
764
+
765
+ self.gate_func = gate_func
766
+ if self.gate_func is not None:
767
+ self.gate_proj = ColumnParallelLinear(
768
+ hidden_size,
769
+ self.total_num_heads * self.head_dim,
770
+ bias=False,
771
+ quant_config=quant_config,
772
+ prefix=f"{prefix}.gate_proj",
773
+ )
774
+
775
+ self.tp_size = get_tensor_model_parallel_world_size()
776
+ self.tp_rank = get_tensor_model_parallel_rank()
777
+
778
+ w1_shape = torch.Size(
779
+ [
780
+ self.num_experts,
781
+ self.total_num_kv_heads * self.head_dim,
782
+ hidden_size,
783
+ ]
784
+ )
785
+ config_dtype = _get_config_dtype_str(
786
+ use_fp8_w8a8=False,
787
+ use_int8_w8a16=False,
788
+ use_int4_w4a16=False,
789
+ dtype=vllm_config.model_config.dtype,
790
+ )
791
+ self.fused_mova_config = try_get_optimal_moe_config(
792
+ w1_shape=w1_shape,
793
+ w2_shape=torch.Size([w1_shape[0], w1_shape[2], w1_shape[1]]),
794
+ top_k=self.num_experts_per_tok,
795
+ dtype=config_dtype,
796
+ M=vllm_config.scheduler_config.max_num_batched_tokens,
797
+ block_shape=None,
798
+ )
799
+
800
+ def compute_mova_v_sparse(self, hidden_states):
801
+ router_logits, _ = self.v_router(hidden_states)
802
+
803
+ routing_weights, selected_values = calc_router_weights(
804
+ router_logits=router_logits,
805
+ e_score_correction_bias=self.v_router.bias,
806
+ score_func=self.router_score_func,
807
+ top_k=self.num_experts_per_tok,
808
+ scaling_factor=self.router_scaling_factor,
809
+ )
810
+
811
+ w1 = torch.stack(
812
+ [expert.weight for expert in self.v_experts], dim=0
813
+ ).contiguous()
814
+
815
+ v = fused_mova_impl(
816
+ config=self.fused_mova_config,
817
+ hidden_states=hidden_states.contiguous(),
818
+ w1=w1,
819
+ topk_weights=routing_weights.contiguous(),
820
+ topk_ids=selected_values.contiguous(),
821
+ global_num_experts=self.num_experts,
822
+ expert_map=None,
823
+ )
824
+
825
+ return v
826
+
827
+ def forward(
828
+ self,
829
+ positions: torch.Tensor,
830
+ hidden_states: torch.Tensor,
831
+ ) -> torch.Tensor:
832
+ qk, _ = self.qk_proj(hidden_states)
833
+ q, k = qk.split([self.q_size, self.kv_size], dim=-1)
834
+ v = self.compute_mova_v_sparse(hidden_states)
835
+
836
+ if self.query_key_norm:
837
+ q = self.q_norm(q)
838
+ k = self.k_norm(k)
839
+
840
+ if self.rope_head_dim == self.head_dim:
841
+ q, k = self.rotary_emb(positions, q, k)
842
+ else:
843
+ q = q.reshape(*q.shape[:-1], self.num_heads, self.head_dim)
844
+ k = k.reshape(*k.shape[:-1], self.num_kv_heads, self.head_dim)
845
+
846
+ q_rope, q_nope = torch.split(
847
+ split_to_interleaved(q),
848
+ split_size_or_sections=[
849
+ self.rope_head_dim,
850
+ self.head_dim - self.rope_head_dim,
851
+ ],
852
+ dim=-1,
853
+ )
854
+ k_rope, k_nope = torch.split(
855
+ split_to_interleaved(k),
856
+ split_size_or_sections=[
857
+ self.rope_head_dim,
858
+ self.head_dim - self.rope_head_dim,
859
+ ],
860
+ dim=-1,
861
+ )
862
+
863
+ q_rope, k_rope = self.rotary_emb(
864
+ positions, interleaved_to_split(q_rope), interleaved_to_split(k_rope)
865
+ )
866
+
867
+ q = interleaved_to_split(
868
+ torch.cat([split_to_interleaved(q_rope), q_nope], dim=-1)
869
+ ).reshape(*q.shape[:-2], -1)
870
+
871
+ k = interleaved_to_split(
872
+ torch.cat([split_to_interleaved(k_rope), k_nope], dim=-1)
873
+ ).reshape(*k.shape[:-2], -1)
874
+
875
+ attn_output = self.attn(q, k, v)
876
+
877
+ if self.gate_func is not None:
878
+ gate, _ = self.gate_proj(hidden_states)
879
+ if self.gate_func == "silu":
880
+ gate = F.silu(gate)
881
+ else:
882
+ assert self.gate_func == "softplus"
883
+ gate = F.softplus(gate, beta=math.log(2))
884
+
885
+ attn_output = attn_output * gate
886
+
887
+ output, _ = self.o_proj(attn_output)
888
+ return output
889
+
890
+
891
+class K2HorizonDecoderLayer(nn.Module):
892
+ def __init__(self, vllm_config: VllmConfig, prefix: str = "") -> None:
893
+ super().__init__()
894
+
895
+ config = vllm_config.model_config.hf_text_config
896
+ cache_config = vllm_config.cache_config
897
+ quant_config = vllm_config.quant_config
898
+
899
+ self.hidden_size = config.hidden_size
900
+ max_position_embeddings = getattr(config, "max_position_embeddings", 8192)
901
+ dual_chunk_attention_config = getattr(
902
+ config, "dual_chunk_attention_config", None
903
+ )
904
+
905
+ # `mlp_only_layers` in the config.
906
+ layer_idx = extract_layer_index(prefix)
907
+ mlp_only_layers = (
908
+ [] if not hasattr(config, "mlp_only_layers") else config.mlp_only_layers
909
+ )
910
+ is_sparse_layer = (layer_idx not in mlp_only_layers) and (
911
+ config.num_experts > 0 and (layer_idx + 1) % config.decoder_sparse_step == 0
912
+ )
913
+
914
+ if is_sparse_layer and config.mova_num_experts > 0:
915
+ self.self_attn = K2HorizonMoVAAttention(
916
+ vllm_config=vllm_config,
917
+ hidden_size=self.hidden_size,
918
+ num_heads=config.num_attention_heads,
919
+ num_kv_heads=config.num_key_value_heads,
920
+ rope_parameters=config.rope_parameters,
921
+ rope_head_dim=config.rope_head_dim,
922
+ query_key_norm=config.query_key_norm,
923
+ max_position_embeddings=max_position_embeddings,
924
+ rms_norm_eps=config.rms_norm_eps,
925
+ qkv_bias=config.attention_bias,
926
+ head_dim=config.head_dim,
927
+ gate_func=config.attention_gate_func,
928
+ cache_config=cache_config,
929
+ quant_config=quant_config,
930
+ prefix=f"{prefix}.self_attn",
931
+ dual_chunk_attention_config=dual_chunk_attention_config,
932
+ num_experts=config.mova_num_experts,
933
+ num_experts_per_tok=config.mova_num_experts_per_tok,
934
+ moe_gate_bias=config.moe_gate_bias,
935
+ router_score_func=config.router_score_func,
936
+ router_scaling_factor=config.router_scaling_factor,
937
+ )
938
+ else:
939
+ self.self_attn = K2HorizonAttention(
940
+ hidden_size=self.hidden_size,
941
+ num_heads=config.num_attention_heads,
942
+ num_kv_heads=config.num_key_value_heads,
943
+ rope_parameters=config.rope_parameters,
944
+ rope_head_dim=config.rope_head_dim,
945
+ query_key_norm=config.query_key_norm,
946
+ max_position_embeddings=max_position_embeddings,
947
+ rms_norm_eps=config.rms_norm_eps,
948
+ qkv_bias=config.attention_bias,
949
+ head_dim=config.head_dim,
950
+ gate_func=config.attention_gate_func,
951
+ cache_config=cache_config,
952
+ quant_config=quant_config,
953
+ prefix=f"{prefix}.self_attn",
954
+ dual_chunk_attention_config=dual_chunk_attention_config,
955
+ )
956
+
957
+ if is_sparse_layer:
958
+ self.mlp = K2HorizonSparseMoeBlock(
959
+ vllm_config=vllm_config, prefix=f"{prefix}.mlp"
960
+ )
961
+ else:
962
+ self.mlp = K2HorizonMLP(
963
+ hidden_size=config.hidden_size,
964
+ intermediate_size=config.intermediate_size,
965
+ hidden_act=config.hidden_act,
966
+ quant_config=quant_config,
967
+ prefix=f"{prefix}.mlp",
968
+ )
969
+
970
+ self.input_layernorm = K2HorizonRMSNorm(
971
+ hidden_size=config.hidden_size,
972
+ n_groups=config.layernorm_num_groups,
973
+ eps=config.rms_norm_eps,
974
+ )
975
+
976
+ self.post_attention_layernorm = K2HorizonRMSNorm(
977
+ hidden_size=config.hidden_size,
978
+ n_groups=config.layernorm_num_groups,
979
+ eps=config.rms_norm_eps,
980
+ )
981
+
982
+ def forward(
983
+ self,
984
+ positions: torch.Tensor,
985
+ hidden_states: torch.Tensor,
986
+ residual: torch.Tensor | None,
987
+ ) -> tuple[torch.Tensor, torch.Tensor]:
988
+ # Self Attention
989
+ if residual is None:
990
+ residual = hidden_states
991
+ hidden_states = self.input_layernorm(hidden_states)
992
+ else:
993
+ hidden_states, residual = self.input_layernorm(hidden_states, residual)
994
+
995
+ hidden_states = self.self_attn(positions=positions, hidden_states=hidden_states)
996
+
997
+ # Fully Connected
998
+ hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
999
+ hidden_states = self.mlp(hidden_states)
1000
+ return hidden_states, residual
1001
+
1002
+
1003
+@support_torch_compile
1004
+class K2HorizonModel(nn.Module, EagleModelMixin):
1005
+ def __init__(
1006
+ self,
1007
+ *,
1008
+ vllm_config: VllmConfig,
1009
+ prefix: str = "",
1010
+ decoder_layer_type: type[torch.nn.Module] = K2HorizonDecoderLayer,
1011
+ ):
1012
+ super().__init__()
1013
+
1014
+ config = vllm_config.model_config.hf_text_config
1015
+ quant_config = vllm_config.quant_config
1016
+ parallel_config = vllm_config.parallel_config
1017
+ eplb_config = parallel_config.eplb_config
1018
+ self.num_redundant_experts = eplb_config.num_redundant_experts
1019
+
1020
+ self.vocab_size = config.vocab_size
1021
+ self.config = config
1022
+ self.quant_config = quant_config
1023
+ self.embed_tokens = VocabParallelEmbedding(
1024
+ config.vocab_size,
1025
+ config.hidden_size,
1026
+ quant_config=quant_config,
1027
+ prefix=f"{prefix}.embed_tokens",
1028
+ )
1029
+ self.start_layer, self.end_layer, self.layers = make_layers(
1030
+ config.num_hidden_layers,
1031
+ lambda prefix: decoder_layer_type(vllm_config=vllm_config, prefix=prefix),
1032
+ prefix=f"{prefix}.layers",
1033
+ )
1034
+
1035
+ self.norm = K2HorizonRMSNorm(
1036
+ hidden_size=config.hidden_size,
1037
+ n_groups=config.layernorm_num_groups,
1038
+ eps=config.rms_norm_eps,
1039
+ )
1040
+
1041
+ self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
1042
+ ["hidden_states", "residual"], config.hidden_size
1043
+ )
1044
+
1045
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
1046
+ return self.embed_tokens(input_ids)
1047
+
1048
+ def forward(
1049
+ self,
1050
+ input_ids: torch.Tensor | None,
1051
+ positions: torch.Tensor,
1052
+ intermediate_tensors: IntermediateTensors | None = None,
1053
+ inputs_embeds: torch.Tensor | None = None,
1054
+ ) -> torch.Tensor | IntermediateTensors | tuple[torch.Tensor, list[torch.Tensor]]:
1055
+ if get_pp_group().is_first_rank:
1056
+ if inputs_embeds is not None:
1057
+ hidden_states = inputs_embeds
1058
+ else:
1059
+ hidden_states = self.embed_input_ids(input_ids)
1060
+ residual = None
1061
+ else:
1062
+ assert intermediate_tensors is not None
1063
+ hidden_states = intermediate_tensors["hidden_states"]
1064
+ residual = intermediate_tensors["residual"]
1065
+
1066
+ aux_hidden_states = self._maybe_add_hidden_state(
1067
+ [], self.start_layer, hidden_states, residual
1068
+ )
1069
+ for layer_idx, layer in enumerate(
1070
+ islice(self.layers, self.start_layer, self.end_layer),
1071
+ start=self.start_layer,
1072
+ ):
1073
+ hidden_states, residual = layer(positions, hidden_states, residual)
1074
+ self._maybe_add_hidden_state(
1075
+ aux_hidden_states, layer_idx + 1, hidden_states, residual
1076
+ )
1077
+
1078
+ if not get_pp_group().is_last_rank:
1079
+ return IntermediateTensors(
1080
+ {"hidden_states": hidden_states, "residual": residual}
1081
+ )
1082
+ hidden_states, _ = self.norm(hidden_states, residual)
1083
+
1084
+ # Return auxiliary hidden states if collected
1085
+ if len(aux_hidden_states) > 0:
1086
+ return hidden_states, aux_hidden_states
1087
+ return hidden_states
1088
+
1089
+ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
1090
+ # Params for weights, fp8 weight scales, fp8 activation scales
1091
+ # (param_name, weight_name, expert_id, shard_id)
1092
+ return fused_moe_make_expert_params_mapping(
1093
+ self,
1094
+ ckpt_gate_proj_name="gate_proj",
1095
+ ckpt_down_proj_name="down_proj",
1096
+ ckpt_up_proj_name="up_proj",
1097
+ num_experts=self.config.num_experts,
1098
+ num_redundant_experts=self.num_redundant_experts,
1099
+ )
1100
+
1101
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
1102
+ stacked_params_mapping = [
1103
+ # (param_name, shard_name, shard_id)
1104
+ ("qkv_proj", "q_proj", "q"),
1105
+ ("qkv_proj", "k_proj", "k"),
1106
+ ("qkv_proj", "v_proj", "v"),
1107
+ ("gate_up_proj", "gate_proj", 0),
1108
+ ("gate_up_proj", "up_proj", 1),
1109
+ ]
1110
+
1111
+ # Skip loading extra parameters for GPTQ/modelopt models.
1112
+ # ignore_suffixes = (
1113
+ # ".bias",
1114
+ # "_bias",
1115
+ # ".weight_scale",
1116
+ # "_weight_scale",
1117
+ # ".input_scale",
1118
+ # "_input_scale",
1119
+ # )
1120
+ ignore_suffixes = ()
1121
+
1122
+ params_dict = dict(self.named_parameters())
1123
+
1124
+ loaded_params: set[str] = set()
1125
+ expert_params_mapping = self.get_expert_mapping()
1126
+ for name, loaded_weight in weights:
1127
+ if "scale" in name or "zero_point" in name:
1128
+ name = maybe_remap_kv_scale_name(name, params_dict)
1129
+ if name is None:
1130
+ continue
1131
+
1132
+ # QK norm weights
1133
+ if name.endswith(".self_attn.q_norm.weight") or name.endswith(
1134
+ ".self_attn.k_norm.weight"
1135
+ ):
1136
+ if is_pp_missing_parameter(name, self):
1137
+ continue
1138
+ if name not in params_dict:
1139
+ continue
1140
+
1141
+ param = params_dict[name]
1142
+ tp_rank = get_tensor_model_parallel_rank()
1143
+ tp_size = get_tensor_model_parallel_world_size()
1144
+
1145
+ if loaded_weight.shape != param.shape:
1146
+ if name.endswith(".self_attn.q_norm.weight"):
1147
+ loaded_weight = loaded_weight.chunk(tp_size, dim=0)[tp_rank]
1148
+ else:
1149
+ num_kv_heads = self.config.num_key_value_heads
1150
+ if num_kv_heads >= tp_size:
1151
+ loaded_weight = loaded_weight.chunk(tp_size, dim=0)[tp_rank]
1152
+ else:
1153
+ num_kv_head_replicas = tp_size // num_kv_heads
1154
+ kv_rank = tp_rank // num_kv_head_replicas
1155
+ loaded_weight = loaded_weight.chunk(num_kv_heads, dim=0)[
1156
+ kv_rank
1157
+ ]
1158
+
1159
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
1160
+ weight_loader(param, loaded_weight)
1161
+ loaded_params.add(name)
1162
+ continue
1163
+
1164
+ # MoVA v experts
1165
+ if ".self_attn.v_experts." in name:
1166
+ if is_pp_missing_parameter(name, self):
1167
+ continue
1168
+ if name not in params_dict:
1169
+ continue
1170
+
1171
+ param = params_dict[name]
1172
+ tp_rank = get_tensor_model_parallel_rank()
1173
+ tp_size = get_tensor_model_parallel_world_size()
1174
+ num_kv_heads = self.config.num_key_value_heads
1175
+
1176
+ if loaded_weight.shape != param.shape:
1177
+ if num_kv_heads >= tp_size:
1178
+ loaded_weight = loaded_weight.chunk(tp_size, dim=0)[tp_rank]
1179
+ else:
1180
+ num_kv_head_replicas = tp_size // num_kv_heads
1181
+ kv_rank = tp_rank // num_kv_head_replicas
1182
+ loaded_weight = loaded_weight.chunk(num_kv_heads, dim=0)[
1183
+ kv_rank
1184
+ ]
1185
+
1186
+ default_weight_loader(param, loaded_weight)
1187
+ loaded_params.add(name)
1188
+ continue
1189
+
1190
+ for param_name, weight_name, shard_id in stacked_params_mapping:
1191
+ # Skip non-stacked layers and experts (experts handled below).
1192
+ if weight_name not in name:
1193
+ continue
1194
+ # We have mlp.experts[0].gate_proj in the checkpoint.
1195
+ # Since we handle the experts below in expert_params_mapping,
1196
+ # we need to skip here BEFORE we update the name, otherwise
1197
+ # name will be updated to mlp.experts[0].gate_up_proj, which
1198
+ # will then be updated below in expert_params_mapping
1199
+ # for mlp.experts[0].gate_gate_up_proj, which breaks load.
1200
+ if "mlp.experts" in name:
1201
+ continue
1202
+ if ".self_attn." in name and param_name == "gate_up_proj":
1203
+ assert weight_name == "gate_proj"
1204
+ continue
1205
+ if (
1206
+ ".self_attn." in name
1207
+ and param_name == "qkv_proj"
1208
+ and name.replace(weight_name, param_name) not in params_dict
1209
+ ):
1210
+ qk_name = name.replace(weight_name, "qk_proj")
1211
+ if is_pp_missing_parameter(qk_name, self):
1212
+ continue
1213
+ if qk_name not in params_dict:
1214
+ continue
1215
+
1216
+ assert weight_name in ["q_proj", "k_proj"]
1217
+ if weight_name == "k_proj":
1218
+ tp_size = get_tensor_model_parallel_world_size()
1219
+ num_kv_heads = self.config.num_key_value_heads
1220
+ if num_kv_heads < tp_size:
1221
+ num_kv_head_replicas = tp_size // num_kv_heads
1222
+ loaded_weight = (
1223
+ loaded_weight.reshape(
1224
+ num_kv_heads, -1, *loaded_weight.shape[1:]
1225
+ )
1226
+ .repeat_interleave(num_kv_head_replicas, dim=0)
1227
+ .reshape(-1, *loaded_weight.shape[1:])
1228
+ )
1229
+
1230
+ param = params_dict[qk_name]
1231
+ weight_loader = getattr(
1232
+ param, "weight_loader", default_weight_loader
1233
+ )
1234
+ weight_loader(
1235
+ param, loaded_weight, 0 if weight_name == "q_proj" else 1
1236
+ )
1237
+ loaded_params.add(qk_name)
1238
+ break
1239
+
1240
+ name = name.replace(weight_name, param_name)
1241
+
1242
+ # Skip loading extra parameters for GPTQ/modelopt models.
1243
+ if name.endswith(ignore_suffixes) and name not in params_dict:
1244
+ continue
1245
+
1246
+ # Skip layers on other devices.
1247
+ if is_pp_missing_parameter(name, self):
1248
+ continue
1249
+ if name.endswith("scale"):
1250
+ # Remapping the name of FP8 kv-scale.
1251
+ name = maybe_remap_kv_scale_name(name, params_dict)
1252
+ if name is None:
1253
+ continue
1254
+ if name not in params_dict:
1255
+ continue
1256
+
1257
+ param = params_dict[name]
1258
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
1259
+ if weight_loader == default_weight_loader:
1260
+ weight_loader(param, loaded_weight)
1261
+ else:
1262
+ weight_loader(param, loaded_weight, shard_id)
1263
+ break
1264
+ else:
1265
+ is_expert_weight = False
1266
+ for mapping in expert_params_mapping:
1267
+ param_name, weight_name, expert_id, shard_id = mapping
1268
+ if weight_name not in name:
1269
+ continue
1270
+
1271
+ # Anyway, this is an expert weight and should not be
1272
+ # attempted to load as other weights later
1273
+ is_expert_weight = True
1274
+
1275
+ # Do not modify `name` since the loop may continue here
1276
+ # Instead, create a new variable
1277
+ name_mapped = name.replace(weight_name, param_name)
1278
+
1279
+ if is_pp_missing_parameter(name_mapped, self):
1280
+ continue
1281
+
1282
+ # Skip loading extra parameters for GPTQ/modelopt models.
1283
+ if (
1284
+ name_mapped.endswith(ignore_suffixes)
1285
+ and name_mapped not in params_dict
1286
+ ):
1287
+ continue
1288
+
1289
+ param = params_dict[name_mapped]
1290
+ # We should ask the weight loader to return success or not
1291
+ # here since otherwise we may skip experts with other
1292
+ # available replicas.
1293
+ weight_loader = typing.cast(
1294
+ Callable[..., bool], param.weight_loader
1295
+ )
1296
+ success = weight_loader(
1297
+ param,
1298
+ loaded_weight,
1299
+ name_mapped,
1300
+ shard_id=shard_id,
1301
+ expert_id=expert_id,
1302
+ return_success=True,
1303
+ )
1304
+ if success:
1305
+ name = name_mapped
1306
+ break
1307
+ else:
1308
+ if is_expert_weight:
1309
+ # We've checked that this is an expert weight
1310
+ # However it's not mapped locally to this rank
1311
+ # So we simply skip it
1312
+ continue
1313
+
1314
+ # Skip loading extra parameters for GPTQ/modelopt models.
1315
+ if name.endswith(ignore_suffixes) and name not in params_dict:
1316
+ continue
1317
+ # Skip layers on other devices.
1318
+ if is_pp_missing_parameter(name, self):
1319
+ continue
1320
+ if name not in params_dict:
1321
+ continue
1322
+ param = params_dict[name]
1323
+ weight_loader = getattr(
1324
+ param, "weight_loader", default_weight_loader
1325
+ )
1326
+ weight_loader(param, loaded_weight)
1327
+ loaded_params.add(name)
1328
+
1329
+ return loaded_params
1330
+
1331
+
1332
+class K2HorizonForCausalLM(
1333
+ nn.Module, SupportsPP, SupportsLoRA, SupportsEagle, SupportsEagle3, MixtureOfExperts
1334
+):
1335
+ packed_modules_mapping = {
1336
+ "qkv_proj": [
1337
+ "q_proj",
1338
+ "k_proj",
1339
+ "v_proj",
1340
+ ],
1341
+ "qk_proj": [
1342
+ "q_proj",
1343
+ "k_proj",
1344
+ ],
1345
+ }
1346
+
1347
+ embedding_modules = {
1348
+ "embed_tokens": "input_embeddings",
1349
+ "lm_head": "output_embeddings",
1350
+ }
1351
+
1352
+ fall_back_to_pt_during_load = False
1353
+
1354
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
1355
+ super().__init__()
1356
+ config = vllm_config.model_config.hf_text_config
1357
+ quant_config = vllm_config.quant_config
1358
+ self.config = config
1359
+ self.quant_config = quant_config
1360
+ # Only perform the following mapping when K2HorizonMLP exists
1361
+ if getattr(config, "mlp_only_layers", []):
1362
+ self.packed_modules_mapping["gate_up_proj"] = ["gate_proj", "up_proj"]
1363
+ self.model = K2HorizonModel(
1364
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
1365
+ )
1366
+ self.lm_head = ParallelLMHead(
1367
+ config.vocab_size,
1368
+ config.hidden_size,
1369
+ quant_config=quant_config,
1370
+ prefix=maybe_prefix(prefix, "lm_head"),
1371
+ )
1372
+ if self.config.tie_word_embeddings:
1373
+ self.lm_head.weight = self.model.embed_tokens.weight
1374
+ self.logits_processor = LogitsProcessor(config.vocab_size)
1375
+ self.make_empty_intermediate_tensors = (
1376
+ self.model.make_empty_intermediate_tensors
1377
+ )
1378
+
1379
+ # Set MoE hyperparameters
1380
+ self.expert_weights = []
1381
+
1382
+ self.moe_layers = []
1383
+ example_layer = None
1384
+ for layer in self.model.layers:
1385
+ if isinstance(layer, PPMissingLayer):
1386
+ continue
1387
+
1388
+ assert isinstance(layer, K2HorizonDecoderLayer)
1389
+ if isinstance(layer.mlp, K2HorizonSparseMoeBlock):
1390
+ example_layer = layer.mlp
1391
+ self.moe_layers.append(layer.mlp.experts)
1392
+
1393
+ self.num_moe_layers = len(self.moe_layers)
1394
+ if example_layer is not None:
1395
+ self.num_logical_experts = example_layer.n_logical_experts
1396
+ self.num_physical_experts = example_layer.n_physical_experts
1397
+ self.num_local_physical_experts = example_layer.n_local_physical_experts
1398
+ self.num_routed_experts = example_layer.n_routed_experts
1399
+ self.num_redundant_experts = example_layer.n_redundant_experts
1400
+
1401
+ def update_physical_experts_metadata(
1402
+ self,
1403
+ num_physical_experts: int,
1404
+ num_local_physical_experts: int,
1405
+ ) -> None:
1406
+ assert self.num_local_physical_experts == num_local_physical_experts
1407
+ self.num_physical_experts = num_physical_experts
1408
+ self.num_local_physical_experts = num_local_physical_experts
1409
+ self.num_redundant_experts = num_physical_experts - self.num_logical_experts
1410
+ for layer in self.model.layers:
1411
+ if isinstance(layer.mlp, K2HorizonSparseMoeBlock):
1412
+ moe = layer.mlp
1413
+ moe.n_local_physical_experts = num_local_physical_experts
1414
+ moe.n_physical_experts = num_physical_experts
1415
+ moe.n_redundant_experts = self.num_redundant_experts
1416
+ moe.experts.update_expert_map()
1417
+
1418
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
1419
+ return self.model.embed_input_ids(input_ids)
1420
+
1421
+ def forward(
1422
+ self,
1423
+ input_ids: torch.Tensor | None,
1424
+ positions: torch.Tensor,
1425
+ intermediate_tensors: IntermediateTensors | None = None,
1426
+ inputs_embeds: torch.Tensor | None = None,
1427
+ ) -> torch.Tensor | IntermediateTensors:
1428
+ hidden_states = self.model(
1429
+ input_ids, positions, intermediate_tensors, inputs_embeds
1430
+ )
1431
+ return hidden_states
1432
+
1433
+ def compute_logits(
1434
+ self,
1435
+ hidden_states: torch.Tensor,
1436
+ ) -> torch.Tensor | None:
1437
+ logits = self.logits_processor(self.lm_head, hidden_states)
1438
+ return logits
1439
+
1440
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
1441
+ loader = AutoWeightsLoader(self)
1442
+ return loader.load_weights(weights)
1443
+
1444
+ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
1445
+ return self.model.get_expert_mapping()
vllm/model_executor/models/registry.py
+1
@@ -141,6 +141,7 @@ _TEXT_GENERATION_MODELS = {
141
"IQuestLoopCoderForCausalLM": ("iquest_loopcoder", "IQuestLoopCoderForCausalLM"),
142
"Jais2ForCausalLM": ("jais2", "Jais2ForCausalLM"),
143
"JambaForCausalLM": ("jamba", "JambaForCausalLM"),
144
+ "K2HorizonForCausalLM": ("k2_horizon", "K2HorizonForCausalLM"),
145
"KimiLinearForCausalLM": (
146
"vllm.models.kimi_k3",
147
"KimiLinearForCausalLM",
vllm/reasoning/__init__.py
+4
@@ -96,6 +96,10 @@ _REASONING_PARSERS_TO_REGISTER = {
96
"kimi_k3_reasoning_parser",
97
"KimiK3ReasoningParser",
98
),
99
+ "k2_horizon": (
100
+ "k2_horizon_reasoning_parser",
101
+ "K2HorizonReasoningParser",
102
+ ),
103
"mimo": (
104
"qwen3_engine_reasoning_parser",
105
"Qwen3ParserReasoningAdapter",
vllm/reasoning/k2_horizon_reasoning_parser.py
new
+159
@@ -0,0 +1,159 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from collections.abc import Iterable, Sequence
5
+
6
+from vllm.entrypoints.generate.base.protocol import DeltaMessage
7
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
8
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
9
+from vllm.reasoning.deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParser
10
+from vllm.tokenizers import TokenizerLike
11
+from vllm.tool_parsers.utils import partial_tag_overlap
12
+
13
+
14
+class K2HorizonReasoningParser(DeepSeekR1ReasoningParser):
15
+ """Reasoning parser for K2 Horizon."""
16
+
17
+ _TOOL_CALLS_START = "<ifm|tool_calls>"
18
+ _EFFORT_TOKENS: dict[str, tuple[str, str]] = {
19
+ "high": ("<ifm|think>", "</ifm|think>"),
20
+ "medium": ("<ifm|think_fast>", "</ifm|think_fast>"),
21
+ "low": ("<ifm|think_faster>", "</ifm|think_faster>"),
22
+ }
23
+
24
+ def __init__(self, tokenizer: TokenizerLike, *args, **kwargs) -> None:
25
+ chat_template_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
26
+ effort = chat_template_kwargs.get("reasoning_effort", "high")
27
+ if not isinstance(effort, str) or effort not in self._EFFORT_TOKENS:
28
+ supported = ", ".join(self._EFFORT_TOKENS)
29
+ raise ValueError(
30
+ f"Unsupported reasoning_effort {effort!r}. "
31
+ f"Supported values: {supported}."
32
+ )
33
+
34
+ self._start_token, self._end_token = self._EFFORT_TOKENS[effort]
35
+ super().__init__(tokenizer, *args, **kwargs)
36
+ self._tool_calls_start_id = self.vocab.get(self._TOOL_CALLS_START)
37
+ self._stream_buffer = ""
38
+ self._stream_at_start = True
39
+ self._stream_finished = False
40
+
41
+ @property
42
+ def start_token(self) -> str:
43
+ return self._start_token
44
+
45
+ @property
46
+ def end_token(self) -> str:
47
+ return self._end_token
48
+
49
+ def _without_generated_start(self, model_output: str) -> str:
50
+ return model_output.removeprefix(self.start_token)
51
+
52
+ def _split_model_output(self, model_output: str) -> tuple[str, str | None]:
53
+ output = self._without_generated_start(model_output)
54
+ if self.end_token in output:
55
+ reasoning, _, content = output.partition(self.end_token)
56
+ return reasoning, content or None
57
+
58
+ tool_start = output.find(self._TOOL_CALLS_START)
59
+ if tool_start != -1:
60
+ return output[:tool_start], output[tool_start:]
61
+
62
+ return "", output or None
63
+
64
+ def extract_reasoning(
65
+ self,
66
+ model_output: str,
67
+ request: ChatCompletionRequest | ResponsesRequest,
68
+ ) -> tuple[str, str | None]:
69
+ del request
70
+ return self._split_model_output(model_output)
71
+
72
+ def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
73
+ for token_id in reversed(input_ids):
74
+ if token_id == self.start_token_id:
75
+ return False
76
+ if token_id == self.end_token_id or token_id == self._tool_calls_start_id:
77
+ return True
78
+ return False
79
+
80
+ def is_reasoning_end_streaming(
81
+ self,
82
+ input_ids: Sequence[int],
83
+ delta_ids: Iterable[int],
84
+ ) -> bool:
85
+ del input_ids
86
+ return self._stream_finished or super().is_reasoning_end_streaming(
87
+ (), delta_ids
88
+ )
89
+
90
+ def extract_content_ids(self, input_ids: list[int]) -> list[int]:
91
+ if self.end_token_id in input_ids:
92
+ return input_ids[input_ids.index(self.end_token_id) + 1 :]
93
+ if (
94
+ self._tool_calls_start_id is not None
95
+ and self._tool_calls_start_id in input_ids
96
+ ):
97
+ return input_ids[input_ids.index(self._tool_calls_start_id) :]
98
+ return input_ids if self._stream_finished else []
99
+
100
+ def _strip_stream_start(self) -> bool:
101
+ if not self._stream_at_start:
102
+ return True
103
+ if self.start_token.startswith(self._stream_buffer):
104
+ if self._stream_buffer != self.start_token:
105
+ return False
106
+ self._stream_buffer = ""
107
+ elif self._stream_buffer.startswith(self.start_token):
108
+ self._stream_buffer = self._stream_buffer[len(self.start_token) :]
109
+ self._stream_at_start = False
110
+ return True
111
+
112
+ def extract_reasoning_streaming(
113
+ self,
114
+ previous_text: str,
115
+ current_text: str,
116
+ delta_text: str,
117
+ previous_token_ids: Sequence[int],
118
+ current_token_ids: Sequence[int],
119
+ delta_token_ids: Sequence[int],
120
+ ) -> DeltaMessage | None:
121
+ del (
122
+ previous_text,
123
+ current_text,
124
+ previous_token_ids,
125
+ current_token_ids,
126
+ delta_token_ids,
127
+ )
128
+
129
+ if self._stream_finished:
130
+ return DeltaMessage(content=delta_text) if delta_text else None
131
+
132
+ self._stream_buffer += delta_text
133
+ if not self._strip_stream_start():
134
+ return None
135
+
136
+ explicit_end = self._stream_buffer.find(self.end_token)
137
+ tool_start = self._stream_buffer.find(self._TOOL_CALLS_START)
138
+ if explicit_end != -1:
139
+ reasoning = self._stream_buffer[:explicit_end]
140
+ content = self._stream_buffer[explicit_end + len(self.end_token) :]
141
+ elif tool_start != -1:
142
+ reasoning = self._stream_buffer[:tool_start]
143
+ content = self._stream_buffer[tool_start:]
144
+ else:
145
+ overlap = max(
146
+ partial_tag_overlap(self._stream_buffer, self.end_token),
147
+ partial_tag_overlap(self._stream_buffer, self._TOOL_CALLS_START),
148
+ )
149
+ sendable_end = len(self._stream_buffer) - overlap
150
+ reasoning = self._stream_buffer[:sendable_end]
151
+ self._stream_buffer = self._stream_buffer[sendable_end:]
152
+ return DeltaMessage(reasoning=reasoning) if reasoning else None
153
+
154
+ self._stream_buffer = ""
155
+ self._stream_finished = True
156
+ return DeltaMessage(
157
+ reasoning=reasoning,
158
+ content=content or None,
159
+ )
vllm/tool_parsers/__init__.py
+4
@@ -118,6 +118,10 @@ _TOOL_PARSERS_TO_REGISTER = {
118
"kimi_k3_tool_parser",
119
"KimiK3ToolParser",
120
),
121
+ "k2_horizon": (
122
+ "k2_horizon_tool_parser",
123
+ "K2HorizonToolParser",
124
+ ),
125
"llama3_json": (
126
"llama_tool_parser",
127
"Llama3JsonToolParser",
vllm/tool_parsers/k2_horizon_tool_parser.py
new
+462
@@ -0,0 +1,462 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import json
5
+from collections.abc import Sequence
6
+from typing import Any
7
+
8
+import regex as re
9
+from openai.types.responses import ToolChoiceFunction
10
+
11
+from vllm.entrypoints.generate.base.protocol import (
12
+ DeltaFunctionCall,
13
+ DeltaMessage,
14
+ DeltaToolCall,
15
+ ExtractedToolCallInformation,
16
+ FunctionCall,
17
+ ToolCall,
18
+)
19
+from vllm.entrypoints.openai.chat_completion.protocol import (
20
+ ChatCompletionNamedToolChoiceParam,
21
+ ChatCompletionRequest,
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 Tool, ToolParser
27
+from vllm.tool_parsers.utils import (
28
+ coerce_to_schema_type,
29
+ extract_types_from_schema,
30
+ find_tool_name,
31
+ find_tool_properties,
32
+ partial_tag_overlap,
33
+)
34
+
35
+logger = init_logger(__name__)
36
+
37
+ToolParserRequest = ChatCompletionRequest | ResponsesRequest
38
+
39
+
40
+class K2HorizonToolParser(ToolParser):
41
+ """Parser for K2 Horizon's IFM tool-call formats."""
42
+
43
+ supports_required_and_named = False
44
+ engine_based_streaming = True
45
+
46
+ _SUPPORTED_TOOL_FORMATS = frozenset({"json", "xml", "xml_typed"})
47
+
48
+ _TOOL_CALLS_START = "<ifm|tool_calls>"
49
+ _TOOL_CALLS_END = "</ifm|tool_calls>"
50
+ _TOOL_CALL_START = "<ifm|tool_call>"
51
+ _TOOL_CALL_END = "</ifm|tool_call>"
52
+ _ARG_KEY_START = "<ifm|arg_key>"
53
+ _ARG_KEY_END = "</ifm|arg_key>"
54
+ _ARG_TYPE_START = "<ifm|arg_type>"
55
+ _ARG_TYPE_END = "</ifm|arg_type>"
56
+ _ARG_VALUE_START = "<ifm|arg_value>"
57
+ _ARG_VALUE_END = "</ifm|arg_value>"
58
+
59
+ _TOOL_CALL_RE = re.compile(
60
+ re.escape(_TOOL_CALL_START) + r"(.*?)" + re.escape(_TOOL_CALL_END),
61
+ re.DOTALL,
62
+ )
63
+ _ARG_RE = re.compile(
64
+ re.escape(_ARG_KEY_START)
65
+ + r"(.*?)"
66
+ + re.escape(_ARG_KEY_END)
67
+ + r"\s*(?:"
68
+ + re.escape(_ARG_TYPE_START)
69
+ + r"(.*?)"
70
+ + re.escape(_ARG_TYPE_END)
71
+ + r"\s*)?"
72
+ + re.escape(_ARG_VALUE_START)
73
+ + r"(.*?)"
74
+ + re.escape(_ARG_VALUE_END),
75
+ re.DOTALL,
76
+ )
77
+ _ARG_MARKERS = (
78
+ _ARG_KEY_START,
79
+ _ARG_KEY_END,
80
+ _ARG_TYPE_START,
81
+ _ARG_TYPE_END,
82
+ _ARG_VALUE_START,
83
+ _ARG_VALUE_END,
84
+ )
85
+
86
+ def __init__(
87
+ self,
88
+ tokenizer: TokenizerLike,
89
+ tools: list[Tool] | None = None,
90
+ ) -> None:
91
+ super().__init__(tokenizer, tools)
92
+ self.tool_format = "xml"
93
+ self._stream_text = ""
94
+ self._stream_cursor = 0
95
+ self._stream_group_start: int | None = None
96
+ self._stream_resolved = False
97
+ self._pending_content = ""
98
+ self._content_seen = False
99
+
100
+ @classmethod
101
+ def _request_tool_format(cls, request: ToolParserRequest) -> str:
102
+ chat_template_kwargs = request.chat_template_kwargs or {}
103
+ tool_format = chat_template_kwargs.get("tool_call_format", "xml")
104
+ if (
105
+ not isinstance(tool_format, str)
106
+ or tool_format not in cls._SUPPORTED_TOOL_FORMATS
107
+ ):
108
+ supported = ", ".join(sorted(cls._SUPPORTED_TOOL_FORMATS))
109
+ raise ValueError(
110
+ f"Unsupported tool_call_format {tool_format!r}. "
111
+ f"Supported values: {supported}."
112
+ )
113
+ return tool_format
114
+
115
+ def adjust_request(self, request: ToolParserRequest) -> ToolParserRequest:
116
+ self._request_tool_format(request)
117
+ return request
118
+
119
+ @staticmethod
120
+ def _content_or_none(content: str) -> str | None:
121
+ return content if content.strip() else None
122
+
123
+ @classmethod
124
+ def _no_tool_calls(cls, content: str) -> ExtractedToolCallInformation:
125
+ return ExtractedToolCallInformation(
126
+ tools_called=False,
127
+ tool_calls=[],
128
+ content=cls._content_or_none(content),
129
+ )
130
+
131
+ @staticmethod
132
+ def _make_tool_call(name: str, arguments: dict[str, Any]) -> ToolCall:
133
+ return ToolCall(
134
+ type="function",
135
+ function=FunctionCall(
136
+ name=name,
137
+ arguments=json.dumps(
138
+ arguments,
139
+ ensure_ascii=False,
140
+ allow_nan=False,
141
+ ),
142
+ ),
143
+ )
144
+
145
+ @staticmethod
146
+ def _base_arg_type(arg_type: str | None) -> str | None:
147
+ if not arg_type:
148
+ return None
149
+ return arg_type.split("[", 1)[0].strip().lower()
150
+
151
+ @staticmethod
152
+ def _coerce_json_value(value: Any, schema: Any) -> Any:
153
+ if not isinstance(schema, dict):
154
+ return value
155
+ raw_value = (
156
+ value
157
+ if isinstance(value, str)
158
+ else json.dumps(value, ensure_ascii=False, allow_nan=False)
159
+ )
160
+ return coerce_to_schema_type(raw_value, extract_types_from_schema(schema))
161
+
162
+ @classmethod
163
+ def _coerce_xml_value(
164
+ cls,
165
+ value: str,
166
+ schema: Any,
167
+ explicit_type: str | None,
168
+ ) -> Any:
169
+ explicit_base = cls._base_arg_type(explicit_type)
170
+ target_types = extract_types_from_schema(schema)
171
+ if isinstance(schema, dict):
172
+ if explicit_base in target_types and len(set(target_types)) > 1:
173
+ target_types = [explicit_base]
174
+ elif explicit_base == "any":
175
+ return value
176
+ elif explicit_base:
177
+ target_types = [explicit_base]
178
+
179
+ raw_value = value if "string" in target_types else value.strip()
180
+ return coerce_to_schema_type(raw_value, target_types)
181
+
182
+ @staticmethod
183
+ def _named_tool(request: ToolParserRequest) -> str | None:
184
+ tool_choice = request.tool_choice
185
+ if isinstance(tool_choice, ChatCompletionNamedToolChoiceParam):
186
+ return tool_choice.function.name
187
+ if isinstance(tool_choice, ToolChoiceFunction):
188
+ return tool_choice.name
189
+ return None
190
+
191
+ @classmethod
192
+ def _validate_tool_name(
193
+ cls,
194
+ name: Any,
195
+ request: ToolParserRequest,
196
+ ) -> str:
197
+ if (
198
+ not isinstance(name, str)
199
+ or not name
200
+ or any(char.isspace() for char in name)
201
+ ):
202
+ raise ValueError("IFM tool call has an invalid function name.")
203
+ if request.tools and not find_tool_name(request.tools, name):
204
+ raise ValueError(f"Unknown IFM tool name: {name}.")
205
+ named_tool = cls._named_tool(request)
206
+ if named_tool is not None and name != named_tool:
207
+ raise ValueError(f"Unexpected IFM tool name: {name}.")
208
+ return name
209
+
210
+ @classmethod
211
+ def _parse_json_call(
212
+ cls,
213
+ body: str,
214
+ request: ToolParserRequest,
215
+ ) -> tuple[str, dict[str, Any]]:
216
+ raw_call = json.loads(body.strip())
217
+ if not isinstance(raw_call, dict):
218
+ raise ValueError("IFM JSON tool call must be an object.")
219
+
220
+ name = cls._validate_tool_name(raw_call.get("name"), request)
221
+ arguments = raw_call.get("arguments", {})
222
+ if not isinstance(arguments, dict):
223
+ raise ValueError("IFM JSON tool-call arguments must be an object.")
224
+
225
+ properties = find_tool_properties(request.tools, name)
226
+ arguments = {
227
+ arg_name: cls._coerce_json_value(arg_value, properties.get(arg_name))
228
+ for arg_name, arg_value in arguments.items()
229
+ }
230
+ return name, arguments
231
+
232
+ @classmethod
233
+ def _parse_xml_call(
234
+ cls,
235
+ body: str,
236
+ request: ToolParserRequest,
237
+ tool_format: str,
238
+ ) -> tuple[str, dict[str, Any]]:
239
+ first_arg = body.find(cls._ARG_KEY_START)
240
+ if first_arg == -1:
241
+ if any(marker in body for marker in cls._ARG_MARKERS):
242
+ raise ValueError("Malformed IFM XML argument tags.")
243
+ name = cls._validate_tool_name(body.strip(), request)
244
+ return name, {}
245
+
246
+ name = cls._validate_tool_name(body[:first_arg].strip(), request)
247
+ properties = find_tool_properties(request.tools, name)
248
+ arguments: dict[str, Any] = {}
249
+ position = first_arg
250
+ for match in cls._ARG_RE.finditer(body, first_arg):
251
+ if body[position : match.start()].strip():
252
+ raise ValueError("Malformed IFM XML argument tags.")
253
+
254
+ arg_name = match.group(1).strip()
255
+ if not arg_name:
256
+ raise ValueError("IFM XML argument is missing a name.")
257
+ if arg_name in arguments:
258
+ raise ValueError(f"Duplicate IFM XML argument: {arg_name}.")
259
+
260
+ explicit_type = match.group(2)
261
+ if explicit_type is not None:
262
+ explicit_type = explicit_type.strip()
263
+ if tool_format == "xml" and explicit_type is not None:
264
+ raise ValueError("Unexpected type tag in IFM XML tool call.")
265
+ if tool_format == "xml_typed" and not explicit_type:
266
+ raise ValueError("IFM typed XML argument is missing a type.")
267
+ arguments[arg_name] = cls._coerce_xml_value(
268
+ match.group(3),
269
+ properties.get(arg_name),
270
+ explicit_type,
271
+ )
272
+ position = match.end()
273
+
274
+ if position == first_arg or body[position:].strip():
275
+ raise ValueError("Malformed IFM XML argument tags.")
276
+ return name, arguments
277
+
278
+ @classmethod
279
+ def _parse_call(
280
+ cls,
281
+ body: str,
282
+ request: ToolParserRequest,
283
+ tool_format: str,
284
+ ) -> tuple[str, dict[str, Any]]:
285
+ if tool_format == "json":
286
+ return cls._parse_json_call(body, request)
287
+ return cls._parse_xml_call(body, request, tool_format)
288
+
289
+ def extract_tool_calls(
290
+ self,
291
+ model_output: str,
292
+ request: ToolParserRequest,
293
+ ) -> ExtractedToolCallInformation:
294
+ tool_format = self._request_tool_format(request)
295
+ if request.tool_choice == "none":
296
+ return self._no_tool_calls(model_output)
297
+
298
+ group_start = model_output.find(self._TOOL_CALLS_START)
299
+ if group_start == -1:
300
+ return self._no_tool_calls(model_output)
301
+
302
+ body_start = group_start + len(self._TOOL_CALLS_START)
303
+ group_end = model_output.find(self._TOOL_CALLS_END, body_start)
304
+ if group_end == -1:
305
+ return self._no_tool_calls(model_output)
306
+
307
+ group_body = model_output[body_start:group_end]
308
+ matches = list(self._TOOL_CALL_RE.finditer(group_body))
309
+ if not matches or self._TOOL_CALL_RE.sub("", group_body).strip():
310
+ logger.warning("Malformed K2 Horizon IFM tool-call group.")
311
+ return self._no_tool_calls(model_output)
312
+
313
+ try:
314
+ tool_calls = [
315
+ self._make_tool_call(
316
+ *self._parse_call(match.group(1), request, tool_format)
317
+ )
318
+ for match in matches
319
+ ]
320
+ except (json.JSONDecodeError, TypeError, ValueError):
321
+ logger.warning("Failed to parse K2 Horizon IFM tool calls.", exc_info=True)
322
+ return self._no_tool_calls(model_output)
323
+
324
+ suffix = model_output[group_end + len(self._TOOL_CALLS_END) :]
325
+ content = model_output[:group_start] + suffix
326
+ return ExtractedToolCallInformation(
327
+ tools_called=True,
328
+ tool_calls=tool_calls,
329
+ content=self._content_or_none(content),
330
+ )
331
+
332
+ def _emit_content(self, addition: str, *, final: bool = False) -> str | None:
333
+ if addition:
334
+ if self._content_seen:
335
+ return addition
336
+ self._pending_content += addition
337
+ if self._pending_content.strip():
338
+ self._content_seen = True
339
+ content = self._pending_content
340
+ self._pending_content = ""
341
+ return content
342
+ if final:
343
+ self._pending_content = ""
344
+ return None
345
+
346
+ def _streaming_tool_calls(
347
+ self,
348
+ tool_calls: list[ToolCall],
349
+ ) -> list[DeltaToolCall]:
350
+ deltas: list[DeltaToolCall] = []
351
+ for index, tool_call in enumerate(tool_calls):
352
+ arguments_json = tool_call.function.arguments
353
+ arguments = json.loads(arguments_json)
354
+ self.prev_tool_call_arr.append(
355
+ {"name": tool_call.function.name, "arguments": arguments}
356
+ )
357
+ self.streamed_args_for_tool.append(arguments_json)
358
+ deltas.append(
359
+ DeltaToolCall(
360
+ index=index,
361
+ id=tool_call.id,
362
+ type="function",
363
+ function=DeltaFunctionCall(
364
+ name=tool_call.function.name,
365
+ arguments=arguments_json,
366
+ ),
367
+ )
368
+ )
369
+ return deltas
370
+
371
+ def extract_tool_calls_streaming(
372
+ self,
373
+ previous_text: str,
374
+ current_text: str,
375
+ delta_text: str,
376
+ previous_token_ids: Sequence[int],
377
+ current_token_ids: Sequence[int],
378
+ delta_token_ids: Sequence[int],
379
+ request: ToolParserRequest,
380
+ ) -> DeltaMessage | None:
381
+ del (
382
+ previous_text,
383
+ current_text,
384
+ previous_token_ids,
385
+ current_token_ids,
386
+ delta_token_ids,
387
+ )
388
+
389
+ self._request_tool_format(request)
390
+ if request.tool_choice == "none":
391
+ emitted_content = self._emit_content(delta_text)
392
+ return (
393
+ DeltaMessage(content=emitted_content)
394
+ if emitted_content is not None
395
+ else None
396
+ )
397
+
398
+ self._stream_text += delta_text
399
+ if self._stream_resolved:
400
+ emitted_content = self._emit_content(delta_text)
401
+ return (
402
+ DeltaMessage(content=emitted_content)
403
+ if emitted_content is not None
404
+ else None
405
+ )
406
+
407
+ content: str | None = None
408
+ if self._stream_group_start is None:
409
+ group_start = self._stream_text.find(
410
+ self._TOOL_CALLS_START, self._stream_cursor
411
+ )
412
+ if group_start == -1:
413
+ pending = self._stream_text[self._stream_cursor :]
414
+ overlap = partial_tag_overlap(pending, self._TOOL_CALLS_START)
415
+ sendable_end = len(self._stream_text) - overlap
416
+ content = self._emit_content(
417
+ self._stream_text[self._stream_cursor : sendable_end]
418
+ )
419
+ self._stream_cursor = sendable_end
420
+ return DeltaMessage(content=content) if content is not None else None
421
+
422
+ self._stream_group_start = group_start
423
+ content = self._emit_content(
424
+ self._stream_text[self._stream_cursor : group_start]
425
+ )
426
+ self._stream_cursor = group_start
427
+
428
+ group_start = self._stream_group_start
429
+ body_start = group_start + len(self._TOOL_CALLS_START)
430
+ group_end = self._stream_text.find(self._TOOL_CALLS_END, body_start)
431
+ if group_end == -1:
432
+ return DeltaMessage(content=content) if content is not None else None
433
+
434
+ group_end += len(self._TOOL_CALLS_END)
435
+ group_text = self._stream_text[group_start:group_end]
436
+ parsed = self.extract_tool_calls(group_text, request)
437
+ self._stream_resolved = True
438
+
439
+ if not parsed.tools_called:
440
+ failed_content = self._emit_content(
441
+ self._stream_text[self._stream_cursor :]
442
+ )
443
+ self._stream_cursor = len(self._stream_text)
444
+ combined = (content or "") + (failed_content or "")
445
+ return DeltaMessage(content=combined) if combined else None
446
+
447
+ tool_calls = self._streaming_tool_calls(parsed.tool_calls)
448
+ suffix = self._emit_content(self._stream_text[group_end:])
449
+ self._stream_cursor = len(self._stream_text)
450
+ combined = (content or "") + (suffix or "")
451
+ return DeltaMessage(content=combined or None, tool_calls=tool_calls)
452
+
453
+ def finish_streaming(self) -> DeltaMessage | None:
454
+ if not self._stream_resolved:
455
+ content = self._emit_content(
456
+ self._stream_text[self._stream_cursor :], final=True
457
+ )
458
+ self._stream_cursor = len(self._stream_text)
459
+ self._stream_resolved = True
460
+ else:
461
+ content = self._emit_content("", final=True)
462
+ return DeltaMessage(content=content) if content is not None else None