# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import json from collections.abc import Sequence from unittest.mock import MagicMock import pytest from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ChatCompletionToolsParam, ) from vllm.tool_parsers.rust_tool_parser import RustToolParser # The PyO3 extension is an optional build artifact; skip when absent. _rust_tool_parser = pytest.importorskip("vllm._rust_tool_parser") MOCK_TOKENIZER = MagicMock() MOCK_TOKENIZER.get_vocab.return_value = {} TC_START = "<|DSML|tool_calls>" TC_END = "" INV_START = '<|DSML|invoke name="' INV_END = "" PARAM_START = '<|DSML|parameter name="' PARAM_END = "" class DeepSeekV4RustToolParser(RustToolParser): rust_parser_name = "DeepSeekV4ToolParser" tool_call_start_token = TC_START class KimiK2RustToolParser(RustToolParser): rust_parser_name = "KimiK2ToolParser" tool_call_start_token = "<|tool_calls_section_begin|>" def sample_tools() -> list[ChatCompletionToolsParam]: return [ ChatCompletionToolsParam( type="function", function={ "name": "get_weather", "description": "Get weather for a location", "parameters": { "type": "object", "properties": { "location": {"type": "string"}, "date": {"type": "string"}, }, }, }, ), ChatCompletionToolsParam( type="function", function={ "name": "add", "description": "Add two integers", "parameters": { "type": "object", "properties": { "x": {"type": "integer"}, "y": {"type": "integer"}, }, }, }, ), ] EXPECTED_CALLS = [ ("get_weather", {"location": "SF", "date": "2024-01-16"}), ("add", {"x": 3, "y": 5}), ] def build_invoke( function_name: str, params: Sequence[tuple[str, str, bool]], ) -> str: param_text = "\n".join( f'{PARAM_START}{name}" string="{str(is_string).lower()}">{value}{PARAM_END}' for name, value, is_string in params ) return f'{INV_START}{function_name}">\n{param_text}\n{INV_END}\n' def build_tool_call() -> str: weather = build_invoke( "get_weather", [ ("location", "SF", True), ("date", "2024-01-16", True), ], ) add = build_invoke( "add", [ ("x", "3", False), ("y", "5", False), ], ) return f"{TC_START}\n{weather}{add}{TC_END}" def parse_streaming( parser: DeepSeekV4RustToolParser, text: str, chunk_size: int, ) -> list: deltas = [] previous_text = "" for start in range(0, len(text), chunk_size): delta_text = text[start : start + chunk_size] current_text = previous_text + delta_text delta = parser.extract_tool_calls_streaming( previous_text=previous_text, current_text=current_text, delta_text=delta_text, previous_token_ids=[], current_token_ids=[], delta_token_ids=[1], request=MagicMock(), ) previous_text = current_text if delta is not None: deltas.append(delta) delta = parser.extract_tool_calls_streaming( previous_text=previous_text, current_text=previous_text, delta_text="", previous_token_ids=[], current_token_ids=[], delta_token_ids=[2], request=MagicMock(), ) if delta is not None: deltas.append(delta) return deltas def collect_streamed_arguments(deltas: Sequence, tool_index: int = 0) -> str: return "".join( tool_call.function.arguments for delta in deltas for tool_call in delta.tool_calls or [] if ( tool_call.index == tool_index and tool_call.function is not None and tool_call.function.arguments is not None ) ) def test_rust_tool_parser_extension_typed_api() -> None: tools = [ _rust_tool_parser.Tool( tool.function.name, tool.function.description, tool.function.parameters, None, ) for tool in sample_tools() ] parser = _rust_tool_parser.ToolParser("DeepSeekV4ToolParser", tools) output = _rust_tool_parser.ToolParserOutput() parser.parse_into(build_tool_call(), output) output.append(parser.finish()) output = output.coalesce() assert parser.preserve_special_tokens() assert output.normal_text == "" assert len(output.calls) == 2 for call, (name, arguments) in zip(output.calls, EXPECTED_CALLS): assert call.name == name assert json.loads(call.arguments) == arguments def test_rust_tool_parser_adapter_extracts_complete_output() -> None: tools = sample_tools() parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=tools) result = parser.extract_tool_calls( "Let me create it. " + build_tool_call(), ChatCompletionRequest(messages=[], model="m", tools=tools), ) assert result.tools_called assert result.content == "Let me create it. " assert len(result.tool_calls) == 2 for tool_call, (name, arguments) in zip(result.tool_calls, EXPECTED_CALLS): assert tool_call.function.name == name assert json.loads(tool_call.function.arguments) == arguments def test_rust_tool_parser_adapter_streaming_handles_multiple_calls() -> None: parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools()) deltas = parse_streaming(parser, build_tool_call(), chunk_size=5) names = [ tool_call.function.name for delta in deltas for tool_call in delta.tool_calls or [] if tool_call.function is not None and tool_call.function.name is not None ] assert names == [name for name, _ in EXPECTED_CALLS] for index, (_, arguments) in enumerate(EXPECTED_CALLS): assert json.loads(collect_streamed_arguments(deltas, index)) == arguments def test_rust_tool_parser_adapter_ignores_midstream_empty_delta() -> None: parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools()) text = build_tool_call() split_at = len(TC_START) + 8 deltas = [] previous_text = "" for delta_text in (text[:split_at], "", text[split_at:], ""): current_text = previous_text + delta_text delta = parser.extract_tool_calls_streaming( previous_text=previous_text, current_text=current_text, delta_text=delta_text, previous_token_ids=[], current_token_ids=[], delta_token_ids=[1], request=MagicMock(), ) previous_text = current_text if delta is not None: deltas.append(delta) names = [ tool_call.function.name for delta in deltas for tool_call in delta.tool_calls or [] if tool_call.function is not None and tool_call.function.name is not None ] assert names == [name for name, _ in EXPECTED_CALLS] for index, (_, arguments) in enumerate(EXPECTED_CALLS): assert json.loads(collect_streamed_arguments(deltas, index)) == arguments KIMI_EXPECTED_IDS = ["functions.get_weather:0", "functions.add:1"] def build_kimi_tool_call() -> str: return ( "<|tool_calls_section_begin|>" "<|tool_call_begin|>functions.get_weather:0<|tool_call_argument_begin|>" '{"location": "SF", "date": "2024-01-16"}<|tool_call_end|>' "<|tool_call_begin|>functions.add:1<|tool_call_argument_begin|>" '{"x": 3, "y": 5}<|tool_call_end|>' "<|tool_calls_section_end|>" ) def test_rust_tool_parser_adapter_complete_prefers_model_tool_call_ids() -> None: tools = sample_tools() parser = KimiK2RustToolParser(MOCK_TOKENIZER, tools=tools) result = parser.extract_tool_calls( "Let me check. " + build_kimi_tool_call(), ChatCompletionRequest(messages=[], model="m", tools=tools), ) assert result.tools_called assert [tool_call.id for tool_call in result.tool_calls] == KIMI_EXPECTED_IDS for tool_call, (name, arguments) in zip(result.tool_calls, EXPECTED_CALLS): assert tool_call.function.name == name assert json.loads(tool_call.function.arguments) == arguments def test_rust_tool_parser_adapter_streaming_prefers_model_tool_call_ids() -> None: parser = KimiK2RustToolParser(MOCK_TOKENIZER, tools=sample_tools()) deltas = parse_streaming(parser, build_kimi_tool_call(), chunk_size=5) ids = [ tool_call.id for delta in deltas for tool_call in delta.tool_calls or [] if tool_call.id is not None ] assert ids == KIMI_EXPECTED_IDS for index, (_, arguments) in enumerate(EXPECTED_CALLS): assert json.loads(collect_streamed_arguments(deltas, index)) == arguments def test_rust_tool_parser_adapter_streaming_generates_ids_as_fallback() -> None: # DeepSeekV4 never emits model tool call IDs, so the bridge mints them. parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools()) deltas = parse_streaming(parser, build_tool_call(), chunk_size=5) ids = [ tool_call.id for delta in deltas for tool_call in delta.tool_calls or [] if tool_call.function is not None and tool_call.function.name is not None ] assert len(ids) == len(EXPECTED_CALLS) assert all(ids) assert len(set(ids)) == len(ids) def test_rust_tool_parser_adapter_adjust_request_is_opaque() -> None: tools = sample_tools() parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=tools) request = ChatCompletionRequest( messages=[], model="m", tools=tools, tool_choice="required", skip_special_tokens=True, ) adjusted = parser.adjust_request(request) assert adjusted is request assert adjusted.skip_special_tokens is False assert adjusted.structured_outputs is None