main
py 275 lines 8.31 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5 from collections.abc import Iterable
6 from unittest.mock import MagicMock
7
8 import pytest
9
10 from vllm.entrypoints.generate.base.protocol import DeltaMessage
11 from vllm.entrypoints.openai.chat_completion.protocol import (
12 ChatCompletionRequest,
13 ChatCompletionToolsParam,
14 )
15 from vllm.tool_parsers import ToolParserManager
16 from vllm.tool_parsers.dots_tool_parser import DotsToolParser
17
18
19 def _tool(name: str, properties: dict) -> ChatCompletionToolsParam:
20 return ChatCompletionToolsParam(
21 type="function",
22 function={
23 "name": name,
24 "description": "test tool",
25 "parameters": {"type": "object", "properties": properties},
26 },
27 )
28
29
30 def _request(tools: list[ChatCompletionToolsParam]) -> ChatCompletionRequest:
31 return ChatCompletionRequest(
32 model="test-model",
33 messages=[{"role": "user", "content": "hi"}],
34 tools=tools,
35 tool_choice="auto",
36 )
37
38
39 def _stream(
40 parser: DotsToolParser,
41 chunks: Iterable[str],
42 request: ChatCompletionRequest,
43 ) -> list[DeltaMessage]:
44 previous_text = ""
45 messages: list[DeltaMessage] = []
46 for chunk in chunks:
47 current_text = previous_text + chunk
48 delta = parser.extract_tool_calls_streaming(
49 previous_text,
50 current_text,
51 chunk,
52 [],
53 [],
54 [],
55 request,
56 )
57 if delta is not None:
58 messages.append(delta)
59 previous_text = current_text
60 return messages
61
62
63 @pytest.fixture
64 def parser() -> DotsToolParser:
65 return DotsToolParser(MagicMock())
66
67
68 def test_registered_in_tool_parser_manager() -> None:
69 assert ToolParserManager.get_tool_parser("dots") is DotsToolParser
70
71
72 def test_non_stream_xml_converts_schema_types_and_resolves_ref(
73 parser: DotsToolParser,
74 ) -> None:
75 tool = ChatCompletionToolsParam(
76 type="function",
77 function={
78 "name": "set_location",
79 "description": "Set location",
80 "parameters": {
81 "type": "object",
82 "properties": {
83 "location": {"$ref": "#/$defs/Location"},
84 "days": {"type": "integer"},
85 "include_weather": {"type": "boolean"},
86 },
87 "$defs": {
88 "Location": {
89 "type": "object",
90 "properties": {"city": {"type": "string"}},
91 }
92 },
93 },
94 },
95 )
96 request = _request([tool])
97 text = (
98 "ok<dots_function_call>"
99 '<invoke name="set_location">'
100 '<parameter name="location">{"city": "Shanghai"}</parameter>'
101 '<parameter name="days">3</parameter>'
102 '<parameter name="include_weather">true</parameter>'
103 "</invoke>"
104 "</dots_function_call>"
105 )
106
107 result = parser.extract_tool_calls(text, request)
108
109 assert result.content == "ok"
110 assert result.tools_called
111 assert len(result.tool_calls) == 1
112 assert result.tool_calls[0].function.name == "set_location"
113 assert json.loads(result.tool_calls[0].function.arguments) == {
114 "location": {"city": "Shanghai"},
115 "days": 3,
116 "include_weather": True,
117 }
118
119
120 def test_non_stream_supports_multiple_invokes_and_json_fallback(
121 parser: DotsToolParser,
122 ) -> None:
123 tools = [
124 _tool("search", {"query": {"type": "string"}}),
125 _tool("open", {"id": {"type": "integer"}}),
126 ]
127 request = _request(tools)
128 text = (
129 "<dots_function_call>"
130 '<invoke name="search"><parameter name="query">chairs</parameter></invoke>'
131 '<invoke name="open"><parameter name="id">7</parameter></invoke>'
132 "</dots_function_call>"
133 '<dots_function_call>{"name":"search","arguments":{"query":"tables"}}'
134 "</dots_function_call>"
135 )
136
137 result = parser.extract_tool_calls(text, request)
138
139 assert [call.function.name for call in result.tool_calls] == [
140 "search",
141 "open",
142 "search",
143 ]
144 assert [json.loads(call.function.arguments) for call in result.tool_calls] == [
145 {"query": "chairs"},
146 {"id": 7},
147 {"query": "tables"},
148 ]
149
150
151 def test_non_stream_unknown_tool_is_left_as_content(parser: DotsToolParser) -> None:
152 request = _request([_tool("search", {"query": {"type": "string"}})])
153 text = (
154 "<dots_function_call>"
155 '<invoke name="ghost"><parameter name="query">chairs</parameter></invoke>'
156 "</dots_function_call>"
157 )
158
159 result = parser.extract_tool_calls(text, request)
160
161 assert not result.tools_called
162 assert result.tool_calls == []
163 assert result.content == text
164
165
166 def test_streaming_buffers_partial_marker_and_emits_all_complete_calls(
167 parser: DotsToolParser,
168 ) -> None:
169 request = _request([_tool("search", {"query": {"type": "string"}})])
170 chunks = [
171 "visible<dots_func",
172 (
173 "tion_call>"
174 '<invoke name="search"><parameter name="query">chairs</parameter></invoke>'
175 "</dots_function_call>"
176 "<dots_function_call>"
177 '<invoke name="search"><parameter name="query">tables</parameter></invoke>'
178 "</dots_function_call>"
179 ),
180 ]
181
182 messages = _stream(parser, chunks, request)
183
184 assert "".join(message.content or "" for message in messages) == "visible"
185 calls = [call for message in messages for call in message.tool_calls]
186 assert [call.index for call in calls] == [0, 1]
187 assert [json.loads(call.function.arguments or "") for call in calls] == [
188 {"query": "chairs"},
189 {"query": "tables"},
190 ]
191
192
193 def test_streaming_filters_unknown_tools_and_surfaces_content(
194 parser: DotsToolParser,
195 ) -> None:
196 request = _request([_tool("search", {"query": {"type": "string"}})])
197 text = (
198 "<dots_function_call>"
199 '<invoke name="ghost"><parameter name="query">chairs</parameter></invoke>'
200 "</dots_function_call>"
201 )
202
203 messages = _stream(parser, [text], request)
204
205 assert len(messages) == 1
206 assert messages[0].tool_calls == []
207 assert "ghost" in (messages[0].content or "")
208 assert parser._buffer == ""
209
210
211 def test_streaming_malformed_block_does_not_block_later_valid_call(
212 parser: DotsToolParser,
213 ) -> None:
214 request = _request([_tool("search", {"query": {"type": "string"}})])
215 messages = _stream(
216 parser,
217 [
218 "<dots_function_call>garbage</dots_function_call>",
219 (
220 "<dots_function_call>"
221 '<invoke name="search">'
222 '<parameter name="query">chairs</parameter>'
223 "</invoke></dots_function_call>"
224 ),
225 ],
226 request,
227 )
228
229 assert messages[0].content == "garbage"
230 assert messages[0].tool_calls == []
231 assert messages[1].tool_calls[0].function.name == "search"
232
233
234 def test_streaming_strips_stray_end_marker(parser: DotsToolParser) -> None:
235 request = _request([_tool("search", {"query": {"type": "string"}})])
236 messages = _stream(parser, ["some text </dots_function_call>"], request)
237
238 assert len(messages) == 1
239 assert messages[0].content == "some text "
240 assert messages[0].tool_calls == []
241
242
243 def test_streaming_flushes_partial_opening_marker_at_eof(
244 parser: DotsToolParser,
245 ) -> None:
246 request = _request([_tool("search", {"query": {"type": "string"}})])
247
248 messages = _stream(parser, ["answer <dots_func"], request)
249
250 assert messages[0].content == "answer "
251 assert parser.flush_pending_normal_text() == "<dots_func"
252 assert parser.flush_pending_normal_text() == ""
253
254
255 def test_streaming_emits_complete_json_before_end_marker_without_duplication(
256 parser: DotsToolParser,
257 ) -> None:
258 request = _request([_tool("search", {"query": {"type": "string"}})])
259
260 messages = _stream(
261 parser,
262 [
263 "<dots_function_call>",
264 '{"name":"search","arguments":{"query":"chairs"}}',
265 "</dots_function_call>",
266 ],
267 request,
268 )
269
270 calls = [call for message in messages for call in message.tool_calls]
271 assert [call.function.name for call in calls] == ["search", None]
272 assert "".join(call.function.arguments or "" for call in calls) == (
273 '{"query": "chairs"}'
274 )
275 assert messages[-1].tool_calls == calls