Move parsers under a single folder (#9912)

This commit is contained in:
Lianmin Zheng
2025-09-02 18:25:04 -07:00
committed by GitHub
parent 369b143366
commit 60e37f8028
28 changed files with 31 additions and 30 deletions

View File

@@ -0,0 +1,132 @@
# 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 auto
from sglang.srt.entrypoints.openai.protocol import CompletionRequest
logger = logging.getLogger(__name__)
completion_template_name = None
class FimPosition:
"""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 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

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

View File

@@ -0,0 +1,197 @@
"""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'
"""
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,
) -> 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
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":
# OpenAI format: preserve structured content list, normalize types
processed_content_parts = []
for chunk in msg_dict["content"]:
if isinstance(chunk, dict):
chunk_type = chunk.get("type")
if chunk_type == "image_url":
image_data.append(
ImageData(
url=chunk["image_url"]["url"],
detail=chunk["image_url"].get("detail", "auto"),
)
)
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_data.append(chunk["video_url"]["url"])
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"})
else:
# Keep other content as-is (text, etc.)
processed_content_parts.append(chunk)
new_msg = {
k: v for k, v in msg_dict.items() if v is not None and k != "content"
}
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") == "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}")

View File

@@ -0,0 +1,309 @@
import re
from typing import Dict, Optional, Tuple, Type
from sglang.srt.parser.harmony_parser import HarmonyParser
class StreamingParseResult:
"""Result of streaming incremental parsing."""
def __init__(
self,
normal_text: Optional[str] = None,
reasoning_text: Optional[str] = None,
):
self.normal_text = normal_text or ""
self.reasoning_text = reasoning_text or ""
class BaseReasoningFormatDetector:
"""Base class providing two sets of interfaces: one-time and streaming incremental."""
def __init__(
self,
think_start_token: str,
think_end_token: str,
force_reasoning: bool = False,
stream_reasoning: bool = True,
):
self.think_start_token = think_start_token
self.think_end_token = think_end_token
self._in_reasoning = force_reasoning
self.stream_reasoning = stream_reasoning
self._buffer = ""
self.stripped_think_start = False
def detect_and_parse(self, text: str) -> StreamingParseResult:
"""
One-time parsing: Detects and parses reasoning sections in the provided text.
Returns both reasoning content and normal text separately.
"""
in_reasoning = self._in_reasoning or self.think_start_token in text
if not in_reasoning:
return StreamingParseResult(normal_text=text)
# The text is considered to be in a reasoning block.
processed_text = text.replace(self.think_start_token, "").strip()
if self.think_end_token not in processed_text:
# Assume reasoning was truncated before `</think>` token
return StreamingParseResult(reasoning_text=processed_text)
# Extract reasoning content
splits = processed_text.split(self.think_end_token, maxsplit=1)
reasoning_text = splits[0]
normal_text = splits[1].strip()
return StreamingParseResult(
normal_text=normal_text, reasoning_text=reasoning_text
)
def parse_streaming_increment(self, new_text: str) -> StreamingParseResult:
"""
Streaming incremental parsing for reasoning content.
Handles partial reasoning tags and content.
If stream_reasoning is False:
Accumulates reasoning content until the end tag is found
If stream_reasoning is True:
Streams reasoning content as it arrives
"""
self._buffer += new_text
current_text = self._buffer
# If the current text is a prefix of the think token, keep buffering
if any(
token.startswith(current_text) and token != current_text
for token in [self.think_start_token, self.think_end_token]
):
return StreamingParseResult()
# Strip `<think>` token if present
if not self.stripped_think_start and self.think_start_token in current_text:
current_text = current_text.replace(self.think_start_token, "")
self.stripped_think_start = True
self._in_reasoning = True
# Handle end of reasoning block
if self._in_reasoning and self.think_end_token in current_text:
end_idx = current_text.find(self.think_end_token)
reasoning_text = current_text[:end_idx]
self._buffer = ""
self._in_reasoning = False
normal_text = current_text[end_idx + len(self.think_end_token) :]
return StreamingParseResult(
normal_text=normal_text, reasoning_text=reasoning_text.rstrip()
)
# Continue with reasoning content
if self._in_reasoning:
if self.stream_reasoning:
# Stream the content immediately
self._buffer = ""
return StreamingParseResult(reasoning_text=current_text)
else:
return StreamingParseResult()
# If we're not in a reasoning block return as normal text
if not self._in_reasoning:
self._buffer = ""
return StreamingParseResult(normal_text=current_text)
return StreamingParseResult()
class DeepSeekR1Detector(BaseReasoningFormatDetector):
"""
Detector for DeepSeek-R1 model.
Assumes reasoning format:
(<think>)*(.*)</think>
Returns all the text before the </think> tag as `reasoning_text`
and the rest of the text as `normal_text`.
Supported models:
- DeepSeek-R1: Always generates thinking content without <think> start tag
- DeepSeek-R1-0528: Generates thinking content with <think> start tag
Format patterns:
- DeepSeek-R1: "I need to think about this...</think>The answer is 42."
- DeepSeek-R1-0528: "<think>I need to think about this...</think>The answer is 42."
Args:
stream_reasoning (bool): If False, accumulates reasoning content until the end tag.
If True, streams reasoning content as it arrives.
"""
def __init__(self, stream_reasoning: bool = True, force_reasoning: bool = True):
# DeepSeek-R1 is assumed to be reasoning until `</think>` token
super().__init__(
"<think>",
"</think>",
force_reasoning=True,
stream_reasoning=stream_reasoning,
)
# https://github.com/sgl-project/sglang/pull/3202#discussion_r1950153599
class Qwen3Detector(BaseReasoningFormatDetector):
"""
Detector for Qwen3 models (e.g., Qwen/Qwen3-235B-A22B).
Assumes reasoning format:
(<think>)*(.*)</think>
Qwen3 models released before 07/2025 supports switching between thinking mode and normal
mode using `enable_thinking` parameter in the request parameter.
- enable_thinking=True: "<think>reasoning content</think>The answer is 42."
- enable_thinking=False: "The answer is 42." (no thinking tokens)
Args:
stream_reasoning (bool): If False, accumulates reasoning content until the end tag.
If True, streams reasoning content as it arrives.
"""
def __init__(self, stream_reasoning: bool = True, force_reasoning: bool = False):
super().__init__(
"<think>",
"</think>",
force_reasoning=force_reasoning,
stream_reasoning=stream_reasoning,
)
class KimiDetector(BaseReasoningFormatDetector):
"""
Detector for Kimi Thinking model.
Assumes reasoning format:
◁think▷*(.*)◁/think▷
Returns all the text before the ◁/think▷ tag as `reasoning_text`
and the rest of the text as `normal_text`.
"""
def __init__(self, stream_reasoning: bool = True, force_reasoning: bool = False):
super().__init__(
"◁think▷",
"◁/think▷",
force_reasoning=False,
stream_reasoning=stream_reasoning,
)
class GptOssDetector(BaseReasoningFormatDetector):
"""
Detector for T4-style reasoning format (GPT-OSS), using the HarmonyParser.
"""
def __init__(self, stream_reasoning: bool = True, force_reasoning: bool = True):
super().__init__(
"<|channel|>analysis<|message|>",
"<|end|>",
force_reasoning=force_reasoning,
stream_reasoning=stream_reasoning,
)
self.parser = HarmonyParser()
def detect_and_parse(self, text: str) -> StreamingParseResult:
events = self.parser.parse(text)
# Flush the buffer for one-shot parsing
events += self.parser.parse("")
reasoning_text = "".join(
[e.content for e in events if e.event_type == "reasoning"]
)
normal_parts = []
for e in events:
if e.event_type == "normal":
normal_parts.append(e.content)
elif e.event_type == "tool_call":
# Use raw_text to preserve structural markers for function call detector
normal_parts.append(e.raw_text if e.raw_text else e.content)
normal_text = "".join(normal_parts)
# Tool call events preserve raw text with structural markers
return StreamingParseResult(
normal_text=normal_text,
reasoning_text=reasoning_text,
)
def parse_streaming_increment(self, new_text: str) -> StreamingParseResult:
events = self.parser.parse(new_text)
reasoning_text = "".join(
[e.content for e in events if e.event_type == "reasoning"]
)
normal_parts = []
for e in events:
if e.event_type == "normal":
normal_parts.append(e.content)
elif e.event_type == "tool_call":
# Use raw_text to preserve structural markers for function call detector
normal_parts.append(e.raw_text if e.raw_text else e.content)
normal_text = "".join(normal_parts)
return StreamingParseResult(
normal_text=normal_text,
reasoning_text=reasoning_text,
)
class ReasoningParser:
"""
Parser that handles both streaming and non-streaming scenarios for extracting
reasoning content from model outputs.
Args:
model_type (str): Type of model to parse reasoning from
stream_reasoning (bool): If False, accumulates reasoning content until complete.
If True, streams reasoning content as it arrives.
"""
DetectorMap: Dict[str, Type[BaseReasoningFormatDetector]] = {
"deepseek-r1": DeepSeekR1Detector,
"deepseek-v3": Qwen3Detector,
"glm45": Qwen3Detector,
"gpt-oss": GptOssDetector,
"kimi": KimiDetector,
"qwen3": Qwen3Detector,
"qwen3-thinking": Qwen3Detector,
"step3": DeepSeekR1Detector,
}
def __init__(
self,
model_type: Optional[str] = None,
stream_reasoning: bool = True,
force_reasoning: Optional[bool] = None,
):
if not model_type:
raise ValueError("Model type must be specified")
detector_class = self.DetectorMap.get(model_type.lower())
if not detector_class:
raise ValueError(f"Unsupported model type: {model_type}")
# Special cases where we override force_reasoning
if model_type.lower() in {"qwen3-thinking", "gpt-oss"}:
force_reasoning = True
# Only pass force_reasoning if explicitly set, let detectors use their defaults
kwargs = {"stream_reasoning": stream_reasoning}
if force_reasoning is not None:
kwargs["force_reasoning"] = force_reasoning
self.detector = detector_class(**kwargs)
def parse_non_stream(self, full_text: str) -> Tuple[Optional[str], Optional[str]]:
"""Non-streaming call: one-time parsing"""
ret = self.detector.detect_and_parse(full_text)
return ret.reasoning_text, ret.normal_text
def parse_stream_chunk(
self, chunk_text: str
) -> Tuple[Optional[str], Optional[str]]:
"""Streaming call: incremental parsing"""
ret = self.detector.parse_streaming_increment(chunk_text)
return ret.reasoning_text, ret.normal_text