| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Adapters that expose :class:`ParserEngine` through the legacy |
| 4 | :class:`ReasoningParser` and :class:`ToolParser` interfaces. |
| 5 | |
| 6 | This lets parser engines flow through the existing serving-layer code |
| 7 | paths that expect separate reasoning and tool parser instances, without |
| 8 | any changes to the serving layer itself. |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | from collections.abc import Iterator, Sequence |
| 14 | from contextlib import contextmanager |
| 15 | from typing import TYPE_CHECKING |
| 16 | |
| 17 | from vllm.parser.engine.parser_engine_config import ParserState |
| 18 | from vllm.reasoning.abs_reasoning_parsers import ReasoningParser |
| 19 | from vllm.tool_parsers.abstract_tool_parser import ToolParser |
| 20 | |
| 21 | if TYPE_CHECKING: |
| 22 | from vllm.entrypoints.generate.base.protocol import ( |
| 23 | DeltaMessage, |
| 24 | ExtractedToolCallInformation, |
| 25 | ) |
| 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 ParserEngine |
| 31 | from vllm.tokenizers import TokenizerLike |
| 32 | from vllm.tool_parsers.utils import Tool |
| 33 | |
| 34 | |
| 35 | class ParserEngineReasoningAdapter(ReasoningParser): |
| 36 | """Adapts a :class:`ParserEngine` to the :class:`ReasoningParser` |
| 37 | interface so parser engines can be used as reasoning parsers in the |
| 38 | existing serving code. |
| 39 | |
| 40 | Subclasses set :attr:`_parser_engine_cls` to the concrete |
| 41 | :class:`ParserEngine` class. |
| 42 | """ |
| 43 | |
| 44 | _parser_engine_cls: type[ParserEngine] |
| 45 | engine_based_streaming: bool = True |
| 46 | |
| 47 | def __init__(self, tokenizer: TokenizerLike, *args, **kwargs) -> None: |
| 48 | super().__init__(tokenizer, *args, **kwargs) |
| 49 | self._parser_engine = self._parser_engine_cls(tokenizer, **kwargs) # type: ignore[call-arg] |
| 50 | self._parser_engine_kwargs = kwargs |
| 51 | self._counting_parser_engine: ParserEngine | None = None |
| 52 | # TODO: Remove once Responses finalization reuses accumulated streaming |
| 53 | # parser results instead of reparsing the complete output. |
| 54 | self._streaming_count_valid = False |
| 55 | |
| 56 | @contextmanager |
| 57 | def _skip_tool_parsing(self) -> Iterator[None]: |
| 58 | saved = self._parser_engine.skip_tool_parsing |
| 59 | self._parser_engine.skip_tool_parsing = True |
| 60 | try: |
| 61 | yield |
| 62 | finally: |
| 63 | self._parser_engine.skip_tool_parsing = saved |
| 64 | |
| 65 | def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: |
| 66 | return self._parser_engine.is_reasoning_end(list(input_ids)) |
| 67 | |
| 68 | def find_reasoning_end_offset(self, token_ids: Sequence[int]) -> int | None: |
| 69 | return self._parser_engine.find_reasoning_end_offset(token_ids) |
| 70 | |
| 71 | def adjust_initial_state_from_prompt(self, prompt_token_ids: Sequence[int]) -> None: |
| 72 | self._parser_engine.adjust_initial_state_from_prompt(prompt_token_ids) |
| 73 | |
| 74 | def extract_content_ids(self, input_ids: list[int]) -> list[int]: |
| 75 | return self._parser_engine.extract_content_ids(input_ids) |
| 76 | |
| 77 | def extract_reasoning( |
| 78 | self, |
| 79 | model_output: str, |
| 80 | request: ChatCompletionRequest | ResponsesRequest, |
| 81 | ) -> tuple[str | None, str | None]: |
| 82 | self._streaming_count_valid = False |
| 83 | with self._skip_tool_parsing(): |
| 84 | return self._parser_engine.extract_reasoning(model_output, request) |
| 85 | |
| 86 | def extract_reasoning_streaming( |
| 87 | self, |
| 88 | previous_text: str, |
| 89 | current_text: str, |
| 90 | delta_text: str, |
| 91 | previous_token_ids: Sequence[int], |
| 92 | current_token_ids: Sequence[int], |
| 93 | delta_token_ids: Sequence[int], |
| 94 | ) -> DeltaMessage | None: |
| 95 | self._streaming_count_valid = True |
| 96 | with self._skip_tool_parsing(): |
| 97 | return self._parser_engine.extract_reasoning_streaming( |
| 98 | previous_text, |
| 99 | current_text, |
| 100 | delta_text, |
| 101 | previous_token_ids, |
| 102 | current_token_ids, |
| 103 | delta_token_ids, |
| 104 | ) |
| 105 | |
| 106 | @property |
| 107 | def reasoning_start_str(self) -> str | None: |
| 108 | return self._parser_engine.reasoning_start_str |
| 109 | |
| 110 | @property |
| 111 | def reasoning_end_str(self) -> str | None: |
| 112 | return self._parser_engine.reasoning_end_str |
| 113 | |
| 114 | @property |
| 115 | def reasoning_end_token_ids(self) -> frozenset[int]: |
| 116 | return self._parser_engine.reasoning_end_token_ids |
| 117 | |
| 118 | def adjust_request( |
| 119 | self, |
| 120 | request: ChatCompletionRequest | ResponsesRequest, |
| 121 | ) -> ChatCompletionRequest | ResponsesRequest: |
| 122 | return self._parser_engine.adjust_request(request) |
| 123 | |
| 124 | def has_engine_confirmed_reasoning_end(self) -> bool: |
| 125 | return self._parser_engine.reasoning_ended |
| 126 | |
| 127 | def finish_streaming(self) -> DeltaMessage | None: |
| 128 | with self._skip_tool_parsing(): |
| 129 | return self._parser_engine.finish_streaming() |
| 130 | |
| 131 | def get_streaming_fallback_content( |
| 132 | self, |
| 133 | text: str, |
| 134 | request: ChatCompletionRequest | ResponsesRequest, |
| 135 | ) -> str | None: |
| 136 | return self._parser_engine.get_streaming_fallback_content(text, request) |
| 137 | |
| 138 | def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int: |
| 139 | if self._streaming_count_valid: |
| 140 | return self._parser_engine.count_reasoning_tokens(token_ids) |
| 141 | if not token_ids: |
| 142 | return 0 |
| 143 | if self._counting_parser_engine is None: |
| 144 | self._counting_parser_engine = self._parser_engine_cls( |
| 145 | self.model_tokenizer, **self._parser_engine_kwargs |
| 146 | ) # type: ignore[call-arg] |
| 147 | self._counting_parser_engine._single_pass_parse( |
| 148 | self.model_tokenizer.decode(token_ids), token_ids |
| 149 | ) |
| 150 | return self._counting_parser_engine.count_reasoning_tokens(token_ids) |
| 151 | |
| 152 | |
| 153 | class ParserEngineToolAdapter(ToolParser): |
| 154 | """Adapts a :class:`ParserEngine` to the :class:`ToolParser` interface. |
| 155 | |
| 156 | :meth:`extract_tool_calls` starts the parser engine in ``CONTENT`` |
| 157 | state so it can parse reasoning-stripped content (i.e. the output of |
| 158 | :meth:`ReasoningParser.extract_reasoning`). |
| 159 | |
| 160 | When no reasoning parser is configured at the serving layer, the |
| 161 | owning :class:`~vllm.parser.abstract_parser.Parser` sets |
| 162 | :attr:`skip_reasoning_parsing` so reasoning markup — plain content |
| 163 | in that configuration — passes through verbatim instead of being |
| 164 | consumed or reclassified by the engine. The engine honors the flag |
| 165 | only for markers that are reasoning-exclusive in its config; a |
| 166 | grammar like Inkling, whose block closer is shared with text and |
| 167 | tool blocks, keeps parsing unchanged. |
| 168 | |
| 169 | Subclasses set :attr:`_parser_engine_cls` to the concrete |
| 170 | :class:`ParserEngine` class. |
| 171 | """ |
| 172 | |
| 173 | _parser_engine_cls: type[ParserEngine] |
| 174 | engine_based_streaming: bool = True |
| 175 | |
| 176 | def __init__( |
| 177 | self, |
| 178 | tokenizer: TokenizerLike, |
| 179 | tools: list[Tool] | None = None, |
| 180 | **kwargs, |
| 181 | ) -> None: |
| 182 | super().__init__(tokenizer, tools) |
| 183 | self._parser_engine = self._parser_engine_cls(tokenizer, tools, **kwargs) # type: ignore[call-arg] |
| 184 | |
| 185 | @property |
| 186 | def skip_reasoning_parsing(self) -> bool: |
| 187 | return self._parser_engine.skip_reasoning_parsing |
| 188 | |
| 189 | @skip_reasoning_parsing.setter |
| 190 | def skip_reasoning_parsing(self, value: bool) -> None: |
| 191 | self._parser_engine.skip_reasoning_parsing = value |
| 192 | |
| 193 | def adjust_request( |
| 194 | self, |
| 195 | request: ChatCompletionRequest | ResponsesRequest, |
| 196 | ) -> ChatCompletionRequest | ResponsesRequest: |
| 197 | request = super().adjust_request(request) |
| 198 | return self._parser_engine.adjust_request(request) |
| 199 | |
| 200 | def extract_tool_calls( |
| 201 | self, |
| 202 | model_output: str, |
| 203 | request: ChatCompletionRequest, |
| 204 | ) -> ExtractedToolCallInformation: |
| 205 | return self._parser_engine.extract_tool_calls_from_content( |
| 206 | model_output, request |
| 207 | ) |
| 208 | |
| 209 | def extract_tool_calls_streaming( |
| 210 | self, |
| 211 | previous_text: str, |
| 212 | current_text: str, |
| 213 | delta_text: str, |
| 214 | previous_token_ids: Sequence[int], |
| 215 | current_token_ids: Sequence[int], |
| 216 | delta_token_ids: Sequence[int], |
| 217 | request: ChatCompletionRequest, |
| 218 | ) -> DeltaMessage | None: |
| 219 | engine = self._parser_engine |
| 220 | engine.initialize_streaming(initial_state=ParserState.CONTENT) |
| 221 | return engine.extract_tool_calls_streaming( |
| 222 | previous_text, |
| 223 | current_text, |
| 224 | delta_text, |
| 225 | previous_token_ids, |
| 226 | current_token_ids, |
| 227 | delta_token_ids, |
| 228 | request, |
| 229 | ) |
| 230 | |
| 231 | def finish_streaming(self) -> DeltaMessage | None: |
| 232 | return self._parser_engine.finish_streaming() |
| 233 | |
| 234 | |
| 235 | def make_adapters( |
| 236 | parser_engine_cls: type[ParserEngine], |
| 237 | ) -> tuple[type[ParserEngineReasoningAdapter], type[ParserEngineToolAdapter]]: |
| 238 | reasoning_adapter = type( |
| 239 | f"{parser_engine_cls.__name__}ReasoningAdapter", |
| 240 | (ParserEngineReasoningAdapter,), |
| 241 | {"_parser_engine_cls": parser_engine_cls}, |
| 242 | ) |
| 243 | tool_adapter = type( |
| 244 | f"{parser_engine_cls.__name__}ToolAdapter", |
| 245 | (ParserEngineToolAdapter,), |
| 246 | {"_parser_engine_cls": parser_engine_cls}, |
| 247 | ) |
| 248 | # Let the serving layer find the adapters and call adjust_request(), |
| 249 | # which sets skip_special_tokens=False for the detokenizer. |
| 250 | parser_engine_cls.reasoning_parser_cls = reasoning_adapter # type: ignore[attr-defined] |
| 251 | parser_engine_cls.tool_parser_cls = tool_adapter # type: ignore[attr-defined] |
| 252 | return reasoning_adapter, tool_adapter |