[Model] Add native IFM K2 Horizon serving support (#37654)

Co-authored-by: Xiaoyu Zhang <1182563586@qq.com>
Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
This commit is contained in:
Yash Akhauri
2026-09-03 16:39:43 +08:00
committed by GitHub
co-authored by Xiaoyu Zhang Xinyuan Tong
parent 02d9b3060a
commit 3bac084d4e
34 changed files with 4487 additions and 16 deletions
@@ -399,6 +399,7 @@ class TestCreateGrammarBackend(unittest.TestCase):
result = create_grammar_backend(args, tokenizer, 32000, think_end_ids=[42])
self.assertIsInstance(result, ReasonerGrammarBackend)
self.assertIs(result.grammar_backend, mock_backend)
self.assertEqual(result.think_end_ids, [42])
@patch("sglang.srt.constrained.outlines_backend.OutlinesGrammarBackend")
def test_no_reasoner_wrapping_without_think_end_ids(self, mock_outlines_cls):
@@ -26,6 +26,9 @@ from sglang.srt.constrained.base_grammar_backend import (
from sglang.srt.constrained.grammar_manager import GrammarManager
from sglang.srt.constrained.reasoner_grammar_backend import ReasonerGrammarObject
from sglang.srt.distributed.communication_tags import P2PTag
from sglang.srt.sampling.sampling_params import (
REQUEST_REASONING_END_TOKEN_IDS_KEY,
)
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(2.0, "base-a-test-cpu")
@@ -42,6 +45,7 @@ def _make_scheduler(grammar_backend_name="none", skip_tokenizer=False):
scheduler.server_args.reasoning_parser = None
scheduler.server_args.constrained_json_whitespace_pattern = None
scheduler.server_args.constrained_json_disable_any_whitespace = False
scheduler.model_config.request_selectable_think_end_id_sequences = None
# Distributed group mocks
scheduler.dp_tp_cpu_group = MagicMock()
@@ -303,6 +307,39 @@ class TestProcessReqWithGrammar(unittest.TestCase):
self.assertEqual(req.grammar.max_think_tokens, 7)
def test_cache_hit_applies_only_request_selected_terminator(self):
mgr = self._make_mgr()
mgr.scheduler.model_config.request_selectable_think_end_id_sequences = [
[2, 3],
[8, 9],
]
grammar_obj = ReasonerGrammarObject(
grammar=None,
think_end_ids=[2, 3],
)
grammar_obj.maybe_init_reasoning(True)
mgr.grammar_backend.get_cached_or_future_value.return_value = (
grammar_obj,
True,
)
req = _make_req(
json_schema="schema",
custom_params={REQUEST_REASONING_END_TOKEN_IDS_KEY: [8, 9]},
)
req.require_reasoning = True
mgr.process_req_with_grammar(req)
self.assertEqual(req.grammar.think_end_ids, (8, 9))
for token_id in (2, 3):
req.grammar.accept_token(token_id)
self.assertTrue(req.grammar._is_thinking())
for token_id in (8, 9):
req.grammar.accept_token(token_id)
self.assertTrue(req.grammar._is_generation())
def test_strict_reasoning_grammar_applies_request_thinking_budget(self):
mgr = self._make_mgr()
mgr._enable_strict_thinking = True
@@ -43,6 +43,9 @@ from sglang.srt.parser.jinja_template_utils import (
jinja_template_may_reorder_tool_results,
)
from sglang.srt.parser.template_detection import ReasoningToggleConfig
from sglang.srt.sampling.sampling_params import (
REQUEST_REASONING_END_TOKEN_IDS_KEY,
)
from sglang.srt.utils import get_or_create_event_loop
from sglang.test.ci.ci_register import register_cpu_ci
@@ -794,6 +797,33 @@ class ServingChatTestCase(unittest.TestCase):
self.assertEqual(req.reasoning_effort, "high")
def test_k2_selected_terminator_reaches_sampling_params(self):
self.tm._config_overrides["reasoning_parser"] = "k2_horizon"
self.chat = OpenAIServingChat(self.tm, self.template_manager)
self.template_manager.chat_template_name = None
self.template_manager.jinja_template_content_format = "string"
self.tm.tokenizer.apply_chat_template.return_value = [1, 2, 3]
self.tm.tokenizer.encode.side_effect = lambda text, **_: (
[8, 9] if text == "</ifm|think_fast>" else [1, 2, 3]
)
req = ChatCompletionRequest(
model="IFM/K2-Horizon-7B",
messages=[{"role": "user", "content": "hi"}],
chat_template_kwargs={"reasoning_effort": "medium"},
)
processed = self.chat._process_messages(req, is_multimodal=False)
self.assertEqual(processed.reasoning_end_token_ids, [8, 9])
with patch.object(self.chat, "_process_messages", return_value=processed):
adapted, _ = self.chat._convert_to_internal_request(req)
self.assertEqual(
adapted.sampling_params["custom_params"][
REQUEST_REASONING_END_TOKEN_IDS_KEY
],
[8, 9],
)
def test_kimi_tool_call_keeps_template_default_thinking(self):
self.template_manager.chat_template_name = None
self.template_manager.jinja_template_content_format = "string"
@@ -23,6 +23,9 @@ from sglang.srt.entrypoints.openai.serving_responses import (
)
from sglang.srt.function_call.core_types import ToolCallItem
from sglang.srt.parser.template_detection import ReasoningToggleConfig
from sglang.srt.sampling.sampling_params import (
REQUEST_REASONING_END_TOKEN_IDS_KEY,
)
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -246,6 +249,38 @@ class ChatToolForwardingTestCase(CustomTestCase):
self.assertEqual(request_prompts, [[4, 5, 6]])
self.assertEqual(engine_prompts, [[4, 5, 6]])
def test_k2_output_parser_reuses_effective_template_default(self):
serving = make_serving()
serving.reasoning_parser = "k2_horizon"
serving.default_chat_template_kwargs = {"reasoning_effort": "low"}
serving.template_manager.chat_template_name = None
serving.tokenizer_manager.tokenizer.apply_chat_template.return_value = [4, 5, 6]
request = ResponsesRequest(
model="IFM/K2-Horizon-7B",
input="hi",
# Template kwargs are the final render inputs, so the server default
# below takes precedence over this API convenience field.
reasoning={"effort": "medium"},
store=False,
)
asyncio.run(
serving._make_request(request, None, serving.tokenizer_manager.tokenizer)
)
render_call = serving.tokenizer_manager.tokenizer.apply_chat_template.call_args
self.assertEqual(render_call.kwargs["reasoning_effort"], "low")
self.assertEqual(request.chat_template_kwargs["reasoning_effort"], "low")
output_items = serving._make_response_output_items(
request,
"work</ifm|think_faster>\nanswer",
tokenizer=Mock(),
require_reasoning=True,
)
self.assertEqual(output_items[0].content[0].text, "work")
self.assertEqual(output_items[1].content[0].text, "\nanswer")
class ReasoningRequestForwardingTestCase(unittest.TestCase):
def test_create_responses_uses_processed_reasoning_state(self):
@@ -263,6 +298,7 @@ class ReasoningRequestForwardingTestCase(unittest.TestCase):
video_data=None,
modalities=[],
stop=[],
reasoning_end_token_ids=[41, 42],
)
captured = {}
@@ -308,6 +344,12 @@ class ReasoningRequestForwardingTestCase(unittest.TestCase):
self.assertEqual(response.status, "completed")
self.assertFalse(captured["adapted_request"].require_reasoning)
self.assertEqual(
captured["adapted_request"].sampling_params["custom_params"][
REQUEST_REASONING_END_TOKEN_IDS_KEY
],
[41, 42],
)
self.assertFalse(parser_cls.call_args.kwargs["force_reasoning"])
@@ -32,6 +32,37 @@ class NonHarmonyStreamTestCase(CustomTestCase):
self.assertTrue(parser_cls.call_args.kwargs["force_reasoning"])
def test_k2_nested_effort_selects_streaming_reasoning_delimiter(self):
serving = make_serving()
serving.reasoning_parser = "k2_horizon"
serving.tool_call_parser = None
request = ResponsesRequest(
model="IFM/K2-Horizon-7B",
input="hi",
reasoning={"effort": "medium"},
stream=True,
store=False,
)
events = StreamFixture(serving, request, require_reasoning=True).run(
[engine_chunk("work</ifm|think_fast>\nanswer", 4, finish=True)]
)
types = event_types(events)
payloads = event_payloads(events)
reasoning = "".join(
payload["delta"]
for event_type, payload in zip(types, payloads)
if event_type == "response.reasoning_text.delta"
)
answer = "".join(
payload["delta"]
for event_type, payload in zip(types, payloads)
if event_type == "response.output_text.delta"
)
self.assertEqual(reasoning, "work")
self.assertEqual(answer, "\nanswer")
def test_emits_typed_sse_events_in_order(self):
serving = make_serving()
serving.reasoning_parser = None
@@ -0,0 +1,325 @@
import json
import unittest
from sglang.srt.entrypoints.openai.protocol import Function, Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.function_call_parser import FunctionCallParser
from sglang.srt.function_call.k2_v3_detector import K2V3Detector
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
def make_weather_tool() -> Tool:
return Tool(
type="function",
function=Function(
name="get_weather",
description="Get weather",
parameters={
"type": "object",
"properties": {
"city": {"type": "string"},
"days": {"type": "integer"},
"options": {"type": "object"},
},
},
),
)
def make_reasoning_with_tool_marker(tag: str) -> str:
return (
f"<ifm|{tag}>Consider "
"<ifm|tool_call>get_weather"
"<ifm|arg_key>city</ifm|arg_key>"
"<ifm|arg_value>Boston</ifm|arg_value>"
"</ifm|tool_call> as hypothetical text."
f"</ifm|{tag}>\n"
)
class TestK2V3Detector(CustomTestCase):
def setUp(self):
self.tools = [make_weather_tool()]
def test_xml_and_typed_values(self):
text = (
"<ifm|tool_call>get_weather\n"
"<ifm|arg_key>city</ifm|arg_key>\n"
"<ifm|arg_type>string</ifm|arg_type>\n"
"<ifm|arg_value> Boston </ifm|arg_value>\n"
"<ifm|arg_key>days</ifm|arg_key>\n"
"<ifm|arg_type>integer</ifm|arg_type>\n"
"<ifm|arg_value>3</ifm|arg_value>\n"
"<ifm|arg_key>options</ifm|arg_key>\n"
'<ifm|arg_value>{"units": "metric"}</ifm|arg_value>\n'
"</ifm|tool_call>"
)
result = K2V3Detector().detect_and_parse(text, self.tools)
self.assertEqual(result.normal_text, "")
self.assertEqual(len(result.calls), 1)
self.assertEqual(result.calls[0].name, "get_weather")
self.assertEqual(
json.loads(result.calls[0].parameters),
{
"city": " Boston ",
"days": 3,
"options": {"units": "metric"},
},
)
def test_json_content_is_detected_from_wire(self):
wire = (
"<ifm|tool_calls>\n<ifm|tool_call>"
'{"name":"get_weather","arguments":{"city":"Tokyo","days":2}}'
"</ifm|tool_call>\n</ifm|tool_calls>"
)
result = K2V3Detector().detect_and_parse(wire, self.tools)
self.assertEqual(len(result.calls), 1)
self.assertEqual(
json.loads(result.calls[0].parameters),
{"city": "Tokyo", "days": 2},
)
def test_json_strings_are_not_reinterpreted_without_a_schema(self):
wire = (
"<ifm|tool_call>"
'{"name":"get_weather","arguments":'
'{"numeric":"123","boolean":"true","object":"{}"}}'
"</ifm|tool_call>"
)
result = K2V3Detector().detect_and_parse(wire, self.tools)
self.assertEqual(
json.loads(result.calls[0].parameters),
{"numeric": "123", "boolean": "true", "object": "{}"},
)
def test_local_schema_ref_preserves_xml_string_value(self):
tool = Tool(
type="function",
function=Function(
name="lookup",
parameters={
"type": "object",
"$defs": {"Identifier": {"type": "string"}},
"properties": {"id": {"$ref": "#/$defs/Identifier"}},
},
),
)
wire = (
"<ifm|tool_call>lookup"
"<ifm|arg_key>id</ifm|arg_key>"
"<ifm|arg_value>123</ifm|arg_value>"
"</ifm|tool_call>"
)
result = K2V3Detector().detect_and_parse(wire, [tool])
self.assertEqual(json.loads(result.calls[0].parameters), {"id": "123"})
def test_xml_typed_union_uses_wire_value_type(self):
tool = Tool(
type="function",
function=Function(
name="lookup",
parameters={
"type": "object",
"properties": {
"id": {"anyOf": [{"type": "string"}, {"type": "integer"}]}
},
},
),
)
wire = (
"<ifm|tool_call>lookup"
"<ifm|arg_key>id</ifm|arg_key>"
"<ifm|arg_type>integer</ifm|arg_type>"
"<ifm|arg_value>123</ifm|arg_value>"
"</ifm|tool_call>"
)
result = K2V3Detector().detect_and_parse(wire, [tool])
self.assertEqual(json.loads(result.calls[0].parameters), {"id": 123})
def test_non_string_json_function_name_is_forwarded_as_text(self):
wire = '<ifm|tool_call>{"name":123,"arguments":{}}</ifm|tool_call>'
result = K2V3Detector().detect_and_parse(wire, self.tools)
self.assertEqual(result.calls, [])
self.assertEqual(result.normal_text, wire)
def test_parallel_calls_keep_wire_order_and_indices(self):
wire = (
"<ifm|tool_calls>\n"
"<ifm|tool_call>get_weather"
"<ifm|arg_key>city</ifm|arg_key>"
"<ifm|arg_value>Tokyo</ifm|arg_value>"
"</ifm|tool_call>\n"
"<ifm|tool_call>get_weather"
"<ifm|arg_key>city</ifm|arg_key>"
"<ifm|arg_value>Boston</ifm|arg_value>"
"</ifm|tool_call>\n"
"</ifm|tool_calls>"
)
result = K2V3Detector().detect_and_parse(wire, self.tools)
self.assertEqual(result.normal_text, "")
self.assertEqual([call.tool_index for call in result.calls], [0, 1])
self.assertEqual(
[json.loads(call.parameters)["city"] for call in result.calls],
["Tokyo", "Boston"],
)
def test_non_streaming_preserves_reasoning_for_ordinary_answer(self):
wire = (
" \n" + make_reasoning_with_tool_marker("think_fast") + "The answer is 42."
)
parser = FunctionCallParser(self.tools, "k2_horizon")
normal, calls = parser.parse_non_stream(wire)
self.assertEqual(normal, wire)
self.assertEqual(calls, [])
def test_streaming_preserves_reasoning_for_ordinary_answer(self):
wire = (
" \n"
+ make_reasoning_with_tool_marker("think_faster")
+ "The answer is 42."
)
parser = FunctionCallParser(self.tools, "k2_horizon")
normal = ""
calls = []
for char in wire:
new_normal, new_calls = parser.parse_stream_chunk(char)
normal += new_normal
calls.extend(new_calls)
end_normal, end_calls = parser.parse_stream_end()
normal += end_normal
calls.extend(end_calls)
self.assertEqual(normal, wire)
self.assertEqual(calls, [])
def test_non_streaming_tool_call_preserves_reasoning_prefix(self):
reasoning = make_reasoning_with_tool_marker("think")
wire = reasoning + (
"<ifm|tool_calls>\n"
"<ifm|tool_call>get_weather"
"<ifm|arg_key>city</ifm|arg_key>"
"<ifm|arg_value>Tokyo</ifm|arg_value>"
"</ifm|tool_call>\n"
"</ifm|tool_calls>"
)
parser = FunctionCallParser(self.tools, "k2_horizon")
normal, calls = parser.parse_non_stream(wire)
self.assertEqual(normal, reasoning)
self.assertEqual(len(calls), 1)
self.assertEqual(json.loads(calls[0].parameters), {"city": "Tokyo"})
def test_forced_reasoning_without_opening_tag_is_preserved(self):
reasoning = "work</ifm|think>\n"
wire = reasoning + (
"<ifm|tool_calls><ifm|tool_call>get_weather"
"<ifm|arg_key>city</ifm|arg_key>"
"<ifm|arg_value>Tokyo</ifm|arg_value>"
"</ifm|tool_call></ifm|tool_calls>"
)
normal, calls = FunctionCallParser(self.tools, "k2_horizon").parse_non_stream(
wire
)
self.assertEqual(normal, reasoning)
self.assertEqual(len(calls), 1)
self.assertEqual(json.loads(calls[0].parameters), {"city": "Tokyo"})
def test_streaming_tool_call_preserves_reasoning_at_every_boundary(self):
reasoning = make_reasoning_with_tool_marker("think")
wire = reasoning + (
"<ifm|tool_calls>\n"
"<ifm|tool_call>get_weather"
"<ifm|arg_key>city</ifm|arg_key>"
"<ifm|arg_value>東京</ifm|arg_value>"
"</ifm|tool_call>\n"
"</ifm|tool_calls>"
)
parser = FunctionCallParser(self.tools, "k2_horizon")
normal = ""
calls = []
for char in wire:
new_normal, new_calls = parser.parse_stream_chunk(char)
normal += new_normal
calls.extend(new_calls)
end_normal, end_calls = parser.parse_stream_end()
normal += end_normal
calls.extend(end_calls)
self.assertEqual(normal, reasoning)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].name, "get_weather")
self.assertEqual(json.loads(calls[0].parameters), {"city": "東京"})
def test_malformed_complete_block_is_forwarded_as_text(self):
wire = (
"prefix<ifm|tool_call>get_weather"
"<ifm|arg_key>city</ifm|arg_key>"
"</ifm|tool_call>suffix"
)
result = K2V3Detector().detect_and_parse(wire, self.tools)
self.assertEqual(result.calls, [])
self.assertEqual(result.normal_text, wire)
def test_malformed_canonical_group_is_forwarded_intact(self):
wire = (
"prefix<ifm|tool_calls>\n"
"<ifm|tool_call>get_weather"
"<ifm|arg_key>city</ifm|arg_key>"
"</ifm|tool_call>\n"
"</ifm|tool_calls>suffix"
)
result = K2V3Detector().detect_and_parse(wire, self.tools)
self.assertEqual(result.calls, [])
self.assertEqual(result.normal_text, wire)
def test_unknown_tool_respects_forwarding_policy(self):
wire = "<ifm|tool_call>unknown</ifm|tool_call>"
with envs.SGLANG_FORWARD_UNKNOWN_TOOLS.override(False):
dropped = K2V3Detector().detect_and_parse(wire, self.tools)
self.assertEqual(dropped.calls, [])
with envs.SGLANG_FORWARD_UNKNOWN_TOOLS.override(True):
forwarded = K2V3Detector().detect_and_parse(wire, self.tools)
self.assertEqual(len(forwarded.calls), 1)
self.assertEqual(forwarded.calls[0].name, "unknown")
def test_any_schema_preserves_json_value(self):
tool = make_weather_tool()
tool.function.parameters["properties"]["options"] = {"type": "any"}
wire = (
"<ifm|tool_call>"
'{"name":"get_weather","arguments":{"options":{"units":"metric"}}}'
"</ifm|tool_call>"
)
result = K2V3Detector().detect_and_parse(wire, [tool])
self.assertEqual(
json.loads(result.calls[0].parameters),
{"options": {"units": "metric"}},
)
def test_unterminated_stream_is_released_on_finish(self):
wire = "prefix<ifm|tool_call>get_weather<ifm|arg_key>city"
detector = K2V3Detector()
streamed = detector.parse_streaming_increment(wire, self.tools)
self.assertEqual(streamed.normal_text, "prefix")
self.assertEqual(streamed.calls, [])
finished = detector.finish(self.tools)
self.assertEqual(
finished.normal_text,
"<ifm|tool_call>get_weather<ifm|arg_key>city",
)
self.assertEqual(finished.calls, [])
def test_function_call_parser_registry(self):
parser = FunctionCallParser(
tools=self.tools,
tool_call_parser="k2_horizon",
)
self.assertIsInstance(parser.detector, K2V3Detector)
if __name__ == "__main__":
unittest.main()
+136
View File
@@ -0,0 +1,136 @@
# Copyright 2023-2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import torch
from sglang.srt.layers.mova import (
RoutedValueExperts,
_prepare_mova_moe_config,
mova_router_topk,
routed_linear,
)
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
def test_router_bias_changes_selection_but_not_mixture_weights():
logits = torch.tensor([[2.0, 1.0, 0.0]], dtype=torch.float32)
bias = torch.tensor([0.0, 0.0, 10.0], dtype=torch.float32)
weights, selected = mova_router_topk(
logits,
bias,
score_func="sigmoid",
top_k=1,
scaling_factor=2.5,
)
assert selected.tolist() == [[2]]
torch.testing.assert_close(weights, torch.sigmoid(logits[:, 2:3]) * 2.5)
def test_router_renormalizes_before_scaling():
logits = torch.tensor([[2.0, 1.0, -2.0]], dtype=torch.float32)
weights, selected = mova_router_topk(
logits,
None,
score_func="sigmoid",
top_k=2,
scaling_factor=2.5,
)
scores = torch.sigmoid(logits)
expected_ids = torch.topk(scores, 2, dim=-1).indices
expected = torch.gather(scores, -1, expected_ids)
expected = expected / expected.sum(-1, keepdim=True) * 2.5
torch.testing.assert_close(selected, expected_ids.to(torch.int32))
torch.testing.assert_close(weights, expected)
def test_softmax_router_bias_is_selection_only():
logits = torch.tensor([[3.0, 2.0, -4.0]], dtype=torch.float32)
bias = torch.tensor([0.0, 0.0, 20.0], dtype=torch.float32)
weights, selected = mova_router_topk(
logits,
bias,
score_func="softmax",
top_k=1,
scaling_factor=1.0,
)
unbiased_scores = torch.softmax(logits, dim=-1)
assert selected.tolist() == [[2]]
torch.testing.assert_close(weights, unbiased_scores[:, 2:3])
def test_routed_linear_cpu_matches_independent_expected_math():
hidden = torch.tensor([[1.0, -2.0], [0.5, 3.0]])
expert_weights = torch.arange(3 * 4 * 2, dtype=torch.float32).reshape(3, 4, 2)
selected = torch.tensor([[0, 2], [1, 0]], dtype=torch.int32)
weights = torch.tensor([[0.25, 0.75], [0.6, 0.4]])
expected_rows = []
for token, expert_ids, mixture in zip(hidden, selected, weights):
projections = torch.stack(
[
torch.nn.functional.silu(expert_weights[expert_id] @ token)
for expert_id in expert_ids.tolist()
]
)
expected_rows.append((projections * mixture[:, None]).sum(dim=0))
expected = torch.stack(expected_rows)
actual = routed_linear(hidden, expert_weights, weights, selected)
torch.testing.assert_close(actual, expected)
def test_value_expert_loader_shards_output_dimension():
experts = RoutedValueExperts(
num_experts=2,
input_size=3,
output_size=4,
tp_rank=1,
tp_size=2,
)
packed = torch.arange(2 * 4 * 3, dtype=torch.float32).reshape(2, 4, 3)
experts.weight_loader(experts.weight, packed)
torch.testing.assert_close(experts.weight, packed[:, 2:])
single = torch.arange(4 * 3, dtype=torch.float32).reshape(4, 3) + 100
experts.weight_loader(experts.weight, single, loaded_shard_id=0)
torch.testing.assert_close(experts.weight[0], single[2:])
def test_mova_moe_config_drops_unsupported_tma_without_mutating_source():
source = {
"BLOCK_SIZE_M": 16,
"BLOCK_SIZE_N": 32,
"BLOCK_SIZE_K": 64,
"GROUP_SIZE_M": 1,
"USE_TMA": True,
}
prepared = _prepare_mova_moe_config(source)
assert "USE_TMA" not in prepared
assert source["USE_TMA"] is True
if __name__ == "__main__":
import sys
import pytest
sys.exit(pytest.main([__file__, "-v"]))
@@ -9,11 +9,15 @@ from types import SimpleNamespace
import torch
from sglang.srt.disaggregation.decode import DecodeRequest, DecodeTransferQueue
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.managers.scheduler_components.batch_result_processor import (
SchedulerBatchResultProcessor,
)
from sglang.srt.sampling.sampling_params import SamplingParams
from sglang.srt.sampling.sampling_params import (
REQUEST_REASONING_END_TOKEN_IDS_KEY,
SamplingParams,
)
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -110,6 +114,48 @@ def _make_result(num_draft_tokens, accept_lens, flat_tokens):
)
def _commit_disagg_handoff(
req: Req,
processor: SchedulerBatchResultProcessor,
token_id: int,
*,
replayed_boundary: bool = False,
) -> None:
queue = DecodeTransferQueue.__new__(DecodeTransferQueue)
queue.scheduler = SimpleNamespace(batch_result_processor=processor)
queue.spec_algorithm = SimpleNamespace(is_none=lambda: True)
queue.metadata_buffers = SimpleNamespace(
get_buf=lambda _: (
torch.tensor([token_id], dtype=torch.long),
torch.zeros(7, dtype=torch.long),
torch.zeros(1),
torch.zeros(1, dtype=torch.long),
torch.zeros(1),
torch.zeros(1, dtype=torch.long),
None,
None,
None,
None,
None,
None,
None,
torch.tensor([1], dtype=torch.long),
)
)
req.bootstrap_host = "127.0.0.1"
req.bootstrap_room = 1
if replayed_boundary:
req.pd_rebootstrap_forced_output_id = token_id
decode_req = DecodeRequest(
req=req,
kv_receiver=SimpleNamespace(clear=lambda: None),
metadata_buffer_index=0,
is_rebootstrap=replayed_boundary,
)
queue._commit_transfer_to_req(decode_req)
class TestSpecV2GrammarTruncation(CustomTestCase):
def test_resolve_truncates_after_grammar_completion(self):
req = _make_req(terminate_after=2)
@@ -147,6 +193,66 @@ class TestReasoningTokenAccounting(CustomTestCase):
self.assertEqual(req.reasoning_tokens, 3)
self.assertTrue(req._is_reasoning_over)
def test_request_selected_end_ignores_other_closer(self):
req = _make_req(terminate_after=99)
req.require_reasoning = True
req.sampling_params.custom_params = {
REQUEST_REASONING_END_TOKEN_IDS_KEY: [17, 18]
}
processor = _make_processor()
processor.model_config.think_end_ids = [7, 8]
processor.model_config.request_selectable_think_end_id_sequences = [
[7, 8],
[17, 18],
]
# The global/default closer must not end a medium request.
processor._maybe_update_reasoning_tokens(req, [10, 7])
processor._maybe_update_reasoning_tokens(req, [8, 11])
self.assertFalse(req._is_reasoning_over)
processor._maybe_update_reasoning_tokens(req, [10, 17])
processor._maybe_update_reasoning_tokens(req, [18, 11])
self.assertEqual(req.reasoning_tokens, 7)
self.assertTrue(req._is_reasoning_over)
def test_disagg_handoff_can_start_multi_token_selected_end(self):
req = _make_req(terminate_after=99)
req.require_reasoning = True
req.sampling_params.custom_params = {
REQUEST_REASONING_END_TOKEN_IDS_KEY: [17, 18]
}
processor = _make_processor()
processor.model_config.request_selectable_think_end_id_sequences = [
[7, 8],
[17, 18],
]
_commit_disagg_handoff(req, processor, 17)
self.assertEqual(req.reasoning_tokens, 1)
self.assertFalse(req._is_reasoning_over)
processor._maybe_update_reasoning_tokens(req, 18)
self.assertEqual(req.reasoning_tokens, 2)
self.assertTrue(req._is_reasoning_over)
def test_disagg_rebootstrap_does_not_recount_boundary(self):
req = _make_req(terminate_after=99)
req.require_reasoning = True
req.sampling_params.custom_params = {REQUEST_REASONING_END_TOKEN_IDS_KEY: [17]}
processor = _make_processor()
processor.model_config.request_selectable_think_end_id_sequences = [
[7],
[17],
]
_commit_disagg_handoff(req, processor, 17, replayed_boundary=True)
self.assertEqual(req.reasoning_tokens, 0)
self.assertFalse(req._is_reasoning_over)
if __name__ == "__main__":
unittest.main()
+349
View File
@@ -0,0 +1,349 @@
# Copyright 2023-2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import math
from types import SimpleNamespace
import pytest
import torch
from sglang.srt.configs.k2_horizon import K2HorizonConfig, XllmConfig
from sglang.srt.models.xllm import (
EntryClass,
K2HorizonForCausalLM,
XllmAttention,
XllmForCausalLM,
XllmGroupRMSNorm,
_normalize_k2_horizon_config,
_validate_mova_config,
_xllm_router_gemm,
_xllm_stacked_params_mapping,
permute_to_hf,
permute_to_xllm,
)
from sglang.srt.runtime_context import get_context
from sglang.srt.utils.hf_transformers.common import _CONFIG_REGISTRY
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
class _IdentityRotary:
def __call__(self, positions, q, k):
self.positions_shape = tuple(positions.shape)
self.q_shape = tuple(q.shape)
self.k_shape = tuple(k.shape)
return q, k
def test_native_config_and_model_registration():
assert _CONFIG_REGISTRY["xllm"] is XllmConfig
assert _CONFIG_REGISTRY["k2_horizon"] is K2HorizonConfig
assert XllmForCausalLM in EntryClass
assert K2HorizonForCausalLM in EntryClass
def test_dense_horizon_yarn_schema_normalization():
config = K2HorizonConfig.from_dict(
{
"architectures": ["K2HorizonForCausalLM"],
"model_type": "k2_horizon",
"hidden_size": 16,
"num_hidden_layers": 2,
"mlp_only_layers": [0, 1],
"rope_theta": 1_000_000.0,
"max_position_embeddings": 32,
"rope_parameters": {
"rope_type": "yarn",
"factor": 4.0,
"original_max_position_embeddings": 8,
"attention_factor": 1.1,
"beta_fast": 32.0,
"beta_slow": 1.0,
"truncate": True,
},
}
)
_normalize_k2_horizon_config(config)
assert config.num_values == 0
assert config.num_values_per_tok == 0
assert config.num_experts == 0
assert config.num_experts_per_tok == 0
assert config.num_shared_experts == 0
assert config.num_dense_layers == 2
assert config.rope_scaling["rope_type"] == "yarn"
assert config.rope_scaling["original_max_position_embeddings"] == 8
assert config.rope_scaling["attn_factor"] == pytest.approx(
1.1 / (0.1 * math.log(4.0) + 1.0)
)
assert config._sglang_xllm_checkpoint_format == "k2_horizon_hf"
def test_mova_horizon_schema_requires_and_applies_source_router_contract():
config = K2HorizonConfig.from_dict(
{
"architectures": ["K2HorizonForCausalLM"],
"model_type": "k2_horizon",
"hidden_size": 16,
"num_hidden_layers": 4,
"num_experts": 8,
"num_experts_per_tok": 2,
"num_shared_experts": 1,
"mova_num_experts": 4,
"mova_num_experts_per_tok": 2,
"mlp_only_layers": [0],
"attention_gate_func": "softplus",
"rope_parameters": {
"rope_type": "default",
"rope_theta": 10_000_000.0,
},
"xllm_source_router_gemm_partitions": 2,
}
)
_normalize_k2_horizon_config(config)
assert config.num_values == 4
assert config.num_values_per_tok == 2
assert config.num_dense_layers == 1
assert config.apply_attn_gate is True
assert config.attn_gate_func == "softplus"
assert config.rope_theta == 10_000_000.0
def test_mova_horizon_schema_rejects_missing_source_router_contract():
config = K2HorizonConfig.from_dict(
{
"hidden_size": 16,
"num_hidden_layers": 4,
"mova_num_experts": 4,
"mova_num_experts_per_tok": 2,
}
)
with pytest.raises(ValueError, match="source router GEMM provenance"):
_normalize_k2_horizon_config(config)
def test_group_rms_norm_matches_groupwise_reference_and_residual_contract():
norm = XllmGroupRMSNorm(hidden_size=4, n_groups=2, eps=0.0)
with torch.no_grad():
norm.weight.copy_(torch.tensor([1.0, 2.0, 3.0, 4.0]))
hidden = torch.tensor([[3.0, 4.0, 0.0, 5.0]])
residual = torch.ones_like(hidden)
combined = hidden + residual
grouped = combined.reshape(1, 2, 2)
expected = grouped * torch.rsqrt(grouped.square().mean(-1, keepdim=True))
expected = expected.reshape(1, 4) * norm.weight
output, returned_residual = norm(hidden, residual=residual)
torch.testing.assert_close(output, expected)
torch.testing.assert_close(returned_residual, combined)
def test_partial_rope_round_trips_non_rotary_dimensions():
attention = object.__new__(XllmAttention)
torch.nn.Module.__init__(attention)
attention.num_heads = 2
attention.num_kv_heads = 1
attention.head_dim = 8
attention.rope_head_dim = 4
attention.rotary_emb = _IdentityRotary()
positions = torch.arange(3)
q = torch.randn(3, attention.num_heads * attention.head_dim)
k = torch.randn(3, attention.num_kv_heads * attention.head_dim)
q_out, k_out = XllmAttention._apply_partial_rope(attention, positions, q, k)
torch.testing.assert_close(q_out, q)
torch.testing.assert_close(k_out, k)
assert attention.rotary_emb.positions_shape == (3,)
assert attention.rotary_emb.q_shape == (3, 8)
assert attention.rotary_emb.k_shape == (3, 4)
def test_permutation_helpers_use_expected_interleave_order():
hf_value = torch.arange(8, dtype=torch.float32).reshape(1, 1, 8)
xllm_value = torch.tensor([[[0.0, 4.0, 1.0, 5.0, 2.0, 6.0, 3.0, 7.0]]])
torch.testing.assert_close(permute_to_xllm(hf_value), xllm_value)
torch.testing.assert_close(permute_to_hf(xllm_value), hf_value)
def test_mp2_router_gemm_preserves_source_rounding_order():
hidden = torch.tensor([[1.25, -0.75, 0.5, 2.0]], dtype=torch.bfloat16)
weight = torch.tensor(
[[0.25, 1.5, -1.0, 0.75], [2.0, -0.5, 1.25, 0.5]],
dtype=torch.bfloat16,
)
hidden_parts = hidden.chunk(2, dim=-1)
weight_parts = weight.chunk(2, dim=-1)
expected = (
torch.nn.functional.linear(
hidden_parts[0].contiguous(), weight_parts[0].contiguous()
).float()
+ torch.nn.functional.linear(
hidden_parts[1].contiguous(), weight_parts[1].contiguous()
).float()
)
torch.testing.assert_close(_xllm_router_gemm(hidden, weight, 2), expected)
with pytest.raises(ValueError, match="requires BF16"):
_xllm_router_gemm(hidden.float(), weight.float(), 2)
def test_mova_weight_mapping_uses_checkpoint_shaped_attention_projections():
config = SimpleNamespace(num_values=4)
mapping = _xllm_stacked_params_mapping(config)
assert mapping == [
(".gate_up_proj", ".gate_proj", 0),
(".gate_up_proj", ".up_proj", 1),
(".v_experts.weight", ".v_experts.0.weight", 0),
(".v_experts.weight", ".v_experts.1.weight", 1),
(".v_experts.weight", ".v_experts.2.weight", 2),
(".v_experts.weight", ".v_experts.3.weight", 3),
]
def test_dense_weight_mapping_packs_qkv_and_gate_up():
config = SimpleNamespace(num_values=0)
assert _xllm_stacked_params_mapping(config) == [
(".qkv_proj", ".q_proj", "q"),
(".qkv_proj", ".k_proj", "k"),
(".qkv_proj", ".v_proj", "v"),
(".gate_up_proj", ".gate_proj", 0),
(".gate_up_proj", ".up_proj", 1),
]
def test_strict_loader_rejects_unknown_checkpoint_weight():
model = object.__new__(XllmForCausalLM)
torch.nn.Module.__init__(model)
model.config = SimpleNamespace(model_type="xllm", tie_word_embeddings=False)
model.model = SimpleNamespace(start_layer=0, end_layer=1)
model.pp_group = SimpleNamespace(is_first_rank=True, is_last_rank=True)
model.stacked_params_mapping = []
model.expert_params_mapping = []
with pytest.raises(RuntimeError, match="did not resolve"):
model.load_weights([("unexpected.weight", torch.ones(1))])
def _native_runtime_config(*, enable_two_batch_overlap=False, **overrides):
values = {
"enable_eplb": False,
"init_expert_location": "trivial",
"ep_num_redundant_experts": 0,
"enable_two_batch_overlap": enable_two_batch_overlap,
}
values.update(overrides)
return values
def test_native_xllm_requires_bfloat16(monkeypatch):
config = XllmConfig(num_values=0, num_experts=192)
monkeypatch.setattr(torch, "get_default_dtype", lambda: torch.float16)
with (
get_context().override_server_args(**_native_runtime_config()),
pytest.raises(ValueError, match="requires --dtype bfloat16"),
):
_validate_mova_config(config, quant_config=None)
def test_native_xllm_rejects_quantized_weights(monkeypatch):
config = XllmConfig(num_values=0, num_experts=0)
monkeypatch.setattr(torch, "get_default_dtype", lambda: torch.bfloat16)
with (
get_context().override_server_args(**_native_runtime_config()),
pytest.raises(ValueError, match="does not support quantized"),
):
_validate_mova_config(config, quant_config=object())
def test_native_xllm_accepts_bfloat16_without_expert_remapping(monkeypatch):
config = XllmConfig(num_values=0, num_experts=192)
monkeypatch.setattr(torch, "get_default_dtype", lambda: torch.bfloat16)
with get_context().override_server_args(**_native_runtime_config()):
_validate_mova_config(config, quant_config=None)
@pytest.mark.parametrize(
("config_override", "error"),
[
({"query_key_norm": True}, "query/key normalization"),
({"sliding_window": 4096}, "full causal attention only"),
({"use_sliding_window": True}, "full causal attention only"),
({"apply_attn_gate": True}, "gated attention"),
],
ids=["qk-norm", "sliding-window", "use-sliding-window", "attention-gate"],
)
def test_native_dense_xllm_rejects_unimplemented_attention_features(
monkeypatch, config_override, error
):
config = XllmConfig(num_values=0, num_experts=192, **config_override)
monkeypatch.setattr(torch, "get_default_dtype", lambda: torch.bfloat16)
with (
get_context().override_server_args(**_native_runtime_config()),
pytest.raises(ValueError, match=error),
):
_validate_mova_config(config, quant_config=None)
def test_native_xllm_rejects_two_batch_overlap(monkeypatch):
config = XllmConfig(num_values=0, num_experts=192)
monkeypatch.setattr(torch, "get_default_dtype", lambda: torch.bfloat16)
with (
get_context().override_server_args(
**_native_runtime_config(enable_two_batch_overlap=True)
),
pytest.raises(ValueError, match="does not yet support.*two-batch-overlap"),
):
_validate_mova_config(config, quant_config=None)
@pytest.mark.parametrize(
"runtime_override",
[
{"enable_eplb": True},
{"init_expert_location": "random"},
{"ep_num_redundant_experts": 1},
],
ids=["eplb", "initial-placement", "redundant-expert"],
)
def test_native_xllm_rejects_unmapped_expert_modes(monkeypatch, runtime_override):
config = XllmConfig(num_values=0, num_experts=192)
monkeypatch.setattr(torch, "get_default_dtype", lambda: torch.bfloat16)
with (
get_context().override_server_args(
**_native_runtime_config(**runtime_override)
),
pytest.raises(ValueError, match="does not yet support EPLB"),
):
_validate_mova_config(config, quant_config=None)
if __name__ == "__main__":
import sys
sys.exit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,116 @@
import unittest
from sglang.srt.entrypoints.openai.protocol import (
ChatCompletionRequest,
ResponsesRequest,
)
from sglang.srt.parser.reasoning_parser import K2V3Detector, ReasoningParser
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=3, suite="base-a-test-cpu")
class TestK2V3ReasoningParser(CustomTestCase):
def test_reasoning_effort_selects_ifm_pair(self):
expected = {
"high": ("<ifm|think>", "</ifm|think>"),
"medium": ("<ifm|think_fast>", "</ifm|think_fast>"),
"low": ("<ifm|think_faster>", "</ifm|think_faster>"),
}
for effort, tokens in expected.items():
with self.subTest(effort=effort):
detector = K2V3Detector(reasoning_effort=effort)
self.assertEqual(
(detector.think_start_token, detector.think_end_token), tokens
)
self.assertEqual(
set(detector.request_selectable_think_end_tokens),
{
"</ifm|think>",
"</ifm|think_fast>",
"</ifm|think_faster>",
},
)
def test_release_template_fallback_uses_medium_pair(self):
# K2-Horizon-0.9B maps unsupported levels to <ifm|think_fast>.
detector = K2V3Detector(reasoning_effort="none")
self.assertEqual(detector.think_start_token, "<ifm|think_fast>")
def test_end_only_output_preserves_newlines(self):
result = K2V3Detector().detect_and_parse("\nreasoning\n</ifm|think>\nanswer")
self.assertEqual(result.reasoning_text, "\nreasoning\n")
self.assertEqual(result.normal_text, "\nanswer")
def test_tool_group_implicitly_ends_malformed_reasoning(self):
result = K2V3Detector().detect_and_parse(
"reasoning\n<ifm|tool_calls><ifm|tool_call>x</ifm|tool_call>"
)
self.assertEqual(result.reasoning_text, "reasoning\n")
self.assertTrue(result.normal_text.startswith("<ifm|tool_calls>"))
def test_streaming_partial_tags(self):
detector = K2V3Detector(reasoning_effort="medium")
reasoning = ""
normal = ""
wire = "work</ifm|think_fast>\nanswer"
for char in wire:
result = detector.parse_streaming_increment(char)
reasoning += result.reasoning_text
normal += result.normal_text
end = detector.finish()
reasoning += end.reasoning_text
normal += end.normal_text
self.assertEqual(reasoning, "work")
self.assertEqual(normal, "\nanswer")
def test_reasoning_parser_reads_request_effort(self):
request = ChatCompletionRequest(
model="IFM/K2-Horizon-7B",
messages=[{"role": "user", "content": "hi"}],
reasoning_effort="low",
)
parser = ReasoningParser("k2_horizon", request=request)
self.assertIsInstance(parser.detector, K2V3Detector)
self.assertEqual(parser.detector.think_end_token, "</ifm|think_faster>")
def test_template_kwarg_effort_has_rendering_precedence(self):
request = ChatCompletionRequest(
model="IFM/K2-Horizon-7B",
messages=[{"role": "user", "content": "hi"}],
reasoning_effort="high",
chat_template_kwargs={"reasoning_effort": "low"},
)
parser = ReasoningParser("k2_horizon", request=request)
self.assertEqual(parser.detector.think_end_token, "</ifm|think_faster>")
def test_responses_request_effort_selects_non_stream_delimiter(self):
end_tokens = {
"high": "</ifm|think>",
"medium": "</ifm|think_fast>",
"low": "</ifm|think_faster>",
}
for effort, end_token in end_tokens.items():
with self.subTest(effort=effort):
request = ResponsesRequest(
model="IFM/K2-Horizon-7B",
input="hi",
reasoning={"effort": effort},
store=False,
)
parser = ReasoningParser(
"k2_horizon", stream_reasoning=False, request=request
)
self.assertEqual(
parser.parse_non_stream(f"work{end_token}\nanswer"),
("work", "\nanswer"),
)
def test_force_reasoning_cannot_be_disabled(self):
with self.assertRaisesRegex(ValueError, "requires force_reasoning=True"):
ReasoningParser("k2_horizon", force_reasoning=False)
if __name__ == "__main__":
unittest.main()
@@ -483,6 +483,28 @@ class TestTemplateDetectionRuleMatrix(unittest.TestCase):
self.assertEqual(config.toggle_param, "enable_thinking")
self.assertTrue(config.default_enabled)
def test_k2_horizon_detects_always_on_reasoning_and_tool_parsers(self):
template = """
{% set tool_call_fmt = tool_call_format | default('xml') %}
{% set effort = reasoning_effort | default('high') %}
<ifm|think><ifm|think_fast><ifm|think_faster>
<ifm|tool_calls><ifm|tool_call>
"""
force, config = detect_reasoning_pattern(template)
tokenizer = _DummyTokenizer([])
self.assertTrue(force)
self.assertIsNotNone(config)
self.assertEqual(config.special_case, "always")
self.assertEqual(
detect_reasoning_parser(template, tokenizer, config, force),
"k2_horizon",
)
self.assertEqual(
detect_tool_call_parser(template, tokenizer, config, force),
"k2_horizon",
)
class TestToolCallParserDetection(unittest.TestCase):
"""Tests for detect_tool_call_parser() using real model tokenizers."""
@@ -16,9 +16,11 @@ import msgspec
from sglang.srt.sampling.sampling_params import (
MAX_LEN,
MAX_REQUEST_REASONING_END_TOKEN_IDS,
MAX_STOP_COUNT,
MAX_STOP_REGEX_COUNT,
MAX_STOP_REGEX_LEN,
REQUEST_REASONING_END_TOKEN_IDS_KEY,
TOP_K_ALL,
SamplingParams,
get_max_seq_length,
@@ -108,6 +110,28 @@ class TestSamplingParamsVerify(CustomTestCase):
sp = self._make()
sp.verify(self.VOCAB_SIZE)
def test_request_reasoning_end_token_ids_are_vocab_bounded_integers(self):
self._make(
custom_params={REQUEST_REASONING_END_TOKEN_IDS_KEY: [17, 18]}
).verify(self.VOCAB_SIZE)
invalid_values = [
[],
[-1],
[True],
[self.VOCAB_SIZE],
"17",
list(range(MAX_REQUEST_REASONING_END_TOKEN_IDS + 1)),
]
for value in invalid_values:
with (
self.subTest(value=value),
self.assertRaisesRegex(ValueError, "request reasoning end token IDs"),
):
self._make(
custom_params={REQUEST_REASONING_END_TOKEN_IDS_KEY: value}
).verify(self.VOCAB_SIZE)
def test_negative_temperature_raises(self):
"""Test that verify() rejects negative temperature (must be >= 0)."""
sp = self._make(temperature=-0.5)
@@ -581,6 +581,11 @@ class TestAttachAdditionalStopTokenIds(unittest.TestCase):
attach_additional_stop_token_ids(tok)
self.assertEqual(tok.additional_stop_token_ids, {128008})
def test_k2_horizon_im_end_registers_as_stop(self):
tok = self._tokenizer({"<|ifm|im_end|>": 64019})
attach_additional_stop_token_ids(tok)
self.assertEqual(tok.additional_stop_token_ids, {64019})
def test_no_known_marker_yields_none(self):
tok = self._tokenizer({"<|other|>": 7})
attach_additional_stop_token_ids(tok)