Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 84 additions & 7 deletions python/sglang/srt/parser/template_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import jinja2
import jinja2.ext
import jinja2.nodes
import jinja2.sandbox

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -71,6 +72,72 @@ def always_on(self) -> bool:
return self.special_case == "always"


class _GenerationTagExtension(jinja2.ext.Extension):
"""Parse-only support for the ``{% generation %}`` blocks emitted by
transformers chat templates (assistant token masking). Needed so the
toggle-default parser below can build an AST for templates that use it."""

tags = {"generation"}

def parse(self, parser):
lineno = next(parser.stream).lineno
body = parser.parse_statements(("name:endgeneration",), drop_needle=True)
return jinja2.nodes.Scope(body).set_lineno(lineno)


def _has_toggle_default_assignment(
ctx: TemplateDetectionContext, param: str, default: bool
) -> bool:
"""True if the template applies Jinja's ``default`` filter (or its ``d``
alias) to ``param`` with working-toggle semantics and the given default,
e.g. ``{%- set enable_thinking = enable_thinking | default(false) -%}``
(Laguna-XS-2.1) or ``default(true)`` (Laguna-S-2.1).

Matches on the parsed ``Filter`` node, so comments, ``{% raw %}`` blocks,
and string literals cannot shadow a live assignment. Boolean-mode handling:
``default(x, false)`` is equivalent to ``default(x)``; ``default(false,
true)`` still maps False -> False and True -> True, so it is a working
default-off toggle; only ``default(true, true)`` collapses the toggle
(an explicit False is replaced by True) and is not matched.
"""
try:
env = jinja2.Environment(
extensions=[jinja2.ext.loopcontrols, _GenerationTagExtension]
)
tree = env.parse(ctx.template)
except jinja2.TemplateError:
return False
except Exception:
# e.g. RecursionError on a pathologically nested template; detection
# must never take down server startup (mirrors
# detect_inline_system_support).
return False
for node in tree.find_all(jinja2.nodes.Filter):
if node.name not in ("default", "d") or node.dyn_args or node.dyn_kwargs:
continue
if not isinstance(node.node, jinja2.nodes.Name) or node.node.name != param:
continue
args = list(node.args)
kwargs = {kw.key: kw.value for kw in node.kwargs}
if not args or not isinstance(args[0], jinja2.nodes.Const):
continue
default_value = args[0].value
if not isinstance(default_value, bool):
continue
boolean_arg = args[1] if len(args) > 1 else kwargs.get("boolean")
if boolean_arg is not None and not isinstance(boolean_arg, jinja2.nodes.Const):
continue
boolean_mode = bool(boolean_arg.value) if boolean_arg is not None else False
if boolean_mode and default_value:
# default(true, true): any falsy value is replaced by True, so an
# explicit enable_thinking=false still renders thinking on -- the
# assignment is not a working toggle.
continue
if default_value is default:
return True
return False


# ---------------------------------------------------------------------------
# Reasoning mode rules (detect toggle config from template)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -103,7 +170,8 @@ def always_on(self) -> bool:
r"{%\s*if\s+not\s+enable_thinking\s+is\s+defined\s*%}.*?"
r"{%\s*set\s+enable_thinking\s*=\s*(?:false|False)\s*%}",
re.DOTALL,
),
)
or _has_toggle_default_assignment(ctx, "enable_thinking", False),
),
DetectionRule(
name="nemotron_3_super_low_effort",
Expand Down Expand Up @@ -134,7 +202,8 @@ def always_on(self) -> bool:
or ctx.has_pattern(
r"enable_thinking\s+is\s+not\s+defined\s+or\s+enable_thinking"
)
or ctx.has_pattern(r"namespace\([^)]*enable_thinking\s*=\s*true"),
or ctx.has_pattern(r"namespace\([^)]*enable_thinking\s*=\s*true")
or _has_toggle_default_assignment(ctx, "enable_thinking", True),
),
DetectionRule(
name="explicit_thinking_default_false",
Expand All @@ -143,7 +212,8 @@ def always_on(self) -> bool:
r"{%\s*if\s+not\s+thinking\s+is\s+defined\s*%}.*?"
r"{%\s*set\s+thinking\s*=\s*(?:false|False)\s*%}",
re.DOTALL,
),
)
or _has_toggle_default_assignment(ctx, "thinking", False),
),
DetectionRule(
name="thinking_default_true",
Expand All @@ -160,7 +230,8 @@ def always_on(self) -> bool:
r"thinking\s+is\s+defined\s+and\s+(?:thinking\s+is\s+false|not\s+thinking)"
)
or ctx.has_pattern(r"thinking\s+is\s+not\s+defined\s+or\s+thinking")
or ctx.has_pattern(r"namespace\([^)]*thinking\s*=\s*true"),
or ctx.has_pattern(r"namespace\([^)]*thinking\s*=\s*true")
or _has_toggle_default_assignment(ctx, "thinking", True),
),
)

Expand Down Expand Up @@ -269,10 +340,16 @@ def _is_hunyuan(ctx):

def _is_poolside_v1(ctx):
has_poolside_tool_format = (
ctx.has_text("unescaped XML-like object")
and ctx.has_text("<tool_call>function-name")
and ctx.has_text("<arg_key>")
ctx.has_text("<arg_key>")
and ctx.has_text("<arg_value>")
and (
# Laguna-XS.2 spells out the tool-call format in prose;
# Laguna-S-2.1 does not, so also key on the tool preamble both
# template families share. The Poolside identity must not depend
# on the enable_thinking default, which differs between families.
ctx.has_text("unescaped XML-like object")
or ctx.has_text("All available function signatures are listed below")
)
)
return has_poolside_tool_format or (
ctx.reasoning_config
Expand Down
136 changes: 135 additions & 1 deletion test/registered/unit/parser/test_template_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,126 @@ def test_interns1_detects_enable_thinking_default_true(self):
)
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 %}
Expand Down Expand Up @@ -225,7 +345,7 @@ def _detect(self, template, vocab=None):
"</tool_call>",
["<tool_call>"],
"poolside_v1",
None,
"enable_thinking",
),
(
"lfm2_not_deepseek_r1_from_history_cleanup",
Expand Down Expand Up @@ -379,6 +499,20 @@ def _detect_all(self, model_name):
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")
Expand Down
Loading