main
py 350 lines 11.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Regression tests for ``PoolsideV1ToolParser``.
4
5 Covers two bugs:
6
7 1. ``adjust_request`` did not skip the forced ``structured_outputs`` JSON
8 for ``required``/named tool choice. These models emit XML tool calls
9 (``<tool_call>...<arg_value>...</arg_value></tool_call>``) per the chat
10 template, so guided JSON decoding conflicts with the format: the call
11 leaks as content with empty ``tool_calls``. ``adjust_request`` now skips
12 the constraint for both ChatCompletion (``ChatCompletionNamedToolChoice``)
13 and Responses (``ToolChoiceFunction``) named choices.
14
15 2. ``extract_tool_calls`` stripped string-typed argument values, corrupting
16 content whose whitespace is significant (e.g. code/file bodies losing
17 leading indent and trailing newline). String values are now kept verbatim;
18 only non-string types are stripped/deserialized.
19 """
20
21 from __future__ import annotations
22
23 import json
24 from typing import Any
25
26 from openai.types.responses.tool_param import FunctionToolParam
27
28 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
29 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
30 from vllm.tool_parsers.poolside_v1_tool_parser import PoolsideV1ToolParser
31
32
33 def _write_file_tool() -> dict[str, Any]:
34 """Tool with a string arg (``content``) and a non-string arg (``mode``)."""
35 return {
36 "type": "function",
37 "function": {
38 "name": "write_file",
39 "description": "Write content to a file",
40 "parameters": {
41 "type": "object",
42 "properties": {
43 "content": {"type": "string"},
44 "mode": {"type": "integer"},
45 },
46 "required": ["content"],
47 },
48 },
49 }
50
51
52 def _responses_write_file_tool() -> FunctionToolParam:
53 return FunctionToolParam(
54 type="function",
55 name="write_file",
56 description="Write content to a file",
57 parameters={
58 "type": "object",
59 "properties": {
60 "content": {"type": "string"},
61 "mode": {"type": "integer"},
62 },
63 "required": ["content"],
64 },
65 strict=True,
66 )
67
68
69 def _build_chat_request(*, tool_choice: str | dict[str, Any]) -> ChatCompletionRequest:
70 return ChatCompletionRequest.model_validate(
71 {
72 "model": "poolside-test",
73 "messages": [{"role": "user", "content": "write the file"}],
74 "tools": [_write_file_tool()],
75 "tool_choice": tool_choice,
76 }
77 )
78
79
80 def _build_responses_request(
81 *, tool_choice: str | dict[str, Any], include: list[str] | None = None
82 ) -> ResponsesRequest:
83 return ResponsesRequest(
84 model="poolside-test",
85 input=[{"role": "user", "content": "write the file"}],
86 tools=[_responses_write_file_tool()],
87 tool_choice=tool_choice,
88 stream=True,
89 max_output_tokens=200,
90 include=include,
91 )
92
93
94 class _StubTokenizer:
95 """Minimal tokenizer stub to satisfy ``PoolsideV1ToolParser.__init__``."""
96
97 def get_vocab(self) -> dict[str, int]:
98 return {"<tool_call>": 151_657, "</tool_call>": 151_658}
99
100
101 def _make_parser(request: ChatCompletionRequest) -> PoolsideV1ToolParser:
102 return PoolsideV1ToolParser(_StubTokenizer(), tools=request.tools)
103
104
105 # ---------------------------------------------------------------------------
106 # Bug 1: required/named must skip forced structured_outputs (#39870 pattern)
107 # ---------------------------------------------------------------------------
108
109
110 def test_required_skips_structured_outputs_chatcompletion() -> None:
111 request = _build_chat_request(tool_choice="required")
112 _make_parser(request).adjust_request(request)
113
114 assert request.structured_outputs is None
115 assert request.skip_special_tokens is False
116
117
118 def test_named_skips_structured_outputs_chatcompletion() -> None:
119 request = _build_chat_request(
120 tool_choice={"type": "function", "function": {"name": "write_file"}}
121 )
122 _make_parser(request).adjust_request(request)
123
124 assert request.structured_outputs is None
125 assert request.skip_special_tokens is False
126
127
128 def test_required_skips_structured_outputs_responses() -> None:
129 request = _build_responses_request(tool_choice="required")
130 PoolsideV1ToolParser(_StubTokenizer()).adjust_request(request)
131
132 assert request.text is None
133 assert request.skip_special_tokens is False
134
135
136 def test_named_skips_structured_outputs_responses() -> None:
137 # Responses-API named choice parses to ToolChoiceFunction, a different
138 # type than the ChatCompletion named choice; both must be handled.
139 request = _build_responses_request(
140 tool_choice={"type": "function", "name": "write_file"}
141 )
142 PoolsideV1ToolParser(_StubTokenizer()).adjust_request(request)
143
144 assert request.text is None
145 assert request.skip_special_tokens is False
146
147
148 def test_auto_still_keeps_special_tokens() -> None:
149 request = _build_chat_request(tool_choice="auto")
150 _make_parser(request).adjust_request(request)
151
152 assert request.skip_special_tokens is False
153
154
155 # ---------------------------------------------------------------------------
156 # Bug 2: string arg whitespace must be preserved (#42026 pattern)
157 # ---------------------------------------------------------------------------
158
159
160 def test_string_arg_preserves_whitespace() -> None:
161 request = _build_chat_request(tool_choice="auto")
162 parser = _make_parser(request)
163
164 content = " def f():\n return 1\n"
165 model_output = (
166 "<tool_call>write_file\n"
167 "<arg_key>content</arg_key>\n"
168 f"<arg_value>{content}</arg_value>\n"
169 "</tool_call>"
170 )
171
172 result = parser.extract_tool_calls(model_output, request)
173
174 assert result.tools_called
175 args = json.loads(result.tool_calls[0].function.arguments)
176 # Leading indent and trailing newline must survive verbatim.
177 assert args["content"] == content
178
179
180 def test_non_string_arg_still_deserialized() -> None:
181 request = _build_chat_request(tool_choice="auto")
182 parser = _make_parser(request)
183
184 model_output = (
185 "<tool_call>write_file\n"
186 "<arg_key>content</arg_key>\n"
187 "<arg_value>hi</arg_value>\n"
188 "<arg_key>mode</arg_key>\n"
189 "<arg_value> 420 </arg_value>\n"
190 "</tool_call>"
191 )
192
193 result = parser.extract_tool_calls(model_output, request)
194
195 assert result.tools_called
196 args = json.loads(result.tool_calls[0].function.arguments)
197 assert args["content"] == "hi"
198 # Non-string value is stripped and parsed to its native type.
199 assert args["mode"] == 420
200
201
202 def test_responses_extract_tool_calls_with_flat_tools() -> None:
203 # required/named Responses calls route into extract_tool_calls with flat
204 # FunctionTool (.name); _is_string_type must not raise.
205 request = _build_responses_request(tool_choice="required")
206 parser = PoolsideV1ToolParser(_StubTokenizer(), tools=request.tools)
207
208 content = " x = 1\n"
209 model_output = (
210 "<tool_call>write_file\n"
211 "<arg_key>content</arg_key>\n"
212 f"<arg_value>{content}</arg_value>\n"
213 "</tool_call>"
214 )
215
216 result = parser.extract_tool_calls(model_output, request)
217
218 assert result.tools_called
219 args = json.loads(result.tool_calls[0].function.arguments)
220 assert args["content"] == content
221
222
223 def _weather_tool() -> dict[str, Any]:
224 return {
225 "type": "function",
226 "function": {
227 "name": "get_weather",
228 "description": "Get the weather for a city",
229 "parameters": {
230 "type": "object",
231 "properties": {"city": {"type": "string"}},
232 "required": ["city"],
233 },
234 },
235 }
236
237
238 def _build_weather_request(*, tool_choice: str) -> ChatCompletionRequest:
239 return ChatCompletionRequest.model_validate(
240 {
241 "model": "poolside-test",
242 "messages": [{"role": "user", "content": "weather in Paris?"}],
243 "tools": [_weather_tool()],
244 "tool_choice": tool_choice,
245 }
246 )
247
248
249 def test_no_newline_after_name_non_streaming() -> None:
250 request = _build_weather_request(tool_choice="auto")
251 parser = _make_parser(request)
252
253 model_output = (
254 "<tool_call>get_weather"
255 "<arg_key>city</arg_key><arg_value>Paris</arg_value>"
256 "</tool_call>"
257 )
258
259 result = parser.extract_tool_calls(model_output, request)
260
261 assert result.tools_called
262 assert len(result.tool_calls) == 1
263 assert result.tool_calls[0].function.name == "get_weather"
264 args = json.loads(result.tool_calls[0].function.arguments)
265 assert args == {"city": "Paris"}
266
267
268 def test_newline_after_name_still_parses_non_streaming() -> None:
269 request = _build_weather_request(tool_choice="auto")
270 parser = _make_parser(request)
271
272 model_output = (
273 "<tool_call>get_weather\n"
274 "<arg_key>city</arg_key>\n<arg_value>Paris</arg_value>\n"
275 "</tool_call>"
276 )
277
278 result = parser.extract_tool_calls(model_output, request)
279
280 assert result.tools_called
281 assert result.tool_calls[0].function.name == "get_weather"
282 args = json.loads(result.tool_calls[0].function.arguments)
283 assert args == {"city": "Paris"}
284
285
286 def test_no_newline_after_name_streaming() -> None:
287 request = _build_weather_request(tool_choice="auto")
288 parser = _make_parser(request)
289
290 model_output = (
291 "<tool_call>get_weather"
292 "<arg_key>city</arg_key><arg_value>Paris</arg_value>"
293 "</tool_call>"
294 )
295
296 name = ""
297 args = ""
298 prev = ""
299 for ch in model_output:
300 cur = prev + ch
301 delta = parser.extract_tool_calls_streaming(
302 previous_text=prev,
303 current_text=cur,
304 delta_text=ch,
305 previous_token_ids=[],
306 current_token_ids=[],
307 delta_token_ids=[],
308 request=request,
309 )
310 prev = cur
311 if delta is None:
312 continue
313 for tc in delta.tool_calls or []:
314 if tc.function is None:
315 continue
316 if tc.function.name:
317 name += tc.function.name
318 if tc.function.arguments:
319 args += tc.function.arguments
320
321 assert name == "get_weather"
322 assert json.loads(args) == {"city": "Paris"}
323
324
325 def _stream_partial_start_token(request: ResponsesRequest):
326 parser = _make_parser(request)
327 delta = parser.tool_call_start_token[0]
328 return parser.extract_tool_calls_streaming(
329 previous_text="",
330 current_text=delta,
331 delta_text=delta,
332 previous_token_ids=[],
333 current_token_ids=[],
334 delta_token_ids=[],
335 request=request,
336 )
337
338
339 def test_streaming_responses_request_without_logprobs() -> None:
340 request = _build_responses_request(tool_choice="auto")
341 assert _stream_partial_start_token(request) is None
342
343
344 def test_streaming_responses_request_with_logprobs_emits_empty_delta() -> None:
345 request = _build_responses_request(
346 tool_choice="auto", include=["message.output_text.logprobs"]
347 )
348 result = _stream_partial_start_token(request)
349 assert result is not None
350 assert result.content == ""