main
py 211 lines 7.23 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_TOOLS,
11 MESSAGES_WITH_TOOL_RESPONSE,
12 SEARCH_TOOL,
13 SEED,
14 WEATHER_TOOL,
15 ServerConfig,
16 ensure_system_prompt,
17 )
18
19
20 # test: request a chat completion that should return tool calls, so we know they
21 # are parsable
22 @pytest.mark.asyncio
23 async def test_tool_call_and_choice(
24 client: openai.AsyncOpenAI, server_config: ServerConfig
25 ):
26 models = await client.models.list()
27 model_name: str = models.data[0].id
28 messages = ensure_system_prompt(MESSAGES_ASKING_FOR_TOOLS, server_config)
29 chat_completion = await client.chat.completions.create(
30 messages=messages,
31 temperature=0,
32 max_completion_tokens=100,
33 model=model_name,
34 tools=[WEATHER_TOOL, SEARCH_TOOL],
35 logprobs=False,
36 seed=SEED,
37 )
38
39 choice = chat_completion.choices[0]
40 stop_reason = chat_completion.choices[0].finish_reason
41 tool_calls = chat_completion.choices[0].message.tool_calls
42
43 # make sure a tool call is present
44 assert choice.message.role == "assistant"
45 assert tool_calls is not None
46 assert len(tool_calls) == 1
47 assert tool_calls[0].type == "function"
48 assert tool_calls[0].function is not None
49 assert isinstance(tool_calls[0].id, str)
50 assert len(tool_calls[0].id) >= 9
51
52 # make sure the weather tool was called (classic example) with arguments
53 assert tool_calls[0].function.name == WEATHER_TOOL["function"]["name"]
54 assert tool_calls[0].function.arguments is not None
55 assert isinstance(tool_calls[0].function.arguments, str)
56
57 # make sure the arguments parse properly
58 parsed_arguments = json.loads(tool_calls[0].function.arguments)
59 assert isinstance(parsed_arguments, dict)
60 assert isinstance(parsed_arguments.get("city"), str)
61 assert isinstance(parsed_arguments.get("state"), str)
62 assert parsed_arguments.get("city") == "Dallas"
63 assert parsed_arguments.get("state") == "TX"
64
65 assert stop_reason == "tool_calls"
66
67 function_name: str | None = None
68 function_args_str: str = ""
69 tool_call_id: str | None = None
70 role_name: str | None = None
71 finish_reason_count: int = 0
72
73 # make the same request, streaming
74 stream = await client.chat.completions.create(
75 model=model_name,
76 messages=messages,
77 temperature=0,
78 max_completion_tokens=100,
79 tools=[WEATHER_TOOL, SEARCH_TOOL],
80 logprobs=False,
81 seed=SEED,
82 stream=True,
83 )
84
85 async for chunk in stream:
86 assert chunk.choices[0].index == 0
87
88 if chunk.choices[0].finish_reason:
89 finish_reason_count += 1
90 assert chunk.choices[0].finish_reason == "tool_calls"
91
92 # if a role is being streamed make sure it wasn't already set to
93 # something else
94 if chunk.choices[0].delta.role:
95 assert not role_name or role_name == "assistant"
96 role_name = "assistant"
97
98 # if a tool call is streamed make sure there's exactly one
99 # (based on the request parameters
100 streamed_tool_calls = chunk.choices[0].delta.tool_calls
101
102 if streamed_tool_calls and len(streamed_tool_calls) > 0:
103 assert len(streamed_tool_calls) == 1
104 tool_call = streamed_tool_calls[0]
105
106 # if a tool call ID is streamed, make sure one hasn't been already
107 if tool_call.id:
108 assert not tool_call_id
109 tool_call_id = tool_call.id
110
111 # if parts of the function start being streamed
112 if tool_call.function:
113 # if the function name is defined, set it. it should be streamed
114 # IN ENTIRETY, exactly one time.
115 if tool_call.function.name:
116 assert function_name is None
117 assert isinstance(tool_call.function.name, str)
118 function_name = tool_call.function.name
119 if tool_call.function.arguments:
120 assert isinstance(tool_call.function.arguments, str)
121 function_args_str += tool_call.function.arguments
122
123 assert finish_reason_count == 1
124 assert role_name == "assistant"
125 assert isinstance(tool_call_id, str) and (len(tool_call_id) >= 9)
126
127 # validate the name and arguments
128 assert function_name == WEATHER_TOOL["function"]["name"]
129 assert function_name == tool_calls[0].function.name
130 assert isinstance(function_args_str, str)
131
132 # validate arguments
133 streamed_args = json.loads(function_args_str)
134 assert isinstance(streamed_args, dict)
135 assert isinstance(streamed_args.get("city"), str)
136 assert isinstance(streamed_args.get("state"), str)
137 assert streamed_args.get("city") == "Dallas"
138 assert streamed_args.get("state") == "TX"
139
140 # make sure everything matches non-streaming except for ID
141 assert function_name == tool_calls[0].function.name
142 assert choice.message.role == role_name
143 assert choice.message.tool_calls[0].function.name == function_name
144
145 # compare streamed with non-streamed args dict-wise, not string-wise
146 # because character-to-character comparison might not work e.g. the tool
147 # call parser adding extra spaces or something like that. we care about the
148 # dicts matching not byte-wise match
149 assert parsed_arguments == streamed_args
150
151
152 # test: providing tools and results back to model to get a non-tool response
153 # (streaming/not)
154 @pytest.mark.asyncio
155 async def test_tool_call_with_results(client: openai.AsyncOpenAI):
156 models = await client.models.list()
157 model_name: str = models.data[0].id
158 chat_completion = await client.chat.completions.create(
159 messages=MESSAGES_WITH_TOOL_RESPONSE,
160 temperature=0,
161 max_completion_tokens=100,
162 model=model_name,
163 tools=[WEATHER_TOOL, SEARCH_TOOL],
164 logprobs=False,
165 seed=SEED,
166 )
167
168 choice = chat_completion.choices[0]
169
170 assert choice.finish_reason != "tool_calls" # "stop" or "length"
171 assert choice.message.role == "assistant"
172 assert choice.message.tool_calls is None or len(choice.message.tool_calls) == 0
173 assert choice.message.content is not None
174 assert "98" in choice.message.content # the temperature from the response
175
176 stream = await client.chat.completions.create(
177 messages=MESSAGES_WITH_TOOL_RESPONSE,
178 temperature=0,
179 max_completion_tokens=100,
180 model=model_name,
181 tools=[WEATHER_TOOL, SEARCH_TOOL],
182 logprobs=False,
183 seed=SEED,
184 stream=True,
185 )
186
187 chunks: list[str] = []
188 finish_reason_count = 0
189 role_sent: bool = False
190
191 async for chunk in stream:
192 delta = chunk.choices[0].delta
193
194 if delta.role:
195 assert not role_sent
196 assert delta.role == "assistant"
197 role_sent = True
198
199 if delta.content:
200 chunks.append(delta.content)
201
202 if chunk.choices[0].finish_reason is not None:
203 finish_reason_count += 1
204 assert chunk.choices[0].finish_reason == choice.finish_reason
205
206 assert not delta.tool_calls or len(delta.tool_calls) == 0
207
208 assert role_sent
209 assert finish_reason_count == 1
210 assert len(chunks)
211 assert "".join(chunks) == choice.message.content