[Bugfix][Tool Parser] PoolsideV1: fix string whitespace and required named tool choice (#46486)
Signed-off-by: Joe Rowell <joerowell4@gmail.com>
Joe Rowell committed
Jun 26, 2026 at 08:05 UTC
63e161f2965e77b2c3ffcd159ce45b2157a21b43
2 files changed
+247
-11
tests/tool_parsers/test_poolside_v1_tool_parser.py
new
+217
@@ -0,0 +1,217 @@
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(*, tool_choice: str | dict[str, Any]) -> ResponsesRequest:
81
+ return ResponsesRequest(
82
+ model="poolside-test",
83
+ input=[{"role": "user", "content": "write the file"}],
84
+ tools=[_responses_write_file_tool()],
85
+ tool_choice=tool_choice,
86
+ stream=True,
87
+ max_output_tokens=200,
88
+ )
89
+
90
+
91
+class _StubTokenizer:
92
+ """Minimal tokenizer stub to satisfy ``PoolsideV1ToolParser.__init__``."""
93
+
94
+ def get_vocab(self) -> dict[str, int]:
95
+ return {"<tool_call>": 151_657, "</tool_call>": 151_658}
96
+
97
+
98
+def _make_parser(request: ChatCompletionRequest) -> PoolsideV1ToolParser:
99
+ return PoolsideV1ToolParser(_StubTokenizer(), tools=request.tools)
100
+
101
+
102
+# ---------------------------------------------------------------------------
103
+# Bug 1: required/named must skip forced structured_outputs (#39870 pattern)
104
+# ---------------------------------------------------------------------------
105
+
106
+
107
+def test_required_skips_structured_outputs_chatcompletion() -> None:
108
+ request = _build_chat_request(tool_choice="required")
109
+ _make_parser(request).adjust_request(request)
110
+
111
+ assert request.structured_outputs is None
112
+ assert request.skip_special_tokens is False
113
+
114
+
115
+def test_named_skips_structured_outputs_chatcompletion() -> None:
116
+ request = _build_chat_request(
117
+ tool_choice={"type": "function", "function": {"name": "write_file"}}
118
+ )
119
+ _make_parser(request).adjust_request(request)
120
+
121
+ assert request.structured_outputs is None
122
+ assert request.skip_special_tokens is False
123
+
124
+
125
+def test_required_skips_structured_outputs_responses() -> None:
126
+ request = _build_responses_request(tool_choice="required")
127
+ PoolsideV1ToolParser(_StubTokenizer()).adjust_request(request)
128
+
129
+ assert request.text is None
130
+ assert request.skip_special_tokens is False
131
+
132
+
133
+def test_named_skips_structured_outputs_responses() -> None:
134
+ # Responses-API named choice parses to ToolChoiceFunction, a different
135
+ # type than the ChatCompletion named choice; both must be handled.
136
+ request = _build_responses_request(
137
+ tool_choice={"type": "function", "name": "write_file"}
138
+ )
139
+ PoolsideV1ToolParser(_StubTokenizer()).adjust_request(request)
140
+
141
+ assert request.text is None
142
+ assert request.skip_special_tokens is False
143
+
144
+
145
+def test_auto_still_keeps_special_tokens() -> None:
146
+ request = _build_chat_request(tool_choice="auto")
147
+ _make_parser(request).adjust_request(request)
148
+
149
+ assert request.skip_special_tokens is False
150
+
151
+
152
+# ---------------------------------------------------------------------------
153
+# Bug 2: string arg whitespace must be preserved (#42026 pattern)
154
+# ---------------------------------------------------------------------------
155
+
156
+
157
+def test_string_arg_preserves_whitespace() -> None:
158
+ request = _build_chat_request(tool_choice="auto")
159
+ parser = _make_parser(request)
160
+
161
+ content = " def f():\n return 1\n"
162
+ model_output = (
163
+ "<tool_call>write_file\n"
164
+ "<arg_key>content</arg_key>\n"
165
+ f"<arg_value>{content}</arg_value>\n"
166
+ "</tool_call>"
167
+ )
168
+
169
+ result = parser.extract_tool_calls(model_output, request)
170
+
171
+ assert result.tools_called
172
+ args = json.loads(result.tool_calls[0].function.arguments)
173
+ # Leading indent and trailing newline must survive verbatim.
174
+ assert args["content"] == content
175
+
176
+
177
+def test_non_string_arg_still_deserialized() -> None:
178
+ request = _build_chat_request(tool_choice="auto")
179
+ parser = _make_parser(request)
180
+
181
+ model_output = (
182
+ "<tool_call>write_file\n"
183
+ "<arg_key>content</arg_key>\n"
184
+ "<arg_value>hi</arg_value>\n"
185
+ "<arg_key>mode</arg_key>\n"
186
+ "<arg_value> 420 </arg_value>\n"
187
+ "</tool_call>"
188
+ )
189
+
190
+ result = parser.extract_tool_calls(model_output, request)
191
+
192
+ assert result.tools_called
193
+ args = json.loads(result.tool_calls[0].function.arguments)
194
+ assert args["content"] == "hi"
195
+ # Non-string value is stripped and parsed to its native type.
196
+ assert args["mode"] == 420
197
+
198
+
199
+def test_responses_extract_tool_calls_with_flat_tools() -> None:
200
+ # required/named Responses calls route into extract_tool_calls with flat
201
+ # FunctionTool (.name); _is_string_type must not raise.
202
+ request = _build_responses_request(tool_choice="required")
203
+ parser = PoolsideV1ToolParser(_StubTokenizer(), tools=request.tools)
204
+
205
+ content = " x = 1\n"
206
+ model_output = (
207
+ "<tool_call>write_file\n"
208
+ "<arg_key>content</arg_key>\n"
209
+ f"<arg_value>{content}</arg_value>\n"
210
+ "</tool_call>"
211
+ )
212
+
213
+ result = parser.extract_tool_calls(model_output, request)
214
+
215
+ assert result.tools_called
216
+ args = json.loads(result.tool_calls[0].function.arguments)
217
+ assert args["content"] == content
vllm/tool_parsers/poolside_v1_tool_parser.py
+30
-11
@@ -17,10 +17,12 @@ from typing import Any
17
18
import partial_json_parser.core.complete
19
import regex as re
20
+from openai.types.responses import ToolChoiceFunction
21
from partial_json_parser.core.options import Allow
22
23
from vllm.entrypoints.chat_utils import make_tool_call_id
24
from vllm.entrypoints.openai.chat_completion.protocol import (
25
+ ChatCompletionNamedToolChoiceParam,
26
ChatCompletionRequest,
27
)
28
from vllm.entrypoints.openai.engine.protocol import (
@@ -53,6 +55,8 @@ class PoolsideV1ToolParser(ToolParser):
55
rather than waiting for the complete </arg_value> tag.
56
"""
57
58
+ supports_required_and_named = False
59
+
60
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
61
super().__init__(tokenizer, tools)
62
# Stateful streaming fields
@@ -132,15 +136,15 @@ class PoolsideV1ToolParser(ToolParser):
136
if tools is None:
137
return False
138
for tool in tools:
135
- if tool.function.name != tool_name:
139
+ # ChatCompletion tools nest under .function; Responses
140
+ # FunctionTool is flat (.name/.parameters at the top level).
141
+ fn = getattr(tool, "function", tool)
142
+ if getattr(fn, "name", None) != tool_name:
143
continue
137
- if tool.function.parameters is None:
144
+ params = getattr(fn, "parameters", None)
145
+ if params is None:
146
return False
139
- arg_type = (
140
- tool.function.parameters.get("properties", {})
141
- .get(arg_name, {})
142
- .get("type", None)
143
- )
147
+ arg_type = params.get("properties", {}).get(arg_name, {}).get("type", None)
148
return arg_type == "string"
149
logger.debug("No tool named '%s'.", tool_name)
150
return False
@@ -159,7 +163,19 @@ class PoolsideV1ToolParser(ToolParser):
163
def adjust_request(
164
self, request: ChatCompletionRequest | ResponsesRequest
165
) -> ChatCompletionRequest | ResponsesRequest:
162
- """Adjust request parameters for tool call token handling."""
166
+ """Adjust request parameters for tool call token handling.
167
+
168
+ For required/named tool_choice, skip super().adjust_request() so it
169
+ does not install JSON guided decoding. These models emit XML tool
170
+ calls (per the chat template), which JSON guidance would break.
171
+ """
172
+ if request.tools:
173
+ tc = request.tool_choice
174
+ if tc == "required" or isinstance(
175
+ tc, (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction)
176
+ ):
177
+ request.skip_special_tokens = False
178
+ return request
179
request = super().adjust_request(request)
180
if request.tools and request.tool_choice != "none":
181
# Ensure tool call tokens (<tool_call>, </tool_call>) are not skipped
@@ -192,9 +208,12 @@ class PoolsideV1ToolParser(ToolParser):
208
arg_dct: dict[str, Any] = {}
209
for key, value in pairs:
210
arg_key = key.strip()
195
- arg_val = value.strip()
196
- if not self._is_string_type(tc_name, arg_key, request.tools):
197
- arg_val = self._deserialize(arg_val)
211
+ # Keep string values verbatim; whitespace is significant
212
+ # (e.g. code/file content). Only strip non-string types.
213
+ if self._is_string_type(tc_name, arg_key, request.tools):
214
+ arg_val = value
215
+ else:
216
+ arg_val = self._deserialize(value.strip())
217
logger.debug("arg_key = %s, arg_val = %s", arg_key, arg_val)
218
arg_dct[arg_key] = arg_val
219
tool_calls.append(