A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 5, 01:00 UTC
[BugFix] Support custom tool parsers when tool_choice is `required` and named function (#39870)
Signed-off-by: JaredforReal <w13431838023@gmail.com> Signed-off-by: sfeng33 <4florafeng@gmail.com> Co-authored-by: sfeng33 <4florafeng@gmail.com>
Jared Wen committed
Apr 18, 2026 at 00:38 UTC
ceade1952c7aebfbf328daa7dd40877c5cd8e8a5
5 files changed
+100
-12
vllm/entrypoints/openai/chat_completion/serving.py
+39
-6
@@ -557,6 +557,20 @@ class OpenAIServingChat(OpenAIServing):
557
and self._should_stream_with_auto_tool_parsing(request)
558
)
559
560
+ # Determine whether required/named tool_choice should fall back to
561
+ # the auto tool_parser path instead of the standard JSON-based parsing.
562
+ # This happens when the parser declares supports_required_and_named=False
563
+ # (e.g. GLM models that output XML instead of JSON).
564
+ tool_choice_uses_parser = (
565
+ self.tool_parser is not None
566
+ and not self.tool_parser.supports_required_and_named
567
+ and request.tools
568
+ and (
569
+ request.tool_choice == "required"
570
+ or isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam)
571
+ )
572
+ )
573
+
574
all_previous_token_ids: list[list[int]] | None
575
function_name_returned = [False] * num_choices
576
if self.tool_call_id_type == "kimi_k2":
@@ -569,7 +583,12 @@ class OpenAIServingChat(OpenAIServing):
583
584
# Only one of these will be used, thus previous_texts and
585
# all_previous_token_ids will not be used twice in the same iteration.
572
- if is_mistral_grammar_path or tool_choice_auto or reasoning_parser:
586
+ if (
587
+ is_mistral_grammar_path
588
+ or tool_choice_auto
589
+ or tool_choice_uses_parser
590
+ or reasoning_parser
591
+ ):
592
# These are only required in "auto" tool choice case
593
all_previous_token_ids = [[] for _ in range(num_choices)]
594
reasoning_end_arr = [False] * num_choices
@@ -764,7 +783,12 @@ class OpenAIServingChat(OpenAIServing):
783
delta_message: DeltaMessage | None
784
785
# just update previous_texts and previous_token_ids
767
- if is_mistral_grammar_path or tool_choice_auto or reasoning_parser:
786
+ if (
787
+ is_mistral_grammar_path
788
+ or tool_choice_auto
789
+ or tool_choice_uses_parser
790
+ or reasoning_parser
791
+ ):
792
assert previous_texts is not None
793
assert all_previous_token_ids is not None
794
previous_text = previous_texts[i]
@@ -813,7 +837,9 @@ class OpenAIServingChat(OpenAIServing):
837
if result.tools_called:
838
tools_streamed[i] = True
839
# handle streaming deltas for tools with named tool_choice
816
- elif tool_choice_function_name:
840
+ # Skip when tool_choice_uses_parser so it falls through
841
+ # to the auto tool_parser branches below.
842
+ elif tool_choice_function_name and not tool_choice_uses_parser:
843
# When encountering think end id in prompt_token_ids
844
# i.e {"enable_thinking": False},
845
# check BEFORE calling the parser to avoid a spurious
@@ -851,7 +877,6 @@ class OpenAIServingChat(OpenAIServing):
877
):
878
reasoning_end_arr[i] = True
879
if delta_message and delta_message.content:
854
- # This need to be added to next `delta_text`
880
current_text = delta_message.content
881
delta_message.content = None
882
else:
@@ -896,7 +921,12 @@ class OpenAIServingChat(OpenAIServing):
921
)
922
tools_streamed[i] = True
923
899
- elif request.tool_choice == "required":
924
+ # Skip when tool_choice_uses_parser so it falls through
925
+ # to the auto tool_parser branches below.
926
+ elif (
927
+ request.tool_choice == "required"
928
+ and not tool_choice_uses_parser
929
+ ):
930
assert previous_texts is not None
931
previous_text = previous_texts[i]
932
current_text = previous_text + delta_text
@@ -966,7 +996,10 @@ class OpenAIServingChat(OpenAIServing):
996
997
# update the previous values for the next iteration
998
if (
969
- is_mistral_grammar_path or tool_choice_auto or reasoning_parser
999
+ is_mistral_grammar_path
1000
+ or tool_choice_auto
1001
+ or tool_choice_uses_parser
1002
+ or reasoning_parser
1003
) and not self.use_harmony:
1004
assert previous_texts is not None
1005
assert all_previous_token_ids is not None
vllm/entrypoints/openai/engine/serving.py
+26
-5
@@ -627,7 +627,7 @@ class OpenAIServing:
627
and isinstance(request.tool_choice, ToolChoiceFunction)
628
):
629
assert content is not None
630
- # Forced Function Call
630
+ # Forced Function Call (Responses API)
631
function_calls.append(
632
FunctionCall(name=request.tool_choice.name, arguments=content)
633
)
@@ -636,14 +636,20 @@ class OpenAIServing:
636
not use_mistral_tool_parser
637
and request.tool_choice
638
and isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam)
639
+ and (tool_parser_cls is None or tool_parser_cls.supports_required_and_named)
640
):
641
+ # Named function with standard JSON-based parsing
642
assert content is not None
641
- # Forced Function Call
643
function_calls.append(
644
FunctionCall(name=request.tool_choice.function.name, arguments=content)
645
)
646
content = None # Clear content since tool is called.
646
- elif not use_mistral_tool_parser and request.tool_choice == "required":
647
+ elif (
648
+ not use_mistral_tool_parser
649
+ and request.tool_choice == "required"
650
+ and (tool_parser_cls is None or tool_parser_cls.supports_required_and_named)
651
+ ):
652
+ # "required" with standard JSON-based parsing
653
tool_calls = []
654
with contextlib.suppress(ValidationError):
655
content = content or ""
@@ -662,15 +668,30 @@ class OpenAIServing:
668
use_mistral_tool_parser
669
or (
670
enable_auto_tools
665
- and (request.tool_choice == "auto" or request.tool_choice is None)
671
+ and (
672
+ request.tool_choice == "auto"
673
+ or request.tool_choice is None
674
+ or (
675
+ not tool_parser_cls.supports_required_and_named
676
+ and request.tools
677
+ and (
678
+ request.tool_choice == "required"
679
+ or isinstance(
680
+ request.tool_choice,
681
+ ChatCompletionNamedToolChoiceParam,
682
+ )
683
+ )
684
+ )
685
+ )
686
)
687
):
688
+ # Automatic Tool Call Parsing (also used as fallback for
689
+ # required/named when supports_required_and_named=False)
690
if tokenizer is None:
691
raise ValueError(
692
"Tokenizer not available when `skip_tokenizer_init=True`"
693
)
694
673
- # Automatic Tool Call Parsing
695
try:
696
tool_parser = tool_parser_cls(tokenizer, request.tools)
697
except RuntimeError as e:
vllm/tool_parsers/abstract_tool_parser.py
+11
@@ -44,6 +44,17 @@ class ToolParser:
44
derived classes.
45
"""
46
47
+ # When True (default), the serving layer uses the standard JSON-based
48
+ # parsing for tool_choice="required" and named function tool_choice,
49
+ # which works for models where guided decoding produces well-formed
50
+ # JSON output (e.g. Hermes).
51
+ # Subclasses set False when the standard parsing does not work for
52
+ # their model's output format (e.g. GLM models that use XML). When
53
+ # False, the serving layer falls back to the tool_parser's
54
+ # extract_tool_calls / extract_tool_calls_streaming methods for
55
+ # required/named tool_choice, treating them the same as "auto".
56
+ supports_required_and_named: bool = True
57
+
58
def __init__(
59
self,
60
tokenizer: TokenizerLike,
vllm/tool_parsers/glm47_moe_tool_parser.py
+2
@@ -23,6 +23,8 @@ logger = init_logger(__name__)
23
24
25
class Glm47MoeModelToolParser(Glm4MoeModelToolParser):
26
+ supports_required_and_named = False
27
+
28
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
29
super().__init__(tokenizer, tools)
30
# GLM-4.7 format: <tool_call>func_name[<arg_key>...]*</tool_call>
vllm/tool_parsers/glm4_moe_tool_parser.py
+22
-1
@@ -20,6 +20,7 @@ import regex as re
20
21
from vllm.entrypoints.chat_utils import make_tool_call_id
22
from vllm.entrypoints.openai.chat_completion.protocol import (
23
+ ChatCompletionNamedToolChoiceParam,
24
ChatCompletionRequest,
25
)
26
from vllm.entrypoints.openai.engine.protocol import (
@@ -50,6 +51,8 @@ class Glm4MoeModelToolParser(ToolParser):
51
call, and diffs against what was previously sent to emit only new content.
52
"""
53
54
+ supports_required_and_named = False
55
+
56
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
57
super().__init__(tokenizer, tools)
58
# Stateful streaming fields
@@ -156,7 +159,25 @@ class Glm4MoeModelToolParser(ToolParser):
159
def adjust_request(
160
self, request: ChatCompletionRequest | ResponsesRequest
161
) -> ChatCompletionRequest | ResponsesRequest:
159
- """Adjust request parameters for tool call token handling."""
162
+ """Adjust request parameters for tool call token handling.
163
+
164
+ For required/named tool_choice, skip setting structured_outputs
165
+ because GLM models output tool calls in XML format (per chat
166
+ template). Guided decoding would force JSON output, conflicting
167
+ with the XML format and causing parsing failures.
168
+ """
169
+ if request.tools:
170
+ tc = request.tool_choice
171
+ if tc == "required" or isinstance(tc, ChatCompletionNamedToolChoiceParam):
172
+ # Do NOT call super().adjust_request() for required/named,
173
+ # because it would set structured_outputs and force JSON
174
+ # output via guided decoding. GLM models use XML tool-call
175
+ # syntax (defined in the chat template), so guided decoding
176
+ # must be skipped to let the model output XML freely.
177
+ # The tool_parser handles extraction from XML output.
178
+ if request.tool_choice != "none":
179
+ request.skip_special_tokens = False
180
+ return request
181
request = super().adjust_request(request)
182
if request.tools and request.tool_choice != "none":
183
# Ensure tool call tokens (<tool_call>, </tool_call>) are not skipped