chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,138 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Completion templates."""
import dataclasses
import logging
from enum import Enum, auto
from typing import Optional
from sglang.srt.entrypoints.openai.protocol import CompletionRequest
logger = logging.getLogger(__name__)
completion_template_name: Optional[str] = None
class FimPosition(Enum):
"""Position of fim middle token."""
MIDDLE = auto()
END = auto()
@dataclasses.dataclass
class CompletionTemplate:
"""A class that manages completion prompt templates. only for code completion currently."""
# The name of this template
name: str
# the fim begin token
fim_begin_token: str
# The fim middle token
fim_middle_token: str
# The fim end token
fim_end_token: str
# The position of the fim middle token
fim_position: FimPosition
# A global registry for all completion templates
completion_templates: dict[str, CompletionTemplate] = {}
def register_completion_template(template: CompletionTemplate, override: bool = False):
"""Register a new completion template."""
if not override:
assert (
template.name not in completion_templates
), f"{template.name} has been registered."
completion_templates[template.name] = template
def completion_template_exists(template_name: str) -> bool:
return template_name in completion_templates
def set_completion_template(template_name: str) -> None:
global completion_template_name
if completion_template_name is None:
completion_template_name = template_name
def is_completion_template_defined() -> bool:
global completion_template_name
return completion_template_name is not None
def generate_completion_prompt_from_request(request: CompletionRequest) -> str:
global completion_template_name
if request.suffix == "":
return request.prompt
return generate_completion_prompt(
request.prompt, request.suffix, completion_template_name
)
def generate_completion_prompt(prompt: str, suffix: str, template_name: str) -> str:
completion_template = completion_templates[template_name]
fim_begin_token = completion_template.fim_begin_token
fim_middle_token = completion_template.fim_middle_token
fim_end_token = completion_template.fim_end_token
fim_position = completion_template.fim_position
if fim_position == FimPosition.MIDDLE:
prompt = f"{fim_begin_token}{prompt}{fim_middle_token}{suffix}{fim_end_token}"
elif fim_position == FimPosition.END:
prompt = f"{fim_begin_token}{prompt}{fim_end_token}{suffix}{fim_middle_token}"
return prompt
register_completion_template(
CompletionTemplate(
name="deepseek_coder",
fim_begin_token="<fim▁begin>",
fim_middle_token="<fim▁hole>",
fim_end_token="<fim▁end>",
fim_position=FimPosition.MIDDLE,
)
)
register_completion_template(
CompletionTemplate(
name="star_coder",
fim_begin_token="<fim_prefix>",
fim_middle_token="<fim_middle>",
fim_end_token="<fim_suffix>",
fim_position=FimPosition.END,
)
)
register_completion_template(
CompletionTemplate(
name="qwen_coder",
fim_begin_token="<|fim_prefix|>",
fim_middle_token="<|fim_middle|>",
fim_end_token="<|fim_suffix|>",
fim_position=FimPosition.END,
)
)
File diff suppressed because it is too large Load Diff
+588
View File
@@ -0,0 +1,588 @@
import re
from dataclasses import dataclass
from typing import Iterator, List, Optional, Tuple
@dataclass
class Event:
"""Represents a parsed event from the Harmony stream."""
event_type: str
content: str
raw_text: str = None # Original text including structural markers
@dataclass
class Token:
"""A structural token in the Harmony format."""
type: str
start: int
end: int
def prefix_hold(text: str, tokens: List[str]) -> Tuple[str, str]:
"""
Holds back the longest suffix of `text` that could be a prefix of any token.
Returns (emit_now, keep_for_later).
"""
if not text:
return "", ""
max_hold = 0
for tok in tokens:
if not tok:
continue
# Check for prefixes of tok in the suffix of text
L = min(len(tok) - 1, len(text))
for k in range(L, 0, -1):
if tok.startswith(text[-k:]):
max_hold = max(max_hold, k)
break
if max_hold == 0:
return text, ""
return text[:-max_hold], text[-max_hold:]
def iter_tokens(text: str, start_pos: int = 0) -> Iterator[Token]:
"""Iterate over structural tokens in left-to-right order."""
TOKENS = {
"<|start|>": "START",
"<|channel|>": "CHANNEL",
"<|message|>": "MESSAGE",
"<|constrain|>": "CONSTRAIN",
"<|end|>": "END",
"<|call|>": "CALL",
"<|return|>": "RETURN",
}
pos = start_pos
has_unknown_tokens = False
while pos < len(text):
# Find next "<|"
marker_pos = text.find("<|", pos)
if marker_pos == -1:
break
# Emit any text before the marker
if marker_pos > pos:
yield Token("TEXT", pos, marker_pos)
# Check which token it is
found_token = False
for literal, token_type in TOKENS.items():
if text.startswith(literal, marker_pos):
yield Token(token_type, marker_pos, marker_pos + len(literal))
pos = marker_pos + len(literal)
found_token = True
break
if not found_token:
tail = text[marker_pos:]
is_partial = any(lit.startswith(tail) for lit in TOKENS)
if is_partial:
# Hold whole tail (partial token)
yield Token("TEXT", marker_pos, len(text))
pos = len(text)
break
else:
# Unknown token like <|weird|> ...
has_unknown_tokens = True
# Emit the "<|" as a TEXT token first
yield Token("TEXT", marker_pos, marker_pos + 2)
# Try to find a closing "|>" for this unknown token
close_pos = text.find("|>", marker_pos + 2)
if close_pos != -1:
# Look ahead to the next structural token after the unknown close
next_marker = text.find("<|", close_pos + 2)
if next_marker != -1:
# Emit the unknown body + any following plain text up to next marker
yield Token("TEXT", marker_pos + 2, next_marker)
pos = next_marker
else:
# Emit until the end
yield Token("TEXT", marker_pos + 2, len(text))
pos = len(text)
break
else:
# No closing; advance past "<|" and continue scanning
pos = marker_pos + 2
# Emit any remaining text
if pos < len(text):
yield Token("TEXT", pos, len(text))
elif pos == len(text) and has_unknown_tokens:
# Add an empty trailing TEXT token only when we encountered unknown tokens
# and the text ends with a known structural token. This matches expected tests.
for literal in TOKENS.keys():
if text.endswith(literal):
yield Token("TEXT", pos, pos)
break
class CanonicalStrategy:
"""Parses the canonical Harmony format with channel markers."""
def __init__(self):
self.guard_tokens = [
"<|start|>",
"<|channel|>",
"<|message|>",
"<|constrain|>",
"<|end|>",
"<|call|>",
"<|return|>",
]
def parse(self, text: str) -> Tuple[List[Event], str]:
events = []
tokens = list(iter_tokens(text))
if not tokens:
return events, ""
pos = 0
while pos < len(tokens):
token = tokens[pos]
if token.type == "TEXT":
# Check if this might be incomplete
if pos == len(tokens) - 1: # Last token
emit, hold = prefix_hold(
text[token.start : token.end], self.guard_tokens
)
if emit:
events.append(Event("normal", emit))
return events, hold
else:
# Check if this might be commentary filler between blocks
if self._is_commentary_filler_between_blocks(text, tokens, pos):
# Skip this filler text - don't emit as normal content
pos += 1
else:
content = text[token.start : token.end]
# Skip standalone structural tokens that shouldn't be emitted as normal text
if not self._is_standalone_structural_token(content):
events.append(Event("normal", content))
pos += 1
elif token.type in ("START", "CHANNEL"):
# Parse a channel block starting here
block_result = self._parse_block(text, tokens, pos)
if block_result is None:
# Incomplete block - check if we can emit partial reasoning content
partial_result = self._parse_partial_analysis(text, tokens, pos)
if partial_result:
event, remaining_text = partial_result
events.append(event)
return events, remaining_text
# No partial content, hold entire remaining text
remaining_start = tokens[pos].start
return events, text[remaining_start:]
event, new_pos = block_result
if event:
events.append(event)
pos = new_pos
else:
# Check if this might be commentary filler between blocks
if self._is_commentary_filler_between_blocks(text, tokens, pos):
# Skip this filler text - don't emit as normal content
pos += 1
else:
# Unexpected token - only emit as text if it's not a standalone structural token
content = text[token.start : token.end]
if not self._is_standalone_structural_token(content):
events.append(Event("normal", content))
pos += 1
return events, ""
def _parse_partial_analysis(
self, text: str, tokens: List[Token], start_pos: int
) -> Optional[Tuple[Event, str]]:
"""Try to parse partial analysis content for incremental streaming."""
pos = start_pos
# Skip <|start|> if present
if pos < len(tokens) and tokens[pos].type == "START":
pos += 1
# Look for <|channel|> followed by analysis
channel_pos = None
message_pos = None
for i in range(pos, len(tokens)):
if tokens[i].type == "CHANNEL" and channel_pos is None:
channel_pos = i
elif tokens[i].type == "MESSAGE":
message_pos = i
break
if channel_pos is None or message_pos is None:
return None
# Extract channel type
channel_start = (
tokens[channel_pos + 1].start
if channel_pos + 1 < len(tokens)
else tokens[channel_pos].end
)
channel_end = tokens[message_pos].start
channel_header = text[channel_start:channel_end]
channel_type = self._extract_channel_type(channel_header)
if channel_type != "analysis":
return None # Only stream analysis content - tool calls wait for completion
# Extract partial content after <|message|>
content_start = tokens[message_pos].end
content = text[content_start:]
# Return partial reasoning content and preserve the channel structure for next parse
remaining_text = text[tokens[start_pos].start : content_start]
return Event("reasoning", content), remaining_text
def _extract_channel_type(self, header_text: str) -> Optional[str]:
"""Extract channel type from header, ignoring other attributes like to=... or <|constrain|>..."""
# Look for channel type at the start of the header (case insensitive)
header_clean = header_text.strip()
if header_clean.lower().startswith("analysis"):
return "analysis"
elif header_clean.lower().startswith("commentary"):
return "commentary"
elif header_clean.lower().startswith("final"):
return "final"
else:
return None # Unknown channel type
def _parse_block(
self, text: str, tokens: List[Token], start_pos: int
) -> Optional[Tuple[Optional[Event], int]]:
"""Parse a channel block. Returns (event, next_pos) or None if incomplete."""
pos = start_pos
# Skip <|start|> if present
if pos < len(tokens) and tokens[pos].type == "START":
pos += 1
# Look for <|channel|> or <|message|> (tool responses go direct to message)
channel_pos = None
message_pos = None
for i in range(pos, len(tokens)):
if tokens[i].type == "CHANNEL" and channel_pos is None:
channel_pos = i
elif tokens[i].type == "MESSAGE":
message_pos = i
break
if message_pos is None:
return None # No message token found
# If no channel found, this is a tool response - treat as normal text
if channel_pos is None:
content_start = tokens[message_pos].end
# Find end token after message
end_token_pos = None
for i in range(message_pos + 1, len(tokens)):
if tokens[i].type in ("END", "CALL", "RETURN"):
end_token_pos = i
break
if end_token_pos is None:
return None # Incomplete
content = text[content_start : tokens[end_token_pos].start]
return Event("normal", content), end_token_pos + 1
# Standard channel block processing - message_pos is already found above
pos = channel_pos + 1 # Skip CHANNEL token
# Extract channel type from header (ignoring other attributes like to=... or <|constrain|>...)
channel_start = tokens[pos].start if pos < len(tokens) else tokens[pos - 1].end
channel_end = tokens[message_pos].start
channel_header = text[channel_start:channel_end]
channel_type = self._extract_channel_type(channel_header)
if not channel_type:
return None # Unknown or malformed channel
pos = message_pos + 1 # Skip MESSAGE token
# Find content and end token
content_start = tokens[message_pos].end
end_pos = pos
# Each channel type has specific valid end tokens
if channel_type == "final":
while end_pos < len(tokens) and tokens[end_pos].type != "RETURN":
end_pos += 1
elif channel_type == "analysis":
while end_pos < len(tokens) and tokens[end_pos].type not in ("END", "CALL"):
end_pos += 1
else: # commentary
while end_pos < len(tokens) and tokens[end_pos].type not in ("END", "CALL"):
end_pos += 1
if end_pos >= len(tokens):
# No end token found
if channel_type == "final":
# Final blocks can end at end of input without requiring <|return|>
content = text[content_start:]
return Event("normal", content), end_pos
return None # Analysis and commentary need proper end tokens
end_token = tokens[end_pos]
content = text[content_start : end_token.start]
# Create event based on channel and end token
if channel_type == "analysis":
if end_token.type == "CALL":
# Built-in tools (browser, python) use analysis channel with <|call|>
raw_text = text[tokens[start_pos].start : end_token.end]
return Event("tool_call", content.strip(), raw_text), end_pos + 1
else:
return Event("reasoning", content), end_pos + 1
elif channel_type == "commentary":
if end_token.type == "CALL":
raw_text = text[tokens[start_pos].start : end_token.end]
return Event("tool_call", content.strip(), raw_text), end_pos + 1
else:
return Event("normal", content), end_pos + 1
elif channel_type == "final":
# For final blocks, include any trailing TEXT immediately after <|return|>
final_content = content
if end_token.type == "RETURN" and end_pos + 1 < len(tokens):
next_token = tokens[end_pos + 1]
if next_token.type == "TEXT":
final_content += text[next_token.start : next_token.end]
return Event("normal", final_content), end_pos + 2
return Event("normal", final_content), end_pos + 1
return None, end_pos + 1
def _is_commentary_filler_between_blocks(
self, text: str, tokens: List[Token], pos: int
) -> bool:
"""Check if this is commentary filler text or problematic structural tokens in malformed sequences."""
current_token = tokens[pos]
current_text = text[current_token.start : current_token.end].strip()
# Check for commentary filler between CALL and CHANNEL
if pos > 0 and pos + 1 < len(tokens):
prev_token = tokens[pos - 1]
next_token = tokens[pos + 1]
# Check if we have CALL -> TEXT("commentary") -> CHANNEL pattern
if (
prev_token.type == "CALL"
and next_token.type == "CHANNEL"
and current_text.lower() == "commentary"
):
return True
# Check for problematic patterns after CALL tokens (malformed sequences)
if pos > 0:
prev_token = tokens[pos - 1]
# Only filter structural tokens that appear immediately after CALL in malformed sequences
# These patterns indicate the content is malformed and the structural tokens are noise
if prev_token.type == "CALL":
# Filter MESSAGE tokens after CALL (should not happen in well-formed content)
if current_token.type == "MESSAGE":
return True
# Filter standalone "commentary" text after CALL
if (
current_token.type == "TEXT"
and current_text.lower() == "commentary"
):
return True
return False
def _is_standalone_structural_token(self, content: str) -> bool:
"""Check if content is just a standalone structural token that should be filtered."""
content_stripped = content.strip()
structural_tokens = [
"<|start|>",
"<|channel|>",
"<|message|>",
"<|constrain|>",
"<|end|>",
"<|call|>",
"<|return|>",
]
return content_stripped in structural_tokens
class TextStrategy:
"""Parses the text-based Harmony fallback format."""
def __init__(self):
self.buffer_context = ""
self.patterns = {
"analysis_then_final": re.compile(
r"^\s*(?:assistant)?\s*(analysis|commentary)(.*?)\s*assistantfinal\s*(.*)\s*$",
re.IGNORECASE | re.DOTALL,
),
"final_only": re.compile(
r"^\s*assistantfinal\s*(.*)\s*$", re.IGNORECASE | re.DOTALL
),
"analysis_only": re.compile(
r"^\s*(?:assistant)?\s*(analysis|commentary)(.*)\s*$",
re.IGNORECASE | re.DOTALL,
),
}
def set_buffer_context(self, buffer: str):
self.buffer_context = buffer
def parse(self, text: str) -> Tuple[List[Event], str]:
events = []
m = self.patterns["analysis_then_final"].match(text)
if m:
channel, reasoning, final = m.groups()
if channel.lower() == "analysis" and reasoning.strip():
events.append(Event("reasoning", reasoning.strip()))
elif channel.lower() == "commentary" and reasoning.strip():
events.append(Event("normal", reasoning.strip()))
if final.strip():
events.append(Event("normal", final.strip()))
return events, ""
# If assistantfinal appears to be incomplete (e.g., 'assistantfin'), hold entire buffer
if re.search(
r"(?:^|\s)(?:assistant)?\s*(analysis|commentary)", text, re.IGNORECASE
):
low = text.lower()
if "assistantfin" in low and "assistantfinal" not in low:
return events, text
m = self.patterns["final_only"].match(text)
if m:
final = m.group(1)
if final.strip():
events.append(Event("normal", final.strip()))
return events, ""
m = self.patterns["analysis_only"].match(text)
if m:
channel, content = m.groups()
emit, hold = prefix_hold(content, ["assistantfinal"])
if channel.lower() == "analysis" and emit:
# Stream reasoning content as-is based on structural markers only.
events.append(Event("reasoning", emit))
# Keep the channel header in the remaining buffer to continue parsing
# subsequent chunks in the text fallback format. Preserve any held
# prefix that may complete into "assistantfinal".
if hold:
return events, text[: m.start(2)] + hold
else:
return events, channel
elif channel.lower() == "commentary" and emit:
# For commentary, stream as normal text. Preserve spaces unless holding.
content_out = emit if hold else emit.strip()
events.append(Event("normal", content_out))
if hold:
return events, text[: m.start(2)] + hold
else:
return events, ""
# If no emit, just return the held content
return events, text[: m.start(2)] + hold
emit, hold = prefix_hold(text, ["analysis", "commentary", "assistantfinal"])
if emit:
events.append(Event("normal", emit))
return events, hold
class HarmonyParser:
"""Facade for parsing Harmony format, switching between strategies."""
def __init__(self):
self.strategy = None
self._buffer = ""
self._should_filter_commentary = (
False # Track if we should filter commentary in next chunks
)
self._partial_commentary = (
"" # Track partial commentary being built across chunks
)
def parse(self, chunk: str) -> List[Event]:
self._buffer += chunk
if self.strategy is None:
if "<|channel|>" in self._buffer or "<|start|>" in self._buffer:
self.strategy = CanonicalStrategy()
elif re.search(
r"(?:^|\s)(?:assistant)?\s*(analysis|commentary|assistantfinal)",
self._buffer,
re.IGNORECASE,
):
self.strategy = TextStrategy()
else:
# Not yet determined, hold
return []
if hasattr(self.strategy, "set_buffer_context"):
# Provide full buffer context to strategy for smarter whitespace handling
self.strategy.set_buffer_context(self._buffer)
events, remaining = self.strategy.parse(self._buffer)
# Check if we should start filtering commentary (after <|call|> token or tool_call event)
buffer_has_call_token = self._buffer.rstrip().endswith("<|call|>")
self._buffer = remaining
# Filter events for streaming case
filtered_events = []
for event in events:
should_filter = False
if event.event_type == "normal":
# Check if we're in a commentary filtering state
if self._should_filter_commentary or self._partial_commentary:
# Try to build partial commentary
potential_commentary = (
self._partial_commentary + event.content.strip().lower()
)
if potential_commentary == "commentary":
# Complete commentary found - filter it
should_filter = True
self._partial_commentary = "" # Reset
self._should_filter_commentary = False # Done filtering
elif "commentary".startswith(potential_commentary):
# Partial match - accumulate and filter this chunk
should_filter = True
self._partial_commentary = potential_commentary
else:
# Not commentary - reset and keep the event
self._partial_commentary = ""
self._should_filter_commentary = False
else:
# Not in commentary filtering state - reset partial state
self._partial_commentary = ""
if should_filter:
# Skip this commentary filler
continue
# Update filtering state based on events and buffer state
if event.event_type == "tool_call":
self._should_filter_commentary = (
True # Filter commentary after tool calls
)
self._partial_commentary = "" # Reset on tool call
elif buffer_has_call_token:
self._should_filter_commentary = (
True # Filter commentary after <|call|> token
)
filtered_events.append(event)
return filtered_events
@@ -0,0 +1,239 @@
"""Template utilities for Jinja template processing.
This module provides utilities for analyzing and processing Jinja chat templates,
including content format detection and message processing.
"""
import logging
import jinja2
import transformers.utils.chat_template_utils as hf_chat_utils
from sglang.srt.utils import ImageData
logger = logging.getLogger(__name__)
# ============================================================================
# JINJA TEMPLATE CONTENT FORMAT DETECTION
# ============================================================================
#
# This adapts vLLM's approach for detecting chat template content format:
# https://github.com/vllm-project/vllm/blob/02f0c7b220422792f5e53de2a7d51d2d3ff2df28/vllm/entrypoints/chat_utils.py#L296-L313
# - Analyzes Jinja template AST to detect content iteration patterns
# - 'openai' format: templates with {%- for content in message['content'] -%} loops
# - 'string' format: templates that expect simple string content
# - Processes content accordingly to match template expectations
def _is_var_access(node: jinja2.nodes.Node, varname: str) -> bool:
"""Check if node is a variable access like {{ varname }}"""
if isinstance(node, jinja2.nodes.Name):
return node.ctx == "load" and node.name == varname
return False
def _is_attr_access(node: jinja2.nodes.Node, varname: str, key: str) -> bool:
"""Check if node is an attribute access like {{ varname['key'] }} or {{ varname.key }}"""
if isinstance(node, jinja2.nodes.Getitem):
return (
_is_var_access(node.node, varname)
and isinstance(node.arg, jinja2.nodes.Const)
and node.arg.value == key
)
if isinstance(node, jinja2.nodes.Getattr):
return _is_var_access(node.node, varname) and node.attr == key
return False
def _is_var_or_elems_access(
node: jinja2.nodes.Node,
varname: str,
key: str = None,
) -> bool:
"""Check if node accesses varname or varname[key] with filters/tests"""
if isinstance(node, jinja2.nodes.Filter):
return node.node is not None and _is_var_or_elems_access(
node.node, varname, key
)
if isinstance(node, jinja2.nodes.Test):
return _is_var_or_elems_access(node.node, varname, key)
if isinstance(node, jinja2.nodes.Getitem) and isinstance(
node.arg, jinja2.nodes.Slice
):
return _is_var_or_elems_access(node.node, varname, key)
return _is_attr_access(node, varname, key) if key else _is_var_access(node, varname)
def _try_extract_ast(chat_template: str):
"""Try to parse the Jinja template into an AST"""
try:
jinja_compiled = hf_chat_utils._compile_jinja_template(chat_template)
return jinja_compiled.environment.parse(chat_template)
except Exception as e:
logger.debug(f"Error when compiling Jinja template: {e}")
return None
def detect_jinja_template_content_format(chat_template: str) -> str:
"""
Detect whether a chat template expects 'string' or 'openai' content format.
- 'string': content is a simple string (like DeepSeek templates)
- 'openai': content is a list of structured dicts (like Llama4 templates)
Detection logic:
- If template has loops like {%- for content in message['content'] -%} → 'openai'
- Otherwise → 'string'
"""
# Shortcut for multimodal templates
if any(
keyword in chat_template for keyword in ["image", "audio", "video", "vision"]
):
return "openai"
jinja_ast = _try_extract_ast(chat_template)
if jinja_ast is None:
return "string"
try:
# Look for patterns like: {%- for content in message['content'] -%}
for loop_ast in jinja_ast.find_all(jinja2.nodes.For):
loop_iter = loop_ast.iter
# Check if iterating over message['content'] or similar
if _is_var_or_elems_access(loop_iter, "message", "content"):
return "openai" # Found content iteration → openai format
# Also check for patterns like: {%- for item in msg.content -%} or {%- for item in m.content -%}
if _is_var_or_elems_access(
loop_iter, "msg", "content"
) or _is_var_or_elems_access(loop_iter, "m", "content"):
return "openai" # Found content iteration → openai format (glm4v)
return "string" # No content loops found → string format
except Exception as e:
logger.debug(f"Error when parsing AST of Jinja template: {e}")
return "string"
def process_content_for_template_format(
msg_dict: dict,
content_format: str,
image_data: list,
video_data: list,
audio_data: list,
modalities: list,
use_dpsk_v32_encoding: bool = False,
) -> dict:
"""
Process message content based on detected template format.
Args:
msg_dict: Message dictionary with content
content_format: 'string' or 'openai' (detected via AST analysis)
image_data: List to append extracted image URLs
video_data: List to append extracted video URLs
audio_data: List to append extracted audio URLs
modalities: List to append modalities
use_dpsk_v32_encoding: If True, extract multimodal data and convert content to string (for DeepSeek-V3.2 encoding)
Returns:
Processed message dictionary
"""
if not isinstance(msg_dict.get("content"), list):
# Already a string or None, no processing needed
return {k: v for k, v in msg_dict.items() if v is not None}
if content_format == "openai" or use_dpsk_v32_encoding:
# OpenAI format: preserve structured content list, normalize types
# V32 encoding: extract multimodal data but convert content to string
processed_content_parts = []
text_parts = []
for chunk in msg_dict["content"]:
if isinstance(chunk, dict):
chunk_type = chunk.get("type")
if chunk_type in ("image_url", "input_image"):
image_obj = chunk.get("image_url") or {}
if isinstance(image_obj, str):
image_obj = {"url": image_obj, "detail": chunk.get("detail")}
mdp = image_obj.get("max_dynamic_patch", None)
# Also allow flat style: chunk["max_dynamic_patch"]
image_data.append(
ImageData(
url=image_obj["url"],
detail=image_obj.get("detail") or "auto",
max_dynamic_patch=mdp,
)
)
if chunk.get("modalities"):
modalities.append(chunk.get("modalities"))
# Normalize to simple 'image' type for template compatibility
processed_content_parts.append({"type": "image"})
elif chunk_type == "video_url":
video_obj = chunk.get("video_url") or {}
mdp = video_obj.get("max_dynamic_patch", None)
if mdp is None:
video_data.append(chunk["video_url"]["url"])
else:
# Keep structured info for backend, but template only sees {"type":"video"}
video_data.append(
{
"url": video_obj["url"],
"max_dynamic_patch": mdp,
}
)
if chunk.get("modalities"):
modalities.append(chunk.get("modalities"))
# Normalize to simple 'video' type for template compatibility
processed_content_parts.append({"type": "video"})
elif chunk_type == "audio_url":
audio_data.append(chunk["audio_url"]["url"])
# Normalize to simple 'audio' type
processed_content_parts.append({"type": "audio"})
elif chunk_type in ("text", "input_text"):
# For v32 encoding, collect text parts separately
if use_dpsk_v32_encoding:
text_parts.append(chunk["text"])
else:
# Keep text content as-is for openai format
processed_content_parts.append(
{"type": "text", "text": chunk["text"]}
)
elif chunk_type == "tool_reference":
# GLM-specific extension: pass through so the chat template
# can match tool_reference.name against tools[*].function.name
# and render the referenced tool schemas inline.
processed_content_parts.append(chunk)
new_msg = {
k: v for k, v in msg_dict.items() if v is not None and k != "content"
}
if use_dpsk_v32_encoding:
new_msg["content"] = " ".join(text_parts) if text_parts else ""
else:
new_msg["content"] = processed_content_parts
return new_msg
elif content_format == "string":
# String format: flatten to text only (for templates like DeepSeek)
text_parts = []
for chunk in msg_dict["content"]:
if isinstance(chunk, dict) and chunk.get("type") in ("text", "input_text"):
text_parts.append(chunk["text"])
# Note: For string format, we ignore images/audio since the template
# doesn't expect structured content - multimodal placeholders would
# need to be inserted differently
new_msg = msg_dict.copy()
new_msg["content"] = " ".join(text_parts) if text_parts else ""
new_msg = {k: v for k, v in new_msg.items() if v is not None}
return new_msg
else:
raise ValueError(f"Invalid content format: {content_format}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,707 @@
# Copyright 2026 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""
Template detection utilities for auto-detecting reasoning and tool-call parsers.
Provides rule-based detection of reasoning mode, reasoning parser, and tool-call
parser from chat templates and tokenizer vocabularies.
"""
import logging
import os
import re
from dataclasses import dataclass
from typing import Callable, Optional, Tuple
import jinja2
import jinja2.ext
import jinja2.sandbox
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class TemplateDetectionContext:
template: str
reasoning_config: Optional["ReasoningToggleConfig"]
force_reasoning: bool
vocab: set[str]
def has_text(self, needle: str) -> bool:
return needle in self.template
def has_vocab(self, token: str) -> bool:
return token in self.vocab
def has_pattern(self, pattern: str, flags: int = 0) -> bool:
return re.search(pattern, self.template, flags) is not None
def has_vocab_pattern(self, pattern: str) -> bool:
compiled = re.compile(pattern)
return any(isinstance(tok, str) and compiled.search(tok) for tok in self.vocab)
@dataclass(frozen=True)
class DetectionRule:
name: str
value: object
predicate: Callable[[TemplateDetectionContext], bool]
@dataclass(frozen=True)
class ReasoningToggleConfig:
toggle_param: Optional[str] = None
default_enabled: Optional[bool] = None
special_case: Optional[str] = None
effort_kwarg: Optional[str] = None
@property
def always_on(self) -> bool:
return self.special_case == "always"
# ---------------------------------------------------------------------------
# Reasoning mode rules (detect toggle config from template)
# ---------------------------------------------------------------------------
REASONING_MODE_RULES = (
DetectionRule(
name="gpt_oss_channel_markers",
value=ReasoningToggleConfig(special_case="always"),
predicate=lambda ctx: ctx.has_text("<|channel|>"),
),
DetectionRule(
name="force_reasoning_pattern",
value=ReasoningToggleConfig(special_case="always"),
predicate=lambda ctx: ctx.has_pattern(r"<\|im_start\|>assistant\\n<think>\\n")
and not ctx.has_text("enable_thinking")
and not ctx.has_text("thinking"),
),
DetectionRule(
name="mistral_reasoning_effort",
value=ReasoningToggleConfig(special_case="mistral"),
predicate=lambda ctx: ctx.has_text("reasoning_effort")
and ctx.has_text("[THINK]"),
),
DetectionRule(
name="explicit_enable_thinking_default_false",
value=ReasoningToggleConfig(
toggle_param="enable_thinking", default_enabled=False
),
predicate=lambda ctx: ctx.has_pattern(
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,
),
),
DetectionRule(
name="nemotron_3_super_low_effort",
value=ReasoningToggleConfig(
toggle_param="enable_thinking",
default_enabled=True,
effort_kwarg="low_effort",
),
predicate=lambda ctx: ctx.has_text("low_effort")
and ctx.has_text("truncate_history_thinking"),
),
DetectionRule(
name="enable_thinking_default_true",
value=ReasoningToggleConfig(
toggle_param="enable_thinking", default_enabled=True
),
predicate=lambda ctx: ctx.has_pattern(
r"{%\s*if\s+not\s+enable_thinking\s+is\s+defined\s*%}.*?"
r"{%\s*set\s+enable_thinking\s*=\s*(?:true|True)\s*%}",
re.DOTALL,
)
or ctx.has_pattern(
r"set\s+enable_thinking\s*=\s*enable_thinking\s+if\s+enable_thinking\s+is\s+defined\s+else\s+(?:true|True)"
)
or ctx.has_pattern(
r"enable_thinking\s+is\s+defined\s+and\s+(?:enable_thinking\s+is\s+false|not\s+enable_thinking)"
)
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"),
),
DetectionRule(
name="explicit_thinking_default_false",
value=ReasoningToggleConfig(toggle_param="thinking", default_enabled=False),
predicate=lambda ctx: ctx.has_pattern(
r"{%\s*if\s+not\s+thinking\s+is\s+defined\s*%}.*?"
r"{%\s*set\s+thinking\s*=\s*(?:false|False)\s*%}",
re.DOTALL,
),
),
DetectionRule(
name="thinking_default_true",
value=ReasoningToggleConfig(toggle_param="thinking", default_enabled=True),
predicate=lambda ctx: ctx.has_pattern(
r"{%\s*if\s+not\s+thinking\s+is\s+defined\s*%}.*?"
r"{%\s*set\s+thinking\s*=\s*(?:true|True)\s*%}",
re.DOTALL,
)
or ctx.has_pattern(
r"set\s+thinking\s*=\s*thinking\s+if\s+thinking\s+is\s+defined\s+else\s+(?:true|True)"
)
or ctx.has_pattern(
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"),
),
)
# ---------------------------------------------------------------------------
# Shared predicates for model-family detection
# ---------------------------------------------------------------------------
def _is_apertus2509(ctx):
return ctx.has_vocab("<|inner_prefix|>")
def _is_gemma4(ctx):
return ctx.has_text("<|channel>")
def _is_kimi(ctx):
return ctx.has_text("◁think▷")
def _is_interns1(ctx):
return ctx.has_text("default_thinking_sys") and ctx.reasoning_config == (
ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True)
)
def _is_mistral(ctx):
return (
ctx.reasoning_config is not None
and ctx.reasoning_config.special_case == "mistral"
)
def _is_gpt_oss(ctx):
return ctx.has_text("<|channel|>")
def _is_kimi_k2(ctx):
return ctx.has_vocab("<|tool_calls_section_begin|>")
def _is_nemotron_3(ctx):
return ctx.has_text("truncate_history_thinking") and (
ctx.reasoning_config is not None
and ctx.reasoning_config.toggle_param == "enable_thinking"
and ctx.reasoning_config.default_enabled is True
)
def _is_glm45(ctx):
return (
(
ctx.has_text("[gMASK]<sop>")
or ctx.has_pattern(r"(?<!<)/nothink")
or ctx.has_pattern(r"(?<!<)/think")
)
and ctx.has_vocab("<tool_call>")
and ctx.reasoning_config
== ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=True)
and (ctx.has_vocab("<|user|>") or ctx.has_vocab("<|endoftext|>"))
)
def _is_glm47(ctx):
return _is_glm45(ctx) and ctx.has_pattern(
r"\{\{[-\s]*['\"]<tool_call>['\"]\s*\+\s*tc\.name"
)
def _is_xml_kv_tool_call(ctx):
# Structural signature for the GLM-4.5 / GLM-4.6 style tool-call format
# (`<tool_call>name<arg_key>k</arg_key>\n<arg_value>v</arg_value>...</tool_call>`).
# Matches any model whose tokenizer carries `<arg_key>` and `<arg_value>` as
# added tokens — e.g., inclusionAI/Ring-2.6, which borrows GLM's tool-call
# format but doesn't share the `[gMASK]<sop>` / `enable_thinking` family
# signature checked by `_is_glm45`.
return ctx.has_vocab("<arg_key>") and ctx.has_vocab("<arg_value>")
def _is_deepseek_v31(ctx):
return ctx.has_text("<tool▁calls▁begin>") and ctx.has_text("<tool▁sep>")
def _is_deepseek_v32(ctx):
return ctx.has_text("<DSMLfunction_calls>")
def _is_deepseek_v4(ctx):
return ctx.has_text("<DSMLtool_calls>")
def _is_hunyuan(ctx):
# The shipping Hy3 tokenizer appends a shared suffix to each special token
# (e.g. ``<tool_calls:opensource>``), so match the bare or suffixed form.
tc = ctx.has_text("<tool_calls>") or ctx.has_vocab_pattern(
r"^<tool_calls(?::[^>]+)?>$"
)
sep = ctx.has_text("<tool_sep>") or ctx.has_vocab_pattern(
r"^<tool_sep(?::[^>]+)?>$"
)
return (tc and sep) or (
ctx.has_text("reasoning_effort") and ctx.has_text("interleaved_thinking")
)
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>")
and ctx.has_text("<arg_value>")
)
return has_poolside_tool_format or (
ctx.reasoning_config
== ReasoningToggleConfig(toggle_param="enable_thinking", default_enabled=False)
and not _is_hunyuan(ctx)
and (ctx.has_text("<arg_key>") or ctx.has_vocab("<arg_key>"))
and (ctx.has_text("<arg_value>") or ctx.has_vocab("<arg_value>"))
)
def _is_mimo(ctx):
return ctx.reasoning_config == ReasoningToggleConfig(
toggle_param="enable_thinking", default_enabled=False
)
def _is_minimax(ctx):
return ctx.has_text("<minimax:tool_call>")
def _is_minimax_m3(ctx):
return ctx.has_text("<mm:think>") or ctx.has_text("]<]minimax[>[")
def _is_minicpm5(ctx):
if ctx.has_vocab("<function") and ctx.has_vocab("<param"):
return True
return ctx.has_pattern(r"<function\s+name=") and ctx.has_pattern(r"<param\s+name=")
def _is_lfm2(ctx):
return (
ctx.has_text("<|tool_call_start|>") or ctx.has_vocab("<|tool_call_start|>")
) and (ctx.has_text("<|tool_call_end|>") or ctx.has_vocab("<|tool_call_end|>"))
def _is_step3p5(ctx):
return ctx.has_pattern(r"Step-?3(?:\.|p)?[57]", re.IGNORECASE) or (
ctx.has_text("reasoning_effort")
and ctx.has_text("Reasoning: ")
and _is_qwen3_coder(ctx)
)
def _is_step3(ctx):
return ctx.has_text("<steptml:invoke") or (
ctx.has_text("<tool_calls_begin>") and ctx.has_text("<tool_sep>")
)
def _is_qwen3_coder(ctx):
return ctx.has_text("<function=") and ctx.has_text("<parameter=")
def _is_qwen3(ctx):
return ctx.reasoning_config == ReasoningToggleConfig(
toggle_param="enable_thinking", default_enabled=True
)
def _is_deepseek_v3(ctx):
return ctx.reasoning_config == ReasoningToggleConfig(
toggle_param="thinking", default_enabled=False
)
def _is_deepseek_r1(ctx):
return ctx.force_reasoning
def _is_deepseek_r1_think_tags(ctx):
return not _is_lfm2(ctx) and (ctx.has_text("<think>") or ctx.has_text("</think>"))
# ---------------------------------------------------------------------------
# Reasoning parser rules
# ---------------------------------------------------------------------------
REASONING_PARSER_RULES = (
DetectionRule(name="apertus2509", value="apertus2509", predicate=_is_apertus2509),
DetectionRule(name="gemma4", value="gemma4", predicate=_is_gemma4),
DetectionRule(name="kimi", value="kimi", predicate=_is_kimi),
DetectionRule(name="interns1", value="interns1", predicate=_is_interns1),
DetectionRule(name="mistral", value="mistral", predicate=_is_mistral),
DetectionRule(name="gpt_oss", value="gpt-oss", predicate=_is_gpt_oss),
DetectionRule(name="kimi_k2", value="kimi_k2", predicate=_is_kimi_k2),
DetectionRule(name="nemotron_3", value="nemotron_3", predicate=_is_nemotron_3),
DetectionRule(name="glm45", value="glm45", predicate=_is_glm45),
DetectionRule(name="hunyuan", value="hunyuan", predicate=_is_hunyuan),
DetectionRule(name="poolside_v1", value="poolside_v1", predicate=_is_poolside_v1),
DetectionRule(name="mimo", value="mimo", predicate=_is_mimo),
DetectionRule(name="minimax_m3", value="minimax-m3", predicate=_is_minimax_m3),
DetectionRule(name="minimax", value="minimax", predicate=_is_minimax),
DetectionRule(name="step3p5", value="step3p5", predicate=_is_step3p5),
DetectionRule(name="step3", value="step3", predicate=_is_step3),
DetectionRule(name="qwen3", value="qwen3", predicate=_is_qwen3),
DetectionRule(name="deepseek_v4", value="deepseek-v4", predicate=_is_deepseek_v4),
DetectionRule(name="deepseek_v3", value="deepseek-v3", predicate=_is_deepseek_v3),
DetectionRule(
name="deepseek_r1_force", value="deepseek-r1", predicate=_is_deepseek_r1
),
DetectionRule(
name="deepseek_r1_think_tags",
value="deepseek-r1",
predicate=_is_deepseek_r1_think_tags,
),
)
# ---------------------------------------------------------------------------
# Tool-call parser rules (reuse shared predicates, different values)
# ---------------------------------------------------------------------------
TOOL_CALL_PARSER_RULES = (
DetectionRule(name="apertus2509", value="apertus2509", predicate=_is_apertus2509),
DetectionRule(name="gemma4", value="gemma4", predicate=_is_gemma4),
DetectionRule(name="gpt_oss", value="gpt-oss", predicate=_is_gpt_oss),
DetectionRule(name="kimi_k2", value="kimi_k2", predicate=_is_kimi_k2),
DetectionRule(name="minimax_m3", value="minimax-m3", predicate=_is_minimax_m3),
DetectionRule(name="minimax", value="minimax-m2", predicate=_is_minimax),
DetectionRule(name="interns1", value="interns1", predicate=_is_interns1),
DetectionRule(name="mistral", value="mistral", predicate=_is_mistral),
DetectionRule(name="deepseek_v4", value="deepseekv4", predicate=_is_deepseek_v4),
DetectionRule(name="deepseek_v32", value="deepseekv32", predicate=_is_deepseek_v32),
DetectionRule(name="deepseek_v31", value="deepseekv31", predicate=_is_deepseek_v31),
DetectionRule(name="lfm2", value="lfm2", predicate=_is_lfm2),
DetectionRule(name="glm47", value="glm47", predicate=_is_glm47),
DetectionRule(name="glm45", value="glm45", predicate=_is_glm45),
DetectionRule(name="minicpm5", value="minicpm5", predicate=_is_minicpm5),
DetectionRule(name="hunyuan", value="hunyuan", predicate=_is_hunyuan),
DetectionRule(name="poolside_v1", value="poolside_v1", predicate=_is_poolside_v1),
DetectionRule(name="step3p5", value="step3p5", predicate=_is_step3p5),
DetectionRule(name="step3", value="step3", predicate=_is_step3),
DetectionRule(
name="xml_kv_tool_call", value="glm45", predicate=_is_xml_kv_tool_call
),
DetectionRule(name="mimo", value="mimo", predicate=_is_mimo),
DetectionRule(name="qwen3_coder", value="qwen3_coder", predicate=_is_qwen3_coder),
DetectionRule(name="qwen", value="qwen", predicate=_is_qwen3),
DetectionRule(name="deepseek_v3", value="deepseekv3", predicate=_is_deepseek_v3),
DetectionRule(name="deepseek_r1", value="deepseekv3", predicate=_is_deepseek_r1),
)
# ---------------------------------------------------------------------------
# Detection functions
# ---------------------------------------------------------------------------
def build_detection_context(
template: Optional[str],
tokenizer,
reasoning_config: Optional[ReasoningToggleConfig] = None,
force_reasoning: bool = False,
) -> Optional[TemplateDetectionContext]:
if template is None:
return None
vocab = set()
if tokenizer is not None:
try:
vocab = set(tokenizer.get_vocab().keys())
except Exception as e:
logger.warning(
"Failed to load tokenizer vocab for template detection: %s. "
"Vocab-dependent detection rules will be skipped.",
e,
)
return TemplateDetectionContext(
template=template,
reasoning_config=reasoning_config,
force_reasoning=force_reasoning,
vocab=vocab,
)
def match_rules(
ctx: TemplateDetectionContext,
rules: Tuple[DetectionRule, ...],
label: str,
) -> Optional[str]:
for rule in rules:
try:
if rule.predicate(ctx):
return rule.value
except Exception as e:
logger.warning(
"Detection rule '%s' for %s raised an exception: %s. Skipping.",
rule.name,
label,
e,
exc_info=True,
)
return None
def detect_reasoning_pattern(
template: Optional[str],
) -> Tuple[bool, Optional[ReasoningToggleConfig]]:
"""Detect if the chat template contains reasoning/thinking patterns."""
if template is None:
return False, None
ctx = TemplateDetectionContext(
template=template,
reasoning_config=None,
force_reasoning=False,
vocab=set(),
)
for rule in REASONING_MODE_RULES:
if rule.predicate(ctx):
return rule.value.always_on, rule.value
return False, None
def detect_reasoning_parser(
template: Optional[str],
tokenizer,
reasoning_config: Optional[ReasoningToggleConfig] = None,
force_reasoning: bool = False,
) -> Optional[str]:
"""Auto-detect which reasoning parser to use from the chat template."""
ctx = build_detection_context(
template, tokenizer, reasoning_config, force_reasoning
)
if ctx is None:
return None
return match_rules(ctx, REASONING_PARSER_RULES, "reasoning parser")
def detect_tool_call_parser(
template: Optional[str],
tokenizer,
reasoning_config: Optional[ReasoningToggleConfig] = None,
force_reasoning: bool = False,
) -> Optional[str]:
"""Auto-detect which tool-call parser to use from the chat template."""
ctx = build_detection_context(
template, tokenizer, reasoning_config, force_reasoning
)
if ctx is None:
return None
return match_rules(ctx, TOOL_CALL_PARSER_RULES, "tool-call parser")
def detect_inline_system_support(chat_template: Optional[str]) -> bool:
"""True if mid-conversation ``role: "system"`` renders inline; False if the
template raises or silently drops it (then merge into the leading block).
The probe requires the second system's sentinel to appear in the output —
not raising isn't enough, since some templates ignore non-leading system."""
if not chat_template:
return False
sentinel = "__sglang_inline_system_sentinel__"
try:
env = jinja2.sandbox.ImmutableSandboxedEnvironment(
trim_blocks=True,
lstrip_blocks=True,
extensions=[jinja2.ext.loopcontrols],
)
rendered = env.from_string(chat_template).render(
messages=[
{"role": "system", "content": "t"},
{"role": "user", "content": "t"},
{"role": "system", "content": sentinel},
{"role": "user", "content": "t"},
],
add_generation_prompt=False,
)
return sentinel in rendered
except jinja2.TemplateError:
return False
except Exception:
return False
def _resolve_auto_parser(
server_args,
attr: str,
ctx: TemplateDetectionContext,
rules: Tuple[DetectionRule, ...],
label: str,
) -> None:
"""Resolve a single auto parser, updating server_args in place."""
detected = match_rules(ctx, rules, label)
if detected:
server_args.override(source="template-detection", **{attr: detected})
logger.info(
f"Auto-detected --{attr.replace('_', '-')} as '{detected}' from chat template"
)
else:
logger.warning(
f"--{attr.replace('_', '-')}=auto specified but could not detect "
f"{label} from chat template. Disabling {label}."
)
server_args.override(source="template-detection", **{attr: None})
def _load_explicit_jinja_template(chat_template_arg: Optional[str]) -> Optional[str]:
if not chat_template_arg or not isinstance(chat_template_arg, str):
return None
if not chat_template_arg.endswith(".jinja") or not os.path.exists(
chat_template_arg
):
return None
with open(chat_template_arg, encoding="utf-8") as f:
return f.read().replace("\\n", "\n")
def _disable_auto_parser(server_args, attr: str, label: str) -> None:
logger.warning(
f"--{attr.replace('_', '-')}=auto specified but could not detect "
f"{label} from chat template. Disabling {label}."
)
server_args.override(source="template-detection", **{attr: None})
def _resolve_architecture_auto_parsers(server_args) -> None:
from sglang.srt.utils.hf_transformers_utils import get_config
config = get_config(
server_args.model_path,
trust_remote_code=server_args.trust_remote_code,
revision=getattr(server_args, "revision", None),
model_config_parser=getattr(server_args, "model_config_parser", "auto"),
)
architectures = getattr(config, "architectures", None) or []
arch = architectures[0] if architectures else ""
if "DeepseekV4" in arch:
reasoning_parser, tool_call_parser = "deepseek-v4", "deepseekv4"
elif "DeepseekV3" in arch:
reasoning_parser, tool_call_parser = "deepseek-v3", "deepseekv32"
else:
return
for attr, detected in (
("reasoning_parser", reasoning_parser),
("tool_call_parser", tool_call_parser),
):
if getattr(server_args, attr) == "auto":
server_args.override(source="template-detection", **{attr: detected})
logger.info(
f"Auto-detected --{attr.replace('_', '-')} as '{detected}' "
f"from model architecture '{arch}'"
)
def resolve_auto_parsers(server_args) -> None:
"""Resolve --reasoning-parser=auto and --tool-call-parser=auto before scheduler.
This performs a lightweight tokenizer load to detect parsers from the chat
template. Called early in engine init before scheduler subprocesses are spawned.
"""
needs_reasoning = server_args.reasoning_parser == "auto"
needs_tool_call = server_args.tool_call_parser == "auto"
if not needs_reasoning and not needs_tool_call:
return
from sglang.srt.utils.hf_transformers_utils import get_tokenizer
chat_template_arg = getattr(server_args, "chat_template", None)
try:
explicit_jinja_template = _load_explicit_jinja_template(chat_template_arg)
except Exception as e:
logger.warning("Failed to load explicit Jinja chat template: %s", e)
explicit_jinja_template = None
has_explicit_template_without_detection = (
chat_template_arg is not None and explicit_jinja_template is None
)
tokenizer = None
try:
tokenizer = get_tokenizer(
server_args.model_path,
trust_remote_code=server_args.trust_remote_code,
)
except Exception as e:
logger.warning(f"Failed to load tokenizer for auto-detection: {e}")
template = explicit_jinja_template
if template is None and tokenizer is not None:
template = getattr(tokenizer, "chat_template", None)
force_reasoning, reasoning_config = detect_reasoning_pattern(template)
ctx = build_detection_context(
template, tokenizer, reasoning_config, force_reasoning
)
if ctx is None:
if has_explicit_template_without_detection:
logger.warning(
"--chat-template=%s is explicit but is not a readable Jinja template, so "
"parser auto-detection from chat template is not available.",
chat_template_arg,
)
else:
try:
_resolve_architecture_auto_parsers(server_args)
except Exception as e:
logger.warning(
"Failed to load model config for architecture-based auto-detection: %s",
e,
)
if needs_reasoning:
if server_args.reasoning_parser == "auto":
_disable_auto_parser(
server_args, "reasoning_parser", "reasoning parser"
)
if needs_tool_call:
if server_args.tool_call_parser == "auto":
_disable_auto_parser(
server_args, "tool_call_parser", "tool-call parser"
)
return
if needs_reasoning:
_resolve_auto_parser(
server_args,
"reasoning_parser",
ctx,
REASONING_PARSER_RULES,
"reasoning parser",
)
if needs_tool_call:
_resolve_auto_parser(
server_args,
"tool_call_parser",
ctx,
TOOL_CALL_PARSER_RULES,
"tool-call parser",
)
@@ -0,0 +1,388 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""
Centralized template management for chat templates and completion templates.
This module provides a unified interface for managing both chat conversation templates
and code completion templates, eliminating global state and improving modularity.
"""
import json
import logging
import os
from typing import Dict, Optional
from sglang.srt.managers.tokenizer_manager import TokenizerManager
from sglang.srt.parser.code_completion_parser import (
CompletionTemplate,
FimPosition,
completion_template_exists,
register_completion_template,
set_completion_template,
)
from sglang.srt.parser.conversation import (
Conversation,
SeparatorStyle,
chat_template_exists,
get_conv_template_by_model_path,
register_conv_template,
)
from sglang.srt.parser.jinja_template_utils import detect_jinja_template_content_format
from sglang.srt.parser.template_detection import (
REASONING_PARSER_RULES,
TOOL_CALL_PARSER_RULES,
ReasoningToggleConfig,
build_detection_context,
detect_reasoning_pattern,
match_rules,
)
logger = logging.getLogger(__name__)
class TemplateManager:
"""
Centralized manager for chat and completion templates.
This class encapsulates all template-related state and operations,
eliminating the need for global variables and providing a clean
interface for template management.
"""
def __init__(self):
self._chat_template_name: Optional[str] = None
self._completion_template_name: Optional[str] = None
self._jinja_template_content_format: Optional[str] = "openai"
self._force_reasoning: bool = False
self._reasoning_config: Optional[ReasoningToggleConfig] = None
self._suggested_reasoning_parser: Optional[str] = None
self._suggested_tool_call_parser: Optional[str] = None
@property
def chat_template_name(self) -> Optional[str]:
"""Get the current chat template name."""
return self._chat_template_name
@property
def completion_template_name(self) -> Optional[str]:
"""Get the current completion template name."""
return self._completion_template_name
@property
def jinja_template_content_format(self) -> Optional[str]:
"""Get the detected template content format ('string' or 'openai' or None)."""
return self._jinja_template_content_format
@property
def force_reasoning(self) -> bool:
"""
Check if the current chat template enforces reasoning/thinking.
Returns:
True if the template contains reasoning patterns like <think> tags
"""
return self._force_reasoning
@property
def reasoning_config(self) -> Optional[ReasoningToggleConfig]:
"""Get the reasoning toggle config inferred from chat template."""
return self._reasoning_config
@property
def suggested_reasoning_parser(self) -> Optional[str]:
"""Get the auto-detected reasoning parser name, or None."""
return self._suggested_reasoning_parser
@property
def suggested_tool_call_parser(self) -> Optional[str]:
"""Get the auto-detected tool-call parser name, or None."""
return self._suggested_tool_call_parser
def _run_template_detection(self, template, tokenizer) -> None:
"""Run reasoning pattern and parser detection on a template."""
self._force_reasoning, self._reasoning_config = detect_reasoning_pattern(
template
)
# Build context once, reuse for both parser detections (avoids
# duplicate tokenizer.get_vocab() calls).
ctx = build_detection_context(
template, tokenizer, self._reasoning_config, self._force_reasoning
)
if ctx is None:
return
self._suggested_reasoning_parser = match_rules(
ctx, REASONING_PARSER_RULES, "reasoning parser"
)
self._suggested_tool_call_parser = match_rules(
ctx, TOOL_CALL_PARSER_RULES, "tool-call parser"
)
def load_chat_template(
self,
tokenizer_manager: TokenizerManager,
chat_template_arg: Optional[str],
model_path: str,
) -> None:
"""
Load a chat template from various sources.
Args:
tokenizer_manager: The tokenizer manager instance
chat_template_arg: Template name, file path, or None to auto-detect
model_path: Path to the model
"""
if chat_template_arg:
self._load_explicit_chat_template(tokenizer_manager, chat_template_arg)
else:
# Guess chat template from model path
self.guess_chat_template_from_model_path(model_path)
# If no pre-defined template was found, fallback to HuggingFace template
if self._chat_template_name is None:
# Try HuggingFace template first
hf_template = self._resolve_hf_chat_template(tokenizer_manager)
if hf_template:
# override the chat template
if tokenizer_manager.tokenizer:
tokenizer_manager.tokenizer.chat_template = hf_template
self._jinja_template_content_format = (
detect_jinja_template_content_format(hf_template)
)
logger.info(
f"Using default HuggingFace chat template with detected content format: {self._jinja_template_content_format}"
)
else:
# Default to string content format if no template was found
self._jinja_template_content_format = "string"
logger.info(
"No chat template found, defaulting to 'string' content format"
)
# Detect reasoning pattern and suggest parser from chat template
if tokenizer_manager.tokenizer:
template = tokenizer_manager.tokenizer.chat_template
self._run_template_detection(template, tokenizer_manager.tokenizer)
parts = []
if self._reasoning_config:
parts.append(f"reasoning_config={self._reasoning_config}")
if self._suggested_reasoning_parser:
parts.append(f"reasoning_parser={self._suggested_reasoning_parser}")
if self._suggested_tool_call_parser:
parts.append(f"tool_call_parser={self._suggested_tool_call_parser}")
if parts:
logger.info(f"Auto-detected template features: {', '.join(parts)}")
def _load_explicit_chat_template(
self, tokenizer_manager: TokenizerManager, chat_template_arg: str
) -> None:
"""Load explicitly specified chat template."""
logger.info(f"Loading chat template from argument: {chat_template_arg}")
if chat_template_exists(chat_template_arg):
self._chat_template_name = chat_template_arg
return
if not os.path.exists(chat_template_arg):
raise RuntimeError(
f"Chat template {chat_template_arg} is not a built-in template name "
"or a valid chat template file path."
)
if chat_template_arg.endswith(".jinja"):
self._load_jinja_template(tokenizer_manager, chat_template_arg)
else:
self._load_json_chat_template(chat_template_arg)
def guess_chat_template_from_model_path(self, model_path: str) -> None:
"""
Infer chat template name from model path.
Args:
model_path: Path to the model
"""
template_name = get_conv_template_by_model_path(model_path)
if template_name is not None:
logger.info(f"Inferred chat template from model path: {template_name}")
self._chat_template_name = template_name
def load_completion_template(self, completion_template_arg: str) -> None:
"""
Load completion template for code completion.
Args:
completion_template_arg: Template name or file path
"""
logger.info(f"Loading completion template: {completion_template_arg}")
if not completion_template_exists(completion_template_arg):
if not os.path.exists(completion_template_arg):
raise RuntimeError(
f"Completion template {completion_template_arg} is not a built-in template name "
"or a valid completion template file path."
)
self._load_json_completion_template(completion_template_arg)
else:
self._completion_template_name = completion_template_arg
set_completion_template(self._completion_template_name)
def initialize_templates(
self,
tokenizer_manager: TokenizerManager,
model_path: str,
chat_template: Optional[str] = None,
completion_template: Optional[str] = None,
) -> None:
"""
Initialize all templates based on provided configuration.
Args:
tokenizer_manager: The tokenizer manager instance
model_path: Path to the model
chat_template: Optional chat template name/path
completion_template: Optional completion template name/path
"""
# Load chat template
self.load_chat_template(tokenizer_manager, chat_template, model_path)
# Load completion template
if completion_template:
self.load_completion_template(completion_template)
def _load_jinja_template(
self, tokenizer_manager: TokenizerManager, template_path: str
) -> None:
"""Load a Jinja template file."""
with open(template_path, "r") as f:
chat_template = "".join(f.readlines()).strip("\n")
tokenizer_manager.tokenizer.chat_template = chat_template.replace("\\n", "\n")
self._chat_template_name = None
# Detect content format from the loaded template
self._jinja_template_content_format = detect_jinja_template_content_format(
chat_template
)
logger.info(
f"Detected user specified Jinja chat template with content format: {self._jinja_template_content_format}"
)
def _load_json_chat_template(self, template_path: str) -> None:
"""Load a JSON chat template file."""
assert template_path.endswith(
".json"
), "unrecognized format of chat template file"
with open(template_path, "r") as filep:
template = json.load(filep)
try:
sep_style = SeparatorStyle[template["sep_style"]]
except KeyError:
raise ValueError(
f"Unknown separator style: {template['sep_style']}"
) from None
register_conv_template(
Conversation(
name=template["name"],
system_template=template["system"] + "\n{system_message}",
system_message=template.get("system_message", ""),
roles=(template["user"], template["assistant"]),
sep_style=sep_style,
sep=template.get("sep", "\n"),
stop_str=template["stop_str"],
),
override=True,
)
self._chat_template_name = template["name"]
def _load_json_completion_template(self, template_path: str) -> None:
"""Load a JSON completion template file."""
assert template_path.endswith(
".json"
), "unrecognized format of completion template file"
with open(template_path, "r") as filep:
template = json.load(filep)
try:
fim_position = FimPosition[template["fim_position"]]
except KeyError:
raise ValueError(
f"Unknown fim position: {template['fim_position']}"
) from None
register_completion_template(
CompletionTemplate(
name=template["name"],
fim_begin_token=template["fim_begin_token"],
fim_middle_token=template["fim_middle_token"],
fim_end_token=template["fim_end_token"],
fim_position=fim_position,
),
override=True,
)
self._completion_template_name = template["name"]
def _resolve_hf_chat_template(
self, tokenizer_manager: TokenizerManager
) -> Optional[str]:
try:
# Try (mm-)processor first, then tokenizer
template = (
getattr(tokenizer_manager.processor, "chat_template", None)
if tokenizer_manager.processor
else None
) or (
getattr(tokenizer_manager.tokenizer, "chat_template", None)
if tokenizer_manager.tokenizer
else None
)
if template is None:
logger.warning("No HuggingFace chat template found")
return None
# Handle dict templates (multiple named templates)
if isinstance(template, dict):
return self._select_named_template(template, tokenizer_manager)
# Single string template
return template
except Exception as e:
logger.warning(f"Error getting chat template: {e}")
return None
def _select_named_template(
self, templates: Dict[str, str], tokenizer_manager: TokenizerManager
) -> str:
if not templates:
raise ValueError("Empty templates dict provided")
available_names = list(templates.keys())
logger.info(f"Multiple HuggingFace chat templates available: {available_names}")
# Use specified template if provided
if preferred_name := tokenizer_manager.server_args.hf_chat_template_name:
if preferred_name not in templates:
raise ValueError(
f"Specified template '{preferred_name}' not found. "
f"Available templates: {available_names}"
)
logger.info(f"Using specified chat template: '{preferred_name}'")
return templates[preferred_name]
# Fallback: Use first available template
first_name = available_names[0]
logger.info(f"Using first available template: '{first_name}'")
return templates[first_name]