diff --git a/docs/features/reasoning_outputs.md b/docs/features/reasoning_outputs.md index a58dc6033e..16f2a675f8 100644 --- a/docs/features/reasoning_outputs.md +++ b/docs/features/reasoning_outputs.md @@ -25,12 +25,14 @@ vLLM currently supports the following reasoning models: | [Holo2 series](https://huggingface.co/collections/Hcompany/holo2) | `holo2` | `json`, `regex` | ✅ | | [Hunyuan A13B series](https://huggingface.co/collections/tencent/hunyuan-a13b-685ec38e5b46321e3ea7c4be) | `hunyuan_a13b` | `json`, `regex` | ✅ | | [IBM Granite 3.2 language models](https://huggingface.co/collections/ibm-granite/granite-32-language-models-67b3bc8c13508f6d064cff9a) | `granite` | ❌ | ❌ | +| [IBM Granite 4.2 language models](https://huggingface.co/ibm-granite/granite-4.2-30b) | `granite_thinking_parser` | `json`, `regex` | ✅ | | [MiniMax-M2](https://huggingface.co/MiniMaxAI/MiniMax-M2) | `minimax_m2_append_think` | `json`, `regex` | ✅ | | [Qwen3 series](https://huggingface.co/collections/Qwen/qwen3-67dd247413f0e2e4f653967f) | `qwen3` | `json`, `regex` | ✅ | | [QwQ-32B](https://huggingface.co/Qwen/QwQ-32B) | `deepseek_r1` | `json`, `regex` | ✅ | !!! note 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`. + IBM Granite 4.2 reasoning is enabled by default. To disable it, you must pass `enable_thinking=False` in your `chat_template_kwargs`. 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`. 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). DeepSeek-V3.1 tool calling is supported in non-thinking mode. diff --git a/tests/parser/engine/replay_harness.py b/tests/parser/engine/replay_harness.py index 5eec8d213a..a33121705d 100644 --- a/tests/parser/engine/replay_harness.py +++ b/tests/parser/engine/replay_harness.py @@ -34,6 +34,7 @@ class Sample: tools: list[dict] | None = None chat_template_kwargs: dict | None = None prompt_token_ids: list[int] | None = None + content_lstrip: str | None = None @dataclass diff --git a/tests/parser/engine/test_replay.py b/tests/parser/engine/test_replay.py index fbb911d7ae..cc8fef483f 100644 --- a/tests/parser/engine/test_replay.py +++ b/tests/parser/engine/test_replay.py @@ -327,7 +327,18 @@ _TOOL_CALL_SAMPLES = [ def _tool_suppression_expectations( sample, think_end: str, tool_start: str, *, include_tool_block: bool ) -> tuple[str, str]: - """Expected (reasoning, content) when tool calls are not extracted. + reasoning, content = _raw_tool_suppression_expectations( + sample, think_end, tool_start, include_tool_block=include_tool_block + ) + if sample.content_lstrip: + content = content.lstrip(sample.content_lstrip) + return reasoning, content + + +def _raw_tool_suppression_expectations( + sample, think_end: str, tool_start: str, *, include_tool_block: bool +) -> tuple[str, str]: + """Expected (reasoning, content) from the raw token text. With ``include_tool_block=True`` (skip_tool_parsing / reasoning adapter first pass), tool terminal text is preserved as content so diff --git a/tests/parser/engine/trace_builder.py b/tests/parser/engine/trace_builder.py index 2851edd4d0..2c101a9960 100644 --- a/tests/parser/engine/trace_builder.py +++ b/tests/parser/engine/trace_builder.py @@ -15,7 +15,7 @@ from __future__ import annotations import functools import json -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any from tests.parser.engine.replay_harness import ( @@ -35,6 +35,7 @@ from vllm.parser.engine.registered_adapters import ( Gemma4Parser, Glm47MoeParser, GraniteParser, + GraniteThinkingParser, InklingParser, KimiK2Parser, MinimaxM2Parser, @@ -594,6 +595,30 @@ def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample: ) +def _build_granite_thinking(scenario: Scenario, validate: bool = True) -> Sample: + """Granite 4.2: the Nemotron V3 grammar plus leading-newline stripping on + content after ````.""" + sample = _build_qwen3( + scenario, + name="granite_thinking_parser", + parser_cls=GraniteThinkingParser, + strip_trailing_ws=True, + validate=False, + ) + sample = replace( + sample, + expected_content=( + sample.expected_content.lstrip("\n") + if sample.expected_content + else sample.expected_content + ), + content_lstrip="\n", + ) + if validate: + _validate_sample(sample, GraniteThinkingParser) + return sample + + # ── Seed-OSS (Qwen3 XML grammar with Seed wrapper tokens) ──────────── _SEED_OSS_VOCAB: dict[str, int] = { @@ -1136,6 +1161,7 @@ _BUILDERS: dict[str, Any] = { "granite": _build_granite, "minimax_m2": _build_minimax_m2, "nemotron_v3": _build_nemotron_v3, + "granite_thinking_parser": _build_granite_thinking, "seed_oss": _build_seed_oss, "glm47_moe": _build_glm47_moe, "kimi_k2": _build_kimi_k2, diff --git a/tests/reasoning/test_granite_thinking_reasoning_parser.py b/tests/reasoning/test_granite_thinking_reasoning_parser.py new file mode 100644 index 0000000000..39ce3bf436 --- /dev/null +++ b/tests/reasoning/test_granite_thinking_reasoning_parser.py @@ -0,0 +1,643 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from typing import TypedDict + +import pytest +import regex as re + +from tests.reasoning.utils import run_reasoning_extraction +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, +) +from vllm.parser.abstract_parser import DelegatingParser +from vllm.parser.engine.registered_adapters import ( + GraniteThinkingParserReasoningAdapter, +) +from vllm.reasoning import ReasoningParser, ReasoningParserManager + +parser_name = "granite_thinking_parser" + + +class ReasoningCase(TypedDict): + output: str + reasoning: str | None + content: str | None + + +class GraniteThinkingTokenizer: + def __init__(self): + self._vocab = { + "": 1, + "": 2, + } + self._inv_vocab = {v: k for k, v in self._vocab.items()} + self._pattern = re.compile(r"(|)") + + def get_vocab(self) -> dict[str, int]: + return self._vocab + + def tokenize(self, text: str) -> list[str]: + tokens: list[str] = [] + for part in self._pattern.split(text): + if part: + tokens.append(part) + return tokens + + def convert_tokens_to_string(self, tokens: list[str]) -> str: + return "".join(tokens) + + def decode(self, token_ids: list[int]) -> str: + return "".join(self._inv_vocab.get(tid, f"") for tid in token_ids) + + +@pytest.fixture +def tokenizer(): + return GraniteThinkingTokenizer() + + +# ── Basic reasoning extraction (non-streaming + streaming) ─────────── + + +@pytest.mark.parametrize( + "streaming,param_dict", + [ + pytest.param( + False, + { + "output": "reasoning\nHello", + "reasoning": "reasoning", + "content": "Hello", + }, + id="leading_newline_stripped", + ), + pytest.param( + True, + { + "output": "reasoning\nHello", + "reasoning": "reasoning", + "content": "Hello", + }, + id="leading_newline_stripped_streaming", + ), + pytest.param( + False, + { + "output": "rc", + "reasoning": "r", + "content": "c", + }, + id="simple_reasoning", + ), + pytest.param( + True, + { + "output": "rc", + "reasoning": "r", + "content": "c", + }, + id="simple_reasoning_streaming", + ), + pytest.param( + False, + { + "output": "This is a reasoning sectionThis is the rest", + "reasoning": "This is a reasoning section", + "content": "This is the rest", + }, + id="without_start_token", + ), + pytest.param( + True, + { + "output": "This is a reasoning sectionThis is the rest", + "reasoning": "This is a reasoning section", + "content": "This is the rest", + }, + id="without_start_token_streaming", + ), + pytest.param( + False, + { + "output": "This is a reasoning sectionThis is the rest", # noqa: E501 + "reasoning": "This is a reasoning section", + "content": "This is the rest", + }, + id="with_start_token", + ), + pytest.param( + True, + { + "output": "This is a reasoning sectionThis is the rest", # noqa: E501 + "reasoning": "This is a reasoning section", + "content": "This is the rest", + }, + id="with_start_token_streaming", + ), + pytest.param( + False, + { + "output": "reasoning\n\n\nHello", + "reasoning": "reasoning", + "content": "Hello", + }, + id="multiple_leading_newlines_stripped", + ), + pytest.param( + True, + { + "output": "reasoning\n\n\nHello", + "reasoning": "reasoning", + "content": "Hello", + }, + id="multiple_leading_newlines_stripped_streaming", + ), + pytest.param( + False, + { + "output": "line1\nline2\nresult1\nresult2", + "reasoning": "line1\nline2", + "content": "result1\nresult2", + }, + id="multiline_reasoning_and_content", + ), + pytest.param( + True, + { + "output": "line1\nline2\nresult1\nresult2", + "reasoning": "line1\nline2", + "content": "result1\nresult2", + }, + id="multiline_reasoning_and_content_streaming", + ), + ], +) +def test_granite_thinking_reasoning( + tokenizer: GraniteThinkingTokenizer, + streaming: bool, + param_dict: ReasoningCase, +): + output = tokenizer.tokenize(param_dict["output"]) + model_output = [tokenizer.convert_tokens_to_string([token]) for token in output] + parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)( + tokenizer + ) + + reasoning, content = run_reasoning_extraction( + parser, model_output, streaming=streaming + ) + + assert reasoning == param_dict["reasoning"] + assert content == param_dict["content"] + + +# ── No content after end token ─────────────────────────────────────── + + +def test_granite_thinking_no_content_after_end_token( + tokenizer: GraniteThinkingTokenizer, +): + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + + reasoning, content = run_reasoning_extraction( + parser, + ["", "reasoning", ""], + streaming=False, + ) + + assert reasoning == "reasoning" + assert content is None + + +# ── Whitespace-only content after end token ────────────────────────── + + +@pytest.mark.parametrize("streaming", [False, True]) +def test_granite_thinking_whitespace_only_content( + tokenizer: GraniteThinkingTokenizer, + streaming: bool, +): + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + + reasoning, content = run_reasoning_extraction( + parser, + ["", "reasoning", "", "\n\n"], + streaming=streaming, + ) + + assert reasoning == "reasoning" + assert content is None + + +# ── Unterminated think block ───────────────────────────────────────── + + +def test_granite_thinking_unterminated_think_block( + tokenizer: GraniteThinkingTokenizer, +): + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"enable_thinking": True}, + ) + + reasoning, content = run_reasoning_extraction( + parser, + ["", "reasoning only"], + request=request, + streaming=False, + ) + + assert reasoning == "reasoning only" + assert content is None + + +# ── enable_thinking=False ──────────────────────────────────────────── + + +def test_granite_thinking_disabled_moves_into_content( + tokenizer: GraniteThinkingTokenizer, +): + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"enable_thinking": False}, + ) + + reasoning, content = run_reasoning_extraction( + parser, + ["This is plain content"], + request=request, + streaming=False, + ) + + assert reasoning is None + assert content == "This is plain content" + + +# ── enable_thinking=False + leading newline (§6.2 ordering) ───────── + + +def test_granite_thinking_disabled_with_leading_newline( + tokenizer: GraniteThinkingTokenizer, +): + # With enable_thinking=False, model output goes through the swap + # path: content is initially None (all text classified as + # reasoning), so lstrip doesn't fire pre-swap. After the swap, + # any leading \n in the original output is preserved. In practice, + # enable_thinking=False output has no template-injected \n, so + # this is a correctness check, not a realistic scenario. + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"enable_thinking": False}, + ) + + reasoning, content = run_reasoning_extraction( + parser, + ["\nThis is plain content"], + request=request, + streaming=False, + ) + + assert reasoning is None + assert content == "\nThis is plain content" + + +# ── force_nonempty_content=True ────────────────────────────────────── + + +def test_granite_thinking_force_nonempty_content_moves_into_content( + tokenizer: GraniteThinkingTokenizer, +): + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"force_nonempty_content": True}, + ) + + reasoning, content = run_reasoning_extraction( + parser, + ["", "This is plain content"], + request=request, + streaming=False, + ) + + assert reasoning is None + assert content == "This is plain content" + + +def test_granite_thinking_force_nonempty_no_swap_when_newlines_only( + tokenizer: GraniteThinkingTokenizer, +): + # When IS present and content is newlines-only, lstrip + # removes them but the swap should NOT fire — content was present, + # just whitespace. Matches the HF plugin behavior. + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"force_nonempty_content": True}, + ) + + reasoning, content = run_reasoning_extraction( + parser, + ["", "reasoning", "", "\n\n"], + request=request, + streaming=False, + ) + + assert reasoning == "reasoning" + assert content is None + + +def test_granite_thinking_force_nonempty_swaps_when_content_absent( + tokenizer: GraniteThinkingTokenizer, +): + # When IS present but content is truly absent (zero + # characters after , e.g. max_tokens cut), swap fires. + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"force_nonempty_content": True}, + ) + + reasoning, content = run_reasoning_extraction( + parser, + ["", "reasoning", ""], + request=request, + streaming=False, + ) + + assert reasoning is None + assert content == "reasoning" + + +def test_granite_thinking_force_nonempty_keeps_real_content( + tokenizer: GraniteThinkingTokenizer, +): + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"force_nonempty_content": True}, + ) + + reasoning, content = run_reasoning_extraction( + parser, + ["", "reasoning here", "", "real answer"], + request=request, + streaming=False, + ) + + assert reasoning == "reasoning here" + assert content == "real answer" + + +# ── Truncated reasoning with thinking on ───────────────────────────── + + +def test_granite_thinking_keeps_truncated_reasoning( + tokenizer: GraniteThinkingTokenizer, +): + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"enable_thinking": True}, + ) + + reasoning, content = run_reasoning_extraction( + parser, + ["This is truncated reasoning"], + request=request, + streaming=False, + ) + + assert reasoning == "This is truncated reasoning" + assert content is None + + +# ── DelegatingParser / parse_delta streaming tests ─────────────────── + +_SPECIAL_TOKEN_IDS = {"": 1, "": 2} + + +def _token_id(token: str) -> int: + return _SPECIAL_TOKEN_IDS.get(token, 0) + + +def _make_reasoning_parser(tokenizer): + class _GraniteThinkingDelegating(DelegatingParser): + reasoning_parser_cls = GraniteThinkingParserReasoningAdapter + tool_parser_cls = None + + return _GraniteThinkingDelegating(tokenizer) + + +def _run_parse_delta(parser, tokenizer, text, request): + tokens = tokenizer.tokenize(text) + reasoning_parts: list[str] = [] + content_parts: list[str] = [] + for i, token in enumerate(tokens): + delta = parser.parse_delta( + delta_text=token, + delta_token_ids=[_token_id(token)], + request=request, + prompt_token_ids=[] if i == 0 else None, + finished=(i == len(tokens) - 1), + ) + if delta is None: + continue + if delta.reasoning: + reasoning_parts.append(delta.reasoning) + if delta.content: + content_parts.append(delta.content) + return "".join(reasoning_parts), "".join(content_parts) + + +def test_granite_thinking_streaming_enable_thinking_false( + tokenizer: GraniteThinkingTokenizer, +): + # With enable_thinking=False, the parser (constructed without + # kwargs) starts in REASONING state. All text streams as reasoning + # AND is duplicated into content via the streaming fallback — + # matching NemotronV3 behavior. + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"enable_thinking": False}, + ) + parser = _make_reasoning_parser(tokenizer) + + reasoning, content = _run_parse_delta( + parser, tokenizer, "This is plain content", request + ) + + assert reasoning == "This is plain content" + assert content == "This is plain content" + + +def test_granite_thinking_streaming_strips_leading_newline( + tokenizer: GraniteThinkingTokenizer, +): + request = ChatCompletionRequest( + model="test-model", + messages=[], + ) + parser = _make_reasoning_parser(tokenizer) + + reasoning, content = _run_parse_delta( + parser, tokenizer, "reason\nHello", request + ) + + assert reasoning == "reason" + assert content == "Hello" + + +def test_granite_thinking_streaming_promotes_reasoning_to_content( + tokenizer: GraniteThinkingTokenizer, +): + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"force_nonempty_content": True}, + ) + parser = _make_reasoning_parser(tokenizer) + + reasoning, content = _run_parse_delta(parser, tokenizer, "4", request) + + assert reasoning == "4" + assert content == "4" + + +def test_granite_thinking_streaming_no_promotion_with_real_content( + tokenizer: GraniteThinkingTokenizer, +): + request = ChatCompletionRequest( + model="test-model", + messages=[], + chat_template_kwargs={"force_nonempty_content": True}, + ) + parser = _make_reasoning_parser(tokenizer) + + reasoning, content = _run_parse_delta( + parser, + tokenizer, + "reasonreal answer", + request, + ) + + assert reasoning == "reason" + assert content == "real answer" + + +def test_granite_thinking_streaming_no_promotion_without_opt_in( + tokenizer: GraniteThinkingTokenizer, +): + request = ChatCompletionRequest(model="test-model", messages=[]) + parser = _make_reasoning_parser(tokenizer) + + reasoning, content = _run_parse_delta(parser, tokenizer, "4", request) + + assert reasoning == "4" + assert content == "" + + +# ── Empty think block ──────────────────────────────────────────────── + + +@pytest.mark.parametrize("streaming", [False, True]) +def test_granite_thinking_empty_think_block( + tokenizer: GraniteThinkingTokenizer, + streaming: bool, +): + # \nHello — empty reasoning, content after newline. + parser_cls = ReasoningParserManager.get_reasoning_parser(parser_name) + parser = parser_cls(tokenizer) + + reasoning, content = run_reasoning_extraction( + parser, + ["", "", "\n", "Hello"], + streaming=streaming, + ) + + assert reasoning is None or reasoning == "" + assert content == "Hello" + + +# ── Chunking-independence streaming test ───────────────────────────── + + +@pytest.mark.parametrize( + "text,expected_reasoning,expected_content", + [ + ("r\nHello", "r", "Hello"), + ("\nHello", "", "Hello"), + ], +) +def test_granite_thinking_streaming_chunking_independent( + tokenizer: GraniteThinkingTokenizer, + text: str, + expected_reasoning: str, + expected_content: str, +): + # Verify the same input produces identical results regardless + # of how it's chunked into streaming deltas. Uses the + # DelegatingParser path (_run_parse_delta) which matches the + # real serving flow. + tokens = tokenizer.tokenize(text) + request = ChatCompletionRequest(model="test-model", messages=[]) + + chunk_patterns = [ + tokens, + # Split at the boundary + [ + tokenizer.convert_tokens_to_string(tokens[: tokens.index("") + 1]), + tokenizer.convert_tokens_to_string(tokens[tokens.index("") + 1 :]), + ], + ] + + for chunks in chunk_patterns: + parser = _make_reasoning_parser(tokenizer) + reasoning_parts: list[str] = [] + content_parts: list[str] = [] + all_tokens = [] + for chunk in chunks: + chunk_tokens = tokenizer.tokenize(chunk) + all_tokens.extend(chunk_tokens) + for i, token in enumerate(all_tokens): + delta = parser.parse_delta( + delta_text=token, + delta_token_ids=[_token_id(token)], + request=request, + prompt_token_ids=[] if i == 0 else None, + finished=(i == len(all_tokens) - 1), + ) + if delta is None: + continue + if delta.reasoning: + reasoning_parts.append(delta.reasoning) + if delta.content: + content_parts.append(delta.content) + reasoning = "".join(reasoning_parts) + content = "".join(content_parts) + assert reasoning == expected_reasoning or ( + not expected_reasoning and not reasoning + ), f"reasoning mismatch with chunks={chunks}" + assert content == expected_content, f"content mismatch with chunks={chunks}" diff --git a/vllm/parser/engine/registered_adapters.py b/vllm/parser/engine/registered_adapters.py index f7f749948d..7353a6016b 100644 --- a/vllm/parser/engine/registered_adapters.py +++ b/vllm/parser/engine/registered_adapters.py @@ -14,6 +14,7 @@ from vllm.parser.engine.adapters import make_adapters from vllm.parser.gemma4 import Gemma4Parser from vllm.parser.glm47_moe import Glm47MoeParser from vllm.parser.granite import GraniteParser +from vllm.parser.granite_thinking import GraniteThinkingParser from vllm.parser.inkling import InklingParser from vllm.parser.kimi_k2 import KimiK2Parser from vllm.parser.minimax_m2 import MinimaxM2Parser @@ -52,6 +53,11 @@ from vllm.parser.seed_oss import SeedOssParser GraniteParserToolAdapter, ) = make_adapters(GraniteParser) +( + GraniteThinkingParserReasoningAdapter, + GraniteThinkingParserToolAdapter, +) = make_adapters(GraniteThinkingParser) + ( NemotronV3ParserReasoningAdapter, NemotronV3ParserToolAdapter, diff --git a/vllm/parser/granite_thinking.py b/vllm/parser/granite_thinking.py new file mode 100644 index 0000000000..3ab3c8c0af --- /dev/null +++ b/vllm/parser/granite_thinking.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Granite 4.2 thinking parser. + +Granite 4.2 uses the same tool call and reasoning format as Nemotron V3 +(````/```` + ```` XML). This config reuses +:class:`NemotronV3Parser` with two Granite-specific behaviors: + +1. Strip leading newlines from content — the Granite chat template writes + ``\\n\\n``, so the first content character is always ``\\n``. +2. Inherit the Nemotron V3 reasoning-to-content swap for + ``enable_thinking=False`` / ``force_nonempty_content=True``. +""" + +from __future__ import annotations + +import dataclasses +import functools +from typing import TYPE_CHECKING + +from vllm.parser.nemotron_v3 import NemotronV3Parser, nemotron_v3_config +from vllm.parser.qwen3 import Qwen3Parser + +if TYPE_CHECKING: + from vllm.entrypoints.generate.base.protocol import DeltaMessage + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, + ) + from vllm.entrypoints.openai.responses.protocol import ResponsesRequest + from vllm.parser.engine.parser_engine import SemanticEvent + from vllm.parser.engine.parser_engine_config import ParserEngineConfig + from vllm.tokenizers import TokenizerLike + from vllm.tool_parsers.abstract_tool_parser import Tool + + +@functools.cache +def granite_thinking_config(thinking: bool = True) -> ParserEngineConfig: + return dataclasses.replace( + nemotron_v3_config(thinking=thinking), + name="granite_thinking_parser", + ) + + +class GraniteThinkingParser(NemotronV3Parser): + """Granite 4.2 parser: same format as Nemotron V3, with + leading-newline stripping on content after ````. + + The ``reasoning_ended`` property is overridden to delay the + reasoning-to-content transition reported to + :class:`~vllm.parser.abstract_parser.DelegatingParser` until the + template's leading newline has been consumed. Without this delay, + ``DelegatingParser.parse_delta`` passes newline tokens through a + raw pass-through path that bypasses the engine's + ``_events_to_delta`` (and therefore our stripping logic). + """ + + def __init__( + self, + tokenizer: TokenizerLike, + tools: list[Tool] | None = None, + **kwargs, + ) -> None: + chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {} + thinking = chat_kwargs.get("enable_thinking", True) + kwargs.pop("parser_engine_config", None) + Qwen3Parser.__init__( + self, + tokenizer, + tools, + parser_engine_config=granite_thinking_config(thinking=thinking), + **kwargs, + ) + self._streamed_reasoning: list[str] = [] + self._content_started = False + + @property + def reasoning_ended(self) -> bool: + # Internal engine logic uses _reasoning_ended directly; this + # property is only read by DelegatingParser (via the adapter's + # has_engine_confirmed_reasoning_end) to decide when to stop + # routing deltas through extract_reasoning_streaming. + if not self._reasoning_ended: + return False + return self._content_started + + def _reset(self, initial_state=None) -> None: + super()._reset(initial_state=initial_state) + self._content_started = False + + def _events_to_delta( + self, + events: list[SemanticEvent], + finished: bool = False, + ) -> DeltaMessage | None: + delta = super()._events_to_delta(events, finished=finished) + if ( + delta is not None + and delta.content is not None + and not self._content_started + ): + stripped = delta.content.lstrip("\n") + if not stripped: + delta.content = None + if delta.reasoning is None and not delta.tool_calls: + return None + else: + self._content_started = True + delta.content = stripped + return delta + + def extract_reasoning( + self, + model_output: str, + request: ChatCompletionRequest | ResponsesRequest, + ) -> tuple[str | None, str | None]: + reasoning, content = Qwen3Parser.extract_reasoning(self, model_output, request) + + # Track whether content was truly absent (None) vs present + # but whitespace-only — the swap should only fire for absent. + content_was_absent = content is None + + if content is not None: + content = content.lstrip("\n") or None + + # Only swap when content was truly absent from model output + # (truncated reasoning or max_tokens cut at ), not + # when it was present but stripped to empty (newlines-only). + if self._should_force_content(request) and content_was_absent: + reasoning, content = content, reasoning + + return reasoning, content diff --git a/vllm/reasoning/__init__.py b/vllm/reasoning/__init__.py index 615820bb29..cad835ff1f 100644 --- a/vllm/reasoning/__init__.py +++ b/vllm/reasoning/__init__.py @@ -76,6 +76,10 @@ _REASONING_PARSERS_TO_REGISTER = { "granite_reasoning_parser", "GraniteReasoningParser", ), + "granite_thinking_parser": ( + "granite_thinking_engine_reasoning_parser", + "GraniteThinkingParserReasoningAdapter", + ), "holo2": ( "deepseek_v3_reasoning_parser", "DeepSeekV3ReasoningWithThinkingParser", diff --git a/vllm/reasoning/granite_thinking_engine_reasoning_parser.py b/vllm/reasoning/granite_thinking_engine_reasoning_parser.py new file mode 100644 index 0000000000..37c7d4b350 --- /dev/null +++ b/vllm/reasoning/granite_thinking_engine_reasoning_parser.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import ( + GraniteThinkingParserReasoningAdapter, +) + +__all__ = ["GraniteThinkingParserReasoningAdapter"]