main
py 320 lines 9.57 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 import pytest_asyncio
9 from typing_extensions import TypedDict
10
11 from tests.utils import RemoteOpenAIServer
12 from vllm.tool_parsers.abstract_tool_parser import ToolParser
13 from vllm.tool_parsers.granite4_tool_parser import Granite4ToolParser
14 from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
15 from vllm.transformers_utils.repo_utils import hf_api
16
17 LORA_MODEL = "minpeter/LoRA-Llama-3.2-1B-tool-vllm-ci"
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 "properties": {
28 "location": {
29 "type": "string",
30 "description": "The city and state, e.g. San Francisco, CA",
31 },
32 "unit": {
33 "type": "string",
34 "enum": ["celsius", "fahrenheit"],
35 },
36 },
37 "required": ["location"],
38 },
39 },
40 }
41 ]
42
43
44 class ServerConfig(TypedDict, total=False):
45 model: str
46 arguments: list[str]
47 model_arg: str
48 tool_parser: type[ToolParser]
49
50
51 CONFIGS: dict[str, ServerConfig] = {
52 "llama": {
53 "model": "meta-llama/Llama-3.2-1B-Instruct",
54 "arguments": [
55 "--enforce-eager",
56 "--enable-auto-tool-choice",
57 "--tool-call-parser",
58 "hermes",
59 "--enable-lora",
60 "--lora-modules",
61 f"{LORA_MODEL}={LORA_MODEL}",
62 "--tokenizer",
63 f"{LORA_MODEL}",
64 ],
65 "model_arg": LORA_MODEL,
66 "tool_parser": Hermes2ProToolParser,
67 },
68 "granite4": {
69 "model": "ibm-granite/granite-4.0-h-tiny",
70 "arguments": [
71 "--enforce-eager",
72 "--enable-auto-tool-choice",
73 "--tool-call-parser",
74 "granite4",
75 "--tokenizer",
76 "ibm-granite/granite-4.0-h-tiny",
77 "--max-model-len",
78 "4096",
79 "--max-num-seqs",
80 "2",
81 ],
82 "model_arg": "ibm-granite/granite-4.0-h-tiny",
83 "tool_parser": Granite4ToolParser,
84 },
85 }
86
87
88 # for each server config, download the model and return the config
89 @pytest.fixture(scope="session", params=CONFIGS.keys())
90 def server_config(request):
91 config = CONFIGS[request.param]
92
93 # download model and tokenizer using transformers
94 hf_api().snapshot_download(config["model"])
95 yield CONFIGS[request.param]
96
97
98 @pytest.fixture(scope="module")
99 def server(request, server_config: ServerConfig):
100 model = server_config["model"]
101 args_for_model = server_config["arguments"]
102 with RemoteOpenAIServer(model, args_for_model, max_wait_seconds=480) as server:
103 yield server
104
105
106 @pytest_asyncio.fixture
107 async def client(server: RemoteOpenAIServer):
108 async with server.get_async_client() as async_client:
109 yield async_client
110
111
112 PRODUCT_TOOLS = [
113 {
114 "type": "function",
115 "function": {
116 "name": "get_product_info",
117 "description": "Get detailed information of a product based on its "
118 "product ID.",
119 "parameters": {
120 "type": "object",
121 "properties": {
122 "inserted": {
123 "type": "boolean",
124 "description": "inserted.",
125 },
126 "product_id": {
127 "type": "integer",
128 "description": "The product ID of the product.",
129 },
130 },
131 "required": ["product_id", "inserted"],
132 },
133 },
134 }
135 ]
136
137 MESSAGES = [{"role": "user", "content": "What's the weather like in Boston?"}]
138
139 PRODUCT_MESSAGES = [
140 {
141 "role": "user",
142 "content": "Hi! Do you have any detailed information about the product id "
143 "7355608 and inserted true?",
144 }
145 ]
146
147
148 @pytest.mark.asyncio
149 async def test_non_streaming_tool_call(
150 client: openai.AsyncOpenAI, server_config: ServerConfig
151 ):
152 """Test tool call in non-streaming mode."""
153 response = await client.chat.completions.create(
154 model=server_config["model_arg"],
155 messages=MESSAGES,
156 tools=TOOLS,
157 tool_choice="auto",
158 temperature=0.0,
159 )
160
161 assert response.choices
162 choice = response.choices[0]
163 message = choice.message
164
165 assert choice.finish_reason == "tool_calls"
166 assert message.tool_calls is not None
167
168 tool_call = message.tool_calls[0]
169 assert tool_call.type == "function"
170 assert tool_call.function.name == "get_current_weather"
171
172 arguments = json.loads(tool_call.function.arguments)
173 assert "location" in arguments
174 assert "Boston" in arguments["location"]
175 print("\n[Non-Streaming Test Passed]")
176 print(f"Tool Call: {tool_call.function.name}")
177 print(f"Arguments: {arguments}")
178
179
180 @pytest.mark.asyncio
181 async def test_streaming_tool_call(
182 client: openai.AsyncOpenAI, server_config: ServerConfig
183 ):
184 """Test tool call in streaming mode."""
185 stream = await client.chat.completions.create(
186 model=server_config["model_arg"],
187 messages=MESSAGES,
188 tools=TOOLS,
189 tool_choice="auto",
190 temperature=0.0,
191 stream=True,
192 )
193
194 tool_call_chunks = {}
195 async for chunk in stream:
196 if not chunk.choices:
197 continue
198
199 delta = chunk.choices[0].delta
200 if not delta or not delta.tool_calls:
201 continue
202
203 for tool_chunk in delta.tool_calls:
204 index = tool_chunk.index
205 if index not in tool_call_chunks:
206 tool_call_chunks[index] = {"name": "", "arguments": ""}
207
208 if tool_chunk.function.name:
209 tool_call_chunks[index]["name"] += tool_chunk.function.name
210 if tool_chunk.function.arguments:
211 tool_call_chunks[index]["arguments"] += tool_chunk.function.arguments
212
213 assert len(tool_call_chunks) == 1
214 reconstructed_tool_call = tool_call_chunks[0]
215
216 assert reconstructed_tool_call["name"] == "get_current_weather"
217
218 arguments = json.loads(reconstructed_tool_call["arguments"])
219 assert "location" in arguments
220 assert "Boston" in arguments["location"]
221 print("\n[Streaming Test Passed]")
222 print(f"Reconstructed Tool Call: {reconstructed_tool_call['name']}")
223 print(f"Reconstructed Arguments: {arguments}")
224
225
226 @pytest.mark.asyncio
227 async def test_non_streaming_product_tool_call(
228 client: openai.AsyncOpenAI, server_config: ServerConfig
229 ):
230 """Test tool call integer and boolean parameters in non-streaming mode."""
231 response = await client.chat.completions.create(
232 model=server_config["model_arg"],
233 messages=PRODUCT_MESSAGES,
234 tools=PRODUCT_TOOLS,
235 tool_choice="auto",
236 temperature=0.66,
237 )
238
239 assert response.choices
240 choice = response.choices[0]
241 message = choice.message
242
243 assert choice.finish_reason == "tool_calls"
244 assert message.tool_calls is not None
245
246 tool_call = message.tool_calls[0]
247 assert tool_call.type == "function"
248 assert tool_call.function.name == "get_product_info"
249
250 arguments = json.loads(tool_call.function.arguments)
251 assert "product_id" in arguments
252 assert "inserted" in arguments
253
254 product_id = arguments.get("product_id")
255 inserted = arguments.get("inserted")
256
257 assert isinstance(product_id, int)
258 assert product_id == 7355608
259 assert isinstance(inserted, bool)
260 assert inserted is True
261
262 print("\n[Non-Streaming Product Test Passed]")
263 print(f"Tool Call: {tool_call.function.name}")
264 print(f"Arguments: {arguments}")
265
266
267 @pytest.mark.asyncio
268 async def test_streaming_product_tool_call(
269 client: openai.AsyncOpenAI, server_config: ServerConfig
270 ):
271 """Test tool call integer and boolean parameters in streaming mode."""
272 stream = await client.chat.completions.create(
273 model=server_config["model_arg"],
274 messages=PRODUCT_MESSAGES,
275 tools=PRODUCT_TOOLS,
276 tool_choice="auto",
277 temperature=0.66,
278 stream=True,
279 )
280
281 tool_call_chunks = {}
282 async for chunk in stream:
283 if not chunk.choices:
284 continue
285
286 delta = chunk.choices[0].delta
287 if not delta or not delta.tool_calls:
288 continue
289
290 for tool_chunk in delta.tool_calls:
291 index = tool_chunk.index
292 if index not in tool_call_chunks:
293 tool_call_chunks[index] = {"name": "", "arguments": ""}
294
295 if tool_chunk.function.name:
296 tool_call_chunks[index]["name"] += tool_chunk.function.name
297 if tool_chunk.function.arguments:
298 tool_call_chunks[index]["arguments"] += tool_chunk.function.arguments
299
300 assert len(tool_call_chunks) == 1
301 reconstructed_tool_call = tool_call_chunks[0]
302
303 assert reconstructed_tool_call["name"] == "get_product_info"
304
305 arguments = json.loads(reconstructed_tool_call["arguments"])
306 assert "product_id" in arguments
307 assert "inserted" in arguments
308
309 # Handle type coercion for streaming test as well
310 product_id = arguments.get("product_id")
311 inserted = arguments.get("inserted")
312
313 assert isinstance(product_id, int)
314 assert product_id == 7355608
315 assert isinstance(inserted, bool)
316 assert inserted is True
317
318 print("\n[Streaming Product Test Passed]")
319 print(f"Reconstructed Tool Call: {reconstructed_tool_call['name']}")
320 print(f"Reconstructed Arguments: {arguments}")