main
py 472 lines 15.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import datetime
5 import json
6
7 import jsonschema
8 import openai # use the official client for correctness check
9 import pytest
10 import pytest_asyncio
11
12 # downloading lora to test lora requests
13 from tests.utils import ROCM_EXTRA_ARGS, RemoteOpenAIServer
14
15 # any model with a chat template should work here
16 MODEL_NAME = "Qwen/Qwen3-0.6B"
17
18
19 tools = [
20 {
21 "type": "function",
22 "function": {
23 "name": "get_current_weather",
24 "description": "Get the current weather in a given location",
25 "parameters": {
26 "type": "object",
27 "strict": True,
28 "properties": {
29 "city": {
30 "type": "string",
31 "description": "The city to find the weather for, e.g. "
32 "'Vienna'",
33 "default": "Vienna",
34 },
35 "country": {
36 "type": "string",
37 "description": "The country that the city is in, e.g. "
38 "'Austria'",
39 },
40 "unit": {
41 "type": "string",
42 "description": "The unit to fetch the temperature in",
43 "enum": ["celsius", "fahrenheit"],
44 },
45 "options": {
46 "$ref": "#/$defs/WeatherOptions",
47 "description": "Optional parameters for weather query",
48 },
49 },
50 "required": ["country", "unit"],
51 "$defs": {
52 "WeatherOptions": {
53 "title": "WeatherOptions",
54 "type": "object",
55 "additionalProperties": False,
56 "properties": {
57 "unit": {
58 "type": "string",
59 "enum": ["celsius", "fahrenheit"],
60 "default": "celsius",
61 "description": "Temperature unit",
62 "title": "Temperature Unit",
63 },
64 "include_forecast": {
65 "type": "boolean",
66 "default": False,
67 "description": "Whether to include a 24-hour forecast",
68 "title": "Include Forecast",
69 },
70 "language": {
71 "type": "string",
72 "default": "zh-CN",
73 "description": "Language of the response",
74 "title": "Language",
75 "enum": ["zh-CN", "en-US", "ja-JP"],
76 },
77 },
78 },
79 },
80 },
81 },
82 },
83 {
84 "type": "function",
85 "function": {
86 "name": "get_forecast",
87 "description": "Get the weather forecast for a given location",
88 "parameters": {
89 "type": "object",
90 "properties": {
91 "city": {
92 "type": "string",
93 "description": "The city to get the forecast for, e.g. "
94 "'Vienna'",
95 "default": "Vienna",
96 },
97 "country": {
98 "type": "string",
99 "description": "The country that the city is in, e.g. "
100 "'Austria'",
101 },
102 "days": {
103 "type": "integer",
104 "description": "Number of days to get the forecast for (1-7)",
105 },
106 "unit": {
107 "type": "string",
108 "description": "The unit to fetch the temperature in",
109 "enum": ["celsius", "fahrenheit"],
110 },
111 },
112 "required": ["country", "days", "unit"],
113 },
114 },
115 },
116 ]
117
118 messages = [
119 {"role": "user", "content": "Hi! How are you doing today?"},
120 {"role": "assistant", "content": "I'm doing well! How can I help you?"},
121 {
122 "role": "user",
123 "content": "Can you tell me what the current weather is in Berlin and the "
124 "forecast for the next 5 days, in fahrenheit?",
125 },
126 ]
127
128
129 @pytest.fixture(scope="module")
130 def server():
131 args = [
132 # use half precision for speed and memory savings in CI environment
133 "--dtype",
134 "half",
135 "--enable-auto-tool-choice",
136 "--structured-outputs-config.backend",
137 "xgrammar",
138 "--tool-call-parser",
139 "hermes",
140 "--reasoning-parser",
141 "qwen3",
142 "--gpu-memory-utilization",
143 "0.4",
144 "--enforce-eager",
145 ] + ROCM_EXTRA_ARGS
146
147 with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
148 yield remote_server
149
150
151 @pytest_asyncio.fixture
152 async def client(server):
153 async with server.get_async_client() as async_client:
154 yield async_client
155
156
157 @pytest.mark.asyncio
158 @pytest.mark.parametrize("model_name", [MODEL_NAME])
159 @pytest.mark.parametrize("stream", [True, False])
160 @pytest.mark.parametrize(
161 "tool_choice",
162 [
163 "auto",
164 "required",
165 {"type": "function", "function": {"name": "get_current_weather"}},
166 ],
167 )
168 @pytest.mark.parametrize("enable_thinking", [True, False])
169 async def test_function_tool_use(
170 client: openai.AsyncOpenAI,
171 model_name: str,
172 stream: bool,
173 tool_choice: str | dict,
174 enable_thinking: bool,
175 ):
176 if not stream:
177 # Non-streaming test
178 chat_completion = await client.chat.completions.create(
179 messages=messages,
180 model=model_name,
181 tools=tools,
182 tool_choice=tool_choice,
183 extra_body={"chat_template_kwargs": {"enable_thinking": enable_thinking}},
184 )
185 if enable_thinking:
186 assert chat_completion.choices[0].message.reasoning is not None
187 assert chat_completion.choices[0].message.reasoning != ""
188 assert chat_completion.choices[0].message.tool_calls is not None
189 assert len(chat_completion.choices[0].message.tool_calls) > 0
190 else:
191 # Streaming test
192 output_stream = await client.chat.completions.create(
193 messages=messages,
194 model=model_name,
195 tools=tools,
196 tool_choice=tool_choice,
197 stream=True,
198 extra_body={"chat_template_kwargs": {"enable_thinking": enable_thinking}},
199 )
200
201 output = []
202 reasoning = []
203 async for chunk in output_stream:
204 if chunk.choices:
205 if enable_thinking and getattr(
206 chunk.choices[0].delta, "reasoning", None
207 ):
208 reasoning.append(chunk.choices[0].delta.reasoning)
209 if chunk.choices[0].delta.tool_calls:
210 output.extend(chunk.choices[0].delta.tool_calls)
211
212 assert len(output) > 0
213 if enable_thinking:
214 assert len(reasoning) > 0
215
216
217 @pytest.mark.asyncio
218 @pytest.mark.parametrize("model_name", [MODEL_NAME])
219 @pytest.mark.parametrize("arguments", ["{}", ""])
220 async def test_no_args_tool_call(
221 client: openai.AsyncOpenAI, model_name: str, arguments: str
222 ):
223 # Step 1: Define a tool that requires no parameters
224 tools = [
225 {
226 "type": "function",
227 "function": {
228 "name": "get_current_time",
229 "description": (
230 "Get the current date and time. Call this when the user "
231 "asks what time or date it is. No parameters needed."
232 ),
233 "parameters": {
234 "type": "object",
235 "properties": {}, # No parameters
236 "required": [], # No required fields
237 },
238 },
239 }
240 ]
241 messages = [
242 {
243 "role": "system",
244 "content": (
245 "You are a helpful assistant. Always use the available tools "
246 "when relevant, and reply with a short sentence after "
247 "receiving a tool result."
248 ),
249 },
250 {"role": "user", "content": "What time is it now?"},
251 ]
252
253 shared_kwargs = dict(
254 model=model_name,
255 temperature=0.0,
256 seed=42,
257 extra_body={"chat_template_kwargs": {"enable_thinking": False}},
258 )
259
260 # Step 2: Send user message and let model decide whether to call the tool
261 response = await client.chat.completions.create(
262 **shared_kwargs,
263 messages=messages,
264 tools=tools,
265 tool_choice="auto", # Let model choose automatically
266 )
267
268 # Step 3: Check if model wants to call a tool
269 message = response.choices[0].message
270 if message.tool_calls:
271 # Get the first tool call
272 tool_call = message.tool_calls[0]
273 tool_name = tool_call.function.name
274 # Step 4: Execute the tool locally (no parameters)
275 if tool_name == "get_current_time":
276 # Test both empty string and "{}" for no-arg tool calls
277 tool_call.function.arguments = arguments
278 messages.append(message)
279 current_time = datetime.datetime.now()
280 result = current_time.isoformat()
281 messages.append(
282 {
283 "role": "tool",
284 "tool_call_id": tool_call.id,
285 "content": result,
286 }
287 )
288 # Step 5: Send tool result back to model to continue conversation
289 final_response = await client.chat.completions.create(
290 **shared_kwargs,
291 messages=messages,
292 max_completion_tokens=128,
293 )
294 # Output final natural language response
295 assert (
296 final_response.choices[0].message.content is not None
297 and final_response.choices[0].message.content.strip() != ""
298 )
299
300 else:
301 # No tool called — just print model's direct reply
302 assert message.content is not None
303
304
305 @pytest.mark.asyncio
306 async def test_named_tool_use(
307 client: openai.AsyncOpenAI,
308 sample_json_schema,
309 ):
310 messages = [
311 {"role": "system", "content": "you are a helpful assistant"},
312 {
313 "role": "user",
314 "content": (
315 "Give an example JSON for an employee profile using the specified tool."
316 ),
317 },
318 ]
319 tools = [
320 {
321 "type": "function",
322 "function": {
323 "name": "dummy_function_name",
324 "description": "This is a dummy function",
325 "parameters": sample_json_schema,
326 },
327 }
328 ]
329 tool_choice = {"type": "function", "function": {"name": "dummy_function_name"}}
330
331 # non-streaming
332
333 chat_completion = await client.chat.completions.create(
334 model=MODEL_NAME,
335 messages=messages,
336 max_completion_tokens=1000,
337 tools=tools,
338 temperature=0.0,
339 tool_choice=tool_choice,
340 )
341 message = chat_completion.choices[0].message
342 assert len(message.content) == 0
343 json_string = message.tool_calls[0].function.arguments
344 json1 = json.loads(json_string)
345 jsonschema.validate(instance=json1, schema=sample_json_schema)
346
347 messages.append({"role": "assistant", "content": json_string})
348 messages.append(
349 {"role": "user", "content": "Give me another one with a different name and age"}
350 )
351
352 # streaming
353
354 stream = await client.chat.completions.create(
355 model=MODEL_NAME,
356 messages=messages,
357 max_completion_tokens=1000,
358 tools=tools,
359 tool_choice=tool_choice,
360 temperature=0.0,
361 stream=True,
362 )
363
364 output = []
365 finish_reason_count = 0
366 async for chunk in stream:
367 delta = chunk.choices[0].delta
368 if delta.role:
369 assert delta.role == "assistant"
370 assert delta.content is None or len(delta.content) == 0
371 if delta.tool_calls and delta.tool_calls[0].function.arguments:
372 output.append(delta.tool_calls[0].function.arguments)
373 if chunk.choices[0].finish_reason is not None:
374 finish_reason_count += 1
375 # finish reason should only return in last block
376 assert finish_reason_count == 1
377 json2 = json.loads("".join(output))
378 jsonschema.validate(instance=json2, schema=sample_json_schema)
379 assert json1["name"] != json2["name"]
380 assert json1["age"] != json2["age"]
381
382
383 @pytest.mark.asyncio
384 async def test_inconsistent_tool_choice_and_tools(
385 client: openai.AsyncOpenAI, sample_json_schema
386 ):
387 messages = [
388 {"role": "system", "content": "you are a helpful assistant"},
389 {
390 "role": "user",
391 "content": f"Give an example JSON for an employee profile that "
392 f"fits this schema: {sample_json_schema}",
393 },
394 ]
395
396 with pytest.raises(openai.BadRequestError):
397 await client.chat.completions.create(
398 model=MODEL_NAME,
399 messages=messages,
400 max_completion_tokens=1000,
401 tool_choice={
402 "type": "function",
403 "function": {"name": "dummy_function_name"},
404 },
405 )
406
407 with pytest.raises(openai.BadRequestError):
408 await client.chat.completions.create(
409 model=MODEL_NAME,
410 messages=messages,
411 max_completion_tokens=1000,
412 tools=[
413 {
414 "type": "function",
415 "function": {
416 "name": "dummy_function_name",
417 "description": "This is a dummy function",
418 "parameters": sample_json_schema,
419 },
420 }
421 ],
422 tool_choice={
423 "type": "function",
424 "function": {"name": "nondefined_function_name"},
425 },
426 )
427 with pytest.raises(openai.BadRequestError):
428 await client.chat.completions.create(
429 model=MODEL_NAME,
430 messages=messages,
431 max_completion_tokens=1000,
432 tools=[
433 {
434 "type": "function",
435 "function": {
436 "name": "dummy_function_name",
437 "description": "This is a dummy function",
438 "parameters": sample_json_schema,
439 },
440 }
441 ],
442 tool_choice={},
443 )
444
445
446 @pytest.mark.asyncio
447 @pytest.mark.parametrize(
448 "tool_choice",
449 ["required", {"type": "function", "function": {"name": "get_current_weather"}}],
450 )
451 async def test_max_tokens_with_tool_choice_required(
452 client: openai.AsyncOpenAI, tool_choice
453 ):
454 """Requesting a single token with a required tool choice must not crash."""
455 models = await client.models.list()
456 model_name: str = models.data[0].id
457
458 # This combination previously crashed the engine
459 chat_completion = await client.chat.completions.create(
460 messages=messages,
461 temperature=0,
462 max_completion_tokens=1,
463 model=model_name,
464 tools=tools,
465 tool_choice=tool_choice,
466 )
467 # When `tool_choice="required"` and the tokens of `tools` exceed `max_tokens`,
468 # `tool_calls` should be absent and `content` should be empty.
469 # This behavior should be consistent with OpenAI.
470 choice = chat_completion.choices[0]
471 assert choice.finish_reason == "length"
472 assert choice.message.tool_calls is None