main
py 131 lines 4.82 KB
Raw
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