[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(