A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 5, 11:00 UTC
[Tool] `adjust_request` to reasoning parser, and Gemma4 fixes (#39027)
Signed-off-by: Ben Browning <bbrownin@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Ben Browning committed
Apr 8, 2026 at 15:04 UTC
8477fe427d174df15204aae2819922cce384ecf2
12 files changed
+878
-16
examples/tool_chat_template_gemma4.jinja
new
+331
@@ -0,0 +1,331 @@
1
+{%- macro format_parameters(properties, required) -%}
2
+ {%- set standard_keys = ['description', 'type', 'properties', 'required', 'nullable'] -%}
3
+ {%- set ns = namespace(found_first=false) -%}
4
+ {%- for key, value in properties | dictsort -%}
5
+ {%- set add_comma = false -%}
6
+ {%- if key not in standard_keys -%}
7
+ {%- if ns.found_first %},{% endif -%}
8
+ {%- set ns.found_first = true -%}
9
+ {{ key }}:{
10
+ {%- if value['description'] -%}
11
+ description:<|"|>{{ value['description'] }}<|"|>
12
+ {%- set add_comma = true -%}
13
+ {%- endif -%}
14
+ {%- if value['nullable'] %}
15
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
16
+ nullable:true
17
+ {%- endif -%}
18
+ {%- if value['type'] | upper == 'STRING' -%}
19
+ {%- if value['enum'] -%}
20
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
21
+ enum:{{ format_argument(value['enum']) }}
22
+ {%- endif -%}
23
+ {%- elif value['type'] | upper == 'OBJECT' -%}
24
+ ,properties:{
25
+ {%- if value['properties'] is defined and value['properties'] is mapping -%}
26
+ {{- format_parameters(value['properties'], value['required'] | default([])) -}}
27
+ {%- elif value is mapping -%}
28
+ {{- format_parameters(value, value['required'] | default([])) -}}
29
+ {%- endif -%}
30
+ }
31
+ {%- if value['required'] -%}
32
+ ,required:[
33
+ {%- for item in value['required'] | default([]) -%}
34
+ <|"|>{{- item -}}<|"|>
35
+ {%- if not loop.last %},{% endif -%}
36
+ {%- endfor -%}
37
+ ]
38
+ {%- endif -%}
39
+ {%- elif value['type'] | upper == 'ARRAY' -%}
40
+ {%- if value['items'] is mapping and value['items'] -%}
41
+ ,items:{
42
+ {%- set ns_items = namespace(found_first=false) -%}
43
+ {%- for item_key, item_value in value['items'] | dictsort -%}
44
+ {%- if item_value is not none -%}
45
+ {%- if ns_items.found_first %},{% endif -%}
46
+ {%- set ns_items.found_first = true -%}
47
+ {%- if item_key == 'properties' -%}
48
+ properties:{
49
+ {%- if item_value is mapping -%}
50
+ {{- format_parameters(item_value, value['items']['required'] | default([])) -}}
51
+ {%- endif -%}
52
+ }
53
+ {%- elif item_key == 'required' -%}
54
+ required:[
55
+ {%- for req_item in item_value -%}
56
+ <|"|>{{- req_item -}}<|"|>
57
+ {%- if not loop.last %},{% endif -%}
58
+ {%- endfor -%}
59
+ ]
60
+ {%- elif item_key == 'type' -%}
61
+ {%- if item_value is string -%}
62
+ type:{{ format_argument(item_value | upper) }}
63
+ {%- else -%}
64
+ type:{{ format_argument(item_value | map('upper') | list) }}
65
+ {%- endif -%}
66
+ {%- else -%}
67
+ {{ item_key }}:{{ format_argument(item_value) }}
68
+ {%- endif -%}
69
+ {%- endif -%}
70
+ {%- endfor -%}
71
+ }
72
+ {%- endif -%}
73
+ {%- endif -%}
74
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
75
+ type:<|"|>{{ value['type'] | upper }}<|"|>}
76
+ {%- endif -%}
77
+ {%- endfor -%}
78
+{%- endmacro -%}
79
+{%- macro format_function_declaration(tool_data) -%}
80
+ declaration:{{- tool_data['function']['name'] -}}{description:<|"|>{{- tool_data['function']['description'] -}}<|"|>
81
+ {%- set params = tool_data['function']['parameters'] -%}
82
+ {%- if params -%}
83
+ ,parameters:{
84
+ {%- if params['properties'] -%}
85
+ properties:{ {{- format_parameters(params['properties'], params['required']) -}} },
86
+ {%- endif -%}
87
+ {%- if params['required'] -%}
88
+ required:[
89
+ {%- for item in params['required'] -%}
90
+ <|"|>{{- item -}}<|"|>
91
+ {{- ',' if not loop.last -}}
92
+ {%- endfor -%}
93
+ ],
94
+ {%- endif -%}
95
+ {%- if params['type'] -%}
96
+ type:<|"|>{{- params['type'] | upper -}}<|"|>}
97
+ {%- endif -%}
98
+ {%- endif -%}
99
+ {%- if 'response' in tool_data['function'] -%}
100
+ {%- set response_declaration = tool_data['function']['response'] -%}
101
+ ,response:{
102
+ {%- if response_declaration['description'] -%}
103
+ description:<|"|>{{- response_declaration['description'] -}}<|"|>,
104
+ {%- endif -%}
105
+ {%- if response_declaration['type'] | upper == 'OBJECT' -%}
106
+ type:<|"|>{{- response_declaration['type'] | upper -}}<|"|>}
107
+ {%- endif -%}
108
+ {%- endif -%}
109
+ }
110
+{%- endmacro -%}
111
+{%- macro format_argument(argument, escape_keys=True) -%}
112
+ {%- if argument is string -%}
113
+ {{- '<|"|>' + argument + '<|"|>' -}}
114
+ {%- elif argument is boolean -%}
115
+ {{- 'true' if argument else 'false' -}}
116
+ {%- elif argument is mapping -%}
117
+ {{- '{' -}}
118
+ {%- set ns = namespace(found_first=false) -%}
119
+ {%- for key, value in argument | dictsort -%}
120
+ {%- if ns.found_first %},{% endif -%}
121
+ {%- set ns.found_first = true -%}
122
+ {%- if escape_keys -%}
123
+ {{- '<|"|>' + key + '<|"|>' -}}
124
+ {%- else -%}
125
+ {{- key -}}
126
+ {%- endif -%}
127
+ :{{- format_argument(value, escape_keys=escape_keys) -}}
128
+ {%- endfor -%}
129
+ {{- '}' -}}
130
+ {%- elif argument is sequence -%}
131
+ {{- '[' -}}
132
+ {%- for item in argument -%}
133
+ {{- format_argument(item, escape_keys=escape_keys) -}}
134
+ {%- if not loop.last %},{% endif -%}
135
+ {%- endfor -%}
136
+ {{- ']' -}}
137
+ {%- else -%}
138
+ {{- argument -}}
139
+ {%- endif -%}
140
+{%- endmacro -%}
141
+{%- macro strip_thinking(text) -%}
142
+ {%- set ns = namespace(result='') -%}
143
+ {%- for part in text.split('<channel|>') -%}
144
+ {%- if '<|channel>' in part -%}
145
+ {%- set ns.result = ns.result + part.split('<|channel>')[0] -%}
146
+ {%- else -%}
147
+ {%- set ns.result = ns.result + part -%}
148
+ {%- endif -%}
149
+ {%- endfor -%}
150
+ {{- ns.result | trim -}}
151
+{%- endmacro -%}
152
+
153
+{%- macro format_tool_response_block(tool_name, response) -%}
154
+ {{- '<|tool_response>' -}}
155
+ {%- if response is mapping -%}
156
+ {{- 'response:' + tool_name + '{' -}}
157
+ {%- for key, value in response | dictsort -%}
158
+ {{- key -}}:{{- format_argument(value, escape_keys=False) -}}
159
+ {%- if not loop.last %},{% endif -%}
160
+ {%- endfor -%}
161
+ {{- '}' -}}
162
+ {%- else -%}
163
+ {{- 'response:' + tool_name + '{value:' + format_argument(response, escape_keys=False) + '}' -}}
164
+ {%- endif -%}
165
+ {{- '<tool_response|>' -}}
166
+{%- endmacro -%}
167
+
168
+{%- set ns = namespace(prev_message_type=None) -%}
169
+{%- set loop_messages = messages -%}
170
+{{ bos_token }}
171
+{%- if (enable_thinking is defined and enable_thinking) or tools or messages[0]['role'] in ['system', 'developer'] -%}
172
+ {{- '<|turn>system\n' -}}
173
+
174
+ {%- if enable_thinking is defined and enable_thinking -%}
175
+ {{- '<|think|>' -}}
176
+ {%- set ns.prev_message_type = 'think' -%}
177
+ {%- endif -%}
178
+
179
+ {%- if messages[0]['role'] in ['system', 'developer'] -%}
180
+ {{- messages[0]['content'] | trim -}}
181
+ {%- set loop_messages = messages[1:] -%}
182
+ {%- endif -%}
183
+
184
+ {%- if tools -%}
185
+ {%- for tool in tools %}
186
+ {{- '<|tool>' -}}
187
+ {{- format_function_declaration(tool) | trim -}}
188
+ {{- '<tool|>' -}}
189
+ {%- endfor %}
190
+ {%- set ns.prev_message_type = 'tool' -%}
191
+ {%- endif -%}
192
+
193
+ {{- '<turn|>\n' -}}
194
+{%- endif %}
195
+
196
+{%- set ns_turn = namespace(last_user_idx=-1) -%}
197
+{%- for i in range(loop_messages | length) -%}
198
+ {%- if loop_messages[i]['role'] == 'user' -%}
199
+ {%- set ns_turn.last_user_idx = i -%}
200
+ {%- endif -%}
201
+{%- endfor -%}
202
+
203
+{%- for message in loop_messages -%}
204
+ {%- if message['role'] != 'tool' -%}
205
+ {%- set ns.prev_message_type = None -%}
206
+ {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
207
+ {#- OpenAI may emit multiple assistant messages in one tool loop (user → asst → tool → asst → tool).
208
+ Only the first of those should open <|turn>model; later ones continue the same model turn. -#}
209
+ {%- set prev_nt = namespace(role=None, found=false) -%}
210
+ {%- if loop.index0 > 0 -%}
211
+ {%- for j in range(loop.index0 - 1, -1, -1) -%}
212
+ {%- if not prev_nt.found -%}
213
+ {%- if loop_messages[j]['role'] != 'tool' -%}
214
+ {%- set prev_nt.role = loop_messages[j]['role'] -%}
215
+ {%- set prev_nt.found = true -%}
216
+ {%- endif -%}
217
+ {%- endif -%}
218
+ {%- endfor -%}
219
+ {%- endif -%}
220
+ {%- set continue_same_model_turn = (role == 'model' and prev_nt.role == 'assistant') -%}
221
+ {%- if not continue_same_model_turn -%}
222
+ {{- '<|turn>' + role + '\n' }}
223
+ {%- endif -%}
224
+
225
+ {%- if message.get('reasoning') and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%}
226
+ {{- '<|channel>thought\n' + message['reasoning'] + '\n<channel|>'}}
227
+ {%- endif -%}
228
+
229
+ {%- if message['tool_calls'] -%}
230
+ {%- for tool_call in message['tool_calls'] -%}
231
+ {%- set function = tool_call['function'] -%}
232
+ {{- '<|tool_call>call:' + function['name'] + '{' -}}
233
+ {%- if function['arguments'] is mapping -%}
234
+ {%- set ns_args = namespace(found_first=false) -%}
235
+ {%- for key, value in function['arguments'] | dictsort -%}
236
+ {%- if ns_args.found_first %},{% endif -%}
237
+ {%- set ns_args.found_first = true -%}
238
+ {{- key -}}:{{- format_argument(value, escape_keys=False) -}}
239
+ {%- endfor -%}
240
+ {%- elif function['arguments'] is string -%}
241
+ {{- function['arguments'] -}}
242
+ {%- endif -%}
243
+ {{- '}<tool_call|>' -}}
244
+ {%- endfor -%}
245
+ {%- set ns.prev_message_type = 'tool_call' -%}
246
+ {%- endif -%}
247
+
248
+ {%- set ns_tr_out = namespace(flag=false) -%}
249
+ {%- if message.get('tool_responses') -%}
250
+ {#- Legacy: tool_responses embedded on the assistant message -#}
251
+ {%- for tool_response in message['tool_responses'] -%}
252
+ {{- format_tool_response_block(tool_response['name'] | default('unknown'), tool_response['response']) -}}
253
+ {%- set ns_tr_out.flag = true -%}
254
+ {%- set ns.prev_message_type = 'tool_response' -%}
255
+ {%- endfor -%}
256
+ {%- elif message.get('tool_calls') -%}
257
+ {#- OpenAI Chat Completions: consecutive following messages with role "tool" (no break/continue; range scan) -#}
258
+ {%- set ns_tool_scan = namespace(stopped=false) -%}
259
+ {%- for k in range(loop.index0 + 1, loop_messages | length) -%}
260
+ {%- if ns_tool_scan.stopped -%}
261
+ {%- elif loop_messages[k]['role'] != 'tool' -%}
262
+ {%- set ns_tool_scan.stopped = true -%}
263
+ {%- else -%}
264
+ {%- set follow = loop_messages[k] -%}
265
+ {%- set ns_tname = namespace(name=follow.get('name') | default('unknown')) -%}
266
+ {%- for tc in message['tool_calls'] -%}
267
+ {%- if tc.get('id') == follow.get('tool_call_id') -%}
268
+ {%- set ns_tname.name = tc['function']['name'] -%}
269
+ {%- endif -%}
270
+ {%- endfor -%}
271
+ {%- set tool_body = follow.get('content') -%}
272
+ {%- if tool_body is string -%}
273
+ {{- format_tool_response_block(ns_tname.name, tool_body) -}}
274
+ {%- elif tool_body is sequence and tool_body is not string -%}
275
+ {%- set ns_txt = namespace(s='') -%}
276
+ {%- for part in tool_body -%}
277
+ {%- if part.get('type') == 'text' -%}
278
+ {%- set ns_txt.s = ns_txt.s + (part.get('text') | default('')) -%}
279
+ {%- endif -%}
280
+ {%- endfor -%}
281
+ {{- format_tool_response_block(ns_tname.name, ns_txt.s) -}}
282
+ {%- else -%}
283
+ {{- format_tool_response_block(ns_tname.name, tool_body) -}}
284
+ {%- endif -%}
285
+ {%- set ns_tr_out.flag = true -%}
286
+ {%- set ns.prev_message_type = 'tool_response' -%}
287
+ {%- endif -%}
288
+ {%- endfor -%}
289
+ {%- endif -%}
290
+
291
+ {%- if message['content'] is string -%}
292
+ {%- if role == 'model' -%}
293
+ {{- strip_thinking(message['content']) -}}
294
+ {%- else -%}
295
+ {{- message['content'] | trim -}}
296
+ {%- endif -%}
297
+ {%- elif message['content'] is sequence -%}
298
+ {%- for item in message['content'] -%}
299
+ {%- if item['type'] == 'text' -%}
300
+ {%- if role == 'model' -%}
301
+ {{- strip_thinking(item['text']) -}}
302
+ {%- else -%}
303
+ {{- item['text'] | trim -}}
304
+ {%- endif -%}
305
+ {%- elif item['type'] == 'image' -%}
306
+ {{- '\n\n<|image|>\n\n' -}}
307
+ {%- set ns.prev_message_type = 'image' -%}
308
+ {%- elif item['type'] == 'audio' -%}
309
+ {{- '<|audio|>' -}}
310
+ {%- set ns.prev_message_type = 'audio' -%}
311
+ {%- elif item['type'] == 'video' -%}
312
+ {{- '\n\n<|video|>\n\n' -}}
313
+ {%- set ns.prev_message_type = 'video' -%}
314
+ {%- endif -%}
315
+ {%- endfor -%}
316
+ {%- endif -%}
317
+
318
+ {%- if not (ns_tr_out.flag and not message.get('content')) -%}
319
+ {{- '<turn|>\n' -}}
320
+ {%- endif -%}
321
+ {%- endif -%}
322
+{%- endfor -%}
323
+
324
+{%- if add_generation_prompt -%}
325
+ {%- if ns.prev_message_type != 'tool_response' -%}
326
+ {{- '<|turn>model\n' -}}
327
+ {%- endif -%}
328
+ {%- if not enable_thinking | default(false) -%}
329
+ {{- '<|channel>thought\n<channel|>' -}}
330
+ {%- endif -%}
331
+{%- endif -%}
tests/reasoning/test_gemma4_reasoning_parser.py
+87
-8
@@ -4,6 +4,9 @@
4
import pytest
5
6
from tests.reasoning.utils import run_reasoning_extraction
7
+from vllm.entrypoints.openai.chat_completion.protocol import (
8
+ ChatCompletionRequest,
9
+)
10
from vllm.reasoning import ReasoningParser, ReasoningParserManager
11
12
# Using mistral tokenizer as a generic mock since the actual model is not on HF
@@ -100,6 +103,39 @@ NEW_LINE_STREAMING = {
103
"is_reasoning_end": True,
104
}
105
106
+THOUGHT_PREFIX = {
107
+ "output": "<|channel>thought\nActual reasoning here<channel|>Final answer",
108
+ "reasoning": "Actual reasoning here",
109
+ "content": "Final answer",
110
+ "is_reasoning_end": True,
111
+}
112
+THOUGHT_PREFIX_ONLY = {
113
+ "output": "<|channel>thought\n<channel|>",
114
+ "reasoning": "",
115
+ "content": None,
116
+ "is_reasoning_end": True,
117
+}
118
+THOUGHT_PREFIX_MULTILINE = {
119
+ "output": "<|channel>thought\nLine1\nLine2<channel|>Answer",
120
+ "reasoning": "Line1\nLine2",
121
+ "content": "Answer",
122
+ "is_reasoning_end": True,
123
+}
124
+# "thousand" starts like "thought" but diverges — exercises Case 2→3 in streaming.
125
+THOUGHT_PREFIX_DIVERGE = {
126
+ "output": "<|channel>thousand reasons<channel|>Done",
127
+ "reasoning": "thousand reasons",
128
+ "content": "Done",
129
+ "is_reasoning_end": True,
130
+}
131
+# The model isn't reasoning if we're generating tool calls.
132
+TOOL_CALL_STARTED = {
133
+ "output": "<|tool_call>",
134
+ "reasoning": None,
135
+ "content": "<|tool_call>",
136
+ "is_reasoning_end": True,
137
+}
138
+
139
TEST_CASES = [
140
pytest.param(False, INVALID_SIMPLE_NONSTREAMING, id="invalid_simple"),
141
pytest.param(True, INVALID_SIMPLE_STREAMING, id="invalid_simple_streaming"),
@@ -120,17 +156,22 @@ TEST_CASES = [
156
pytest.param(False, EMPTY, id="empty"),
157
pytest.param(False, NEW_LINE_NONSTREAMING, id="new_line"),
158
pytest.param(True, NEW_LINE_STREAMING, id="new_line_streaming"),
159
+ pytest.param(False, THOUGHT_PREFIX, id="thought_prefix"),
160
+ pytest.param(True, THOUGHT_PREFIX, id="thought_prefix_streaming"),
161
+ pytest.param(False, THOUGHT_PREFIX_ONLY, id="thought_prefix_only"),
162
+ pytest.param(True, THOUGHT_PREFIX_ONLY, id="thought_prefix_only_streaming"),
163
+ pytest.param(False, THOUGHT_PREFIX_MULTILINE, id="thought_prefix_multiline"),
164
+ pytest.param(
165
+ True, THOUGHT_PREFIX_MULTILINE, id="thought_prefix_multiline_streaming"
166
+ ),
167
+ pytest.param(False, THOUGHT_PREFIX_DIVERGE, id="thought_prefix_diverge"),
168
+ pytest.param(True, THOUGHT_PREFIX_DIVERGE, id="thought_prefix_diverge_streaming"),
169
+ pytest.param(False, TOOL_CALL_STARTED, id="tool_call_started"),
170
+ pytest.param(True, TOOL_CALL_STARTED, id="tool_call_started_streaming"),
171
]
172
173
126
-@pytest.mark.parametrize("streaming, param_dict", TEST_CASES)
127
-def test_gemma4_reasoning(
128
- streaming: bool,
129
- param_dict: dict,
130
- generic_tokenizer,
131
-):
132
- output = param_dict["output"]
133
-
174
+def gemma4_encode_output(generic_tokenizer, output: str) -> list[int]:
175
# Resolve token IDs dynamically from the real tokenizer
176
vocab = generic_tokenizer.get_vocab()
177
start_token_id = vocab["<|channel>"]
@@ -176,6 +217,18 @@ def test_gemma4_reasoning(
217
else:
218
output_tokens += _encode(output)
219
220
+ return output_tokens
221
+
222
+
223
+@pytest.mark.parametrize("streaming, param_dict", TEST_CASES)
224
+def test_gemma4_reasoning(
225
+ streaming: bool,
226
+ param_dict: dict,
227
+ generic_tokenizer,
228
+):
229
+ output = param_dict["output"]
230
+ output_tokens = gemma4_encode_output(generic_tokenizer, output)
231
+
232
parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
233
generic_tokenizer
234
)
@@ -194,3 +247,29 @@ def test_gemma4_reasoning(
247
# Test is_reasoning_end
248
is_reasoning_end = parser.is_reasoning_end(output_tokens)
249
assert is_reasoning_end == param_dict["is_reasoning_end"]
250
+
251
+
252
+def test_gemma4_adjust_request(generic_tokenizer):
253
+ parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
254
+ generic_tokenizer
255
+ )
256
+
257
+ request = ChatCompletionRequest(messages=[], model="test-model")
258
+ assert request.skip_special_tokens is True
259
+
260
+ result = parser.adjust_request(request)
261
+ assert result.skip_special_tokens is False
262
+ assert result is request
263
+
264
+
265
+def test_gemma4_previous_turn_reasoning_is_reasoning_end(generic_tokenizer):
266
+ output = (
267
+ "<|channel>thought\n1st thought<channel|>1st content<turn|>\n"
268
+ "<|turn>user\nThanks<|turn>model\n"
269
+ )
270
+ output_tokens = gemma4_encode_output(generic_tokenizer, output)
271
+ parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
272
+ generic_tokenizer
273
+ )
274
+ is_reasoning_end = parser.is_reasoning_end(output_tokens)
275
+ assert not is_reasoning_end
tests/renderers/test_gemma4_chat_template.py
new
+345
@@ -0,0 +1,345 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""Tests for Gemma4 chat template rendering."""
5
+
6
+from pathlib import Path
7
+
8
+import jinja2.sandbox
9
+import pytest
10
+
11
+TEMPLATE_PATH = (
12
+ Path(__file__).resolve().parent.parent.parent
13
+ / "examples"
14
+ / "tool_chat_template_gemma4.jinja"
15
+)
16
+
17
+
18
+@pytest.fixture(scope="module")
19
+def gemma4_template():
20
+ """Load and compile the Gemma4 chat template."""
21
+ template_str = TEMPLATE_PATH.read_text()
22
+ env = jinja2.sandbox.ImmutableSandboxedEnvironment()
23
+ return env.from_string(template_str)
24
+
25
+
26
+def _render(template, messages, **kwargs):
27
+ """Render the template with sensible defaults."""
28
+ kwargs.setdefault("bos_token", "<bos>")
29
+ kwargs.setdefault("add_generation_prompt", False)
30
+ return template.render(messages=messages, **kwargs)
31
+
32
+
33
+class TestGemma4ChatTemplate:
34
+ def test_basic_multiturn_thinking_disabled(self, gemma4_template):
35
+ """With enable_thinking=False (default), generation prompt ends with
36
+ an empty thought channel to suppress thinking."""
37
+ messages = [
38
+ {"role": "user", "content": "Hello"},
39
+ {"role": "assistant", "content": "Hi there!"},
40
+ {"role": "user", "content": "How are you?"},
41
+ ]
42
+ result = _render(gemma4_template, messages, add_generation_prompt=True)
43
+ assert "<|turn>user\n" in result
44
+ assert "<|turn>model\n" in result
45
+ assert "Hello" in result
46
+ assert "Hi there!" in result
47
+ assert "How are you?" in result
48
+ assert result.rstrip("\n").endswith("<|channel>thought\n<channel|>")
49
+
50
+ def test_basic_multiturn_thinking_enabled(self, gemma4_template):
51
+ """With enable_thinking=True, generation prompt ends with model
52
+ turn opener (no thought suppression)."""
53
+ messages = [
54
+ {"role": "user", "content": "Hello"},
55
+ {"role": "assistant", "content": "Hi there!"},
56
+ {"role": "user", "content": "How are you?"},
57
+ ]
58
+ result = _render(
59
+ gemma4_template,
60
+ messages,
61
+ add_generation_prompt=True,
62
+ enable_thinking=True,
63
+ )
64
+ assert "<|turn>user\n" in result
65
+ assert "<|turn>model\n" in result
66
+ assert "Hello" in result
67
+ assert "Hi there!" in result
68
+ assert "How are you?" in result
69
+ assert result.rstrip("\n").endswith("<|turn>model")
70
+
71
+ def test_system_message(self, gemma4_template):
72
+ messages = [
73
+ {"role": "system", "content": "You are helpful."},
74
+ {"role": "user", "content": "Hi"},
75
+ ]
76
+ result = _render(gemma4_template, messages)
77
+ assert "<|turn>system\n" in result
78
+ assert "You are helpful." in result
79
+
80
+ def test_thinking_enabled(self, gemma4_template):
81
+ messages = [{"role": "user", "content": "Think about this"}]
82
+ result = _render(
83
+ gemma4_template,
84
+ messages,
85
+ add_generation_prompt=True,
86
+ enable_thinking=True,
87
+ )
88
+ assert "<|think|>" in result
89
+ assert "<|turn>system\n" in result
90
+
91
+ def test_tool_declarations(self, gemma4_template):
92
+ tools = [
93
+ {
94
+ "type": "function",
95
+ "function": {
96
+ "name": "get_weather",
97
+ "description": "Get weather for a city",
98
+ "parameters": {
99
+ "type": "object",
100
+ "properties": {
101
+ "city": {
102
+ "type": "string",
103
+ "description": "City name",
104
+ }
105
+ },
106
+ "required": ["city"],
107
+ },
108
+ },
109
+ }
110
+ ]
111
+ messages = [{"role": "user", "content": "What is the weather?"}]
112
+ result = _render(
113
+ gemma4_template,
114
+ messages,
115
+ tools=tools,
116
+ add_generation_prompt=True,
117
+ )
118
+ assert "<|tool>" in result
119
+ assert "declaration:get_weather" in result
120
+ assert "<tool|>" in result
121
+ assert '<|"|>City name<|"|>' in result
122
+
123
+ def test_tool_calls_in_assistant(self, gemma4_template):
124
+ messages = [
125
+ {"role": "user", "content": "Weather in London?"},
126
+ {
127
+ "role": "assistant",
128
+ "content": "",
129
+ "tool_calls": [
130
+ {
131
+ "id": "call_1",
132
+ "function": {
133
+ "name": "get_weather",
134
+ "arguments": {"city": "London"},
135
+ },
136
+ }
137
+ ],
138
+ },
139
+ ]
140
+ result = _render(gemma4_template, messages)
141
+ assert "<|tool_call>call:get_weather{" in result
142
+ assert "}<tool_call|>" in result
143
+ assert '<|"|>London<|"|>' in result
144
+
145
+ def test_tool_responses_openai_style(self, gemma4_template):
146
+ """role='tool' messages are formatted as <|tool_response> blocks
147
+ with content dumped as-is."""
148
+ messages = [
149
+ {"role": "user", "content": "Weather?"},
150
+ {
151
+ "role": "assistant",
152
+ "content": "",
153
+ "tool_calls": [
154
+ {
155
+ "id": "call_1",
156
+ "function": {
157
+ "name": "get_weather",
158
+ "arguments": {"city": "London"},
159
+ },
160
+ }
161
+ ],
162
+ },
163
+ {
164
+ "role": "tool",
165
+ "tool_call_id": "call_1",
166
+ "content": '{"temperature": 15, "condition": "sunny"}',
167
+ },
168
+ ]
169
+ result = _render(gemma4_template, messages, add_generation_prompt=True)
170
+ assert "<|tool_response>" in result
171
+ assert "response:get_weather{" in result
172
+ assert "<tool_response|>" in result
173
+ assert '"temperature": 15' in result
174
+
175
+ def test_tool_responses_legacy_style(self, gemma4_template):
176
+ """tool_responses embedded on the assistant message."""
177
+ messages = [
178
+ {"role": "user", "content": "Weather?"},
179
+ {
180
+ "role": "assistant",
181
+ "content": "",
182
+ "tool_calls": [
183
+ {
184
+ "function": {
185
+ "name": "get_weather",
186
+ "arguments": {"city": "London"},
187
+ },
188
+ }
189
+ ],
190
+ "tool_responses": [
191
+ {
192
+ "name": "get_weather",
193
+ "response": {"temperature": 20},
194
+ }
195
+ ],
196
+ },
197
+ ]
198
+ result = _render(gemma4_template, messages)
199
+ assert "<|tool_response>" in result
200
+ assert "response:get_weather{" in result
201
+ assert "temperature:" in result
202
+
203
+ def test_generation_prompt_not_after_tool_response(self, gemma4_template):
204
+ """add_generation_prompt=True should NOT add <|turn>model when the
205
+ last message type was tool_response (the model turn continues)."""
206
+ messages = [
207
+ {"role": "user", "content": "Weather?"},
208
+ {
209
+ "role": "assistant",
210
+ "content": "",
211
+ "tool_calls": [
212
+ {
213
+ "id": "call_1",
214
+ "function": {
215
+ "name": "get_weather",
216
+ "arguments": {"city": "London"},
217
+ },
218
+ }
219
+ ],
220
+ },
221
+ {
222
+ "role": "tool",
223
+ "tool_call_id": "call_1",
224
+ "content": "sunny",
225
+ },
226
+ ]
227
+ result = _render(gemma4_template, messages, add_generation_prompt=True)
228
+ assert not result.strip().endswith("<|turn>model\n")
229
+
230
+ def test_reasoning_in_tool_chains(self, gemma4_template):
231
+ """reasoning field on assistant with tool_calls after last user
232
+ message emits <|channel>thought\\n...<channel|>."""
233
+ messages = [
234
+ {"role": "user", "content": "Calculate something"},
235
+ {
236
+ "role": "assistant",
237
+ "content": "",
238
+ "reasoning": "Let me think about this...",
239
+ "tool_calls": [
240
+ {
241
+ "function": {
242
+ "name": "calculator",
243
+ "arguments": {"expr": "2+2"},
244
+ },
245
+ }
246
+ ],
247
+ },
248
+ ]
249
+ result = _render(gemma4_template, messages)
250
+ assert "<|channel>thought\n" in result
251
+ assert "Let me think about this..." in result
252
+ assert "<channel|>" in result
253
+
254
+ def test_reasoning_not_before_last_user(self, gemma4_template):
255
+ """reasoning on assistant BEFORE the last user message is dropped."""
256
+ messages = [
257
+ {"role": "user", "content": "First"},
258
+ {
259
+ "role": "assistant",
260
+ "content": "Response",
261
+ "reasoning": "Old reasoning that should be dropped",
262
+ "tool_calls": [
263
+ {
264
+ "function": {
265
+ "name": "fn",
266
+ "arguments": {},
267
+ },
268
+ }
269
+ ],
270
+ },
271
+ {"role": "user", "content": "Second"},
272
+ ]
273
+ result = _render(gemma4_template, messages, add_generation_prompt=True)
274
+ assert "Old reasoning" not in result
275
+
276
+ def test_strip_thinking_in_model_content(self, gemma4_template):
277
+ """<|channel>...<channel|> in model content is stripped by the
278
+ strip_thinking macro."""
279
+ messages = [
280
+ {"role": "user", "content": "Hi"},
281
+ {
282
+ "role": "assistant",
283
+ "content": ("<|channel>internal thought<channel|>Visible answer"),
284
+ },
285
+ ]
286
+ result = _render(gemma4_template, messages)
287
+ assert "internal thought" not in result
288
+ assert "Visible answer" in result
289
+
290
+ def test_multi_turn_tool_chain(self, gemma4_template):
291
+ """assistant->tool->assistant->tool produces exactly one
292
+ <|turn>model (later assistants continue the same turn)."""
293
+ messages = [
294
+ {"role": "user", "content": "Do two things"},
295
+ {
296
+ "role": "assistant",
297
+ "content": "",
298
+ "tool_calls": [
299
+ {
300
+ "id": "c1",
301
+ "function": {"name": "step1", "arguments": {}},
302
+ },
303
+ ],
304
+ },
305
+ {"role": "tool", "tool_call_id": "c1", "content": "result1"},
306
+ {
307
+ "role": "assistant",
308
+ "content": "",
309
+ "tool_calls": [
310
+ {
311
+ "id": "c2",
312
+ "function": {"name": "step2", "arguments": {}},
313
+ },
314
+ ],
315
+ },
316
+ {"role": "tool", "tool_call_id": "c2", "content": "result2"},
317
+ ]
318
+ result = _render(gemma4_template, messages, add_generation_prompt=True)
319
+ assert result.count("<|turn>model\n") == 1
320
+
321
+ def test_format_argument_types(self, gemma4_template):
322
+ """Strings wrapped in <|"|>, booleans as true/false, numbers bare."""
323
+ messages = [
324
+ {"role": "user", "content": "Test"},
325
+ {
326
+ "role": "assistant",
327
+ "content": "",
328
+ "tool_calls": [
329
+ {
330
+ "function": {
331
+ "name": "test_fn",
332
+ "arguments": {
333
+ "name": "Alice",
334
+ "active": True,
335
+ "count": 42,
336
+ },
337
+ },
338
+ }
339
+ ],
340
+ },
341
+ ]
342
+ result = _render(gemma4_template, messages)
343
+ assert '<|"|>Alice<|"|>' in result
344
+ assert "active:true" in result
345
+ assert "count:42" in result
tests/tool_parsers/test_gemma4_tool_parser.py
+40
@@ -114,6 +114,19 @@ class TestParseGemma4Args:
114
result = _parse_gemma4_args("key:")
115
assert result == {"key": ""}
116
117
+ def test_empty_value_partial_withheld(self):
118
+ """Key with no value is withheld in partial mode to avoid premature emission."""
119
+ result = _parse_gemma4_args("key:", partial=True)
120
+ assert result == {}
121
+ # also with a space after the colon
122
+ result = _parse_gemma4_args("key: ", partial=True)
123
+ assert result == {}
124
+
125
+ def test_empty_value_after_other_keys_partial_withheld(self):
126
+ """Trailing key with no value is withheld; earlier keys are kept."""
127
+ result = _parse_gemma4_args('name:<|"|>test<|"|>,flag:', partial=True)
128
+ assert result == {"name": "test"}
129
+
130
131
class TestParseGemma4Array:
132
def test_string_array(self):
@@ -636,3 +649,30 @@ class TestStreamingExtraction:
649
' <meta charset="UTF-8">\n'
650
' <meta name="viewport" content="width=device-width">\n'
651
)
652
+
653
+ def test_streaming_trailing_bare_bool_not_duplicated(self, parser, mock_request):
654
+ """Trailing bare boolean must not be streamed twice."""
655
+ chunks = [
656
+ "<|tool_call>",
657
+ "call:Edit{",
658
+ 'file_path:<|"|>src/env.py<|"|>,',
659
+ 'old_string:<|"|>old_val<|"|>,',
660
+ 'new_string:<|"|>new_val<|"|>,',
661
+ "replace_all:",
662
+ "false}",
663
+ "<tool_call|>",
664
+ ]
665
+
666
+ results = self._simulate_streaming(parser, mock_request, chunks)
667
+ args_text = self._collect_arguments(results)
668
+ assert args_text, "No arguments were streamed"
669
+
670
+ parsed_args = json.loads(args_text)
671
+ assert parsed_args == {
672
+ "file_path": "src/env.py",
673
+ "old_string": "old_val",
674
+ "new_string": "new_val",
675
+ "replace_all": False,
676
+ }
677
+
678
+ assert args_text.count("replace_all") == 1
vllm/entrypoints/anthropic/serving.py
+2
-1
@@ -170,7 +170,8 @@ class AnthropicServingMessages(OpenAIServingChat):
170
else:
171
cls._convert_message_content(msg, openai_msg, openai_messages)
172
173
- openai_messages.append(openai_msg)
173
+ if not (msg.role == "user" and "content" not in openai_msg):
174
+ openai_messages.append(openai_msg)
175
176
@classmethod
177
def _convert_message_content(
vllm/entrypoints/openai/api_server.py
+2
@@ -379,6 +379,7 @@ async def init_app_state(
379
enable_auto_tools=args.enable_auto_tool_choice,
380
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
381
tool_parser=args.tool_call_parser,
382
+ reasoning_parser=args.structured_outputs_config.reasoning_parser,
383
default_chat_template_kwargs=args.default_chat_template_kwargs,
384
log_error_stack=args.log_error_stack,
385
)
@@ -481,6 +482,7 @@ async def init_render_app_state(
482
enable_auto_tools=args.enable_auto_tool_choice,
483
exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
484
tool_parser=args.tool_call_parser,
485
+ reasoning_parser=args.structured_outputs_config.reasoning_parser,
486
default_chat_template_kwargs=args.default_chat_template_kwargs,
487
log_error_stack=args.log_error_stack,
488
)
vllm/entrypoints/openai/responses/serving.py
+2
@@ -594,6 +594,7 @@ class OpenAIServingResponses(OpenAIServing):
594
default_template_kwargs=None,
595
tool_dicts=tool_dicts,
596
tool_parser=self.parser.tool_parser_cls if self.parser else None,
597
+ reasoning_parser=self.parser.reasoning_parser_cls if self.parser else None,
598
)
599
return messages, engine_inputs
600
@@ -618,6 +619,7 @@ class OpenAIServingResponses(OpenAIServing):
619
default_template_kwargs=None,
620
tool_dicts=tool_dicts,
621
tool_parser=tool_parser,
622
+ reasoning_parser=self.parser.reasoning_parser_cls if self.parser else None,
623
)
624
return engine_inputs
625
vllm/entrypoints/serve/render/serving.py
+13
@@ -44,6 +44,7 @@ from vllm.inputs import (
44
)
45
from vllm.logger import init_logger
46
from vllm.parser import ParserManager
47
+from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
48
from vllm.renderers import BaseRenderer, merge_kwargs
49
from vllm.renderers.inputs.preprocess import (
50
extract_prompt_components,
@@ -74,6 +75,7 @@ class OpenAIServingRender:
75
enable_auto_tools: bool = False,
76
exclude_tools_when_tool_choice_none: bool = False,
77
tool_parser: str | None = None,
78
+ reasoning_parser: str | None = None,
79
default_chat_template_kwargs: dict[str, Any] | None = None,
80
log_error_stack: bool = False,
81
) -> None:
@@ -94,6 +96,11 @@ class OpenAIServingRender:
96
enable_auto_tools=enable_auto_tools,
97
model_name=model_config.model,
98
)
99
+ self.reasoning_parser: type[ReasoningParser] | None = (
100
+ ParserManager.get_reasoning_parser(
101
+ reasoning_parser_name=reasoning_parser,
102
+ )
103
+ )
104
self.default_chat_template_kwargs: dict[str, Any] = (
105
default_chat_template_kwargs or {}
106
)
@@ -245,6 +252,7 @@ class OpenAIServingRender:
252
default_template_kwargs=self.default_chat_template_kwargs,
253
tool_dicts=tool_dicts,
254
tool_parser=tool_parser,
255
+ reasoning_parser=self.reasoning_parser,
256
)
257
else:
258
# For GPT-OSS.
@@ -503,6 +511,7 @@ class OpenAIServingRender:
511
default_template_kwargs: dict[str, Any] | None,
512
tool_dicts: list[dict[str, Any]] | None = None,
513
tool_parser: type[ToolParser] | None = None,
514
+ reasoning_parser: type[ReasoningParser] | None = None,
515
*,
516
skip_mm_cache: bool = False,
517
) -> tuple[list[ConversationMessage], list[EngineInput]]:
@@ -539,6 +548,10 @@ class OpenAIServingRender:
548
skip_mm_cache=skip_mm_cache,
549
)
550
551
+ if reasoning_parser is not None:
552
+ tokenizer = renderer.get_tokenizer()
553
+ request = reasoning_parser(tokenizer).adjust_request(request=request)
554
+
555
# tool parsing is done only if a tool_parser has been set and if
556
# tool_choice is not "none" (if tool_choice is "none" but a tool_parser
557
# is set, we want to prevent parsing a tool_call hallucinated by the LLM
vllm/parser/abstract_parser.py
+9
@@ -496,6 +496,15 @@ class DelegatingParser(Parser):
496
# No tool calls
497
return [], content
498
499
+ def adjust_request(
500
+ self, request: ChatCompletionRequest | ResponsesRequest
501
+ ) -> ChatCompletionRequest | ResponsesRequest:
502
+ if self._reasoning_parser is not None:
503
+ request = self._reasoning_parser.adjust_request(request)
504
+ if self._tool_parser is not None:
505
+ request = self._tool_parser.adjust_request(request)
506
+ return request
507
+
508
def extract_reasoning_streaming(
509
self,
510
previous_text: str,
vllm/reasoning/abs_reasoning_parsers.py
+8
-2
@@ -6,7 +6,7 @@ import os
6
from abc import abstractmethod
7
from collections.abc import Callable, Iterable, Sequence
8
from functools import cached_property
9
-from typing import TYPE_CHECKING
9
+from typing import TYPE_CHECKING, cast
10
11
from vllm.entrypoints.mcp.tool_server import ToolServer
12
from vllm.logger import init_logger
@@ -150,6 +150,12 @@ class ReasoningParser:
150
previously been parsed and extracted (see constructor)
151
"""
152
153
+ def adjust_request(
154
+ self, request: "ChatCompletionRequest | ResponsesRequest"
155
+ ) -> "ChatCompletionRequest | ResponsesRequest":
156
+ """Adjust request parameters; override in subclasses as needed."""
157
+ return request
158
+
159
def prepare_structured_tag(
160
self,
161
original_tag: str | None,
@@ -298,7 +304,7 @@ class ReasoningParserManager:
304
if isinstance(name, str):
305
names = [name]
306
elif is_list_of(name, str):
301
- names = name
307
+ names = cast(list[str], name)
308
else:
309
names = [class_name]
310
vllm/reasoning/gemma4_reasoning_parser.py
+35
-3
@@ -52,6 +52,16 @@ class Gemma4ReasoningParser(BaseThinkingReasoningParser):
52
# skip_special_tokens=True).
53
self._reasoning_text: str = ""
54
self._prefix_stripped: bool = False
55
+ self.new_turn_token_id = self.vocab["<|turn>"]
56
+ self.tool_call_token_id = self.vocab["<|tool_call>"]
57
+ self.tool_response_token_id = self.vocab["<|tool_response>"]
58
+
59
+ def adjust_request(
60
+ self, request: "ChatCompletionRequest | ResponsesRequest"
61
+ ) -> "ChatCompletionRequest | ResponsesRequest":
62
+ """Disable special-token stripping to preserve boundary tokens."""
63
+ request.skip_special_tokens = False
64
+ return request
65
66
@property
67
def start_token(self) -> str:
@@ -63,6 +73,29 @@ class Gemma4ReasoningParser(BaseThinkingReasoningParser):
73
"""The token that ends reasoning content."""
74
return "<channel|>"
75
76
+ def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
77
+ start_token_id = self.start_token_id
78
+ end_token_id = self.end_token_id
79
+ new_turn_token_id = self.new_turn_token_id
80
+ tool_call_token_id = self.tool_call_token_id
81
+ tool_response_token_id = self.tool_response_token_id
82
+
83
+ # Search from the end of input_ids to find the last match.
84
+ for i in range(len(input_ids) - 1, -1, -1):
85
+ if input_ids[i] == start_token_id:
86
+ return False
87
+ if input_ids[i] == tool_call_token_id:
88
+ # We're generating a tool call, so reasoning must be ended.
89
+ return True
90
+ if input_ids[i] in (new_turn_token_id, tool_response_token_id):
91
+ # We found a new turn or tool response token so don't consider
92
+ # reasoning ended yet, since the model starts new reasoning
93
+ # after these tokens.
94
+ return False
95
+ if input_ids[i] == end_token_id:
96
+ return True
97
+ return False
98
+
99
# ------------------------------------------------------------------
100
# Non-streaming path
101
# ------------------------------------------------------------------
@@ -159,11 +192,10 @@ class Gemma4ReasoningParser(BaseThinkingReasoningParser):
192
result.reasoning = stripped
193
return result
194
else:
162
- # This entire delta was prefix — suppress it.
163
- # Don't set _prefix_stripped yet; there may be more
164
- # prefix chars to consume in the next delta.
195
if len(self._reasoning_text) >= prefix_len:
196
self._prefix_stripped = True
197
+ result.reasoning = ""
198
+ return result
199
return None
200
201
# Case 2: Accumulated text is a strict prefix of
vllm/tool_parsers/gemma4_tool_parser.py
+4
-2
@@ -122,14 +122,16 @@ def _parse_gemma4_args(args_str: str, *, partial: bool = False) -> dict:
122
123
# Parse value
124
if i >= n:
125
- result[key] = ""
125
+ if not partial:
126
+ result[key] = ""
127
break
128
129
# Skip whitespace after ':'
130
while i < n and args_str[i] in (" ", "\n", "\t"):
131
i += 1
132
if i >= n:
132
- result[key] = ""
133
+ if not partial:
134
+ result[key] = ""
135
break
136
137
# String value: <|"|>...<|"|>