Fix tool call constrained decoding and parsing for models with native formats (#21593)
This commit is contained in:
@@ -23,6 +23,7 @@ from xgrammar import (
|
||||
CompiledGrammar,
|
||||
GrammarCompiler,
|
||||
GrammarMatcher,
|
||||
StructuralTag,
|
||||
StructuralTagItem,
|
||||
TokenizerInfo,
|
||||
allocate_token_bitmask,
|
||||
@@ -295,9 +296,11 @@ class XGrammarGrammarBackend(BaseGrammarBackend):
|
||||
)
|
||||
for structure in structural_tag["structures"]
|
||||
]
|
||||
ctx = self.grammar_compiler.compile_structural_tag(
|
||||
new_tag = StructuralTag.from_legacy_structural_tag(
|
||||
tags, structural_tag["triggers"]
|
||||
)
|
||||
new_tag.format.at_least_one = structural_tag.get("at_least_one", False)
|
||||
ctx = self.grammar_compiler.compile_structural_tag(new_tag)
|
||||
else:
|
||||
format_dict = structural_tag.get("format")
|
||||
if isinstance(format_dict, dict):
|
||||
|
||||
@@ -166,6 +166,7 @@ class LegacyStructuralTagResponseFormat(BaseModel):
|
||||
type: Literal["structural_tag"]
|
||||
structures: List[StructuresResponseFormat]
|
||||
triggers: List[str]
|
||||
at_least_one: bool = False
|
||||
|
||||
|
||||
StructuralTagResponseFormat: TypeAlias = Union[
|
||||
|
||||
@@ -361,9 +361,11 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
request.tool_choice,
|
||||
parallel_tool_calls=request.parallel_tool_calls,
|
||||
)
|
||||
# Handle JSON schema constraint directly for required or named tool choice
|
||||
if request.tool_choice == "required" or isinstance(
|
||||
request.tool_choice, ToolChoice
|
||||
# Fallback: use generic JSON schema for required/named tool choice
|
||||
# only when no parser-specific constraint was set
|
||||
if tool_call_constraint is None and (
|
||||
request.tool_choice == "required"
|
||||
or isinstance(request.tool_choice, ToolChoice)
|
||||
):
|
||||
json_schema = get_json_schema_constraint(
|
||||
request.tools,
|
||||
@@ -1136,22 +1138,56 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
) -> ToolCallProcessingResult:
|
||||
"""Process tool calls in the response"""
|
||||
|
||||
# Handle required or named tool choice
|
||||
if tool_choice == "required" or (
|
||||
isinstance(tool_choice, ToolChoice) and tool_choice.type == "function"
|
||||
):
|
||||
# Set finish reason to tool_calls since we're processing tool calls
|
||||
is_required = tool_choice == "required" or isinstance(tool_choice, ToolChoice)
|
||||
|
||||
# Try model-specific parser when output is in native format.
|
||||
# For required/named: only use parser when structural_tag was used
|
||||
# as constraint (mirrors the streaming path). For auto: always try.
|
||||
if self.tool_call_parser:
|
||||
parser = FunctionCallParser(tools, self.tool_call_parser)
|
||||
should_try_parser = (
|
||||
not is_required or parser.detector.supports_structural_tag()
|
||||
)
|
||||
if should_try_parser and parser.has_tool_call(text):
|
||||
original_finish_type = finish_reason["type"]
|
||||
if finish_reason["type"] == "stop":
|
||||
finish_reason["type"] = "tool_calls"
|
||||
finish_reason["matched"] = None
|
||||
try:
|
||||
text, call_info_list = parser.parse_non_stream(text)
|
||||
tool_calls = []
|
||||
for call_info in call_info_list:
|
||||
tool_id = self._process_tool_call_id(
|
||||
call_info, history_tool_calls_cnt
|
||||
)
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=tool_id,
|
||||
index=getattr(call_info, "tool_index", None),
|
||||
function=FunctionResponse(
|
||||
name=call_info.name,
|
||||
arguments=call_info.parameters,
|
||||
),
|
||||
)
|
||||
)
|
||||
return ToolCallProcessingResult(tool_calls, text, finish_reason)
|
||||
except Exception as e:
|
||||
logger.error(f"Tool call parsing error: {e}")
|
||||
finish_reason["type"] = original_finish_type
|
||||
return ToolCallProcessingResult(None, text, finish_reason)
|
||||
|
||||
# json_schema constraint → JSON array output for required/named
|
||||
if is_required:
|
||||
original_finish_type = finish_reason["type"]
|
||||
if finish_reason["type"] == "stop":
|
||||
finish_reason["type"] = "tool_calls"
|
||||
finish_reason["matched"] = None
|
||||
try:
|
||||
# For required tool choice, we expect a JSON array of tool calls
|
||||
tool_call_data = orjson.loads(text)
|
||||
tool_calls = []
|
||||
for i, tool in enumerate(tool_call_data):
|
||||
# Create a ToolCallItem from the JSON data
|
||||
call_info = ToolCallItem(
|
||||
tool_index=i, # Use the loop index as tool_index
|
||||
tool_index=i,
|
||||
name=tool["name"],
|
||||
parameters=json.dumps(tool["parameters"], ensure_ascii=False),
|
||||
)
|
||||
@@ -1171,36 +1207,9 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
)
|
||||
)
|
||||
return ToolCallProcessingResult(tool_calls, "", finish_reason)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Tool call parsing error: {e}")
|
||||
return ToolCallProcessingResult(None, text, finish_reason)
|
||||
|
||||
# Use parser since output is not constrained by JSON schema
|
||||
parser = FunctionCallParser(tools, self.tool_call_parser)
|
||||
if parser.has_tool_call(text):
|
||||
if finish_reason["type"] == "stop":
|
||||
finish_reason["type"] = "tool_calls"
|
||||
finish_reason["matched"] = None
|
||||
try:
|
||||
text, call_info_list = parser.parse_non_stream(text)
|
||||
tool_calls = []
|
||||
for call_info in call_info_list:
|
||||
tool_id = self._process_tool_call_id(
|
||||
call_info, history_tool_calls_cnt
|
||||
)
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=tool_id,
|
||||
index=getattr(call_info, "tool_index", None),
|
||||
function=FunctionResponse(
|
||||
name=call_info.name, arguments=call_info.parameters
|
||||
),
|
||||
)
|
||||
)
|
||||
return ToolCallProcessingResult(tool_calls, text, finish_reason)
|
||||
except Exception as e:
|
||||
logger.error(f"Tool call parsing error: {e}")
|
||||
# Return error but don't fail the whole request
|
||||
finish_reason["type"] = original_finish_type
|
||||
return ToolCallProcessingResult(None, text, finish_reason)
|
||||
|
||||
return ToolCallProcessingResult(None, text, finish_reason)
|
||||
@@ -1341,11 +1350,26 @@ class OpenAIServingChat(OpenAIServingBase):
|
||||
):
|
||||
"""Process tool calls in streaming response"""
|
||||
if index not in parser_dict:
|
||||
# Use JSON detector directly for required or named tool choice
|
||||
if request.tool_choice == "required" or isinstance(
|
||||
is_required = request.tool_choice == "required" or isinstance(
|
||||
request.tool_choice, ToolChoice
|
||||
):
|
||||
parser_dict[index] = JsonArrayParser()
|
||||
)
|
||||
# For required/named tool choice: use JsonArrayParser when the
|
||||
# constrained output is plain JSON (detector doesn't support
|
||||
# structural_tag or no parser configured). Use FunctionCallParser
|
||||
# only when the detector supports structural_tag and will produce
|
||||
# native format output.
|
||||
if is_required:
|
||||
use_native_parser = False
|
||||
if self.tool_call_parser:
|
||||
probe = FunctionCallParser(
|
||||
tools=request.tools,
|
||||
tool_call_parser=self.tool_call_parser,
|
||||
)
|
||||
use_native_parser = probe.detector.supports_structural_tag()
|
||||
if use_native_parser:
|
||||
parser_dict[index] = probe
|
||||
else:
|
||||
parser_dict[index] = JsonArrayParser()
|
||||
else:
|
||||
parser_dict[index] = FunctionCallParser(
|
||||
tools=request.tools,
|
||||
|
||||
@@ -270,7 +270,7 @@ class BaseFormatDetector(ABC):
|
||||
cur_arguments = current_tool_call.get("arguments")
|
||||
res = StreamingParseResult()
|
||||
|
||||
if cur_arguments:
|
||||
if cur_arguments is not None:
|
||||
# Calculate how much of the arguments we've already streamed
|
||||
sent = len(self.streamed_args_for_tool[self.current_tool_id])
|
||||
cur_args_json = json.dumps(cur_arguments, ensure_ascii=False)
|
||||
|
||||
@@ -203,7 +203,9 @@ class DeepSeekV3Detector(BaseFormatDetector):
|
||||
|
||||
def structure_info(self) -> _GetInfoFunc:
|
||||
return lambda name: StructureInfo(
|
||||
begin=">" + name + "\n```json\n",
|
||||
end="\n```<",
|
||||
trigger=">" + name + "\n```json\n",
|
||||
begin="<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>"
|
||||
+ name
|
||||
+ "\n```json\n",
|
||||
end="\n```<|tool▁call▁end|><|tool▁calls▁end|>",
|
||||
trigger="<|tool▁calls▁begin|>",
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Dict, List, Literal, Optional, Set, Tuple, Type, Union
|
||||
|
||||
from sglang.srt.entrypoints.openai.protocol import (
|
||||
LegacyStructuralTagResponseFormat,
|
||||
StructuralTagResponseFormat,
|
||||
StructuresResponseFormat,
|
||||
Tool,
|
||||
ToolCallConstraint,
|
||||
@@ -32,7 +33,10 @@ from sglang.srt.function_call.qwen3_coder_detector import Qwen3CoderDetector
|
||||
from sglang.srt.function_call.qwen25_detector import Qwen25Detector
|
||||
from sglang.srt.function_call.step3_detector import Step3Detector
|
||||
from sglang.srt.function_call.trinity_detector import TrinityDetector
|
||||
from sglang.srt.function_call.utils import get_json_schema_constraint
|
||||
from sglang.srt.function_call.utils import (
|
||||
_get_tool_schema_defs,
|
||||
get_json_schema_constraint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -146,12 +150,24 @@ class FunctionCallParser:
|
||||
|
||||
return final_normal_text, final_calls
|
||||
|
||||
def get_structure_tag(self) -> LegacyStructuralTagResponseFormat:
|
||||
def get_structure_tag(
|
||||
self, at_least_one: bool = False
|
||||
) -> StructuralTagResponseFormat:
|
||||
"""
|
||||
Generate a structural tag response format for all available tools.
|
||||
|
||||
This creates the necessary structural tags that guide the model's output format.
|
||||
|
||||
Args:
|
||||
at_least_one: If True, the grammar forces at least one tool call
|
||||
(no free text allowed). Used for required/named tool_choice.
|
||||
|
||||
Raises:
|
||||
ValueError: If tools have conflicting $defs schemas.
|
||||
"""
|
||||
# Validate $defs consistency before building structural tags
|
||||
_get_tool_schema_defs(self.tools)
|
||||
|
||||
tool_structures: List[StructuresResponseFormat] = list()
|
||||
tool_trigger_set: Set[str] = set()
|
||||
|
||||
@@ -183,6 +199,7 @@ class FunctionCallParser:
|
||||
type="structural_tag",
|
||||
structures=tool_structures,
|
||||
triggers=list(tool_trigger_set),
|
||||
at_least_one=at_least_one,
|
||||
)
|
||||
|
||||
def get_structure_constraint(
|
||||
@@ -203,16 +220,23 @@ class FunctionCallParser:
|
||||
"""
|
||||
# NOTE: structural_tag only supports JSON-compatible content between the begin and end.
|
||||
# It cannot parse or validate function call Pythonic or XML-ish syntax.
|
||||
if (
|
||||
self.detector.supports_structural_tag()
|
||||
and tool_choice == "auto"
|
||||
and (
|
||||
any(tool.function.strict for tool in self.tools)
|
||||
or self.tool_strict_level >= ToolStrictLevel.FUNCTION
|
||||
if self.detector.supports_structural_tag():
|
||||
# For "required"/named: always use structural_tag to preserve the
|
||||
# model's native tool call format. Schema is only included when
|
||||
# strict=True, per OpenAI protocol semantics.
|
||||
# For "auto": only constrain when strict is enabled.
|
||||
is_required = tool_choice == "required" or isinstance(
|
||||
tool_choice, ToolChoice
|
||||
)
|
||||
):
|
||||
tag = self.get_structure_tag()
|
||||
return ("structural_tag", tag)
|
||||
if is_required or (
|
||||
tool_choice == "auto"
|
||||
and (
|
||||
any(tool.function.strict for tool in self.tools)
|
||||
or self.tool_strict_level >= ToolStrictLevel.FUNCTION
|
||||
)
|
||||
):
|
||||
tag = self.get_structure_tag(at_least_one=is_required)
|
||||
return ("structural_tag", tag)
|
||||
elif tool_choice == "required" or isinstance(tool_choice, ToolChoice):
|
||||
json_schema = get_json_schema_constraint(
|
||||
self.tools, tool_choice, parallel_tool_calls=parallel_tool_calls
|
||||
|
||||
Reference in New Issue
Block a user