| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import json |
| 5 | from typing import Any |
| 6 | |
| 7 | import pytest |
| 8 | |
| 9 | from vllm.entrypoints.generate.base.protocol import DeltaMessage |
| 10 | from vllm.entrypoints.openai.chat_completion.protocol import ( |
| 11 | ChatCompletionToolsParam, |
| 12 | FunctionDefinition, |
| 13 | ) |
| 14 | from vllm.tool_parsers import ToolParserManager |
| 15 | from vllm.tool_parsers.minimax_m3_tool_parser import MinimaxM3ToolParser |
| 16 | |
| 17 | # MinimaxM3ToolParser extends RustToolParser; skip when the PyO3 extension |
| 18 | # is absent (mirrors the guard in test_rust_tool_parser.py). |
| 19 | pytest.importorskip("vllm._rust_tool_parser") |
| 20 | |
| 21 | pytestmark = [pytest.mark.cpu_test, pytest.mark.skip_global_cleanup] |
| 22 | |
| 23 | NS = "]<]minimax[>[" |
| 24 | EOS_ID = 99 |
| 25 | |
| 26 | |
| 27 | class FakeTokenizer: |
| 28 | """Minimal fake tokenizer for unit tests.""" |
| 29 | |
| 30 | def __init__(self): |
| 31 | self.model_tokenizer = True |
| 32 | self.vocab: dict[str, int] = {} |
| 33 | |
| 34 | def get_vocab(self) -> dict[str, int]: |
| 35 | return self.vocab |
| 36 | |
| 37 | |
| 38 | def sample_tools() -> list[ChatCompletionToolsParam]: |
| 39 | return [ |
| 40 | ChatCompletionToolsParam( |
| 41 | function=FunctionDefinition( |
| 42 | name="create_order", |
| 43 | parameters={ |
| 44 | "type": "object", |
| 45 | "properties": { |
| 46 | "user_id": {"type": "integer"}, |
| 47 | "urgent": {"type": "boolean"}, |
| 48 | "note": {"type": "string"}, |
| 49 | "shipping": { |
| 50 | "type": "object", |
| 51 | "properties": { |
| 52 | "city": {"type": "string"}, |
| 53 | "zip": {"type": "integer"}, |
| 54 | }, |
| 55 | }, |
| 56 | "items": { |
| 57 | "type": "array", |
| 58 | "items": { |
| 59 | "type": "object", |
| 60 | "properties": { |
| 61 | "sku": {"type": "string"}, |
| 62 | "qty": {"type": "integer"}, |
| 63 | }, |
| 64 | }, |
| 65 | }, |
| 66 | "metadata": { |
| 67 | "type": "object", |
| 68 | "additionalProperties": {"type": "string"}, |
| 69 | }, |
| 70 | "duplicate_demo": {"type": "object"}, |
| 71 | }, |
| 72 | }, |
| 73 | ), |
| 74 | ) |
| 75 | ] |
| 76 | |
| 77 | |
| 78 | @pytest.fixture |
| 79 | def parser() -> MinimaxM3ToolParser: |
| 80 | return MinimaxM3ToolParser(FakeTokenizer(), tools=sample_tools()) |
| 81 | |
| 82 | |
| 83 | def build_order_call() -> str: |
| 84 | return ( |
| 85 | f"{NS}<tool_call>\n" |
| 86 | f'{NS}<invoke name="create_order">' |
| 87 | f"{NS}<user_id>42{NS}</user_id>" |
| 88 | f"{NS}<urgent>true{NS}</urgent>" |
| 89 | f"{NS}<note>Please leave at front desk.{NS}</note>" |
| 90 | f"{NS}<shipping>" |
| 91 | f"{NS}<city>Singapore{NS}</city>" |
| 92 | f"{NS}<zip>018956{NS}</zip>" |
| 93 | f"{NS}</shipping>" |
| 94 | f"{NS}<items>" |
| 95 | f"{NS}<item>{NS}<sku>book-001{NS}</sku>{NS}<qty>2{NS}</qty>{NS}</item>" |
| 96 | f"{NS}<item>{NS}<sku>pen-007{NS}</sku>{NS}<qty>5{NS}</qty>{NS}</item>" |
| 97 | f"{NS}</items>" |
| 98 | f"{NS}<metadata>" |
| 99 | f"{NS}<source>mobile{NS}</source>" |
| 100 | f"{NS}<campaign>may-launch{NS}</campaign>" |
| 101 | f"{NS}</metadata>" |
| 102 | f"{NS}<duplicate_demo>" |
| 103 | f"{NS}<tag>a{NS}</tag>" |
| 104 | f"{NS}<tag>b{NS}</tag>" |
| 105 | f"{NS}</duplicate_demo>" |
| 106 | f"{NS}</invoke>\n" |
| 107 | f"{NS}</tool_call>" |
| 108 | ) |
| 109 | |
| 110 | |
| 111 | def build_order_invocation(user_id: int) -> str: |
| 112 | return ( |
| 113 | f'{NS}<invoke name="create_order">' |
| 114 | f"{NS}<user_id>{user_id}{NS}</user_id>" |
| 115 | f"{NS}</invoke>" |
| 116 | ) |
| 117 | |
| 118 | |
| 119 | def build_multiple_order_call() -> str: |
| 120 | return ( |
| 121 | f"{NS}<tool_call>\n" |
| 122 | f"{build_order_invocation(1)}\n" |
| 123 | f"{build_order_invocation(2)}\n" |
| 124 | f"{NS}</tool_call>" |
| 125 | ) |
| 126 | |
| 127 | |
| 128 | def _feed( |
| 129 | parser: MinimaxM3ToolParser, chunks: list[str | tuple[str, list[int]]] |
| 130 | ) -> list[DeltaMessage]: |
| 131 | previous = "" |
| 132 | results: list[DeltaMessage] = [] |
| 133 | for chunk in chunks: |
| 134 | if isinstance(chunk, tuple): |
| 135 | delta, delta_ids = chunk |
| 136 | else: |
| 137 | delta = chunk |
| 138 | delta_ids = [] |
| 139 | |
| 140 | current = previous + delta |
| 141 | result = parser.extract_tool_calls_streaming( |
| 142 | previous_text=previous, |
| 143 | current_text=current, |
| 144 | delta_text=delta, |
| 145 | previous_token_ids=[], |
| 146 | current_token_ids=[], |
| 147 | delta_token_ids=delta_ids, |
| 148 | request=None, |
| 149 | ) |
| 150 | if result is not None: |
| 151 | results.append(result) |
| 152 | previous = current |
| 153 | return results |
| 154 | |
| 155 | |
| 156 | def _collect_content(results: list[DeltaMessage]) -> str: |
| 157 | return "".join(result.content for result in results if result.content) |
| 158 | |
| 159 | |
| 160 | def _collect_tool_calls(results: list[DeltaMessage]) -> dict[int, dict[str, Any]]: |
| 161 | tool_calls: dict[int, dict[str, Any]] = {} |
| 162 | for result in results: |
| 163 | for tool_call in result.tool_calls or []: |
| 164 | tool_calls.setdefault( |
| 165 | tool_call.index, |
| 166 | {"id": None, "name": "", "arguments": ""}, |
| 167 | ) |
| 168 | if tool_call.id: |
| 169 | tool_calls[tool_call.index]["id"] = tool_call.id |
| 170 | if tool_call.function: |
| 171 | if tool_call.function.name: |
| 172 | tool_calls[tool_call.index]["name"] += tool_call.function.name |
| 173 | if tool_call.function.arguments: |
| 174 | tool_calls[tool_call.index]["arguments"] += ( |
| 175 | tool_call.function.arguments |
| 176 | ) |
| 177 | return tool_calls |
| 178 | |
| 179 | |
| 180 | def test_minimax_m3_parser_registered(): |
| 181 | assert ToolParserManager.get_tool_parser("minimax_m3") is MinimaxM3ToolParser |
| 182 | |
| 183 | |
| 184 | def test_non_streaming_nested_tool_call(parser): |
| 185 | result = parser.extract_tool_calls( |
| 186 | "I will create it.\n" + build_order_call(), |
| 187 | request=None, |
| 188 | ) |
| 189 | |
| 190 | assert result.tools_called |
| 191 | assert result.content == "I will create it.\n" |
| 192 | assert len(result.tool_calls) == 1 |
| 193 | tool_call = result.tool_calls[0] |
| 194 | assert tool_call.function.name == "create_order" |
| 195 | assert json.loads(tool_call.function.arguments) == { |
| 196 | "user_id": 42, |
| 197 | "urgent": True, |
| 198 | "note": "Please leave at front desk.", |
| 199 | "shipping": {"city": "Singapore", "zip": 18956}, |
| 200 | "items": [ |
| 201 | {"sku": "book-001", "qty": 2}, |
| 202 | {"sku": "pen-007", "qty": 5}, |
| 203 | ], |
| 204 | "metadata": { |
| 205 | "source": "mobile", |
| 206 | "campaign": "may-launch", |
| 207 | }, |
| 208 | "duplicate_demo": {"tag": ["a", "b"]}, |
| 209 | } |
| 210 | |
| 211 | |
| 212 | def test_non_streaming_without_tool_call_keeps_content(parser): |
| 213 | result = parser.extract_tool_calls("plain response", request=None) |
| 214 | |
| 215 | assert not result.tools_called |
| 216 | assert result.tool_calls == [] |
| 217 | assert result.content == "plain response" |
| 218 | |
| 219 | |
| 220 | def test_non_streaming_multiple_tool_calls(parser): |
| 221 | result = parser.extract_tool_calls(build_multiple_order_call(), request=None) |
| 222 | |
| 223 | assert result.tools_called |
| 224 | assert result.content is None |
| 225 | assert [tool_call.function.name for tool_call in result.tool_calls] == [ |
| 226 | "create_order", |
| 227 | "create_order", |
| 228 | ] |
| 229 | assert [ |
| 230 | json.loads(tool_call.function.arguments)["user_id"] |
| 231 | for tool_call in result.tool_calls |
| 232 | ] == [1, 2] |
| 233 | |
| 234 | |
| 235 | def test_streaming_without_tool_call_emits_text(parser): |
| 236 | results = _feed(parser, ["plain ", "response"]) |
| 237 | |
| 238 | assert _collect_content(results) == "plain response" |
| 239 | assert _collect_tool_calls(results) == {} |
| 240 | |
| 241 | |
| 242 | def test_streaming_nested_tool_call(parser): |
| 243 | tool_call_text = build_order_call() |
| 244 | results = _feed( |
| 245 | parser, |
| 246 | [ |
| 247 | "I will create it.\n", |
| 248 | tool_call_text[:5], |
| 249 | tool_call_text[5:17], |
| 250 | tool_call_text[17:120], |
| 251 | tool_call_text[120:], |
| 252 | ("", [EOS_ID]), |
| 253 | ], |
| 254 | ) |
| 255 | |
| 256 | assert _collect_content(results) == "I will create it.\n" |
| 257 | tool_calls = _collect_tool_calls(results) |
| 258 | assert len(tool_calls) == 1 |
| 259 | assert tool_calls[0]["name"] == "create_order" |
| 260 | assert tool_calls[0]["id"] is not None |
| 261 | assert json.loads(tool_calls[0]["arguments"]) == json.loads( |
| 262 | parser.streamed_args_for_tool[0] |
| 263 | ) |
| 264 | assert json.loads(parser.prev_tool_call_arr[0]["arguments"])["items"][1]["qty"] == 5 |
| 265 | assert results[-1].content is None |