Files
sglang/test/registered/unit/parser/test_template_manager.py
T
b63f8416b3 [Feature] Gigachat 3.5 support (#29189)
Co-authored-by: Stanislav Petrov <stapetrov@sberbank.ru>
Co-authored-by: Viacheslav Barinov <vvadbarinov@sberbank.ru>
Co-authored-by: Viacheslav <viacheslav.teh@gmail.com>
Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
2026-09-21 14:37:55 +08:00

1183 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys
import tempfile
import unittest
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock, patch
import msgspec
from sglang.srt.parser.template_detection import (
REASONING_PARSER_RULES,
TOOL_CALL_PARSER_RULES,
ReasoningToggleConfig,
detect_reasoning_parser,
detect_reasoning_pattern,
detect_tool_call_parser,
resolve_auto_parsers,
)
from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=2.0, suite="base-a-test-cpu")
class _DummyTokenizer:
def __init__(self, vocab, chat_template=None):
self._vocab = vocab
self.chat_template = chat_template
def get_vocab(self):
return {token: i for i, token in enumerate(self._vocab)}
def _patch_hf_transformers_utils(get_tokenizer, get_config=None):
module = ModuleType("sglang.srt.utils.hf_transformers_utils")
module.get_tokenizer = get_tokenizer
if get_config is not None:
module.get_config = get_config
return patch.dict(sys.modules, {module.__name__: module})
def _glm53_template(concat):
"""GLM-5.3 shape: always-on thinking behind a ``Reasoning Effort:`` header
(no ``enable_thinking`` toggle) and the compact GLM-4.7 tool-call format;
HF revisions differ only in the ``+`` / ``~`` concat operator."""
return (
"[gMASK]<sop>\n"
"{%- set effective_reasoning_effort = reasoning_effort if reasoning_effort is defined "
"and reasoning_effort in ['low', 'high'] else 'max' -%}\n"
"<|system|>Reasoning Effort: {{ effective_reasoning_effort | capitalize }}\n"
"{% for tc in m.tool_calls %}\n"
f"{{{{- '<tool_call>' {concat} tc.name -}}}}\n"
"{% set _args = tc.arguments %}"
"{% for k, v in _args.items() %}"
"<arg_key>{{ k }}</arg_key><arg_value>{{ v }}</arg_value>"
"{% endfor %}</tool_call>\n"
"{% endfor %}\n"
"<|assistant|>{{- '<think>' -}}"
)
class TestTemplateManagerReasoningDetection(CustomTestCase):
def _detect(self, template, vocab):
force, config = detect_reasoning_pattern(template)
parser = detect_reasoning_parser(
template, _DummyTokenizer(vocab), config, force
)
return force, config, parser
def test_gigachat35_gcml_template_wins_over_generic_think_tags(self):
"""The GCML marker must map both parsers to gigachat35; the template also
carries <think>, so the rule has to outrank the generic deepseek-r1
think-tags fallback."""
template = """
{{- 'assistant<|role_sep|>\\n<think>' -}}
<GCMLtool_calls>
"""
vocab = ["<think>", "</think>", "<|role_sep|>\n"]
force, config, parser = self._detect(template, vocab)
self.assertEqual(parser, "gigachat35")
self.assertEqual(
detect_tool_call_parser(template, _DummyTokenizer(vocab), config, force),
"gigachat35",
)
def test_qwen3_template_not_misclassified_as_glm45(self):
template = """
{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}
{% if '</think>' in content %}
<tool_call>
"""
_, config, parser = self._detect(
template, ["<tool_call>", "<|endoftext|>", "</think>"]
)
self.assertEqual(
config,
ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
)
self.assertEqual(parser, "qwen3")
def test_glm45_requires_glm_specific_template_markers(self):
template = """
[gMASK]<sop>
{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}
/nothink
<tool_call>
"""
_, config, parser = self._detect(
template, ["<tool_call>", "<|endoftext|>", "<|user|>"]
)
self.assertEqual(
config,
ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
)
self.assertEqual(parser, "glm45")
def test_ling3_template_uses_ling3_parsers(self):
template = """
{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}
{{ '<role>SYSTEM</role>' }}
{{ '<role>ASSISTANT</role>' }}
{{ '<|role_end|>' }}
<tool_call>{function-name}
<arg_key>{arg-key}</arg_key>
<arg_value>{arg-value}</arg_value>
</tool_call>
"""
force, config, reasoning_parser = self._detect(template, [])
tool_call_parser = detect_tool_call_parser(
template, _DummyTokenizer([]), config, force
)
self.assertEqual(reasoning_parser, "ling3")
self.assertEqual(tool_call_parser, "ling3")
def test_glm53_effort_template_resolves_glm_parsers(self):
# Without an enable_thinking toggle the GLM-4.5 rule misses, and the
# template used to fall through to deepseek-r1 + the xml_kv fallback
# (glm45 tool parser), which cannot read the compact tool-call format.
vocab = ["<tool_call>", "<arg_key>", "<arg_value>", "<|user|>", "<|endoftext|>"]
for concat in ("+", "~"):
template = _glm53_template(concat)
with self.subTest(concat=concat):
force, config, parser = self._detect(template, vocab)
self.assertEqual(parser, "glm45")
self.assertEqual(
detect_tool_call_parser(
template, _DummyTokenizer(vocab), config, force
),
"glm47",
)
def test_glm53_effort_template_forces_reasoning(self):
vocab = ["<tool_call>", "<arg_key>", "<arg_value>", "<|user|>", "<|endoftext|>"]
force, config, _ = self._detect(_glm53_template("+"), vocab)
self.assertTrue(force)
self.assertEqual(config, ReasoningToggleConfig(special_case="always"))
def test_interns1_detects_enable_thinking_default_true(self):
template = """
{% set default_thinking_sys %}...<think>...</think>{% endset %}
{% if enable_thinking is not defined or enable_thinking %}
"""
_, config, parser = self._detect(template, ["<|endoftext|>"])
self.assertEqual(
config,
ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
)
self.assertEqual(parser, "interns1")
def test_poolside_v1_detects_variant_template_defaults(self):
tool_format = """
return an unescaped XML-like object with function name and arguments
within '<tool_call>' and '</tool_call>' tags
<tool_call>function-name
<arg_key>argument-key</arg_key>
<arg_value>value-of-argument-key</arg_value>
</tool_call>
"""
for default, expected in (("false", False), ("true", True)):
with self.subTest(default=default):
template = (
"{%- set enable_thinking = enable_thinking | default("
f"{default}) -%}}\n{tool_format}"
)
_, config, parser = self._detect(template, ["<tool_call>"])
self.assertEqual(
config,
ReasoningToggleConfig(
toggle_param="enable_thinking", default_enabled=expected
),
)
self.assertEqual(parser, "poolside_v1")
def test_poolside_v1_laguna_s21_shaped_template(self):
# Laguna-S-2.1's real template defaults enable_thinking on and lacks
# the XS-style prose describing the tool-call format; only the tool
# preamble and the <arg_key>/<arg_value> markup identify the family.
template = (
"{%- set enable_thinking = enable_thinking | default(true) -%}\n"
"You may call functions to assist with the user query.\n"
"All available function signatures are listed below:\n"
"{{- '<tool_call>' + function_data.name -}}\n"
'{{- "<arg_key>" ~ k ~ "</arg_key>" -}}'
'{{- "<arg_value>" ~ v ~ "</arg_value>" -}}\n'
"{{- '</tool_call>' -}}"
)
_, config, parser = self._detect(template, ["<tool_call>"])
self.assertEqual(
config,
ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
)
self.assertEqual(parser, "poolside_v1")
def test_enable_thinking_default_filter_variants(self):
cases = [
# Jinja's documented alias for the default filter.
("{%- set enable_thinking = enable_thinking | d(true) -%}", True),
# No whitespace around the pipe.
("{% set enable_thinking = enable_thinking|default(false) %}", False),
# An explicit boolean=false second argument is equivalent to the
# one-argument form.
(
"{%- set enable_thinking = enable_thinking | default(true, false) -%}",
True,
),
# Boolean mode with a false default still maps False -> False and
# True -> True, so it is a working default-off toggle.
(
"{%- set enable_thinking = enable_thinking | default(false, true) -%}",
False,
),
(
"{%- set enable_thinking = enable_thinking"
" | default(false, boolean=true) -%}",
False,
),
# The filter also counts outside a set-assignment (gemma-4 style).
("{%- if enable_thinking | default(false) -%}x{%- endif -%}", False),
# A commented-out assignment must not shadow the live one.
(
"{# {%- set enable_thinking = enable_thinking | default(false) -%} #}\n"
"{%- set enable_thinking = enable_thinking | default(true) -%}",
True,
),
# Neither must a raw block or a string literal.
(
"{% raw %}{% set enable_thinking = enable_thinking"
" | default(false) %}{% endraw %}\n"
"{%- set enable_thinking = enable_thinking | default(true) -%}",
True,
),
]
for template, expected in cases:
with self.subTest(template=template):
_, config, _ = self._detect(template, [])
self.assertEqual(
config,
ReasoningToggleConfig(
toggle_param="enable_thinking", default_enabled=expected
),
)
def test_pathological_template_does_not_raise(self):
# A template jinja cannot parse (RecursionError from deep nesting) must
# degrade to no detection, not crash template loading.
template = (
"{% if a %}" * 5000 + "x" + "{% endif %}" * 5000 + "\n"
"{%- set enable_thinking = enable_thinking | default(true) -%}"
)
_, config = detect_reasoning_pattern(template)
self.assertIsNone(config)
def test_enable_thinking_collapsing_default_not_a_toggle(self):
# default(true, true) / default(true, boolean=true) replace any falsy
# value with True, so an explicit enable_thinking=false still renders
# thinking on; the assignment is not a working toggle and must stay
# undetected.
for template in (
"{%- set enable_thinking = enable_thinking | default(true, true) -%}",
"{%- set enable_thinking = enable_thinking"
" | default(true, boolean=true) -%}",
):
with self.subTest(template=template):
_, config, _ = self._detect(template, [])
self.assertIsNone(config)
def test_nemotron_detects_uppercase_true_assignment(self):
template = """
{% set enable_thinking = enable_thinking if enable_thinking is defined else True %}
{% set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %}
"""
_, config, parser = self._detect(template, ["<|endoftext|>"])
self.assertEqual(
config,
ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
)
self.assertEqual(parser, "nemotron_3")
def test_nemotron_super_detects_low_effort_kwarg(self):
template = """
{% set enable_thinking = enable_thinking if enable_thinking is defined else True %}
{%- set low_effort = low_effort if low_effort is defined else False %}
{% set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %}
"""
_, config, parser = self._detect(template, [""])
self.assertEqual(
config,
ReasoningToggleConfig(
toggle_param="enable_thinking",
default_enabled=True,
effort_kwarg="low_effort",
),
)
self.assertEqual(parser, "nemotron_3")
def test_nemotron_with_shared_parameter_block_not_misclassified_as_granite(self):
# Granite 4.2 and Nemotron-3 templates share the same
# <function=name>/<parameter=key> tool-call instruction block; without
# a Granite-only signature (defer_loading) this must stay nemotron_3.
template = """
{% set enable_thinking = enable_thinking if enable_thinking is defined else True %}
{% set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %}
{{- '<tool_call>\\n<function=example_function_name>\\n<parameter=example_parameter_1>\\nvalue_1\\n</parameter>\\n</function>\\n</tool_call>' }}
"""
_, config, parser = self._detect(template, ["<|endoftext|>"])
self.assertEqual(
config,
ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
)
self.assertEqual(parser, "nemotron_3")
def test_granite_detected_via_defer_loading_signature(self):
template = """
{% set enable_thinking = enable_thinking if enable_thinking is defined else True %}
{% set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %}
{%- if tool.defer_loading is not defined or not tool.defer_loading %}{%- endif %}
{{- '<tool_call>\\n<function=example_function_name>\\n<parameter=example_parameter_1>\\nvalue_1\\n</parameter>\\n</function>\\n</tool_call>' }}
"""
_, config, parser = self._detect(template, [])
self.assertEqual(
config,
ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True),
)
self.assertEqual(parser, "granite_thinking_parser")
def test_minimax_uses_template_signature_without_toggle_config(self):
template = """
{%- set toolcall_begin_token = '<minimax:tool_call>' -%}
"""
_, config, parser = self._detect(template, ["<minimax:tool_call>"])
self.assertIsNone(config)
self.assertEqual(parser, "minimax")
MINIMAX_M3_TEMPLATE = (
"{%- set ns_token = ']<]minimax[>[' -%}\n"
"{%- set toolcall_begin_token = ns_token ~ '<tool_call>' -%}\n"
"<mm:think>\n"
)
def test_minimax_m3_detected_via_mm_think_signature(self):
_, config, parser = self._detect(
self.MINIMAX_M3_TEMPLATE, ["<mm:think>", "</mm:think>"]
)
self.assertIsNone(config)
self.assertEqual(parser, "minimax-m3")
def test_minimax_m2_not_misclassified_as_m3(self):
template = """
{%- set toolcall_begin_token = '<minimax:tool_call>' -%}
"""
_, _, parser = self._detect(template, ["<minimax:tool_call>"])
self.assertEqual(parser, "minimax")
HYV4_TEMPLATE = (
"{%- set reasoning_mode_token = '<reasoning_mode:opensource>' %}\n"
"{%- if not reasoning_effort is defined %}\n"
" {%- set reasoning_effort = 'high' %}\n"
"{%- elif reasoning_effort not in ['high', 'low', 'no_think'] %}\n"
"{%- endif %}\n"
"<tool_call:opensource>{{ name }}<arg_key:opensource>{{ k }}</arg_key:opensource>"
)
HYV4_VOCAB = [
"<tool_calls:opensource>",
"<tool_call:opensource>",
"<arg_key:opensource>",
"<arg_value:opensource>",
]
def test_hyv4_effort_template_detected_with_special_case(self):
# Hy4 drops <tool_sep>; detection must key on the effort-mode template
# signature plus the suffixed arg tokens instead.
force, config, parser = self._detect(self.HYV4_TEMPLATE, self.HYV4_VOCAB)
self.assertEqual(config, ReasoningToggleConfig(special_case="hunyuan_effort"))
self.assertEqual(parser, "hunyuan")
self.assertEqual(
detect_tool_call_parser(
self.HYV4_TEMPLATE, _DummyTokenizer(self.HYV4_VOCAB), config, force
),
"hunyuan",
)
def test_hyv4_template_without_arg_tokens_not_hunyuan(self):
_, config, parser = self._detect(self.HYV4_TEMPLATE, ["<tool_call:opensource>"])
self.assertEqual(config, ReasoningToggleConfig(special_case="hunyuan_effort"))
self.assertNotEqual(parser, "hunyuan")
class TestTemplateDetectionRuleMatrix(unittest.TestCase):
"""Table-driven tests for REASONING_PARSER_RULES and REASONING_MODE_RULES."""
def _detect(self, template, vocab=None):
if vocab is None:
vocab = []
force, config = detect_reasoning_pattern(template)
parser = detect_reasoning_parser(
template, _DummyTokenizer(vocab), config, force
)
return force, config, parser
PARSER_RULES_MATRIX = [
# (name, template_snippet, vocab, expected_parser, expected_toggle_param)
(
"apertus2509_via_unique_vocab_token",
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}\n",
["<|inner_prefix|>"],
"apertus2509",
"enable_thinking",
),
(
"deepseek_r1_think_tags",
"<think>\nLet me reason about this\n</think>\nAnswer here",
[],
"deepseek-r1",
None, # matched by deepseek_r1_think_tags rule (has <think> text)
),
(
"deepseek_v3",
"{% if not thinking is defined %}{% set thinking = false %}{% endif %}\n"
"<think>",
[],
"deepseek-v3",
"thinking",
),
(
"deepseek_v4_dsml_tool_calls",
"{% if not thinking is defined %}{% set thinking = false %}{% endif %}\n"
'<DSMLtool_calls><DSMLinvoke name="tool"></DSMLinvoke>',
[],
"deepseek-v4",
"thinking",
),
(
"hunyuan_interleaved_thinking",
"{% set reasoning_effort = reasoning_effort | default('no_think', true) %}\n"
"{% set interleaved_thinking = interleaved_thinking | default(false, true) %}\n"
"<think>reasoning</think><tool_calls><tool_call>name<tool_sep>",
["<tool_calls>", "<tool_sep>", "<arg_key>", "<arg_value>"],
"hunyuan",
None,
),
(
"poolside_v1_enable_thinking_false",
"{% if not enable_thinking is defined %}{% set enable_thinking = false %}{% endif %}\n"
"<tool_call>{{ name }}\n<arg_key>{{ key }}</arg_key><arg_value>{{ value }}</arg_value>",
["<tool_call>", "<arg_key>", "<arg_value>"],
"poolside_v1",
"enable_thinking",
),
(
"poolside_v1_actual_template_shape",
"{% set enable_thinking = enable_thinking | default(false) %}\n"
"Wrap your thinking in '<think>', '</think>' tags, followed by a function call.\n"
"For each function call, return an unescaped XML-like object with function name "
"and arguments within '<tool_call>' and '</tool_call>' tags, like here:\n"
"<tool_call>function-name\n"
"<arg_key>argument-key</arg_key>\n"
"<arg_value>value-of-argument-key</arg_value>\n"
"</tool_call>",
["<tool_call>"],
"poolside_v1",
"enable_thinking",
),
(
"lfm2_not_deepseek_r1_from_history_cleanup",
"{% set keep_past_thinking = keep_past_thinking | default(false) %}\n"
"{% if not keep_past_thinking and '</think>' in content %}"
"{{ content.split('</think>')[-1] }}{% endif %}\n"
'<|tool_call_start|>[get_weather(city="Paris")]<|tool_call_end|>',
["<|tool_call_start|>", "<|tool_call_end|>"],
None,
None,
),
(
"qwen3_coder_actual_template_shape_has_no_reasoning",
"<tools>\n"
"<tool_call><function=get_weather>"
"<parameter=city>Paris</parameter></function></tool_call>",
["<tool_call>"],
None,
None,
),
(
"step3p5_actual_template_shape",
"{% if reasoning_effort is defined %}Reasoning: {{ reasoning_effort }}{% endif %}\n"
"<tool_call><function=get_weather>"
"<parameter=city>Paris</parameter></function></tool_call>\n"
"{% if '<think>' in content %}{{ content.split('</think>')[-1] }}{% endif %}",
["<tool_call>", "<tool_calls>"],
"step3p5",
None,
),
(
"step3p5_think_tags",
"Step3.5-Flash\n<function=tool><parameter=arg>value</parameter></function>\n<think>",
[],
"step3p5",
None,
),
(
"step3_steptml",
"<tool_calls_begin><tool_call_begin>function<tool_sep>"
'<steptml:invoke name="tool"><steptml:parameter name="arg">value</steptml:parameter>'
"</steptml:invoke><tool_call_end><think>",
[],
"step3",
None,
),
(
"qwen3_enable_thinking_true",
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}\n",
[],
"qwen3",
"enable_thinking",
),
(
"kimi_unicode_markers",
"\u25c1think\u25b7some text\u25c1/think\u25b7",
[],
"kimi",
None,
),
(
"mistral_reasoning_effort",
"{% if reasoning_effort %}[THINK]{% endif %}",
[],
"mistral",
None, # special_case="mistral"
),
(
"gpt_oss_channel",
"<|channel|>analysis<|message|>",
[],
"gpt-oss",
None, # special_case="always"
),
(
"kimi_k2_with_tool_vocab",
"{% set thinking = thinking if thinking is defined else true %}\n<think>",
["<|tool_calls_section_begin|>", "<|tool_calls_section_end|>"],
"kimi_k2",
"thinking",
),
(
"mimo_enable_thinking_false",
"{% if not enable_thinking is defined %}{% set enable_thinking = false %}{% endif %}\n"
"enable_thinking",
[],
"mimo",
"enable_thinking",
),
]
def test_parser_rules_matrix(self):
for (
name,
template,
vocab,
expected_parser,
expected_toggle,
) in self.PARSER_RULES_MATRIX:
with self.subTest(name=name):
_, config, parser = self._detect(template, vocab)
self.assertEqual(
parser,
expected_parser,
f"Rule '{name}': expected parser '{expected_parser}', got '{parser}'",
)
if expected_toggle is not None:
self.assertIsNotNone(
config, f"Rule '{name}': expected config, got None"
)
self.assertEqual(
config.toggle_param,
expected_toggle,
f"Rule '{name}': expected toggle '{expected_toggle}', "
f"got '{config.toggle_param}'",
)
def test_unrecognized_template_returns_none(self):
template = "Hello {{ user_message }}, how can I help you?"
_, config, parser = self._detect(template)
self.assertIsNone(config)
self.assertIsNone(parser)
def test_empty_template_returns_none(self):
_, config, parser = self._detect("")
self.assertIsNone(config)
self.assertIsNone(parser)
def test_qwen3_precedence_over_deepseek_r1(self):
"""Template with enable_thinking=true but no <think> tag should be qwen3, not deepseek_r1."""
template = "{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}"
_, config, parser = self._detect(template)
self.assertEqual(parser, "qwen3")
self.assertEqual(config.toggle_param, "enable_thinking")
self.assertTrue(config.default_enabled)
def test_k2_horizon_detects_always_on_reasoning_and_tool_parsers(self):
template = """
{% set tool_call_fmt = tool_call_format | default('xml') %}
{% set effort = reasoning_effort | default('high') %}
<ifm|think><ifm|think_fast><ifm|think_faster>
<ifm|tool_calls><ifm|tool_call>
"""
force, config = detect_reasoning_pattern(template)
tokenizer = _DummyTokenizer([])
self.assertTrue(force)
self.assertIsNotNone(config)
self.assertEqual(config.special_case, "always")
self.assertEqual(
detect_reasoning_parser(template, tokenizer, config, force),
"k2_horizon",
)
self.assertEqual(
detect_tool_call_parser(template, tokenizer, config, force),
"k2_horizon",
)
class TestToolCallParserDetection(unittest.TestCase):
"""Tests for detect_tool_call_parser() using real model tokenizers."""
def _detect_all(self, model_name):
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
template = tok.chat_template
force, config = detect_reasoning_pattern(template)
rp = detect_reasoning_parser(template, tok, config, force)
tcp = detect_tool_call_parser(template, tok, config, force)
return rp, tcp
def test_poolside_s21_shape_resolves_poolside_tool_call_parser(self):
# Regression: with default(true) the S-2.1 shape must not fall through
# to the qwen tool-call parser, which cannot read Poolside's XML-KV
# tool format.
template = (
"{%- set enable_thinking = enable_thinking | default(true) -%}\n"
"All available function signatures are listed below:\n"
"<tool_call>{{ name }}<arg_key>{{ k }}</arg_key>"
"<arg_value>{{ v }}</arg_value></tool_call>"
)
force, config = detect_reasoning_pattern(template)
result = detect_tool_call_parser(template, _DummyTokenizer([]), config, force)
self.assertEqual(result, "poolside_v1")
def test_qwen3_detects_qwen_tool_call_parser(self):
rp, tcp = self._detect_all("Qwen/Qwen3-0.6B")
self.assertEqual(rp, "qwen3")
self.assertEqual(tcp, "qwen")
def test_tool_call_parser_rule_values_via_snippets(self):
"""Table-driven: verify tool-call rule values differ from reasoning where expected."""
cases = [
# (name, template, vocab, expected_tool_call)
(
"qwen_maps_from_qwen3_config",
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}",
[],
"qwen",
),
("gpt_oss", "<|channel|>analysis<|message|>", [], "gpt-oss"),
("gemma4", "<|channel>content", [], "gemma4"),
("minimax_maps_to_m2", "<minimax:tool_call>", [], "minimax-m2"),
(
"minimax_m3_ns_token_only",
"{%- set ns_token = ']<]minimax[>[' -%}\n"
"{%- set toolcall_begin_token = ns_token ~ '<tool_call>' -%}",
[],
"minimax-m3",
),
(
"deepseekv3",
"{% if not thinking is defined %}{% set thinking = false %}{% endif %}",
[],
"deepseekv3",
),
(
"deepseekv31",
(
"{% if not thinking is defined %}{% set thinking = false %}{% endif %}\n"
"<tool▁calls▁begin><tool▁call▁begin>tool<tool▁sep>{}<tool▁call▁end>"
),
[],
"deepseekv31",
),
(
"deepseekv32",
'<DSMLfunction_calls><DSMLinvoke name="tool"></DSMLinvoke></DSMLfunction_calls>',
[],
"deepseekv32",
),
(
"deepseekv4",
'<DSMLtool_calls><DSMLinvoke name="tool"></DSMLinvoke></DSMLtool_calls>',
[],
"deepseekv4",
),
(
"kimi_k2",
"{% set thinking = thinking if thinking is defined else true %}\n<think>",
["<|tool_calls_section_begin|>"],
"kimi_k2",
),
(
"minicpm5",
(
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}"
'\n<function name="{{ tool.name }}">'
'\n<param name="{{ param.name }}">{{ param.value }}</param>'
"\n</function>"
),
["<function", "<param"],
"minicpm5",
),
(
"lfm2",
'<|tool_call_start|>[get_weather(city="Paris")]<|tool_call_end|>',
["<|tool_call_start|>", "<|tool_call_end|>"],
"lfm2",
),
(
"hunyuan",
"<tool_calls><tool_call>get_weather<tool_sep><arg_key>city</arg_key><arg_value>Paris</arg_value></tool_call></tool_calls>",
["<tool_calls>", "<tool_sep>", "<arg_key>", "<arg_value>"],
"hunyuan",
),
(
"poolside_v1",
"{% if not enable_thinking is defined %}{% set enable_thinking = false %}{% endif %}\n"
"<tool_call>get_weather\n<arg_key>city</arg_key><arg_value>Paris</arg_value></tool_call>",
["<tool_call>", "<arg_key>", "<arg_value>"],
"poolside_v1",
),
(
"poolside_v1_actual_template_shape",
"{% set enable_thinking = enable_thinking | default(false) %}\n"
"return an unescaped XML-like object with function name and arguments "
"within '<tool_call>' and '</tool_call>' tags\n"
"<tool_call>function-name\n"
"<arg_key>argument-key</arg_key>\n"
"<arg_value>value-of-argument-key</arg_value>\n"
"</tool_call>",
["<tool_call>"],
"poolside_v1",
),
(
"qwen3_coder",
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}\n"
"<tool_call><function=get_weather><parameter=city>Paris</parameter></function></tool_call>",
["<tool_call>"],
"qwen3_coder",
),
(
"step3p5",
"Step3.5-Flash\n<tool_call><function=get_weather><parameter=city>Paris</parameter></function></tool_call>",
["<tool_call>"],
"step3p5",
),
(
"step3p5_actual_template_shape",
"{% if reasoning_effort is defined %}Reasoning: {{ reasoning_effort }}{% endif %}\n"
"<tool_call><function=get_weather>"
"<parameter=city>Paris</parameter></function></tool_call>",
["<tool_call>", "<tool_calls>"],
"step3p5",
),
(
"step3",
"<tool_calls_begin><tool_call_begin>function<tool_sep>"
'<steptml:invoke name="get_weather"><steptml:parameter name="city">Paris</steptml:parameter>'
"</steptml:invoke><tool_call_end><tool_calls_end>",
[],
"step3",
),
(
"glm47_compact_tool_call",
(
"[gMASK]<sop>\n"
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}\n"
"{% for tc in m.tool_calls %}\n"
"{{- '<tool_call>' + tc.name -}}\n"
"{% set _args = tc.arguments %}"
"{% for k, v in _args.items() %}"
"<arg_key>{{ k }}</arg_key><arg_value>{{ v }}</arg_value>"
"{% endfor %}</tool_call>\n"
"{% endfor %}"
),
["<tool_call>", "<arg_key>", "<arg_value>", "<|endoftext|>"],
"glm47",
),
(
"glm47_tilde_concat_tool_call",
(
"[gMASK]<sop>\n"
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}\n"
"{% for tc in m.tool_calls %}\n"
"{{- '<tool_call>' ~ tc.name -}}\n"
"{% set _args = tc.arguments %}"
"{% for k, v in _args.items() %}"
"<arg_key>{{ k }}</arg_key><arg_value>{{ v }}</arg_value>"
"{% endfor %}</tool_call>\n"
"{% endfor %}"
),
["<tool_call>", "<arg_key>", "<arg_value>", "<|endoftext|>"],
"glm47",
),
(
"glm45_newline_tool_call",
(
"[gMASK]<sop>\n"
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}\n"
"{% for tc in m.tool_calls %}\n"
"{{ '\\n<tool_call>' + tc.name }}\n"
"{% set _args = tc.arguments %}\n"
"{% for k, v in _args.items() %}\n"
"<arg_key>{{ k }}</arg_key>\n<arg_value>{{ v }}</arg_value>\n"
"{% endfor %}\n</tool_call>\n"
"{% endfor %}"
),
["<tool_call>", "<arg_key>", "<arg_value>", "<|endoftext|>"],
"glm45",
),
(
"xml_kv_tool_call_via_vocab",
"{% set reasoning_effort = reasoning_effort | default('high', true) %}\n<think>",
["<tool_call>", "<arg_key>", "<arg_value>", "<|endoftext|>"],
"glm45",
),
]
for name, template, vocab, expected in cases:
with self.subTest(name=name):
force, config = detect_reasoning_pattern(template)
result = detect_tool_call_parser(
template, _DummyTokenizer(vocab), config, force
)
self.assertEqual(result, expected)
def test_glm45_rule_precedes_xml_kv_fallback(self):
# The specific GLM-4.5 family check must run before the generic
# xml_kv_tool_call fallback. Both currently map to "glm45", so the
# value-based test above can't catch a swap — assert positions directly.
rule_index = {rule.name: i for i, rule in enumerate(TOOL_CALL_PARSER_RULES)}
self.assertLess(rule_index["glm47"], rule_index["glm45"])
self.assertLess(rule_index["glm45"], rule_index["xml_kv_tool_call"])
def test_specific_rules_precede_broad_fallbacks(self):
reasoning_index = {
rule.name: i for i, rule in enumerate(REASONING_PARSER_RULES)
}
self.assertLess(reasoning_index["deepseek_v4"], reasoning_index["deepseek_v3"])
self.assertLess(
reasoning_index["glm45"], reasoning_index["deepseek_r1_think_tags"]
)
self.assertLess(
reasoning_index["hunyuan"], reasoning_index["deepseek_r1_think_tags"]
)
self.assertLess(reasoning_index["poolside_v1"], reasoning_index["mimo"])
self.assertLess(
reasoning_index["step3p5"], reasoning_index["deepseek_r1_think_tags"]
)
self.assertLess(
reasoning_index["step3"], reasoning_index["deepseek_r1_think_tags"]
)
tool_index = {rule.name: i for i, rule in enumerate(TOOL_CALL_PARSER_RULES)}
self.assertLess(tool_index["deepseek_v31"], tool_index["deepseek_v3"])
self.assertLess(tool_index["hunyuan"], tool_index["xml_kv_tool_call"])
self.assertLess(tool_index["poolside_v1"], tool_index["xml_kv_tool_call"])
self.assertLess(tool_index["step3p5"], tool_index["qwen3_coder"])
self.assertLess(tool_index["step3"], tool_index["deepseek_v3"])
self.assertLess(tool_index["qwen3_coder"], tool_index["qwen"])
def test_xml_kv_requires_both_arg_tokens(self):
template = "Hello {{ user }}"
force, config = detect_reasoning_pattern(template)
for vocab in (["<arg_key>"], ["<arg_value>"], []):
with self.subTest(vocab=vocab):
result = detect_tool_call_parser(
template, _DummyTokenizer(vocab), config, force
)
self.assertIsNone(result)
def test_none_template_returns_none(self):
self.assertIsNone(detect_tool_call_parser(None, None))
def test_unrecognized_template_returns_none(self):
force, config = detect_reasoning_pattern("Hello {{ user }}")
result = detect_tool_call_parser("Hello {{ user }}", None, config, force)
self.assertIsNone(result)
def test_minicpm5_rule_precedes_broad_fallback_rules(self):
rule_names = [rule.name for rule in TOOL_CALL_PARSER_RULES]
minicpm5_idx = rule_names.index("minicpm5")
self.assertLess(minicpm5_idx, rule_names.index("mimo"))
self.assertLess(minicpm5_idx, rule_names.index("qwen"))
def test_minimax_m3_rule_precedes_m2_in_both_registries(self):
for rules in (REASONING_PARSER_RULES, TOOL_CALL_PARSER_RULES):
names = [rule.name for rule in rules]
self.assertLess(names.index("minimax_m3"), names.index("minimax"))
def test_minicpm5_not_misclassified_as_qwen(self):
template = (
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}"
'\n<function name="{{ tool.name }}">'
'\n<param name="{{ param.name }}">{{ param.value }}</param>'
"\n</function>"
)
force, config = detect_reasoning_pattern(template)
result = detect_tool_call_parser(
template, _DummyTokenizer(["<function", "<param"]), config, force
)
self.assertEqual(result, "minicpm5")
def _declared(server_args, field):
"""What late resolution decided for `field` on this record.
`resolve_auto_parsers` declares; the field keeps what the operator passed,
so the decision is read through the resolution result -- the same surface
the config bags are projected from.
"""
from sglang.srt.arg_groups.overrides import resolution_result
return resolution_result(server_args, field)
class TestResolveAutoParsers(CustomTestCase):
"""Tests for resolve_auto_parsers()."""
qwen3_template = "{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}"
def _make_server_args(
self, reasoning_parser=None, tool_call_parser=None, chat_template=None
):
# The dummy model path skips resolution; the tokenizer / HF-config
# loads that detection performs are patched per test.
return ServerArgs(
model_path="dummy",
reasoning_parser=reasoning_parser,
tool_call_parser=tool_call_parser,
trust_remote_code=False,
chat_template=chat_template,
)
def test_resolves_both_parsers_with_tokenizer_template(self):
args = self._make_server_args(reasoning_parser="auto", tool_call_parser="auto")
tokenizer = _DummyTokenizer([], chat_template=self.qwen3_template)
with _patch_hf_transformers_utils(Mock(return_value=tokenizer)):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "qwen3")
self.assertEqual(_declared(args, "tool_call_parser"), "qwen")
def test_resolves_reasoning_parser_only(self):
args = self._make_server_args(reasoning_parser="auto", tool_call_parser=None)
tokenizer = _DummyTokenizer([], chat_template=self.qwen3_template)
with _patch_hf_transformers_utils(Mock(return_value=tokenizer)):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "qwen3")
self.assertIsNone(_declared(args, "tool_call_parser"))
def test_resolves_tool_call_parser_only(self):
args = self._make_server_args(reasoning_parser="qwen3", tool_call_parser="auto")
tokenizer = _DummyTokenizer([], chat_template=self.qwen3_template)
with _patch_hf_transformers_utils(Mock(return_value=tokenizer)):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "qwen3")
self.assertEqual(_declared(args, "tool_call_parser"), "qwen")
def test_neither_auto_is_noop(self):
args = self._make_server_args(reasoning_parser="qwen3", tool_call_parser="qwen")
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "qwen3")
self.assertEqual(_declared(args, "tool_call_parser"), "qwen")
def test_nonexistent_model_disables_both_parsers(self):
args = self._make_server_args(reasoning_parser="auto", tool_call_parser="auto")
args = self._make_server_args(
reasoning_parser="auto",
tool_call_parser="auto",
)
msgspec.Struct.__setattr__(
args, "model_path", "nonexistent/model-does-not-exist-xyz"
)
with _patch_hf_transformers_utils(
Mock(side_effect=RuntimeError("tokenizer unavailable")),
Mock(side_effect=RuntimeError("config unavailable")),
):
resolve_auto_parsers(args)
self.assertIsNone(_declared(args, "reasoning_parser"))
self.assertIsNone(_declared(args, "tool_call_parser"))
def test_none_chat_template_disables_both_parsers(self):
args = self._make_server_args(reasoning_parser="auto", tool_call_parser="auto")
tokenizer = _DummyTokenizer([])
with _patch_hf_transformers_utils(Mock(return_value=tokenizer)):
resolve_auto_parsers(args)
self.assertIsNone(_declared(args, "reasoning_parser"))
self.assertIsNone(_declared(args, "tool_call_parser"))
def test_deepseek_v32_arch_without_chat_template_uses_custom_encoder(self):
args = self._make_server_args(reasoning_parser="auto", tool_call_parser="auto")
tokenizer = _DummyTokenizer([])
config = SimpleNamespace(architectures=["DeepseekV32ForCausalLM"])
with _patch_hf_transformers_utils(
Mock(return_value=tokenizer), Mock(return_value=config)
):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "deepseek-v3")
self.assertEqual(_declared(args, "tool_call_parser"), "deepseekv32")
def test_deepseek_v4_arch_without_chat_template_uses_custom_encoder(self):
args = self._make_server_args(reasoning_parser="auto", tool_call_parser="auto")
tokenizer = _DummyTokenizer([])
config = SimpleNamespace(architectures=["DeepseekV4ForCausalLM"])
with _patch_hf_transformers_utils(
Mock(return_value=tokenizer), Mock(return_value=config)
):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "deepseek-v4")
self.assertEqual(_declared(args, "tool_call_parser"), "deepseekv4")
def test_kimi_k3_arch_without_chat_template_uses_custom_encoder(self):
args = self._make_server_args(reasoning_parser="auto", tool_call_parser="auto")
tokenizer = _DummyTokenizer([])
config = SimpleNamespace(
architectures=["KimiK3ForConditionalGeneration"], model_type="kimi_k3"
)
with _patch_hf_transformers_utils(
Mock(return_value=tokenizer), Mock(return_value=config)
):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "kimi_k3")
self.assertEqual(_declared(args, "tool_call_parser"), "kimi_k3")
def test_kimi_k3_model_type_without_architecture_uses_custom_encoder(self):
args = self._make_server_args(reasoning_parser="auto", tool_call_parser="auto")
tokenizer = _DummyTokenizer([])
config = SimpleNamespace(architectures=None, model_type="kimi_k3")
with _patch_hf_transformers_utils(
Mock(return_value=tokenizer), Mock(return_value=config)
):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "kimi_k3")
self.assertEqual(_declared(args, "tool_call_parser"), "kimi_k3")
def test_bailing_architectures_and_model_types_use_ling3_parsers(self):
cases = (
(["BailingMoeV3VLForConditionalGeneration"], ""),
(None, "bailing_moe_v3_vl"),
(["BailingMoeV3ForCausalLM"], ""),
(None, "bailing_hybrid"),
)
for architectures, model_type in cases:
with self.subTest(architectures=architectures, model_type=model_type):
args = self._make_server_args(
reasoning_parser="auto", tool_call_parser="auto"
)
tokenizer = _DummyTokenizer([])
config = SimpleNamespace(
architectures=architectures, model_type=model_type
)
with _patch_hf_transformers_utils(
Mock(return_value=tokenizer), Mock(return_value=config)
):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "ling3")
self.assertEqual(_declared(args, "tool_call_parser"), "ling3")
def test_deepseek_arch_fallback_runs_when_tokenizer_load_fails(self):
args = self._make_server_args(reasoning_parser="auto", tool_call_parser="auto")
config = SimpleNamespace(architectures=["DeepseekV32ForCausalLM"])
with _patch_hf_transformers_utils(
Mock(side_effect=RuntimeError("tokenizer unavailable")),
Mock(return_value=config),
):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "deepseek-v3")
self.assertEqual(_declared(args, "tool_call_parser"), "deepseekv32")
def test_explicit_non_jinja_template_skips_architecture_fallback(self):
args = self._make_server_args(
reasoning_parser="auto",
tool_call_parser="auto",
chat_template="chatml",
)
args.model_path = "deepseek-ai/DeepSeek-V3.2"
tokenizer = _DummyTokenizer([])
get_config = Mock()
with _patch_hf_transformers_utils(Mock(return_value=tokenizer), get_config):
resolve_auto_parsers(args)
get_config.assert_not_called()
self.assertIsNone(_declared(args, "reasoning_parser"))
self.assertIsNone(_declared(args, "tool_call_parser"))
def test_explicit_jinja_template_takes_precedence(self):
tokenizer = _DummyTokenizer([], chat_template=None)
with tempfile.NamedTemporaryFile("w", suffix=".jinja") as f:
f.write(
"{% if not thinking is defined %}{% set thinking = false %}{% endif %}\n"
'<DSMLfunction_calls><DSMLinvoke name="tool"></DSMLinvoke>'
)
f.flush()
args = self._make_server_args(
reasoning_parser="auto",
tool_call_parser="auto",
chat_template=f.name,
)
with _patch_hf_transformers_utils(Mock(return_value=tokenizer)):
resolve_auto_parsers(args)
self.assertEqual(_declared(args, "reasoning_parser"), "deepseek-v3")
self.assertEqual(_declared(args, "tool_call_parser"), "deepseekv32")
if __name__ == "__main__":
unittest.main()