| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Nemotron V3 parser. |
| 4 | |
| 5 | The Nemotron 3 Super model uses the same tool call and reasoning |
| 6 | format as Qwen3 (``<think>``/``</think>`` + ``<tool_call>`` XML). |
| 7 | This config reuses :func:`qwen3_config` with a distinct name. |
| 8 | |
| 9 | When ``enable_thinking=False`` or ``force_nonempty_content=True`` and |
| 10 | content is empty, reasoning and content are swapped. |
| 11 | """ |
| 12 | |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import dataclasses |
| 16 | import functools |
| 17 | from typing import TYPE_CHECKING |
| 18 | |
| 19 | from vllm.parser.qwen3 import CHATML_TURN_BOUNDARIES, Qwen3Parser, qwen3_config |
| 20 | |
| 21 | if TYPE_CHECKING: |
| 22 | from vllm.entrypoints.generate.base.protocol import DeltaMessage |
| 23 | from vllm.entrypoints.openai.chat_completion.protocol import ( |
| 24 | ChatCompletionRequest, |
| 25 | ) |
| 26 | from vllm.entrypoints.openai.responses.protocol import ResponsesRequest |
| 27 | from vllm.parser.engine.parser_engine import SemanticEvent |
| 28 | from vllm.parser.engine.parser_engine_config import ParserEngineConfig |
| 29 | from vllm.tokenizers import TokenizerLike |
| 30 | from vllm.tool_parsers.abstract_tool_parser import Tool |
| 31 | |
| 32 | |
| 33 | @functools.cache |
| 34 | def nemotron_v3_config(thinking: bool = True) -> ParserEngineConfig: |
| 35 | return dataclasses.replace( |
| 36 | qwen3_config(thinking=thinking, turn_boundary_tokens=CHATML_TURN_BOUNDARIES), |
| 37 | name="nemotron_v3", |
| 38 | strip_trailing_reasoning_whitespace=True, |
| 39 | ) |
| 40 | |
| 41 | |
| 42 | class NemotronV3Parser(Qwen3Parser): |
| 43 | """Nemotron V3 parser: same format as Qwen3, with Nemotron-specific |
| 44 | behavior: when ``enable_thinking=False`` or |
| 45 | ``force_nonempty_content=True`` and content is empty, swaps |
| 46 | reasoning and content. |
| 47 | """ |
| 48 | |
| 49 | def __init__( |
| 50 | self, |
| 51 | tokenizer: TokenizerLike, |
| 52 | tools: list[Tool] | None = None, |
| 53 | **kwargs, |
| 54 | ) -> None: |
| 55 | chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {} |
| 56 | thinking = chat_kwargs.get("enable_thinking", True) |
| 57 | super().__init__( |
| 58 | tokenizer, |
| 59 | tools, |
| 60 | parser_engine_config=nemotron_v3_config(thinking=thinking), |
| 61 | **kwargs, |
| 62 | ) |
| 63 | self._streamed_reasoning: list[str] = [] |
| 64 | |
| 65 | def _reset(self, initial_state=None) -> None: |
| 66 | super()._reset(initial_state=initial_state) |
| 67 | self._streamed_reasoning = [] |
| 68 | |
| 69 | def _events_to_delta( |
| 70 | self, |
| 71 | events: list[SemanticEvent], |
| 72 | finished: bool = False, |
| 73 | ) -> DeltaMessage | None: |
| 74 | delta = super()._events_to_delta(events, finished=finished) |
| 75 | if delta is not None and delta.reasoning is not None: |
| 76 | self._streamed_reasoning.append(delta.reasoning) |
| 77 | return delta |
| 78 | |
| 79 | @staticmethod |
| 80 | def _should_force_content( |
| 81 | request: ChatCompletionRequest | ResponsesRequest, |
| 82 | ) -> bool: |
| 83 | chat_template_kwargs = getattr(request, "chat_template_kwargs", None) |
| 84 | return bool( |
| 85 | chat_template_kwargs |
| 86 | and ( |
| 87 | chat_template_kwargs.get("enable_thinking") is False |
| 88 | or chat_template_kwargs.get("force_nonempty_content") is True |
| 89 | ) |
| 90 | ) |
| 91 | |
| 92 | def get_streaming_fallback_content( |
| 93 | self, |
| 94 | text: str, |
| 95 | request: ChatCompletionRequest | ResponsesRequest, |
| 96 | ) -> str | None: |
| 97 | if not self._should_force_content(request): |
| 98 | return None |
| 99 | return "".join(self._streamed_reasoning) or None |
| 100 | |
| 101 | def extract_reasoning( |
| 102 | self, |
| 103 | model_output: str, |
| 104 | request: ChatCompletionRequest | ResponsesRequest, |
| 105 | ) -> tuple[str | None, str | None]: |
| 106 | reasoning, content = super().extract_reasoning(model_output, request) |
| 107 | |
| 108 | if self._should_force_content(request) and ( |
| 109 | content is None or not content.strip() |
| 110 | ): |
| 111 | reasoning, content = content, reasoning |
| 112 | |
| 113 | return reasoning, content |