Skip to content

Commit e9093e8

Browse files
authored
[https://nvbugs/6932723][feat] Support required tool choice for Nemotron Super 3.5 VL (#20422)
Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
1 parent 791a402 commit e9093e8

18 files changed

Lines changed: 1820 additions & 141 deletions

‎tensorrt_llm/llmapi/reasoning_parser.py‎

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,10 @@ class NemotronV3ReasoningParser(DeepSeekR1Parser):
768768
The `enable_thinking` flag is read from `chat_template_kwargs`.
769769
"""
770770

771+
resolves_thinking_from_prompt = True
772+
reasoning_start = "<think>"
773+
reasoning_end = "</think>"
774+
771775
def __init__(self,
772776
*,
773777
reasoning_at_start: bool = True,
@@ -803,32 +807,35 @@ def _maybe_swap_content(
803807
return result
804808

805809
def parse_delta(self, delta_text: str) -> ReasoningParserResult:
806-
"""Wraps the parent parse_delta to also treat `<tool_call>` as an
807-
implicit end-of-reasoning marker. When the model omits `</think>`
808-
before generating a tool call, the tag would otherwise be absorbed
809-
into reasoning_content and the downstream tool parser would never
810-
see it (NVBug 6082303).
811-
812-
`<tool_call>` is a special token that always arrives as a single
813-
atomic delta, so we only need to check `delta_text` (not the
814-
parent's internal buffer)."""
815-
if (self.in_reasoning and self._tool_call_start in delta_text
816-
and self.reasoning_end not in self._buffer):
817-
remaining = self._buffer
810+
"""End reasoning at the first closing marker or tool-call opener."""
811+
buffered = self._buffer + delta_text
812+
tool_idx = buffered.find(self._tool_call_start)
813+
end_idx = buffered.find(self.reasoning_end)
814+
if self.in_reasoning and tool_idx != -1 and (end_idx == -1
815+
or tool_idx < end_idx):
818816
self._buffer = ""
819817
self.in_reasoning = False
820-
# Guaranteed non-negative: guarded by `in delta_text` above.
821-
tool_idx = delta_text.find(self._tool_call_start)
822-
reasoning = remaining + delta_text[:tool_idx]
823-
content = delta_text[tool_idx:]
818+
reasoning = buffered[:tool_idx]
819+
content = buffered[tool_idx:]
824820
if self._force_nonempty_content:
825821
self._found_closing_tag = True
826822
self._accumulated_reasoning = ""
827823
return ReasoningParserResult(content=content,
828824
reasoning_content=reasoning)
829825

826+
pending = ""
827+
if self.in_reasoning and end_idx == -1 and tool_idx == -1:
828+
for size in range(
829+
min(len(buffered),
830+
len(self._tool_call_start) - 1), 0, -1):
831+
if buffered.endswith(self._tool_call_start[:size]):
832+
pending = buffered[-size:]
833+
self._buffer = ""
834+
delta_text = buffered[:-size]
835+
break
830836
was_in_reasoning = self.in_reasoning
831837
result = super().parse_delta(delta_text)
838+
self._buffer += pending
832839
if self._force_nonempty_content:
833840
if result.reasoning_content:
834841
self._accumulated_reasoning += result.reasoning_content
@@ -868,14 +875,19 @@ def finish(self) -> ReasoningParserResult:
868875
return ReasoningParserResult()
869876

870877
def parse(self, text: str) -> ReasoningParserResult:
871-
result = super().parse(text)
872-
tc = (result.reasoning_content.find(self._tool_call_start)
873-
if result.reasoning_content else -1)
874-
if tc != -1:
875-
result = ReasoningParserResult(
876-
content=result.reasoning_content[tc:] + result.content,
877-
reasoning_content=result.reasoning_content[:tc])
878-
return self._maybe_swap_content(result)
878+
start = 0 if self.reasoning_at_start else text.find(
879+
self.reasoning_start)
880+
if start != -1:
881+
if not self.reasoning_at_start:
882+
start += len(self.reasoning_start)
883+
tool_idx = text.find(self._tool_call_start, start)
884+
end_idx = text.find(self.reasoning_end, start)
885+
if tool_idx != -1 and (end_idx == -1 or tool_idx < end_idx):
886+
return self._maybe_swap_content(
887+
ReasoningParserResult(
888+
content=text[tool_idx:],
889+
reasoning_content=text[start:tool_idx]))
890+
return self._maybe_swap_content(super().parse(text))
879891

880892

881893
@register_reasoning_parser("gemma4")

‎tensorrt_llm/serve/extensions/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@
2525
turn imports the registry module. Nothing here may import ``openai_server``.
2626
"""
2727

28-
from . import gpt_oss, kimi_k3
28+
from . import gpt_oss, kimi_k3, nemotron
2929

30-
__all__ = ["gpt_oss", "kimi_k3"]
30+
__all__ = ["gpt_oss", "kimi_k3", "nemotron"]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Serving extension for Nemotron checkpoints using required-tool grammars."""
16+
17+
from tensorrt_llm.serve.serving_extensions import (
18+
RequiredToolChoiceMode,
19+
ServingExtension,
20+
register_serving_extension,
21+
)
22+
23+
24+
@register_serving_extension(model_types=("nemotron_h", "nemotron_h_omni"))
25+
class NemotronServingExtension(ServingExtension):
26+
"""Use mandatory tool-call grammars for native Nemotron chat requests."""
27+
28+
def required_tool_choice_mode(self) -> RequiredToolChoiceMode:
29+
return "grammar"

‎tensorrt_llm/serve/openai_server.py‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,34 @@ def _warn_unresolvable_thinking_once(reasoning_parser: str) -> None:
229229
"build that relays 'resolved_thinking'.")
230230

231231

232+
def _validate_required_tool_grammar_request(
233+
request: ChatCompletionRequest, sampling_params: SamplingParams, *,
234+
guided_decoding_backend: Optional[str], tool_parser: Optional[str],
235+
reasoning_parser: Optional[str]) -> None:
236+
"""Validate inputs for mandatory tool-call grammar construction."""
237+
if guided_decoding_backend != "xgrammar":
238+
raise ValueError("Required tool grammars need the xgrammar backend.")
239+
if not tool_parser:
240+
raise ValueError("Required tool grammars need a tool parser.")
241+
if not reasoning_parser or not ReasoningParserFactory.resolves_thinking_from_prompt(
242+
reasoning_parser):
243+
raise ValueError(
244+
"Required tool grammars need a reasoning parser that resolves its "
245+
"mode from the rendered prompt. Configure the server with "
246+
"--reasoning_parser nemotron-v3 (or a compatible parser alias).")
247+
if not request.tools:
248+
raise ValueError("tool_choice='required' requires request-level tools.")
249+
if (request.prompt_token_ids is not None or request.prompt_token_ids_b64
250+
or not request.add_generation_prompt
251+
or request.disaggregated_params is not None):
252+
raise ValueError("Required tool grammars need chat messages with "
253+
"add_generation_prompt=True on a native server.")
254+
if sampling_params.guided_decoding is not None:
255+
raise ValueError(
256+
"tool_choice='required' cannot be combined with response_format "
257+
"or other guided-decoding settings.")
258+
259+
232260
def _configure_parser_special_token_decoding(
233261
sampling_params: SamplingParams, reasoning_parser_name: Optional[str],
234262
tool_parser_name: Optional[str], has_tools: bool) -> None:
@@ -2044,6 +2072,9 @@ async def chat_stream_generator(
20442072
extension = render_resources.extension
20452073
tool_dicts = prepare_chat_request(request,
20462074
render_resources).tool_dicts
2075+
required_mode = extension.required_tool_choice_mode()
2076+
required_tool_grammar = (request.tool_choice == "required"
2077+
and required_mode == "grammar")
20472078
conversation: List[ConversationMessage] = []
20482079
# Pass the model vocabulary size so ``logit_bias`` can be
20492080
# expanded into an embedding bias tensor in the sampler.
@@ -2056,6 +2087,14 @@ async def chat_stream_generator(
20562087
gather_generation_logits,
20572088
reasoning_parser=self.generator.args.reasoning_parser,
20582089
backend=self.generator.args.backend)
2090+
if required_tool_grammar:
2091+
_validate_required_tool_grammar_request(
2092+
request,
2093+
sampling_params,
2094+
guided_decoding_backend=self.generator.args.
2095+
guided_decoding_backend,
2096+
tool_parser=self.tool_parser,
2097+
reasoning_parser=self.generator.args.reasoning_parser)
20592098
# Pre-render, so the mode may be unresolved. Safe because the
20602099
# boundary lookup reads the parser class attributes, not the
20612100
# branch `__init__` picked.
@@ -2173,7 +2212,7 @@ async def chat_stream_generator(
21732212
# via structural tags (only if response_format doesn't already
21742213
# set guided decoding). Dynamic tools must be in the grammar
21752214
# too, or their calls would be masked out.
2176-
if sampling_params.guided_decoding is None:
2215+
if sampling_params.guided_decoding is None and not required_tool_grammar:
21772216
if (forced_tool_name is not None
21782217
and _parser_extracts_forced_tool_calls(
21792218
self.tool_parser)):
@@ -2330,14 +2369,14 @@ async def chat_stream_generator(
23302369
if request.mm_processor_kwargs:
23312370
prompt["mm_processor_kwargs"] = request.mm_processor_kwargs
23322371

2372+
thinking = None
23332373
postproc_args.reasoning_parser = self.generator.args.reasoning_parser
23342374
# Templates that prefill <think>/</think> leave the marker in the
23352375
# prompt, so the request kwargs alone cannot tell the parser which
23362376
# mode was rendered. Take it from the prompt instead.
23372377
if postproc_args.reasoning_parser and (
23382378
ReasoningParserFactory.resolves_thinking_from_prompt(
23392379
postproc_args.reasoning_parser)):
2340-
thinking = None
23412380
if rendered_prompt and request.add_generation_prompt:
23422381
thinking = ReasoningParserFactory.resolve_prefilled_thinking(
23432382
postproc_args.reasoning_parser, rendered_prompt)
@@ -2356,6 +2395,26 @@ async def chat_stream_generator(
23562395
"thinking": thinking,
23572396
"enable_thinking": thinking,
23582397
}
2398+
if required_tool_grammar:
2399+
if thinking is None:
2400+
raise ValueError(
2401+
"Cannot resolve required-tool reasoning state from the rendered prompt."
2402+
)
2403+
parser = ToolParserFactory.create_tool_parser(self.tool_parser)
2404+
build_required = functools.partial(
2405+
parser.build_required_structural_tag,
2406+
request.tools,
2407+
thinking=thinking)
2408+
required_format = await asyncio.get_running_loop(
2409+
).run_in_executor(self._input_proc_executor, build_required)
2410+
if required_format is None:
2411+
raise ValueError(
2412+
"The configured tool parser does not support required tools."
2413+
)
2414+
sampling_params.guided_decoding = GuidedDecodingParams(
2415+
structural_tag=required_format.model_dump_json(
2416+
by_alias=True))
2417+
postproc_args.tool_parser_grammar_constrained = True
23592418
postproc_args.tool_parser = self.tool_parser
23602419
postproc_args.tool_call_id_type = self.tool_call_id_type
23612420
if conversation and conversation[-1].get(

‎tensorrt_llm/serve/postprocess_handlers.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class ChatPostprocArgs(PostprocArgs):
103103
last_message_content: Optional[str] = None
104104
reasoning_parser: Optional[str] = None
105105
tool_parser: Optional[str] = None
106+
tool_parser_grammar_constrained: bool = False
106107
reasoning_parser_dict: dict[int, BaseReasoningParser] = field(
107108
default_factory=dict)
108109
tool_parser_dict: dict[int, BaseToolParser] = field(default_factory=dict)
@@ -299,7 +300,8 @@ def apply_tool_parser(args: ChatPostprocArgs,
299300
if output_index not in args.tool_parser_dict:
300301
args.tool_parser_dict[
301302
output_index] = ToolParserFactory.create_tool_parser(
302-
args.tool_parser)
303+
args.tool_parser,
304+
grammar_constrained=args.tool_parser_grammar_constrained)
303305
tool_parser = args.tool_parser_dict[output_index]
304306

305307
if tool_parser is not None and tools is not None:

‎tensorrt_llm/serve/render/chat.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def prepare_chat_request(request: "ChatCompletionRequest", res: RenderResources)
9292
ensure_request_chat_template_allowed(request, res.allow_request_chat_template)
9393
extension = res.extension
9494
extension.apply_chat_extensions(request)
95-
if request.tool_choice == "required" and not extension.allows_required_tool_choice():
95+
if request.tool_choice == "required" and extension.required_tool_choice_mode() is None:
9696
# Schema-accepting "required" everywhere but enforcing it only for models
9797
# whose extension allows it would silently degrade to "auto" elsewhere;
9898
# reject loudly for models that cannot honor it. Still a ValueError for the

‎tensorrt_llm/serve/responses_utils.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
23
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
34

45
import asyncio
@@ -1445,6 +1446,8 @@ def _apply_tool_parser(
14451446
text: str,
14461447
streaming: bool,
14471448
tool_parser_dict: Optional[dict[int, BaseToolParser]] = None,
1449+
finished: bool = False,
1450+
partial: bool = False,
14481451
) -> Tuple[str, list[ToolCallItem]]:
14491452
tool_parser: Optional[BaseToolParser] = None
14501453
if tool_parser_id is not None and tools is not None:
@@ -1460,10 +1463,15 @@ def _apply_tool_parser(
14601463

14611464
if tool_parser is not None and tools is not None:
14621465
if not streaming:
1463-
result = tool_parser.detect_and_parse(text, tools)
1466+
result = (tool_parser.parse_partial(text, tools)
1467+
if partial else tool_parser.detect_and_parse(text, tools))
14641468
else:
14651469
result = tool_parser.parse_streaming_increment(text, tools)
14661470
normal_text, calls = result.normal_text, result.calls
1471+
if streaming and finished:
1472+
tail = tool_parser.finish(tools)
1473+
normal_text += tail.normal_text
1474+
calls += tail.calls
14671475
if not streaming:
14681476
warn_if_tool_call_unparsed(tool_parser_id, tool_parser, text, calls)
14691477
else:
@@ -2159,6 +2167,7 @@ def _should_send_done_events(
21592167
text=full_text,
21602168
streaming=False,
21612169
tool_parser_dict=tool_parser_dict,
2170+
partial=not finished_generation,
21622171
)
21632172

21642173
# Detect reasoning -> text transition
@@ -2278,15 +2287,15 @@ def check_parser(parser_id: Optional[str],
22782287
chat_template_kwargs=reasoning_chat_template_kwargs(request),
22792288
)
22802289

2281-
if delta_text:
2282-
# TODO(JunyiXu-nv): handle tool calls in streaming mode
2290+
if delta_text or finished_generation:
22832291
delta_text, calls = _apply_tool_parser(
22842292
tool_parser_id=tool_parser_id,
22852293
tools=available_tools,
22862294
output_index=output_idx,
22872295
text=delta_text,
22882296
streaming=True,
22892297
tool_parser_dict=tool_parser_dict,
2298+
finished=finished_generation,
22902299
)
22912300

22922301
_responses_debug_log(

‎tensorrt_llm/serve/serving_extensions.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
(:func:`structured_output_format_for`).
2626
2727
Built-in extensions live in :mod:`tensorrt_llm.serve.extensions`; importing
28-
that package registers them. Both lookup functions import it on first use so
28+
that package registers them. The hook lookup functions import it on first use so
2929
the registry is populated wherever it is consulted (``openai_protocol`` reaches
3030
it from ``to_sampling_params`` without going through ``openai_server``). The
3131
import is deferred to call time because the built-ins import
@@ -34,12 +34,15 @@
3434

3535
import enum
3636
import importlib
37-
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional
37+
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Literal, Optional
3838

3939
if TYPE_CHECKING:
4040
from tensorrt_llm.serve.openai_protocol import ChatCompletionRequest
4141

4242

43+
RequiredToolChoiceMode = Literal["template", "grammar"]
44+
45+
4346
class OutputMode(enum.Enum):
4447
"""How a model's generated output is turned into an API response."""
4548

@@ -103,6 +106,10 @@ def apply_chat_extensions(self, request) -> None:
103106
``model_type`` this extension is registered for.
104107
"""
105108

109+
def required_tool_choice_mode(self) -> Optional[RequiredToolChoiceMode]:
110+
"""Return the required-tool policy; boolean permission selects template enforcement."""
111+
return "template" if self.allows_required_tool_choice() else None
112+
106113
def structured_output_format(
107114
self, content: Dict[str, Any], chat_template_kwargs: Optional[Dict[str, Any]]
108115
) -> Optional[Dict[str, Any]]:
@@ -189,3 +196,10 @@ def structured_output_format_for(
189196
_load_builtin_extensions()
190197
extension = _BY_REASONING_PARSER.get(reasoning_parser) if reasoning_parser else None
191198
return None if extension is None else extension.structured_output_format
199+
200+
201+
def required_tool_choice_mode(model_type: Optional[str]) -> Optional[RequiredToolChoiceMode]:
202+
"""Return required-tool handling for native chat, or None if unsupported."""
203+
_load_builtin_extensions()
204+
extension = _BY_MODEL_TYPE.get(model_type) if model_type else None
205+
return None if extension is None else extension.required_tool_choice_mode()

0 commit comments

Comments
 (0)