| 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 |