[Frontend] Parse tool calls and reasoning from checkpoint response templates (#58604)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Yoni Gozlan <yonigozlan@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Yoni Gozlan committed
Oct 1, 2026 at 22:33 UTC
9bd993564f5f3d172f9da398fd56b64db1074d9e
18 files changed
+2298
-21
docs/features/tool_calling.md
+23
@@ -171,6 +171,29 @@ from HuggingFace; and you can find an example of this in a `tokenizer_config.jso
171
172
If your favorite tool-calling model is not supported, please feel free to contribute a parser & tool use chat template!
173
174
+### Checkpoint Response Templates (`hf`)
175
+
176
+Checkpoints can define a `response_template` in `tokenizer_config.json` to describe their reasoning, content, and tool-call wire format. Select the
177
+`hf` parser to parse output from this metadata instead of a model-specific parser. Like `--tokenizer-mode hf`, it relies on the checkpoint's
178
+Hugging Face metadata rather than model-specific code:
179
+
180
+```bash
181
+vllm serve <model> \
182
+ --enable-auto-tool-choice \
183
+ --tool-call-parser hf \
184
+ --reasoning-parser hf
185
+```
186
+
187
+Either parser can be selected on its own. Startup fails if the tokenizer has no `response_template`, or if it lacks the `thinking` or `tool_calls`
188
+field the selected parser needs. The `hf` parser cannot be combined with other reasoning or tool call parsers.
189
+
190
+When streaming, each tool call is emitted whole once its region parses. A call cut off before its closer, for example by a closer that is also a
191
+stop token, is parsed from the text generated so far; a call that does not parse is dropped.
192
+
193
+Response templates currently provide parsing, not format-specific constrained decoding. The parser therefore rejects requests for strict tools,
194
+required or named tool choice, or `parallel_tool_calls=false`. A custom server or request chat template is allowed, but the parser still expects the
195
+checkpoint's output format.
196
+
197
!!! note
198
With `tool_choice="auto"`, structural-tag constraints require both `VLLM_ENFORCE_STRICT_TOOL_CALLING=true` (the default) and at least one tool with `strict: true`, or a server-side floor set via `--tool-strict-level`. When these conditions are met and the selected parser supports structural tags, vLLM constrains the tool-call envelope and pins the argument schema of each tool that sets `strict: true` (or of every tool under `--tool-strict-level parameter`). Otherwise, vLLM extracts tool calls from raw text, so arguments may occasionally be malformed or violate the function's parameter schema.
199
tests/entrypoints/openai/chat_completion/test_batched_chat_completions.py
+56
-4
@@ -246,11 +246,15 @@ async def test_batched_chat_completions_logprob_token_ids(
246
}
247
248
249
-def _make_request_output(prompt_idx: int, text: str) -> RequestOutput:
249
+def _make_request_output(
250
+ prompt_idx: int,
251
+ text: str,
252
+ prompt_token_ids: list[int] | None = None,
253
+) -> RequestOutput:
254
return RequestOutput(
255
request_id=f"req-{prompt_idx}",
256
prompt=None,
253
- prompt_token_ids=[1, 2, 3],
257
+ prompt_token_ids=prompt_token_ids or [1, 2, 3],
258
prompt_logprobs=None,
259
outputs=[
260
CompletionOutput(
@@ -266,8 +270,12 @@ def _make_request_output(prompt_idx: int, text: str) -> RequestOutput:
270
)
271
272
269
-async def _generator(prompt_idx: int, text: str) -> AsyncGenerator[RequestOutput, None]:
270
- yield _make_request_output(prompt_idx, text)
273
+async def _generator(
274
+ prompt_idx: int,
275
+ text: str,
276
+ prompt_token_ids: list[int] | None = None,
277
+) -> AsyncGenerator[RequestOutput, None]:
278
+ yield _make_request_output(prompt_idx, text, prompt_token_ids)
279
280
281
@pytest.mark.asyncio
@@ -402,3 +410,47 @@ def test_batch_rejects_kv_transfer_prompt_token_ids():
410
],
411
kv_transfer_params={"prompt_token_ids": [10, 20, 30]},
412
)
413
+
414
+
415
+@pytest.mark.asyncio
416
+@pytest.mark.skip_global_cleanup
417
+async def test_batched_parser_receives_each_prompt_prefix() -> None:
418
+ prefixes = []
419
+
420
+ class RecordingParser:
421
+ def __init__(self, *args, **kwargs):
422
+ self.prompt_token_ids = None
423
+
424
+ def set_prompt_token_ids(self, prompt_token_ids):
425
+ self.prompt_token_ids = prompt_token_ids
426
+
427
+ def parse(self, model_output, **kwargs):
428
+ prefixes.append(self.prompt_token_ids)
429
+ return None, model_output, None
430
+
431
+ serving = OpenAIServingChatBatch.__new__(OpenAIServingChatBatch)
432
+ serving.response_role = "assistant"
433
+ serving.system_fingerprint = None
434
+ serving.chat_template = None
435
+ serving.chat_template_content_format = "auto"
436
+ serving.default_chat_template_kwargs = {}
437
+
438
+ request = BatchChatCompletionRequest(
439
+ model="test-model",
440
+ messages=[
441
+ [{"role": "user", "content": "first"}],
442
+ [{"role": "user", "content": "second"}],
443
+ ],
444
+ )
445
+ await serving.chat_completion_full_generator_batch(
446
+ request=request,
447
+ generators=[_generator(0, "one", [1]), _generator(1, "two", [2])],
448
+ request_id="req-prefix",
449
+ model_name="test-model",
450
+ all_conversations=request.messages,
451
+ tokenizer=object(),
452
+ request_metadata=RequestResponseMetadata(request_id="req-prefix"),
453
+ parser_cls=RecordingParser,
454
+ )
455
+
456
+ assert prefixes == [[1], [2]]
tests/entrypoints/openai/chat_completion/test_serving_chat.py
+73
@@ -790,6 +790,64 @@ async def test_chat_per_request_metrics_suppressed_for_n_greater_than_one():
790
assert response.metrics is None
791
792
793
+@pytest.mark.asyncio
794
+async def test_chat_full_generator_gives_each_choice_parser_the_prompt():
795
+ prompts: list[list[int] | None] = []
796
+
797
+ class RecordingParser:
798
+ tool_parser_cls = None
799
+
800
+ def __init__(self, *args, **kwargs):
801
+ self.prompt_token_ids = None
802
+
803
+ def set_prompt_token_ids(self, prompt_token_ids):
804
+ self.prompt_token_ids = list(prompt_token_ids)
805
+
806
+ def parse(self, model_output, request, **kwargs):
807
+ prompts.append(self.prompt_token_ids)
808
+ return None, model_output, None
809
+
810
+ def count_reasoning_tokens(self, token_ids):
811
+ return 0
812
+
813
+ serving = _build_minimal_metrics_serving_chat(enable_per_request_metrics=False)
814
+ serving.parser_cls = RecordingParser
815
+ serving.model_config = None
816
+ serving.chat_template = None
817
+ serving.chat_template_content_format = "auto"
818
+ serving.default_chat_template_kwargs = {}
819
+ request_output = _make_metrics_request_output()
820
+ request_output.prompt_token_ids = [7, 8]
821
+ request_output.outputs.append(
822
+ CompletionOutput(
823
+ index=1,
824
+ text="Hi",
825
+ token_ids=[102],
826
+ cumulative_logprob=None,
827
+ logprobs=None,
828
+ finish_reason="stop",
829
+ )
830
+ )
831
+
832
+ await serving.chat_completion_full_generator(
833
+ ChatCompletionRequest(
834
+ model="test-model",
835
+ messages=[{"role": "user", "content": "Test prompt"}],
836
+ max_tokens=10,
837
+ n=2,
838
+ ),
839
+ _single_request_output(request_output),
840
+ "chatcmpl-test-id",
841
+ "test-model",
842
+ conversation=[{"role": "user", "content": "Test"}],
843
+ tokenizer=MagicMock(),
844
+ request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"),
845
+ parser=RecordingParser(),
846
+ )
847
+
848
+ assert prompts == [[7, 8], [7, 8]]
849
+
850
+
851
@pytest.mark.asyncio
852
async def test_chat_streaming_metrics_ride_on_usage_chunk():
853
serving = _build_minimal_metrics_serving_chat(enable_per_request_metrics=True)
@@ -1608,6 +1666,21 @@ async def test_serving_chat_truncation_side_controls_prompt_truncation():
1666
assert left_token_ids == full_token_ids[-4:]
1667
1668
1669
+def test_serving_chat_starts_without_tokenizer():
1670
+ """Regression test: building the serving objects must not require a
1671
+ tokenizer, which is absent under `skip_tokenizer_init`."""
1672
+ mock_engine = _build_mock_engine()
1673
+ mock_engine.model_config = MockModelConfig(skip_tokenizer_init=True)
1674
+ mock_engine.renderer = HfRenderer(
1675
+ MockVllmConfig(mock_engine.model_config, parallel_config=MockParallelConfig()),
1676
+ None,
1677
+ )
1678
+
1679
+ serving_chat = _build_serving_chat(mock_engine)
1680
+
1681
+ assert serving_chat.parser_cls is None
1682
+
1683
+
1684
@pytest.mark.asyncio
1685
async def test_serving_chat_mistral_token_ids_prompt_is_validated():
1686
"""Regression test: when the Mistral tokenizer path returns token IDs
tests/entrypoints/scale_out/derender/test_derender_stream.py
+39
@@ -296,6 +296,45 @@ def parsed_derenderer(tokenizer):
296
return dr
297
298
299
+def test_non_streaming_derender_initializes_parser_prefix(
300
+ derenderer, tokenizer, monkeypatch
301
+):
302
+ from unittest.mock import MagicMock
303
+
304
+ from vllm.entrypoints.generate.base.protocol import FunctionCall
305
+ from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
306
+
307
+ parser = MagicMock()
308
+ parser.parse.return_value = (
309
+ None,
310
+ None,
311
+ [FunctionCall(name="tool", arguments="{}")],
312
+ )
313
+ monkeypatch.setattr(derenderer, "parser", MagicMock(return_value=parser))
314
+ generated_ids = tokenizer.encode("answer", add_special_tokens=False)
315
+ response = GenerateResponse(
316
+ request_id="test",
317
+ prompt_token_ids=[11, 12],
318
+ choices=[
319
+ GenerateResponseChoice(
320
+ index=0,
321
+ token_ids=generated_ids,
322
+ finish_reason="length",
323
+ )
324
+ ],
325
+ )
326
+ request = ChatCompletionRequest(
327
+ model=MODEL_NAME,
328
+ messages=[{"role": "user", "content": "question"}],
329
+ )
330
+
331
+ choices = derenderer._derender_chat(response, request)
332
+
333
+ parser.set_prompt_token_ids.assert_called_once_with([11, 12])
334
+ assert choices[0].message.tool_calls[0].function.name == "tool"
335
+ assert choices[0].finish_reason == "length"
336
+
337
+
338
class TestDetokenizeDelta:
339
"""_detokenize_delta: chunked decode must equal one shot decode."""
340
tests/parser/test_response_template.py
new
+1349
@@ -0,0 +1,1349 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import json
5
+from copy import deepcopy
6
+from types import SimpleNamespace
7
+from typing import Any
8
+
9
+import pytest
10
+from openai.types.responses import NamespaceTool
11
+
12
+from vllm.entrypoints.openai.chat_completion.protocol import (
13
+ ChatCompletionNamedToolChoiceParam,
14
+ ChatCompletionToolsParam,
15
+)
16
+from vllm.exceptions import VLLMValidationError
17
+from vllm.parser import response_template as response_template_module
18
+from vllm.parser.gemma4 import Gemma4Parser
19
+from vllm.parser.parser_manager import ParserManager
20
+from vllm.parser.response_template import (
21
+ ResponseTemplateParser,
22
+ ResponseTemplateReasoningParser,
23
+ ResponseTemplateToolParser,
24
+ resolve_response_template,
25
+)
26
+from vllm.reasoning import ReasoningParserManager
27
+from vllm.tool_parsers import ToolParserManager
28
+
29
+# Public metadata shared by the Gemma 4 instruction checkpoints.
30
+GEMMA4_RESPONSE_TEMPLATE: dict[str, Any] = {
31
+ "defaults": {"role": "assistant"},
32
+ "fields": {
33
+ "content": {
34
+ "close": ["<turn|>", "<|tool_response>", "<eos>"],
35
+ "content": "text",
36
+ },
37
+ "thinking": {
38
+ "close": "<channel|>",
39
+ "content": "text",
40
+ "open": "<|channel>thought\n",
41
+ },
42
+ "tool_calls": {
43
+ "close": "<tool_call|>",
44
+ "content": "json",
45
+ "content_args": {
46
+ "string_delims": [['<|"|>', '<|"|>']],
47
+ "unquoted_keys": True,
48
+ },
49
+ "open_pattern": r"<\|tool_call>call:(?P<name>\w+)",
50
+ "repeats": True,
51
+ "transform": {
52
+ "function": {
53
+ "arguments": "{content}",
54
+ "name": "{name}",
55
+ },
56
+ "type": "function",
57
+ },
58
+ },
59
+ },
60
+ "start_anchor": ["<|turn>model\n", "<tool_response|>"],
61
+}
62
+
63
+PREFIX = "<|turn>model\n"
64
+THINKING = "<|channel>thought\nplan<channel|>"
65
+TOOL_GENERATION = (
66
+ '<|tool_call>call:set_alarm{hour:7,label:<|"|>morning<|"|>}<tool_call|>'
67
+)
68
+
69
+TOOLS = [
70
+ ChatCompletionToolsParam(
71
+ type="function",
72
+ function={
73
+ "name": "set_alarm",
74
+ "parameters": {
75
+ "type": "object",
76
+ "properties": {
77
+ "hour": {
78
+ "oneOf": [
79
+ {"type": "integer"},
80
+ {"type": "null"},
81
+ ]
82
+ },
83
+ "label": {"type": "string"},
84
+ },
85
+ },
86
+ },
87
+ ),
88
+ ChatCompletionToolsParam(
89
+ type="function",
90
+ function={
91
+ "name": "get_weather",
92
+ "parameters": {
93
+ "type": "object",
94
+ "properties": {
95
+ "city": {"type": "string"},
96
+ "unit": {"type": "string"},
97
+ },
98
+ },
99
+ },
100
+ ),
101
+ ChatCompletionToolsParam(
102
+ type="function",
103
+ function={
104
+ "name": "set_status",
105
+ "parameters": {
106
+ "type": "object",
107
+ "properties": {
108
+ "is_active": {"type": "boolean"},
109
+ "count": {"type": "integer"},
110
+ "score": {"type": "number"},
111
+ },
112
+ },
113
+ },
114
+ ),
115
+ ChatCompletionToolsParam(
116
+ type="function",
117
+ function={
118
+ "name": "complex_function",
119
+ "parameters": {
120
+ "type": "object",
121
+ "properties": {
122
+ "nested": {
123
+ "type": "object",
124
+ "properties": {"inner": {"type": "string"}},
125
+ },
126
+ "list": {
127
+ "type": "array",
128
+ "items": {"type": "string"},
129
+ },
130
+ },
131
+ },
132
+ },
133
+ ),
134
+ ChatCompletionToolsParam(
135
+ type="function",
136
+ function={
137
+ "name": "configure",
138
+ "parameters": {
139
+ "type": "object",
140
+ "properties": {
141
+ "enabled": {"type": "boolean"},
142
+ "ratio": {"type": "number"},
143
+ "label": {"type": "string"},
144
+ "value": {"type": ["string", "null"]},
145
+ },
146
+ },
147
+ },
148
+ ),
149
+ ChatCompletionToolsParam(
150
+ type="function",
151
+ function={
152
+ "name": "get_status",
153
+ "parameters": {"type": "object", "properties": {}},
154
+ },
155
+ ),
156
+ ChatCompletionToolsParam(
157
+ type="function",
158
+ function={
159
+ "name": "get-weather",
160
+ "parameters": {
161
+ "type": "object",
162
+ "properties": {"city": {"type": "string"}},
163
+ },
164
+ },
165
+ ),
166
+ ChatCompletionToolsParam(
167
+ type="function",
168
+ function={
169
+ "name": "search",
170
+ "parameters": {
171
+ "type": "object",
172
+ "properties": {
173
+ "query": {"type": "string"},
174
+ "filters": {
175
+ "type": "object",
176
+ "properties": {
177
+ "language": {"type": "string"},
178
+ "min_stars": {"type": "integer"},
179
+ },
180
+ },
181
+ },
182
+ },
183
+ },
184
+ ),
185
+]
186
+
187
+VOCAB = {
188
+ "<|tool_call>": 48,
189
+ "<tool_call|>": 49,
190
+ "<|channel>": 50,
191
+ "<channel|>": 51,
192
+ '<|"|>': 52,
193
+ "<|turn>": 53,
194
+ "<|tool_response>": 54,
195
+ "<turn|>": 55,
196
+}
197
+PROMPT_TOKEN_IDS = [1]
198
+OPEN_REASONING_PROMPT_TOKEN_IDS = [53, 100, 50, 101]
199
+PROMPT_TOKEN_TEXT = {
200
+ 100: "model\n",
201
+ 101: "thought\n",
202
+ 102: "plan",
203
+ 103: "answer",
204
+}
205
+
206
+
207
+class FakeTokenizer:
208
+ def __init__(self, response_template=None, *, prefix=PREFIX):
209
+ self.response_template = response_template
210
+ self.init_kwargs = {}
211
+ self.name_or_path = ""
212
+ self.prefix = prefix
213
+ self.all_special_tokens = list(VOCAB)
214
+ self.all_special_ids = list(VOCAB.values())
215
+
216
+ def decode(self, token_ids, skip_special_tokens=False, **_kwargs):
217
+ if token_ids == [2]:
218
+ return PREFIX + "answer"
219
+ if token_ids == OPEN_REASONING_PROMPT_TOKEN_IDS:
220
+ return PREFIX + "<|channel>thought\n"
221
+ if token_ids != PROMPT_TOKEN_IDS:
222
+ inverse_vocab = {token_id: token for token, token_id in VOCAB.items()}
223
+ return "".join(
224
+ PROMPT_TOKEN_TEXT.get(token_id, inverse_vocab.get(token_id, ""))
225
+ for token_id in token_ids
226
+ )
227
+ return self.prefix if token_ids else ""
228
+
229
+ def get_vocab(self):
230
+ return VOCAB
231
+
232
+ def encode(self, text, add_special_tokens=False):
233
+ del add_special_tokens
234
+ if text == "plan":
235
+ return [102]
236
+ return list(text.encode())
237
+
238
+
239
+def request(tool_choice="auto", *, include_reasoning=True, **overrides):
240
+ values = dict(
241
+ tool_choice=tool_choice,
242
+ include_reasoning=include_reasoning,
243
+ tools=TOOLS,
244
+ skip_special_tokens=True,
245
+ spaces_between_special_tokens=True,
246
+ include_stop_str_in_output=False,
247
+ parallel_tool_calls=True,
248
+ )
249
+ values.update(overrides)
250
+ return SimpleNamespace(**values)
251
+
252
+
253
+def normalize_calls(calls):
254
+ return [(call.name, json.loads(call.arguments)) for call in calls or []]
255
+
256
+
257
+def feed_chunks(
258
+ parser,
259
+ chunks,
260
+ req,
261
+ *,
262
+ prompt_token_ids=PROMPT_TOKEN_IDS,
263
+):
264
+ deltas = []
265
+ for index, chunk in enumerate(chunks):
266
+ delta = parser.parse_delta(
267
+ chunk,
268
+ [],
269
+ req,
270
+ prompt_token_ids=prompt_token_ids if index == 0 else None,
271
+ finished=index == len(chunks) - 1,
272
+ )
273
+ if delta is not None:
274
+ deltas.append(delta)
275
+ return deltas
276
+
277
+
278
+def collect_stream(
279
+ parser,
280
+ text,
281
+ req,
282
+ chunk_size,
283
+ *,
284
+ prompt_token_ids=PROMPT_TOKEN_IDS,
285
+):
286
+ reasoning = []
287
+ content = []
288
+ calls: dict[int, dict[str, str]] = {}
289
+ for start in range(0, len(text), chunk_size):
290
+ end = min(start + chunk_size, len(text))
291
+ delta = parser.parse_delta(
292
+ text[start:end],
293
+ [],
294
+ req,
295
+ prompt_token_ids=prompt_token_ids if start == 0 else None,
296
+ finished=end == len(text),
297
+ )
298
+ if delta is None:
299
+ continue
300
+ if delta.reasoning:
301
+ reasoning.append(delta.reasoning)
302
+ if delta.content:
303
+ content.append(delta.content)
304
+ for call in delta.tool_calls:
305
+ function = call.function
306
+ assert function is not None
307
+ slot = calls.setdefault(
308
+ call.index,
309
+ {"name": "", "arguments": ""},
310
+ )
311
+ slot["name"] += function.name or ""
312
+ slot["arguments"] += function.arguments or ""
313
+ normalized_calls = [
314
+ (call["name"], json.loads(call["arguments"]))
315
+ for _, call in sorted(calls.items())
316
+ ]
317
+ return "".join(reasoning), "".join(content), normalized_calls
318
+
319
+
320
+def test_resolve_response_template_from_tokenizer_configuration():
321
+ tokenizer = FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE)
322
+ assert resolve_response_template(tokenizer) == GEMMA4_RESPONSE_TEMPLATE
323
+
324
+ tokenizer = FakeTokenizer()
325
+ del tokenizer.response_template
326
+ tokenizer.init_kwargs["response_template"] = GEMMA4_RESPONSE_TEMPLATE
327
+ assert resolve_response_template(tokenizer) == GEMMA4_RESPONSE_TEMPLATE
328
+
329
+ explicit = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
330
+ explicit["defaults"]["role"] = "custom"
331
+ assert resolve_response_template(tokenizer, explicit) is explicit
332
+
333
+
334
+def test_response_template_parsers_are_registered():
335
+ assert ToolParserManager.get_tool_parser("hf") is ResponseTemplateToolParser
336
+ assert (
337
+ ReasoningParserManager.get_reasoning_parser("hf")
338
+ is ResponseTemplateReasoningParser
339
+ )
340
+
341
+
342
+@pytest.mark.parametrize(
343
+ ("reasoning", "tools"),
344
+ [(True, True), (True, False), (False, True)],
345
+ ids=["both", "reasoning", "tools"],
346
+)
347
+def test_parser_manager_selects_response_template_by_name(reasoning, tools):
348
+ parser_cls = ParserManager.get_parser(
349
+ tool_parser_name="hf" if tools else None,
350
+ reasoning_parser_name="hf" if reasoning else None,
351
+ enable_auto_tools=tools,
352
+ tokenizer=FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
353
+ )
354
+
355
+ assert parser_cls is not None
356
+ assert issubclass(parser_cls, ResponseTemplateParser)
357
+ assert (parser_cls.reasoning_parser_cls is not None) is reasoning
358
+ assert (parser_cls.tool_parser_cls is not None) is tools
359
+
360
+
361
+def test_parser_manager_validates_checkpoint_metadata_at_startup():
362
+ with pytest.raises(TypeError, match="requires `response_template` metadata"):
363
+ ParserManager.get_parser(
364
+ reasoning_parser_name="hf",
365
+ tokenizer=FakeTokenizer(),
366
+ )
367
+ with pytest.raises(TypeError, match="Invalid response_template"):
368
+ ParserManager.get_parser(
369
+ reasoning_parser_name="hf",
370
+ tokenizer=FakeTokenizer({"fields": {}}),
371
+ )
372
+
373
+ template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
374
+ del template["fields"]["tool_calls"]
375
+ with pytest.raises(TypeError, match="tool_calls field"):
376
+ ParserManager.get_parser(
377
+ tool_parser_name="hf",
378
+ enable_auto_tools=True,
379
+ tokenizer=FakeTokenizer(template),
380
+ )
381
+ assert (
382
+ ParserManager.get_parser(
383
+ reasoning_parser_name="hf",
384
+ tokenizer=FakeTokenizer(template),
385
+ )
386
+ is not None
387
+ )
388
+
389
+
390
+def test_response_template_parser_rejects_other_parsers():
391
+ with pytest.raises(TypeError, match="cannot be combined"):
392
+ ParserManager.get_parser(
393
+ tool_parser_name="hermes",
394
+ reasoning_parser_name="hf",
395
+ enable_auto_tools=True,
396
+ tokenizer=FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
397
+ )
398
+
399
+
400
+def test_request_chat_template_is_honored_with_warning(monkeypatch):
401
+ warnings: list[str] = []
402
+ monkeypatch.setattr(
403
+ response_template_module.logger, "warning_once", warnings.append
404
+ )
405
+ parser = ResponseTemplateParser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
406
+
407
+ parser.adjust_request(request(chat_template="{{ messages }}"))
408
+
409
+ assert len(warnings) == 1
410
+ assert parser.response_template.fields
411
+
412
+
413
+def test_reasoning_gate_waits_for_content_region():
414
+ parser = ResponseTemplateParser(
415
+ FakeTokenizer(
416
+ GEMMA4_RESPONSE_TEMPLATE,
417
+ prefix=PREFIX + "<|channel>thought\n",
418
+ ),
419
+ TOOLS,
420
+ )
421
+
422
+ assert parser.is_reasoning_end([1]) is False
423
+ assert parser.is_reasoning_end([2]) is True
424
+
425
+
426
+def test_reasoning_gate_ends_at_content_opener_held_by_prompt():
427
+ template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
428
+ template["fields"]["content"]["open_pattern"] = r"<\|channel>final\n"
429
+ parser = ResponseTemplateParser(
430
+ FakeTokenizer(template, prefix=PREFIX + "<|channel>final\n"),
431
+ TOOLS,
432
+ )
433
+
434
+ assert parser.is_reasoning_end([1]) is True
435
+
436
+
437
+def streamed_reasoning_end(reasoner, output, prompt=PROMPT_TOKEN_IDS):
438
+ """Index of the output token where the per-step gate reports the end."""
439
+ for index, token in enumerate(output):
440
+ if reasoner.is_reasoning_end_streaming(prompt + output[: index + 1], [token]):
441
+ return index
442
+ return None
443
+
444
+
445
+def test_streaming_reasoning_gate_matches_thinking_closer_without_decoding():
446
+ tokenizer = FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE)
447
+ reasoner = ResponseTemplateReasoningParser(tokenizer)
448
+ tokenizer.decode = lambda *_args, **_kwargs: pytest.fail("decoded") # type: ignore[method-assign]
449
+ output = [50, 101] + [102] * 100 + [51, 103]
450
+
451
+ assert streamed_reasoning_end(reasoner, output) == len(output) - 2
452
+ assert reasoner.is_reasoning_end_streaming(PROMPT_TOKEN_IDS + output, output[-3:])
453
+
454
+
455
+def test_reasoning_gate_honors_disabled_and_repeated_thinking():
456
+ disabled = ResponseTemplateParser(
457
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
458
+ TOOLS,
459
+ chat_template_kwargs={"enable_thinking": False},
460
+ )
461
+ assert disabled.is_reasoning_end([1]) is True
462
+
463
+ repeated_template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
464
+ repeated_template["fields"]["thinking"]["repeats"] = True
465
+ repeated = ResponseTemplateParser(
466
+ FakeTokenizer(repeated_template),
467
+ TOOLS,
468
+ response_template=repeated_template,
469
+ )
470
+ assert repeated.is_reasoning_end(OPEN_REASONING_PROMPT_TOKEN_IDS) is False
471
+ assert repeated.is_reasoning_end([2]) is False
472
+
473
+
474
+@pytest.mark.parametrize(
475
+ "text",
476
+ [
477
+ "Hello<turn|>",
478
+ THINKING + "The answer is 42.<turn|>",
479
+ THINKING + TOOL_GENERATION,
480
+ "Let me check." + TOOL_GENERATION,
481
+ (
482
+ '<|tool_call>call:get_weather{city:<|"|>Paris<|"|>,'
483
+ 'unit:<|"|>celsius<|"|>}<tool_call|>' + TOOL_GENERATION
484
+ ),
485
+ (
486
+ "<|tool_call>call:complex_function{"
487
+ 'nested:{inner:<|"|>value<|"|>},'
488
+ 'list:[<|"|>a<|"|>,<|"|>b<|"|>]}'
489
+ "<tool_call|>"
490
+ ),
491
+ ],
492
+ ids=[
493
+ "content",
494
+ "reasoning",
495
+ "tool",
496
+ "content_then_tool",
497
+ "multiple_tools",
498
+ "nested_arguments",
499
+ ],
500
+)
501
+def test_gemma4_response_template_matches_registered_parser(text):
502
+ tokenizer = FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE)
503
+ req = request()
504
+ registered = Gemma4Parser(tokenizer, TOOLS)
505
+ metadata = ResponseTemplateParser(
506
+ tokenizer,
507
+ TOOLS,
508
+ response_template=GEMMA4_RESPONSE_TEMPLATE,
509
+ )
510
+ metadata.set_prompt_token_ids([1])
511
+
512
+ expected_reasoning, expected_content, expected_calls = registered.parse(
513
+ text,
514
+ req,
515
+ enable_auto_tools=True,
516
+ )
517
+ reasoning, content, calls = metadata.parse(
518
+ text,
519
+ req,
520
+ enable_auto_tools=True,
521
+ )
522
+
523
+ assert reasoning == expected_reasoning
524
+ assert content == expected_content
525
+ assert normalize_calls(calls) == normalize_calls(expected_calls)
526
+
527
+
528
+def test_metadata_parser_keeps_already_decoded_nullable_values():
529
+ text = (
530
+ "<|tool_call>call:configure{"
531
+ "enabled:false,ratio:5.0,label:null,value:null}"
532
+ "<tool_call|>"
533
+ )
534
+ parser = ResponseTemplateParser(
535
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
536
+ TOOLS,
537
+ response_template=GEMMA4_RESPONSE_TEMPLATE,
538
+ )
539
+ parser.set_prompt_token_ids([1])
540
+
541
+ _, content, calls = parser.parse(
542
+ text,
543
+ request(),
544
+ enable_auto_tools=True,
545
+ )
546
+
547
+ assert content is None
548
+ assert normalize_calls(calls) == [
549
+ (
550
+ "configure",
551
+ {
552
+ "enabled": False,
553
+ "ratio": 5.0,
554
+ "label": None,
555
+ "value": None,
556
+ },
557
+ )
558
+ ]
559
+
560
+
561
+@pytest.mark.parametrize(
562
+ "text",
563
+ [
564
+ THINKING + TOOL_GENERATION,
565
+ THINKING + "The answer is 42.<turn|>",
566
+ "Let me check. " + TOOL_GENERATION,
567
+ (
568
+ '<|tool_call>call:get_weather{city:<|"|>Paris<|"|>}<tool_call|>'
569
+ + TOOL_GENERATION
570
+ ),
571
+ ],
572
+ ids=[
573
+ "reasoning_then_tool",
574
+ "reasoning_then_content",
575
+ "content_then_tool",
576
+ "multiple_tools",
577
+ ],
578
+)
579
+@pytest.mark.parametrize("chunk_size", [1, 7, 10_000])
580
+def test_gemma4_response_template_streaming_matches_registered_parser(
581
+ text,
582
+ chunk_size,
583
+):
584
+ req = request()
585
+ registered = Gemma4Parser(
586
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
587
+ TOOLS,
588
+ )
589
+ metadata = ResponseTemplateParser(
590
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
591
+ TOOLS,
592
+ enable_auto_tools=True,
593
+ )
594
+
595
+ assert collect_stream(metadata, text, req, chunk_size) == collect_stream(
596
+ registered,
597
+ text,
598
+ req,
599
+ chunk_size,
600
+ )
601
+
602
+
603
+@pytest.mark.parametrize(
604
+ ("prompt_token_ids", "text"),
605
+ [
606
+ (
607
+ OPEN_REASONING_PROMPT_TOKEN_IDS,
608
+ "continued reasoning<channel|>Final answer<turn|>",
609
+ ),
610
+ (
611
+ PROMPT_TOKEN_IDS,
612
+ "Direct answer without reasoning.<turn|>",
613
+ ),
614
+ ],
615
+ ids=["prefilled_reasoning", "direct_answer"],
616
+)
617
+def test_gemma4_response_template_prompt_state_matches_registered_parser(
618
+ prompt_token_ids,
619
+ text,
620
+):
621
+ req = request()
622
+ registered = Gemma4Parser(
623
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
624
+ TOOLS,
625
+ chat_template_kwargs={"enable_thinking": True},
626
+ )
627
+ metadata = ResponseTemplateParser(
628
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
629
+ TOOLS,
630
+ enable_auto_tools=True,
631
+ )
632
+
633
+ assert collect_stream(
634
+ metadata,
635
+ text,
636
+ req,
637
+ 1,
638
+ prompt_token_ids=prompt_token_ids,
639
+ ) == collect_stream(
640
+ registered,
641
+ text,
642
+ req,
643
+ 1,
644
+ prompt_token_ids=prompt_token_ids,
645
+ )
646
+
647
+
648
+def test_gemma4_response_template_reasoning_suppression_matches_registered_parser():
649
+ req = request(include_reasoning=False)
650
+ text = THINKING + "Final answer<turn|>"
651
+
652
+ assert collect_stream(
653
+ ResponseTemplateParser(
654
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
655
+ TOOLS,
656
+ enable_auto_tools=True,
657
+ ),
658
+ text,
659
+ req,
660
+ 1,
661
+ ) == collect_stream(
662
+ Gemma4Parser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS),
663
+ text,
664
+ req,
665
+ 1,
666
+ )
667
+
668
+
669
+def test_streamed_tool_call_is_emitted_when_its_region_parses():
670
+ chunks = [
671
+ "<|tool_call>call:set_alarm{",
672
+ "hour:7,",
673
+ 'label:<|"|>morning<|"|>}',
674
+ "<tool_call|>",
675
+ ]
676
+ deltas = feed_chunks(
677
+ ResponseTemplateParser(
678
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
679
+ TOOLS,
680
+ enable_auto_tools=True,
681
+ ),
682
+ chunks,
683
+ request(),
684
+ )
685
+
686
+ assert all(not delta.tool_calls for delta in deltas[:-1])
687
+ calls = deltas[-1].tool_calls
688
+ assert len(calls) == 1
689
+ assert calls[0].id is not None
690
+ assert calls[0].index == 0
691
+ assert calls[0].function is not None
692
+ assert calls[0].function.name == "set_alarm"
693
+ assert json.loads(calls[0].function.arguments) == {
694
+ "hour": 7,
695
+ "label": "morning",
696
+ }
697
+
698
+
699
+def test_response_template_emits_whole_call_from_one_chunk():
700
+ parser = ResponseTemplateParser(
701
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
702
+ TOOLS,
703
+ enable_auto_tools=True,
704
+ )
705
+
706
+ delta = parser.parse_delta(
707
+ TOOL_GENERATION,
708
+ [],
709
+ request(),
710
+ prompt_token_ids=PROMPT_TOKEN_IDS,
711
+ finished=True,
712
+ )
713
+
714
+ assert delta is not None
715
+ assert len(delta.tool_calls) == 1
716
+ call = delta.tool_calls[0]
717
+ assert call.id is not None
718
+ assert call.function is not None
719
+ assert call.function.name == "set_alarm"
720
+ assert json.loads(call.function.arguments) == {"hour": 7, "label": "morning"}
721
+
722
+
723
+def test_response_template_takes_name_from_region_content():
724
+ template = {
725
+ "defaults": {"role": "assistant"},
726
+ "start_anchor": PREFIX,
727
+ "fields": {
728
+ "tool_calls": {
729
+ "open": "<tool>",
730
+ "close": "</tool>",
731
+ "content": "json",
732
+ "transform": {
733
+ "type": "function",
734
+ "function": {
735
+ "name": "{content.name}",
736
+ "arguments": "{content.arguments}",
737
+ },
738
+ },
739
+ }
740
+ },
741
+ }
742
+ chunks = [
743
+ "<tool>",
744
+ '{"name":"set_',
745
+ 'alarm","arguments":{"hour":7}}',
746
+ "</tool>",
747
+ ]
748
+ parser = ResponseTemplateParser(
749
+ FakeTokenizer(template),
750
+ TOOLS,
751
+ response_template=template,
752
+ enable_auto_tools=True,
753
+ )
754
+
755
+ deltas = feed_chunks(parser, chunks, request())
756
+
757
+ assert all(not delta.tool_calls for delta in deltas[:-1])
758
+ assert len(deltas[-1].tool_calls) == 1
759
+ call = deltas[-1].tool_calls[0]
760
+ assert call.function is not None
761
+ assert call.function.name == "set_alarm"
762
+ assert json.loads(call.function.arguments) == {"hour": 7}
763
+
764
+
765
+def test_response_template_applies_literal_transformed_name():
766
+ template = {
767
+ "defaults": {"role": "assistant"},
768
+ "start_anchor": PREFIX,
769
+ "fields": {
770
+ "tool_calls": {
771
+ "open": "<call>",
772
+ "close": "</call>",
773
+ "content": "json",
774
+ "transform": {
775
+ "type": "function",
776
+ "function": {
777
+ "name": "set_alarm",
778
+ "arguments": "{content}",
779
+ },
780
+ },
781
+ }
782
+ },
783
+ }
784
+ parser = ResponseTemplateParser(
785
+ FakeTokenizer(template),
786
+ TOOLS,
787
+ response_template=template,
788
+ enable_auto_tools=True,
789
+ )
790
+
791
+ deltas = feed_chunks(
792
+ parser,
793
+ ["<call>", '{"hour":7}', "</call>"],
794
+ request(),
795
+ )
796
+
797
+ assert len(deltas) == 1
798
+ assert deltas[0].tool_calls[0].function.name == "set_alarm"
799
+ assert json.loads(deltas[0].tool_calls[0].function.arguments) == {"hour": 7}
800
+
801
+
802
+def test_gemma4_response_template_drops_incomplete_call():
803
+ text = '<|tool_call>call:get_weather{city:<|"|>London'
804
+ req = request()
805
+ registered = Gemma4Parser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
806
+ metadata = ResponseTemplateParser(
807
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
808
+ TOOLS,
809
+ )
810
+ metadata.set_prompt_token_ids(PROMPT_TOKEN_IDS)
811
+
812
+ _, _, registered_calls = registered.parse(
813
+ text,
814
+ req,
815
+ enable_auto_tools=True,
816
+ )
817
+ _, metadata_content, metadata_calls = metadata.parse(
818
+ text,
819
+ req,
820
+ enable_auto_tools=True,
821
+ )
822
+
823
+ assert normalize_calls(registered_calls) == [("get_weather", {"city": "London"})]
824
+ assert metadata_content is None
825
+ assert metadata_calls is None
826
+
827
+
828
+def test_gemma4_response_template_preserves_content_after_tool_call():
829
+ text = TOOL_GENERATION + "Done.<turn|>"
830
+ req = request()
831
+ registered = Gemma4Parser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
832
+ metadata = ResponseTemplateParser(
833
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
834
+ TOOLS,
835
+ )
836
+ metadata.set_prompt_token_ids(PROMPT_TOKEN_IDS)
837
+
838
+ _, registered_content, registered_calls = registered.parse(
839
+ text,
840
+ req,
841
+ enable_auto_tools=True,
842
+ )
843
+ _, metadata_content, metadata_calls = metadata.parse(
844
+ text,
845
+ req,
846
+ enable_auto_tools=True,
847
+ )
848
+
849
+ assert registered_content is None
850
+ assert metadata_content == "Done."
851
+ assert normalize_calls(metadata_calls) == normalize_calls(registered_calls)
852
+
853
+
854
+def test_gemma4_checkpoint_grammar_excludes_hyphenated_name():
855
+ text = '<|tool_call>call:get-weather{city:<|"|>London<|"|>}<tool_call|>'
856
+ req = request()
857
+ registered = Gemma4Parser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
858
+ metadata = ResponseTemplateParser(
859
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
860
+ TOOLS,
861
+ )
862
+ metadata.set_prompt_token_ids(PROMPT_TOKEN_IDS)
863
+
864
+ _, _, registered_calls = registered.parse(
865
+ text,
866
+ req,
867
+ enable_auto_tools=True,
868
+ )
869
+ _, metadata_content, metadata_calls = metadata.parse(
870
+ text,
871
+ req,
872
+ enable_auto_tools=True,
873
+ )
874
+
875
+ assert normalize_calls(registered_calls) == [("get-weather", {"city": "London"})]
876
+ assert metadata_content is None
877
+ assert metadata_calls is None
878
+
879
+
880
+@pytest.mark.parametrize(
881
+ (
882
+ "prefix_suffix",
883
+ "generated",
884
+ "enable_auto_tools",
885
+ "expected_reasoning",
886
+ "expected_content",
887
+ ),
888
+ [
889
+ (
890
+ "<|channel>thought\nprefill",
891
+ " generated<channel|>",
892
+ False,
893
+ " generated",
894
+ None,
895
+ ),
896
+ ("prefill ", "generated<turn|>", False, None, "generated"),
897
+ ("prefill", "", False, None, None),
898
+ (TOOL_GENERATION.split("{", 1)[0], "", True, None, None),
899
+ ("<|chan", "", False, None, None),
900
+ ("<|chan", "nel", False, None, "nel"),
901
+ (
902
+ "<|chan",
903
+ "nel>thought\nreason<channel|>",
904
+ False,
905
+ "reason",
906
+ None,
907
+ ),
908
+ ],
909
+ ids=[
910
+ "reasoning",
911
+ "content",
912
+ "empty_content",
913
+ "empty_tool_open",
914
+ "empty_partial_delimiter",
915
+ "unresolved_partial_delimiter",
916
+ "completed_partial_delimiter",
917
+ ],
918
+)
919
+def test_streaming_prefill_is_state_only(
920
+ prefix_suffix,
921
+ generated,
922
+ enable_auto_tools,
923
+ expected_reasoning,
924
+ expected_content,
925
+):
926
+ tokenizer = FakeTokenizer(
927
+ GEMMA4_RESPONSE_TEMPLATE,
928
+ prefix=PREFIX + prefix_suffix,
929
+ )
930
+ parser = ResponseTemplateParser(
931
+ tokenizer,
932
+ TOOLS,
933
+ enable_auto_tools=enable_auto_tools,
934
+ )
935
+
936
+ delta = parser.parse_delta(
937
+ generated,
938
+ [],
939
+ request(),
940
+ prompt_token_ids=[1],
941
+ finished=True,
942
+ )
943
+
944
+ if expected_reasoning is None and expected_content is None:
945
+ assert delta is None
946
+ else:
947
+ assert delta is not None
948
+ assert delta.reasoning == expected_reasoning
949
+ assert delta.content == expected_content
950
+
951
+
952
+def test_malformed_call_opened_across_prompt_boundary_is_dropped():
953
+ parser = ResponseTemplateParser(
954
+ FakeTokenizer(
955
+ GEMMA4_RESPONSE_TEMPLATE,
956
+ prefix=PREFIX + "<|tool_call>call:set_",
957
+ ),
958
+ TOOLS,
959
+ enable_auto_tools=True,
960
+ )
961
+
962
+ opened = parser.parse_delta(
963
+ "alarm{",
964
+ [],
965
+ request(),
966
+ prompt_token_ids=[1],
967
+ finished=False,
968
+ )
969
+ delta = parser.parse_delta(
970
+ "hour:7}unexpected<tool_call|>",
971
+ [],
972
+ request(),
973
+ finished=True,
974
+ )
975
+
976
+ assert opened is None
977
+ assert delta is None
978
+
979
+
980
+def test_responses_namespace_tools_use_vllm_flattened_names():
981
+ namespace = NamespaceTool(
982
+ type="namespace",
983
+ name="calendar",
984
+ description="Calendar tools",
985
+ tools=[
986
+ {
987
+ "type": "function",
988
+ "name": "set_alarm",
989
+ "description": "Set an alarm",
990
+ "parameters": TOOLS[0].function.parameters,
991
+ }
992
+ ],
993
+ )
994
+ parser = ResponseTemplateParser(
995
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
996
+ [namespace],
997
+ )
998
+ parser.set_prompt_token_ids([1])
999
+ output = TOOL_GENERATION.replace(
1000
+ "call:set_alarm",
1001
+ "call:calendar__set_alarm",
1002
+ )
1003
+
1004
+ _, content, calls = parser.parse(
1005
+ output,
1006
+ request(),
1007
+ enable_auto_tools=True,
1008
+ )
1009
+
1010
+ assert content is None
1011
+ assert normalize_calls(calls) == [
1012
+ ("calendar__set_alarm", {"hour": 7, "label": "morning"})
1013
+ ]
1014
+
1015
+
1016
+def test_streaming_unknown_call_is_forwarded():
1017
+ text = TOOL_GENERATION.replace("call:set_alarm", "call:unknown")
1018
+ parser = ResponseTemplateParser(
1019
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1020
+ TOOLS,
1021
+ enable_auto_tools=True,
1022
+ )
1023
+ _, content, calls = collect_stream(parser, text, request(), 7)
1024
+
1025
+ assert content == ""
1026
+ assert calls == [("unknown", {"hour": 7, "label": "morning"})]
1027
+
1028
+
1029
+@pytest.mark.parametrize(
1030
+ "text",
1031
+ [
1032
+ TOOL_GENERATION.replace(
1033
+ "<tool_call|>",
1034
+ "unexpected<tool_call|>",
1035
+ ),
1036
+ TOOL_GENERATION.removesuffix("<tool_call|>") + "<tool_",
1037
+ ],
1038
+ ids=["malformed", "truncated"],
1039
+)
1040
+def test_streaming_invalid_call_is_dropped(text):
1041
+ parser = ResponseTemplateParser(
1042
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1043
+ TOOLS,
1044
+ enable_auto_tools=True,
1045
+ )
1046
+ deltas = feed_chunks(
1047
+ parser,
1048
+ [text[start : start + 7] for start in range(0, len(text), 7)],
1049
+ request(),
1050
+ )
1051
+
1052
+ assert deltas == []
1053
+
1054
+
1055
+def test_malformed_call_does_not_block_following_call():
1056
+ parser = ResponseTemplateParser(
1057
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1058
+ TOOLS,
1059
+ enable_auto_tools=True,
1060
+ )
1061
+ malformed = TOOL_GENERATION.replace("<tool_call|>", "unexpected<tool_call|>")
1062
+
1063
+ _, content, calls = collect_stream(
1064
+ parser, malformed + TOOL_GENERATION, request(), 7
1065
+ )
1066
+
1067
+ assert content == ""
1068
+ assert calls == [("set_alarm", {"hour": 7, "label": "morning"})]
1069
+
1070
+
1071
+def test_non_streaming_malformed_call_is_dropped():
1072
+ text = TOOL_GENERATION.replace(
1073
+ "<tool_call|>",
1074
+ "unexpected<tool_call|>",
1075
+ )
1076
+ parser = ResponseTemplateParser(
1077
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1078
+ TOOLS,
1079
+ )
1080
+ parser.set_prompt_token_ids([1])
1081
+
1082
+ reasoning, content, calls = parser.parse(
1083
+ text,
1084
+ request(),
1085
+ enable_auto_tools=True,
1086
+ )
1087
+
1088
+ assert reasoning is None
1089
+ assert content is None
1090
+ assert calls is None
1091
+
1092
+
1093
+def test_streaming_malformed_call_is_dropped_before_emission():
1094
+ parser = ResponseTemplateParser(
1095
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1096
+ TOOLS,
1097
+ enable_auto_tools=True,
1098
+ )
1099
+ first = parser.parse_delta(
1100
+ "hello<turn|>",
1101
+ [],
1102
+ request(),
1103
+ prompt_token_ids=[1],
1104
+ finished=False,
1105
+ )
1106
+ malformed = TOOL_GENERATION.replace(
1107
+ "<tool_call|>",
1108
+ "unexpected<tool_call|>",
1109
+ )
1110
+ failed = parser.parse_delta(
1111
+ malformed,
1112
+ [],
1113
+ request(),
1114
+ finished=False,
1115
+ )
1116
+
1117
+ assert first is not None
1118
+ assert first.content == "hello"
1119
+ assert failed is None
1120
+
1121
+
1122
+def test_prefilled_malformed_call_is_dropped():
1123
+ opening, body = TOOL_GENERATION.split("{", 1)
1124
+ malformed_body = "{" + body.replace(
1125
+ "<tool_call|>",
1126
+ "unexpected<tool_call|>",
1127
+ )
1128
+ tokenizer = FakeTokenizer(
1129
+ GEMMA4_RESPONSE_TEMPLATE,
1130
+ prefix=PREFIX + opening,
1131
+ )
1132
+ parser = ResponseTemplateParser(
1133
+ tokenizer,
1134
+ TOOLS,
1135
+ enable_auto_tools=True,
1136
+ )
1137
+
1138
+ delta = parser.parse_delta(
1139
+ malformed_body,
1140
+ [],
1141
+ request(),
1142
+ prompt_token_ids=[1],
1143
+ finished=True,
1144
+ )
1145
+
1146
+ assert delta is None
1147
+
1148
+
1149
+def test_tool_choice_none_preserves_raw_call_as_content():
1150
+ parser = ResponseTemplateParser(
1151
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1152
+ TOOLS,
1153
+ enable_auto_tools=True,
1154
+ )
1155
+ _, content, calls = collect_stream(
1156
+ parser,
1157
+ TOOL_GENERATION,
1158
+ request("none"),
1159
+ 5,
1160
+ )
1161
+
1162
+ assert content == TOOL_GENERATION
1163
+ assert calls == []
1164
+
1165
+
1166
+def test_adjust_request_preserves_parser_delimiters_without_forcing_stop_text():
1167
+ req = request()
1168
+ parser = ResponseTemplateParser(
1169
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1170
+ TOOLS,
1171
+ )
1172
+
1173
+ assert parser.adjust_request(req) is req
1174
+ assert req.skip_special_tokens is False
1175
+ assert req.spaces_between_special_tokens is False
1176
+ assert req.include_stop_str_in_output is False
1177
+
1178
+
1179
+def test_call_cut_off_before_its_closer_is_parsed():
1180
+ """A stop-token closer, such as a tool closer that ends generation, is
1181
+ absent from the text; the open region is parsed at the end of the stream."""
1182
+ trimmed = TOOL_GENERATION.removesuffix("<tool_call|>")
1183
+ parser = ResponseTemplateParser(FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE), TOOLS)
1184
+ streaming = ResponseTemplateParser(
1185
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1186
+ TOOLS,
1187
+ enable_auto_tools=True,
1188
+ )
1189
+
1190
+ _, content, calls = parser.parse(trimmed, request(), enable_auto_tools=True)
1191
+ streamed = streaming.parse_delta(
1192
+ trimmed,
1193
+ [],
1194
+ request(),
1195
+ prompt_token_ids=[1],
1196
+ finished=True,
1197
+ )
1198
+
1199
+ assert content is None
1200
+ assert normalize_calls(calls) == [("set_alarm", {"hour": 7, "label": "morning"})]
1201
+ assert streamed is not None
1202
+ assert [call.function.name for call in streamed.tool_calls] == ["set_alarm"]
1203
+
1204
+
1205
+@pytest.mark.parametrize(
1206
+ ("req", "match"),
1207
+ [
1208
+ (request("required"), "required tool choice"),
1209
+ (
1210
+ request(
1211
+ ChatCompletionNamedToolChoiceParam(
1212
+ type="function",
1213
+ function={"name": "set_alarm"},
1214
+ )
1215
+ ),
1216
+ "named tool choice",
1217
+ ),
1218
+ (request("auto", parallel_tool_calls=False), "parallel_tool_calls=False"),
1219
+ (
1220
+ request(
1221
+ "auto",
1222
+ tools=[
1223
+ {
1224
+ "type": "function",
1225
+ "name": "set_alarm",
1226
+ "parameters": {"type": "object"},
1227
+ "strict": True,
1228
+ }
1229
+ ],
1230
+ ),
1231
+ "strict tools",
1232
+ ),
1233
+ ],
1234
+)
1235
+def test_unconstrained_tool_guarantees_are_rejected(req, match):
1236
+ parser = ResponseTemplateParser(
1237
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1238
+ req.tools,
1239
+ enable_auto_tools=True,
1240
+ )
1241
+
1242
+ with pytest.raises(VLLMValidationError, match=match):
1243
+ parser.adjust_request(req)
1244
+
1245
+
1246
+def test_tool_choice_none_does_not_require_structural_guarantees():
1247
+ req = request(
1248
+ "none",
1249
+ tools=[
1250
+ {
1251
+ "type": "function",
1252
+ "name": "set_alarm",
1253
+ "parameters": {"type": "object"},
1254
+ "strict": True,
1255
+ }
1256
+ ],
1257
+ parallel_tool_calls=False,
1258
+ )
1259
+ parser = ResponseTemplateParser(
1260
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1261
+ req.tools,
1262
+ enable_auto_tools=True,
1263
+ )
1264
+
1265
+ assert parser.adjust_request(req) is req
1266
+
1267
+
1268
+def test_unsupported_semantic_field_is_rejected():
1269
+ template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
1270
+ template["fields"]["citations"] = {
1271
+ "open": "<citation>",
1272
+ "close": "</citation>",
1273
+ }
1274
+
1275
+ with pytest.raises(ValueError, match="citations"):
1276
+ ResponseTemplateParser(
1277
+ FakeTokenizer(template),
1278
+ TOOLS,
1279
+ response_template=template,
1280
+ )
1281
+
1282
+
1283
+@pytest.mark.parametrize(
1284
+ ("field_name", "field_update"),
1285
+ [
1286
+ ("content", {"content": "json"}),
1287
+ ("thinking", {"transform": "{content}"}),
1288
+ ],
1289
+)
1290
+def test_unsupported_content_field_semantics_are_rejected(
1291
+ field_name,
1292
+ field_update,
1293
+):
1294
+ template = deepcopy(GEMMA4_RESPONSE_TEMPLATE)
1295
+ template["fields"][field_name].update(field_update)
1296
+
1297
+ with pytest.raises(ValueError, match="cannot be streamed"):
1298
+ ResponseTemplateParser(
1299
+ FakeTokenizer(template),
1300
+ TOOLS,
1301
+ response_template=template,
1302
+ )
1303
+
1304
+
1305
+def test_delimited_template_keeps_plain_content_and_tolerates_required_fields():
1306
+ template = {
1307
+ "start_anchor": "<assistant>",
1308
+ "fields": {
1309
+ "thinking": {"open": "<think>", "close": "</think>"},
1310
+ "tool_calls": {
1311
+ "open": "<call>",
1312
+ "close": "</call>",
1313
+ "content": "json",
1314
+ "optional": False,
1315
+ },
1316
+ },
1317
+ }
1318
+ parser = ResponseTemplateParser(
1319
+ FakeTokenizer(template, prefix="<assistant>"),
1320
+ TOOLS,
1321
+ response_template=template,
1322
+ )
1323
+
1324
+ assert parser.parse("<think>plan</think>answer", request()) == (
1325
+ "plan",
1326
+ "answer",
1327
+ None,
1328
+ )
1329
+
1330
+
1331
+def test_separate_parse_calls_use_distinct_tool_call_ids():
1332
+ parser = ResponseTemplateParser(
1333
+ FakeTokenizer(GEMMA4_RESPONSE_TEMPLATE),
1334
+ TOOLS,
1335
+ enable_auto_tools=True,
1336
+ )
1337
+ parsed_calls = []
1338
+ for _ in range(2):
1339
+ _, _, calls = parser.parse(
1340
+ TOOL_GENERATION,
1341
+ request(),
1342
+ enable_auto_tools=True,
1343
+ )
1344
+ assert calls is not None
1345
+ parsed_calls.append(calls[0])
1346
+
1347
+ assert parsed_calls[0].id is not None
1348
+ assert parsed_calls[1].id is not None
1349
+ assert parsed_calls[0].id != parsed_calls[1].id
vllm/entrypoints/launchers/cli_args.py
+11
@@ -22,6 +22,7 @@ from vllm.entrypoints.chat_utils import (
22
validate_chat_template,
23
)
24
from vllm.entrypoints.openai.models.protocol import LoRAModulePath
25
+from vllm.logger import init_logger
26
from vllm.tool_parsers import ToolParserManager
27
from vllm.tool_parsers.tool_strict_level import ToolStrictLevelName
28
from vllm.utils.argparse_utils import FlexibleArgumentParser
@@ -31,6 +32,8 @@ from .utils.constants import (
32
H11_MAX_INCOMPLETE_EVENT_SIZE_DEFAULT,
33
)
34
35
+logger = init_logger(__name__)
36
+
37
38
class LoRAParserAction(argparse.Action):
39
def __call__(
@@ -466,6 +469,14 @@ def validate_parsed_serve_args(args: argparse.Namespace):
469
470
# Ensure that the chat template is valid; raises if it likely isn't
471
validate_chat_template(args.chat_template)
472
+ if args.chat_template is not None and "hf" in (
473
+ args.tool_call_parser,
474
+ getattr(args, "reasoning_parser", None),
475
+ ):
476
+ logger.warning(
477
+ "--chat-template is set; the hf parser still expects "
478
+ "the checkpoint's output format."
479
+ )
480
481
# Enable auto tool needs a tool call parser to be valid
482
if args.enable_auto_tool_choice and not args.tool_call_parser:
vllm/entrypoints/openai/chat_completion/batch_serving.py
+15
-15
@@ -129,17 +129,6 @@ class OpenAIServingChatBatch(OpenAIServingChat):
129
return render_result
130
single_requests, all_conversations, engine_prompts = render_result
131
132
- parser: Parser | None = None
133
- if self.parser_cls is not None:
134
- chat_template_kwargs = self._effective_chat_template_kwargs(
135
- single_requests[0]
136
- )
137
- parser = self.parser_cls(
138
- tokenizer,
139
- None, # tools
140
- chat_template_kwargs=chat_template_kwargs,
141
- )
142
-
132
request_id = (
133
f"chatcmpl-{self._base_request_id(raw_request, request.request_id)}"
134
)
@@ -202,7 +191,7 @@ class OpenAIServingChatBatch(OpenAIServingChat):
191
all_conversations,
192
tokenizer,
193
request_metadata,
205
- parser,
194
+ self.parser_cls,
195
)
196
197
async def chat_completion_full_generator_batch(
@@ -214,7 +203,7 @@ class OpenAIServingChatBatch(OpenAIServingChat):
203
all_conversations: list[list[ConversationMessage]],
204
tokenizer: TokenizerLike,
205
request_metadata: RequestResponseMetadata,
217
- parser: Parser | None = None,
206
+ parser_cls: type[Parser] | None = None,
207
) -> ErrorResponse | ChatCompletionResponse:
208
"""Handle batched (non-streaming) chat completions.
209
@@ -273,10 +262,21 @@ class OpenAIServingChatBatch(OpenAIServingChat):
262
else:
263
logprobs = None
264
276
- if parser is not None:
265
+ if parser_cls is not None:
266
+ single_request = request.to_chat_completion_request(
267
+ request.messages[prompt_idx]
268
+ )
269
+ parser = parser_cls(
270
+ tokenizer,
271
+ None,
272
+ chat_template_kwargs=self._effective_chat_template_kwargs(
273
+ single_request
274
+ ),
275
+ )
276
+ parser.set_prompt_token_ids(final_res.prompt_token_ids)
277
reasoning, content, _ = parser.parse(
278
output.text,
279
- request=request, # type: ignore[arg-type]
279
+ request=single_request,
280
model_output_token_ids=output.token_ids,
281
)
282
if not request.include_reasoning:
vllm/entrypoints/openai/chat_completion/serving.py
+3
@@ -165,6 +165,7 @@ class OpenAIServingChat(GenerateBaseServing):
165
tool_strict_level=tool_strict_level,
166
model_name=self.model_config.model,
167
is_harmony=self.model_config.hf_config.model_type == "gpt_oss",
168
+ tokenizer=self.renderer.tokenizer,
169
)
170
self.exclude_tools_when_tool_choice_none = exclude_tools_when_tool_choice_none
171
@@ -974,6 +975,8 @@ class OpenAIServingChat(GenerateBaseServing):
975
parser = self._make_parser(
976
request, tokenizer, self._effective_chat_template_kwargs(request)
977
)
978
+ if parser is not None and final_res.prompt_token_ids is not None:
979
+ parser.set_prompt_token_ids(final_res.prompt_token_ids)
980
token_ids = output.token_ids
981
out_logprobs = output.logprobs
982
vllm/entrypoints/openai/responses/serving.py
+16
-1
@@ -141,6 +141,7 @@ class OpenAIServingResponses(GenerateBaseServing):
141
tool_strict_level=tool_strict_level,
142
model_name=self.model_config.model,
143
is_harmony=self.model_config.hf_config.model_type == "gpt_oss",
144
+ tokenizer=self.renderer.tokenizer,
145
)
146
self.enable_prompt_tokens_details = enable_prompt_tokens_details
147
self.enable_force_include_usage = enable_force_include_usage
@@ -446,6 +447,12 @@ class OpenAIServingResponses(GenerateBaseServing):
447
response_parser = self._make_response_parser(
448
request, tokenizer, chat_template_kwargs
449
)
450
+ if response_parser is not None:
451
+ prompt_token_ids = self._extract_prompt_components(
452
+ engine_input
453
+ ).token_ids
454
+ if prompt_token_ids is not None:
455
+ response_parser.set_prompt_token_ids(prompt_token_ids)
456
457
context: ConversationContext
458
function_tool_names = extract_function_tool_names(request.tools)
@@ -703,6 +710,12 @@ class OpenAIServingResponses(GenerateBaseServing):
710
context.request,
711
context.response_messages,
712
)
713
+ if context.response_parser is not None:
714
+ prompt_token_ids = self._extract_prompt_components(
715
+ engine_input
716
+ ).token_ids
717
+ if prompt_token_ids is not None:
718
+ context.response_parser.set_prompt_token_ids(prompt_token_ids)
719
720
sampling_params.max_tokens = get_max_tokens(
721
max_model_len,
@@ -1187,7 +1200,9 @@ class OpenAIServingResponses(GenerateBaseServing):
1200
) -> AsyncGenerator[StreamingResponsesResponse, None]:
1201
processor = SimpleStreamingEventProcessor(tools=request.tools)
1202
1190
- hide_stream_metadata = not request.include_reasoning and self.parser is not None
1203
+ hide_stream_metadata = (
1204
+ not request.include_reasoning and context.response_parser is not None
1205
+ )
1206
1207
def _get_logprobs(output: CompletionOutput) -> list[Logprob]:
1208
if not request.is_include_output_logprobs():
vllm/parser/abstract_parser.py
+5
@@ -115,6 +115,7 @@ class Parser:
115
tool_parser_cls: type[ToolParser] | None = None
116
# Server-side floor for tool-call structural tags (--tool-strict-level).
117
tool_strict_level: ToolStrictLevel = ToolStrictLevel.AUTO
118
+ always_adjust_request: bool = False
119
120
def __init__(
121
self,
@@ -206,6 +207,10 @@ class Parser:
207
"""
208
return request
209
210
+ def set_prompt_token_ids(self, prompt_token_ids: Sequence[int]) -> None:
211
+ """Provide the exact rendered prompt to parsers that need prefix state."""
212
+ return
213
+
214
@abstractmethod
215
def is_reasoning_end(self, input_ids: list[int]) -> bool:
216
"""Check if the reasoning content ends in the input_ids.
vllm/parser/parser_manager.py
+40
@@ -11,10 +11,13 @@ from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
11
if TYPE_CHECKING:
12
from vllm.parser.abstract_parser import Parser
13
from vllm.reasoning import ReasoningParser
14
+ from vllm.tokenizers import TokenizerLike
15
from vllm.tool_parsers import ToolParser
16
17
logger = init_logger(__name__)
18
19
+HF_PARSER = "hf"
20
+
21
22
class ParserManager:
23
"""Provides a unified Parser by composing reasoning and tool parser adapters."""
@@ -79,6 +82,7 @@ class ParserManager:
82
model_name: str | None = None,
83
is_harmony: bool = False,
84
tool_strict_level: str = "auto",
85
+ tokenizer: TokenizerLike | None = None,
86
) -> type[Parser] | None:
87
"""Get a Parser that handles both reasoning and tool parsing.
88
@@ -93,6 +97,8 @@ class ParserManager:
97
If True, HarmonyParser is always returned.
98
tool_strict_level: Server-side floor for tool-call structural
99
tags (``--tool-strict-level``).
100
+ tokenizer: Tokenizer whose `response_template` metadata is
101
+ validated when the `hf` parser is selected.
102
103
Returns:
104
A Parser class, or None if neither parser is specified.
@@ -119,6 +125,40 @@ class ParserManager:
125
HarmonyParser.tool_strict_level = strict_level
126
return HarmonyParser
127
128
+ if HF_PARSER in (reasoning_parser_name, tool_parser_name):
129
+ if {reasoning_parser_name, tool_parser_name} - {
130
+ HF_PARSER,
131
+ None,
132
+ "",
133
+ }:
134
+ raise TypeError(
135
+ "The hf parser cannot be combined with other "
136
+ "reasoning or tool call parsers"
137
+ )
138
+ from vllm.parser.response_template import (
139
+ ResponseTemplateParser,
140
+ validate_tokenizer_response_template,
141
+ )
142
+
143
+ if tokenizer is not None:
144
+ validate_tokenizer_response_template(
145
+ tokenizer,
146
+ reasoning=reasoning_parser_cls is not None,
147
+ tools=tool_parser_cls is not None,
148
+ )
149
+
150
+ r_cls = reasoning_parser_cls
151
+ t_cls = tool_parser_cls
152
+ auto_tools = enable_auto_tools
153
+
154
+ class _ResponseTemplateParser(ResponseTemplateParser):
155
+ reasoning_parser_cls = r_cls
156
+ tool_parser_cls = t_cls
157
+ tool_strict_level = strict_level
158
+ _enable_auto_tools = auto_tools
159
+
160
+ return _ResponseTemplateParser
161
+
162
if reasoning_parser_name == "kimi_k3" or tool_parser_name == "kimi_k3":
163
from vllm.parser.kimi_k3 import KimiK3Parser
164
vllm/parser/response_template.py
new
+642
@@ -0,0 +1,642 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Unified vLLM parser backed by checkpoint `response_template` metadata."""
4
+
5
+from __future__ import annotations
6
+
7
+import copy
8
+import json
9
+from collections.abc import Callable, Iterable, Sequence
10
+from typing import TYPE_CHECKING, Any, cast
11
+
12
+from openai.types.responses import ToolChoiceFunction
13
+
14
+from vllm.entrypoints.chat_utils import make_tool_call_id
15
+from vllm.entrypoints.generate.base.protocol import (
16
+ DeltaFunctionCall,
17
+ DeltaMessage,
18
+ DeltaToolCall,
19
+ FunctionCall,
20
+)
21
+from vllm.entrypoints.openai.chat_completion.protocol import (
22
+ ChatCompletionNamedToolChoiceParam,
23
+)
24
+from vllm.exceptions import VLLMValidationError
25
+from vllm.logger import init_logger
26
+from vllm.parser.abstract_parser import Parser
27
+from vllm.parser.chat_parsing import ResponseParser
28
+from vllm.parser.chat_parsing.response_templates import (
29
+ ResponseTemplate,
30
+ load_response_template,
31
+)
32
+from vllm.reasoning.abs_reasoning_parsers import ReasoningParser
33
+from vllm.tool_parsers.abstract_tool_parser import ToolParser
34
+from vllm.tool_parsers.utils import iter_response_function_tool_dicts
35
+
36
+if TYPE_CHECKING:
37
+ from vllm.entrypoints.openai.chat_completion.protocol import (
38
+ ChatCompletionRequest,
39
+ )
40
+ from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
41
+ from vllm.tokenizers import TokenizerLike
42
+ from vllm.tool_parsers.abstract_tool_parser import Tool
43
+
44
+logger = init_logger(__name__)
45
+
46
+THINKING_FIELD = "thinking"
47
+CONTENT_FIELD = "content"
48
+TOOL_FIELD = "tool_calls"
49
+_SUPPORTED_FIELDS = frozenset({THINKING_FIELD, CONTENT_FIELD, TOOL_FIELD})
50
+
51
+
52
+def _tool_dict(tool: Any) -> dict[str, Any]:
53
+ if hasattr(tool, "model_dump"):
54
+ tool = tool.model_dump(exclude_none=True)
55
+ return tool if isinstance(tool, dict) else {}
56
+
57
+
58
+def _as_tool_dicts(tools: Sequence[Any] | None) -> list[dict[str, Any]]:
59
+ """Responses function tools with flattened namespaces, plus chat tools."""
60
+ tools = list(tools or ())
61
+ result = iter_response_function_tool_dicts(tools)
62
+ result.extend(
63
+ tool_dict
64
+ for tool_dict in map(_tool_dict, tools)
65
+ if isinstance(tool_dict.get("function"), dict)
66
+ )
67
+ return result
68
+
69
+
70
+def _tool_is_strict(tool: Any) -> bool:
71
+ tool_dict = _tool_dict(tool)
72
+ function = tool_dict.get("function")
73
+ return bool(
74
+ tool_dict.get("strict")
75
+ or (isinstance(function, dict) and function.get("strict"))
76
+ )
77
+
78
+
79
+def _decode(tokenizer: TokenizerLike, token_ids: Sequence[int]) -> str:
80
+ try:
81
+ return cast(Any, tokenizer.decode)(
82
+ token_ids,
83
+ skip_special_tokens=False,
84
+ clean_up_tokenization_spaces=False,
85
+ spaces_between_special_tokens=False,
86
+ )
87
+ except TypeError:
88
+ return tokenizer.decode(token_ids, skip_special_tokens=False)
89
+
90
+
91
+def resolve_response_template(
92
+ tokenizer: Any | None,
93
+ template: dict[str, Any] | None = None,
94
+) -> dict[str, Any] | None:
95
+ """Return `template` if given, otherwise the tokenizer's metadata."""
96
+ if template is not None or tokenizer is None:
97
+ return template
98
+ template = getattr(tokenizer, "response_template", None)
99
+ if not isinstance(template, dict):
100
+ init_kwargs = getattr(tokenizer, "init_kwargs", None) or {}
101
+ template = init_kwargs.get("response_template")
102
+ return template if isinstance(template, dict) else None
103
+
104
+
105
+def validate_response_template_for_serving(
106
+ template: dict[str, Any],
107
+) -> ResponseTemplate:
108
+ """Validate the template and reject semantic fields serving cannot route."""
109
+ loaded = load_response_template(template)
110
+ unsupported = (set(loaded.fields) - _SUPPORTED_FIELDS) | (
111
+ set(loaded.defaults) - {"role"}
112
+ )
113
+ if unsupported:
114
+ raise ValueError(
115
+ "response_template contains unsupported semantic fields: "
116
+ f"{sorted(unsupported)}. Supported fields are: "
117
+ f"{sorted(_SUPPORTED_FIELDS)}"
118
+ )
119
+ for name in (THINKING_FIELD, CONTENT_FIELD):
120
+ field = loaded.fields.get(name)
121
+ if field is None:
122
+ continue
123
+ if (
124
+ field.content != "text"
125
+ or field.transform is not None
126
+ or field.join is not None
127
+ ):
128
+ raise ValueError(
129
+ f"response_template field {name!r} uses semantics that cannot "
130
+ "be streamed by the OpenAI serving adapter"
131
+ )
132
+ return loaded
133
+
134
+
135
+def validate_tokenizer_response_template(
136
+ tokenizer: Any,
137
+ *,
138
+ reasoning: bool,
139
+ tools: bool,
140
+) -> None:
141
+ """Fail at startup if the tokenizer cannot serve the requested parsing."""
142
+ template = resolve_response_template(tokenizer)
143
+ if template is None:
144
+ raise TypeError(
145
+ "The hf parser requires `response_template` metadata "
146
+ "in the tokenizer configuration"
147
+ )
148
+ try:
149
+ fields = validate_response_template_for_serving(template).fields
150
+ except (TypeError, ValueError) as exc:
151
+ raise TypeError(f"Invalid response_template metadata: {exc}") from exc
152
+ if reasoning and THINKING_FIELD not in fields:
153
+ raise TypeError(
154
+ "--reasoning-parser hf requires a response_template with a thinking field"
155
+ )
156
+ if tools and TOOL_FIELD not in fields:
157
+ raise TypeError(
158
+ "--tool-call-parser hf requires a response_template with a tool_calls field"
159
+ )
160
+
161
+
162
+def _streaming_template(template: dict[str, Any]) -> ResponseTemplate:
163
+ result = copy.deepcopy(template)
164
+ fields = result["fields"]
165
+ if CONTENT_FIELD not in fields and all(
166
+ "open" in field or "open_pattern" in field for field in fields.values()
167
+ ):
168
+ fields[CONTENT_FIELD] = {}
169
+ for field in fields.values():
170
+ field["optional"] = True
171
+ return load_response_template(result)
172
+
173
+
174
+def _is_named_tool_choice(request: ChatCompletionRequest | ResponsesRequest) -> bool:
175
+ return isinstance(
176
+ request.tool_choice, (ToolChoiceFunction, ChatCompletionNamedToolChoiceParam)
177
+ )
178
+
179
+
180
+def _tool_parsing_enabled(
181
+ request: ChatCompletionRequest | ResponsesRequest,
182
+ *,
183
+ enable_auto_tools: bool,
184
+) -> bool:
185
+ if request.tool_choice == "none":
186
+ return False
187
+ if request.tool_choice == "required" or _is_named_tool_choice(request):
188
+ return True
189
+ return enable_auto_tools and request.tool_choice in (None, "auto")
190
+
191
+
192
+def _unsupported_tool_guarantee(
193
+ request: ChatCompletionRequest | ResponsesRequest,
194
+) -> tuple[str, str] | None:
195
+ """The first requested tool guarantee this parser cannot enforce, with the
196
+ request parameter that asked for it."""
197
+ if any(_tool_is_strict(tool) for tool in request.tools or ()):
198
+ return "strict tools", "tools"
199
+ if request.tool_choice == "required":
200
+ return "required tool choice", "tool_choice"
201
+ if _is_named_tool_choice(request):
202
+ return "named tool choice", "tool_choice"
203
+ if getattr(request, "parallel_tool_calls", True) is False:
204
+ return "parallel_tool_calls=False", "parallel_tool_calls"
205
+ return None
206
+
207
+
208
+def _reasoning_has_ended(
209
+ parser: ResponseParser,
210
+ template: ResponseTemplate,
211
+ *,
212
+ thinking_disabled: bool = False,
213
+) -> bool:
214
+ thinking = template.fields.get(THINKING_FIELD)
215
+ if thinking is None:
216
+ return True
217
+ if thinking.repeats:
218
+ return False
219
+
220
+ active_field: str | None = None
221
+ thinking_closed = False
222
+ for event in parser.initial_events:
223
+ if event["type"] == "region_open":
224
+ active_field = event["field"]
225
+ elif event["type"] in {"region_close", "region_malformed"}:
226
+ thinking_closed |= event["field"] == THINKING_FIELD
227
+ active_field = None
228
+ if thinking_closed:
229
+ return True
230
+ if active_field in (CONTENT_FIELD, TOOL_FIELD):
231
+ return True
232
+
233
+ # Resolve delimiters held at the prompt boundary. A NUL cannot occur in
234
+ # the model wire format and any implicit chunk containing it is a probe,
235
+ # not evidence that content generation has started.
236
+ for event in parser.feed("\0"):
237
+ if (
238
+ event["type"] == "region_open"
239
+ and event["field"] in (CONTENT_FIELD, TOOL_FIELD)
240
+ and event["end"] > event["start"]
241
+ ):
242
+ return True
243
+ if event["field"] == THINKING_FIELD:
244
+ if event["type"] == "region_open":
245
+ return False
246
+ if event["type"] in {"region_close", "region_malformed"}:
247
+ return True
248
+ if active_field == THINKING_FIELD:
249
+ return False
250
+ return thinking_disabled
251
+
252
+
253
+def _literal_ids(
254
+ tokenizer: TokenizerLike, vocab: dict[str, int], literals: Sequence[str]
255
+) -> list[list[int]] | None:
256
+ """Token ids of each literal, or `None` when one encodes to nothing."""
257
+ ids = [
258
+ [vocab[literal]]
259
+ if literal in vocab
260
+ else tokenizer.encode(literal, add_special_tokens=False)
261
+ for literal in literals
262
+ ]
263
+ return ids if all(ids) else None
264
+
265
+
266
+def _reasoning_end_ids(
267
+ tokenizer: TokenizerLike, vocab: dict[str, int], template: ResponseTemplate
268
+) -> list[list[int]] | None:
269
+ """Token ids of each thinking closer, or `None` when they are not all
270
+ literal."""
271
+ thinking = template.fields.get(THINKING_FIELD)
272
+ if thinking is None or thinking.repeats or not thinking.close_literals:
273
+ return None
274
+ return _literal_ids(tokenizer, vocab, thinking.close_literals)
275
+
276
+
277
+def _ends_within(token_ids: Sequence[int], end_ids: list[int], num_new: int) -> bool:
278
+ """Whether `end_ids` completes within the last `num_new` of `token_ids`."""
279
+ window = list(token_ids[max(0, len(token_ids) - num_new - len(end_ids) + 1) :])
280
+ return any(
281
+ window[index : index + len(end_ids)] == end_ids
282
+ for index in range(len(window) - len(end_ids) + 1)
283
+ )
284
+
285
+
286
+def _serving_template(
287
+ tokenizer: Any,
288
+ template: dict[str, Any] | None,
289
+) -> ResponseTemplate:
290
+ """The validated streaming form of `template` or the tokenizer's metadata."""
291
+ template = resolve_response_template(tokenizer, template)
292
+ if template is None:
293
+ raise ValueError(
294
+ "The hf parser requires `response_template` "
295
+ "metadata in the tokenizer configuration"
296
+ )
297
+ validate_response_template_for_serving(template)
298
+ return _streaming_template(template)
299
+
300
+
301
+def _to_tool_calls(value: Any) -> list[tuple[str, str]]:
302
+ """`(name, arguments)` of each call in a tool region value, or nothing if
303
+ any call is incomplete."""
304
+ values = value if isinstance(value, list) else [value]
305
+ calls: list[tuple[str, str]] = []
306
+ for item in values:
307
+ function = item.get("function") if isinstance(item, dict) else None
308
+ if not isinstance(function, dict):
309
+ return []
310
+ name = function.get("name")
311
+ arguments = function.get("arguments")
312
+ if not isinstance(name, str) or not name or arguments is None:
313
+ return []
314
+ if not isinstance(arguments, str):
315
+ arguments = json.dumps(arguments, ensure_ascii=False)
316
+ calls.append((name, arguments))
317
+ return calls
318
+
319
+
320
+class ResponseTemplateReasoningParser(ReasoningParser):
321
+ """Reasoning end detection for `--reasoning-parser hf`.
322
+
323
+ `ResponseTemplateParser` parses the output; this parser tells it and the
324
+ structured-output engine where reasoning ends.
325
+ """
326
+
327
+ def __init__(
328
+ self,
329
+ tokenizer: TokenizerLike,
330
+ *args,
331
+ response_template: dict[str, Any] | None = None,
332
+ **kwargs,
333
+ ) -> None:
334
+ super().__init__(tokenizer, *args, **kwargs)
335
+ self.response_template = _serving_template(tokenizer, response_template)
336
+ chat_template_kwargs = kwargs.get("chat_template_kwargs") or {}
337
+ self._thinking_disabled = not chat_template_kwargs.get("enable_thinking", True)
338
+ self._reasoning_end_ids = _reasoning_end_ids(
339
+ tokenizer, self.vocab, self.response_template
340
+ )
341
+
342
+ @property
343
+ def reasoning_start_str(self) -> str | None:
344
+ thinking = self.response_template.fields.get(THINKING_FIELD)
345
+ literals = thinking.open_literals if thinking else None
346
+ return literals[0] if literals else None
347
+
348
+ @property
349
+ def reasoning_end_str(self) -> str | None:
350
+ thinking = self.response_template.fields.get(THINKING_FIELD)
351
+ literals = thinking.close_literals if thinking else None
352
+ return literals[0] if literals else None
353
+
354
+ def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
355
+ try:
356
+ parser = ResponseParser(
357
+ self.response_template,
358
+ prefix=_decode(self.model_tokenizer, input_ids),
359
+ )
360
+ except Exception:
361
+ return False
362
+ return _reasoning_has_ended(
363
+ parser,
364
+ self.response_template,
365
+ thinking_disabled=self._thinking_disabled,
366
+ )
367
+
368
+ def is_reasoning_end_streaming(
369
+ self, input_ids: Sequence[int], delta_ids: Iterable[int]
370
+ ) -> bool:
371
+ if self._reasoning_end_ids is None:
372
+ return self.is_reasoning_end(input_ids)
373
+ num_new = len(list(delta_ids))
374
+ return any(
375
+ _ends_within(input_ids, end_ids, num_new)
376
+ for end_ids in self._reasoning_end_ids
377
+ )
378
+
379
+ def extract_content_ids(self, input_ids: list[int]) -> list[int]:
380
+ raise NotImplementedError("ResponseTemplateParser extracts content")
381
+
382
+ def extract_reasoning(
383
+ self,
384
+ model_output: str,
385
+ request: ChatCompletionRequest | ResponsesRequest,
386
+ ) -> tuple[str | None, str | None]:
387
+ raise NotImplementedError("ResponseTemplateParser extracts reasoning")
388
+
389
+ def extract_reasoning_streaming(
390
+ self,
391
+ previous_text: str,
392
+ current_text: str,
393
+ delta_text: str,
394
+ previous_token_ids: Sequence[int],
395
+ current_token_ids: Sequence[int],
396
+ delta_token_ids: Sequence[int],
397
+ ) -> DeltaMessage | None:
398
+ raise NotImplementedError("ResponseTemplateParser extracts reasoning")
399
+
400
+
401
+class ResponseTemplateToolParser(ToolParser):
402
+ """Registers `--tool-call-parser hf`; `ResponseTemplateParser` extracts the
403
+ tool calls."""
404
+
405
+
406
+class _DeltaBuilder:
407
+ """Accumulates one `DeltaMessage`."""
408
+
409
+ def __init__(self) -> None:
410
+ self.reasoning: list[str] = []
411
+ self.content: list[str] = []
412
+ self.tool_calls: list[DeltaToolCall] = []
413
+
414
+ def build(self) -> DeltaMessage | None:
415
+ fields: dict[str, Any] = {}
416
+ if reasoning := "".join(self.reasoning):
417
+ fields["reasoning"] = reasoning
418
+ if content := "".join(self.content):
419
+ fields["content"] = content
420
+ if self.tool_calls:
421
+ fields["tool_calls"] = self.tool_calls
422
+ return DeltaMessage(**fields) if fields else None
423
+
424
+
425
+class _ResponseStream:
426
+ """One generation fed through `ResponseParser`, routed to message deltas."""
427
+
428
+ def __init__(
429
+ self,
430
+ template: ResponseTemplate,
431
+ *,
432
+ prefix: str,
433
+ tools: list[dict[str, Any]],
434
+ parse_reasoning: bool,
435
+ include_reasoning: bool,
436
+ parse_tools: bool,
437
+ new_tool_call_id: Callable[[str], str],
438
+ ) -> None:
439
+ self.parse_reasoning = parse_reasoning
440
+ self.include_reasoning = include_reasoning
441
+ self.parse_tools = parse_tools
442
+ self.new_tool_call_id = new_tool_call_id
443
+ self.parser = ResponseParser(template, prefix=prefix, tools=tools)
444
+ self._prefix_end = len(self.parser.input_text)
445
+ self._next_tool_index = 0
446
+ self._finalized = False
447
+
448
+ def feed(self, text: str, *, finished: bool) -> DeltaMessage | None:
449
+ if self._finalized:
450
+ return None
451
+ delta = _DeltaBuilder()
452
+ self._route(self.parser.feed(text), delta)
453
+ if finished:
454
+ self._finalized = True
455
+ if len(self.parser.input_text) > self._prefix_end:
456
+ _, events = self.parser.finalize()
457
+ self._route(events, delta)
458
+ return delta.build()
459
+
460
+ def _route(self, events: Sequence[dict[str, Any]], delta: _DeltaBuilder) -> None:
461
+ for event in events:
462
+ field = event.get("field")
463
+ if field == TOOL_FIELD:
464
+ self._route_tool_event(event, delta)
465
+ elif event["type"] == "region_chunk":
466
+ if field == THINKING_FIELD and self.parse_reasoning:
467
+ if self.include_reasoning:
468
+ delta.reasoning.append(event["text"])
469
+ elif field in (THINKING_FIELD, CONTENT_FIELD):
470
+ delta.content.append(event["text"])
471
+
472
+ def _route_tool_event(self, event: dict[str, Any], delta: _DeltaBuilder) -> None:
473
+ """Emit the calls of a tool region once it ends and parses; a region cut
474
+ off at the end of the stream is parsed as generated. With tool parsing
475
+ disabled, the region passes through as content."""
476
+ if not self.parse_tools:
477
+ if event["type"] == "region_chunk":
478
+ delta.content.append(event["text"])
479
+ else:
480
+ start = max(event["start"], self._prefix_end)
481
+ delta.content.append(self.parser.input_text[start : event["end"]])
482
+ return
483
+ if event["type"] == "region_malformed":
484
+ logger.warning("response_template: dropping a malformed tool call")
485
+ elif event["type"] == "region_close":
486
+ calls = _to_tool_calls(event.get("value"))
487
+ if not calls:
488
+ logger.warning("response_template: dropping an incomplete tool call")
489
+ for name, arguments in calls:
490
+ delta.tool_calls.append(
491
+ DeltaToolCall(
492
+ index=self._next_tool_index,
493
+ id=self.new_tool_call_id(name),
494
+ type="function",
495
+ function=DeltaFunctionCall(name=name, arguments=arguments),
496
+ )
497
+ )
498
+ self._next_tool_index += 1
499
+
500
+
501
+class ResponseTemplateParser(Parser):
502
+ """Parse reasoning, content, and tool calls with one response template."""
503
+
504
+ reasoning_parser_cls: type[ReasoningParser] | None = ResponseTemplateReasoningParser
505
+ tool_parser_cls: type[ToolParser] | None = ResponseTemplateToolParser
506
+ always_adjust_request = True
507
+ _enable_auto_tools = False
508
+
509
+ def __init__(
510
+ self,
511
+ tokenizer: TokenizerLike,
512
+ tools: list[Tool] | None = None,
513
+ *,
514
+ response_template: dict[str, Any] | None = None,
515
+ enable_auto_tools: bool | None = None,
516
+ **kwargs,
517
+ ) -> None:
518
+ self.response_template = _serving_template(tokenizer, response_template)
519
+ super().__init__(
520
+ tokenizer, tools, response_template=response_template, **kwargs
521
+ )
522
+ if enable_auto_tools is not None:
523
+ self._enable_auto_tools = enable_auto_tools
524
+ self._tools = _as_tool_dicts(tools)
525
+ self._prompt_token_ids: list[int] | None = None
526
+ self._prefix = ""
527
+ self._stream: _ResponseStream | None = None
528
+
529
+ def set_prompt_token_ids(self, prompt_token_ids: Sequence[int]) -> None:
530
+ prompt_token_ids = list(prompt_token_ids)
531
+ if prompt_token_ids == self._prompt_token_ids:
532
+ return
533
+ self._prompt_token_ids = prompt_token_ids
534
+ self._prefix = _decode(self.model_tokenizer, prompt_token_ids)
535
+ self._stream = None
536
+
537
+ def adjust_request(
538
+ self,
539
+ request: ChatCompletionRequest | ResponsesRequest,
540
+ ) -> ChatCompletionRequest | ResponsesRequest:
541
+ if getattr(request, "chat_template", None) is not None:
542
+ logger.warning_once(
543
+ "A request chat template is set; response_template parsing still "
544
+ "expects the checkpoint's output format."
545
+ )
546
+ request.skip_special_tokens = False
547
+ if hasattr(request, "spaces_between_special_tokens"):
548
+ request.spaces_between_special_tokens = False
549
+ if (
550
+ self._tool_parser is not None
551
+ and request.tools
552
+ and request.tool_choice != "none"
553
+ and (unsupported := _unsupported_tool_guarantee(request)) is not None
554
+ ):
555
+ guarantee, parameter = unsupported
556
+ raise VLLMValidationError(
557
+ f"response_template parsing cannot guarantee {guarantee} "
558
+ "without a format-compatible structural grammar",
559
+ parameter=parameter,
560
+ )
561
+ return request
562
+
563
+ def is_reasoning_end(self, input_ids: list[int]) -> bool:
564
+ return (
565
+ self._reasoning_parser is None
566
+ or self._reasoning_parser.is_reasoning_end(input_ids)
567
+ )
568
+
569
+ def parse(
570
+ self,
571
+ model_output: str,
572
+ request: ChatCompletionRequest | ResponsesRequest,
573
+ enable_auto_tools: bool = False,
574
+ model_output_token_ids: Sequence[int] = (),
575
+ ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
576
+ self._initialize_history_tool_call_cnt(request)
577
+ stream = self._new_stream(
578
+ request,
579
+ enable_auto_tools=enable_auto_tools,
580
+ include_reasoning=True,
581
+ )
582
+ delta = stream.feed(model_output, finished=True)
583
+ if delta is None:
584
+ return None, None, None
585
+ tool_calls = [
586
+ FunctionCall(
587
+ id=call.id,
588
+ name=call.function.name or "",
589
+ arguments=call.function.arguments or "{}",
590
+ )
591
+ for call in delta.tool_calls
592
+ if call.function is not None
593
+ ]
594
+ return delta.reasoning, delta.content, tool_calls or None
595
+
596
+ def parse_delta(
597
+ self,
598
+ delta_text: str,
599
+ delta_token_ids: list[int],
600
+ request: ChatCompletionRequest | ResponsesRequest,
601
+ prompt_token_ids: list[int] | None = None,
602
+ *,
603
+ finished: bool,
604
+ ) -> DeltaMessage | None:
605
+ self._initialize_history_tool_call_cnt(request)
606
+ if self._prompt_token_ids is None and prompt_token_ids is not None:
607
+ self.set_prompt_token_ids(prompt_token_ids)
608
+ if self._stream is None:
609
+ self._stream = self._new_stream(
610
+ request,
611
+ enable_auto_tools=self._enable_auto_tools,
612
+ include_reasoning=request.include_reasoning,
613
+ )
614
+ return self._stream.feed(delta_text, finished=finished)
615
+
616
+ def _new_stream(
617
+ self,
618
+ request: ChatCompletionRequest | ResponsesRequest,
619
+ *,
620
+ enable_auto_tools: bool,
621
+ include_reasoning: bool,
622
+ ) -> _ResponseStream:
623
+ return _ResponseStream(
624
+ self.response_template,
625
+ prefix=self._prefix,
626
+ tools=self._tools,
627
+ parse_reasoning=self._reasoning_parser is not None,
628
+ include_reasoning=include_reasoning,
629
+ parse_tools=self._tool_parser is not None
630
+ and _tool_parsing_enabled(request, enable_auto_tools=enable_auto_tools),
631
+ new_tool_call_id=self._new_tool_call_id,
632
+ )
633
+
634
+ def _new_tool_call_id(self, name: str) -> str:
635
+ state = self._stream_state
636
+ tool_call_id = make_tool_call_id(
637
+ id_type=state.tool_call_id_type,
638
+ func_name=name,
639
+ idx=state.history_tool_call_cnt,
640
+ )
641
+ state.history_tool_call_cnt += 1
642
+ return tool_call_id
vllm/reasoning/__init__.py
+4
@@ -80,6 +80,10 @@ _REASONING_PARSERS_TO_REGISTER = {
80
"granite_thinking_engine_reasoning_parser",
81
"GraniteThinkingParserReasoningAdapter",
82
),
83
+ "hf": (
84
+ "response_template_reasoning_parser",
85
+ "ResponseTemplateReasoningParser",
86
+ ),
87
"holo2": (
88
"deepseek_v3_reasoning_parser",
89
"DeepSeekV3ReasoningWithThinkingParser",
vllm/reasoning/response_template_reasoning_parser.py
new
+6
@@ -0,0 +1,6 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.parser.response_template import ResponseTemplateReasoningParser
5
+
6
+__all__ = ["ResponseTemplateReasoningParser"]
vllm/renderers/online_derenderer.py
+3
@@ -79,6 +79,7 @@ class OnlineDerenderer:
79
tool_strict_level=tool_strict_level,
80
model_name=model_config.model,
81
is_harmony=self.use_harmony,
82
+ tokenizer=renderer.tokenizer,
83
)
84
85
self.chat_template = chat_template
@@ -160,6 +161,8 @@ class OnlineDerenderer:
161
chat_template_kwargs=chat_template_kwargs,
162
model_config=self.model_config,
163
)
164
+ if generate_response.prompt_token_ids is not None:
165
+ parser.set_prompt_token_ids(generate_response.prompt_token_ids)
166
reasoning, content, tool_calls = parser.parse(
167
decoded_text,
168
chat_request,
vllm/renderers/online_renderer.py
+3
-1
@@ -183,6 +183,7 @@ class OnlineRenderer:
183
tool_strict_level=tool_strict_level,
184
model_name=model_config.model,
185
is_harmony=self.use_harmony,
186
+ tokenizer=renderer.tokenizer,
187
)
188
189
self.chat_template = chat_template
@@ -825,7 +826,8 @@ class OnlineRenderer:
826
and tokenizer.supports_grammar
827
)
828
should_adjust_request = (
828
- parser.reasoning_parser_cls is not None
829
+ parser.always_adjust_request
830
+ or parser.reasoning_parser_cls is not None
831
or tool_choice != "none"
832
or is_mistral_grammar_eligible
833
)
vllm/tool_parsers/__init__.py
+4
@@ -86,6 +86,10 @@ _TOOL_PARSERS_TO_REGISTER = {
86
"hermes_tool_parser",
87
"Hermes2ProToolParser",
88
),
89
+ "hf": (
90
+ "response_template_tool_parser",
91
+ "ResponseTemplateToolParser",
92
+ ),
93
"poolside_v1": (
94
"poolside_v1_tool_parser",
95
"PoolsideV1ToolParser",
vllm/tool_parsers/response_template_tool_parser.py
new
+6
@@ -0,0 +1,6 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.parser.response_template import ResponseTemplateToolParser
5
+
6
+__all__ = ["ResponseTemplateToolParser"]