# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import json import pytest from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ) from vllm.entrypoints.openai.engine.protocol import DeltaMessage from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.entrypoints.openai.responses.utils import build_response_output_items from vllm.exceptions import VLLMValidationError from vllm.parser.kimi_k3 import KimiK3Parser from vllm.parser.parser_manager import ParserManager from vllm.reasoning.kimi_k3_reasoning_parser import KimiK3ReasoningParser from vllm.tool_parsers.kimi_k3_tool_parser import KimiK3ToolParser OPEN = "<|open|>" CLOSE = "<|close|>" SEP = "<|sep|>" THINK_OPEN = f"{OPEN}think{SEP}" THINK_CLOSE = f"{CLOSE}think{SEP}" RESPONSE_CLOSE = f"{CLOSE}response{SEP}" class DummyTokenizer: def get_vocab(self) -> dict[str, int]: return {} def encode(self, text: str, add_special_tokens: bool = False) -> list[int]: if text == THINK_OPEN: return [1, 2, 3] if text == THINK_CLOSE: return [4, 2, 3] return [ord(ch) for ch in text] class KimiK3DelegatingParser(KimiK3Parser): reasoning_parser_cls = KimiK3ReasoningParser tool_parser_cls = KimiK3ToolParser def test_parser_manager_selects_kimi_k3_parser(): parser_cls = ParserManager.get_parser( tool_parser_name="kimi_k3", reasoning_parser_name="kimi_k3", enable_auto_tools=True, ) assert parser_cls is not None assert issubclass(parser_cls, KimiK3Parser) assert parser_cls.reasoning_parser_cls is KimiK3ReasoningParser assert parser_cls.tool_parser_cls is KimiK3ToolParser def _request() -> ChatCompletionRequest: return ChatCompletionRequest( model="test-model", messages=[], tools=[ { "type": "function", "function": { "name": "calc", "parameters": {"type": "object", "properties": {}}, }, } ], tool_choice="auto", ) def _named_request() -> ChatCompletionRequest: return ChatCompletionRequest( model="test-model", messages=[], tools=[ { "type": "function", "function": { "name": "calc", "parameters": {"type": "object", "properties": {}}, }, } ], tool_choice={"type": "function", "function": {"name": "calc"}}, ) def _responses_request(*, tool_choice="auto") -> ResponsesRequest: return ResponsesRequest.model_validate( { "model": "test-model", "input": "Call the calc tool.", "tools": [ { "type": "function", "name": "calc", "parameters": {"type": "object", "properties": {}}, } ], "tool_choice": tool_choice, } ) def _arg(key: str, typ: str, value: str) -> str: return f'{OPEN}argument key="{key}" type="{typ}"{SEP}{value}{CLOSE}argument{SEP}' def _call(tool: str, index: int, *args: str) -> str: body = "".join(args) return f'{OPEN}call tool="{tool}" index="{index}"{SEP}{body}{CLOSE}call{SEP}' def _response(content: str) -> str: return f"{OPEN}response{SEP}{content}{RESPONSE_CLOSE}" def _tools(*calls: str) -> str: return f"{OPEN}tools{SEP}{''.join(calls)}{CLOSE}tools{SEP}" def test_extract_tool_calls_with_response_and_typed_arguments(): parser = KimiK3ToolParser(DummyTokenizer()) output = _response("answer") + _tools( _call( "calc", 1, _arg("x", "number", "1"), _arg("flag", "boolean", "true"), _arg("text", "string", "raw"), ) ) extracted = parser.extract_tool_calls(output, _request()) assert extracted.tools_called is True assert extracted.content == "answer" assert len(extracted.tool_calls) == 1 tool_call = extracted.tool_calls[0] assert tool_call.function.name == "calc" assert json.loads(tool_call.function.arguments) == { "x": 1, "flag": True, "text": "raw", } def test_delegating_parser_preserves_tool_calls_after_reasoning(): parser = KimiK3DelegatingParser(DummyTokenizer()) output = ( f"{THINK_OPEN}step{THINK_CLOSE}" + _response("answer") + _tools(_call("calc", 1, _arg("x", "number", "1"))) ) reasoning, content, tool_calls = parser.parse( output, _request(), enable_auto_tools=True, ) assert reasoning == "step" assert content == "answer" assert tool_calls is not None assert len(tool_calls) == 1 assert tool_calls[0].name == "calc" assert json.loads(tool_calls[0].arguments) == {"x": 1} def test_delegating_parser_required_tool_choice_uses_xtml_parser(): parser = KimiK3DelegatingParser(DummyTokenizer()) request = _request().model_copy(update={"tool_choice": "required"}) output = ( f"{THINK_OPEN}step{THINK_CLOSE}" + _response("") + _tools(_call("calc", 1, _arg("x", "number", "1"))) ) reasoning, content, tool_calls = parser.parse( output, request, enable_auto_tools=True, ) assert reasoning == "step" assert content is None assert tool_calls is not None assert len(tool_calls) == 1 assert tool_calls[0].name == "calc" assert json.loads(tool_calls[0].arguments) == {"x": 1} def test_delegating_parser_named_tool_choice_uses_xtml_parser(): parser = KimiK3DelegatingParser(DummyTokenizer()) output = ( f"{THINK_OPEN}step{THINK_CLOSE}" + _response("") + _tools(_call("calc", 1, _arg("x", "number", "1"))) ) reasoning, content, tool_calls = parser.parse( output, _named_request(), enable_auto_tools=True, ) assert reasoning == "step" assert content is None assert tool_calls is not None assert len(tool_calls) == 1 assert tool_calls[0].name == "calc" assert json.loads(tool_calls[0].arguments) == {"x": 1} def test_delegating_parser_auto_no_call_strips_consumed_response_prefix(): parser = KimiK3DelegatingParser( DummyTokenizer(), chat_template_kwargs={"thinking": False} ) request = _request().model_copy( update={"chat_template_kwargs": {"thinking": False}} ) reasoning, content, tool_calls = parser.parse( f"answer{RESPONSE_CLOSE}", request, enable_auto_tools=True, ) assert reasoning is None assert content == "answer" assert tool_calls is None def test_delegating_parser_required_call_strips_consumed_response_prefix(): parser = KimiK3DelegatingParser( DummyTokenizer(), chat_template_kwargs={"thinking": False} ) request = _request().model_copy( update={ "tool_choice": "required", "chat_template_kwargs": {"thinking": False}, } ) output = RESPONSE_CLOSE + _tools(_call("calc", 1, _arg("x", "number", "1"))) reasoning, content, tool_calls = parser.parse( output, request, enable_auto_tools=True, ) assert reasoning is None assert content is None assert tool_calls is not None assert len(tool_calls) == 1 assert tool_calls[0].name == "calc" assert json.loads(tool_calls[0].arguments) == {"x": 1} def test_delegating_parser_truncated_tools_do_not_leak_xtml(): parser = KimiK3DelegatingParser( DummyTokenizer(), chat_template_kwargs={"thinking": False} ) request = _request().model_copy( update={ "tool_choice": "required", "chat_template_kwargs": {"thinking": False}, } ) reasoning, content, tool_calls = parser.parse( (f'{RESPONSE_CLOSE}{OPEN}tools{SEP}{OPEN}call tool="calc" index="1"'), request, enable_auto_tools=True, ) assert reasoning is None assert content is None assert tool_calls is None def test_extract_tool_calls_unescapes_attributes(): parser = KimiK3ToolParser(DummyTokenizer()) output = _tools(_call("a&b"c", 1, _arg("k&q", "string", "v"))) extracted = parser.extract_tool_calls(output, _request()) assert extracted.tools_called is True assert extracted.tool_calls[0].function.name == 'a&b"c' assert json.loads(extracted.tool_calls[0].function.arguments) == {"k&q": "v"} def test_extract_tool_calls_allows_less_than_in_attributes(): parser = KimiK3ToolParser(DummyTokenizer()) output = _tools(_call("calc