@@ -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+
232260def _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 (
0 commit comments