[Model] Add K2-Horizon model support (#55063)

This commit is contained in:
Bowen Tan
2026-09-03 01:17:08 -07:00
committed by GitHub
parent 092334c828
commit 1f76efaa21
10 changed files with 2613 additions and 0 deletions
+1
View File
@@ -414,6 +414,7 @@ th {
| `IQuestLoopCoderForCausalLM` | IQuestLoopCoderV1 | `IQuestLab/IQuest-Coder-V1-40B-Loop-Instruct`, etc. | | |
| `Jais2ForCausalLM` | Jais2 | `inceptionai/Jais-2-8B-Chat`, `inceptionai/Jais-2-70B-Chat`, etc. | | ✅︎ |
| `JambaForCausalLM` | Jamba | `ai21labs/AI21-Jamba-1.5-Large`, `ai21labs/AI21-Jamba-1.5-Mini`, `ai21labs/Jamba-v0.1`, etc. | ✅︎ | ✅︎ |
| `K2HorizonForCausalLM` | K2-Horizon | `IFM/K2-Horizon-7B`, `IFM/K2-Horizon-375B` | | ✅︎ |
| `KimiLinearForCausalLM` | Kimi-Linear-48B-A3B-Base, Kimi-Linear-48B-A3B-Instruct | `moonshotai/Kimi-Linear-48B-A3B-Base`, `moonshotai/Kimi-Linear-48B-A3B-Instruct` | | ✅︎ |
| `Lfm2ForCausalLM` | LFM2 | `LiquidAI/LFM2-1.2B`, `LiquidAI/LFM2-700M`, `LiquidAI/LFM2-350M`, etc. | ✅︎ | ✅︎ |
| `Lfm2MoeForCausalLM` | LFM2MoE | `LiquidAI/LFM2-8B-A1B-preview`, etc. | ✅︎ | ✅︎ |
+5
View File
@@ -364,6 +364,11 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
"random": "ai21labs/Jamba-tiny-random",
},
),
"K2HorizonForCausalLM": _HfExamplesInfo(
"IFM/K2-Horizon-36B",
trust_remote_code=True,
is_available_online=False,
),
"KimiLinearForCausalLM": _HfExamplesInfo(
"moonshotai/Kimi-Linear-48B-A3B-Instruct", trust_remote_code=True
),
@@ -0,0 +1,214 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import string
from collections.abc import Sequence
import pytest
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.parser.parser_manager import ParserManager
from vllm.reasoning import ReasoningParserManager
from vllm.reasoning.k2_horizon_reasoning_parser import K2HorizonReasoningParser
pytestmark = pytest.mark.skip_global_cleanup
EFFORT_TOKENS = {
"high": ("<ifm|think>", "</ifm|think>"),
"medium": ("<ifm|think_fast>", "</ifm|think_fast>"),
"low": ("<ifm|think_faster>", "</ifm|think_faster>"),
}
TOOL_CALLS_START = "<ifm|tool_calls>"
class K2Tokenizer:
def __init__(self) -> None:
special_tokens = [
token for token_pair in EFFORT_TOKENS.values() for token in token_pair
] + [TOOL_CALLS_START]
tokens = special_tokens + list(string.printable)
self._vocab = {token: index for index, token in enumerate(tokens, start=1)}
def get_vocab(self) -> dict[str, int]:
return dict(self._vocab)
def encode(self, text: str) -> list[int]:
tokens: list[int] = []
position = 0
specials = sorted(EFFORT_TOKENS["high"] + (TOOL_CALLS_START,), key=len)
while position < len(text):
for special in specials:
if text.startswith(special, position):
tokens.append(self._vocab[special])
position += len(special)
break
else:
tokens.append(self._vocab[text[position]])
position += 1
return tokens
def decode(
self,
ids: Sequence[int] | int,
skip_special_tokens: bool = False,
) -> str:
del skip_special_tokens
if isinstance(ids, int):
ids = [ids]
inverse_vocab = {token_id: token for token, token_id in self._vocab.items()}
return "".join(inverse_vocab[token_id] for token_id in ids)
def _request(**kwargs) -> ChatCompletionRequest:
return ChatCompletionRequest(
model="k2-horizon",
messages=[{"role": "user", "content": "test"}],
**kwargs,
)
@pytest.mark.parametrize("effort", ["high", "medium", "low"])
def test_reasoning_effort_selects_tokens(effort: str):
parser = K2HorizonReasoningParser(
K2Tokenizer(),
chat_template_kwargs={"reasoning_effort": effort},
)
assert (parser.start_token, parser.end_token) == EFFORT_TOKENS[effort]
def test_reasoning_effort_precedence_and_default():
tokenizer = K2Tokenizer()
request = _request(
reasoning_effort="medium",
chat_template_kwargs={"reasoning_effort": "low"},
)
resolved_kwargs = (
request.build_chat_params(None, "auto")
.with_defaults({"reasoning_effort": "high"})
.chat_template_kwargs
)
parser = K2HorizonReasoningParser(
tokenizer,
chat_template_kwargs=resolved_kwargs,
)
default_parser = K2HorizonReasoningParser(tokenizer)
assert parser.start_token == EFFORT_TOKENS["medium"][0]
assert default_parser.start_token == EFFORT_TOKENS["high"][0]
@pytest.mark.parametrize("effort", ["none", "unknown", None])
def test_invalid_reasoning_effort_raises(effort: str | None):
with pytest.raises(ValueError, match="Unsupported reasoning_effort"):
K2HorizonReasoningParser(
K2Tokenizer(),
chat_template_kwargs={"reasoning_effort": effort},
)
def test_non_streaming_explicit_and_implicit_boundaries():
parser = K2HorizonReasoningParser(K2Tokenizer())
request = _request()
assert parser.extract_reasoning("<ifm|think>plan</ifm|think>answer", request) == (
"plan",
"answer",
)
assert parser.extract_reasoning(
f"plan{TOOL_CALLS_START}<ifm|tool_call>ping</ifm|tool_call>", request
) == (
"plan",
f"{TOOL_CALLS_START}<ifm|tool_call>ping</ifm|tool_call>",
)
@pytest.mark.parametrize("reasoning", ["plan", " ", "\n", " \n"])
def test_character_split_reasoning_stream(reasoning: str):
parser = K2HorizonReasoningParser(K2Tokenizer())
reasoning_parts: list[str] = []
content_parts: list[str] = []
for char in f"{reasoning}</ifm|think>answer":
delta = parser.extract_reasoning_streaming(
previous_text="",
current_text="",
delta_text=char,
previous_token_ids=[],
current_token_ids=[],
delta_token_ids=[],
)
if delta is not None:
if delta.reasoning is not None:
reasoning_parts.append(delta.reasoning)
if delta.content is not None:
content_parts.append(delta.content)
assert "".join(reasoning_parts) == reasoning
assert "".join(content_parts) == "answer"
def test_latest_boundary_controls_reasoning_state():
tokenizer = K2Tokenizer()
parser = K2HorizonReasoningParser(tokenizer)
vocab = tokenizer.get_vocab()
assert parser.is_reasoning_end([vocab["<ifm|think>"], vocab[TOOL_CALLS_START]])
assert not parser.is_reasoning_end([vocab[TOOL_CALLS_START], vocab["<ifm|think>"]])
def test_composed_streaming_reasoning_to_tool_handoff():
tokenizer = K2Tokenizer()
tools = [
{
"type": "function",
"function": {
"name": "ping",
"parameters": {"type": "object", "properties": {}},
},
}
]
request = _request(tools=tools, tool_choice="auto")
parser_cls = ParserManager.get_parser(
tool_parser_name="k2_horizon",
reasoning_parser_name="k2_horizon",
enable_auto_tools=True,
)
assert parser_cls is not None
parser = parser_cls(
tokenizer,
request.tools,
chat_template_kwargs={"reasoning_effort": "high"},
)
output = "plan<ifm|tool_calls><ifm|tool_call>ping</ifm|tool_call></ifm|tool_calls>"
reasoning_parts: list[str] = []
tool_names: list[str] = []
for index, char in enumerate(output):
delta = parser.parse_delta(
delta_text=char,
delta_token_ids=tokenizer.encode(char),
request=request,
prompt_token_ids=[] if index == 0 else None,
finished=index == len(output) - 1,
)
if delta is not None:
if delta.reasoning is not None:
reasoning_parts.append(delta.reasoning)
tool_names.extend(
tool_call.function.name
for tool_call in delta.tool_calls
if tool_call.function is not None
and tool_call.function.name is not None
)
assert "".join(reasoning_parts) == "plan"
assert tool_names == ["ping"]
def test_reasoning_parser_registered():
assert (
ReasoningParserManager.get_reasoning_parser("k2_horizon")
is K2HorizonReasoningParser
)
@@ -0,0 +1,318 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from unittest.mock import MagicMock
import pytest
from vllm.entrypoints.generate.base.protocol import DeltaMessage
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.parser.parser_manager import ParserManager
from vllm.tool_parsers import ToolParserManager
from vllm.tool_parsers.k2_horizon_tool_parser import K2HorizonToolParser
pytestmark = pytest.mark.skip_global_cleanup
TOOLS = [
{
"type": "function",
"function": {
"name": "lookup",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"enabled": {"type": "boolean"},
},
},
},
},
{
"type": "function",
"function": {
"name": "ping",
"parameters": {"type": "object", "properties": {}},
},
},
]
@pytest.fixture
def tokenizer():
tokenizer = MagicMock()
tokenizer.get_vocab.return_value = {}
return tokenizer
def _request(
tool_format: str | None = None,
tool_choice="auto",
) -> ChatCompletionRequest:
chat_template_kwargs = (
{"tool_call_format": tool_format} if tool_format is not None else None
)
return ChatCompletionRequest(
model="k2-horizon",
messages=[{"role": "user", "content": "test"}],
tools=TOOLS,
tool_choice=tool_choice,
chat_template_kwargs=chat_template_kwargs,
)
def _group(*calls: str, prefix: str = "", suffix: str = "") -> str:
return prefix + "<ifm|tool_calls>" + "\n".join(calls) + "</ifm|tool_calls>" + suffix
def _json_call(name: str, arguments: dict) -> str:
payload = json.dumps({"name": name, "arguments": arguments})
return f"<ifm|tool_call>{payload}</ifm|tool_call>"
def _xml_call(
name: str,
arguments: list[tuple[str, str]],
*,
typed: bool = False,
) -> str:
parts = [f"<ifm|tool_call>{name}"]
for key, value in arguments:
parts.append(f"<ifm|arg_key>{key}</ifm|arg_key>")
if typed:
arg_type = "integer" if key == "limit" else "string"
parts.append(f"<ifm|arg_type>{arg_type}</ifm|arg_type>")
parts.append(f"<ifm|arg_value>{value}</ifm|arg_value>")
parts.append("</ifm|tool_call>")
return "".join(parts)
def _collect_stream(
parser: K2HorizonToolParser,
request: ChatCompletionRequest,
output: str,
) -> tuple[str, list[tuple[str, dict]]]:
messages: list[DeltaMessage | None] = []
for char in output:
messages.append(
parser.extract_tool_calls_streaming(
previous_text="",
current_text="",
delta_text=char,
previous_token_ids=[],
current_token_ids=[],
delta_token_ids=[],
request=request,
)
)
messages.append(parser.finish_streaming())
content: list[str] = []
calls: list[tuple[str, dict]] = []
for message in messages:
if message is None:
continue
if message.content is not None:
content.append(message.content)
for tool_call in message.tool_calls:
assert tool_call.function is not None
assert tool_call.function.name is not None
assert tool_call.function.arguments is not None
calls.append(
(
tool_call.function.name,
json.loads(tool_call.function.arguments),
)
)
return "".join(content), calls
def test_xml_default_parses_multiple_calls_and_coerces_schema(tokenizer):
parser = K2HorizonToolParser(tokenizer, _request().tools)
output = _group(
_xml_call("lookup", [("query", "123"), ("limit", "3")]),
_xml_call("ping", []),
prefix="Before ",
suffix=" after.",
)
result = parser.extract_tool_calls(output, _request())
assert parser.tool_format == "xml"
assert result.tools_called
assert result.content == "Before after."
assert [call.function.name for call in result.tool_calls] == ["lookup", "ping"]
assert json.loads(result.tool_calls[0].function.arguments) == {
"query": "123",
"limit": 3,
}
@pytest.mark.parametrize(
("tool_format", "call", "expected"),
[
("json", _json_call("lookup", {"limit": "2"}), {"limit": 2}),
(
"xml_typed",
_xml_call("lookup", [("query", "weather"), ("limit", "2")], typed=True),
{"query": "weather", "limit": 2},
),
],
)
def test_request_level_format_overrides(tokenizer, tool_format, call, expected):
request = _request(tool_format)
parser = K2HorizonToolParser(tokenizer, request.tools)
result = parser.extract_tool_calls(_group(call), request)
assert result.tools_called
assert json.loads(result.tool_calls[0].function.arguments) == expected
@pytest.mark.parametrize(
"tool_format",
["yaml", "python", "xml_untyped", "", None, 1],
)
def test_invalid_request_format_rejected_before_generation(tokenizer, tool_format):
parser = K2HorizonToolParser(tokenizer, _request().tools)
request = _request().model_copy(
update={"chat_template_kwargs": {"tool_call_format": tool_format}}
)
with pytest.raises(ValueError, match="Unsupported tool_call_format"):
parser.adjust_request(request)
@pytest.mark.parametrize(
("tool_request", "call"),
[
(_request(), _json_call("ping", {})),
(_request("json"), _xml_call("ping", [])),
(_request("xml"), _xml_call("lookup", [("query", "x")], typed=True)),
(_request("xml_typed"), _xml_call("lookup", [("query", "x")])),
],
)
def test_mismatched_formats_fail_closed(tokenizer, tool_request, call):
parser = K2HorizonToolParser(tokenizer, tool_request.tools)
output = _group(call)
result = parser.extract_tool_calls(output, tool_request)
assert not result.tools_called
assert result.content == output
@pytest.mark.parametrize(
"tool_choice",
[
"auto",
"required",
{"type": "function", "function": {"name": "lookup"}},
],
)
def test_auto_required_and_named_use_ifm_parser(tokenizer, tool_choice):
request = _request("json", tool_choice)
parser_cls = ParserManager.get_parser(
tool_parser_name="k2_horizon",
enable_auto_tools=True,
)
assert parser_cls is not None
parser = parser_cls(tokenizer, request.tools)
assert parser.adjust_request(request) is request
assert request.structured_outputs is None
_, content, tool_calls = parser.parse(
_group(_json_call("lookup", {"enabled": "true"})),
request,
enable_auto_tools=True,
)
assert content is None
assert tool_calls is not None
assert json.loads(tool_calls[0].arguments) == {"enabled": True}
def test_unknown_or_wrong_named_tool_fails_closed(tokenizer):
named_request = _request(
"json", {"type": "function", "function": {"name": "lookup"}}
)
parser = K2HorizonToolParser(tokenizer, named_request.tools)
unknown = parser.extract_tool_calls(
_group(_json_call("missing", {})), _request("json")
)
wrong_named = parser.extract_tool_calls(
_group(_json_call("ping", {})), named_request
)
assert not unknown.tools_called
assert not wrong_named.tools_called
@pytest.mark.parametrize(
"output",
[
"<ifm|tool_calls><ifm|tool_call>ping",
"<ifm|tool_calls><ifm|tool_call>ping</ifm|tool_call>",
_group("junk"),
],
)
def test_malformed_and_incomplete_input_is_content(tokenizer, output):
request = _request()
parser = K2HorizonToolParser(tokenizer, request.tools)
result = parser.extract_tool_calls(output, request)
assert not result.tools_called
assert result.content == output
@pytest.mark.parametrize(
("tool_request", "call", "expected"),
[
(_request(), _xml_call("lookup", [("limit", "2")]), {"limit": 2}),
(_request("json"), _json_call("lookup", {"limit": "2"}), {"limit": 2}),
(
_request("xml_typed"),
_xml_call("lookup", [("limit", "2")], typed=True),
{"limit": 2},
),
],
)
def test_character_split_streaming(tokenizer, tool_request, call, expected):
parser = K2HorizonToolParser(tokenizer, tool_request.tools)
output = _group(call, prefix="Before", suffix="after")
content, calls = _collect_stream(parser, tool_request, output)
assert content == "Beforeafter"
assert calls == [("lookup", expected)]
def test_streaming_flushes_incomplete_markup_as_content(tokenizer):
request = _request()
parser = K2HorizonToolParser(tokenizer, request.tools)
output = "Before<ifm|tool_calls><ifm|tool_call>ping"
content, calls = _collect_stream(parser, request, output)
assert content == output
assert calls == []
def test_whitespace_only_surrounding_content_is_not_preserved(tokenizer):
request = _request()
parser = K2HorizonToolParser(tokenizer, request.tools)
result = parser.extract_tool_calls(
_group(_xml_call("ping", []), prefix=" \n", suffix="\t "), request
)
assert result.tools_called
assert result.content is None
def test_tool_parser_registered():
assert ToolParserManager.get_tool_parser("k2_horizon") is K2HorizonToolParser
assert K2HorizonToolParser.supports_required_and_named is False
File diff suppressed because it is too large Load Diff
+1
View File
@@ -141,6 +141,7 @@ _TEXT_GENERATION_MODELS = {
"IQuestLoopCoderForCausalLM": ("iquest_loopcoder", "IQuestLoopCoderForCausalLM"),
"Jais2ForCausalLM": ("jais2", "Jais2ForCausalLM"),
"JambaForCausalLM": ("jamba", "JambaForCausalLM"),
"K2HorizonForCausalLM": ("k2_horizon", "K2HorizonForCausalLM"),
"KimiLinearForCausalLM": (
"vllm.models.kimi_k3",
"KimiLinearForCausalLM",
+4
View File
@@ -96,6 +96,10 @@ _REASONING_PARSERS_TO_REGISTER = {
"kimi_k3_reasoning_parser",
"KimiK3ReasoningParser",
),
"k2_horizon": (
"k2_horizon_reasoning_parser",
"K2HorizonReasoningParser",
),
"mimo": (
"qwen3_engine_reasoning_parser",
"Qwen3ParserReasoningAdapter",
@@ -0,0 +1,159 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Iterable, Sequence
from vllm.entrypoints.generate.base.protocol import DeltaMessage
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.reasoning.deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParser
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.utils import partial_tag_overlap
class K2HorizonReasoningParser(DeepSeekR1ReasoningParser):
"""Reasoning parser for K2 Horizon."""
_TOOL_CALLS_START = "<ifm|tool_calls>"
_EFFORT_TOKENS: dict[str, tuple[str, str]] = {
"high": ("<ifm|think>", "</ifm|think>"),
"medium": ("<ifm|think_fast>", "</ifm|think_fast>"),
"low": ("<ifm|think_faster>", "</ifm|think_faster>"),
}
def __init__(self, tokenizer: TokenizerLike, *args, **kwargs) -> None:
chat_template_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
effort = chat_template_kwargs.get("reasoning_effort", "high")
if not isinstance(effort, str) or effort not in self._EFFORT_TOKENS:
supported = ", ".join(self._EFFORT_TOKENS)
raise ValueError(
f"Unsupported reasoning_effort {effort!r}. "
f"Supported values: {supported}."
)
self._start_token, self._end_token = self._EFFORT_TOKENS[effort]
super().__init__(tokenizer, *args, **kwargs)
self._tool_calls_start_id = self.vocab.get(self._TOOL_CALLS_START)
self._stream_buffer = ""
self._stream_at_start = True
self._stream_finished = False
@property
def start_token(self) -> str:
return self._start_token
@property
def end_token(self) -> str:
return self._end_token
def _without_generated_start(self, model_output: str) -> str:
return model_output.removeprefix(self.start_token)
def _split_model_output(self, model_output: str) -> tuple[str, str | None]:
output = self._without_generated_start(model_output)
if self.end_token in output:
reasoning, _, content = output.partition(self.end_token)
return reasoning, content or None
tool_start = output.find(self._TOOL_CALLS_START)
if tool_start != -1:
return output[:tool_start], output[tool_start:]
return "", output or None
def extract_reasoning(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> tuple[str, str | None]:
del request
return self._split_model_output(model_output)
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
for token_id in reversed(input_ids):
if token_id == self.start_token_id:
return False
if token_id == self.end_token_id or token_id == self._tool_calls_start_id:
return True
return False
def is_reasoning_end_streaming(
self,
input_ids: Sequence[int],
delta_ids: Iterable[int],
) -> bool:
del input_ids
return self._stream_finished or super().is_reasoning_end_streaming(
(), delta_ids
)
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
if self.end_token_id in input_ids:
return input_ids[input_ids.index(self.end_token_id) + 1 :]
if (
self._tool_calls_start_id is not None
and self._tool_calls_start_id in input_ids
):
return input_ids[input_ids.index(self._tool_calls_start_id) :]
return input_ids if self._stream_finished else []
def _strip_stream_start(self) -> bool:
if not self._stream_at_start:
return True
if self.start_token.startswith(self._stream_buffer):
if self._stream_buffer != self.start_token:
return False
self._stream_buffer = ""
elif self._stream_buffer.startswith(self.start_token):
self._stream_buffer = self._stream_buffer[len(self.start_token) :]
self._stream_at_start = False
return True
def extract_reasoning_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
del (
previous_text,
current_text,
previous_token_ids,
current_token_ids,
delta_token_ids,
)
if self._stream_finished:
return DeltaMessage(content=delta_text) if delta_text else None
self._stream_buffer += delta_text
if not self._strip_stream_start():
return None
explicit_end = self._stream_buffer.find(self.end_token)
tool_start = self._stream_buffer.find(self._TOOL_CALLS_START)
if explicit_end != -1:
reasoning = self._stream_buffer[:explicit_end]
content = self._stream_buffer[explicit_end + len(self.end_token) :]
elif tool_start != -1:
reasoning = self._stream_buffer[:tool_start]
content = self._stream_buffer[tool_start:]
else:
overlap = max(
partial_tag_overlap(self._stream_buffer, self.end_token),
partial_tag_overlap(self._stream_buffer, self._TOOL_CALLS_START),
)
sendable_end = len(self._stream_buffer) - overlap
reasoning = self._stream_buffer[:sendable_end]
self._stream_buffer = self._stream_buffer[sendable_end:]
return DeltaMessage(reasoning=reasoning) if reasoning else None
self._stream_buffer = ""
self._stream_finished = True
return DeltaMessage(
reasoning=reasoning,
content=content or None,
)
+4
View File
@@ -118,6 +118,10 @@ _TOOL_PARSERS_TO_REGISTER = {
"kimi_k3_tool_parser",
"KimiK3ToolParser",
),
"k2_horizon": (
"k2_horizon_tool_parser",
"K2HorizonToolParser",
),
"llama3_json": (
"llama_tool_parser",
"Llama3JsonToolParser",
+462
View File
@@ -0,0 +1,462 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from collections.abc import Sequence
from typing import Any
import regex as re
from openai.types.responses import ToolChoiceFunction
from vllm.entrypoints.generate.base.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
ExtractedToolCallInformation,
FunctionCall,
ToolCall,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionNamedToolChoiceParam,
ChatCompletionRequest,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.logger import init_logger
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import Tool, ToolParser
from vllm.tool_parsers.utils import (
coerce_to_schema_type,
extract_types_from_schema,
find_tool_name,
find_tool_properties,
partial_tag_overlap,
)
logger = init_logger(__name__)
ToolParserRequest = ChatCompletionRequest | ResponsesRequest
class K2HorizonToolParser(ToolParser):
"""Parser for K2 Horizon's IFM tool-call formats."""
supports_required_and_named = False
engine_based_streaming = True
_SUPPORTED_TOOL_FORMATS = frozenset({"json", "xml", "xml_typed"})
_TOOL_CALLS_START = "<ifm|tool_calls>"
_TOOL_CALLS_END = "</ifm|tool_calls>"
_TOOL_CALL_START = "<ifm|tool_call>"
_TOOL_CALL_END = "</ifm|tool_call>"
_ARG_KEY_START = "<ifm|arg_key>"
_ARG_KEY_END = "</ifm|arg_key>"
_ARG_TYPE_START = "<ifm|arg_type>"
_ARG_TYPE_END = "</ifm|arg_type>"
_ARG_VALUE_START = "<ifm|arg_value>"
_ARG_VALUE_END = "</ifm|arg_value>"
_TOOL_CALL_RE = re.compile(
re.escape(_TOOL_CALL_START) + r"(.*?)" + re.escape(_TOOL_CALL_END),
re.DOTALL,
)
_ARG_RE = re.compile(
re.escape(_ARG_KEY_START)
+ r"(.*?)"
+ re.escape(_ARG_KEY_END)
+ r"\s*(?:"
+ re.escape(_ARG_TYPE_START)
+ r"(.*?)"
+ re.escape(_ARG_TYPE_END)
+ r"\s*)?"
+ re.escape(_ARG_VALUE_START)
+ r"(.*?)"
+ re.escape(_ARG_VALUE_END),
re.DOTALL,
)
_ARG_MARKERS = (
_ARG_KEY_START,
_ARG_KEY_END,
_ARG_TYPE_START,
_ARG_TYPE_END,
_ARG_VALUE_START,
_ARG_VALUE_END,
)
def __init__(
self,
tokenizer: TokenizerLike,
tools: list[Tool] | None = None,
) -> None:
super().__init__(tokenizer, tools)
self.tool_format = "xml"
self._stream_text = ""
self._stream_cursor = 0
self._stream_group_start: int | None = None
self._stream_resolved = False
self._pending_content = ""
self._content_seen = False
@classmethod
def _request_tool_format(cls, request: ToolParserRequest) -> str:
chat_template_kwargs = request.chat_template_kwargs or {}
tool_format = chat_template_kwargs.get("tool_call_format", "xml")
if (
not isinstance(tool_format, str)
or tool_format not in cls._SUPPORTED_TOOL_FORMATS
):
supported = ", ".join(sorted(cls._SUPPORTED_TOOL_FORMATS))
raise ValueError(
f"Unsupported tool_call_format {tool_format!r}. "
f"Supported values: {supported}."
)
return tool_format
def adjust_request(self, request: ToolParserRequest) -> ToolParserRequest:
self._request_tool_format(request)
return request
@staticmethod
def _content_or_none(content: str) -> str | None:
return content if content.strip() else None
@classmethod
def _no_tool_calls(cls, content: str) -> ExtractedToolCallInformation:
return ExtractedToolCallInformation(
tools_called=False,
tool_calls=[],
content=cls._content_or_none(content),
)
@staticmethod
def _make_tool_call(name: str, arguments: dict[str, Any]) -> ToolCall:
return ToolCall(
type="function",
function=FunctionCall(
name=name,
arguments=json.dumps(
arguments,
ensure_ascii=False,
allow_nan=False,
),
),
)
@staticmethod
def _base_arg_type(arg_type: str | None) -> str | None:
if not arg_type:
return None
return arg_type.split("[", 1)[0].strip().lower()
@staticmethod
def _coerce_json_value(value: Any, schema: Any) -> Any:
if not isinstance(schema, dict):
return value
raw_value = (
value
if isinstance(value, str)
else json.dumps(value, ensure_ascii=False, allow_nan=False)
)
return coerce_to_schema_type(raw_value, extract_types_from_schema(schema))
@classmethod
def _coerce_xml_value(
cls,
value: str,
schema: Any,
explicit_type: str | None,
) -> Any:
explicit_base = cls._base_arg_type(explicit_type)
target_types = extract_types_from_schema(schema)
if isinstance(schema, dict):
if explicit_base in target_types and len(set(target_types)) > 1:
target_types = [explicit_base]
elif explicit_base == "any":
return value
elif explicit_base:
target_types = [explicit_base]
raw_value = value if "string" in target_types else value.strip()
return coerce_to_schema_type(raw_value, target_types)
@staticmethod
def _named_tool(request: ToolParserRequest) -> str | None:
tool_choice = request.tool_choice
if isinstance(tool_choice, ChatCompletionNamedToolChoiceParam):
return tool_choice.function.name
if isinstance(tool_choice, ToolChoiceFunction):
return tool_choice.name
return None
@classmethod
def _validate_tool_name(
cls,
name: Any,
request: ToolParserRequest,
) -> str:
if (
not isinstance(name, str)
or not name
or any(char.isspace() for char in name)
):
raise ValueError("IFM tool call has an invalid function name.")
if request.tools and not find_tool_name(request.tools, name):
raise ValueError(f"Unknown IFM tool name: {name}.")
named_tool = cls._named_tool(request)
if named_tool is not None and name != named_tool:
raise ValueError(f"Unexpected IFM tool name: {name}.")
return name
@classmethod
def _parse_json_call(
cls,
body: str,
request: ToolParserRequest,
) -> tuple[str, dict[str, Any]]:
raw_call = json.loads(body.strip())
if not isinstance(raw_call, dict):
raise ValueError("IFM JSON tool call must be an object.")
name = cls._validate_tool_name(raw_call.get("name"), request)
arguments = raw_call.get("arguments", {})
if not isinstance(arguments, dict):
raise ValueError("IFM JSON tool-call arguments must be an object.")
properties = find_tool_properties(request.tools, name)
arguments = {
arg_name: cls._coerce_json_value(arg_value, properties.get(arg_name))
for arg_name, arg_value in arguments.items()
}
return name, arguments
@classmethod
def _parse_xml_call(
cls,
body: str,
request: ToolParserRequest,
tool_format: str,
) -> tuple[str, dict[str, Any]]:
first_arg = body.find(cls._ARG_KEY_START)
if first_arg == -1:
if any(marker in body for marker in cls._ARG_MARKERS):
raise ValueError("Malformed IFM XML argument tags.")
name = cls._validate_tool_name(body.strip(), request)
return name, {}
name = cls._validate_tool_name(body[:first_arg].strip(), request)
properties = find_tool_properties(request.tools, name)
arguments: dict[str, Any] = {}
position = first_arg
for match in cls._ARG_RE.finditer(body, first_arg):
if body[position : match.start()].strip():
raise ValueError("Malformed IFM XML argument tags.")
arg_name = match.group(1).strip()
if not arg_name:
raise ValueError("IFM XML argument is missing a name.")
if arg_name in arguments:
raise ValueError(f"Duplicate IFM XML argument: {arg_name}.")
explicit_type = match.group(2)
if explicit_type is not None:
explicit_type = explicit_type.strip()
if tool_format == "xml" and explicit_type is not None:
raise ValueError("Unexpected type tag in IFM XML tool call.")
if tool_format == "xml_typed" and not explicit_type:
raise ValueError("IFM typed XML argument is missing a type.")
arguments[arg_name] = cls._coerce_xml_value(
match.group(3),
properties.get(arg_name),
explicit_type,
)
position = match.end()
if position == first_arg or body[position:].strip():
raise ValueError("Malformed IFM XML argument tags.")
return name, arguments
@classmethod
def _parse_call(
cls,
body: str,
request: ToolParserRequest,
tool_format: str,
) -> tuple[str, dict[str, Any]]:
if tool_format == "json":
return cls._parse_json_call(body, request)
return cls._parse_xml_call(body, request, tool_format)
def extract_tool_calls(
self,
model_output: str,
request: ToolParserRequest,
) -> ExtractedToolCallInformation:
tool_format = self._request_tool_format(request)
if request.tool_choice == "none":
return self._no_tool_calls(model_output)
group_start = model_output.find(self._TOOL_CALLS_START)
if group_start == -1:
return self._no_tool_calls(model_output)
body_start = group_start + len(self._TOOL_CALLS_START)
group_end = model_output.find(self._TOOL_CALLS_END, body_start)
if group_end == -1:
return self._no_tool_calls(model_output)
group_body = model_output[body_start:group_end]
matches = list(self._TOOL_CALL_RE.finditer(group_body))
if not matches or self._TOOL_CALL_RE.sub("", group_body).strip():
logger.warning("Malformed K2 Horizon IFM tool-call group.")
return self._no_tool_calls(model_output)
try:
tool_calls = [
self._make_tool_call(
*self._parse_call(match.group(1), request, tool_format)
)
for match in matches
]
except (json.JSONDecodeError, TypeError, ValueError):
logger.warning("Failed to parse K2 Horizon IFM tool calls.", exc_info=True)
return self._no_tool_calls(model_output)
suffix = model_output[group_end + len(self._TOOL_CALLS_END) :]
content = model_output[:group_start] + suffix
return ExtractedToolCallInformation(
tools_called=True,
tool_calls=tool_calls,
content=self._content_or_none(content),
)
def _emit_content(self, addition: str, *, final: bool = False) -> str | None:
if addition:
if self._content_seen:
return addition
self._pending_content += addition
if self._pending_content.strip():
self._content_seen = True
content = self._pending_content
self._pending_content = ""
return content
if final:
self._pending_content = ""
return None
def _streaming_tool_calls(
self,
tool_calls: list[ToolCall],
) -> list[DeltaToolCall]:
deltas: list[DeltaToolCall] = []
for index, tool_call in enumerate(tool_calls):
arguments_json = tool_call.function.arguments
arguments = json.loads(arguments_json)
self.prev_tool_call_arr.append(
{"name": tool_call.function.name, "arguments": arguments}
)
self.streamed_args_for_tool.append(arguments_json)
deltas.append(
DeltaToolCall(
index=index,
id=tool_call.id,
type="function",
function=DeltaFunctionCall(
name=tool_call.function.name,
arguments=arguments_json,
),
)
)
return deltas
def extract_tool_calls_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
request: ToolParserRequest,
) -> DeltaMessage | None:
del (
previous_text,
current_text,
previous_token_ids,
current_token_ids,
delta_token_ids,
)
self._request_tool_format(request)
if request.tool_choice == "none":
emitted_content = self._emit_content(delta_text)
return (
DeltaMessage(content=emitted_content)
if emitted_content is not None
else None
)
self._stream_text += delta_text
if self._stream_resolved:
emitted_content = self._emit_content(delta_text)
return (
DeltaMessage(content=emitted_content)
if emitted_content is not None
else None
)
content: str | None = None
if self._stream_group_start is None:
group_start = self._stream_text.find(
self._TOOL_CALLS_START, self._stream_cursor
)
if group_start == -1:
pending = self._stream_text[self._stream_cursor :]
overlap = partial_tag_overlap(pending, self._TOOL_CALLS_START)
sendable_end = len(self._stream_text) - overlap
content = self._emit_content(
self._stream_text[self._stream_cursor : sendable_end]
)
self._stream_cursor = sendable_end
return DeltaMessage(content=content) if content is not None else None
self._stream_group_start = group_start
content = self._emit_content(
self._stream_text[self._stream_cursor : group_start]
)
self._stream_cursor = group_start
group_start = self._stream_group_start
body_start = group_start + len(self._TOOL_CALLS_START)
group_end = self._stream_text.find(self._TOOL_CALLS_END, body_start)
if group_end == -1:
return DeltaMessage(content=content) if content is not None else None
group_end += len(self._TOOL_CALLS_END)
group_text = self._stream_text[group_start:group_end]
parsed = self.extract_tool_calls(group_text, request)
self._stream_resolved = True
if not parsed.tools_called:
failed_content = self._emit_content(
self._stream_text[self._stream_cursor :]
)
self._stream_cursor = len(self._stream_text)
combined = (content or "") + (failed_content or "")
return DeltaMessage(content=combined) if combined else None
tool_calls = self._streaming_tool_calls(parsed.tool_calls)
suffix = self._emit_content(self._stream_text[group_end:])
self._stream_cursor = len(self._stream_text)
combined = (content or "") + (suffix or "")
return DeltaMessage(content=combined or None, tool_calls=tool_calls)
def finish_streaming(self) -> DeltaMessage | None:
if not self._stream_resolved:
content = self._emit_content(
self._stream_text[self._stream_cursor :], final=True
)
self._stream_cursor = len(self._stream_text)
self._stream_resolved = True
else:
content = self._emit_content("", final=True)
return DeltaMessage(content=content) if content is not None else None