main
py 294 lines 9.71 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import json
5
6 import openai
7 import pytest
8
9 from .utils import (
10 MESSAGES_ASKING_FOR_PARALLEL_TOOLS,
11 MESSAGES_WITH_PARALLEL_TOOL_RESPONSE,
12 SEARCH_TOOL,
13 SEED,
14 WEATHER_TOOL,
15 ServerConfig,
16 ensure_system_prompt,
17 )
18
19
20 def apply_parallel_tool_system_prompt(
21 messages,
22 server_config: ServerConfig,
23 ):
24 if server_config["model"] == "ibm-granite/granite-3.0-8b-instruct":
25 return ensure_system_prompt(messages, server_config)
26 return messages
27
28
29 # test: getting the model to generate parallel tool calls (streaming/not)
30 # when requested. NOTE that not all models may support this, so some exclusions
31 # may be added in the future. e.g. llama 3.1 models are not designed to support
32 # parallel tool calls.
33 @pytest.mark.asyncio
34 async def test_parallel_tool_calls(
35 client: openai.AsyncOpenAI, server_config: ServerConfig
36 ):
37 if not server_config.get("supports_parallel", True):
38 pytest.skip(
39 "The {} model doesn't support parallel tool calls".format(
40 server_config["model"]
41 )
42 )
43
44 models = await client.models.list()
45 model_name: str = models.data[0].id
46 messages = apply_parallel_tool_system_prompt(
47 MESSAGES_ASKING_FOR_PARALLEL_TOOLS, server_config
48 )
49 chat_completion = await client.chat.completions.create(
50 messages=messages,
51 temperature=0,
52 max_completion_tokens=200,
53 model=model_name,
54 tools=[WEATHER_TOOL, SEARCH_TOOL],
55 logprobs=False,
56 seed=SEED,
57 )
58
59 choice = chat_completion.choices[0]
60 stop_reason = chat_completion.choices[0].finish_reason
61 non_streamed_tool_calls = chat_completion.choices[0].message.tool_calls
62
63 # make sure 2 tool calls are present
64 assert choice.message.role == "assistant"
65 assert non_streamed_tool_calls is not None
66 assert len(non_streamed_tool_calls) == 2
67
68 for tool_call in non_streamed_tool_calls:
69 # make sure the tool includes a function and ID
70 assert tool_call.type == "function"
71 assert tool_call.function is not None
72 assert isinstance(tool_call.id, str)
73 assert len(tool_call.id) >= 9
74
75 # make sure the weather tool was called correctly
76 assert tool_call.function.name == WEATHER_TOOL["function"]["name"]
77 assert isinstance(tool_call.function.arguments, str)
78
79 parsed_arguments = json.loads(tool_call.function.arguments)
80 assert isinstance(parsed_arguments, dict)
81 assert isinstance(parsed_arguments.get("city"), str)
82 assert isinstance(parsed_arguments.get("state"), str)
83
84 assert stop_reason == "tool_calls"
85
86 # make the same request, streaming
87 stream = await client.chat.completions.create(
88 model=model_name,
89 messages=messages,
90 temperature=0,
91 max_completion_tokens=200,
92 tools=[WEATHER_TOOL, SEARCH_TOOL],
93 logprobs=False,
94 seed=SEED,
95 stream=True,
96 )
97
98 role_name: str | None = None
99 finish_reason_count: int = 0
100
101 tool_call_names: list[str] = []
102 tool_call_args: list[str] = []
103 tool_call_idx: int = -1
104 tool_call_id_count: int = 0
105
106 async for chunk in stream:
107 # if there's a finish reason make sure it's tools
108 if chunk.choices[0].finish_reason:
109 finish_reason_count += 1
110 assert chunk.choices[0].finish_reason == "tool_calls"
111
112 # if a role is being streamed make sure it wasn't already set to
113 # something else
114 if chunk.choices[0].delta.role:
115 assert not role_name or role_name == "assistant"
116 role_name = "assistant"
117
118 # a chunk may carry >1 tool-call delta at a parallel-call boundary
119 streamed_tool_calls = chunk.choices[0].delta.tool_calls
120
121 for tool_call in streamed_tool_calls or []:
122 # deltas arrive in non-decreasing index order
123 assert tool_call.index >= tool_call_idx
124
125 # if a new tool is being called, set up empty arguments
126 if tool_call.index != tool_call_idx:
127 tool_call_idx = tool_call.index
128 tool_call_args.append("")
129
130 # if a tool call ID is streamed, make sure one hasn't been already
131 if tool_call.id:
132 tool_call_id_count += 1
133 assert isinstance(tool_call.id, str) and (len(tool_call.id) >= 9)
134
135 # if parts of the function start being streamed
136 if tool_call.function:
137 # if the function name is defined, set it. it should be streamed
138 # IN ENTIRETY, exactly one time.
139 if tool_call.function.name:
140 assert isinstance(tool_call.function.name, str)
141 tool_call_names.append(tool_call.function.name)
142
143 if tool_call.function.arguments:
144 # make sure they're a string and then add them to the list
145 assert isinstance(tool_call.function.arguments, str)
146
147 tool_call_args[tool_call.index] += tool_call.function.arguments
148
149 assert finish_reason_count == 1
150 assert role_name == "assistant"
151
152 assert len(non_streamed_tool_calls) == len(tool_call_names) == len(tool_call_args)
153
154 for i in range(2):
155 assert non_streamed_tool_calls[i].function.name == tool_call_names[i]
156 streamed_args = json.loads(tool_call_args[i])
157 non_streamed_args = json.loads(non_streamed_tool_calls[i].function.arguments)
158 assert streamed_args == non_streamed_args
159
160
161 # test: providing parallel tool calls back to the model to get a response
162 # (streaming/not)
163 @pytest.mark.asyncio
164 async def test_parallel_tool_calls_with_results(
165 client: openai.AsyncOpenAI, server_config: ServerConfig
166 ):
167 if not server_config.get("supports_parallel", True):
168 pytest.skip(
169 "The {} model doesn't support parallel tool calls".format(
170 server_config["model"]
171 )
172 )
173
174 models = await client.models.list()
175 model_name: str = models.data[0].id
176 messages = apply_parallel_tool_system_prompt(
177 MESSAGES_WITH_PARALLEL_TOOL_RESPONSE, server_config
178 )
179 chat_completion = await client.chat.completions.create(
180 messages=messages,
181 temperature=0,
182 max_completion_tokens=200,
183 model=model_name,
184 tools=[WEATHER_TOOL, SEARCH_TOOL],
185 logprobs=False,
186 seed=SEED,
187 )
188
189 choice = chat_completion.choices[0]
190
191 assert choice.finish_reason != "tool_calls" # "stop" or "length"
192 assert choice.message.role == "assistant"
193 assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
194 assert choice.message.content is not None
195 assert "98" in choice.message.content # Dallas temp in tool response
196 assert "78" in choice.message.content # Orlando temp in tool response
197
198 stream = await client.chat.completions.create(
199 messages=messages,
200 temperature=0,
201 max_completion_tokens=200,
202 model=model_name,
203 tools=[WEATHER_TOOL, SEARCH_TOOL],
204 logprobs=False,
205 seed=SEED,
206 stream=True,
207 )
208
209 chunks: list[str] = []
210 finish_reason_count = 0
211 role_sent: bool = False
212
213 async for chunk in stream:
214 delta = chunk.choices[0].delta
215
216 if delta.role:
217 assert not role_sent
218 assert delta.role == "assistant"
219 role_sent = True
220
221 if delta.content:
222 chunks.append(delta.content)
223
224 if chunk.choices[0].finish_reason is not None:
225 finish_reason_count += 1
226 assert chunk.choices[0].finish_reason == choice.finish_reason
227
228 assert not delta.tool_calls or len(delta.tool_calls) == 0
229
230 assert role_sent
231 assert finish_reason_count == 1
232 assert len(chunks)
233 assert "".join(chunks) == choice.message.content
234
235
236 @pytest.mark.asyncio
237 async def test_parallel_tool_calls_false(
238 client: openai.AsyncOpenAI, server_config: ServerConfig
239 ):
240 """Ensure only one tool call is returned when parallel_tool_calls is False."""
241 models = await client.models.list()
242 model_name: str = models.data[0].id
243 messages = apply_parallel_tool_system_prompt(
244 MESSAGES_ASKING_FOR_PARALLEL_TOOLS, server_config
245 )
246 chat_completion = await client.chat.completions.create(
247 messages=messages,
248 temperature=0,
249 max_completion_tokens=200,
250 model=model_name,
251 tools=[WEATHER_TOOL, SEARCH_TOOL],
252 logprobs=False,
253 seed=SEED,
254 parallel_tool_calls=False,
255 )
256
257 stop_reason = chat_completion.choices[0].finish_reason
258 non_streamed_tool_calls = chat_completion.choices[0].message.tool_calls
259
260 # make sure only 1 tool call is present
261 assert len(non_streamed_tool_calls) == 1
262 assert stop_reason == "tool_calls"
263
264 # make the same request, streaming
265 stream = await client.chat.completions.create(
266 model=model_name,
267 messages=messages,
268 temperature=0,
269 max_completion_tokens=200,
270 tools=[WEATHER_TOOL, SEARCH_TOOL],
271 logprobs=False,
272 seed=SEED,
273 parallel_tool_calls=False,
274 stream=True,
275 )
276
277 finish_reason_count: int = 0
278 tool_call_id_count: int = 0
279
280 async for chunk in stream:
281 # if there's a finish reason make sure it's tools
282 if chunk.choices[0].finish_reason:
283 finish_reason_count += 1
284 assert chunk.choices[0].finish_reason == "tool_calls"
285
286 streamed_tool_calls = chunk.choices[0].delta.tool_calls
287 if streamed_tool_calls and len(streamed_tool_calls) > 0:
288 tool_call = streamed_tool_calls[0]
289 if tool_call.id:
290 tool_call_id_count += 1
291
292 # make sure only 1 streaming tool call is present
293 assert tool_call_id_count == 1
294 assert finish_reason_count == 1