main
py 527 lines 18.1 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 # use the official client for correctness check
7 import pytest
8
9 MODEL_NAME = "Qwen/Qwen3-1.7B"
10 tools = [
11 {
12 "type": "function",
13 "name": "get_current_weather",
14 "description": "Get the current weather in a given location",
15 "parameters": {
16 "type": "object",
17 "properties": {
18 "city": {
19 "type": "string",
20 "description": "The city to find the weather for, e.g. 'Vienna'",
21 "default": "Vienna",
22 },
23 "country": {
24 "type": "string",
25 "description": "The country that the city is in, e.g. 'Austria'",
26 },
27 "unit": {
28 "type": "string",
29 "description": "The unit to fetch the temperature in",
30 "enum": ["celsius", "fahrenheit"],
31 },
32 "options": {
33 "$ref": "#/$defs/WeatherOptions",
34 "description": "Optional parameters for weather query",
35 },
36 },
37 "required": ["country", "unit"],
38 "$defs": {
39 "WeatherOptions": {
40 "title": "WeatherOptions",
41 "type": "object",
42 "additionalProperties": False,
43 "properties": {
44 "unit": {
45 "type": "string",
46 "enum": ["celsius", "fahrenheit"],
47 "default": "celsius",
48 "description": "Temperature unit",
49 "title": "Temperature Unit",
50 },
51 "include_forecast": {
52 "type": "boolean",
53 "default": False,
54 "description": "Whether to include a 24-hour forecast",
55 "title": "Include Forecast",
56 },
57 "language": {
58 "type": "string",
59 "default": "zh-CN",
60 "description": "Language of the response",
61 "title": "Language",
62 "enum": ["zh-CN", "en-US", "ja-JP"],
63 },
64 },
65 },
66 },
67 },
68 },
69 {
70 "type": "function",
71 "name": "get_forecast",
72 "description": "Get the weather forecast for a given location",
73 "parameters": {
74 "type": "object",
75 "properties": {
76 "city": {
77 "type": "string",
78 "description": "The city to get the forecast for, e.g. 'Vienna'",
79 "default": "Vienna",
80 },
81 "country": {
82 "type": "string",
83 "description": "The country that the city is in, e.g. 'Austria'",
84 },
85 "days": {
86 "type": "integer",
87 "description": "Number of days to get the forecast for (1-7)",
88 },
89 "unit": {
90 "type": "string",
91 "description": "The unit to fetch the temperature in",
92 "enum": ["celsius", "fahrenheit"],
93 },
94 },
95 "required": ["country", "days", "unit"],
96 },
97 },
98 ]
99
100
101 @pytest.mark.asyncio
102 @pytest.mark.parametrize("model_name", [MODEL_NAME])
103 @pytest.mark.parametrize("tool_choice", ["auto", "required"])
104 async def test_function_tool_use(
105 client: openai.AsyncOpenAI, model_name: str, tool_choice: str
106 ):
107 prompt = [
108 {
109 "role": "user",
110 "content": "Can you tell me what the current weather is in Berlin and the "
111 "forecast for the next 5 days, in fahrenheit?",
112 },
113 ]
114 response = await client.responses.create(
115 model=model_name,
116 input=prompt,
117 tools=tools,
118 tool_choice=tool_choice,
119 temperature=0.0,
120 )
121 assert len(response.output) >= 1
122 tool_call = None
123 reasoning = None
124 for out in response.output:
125 if out.type == "function_call":
126 tool_call = out
127 if out.type == "reasoning":
128 reasoning = out
129 if response.incomplete_details is None:
130 assert tool_call is not None
131 assert tool_call.type == "function_call"
132 assert json.loads(tool_call.arguments) is not None
133 assert reasoning is not None
134 assert reasoning.type == "reasoning"
135 else:
136 print(response.model_dump_json(indent=2))
137 assert response.incomplete_details.reason == "max_output_tokens"
138
139
140 @pytest.mark.asyncio
141 @pytest.mark.parametrize("model_name", [MODEL_NAME])
142 async def test_max_tokens_with_tool_choice_required(
143 client: openai.AsyncOpenAI, model_name: str
144 ):
145 prompt = [
146 {
147 "role": "user",
148 "content": "Can you tell me what the current weather is in Berlin and the "
149 "forecast for the next 5 days, in fahrenheit?",
150 },
151 ]
152 response = await client.responses.create(
153 model=model_name,
154 input=prompt,
155 tools=tools,
156 tool_choice="required",
157 max_output_tokens=10,
158 )
159 assert len(response.output) >= 1
160 for out in response.output:
161 # When `tool_choice="required"` and the tokens of `tools`
162 # exceed `max_output_tokens`,`function_call` should be empty.
163 # This behavior should be consistent with OpenAI
164 assert out.type != "function_call"
165 assert response.incomplete_details.reason == "max_output_tokens"
166
167
168 @pytest.mark.asyncio
169 async def test_named_tool_use(client: openai.AsyncOpenAI):
170 def get_weather(latitude: float, longitude: float) -> str:
171 """Mock function to simulate getting weather data.
172 In a real application, this would call an external weather API.
173 """
174 return f"Current temperature at ({latitude}, {longitude}) is 20°C."
175
176 tools = [
177 {
178 "type": "function",
179 "name": "get_weather",
180 "description": (
181 "Get current temperature for provided coordinates in celsius."
182 ),
183 "parameters": {
184 "type": "object",
185 "properties": {
186 "latitude": {"type": "number"},
187 "longitude": {"type": "number"},
188 },
189 "required": ["latitude", "longitude"],
190 "additionalProperties": False,
191 },
192 "strict": True,
193 }
194 ]
195
196 input_messages = [
197 {"role": "user", "content": "What's the weather like in Paris today?"}
198 ]
199
200 response = await client.responses.create(
201 model=MODEL_NAME,
202 input=input_messages,
203 tools=tools,
204 tool_choice={"type": "function", "name": "get_weather"},
205 )
206 assert len(response.output) >= 1
207 for out in response.output:
208 if out.type == "function_call":
209 tool_call = out
210 assert tool_call is not None
211 assert tool_call.type == "function_call"
212 assert tool_call.name == "get_weather"
213 args = json.loads(tool_call.arguments)
214 assert args["latitude"] is not None
215 assert args["longitude"] is not None
216 # call the tool
217 result = get_weather(args["latitude"], args["longitude"])
218 input_messages.append(tool_call) # append model's function call message
219 input_messages.append(
220 { # append result message
221 "type": "function_call_output",
222 "call_id": tool_call.call_id,
223 "output": str(result),
224 }
225 )
226 # create a new response with the tool call result
227 response_2 = await client.responses.create(model=MODEL_NAME, input=input_messages)
228 # check the output
229 assert len(response_2.output_text) > 0
230
231
232 @pytest.mark.asyncio
233 @pytest.mark.parametrize("model_name", [MODEL_NAME])
234 async def test_function_calling_with_streaming_expected_arguments(
235 client: openai.AsyncOpenAI, model_name: str
236 ):
237 tools = [
238 {
239 "type": "function",
240 "name": "get_weather",
241 "description": "Get current temperature for provided location in celsius.",
242 "parameters": {
243 "type": "object",
244 "properties": {
245 "location": {"type": "string"},
246 },
247 "required": ["location"],
248 "additionalProperties": False,
249 },
250 "strict": True,
251 },
252 {
253 "type": "function",
254 "name": "get_time",
255 "description": "Get current local time for provided location.",
256 "parameters": {
257 "type": "object",
258 "properties": {
259 "location": {"type": "string"},
260 },
261 "required": ["location"],
262 "additionalProperties": False,
263 },
264 "strict": True,
265 },
266 ]
267
268 stream_response = await client.responses.create(
269 model=model_name,
270 input=(
271 "Use tools only. Call get_weather for Berlin and get_time for Tokyo. "
272 "Do not answer directly."
273 ),
274 tools=tools,
275 stream=True,
276 )
277
278 tool_call_items = {}
279 arguments_done_events = {}
280 completed_events = {}
281 async for event in stream_response:
282 if (
283 event.type == "response.output_item.added"
284 and event.item.type == "function_call"
285 ):
286 tool_call_items[event.output_index] = event.item
287 elif event.type == "response.function_call_arguments.delta":
288 tool_call_item = tool_call_items[event.output_index]
289 tool_call_item.arguments += event.delta
290 elif event.type == "response.function_call_arguments.done":
291 arguments_done_events[event.output_index] = event
292 elif (
293 event.type == "response.output_item.done"
294 and event.item.type == "function_call"
295 ):
296 completed_events[event.output_index] = event
297 assert len(tool_call_items) >= 2
298 assert len(arguments_done_events) >= 2
299 assert len(completed_events) >= 2
300
301 tool_calls_by_name = {
302 event.item.name: (
303 tool_call_items[output_index],
304 arguments_done_events[output_index],
305 event.item,
306 )
307 for output_index, event in completed_events.items()
308 }
309 assert {"get_weather", "get_time"}.issubset(tool_calls_by_name)
310 for added_item, arguments_done_event, completed_item in tool_calls_by_name.values():
311 assert added_item.type == "function_call"
312 assert added_item.arguments == arguments_done_event.arguments
313 assert added_item.arguments == completed_item.arguments
314 assert added_item.name == arguments_done_event.name
315 assert added_item.name == completed_item.name
316 args = json.loads(added_item.arguments)
317 assert "location" in args
318 assert args["location"] is not None
319
320
321 @pytest.mark.asyncio
322 @pytest.mark.parametrize("model_name", [MODEL_NAME])
323 @pytest.mark.parametrize(
324 "tool_choice",
325 ["auto", "required", {"type": "function", "name": "get_current_weather"}],
326 )
327 @pytest.mark.parametrize(
328 "enable_thinking",
329 [True, False],
330 )
331 async def test_function_calling_with_streaming_types(
332 client: openai.AsyncOpenAI, model_name: str, tool_choice, enable_thinking: bool
333 ):
334 # this links the "done" type with the "start" type
335 # so every "done" type should have a corresponding "start" type
336 # and every open block should be closed by the end of the stream
337 #
338 # stream of events for a response with function call could look like this:
339 # option1: reasoning -> content(option) -> function_call
340 # response.created
341 # -> response.in_progress
342 # -> response.output_item.added
343 # -> response.content_part.added
344 # -> response.reasoning_text.delta
345 # ....
346 # -> response.reasoning_text.delta
347 # -> response.reasoning_text.done
348 # -> response.content_part.done
349 # -> response.output_item.done
350 # -> response.output_item.added
351 # -> response.content_part.added
352 # -> response.output_text.delta
353 # ...
354 # -> response.output_text.delta
355 # -> response.output_text.done
356 # -> response.content_part.done
357 # -> response.output_item.done
358 # -> response.output_item.added
359 # -> response.function_call_arguments.delta
360 # ...
361 # -> response.function_call_arguments.delta
362 # -> response.function_call_arguments.done
363 # -> response.output_item.done
364 # -> response.completed
365 #
366 #
367 # option2: reasoning -> content
368 # response.created
369 # -> response.in_progress
370 # -> response.output_item.added
371 # -> response.content_part.added
372 # -> response.reasoning_text.delta
373 # ....
374 # -> response.reasoning_text.delta
375 # -> response.reasoning_text.done
376 # -> response.content_part.done
377 # -> response.output_item.done
378 # -> response.output_item.added
379 # -> response.content_part.added
380 # -> response.output_text.delta
381 # ..
382 # -> response.output_text.delta
383 # -> response.output_text.done
384 # -> response.content_part.done
385 # -> response.output_item.done
386 # -> response.completed
387 #
388 # option3: content
389 #
390 # response.created
391 # -> response.in_progress
392 # -> response.output_item.added
393 # -> response.content_part.added
394 # -> response.output_text.delta
395 # ...
396 # -> response.output_text.delta
397 # -> response.output_text.done
398 # -> response.content_part.done
399 # -> response.output_item.done
400 # -> response.completed
401 #
402 # option4: content -> function_call
403 # response.created
404 # -> response.in_progress
405 # -> response.output_item.added
406 # -> response.content_part.added
407 # -> response.output_text.delta
408 # ...
409 # -> response.output_text.delta
410 # -> response.output_text.done
411 # -> response.content_part.done
412 # -> response.output_item.done
413 # -> response.output_item.added
414 # -> response.function_call_arguments.delta
415 # ...
416 # -> response.function_call_arguments.delta
417 # -> response.function_call_arguments.done
418 # -> response.output_item.done
419 # -> response.completed
420
421 pairs_of_event_types = {
422 "response.completed": "response.created",
423 "response.output_item.done": "response.output_item.added",
424 "response.output_text.done": "response.output_text.delta",
425 "response.content_part.done": "response.content_part.added",
426 "response.reasoning_text.done": "response.reasoning_text.delta",
427 "response.function_call_arguments.done": "response.function_call_arguments.delta", # noqa
428 }
429
430 input_list = [
431 {
432 "role": "user",
433 "content": "Can you tell me what the current weather is in Berlin?",
434 }
435 ]
436 stream_response = await client.responses.create(
437 model=model_name,
438 input=input_list,
439 tools=tools,
440 tool_choice=tool_choice,
441 extra_body={"chat_template_kwargs": {"enable_thinking": enable_thinking}},
442 stream=True,
443 )
444
445 stack_of_event_types = []
446 async for event in stream_response:
447 if event.type == "response.created":
448 stack_of_event_types.append(event.type)
449 elif event.type == "response.completed":
450 assert stack_of_event_types[-1] == pairs_of_event_types[event.type]
451 stack_of_event_types.pop()
452 if event.type.endswith("added"):
453 stack_of_event_types.append(event.type)
454 elif event.type.endswith("delta"):
455 if stack_of_event_types[-1] == event.type:
456 continue
457 stack_of_event_types.append(event.type)
458 elif event.type.endswith("done"):
459 assert stack_of_event_types[-1] == pairs_of_event_types[event.type]
460 stack_of_event_types.pop()
461 assert len(stack_of_event_types) == 0
462
463
464 @pytest.mark.asyncio
465 @pytest.mark.parametrize("model_name", [MODEL_NAME])
466 @pytest.mark.parametrize(
467 "tool_choice",
468 ["required", "auto", {"type": "function", "name": "get_weather"}],
469 )
470 async def test_function_calling_with_streaming_forced_tool_choice(
471 client: openai.AsyncOpenAI, model_name: str, tool_choice: str
472 ):
473 tools = [
474 {
475 "type": "function",
476 "name": "get_weather",
477 "description": "Get current temperature for provided location in celsius.",
478 "parameters": {
479 "type": "object",
480 "properties": {
481 "location": {"type": "string"},
482 },
483 "required": ["location"],
484 "additionalProperties": False,
485 },
486 "strict": True,
487 }
488 ]
489
490 stream_response = await client.responses.create(
491 model=model_name,
492 input="Call the get_weather function for Berlin and do not answer directly.",
493 tools=tools,
494 tool_choice=tool_choice,
495 stream=True,
496 )
497
498 tool_call_item = None
499 completed_event = None
500 text_deltas = []
501 async for event in stream_response:
502 if (
503 event.type == "response.output_item.added"
504 and event.item.type == "function_call"
505 ):
506 tool_call_item = event.item
507 elif event.type == "response.output_text.delta":
508 text_deltas.append(event.delta)
509 elif event.type == "response.function_call_arguments.delta" and tool_call_item:
510 tool_call_item.arguments += event.delta
511 elif (
512 event.type == "response.output_item.done"
513 and event.item.type == "function_call"
514 ):
515 completed_event = event
516
517 assert tool_call_item is not None
518 assert tool_call_item.type == "function_call"
519 assert tool_call_item.name == "get_weather"
520 assert completed_event is not None
521 assert tool_call_item.arguments == completed_event.item.arguments
522 assert tool_call_item.name == completed_event.item.name
523 args = json.loads(tool_call_item.arguments)
524 assert "location" in args
525 assert args["location"] is not None
526 # Forced tool choice should not leak tool-call JSON via output_text delta.
527 assert "".join(text_deltas).strip() == ""