[Bugfix][Tool Parser] Use LongCat tags in the LongCat structural tag (#60747)

Signed-off-by: hpdkhoa <hpdkhoa2311@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Flora Feng <4florafeng@gmail.com>
This commit is contained in:
Kai Hoang
2026-10-08 21:20:54 -07:00
committed by GitHub
co-authored by Claude Opus 5.5 Flora Feng
parent 9824159d94
commit e31b823bf5
3 changed files with 94 additions and 27 deletions
@@ -48,6 +48,7 @@ from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser
from vllm.tool_parsers.kimi_k3_tool_parser import KimiK3ToolParser
from vllm.tool_parsers.llama_tool_parser import Llama3JsonToolParser
from vllm.tool_parsers.longcat_tool_parser import LongcatFlashToolParser
from vllm.tool_parsers.mimo_tool_parser import MiMoToolParser
from vllm.tool_parsers.minimax_m2_tool_parser import MinimaxM2ToolParser
from vllm.tool_parsers.plamo3_engine_tool_parser import Plamo3EngineToolParser
@@ -850,6 +851,44 @@ def test_step3p5_forced_tool_choice_round_trips_native_xml(
]
@pytest.mark.parametrize(
"tool_choice",
["required", {"type": "function", "function": {"name": "get_weather"}}],
)
def test_longcat_forced_tool_choice_round_trips_native_tags(
sample_tools: list[ChatCompletionToolsParam], tool_choice
):
"""Forced LongCat calls must use <longcat_tool_call>, not Hermes tags."""
class LongcatParser(DelegatingParser):
tool_parser_cls = LongcatFlashToolParser
request = ChatCompletionRequest(
messages=[],
model="m",
tools=[tool.model_dump(exclude_none=True) for tool in sample_tools],
tool_choice=tool_choice,
)
parser = LongcatParser(MagicMock(), tools=sample_tools)
request = parser.adjust_request(request)
grammar = Grammar.from_structural_tag(request.structured_outputs.structural_tag)
# Rendered exactly as the LongCat-Flash chat template renders tool calls.
output = (
'<longcat_tool_call>\n{"name": "get_weather", '
'"arguments": {"city": "Dallas"}}\n</longcat_tool_call>'
)
assert _is_grammar_accept_string(grammar, output)
assert not _is_grammar_accept_string(grammar, output.replace("longcat_", ""))
_, content, tool_calls = parser.parse(output, request, enable_auto_tools=True)
assert not content
assert [(call.name, json.loads(call.arguments)) for call in tool_calls] == [
("get_weather", {"city": "Dallas"})
]
def test_tool_parsers_without_structural_tag_support_required_and_named():
class NonStructuralTagToolParser(ToolParser):
pass
+2
View File
@@ -9,6 +9,8 @@ from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
class LongcatFlashToolParser(Hermes2ProToolParser):
structural_tag_model = "longcat"
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
super().__init__(tokenizer, tools)
+53 -27
View File
@@ -79,7 +79,7 @@ XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
}
)
VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset(
{"glm_4_7", "hermes", "hy_v4", "kimi_k3", "plamo3"}
{"glm_4_7", "hermes", "hy_v4", "kimi_k3", "longcat", "plamo3"}
)
SUPPORTED_STRUCTURAL_TAG_MODELS = (
XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS
@@ -346,15 +346,19 @@ def get_function_parameters(function) -> dict[str, Any] | bool:
return function.parameters if function.parameters is not None else True
def _hermes_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]:
def _hermes_tool_tags(
tools: list[FunctionToolParam],
start_tag: str,
end_tag: str,
) -> list[TagFormat]:
arguments_field_prefix = '", "arguments": '
formats = [
# <tool_call>
# {"name": "t1", "arguments": {"q": "v"}}
# </tool_call>
('<tool_call>\n{"name": "', "}\n</tool_call>"),
(start_tag + '\n{"name": "', "}\n" + end_tag),
# <tool_call>{"name": "t1", "arguments": {"q": "v"}}</tool_call>
('<tool_call>{"name": "', "}</tool_call>"),
(start_tag + '{"name": "', "}" + end_tag),
]
return [
@@ -370,6 +374,36 @@ def _hermes_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]:
]
def _hermes_style_structural_tag(
tools: list[FunctionToolParam],
tool_choice: SimplifiedToolChoice,
start_tag: str,
end_tag: str,
) -> StructuralTag:
tags = _hermes_tool_tags(tools, start_tag, end_tag)
if tool_choice == "auto":
suffix_tag = (
TriggeredTagsFormat(triggers=[start_tag], tags=tags)
if tags
else AnyTextFormat()
)
elif tool_choice == "forced":
suffix_tag = TagsWithSeparatorFormat(
tags=tags,
separator="",
at_least_one=True,
stop_after_first=True,
)
else:
suffix_tag = TagsWithSeparatorFormat(
tags=tags,
separator="",
at_least_one=True,
)
return StructuralTag(format=suffix_tag)
@register_vllm_structural_tag("hermes")
def get_hermes_structural_tag(
tools: list[FunctionToolParam],
@@ -379,31 +413,23 @@ def get_hermes_structural_tag(
token_suffix: str = "",
) -> StructuralTag:
del builtin_tools, reasoning, token_suffix
return _hermes_style_structural_tag(
tools, tool_choice, "<tool_call>", "</tool_call>"
)
tool_call_trigger = "<tool_call>"
if tool_choice == "auto":
tags = _hermes_tool_tags(tools)
suffix_tag = (
TriggeredTagsFormat(triggers=[tool_call_trigger], tags=tags)
if tags
else AnyTextFormat()
)
elif tool_choice == "forced":
suffix_tag = TagsWithSeparatorFormat(
tags=_hermes_tool_tags(tools),
separator="",
at_least_one=True,
stop_after_first=True,
)
else:
suffix_tag = TagsWithSeparatorFormat(
tags=_hermes_tool_tags(tools),
separator="",
at_least_one=True,
)
return StructuralTag(format=suffix_tag)
@register_vllm_structural_tag("longcat")
def get_longcat_structural_tag(
tools: list[FunctionToolParam],
builtin_tools: list[BuiltinToolParam],
tool_choice: SimplifiedToolChoice,
reasoning: bool,
token_suffix: str = "",
) -> StructuralTag:
del builtin_tools, reasoning, token_suffix
return _hermes_style_structural_tag(
tools, tool_choice, "<longcat_tool_call>", "</longcat_tool_call>"
)
def _minimax_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]: