| 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 == "" |