[Feature][Frontend] Add granite_thinking_parser reasoning parser for Granite 4.2 (#55957)
Signed-off-by: Yousaf shah <yousaf.shah@gmail.com> Co-authored-by: sfeng33 <4florafeng@gmail.com>
Yousaf committed
Sep 25, 2026 at 16:43 UTC
83db8c83904dcccb95ed202a3453b0492ad9c2df
9 files changed
+834
-2
docs/features/reasoning_outputs.md
+2
@@ -25,12 +25,14 @@ vLLM currently supports the following reasoning models:
25
| [Holo2 series](https://huggingface.co/collections/Hcompany/holo2) | `holo2` | `json`, `regex` | ✅ |
26
| [Hunyuan A13B series](https://huggingface.co/collections/tencent/hunyuan-a13b-685ec38e5b46321e3ea7c4be) | `hunyuan_a13b` | `json`, `regex` | ✅ |
27
| [IBM Granite 3.2 language models](https://huggingface.co/collections/ibm-granite/granite-32-language-models-67b3bc8c13508f6d064cff9a) | `granite` | ❌ | ❌ |
28
+| [IBM Granite 4.2 language models](https://huggingface.co/ibm-granite/granite-4.2-30b) | `granite_thinking_parser` | `json`, `regex` | ✅ |
29
| [MiniMax-M2](https://huggingface.co/MiniMaxAI/MiniMax-M2) | `minimax_m2_append_think` | `json`, `regex` | ✅ |
30
| [Qwen3 series](https://huggingface.co/collections/Qwen/qwen3-67dd247413f0e2e4f653967f) | `qwen3` | `json`, `regex` | ✅ |
31
| [QwQ-32B](https://huggingface.co/Qwen/QwQ-32B) | `deepseek_r1` | `json`, `regex` | ✅ |
32
33
!!! note
34
IBM Granite 3.2 and DeepSeek-V3.1 reasoning is disabled by default; to enable it, you must also pass `thinking=True` in your `chat_template_kwargs`.
35
+ IBM Granite 4.2 reasoning is enabled by default. To disable it, you must pass `enable_thinking=False` in your `chat_template_kwargs`.
36
The reasoning feature for the Qwen3 series is enabled by default. To disable it, you must pass `enable_thinking=False` in your `chat_template_kwargs`.
37
Gemma 4 reasoning is disabled by default; to enable it, pass `enable_thinking=True` in your `chat_template_kwargs` or set `reasoning_effort` (which enables it automatically).
38
DeepSeek-V3.1 tool calling is supported in non-thinking mode.
tests/parser/engine/replay_harness.py
+1
@@ -34,6 +34,7 @@ class Sample:
34
tools: list[dict] | None = None
35
chat_template_kwargs: dict | None = None
36
prompt_token_ids: list[int] | None = None
37
+ content_lstrip: str | None = None
38
39
40
@dataclass
tests/parser/engine/test_replay.py
+12
-1
@@ -327,7 +327,18 @@ _TOOL_CALL_SAMPLES = [
327
def _tool_suppression_expectations(
328
sample, think_end: str, tool_start: str, *, include_tool_block: bool
329
) -> tuple[str, str]:
330
- """Expected (reasoning, content) when tool calls are not extracted.
330
+ reasoning, content = _raw_tool_suppression_expectations(
331
+ sample, think_end, tool_start, include_tool_block=include_tool_block
332
+ )
333
+ if sample.content_lstrip:
334
+ content = content.lstrip(sample.content_lstrip)
335
+ return reasoning, content
336
+
337
+
338
+def _raw_tool_suppression_expectations(
339
+ sample, think_end: str, tool_start: str, *, include_tool_block: bool
340
+) -> tuple[str, str]:
341
+ """Expected (reasoning, content) from the raw token text.
342
343
With ``include_tool_block=True`` (skip_tool_parsing / reasoning
344
adapter first pass), tool terminal text is preserved as content so
tests/parser/engine/trace_builder.py
+27
-1
@@ -15,7 +15,7 @@ from __future__ import annotations
15
16
import functools
17
import json
18
-from dataclasses import dataclass
18
+from dataclasses import dataclass, replace
19
from typing import Any
20
21
from tests.parser.engine.replay_harness import (
@@ -35,6 +35,7 @@ from vllm.parser.engine.registered_adapters import (
35
Gemma4Parser,
36
Glm47MoeParser,
37
GraniteParser,
38
+ GraniteThinkingParser,
39
InklingParser,
40
KimiK2Parser,
41
MinimaxM2Parser,
@@ -594,6 +595,30 @@ def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
595
)
596
597
598
+def _build_granite_thinking(scenario: Scenario, validate: bool = True) -> Sample:
599
+ """Granite 4.2: the Nemotron V3 grammar plus leading-newline stripping on
600
+ content after ``</think>``."""
601
+ sample = _build_qwen3(
602
+ scenario,
603
+ name="granite_thinking_parser",
604
+ parser_cls=GraniteThinkingParser,
605
+ strip_trailing_ws=True,
606
+ validate=False,
607
+ )
608
+ sample = replace(
609
+ sample,
610
+ expected_content=(
611
+ sample.expected_content.lstrip("\n")
612
+ if sample.expected_content
613
+ else sample.expected_content
614
+ ),
615
+ content_lstrip="\n",
616
+ )
617
+ if validate:
618
+ _validate_sample(sample, GraniteThinkingParser)
619
+ return sample
620
+
621
+
622
# ── Seed-OSS (Qwen3 XML grammar with Seed wrapper tokens) ────────────
623
624
_SEED_OSS_VOCAB: dict[str, int] = {
@@ -1136,6 +1161,7 @@ _BUILDERS: dict[str, Any] = {
1161
"granite": _build_granite,
1162
"minimax_m2": _build_minimax_m2,
1163
"nemotron_v3": _build_nemotron_v3,
1164
+ "granite_thinking_parser": _build_granite_thinking,
1165
"seed_oss": _build_seed_oss,
1166
"glm47_moe": _build_glm47_moe,
1167
"kimi_k2": _build_kimi_k2,
tests/reasoning/test_granite_thinking_reasoning_parser.py
new
+643
@@ -0,0 +1,643 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from typing import TypedDict
5
+
6
+import pytest
7
+import regex as re
8
+
9
+from tests.reasoning.utils import run_reasoning_extraction
10
+from vllm.entrypoints.openai.chat_completion.protocol import (
11
+ ChatCompletionRequest,
12
+)
13
+from vllm.parser.abstract_parser import DelegatingParser
14
+from vllm.parser.engine.registered_adapters import (
15
+ GraniteThinkingParserReasoningAdapter,
16
+)
17
+from vllm.reasoning import ReasoningParser, ReasoningParserManager
18
+
19
+parser_name = "granite_thinking_parser"
20
+
21
+
22
+class ReasoningCase(TypedDict):
23
+ output: str
24
+ reasoning: str | None
25
+ content: str | None
26
+
27
+
28
+class GraniteThinkingTokenizer:
29
+ def __init__(self):
30
+ self._vocab = {
31
+ "<think>": 1,
32
+ "</think>": 2,
33
+ }
34
+ self._inv_vocab = {v: k for k, v in self._vocab.items()}
35
+ self._pattern = re.compile(r"(<think>|</think>)")
36
+
37
+ def get_vocab(self) -> dict[str, int]:
38
+ return self._vocab
39
+
40
+ def tokenize(self, text: str) -> list[str]:
41
+ tokens: list[str] = []
42
+ for part in self._pattern.split(text):
43
+ if part:
44
+ tokens.append(part)
45
+ return tokens
46
+
47
+ def convert_tokens_to_string(self, tokens: list[str]) -> str:
48
+ return "".join(tokens)
49
+
50
+ def decode(self, token_ids: list[int]) -> str:
51
+ return "".join(self._inv_vocab.get(tid, f"<unk:{tid}>") for tid in token_ids)
52
+
53
+
54
+@pytest.fixture
55
+def tokenizer():
56
+ return GraniteThinkingTokenizer()
57
+
58
+
59
+# ── Basic reasoning extraction (non-streaming + streaming) ───────────
60
+
61
+
62
+@pytest.mark.parametrize(
63
+ "streaming,param_dict",
64
+ [
65
+ pytest.param(
66
+ False,
67
+ {
68
+ "output": "<think>reasoning</think>\nHello",
69
+ "reasoning": "reasoning",
70
+ "content": "Hello",
71
+ },
72
+ id="leading_newline_stripped",
73
+ ),
74
+ pytest.param(
75
+ True,
76
+ {
77
+ "output": "<think>reasoning</think>\nHello",
78
+ "reasoning": "reasoning",
79
+ "content": "Hello",
80
+ },
81
+ id="leading_newline_stripped_streaming",
82
+ ),
83
+ pytest.param(
84
+ False,
85
+ {
86
+ "output": "<think>r</think>c",
87
+ "reasoning": "r",
88
+ "content": "c",
89
+ },
90
+ id="simple_reasoning",
91
+ ),
92
+ pytest.param(
93
+ True,
94
+ {
95
+ "output": "<think>r</think>c",
96
+ "reasoning": "r",
97
+ "content": "c",
98
+ },
99
+ id="simple_reasoning_streaming",
100
+ ),
101
+ pytest.param(
102
+ False,
103
+ {
104
+ "output": "This is a reasoning section</think>This is the rest",
105
+ "reasoning": "This is a reasoning section",
106
+ "content": "This is the rest",
107
+ },
108
+ id="without_start_token",
109
+ ),
110
+ pytest.param(
111
+ True,
112
+ {
113
+ "output": "This is a reasoning section</think>This is the rest",
114
+ "reasoning": "This is a reasoning section",
115
+ "content": "This is the rest",
116
+ },
117
+ id="without_start_token_streaming",
118
+ ),
119
+ pytest.param(
120
+ False,
121
+ {
122
+ "output": "<think>This is a reasoning section</think>This is the rest", # noqa: E501
123
+ "reasoning": "This is a reasoning section",
124
+ "content": "This is the rest",
125
+ },
126
+ id="with_start_token",
127
+ ),
128
+ pytest.param(
129
+ True,
130
+ {
131
+ "output": "<think>This is a reasoning section</think>This is the rest", # noqa: E501
132
+ "reasoning": "This is a reasoning section",
133
+ "content": "This is the rest",
134
+ },
135
+ id="with_start_token_streaming",
136
+ ),
137
+ pytest.param(
138
+ False,
139
+ {
140
+ "output": "<think>reasoning</think>\n\n\nHello",
141
+ "reasoning": "reasoning",
142
+ "content": "Hello",
143
+ },
144
+ id="multiple_leading_newlines_stripped",
145
+ ),
146
+ pytest.param(
147
+ True,
148
+ {
149
+ "output": "<think>reasoning</think>\n\n\nHello",
150
+ "reasoning": "reasoning",
151
+ "content": "Hello",
152
+ },
153
+ id="multiple_leading_newlines_stripped_streaming",
154
+ ),
155
+ pytest.param(
156
+ False,
157
+ {
158
+ "output": "<think>line1\nline2</think>\nresult1\nresult2",
159
+ "reasoning": "line1\nline2",
160
+ "content": "result1\nresult2",
161
+ },
162
+ id="multiline_reasoning_and_content",
163
+ ),
164
+ pytest.param(
165
+ True,
166
+ {
167
+ "output": "<think>line1\nline2</think>\nresult1\nresult2",
168
+ "reasoning": "line1\nline2",
169
+ "content": "result1\nresult2",
170
+ },
171
+ id="multiline_reasoning_and_content_streaming",
172
+ ),
173
+ ],
174
+)
175
+def test_granite_thinking_reasoning(
176
+ tokenizer: GraniteThinkingTokenizer,
177
+ streaming: bool,
178
+ param_dict: ReasoningCase,
179
+):
180
+ output = tokenizer.tokenize(param_dict["output"])
181
+ model_output = [tokenizer.convert_tokens_to_string([token]) for token in output]
182
+ parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
183
+ tokenizer
184
+ )
185
+
186
+ reasoning, content = run_reasoning_extraction(
187
+ parser, model_output, streaming=streaming
188
+ )
189
+
190
+ assert reasoning == param_dict["reasoning"]
191
+ assert content == param_dict["content"]
192
+
193
+
194
+# ── No content after end token ───────────────────────────────────────
195
+
196
+
197
+def test_granite_thinking_no_content_after_end_token(
198
+ tokenizer: GraniteThinkingTokenizer,
199
+):
200
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
201
+ parser = parser_cls(tokenizer)
202
+
203
+ reasoning, content = run_reasoning_extraction(
204
+ parser,
205
+ ["<think>", "reasoning", "</think>"],
206
+ streaming=False,
207
+ )
208
+
209
+ assert reasoning == "reasoning"
210
+ assert content is None
211
+
212
+
213
+# ── Whitespace-only content after end token ──────────────────────────
214
+
215
+
216
+@pytest.mark.parametrize("streaming", [False, True])
217
+def test_granite_thinking_whitespace_only_content(
218
+ tokenizer: GraniteThinkingTokenizer,
219
+ streaming: bool,
220
+):
221
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
222
+ parser = parser_cls(tokenizer)
223
+
224
+ reasoning, content = run_reasoning_extraction(
225
+ parser,
226
+ ["<think>", "reasoning", "</think>", "\n\n"],
227
+ streaming=streaming,
228
+ )
229
+
230
+ assert reasoning == "reasoning"
231
+ assert content is None
232
+
233
+
234
+# ── Unterminated think block ─────────────────────────────────────────
235
+
236
+
237
+def test_granite_thinking_unterminated_think_block(
238
+ tokenizer: GraniteThinkingTokenizer,
239
+):
240
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
241
+ parser = parser_cls(tokenizer)
242
+ request = ChatCompletionRequest(
243
+ model="test-model",
244
+ messages=[],
245
+ chat_template_kwargs={"enable_thinking": True},
246
+ )
247
+
248
+ reasoning, content = run_reasoning_extraction(
249
+ parser,
250
+ ["<think>", "reasoning only"],
251
+ request=request,
252
+ streaming=False,
253
+ )
254
+
255
+ assert reasoning == "reasoning only"
256
+ assert content is None
257
+
258
+
259
+# ── enable_thinking=False ────────────────────────────────────────────
260
+
261
+
262
+def test_granite_thinking_disabled_moves_into_content(
263
+ tokenizer: GraniteThinkingTokenizer,
264
+):
265
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
266
+ parser = parser_cls(tokenizer)
267
+ request = ChatCompletionRequest(
268
+ model="test-model",
269
+ messages=[],
270
+ chat_template_kwargs={"enable_thinking": False},
271
+ )
272
+
273
+ reasoning, content = run_reasoning_extraction(
274
+ parser,
275
+ ["This is plain content"],
276
+ request=request,
277
+ streaming=False,
278
+ )
279
+
280
+ assert reasoning is None
281
+ assert content == "This is plain content"
282
+
283
+
284
+# ── enable_thinking=False + leading newline (§6.2 ordering) ─────────
285
+
286
+
287
+def test_granite_thinking_disabled_with_leading_newline(
288
+ tokenizer: GraniteThinkingTokenizer,
289
+):
290
+ # With enable_thinking=False, model output goes through the swap
291
+ # path: content is initially None (all text classified as
292
+ # reasoning), so lstrip doesn't fire pre-swap. After the swap,
293
+ # any leading \n in the original output is preserved. In practice,
294
+ # enable_thinking=False output has no template-injected \n, so
295
+ # this is a correctness check, not a realistic scenario.
296
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
297
+ parser = parser_cls(tokenizer)
298
+ request = ChatCompletionRequest(
299
+ model="test-model",
300
+ messages=[],
301
+ chat_template_kwargs={"enable_thinking": False},
302
+ )
303
+
304
+ reasoning, content = run_reasoning_extraction(
305
+ parser,
306
+ ["\nThis is plain content"],
307
+ request=request,
308
+ streaming=False,
309
+ )
310
+
311
+ assert reasoning is None
312
+ assert content == "\nThis is plain content"
313
+
314
+
315
+# ── force_nonempty_content=True ──────────────────────────────────────
316
+
317
+
318
+def test_granite_thinking_force_nonempty_content_moves_into_content(
319
+ tokenizer: GraniteThinkingTokenizer,
320
+):
321
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
322
+ parser = parser_cls(tokenizer)
323
+ request = ChatCompletionRequest(
324
+ model="test-model",
325
+ messages=[],
326
+ chat_template_kwargs={"force_nonempty_content": True},
327
+ )
328
+
329
+ reasoning, content = run_reasoning_extraction(
330
+ parser,
331
+ ["<think>", "This is plain content"],
332
+ request=request,
333
+ streaming=False,
334
+ )
335
+
336
+ assert reasoning is None
337
+ assert content == "This is plain content"
338
+
339
+
340
+def test_granite_thinking_force_nonempty_no_swap_when_newlines_only(
341
+ tokenizer: GraniteThinkingTokenizer,
342
+):
343
+ # When </think> IS present and content is newlines-only, lstrip
344
+ # removes them but the swap should NOT fire — content was present,
345
+ # just whitespace. Matches the HF plugin behavior.
346
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
347
+ parser = parser_cls(tokenizer)
348
+ request = ChatCompletionRequest(
349
+ model="test-model",
350
+ messages=[],
351
+ chat_template_kwargs={"force_nonempty_content": True},
352
+ )
353
+
354
+ reasoning, content = run_reasoning_extraction(
355
+ parser,
356
+ ["<think>", "reasoning", "</think>", "\n\n"],
357
+ request=request,
358
+ streaming=False,
359
+ )
360
+
361
+ assert reasoning == "reasoning"
362
+ assert content is None
363
+
364
+
365
+def test_granite_thinking_force_nonempty_swaps_when_content_absent(
366
+ tokenizer: GraniteThinkingTokenizer,
367
+):
368
+ # When </think> IS present but content is truly absent (zero
369
+ # characters after </think>, e.g. max_tokens cut), swap fires.
370
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
371
+ parser = parser_cls(tokenizer)
372
+ request = ChatCompletionRequest(
373
+ model="test-model",
374
+ messages=[],
375
+ chat_template_kwargs={"force_nonempty_content": True},
376
+ )
377
+
378
+ reasoning, content = run_reasoning_extraction(
379
+ parser,
380
+ ["<think>", "reasoning", "</think>"],
381
+ request=request,
382
+ streaming=False,
383
+ )
384
+
385
+ assert reasoning is None
386
+ assert content == "reasoning"
387
+
388
+
389
+def test_granite_thinking_force_nonempty_keeps_real_content(
390
+ tokenizer: GraniteThinkingTokenizer,
391
+):
392
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
393
+ parser = parser_cls(tokenizer)
394
+ request = ChatCompletionRequest(
395
+ model="test-model",
396
+ messages=[],
397
+ chat_template_kwargs={"force_nonempty_content": True},
398
+ )
399
+
400
+ reasoning, content = run_reasoning_extraction(
401
+ parser,
402
+ ["<think>", "reasoning here", "</think>", "real answer"],
403
+ request=request,
404
+ streaming=False,
405
+ )
406
+
407
+ assert reasoning == "reasoning here"
408
+ assert content == "real answer"
409
+
410
+
411
+# ── Truncated reasoning with thinking on ─────────────────────────────
412
+
413
+
414
+def test_granite_thinking_keeps_truncated_reasoning(
415
+ tokenizer: GraniteThinkingTokenizer,
416
+):
417
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
418
+ parser = parser_cls(tokenizer)
419
+ request = ChatCompletionRequest(
420
+ model="test-model",
421
+ messages=[],
422
+ chat_template_kwargs={"enable_thinking": True},
423
+ )
424
+
425
+ reasoning, content = run_reasoning_extraction(
426
+ parser,
427
+ ["This is truncated reasoning"],
428
+ request=request,
429
+ streaming=False,
430
+ )
431
+
432
+ assert reasoning == "This is truncated reasoning"
433
+ assert content is None
434
+
435
+
436
+# ── DelegatingParser / parse_delta streaming tests ───────────────────
437
+
438
+_SPECIAL_TOKEN_IDS = {"<think>": 1, "</think>": 2}
439
+
440
+
441
+def _token_id(token: str) -> int:
442
+ return _SPECIAL_TOKEN_IDS.get(token, 0)
443
+
444
+
445
+def _make_reasoning_parser(tokenizer):
446
+ class _GraniteThinkingDelegating(DelegatingParser):
447
+ reasoning_parser_cls = GraniteThinkingParserReasoningAdapter
448
+ tool_parser_cls = None
449
+
450
+ return _GraniteThinkingDelegating(tokenizer)
451
+
452
+
453
+def _run_parse_delta(parser, tokenizer, text, request):
454
+ tokens = tokenizer.tokenize(text)
455
+ reasoning_parts: list[str] = []
456
+ content_parts: list[str] = []
457
+ for i, token in enumerate(tokens):
458
+ delta = parser.parse_delta(
459
+ delta_text=token,
460
+ delta_token_ids=[_token_id(token)],
461
+ request=request,
462
+ prompt_token_ids=[] if i == 0 else None,
463
+ finished=(i == len(tokens) - 1),
464
+ )
465
+ if delta is None:
466
+ continue
467
+ if delta.reasoning:
468
+ reasoning_parts.append(delta.reasoning)
469
+ if delta.content:
470
+ content_parts.append(delta.content)
471
+ return "".join(reasoning_parts), "".join(content_parts)
472
+
473
+
474
+def test_granite_thinking_streaming_enable_thinking_false(
475
+ tokenizer: GraniteThinkingTokenizer,
476
+):
477
+ # With enable_thinking=False, the parser (constructed without
478
+ # kwargs) starts in REASONING state. All text streams as reasoning
479
+ # AND is duplicated into content via the streaming fallback —
480
+ # matching NemotronV3 behavior.
481
+ request = ChatCompletionRequest(
482
+ model="test-model",
483
+ messages=[],
484
+ chat_template_kwargs={"enable_thinking": False},
485
+ )
486
+ parser = _make_reasoning_parser(tokenizer)
487
+
488
+ reasoning, content = _run_parse_delta(
489
+ parser, tokenizer, "This is plain content", request
490
+ )
491
+
492
+ assert reasoning == "This is plain content"
493
+ assert content == "This is plain content"
494
+
495
+
496
+def test_granite_thinking_streaming_strips_leading_newline(
497
+ tokenizer: GraniteThinkingTokenizer,
498
+):
499
+ request = ChatCompletionRequest(
500
+ model="test-model",
501
+ messages=[],
502
+ )
503
+ parser = _make_reasoning_parser(tokenizer)
504
+
505
+ reasoning, content = _run_parse_delta(
506
+ parser, tokenizer, "<think>reason</think>\nHello", request
507
+ )
508
+
509
+ assert reasoning == "reason"
510
+ assert content == "Hello"
511
+
512
+
513
+def test_granite_thinking_streaming_promotes_reasoning_to_content(
514
+ tokenizer: GraniteThinkingTokenizer,
515
+):
516
+ request = ChatCompletionRequest(
517
+ model="test-model",
518
+ messages=[],
519
+ chat_template_kwargs={"force_nonempty_content": True},
520
+ )
521
+ parser = _make_reasoning_parser(tokenizer)
522
+
523
+ reasoning, content = _run_parse_delta(parser, tokenizer, "<think>4", request)
524
+
525
+ assert reasoning == "4"
526
+ assert content == "4"
527
+
528
+
529
+def test_granite_thinking_streaming_no_promotion_with_real_content(
530
+ tokenizer: GraniteThinkingTokenizer,
531
+):
532
+ request = ChatCompletionRequest(
533
+ model="test-model",
534
+ messages=[],
535
+ chat_template_kwargs={"force_nonempty_content": True},
536
+ )
537
+ parser = _make_reasoning_parser(tokenizer)
538
+
539
+ reasoning, content = _run_parse_delta(
540
+ parser,
541
+ tokenizer,
542
+ "<think>reason</think>real answer",
543
+ request,
544
+ )
545
+
546
+ assert reasoning == "reason"
547
+ assert content == "real answer"
548
+
549
+
550
+def test_granite_thinking_streaming_no_promotion_without_opt_in(
551
+ tokenizer: GraniteThinkingTokenizer,
552
+):
553
+ request = ChatCompletionRequest(model="test-model", messages=[])
554
+ parser = _make_reasoning_parser(tokenizer)
555
+
556
+ reasoning, content = _run_parse_delta(parser, tokenizer, "<think>4", request)
557
+
558
+ assert reasoning == "4"
559
+ assert content == ""
560
+
561
+
562
+# ── Empty think block ────────────────────────────────────────────────
563
+
564
+
565
+@pytest.mark.parametrize("streaming", [False, True])
566
+def test_granite_thinking_empty_think_block(
567
+ tokenizer: GraniteThinkingTokenizer,
568
+ streaming: bool,
569
+):
570
+ # <think></think>\nHello — empty reasoning, content after newline.
571
+ parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name)
572
+ parser = parser_cls(tokenizer)
573
+
574
+ reasoning, content = run_reasoning_extraction(
575
+ parser,
576
+ ["<think>", "</think>", "\n", "Hello"],
577
+ streaming=streaming,
578
+ )
579
+
580
+ assert reasoning is None or reasoning == ""
581
+ assert content == "Hello"
582
+
583
+
584
+# ── Chunking-independence streaming test ─────────────────────────────
585
+
586
+
587
+@pytest.mark.parametrize(
588
+ "text,expected_reasoning,expected_content",
589
+ [
590
+ ("<think>r</think>\nHello", "r", "Hello"),
591
+ ("<think></think>\nHello", "", "Hello"),
592
+ ],
593
+)
594
+def test_granite_thinking_streaming_chunking_independent(
595
+ tokenizer: GraniteThinkingTokenizer,
596
+ text: str,
597
+ expected_reasoning: str,
598
+ expected_content: str,
599
+):
600
+ # Verify the same input produces identical results regardless
601
+ # of how it's chunked into streaming deltas. Uses the
602
+ # DelegatingParser path (_run_parse_delta) which matches the
603
+ # real serving flow.
604
+ tokens = tokenizer.tokenize(text)
605
+ request = ChatCompletionRequest(model="test-model", messages=[])
606
+
607
+ chunk_patterns = [
608
+ tokens,
609
+ # Split at the </think> boundary
610
+ [
611
+ tokenizer.convert_tokens_to_string(tokens[: tokens.index("</think>") + 1]),
612
+ tokenizer.convert_tokens_to_string(tokens[tokens.index("</think>") + 1 :]),
613
+ ],
614
+ ]
615
+
616
+ for chunks in chunk_patterns:
617
+ parser = _make_reasoning_parser(tokenizer)
618
+ reasoning_parts: list[str] = []
619
+ content_parts: list[str] = []
620
+ all_tokens = []
621
+ for chunk in chunks:
622
+ chunk_tokens = tokenizer.tokenize(chunk)
623
+ all_tokens.extend(chunk_tokens)
624
+ for i, token in enumerate(all_tokens):
625
+ delta = parser.parse_delta(
626
+ delta_text=token,
627
+ delta_token_ids=[_token_id(token)],
628
+ request=request,
629
+ prompt_token_ids=[] if i == 0 else None,
630
+ finished=(i == len(all_tokens) - 1),
631
+ )
632
+ if delta is None:
633
+ continue
634
+ if delta.reasoning:
635
+ reasoning_parts.append(delta.reasoning)
636
+ if delta.content:
637
+ content_parts.append(delta.content)
638
+ reasoning = "".join(reasoning_parts)
639
+ content = "".join(content_parts)
640
+ assert reasoning == expected_reasoning or (
641
+ not expected_reasoning and not reasoning
642
+ ), f"reasoning mismatch with chunks={chunks}"
643
+ assert content == expected_content, f"content mismatch with chunks={chunks}"
vllm/parser/engine/registered_adapters.py
+6
@@ -14,6 +14,7 @@ from vllm.parser.engine.adapters import make_adapters
14
from vllm.parser.gemma4 import Gemma4Parser
15
from vllm.parser.glm47_moe import Glm47MoeParser
16
from vllm.parser.granite import GraniteParser
17
+from vllm.parser.granite_thinking import GraniteThinkingParser
18
from vllm.parser.inkling import InklingParser
19
from vllm.parser.kimi_k2 import KimiK2Parser
20
from vllm.parser.minimax_m2 import MinimaxM2Parser
@@ -52,6 +53,11 @@ from vllm.parser.seed_oss import SeedOssParser
53
GraniteParserToolAdapter,
54
) = make_adapters(GraniteParser)
55
56
+(
57
+ GraniteThinkingParserReasoningAdapter,
58
+ GraniteThinkingParserToolAdapter,
59
+) = make_adapters(GraniteThinkingParser)
60
+
61
(
62
NemotronV3ParserReasoningAdapter,
63
NemotronV3ParserToolAdapter,
vllm/parser/granite_thinking.py
new
+131
@@ -0,0 +1,131 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Granite 4.2 thinking parser.
4
+
5
+Granite 4.2 uses the same tool call and reasoning format as Nemotron V3
6
+(``<think>``/``</think>`` + ``<tool_call>`` XML). This config reuses
7
+:class:`NemotronV3Parser` with two Granite-specific behaviors:
8
+
9
+1. Strip leading newlines from content — the Granite chat template writes
10
+ ``\\n</think>\\n``, so the first content character is always ``\\n``.
11
+2. Inherit the Nemotron V3 reasoning-to-content swap for
12
+ ``enable_thinking=False`` / ``force_nonempty_content=True``.
13
+"""
14
+
15
+from __future__ import annotations
16
+
17
+import dataclasses
18
+import functools
19
+from typing import TYPE_CHECKING
20
+
21
+from vllm.parser.nemotron_v3 import NemotronV3Parser, nemotron_v3_config
22
+from vllm.parser.qwen3 import Qwen3Parser
23
+
24
+if TYPE_CHECKING:
25
+ from vllm.entrypoints.generate.base.protocol import DeltaMessage
26
+ from vllm.entrypoints.openai.chat_completion.protocol import (
27
+ ChatCompletionRequest,
28
+ )
29
+ from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
30
+ from vllm.parser.engine.parser_engine import SemanticEvent
31
+ from vllm.parser.engine.parser_engine_config import ParserEngineConfig
32
+ from vllm.tokenizers import TokenizerLike
33
+ from vllm.tool_parsers.abstract_tool_parser import Tool
34
+
35
+
36
+@functools.cache
37
+def granite_thinking_config(thinking: bool = True) -> ParserEngineConfig:
38
+ return dataclasses.replace(
39
+ nemotron_v3_config(thinking=thinking),
40
+ name="granite_thinking_parser",
41
+ )
42
+
43
+
44
+class GraniteThinkingParser(NemotronV3Parser):
45
+ """Granite 4.2 parser: same format as Nemotron V3, with
46
+ leading-newline stripping on content after ``</think>``.
47
+
48
+ The ``reasoning_ended`` property is overridden to delay the
49
+ reasoning-to-content transition reported to
50
+ :class:`~vllm.parser.abstract_parser.DelegatingParser` until the
51
+ template's leading newline has been consumed. Without this delay,
52
+ ``DelegatingParser.parse_delta`` passes newline tokens through a
53
+ raw pass-through path that bypasses the engine's
54
+ ``_events_to_delta`` (and therefore our stripping logic).
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ tokenizer: TokenizerLike,
60
+ tools: list[Tool] | None = None,
61
+ **kwargs,
62
+ ) -> None:
63
+ chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
64
+ thinking = chat_kwargs.get("enable_thinking", True)
65
+ kwargs.pop("parser_engine_config", None)
66
+ Qwen3Parser.__init__(
67
+ self,
68
+ tokenizer,
69
+ tools,
70
+ parser_engine_config=granite_thinking_config(thinking=thinking),
71
+ **kwargs,
72
+ )
73
+ self._streamed_reasoning: list[str] = []
74
+ self._content_started = False
75
+
76
+ @property
77
+ def reasoning_ended(self) -> bool:
78
+ # Internal engine logic uses _reasoning_ended directly; this
79
+ # property is only read by DelegatingParser (via the adapter's
80
+ # has_engine_confirmed_reasoning_end) to decide when to stop
81
+ # routing deltas through extract_reasoning_streaming.
82
+ if not self._reasoning_ended:
83
+ return False
84
+ return self._content_started
85
+
86
+ def _reset(self, initial_state=None) -> None:
87
+ super()._reset(initial_state=initial_state)
88
+ self._content_started = False
89
+
90
+ def _events_to_delta(
91
+ self,
92
+ events: list[SemanticEvent],
93
+ finished: bool = False,
94
+ ) -> DeltaMessage | None:
95
+ delta = super()._events_to_delta(events, finished=finished)
96
+ if (
97
+ delta is not None
98
+ and delta.content is not None
99
+ and not self._content_started
100
+ ):
101
+ stripped = delta.content.lstrip("\n")
102
+ if not stripped:
103
+ delta.content = None
104
+ if delta.reasoning is None and not delta.tool_calls:
105
+ return None
106
+ else:
107
+ self._content_started = True
108
+ delta.content = stripped
109
+ return delta
110
+
111
+ def extract_reasoning(
112
+ self,
113
+ model_output: str,
114
+ request: ChatCompletionRequest | ResponsesRequest,
115
+ ) -> tuple[str | None, str | None]:
116
+ reasoning, content = Qwen3Parser.extract_reasoning(self, model_output, request)
117
+
118
+ # Track whether content was truly absent (None) vs present
119
+ # but whitespace-only — the swap should only fire for absent.
120
+ content_was_absent = content is None
121
+
122
+ if content is not None:
123
+ content = content.lstrip("\n") or None
124
+
125
+ # Only swap when content was truly absent from model output
126
+ # (truncated reasoning or max_tokens cut at </think>), not
127
+ # when it was present but stripped to empty (newlines-only).
128
+ if self._should_force_content(request) and content_was_absent:
129
+ reasoning, content = content, reasoning
130
+
131
+ return reasoning, content
vllm/reasoning/__init__.py
+4
@@ -76,6 +76,10 @@ _REASONING_PARSERS_TO_REGISTER = {
76
"granite_reasoning_parser",
77
"GraniteReasoningParser",
78
),
79
+ "granite_thinking_parser": (
80
+ "granite_thinking_engine_reasoning_parser",
81
+ "GraniteThinkingParserReasoningAdapter",
82
+ ),
83
"holo2": (
84
"deepseek_v3_reasoning_parser",
85
"DeepSeekV3ReasoningWithThinkingParser",
vllm/reasoning/granite_thinking_engine_reasoning_parser.py
new
+8
@@ -0,0 +1,8 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.parser.engine.registered_adapters import (
5
+ GraniteThinkingParserReasoningAdapter,
6
+)
7
+
8
+__all__ = ["GraniteThinkingParserReasoningAdapter"]