[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"]