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,267 @@
import json
import logging
from typing import Any, List, Optional, Tuple
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
class Apertus2509Detector(BaseFormatDetector):
"""
Detector for Apertus 2509 tool/function call format
```
<|tools_prefix|>[{"tool1": {...}}, {"tool2": {...}}]<|tools_suffix|>
```
Notes:
- Each list element is a single-key object: {"<tool_name>": <arguments_object>}
- The list can contain multiple tool calls separated by ", "
- This is distinct from the OpenAI-style {"name": "...", "arguments": {...}} objects
"""
def __init__(self):
super().__init__()
self.bot = "<|tools_prefix|>["
self.suffix = "<|tools_suffix|>"
self._in_tools_block: bool = False
def has_tool_call(self, text: str) -> bool:
return self.bot in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Extract all Apertus tools blocks and parse their JSON payloads.
"""
if not self.has_tool_call(text):
return StreamingParseResult(normal_text=text, calls=[])
calls: List[ToolCallItem] = []
normal_parts: List[str] = []
cursor = 0
while True:
if (start := text.find(self.bot, cursor)) == -1:
normal_parts.append(text[cursor:])
break
normal_parts.append(text[cursor:start])
tool_part = text[start:]
parsed_arr, json_end = self._try_parse_json_array(tool_part)
if parsed_arr is None:
normal_parts.append(tool_part)
break
if (suffix_pos := tool_part.find(self.suffix, json_end)) == -1:
normal_parts.append(tool_part)
break
calls.extend(
self._parse_apertus_call_list(
parsed_arr, tools, tool_index_offset=len(calls)
)
)
cursor = start + suffix_pos + len(self.suffix)
return StreamingParseResult(
normal_text="".join(normal_parts).strip(), calls=calls
)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing for Apertus tool calls.
- Streams any normal text before `<|tools_prefix|>[` immediately.
- Buffers tool calls until we have a complete tools block, then emits:
- Tool name (empty args), then
- Full JSON arguments string
"""
self._buffer += new_text
out_normal = ""
out_calls: List[ToolCallItem] = []
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
while True:
if not self._in_tools_block:
if (pos := self._buffer.find(self.bot)) > 0:
out_normal += self._buffer[:pos]
self._buffer = self._buffer[pos:]
elif pos == -1:
if partial_bot := self._ends_with_partial_token(
self._buffer, self.bot
):
out_normal += self._buffer[:-partial_bot]
self._buffer = self._buffer[-partial_bot:]
else:
out_normal += self._buffer
self._buffer = ""
return StreamingParseResult(normal_text=out_normal, calls=out_calls)
self._in_tools_block = True
if not self._buffer.startswith(self.bot):
if (marker_pos := self._buffer.find(self.bot)) == -1:
out_normal += self._buffer
self._buffer = ""
self._in_tools_block = False
return StreamingParseResult(normal_text=out_normal, calls=out_calls)
out_normal += self._buffer[:marker_pos]
self._buffer = self._buffer[marker_pos:]
continue
parsed_arr, suffix_pos = self._try_parse_json_array(self._buffer)
if parsed_arr is None:
if self.suffix in self._buffer:
out_normal += self._buffer
self._buffer = ""
self._in_tools_block = False
return StreamingParseResult(normal_text=out_normal, calls=out_calls)
return StreamingParseResult(normal_text=out_normal, calls=out_calls)
while suffix_pos < len(self._buffer) and self._buffer[suffix_pos].isspace():
suffix_pos += 1
if not self._buffer.startswith(self.suffix, suffix_pos):
return StreamingParseResult(normal_text=out_normal, calls=out_calls)
if self.current_tool_id == -1:
self.current_tool_id = 0
for item in parsed_arr:
name, args = self._apertus_obj_to_call(item)
if name is None:
continue
if args is None:
args = {}
if (
name not in self._tool_indices
and not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get()
):
logger.warning(
f"Model attempted to call undefined function: {name}"
)
continue
tool_id = self.current_tool_id
self.current_tool_id += 1
args_json = json.dumps(args, ensure_ascii=False)
while len(self.prev_tool_call_arr) <= tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= tool_id:
self.streamed_args_for_tool.append("")
self.prev_tool_call_arr[tool_id] = {"name": name, "arguments": args}
self.streamed_args_for_tool[tool_id] = args_json
# Emit tool name first, then full args (OpenAI streaming semantics)
out_calls.append(
ToolCallItem(tool_index=tool_id, name=name, parameters="")
)
out_calls.append(
ToolCallItem(tool_index=tool_id, name=None, parameters=args_json)
)
# Consume the parsed tools block and reset state
self._buffer = self._buffer[suffix_pos + len(self.suffix) :]
self._in_tools_block = False
if out_calls:
# Flush normal text after the tools block, but keep a tool marker or its partial prefix in the buffer for the next stream
if (marker_pos := self._buffer.find(self.bot)) > 0:
out_normal += self._buffer[:marker_pos]
self._buffer = self._buffer[marker_pos:]
elif marker_pos == -1:
if partial_bot := self._ends_with_partial_token(
self._buffer, self.bot
):
out_normal += self._buffer[:-partial_bot]
self._buffer = self._buffer[-partial_bot:]
else:
out_normal += self._buffer
self._buffer = ""
return StreamingParseResult(normal_text=out_normal, calls=out_calls)
continue
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin='<|tools_prefix|>[{"' + name + '": ',
end="}]<|tools_suffix|>",
trigger="<|tools_prefix|>",
)
def _apertus_obj_to_call(self, obj: Any) -> Tuple[Optional[str], Optional[Any]]:
"""
Convert a single Apertus tool-call object to (name, arguments).
Expected shape: {"tool_name": {...}}.
"""
if not isinstance(obj, dict) or not obj:
return None, None
name = next(iter(obj.keys()))
return name, obj.get(name)
def _parse_apertus_call_list(
self, arr: Any, tools: List[Tool], tool_index_offset: int = 0
) -> List[ToolCallItem]:
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
calls: List[ToolCallItem] = []
for item in arr:
name, args = self._apertus_obj_to_call(item)
if name is None:
continue
if args is None:
args = {}
if (
name not in self._tool_indices
and not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get()
):
logger.warning(f"Model attempted to call undefined function: {name}")
continue
calls.append(
ToolCallItem(
tool_index=tool_index_offset + len(calls),
name=name,
parameters=json.dumps(args, ensure_ascii=False),
)
)
return calls
def _try_parse_json_array(self, text: str) -> Tuple[Optional[Any], int]:
"""
Returns: (parsed_array_or_None, end_index_exclusive_in_text)
"""
if (start_idx := text.find(self.bot)) == -1:
return None, 0
json_start = start_idx + len(self.bot) - 1 # points to '['
try:
parsed, end_idx = json.JSONDecoder().raw_decode(text, json_start)
except json.JSONDecodeError:
return None, 0
if isinstance(parsed, list):
return parsed, end_idx
return [parsed], end_idx
@@ -0,0 +1,411 @@
import json
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Literal, Optional, Union
import orjson
from partial_json_parser.core.exceptions import MalformedJSON
from partial_json_parser.core.options import Allow
try:
from xgrammar import StructuralTag, get_model_structural_tag
except ImportError:
StructuralTag = Any
get_model_structural_tag = None
from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice
from sglang.srt.environ import envs
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.utils import (
_find_common_prefix,
_is_complete_json,
_partial_json_loads,
)
logger = logging.getLogger(__name__)
class BaseFormatDetector(ABC):
"""Base class providing two sets of interfaces: one-time and streaming incremental."""
def __init__(self):
# Streaming state management
# Buffer for accumulating incomplete patterns that arrive across multiple streaming chunks
self._buffer = ""
# Stores complete tool call info (name and arguments) for each tool being parsed.
# Used by serving layer for completion handling when streaming ends.
# Format: [{"name": str, "arguments": dict}, ...]
self.prev_tool_call_arr: List[Dict] = []
# Index of currently streaming tool call. Starts at -1 (no active tool),
# increments as each tool completes. Tracks which tool's arguments are streaming.
self.current_tool_id: int = -1
# Flag for whether current tool's name has been sent to client.
# Tool names sent first with empty parameters, then arguments stream incrementally.
self.current_tool_name_sent: bool = False
# Tracks raw JSON string content streamed to client for each tool's arguments.
# Critical for serving layer to calculate remaining content when streaming ends.
# Each index corresponds to a tool_id. Example: ['{"location": "San Francisco"', '{"temp": 72']
self.streamed_args_for_tool: List[str] = []
# Token configuration (override in subclasses)
self.bot_token = ""
self.eot_token = ""
self.tool_call_separator = ", "
def _get_tool_indices(self, tools: List[Tool]) -> Dict[str, int]:
"""
Get a mapping of tool names to their indices in the tools list.
This utility method creates a dictionary mapping function names to their
indices in the tools list, which is commonly needed for tool validation
and ToolCallItem creation.
Args:
tools: List of available tools
Returns:
Dictionary mapping tool names to their indices
"""
return {
tool.function.name: i for i, tool in enumerate(tools) if tool.function.name
}
def parse_base_json(self, action: Any, tools: List[Tool]) -> List[ToolCallItem]:
tool_indices = self._get_tool_indices(tools)
if not isinstance(action, list):
action = [action]
results = []
for act in action:
name = act.get("name")
if not (name and name in tool_indices):
logger.warning(f"Model attempted to call undefined function: {name}")
if not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get():
continue # Skip unknown tools (default legacy behavior)
results.append(
ToolCallItem(
tool_index=tool_indices.get(name, -1),
name=name,
parameters=json.dumps(
act.get("parameters") or act.get("arguments", {}),
ensure_ascii=False,
),
)
)
return results
@abstractmethod
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
Parses the text in one go. Returns success=True if the format matches, otherwise False.
Note that leftover_text here represents "content that this parser will not consume further".
"""
action = orjson.loads(text)
return StreamingParseResult(calls=self.parse_base_json(action, tools))
def _ends_with_partial_token(self, buffer: str, bot_token: str) -> int:
"""
Check if buffer ends with a partial bot_token.
Return the length of the partial bot_token.
For some format, the bot_token is not a token in model's vocabulary, such as
`[TOOL_CALLS] [` in Mistral.
"""
for i in range(1, min(len(buffer) + 1, len(bot_token))):
if bot_token.startswith(buffer[-i:]):
return i
return 0
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing with tool validation.
This base implementation works best with formats where:
1. bot_token is followed immediately by JSON (e.g., bot_token + JSON_array)
2. JSON can be parsed incrementally using partial_json_loads
3. Multiple tool calls are separated by "; " or ", "
Examples of incompatible formats (need custom implementation, may reuse some logic from this class):
- Each tool call is wrapped in a separate block: See Qwen25Detector
- Multiple separate blocks: [TOOL_CALLS] [...] \n [TOOL_CALLS] [...]
- Tool call is Pythonic style
For incompatible formats, detectors should override this method with custom logic.
"""
# Append new text to buffer
self._buffer += new_text
current_text = self._buffer
# The current_text has tool_call if it is the start of a new tool call sequence
# or it is the start of a new tool call after a tool call separator, when there is a previous tool call
if not (
self.has_tool_call(current_text)
or (
self.current_tool_id > 0
and current_text.startswith(self.tool_call_separator)
)
):
# Only clear buffer if we're sure no tool call is starting
if not self._ends_with_partial_token(self._buffer, self.bot_token):
normal_text = self._buffer
self._buffer = ""
if self.eot_token in normal_text:
normal_text = normal_text.replace(self.eot_token, "")
return StreamingParseResult(normal_text=normal_text)
else:
# Might be partial bot_token, keep buffering
return StreamingParseResult()
# Build tool indices if not already built
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
flags = Allow.ALL if self.current_tool_name_sent else Allow.ALL & ~Allow.STR
try:
try:
# Priority check: if we're processing a subsequent tool (current_tool_id > 0),
# first check if text starts with the tool separator. This is critical for
# parallel tool calls because the bot_token (e.g., '[') can also
# appear inside array parameters of the current tool, and we must not
# mistakenly identify that as the start of a new tool.
used_separator_branch = False
if self.current_tool_id > 0 and current_text.startswith(
self.tool_call_separator
):
start_idx = len(self.tool_call_separator)
used_separator_branch = True
else:
tool_call_pos = current_text.find(self.bot_token)
if tool_call_pos != -1:
start_idx = tool_call_pos + len(self.bot_token)
else:
start_idx = 0
if start_idx >= len(current_text):
return StreamingParseResult()
try:
obj, end_idx = _partial_json_loads(current_text[start_idx:], flags)
except (MalformedJSON, json.JSONDecodeError):
# Separator landed on non-JSON markup; fall back to
# bot_token which skips past all inter-object markup.
# e.g. Qwen25: separator "," matches between eot/bot tags.
if used_separator_branch and self.bot_token in current_text:
start_idx = current_text.find(self.bot_token) + len(
self.bot_token
)
if start_idx >= len(current_text):
return StreamingParseResult()
obj, end_idx = _partial_json_loads(
current_text[start_idx:], flags
)
else:
raise
is_current_complete = _is_complete_json(
current_text[start_idx : start_idx + end_idx]
)
# Validate tool name if present
if "name" in obj and obj["name"] not in self._tool_indices:
# Invalid tool name - reset state
self._buffer = ""
self.current_tool_id = -1
self.current_tool_name_sent = False
if self.streamed_args_for_tool:
self.streamed_args_for_tool.pop()
return StreamingParseResult()
# Handle parameters/arguments consistency
# NOTE: we assume here that the obj is always partial of a single tool call
if "parameters" in obj:
assert (
"arguments" not in obj
), "model generated both parameters and arguments"
obj["arguments"] = obj["parameters"]
current_tool_call = obj
except (MalformedJSON, json.JSONDecodeError):
return StreamingParseResult()
if not current_tool_call:
return StreamingParseResult()
# Case 1: Handle tool name streaming
# This happens when we encounter a tool but haven't sent its name yet
if not self.current_tool_name_sent:
function_name = current_tool_call.get("name")
if function_name and function_name in self._tool_indices:
# If this is a new tool (current_tool_id was -1), initialize it
if self.current_tool_id == -1:
self.current_tool_id = 0
self.streamed_args_for_tool.append("")
# If this is a subsequent tool, ensure streamed_args_for_tool is large enough
elif self.current_tool_id >= len(self.streamed_args_for_tool):
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Send the tool name with empty parameters
res = StreamingParseResult(
calls=[
ToolCallItem(
tool_index=self.current_tool_id,
name=function_name,
parameters="",
)
],
)
self.current_tool_name_sent = True
else:
res = StreamingParseResult()
# Case 2: Handle streaming arguments
# This happens when we've already sent the tool name and now need to stream arguments incrementally
else:
cur_arguments = current_tool_call.get("arguments")
res = StreamingParseResult()
if cur_arguments is not None:
# Calculate how much of the arguments we've already streamed
sent = len(self.streamed_args_for_tool[self.current_tool_id])
cur_args_json = json.dumps(cur_arguments, ensure_ascii=False)
prev_arguments = None
if self.current_tool_id < len(self.prev_tool_call_arr):
prev_arguments = self.prev_tool_call_arr[
self.current_tool_id
].get("arguments")
argument_diff = None
# If the current tool's JSON is complete, send all remaining arguments
if is_current_complete:
argument_diff = cur_args_json[sent:]
completing_tool_id = (
self.current_tool_id
) # Save the ID of the tool that's completing
# Only remove the processed portion, keep unprocessed content
self._buffer = current_text[start_idx + end_idx :]
# If the tool is still being parsed, send incremental changes
elif prev_arguments:
prev_args_json = json.dumps(prev_arguments, ensure_ascii=False)
if cur_args_json != prev_args_json:
prefix = _find_common_prefix(prev_args_json, cur_args_json)
argument_diff = prefix[sent:]
# Update prev_tool_call_arr with current state
if self.current_tool_id >= 0:
# Ensure prev_tool_call_arr is large enough
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
self.prev_tool_call_arr[self.current_tool_id] = (
current_tool_call
)
# Advance to next tool if complete
if is_current_complete:
self.current_tool_name_sent = False
self.current_tool_id += 1
# Send the argument diff if there's something new
if argument_diff is not None:
# Use the correct tool_index: completing_tool_id for completed tools, current_tool_id for ongoing
tool_index_to_use = (
completing_tool_id
if is_current_complete
else self.current_tool_id
)
res = StreamingParseResult(
calls=[
ToolCallItem(
tool_index=tool_index_to_use,
parameters=argument_diff,
)
],
)
self.streamed_args_for_tool[tool_index_to_use] += argument_diff
return res
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
return StreamingParseResult()
@abstractmethod
def has_tool_call(self, text: str) -> bool:
"""
Check if the given text contains function call markers specific to this format.
"""
raise NotImplementedError()
def supports_structural_tag(self) -> bool:
"""Return True if this detector supports structural tag format."""
return True
@abstractmethod
def structure_info(self) -> _GetInfoFunc:
"""
Return a function that creates StructureInfo for constrained generation.
The returned function takes a tool name and returns a StructureInfo object
containing the begin/end patterns and trigger tokens needed for constrained
generation of function calls in this format.
Returns:
A function that takes a tool name (str) and returns StructureInfo
"""
raise NotImplementedError()
def get_structural_tag_name(self) -> Optional[str]:
"""Return the XGrammar model name for native structural tags, if supported."""
return None
def get_structural_tag(
self,
tools: Union[List[Tool], None] = None,
tool_choice: Union[ToolChoice, Literal["auto", "required"]] = "auto",
thinking_mode: bool = False,
) -> Optional[StructuralTag]:
"""
Return a model-native XGrammar structural tag when supported.
Args:
tools: List of available tools
tool_choice: The tool choice setting from the request
thinking_mode: Whether to include the model's reasoning prefix in
the returned structural tag. Pass False when SGLang's
ReasonerGrammarBackend will own the <think>...</think> prefix
(the typical case when --reasoning-parser is configured) so
only one layer constrains the reasoning section.
Returns:
StructuralTag if this detector supports model-native tags, otherwise None
"""
structural_tag_name = self.get_structural_tag_name()
if not structural_tag_name or get_model_structural_tag is None:
return None
converted_tools = [tool.model_dump() for tool in tools or []]
converted_tool_choice = (
tool_choice.model_dump()
if isinstance(tool_choice, ToolChoice)
else tool_choice
)
return get_model_structural_tag(
model=structural_tag_name,
tools=converted_tools,
tool_choice=converted_tool_choice,
reasoning=thinking_mode,
)
@@ -0,0 +1,148 @@
import json
import logging
from typing import List
import orjson
from partial_json_parser.core.exceptions import MalformedJSON
from partial_json_parser.core.options import Allow
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
_GetInfoFunc,
)
from sglang.srt.function_call.utils import _partial_json_loads
logger = logging.getLogger(__name__)
class CohereCommand4Detector(BaseFormatDetector):
"""Detector for ``<|START_ACTION|>[...JSON array...]<|END_ACTION|>``."""
def __init__(self):
super().__init__()
self.bot_token = "<|START_ACTION|>"
self.eot_token = "<|END_ACTION|>"
# Per the chat template the array items are separated by ``,`` only --
# the surrounding newlines/whitespace are also valid JSON whitespace.
self.tool_call_separator = ","
def has_tool_call(self, text: str) -> bool:
return self.bot_token in text
@staticmethod
def _normalize_calls(arr) -> List[dict]:
"""Translate Cohere's per-item shape ``{tool_call_id, tool_name,
parameters}`` into the shape ``parse_base_json`` expects (``name`` /
``parameters``). Drops ``tool_call_id`` since the OpenAI Chat
Completions schema assigns its own id."""
if isinstance(arr, dict):
arr = [arr]
if not isinstance(arr, list):
return []
out: List[dict] = []
for act in arr:
if not isinstance(act, dict):
continue
normalized = dict(act)
if "name" not in normalized and "tool_name" in normalized:
normalized["name"] = normalized.pop("tool_name")
normalized.pop("tool_call_id", None)
out.append(normalized)
return out
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""Non-streaming parse."""
idx = text.find(self.bot_token)
if idx == -1:
return StreamingParseResult(normal_text=text)
normal_text = text[:idx]
body_start = idx + len(self.bot_token)
eot_idx = text.find(self.eot_token, body_start)
body = text[body_start:eot_idx] if eot_idx != -1 else text[body_start:]
# body should be ``[ {...}, {...} ]`` (with arbitrary whitespace).
# Prefer the full-text JSON parser when the block is complete; fall
# back to ``_partial_json_loads`` to be forgiving when generation was
# truncated before ``<|END_ACTION|>``.
arr = None
try:
arr = orjson.loads(body)
except (orjson.JSONDecodeError, TypeError, ValueError):
try:
arr, _ = _partial_json_loads(body, Allow.ALL)
except (MalformedJSON, json.JSONDecodeError, ValueError) as e:
logger.warning(
f"Cohere tool-call body did not parse as JSON: {e}; "
"returning surrounding text as normal output."
)
return StreamingParseResult(normal_text=normal_text)
normalized = self._normalize_calls(arr)
return StreamingParseResult(
normal_text=normal_text,
calls=self.parse_base_json(normalized, tools),
)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""Buffered streaming. Tool-call blocks are short (typically <2KB) so
we accumulate until the closing ``<|END_ACTION|>`` arrives and emit
the whole block at once. Anything before ``<|START_ACTION|>`` streams
through as normal text.
"""
self._buffer += new_text
current = self._buffer
bot_pos = current.find(self.bot_token)
if bot_pos == -1:
# Defensive: keep any trailing characters that might be the start
# of a partial bot_token in the buffer for the next chunk.
partial = self._ends_with_partial_token(current, self.bot_token)
if partial:
head = current[:-partial]
self._buffer = current[-partial:]
return StreamingParseResult(normal_text=head)
self._buffer = ""
return StreamingParseResult(normal_text=current)
# ``bot_token`` is somewhere in the buffer. Stream out anything before
# it as normal text exactly once.
if bot_pos > 0:
head = current[:bot_pos]
self._buffer = current[bot_pos:]
current = self._buffer
return StreamingParseResult(normal_text=head)
# Buffer starts with bot_token. Wait for the closing token, then
# parse and emit the full call list. Anything past <|END_ACTION|>
# (typically <|END_OF_TURN_TOKEN|>) stays in the buffer for the next
# increment to handle.
eot_pos = current.find(self.eot_token, len(self.bot_token))
if eot_pos == -1:
return StreamingParseResult()
block_end = eot_pos + len(self.eot_token)
result = self.detect_and_parse(current[:block_end], tools)
self._buffer = current[block_end:]
return result
def supports_structural_tag(self) -> bool:
return False
def structure_info(self) -> _GetInfoFunc:
def _info(name: str) -> StructureInfo:
return StructureInfo(
begin=(
'<|START_ACTION|>[{"tool_call_id": "0", "tool_name": "'
+ name
+ '", "parameters": '
),
end="}]<|END_ACTION|>",
trigger="<|START_ACTION|>",
)
return _info
@@ -0,0 +1,34 @@
from dataclasses import dataclass
from typing import Callable, List, Optional
from pydantic import BaseModel
class ToolCallItem(BaseModel):
"""Simple encapsulation of the parsed ToolCall result for easier usage in streaming contexts."""
tool_index: int
name: Optional[str] = None
parameters: str # JSON string
class StreamingParseResult(BaseModel):
"""Result of streaming incremental parsing."""
normal_text: str = ""
calls: List[ToolCallItem] = []
@dataclass
class StructureInfo:
begin: str
end: str
trigger: str
"""
Helper alias of function
Usually it is a function that takes a name string and returns a StructureInfo object,
which can be used to construct a structural_tag object
"""
_GetInfoFunc = Callable[[str], StructureInfo]
@@ -0,0 +1,206 @@
import json
import logging
import re
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.utils import _is_complete_json
logger = logging.getLogger(__name__)
class DeepSeekV31Detector(BaseFormatDetector):
"""
Detector for DeepSeek V3 model function call format.
The DeepSeek V3 format uses special Unicode tokens to delimit function calls
with JSON code blocks for arguments.
Format Structure:
```
<tool▁calls▁begin><tool▁call▁begin>{function_name}<tool▁sep>{json_arguments}<tool▁calls▁end><end▁of▁sentence>
```
Examples:
```
<tool▁calls▁begin><tool▁call▁begin>get_current_weather<tool▁sep>{"location": "Tokyo"}<tool▁call▁end><tool▁call▁begin>get_current_weather<tool▁sep>{"location": "Paris"}<tool▁call▁end><tool▁calls▁end><end▁of▁sentence>
```
Key Components:
- Tool Calls Section: Wrapped between `<tool▁calls▁begin>` and `<tool▁calls▁end>`
- Individual Tool Call: Wrapped between `<tool▁call▁begin>` and `<tool▁call▁end>`
- Function Declaration: `<tool▁call▁begin>{function_name}<tool▁sep>`
- Arguments: JSON code block between `<tool▁sep>` and `<tool▁call▁end>`
- Supports multiple tool calls
Reference: https://www.modelscope.cn/models/deepseek-ai/DeepSeek-V3.1
"""
def __init__(self):
super().__init__()
self.bot_token = "<tool▁calls▁begin>"
self.eot_token = "<tool▁calls▁end>"
self.func_call_regex = r"<tool▁call▁begin>.*?<tool▁call▁end>"
self.func_detail_regex = (
r"<tool▁call▁begin>(.*)<tool▁sep>(.*)<tool▁call▁end>"
)
self._last_arguments = ""
self.current_tool_id = -1
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a deepseek format tool call."""
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
:param text: The complete text to parse.
:param tools: List of available tools.
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
"""
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if self.bot_token not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
match_result_list = re.findall(self.func_call_regex, text, re.DOTALL)
calls = []
try:
for match_result in match_result_list:
# Get function name
func_detail = re.search(self.func_detail_regex, match_result, re.DOTALL)
func_name = func_detail.group(1)
func_args = func_detail.group(2)
func_args = json.loads(func_args)
# construct match_result for parse_base_json
match_result = {"name": func_name, "parameters": func_args}
calls.extend(self.parse_base_json(match_result, tools))
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}")
# return the normal text if parsing fails
return StreamingParseResult(normal_text=text)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing tool calls for DeepSeekV3 format.
"""
self._buffer += new_text
current_text = self._buffer
# Check if we have a tool call (either the start token or individual tool call)
has_tool_call = (
self.bot_token in current_text or "<tool▁call▁begin>" in current_text
)
if not has_tool_call:
self._buffer = ""
for e_token in [self.eot_token, "<tool▁call▁end>"]:
if e_token in new_text:
new_text = new_text.replace(e_token, "")
return StreamingParseResult(normal_text=new_text)
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
calls: list[ToolCallItem] = []
try:
partial_match = re.search(
pattern=r"<tool▁call▁begin>(.*)<tool▁sep>(.*?)(<tool▁call▁end>|$)",
string=current_text,
flags=re.DOTALL,
)
if partial_match:
func_name = partial_match.group(1).strip()
func_args_raw = partial_match.group(2).strip()
is_tool_end = partial_match.group(3)
# Initialize state if this is the first tool call
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
# Ensure we have enough entries in our tracking arrays
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
if not self.current_tool_name_sent:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=func_name,
parameters="",
)
)
self.current_tool_name_sent = True
# Store the tool call info for serving layer completions endpoint
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
else:
argument_diff = (
func_args_raw[len(self._last_arguments) :]
if func_args_raw.startswith(self._last_arguments)
else func_args_raw
)
if argument_diff:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=argument_diff,
)
)
self._last_arguments += argument_diff
self.streamed_args_for_tool[
self.current_tool_id
] += argument_diff
if _is_complete_json(func_args_raw):
# Update the stored arguments
try:
parsed_args = json.loads(func_args_raw)
self.prev_tool_call_arr[self.current_tool_id][
"arguments"
] = parsed_args
except json.JSONDecodeError:
pass
# Find the end of the current tool call and remove only that part from buffer
if is_tool_end:
# Remove the completed tool call from buffer, keep any remaining content
self._buffer = current_text[partial_match.end(3) :]
else:
self._buffer = ""
result = StreamingParseResult(normal_text="", calls=calls)
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
return result
return StreamingParseResult(normal_text="", calls=calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
return StreamingParseResult(normal_text=current_text)
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin="<tool▁call▁begin>" + name + "<tool▁sep>",
end="<tool▁call▁end>",
trigger="<tool▁call▁begin>",
)
@@ -0,0 +1,372 @@
import json
import logging
import re
from partial_json_parser.core.options import Allow
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.utils import _find_common_prefix, _partial_json_loads
logger = logging.getLogger(__name__)
class DeepSeekV32Detector(BaseFormatDetector):
"""
Detector for DeepSeek V3.2 model function call format.
The DeepSeek V3.2 format uses XML-like DSML tags to delimit function calls.
Supports two parameter formats:
Format 1 - XML Parameter Tags:
```
<DSMLfunction_calls>
<DSMLinvoke name="function_name">
<DSMLparameter name="param_name" string="true">value</DSMLparameter>
...
</DSMLinvoke>
</DSMLfunction_calls>
```
Format 2 - Direct JSON:
```
<DSMLfunction_calls>
<DSMLinvoke name="function_name">
{
"param_name": "value"
}
</DSMLinvoke>
</DSMLfunction_calls>
```
Examples:
```
<DSMLfunction_calls>
<DSMLinvoke name="get_favorite_tourist_spot">
<DSMLparameter name="city" string="true">San Francisco</DSMLparameter>
</DSMLinvoke>
</DSMLfunction_calls>
<DSMLfunction_calls>
<DSMLinvoke name="get_favorite_tourist_spot">
{ "city": "San Francisco" }
</DSMLinvoke>
</DSMLfunction_calls>
```
Key Components:
- Tool Calls Section: Wrapped between `<DSMLfunction_calls>` and `</DSMLfunction_calls>`
- Individual Tool Call: Wrapped between `<DSMLinvoke name="...">` and `</DSMLinvoke>`
- Parameters: Either XML tags or direct JSON format
- Supports multiple tool calls
Reference: DeepSeek V3.2 format specification
"""
def __init__(self):
super().__init__()
self.bot_token = "<DSMLfunction_calls>"
self.eot_token = "</DSMLfunction_calls>"
self.invoke_end_token = "</DSMLinvoke>"
self.parameter_regex = r'<DSMLparameter\s+name="([^"]+)"\s+string="([^"]+)"\s*>(.*?)</DSMLparameter>'
self.partial_parameter_regex = (
r'<DSMLparameter\s+name="([^"]+)"\s+string="([^"]+)"\s*>(.*)$'
)
self.function_calls_regex = (
r"<DSMLfunction_calls>(.*?)</DSMLfunction_calls>"
)
# Long-form `<DSMLinvoke name="x">...</DSMLinvoke>` and the
# self-closing `<DSMLinvoke name="x"/>` shape V4 emits for zero-arg
# tools. The `end` group is empty when the closer hasn't streamed in.
self.invoke_regex = (
r'<DSMLinvoke\s+name="(?P<name>[^"]+)"\s*'
r"(?:(?P<self_close>/>)"
r"|>(?P<body>.*?)(?P<end>(?:</DSMLinvoke>|$)))"
)
self.prefix_parameter_end_call = ["</", "DSML", "parameter"]
self.prefix_invoke_end_call = ["</", "DSML", "inv", "oke"]
self.current_tool_id = -1
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a deepseek v32 format tool call."""
return self.bot_token in text or "<DSMLinvoke" in text
@staticmethod
def _unpack_invoke_match(m: "re.Match[str]") -> tuple[str, str, bool]:
"""Returns (name, body, is_complete) for an invoke_regex match.
Self-closing invokes have empty body and are always complete.
Long-form bodies are always strings (possibly empty); they're
incomplete when matched against `$` because the closing tag
hasn't streamed in yet.
"""
name = m.group("name").strip()
if m.group("self_close"):
return name, "", True
return name, m.group("body"), bool(m.group("end"))
def _parse_parameters_from_xml(
self, invoke_content: str, allow_partial: bool = False
) -> str:
"""
Parse parameters from either XML-like format or JSON format to str.
Supports two formats:
1. XML parameter tags: <DSMLparameter name="..." string="...">value</DSMLparameter>
2. Direct JSON: { "key": "value" }
"""
# First, try to parse as direct JSON (new format)
invoke_content_stripped = invoke_content.strip()
if invoke_content_stripped.startswith("{"):
if allow_partial:
# Remove incomplete invoke end call prefix in case they are captured by param
for token in reversed(self.prefix_invoke_end_call):
invoke_content_stripped = invoke_content_stripped.rstrip(token)
return invoke_content_stripped
elif invoke_content_stripped.endswith("}"):
return invoke_content_stripped
# Fall back to XML parameter tag parsing (original format)
parameters = {}
# Find all complete parameter matches
param_matches = list(
re.finditer(self.parameter_regex, invoke_content, re.DOTALL)
)
last_match_end = 0
for match in param_matches:
param_name = match.group(1)
param_type = match.group(2)
param_value = match.group(3)
last_match_end = match.end()
# Convert value based on type
if param_type == "true": # string type
parameters[param_name] = param_value.strip()
else:
# Try to parse as JSON for other types
try:
parameters[param_name] = json.loads(param_value.strip())
except (json.JSONDecodeError, ValueError):
parameters[param_name] = param_value.strip()
# If allowed, try to parse a partial parameter at the end
if allow_partial:
remaining_content = invoke_content[last_match_end:]
# Remove incomplete parameter_end_call prefix in case they are captured by param
for token in reversed(self.prefix_parameter_end_call):
remaining_content = remaining_content.rstrip(token)
# Match start of a parameter tag + value (potentially incomplete)
# Regex: <tag name="..." string="...">VALUE... (no end tag)
partial_match = re.search(
self.partial_parameter_regex, remaining_content, re.DOTALL
)
if partial_match and (param_value := partial_match.group(3)):
param_name = partial_match.group(1)
if partial_match.group(2) == "true":
parameters[param_name] = param_value.strip()
else:
try:
parameters[param_name] = _partial_json_loads(
param_value, Allow.ALL
)[0]
except json.JSONDecodeError:
parameters[param_name] = param_value.strip()
return json.dumps(parameters, ensure_ascii=False)
def detect_and_parse(self, text: str, tools: list[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
:param text: The complete text to parse.
:param tools: List of available tools.
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
"""
idx = text.find(self.bot_token)
normal_text = text[:idx].removesuffix("\n\n") if idx != -1 else text
if self.bot_token not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
calls = []
try:
# Extract content between function_calls tags
function_calls_match = re.search(
self.function_calls_regex,
text,
re.DOTALL,
)
if not function_calls_match:
return StreamingParseResult(normal_text=normal_text, calls=[])
function_calls_content = function_calls_match.group(1)
# Find all invoke blocks
for invoke_match in re.finditer(
self.invoke_regex, function_calls_content, re.DOTALL
):
func_name, invoke_content, _ = self._unpack_invoke_match(invoke_match)
func_args = self._parse_parameters_from_xml(invoke_content)
# construct match_result for parse_base_json
match_result = {"name": func_name, "parameters": json.loads(func_args)}
calls.extend(self.parse_base_json(match_result, tools))
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}")
# return the normal text if parsing fails
return StreamingParseResult(normal_text=text)
def parse_streaming_increment(
self, new_text: str, tools: list[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing tool calls for DeepSeekV32 format.
Supports multiple consecutive invoke blocks and argument streaming.
"""
self._buffer += new_text
current_text = self._buffer
# Check if buffer contains any DSML markers or ends with potential tag prefix
# This handles partial/streaming DSML content
dsml_markers = ["DSML", "<", "</"]
potentially_dsml = any(marker in current_text for marker in dsml_markers)
# Also check if text ends with start of a tag (to handle "<" arriving separately)
dsml_prefixes = ["<", "<", "</", "</"]
ends_with_prefix = any(
current_text.rstrip().endswith(prefix) for prefix in dsml_prefixes
)
if (
not self.has_tool_call(current_text)
and not potentially_dsml
and not ends_with_prefix
):
self._buffer = ""
for e_token in [self.eot_token, self.invoke_end_token]:
if e_token in current_text:
current_text = current_text.replace(e_token, "")
return StreamingParseResult(normal_text=current_text)
all_calls: list[ToolCallItem] = []
try:
# Loop to handle multiple consecutive invoke blocks
while True:
# Try to match an invoke block (may be partial)
invoke_match = re.search(
pattern=self.invoke_regex,
string=current_text,
flags=re.DOTALL,
)
if not invoke_match:
break
func_name, invoke_content, is_tool_end = self._unpack_invoke_match(
invoke_match
)
# Initialize state if this is the first tool call
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
# Ensure arrays are large enough for current tool
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# 1. Send tool name if not sent yet
if not self.current_tool_name_sent:
all_calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=func_name,
parameters="",
)
)
self.current_tool_name_sent = True
# 2. Parse current parameters (partial or complete)
current_params = self._parse_parameters_from_xml(
invoke_content, allow_partial=not is_tool_end
)
# 3. Calculate and send incremental arguments
sent_len = len(self.streamed_args_for_tool[self.current_tool_id])
prev_params = self.prev_tool_call_arr[self.current_tool_id].get(
"arguments"
)
argument_diff = None
if is_tool_end:
# If complete, send everything remaining
argument_diff = current_params[sent_len:]
elif prev_params is not None:
# If partial, send stable prefix diff
if current_params != prev_params:
prefix = _find_common_prefix(current_params, prev_params)
if len(prefix) > sent_len:
argument_diff = prefix[sent_len:]
if argument_diff:
all_calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=argument_diff,
)
)
self.streamed_args_for_tool[self.current_tool_id] += argument_diff
# Update the stored arguments
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": current_params,
}
# Check if tool call is complete (has closing tag)
if is_tool_end:
# Remove the completed tool call from buffer
self._buffer = current_text[invoke_match.end() :]
current_text = self._buffer # Update for next iteration
# Move to next tool call
self.current_tool_id += 1
self.current_tool_name_sent = False
# Continue loop to check for more invoke blocks
continue
else:
# Tool call not complete yet, don't return anything
# Wait for more chunks until we see </DSMLinvoke>
break
# No more invoke blocks found
return StreamingParseResult(normal_text="", calls=all_calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
return StreamingParseResult(normal_text=current_text)
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin=f'<DSMLinvoke name="{name}">',
end="</DSMLinvoke>",
trigger="<DSMLinvoke",
)
def get_structural_tag_name(self) -> str:
return "deepseek_v3_2"
@@ -0,0 +1,211 @@
import json
import logging
import re
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.utils import _is_complete_json
logger = logging.getLogger(__name__)
class DeepSeekV3Detector(BaseFormatDetector):
"""
Detector for DeepSeek V3 model function call format.
The DeepSeek V3 format uses special Unicode tokens to delimit function calls
with JSON code blocks for arguments.
Format Structure:
```
<tool▁calls▁begin><tool▁call▁begin>function<tool▁sep>{function_name}\n```json\n{json_arguments}\n```<tool▁calls▁end><end▁of▁sentence>
```
Examples:
```
<tool▁calls▁begin><tool▁call▁begin>function<tool▁sep>get_current_weather\n```json\n{"location": "Tokyo"}\n```<tool▁call▁end>\n<tool▁call▁begin>function<tool▁sep>get_current_weather\n```json\n{"location": "Paris"}\n```<tool▁call▁end><tool▁calls▁end><end▁of▁sentence>
```
Key Components:
- Tool Calls Section: Wrapped between `<tool▁calls▁begin>` and `<tool▁calls▁end>`
- Individual Tool Call: Wrapped between `<tool▁call▁begin>` and `<tool▁call▁end>`
- Function Declaration: `function<tool▁sep>{function_name}`
- Arguments: JSON code block between ````json` and ````
- Supports multiple tool calls
Reference: https://huggingface.co/deepseek-ai/DeepSeek-V3-0324?chat_template=default
"""
def __init__(self):
super().__init__()
self.bot_token = "<tool▁calls▁begin>"
self.eot_token = "<tool▁calls▁end>"
self.func_call_regex = r"<tool▁call▁begin>.*?<tool▁call▁end>"
self.func_detail_regex = r"<tool▁call▁begin>(.*)<tool▁sep>(.*)\n```json\n(.*)\n```<tool▁call▁end>"
self._last_arguments = ""
self.current_tool_id = -1
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a deepseek format tool call."""
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
:param text: The complete text to parse.
:param tools: List of available tools.
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
"""
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if self.bot_token not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
match_result_list = re.findall(self.func_call_regex, text, re.DOTALL)
calls = []
try:
for match_result in match_result_list:
# Get function name
func_detail = re.search(self.func_detail_regex, match_result, re.DOTALL)
func_name = func_detail.group(2)
func_args = func_detail.group(3)
func_args = json.loads(func_args)
# construct match_result for parse_base_json
match_result = {"name": func_name, "parameters": func_args}
calls.extend(self.parse_base_json(match_result, tools))
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}")
# return the normal text if parsing fails
return StreamingParseResult(normal_text=text)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing tool calls for DeepSeekV3 format.
"""
self._buffer += new_text
current_text = self._buffer
# Check if we have a tool call (either the start token or individual tool call)
has_tool_call = (
self.bot_token in current_text or "<tool▁call▁begin>" in current_text
)
if not has_tool_call:
self._buffer = ""
for e_token in [self.eot_token, "```", "<tool▁call▁end>"]:
if e_token in new_text:
new_text = new_text.replace(e_token, "")
return StreamingParseResult(normal_text=new_text)
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
calls: list[ToolCallItem] = []
try:
partial_match = re.search(
pattern=r"<tool▁call▁begin>(.*)<tool▁sep>(.*)\n```json\n(.*)\n```.*",
string=current_text,
flags=re.DOTALL,
)
if partial_match:
func_name = partial_match.group(2).strip()
func_args_raw = partial_match.group(3).strip()
# Initialize state if this is the first tool call
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
# Ensure we have enough entries in our tracking arrays
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
if not self.current_tool_name_sent:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=func_name,
parameters="",
)
)
self.current_tool_name_sent = True
# Store the tool call info for serving layer completions endpoint
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
else:
argument_diff = (
func_args_raw[len(self._last_arguments) :]
if func_args_raw.startswith(self._last_arguments)
else func_args_raw
)
if argument_diff:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=argument_diff,
)
)
self._last_arguments += argument_diff
self.streamed_args_for_tool[
self.current_tool_id
] += argument_diff
if _is_complete_json(func_args_raw):
# Update the stored arguments
try:
parsed_args = json.loads(func_args_raw)
self.prev_tool_call_arr[self.current_tool_id][
"arguments"
] = parsed_args
except json.JSONDecodeError:
pass
# Find the end of the current tool call and remove only that part from buffer
tool_call_end_pattern = (
r"<tool▁call▁begin>.*?<tool▁call▁end>"
)
match = re.search(
tool_call_end_pattern, current_text, re.DOTALL
)
if match:
# Remove the completed tool call from buffer, keep any remaining content
self._buffer = current_text[match.end() :]
else:
self._buffer = ""
result = StreamingParseResult(normal_text="", calls=calls)
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
return result
return StreamingParseResult(normal_text="", calls=calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}")
return StreamingParseResult(normal_text=current_text)
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin="<tool▁calls▁begin><tool▁call▁begin>function<tool▁sep>"
+ name
+ "\n```json\n",
end="\n```<tool▁call▁end><tool▁calls▁end>",
trigger="<tool▁calls▁begin>",
)
@@ -0,0 +1,67 @@
import logging
from sglang.srt.function_call.deepseekv32_detector import DeepSeekV32Detector
logger = logging.getLogger(__name__)
class DeepSeekV4Detector(DeepSeekV32Detector):
"""
Detector for DeepSeek V4 model function call format.
The DeepSeek V4 format uses XML-like DSML tags to delimit function calls.
Supports two parameter formats:
Format 1 - XML Parameter Tags:
```
<DSMLtool_calls>
<DSMLinvoke name="function_name">
<DSMLparameter name="param_name" string="true">value</DSMLparameter>
...
</DSMLinvoke>
</DSMLtool_calls>
```
Format 2 - Direct JSON:
```
<DSMLtool_calls>
<DSMLinvoke name="function_name">
{
"param_name": "value"
}
</DSMLinvoke>
</DSMLtool_calls>
```
Examples:
```
<DSMLtool_calls>
<DSMLinvoke name="get_favorite_tourist_spot">
<DSMLparameter name="city" string="true">San Francisco</DSMLparameter>
</DSMLinvoke>
</DSMLtool_calls>
<DSMLtool_calls>
<DSMLinvoke name="get_favorite_tourist_spot">
{ "city": "San Francisco" }
</DSMLinvoke>
</DSMLtool_calls>
```
Key Components:
- Tool Calls Section: Wrapped between `<DSMLtool_calls>` and `</DSMLtool_calls>`
- Individual Tool Call: Wrapped between `<DSMLinvoke name="...">` and `</DSMLinvoke>`
- Parameters: Either XML tags or direct JSON format
- Supports multiple tool calls
Reference: DeepSeek V4 format specification
"""
def __init__(self):
super().__init__()
self.bot_token = "<DSMLtool_calls>"
self.eot_token = "</DSMLtool_calls>"
self.function_calls_regex = r"<DSMLtool_calls>(.*?)</DSMLtool_calls>"
def get_structural_tag_name(self) -> str:
return "deepseek_v4"
@@ -0,0 +1,275 @@
import inspect
import logging
from typing import Dict, List, Literal, Optional, Set, Tuple, Type, Union
from sglang.srt.entrypoints.openai.protocol import (
LegacyStructuralTagResponseFormat,
StructuralTagResponseFormat,
StructuresResponseFormat,
Tool,
ToolCallConstraint,
ToolChoice,
)
from sglang.srt.environ import ToolStrictLevel, envs
from sglang.srt.function_call.apertus2509_detector import Apertus2509Detector
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.cohere_command4_detector import CohereCommand4Detector
from sglang.srt.function_call.core_types import ToolCallItem
from sglang.srt.function_call.deepseekv3_detector import DeepSeekV3Detector
from sglang.srt.function_call.deepseekv4_detector import DeepSeekV4Detector
from sglang.srt.function_call.deepseekv31_detector import DeepSeekV31Detector
from sglang.srt.function_call.deepseekv32_detector import DeepSeekV32Detector
from sglang.srt.function_call.gemma4_detector import Gemma4Detector
from sglang.srt.function_call.gigachat3_detector import GigaChat3Detector
from sglang.srt.function_call.glm4_moe_detector import Glm4MoeDetector
from sglang.srt.function_call.glm47_moe_detector import Glm47MoeDetector
from sglang.srt.function_call.gpt_oss_detector import GptOssDetector
from sglang.srt.function_call.hermes_detector import HermesDetector
from sglang.srt.function_call.hunyuan_detector import HunyuanDetector
from sglang.srt.function_call.internlm_detector import InternlmDetector
from sglang.srt.function_call.kimik2_detector import KimiK2Detector
from sglang.srt.function_call.lfm2_detector import Lfm2Detector
from sglang.srt.function_call.llama32_detector import Llama32Detector
from sglang.srt.function_call.mimo_detector import MiMoDetector
from sglang.srt.function_call.minicpm5_detector import MiniCPM5Detector
from sglang.srt.function_call.minimax_m2 import MinimaxM2Detector
from sglang.srt.function_call.minimax_m3 import MinimaxM3Detector
from sglang.srt.function_call.mistral_detector import MistralDetector
from sglang.srt.function_call.poolside_v1_detector import PoolsideV1Detector
from sglang.srt.function_call.pythonic_detector import PythonicDetector
from sglang.srt.function_call.qwen3_coder_detector import Qwen3CoderDetector
from sglang.srt.function_call.qwen25_detector import Qwen25Detector
from sglang.srt.function_call.step3_detector import Step3Detector
from sglang.srt.function_call.trinity_detector import TrinityDetector
from sglang.srt.function_call.utils import (
_get_tool_schema_defs,
get_json_schema_constraint,
)
logger = logging.getLogger(__name__)
class FunctionCallParser:
"""
Parser for function/tool calls in model outputs.
This class handles both streaming and non-streaming parsing of function calls using a detector.
In streaming scenarios, each time new_text is received, it calls detector.parse_streaming_increment
and returns the resulting normal_text and calls to the upper layer (or SSE).
"""
ToolCallParserEnum: Dict[str, Type[BaseFormatDetector]] = {
"apertus2509": Apertus2509Detector,
"cohere_command4": CohereCommand4Detector,
"deepseekv3": DeepSeekV3Detector,
"deepseekv31": DeepSeekV31Detector,
"deepseekv32": DeepSeekV32Detector,
"deepseekv4": DeepSeekV4Detector,
"glm": Glm4MoeDetector,
"glm45": Glm4MoeDetector,
"glm47": Glm47MoeDetector,
"gpt-oss": GptOssDetector,
"kimi_k2": KimiK2Detector,
"lfm2": Lfm2Detector,
"llama3": Llama32Detector,
"mimo": MiMoDetector,
"minicpm5": MiniCPM5Detector,
"mistral": MistralDetector,
"poolside_v1": PoolsideV1Detector,
"pythonic": PythonicDetector,
"qwen": Qwen25Detector,
"qwen25": Qwen25Detector,
"qwen3_coder": Qwen3CoderDetector,
"step3": Step3Detector,
"step3p5": Qwen3CoderDetector,
"minimax-m2": MinimaxM2Detector,
"minimax-m3": MinimaxM3Detector,
"trinity": TrinityDetector,
"interns1": InternlmDetector,
"hermes": HermesDetector,
"hunyuan": HunyuanDetector,
"gigachat3": GigaChat3Detector,
"gemma4": Gemma4Detector,
}
def __init__(self, tools: List[Tool], tool_call_parser: str, tokenizer=None):
detector_class = self.ToolCallParserEnum.get(tool_call_parser)
if detector_class:
kwargs = {}
if tokenizer is not None:
sig = inspect.signature(detector_class)
if "tokenizer" in sig.parameters:
kwargs["tokenizer"] = tokenizer
detector = detector_class(**kwargs)
else:
raise ValueError(f"Unsupported tool_call_parser: {tool_call_parser}")
self.detector = detector
self.tools = tools
self.tool_strict_level = envs.SGLANG_TOOL_STRICT_LEVEL.get()
def has_tool_call(self, text: str) -> bool:
"""
Check if the given text contains a tool call in the format supported by this parser.
This delegates to the detector's implementation.
Args:
text: The text to check for tool calls
Returns:
True if the text contains a tool call, False otherwise
"""
if not self.tools:
return False
return self.detector.has_tool_call(text)
def parse_non_stream(self, full_text: str) -> Tuple[str, list[ToolCallItem]]:
"""
One-time parsing of the full text to extract tool calls.
Args:
full_text: The complete text to parse
Returns:
A tuple containing:
- The remaining text after parsing that was not consumed by the detector (can be treated as normal text)
- A list of tool calls parsed from the text
"""
if not self.tools:
return full_text, []
parsed_result = self.detector.detect_and_parse(full_text, self.tools)
tool_call_list = parsed_result.calls
if tool_call_list:
return parsed_result.normal_text, tool_call_list
else:
return full_text, []
def parse_stream_chunk(self, chunk_text: str) -> Tuple[str, list[ToolCallItem]]:
"""
Streaming incremental parsing of chunks of text as they arrive.
Args:
chunk_text: The new chunk of text to parse
Returns:
A tuple containing:
- The normal text that should be displayed to the user
- A list of tool calls parsed from the chunk
"""
if not self.tools:
return chunk_text, []
final_normal_text = ""
final_calls = []
sp_result = self.detector.parse_streaming_increment(chunk_text, self.tools)
if sp_result.normal_text:
final_normal_text = sp_result.normal_text
if sp_result.calls:
final_calls.extend(sp_result.calls)
final_normal_text = sp_result.normal_text
return final_normal_text, final_calls
def get_legacy_structural_tag(
self, at_least_one: bool = False
) -> StructuralTagResponseFormat:
"""
Generate a structural tag response format for all available tools.
This creates the necessary structural tags that guide the model's output format.
Args:
at_least_one: If True, the grammar forces at least one tool call
(no free text allowed). Used for required/named tool_choice.
Raises:
ValueError: If tools have conflicting $defs schemas.
"""
# Validate $defs consistency before building structural tags
_get_tool_schema_defs(self.tools)
tool_structures: List[StructuresResponseFormat] = list()
tool_trigger_set: Set[str] = set()
get_structure_info = self.detector.structure_info()
for tool in self.tools:
function = tool.function
name = function.name
assert name is not None
info = get_structure_info(name)
# accept all if not strict, otherwise only accept the schema
is_strict = (
function.strict or self.tool_strict_level >= ToolStrictLevel.PARAMETER
)
schema = function.parameters if is_strict else {}
tool_structures.append(
StructuresResponseFormat(
begin=info.begin,
schema=schema or {}, # type: ignore
end=info.end,
)
)
tool_trigger_set.add(info.trigger)
# TODO(dark): move this into new structural tag format
# This requires all grammar backend support the new format
return LegacyStructuralTagResponseFormat(
type="structural_tag",
structures=tool_structures,
triggers=list(tool_trigger_set),
at_least_one=at_least_one,
)
def get_structure_constraint(
self,
tool_choice: Union[ToolChoice, Literal["auto", "required"]],
parallel_tool_calls: bool = True,
thinking_mode: bool = False,
) -> Optional[ToolCallConstraint]:
"""
Returns the appropriate structure constraint for tool calls based on the tool_choice.
The constraint is used to guide the model's output format.
Args:
tool_choice: The tool choice setting from the request
Returns:
A tuple of (constraint_type, constraint_value) to be added to sampling parameters,
or None if no constraint applies.
"""
is_required = tool_choice == "required" or isinstance(tool_choice, ToolChoice)
should_constrain_auto = tool_choice == "auto" and (
any(tool.function.strict for tool in self.tools)
or self.tool_strict_level >= ToolStrictLevel.FUNCTION
)
# Highest priority: model-native structural_tag when available.
try:
if is_required or should_constrain_auto:
structural_tag = self.detector.get_structural_tag(
tools=self.tools,
thinking_mode=thinking_mode,
tool_choice=tool_choice,
)
if structural_tag is not None:
return ("structural_tag", structural_tag)
# Fallback to legacy structural tag if model-native tag is not supported.
if self.detector.supports_structural_tag():
# For "required"/named: always use structural_tag to preserve the
# model's native tool call format. Schema is only included when
# strict=True, per OpenAI protocol semantics.
# For "auto": only constrain when strict is enabled.
tag = self.get_legacy_structural_tag(at_least_one=is_required)
return ("structural_tag", tag)
if tool_choice == "required" or isinstance(tool_choice, ToolChoice):
json_schema = get_json_schema_constraint(
self.tools, tool_choice, parallel_tool_calls=parallel_tool_calls
)
return ("json_schema", json_schema)
except Exception as e:
logger.error(f"Error getting structure constraint: {e}")
return None
@@ -0,0 +1,445 @@
import json
import logging
from typing import List, Optional
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
# Gemma4 special tokens for tool calls
TOOL_CALL_START = "<|tool_call>"
TOOL_CALL_END = "<tool_call|>"
STRING_DELIM = '<|"|>'
def _parse_gemma4_value(value_str: str) -> object:
"""Parse a single Gemma4 value (after key:) into a Python object."""
value_str = value_str.strip()
if not value_str:
return value_str
# Boolean
if value_str == "true":
return True
if value_str == "false":
return False
# Number (int or float)
try:
if "." in value_str:
return float(value_str)
return int(value_str)
except ValueError:
pass
# Bare string (no <|"|> delimiters)
return value_str
def _parse_gemma4_array(arr_str: str) -> list:
"""Parse a Gemma4 array content string into a Python list."""
items: list = []
i = 0
n = len(arr_str)
while i < n:
while i < n and arr_str[i] in (" ", ",", "\n", "\t"):
i += 1
if i >= n:
break
# String element
if arr_str[i : i + len(STRING_DELIM)] == STRING_DELIM:
i += len(STRING_DELIM)
end_pos = arr_str.find(STRING_DELIM, i)
if end_pos == -1:
items.append(arr_str[i:])
break
items.append(arr_str[i:end_pos])
i = end_pos + len(STRING_DELIM)
# Nested object
elif arr_str[i] == "{":
depth = 1
obj_start = i + 1
i += 1
while i < n and depth > 0:
if arr_str[i : i + len(STRING_DELIM)] == STRING_DELIM:
i += len(STRING_DELIM)
next_delim = arr_str.find(STRING_DELIM, i)
i = next_delim + len(STRING_DELIM) if next_delim != -1 else n
continue
if arr_str[i] == "{":
depth += 1
elif arr_str[i] == "}":
depth -= 1
i += 1
items.append(_parse_gemma4_args(arr_str[obj_start : i - 1]))
# Nested array
elif arr_str[i] == "[":
depth = 1
sub_start = i + 1
i += 1
while i < n and depth > 0:
if arr_str[i] == "[":
depth += 1
elif arr_str[i] == "]":
depth -= 1
i += 1
items.append(_parse_gemma4_array(arr_str[sub_start : i - 1]))
# Bare value
else:
val_start = i
while i < n and arr_str[i] not in (",", "]"):
i += 1
items.append(_parse_gemma4_value(arr_str[val_start:i]))
return items
def _parse_gemma4_args(args_str: str) -> dict:
"""Parse Gemma4's custom key:value format into a Python dict."""
if not args_str or not args_str.strip():
return {}
result: dict = {}
i = 0
n = len(args_str)
while i < n:
# Skip whitespace and commas
while i < n and args_str[i] in (" ", ",", "\n", "\t"):
i += 1
if i >= n:
break
# Parse key (unquoted, ends at ':')
key_start = i
while i < n and args_str[i] != ":":
i += 1
if i >= n:
break
key = args_str[key_start:i].strip()
i += 1 # skip ':'
# Parse value
if i >= n:
result[key] = ""
break
# Skip whitespace after ':'
while i < n and args_str[i] in (" ", "\n", "\t"):
i += 1
if i >= n:
result[key] = ""
break
# String value: <|"|>...<|"|>
if args_str[i : i + len(STRING_DELIM)] == STRING_DELIM:
i += len(STRING_DELIM)
val_start = i
end_pos = args_str.find(STRING_DELIM, i)
if end_pos == -1:
# Unterminated string — take rest
result[key] = args_str[val_start:]
break
result[key] = args_str[val_start:end_pos]
i = end_pos + len(STRING_DELIM)
# Nested object: {...}
elif args_str[i] == "{":
depth = 1
obj_start = i + 1
i += 1
while i < n and depth > 0:
if args_str[i : i + len(STRING_DELIM)] == STRING_DELIM:
# Skip over string contents
i += len(STRING_DELIM)
next_delim = args_str.find(STRING_DELIM, i)
if next_delim == -1:
i = n
else:
i = next_delim + len(STRING_DELIM)
continue
if args_str[i] == "{":
depth += 1
elif args_str[i] == "}":
depth -= 1
i += 1
result[key] = _parse_gemma4_args(args_str[obj_start : i - 1])
# Array: [...]
elif args_str[i] == "[":
depth = 1
arr_start = i + 1
i += 1
while i < n and depth > 0:
if args_str[i : i + len(STRING_DELIM)] == STRING_DELIM:
i += len(STRING_DELIM)
next_delim = args_str.find(STRING_DELIM, i)
if next_delim == -1:
i = n
else:
i = next_delim + len(STRING_DELIM)
continue
if args_str[i] == "[":
depth += 1
elif args_str[i] == "]":
depth -= 1
i += 1
arr_content = args_str[arr_start : i - 1]
result[key] = _parse_gemma4_array(arr_content)
# Bare value (number, boolean, etc.)
else:
val_start = i
while i < n and args_str[i] not in (",", "}", "]"):
i += 1
result[key] = _parse_gemma4_value(args_str[val_start:i])
return result
def _find_matching_brace(text: str) -> int:
"""Find index of matching '}' in text, respecting STRING_DELIM and nesting.
Assumes text starts just after the opening '{'.
Returns index of closing brace, or -1 if not found (incomplete).
"""
depth = 1
i = 0
n = len(text)
delim_len = len(STRING_DELIM)
while i < n and depth > 0:
if text[i : i + delim_len] == STRING_DELIM:
i += delim_len
next_delim = text.find(STRING_DELIM, i)
if next_delim == -1:
return -1
i = next_delim + delim_len
continue
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
return (i - 1) if depth == 0 else -1
class Gemma4Detector(BaseFormatDetector):
def __init__(self):
super().__init__()
self.tool_call_start_token = TOOL_CALL_START
self.tool_call_end_token = TOOL_CALL_END
# Streaming state
self.parsed_pos: int = 0
self.is_inside_tool_call: bool = False
self.current_func_name: Optional[str] = None
self._tool_indices: Optional[dict] = None
@staticmethod
def _extract_tool_calls(text: str) -> list:
"""Extract (func_name, args_str) pairs using brace-balanced parsing."""
results = []
search_from = 0
while True:
start = text.find(TOOL_CALL_START, search_from)
if start == -1:
break
end = text.find(TOOL_CALL_END, start)
if end == -1:
break
inner = text[start + len(TOOL_CALL_START) : end]
if inner.startswith("call:"):
brace = inner.find("{")
if brace != -1:
func_name = inner[5:brace]
args_content = inner[brace + 1 :]
match_idx = _find_matching_brace(args_content)
args_str = (
args_content[:match_idx] if match_idx != -1 else args_content
)
results.append((func_name, args_str))
search_from = end + len(TOOL_CALL_END)
return results
def has_tool_call(self, text: str) -> bool:
return self.tool_call_start_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
if self.tool_call_start_token not in text:
return StreamingParseResult(normal_text=text)
calls = []
try:
matches = self._extract_tool_calls(text)
if not matches:
return StreamingParseResult(normal_text=text)
tool_indices = self._get_tool_indices(tools)
for func_name, args_str in matches:
arguments = _parse_gemma4_args(args_str)
calls.append(
ToolCallItem(
tool_index=tool_indices.get(func_name, -1),
name=func_name,
parameters=json.dumps(arguments, ensure_ascii=False),
)
)
# Content = text before first tool call
content_end = text.find(self.tool_call_start_token)
normal_text = text[:content_end] if content_end > 0 else ""
return StreamingParseResult(normal_text=normal_text, calls=calls)
except (ValueError, IndexError, TypeError, KeyError) as e:
logger.error(f"Error in detect_and_parse: {e}", exc_info=True)
return StreamingParseResult(normal_text=text)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
self._buffer += new_text
if not self._buffer:
return StreamingParseResult()
calls = []
normal_text_chunks = []
if self._tool_indices is None:
self._tool_indices = self._get_tool_indices(tools)
try:
while True:
current_slice = self._buffer[self.parsed_pos :]
if not current_slice:
break
if not self.is_inside_tool_call:
# Outside tool call block
next_start = current_slice.find(self.tool_call_start_token)
if next_start == -1:
# Check for partial match at the end
partial_len = self._ends_with_partial_token(
current_slice, self.tool_call_start_token
)
if partial_len > 0:
text_to_append = current_slice[:-partial_len]
if text_to_append:
normal_text_chunks.append(text_to_append)
self.parsed_pos += len(text_to_append)
break
else:
normal_text_chunks.append(current_slice)
self.parsed_pos += len(current_slice)
continue
elif next_start == 0:
self.parsed_pos += len(self.tool_call_start_token)
self.is_inside_tool_call = True
continue
else:
normal_text_chunks.append(current_slice[:next_start])
self.parsed_pos += next_start
continue
else:
# Inside tool call block
# Check for TOOL_CALL_END first
if current_slice.startswith(self.tool_call_end_token):
self.parsed_pos += len(self.tool_call_end_token)
self.is_inside_tool_call = False
self.current_func_name = None
continue
if not self.current_func_name:
# Skip leading whitespace
if current_slice[0] in (" ", "\n", "\t"):
self.parsed_pos += 1
continue
if current_slice.startswith("call:"):
brace_pos = current_slice.find("{")
if brace_pos != -1:
func_name = current_slice[5:brace_pos]
self.current_tool_id += 1
self.current_func_name = func_name
self.current_tool_name_sent = True
calls.append(
ToolCallItem(
tool_index=self._tool_indices.get(
func_name, -1
),
name=func_name,
parameters="",
)
)
self.parsed_pos += brace_pos + 1
continue
else:
# Incomplete call:name{
break
else:
# Check for partial matches
if "call:".startswith(
current_slice
) or self.tool_call_end_token.startswith(current_slice):
break
# Unexpected content, skip
self.parsed_pos += 1
continue
else:
# Parsing arguments (looking for balancing })
match_idx = _find_matching_brace(current_slice)
if match_idx != -1:
args_str = current_slice[:match_idx]
arguments = _parse_gemma4_args(args_str)
calls.append(
ToolCallItem(
tool_index=self._tool_indices.get(
self.current_func_name, -1
),
parameters=json.dumps(
arguments, ensure_ascii=False
),
)
)
self.parsed_pos += match_idx + 1
self.current_func_name = None
continue
else:
# Incomplete arguments block
break
except (ValueError, IndexError, TypeError, KeyError) as e:
logger.error(f"Error in parse_streaming_increment: {e}", exc_info=True)
# Reset parser state to prevent corruption
self.is_inside_tool_call = False
self.current_func_name = None
self._buffer = ""
self.parsed_pos = 0
if self.parsed_pos > 0:
self._buffer = self._buffer[self.parsed_pos :]
self.parsed_pos = 0
normal_text = "".join(normal_text_chunks) if normal_text_chunks else ""
return StreamingParseResult(calls=calls, normal_text=normal_text)
def supports_structural_tag(self) -> bool:
return False
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
@@ -0,0 +1,202 @@
import json
import logging
import re
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
REGEX_FUNCTION_CALL = re.compile(
r"(?:function call<\|role_sep\|>\n|<\|function_call\|>)(.*)",
re.DOTALL,
)
REGEX_CONTENT_PATTERN = re.compile(
r"^(.*?)(?:<\|message_sep\|>|<\|function_call\|>)",
re.DOTALL,
)
NAME_REGEX = re.compile(
r'"name"\s*:\s*"([^"]*)"',
re.DOTALL,
)
ARGS_REGEX = re.compile(
r'"arguments"\s*:\s*(.*)',
re.DOTALL,
)
class GigaChat3Detector(BaseFormatDetector):
def __init__(self) -> None:
super().__init__()
self.tool_started: bool = False
self.tool_name_sent: bool = False
self.end_content: bool = False
self._buffer: str = ""
self.prev_tool_call_arr: list[dict] = []
def has_tool_call(self, text: str) -> bool:
"""Check if text contains a tool call marker"""
return "function call<|role_sep|>\n" in text or "<|function_call|>" in text
def detect_and_parse(
self,
text: str,
tools: List[Tool],
) -> StreamingParseResult:
"""
Non-streaming parsing of complete model output.
Extracts tool calls and content from the full text.
"""
logger.debug(f"[GigaChat3] detect_and_parse: {text}")
model_output = text
function_call = None
content = None
if model_output.rstrip().endswith("</s>"):
model_output = model_output[: model_output.rfind("</s>")]
m_func = REGEX_FUNCTION_CALL.search(model_output)
if m_func:
try:
function_call = json.loads(m_func.group(1), strict=False)
if not (
isinstance(function_call, dict)
and "name" in function_call
and "arguments" in function_call
):
function_call = None
elif not isinstance(function_call["arguments"], dict):
function_call = None
except json.JSONDecodeError as e:
logger.warning(f"[GigaChat3] JSON decode error: {e}")
return StreamingParseResult(
normal_text=model_output,
calls=[],
)
m_content = REGEX_CONTENT_PATTERN.search(model_output)
if m_content:
content = m_content.group(1)
else:
content = model_output
if not function_call:
return StreamingParseResult(normal_text=content, calls=[])
name = function_call["name"]
args = function_call["arguments"]
match_result = {"name": name, "arguments": args}
calls = self.parse_base_json(match_result, tools)
return StreamingParseResult(normal_text=content, calls=calls)
def parse_streaming_increment(
self,
new_text: str,
tools: List[Tool],
) -> StreamingParseResult:
"""
Streaming parser for incremental text chunks.
Maintains state across calls to build complete tool calls.
"""
if not new_text:
return StreamingParseResult()
logger.debug(f"[GigaChat3] parse_streaming_increment: '{new_text}'")
self._buffer += new_text
current_text = self._buffer
delta_text = new_text
content = None
func_name = None
cur_args = None
m_func = REGEX_FUNCTION_CALL.search(current_text)
if not self.tool_started:
m_content = REGEX_CONTENT_PATTERN.search(delta_text)
if m_content:
content = m_content.group(1)
self.end_content = True
else:
if not self.end_content:
content = delta_text
if m_func:
self.tool_started = True
logger.debug("[GigaChat3] Tool call started")
if content:
return StreamingParseResult(normal_text=content)
if not m_func:
return StreamingParseResult()
json_tail = m_func.group(1).strip()
name_match = NAME_REGEX.search(json_tail)
if name_match:
func_name = name_match.group(1)
args_match = ARGS_REGEX.search(json_tail)
if args_match:
cur_args = args_match.group(1).strip()
if cur_args.endswith("</s>"):
cur_args = cur_args[: -len("</s>")]
if cur_args.endswith("}"):
try:
candidate = cur_args[:-1].strip()
json.loads(candidate, strict=False)
cur_args = candidate
except json.JSONDecodeError:
pass
calls: List[ToolCallItem] = []
if not self.prev_tool_call_arr:
self.prev_tool_call_arr.append({})
if not self.tool_name_sent:
if not func_name:
return StreamingParseResult()
self.tool_name_sent = True
self.prev_tool_call_arr[0]["name"] = func_name
logger.debug(f"[GigaChat3] Sending tool name: {func_name}")
calls.append(
ToolCallItem(
tool_index=0,
name=func_name,
parameters="",
)
)
return StreamingParseResult(calls=calls)
if cur_args is None:
return StreamingParseResult()
prev_args = self.prev_tool_call_arr[0].get("arguments_str", "")
if not prev_args:
delta_args = cur_args
elif cur_args.startswith(prev_args):
delta_args = cur_args[len(prev_args) :]
else:
logger.warning(
f"[GigaChat3] Arguments overlap mismatch. "
f"prev='{prev_args[:50]}...' cur='{cur_args[:50]}...'"
)
return StreamingParseResult()
if not delta_args:
return StreamingParseResult()
self.prev_tool_call_arr[0]["arguments_str"] = cur_args
try:
args_dict = json.loads(cur_args, strict=False)
self.prev_tool_call_arr[0]["arguments"] = args_dict
except json.JSONDecodeError:
self.prev_tool_call_arr[0]["arguments"] = {}
logger.debug(f"[GigaChat3] Sending args delta: '{delta_args[:100]}...'")
calls.append(
ToolCallItem(
tool_index=0,
name=None,
parameters=delta_args,
)
)
return StreamingParseResult(calls=calls)
def supports_structural_tag(self) -> bool:
"""GigaChat3 does not use structural tags"""
return False
def structure_info(self) -> _GetInfoFunc:
"""Not applicable for GigaChat3"""
raise NotImplementedError(
"GigaChat3Detector does not support structural_tag format."
)
@@ -0,0 +1,822 @@
import ast
import json
import logging
import re
from enum import Enum
from functools import lru_cache
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice
from sglang.srt.function_call.base_format_detector import (
BaseFormatDetector,
StructuralTag,
get_model_structural_tag,
)
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.utils import infer_type_from_json_schema
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def _glm47_native_structural_tag_available() -> bool:
# "glm_4_7" is only registered in newer xgrammar, so the import can succeed
# while the model name stays unknown. Probe once and fall back if absent.
if get_model_structural_tag is None:
return False
try:
get_model_structural_tag(
model="glm_4_7", tools=[], tool_choice="auto", reasoning=False
)
return True
except Exception:
return False
class StreamState(str, Enum):
"""State machine states for XML to JSON streaming conversion."""
INIT = "INIT"
BETWEEN = "BETWEEN"
IN_KEY = "IN_KEY"
WAITING_VALUE = "WAITING_VALUE"
IN_VALUE = "IN_VALUE"
def get_argument_type(
func_name: str, arg_key: str, defined_tools: List[Tool]
) -> Optional[str]:
"""Get the expected type of a function argument from tool definitions.
Supports complex JSON Schema definitions including:
- Direct type field (including type arrays)
- anyOf/oneOf: parameter can be any of multiple types
- enum: parameter must be one of enum values
- allOf: parameter must satisfy all type definitions
- properties: inferred as object type
- items: inferred as array type
Args:
func_name: Name of the function/tool
arg_key: Name of the argument
defined_tools: List of available tools
Returns:
The type string (e.g., 'string', 'number', 'object') or None if not found
"""
name2tool = {tool.function.name: tool for tool in defined_tools}
# Check if function exists
tool = name2tool.get(func_name)
if not tool:
return None
# Get parameters safely using getattr
params = getattr(tool.function, "parameters", None)
if not isinstance(params, dict):
return None
# Navigate to the type using dict.get() for safe access
properties = params.get("properties")
if not isinstance(properties, dict):
return None
arg_spec = properties.get(arg_key)
if isinstance(arg_spec, dict):
# Use the new type inference function for complex JSON Schema support
return infer_type_from_json_schema(arg_spec)
return None
def _convert_to_number(value: str) -> Any:
"""Convert string to appropriate number type (int or float).
Args:
value: String value to convert
Returns:
Converted number or original string if conversion fails
"""
try:
if "." in value or "e" in value.lower():
return float(value)
else:
return int(value)
except (ValueError, AttributeError):
return value
def parse_arguments(
json_value: str, arg_type: Optional[str] = None
) -> Tuple[Any, bool]:
"""Parse argument value with multiple fallback strategies.
Args:
json_value: Raw string value to parse
arg_type: Expected type hint ('string', 'number', 'object', etc.)
Returns:
Tuple of (parsed_value, is_valid_json)
"""
# Strategy 1: Direct JSON parsing
try:
parsed_value = json.loads(json_value)
# Type coercion for number type
if arg_type == "number" and isinstance(parsed_value, str):
parsed_value = _convert_to_number(parsed_value)
return parsed_value, True
except (json.JSONDecodeError, ValueError):
pass
# Strategy 2: Unescape and parse
try:
wrapped = json.loads('{"tmp": "' + json_value + '"}')
parsed_value = json.loads(wrapped["tmp"])
if arg_type == "number" and isinstance(parsed_value, str):
parsed_value = _convert_to_number(parsed_value)
return parsed_value, True
except (json.JSONDecodeError, ValueError, KeyError):
pass
# Strategy 3: ast.literal_eval
try:
parsed_value = ast.literal_eval(json_value)
return parsed_value, True
except (ValueError, SyntaxError):
pass
# Strategy 4: Treat as string
try:
quoted_value = json.dumps(str(json_value))
return json.loads(quoted_value), True
except (json.JSONDecodeError, ValueError):
return json_value, False
class Glm47MoeDetector(BaseFormatDetector):
"""
Detector for GLM-4.7 and GLM-5 models.
Assumes function call format:
<tool_call>get_weather<arg_key>city</arg_key><arg_value>北京</arg_value><arg_key>date</arg_key><arg_value>2024-06-27</arg_value></tool_call><tool_call>get_weather<arg_key>city</arg_key><arg_value>上海</arg_value><arg_key>date</arg_key><arg_value>2024-06-27</arg_value></tool_call>
"""
def __init__(self):
super().__init__()
self.bot_token = "<tool_call>"
self.eot_token = "</tool_call>"
self.func_call_regex = r"<tool_call>.*?</tool_call>"
self.func_detail_regex = re.compile(
r"<tool_call>(.*?)(<arg_key>.*?)?</tool_call>", re.DOTALL
)
self.func_arg_regex = re.compile(
r"<arg_key>(.*?)</arg_key>(?:\\n|\s)*<arg_value>(.*?)</arg_value>",
re.DOTALL,
)
self._last_arguments = ""
self.current_tool_id = -1
self.current_tool_name_sent = False
self._streamed_raw_length = 0
self._tool_call_completed = False # Track if tool call has been completed
self._sent_empty_object = (
False # Track if empty object has been sent for no-arg functions
)
self._reset_streaming_state()
def _reset_streaming_state(self) -> None:
"""Reset the streaming state machine for a new tool call."""
self._stream_state = StreamState.INIT
self._current_key = ""
self._current_value = ""
self._xml_tag_buffer = ""
self._is_first_param = True
self._value_started = False
self._cached_value_type: Optional[str] = (
None # Cache the value type for consistency
)
self._tool_call_completed = False # Reset tool call completion status
self._sent_empty_object = False # Reset empty object sent status
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a glm-4.5 / glm-4.6 format tool call."""
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
:param text: The complete text to parse.
:param tools: List of available tools.
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
"""
if self.bot_token not in text:
return StreamingParseResult(normal_text=text, calls=[])
# Extract all normal text (before, between, and after tool calls)
normal_text_parts = []
last_end = 0
# Find all tool call matches
for match in re.finditer(self.func_call_regex, text, re.DOTALL):
# Add text before this tool call
if match.start() > last_end:
normal_text_parts.append(text[last_end : match.start()])
last_end = match.end()
# Add any remaining text after the last tool call
if last_end < len(text):
normal_text_parts.append(text[last_end:])
# Combine all normal text parts
normal_text = "".join(normal_text_parts).strip()
# Parse tool calls
match_result_list = re.findall(self.func_call_regex, text, re.DOTALL)
calls = []
try:
for match_result in match_result_list:
# Get function name
func_detail = self.func_detail_regex.search(match_result)
if func_detail is None:
continue
func_name = func_detail.group(1) if func_detail.group(1) else ""
func_args = func_detail.group(2) if func_detail.group(2) else ""
arguments = {}
if func_args:
pairs = self.func_arg_regex.findall(func_args)
# Parse arguments using shared method
arguments = self._parse_argument_pairs(pairs, func_name, tools)
# construct match_result for parse_base_json
match_result = {"name": func_name, "parameters": arguments}
calls.extend(self.parse_base_json(match_result, tools))
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}", exc_info=True)
# return the normal text if parsing fails
return StreamingParseResult(normal_text=text)
def _get_value_type(self, func_name: str, key: str, tools: List[Tool]) -> str:
"""Get parameter type from tool definition, with fallback to auto-detection.
Args:
func_name: Name of the function
key: Parameter name
tools: List of available tools
Returns:
Type string: 'string', 'number', 'object', 'array', or 'boolean'
"""
arg_type = get_argument_type(func_name, key, tools)
if arg_type:
return arg_type
# Improved auto-detection type from value (best effort)
value_content = self._current_value.strip() if self._current_value else ""
if not value_content:
return "string"
# Try to parse as valid JSON first
try:
parsed = json.loads(value_content)
if isinstance(parsed, dict):
return "object"
elif isinstance(parsed, list):
return "array"
elif isinstance(parsed, bool):
return "boolean"
elif isinstance(parsed, (int, float)):
return "number"
# For string values, check if they look like numbers
elif isinstance(parsed, str):
if parsed.isdigit() or (
parsed.startswith("-") and parsed[1:].isdigit()
):
return "number"
return "string"
except json.JSONDecodeError:
# Not valid JSON, try heuristic detection
first_char = value_content[0] if value_content else ""
if first_char.isdigit() or first_char in ["-", "."]:
return "number"
elif first_char in ["{", "["]:
return "object"
elif first_char in ['"', "'"]:
return "string"
# Default to string (safest fallback)
return "string"
def _format_value_complete(self, value: str, value_type: str) -> str:
"""Format complete value based on type.
Args:
value: Raw value string
value_type: Expected type ('string', 'number', 'object')
Returns:
Properly formatted JSON value string
"""
if value_type == "string":
# Ensure proper JSON string formatting with quotes
return json.dumps(value, ensure_ascii=False)
elif value_type == "number":
try:
num = _convert_to_number(value.strip() if value else "")
return str(num)
except (ValueError, AttributeError):
# Fallback to string if not a valid number
logger.warning(
f"Failed to parse '{value}' as number, treating as string"
)
return json.dumps(str(value) if value else "", ensure_ascii=False)
else:
# For object/array types, return as-is (should already be valid JSON)
return value
def _process_xml_to_json_streaming(
self, raw_increment: str, func_name: str, tools: List[Tool]
) -> str:
"""Convert XML increment to JSON streaming output using state machine.
This method processes XML fragments character by character and converts them
to JSON format incrementally. It maintains state across calls to handle
partial XML tags and values.
Args:
raw_increment: New XML content to process
func_name: Name of the function being called
tools: List of available tools for type inference
Returns:
JSON string increment to append to the output
"""
json_output = ""
for char in raw_increment:
self._xml_tag_buffer += char
if self._stream_state in [StreamState.INIT, StreamState.BETWEEN]:
if self._xml_tag_buffer.endswith("<arg_key>"):
self._stream_state = StreamState.IN_KEY
self._current_key = ""
self._xml_tag_buffer = ""
json_output += "{" if self._is_first_param else ", "
self._is_first_param = False
elif self._stream_state == StreamState.IN_KEY:
if self._xml_tag_buffer.endswith("</arg_key>"):
self._current_key = self._xml_tag_buffer[:-10].strip()
self._xml_tag_buffer = ""
self._stream_state = StreamState.WAITING_VALUE
json_output += (
json.dumps(self._current_key, ensure_ascii=False) + ": "
)
elif self._stream_state == StreamState.WAITING_VALUE:
if self._xml_tag_buffer.endswith("<arg_value>"):
self._stream_state = StreamState.IN_VALUE
self._current_value = ""
self._xml_tag_buffer = ""
self._value_started = False
# Determine and cache the value type at the start
self._cached_value_type = self._get_value_type(
func_name, self._current_key, tools
)
elif self._stream_state == StreamState.IN_VALUE:
if self._xml_tag_buffer.endswith("</arg_value>"):
final_value = self._xml_tag_buffer[:-12]
self._current_value += final_value
# Use cached value type for consistency
value_type = self._cached_value_type or "string"
if self._value_started:
# Output any remaining content
if final_value:
if value_type == "string":
json_output += json.dumps(
final_value, ensure_ascii=False
)[1:-1]
else:
json_output += final_value
# Always output closing quote for string type when value was started
if value_type == "string":
json_output += '"'
else:
# Value was never started (empty or complete in one chunk)
json_output += self._format_value_complete(
self._current_value, value_type
)
self._xml_tag_buffer = ""
self._stream_state = StreamState.BETWEEN
self._current_value = ""
self._value_started = False
self._cached_value_type = None # Reset cached type
else:
closing_tag = "</arg_value>"
is_potential_closing = len(self._xml_tag_buffer) <= len(
closing_tag
) and closing_tag.startswith(self._xml_tag_buffer)
if not is_potential_closing:
content = self._xml_tag_buffer
# Use cached value type for consistency
value_type = self._cached_value_type or "string"
if value_type == "string":
if not self._value_started:
json_output += '"'
self._value_started = True
if content:
json_output += json.dumps(content, ensure_ascii=False)[
1:-1
]
self._current_value += content
self._xml_tag_buffer = ""
elif value_type == "number":
if content:
if not self._value_started:
self._value_started = True
json_output += content
self._current_value += content
self._xml_tag_buffer = ""
else:
# For object/array types, output as-is
if content:
if not self._value_started:
self._value_started = True
json_output += content
self._current_value += content
self._xml_tag_buffer = ""
return json_output
def _extract_match_groups(self, match: re.Match) -> tuple[str, str, str]:
"""Extract function name, arguments and end marker from regex match.
Args:
match: Regex match object
Returns:
(func_name, func_args_raw, is_tool_end)
"""
func_name = match.group(1).strip()
func_args_raw = match.group(2).strip() if match.group(2) else ""
is_tool_end = match.group(3) or ""
return func_name, func_args_raw, is_tool_end
def _send_tool_name_if_needed(
self, func_name: str, has_arg_key: bool, is_tool_end: str
) -> Optional[ToolCallItem]:
"""Send tool name if needed.
Args:
func_name: Function name
has_arg_key: Whether current text contains <arg_key
is_tool_end: Whether end marker is encountered
Returns:
Tool call item or None
"""
if self.current_tool_name_sent:
return None
# Function name completeness check
is_func_name_complete = has_arg_key or is_tool_end == self.eot_token
if not is_func_name_complete:
return None
if not func_name:
logger.warning("Empty function name detected, skipping tool call")
return None
# Send tool name
self.current_tool_name_sent = True
self._streamed_raw_length = 0
self._reset_streaming_state()
# Record tool info
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
return ToolCallItem(
tool_index=self.current_tool_id,
name=func_name,
parameters="",
)
def _process_arguments_streaming(
self, func_name: str, func_args_raw: str, tools: List[Tool]
) -> Optional[ToolCallItem]:
"""Process streaming arguments.
Args:
func_name: Function name
func_args_raw: Raw argument string
tools: List of available tools
Returns:
Tool call item with parameter updates or None
"""
current_raw_length = len(func_args_raw)
if current_raw_length <= self._streamed_raw_length:
return None
# Get new raw XML content
raw_increment = func_args_raw[self._streamed_raw_length :]
# Convert XML to JSON using state machine
json_increment = self._process_xml_to_json_streaming(
raw_increment, func_name, tools
)
# CRITICAL: Update streamed length BEFORE early return
# Even if json_increment is empty, the input has been consumed by the state machine
self._streamed_raw_length = current_raw_length
if not json_increment:
return None
# Update state
self._last_arguments += json_increment
self.streamed_args_for_tool[self.current_tool_id] += json_increment
return ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=json_increment,
)
def _finalize_tool_call(
self,
func_name: str,
func_args_raw: str,
tools: List[Tool],
match_end_pos: int,
current_text: str,
) -> List[ToolCallItem]:
"""Complete tool call processing.
Args:
func_name: Function name
func_args_raw: Raw argument string
tools: List of available tools
match_end_pos: Match end position
current_text: Current text
Returns:
List of tool call items to add
"""
calls = []
# Handle no-arg function or need to close braces
if self._is_first_param and not self._sent_empty_object:
# No-arg function
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters="{}",
)
)
self._last_arguments += "{}"
self.streamed_args_for_tool[self.current_tool_id] += "{}"
self._sent_empty_object = True
elif not self._last_arguments.endswith("}") and not self._sent_empty_object:
# Need to close brace
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters="}",
)
)
self._last_arguments += "}"
self.streamed_args_for_tool[self.current_tool_id] += "}"
self._sent_empty_object = True
# Parse final arguments
if func_args_raw:
try:
pairs = self.func_arg_regex.findall(func_args_raw)
if pairs:
arguments = self._parse_argument_pairs(pairs, func_name, tools)
self.prev_tool_call_arr[self.current_tool_id][
"arguments"
] = arguments
except Exception as e:
logger.debug(f"Failed to parse arguments: {e}", exc_info=True)
# Clean buffer
self._buffer = current_text[match_end_pos:]
# Reset state for next tool call
self._tool_call_completed = True
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
self._streamed_raw_length = 0
self._reset_streaming_state()
return calls
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing tool calls for GLM-4.5 and GLM-4.6 format.
Uses a state machine to convert XML to JSON incrementally for true character-by-character streaming.
Outputs JSON increments immediately as XML data arrives.
"""
self._buffer += new_text
current_text = self._buffer
# Check if we have a tool call
has_tool_call = self.bot_token in current_text
if not has_tool_call:
# Check if buffer could be the start of a tool call
# Keep buffer if it could be a partial match of bot_token
is_potential_start = any(
self.bot_token.startswith(current_text[-i:])
for i in range(1, min(len(current_text), len(self.bot_token)) + 1)
)
if not is_potential_start:
# Not a potential tool call, return as normal text
# Must return the entire buffer (current_text), not just new_text,
# because buffer may contain previously accumulated characters like '<'
# that turned out not to be part of a tool call
output_text = current_text
self._buffer = ""
if self.eot_token in output_text:
output_text = output_text.replace(self.eot_token, "")
return StreamingParseResult(normal_text=output_text)
else:
# Could be start of tool call, keep buffering
return StreamingParseResult(normal_text="", calls=[])
# Extract any text before the first bot_token and return it as normal_text
normal_text = ""
first_bot_token_idx = current_text.find(self.bot_token)
if first_bot_token_idx > 0:
normal_text = current_text[:first_bot_token_idx]
current_text = current_text[first_bot_token_idx:]
# Update buffer to only include from the bot token onwards
self._buffer = current_text
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
calls: list[ToolCallItem] = []
try:
# Try to match a partial or complete tool call
# Use a single flexible regex pattern that handles all cases
partial_match = re.search(
r"<tool_call>(.*?)(?:(<arg_key.*?))?(?:(</tool_call>)|$)",
current_text,
re.DOTALL,
)
if not partial_match:
return StreamingParseResult(normal_text=normal_text, calls=[])
# Extract match groups using helper method
func_name, func_args_raw, is_tool_end = self._extract_match_groups(
partial_match
)
# Initialize tool call state if needed (keeping existing logic)
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
self._streamed_raw_length = 0
self.current_tool_name_sent = False # Reset for new tool call
self._reset_streaming_state()
# Check if this is a continuation of an existing tool call or a new one
elif not self.current_tool_name_sent:
# Only increment tool_id if we're truly starting a NEW tool call
# Don't increment if this is just the first time we're processing
# a tool call that was received in the buffer
# The key insight: only increment when we've COMPLETED a previous tool call
# and now see another bot_token in new_text
pass # Remove the problematic auto-increment logic
# Ensure tracking arrays are large enough (keeping existing logic)
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Determine if function name is complete by checking for <arg_key> in the full text
# This is important for streaming scenarios where args come in later chunks
has_arg_key = "<arg_key" in current_text
# Send tool name if needed
tool_name_item = self._send_tool_name_if_needed(
func_name, has_arg_key, is_tool_end
)
if tool_name_item:
calls.append(tool_name_item)
# Process streaming arguments if tool name has been sent
if self.current_tool_name_sent:
arg_item = self._process_arguments_streaming(
func_name, func_args_raw, tools
)
if arg_item:
calls.append(arg_item)
# Finalize tool call if end token is encountered
if is_tool_end == self.eot_token and not self._tool_call_completed:
finalize_calls = self._finalize_tool_call(
func_name,
func_args_raw,
tools,
partial_match.end(),
current_text,
)
calls.extend(finalize_calls)
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}", exc_info=True)
return StreamingParseResult(normal_text=current_text)
return StreamingParseResult(normal_text=normal_text, calls=calls)
def _parse_argument_pairs(
self, pairs: List[Tuple[str, str]], func_name: str, tools: List[Tool]
) -> Dict[str, Any]:
"""Parse argument key-value pairs with type coercion.
Args:
pairs: List of (key, value) tuples from regex matching
func_name: Name of the function
tools: List of available tools
Returns:
Dictionary of parsed arguments
"""
arguments = {}
for arg_key, arg_value in pairs:
arg_key = arg_key.strip()
arg_type = get_argument_type(func_name, arg_key, tools)
parsed_value, is_good_json = parse_arguments(arg_value, arg_type)
if arg_type == "string":
# Only convert to string if explicitly defined as string type
if isinstance(parsed_value, str):
arguments[arg_key] = parsed_value
elif isinstance(parsed_value, (dict, list)):
# If parsed as dict/list but schema says string, convert to JSON string
arguments[arg_key] = json.dumps(parsed_value, ensure_ascii=False)
else:
arguments[arg_key] = str(parsed_value)
elif arg_type is None:
# If type is not defined, keep the parsed value as-is
arguments[arg_key] = parsed_value if is_good_json else arg_value
else:
# For other types (number, object, array, etc.), use parsed value
arguments[arg_key] = parsed_value if is_good_json else arg_value
return arguments
def supports_structural_tag(self) -> bool:
return _glm47_native_structural_tag_available()
def get_structural_tag(
self,
tools: Union[List[Tool], None] = None,
tool_choice: Union[ToolChoice, Literal["auto", "required"]] = "auto",
thinking_mode: bool = False,
) -> Optional[StructuralTag]:
if not self.supports_structural_tag():
return None
return super().get_structural_tag(
tools=tools, tool_choice=tool_choice, thinking_mode=thinking_mode
)
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
def get_structural_tag_name(self) -> str:
return "glm_4_7"
@@ -0,0 +1,641 @@
import ast
import json
import logging
import re
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.utils import infer_type_from_json_schema
logger = logging.getLogger(__name__)
class StreamState(str, Enum):
"""State machine states for XML to JSON streaming conversion."""
INIT = "INIT"
BETWEEN = "BETWEEN"
IN_KEY = "IN_KEY"
WAITING_VALUE = "WAITING_VALUE"
IN_VALUE = "IN_VALUE"
def get_argument_type(
func_name: str, arg_key: str, defined_tools: List[Tool]
) -> Optional[str]:
"""Get the expected type of a function argument from tool definitions.
Supports complex JSON Schema definitions including:
- Direct type field (including type arrays)
- anyOf/oneOf: parameter can be any of multiple types
- enum: parameter must be one of enum values
- allOf: parameter must satisfy all type definitions
- properties: inferred as object type
- items: inferred as array type
Args:
func_name: Name of the function/tool
arg_key: Name of the argument
defined_tools: List of available tools
Returns:
The type string (e.g., 'string', 'number', 'object') or None if not found
"""
name2tool = {tool.function.name: tool for tool in defined_tools}
if func_name not in name2tool:
return None
tool = name2tool[func_name]
properties = (tool.function.parameters or {}).get("properties", {})
if not isinstance(properties, dict):
properties = {}
if arg_key not in properties:
return None
# Use new type inference function for complex JSON Schema support
return infer_type_from_json_schema(properties[arg_key])
def _convert_to_number(value: str) -> Any:
"""Convert string to appropriate number type (int or float).
Args:
value: String value to convert
Returns:
Converted number or original string if conversion fails
"""
try:
if "." in value or "e" in value.lower():
return float(value)
else:
return int(value)
except (ValueError, AttributeError):
return value
def parse_arguments(
json_value: str, arg_type: Optional[str] = None
) -> Tuple[Any, bool]:
"""Parse argument value with multiple fallback strategies.
Args:
json_value: Raw string value to parse
arg_type: Expected type hint ('string', 'number', 'object', etc.)
Returns:
Tuple of (parsed_value, is_valid_json)
"""
# Strategy 1: Direct JSON parsing
try:
parsed_value = json.loads(json_value)
# Type coercion for number type
if arg_type == "number" and isinstance(parsed_value, str):
parsed_value = _convert_to_number(parsed_value)
return parsed_value, True
except (json.JSONDecodeError, ValueError):
pass
# Strategy 2: Unescape and parse
try:
wrapped = json.loads('{"tmp": "' + json_value + '"}')
parsed_value = json.loads(wrapped["tmp"])
if arg_type == "number" and isinstance(parsed_value, str):
parsed_value = _convert_to_number(parsed_value)
return parsed_value, True
except (json.JSONDecodeError, ValueError, KeyError):
pass
# Strategy 3: ast.literal_eval
try:
parsed_value = ast.literal_eval(json_value)
return parsed_value, True
except (ValueError, SyntaxError):
pass
# Strategy 4: Treat as string
try:
quoted_value = json.dumps(str(json_value))
return json.loads(quoted_value), True
except (json.JSONDecodeError, ValueError):
return json_value, False
class Glm4MoeDetector(BaseFormatDetector):
"""
Detector for GLM-4.5 and GLM-4.6 models.
Assumes function call format (with actual newlines):
<tool_call>get_weather
<arg_key>city</arg_key>
<arg_value>北京</arg_value>
<arg_key>date</arg_key>
<arg_value>2024-06-27</arg_value>
</tool_call>
Or with literal \n characters (escaped as \\n in the output):
<tool_call>get_weather\n<arg_key>city</arg_key>\n<arg_value>北京</arg_value>\n</tool_call>
Uses a streaming state machine to convert XML to JSON incrementally for maximum speed.
"""
def __init__(self):
super().__init__()
self.bot_token = "<tool_call>"
self.eot_token = "</tool_call>"
self.func_call_regex = r"<tool_call>.*?</tool_call>"
self.func_detail_regex = re.compile(
r"<tool_call>(.*?)(?:\\n|\n)(.*)</tool_call>", re.DOTALL
)
self.func_arg_regex = re.compile(
r"<arg_key>(.*?)</arg_key>(?:\\n|\s)*<arg_value>(.*?)</arg_value>",
re.DOTALL,
)
self._last_arguments = ""
self.current_tool_id = -1
self.current_tool_name_sent = False
self._streamed_raw_length = 0
self._reset_streaming_state()
def _reset_streaming_state(self) -> None:
"""Reset the streaming state machine for a new tool call."""
self._stream_state = StreamState.INIT
self._current_key = ""
self._current_value = ""
self._xml_tag_buffer = ""
self._is_first_param = True
self._value_started = False
self._cached_value_type: Optional[str] = (
None # Cache the value type for consistency
)
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a glm-4.5 / glm-4.6 format tool call."""
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
:param text: The complete text to parse.
:param tools: List of available tools.
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
"""
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if self.bot_token not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
match_result_list = re.findall(self.func_call_regex, text, re.DOTALL)
calls = []
try:
for match_result in match_result_list:
# Get function name
func_detail = self.func_detail_regex.search(match_result)
if func_detail is None:
continue
func_name = func_detail.group(1) if func_detail.group(1) else ""
func_args = func_detail.group(2) if func_detail.group(2) else ""
pairs = self.func_arg_regex.findall(func_args)
# Parse arguments using shared method
arguments = self._parse_argument_pairs(pairs, func_name, tools)
# construct match_result for parse_base_json
match_result = {"name": func_name, "parameters": arguments}
calls.extend(self.parse_base_json(match_result, tools))
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}", exc_info=True)
# return the normal text if parsing fails
return StreamingParseResult(normal_text=text)
def _get_value_type(self, func_name: str, key: str, tools: List[Tool]) -> str:
"""Get parameter type from tool definition, with fallback to auto-detection.
Args:
func_name: Name of the function
key: Parameter name
tools: List of available tools
Returns:
Type string: 'string', 'number', 'object', 'array', or 'boolean'
"""
arg_type = get_argument_type(func_name, key, tools)
if arg_type:
return arg_type
# Improved auto-detection type from value (best effort)
value_content = self._current_value.strip() if self._current_value else ""
if not value_content:
return "string"
# Try to parse as valid JSON first
try:
parsed = json.loads(value_content)
if isinstance(parsed, dict):
return "object"
elif isinstance(parsed, list):
return "array"
elif isinstance(parsed, bool):
return "boolean"
elif isinstance(parsed, (int, float)):
return "number"
# For string values, check if they look like numbers
elif isinstance(parsed, str):
if parsed.isdigit() or (
parsed.startswith("-") and parsed[1:].isdigit()
):
return "number"
return "string"
except json.JSONDecodeError:
# Not valid JSON, try heuristic detection
first_char = value_content[0] if value_content else ""
if first_char.isdigit() or first_char in ["-", "."]:
return "number"
elif first_char in ["{", "["]:
return "object"
elif first_char in ['"', "'"]:
return "string"
# Default to string (safest fallback)
return "string"
def _format_value_complete(self, value: str, value_type: str) -> str:
"""Format complete value based on type.
Args:
value: Raw value string
value_type: Expected type ('string', 'number', 'object')
Returns:
Properly formatted JSON value string
"""
if value_type == "string":
# Ensure proper JSON string formatting with quotes
return json.dumps(value, ensure_ascii=False)
elif value_type == "number":
try:
num = _convert_to_number(value.strip())
return str(num)
except (ValueError, AttributeError):
# Fallback to string if not a valid number
logger.warning(
f"Failed to parse '{value}' as number, treating as string"
)
return json.dumps(str(value), ensure_ascii=False)
else:
# For object/array types, return as-is (should already be valid JSON)
return value
def _process_xml_to_json_streaming(
self, raw_increment: str, func_name: str, tools: List[Tool]
) -> str:
"""Convert XML increment to JSON streaming output using state machine.
This method processes XML fragments character by character and converts them
to JSON format incrementally. It maintains state across calls to handle
partial XML tags and values.
Args:
raw_increment: New XML content to process
func_name: Name of the function being called
tools: List of available tools for type inference
Returns:
JSON string increment to append to the output
"""
json_output = ""
for char in raw_increment:
self._xml_tag_buffer += char
if self._stream_state in [StreamState.INIT, StreamState.BETWEEN]:
if self._xml_tag_buffer.endswith("<arg_key>"):
self._stream_state = StreamState.IN_KEY
self._current_key = ""
self._xml_tag_buffer = ""
json_output += "{" if self._is_first_param else ", "
self._is_first_param = False
elif self._stream_state == StreamState.IN_KEY:
if self._xml_tag_buffer.endswith("</arg_key>"):
self._current_key = self._xml_tag_buffer[:-10].strip()
self._xml_tag_buffer = ""
self._stream_state = StreamState.WAITING_VALUE
json_output += (
json.dumps(self._current_key, ensure_ascii=False) + ": "
)
elif self._stream_state == StreamState.WAITING_VALUE:
if self._xml_tag_buffer.endswith("<arg_value>"):
self._stream_state = StreamState.IN_VALUE
self._current_value = ""
self._xml_tag_buffer = ""
self._value_started = False
# Determine and cache the value type at the start
self._cached_value_type = self._get_value_type(
func_name, self._current_key, tools
)
elif self._stream_state == StreamState.IN_VALUE:
if self._xml_tag_buffer.endswith("</arg_value>"):
final_value = self._xml_tag_buffer[:-12]
self._current_value += final_value
# Use cached value type for consistency
value_type = self._cached_value_type or "string"
if self._value_started:
# Output any remaining content
if final_value:
if value_type == "string":
json_output += json.dumps(
final_value, ensure_ascii=False
)[1:-1]
else:
json_output += final_value
# Always output closing quote for string type when value was started
if value_type == "string":
json_output += '"'
else:
# Value was never started (empty or complete in one chunk)
json_output += self._format_value_complete(
self._current_value, value_type
)
self._xml_tag_buffer = ""
self._stream_state = StreamState.BETWEEN
self._current_value = ""
self._value_started = False
self._cached_value_type = None # Reset cached type
else:
closing_tag = "</arg_value>"
is_potential_closing = len(self._xml_tag_buffer) <= len(
closing_tag
) and closing_tag.startswith(self._xml_tag_buffer)
if not is_potential_closing:
content = self._xml_tag_buffer
# Use cached value type for consistency
value_type = self._cached_value_type or "string"
if value_type == "string":
if not self._value_started:
json_output += '"'
self._value_started = True
if content:
json_output += json.dumps(content, ensure_ascii=False)[
1:-1
]
self._current_value += content
self._xml_tag_buffer = ""
elif value_type == "number":
if content:
if not self._value_started:
self._value_started = True
json_output += content
self._current_value += content
self._xml_tag_buffer = ""
else:
# For object/array types, output as-is
if content:
if not self._value_started:
self._value_started = True
json_output += content
self._current_value += content
self._xml_tag_buffer = ""
return json_output
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing tool calls for GLM-4.5 and GLM-4.6 format.
Uses a state machine to convert XML to JSON incrementally for true character-by-character streaming.
Outputs JSON increments immediately as XML data arrives.
"""
self._buffer += new_text
current_text = self._buffer
# Check if we have a tool call
has_tool_call = self.bot_token in current_text
if not has_tool_call:
# Check if buffer could be the start of a tool call
# Keep buffer if it could be a partial match of bot_token
is_potential_start = any(
self.bot_token.startswith(current_text[-i:])
for i in range(1, min(len(current_text), len(self.bot_token)) + 1)
)
if not is_potential_start:
# Not a potential tool call, return as normal text
# Must return the entire buffer (current_text), not just new_text,
# because buffer may contain previously accumulated characters like '<'
# that turned out not to be part of a tool call
output_text = current_text
self._buffer = ""
if self.eot_token in output_text:
output_text = output_text.replace(self.eot_token, "")
return StreamingParseResult(normal_text=output_text)
else:
# Could be start of tool call, keep buffering
return StreamingParseResult(normal_text="", calls=[])
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
calls: list[ToolCallItem] = []
try:
# Try to match a partial or complete tool call
partial_match = re.search(
pattern=r"<tool_call>(.*?)(?:\\n|\n)(.*?)(</tool_call>|$)",
string=current_text,
flags=re.DOTALL,
)
if partial_match:
func_name_raw = partial_match.group(1)
func_args_raw = partial_match.group(2)
is_tool_end = partial_match.group(3)
# Only proceed if we have a non-empty function name
if func_name_raw is None or not func_name_raw.strip():
# If we only have the start token without a function name,
# continue buffering until we get more content
return StreamingParseResult(normal_text="", calls=[])
func_name = func_name_raw.strip()
func_args_raw = func_args_raw.strip() if func_args_raw else ""
# Initialize state if this is the first tool call
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
self._streamed_raw_length = 0
self.current_tool_name_sent = False
self._reset_streaming_state()
# Ensure we have enough entries in our tracking arrays
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Send tool name first if not sent yet
if not self.current_tool_name_sent:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=func_name,
parameters="",
)
)
self.current_tool_name_sent = True
self._streamed_raw_length = 0
self._reset_streaming_state()
# Store the tool call info
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
else:
# Process XML to JSON streaming
current_raw_length = len(func_args_raw)
if current_raw_length > self._streamed_raw_length:
# Get the new raw XML content
raw_increment = func_args_raw[self._streamed_raw_length :]
# Convert XML increment to JSON increment using state machine
json_increment = self._process_xml_to_json_streaming(
raw_increment, func_name, tools
)
# CRITICAL: Update streamed length BEFORE checking json_increment
# Even if json_increment is empty, the input has been consumed by the state machine
self._streamed_raw_length = current_raw_length
if json_increment:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=json_increment,
)
)
self._last_arguments += json_increment
self.streamed_args_for_tool[
self.current_tool_id
] += json_increment
if is_tool_end == self.eot_token:
if self._is_first_param:
empty_object = "{}"
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=empty_object,
)
)
self._last_arguments += empty_object
elif not self._last_arguments.endswith("}"):
closing_brace = "}"
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=closing_brace,
)
)
self._last_arguments += closing_brace
self.streamed_args_for_tool[
self.current_tool_id
] += closing_brace
try:
pairs = self.func_arg_regex.findall(func_args_raw)
if pairs:
arguments = self._parse_argument_pairs(
pairs, func_name, tools
)
self.prev_tool_call_arr[self.current_tool_id][
"arguments"
] = arguments
except Exception as e:
logger.debug(
f"Failed to parse arguments: {e}", exc_info=True
)
# Remove the completed tool call from buffer
self._buffer = current_text[partial_match.end(3) :]
result = StreamingParseResult(normal_text="", calls=calls)
self.current_tool_id += 1
self._last_arguments = ""
self.current_tool_name_sent = False
self._streamed_raw_length = 0
self._reset_streaming_state()
return result
return StreamingParseResult(normal_text="", calls=calls)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}", exc_info=True)
return StreamingParseResult(normal_text=current_text)
def _parse_argument_pairs(
self, pairs: List[Tuple[str, str]], func_name: str, tools: List[Tool]
) -> Dict[str, Any]:
"""Parse argument key-value pairs with type coercion.
Args:
pairs: List of (key, value) tuples from regex matching
func_name: Name of the function
tools: List of available tools
Returns:
Dictionary of parsed arguments
"""
arguments = {}
for arg_key, arg_value in pairs:
arg_key = arg_key.strip()
arg_type = get_argument_type(func_name, arg_key, tools)
parsed_value, is_good_json = parse_arguments(arg_value, arg_type)
if arg_type == "string":
# Only convert to string if explicitly defined as string type
if isinstance(parsed_value, str):
arguments[arg_key] = parsed_value
elif isinstance(parsed_value, (dict, list)):
# If parsed as dict/list but schema says string, convert to JSON string
arguments[arg_key] = json.dumps(parsed_value, ensure_ascii=False)
else:
arguments[arg_key] = str(parsed_value)
elif arg_type is None:
# If type is not defined, keep the parsed value as-is
arguments[arg_key] = parsed_value if is_good_json else arg_value
else:
# For other types (number, object, array, etc.), use parsed value
arguments[arg_key] = parsed_value if is_good_json else arg_value
return arguments
def supports_structural_tag(self) -> bool:
return False
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError()
@@ -0,0 +1,244 @@
import json
import logging
import re
from typing import List, Optional
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.parser.harmony_parser import HarmonyParser
logger = logging.getLogger(__name__)
class GptOssDetector(BaseFormatDetector):
"""
Detector for T4-style function calls using HarmonyParser.
Handles tool calls in the format:
<|channel|>commentary to={namespace.function}<|constrain|>json<|message|>{args}<|call|>
"""
def __init__(self):
super().__init__()
self.harmony_parser = HarmonyParser()
self.bot_token = "<|start|>assistant<|channel|>commentary"
self.eot_token = "<|call|>"
# Pattern to extract function name and JSON from tool_call event content
self.tool_extract_pattern = re.compile(
r"to=([a-zA-Z_][a-zA-Z0-9_.-]*)\s*<\|constrain\|>json<\|message\|>(.*?)(?:<\|call\|>|$)",
re.DOTALL,
)
def has_tool_call(self, text: str) -> bool:
"""Check if text contains TypeScript-style function call markers."""
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""Parse TypeScript-style function calls from complete text."""
if not self.has_tool_call(text):
return StreamingParseResult(normal_text=text, calls=[])
# Parse with HarmonyParser
events = self.harmony_parser.parse(text)
# Flush buffer for complete parsing
events += self.harmony_parser.parse("")
tool_indices = self._get_tool_indices(tools)
calls = []
normal_parts = []
tool_index = 0
for event in events:
if event.event_type == "tool_call":
# Extract tool call from event content
tool_call = self._extract_tool_call_from_event(
event.raw_text if event.raw_text else event.content,
tool_indices,
tool_index,
)
if tool_call:
calls.append(tool_call)
tool_index += 1
elif event.event_type == "normal":
normal_parts.append(event.content)
# Ignore reasoning events in function call context
normal_text = " ".join(normal_parts).strip()
return StreamingParseResult(normal_text=normal_text, calls=calls)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""Parse incremental streaming text for TypeScript-style function calls."""
self._buffer += new_text
# Always use HarmonyParser for parsing to ensure proper filtering
events = self.harmony_parser.parse(new_text)
# If there are no parsed events and the chunk contains no Harmony structural
# markers, treat it as plain text and pass it through. This fixes a bug where
# normal content was held in the buffer when tools were provided but not used.
if not events:
has_harmony_markers = any(
marker in self._buffer
for marker in (
"<|start|>",
"<|channel|>",
"<|message|>",
"<|constrain|>",
"<|end|>",
"<|call|>",
"<|return|>",
"assistantfinal",
)
)
if not has_harmony_markers:
# Plain text with no tool markers — emit as normal content
out = self._buffer
self._buffer = ""
return StreamingParseResult(normal_text=out, calls=[])
# Quick check if we might have tool calls
if (
"<|channel|>commentary to=" not in self._buffer
and not self.current_tool_name_sent
):
# No tool calls detected, check for final content
if (
"<|channel|>final" in self._buffer
or "assistantfinal" in self._buffer.lower()
):
# Extract normal text from events
normal_text = "".join(
[e.content for e in events if e.event_type == "normal"]
)
if normal_text:
self._buffer = ""
return StreamingParseResult(normal_text=normal_text, calls=[])
# For other content, extract normal text from events (with filtering applied)
normal_text = "".join(
[e.content for e in events if e.event_type == "normal"]
)
if normal_text or events:
self._buffer = ""
return StreamingParseResult(normal_text=normal_text, calls=[])
else:
# No events processed, continue buffering
return StreamingParseResult(normal_text="", calls=[])
if not events:
# No complete events yet
return StreamingParseResult(normal_text="", calls=[])
# Initialize state if needed
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
calls = []
normal_text = ""
for event in events:
if event.event_type == "tool_call":
# We got a complete tool call from HarmonyParser
tool_call_info = self._extract_tool_call_from_event(
event.raw_text if event.raw_text else event.content,
self._tool_indices,
self.current_tool_id if self.current_tool_id >= 0 else 0,
)
if tool_call_info:
# Initialize state if first tool
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
# Ensure arrays are large enough
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Store tool call info
self.prev_tool_call_arr[self.current_tool_id] = {
"name": tool_call_info.name,
"arguments": json.loads(tool_call_info.parameters),
}
# Emit the complete tool call at once
# (Could be modified to emit name first, then args, if needed)
calls.append(tool_call_info)
# Mark as streamed
self.streamed_args_for_tool[self.current_tool_id] = (
tool_call_info.parameters
)
# Move to next tool
self.current_tool_id += 1
self.current_tool_name_sent = False
elif event.event_type == "normal":
normal_text += event.content
# Clear buffer since HarmonyParser handles buffering
self._buffer = ""
return StreamingParseResult(normal_text=normal_text, calls=calls)
def _extract_tool_call_from_event(
self, content: str, tool_indices: dict, tool_index: int
) -> Optional[ToolCallItem]:
"""
Extract tool call information from HarmonyParser event content.
Content format: "commentary to=functions.get_weather<|constrain|>json<|message|>{...}"
"""
match = self.tool_extract_pattern.search(content)
if not match:
logger.debug(f"Could not extract tool call from: {content[:100]}")
return None
full_function_name = match.group(1)
json_content = match.group(2)
# Extract function name (last part after .)
function_name = (
full_function_name.split(".")[-1]
if "." in full_function_name
else full_function_name
)
# Check if tool exists
if function_name not in tool_indices:
logger.debug(f"Function {function_name} not in available tools")
if not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get():
return None # Skip unknown tools (default legacy behavior)
# Parse JSON arguments
try:
arguments = json.loads(json_content) if json_content.strip() else {}
except json.JSONDecodeError as e:
logger.debug(f"Failed to parse JSON arguments: {e}")
return None
return ToolCallItem(
tool_index=tool_index,
name=function_name,
parameters=json.dumps(arguments, ensure_ascii=False),
)
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError("structure_info not used with HarmonyParser")
def get_structural_tag_name(self) -> str:
return "harmony"
@@ -0,0 +1,120 @@
import json
import logging
import re
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
class HermesDetector(BaseFormatDetector):
"""
Detector for Hermes tool call format.
Format:
<tool_call>{"name": "...", "arguments": {...}}</tool_call>
"""
def __init__(self):
super().__init__()
self.bot_token = "<tool_call>"
self.eot_token = "</tool_call>"
self.tool_call_regex = re.compile(
r"<tool_call>(.*?)</tool_call>|<tool_call>(.*)", re.DOTALL
)
self._normal_text_buffer = ""
def has_tool_call(self, text: str) -> bool:
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
"""
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if self.bot_token not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
calls = []
try:
for match in self.tool_call_regex.findall(text):
raw = match[0] or match[1]
if not raw:
continue
parsed = json.loads(raw.strip())
if isinstance(parsed, list):
calls.extend(self.parse_base_json(parsed, tools))
else:
calls.extend(self.parse_base_json(parsed, tools))
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}")
return StreamingParseResult(normal_text=text)
def _clean_normal_text(self, text: str) -> str:
if not text:
return text
self._normal_text_buffer += text
if self.eot_token in self._normal_text_buffer:
cleaned = self._normal_text_buffer.replace(self.eot_token, "")
self._normal_text_buffer = ""
return cleaned
partial_len = self._ends_with_partial_token(
self._normal_text_buffer, self.eot_token
)
if partial_len:
safe_text = self._normal_text_buffer[:-partial_len]
self._normal_text_buffer = self._normal_text_buffer[-partial_len:]
return safe_text
cleaned = self._normal_text_buffer
self._normal_text_buffer = ""
return cleaned
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming parsing: handle normal text, partial tags, and tool calls.
"""
self._buffer += new_text
current_text = self._buffer
if self.bot_token not in current_text:
partial_len = self._ends_with_partial_token(current_text, self.bot_token)
if partial_len:
safe_text = current_text[:-partial_len]
self._buffer = current_text[-partial_len:]
else:
safe_text = current_text
self._buffer = ""
return StreamingParseResult(normal_text=self._clean_normal_text(safe_text))
bot_pos = current_text.find(self.bot_token)
if bot_pos > 0:
normal_text = current_text[:bot_pos]
self._buffer = current_text[bot_pos:]
return StreamingParseResult(normal_text=normal_text)
result = super().parse_streaming_increment(new_text="", tools=tools)
if result.normal_text:
result.normal_text = self._clean_normal_text(result.normal_text)
return result
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin='<tool_call>{"name":"' + name + '", "arguments":',
end="}</tool_call>",
trigger="<tool_call>",
)
@@ -0,0 +1,543 @@
import json
import logging
import re
from typing import Any, Dict, List, Optional, Set
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
# Bare (suffix-less) Hunyuan special tokens. The shipping Hy3 tokenizer appends
# a shared suffix to each (e.g. ``<tool_calls:opensource>``); resolve the real
# token string from the vocab at runtime and fall back to these literals.
_HUNYUAN_TOKEN_NAMES = (
"tool_calls",
"tool_call",
"tool_sep",
"arg_key",
"arg_value",
"think",
)
_HUNYUAN_TOKEN_RE = re.compile(
r"^<(?P<name>" + "|".join(_HUNYUAN_TOKEN_NAMES) + r")(?::[^>]+)?>$"
)
def resolve_hunyuan_tokens(tokenizer) -> Dict[str, str]:
"""Map bare token names to their real (possibly suffixed) strings in vocab.
Returns ``{name: token_str}`` for each name found. A bare literal is used
when the tokenizer carries no suffixed form, so the same detector serves
both the preview (suffix-less) and shipping (suffixed) Hy3 tokenizers.
"""
tokens: Dict[str, str] = {}
vocab = None
if tokenizer is not None:
try:
vocab = tokenizer.get_vocab()
except Exception as e:
logger.warning("Failed to read Hunyuan tokenizer vocab: %s", e)
vocab = None
if isinstance(vocab, dict):
for tok in vocab:
if not isinstance(tok, str):
continue
m = _HUNYUAN_TOKEN_RE.match(tok)
if m:
tokens[m.group("name")] = tok
for name in _HUNYUAN_TOKEN_NAMES:
tokens.setdefault(name, f"<{name}>")
return tokens
class HunyuanDetector(BaseFormatDetector):
"""
Detector for Hunyuan (HYV3) tool call format.
Format:
<tool_calls>
<tool_call>function_name<tool_sep>
<arg_key>key1</arg_key>
<arg_value>value1</arg_value>
</tool_call>
</tool_calls>
Streaming behavior:
* Phase 1 emits the tool name once <tool_sep> is seen.
* Phase 2 streams argument JSON incrementally. Closed <arg_value>
pairs are parsed with schema-aware type coercion; pure-string
args may be streamed char-by-char (with JSON escaping). The
closing "}" is withheld until </tool_call> arrives.
"""
_TYPE_ALIASES: Dict[str, str] = {
"str": "string",
"text": "string",
"varchar": "string",
"char": "string",
"enum": "string",
"bool": "boolean",
"binary": "boolean",
"int": "integer",
"float": "number",
"double": "number",
"list": "array",
"dict": "object",
"map": "object",
}
_INTEGER_PREFIXES = ("int", "uint", "long", "short", "unsigned")
_NUMBER_PREFIXES = ("num", "float")
def __init__(self, tokenizer=None):
super().__init__()
t = resolve_hunyuan_tokens(tokenizer)
tool_calls = t["tool_calls"]
tool_call = t["tool_call"]
tool_sep = t["tool_sep"]
arg_key = t["arg_key"]
arg_value = t["arg_value"]
def _close(open_tok: str) -> str:
return "</" + open_tok[1:] if open_tok.startswith("<") else open_tok
self.bot_token = tool_calls
self.eot_token = _close(tool_calls)
self.tool_call_start_token = tool_call
self.tool_call_end_token = _close(tool_call)
self.tool_sep_token = tool_sep
self.arg_key_start_token = arg_key
self.arg_key_end_token = _close(arg_key)
self.arg_value_start_token = arg_value
self.arg_value_end_token = _close(arg_value)
tc_end = _close(tool_call)
ak_end = _close(arg_key)
av_end = _close(arg_value)
self.tool_call_regex = re.compile(
re.escape(tool_call)
+ r"(.*?)"
+ re.escape(tool_sep)
+ r"(.*?)"
+ re.escape(tc_end),
re.DOTALL,
)
self.func_args_regex = re.compile(
re.escape(arg_key)
+ r"(.*?)"
+ re.escape(ak_end)
+ r"\s*"
+ re.escape(arg_value)
+ r"(.*?)"
+ re.escape(av_end),
re.DOTALL,
)
# Streaming state
self._in_tool_calls: bool = False
self._streaming_tool_name: Optional[str] = None
self._completed_args: Dict[str, Any] = {}
self._streamed_json_len: int = 0
# ------------------------------------------------------------------
# Type-normalization helpers
# ------------------------------------------------------------------
@staticmethod
def _normalize_type(raw_type: str) -> str:
exact = HunyuanDetector._TYPE_ALIASES.get(raw_type)
if exact is not None:
return exact
lower = raw_type.lower()
if any(lower.startswith(p) for p in HunyuanDetector._INTEGER_PREFIXES):
return "integer"
if any(lower.startswith(p) for p in HunyuanDetector._NUMBER_PREFIXES):
return "number"
return raw_type
@staticmethod
def _get_arg_schema(
function_name: str, arg_key: str, tools: Optional[List[Tool]]
) -> dict:
if not tools:
return {}
for tool in tools:
if tool.function.name == function_name:
if tool.function.parameters is None:
return {}
return tool.function.parameters.get("properties", {}).get(arg_key, {})
return {}
@staticmethod
def _get_schema_options(arg_schema: dict) -> List[dict]:
"""Priority: single ``type`` > ``anyOf`` > ``oneOf``; else default string."""
if "type" in arg_schema:
return [arg_schema]
if "anyOf" in arg_schema:
return arg_schema["anyOf"]
if "oneOf" in arg_schema:
return arg_schema["oneOf"]
return [{"type": "string"}]
@staticmethod
def _get_types(arg_schema: dict) -> Set[str]:
schemas = HunyuanDetector._get_schema_options(arg_schema)
return {
HunyuanDetector._normalize_type(s.get("type", "string")) for s in schemas
} - {"null"}
@staticmethod
def _is_only_string_type(
function_name: str, arg_key: str, tools: Optional[List[Tool]]
) -> bool:
"""Only pure-string args get char-by-char value streaming; compound
types like anyOf(string | array) might resolve to a JSON array or
object, so we can't safely stream them as open JSON strings."""
arg_schema = HunyuanDetector._get_arg_schema(function_name, arg_key, tools)
return HunyuanDetector._get_types(arg_schema) == {"string"}
@staticmethod
def _try_parse_bool(value: str) -> Optional[bool]:
lower = value.lower()
if lower == "true":
return True
if lower == "false":
return False
return None
@staticmethod
def _try_parse_int(value: str) -> Optional[int]:
try:
return int(value)
except (ValueError, TypeError):
return None
@staticmethod
def _try_parse_number(value: str):
"""int if no '.'/'e'/'E', else float."""
try:
if "." in value or "e" in value or "E" in value:
return float(value)
return int(value)
except (ValueError, TypeError):
return None
@staticmethod
def _deserialize(value: str) -> Any:
try:
return json.loads(value)
except (json.JSONDecodeError, ValueError):
return value
@staticmethod
def _parse_value(
value: str,
function_name: str,
arg_key: str,
tools: Optional[List[Tool]],
) -> Any:
"""Unified value parser: bool → int → number → json (array/obj) → string."""
arg_schema = HunyuanDetector._get_arg_schema(function_name, arg_key, tools)
types = HunyuanDetector._get_types(arg_schema)
if "boolean" in types:
r = HunyuanDetector._try_parse_bool(value)
if r is not None:
return r
if "integer" in types:
r = HunyuanDetector._try_parse_int(value)
if r is not None:
return r
if "number" in types:
r = HunyuanDetector._try_parse_number(value)
if r is not None:
return r
if types - {"string", "boolean", "integer", "number"}:
try:
return json.loads(value)
except (json.JSONDecodeError, ValueError):
pass
if "string" in types:
return value
return HunyuanDetector._deserialize(value)
# ------------------------------------------------------------------
# Non-streaming
# ------------------------------------------------------------------
def has_tool_call(self, text: str) -> bool:
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
if self.bot_token not in text:
return StreamingParseResult(normal_text=text, calls=[])
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx > 0 else ""
tool_indices = self._get_tool_indices(tools)
forward_unknown = envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get()
calls: List[ToolCallItem] = []
try:
for function_name, function_args in self.tool_call_regex.findall(text):
function_name = function_name.strip()
if function_name not in tool_indices and not forward_unknown:
logger.warning(
"Model attempted to call undefined function: %s", function_name
)
continue
arg_dict: Dict[str, Any] = {}
for key, value in self.func_args_regex.findall(function_args):
key = key.strip()
arg_dict[key] = self._parse_value(value, function_name, key, tools)
calls.append(
ToolCallItem(
tool_index=tool_indices.get(function_name, -1),
name=function_name,
parameters=json.dumps(arg_dict, ensure_ascii=False),
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}", exc_info=True)
return StreamingParseResult(normal_text=text)
# ------------------------------------------------------------------
# Streaming
# ------------------------------------------------------------------
def _reset_streaming_tool_state(self):
self._streaming_tool_name = None
self._completed_args = {}
self._streamed_json_len = 0
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
try:
return self._parse_streaming_increment_impl(new_text, tools)
except Exception as e:
logger.error(f"Error in parse_streaming_increment: {e}", exc_info=True)
return StreamingParseResult()
def _parse_streaming_increment_impl(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
# Not yet inside <tool_calls>: emit normal text or buffer partial bot_token.
if not self._in_tool_calls:
combined = self._buffer + new_text
if self.bot_token in combined:
bot_pos = combined.find(self.bot_token)
normal_text = combined[:bot_pos]
self._buffer = combined[bot_pos + len(self.bot_token) :]
self._in_tool_calls = True
return self._continue_streaming(tools, leading_normal=normal_text)
partial_len = self._ends_with_partial_token(combined, self.bot_token)
if partial_len:
self._buffer = combined[-partial_len:]
return StreamingParseResult(normal_text=combined[:-partial_len])
self._buffer = ""
return StreamingParseResult(normal_text=combined)
self._buffer += new_text
return self._continue_streaming(tools)
def _continue_streaming(
self, tools: List[Tool], leading_normal: str = ""
) -> StreamingParseResult:
"""Drive the state machine after <tool_calls> is open."""
calls: List[ToolCallItem] = []
while True:
if self._streaming_tool_name is None:
# Phase 1: wait for <tool_call>..<tool_sep>.
tc_start = self._buffer.find(self.tool_call_start_token)
if tc_start == -1:
if self.eot_token in self._buffer:
eot_pos = self._buffer.find(self.eot_token)
self._buffer = self._buffer[eot_pos + len(self.eot_token) :]
self._in_tool_calls = False
break
sep_pos = self._buffer.find(self.tool_sep_token, tc_start)
if sep_pos == -1:
self._buffer = self._buffer[tc_start:]
break
tool_name = self._buffer[
tc_start + len(self.tool_call_start_token) : sep_pos
].strip()
if (
tool_name not in self._tool_indices
and not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get()
):
logger.warning(
"Model attempted to call undefined function: %s", tool_name
)
self._streaming_tool_name = tool_name
self.current_tool_id += 1
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=tool_name,
parameters="",
)
)
self._buffer = self._buffer[sep_pos + len(self.tool_sep_token) :]
# Phase 2: stream argument JSON of the current tool.
before_name = self._streaming_tool_name
calls.extend(self._stream_args(tools))
if self._streaming_tool_name is not None:
break # current tool still open; need more data.
if self._streaming_tool_name == before_name:
break # safety: avoid infinite loop if state didn't advance.
return StreamingParseResult(normal_text=leading_normal, calls=calls)
def _stream_args(self, tools: List[Tool]) -> List[ToolCallItem]:
"""Emit argument-JSON deltas for the currently-open tool call."""
is_complete = self.tool_call_end_token in self._buffer
if is_complete:
end_idx = self._buffer.find(self.tool_call_end_token)
args_text = self._buffer[:end_idx]
else:
args_text = self._buffer
# 1. Absorb closed <arg_key>..<arg_value> pairs.
last_closed_end = 0
for m in self.func_args_regex.finditer(args_text):
key, value = m.groups()
key = key.strip()
if key not in self._completed_args:
self._completed_args[key] = self._parse_value(
value, self._streaming_tool_name or "", key, tools
)
last_closed_end = m.end()
# 2. Detect a partial (unclosed) kv pair at the tail.
tail = args_text[last_closed_end:]
partial_key: Optional[str] = None
partial_value: Optional[str] = None
ak_start = tail.find(self.arg_key_start_token)
if ak_start != -1:
ak_end = tail.find(
self.arg_key_end_token, ak_start + len(self.arg_key_start_token)
)
if ak_end != -1:
partial_key = tail[
ak_start + len(self.arg_key_start_token) : ak_end
].strip()
av_start = tail.find(self.arg_value_start_token, ak_end)
if av_start != -1 and self._is_only_string_type(
self._streaming_tool_name or "", partial_key, tools
):
partial_value = tail[av_start + len(self.arg_value_start_token) :]
# Avoid emitting a lone "{" before any arg content is knowable.
if not is_complete and not self._completed_args and partial_value is None:
return []
# 3. Build the JSON snapshot manually to control streaming boundaries.
snapshot_parts: List[str] = []
for k, v in self._completed_args.items():
k_json = json.dumps(k, ensure_ascii=False)
v_json = json.dumps(v, ensure_ascii=False)
snapshot_parts.append(f"{k_json}: {v_json}")
if partial_key is not None and partial_value is not None:
# Hold back chars that could be a partial </arg_value> marker so
# that a `<` starting the end-tag doesn't leak into the streamed
# JSON string value.
hold = self._ends_with_partial_token(
partial_value, self.arg_value_end_token
)
safe_value = partial_value[:-hold] if hold else partial_value
k_json = json.dumps(partial_key, ensure_ascii=False)
escaped = (
safe_value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
# No closing `"` here — it's appended when the value closes.
snapshot_parts.append(f'{k_json}: "{escaped}')
snapshot = "{" + ", ".join(snapshot_parts) + "}"
argument_diff: Optional[str] = None
if is_complete:
final_json = json.dumps(self._completed_args, ensure_ascii=False)
if self._streamed_json_len < len(final_json):
argument_diff = final_json[self._streamed_json_len :]
self._streamed_json_len = len(final_json)
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
self.prev_tool_call_arr[self.current_tool_id] = {
"name": self._streaming_tool_name,
"arguments": dict(self._completed_args),
}
end_idx = self._buffer.find(self.tool_call_end_token)
self._buffer = self._buffer[end_idx + len(self.tool_call_end_token) :]
self._reset_streaming_tool_state()
else:
# Withhold the trailing "}" while the tool call is still open.
end = len(snapshot) - 1
if end > self._streamed_json_len:
argument_diff = snapshot[self._streamed_json_len : end]
self._streamed_json_len = end
if argument_diff:
self.streamed_args_for_tool[self.current_tool_id] += argument_diff
return [
ToolCallItem(
tool_index=self.current_tool_id,
parameters=argument_diff,
)
]
return []
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin=f"{self.bot_token}\n{self.tool_call_start_token}{name}{self.tool_sep_token}",
end=f"{self.tool_call_end_token}\n{self.eot_token}",
trigger=self.bot_token,
)
def supports_structural_tag(self) -> bool:
return False
@@ -0,0 +1,248 @@
# modified from https://github.com/InternLM/lmdeploy/blob/main/lmdeploy/serve/openai/tool_parser/internlm2_parser.py
import json
import logging
import re
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
class InternlmDetector(BaseFormatDetector):
"""
Detector for InternLM2/Intern-S1 model function call format.
The InternLM format uses special tokens to delimit function calls
with JSON for arguments.
Format Structure:
```
text<|action_start|> <|plugin|>
{json}<|action_end|>
```
Examples:
```
What's the weather like?<|action_start|> <|plugin|>
{"name": "get_weather", "parameters": {"location": "Tokyo"}}<|action_end|>
```
Key Components:
- Tool Call Start: `<|action_start|> <|plugin|>`
- Tool Call End: `<|action_end|>`
- Arguments: JSON object with `name` and `parameters`/`arguments`
- Supports multiple sequential tool calls in both streaming and non-streaming modes
"""
def __init__(self):
super().__init__()
self.bot_token = "<|action_start|> <|plugin|>"
self.eot_token = "<|action_end|>"
self.position = 0
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains an InternLM format tool call."""
has_call = self.bot_token in text
return has_call
def get_arguments(self, obj):
"""Extract arguments from object, supporting both 'parameters' and 'arguments' keys."""
if "parameters" in obj:
return obj.get("parameters")
elif "arguments" in obj:
return obj.get("arguments")
return None
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
Supports multiple tool calls in the format:
<|action_start|> <|plugin|>\n{JSON}<|action_end|>
:param text: The complete text to parse.
:param tools: List of available tools.
:return: StreamingParseResult with normal text and parsed tool calls.
"""
# Find the first occurrence of tool call marker to extract normal text
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if self.bot_token not in text:
logger.warning("[InternLM Tool Call] No tool call markers found in text")
return StreamingParseResult(normal_text=normal_text, calls=[])
# Use regex to find all tool call blocks
# Pattern matches: {self.bot_token}{...}{self.eot_token}
tool_call_pattern = (
rf"{re.escape(self.bot_token)}\s*(.*?){re.escape(self.eot_token)}"
)
matches = re.findall(tool_call_pattern, text, re.DOTALL)
if not matches:
logger.warning("[InternLM Tool Call] No complete tool call blocks found")
return StreamingParseResult(normal_text=text, calls=[])
logger.info(f"[InternLM Tool Call] Found {len(matches)} tool call(s)")
calls = []
tool_indices = self._get_tool_indices(tools)
try:
for idx, action_json in enumerate(matches):
action_json = action_json.strip()
try:
# Parse the JSON
action_dict = json.loads(action_json)
name = action_dict.get("name")
parameters = self.get_arguments(action_dict)
if not parameters:
parameters = {}
logger.info(
f"[InternLM Tool Call] Parsed tool call #{idx+1}: name={name}, "
f"parameters={json.dumps(parameters, ensure_ascii=False)}"
)
# Validate tool name
if not (name and name in tool_indices):
logger.warning(
f"[InternLM Tool Call] Model attempted to call undefined function: {name}, "
f"available_tools={list(tool_indices.keys())}"
)
if not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get():
continue # Skip this tool call
# Create tool call item and add to list
tool_call = ToolCallItem(
tool_index=tool_indices[name],
name=name,
parameters=json.dumps(parameters, ensure_ascii=False),
)
calls.append(tool_call)
except json.JSONDecodeError as e:
logger.error(
f"[InternLM Tool Call] Failed to parse JSON for tool call #{idx+1}: {e}"
)
continue
logger.info(
f"[InternLM Tool Call] Successfully parsed {len(calls)} tool call(s), "
f"normal_text_length={len(normal_text)}"
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(
f"[InternLM Tool Call] Error in detect_and_parse: {e}", exc_info=True
)
return StreamingParseResult(normal_text=text, calls=[])
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing for InternLM format.
Supports a single tool call in streaming mode.
"""
self._buffer += new_text
current_text = self._buffer
# Check if we don't have a tool call start marker
start = current_text.find(self.bot_token)
if start == -1:
# No tool call marker found
# If we've already processed tool calls, don't return text again
if self.current_tool_id > 0:
self._buffer = ""
return StreamingParseResult(normal_text="")
# Check if buffer could be partial start of bot_token
if not self._ends_with_partial_token(current_text, self.bot_token):
# Not a partial match, return as normal text
normal_text = current_text
self._buffer = ""
# Clean up any stray end tokens
if self.eot_token in normal_text:
normal_text = normal_text.replace(self.eot_token, "")
return StreamingParseResult(normal_text=normal_text)
else:
# Might be partial start token, keep buffering
return StreamingParseResult()
# Check if we have a complete tool call (with end marker)
end = current_text.find(self.eot_token)
if end != -1:
# We have a complete tool call
# Initialize state if this is the first tool call
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
# Ensure we have enough entries in our tracking arrays
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Use detect_and_parse on the complete tool call
complete_section = current_text[: end + len(self.eot_token)]
result = self.detect_and_parse(complete_section, tools=tools)
if result.calls:
# Update the tool call index
result.calls[0].tool_index = self.current_tool_id
# Store the parsed tool call for reference
self.prev_tool_call_arr[self.current_tool_id] = {
"name": result.calls[0].name,
"arguments": json.loads(result.calls[0].parameters),
}
self.streamed_args_for_tool[self.current_tool_id] = result.calls[
0
].parameters
# Increment tool ID for next tool call
self.current_tool_id += 1
# Remove the completed tool call from buffer
self._buffer = current_text[end + len(self.eot_token) :]
return result
# We have bot_token but no eot_token yet - handle partial tool call streaming
# Extract normal text before the tool call
normal_text = current_text[:start]
# Keep the tool call part in buffer
self._buffer = current_text[start:]
return StreamingParseResult(normal_text=normal_text)
def structure_info(self) -> _GetInfoFunc:
"""
Return structure information for constrained generation.
For InternLM format, the structure is:
- begin: <|action_start|> <|plugin|>\n
- end: <|action_end|>
- trigger: the begin token
"""
return lambda name: StructureInfo(
begin='<|action_start|> <|plugin|>\n{"name": "'
+ name
+ '", "parameters": ',
end="}<|action_end|>",
trigger="<|action_start|> <|plugin|>",
)
@@ -0,0 +1,51 @@
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import StreamingParseResult
class JsonArrayParser(BaseFormatDetector):
"""
Parser for JSON array tool calls when JSON schema constraints are active.
This parser is used when tool_choice="required" or a specific tool is named,
bypassing model-specific parsers in favor of direct JSON array parsing.
"""
def __init__(self):
super().__init__()
# Configure for JSON array parsing
self.bot_token = "["
self.eot_token = "]"
self.tool_call_separator = ","
def has_tool_call(self, text: str) -> bool:
"""
Check if the given text contains a JSON tool call (array or single object).
"""
return "[" in text or "{" in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
Parse JSON tool calls using the base class implementation.
"""
raise NotImplementedError(
"Detect and parse not supported for JSON schema constraints."
)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing with tool validation.
"""
return super().parse_streaming_increment(new_text, tools)
def structure_info(self) -> callable:
"""
Return a function that creates StructureInfo for constrained generation.
This is not used for JSON schema constraints as they are handled
by the constraint backends directly.
"""
raise NotImplementedError("structure_info not used for JSON schema constraints")
@@ -0,0 +1,469 @@
import json
import logging
import re
from typing import List, Literal, Optional, Union
from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice
from sglang.srt.function_call.base_format_detector import (
BaseFormatDetector,
StructuralTag,
get_model_structural_tag,
)
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
_KIMI_K2_SPECIAL_TOKENS = [
"<|tool_calls_section_begin|>",
"<|tool_calls_section_end|>",
"<|tool_call_begin|>",
"<|tool_call_end|>",
"<|tool_call_argument_begin|>",
]
_KIMI_NON_STRICT_ARGUMENTS_SCHEMA = {"type": "object"}
def _strip_special_tokens(text: str) -> str:
"""Remove all Kimi-K2 tool-call special tokens from text."""
for token in _KIMI_K2_SPECIAL_TOKENS:
text = text.replace(token, "")
return text
class KimiK2Detector(BaseFormatDetector):
"""
Detector for Kimi K2 / K2.5 model function call format.
Format Structure (standard):
```
<|tool_calls_section_begin|>
<|tool_call_begin|>functions.{func_name}:{index}<|tool_call_argument_begin|>{json_args}<|tool_call_end|>
<|tool_calls_section_end|>
```
Format Structure (bare counter — model omits function name):
```
<|tool_call_begin|>{counter}<|tool_call_argument_begin|>{json_args}<|tool_call_end|>
```
Reference: https://huggingface.co/moonshotai/Kimi-K2-Instruct/blob/main/docs/tool_call_guidance.md
"""
def __init__(self):
super().__init__()
self.bot_token: str = "<|tool_calls_section_begin|>"
self.eot_token: str = "<|tool_calls_section_end|>"
self.tool_call_start_token: str = "<|tool_call_begin|>"
self.tool_call_end_token: str = "<|tool_call_end|>"
self.tool_call_argument_begin_token: str = "<|tool_call_argument_begin|>"
# Capture tool_call_id broadly: the model may emit standard IDs
# like "functions.ReadFile:0" or bare call counters like "3".
self.tool_call_regex = re.compile(
r"<\|tool_call_begin\|>\s*(?P<tool_call_id>[^\s<|]+)\s*<\|tool_call_argument_begin\|>\s*(?P<function_arguments>\{.*?\})\s*<\|tool_call_end\|>",
re.DOTALL,
)
self.stream_tool_call_portion_regex = re.compile(
r"<\|tool_call_begin\|>\s*(?P<tool_call_id>[^\s<|]+)\s*<\|tool_call_argument_begin\|>\s*(?P<function_arguments>\{.*)",
re.DOTALL,
)
self._last_arguments = ""
self._current_stream_function_name: str | None = None
# Standard ID: "functions.search:0", "search:0"
self.tool_call_id_regex = re.compile(
r"^(?:functions\.)?(?P<name>[\w.\-]+):(?P<index>\d+)$"
)
# Bare call counter: "0", "3" (model uses auto-incrementing counter)
self.tool_call_id_counter_regex = re.compile(r"^\d+$")
def _parse_tool_call_id(
self, function_id: str, tools: List[Tool], function_args: str = None
):
"""Parse a tool call ID into (function_name, call_index).
Standard format: "functions.ReadFile:0" → ("ReadFile", 0)
Bare counter: "3" → call_index=3, infer name from arguments.
The bare counter is a conversation-level auto-increment, NOT an index
into the tools list. The function name is inferred by matching argument
keys against tool parameter schemas.
"""
m = self.tool_call_id_regex.match(function_id)
if m:
return m.group("name"), int(m.group("index"))
if self.tool_call_id_counter_regex.match(function_id):
call_index = int(function_id)
name = self._infer_tool_name(tools, function_args)
if name:
return name, call_index
return None, call_index
logger.warning("Unexpected tool_call_id format: %s", function_id)
return None, 0
def _infer_tool_name(self, tools: List[Tool], function_args: str = None):
"""Infer function name when the model omits it (bare counter ID).
Matches argument keys against tool parameter schemas, preferring the
tool whose declared properties best match the actual arguments.
"""
if not tools:
return None
if len(tools) == 1:
return tools[0].function.name
if not function_args:
logger.debug(
"No function_args for tool name inference with %d tools", len(tools)
)
return None
try:
arg_keys = set(json.loads(function_args).keys())
except (json.JSONDecodeError, TypeError):
logger.debug(
"Could not parse function_args for tool name inference "
"(may be partial JSON in streaming)"
)
return None
# Pick the tool whose properties best match the argument keys.
best_name = None
best_score = -1
for tool in tools:
params = tool.function.parameters or {}
props = set(params.get("properties", {}).keys())
if not props:
continue
overlap = len(arg_keys & props)
extra = len(arg_keys - props)
score = overlap - extra
if score > best_score:
best_score = score
best_name = tool.function.name
return best_name
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a KimiK2 format tool call."""
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
:param text: The complete text to parse.
:param tools: List of available tools.
:return: StreamingParseResult with normal_text (content before tool calls) and calls (parsed items).
"""
if self.bot_token not in text:
return StreamingParseResult(normal_text=text, calls=[])
try:
function_call_tuples = self.tool_call_regex.findall(text)
logger.debug("function_call_tuples: %s", function_call_tuples)
tool_calls = []
# ``tool_index`` is the per-response 0-based position of the call
# (OpenAI spec); enumerate parsed calls locally and ignore the
# model's ``:N`` suffix, which is a conversation-level counter.
# ``serving_chat._process_tool_call_id()`` later offsets these by
# ``history_tool_calls_cnt`` for multi-turn responses.
local_tool_index = 0
for match in function_call_tuples:
function_id, function_args = match
function_name, _ = self._parse_tool_call_id(
function_id, tools, function_args
)
if function_name is None:
continue
logger.debug(f"function_name {function_name}")
tool_calls.append(
ToolCallItem(
tool_index=local_tool_index,
name=function_name,
parameters=function_args,
)
)
local_tool_index += 1
content = text[: text.find(self.bot_token)]
return StreamingParseResult(normal_text=content, calls=tool_calls)
except Exception as e:
logger.error("Error in detect_and_parse: %s", e, exc_info=True)
return StreamingParseResult(normal_text=text)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""Streaming incremental parsing tool calls for KimiK2 format."""
self._buffer += new_text
# Fast path: no tool call in flight and no markers yet -- emit as
# normal text, holding back any trailing partial start token.
if (
self._current_stream_function_name is None
and self.bot_token not in self._buffer
and self.tool_call_start_token not in self._buffer
):
emit, hold = self._split_pending_start(self._buffer)
self._buffer = hold
return StreamingParseResult(normal_text=_strip_special_tokens(emit))
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
normal_text_parts: list[str] = []
calls: list[ToolCallItem] = []
try:
while True:
buffer = self._buffer
# Locate next <|tool_call_begin|>, draining any prefix as text.
begin_idx = self._locate_tool_call_start(buffer, normal_text_parts)
if begin_idx is None:
break
buffer = self._buffer
# If another <|tool_call_begin|> appears before the header
# closes with <|tool_call_argument_begin|>, the section is
# malformed -- discard and restart at the orphan.
arg_begin_idx = buffer.find(self.tool_call_argument_begin_token)
next_begin = buffer.find(
self.tool_call_start_token, len(self.tool_call_start_token)
)
if next_begin != -1 and (
arg_begin_idx == -1 or next_begin < arg_begin_idx
):
logger.warning(
"Kimi-K2 tool_call_begin without preceding tool_call_end; "
"discarding incomplete section."
)
self._buffer = buffer[next_begin:]
self._reset_inflight_call_state()
continue
if arg_begin_idx == -1:
# Header not fully arrived yet.
break
id_start = len(self.tool_call_start_token)
function_id = buffer[id_start:arg_begin_idx].strip()
args_start = arg_begin_idx + len(self.tool_call_argument_begin_token)
end_idx = buffer.find(self.tool_call_end_token)
# Resolve function name (cached across chunks within a section).
name_just_resolved = False
if self._current_stream_function_name is None:
args_for_inference = (
buffer[args_start:end_idx]
if end_idx != -1
else buffer[args_start:]
)
resolved = self._resolve_function_name(
function_id, tools, args_for_inference
)
if resolved is None:
if end_idx == -1:
# Wait for the end marker before deciding.
break
logger.warning(
"Kimi-K2 unrecognized tool_call_id %r; skipping section.",
function_id,
)
self._buffer = buffer[end_idx + len(self.tool_call_end_token) :]
self._reset_inflight_call_state()
continue
name = resolved
self._current_stream_function_name = name
name_just_resolved = True
# ``tool_index`` is the per-response 0-based position
# (OpenAI streaming spec); ignore the model's ``:N`` suffix
# which is a conversation-level counter.
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
self.prev_tool_call_arr[self.current_tool_id] = {
"name": name,
"arguments": {},
}
self.current_tool_name_sent = True
# Stream newly-arrived args, combining the first event with
# the freshly-resolved name.
if end_idx != -1:
args_full = buffer[args_start:end_idx]
else:
args_full = buffer[args_start:]
argument_diff = args_full[len(self._last_arguments) :]
if argument_diff or name_just_resolved:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=(
self._current_stream_function_name
if name_just_resolved
else None
),
parameters=argument_diff,
)
)
if argument_diff:
self._last_arguments += argument_diff
self.streamed_args_for_tool[
self.current_tool_id
] += argument_diff
if end_idx == -1:
# Args still streaming.
break
# Section finalized -- advance buffer and prepare next call.
self._buffer = buffer[end_idx + len(self.tool_call_end_token) :]
self.current_tool_id += 1
self._reset_inflight_call_state()
return StreamingParseResult(
normal_text="".join(normal_text_parts), calls=calls
)
except Exception as e:
logger.error("Error in parse_streaming_increment: %s", e, exc_info=True)
# Drop the buffer to avoid leaking raw special tokens.
self._buffer = ""
self._reset_inflight_call_state()
return StreamingParseResult(
normal_text="".join(normal_text_parts), calls=calls
)
def _reset_inflight_call_state(self) -> None:
"""Reset per-section streaming state after finalize/discard."""
self._last_arguments = ""
self.current_tool_name_sent = False
self._current_stream_function_name = None
def _locate_tool_call_start(
self, buffer: str, normal_text_parts: list
) -> int | None:
"""Find the next <|tool_call_begin|>; drain any prefix as normal text.
Returns 0 on success, or ``None`` when no start token is present yet.
"""
begin_idx = buffer.find(self.tool_call_start_token)
if begin_idx == -1:
emit, hold = self._split_pending_start(buffer)
if emit:
normal_text_parts.append(_strip_special_tokens(emit))
self._buffer = hold
return None
if begin_idx > 0:
normal_text_parts.append(_strip_special_tokens(buffer[:begin_idx]))
self._buffer = buffer[begin_idx:]
return 0
def _split_pending_start(self, text: str) -> tuple[str, str]:
"""Hold back a trailing fragment that could be the start of
<|tool_calls_section_begin|> or <|tool_call_begin|>. Everything
before it is safe to emit as normal text.
"""
candidates = (self.bot_token, self.tool_call_start_token)
max_tail = max(len(t) for t in candidates) - 1
for n in range(min(len(text), max_tail), 1, -1):
tail = text[-n:]
if any(t.startswith(tail) for t in candidates):
return text[:-n], tail
return text, ""
def _resolve_function_name(
self, function_id: str, tools: List[Tool], function_args: str
) -> Optional[str]:
"""Map a Kimi-K2 tool_call_id to a tool name, or ``None`` if unknown."""
if not function_id:
return self._infer_tool_name(tools, function_args)
m = self.tool_call_id_regex.match(function_id)
if m:
return m.group("name")
if self.tool_call_id_counter_regex.match(function_id):
return self._infer_tool_name(tools, function_args)
return None
def structure_info(self) -> _GetInfoFunc:
"""Return function that creates StructureInfo for guided generation."""
def get_info(name: str) -> StructureInfo:
return StructureInfo(
begin=f"<|tool_calls_section_begin|><|tool_call_begin|>functions.{name}:0<|tool_call_argument_begin|>",
end="<|tool_call_end|><|tool_calls_section_end|>",
trigger="<|tool_calls_section_begin|>",
)
return get_info
def get_structural_tag(
self,
tools: Union[List[Tool], None] = None,
tool_choice: Union[ToolChoice, Literal["auto", "required"]] = "auto",
thinking_mode: bool = False,
) -> Optional[StructuralTag]:
if not (
tools and (tool_choice == "required" or isinstance(tool_choice, ToolChoice))
):
return super().get_structural_tag(
tools=tools, tool_choice=tool_choice, thinking_mode=thinking_mode
)
if get_model_structural_tag is None:
return None
converted_tools = []
for tool in tools:
converted_tool = tool.model_dump()
function = converted_tool["function"]
if not function.get("strict", False):
# Kimi's parser accepts only object-shaped tool arguments. XGrammar
# treats strict=False arguments as unconstrained JSON, which can
# generate strings/arrays/numbers that Kimi cannot parse. Keep
# non-strict semantics loose by constraining only the outer type.
function["strict"] = True
function["parameters"] = _KIMI_NON_STRICT_ARGUMENTS_SCHEMA
converted_tools.append(converted_tool)
converted_tool_choice = (
tool_choice.model_dump()
if isinstance(tool_choice, ToolChoice)
else tool_choice
)
return get_model_structural_tag(
model="kimi",
tools=converted_tools,
tool_choice=converted_tool_choice,
reasoning=thinking_mode,
)
def get_structural_tag_name(self) -> str:
return "kimi"
@@ -0,0 +1,387 @@
"""
Detector for LFM2 (Liquid Foundation Model 2) function call format.
Format Structure (Pythonic style):
```
<|tool_call_start|>[function_name(arg1="value1", arg2="value2")]<|tool_call_end|>
```
Multiple tool calls:
```
<|tool_call_start|>[func1(arg="val"), func2(arg="val")]<|tool_call_end|>
```
Also supports JSON format:
```
<|tool_call_start|>[{"name": "func_name", "arguments": {...}}]<|tool_call_end|>
```
"""
import ast
import json
import logging
import re
from typing import Any, Dict, List, Optional, Tuple
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
class Lfm2Detector(BaseFormatDetector):
"""
Detector for LFM2 (Liquid Foundation Model 2) function call format.
Supports both Pythonic and JSON formats:
Pythonic:
```
<|tool_call_start|>[calculator(expression="5 * 7")]<|tool_call_end|>
```
JSON:
```
<|tool_call_start|>[{"name": "calculator", "arguments": {"expression": "5 * 7"}}]<|tool_call_end|>
```
"""
def __init__(self):
"""
Initializes the detector with necessary state variables.
"""
super().__init__()
self.bot_token = "<|tool_call_start|>"
self.eot_token = "<|tool_call_end|>"
self.tool_call_separator = ""
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains an LFM2 format tool call."""
return self.bot_token in text
def _get_parameter_value(self, val: ast.AST) -> Any:
"""
Extract Python literal value from AST node.
Handles constants, dicts, and lists recursively.
Reuses pattern from PythonicDetector.
"""
if isinstance(val, ast.Constant):
return val.value
elif isinstance(val, ast.Dict):
return {
self._get_parameter_value(k): self._get_parameter_value(v)
for k, v in zip(val.keys, val.values)
if k is not None # Handle {**kwargs} case where key is None
}
elif isinstance(val, ast.List):
return [self._get_parameter_value(v) for v in val.elts]
elif isinstance(val, ast.Tuple):
return tuple(self._get_parameter_value(v) for v in val.elts)
elif isinstance(val, ast.Name):
# Handle True, False, None as names in older Python
if val.id == "True":
return True
elif val.id == "False":
return False
elif val.id == "None":
return None
else:
raise ValueError(f"Unsupported name reference: {val.id}")
elif isinstance(val, ast.UnaryOp) and isinstance(val.op, ast.USub):
# Handle negative numbers like -5
inner = self._get_parameter_value(val.operand)
if isinstance(inner, (int, float)):
return -inner
raise ValueError(f"Cannot negate non-numeric value: {inner}")
else:
raise ValueError(
f"Tool call arguments must be literals, got: {type(val).__name__}"
)
def _parse_pythonic_call(
self, call: ast.Call, call_index: int, tool_indices: Dict[str, int]
) -> Optional[ToolCallItem]:
"""
Parse a single AST Call node into a ToolCallItem.
Args:
call: AST Call node representing a function call
call_index: Index of this call in the list of calls
tool_indices: Mapping of tool names to their indices
Returns:
ToolCallItem if successful, None if the call should be skipped
"""
if not isinstance(call.func, ast.Name):
logger.warning(
f"Tool call function must be a simple name, got: {type(call.func).__name__}"
)
return None
function_name = call.func.id
# Validate that the function exists in the tools
if function_name not in tool_indices:
logger.warning(
f"Model attempted to call undefined function: {function_name}"
)
if not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get():
return None # Skip unknown tools (default legacy behavior)
# Parse arguments
arguments = {}
for keyword in call.keywords:
if keyword.arg is None:
# **kwargs unpacking - skip for now
logger.warning("Tool call with **kwargs unpacking is not supported")
continue
try:
arguments[keyword.arg] = self._get_parameter_value(keyword.value)
except ValueError as e:
logger.warning(f"Failed to parse argument {keyword.arg}: {e}")
return None
return ToolCallItem(
tool_index=call_index, # Use the call index in the response, not tool position
name=function_name,
parameters=json.dumps(arguments, ensure_ascii=False),
)
def _parse_pythonic_content(
self, content: str, tools: List[Tool]
) -> Tuple[List[ToolCallItem], str]:
"""
Parse Pythonic format tool calls using AST.
Args:
content: The content between tool call tags (without the tags)
tools: List of available tools
Returns:
Tuple of (list of parsed calls, error message if any)
"""
content = content.strip()
tool_indices = self._get_tool_indices(tools)
try:
module = ast.parse(content)
parsed = getattr(module.body[0], "value", None) if module.body else None
if parsed is None:
return [], "Empty or invalid Python expression"
# Handle both single call and list of calls
if isinstance(parsed, ast.List):
call_nodes = parsed.elts
elif isinstance(parsed, ast.Call):
call_nodes = [parsed]
else:
return (
[],
f"Expected function call or list, got: {type(parsed).__name__}",
)
# Validate all elements are calls
if not all(isinstance(e, ast.Call) for e in call_nodes):
return [], "Not all elements in list are function calls"
calls = []
for call_index, call in enumerate(call_nodes):
item = self._parse_pythonic_call(call, call_index, tool_indices)
if item is not None:
calls.append(item)
return calls, ""
except SyntaxError as e:
return [], f"Python syntax error: {e}"
except Exception as e:
logger.exception("Unexpected error in pythonic tool call parsing")
return [], f"Unexpected error: {e}"
def _parse_json_content(
self, content: str, tools: List[Tool]
) -> Tuple[List[ToolCallItem], str]:
"""
Parse JSON format tool calls.
Uses parse_base_json from BaseFormatDetector for consistent handling
of SGLANG_FORWARD_UNKNOWN_TOOLS and tool validation.
Args:
content: The content between tool call tags (without the tags)
tools: List of available tools
Returns:
Tuple of (list of parsed calls, error message if any)
"""
content = content.strip()
try:
parsed = json.loads(content)
# parse_base_json handles list/dict normalization, tool validation,
# and SGLANG_FORWARD_UNKNOWN_TOOLS consistently with other detectors
calls = self.parse_base_json(parsed, tools)
return calls, ""
except json.JSONDecodeError as e:
return [], f"JSON parse error: {e}"
def _parse_tool_calls_content(
self, content: str, tools: List[Tool]
) -> List[ToolCallItem]:
"""
Parse the content between tool call tags.
Handles both JSON and Pythonic formats.
"""
content = content.strip()
# First, try JSON format (faster check)
if content.startswith("[{") or content.startswith("{"):
calls, error = self._parse_json_content(content, tools)
if calls:
return calls
# If JSON parsing failed but it looked like JSON, log the error
if error:
logger.debug(f"JSON parsing failed: {error}, trying Pythonic format")
# Try Pythonic format
calls, error = self._parse_pythonic_content(content, tools)
if calls:
return calls
if error:
logger.warning(f"Failed to parse tool calls: {error}")
return []
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
"""
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if self.bot_token not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
# Find all <|tool_call_start|>...<|tool_call_end|> blocks
pattern = rf"{re.escape(self.bot_token)}(.*?){re.escape(self.eot_token)}"
match_result_list = re.findall(pattern, text, re.DOTALL)
calls = []
for match_result in match_result_list:
parsed_calls = self._parse_tool_calls_content(match_result, tools)
calls.extend(parsed_calls)
return StreamingParseResult(normal_text=normal_text, calls=calls)
def _strip_special_tokens(self, text: str) -> str:
"""Remove special tokens from text."""
return text.replace(self.bot_token, "").replace(self.eot_token, "")
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing for LFM2 tool calls.
This implementation properly handles Pythonic format by:
1. Buffering until we see complete <|tool_call_start|>[...]<|tool_call_end|>
2. Emitting normal text before tool calls immediately
3. Parsing complete tool call blocks using detect_and_parse
Based on PythonicDetector streaming logic.
"""
self._buffer += new_text
# Check for partial bot_token at the end
partial_bot = self._ends_with_partial_token(self._buffer, self.bot_token)
partial_eot = self._ends_with_partial_token(self._buffer, self.eot_token)
# Find bot_token position
bot_pos = self._buffer.find(self.bot_token)
if bot_pos == -1:
# No tool call start found
if partial_bot:
# Might be partial bot_token, hold back that part
safe_text = self._buffer[:-partial_bot]
self._buffer = self._buffer[-partial_bot:]
return StreamingParseResult(normal_text=safe_text)
else:
# No tool call, emit all as normal text
normal_text = self._strip_special_tokens(self._buffer)
self._buffer = ""
return StreamingParseResult(normal_text=normal_text)
# We have bot_token - extract any normal text before it
normal_text_before = self._buffer[:bot_pos] if bot_pos > 0 else ""
# Look for the end token
eot_pos = self._buffer.find(self.eot_token, bot_pos + len(self.bot_token))
if eot_pos == -1:
# No end token yet - check if we might have a partial one
if partial_eot:
# Hold back the partial token, but we need to keep buffering
# Just emit any normal text before the tool call
if normal_text_before:
self._buffer = self._buffer[bot_pos:]
return StreamingParseResult(normal_text=normal_text_before)
# Keep buffering
return StreamingParseResult(normal_text="")
# No end token and no partial - keep buffering but emit normal text
if normal_text_before:
self._buffer = self._buffer[bot_pos:]
return StreamingParseResult(normal_text=normal_text_before)
# Just keep buffering
return StreamingParseResult(normal_text="")
# We have a complete tool call block
tool_call_block = self._buffer[bot_pos : eot_pos + len(self.eot_token)]
remaining = self._buffer[eot_pos + len(self.eot_token) :]
# Parse the complete block
result = self.detect_and_parse(tool_call_block, tools)
# Update buffer with remaining text
self._buffer = remaining
# Add any normal text before the tool call
if normal_text_before:
result.normal_text = normal_text_before + (result.normal_text or "")
return result
def supports_structural_tag(self) -> bool:
"""
Return False because LFM2 uses Pythonic format which is not JSON-compatible.
structural_tag only supports JSON-compatible content between begin and end,
so it cannot parse Pythonic function call syntax like `func(arg="val")`.
"""
return False
def structure_info(self) -> _GetInfoFunc:
"""
Return structure info for constrained generation.
Note: This is provided for completeness but won't be used since
supports_structural_tag() returns False.
"""
return lambda name: StructureInfo(
begin="<|tool_call_start|>[" + name + "(",
end=")]<|tool_call_end|>",
trigger="<|tool_call_start|>",
)
@@ -0,0 +1,144 @@
import ast
import json
import logging
import re
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
class Llama32Detector(BaseFormatDetector):
"""
Detector for Llama 3.2 models with json tool call format.
Format Structure:
```
<python_tag>{"name":"xxx", "arguments":{...}}
```
"""
def __init__(self):
super().__init__()
self.bot_token = "<|python_tag|>"
# NOTE: technically Llama3.2 doesn't support well with parallel tool calls
# They need specific prompt engineering to support parallel tool calls
# Here we use ';' as the separator, which might have compatibility issues
# if users define to use a different separator in their prompt
self.tool_call_separator = ";"
def _convert_python_dict_to_json(self, text: str) -> str:
"""Convert Python dict strings to JSON format."""
try:
parsed = ast.literal_eval(text.strip())
if isinstance(parsed, dict):
return json.dumps(parsed, ensure_ascii=False)
except:
pass
return text
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a Llama 3.2 format tool call."""
# depending on the prompt format the Llama model may or may not
# prefix the output with the <|python_tag|> token
return "<|python_tag|>" in text or text.startswith("{")
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""Parse function calls from text, handling multiple JSON objects."""
if "<|python_tag|>" not in text and not text.startswith("{"):
return StreamingParseResult(normal_text=text, calls=[])
if "<|python_tag|>" in text:
normal_text, action_text = text.split("<|python_tag|>", maxsplit=1)
else:
normal_text, action_text = "", text
decoder = json.JSONDecoder()
idx = 0
safe_idx = idx # the index of the last valid JSON object
all_actions = []
action_text_len = len(action_text)
while idx < action_text_len:
try:
obj, end = decoder.raw_decode(action_text[idx:])
all_actions.append(obj)
idx += end + len(self.tool_call_separator)
safe_idx = idx
except json.JSONDecodeError:
# Try Python dict conversion as fallback
try:
dict_end = idx
brace_count = 0
for i in range(idx, action_text_len):
if action_text[i] == "{":
brace_count += 1
elif action_text[i] == "}":
brace_count -= 1
if brace_count == 0:
dict_end = i + 1
break
if dict_end > idx:
potential_dict = action_text[idx:dict_end]
json_version = self._convert_python_dict_to_json(potential_dict)
if json_version != potential_dict:
obj, _ = decoder.raw_decode(json_version)
all_actions.append(obj)
idx = dict_end + len(self.tool_call_separator)
safe_idx = idx
continue
except:
pass
next_obj_start = action_text.find('{"name":', idx + 1)
if next_obj_start == -1:
break
idx = next_obj_start
# Only process if we found valid JSON objects
calls = self.parse_base_json(all_actions, tools) if all_actions else []
# Use safe_idx to avoid idx containing the last part of an invalid JSON object
trailing_text = (
action_text[safe_idx:].strip() if safe_idx < action_text_len else ""
)
return StreamingParseResult(
normal_text=normal_text + trailing_text, calls=calls
)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""Override to handle Python dict format in streaming."""
# First try with converted Python dict
self._buffer += new_text
converted_buffer = self._buffer
# Convert Python dict syntax to JSON
converted_buffer = re.sub(r"'([^']*)':", r'"\1":', converted_buffer)
converted_buffer = re.sub(r":\s*'([^']*)'", r': "\1"', converted_buffer)
# Temporarily replace buffer for parsing
original_buffer = self._buffer
self._buffer = converted_buffer
try:
result = super().parse_streaming_increment("", tools)
return result
except:
# Fall back to original buffer
self._buffer = original_buffer
return super().parse_streaming_increment(new_text, tools)
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin='<|python_tag|>{"name":"' + name + '", "arguments":',
end="}",
trigger="<|python_tag|>",
)
@@ -0,0 +1,281 @@
# 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.
# ==============================================================================
import ast
import html
import json
import logging
import re
from typing import Any, Dict, List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import StreamingParseResult, _GetInfoFunc
logger = logging.getLogger(__name__)
def _get_param_type(func_name: str, param_name: str, tools: List[Tool]) -> str:
"""Get parameter type from tool schema."""
for tool in tools:
if tool.function.name == func_name:
props = tool.function.parameters.get("properties", {})
if param_name in props:
return props[param_name].get("type", "string")
return "string"
def _convert_param_value(
param_value: str, param_name: str, func_name: str, tools: List[Tool]
) -> Any:
"""
Convert parameter value based on its type in the schema.
Adapted from vllm-project/vllm (vllm/entrypoints/openai/tool_parsers/qwen3coder_tool_parser.py)
"""
param_value = html.unescape(param_value)
# Handle null value for any type
if param_value.lower() == "null":
return None
param_type = _get_param_type(func_name, param_name, tools)
if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
return param_value
elif (
param_type.startswith("int")
or param_type.startswith("integer")
or param_type.startswith("uint")
or param_type.startswith("long")
or param_type.startswith("short")
or param_type.startswith("unsigned")
):
try:
return int(param_value)
except (ValueError, TypeError):
logger.warning(
"Parsed value '%s' of parameter '%s' is not an "
"integer in tool '%s', degenerating to string.",
param_value,
param_name,
func_name,
)
return param_value
elif param_type.startswith("num") or param_type.startswith("float"):
try:
float_param_value = float(param_value)
return (
float_param_value
if float_param_value - int(float_param_value) != 0
else int(float_param_value)
)
except (ValueError, TypeError):
logger.warning(
"Parsed value '%s' of parameter '%s' is not a float "
"in tool '%s', degenerating to string.",
param_value,
param_name,
func_name,
)
return param_value
elif param_type in ["boolean", "bool", "binary"]:
param_value = param_value.lower()
if param_value not in ["true", "false"]:
logger.warning(
"Parsed value '%s' of parameter '%s' is not a boolean "
"(`true` or `false`) in tool '%s', degenerating to "
"false.",
param_value,
param_name,
func_name,
)
return param_value == "true"
else:
if (
param_type in ["object", "array", "arr"]
or param_type.startswith("dict")
or param_type.startswith("list")
):
try:
param_value = json.loads(param_value)
return param_value
except (json.JSONDecodeError, TypeError, ValueError):
logger.warning(
"Parsed value '%s' of parameter '%s' cannot be "
"parsed with json.loads in tool '%s', will try "
"other methods to parse it.",
param_value,
param_name,
func_name,
)
try:
param_value = ast.literal_eval(param_value) # safer
except (ValueError, SyntaxError, TypeError):
logger.warning(
"Parsed value '%s' of parameter '%s' cannot be "
"converted via Python `ast.literal_eval()` in tool "
"'%s', degenerating to string.",
param_value,
param_name,
func_name,
)
return param_value
class MiMoDetector(BaseFormatDetector):
"""
Detector for MiMo function call format.
Format:
<tool_call>
<function=execute_bash>
<parameter=command>pwd && ls</parameter>
</function>
</tool_call>
"""
def __init__(self):
super().__init__()
self.bot_token = "<tool_call>"
self.eot_token = "</tool_call>"
self.tool_call_regex = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL)
self.func_regex = re.compile(r"<function=([^>]+)>(.*?)</function>", re.DOTALL)
self.param_regex = re.compile(
r"<parameter=([^>]+)>(.*?)</parameter>", re.DOTALL
)
def has_tool_call(self, text: str) -> bool:
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""Parse complete text for tool calls."""
idx = text.find(self.bot_token)
if idx == -1:
return StreamingParseResult(normal_text=text, calls=[])
normal_text = text[:idx]
tool_indices = self._get_tool_indices(tools)
calls = []
last_end = idx
for match in self.tool_call_regex.finditer(text):
tool_call_body = match.group(1)
parsed = self._parse_tool_call(tool_call_body, tools)
if parsed:
func_name = parsed.get("name")
if func_name not in tool_indices:
# Unknown function
logger.warning(f"Unknown function: {func_name}")
if not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get():
# Return tool call block as normal text
normal_text += text[last_end : match.end()]
last_end = match.end()
continue
calls.extend(self.parse_base_json(parsed, tools))
last_end = match.end()
return StreamingParseResult(normal_text=normal_text, calls=calls)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming parsing: buffer until complete tool call block.
"""
self._buffer += new_text
current_text = self._buffer
start = current_text.find(self.bot_token)
if start == -1:
if self.current_tool_id > 0:
# Already processing tool calls, keep buffering
# (more tool calls might come, don't discard text yet)
return StreamingParseResult(normal_text="")
else:
# No tool calls seen yet, return as normal text
self._buffer = ""
return StreamingParseResult(normal_text=current_text)
# Find end token AFTER the start token
end = current_text.find(self.eot_token, start)
if end == -1:
# Incomplete tool call, return text before start and keep buffering
normal_text = current_text[:start]
self._buffer = current_text[start:]
return StreamingParseResult(normal_text=normal_text)
# Parse the complete tool call block
result = self.detect_and_parse(current_text[: end + len(self.eot_token)], tools)
if result.calls:
# Valid tool call - initialize tracking if first one
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
call = result.calls[0]
self.prev_tool_call_arr[self.current_tool_id] = {
"name": call.name,
"arguments": json.loads(call.parameters) if call.parameters else {},
}
self.streamed_args_for_tool[self.current_tool_id] = call.parameters
call.tool_index = self.current_tool_id
self.current_tool_id += 1
self._buffer = current_text[end + len(self.eot_token) :]
return result
def _parse_tool_call(
self, tool_call_body: str, tools: List[Tool]
) -> Dict[str, Any]:
"""
Parse content inside <tool_call>...</tool_call>.
Structure:
tool_call_body contains: <function=name>...params...</function>
"""
# Match complete <function=name>body</function> block
func_match = self.func_regex.search(tool_call_body)
if not func_match:
return None
func_name = func_match.group(1).strip()
func_body = func_match.group(2)
params = {}
for param_match in self.param_regex.finditer(func_body):
param_name = param_match.group(1).strip()
param_value = param_match.group(2)
params[param_name] = _convert_param_value(
param_value, param_name, func_name, tools
)
return {"name": func_name, "parameters": params}
def supports_structural_tag(self) -> bool:
return False
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
@@ -0,0 +1,317 @@
import ast
import json
import logging
import re
from typing import Dict, List, Optional
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
try:
from lxml import etree as ET # type: ignore
_HAS_LXML = True
except Exception: # pragma: no cover - environment may not have lxml
import xml.etree.ElementTree as ET # type: ignore
_HAS_LXML = False
_FUNC_NAME_V1_REGEX = re.compile(r"<function\s+name=[\'\"]([^\'\"]+)[\'\"][^>]*>")
_PARAM_WITH_NAME_REGEX = re.compile(
r"<param\s+name=[\'\"]([^\'\"]+)[\'\"]>([\s\S]*?)</param>", re.DOTALL
)
_PARAM_MISSING_NAME_REGEX = re.compile(r"<param(?![^>]*\bname=)[^>]*>", re.DOTALL)
def get_argument_type(
func_name: str, arg_key: str, name_to_tool: Dict[str, Tool]
) -> Optional[str]:
tool = name_to_tool.get(func_name)
if not tool:
return None
params = tool.function.parameters or {}
if not isinstance(params, dict):
return None
return params.get("properties", {}).get(arg_key, {}).get("type")
def parse_arguments(json_value):
try:
try:
parsed_value = json.loads(json_value)
except (json.JSONDecodeError, TypeError):
parsed_value = ast.literal_eval(json_value)
return parsed_value, True
except (ValueError, SyntaxError, TypeError):
return json_value, False
class MiniCPM5Detector(BaseFormatDetector):
"""
Detector for MiniCPM-4 models (V3 schema) adapted to the new chat template.
Expected format example (multiple calls allowed):
<function name="get_weather"><param name="city">北京</param><param name="date">2024-06-27</param></function>
<function name="get_weather"><param name="city"><![CDATA[多行\n文本]]></param></function>
"""
def __init__(self):
super().__init__()
self.bot_token = "<function"
self.eot_token = "</function>"
self.func_call_regex = r"<function.*?</function>"
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a MiniCPM-4 V3 XML-styled tool call."""
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
idx = text.find(self.bot_token)
if idx == -1:
return StreamingParseResult(normal_text=text, calls=[])
normal_parts = []
calls = []
name_to_tool = {t.function.name: t for t in tools if t.function.name}
tool_names = set(name_to_tool.keys())
name_to_allowed_props = {}
name_to_required = {}
for name, t in name_to_tool.items():
params = t.function.parameters or {}
props = (
(params.get("properties", {}) or {}) if isinstance(params, dict) else {}
)
name_to_allowed_props[name] = set(props.keys())
req = params.get("required", []) if isinstance(params, dict) else []
try:
name_to_required[name] = set(req)
except Exception:
name_to_required[name] = set()
try:
last_end = 0
for m in re.finditer(self.func_call_regex, text, re.DOTALL):
if m.start() > last_end:
normal_parts.append(text[last_end : m.start()])
block = m.group(0)
func_name = None
arguments = {}
parsed_ok = False
param_invalid = False
# Primary path: XML parsing (lxml preferred, stdlib fallback)
try:
if _HAS_LXML:
try:
parser = ET.XMLParser(**{"strip_cdata": False}) # type: ignore[call-arg]
except TypeError:
parser = ET.XMLParser()
root = ET.fromstring(block, parser=parser)
else:
root = ET.fromstring(block)
if root.tag == "function":
func_node = root
else:
func_node = (
root.find("function") if hasattr(root, "find") else None
)
if func_node is not None:
func_name = (func_node.attrib.get("name") or "").strip()
args_node = (
func_node.find("arguments") if func_node is not None else None
)
param_nodes = []
if func_node is not None:
param_nodes = list(func_node.findall("param"))
if args_node is not None and not param_nodes:
param_nodes = list(args_node.findall("param"))
if func_node is not None:
seen_keys = set()
allowed_props = set()
if func_name in tool_names:
allowed_props = name_to_allowed_props.get(func_name, set())
has_invalid_param = False
for param in param_nodes:
key = param.attrib.get("name")
if not key:
has_invalid_param = True
break
if allowed_props and key not in allowed_props:
has_invalid_param = True
break
if key in seen_keys:
has_invalid_param = True
break
seen_keys.add(key)
val_text = param.text or ""
val_text = val_text.strip()
arg_type = get_argument_type(
func_name or "", key, name_to_tool
)
if arg_type != "string":
parsed_val, _ = parse_arguments(val_text)
arguments[key] = parsed_val
else:
arguments[key] = val_text
if has_invalid_param:
arguments.clear()
param_invalid = True
parsed_ok = bool(func_name)
except Exception:
parsed_ok = False
if not parsed_ok:
# Fallback path: regex extraction
try:
m_fn = _FUNC_NAME_V1_REGEX.search(block)
if m_fn:
func_name = (m_fn.group(1) or "").strip()
has_invalid_param = (
_PARAM_MISSING_NAME_REGEX.search(block) is not None
)
seen_keys = set()
allowed_props = set()
if func_name in tool_names:
allowed_props = name_to_allowed_props.get(func_name, set())
for pm in _PARAM_WITH_NAME_REGEX.finditer(block):
key = pm.group(1).strip()
if allowed_props and key not in allowed_props:
has_invalid_param = True
break
if key in seen_keys:
has_invalid_param = True
break
seen_keys.add(key)
val_text = pm.group(2) or ""
if val_text.startswith("<![CDATA[") and val_text.endswith(
"]]>"
):
val_text = val_text[len("<![CDATA[") : -len("]]>")]
val_text = val_text.strip()
arg_type = get_argument_type(
func_name or "", key, name_to_tool
)
if arg_type != "string":
parsed_val, _ = parse_arguments(val_text)
arguments[key] = parsed_val
else:
arguments[key] = val_text
if has_invalid_param:
arguments.clear()
param_invalid = True
parsed_ok = bool(func_name)
except Exception:
parsed_ok = False
if not func_name or func_name not in tool_names or param_invalid:
parsed_ok = False
else:
req_props = name_to_required.get(func_name, set())
if req_props and not req_props.issubset(arguments.keys()):
parsed_ok = False
if parsed_ok:
tool_call_obj = {"name": func_name, "parameters": arguments}
calls.extend(self.parse_base_json(tool_call_obj, tools))
else:
normal_parts.append(block)
last_end = m.end()
if last_end < len(text):
normal_parts.append(text[last_end:])
return StreamingParseResult(normal_text="".join(normal_parts), calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}")
return StreamingParseResult(normal_text=text)
def _append_tool_call(self, call, all_calls: List) -> None:
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = [""]
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
self.prev_tool_call_arr[self.current_tool_id] = {
"name": call.name,
"arguments": json.loads(call.parameters),
}
self.streamed_args_for_tool[self.current_tool_id] = call.parameters
call.tool_index = self.current_tool_id
self.current_tool_id += 1
all_calls.append(call)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
self._buffer += new_text
normal_parts = []
all_calls = []
while True:
current_text = self._buffer
start = current_text.find(self.bot_token)
if start == -1:
partial_len = self._ends_with_partial_token(
current_text, self.bot_token
)
if partial_len > 0:
self._buffer = current_text[-partial_len:]
emit = current_text[:-partial_len]
else:
self._buffer = ""
emit = "" if self.current_tool_id > 0 else current_text
if emit:
normal_parts.append(emit)
break
if start > 0:
normal_parts.append(current_text[:start])
current_text = current_text[start:]
end = current_text.find(self.eot_token)
if end == -1:
self._buffer = current_text
break
block = current_text[: end + len(self.eot_token)]
self._buffer = current_text[end + len(self.eot_token) :]
result = self.detect_and_parse(block, tools=tools)
for call in result.calls:
self._append_tool_call(call, all_calls)
if self.bot_token not in self._buffer:
partial_len = self._ends_with_partial_token(
self._buffer, self.bot_token
)
if partial_len == 0:
emit = "" if self.current_tool_id > 0 else self._buffer
if emit:
normal_parts.append(emit)
self._buffer = ""
break
return StreamingParseResult(normal_text="".join(normal_parts), calls=all_calls)
def supports_structural_tag(self) -> bool:
return False
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError()
@@ -0,0 +1,522 @@
import json
import logging
import re
from typing import Any, Dict, List, Tuple
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
class MinimaxM2Detector(BaseFormatDetector):
"""
Detector for MiniMax M2 models.
Assumes function call format:
<minimax:tool_call>
<invoke name="func1">
<parameter name="param1">value1</parameter>
<parameter name="param2">value2</parameter>
</invoke>
</minimax:tool_call>
"""
def __init__(self):
super().__init__()
self.tool_call_start_token: str = "<minimax:tool_call>"
self.tool_call_end_token: str = "</minimax:tool_call>"
self.tool_call_prefix: str = '<invoke name="'
self.tool_call_function_end_token: str = "</invoke>"
self.tool_call_regex = re.compile(
r"<minimax:tool_call>(.*?)</minimax:tool_call>|<minimax:tool_call>(.*?)$",
re.DOTALL,
)
self.tool_call_function_regex = re.compile(
r"<invoke name=\"(.*?)</invoke>|<invoke name=\"(.*)$", re.DOTALL
)
self.tool_call_parameter_regex = re.compile(
r"<parameter name=\"(.*?)</parameter>|<parameter name=\"(.*?)$", re.DOTALL
)
self._buf: str = ""
# Streaming state variables
self._current_function_name: str = ""
self._current_parameters: Dict[str, Any] = {}
self._streamed_parameters: Dict[str, str] = (
{}
) # Track what parameter content we've streamed
self._in_tool_call: bool = False
self._function_name_sent: bool = False
def has_tool_call(self, text: str) -> bool:
return self.tool_call_start_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
normal, calls = self._extract(text, tools)
return StreamingParseResult(normal_text=normal, calls=calls)
def _convert_param_value(self, value: str, param_type: str) -> Any:
"""Convert parameter value to the correct type (legacy single-type version)."""
return self._convert_param_value_with_types(value, [param_type])
def _extract_types_from_schema(self, schema: Any) -> list[str]:
"""
Extract all possible types from a JSON schema definition.
Handles anyOf, oneOf, allOf, type arrays, and enum fields.
Args:
schema: The JSON schema definition for a parameter
Returns:
List of type strings (e.g., ["string", "integer", "null"])
"""
if schema is None:
return ["string"]
if not isinstance(schema, dict):
return ["string"]
types: set[str] = set()
# Handle direct "type" field
if "type" in schema:
type_value = schema["type"]
if isinstance(type_value, str):
types.add(type_value)
elif isinstance(type_value, list):
for t in type_value:
if isinstance(t, str):
types.add(t)
# Handle enum - infer types from enum values
if "enum" in schema and isinstance(schema["enum"], list) and schema["enum"]:
for value in schema["enum"]:
if value is None:
types.add("null")
elif isinstance(value, bool):
types.add("boolean")
elif isinstance(value, int):
types.add("integer")
elif isinstance(value, float):
types.add("number")
elif isinstance(value, str):
types.add("string")
elif isinstance(value, list):
types.add("array")
elif isinstance(value, dict):
types.add("object")
# Handle anyOf, oneOf, allOf - recursively extract types
for choice_field in ("anyOf", "oneOf", "allOf"):
if choice_field in schema and isinstance(schema[choice_field], list):
for choice in schema[choice_field]:
extracted = self._extract_types_from_schema(choice)
types.update(extracted)
# If no types found, default to string
if not types:
return ["string"]
return list(types)
def _convert_param_value_with_types(
self, value: str, param_types: list[str]
) -> Any:
"""
Convert parameter value to the correct type based on a list of possible types.
Tries each type in order until one succeeds.
Args:
value: The string value to convert
param_types: List of possible type strings
Returns:
The converted value
"""
if value.lower() == "null":
return None
# Normalize types
normalized_types = [t.lower() for t in param_types]
# Try null first if it's in the list
if "null" in normalized_types or value.lower() in ("null", "none", "nil"):
return None
# Try each type in order of preference (most specific first, string as fallback)
# Priority: integer > number > boolean > object > array > string
type_priority = [
"integer",
"int",
"number",
"float",
"boolean",
"bool",
"object",
"array",
"string",
"str",
"text",
]
for param_type in type_priority:
if param_type not in normalized_types:
continue
if param_type in ["string", "str", "text"]:
return value
elif param_type in ["integer", "int"]:
try:
return int(value)
except (ValueError, TypeError):
continue
elif param_type in ["number", "float"]:
try:
val = float(value)
return val if val != int(val) else int(val)
except (ValueError, TypeError):
continue
elif param_type in ["boolean", "bool"]:
lower_val = value.lower().strip()
if lower_val in ["true", "1", "yes", "on"]:
return True
elif lower_val in ["false", "0", "no", "off"]:
return False
continue
elif param_type in ["object", "array"]:
try:
return json.loads(value)
except json.JSONDecodeError:
continue
# Fallback: try JSON parse, then return as string
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def _get_param_types_from_config(
self, param_name: str, param_config: dict
) -> list[str]:
"""
Get parameter types from parameter configuration.
Handles anyOf, oneOf, allOf, and direct type definitions.
Args:
param_name: The name of the parameter
param_config: The properties dict from the tool schema
Returns:
List of type strings
"""
if param_name not in param_config:
return ["string"]
param_schema = param_config[param_name]
if not isinstance(param_schema, dict):
return ["string"]
return self._extract_types_from_schema(param_schema)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
self._buf += new_text
normal = ""
calls: List[ToolCallItem] = []
# Build tool indices for validation
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
while True:
# If we're not in a tool call and don't see a start token, return normal text
if not self._in_tool_call and self.tool_call_start_token not in self._buf:
normal += self._buf
self._buf = ""
break
# Look for tool call start
if not self._in_tool_call:
s = self._buf.find(self.tool_call_start_token)
if s == -1:
normal += self._buf
self._buf = ""
break
normal += self._buf[:s]
self._buf = self._buf[s:]
self._in_tool_call = True
self._function_name_sent = False
self._current_function_name = ""
self._current_parameters = {}
self._streamed_parameters = {}
# Remove the start token
self._buf = self._buf[len(self.tool_call_start_token) :]
continue
# We're in a tool call, try to parse function name if not sent yet
if not self._function_name_sent:
# Look for function name pattern: <invoke name=name>
function_match = re.search(r"<invoke name=\"([^>]+)\">", self._buf)
if function_match:
function_name = function_match.group(1).strip()
# Validate function name
if function_name in self._tool_indices:
self._current_function_name = function_name
self._function_name_sent = True
# Initialize tool call tracking
if self.current_tool_id == -1:
self.current_tool_id = 0
# Ensure tracking arrays are large enough
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Store tool call info
self.prev_tool_call_arr[self.current_tool_id] = {
"name": function_name,
"arguments": {},
}
# Send tool name with empty parameters
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=function_name,
parameters="",
)
)
# Remove the processed function declaration
self._buf = self._buf[function_match.end() :]
continue
else:
# Invalid function name, reset state
logger.warning(f"Invalid function name: {function_name}")
self._reset_streaming_state()
normal += self._buf
self._buf = ""
break
else:
# Function name not complete yet, wait for more text
break
# Parse parameters incrementally
if self._function_name_sent:
# Process parameters and get any calls to emit
parameter_calls = self._parse_and_stream_parameters(self._buf, tools)
calls.extend(parameter_calls)
# Check if tool call is complete
if self.tool_call_function_end_token in self._buf:
end_pos = self._buf.find(self.tool_call_function_end_token)
# Add closing brace to complete the JSON object
current_streamed = self.streamed_args_for_tool[self.current_tool_id]
if current_streamed:
# Count opening and closing braces to check if JSON is complete
open_braces = current_streamed.count("{")
close_braces = current_streamed.count("}")
if open_braces > close_braces:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters="}",
)
)
self.streamed_args_for_tool[self.current_tool_id] = (
current_streamed + "}"
)
# Complete the tool call
self._buf = self._buf[
end_pos + len(self.tool_call_function_end_token) :
]
self._reset_streaming_state(True)
self.current_tool_id += 1
continue
else:
# Tool call not complete yet, wait for more text
break
return StreamingParseResult(normal_text=normal, calls=calls)
def _parse_and_stream_parameters(
self, text_to_parse: str, tools: List[Tool]
) -> List[ToolCallItem]:
"""
Parse complete parameter blocks from text and return any tool call items to emit.
This method:
1. Finds all complete <parameter> blocks
2. Parses them into a dictionary
3. Compares with current parameters and generates diff if needed
4. Updates internal state
Args:
text_to_parse: The text to search for parameter blocks
Returns:
List of ToolCallItem objects to emit (may be empty)
"""
calls: List[ToolCallItem] = []
# Find all complete parameter patterns
param_matches = list(
re.finditer(
r"<parameter name=\"([^>]+)\">(.*?)</parameter>",
text_to_parse,
re.DOTALL,
)
)
# Build new parameters dictionary
new_params = {}
for match in param_matches:
param_name = match.group(1).strip()
param_value = match.group(2)
new_params[param_name] = self._parse_parameter(
self._current_function_name, param_name, param_value, tools
)
# Calculate parameter diff to stream with proper incremental JSON building
if new_params != self._current_parameters:
previous_args_json = self.streamed_args_for_tool[self.current_tool_id]
# Build incremental JSON properly
if not self._current_parameters:
# First parameter(s) - start JSON object but don't close it yet
items = []
for key, value in new_params.items():
items.append(
f"{json.dumps(key, ensure_ascii=False)}: {json.dumps(value, ensure_ascii=False)}"
)
json_fragment = "{" + ", ".join(items)
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=json_fragment,
)
)
self.streamed_args_for_tool[self.current_tool_id] = json_fragment
else:
# Additional parameters - add them incrementally
new_keys = set(new_params.keys()) - set(self._current_parameters.keys())
if new_keys:
# Build the continuation part (no closing brace yet)
continuation_parts = []
for key in new_keys:
value = new_params[key]
continuation_parts.append(
f"{json.dumps(key, ensure_ascii=False)}: {json.dumps(value, ensure_ascii=False)}"
)
json_fragment = ", " + ", ".join(continuation_parts)
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=None,
parameters=json_fragment,
)
)
self.streamed_args_for_tool[self.current_tool_id] = (
previous_args_json + json_fragment
)
# Update current state
self._current_parameters = new_params
self.prev_tool_call_arr[self.current_tool_id]["arguments"] = new_params
return calls
def _reset_streaming_state(self, still_in_tool_call: bool = False):
"""Reset streaming state for the next tool call"""
self._in_tool_call = still_in_tool_call
self._function_name_sent = False
self._current_function_name = ""
self._current_parameters = {}
self._streamed_parameters = {}
self.current_tool_name_sent = False
def _extract(self, text: str, tools: List[Tool]) -> Tuple[str, List[ToolCallItem]]:
normal_parts: List[str] = []
calls: List[ToolCallItem] = []
cursor = 0
while True:
s = text.find(self.tool_call_start_token, cursor)
if s == -1:
normal_parts.append(text[cursor:])
break
normal_parts.append(text[cursor:s])
e = text.find(self.tool_call_end_token, s)
if e == -1:
normal_parts.append(text[s:])
break
block = text[s : e + len(self.tool_call_end_token)]
cursor = e + len(self.tool_call_end_token)
calls.extend(self._parse_block(block, tools))
return "".join(normal_parts), calls
def _parse_block(self, block: str, tools: List[Tool]) -> List[ToolCallItem]:
res: List[ToolCallItem] = []
for m in self.tool_call_function_regex.findall(block):
txt = m[0] if m[0] else m[1]
if '">' not in txt:
continue
idx = txt.index('">')
fname = txt[:idx].strip()
body = txt[idx + 2 :]
params: Dict[str, Any] = {}
for pm in self.tool_call_parameter_regex.findall(body):
ptxt = pm[0] if pm[0] else pm[1]
if '">' not in ptxt:
continue
pidx = ptxt.index('">')
pname = ptxt[:pidx].strip()
pval = ptxt[pidx + 2 :].lstrip("\n").rstrip("\n")
params[pname] = self._parse_parameter(fname, pname, pval, tools)
raw = {"name": fname, "arguments": params}
try:
# TODO: fix idx in function call, the index for a function
# call will always be -1 in parse_base_json
res.extend(self.parse_base_json(raw, tools))
except Exception:
logger.warning("invalid tool call for %s dropped", fname)
return res
def _parse_parameter(
self, fname: str, pname: str, pval: str, tools: List[Tool]
) -> Any:
param_config = {}
for tool in tools:
if tool.function.name == fname and tool.function.parameters is not None:
parameters = tool.function.parameters
if isinstance(parameters, dict) and "properties" in parameters:
param_config = parameters["properties"]
break
param_type = self._get_param_types_from_config(pname, param_config)
return self._convert_param_value_with_types(pval, param_type)
def supports_structural_tag(self) -> bool:
return False
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
@@ -0,0 +1,542 @@
import json
import logging
import re
from typing import Any, Dict, List, Optional, Tuple
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
MINIMAX_NS_TOKEN = "]<]minimax[>["
STRING_TYPES = {"string", "str"}
INTEGER_TYPES = {"integer", "int"}
NUMBER_TYPES = {"number", "float"}
BOOLEAN_TYPES = {"boolean", "bool"}
SCALAR_TYPES = STRING_TYPES | INTEGER_TYPES | NUMBER_TYPES | BOOLEAN_TYPES | {"null"}
CONTAINER_TYPES = {"object", "array"}
# Exact-case only; "none"/"nil" are valid string enum values, never null-coerced.
NULL_STRINGS = {"null", "Null", "NULL"}
class MinimaxM3Detector(BaseFormatDetector):
TOOL_CALL_START = MINIMAX_NS_TOKEN + "<tool_call>"
TOOL_CALL_END = MINIMAX_NS_TOKEN + "</tool_call>"
INVOKE_PREFIX = MINIMAX_NS_TOKEN + '<invoke name="'
INVOKE_SUFFIX = MINIMAX_NS_TOKEN + "</invoke>"
PARAM_START_PREFIX = MINIMAX_NS_TOKEN + "<"
TAG_SPACING_CHARS = " \t\r\n"
TAG_SPACING_RE = re.compile(re.escape(MINIMAX_NS_TOKEN) + r"[ \t\r\n]+(?=<)")
def __init__(self):
super().__init__()
self._in_tool_call = False
self._current_function_name: Optional[str] = None
self._current_function_schema: Optional[Dict[str, Any]] = None
self._current_param_name: Optional[str] = None
self._current_param_schema: Optional[Dict[str, Any]] = None
self._current_param_buffer = ""
self._current_param_is_complex = False
self._current_string_started = False
self._is_first_param = True
@classmethod
def _normalize_tag_spacing(cls, text: str) -> str:
return cls.TAG_SPACING_RE.sub(MINIMAX_NS_TOKEN, text)
@classmethod
def _flushable_prefix_length(cls, text: str, token: str) -> int:
for length in range(min(len(token) - 1, len(text)), 0, -1):
if token.startswith(text[-length:]):
return len(text) - length
namespace_start = text.rfind(MINIMAX_NS_TOKEN)
if namespace_start == -1:
return len(text)
suffix = text[namespace_start + len(MINIMAX_NS_TOKEN) :]
if suffix and all(ch in cls.TAG_SPACING_CHARS for ch in suffix):
return namespace_start
return len(text)
def has_tool_call(self, text: str) -> bool:
return self.TOOL_CALL_START in self._normalize_tag_spacing(text)
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
original_text = text
try:
text = self._normalize_tag_spacing(text)
normal_text, calls = self._extract(text, tools)
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as exc:
logger.warning(
"invalid MiniMax M3 tool call returned as content: %s",
exc,
exc_info=True,
)
return StreamingParseResult(normal_text=original_text, calls=[])
def supports_structural_tag(self) -> bool:
return False
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
def _extract(self, text: str, tools: List[Tool]) -> Tuple[str, List[ToolCallItem]]:
normal_parts: List[str] = []
calls: List[ToolCallItem] = []
cursor = 0
while True:
start = text.find(self.TOOL_CALL_START, cursor)
if start == -1:
normal_parts.append(text[cursor:])
break
normal_parts.append(text[cursor:start])
end = text.find(self.TOOL_CALL_END, start)
if end == -1:
normal_parts.append(text[start:])
break
block = text[start + len(self.TOOL_CALL_START) : end]
cursor = end + len(self.TOOL_CALL_END)
calls.extend(self._parse_block(block, tools))
return "".join(normal_parts), calls
def _parse_block(self, block: str, tools: List[Tool]) -> List[ToolCallItem]:
results: List[ToolCallItem] = []
cursor = 0
while True:
start = block.find(self.INVOKE_PREFIX, cursor)
end = block.find(self.INVOKE_SUFFIX, start)
if start == -1 or end == -1:
break
invoke_str = block[start:end]
cursor = end + len(self.INVOKE_SUFFIX)
name_end = invoke_str.find('">', len(self.INVOKE_PREFIX))
if name_end == -1:
continue
func_name = invoke_str[len(self.INVOKE_PREFIX) : name_end]
body = invoke_str[name_end + len('">') :]
params = self._parse_parameter(
body, self._get_function_parameters_schema(func_name, tools)
)
action = {"name": func_name, "arguments": params}
try:
parsed_calls = self.parse_base_json(action, tools)
for call in parsed_calls:
call.tool_index = len(results)
results.append(call)
except Exception:
logger.warning("invalid tool call for %s dropped", func_name)
return results
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
self._buffer += new_text
self._buffer = self._normalize_tag_spacing(self._buffer)
normal_text = ""
calls: List[ToolCallItem] = []
while True:
if not self._in_tool_call:
start = self._buffer.find(self.TOOL_CALL_START)
if start == -1:
flush_length = self._flushable_prefix_length(
self._buffer, self.TOOL_CALL_START
)
normal_text += self._buffer[:flush_length]
self._buffer = self._buffer[flush_length:]
break
normal_text += self._buffer[:start]
self._buffer = self._buffer[start + len(self.TOOL_CALL_START) :]
self._in_tool_call = True
self._current_function_name = None
continue
if self._current_function_name is None:
if self._consume_tool_call_end():
continue
if not self._consume_invoke_start(tools, calls):
break
continue
if self._current_param_name is None:
if self._consume_invoke_end(calls):
continue
if not self._consume_param_start(calls):
break
continue
if self._current_param_is_complex:
if not self._consume_complex_param(calls):
break
elif not self._consume_scalar_param(calls):
break
return StreamingParseResult(normal_text=normal_text, calls=calls)
def _consume_tool_call_end(self) -> bool:
start = self._buffer.find(self.TOOL_CALL_END)
invoke_start = self._buffer.find(self.INVOKE_PREFIX)
if start == -1 or (invoke_start != -1 and invoke_start < start):
return False
self._buffer = self._buffer[start + len(self.TOOL_CALL_END) :]
self._in_tool_call = False
return True
def _consume_invoke_start(
self, tools: List[Tool], calls: List[ToolCallItem]
) -> bool:
start = self._buffer.find(self.INVOKE_PREFIX)
if start == -1:
return False
name_start = start + len(self.INVOKE_PREFIX)
name_end = self._buffer.find('">', name_start)
if name_end == -1:
return False
function_name = self._buffer[name_start:name_end]
self._buffer = self._buffer[name_end + len('">') :]
self._current_function_name = function_name
self._current_function_schema = self._get_function_parameters_schema(
function_name, tools
)
self._is_first_param = True
if self.current_tool_id == -1:
self.current_tool_id = 0
self._append_stream_call(calls, "", name=function_name)
self._append_stream_call(calls, "{")
return True
def _consume_invoke_end(self, calls: List[ToolCallItem]) -> bool:
start = self._buffer.find(self.INVOKE_SUFFIX)
param_start = self._buffer.find(self.PARAM_START_PREFIX)
if start == -1 or (param_start != -1 and param_start < start):
return False
self._buffer = self._buffer[start + len(self.INVOKE_SUFFIX) :]
self._append_stream_call(calls, "}")
self.current_tool_id += 1
self._current_function_name = None
self._current_function_schema = None
return True
def _consume_param_start(self, calls: List[ToolCallItem]) -> bool:
start = self._buffer.find(self.PARAM_START_PREFIX)
if start == -1:
return False
gt = self._buffer.find(">", start + len(self.PARAM_START_PREFIX))
if gt == -1:
return False
tag = self._buffer[start + len(self.PARAM_START_PREFIX) : gt].strip()
self._buffer = self._buffer[gt + 1 :]
self._current_param_name = tag
self._current_param_schema = self._get_child_schema(
self._current_function_schema, tag
)
self._current_param_buffer = ""
self._current_param_is_complex = self._schema_has_type(
self._current_param_schema, ("object", "array")
) or self._buffer.startswith(self.PARAM_START_PREFIX)
self._current_string_started = False
prefix = "{}{}: ".format(
"" if self._is_first_param else ", ",
json.dumps(tag, ensure_ascii=False),
)
self._append_stream_call(calls, prefix)
self._is_first_param = False
return True
def _consume_complex_param(self, calls: List[ToolCallItem]) -> bool:
end_token = self._parameter_end_token(self._current_param_name)
end = self._buffer.find(end_token)
if end == -1:
flush_length = self._flushable_prefix_length(self._buffer, end_token)
self._current_param_buffer += self._buffer[:flush_length]
self._buffer = self._buffer[flush_length:]
return False
self._current_param_buffer += self._buffer[:end]
value = self._parse_parameter(
self._current_param_buffer, self._current_param_schema
)
self._append_stream_call(calls, json.dumps(value, ensure_ascii=False))
self._buffer = self._buffer[end + len(end_token) :]
self._clear_current_param()
return True
def _consume_scalar_param(self, calls: List[ToolCallItem]) -> bool:
end_token = self._parameter_end_token(self._current_param_name)
end = self._buffer.find(end_token)
if end == -1:
flush_length = self._flushable_prefix_length(self._buffer, end_token)
text = self._buffer[:flush_length]
self._buffer = self._buffer[flush_length:]
self._stream_scalar_text(text, calls)
return False
self._stream_scalar_text(self._buffer[:end], calls)
if self._schema_has_type(self._current_param_schema, tuple(STRING_TYPES)):
if not self._current_string_started:
self._append_stream_call(calls, '"')
self._append_stream_call(calls, '"')
else:
value = self._convert_leaf_value(
self._current_param_buffer, self._current_param_schema
)
self._append_stream_call(calls, json.dumps(value, ensure_ascii=False))
self._buffer = self._buffer[end + len(end_token) :]
self._clear_current_param()
return True
def _stream_scalar_text(self, text: str, calls: List[ToolCallItem]) -> None:
if not text:
return
if self._schema_has_type(self._current_param_schema, tuple(STRING_TYPES)):
escaped = json.dumps(text, ensure_ascii=False)[1:-1]
if self._current_string_started:
self._append_stream_call(calls, escaped)
else:
self._append_stream_call(calls, '"' + escaped)
self._current_string_started = True
else:
self._current_param_buffer += text
def _clear_current_param(self) -> None:
self._current_param_name = None
self._current_param_schema = None
self._current_param_buffer = ""
self._current_param_is_complex = False
self._current_string_started = False
def _append_stream_call(
self, calls: List[ToolCallItem], parameters: str, *, name: Optional[str] = None
) -> None:
if (
name is None
and calls
and calls[-1].tool_index == self.current_tool_id
and calls[-1].name is None
):
calls[-1].parameters += parameters
else:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=name,
parameters=parameters,
)
)
def _parameter_end_token(self, tag: Optional[str]) -> str:
return MINIMAX_NS_TOKEN + f"</{tag}>"
def _get_function_parameters_schema(
self, function_name: str, tools: List[Tool]
) -> Optional[Dict[str, Any]]:
for tool in tools:
if tool.function.name == function_name:
parameters = tool.function.parameters
if isinstance(parameters, dict):
return parameters
break
return None
def _get_child_schema(
self, parent_schema: Any, child_tag: str, parent_value: Any = None
) -> Optional[Dict]:
if not isinstance(parent_schema, dict):
return None
if self._schema_has_type(parent_schema, ("array",)) and child_tag == "item":
return self._get_array_item_schema(parent_schema, parent_value)
properties = parent_schema.get("properties")
if isinstance(properties, dict) and child_tag in properties:
child_schema = properties[child_tag]
return child_schema if isinstance(child_schema, dict) else None
additional_properties = parent_schema.get("additionalProperties")
if isinstance(additional_properties, dict):
return additional_properties
return None
def _get_array_item_schema(
self, array_schema: Dict[str, Any], array_value: Any
) -> Optional[Dict]:
item_index = len(array_value) if isinstance(array_value, list) else 0
prefix_items = array_schema.get("prefixItems")
if isinstance(prefix_items, list) and item_index < len(prefix_items):
item_schema = prefix_items[item_index]
return item_schema if isinstance(item_schema, dict) else None
additional_items = array_schema.get("additionalItems")
if isinstance(additional_items, dict):
return additional_items
items = array_schema.get("items")
if isinstance(items, dict):
return items
if isinstance(prefix_items, list) and prefix_items:
item_schema = prefix_items[-1]
return item_schema if isinstance(item_schema, dict) else None
return None
def _schema_types(self, schema: Any) -> List[str]:
if not isinstance(schema, dict):
return []
schema_type = schema.get("type")
if isinstance(schema_type, str):
return [schema_type.lower()]
if isinstance(schema_type, list):
return [t.lower() for t in schema_type if isinstance(t, str)]
return []
def _schema_has_type(self, schema: Any, schema_types: Tuple[str, ...]) -> bool:
return any(t in self._schema_types(schema) for t in schema_types)
def _is_scalar_schema(self, schema: Any) -> bool:
schema_types = set(self._schema_types(schema))
return bool(schema_types & SCALAR_TYPES) and not schema_types & CONTAINER_TYPES
def _new_container_for_schema(self, schema: Any) -> Any:
if self._schema_has_type(schema, ("array",)):
return []
return {}
def _convert_leaf_value(self, value: str, schema: Any) -> Any:
schema_types = set(self._schema_types(schema))
null_permitted = "null" in schema_types
# Return verbatim; streaming emits strings literally, so null-coercing
# here would diverge the non-streaming path from streaming.
if schema_types & STRING_TYPES and not null_permitted:
return value
if null_permitted:
if value in NULL_STRINGS:
return None
if value == "":
return None
lower_value = value.lower().strip()
if schema_types & INTEGER_TYPES:
try:
return int(value)
except (TypeError, ValueError):
pass
if schema_types & NUMBER_TYPES:
try:
parsed = float(value)
return parsed if parsed != int(parsed) else int(parsed)
except (TypeError, ValueError):
pass
if schema_types & BOOLEAN_TYPES:
if lower_value in ("true", "1", "yes", "on"):
return True
if lower_value in ("false", "0", "no", "off"):
return False
return value
def _assign_child(
self, parent_value: Any, child_tag: str, child_value: Any
) -> None:
if isinstance(parent_value, list):
parent_value.append(child_value)
return
if child_tag in parent_value:
existing_value = parent_value[child_tag]
if isinstance(existing_value, list):
existing_value.append(child_value)
else:
parent_value[child_tag] = [existing_value, child_value]
return
parent_value[child_tag] = child_value
def _parse_parameter(self, body: str, parameters_schema: Optional[Dict]) -> dict:
if self._schema_has_type(
parameters_schema, ("array",)
) and self._body_starts_with_item(body):
root: Any = []
else:
root = {}
stack: List[Dict[str, Any]] = [
{"tag": "", "schema": parameters_schema, "value": root}
]
for chunk in body.split(MINIMAX_NS_TOKEN):
chunk = chunk.strip()
if not chunk:
continue
if chunk.startswith("</"):
gt = chunk.find(">", 2)
tag = chunk[2:gt].strip() if gt != -1 else chunk[2:].strip()
if len(stack) == 1:
raise ValueError(f"unexpected closing tag: {tag}")
if stack[-1]["tag"] != tag:
raise ValueError(
f"mismatched closing tag: expected {stack[-1]['tag']}, got {tag}"
)
frame = stack.pop()
self._assign_child(stack[-1]["value"], frame["tag"], frame["value"])
continue
if chunk.startswith("<"):
gt = chunk.index(">")
tag = chunk[1:gt].strip()
text = chunk[gt + 1 :]
parent_frame = stack[-1]
child_schema = self._get_child_schema(
parent_frame["schema"], tag, parent_frame["value"]
)
if text or self._is_scalar_schema(child_schema):
value = self._convert_leaf_value(text, child_schema)
else:
value = self._new_container_for_schema(child_schema)
stack.append({"tag": tag, "schema": child_schema, "value": value})
return root
def _body_starts_with_item(self, body: str) -> bool:
for chunk in body.split(MINIMAX_NS_TOKEN):
chunk = chunk.strip()
if chunk:
return chunk.startswith("<item>")
return False
@@ -0,0 +1,343 @@
import json
import logging
from typing import Any, List, Optional, Tuple
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
from sglang.srt.function_call.utils import _is_complete_json
logger = logging.getLogger(__name__)
class MistralDetector(BaseFormatDetector):
"""
Detector for Mistral tool/function call formats.
Supported formats:
1) JSON-array format:
`[TOOL_CALLS] [{"name": "...", "arguments": {...}}, ...]`
2) Compact format (common in newer templates/models, especially in streaming):
`[TOOL_CALLS]tool_name[ARGS]{...}`
(also tolerates missing delimiters like `]` after `[TOOL_CALLS` and/or `[ARGS]` while streaming)
Reference: https://huggingface.co/mistralai/Mistral-7B-Instruct-v0.3?chat_template=default
"""
def __init__(self):
"""Initialize tokens and streaming state."""
super().__init__()
# Canonical Mistral prefix for JSON-array tool calls.
self.bot_token = "[TOOL_CALLS] ["
# Common marker shared by both JSON-array and compact formats.
self._tool_calls_marker = "[TOOL_CALLS"
self.eot_token = "]"
self.tool_call_separator = ", "
def has_tool_call(self, text: str) -> bool:
"""Return True if the text contains either supported tool-call marker."""
return self._tool_calls_marker in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
:param text: The complete text to parse.
:param tools: List of available tools.
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
"""
marker_idx = text.find(self._tool_calls_marker)
if marker_idx == -1:
return StreamingParseResult(normal_text=text, calls=[])
normal_text = text[:marker_idx].strip()
tool_part = text[marker_idx:]
# Canonical: `[TOOL_CALLS] [{...}, ...]`
if self.bot_token in tool_part:
json_array_str = self._extract_json_array(tool_part)
if not json_array_str:
return StreamingParseResult(normal_text=normal_text, calls=[])
calls: list = []
try:
function_call_arr = json.loads(json_array_str)
if not isinstance(function_call_arr, list):
function_call_arr = [function_call_arr]
calls = self.parse_base_json(function_call_arr, tools)
except json.JSONDecodeError as e:
logger.warning(
f"Failed to parse JSON part: {json_array_str}, JSON parse error: {str(e)}"
)
json_pos = tool_part.find(json_array_str) if json_array_str else -1
trailing_text = (
tool_part[json_pos + len(json_array_str) :].strip()
if json_pos != -1
else ""
)
combined_normal = (
(normal_text + " " + trailing_text).strip()
if trailing_text
else normal_text
)
return StreamingParseResult(normal_text=combined_normal, calls=calls)
# Compact: `[TOOL_CALLS]tool_name[ARGS]{...}`
# Loop to extract all consecutive compact tool calls.
all_calls: list = []
remaining = tool_part
while remaining:
parsed = self._try_parse_compact_args_format(remaining)
if not parsed:
break
func_name, args_obj, consumed = parsed
new_calls = self.parse_base_json(
{"name": func_name, "arguments": args_obj}, tools
)
all_calls.extend(new_calls)
remaining = remaining[consumed:].strip()
if not all_calls:
return StreamingParseResult(normal_text=normal_text, calls=[])
combined_normal = (
(normal_text + " " + remaining).strip() if remaining else normal_text
)
return StreamingParseResult(normal_text=combined_normal, calls=all_calls)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming parsing for both JSON-array and compact formats.
For the compact format, this buffers until the JSON arguments payload is complete,
then emits two items: tool name (with empty parameters) and a full arguments JSON
chunk (OpenAI streaming semantics).
"""
self._buffer += new_text
current_text = self._buffer
# No marker: either flush as normal text or keep buffering a partial marker.
if self._tool_calls_marker not in current_text:
if not self._ends_with_partial_token(self._buffer, self._tool_calls_marker):
normal_text = self._buffer
self._buffer = ""
if self.eot_token in normal_text:
normal_text = normal_text.replace(self.eot_token, "")
return StreamingParseResult(normal_text=normal_text)
return StreamingParseResult()
# If there's leading normal text before the marker, stream it out first.
marker_pos = current_text.find(self._tool_calls_marker)
if marker_pos > 0:
normal_text = current_text[:marker_pos]
self._buffer = current_text[marker_pos:]
return StreamingParseResult(normal_text=normal_text)
# Build tool indices if not already built.
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
# Try compact first; JSON-array requires `] [` and often arrives later in streaming.
compact = self._try_parse_compact_args_format(current_text)
if compact:
func_name, args_obj, consumed = compact
if func_name not in self._tool_indices:
# Unknown tool: treat as normal text and reset state.
normal_text = self._buffer
self._buffer = ""
return StreamingParseResult(normal_text=normal_text)
# Initialize state if this is the first tool call.
if self.current_tool_id == -1:
self.current_tool_id = 0
self.prev_tool_call_arr = []
self.streamed_args_for_tool = []
args_json = json.dumps(args_obj, ensure_ascii=False)
tool_id = self.current_tool_id
# Ensure arrays are large enough.
while len(self.prev_tool_call_arr) <= tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= tool_id:
self.streamed_args_for_tool.append("")
self.prev_tool_call_arr[tool_id] = {
"name": func_name,
"arguments": args_obj,
}
self.streamed_args_for_tool[tool_id] = args_json
calls: List[ToolCallItem] = [
ToolCallItem(tool_index=tool_id, name=func_name, parameters=""),
ToolCallItem(tool_index=tool_id, name=None, parameters=args_json),
]
# Consume parsed content from buffer.
self._buffer = current_text[consumed:]
self.current_tool_id += 1
self.current_tool_name_sent = False
return StreamingParseResult(normal_text="", calls=calls)
# Canonical format delegates to the BaseFormatDetector JSON streaming logic.
if self.bot_token in current_text:
return super().parse_streaming_increment(new_text="", tools=tools)
# Otherwise, keep buffering.
return StreamingParseResult()
def _try_parse_compact_args_format(
self, text: str
) -> Optional[Tuple[str, Any, int]]:
"""
Parse the compact tool call format:
`[TOOL_CALLS]tool_name[ARGS]{...}`
Tolerates common streaming variants where delimiters are missing:
`[TOOL_CALLStool_name[ARGS{...}`
Returns:
(tool_name, arguments_obj, consumed_end_index) if a complete JSON arguments
payload is present; otherwise None.
"""
start = text.find(self._tool_calls_marker)
if start == -1:
return None
i = start + len(self._tool_calls_marker) # position after "[TOOL_CALLS"
if i < len(text) and text[i] == "]":
i += 1
while i < len(text) and text[i].isspace():
i += 1
args_marker = "[ARGS"
args_pos = text.find(args_marker, i)
if args_pos == -1:
return None
func_name = text[i:args_pos].strip()
if not func_name:
return None
j = args_pos + len(args_marker)
if j < len(text) and text[j] == "]":
j += 1
while j < len(text) and text[j].isspace():
j += 1
if j >= len(text) or text[j] not in "{[":
return None
json_str, end_idx = self._extract_json_value(text, j)
if not json_str:
return None
if not _is_complete_json(json_str):
return None
try:
args_obj = json.loads(json_str)
except json.JSONDecodeError:
return None
return func_name, args_obj, end_idx
def _extract_json_value(
self, text: str, json_start: int
) -> Tuple[Optional[str], int]:
"""
Extract a JSON value (object or array) starting at json_start using bracket counting,
robust to nested braces/brackets inside strings.
Returns:
(json_str_or_None, end_index_exclusive)
"""
if json_start >= len(text) or text[json_start] not in "{[":
return None, json_start
opening = text[json_start]
closing = "}" if opening == "{" else "]"
depth = 0
in_string = False
escape_next = False
for k in range(json_start, len(text)):
ch = text[k]
if escape_next:
escape_next = False
continue
if ch == "\\":
escape_next = True
continue
if ch == '"' and not escape_next:
in_string = not in_string
continue
if in_string:
continue
if ch == opening:
depth += 1
elif ch == closing:
depth -= 1
if depth == 0:
return text[json_start : k + 1], k + 1
return None, json_start
def _extract_json_array(self, text: str) -> str:
"""
Extract the JSON array part using bracket counting to handle nested brackets.
:param text: The complete text containing [TOOL_CALLS] [...]
:return: The JSON array string or None if not found
"""
start_idx = text.find(self.bot_token)
if start_idx == -1:
return None
# Start from the opening bracket after [TOOL_CALLS]
json_start = (
start_idx + len(self.bot_token) - 1
) # -1 to include the opening bracket
bracket_count = 0
in_string = False
escape_next = False
for i in range(json_start, len(text)):
char = text[i]
if escape_next:
escape_next = False
continue
if char == "\\":
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
continue
if not in_string:
if char == "[":
bracket_count += 1
elif char == "]":
bracket_count -= 1
if bracket_count == 0:
return text[json_start : i + 1]
return None
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin='[TOOL_CALLS] [{"name":"' + name + '", "arguments":',
end="}]",
trigger="[TOOL_CALLS]",
)
@@ -0,0 +1,452 @@
import ast
import json
import re
from enum import Enum, auto
from typing import Any, List, Optional
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
ToolCallItem,
_GetInfoFunc,
)
class _ParseState(Enum):
"""5 FSM states for the streaming parser.
Entry guard: READING_VALUE is reachable only from READING_KEY, so the
"stray <arg_value> before <tool_call>" bug class is structurally
impossible.
Exit guard: both READING_KEY and READING_VALUE recover on `</tool_call>`
by closing the active call (orphan key dropped if any). READING_VALUE
additionally recovers on `<arg_key>` by replacing the orphan pending key
with the new one. Both guards match the (regex-tightened) non-streaming
path. Without them, malformed inputs would leave the FSM stuck in
READING_VALUE and mis-attribute subsequent values to stale state.
"""
OUTSIDE = auto()
READING_NAME = auto()
READING_KEY = auto()
READING_VALUE = auto()
DRAINING = auto()
class PoolsideV1Detector(BaseFormatDetector):
"""
Detector for poolside Laguna-XS.2 (poolside_v1 series) tool-call wire format.
Wire format:
<tool_call>{name}\\n
<arg_key>{key}</arg_key>\\n
<arg_value>{val}</arg_value>\\n
...
</tool_call>
String values are emitted as raw text; non-strings are JSON-encoded by
the chat template. The parser does schema-based type coercion to round-trip
them: schema type `string` keeps the raw value; other types attempt
`json.loads` and fall back to `ast.literal_eval`, then to the raw string.
"""
# Wire-format tag tokens — constants, not per-instance.
tool_call_start_token = "<tool_call>"
tool_call_end_token = "</tool_call>"
arg_key_start = "<arg_key>"
arg_key_end = "</arg_key>"
arg_value_start = "<arg_value>"
arg_value_end = "</arg_value>"
tool_call_regex = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL)
# Key uses [^<]*? to prevent the non-greedy `.*?` from backtracking
# across an `</arg_key>` boundary on malformed inputs like
# `<arg_key>K1</arg_key><arg_key>K2</arg_key><arg_value>V</arg_value>`
# — without the `[^<]` constraint, the regex matches the entire orphan
# span as a single key (`K1</arg_key><arg_key>K2`). Param names never
# contain `<` in practice, so this is safe. The value side keeps `.*?`
# because legitimate values can contain `<` (HTML, paths, etc.); the
# `</arg_value>` boundary is anchored enough.
arg_pair_regex = re.compile(
r"<arg_key>([^<]*?)</arg_key>\s*<arg_value>(.*?)</arg_value>",
re.DOTALL,
)
_partial_tag_prefixes = (
tool_call_start_token,
tool_call_end_token,
arg_key_start,
arg_key_end,
arg_value_start,
arg_value_end,
)
def __init__(self):
super().__init__()
self.parsed_pos: int = 0
self._state: _ParseState = _ParseState.OUTSIDE
self.current_func_name: Optional[str] = None
self.current_pending_key: Optional[str] = None
self.json_started: bool = False
# ---------- Helpers ----------
def _reset_call_state(self) -> None:
"""Reset per-call FSM scratch fields. Called when entering a new
<tool_call> and on </tool_call> close."""
self.current_func_name = None
self.current_pending_key = None
self.json_started = False
def _consume_arg_key(self, slice_: str) -> bool:
"""Consume `<arg_key>K</arg_key>`, set `current_pending_key` to K.
Returns True if consumed, False if `</arg_key>` hasn't arrived yet
(caller should break to wait for more bytes). Shared by READING_KEY
(well-formed: transitions to READING_VALUE) and READING_VALUE
(orphan-key-replace: stays in READING_VALUE)."""
end = slice_.find(self.arg_key_end)
if end == -1:
return False
self.current_pending_key = slice_[len(self.arg_key_start) : end].strip()
self.parsed_pos += end + len(self.arg_key_end)
return True
def _close_current_call(self, calls: List[ToolCallItem]) -> None:
"""Emit the closing `}` (or `{}` for zero-arg) for the active call,
advance past `</tool_call>`, return to OUTSIDE, and reset per-call
state. Called from both READING_KEY (the well-formed close path) and
READING_VALUE (malformed close: `<arg_key>...</arg_key></tool_call>`
with no value — orphan key is discarded, matching the regex
non-streaming path which drops unmatched <arg_key>...</arg_key>
pairs)."""
fragment = "}" if self.json_started else "{}"
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
parameters=fragment,
)
)
self.streamed_args_for_tool[self.current_tool_id] += fragment
self.parsed_pos += len(self.tool_call_end_token)
self._state = _ParseState.OUTSIDE
self._reset_call_state()
def has_tool_call(self, text: str) -> bool:
return self.tool_call_start_token in text
@staticmethod
def _get_param_schema(
func_name: Optional[str], tools: Optional[List[Tool]]
) -> dict:
if not tools or not func_name:
return {}
for tool in tools:
try:
if (
tool.type == "function"
and tool.function.name == func_name
and isinstance(tool.function.parameters, dict)
):
return tool.function.parameters.get("properties", {})
except AttributeError:
continue
return {}
_STRING_TYPES = frozenset({"string", "str", "text", "enum"})
@staticmethod
def _convert_param_value(raw: str, schema: dict, key: str) -> Any:
"""Coerce a raw arg_value string per schema; fall back to raw on failure.
Decoder selection by schema type:
- string-like types → identity (raw text)
- no schema entry → json.loads only (conservative; don't
ast-eval untyped values)
- everything else (int,
number, bool, object, …) → json.loads, then ast.literal_eval
Each decoder result is round-tripped through `json.dumps` before being
returned; non-JSON-serializable values (sets / complex / bytes from
`ast.literal_eval`) are rejected to the next decoder, ultimately
falling through to the raw-string fallback rather than crashing the
streaming JSON emission downstream.
"""
spec = schema.get(key) if isinstance(schema, dict) else None
param_type = str(spec.get("type", "")).lower() if isinstance(spec, dict) else ""
if param_type in PoolsideV1Detector._STRING_TYPES:
return raw
decoders = (json.loads,) if not param_type else (json.loads, ast.literal_eval)
for decoder in decoders:
try:
result = decoder(raw)
# ast.literal_eval can return non-JSON-serializable values
# (sets, complex numbers); reject so json.dumps downstream
# doesn't choke.
json.dumps(result)
return result
except (ValueError, SyntaxError, TypeError):
continue
return raw
def _find_name_boundary(self, text: str) -> int:
"""Earliest of `\\n`, `<arg_key>`, `</tool_call>`. -1 if none."""
hits = (
text.find("\n"),
text.find(self.arg_key_start),
text.find(self.tool_call_end_token),
)
positive = [h for h in hits if h != -1]
return min(positive) if positive else -1
def _is_partial_tag(self, slice_: str) -> bool:
"""True if slice_ is a strict prefix of any known tag — i.e. more
bytes might complete it into a real tag."""
return any(
tag.startswith(slice_) and tag != slice_
for tag in self._partial_tag_prefixes
)
# ---------- Non-streaming ----------
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
if self.tool_call_start_token not in text:
return StreamingParseResult(normal_text=text)
tool_indices = self._get_tool_indices(tools)
first_idx = text.find(self.tool_call_start_token)
normal_text = text[:first_idx] if first_idx > 0 else ""
calls: List[ToolCallItem] = []
for body in self.tool_call_regex.findall(text):
# _find_name_boundary searches for `\n` / `<arg_key>` /
# `</tool_call>`, but the regex already stripped `</tool_call>`,
# so a no-arg call without a trailing newline
# (`<tool_call>now</tool_call>`) gives boundary == -1. Treat
# that case as "name == entire body".
boundary = self._find_name_boundary(body)
name = (body if boundary == -1 else body[:boundary]).strip()
if not name or name not in tool_indices:
continue
schema = self._get_param_schema(name, tools)
args: dict = {}
for raw_key, raw_val in self.arg_pair_regex.findall(body):
key = raw_key.strip()
# Strip at most one wrapping `\n` on each side (template adds
# them around the value); preserve newlines that are part of
# the value itself.
val = raw_val.removeprefix("\n").removesuffix("\n")
args[key] = self._convert_param_value(val, schema, key)
calls.append(
ToolCallItem(
tool_index=tool_indices[name],
name=name,
parameters=json.dumps(args, ensure_ascii=False),
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
# ---------- Streaming ----------
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
self._buffer += new_text
if not self._buffer:
return StreamingParseResult()
tool_indices = self._get_tool_indices(tools)
calls: List[ToolCallItem] = []
normal_text_chunks: List[str] = []
# No try/except: the FSM's invariants make the prior masked-IndexError
# class unreachable, and TypeError from json.dumps is prevented at the
# source (_convert_param_value round-trips its decoder output). If a
# real bug surfaces, let it surface.
while True:
slice_ = self._buffer[self.parsed_pos :]
if not slice_:
break
state = self._state
if state is _ParseState.OUTSIDE:
if slice_.startswith(self.tool_call_start_token):
self.parsed_pos += len(self.tool_call_start_token)
self._state = _ParseState.READING_NAME
self._reset_call_state()
continue
if slice_.startswith("<"):
if self._is_partial_tag(slice_):
break # could be a partial <tool_call>
normal_text_chunks.append("<")
self.parsed_pos += 1
continue
next_lt = slice_.find("<")
segment = slice_ if next_lt == -1 else slice_[:next_lt]
normal_text_chunks.append(segment)
self.parsed_pos += len(segment)
continue
if state is _ParseState.READING_NAME:
boundary = self._find_name_boundary(slice_)
if boundary == -1:
break # name still incoming
name = slice_[:boundary].strip()
# Consume the name and a single delimiting newline (if
# present). The other boundary types (<arg_key>,
# </tool_call>) are left for the next state. boundary may
# be 0 for a malformed `<tool_call><arg_key>...` (no
# name); the state transition below is the loop-progress
# guarantee.
consume = boundary
if boundary < len(slice_) and slice_[boundary : boundary + 1] == "\n":
consume += 1
self.parsed_pos += consume
if name and name in tool_indices:
self.current_tool_id += 1
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
self.current_func_name = name
# Per-response sequential index — OpenAI clients group
# chunks by tool_index, so the name event and later
# parameter fragments must share this value.
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=name,
parameters="",
)
)
self._state = _ParseState.READING_KEY
else:
# Unknown / empty name — drain to </tool_call> with no
# client-visible emission.
self._state = _ParseState.DRAINING
continue
if state is _ParseState.READING_KEY:
if slice_.startswith(self.tool_call_end_token):
self._close_current_call(calls)
continue
if slice_.startswith(self.arg_key_start):
if not self._consume_arg_key(slice_):
break # incomplete <arg_key>
self._state = _ParseState.READING_VALUE
continue
if slice_.startswith("<"):
if self._is_partial_tag(slice_):
break
# Bare '<' that's not any known tag — discard silently
# (inside a tool call, this is not normal_text).
self.parsed_pos += 1
continue
# Inter-tag whitespace / newline — discard.
next_lt = slice_.find("<")
self.parsed_pos += len(slice_) if next_lt == -1 else next_lt
continue
if state is _ParseState.READING_VALUE:
# Recover from a malformed `<arg_key>K</arg_key></tool_call>`
# (no <arg_value>) by closing the call here. Without this
# branch the FSM would stay stuck in READING_VALUE and
# mis-attribute the next call's <arg_value> to the orphan
# `current_pending_key`, silently swallowing the next call's
# name. Matches the regex non-streaming path, which drops
# unmatched <arg_key>...</arg_key> pairs.
if slice_.startswith(self.tool_call_end_token):
self._close_current_call(calls)
continue
# Recover from a malformed `<arg_key>K1</arg_key><arg_key>K2`
# (no value for K1, model went straight to a new key) by
# replacing the orphan pending_key with the new one. Stays
# in READING_VALUE so the next <arg_value> binds to K2.
# Without this branch the FSM treats the second <arg_key>
# as bare-`<` garbage and the next <arg_value> binds to
# the stale K1 — wrong-argument corruption.
if slice_.startswith(self.arg_key_start):
if not self._consume_arg_key(slice_):
break # incomplete <arg_key>
continue # stay in READING_VALUE: orphan replaced
if slice_.startswith(self.arg_value_start):
end = slice_.find(self.arg_value_end)
if end == -1:
break # incomplete <arg_value> — no partial emission
raw = (
slice_[len(self.arg_value_start) : end]
.removeprefix("\n")
.removesuffix("\n")
)
# READING_VALUE is reachable only via READING_KEY
# consuming an <arg_key>...</arg_key>, so
# current_pending_key is set by construction.
schema = self._get_param_schema(self.current_func_name, tools)
converted = self._convert_param_value(
raw, schema, self.current_pending_key
)
kv = (
f"{json.dumps(self.current_pending_key)}: "
f"{json.dumps(converted, ensure_ascii=False)}"
)
fragment = "{" + kv if not self.json_started else ", " + kv
self.json_started = True
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
parameters=fragment,
)
)
self.streamed_args_for_tool[self.current_tool_id] += fragment
self.current_pending_key = None
self.parsed_pos += end + len(self.arg_value_end)
self._state = _ParseState.READING_KEY
continue
if slice_.startswith("<"):
if self._is_partial_tag(slice_):
break
self.parsed_pos += 1
continue
next_lt = slice_.find("<")
self.parsed_pos += len(slice_) if next_lt == -1 else next_lt
continue
if state is _ParseState.DRAINING:
end_idx = slice_.find(self.tool_call_end_token)
if end_idx != -1:
self.parsed_pos += end_idx + len(self.tool_call_end_token)
self._state = _ParseState.OUTSIDE
continue
# Hold back trailing bytes that could be a prefix of
# </tool_call>; the next chunk extends the tail.
holdback = self._ends_with_partial_token(
slice_, self.tool_call_end_token
)
self.parsed_pos += len(slice_) - holdback
break
if self.parsed_pos > 0:
self._buffer = self._buffer[self.parsed_pos :]
self.parsed_pos = 0
return StreamingParseResult(
calls=calls,
normal_text="".join(normal_text_chunks),
)
# ---------- Constrained generation ----------
def supports_structural_tag(self) -> bool:
return False
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin=f"<tool_call>{name}\n",
end="</tool_call>",
trigger="<tool_call>",
)
@@ -0,0 +1,224 @@
import ast
import json
import logging
import re
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
class PythonicDetector(BaseFormatDetector):
"""
Detector for Llama-4 models with Pythonic tool call format.
The Pythonic format uses Python function call syntax within square brackets,
with arguments as Python literals rather than JSON.
Format Structure:
```
[tool1(arg1=val1, arg2=val2), tool2(arg1=val3)]
```
Reference: https://huggingface.co/meta-llama/Llama-4-Scout-17B-16E-Instruct?chat_template=default
"""
def __init__(self):
super().__init__()
self.tool_call_regex = re.compile(
r"\[([a-zA-Z]+\w*\(([a-zA-Z]+\w*=.*,\s*)*([a-zA-Z]+\w*=.*\s)?\),\s*)*([a-zA-Z]+\w*\(([a-zA-Z]+\w*=.*,\s*)*([a-zA-Z]+\w*=.*\s*)?\)\s*)+\]",
re.DOTALL,
)
@staticmethod
def _text_strip(text: str) -> str:
# Llama 4 model sometime will output <|python_start|> and <|python_end|> tokens
# remove those tokens
text = text.replace("<|python_start|>", "")
text = text.replace("<|python_end|>", "")
return text
def has_tool_call(self, text: str) -> bool:
return bool(self.tool_call_regex.search(self._text_strip(text.strip())))
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
# Try parsing the text as a Python list of function calls
text = text.strip()
# Remove unexpected <|python_start|> and <|python_end|> for llama4
text = self._text_strip(text)
match = self.tool_call_regex.search(text)
if match is None:
return StreamingParseResult(normal_text=text, calls=[])
# Extract the tool call part and any text before/after it
tool_call_start = match.start()
tool_call_end = match.end()
normal_text_before = text[:tool_call_start] if tool_call_start > 0 else ""
tool_call_text = text[tool_call_start:tool_call_end]
normal_text_after = text[tool_call_end:] if tool_call_end < len(text) else ""
# Combine normal text
normal_text = normal_text_before + normal_text_after
try:
module = ast.parse(tool_call_text)
parsed = getattr(module.body[0], "value", None)
if not (
isinstance(parsed, ast.List)
and all(isinstance(e, ast.Call) for e in parsed.elts)
):
return StreamingParseResult(normal_text=normal_text, calls=[])
calls = []
tool_indices = self._get_tool_indices(tools)
for call_index, call in enumerate(parsed.elts):
if not isinstance(call.func, ast.Name):
continue
function_name = call.func.id
# Validate that the function exists in the tools
if function_name not in tool_indices:
logger.warning(
f"Model attempted to call undefined function: {function_name}"
)
if not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get():
continue # Skip unknown tools (default legacy behavior)
arguments = {}
for keyword in call.keywords:
arguments[keyword.arg] = self._get_parameter_value(keyword.value)
calls.append(
ToolCallItem(
tool_index=call_index, # Use the call index in the response, not tool position
name=function_name,
parameters=json.dumps(arguments, ensure_ascii=False),
)
)
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception:
logger.exception("Error in pythonic tool call parsing.")
return StreamingParseResult(normal_text=normal_text, calls=[])
def _find_matching_bracket(self, buffer: str, start: int) -> int:
"""
Find the matching closing bracket for the opening bracket at start position.
Properly handles nested brackets.
Args:
buffer: The text buffer to search in
start: Position of the opening bracket '['
Returns:
Position of the matching closing bracket ']', or -1 if not found
"""
bracket_count = 0
for i in range(start, len(buffer)):
if buffer[i] == "[":
bracket_count += 1
elif buffer[i] == "]":
bracket_count -= 1
if bracket_count == 0:
return i
return -1 # No matching bracket found
def _strip_and_split_buffer(self, buffer: str) -> tuple[str, str]:
"""
Strip special tokens from buffer and split into safe_text and held_back_text.
Returns:
tuple of (safe_text_to_output, text_to_hold_in_buffer)
"""
# Check if original buffer ends with a partial token at the end
special_tokens = ["<|python_start|>", "<|python_end|>"]
for token in special_tokens:
partial_length = self._ends_with_partial_token(buffer, token)
if partial_length > 0:
# Split buffer: safe part + held back partial token
safe_text = buffer[:-partial_length]
held_back = buffer[-partial_length:]
# Strip complete special tokens from safe part only
safe_text = self._text_strip(safe_text)
return safe_text, held_back
# No partial tokens found, strip complete tokens from entire buffer
safe_text = self._text_strip(buffer)
return safe_text, ""
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing for pythonic tool calls.
Buffers input until a complete pythonic tool call (from [ to ]) is found,
then parses and emits any detected calls.
"""
self._buffer += new_text
# Strip special tokens from entire buffer and handle partial tokens
stripped_buffer, held_back = self._strip_and_split_buffer(self._buffer)
start = stripped_buffer.find("[")
if start == -1:
# No tool call bracket found
self._buffer = held_back
return StreamingParseResult(normal_text=stripped_buffer)
normal_text = stripped_buffer[:start] if start > 0 else ""
end = self._find_matching_bracket(stripped_buffer, start)
if end != -1:
# Found complete tool call
call_text = stripped_buffer[start : end + 1]
result = self.detect_and_parse(call_text, tools)
# Update buffer with remaining text after tool call plus any held back text
remaining_text = stripped_buffer[end + 1 :] + held_back
self._buffer = remaining_text
# If we had normal text before the tool call, add it to the result
if normal_text:
result.normal_text = normal_text + (result.normal_text or "")
return result
# We have an opening bracket but no closing bracket yet
# Put back everything from the bracket onwards plus held back text
self._buffer = stripped_buffer[start:] + held_back
if normal_text:
return StreamingParseResult(normal_text=normal_text)
# Otherwise, we're still accumulating a potential tool call
return StreamingParseResult(normal_text="")
def _get_parameter_value(self, val):
if isinstance(val, ast.Constant):
return val.value
elif isinstance(val, ast.Dict):
return {
k.value: self._get_parameter_value(v)
for k, v in zip(val.keys, val.values)
}
elif isinstance(val, ast.List):
return [self._get_parameter_value(v) for v in val.elts]
else:
raise ValueError("Tool call arguments must be literals")
def supports_structural_tag(self) -> bool:
return False
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
@@ -0,0 +1,120 @@
import json
import logging
import re
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
StructureInfo,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
class Qwen25Detector(BaseFormatDetector):
"""
Detector for Qwen 2.5 and Qwen 3 model function call format.
Format Structure:
```
<tool_call>\n{"name":"func1", "arguments":{...}}\n</tool_call>\n<tool_call>\n{"name":"func2", "arguments":{...}}\n</tool_call>
```
Key Components:
- Tool Call Tags: `<tool_call>` and `</tool_call>` wrap each individual call
- Function Call Object: JSON object with "name" and "arguments" fields
Reference: https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct?chat_template=default
"""
def __init__(self):
"""
Initializes the detector with necessary state variables.
"""
super().__init__()
self.bot_token = "<tool_call>\n"
self.eot_token = "\n</tool_call>"
self.tool_call_separator = "\n"
self._normal_text_buffer = "" # Buffer for handling partial end tokens
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a Qwen 2.5 format tool call."""
return self.bot_token in text
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
:param text: The complete text to parse.
:param tools: List of available tools.
:return: ParseResult indicating success or failure, consumed text, leftover text, and parsed calls.
"""
idx = text.find(self.bot_token)
normal_text = text[:idx].strip() if idx != -1 else text
if self.bot_token not in text:
return StreamingParseResult(normal_text=normal_text, calls=[])
# Find all <tool_call>\n...\n</tool_call> blocks
pattern = rf"{re.escape(self.bot_token)}(.*?){re.escape(self.eot_token)}"
match_result_list = re.findall(pattern, text, re.DOTALL)
calls = []
for match_result in match_result_list:
try:
parsed_call = json.loads(match_result.strip())
calls.extend(self.parse_base_json(parsed_call, tools))
except json.JSONDecodeError as e:
logger.warning(
f"Failed to parse JSON part: {match_result}, JSON parse error: {str(e)}"
)
continue
return StreamingParseResult(normal_text=normal_text, calls=calls)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing for Qwen 2.5 tool calls.
Uses base class implementation with buffering to handle partial end tokens.
"""
result = super().parse_streaming_increment(new_text, tools)
# Handle partial end tokens that are streamed character by character
if result.normal_text:
self._normal_text_buffer += result.normal_text
# Check if buffer contains complete end token (without leading newline)
end_token_without_newline = self.eot_token[1:] # "</tool_call>"
if end_token_without_newline in self._normal_text_buffer:
cleaned_text = self._normal_text_buffer.replace(
end_token_without_newline, ""
)
self._normal_text_buffer = ""
result.normal_text = cleaned_text
else:
# Check if buffer might contain partial end token at the end
partial_match_len = self._ends_with_partial_token(
self._normal_text_buffer, end_token_without_newline
)
if partial_match_len:
# Keep potential partial match in buffer, return the rest
result.normal_text = self._normal_text_buffer[:-partial_match_len]
self._normal_text_buffer = self._normal_text_buffer[
-partial_match_len:
]
else:
# No partial match, return all buffered text
result.normal_text = self._normal_text_buffer
self._normal_text_buffer = ""
return result
def structure_info(self) -> _GetInfoFunc:
return lambda name: StructureInfo(
begin='<tool_call>\n{"name":"' + name + '", "arguments":',
end="}\n</tool_call>",
trigger="<tool_call>",
)
@@ -0,0 +1,477 @@
import ast
import json
import logging
import re
from typing import Any, List, Optional
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
class Qwen3CoderDetector(BaseFormatDetector):
def __init__(self):
super().__init__()
# Sentinel tokens
self.tool_call_start_token: str = "<tool_call>"
self.tool_call_end_token: str = "</tool_call>"
self.tool_call_prefix: str = "<function="
self.function_end_token: str = "</function>"
self.parameter_prefix: str = "<parameter="
self.parameter_end_token: str = "</parameter>"
# Regex for non-streaming fallback
self.tool_call_regex = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL)
self.tool_call_function_regex = re.compile(
r"<function=(.*?)</function>|<function=(.*)$", re.DOTALL
)
self.tool_call_parameter_regex = re.compile(
r"<parameter=(.*?)(?:</parameter>|(?=<parameter=)|(?=</function>)|$)",
re.DOTALL,
)
# Streaming State
# Base class already initializes _buffer, we just use it directly
# No need to check with hasattr - we control the lifecycle through inheritance
# Index pointing to the next character to be processed in buffer
self.parsed_pos: int = 0
# Parameter count inside the current tool being processed, used to determine whether to add comma
self.current_tool_param_count: int = 0
# Flag indicating whether current tool has already sent '{'
self.json_started: bool = False
# [FIX] New state flag: mark whether inside tool_call structure block
self.is_inside_tool_call: bool = False
# Initialize attributes that were missing in the original PR
self.current_func_name: Optional[str] = None
def has_tool_call(self, text: str) -> bool:
return self.tool_call_start_token in text
def _get_arguments_config(
self, func_name: str, tools: Optional[list[Tool]]
) -> dict:
"""Extract argument configuration for a function."""
if tools is None:
return {}
for config in tools:
try:
config_type = config.type
config_function = config.function
config_function_name = config_function.name
except AttributeError:
continue
if config_type == "function" and config_function_name == func_name:
try:
params = config_function.parameters
except AttributeError:
return {}
if isinstance(params, dict) and "properties" in params:
return params["properties"]
elif isinstance(params, dict):
return params
else:
return {}
logger.warning(f"Tool '{func_name}' is not defined in the tools list.")
return {}
def _convert_param_value(
self, param_value: str, param_name: str, param_config: dict, func_name: str
) -> Any:
"""Convert parameter value based on its type in the schema."""
# Handle null value for any type
if param_value.lower() == "null":
return None
if param_name not in param_config:
if param_config != {}:
logger.warning(
f"Parsed parameter '{param_name}' is not defined in the tool "
f"parameters for tool '{func_name}', directly returning the string value."
)
return param_value
if (
isinstance(param_config[param_name], dict)
and "type" in param_config[param_name]
):
param_type = str(param_config[param_name]["type"]).strip().lower()
else:
param_type = "string"
if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
return param_value
elif (
param_type.startswith("int")
or param_type.startswith("uint")
or param_type.startswith("long")
or param_type.startswith("short")
or param_type.startswith("unsigned")
):
try:
param_value = int(param_value)
except Exception:
logger.warning(
f"Parsed value '{param_value}' of parameter '{param_name}' is not an integer in tool "
f"'{func_name}', degenerating to string."
)
return param_value
elif param_type.startswith("num") or param_type.startswith("float"):
try:
maybe_convert = (
False if "." in param_value or "e" in param_value.lower() else True
)
param_value: float = float(param_value)
if maybe_convert and param_value.is_integer():
param_value = int(param_value)
except Exception:
logger.warning(
f"Parsed value '{param_value}' of parameter '{param_name}' is not a float in tool "
f"'{func_name}', degenerating to string."
)
return param_value
elif param_type in ["boolean", "bool", "binary"]:
param_value = param_value.lower()
if param_value not in ["true", "false"]:
logger.warning(
f"Parsed value '{param_value}' of parameter '{param_name}' is not a boolean (`true` of `false`) in tool '{func_name}', degenerating to false."
)
return param_value == "true"
else:
if (
param_type in ["object", "array", "arr"]
or param_type.startswith("dict")
or param_type.startswith("list")
):
try:
param_value = json.loads(param_value)
return param_value
except Exception:
logger.warning(
f"Parsed value '{param_value}' of parameter '{param_name}' cannot be parsed with json.loads in tool "
f"'{func_name}', will try other methods to parse it."
)
try:
param_value = ast.literal_eval(param_value) # safer
except Exception:
logger.warning(
f"Parsed value '{param_value}' of parameter '{param_name}' cannot be converted via Python `ast.literal_eval()` in tool '{func_name}', degenerating to string."
)
return param_value
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""One-shot parsing for non-streaming scenarios."""
if self.tool_call_start_token not in text:
return StreamingParseResult(normal_text=text)
calls = []
try:
# Simple cleanup of the text to find tool calls
# Note: This is a simplified regex approach consistent with vLLM
raw_tool_calls = self.tool_call_regex.findall(text)
if not raw_tool_calls:
# Fallback: maybe the whole text is inside the tag or tags are stripped
if self.tool_call_prefix in text:
raw_tool_calls = [text]
tool_idx = 0
for tool_content in raw_tool_calls:
# Find function calls
funcs = self.tool_call_function_regex.findall(tool_content)
for func_match in funcs:
func_body = func_match[0] or func_match[1]
if ">" not in func_body:
continue
name_end = func_body.index(">")
func_name = func_body[:name_end]
params_str = func_body[name_end + 1 :]
param_config = self._get_arguments_config(func_name, tools)
parsed_params = {}
for p_match in self.tool_call_parameter_regex.findall(params_str):
if ">" not in p_match:
continue
p_idx = p_match.index(">")
p_name = p_match[:p_idx]
p_val = p_match[p_idx + 1 :]
# Remove prefixing and trailing \n
if p_val.startswith("\n"):
p_val = p_val[1:]
if p_val.endswith("\n"):
p_val = p_val[:-1]
parsed_params[p_name] = self._convert_param_value(
p_val, p_name, param_config, func_name
)
calls.append(
ToolCallItem(
tool_index=tool_idx,
name=func_name,
parameters=json.dumps(parsed_params, ensure_ascii=False),
)
)
tool_idx += 1
# Determine normal text (text before the first tool call)
start_idx = text.find(self.tool_call_start_token)
if start_idx == -1:
start_idx = text.find(self.tool_call_prefix)
normal_text = text[:start_idx] if start_idx > 0 else ""
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}")
return StreamingParseResult(normal_text=text)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Robust cursor-based streaming parser.
"""
self._buffer += new_text
# Guard against empty buffer
if not self._buffer:
return StreamingParseResult()
calls = []
normal_text_chunks = []
while True:
# Working text slice
current_slice = self._buffer[self.parsed_pos :]
# Optimization: If almost empty, wait for more
if not current_slice:
break
# -------------------------------------------------------
# 1. Priority detection: check if it's the start of Tool Call
# -------------------------------------------------------
if current_slice.startswith(self.tool_call_start_token):
self.parsed_pos += len(self.tool_call_start_token)
self.is_inside_tool_call = True
continue
# -------------------------------------------------------
# 2. Function Name: <function=name>
# -------------------------------------------------------
if current_slice.startswith(self.tool_call_prefix):
end_angle = current_slice.find(">")
if end_angle != -1:
func_name = current_slice[len(self.tool_call_prefix) : end_angle]
self.current_tool_id += 1
self.current_tool_name_sent = True
self.current_tool_param_count = 0
self.json_started = False
self.current_func_name = func_name
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=func_name,
parameters="",
)
)
self.parsed_pos += end_angle + 1
continue
else:
# Incomplete tag
break
# -------------------------------------------------------
# 3. Parameter: <parameter=name>value...
# -------------------------------------------------------
if current_slice.startswith(self.parameter_prefix):
name_end = current_slice.find(">")
if name_end != -1:
value_start_idx = name_end + 1
rest_of_slice = current_slice[value_start_idx:]
# A parameter can end in multiple ways:
# 1. [Normal] Encounter </parameter>
# 2. [Abnormal] Encounter next <parameter=
# 3. [Abnormal] Encounter </function>
# So we need to find the smallest one as the parameter end position.
cand_end_param = rest_of_slice.find(self.parameter_end_token)
cand_next_param = rest_of_slice.find(self.parameter_prefix)
cand_end_func = rest_of_slice.find(self.function_end_token)
candidates = []
if cand_end_param != -1:
candidates.append(
(cand_end_param, len(self.parameter_end_token))
)
if cand_next_param != -1:
candidates.append((cand_next_param, 0))
if cand_end_func != -1:
candidates.append((cand_end_func, 0))
if candidates:
best_cand = min(candidates, key=lambda x: x[0])
end_pos = best_cand[0]
end_token_len = best_cand[1]
param_name = current_slice[
len(self.parameter_prefix) : name_end
]
raw_value = rest_of_slice[:end_pos]
# Cleanup value
if raw_value.startswith("\n"):
raw_value = raw_value[1:]
if raw_value.endswith("\n"):
raw_value = raw_value[:-1]
# JSON Construction
if not self.json_started:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id, parameters="{"
)
)
self.json_started = True
param_config = self._get_arguments_config(
self.current_func_name, tools
)
converted_val = self._convert_param_value(
raw_value, param_name, param_config, self.current_func_name
)
# Construct JSON fragment: "key": value
# Note: We must be careful with json.dumps to ensure valid JSON streaming
json_key_val = f"{json.dumps(param_name)}: {json.dumps(converted_val, ensure_ascii=False)}"
if self.current_tool_param_count > 0:
fragment = f", {json_key_val}"
else:
fragment = json_key_val
calls.append(
ToolCallItem(
tool_index=self.current_tool_id, parameters=fragment
)
)
self.current_tool_param_count += 1
# Advance cursor
total_len = (name_end + 1) + end_pos + end_token_len
self.parsed_pos += total_len
continue
# Incomplete parameter tag or value
break
# -------------------------------------------------------
# 4. Function End: </function>
# -------------------------------------------------------
if current_slice.startswith(self.function_end_token):
if not self.json_started:
calls.append(
ToolCallItem(tool_index=self.current_tool_id, parameters="{")
)
self.json_started = True
calls.append(
ToolCallItem(tool_index=self.current_tool_id, parameters="}")
)
self.parsed_pos += len(self.function_end_token)
self.current_func_name = None
continue
# -------------------------------------------------------
# 5. Tool Call End: </tool_call>
# -------------------------------------------------------
if current_slice.startswith(self.tool_call_end_token):
self.parsed_pos += len(self.tool_call_end_token)
self.is_inside_tool_call = False # [FIX] Exit tool call region
continue
# -------------------------------------------------------
# 6. Handling content / whitespace / normal text
# -------------------------------------------------------
# If current position is not the start of a tag (i.e., doesn't start with <), it might be plain text,
# or a newline between two tags.
# But we need to be careful not to output truncated tags like "<fun" as text.
next_open_angle = current_slice.find("<")
if next_open_angle == -1:
# This entire segment is plain text
if not self.is_inside_tool_call:
normal_text_chunks.append(current_slice)
# [FIX] If inside tool call, discard this text (usually \n), don't append
self.parsed_pos += len(current_slice)
continue
elif next_open_angle == 0:
# Looks like a Tag, but doesn't match any known Tag above
possible_tags = [
self.tool_call_start_token,
self.tool_call_end_token,
self.tool_call_prefix,
self.function_end_token,
self.parameter_prefix,
self.parameter_end_token,
]
is_potential_tag = False
for tag in possible_tags:
if tag.startswith(current_slice):
is_potential_tag = True
break
if is_potential_tag:
break # Wait for more
else:
# Just a plain '<' symbol
if not self.is_inside_tool_call:
normal_text_chunks.append("<")
self.parsed_pos += 1
continue
else:
# '<' is in the middle
text_segment = current_slice[:next_open_angle]
if not self.is_inside_tool_call:
normal_text_chunks.append(text_segment)
# [FIX] If inside tool call, discard whitespace/text before Tag
self.parsed_pos += next_open_angle
continue
# Memory Cleanup: Slice the buffer
# Keep unparsed part, discard parsed part
if self.parsed_pos > 0:
self._buffer = self._buffer[self.parsed_pos :]
self.parsed_pos = 0
normal_text = "".join(normal_text_chunks) if normal_text_chunks else ""
return StreamingParseResult(calls=calls, normal_text=normal_text)
def supports_structural_tag(self) -> bool:
return True
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError
def get_structural_tag_name(self) -> str:
return "qwen_3_coder"
@@ -0,0 +1,407 @@
import ast
import json
import logging
import re
from typing import Any, Dict, List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import (
StreamingParseResult,
ToolCallItem,
_GetInfoFunc,
)
logger = logging.getLogger(__name__)
def get_argument_type(func_name: str, arg_key: str, defined_tools: List[Tool]) -> str:
"""Get the expected type for a function argument from tool schema."""
name2tool = {tool.function.name: tool for tool in defined_tools}
if func_name not in name2tool:
return None
tool = name2tool[func_name]
parameters = tool.function.parameters or {}
properties = parameters.get("properties", {})
if arg_key not in properties:
return None
return properties[arg_key].get("type", None)
def parse_arguments(value: str) -> tuple[Any, bool]:
"""Parse a string value to appropriate type. Returns (parsed_value, success)."""
try:
try:
parsed_value = json.loads(value)
except:
parsed_value = ast.literal_eval(value)
return parsed_value, True
except:
return value, False
class Step3Detector(BaseFormatDetector):
"""
Detector for Step3 model function call format.
The Step3 format uses special Unicode tokens to delimit function calls
with steptml XML format for invocations.
Format Structure:
```
<tool_calls_begin>
<tool_call_begin>function<tool_sep><steptml:invoke name="function_name">
<steptml:parameter name="param1">value1</steptml:parameter>
<steptml:parameter name="param2">value2</steptml:parameter>
</steptml:invoke><tool_call_end>
<tool_calls_end>
```
"""
def __init__(self):
super().__init__()
self.bot_token = "<tool_calls_begin>"
self.eot_token = "<tool_calls_end>"
self.tool_call_begin = "<tool_call_begin>"
self.tool_call_end = "<tool_call_end>"
self.tool_sep = "<tool_sep>"
# Regex for parsing steptml invocations
self.invoke_regex = re.compile(
r'<steptml:invoke name="([^"]+)">(.+?)</steptml:invoke>', re.DOTALL
)
self.param_regex = re.compile(
r'<steptml:parameter name="([^"]+)">([^<]*)</steptml:parameter>', re.DOTALL
)
# Streaming state variables
self._in_tool_block: bool = False
self._tool_block_finished: bool = False
self._current_function_name: str = ""
self._current_parameters: Dict[str, Any] = {}
self._in_tool_call: bool = False
self._function_name_sent: bool = False
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a Step3 format tool call."""
return self.bot_token in text
def _parse_steptml_invoke(
self, text: str, tools: List[Tool] = None
) -> tuple[str, dict]:
"""Parse steptml invoke format to extract function name and parameters."""
invoke_match = self.invoke_regex.search(text)
if not invoke_match:
return None, {}
func_name = invoke_match.group(1)
params_text = invoke_match.group(2)
params = {}
for param_match in self.param_regex.finditer(params_text):
param_name = param_match.group(1)
param_value = param_match.group(2).strip()
# If tools provided, use schema-aware parsing
if tools:
arg_type = get_argument_type(func_name, param_name, tools)
if arg_type and arg_type != "string":
parsed_value, _ = parse_arguments(param_value)
params[param_name] = parsed_value
else:
params[param_name] = param_value
else:
# Fallback to generic parsing if no tools provided
parsed_value, _ = parse_arguments(param_value)
params[param_name] = parsed_value
return func_name, params
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
"""
if self.bot_token not in text:
return StreamingParseResult(normal_text=text, calls=[])
try:
pre_text, rest = text.split(self.bot_token, 1)
# If no end token, return everything as normal text
if self.eot_token not in rest:
return StreamingParseResult(normal_text=text, calls=[])
tool_section, post_text = rest.split(self.eot_token, 1)
# Find all individual tool calls using regex
calls = []
tool_call_pattern = (
f"{re.escape(self.tool_call_begin)}(.*?){re.escape(self.tool_call_end)}"
)
for match in re.finditer(tool_call_pattern, tool_section, re.DOTALL):
call_content = match.group(1)
# Check if it's a function call
if self.tool_sep not in call_content:
continue
type_part, invoke_part = call_content.split(self.tool_sep, 1)
if type_part.strip() != "function":
continue
func_name, params = self._parse_steptml_invoke(invoke_part, tools)
if func_name:
# Use parse_base_json to create the ToolCallItem
action = {"name": func_name, "arguments": params}
calls.extend(self.parse_base_json(action, tools))
# Combine pre and post text
normal_text = pre_text + post_text
return StreamingParseResult(normal_text=normal_text, calls=calls)
except Exception as e:
logger.error(f"Error in detect_and_parse: {e}")
# Return the original text if parsing fails
return StreamingParseResult(normal_text=text)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing for Step3 format.
"""
self._buffer += new_text
# Build tool indices for validation
if not hasattr(self, "_tool_indices"):
self._tool_indices = self._get_tool_indices(tools)
# If we've finished the tool block, everything is normal text
if self._tool_block_finished:
normal_text = self._buffer
self._buffer = ""
return StreamingParseResult(normal_text=normal_text)
# Check if tool block hasn't started yet
if not self._in_tool_block:
if self.bot_token in self._buffer:
idx = self._buffer.find(self.bot_token)
normal_text = self._buffer[:idx]
self._buffer = self._buffer[idx + len(self.bot_token) :]
self._in_tool_block = True
return StreamingParseResult(normal_text=normal_text)
else:
# Check if we might have a partial bot_token
partial_len = self._ends_with_partial_token(
self._buffer, self.bot_token
)
if partial_len:
return StreamingParseResult() # Wait for more text
else:
normal_text = self._buffer
self._buffer = ""
return StreamingParseResult(normal_text=normal_text)
# We're inside the tool block
calls: List[ToolCallItem] = []
# Check if tool block is ending
if self.eot_token in self._buffer:
idx = self._buffer.find(self.eot_token)
# If we're in the middle of a tool call, we need to handle it
if self._in_tool_call:
# The buffer before eot_token might contain the end of the current tool call
before_eot = self._buffer[:idx]
if self.tool_call_end in before_eot:
# Parse this final tool call
result = self._parse_partial_tool_call(tools)
calls.extend(result.calls)
else:
# Incomplete tool call - log warning
logger.warning("Tool block ended with incomplete tool call")
remaining = self._buffer[idx + len(self.eot_token) :]
self._buffer = ""
self._tool_block_finished = True
# Reset any partial tool call state
self._reset_streaming_state()
return StreamingParseResult(normal_text=remaining, calls=calls)
# Check if we're in a tool call or need to start one
if not self._in_tool_call:
if self.tool_call_begin in self._buffer:
idx = self._buffer.find(self.tool_call_begin)
# Remove any content before tool call begin (shouldn't happen but be safe)
self._buffer = self._buffer[idx + len(self.tool_call_begin) :]
self._in_tool_call = True
self._function_name_sent = False
self._current_function_name = ""
self._current_parameters = {}
# Fall through to parse the partial tool call
else:
# Wait for tool call to begin
return StreamingParseResult()
# Parse partial tool call
if self._in_tool_call:
return self._parse_partial_tool_call(tools)
return StreamingParseResult()
def _parse_partial_tool_call(self, tools: List[Tool]) -> StreamingParseResult:
"""Parse partial tool call for streaming scenarios."""
calls = []
# Check if we have tool_sep (means we're past the type declaration)
if self.tool_sep not in self._buffer:
return StreamingParseResult(calls=calls) # Wait for more text
type_part, invoke_part = self._buffer.split(self.tool_sep, 1)
if type_part.strip() != "function":
# Invalid tool type, skip this tool call
self._reset_streaming_state()
return StreamingParseResult(calls=calls)
# Try to extract function name if not sent yet
if not self._function_name_sent:
name_match = re.search(r'<steptml:invoke name="([^"]+)">', invoke_part)
if name_match:
func_name = name_match.group(1)
# Validate function name
if func_name in self._tool_indices:
self._current_function_name = func_name
self._function_name_sent = True
# Initialize tool tracking
if self.current_tool_id == -1:
self.current_tool_id = 0
# Ensure tracking arrays are large enough
while len(self.prev_tool_call_arr) <= self.current_tool_id:
self.prev_tool_call_arr.append({})
while len(self.streamed_args_for_tool) <= self.current_tool_id:
self.streamed_args_for_tool.append("")
# Store tool call info
self.prev_tool_call_arr[self.current_tool_id] = {
"name": func_name,
"arguments": {},
}
# Send tool name with empty parameters
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
name=func_name,
parameters="",
)
)
else:
# Invalid function name
logger.warning(f"Invalid function name: {func_name}")
self._reset_streaming_state()
return StreamingParseResult(calls=calls)
else:
# Function name not complete yet
return StreamingParseResult(calls=calls)
# Parse parameters incrementally
if self._function_name_sent:
# Extract all complete parameters
new_params = {}
for param_match in self.param_regex.finditer(invoke_part):
param_name = param_match.group(1)
param_value = param_match.group(2).strip()
# Use schema-aware parsing
arg_type = get_argument_type(
self._current_function_name, param_name, tools
)
if arg_type and arg_type != "string":
parsed_value, _ = parse_arguments(param_value)
new_params[param_name] = parsed_value
else:
new_params[param_name] = param_value
# Check if we have new parameters to stream
if new_params != self._current_parameters:
# Build the JSON content without the closing brace for streaming
if not self._current_parameters:
# First parameters - send opening brace and content
params_content = json.dumps(new_params, ensure_ascii=False)
if len(params_content) > 2: # More than just "{}"
# Send everything except the closing brace
diff = params_content[:-1]
else:
diff = "{"
else:
# Subsequent parameters - calculate the incremental diff
old_json = json.dumps(self._current_parameters, ensure_ascii=False)
new_json = json.dumps(new_params, ensure_ascii=False)
# Remove closing braces for comparison
old_without_brace = old_json[:-1]
new_without_brace = new_json[:-1]
# The new content should extend the old content
if new_without_brace.startswith(old_without_brace):
diff = new_without_brace[len(old_without_brace) :]
else:
# Parameters changed in unexpected way - shouldn't happen in normal streaming
diff = ""
if diff:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
parameters=diff,
)
)
self.streamed_args_for_tool[self.current_tool_id] += diff
# Update current state
self._current_parameters = new_params
self.prev_tool_call_arr[self.current_tool_id]["arguments"] = new_params
# Check if tool call is complete
if self.tool_call_end in self._buffer:
# Send closing brace if we've sent any parameters
if self.streamed_args_for_tool[self.current_tool_id]:
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
parameters="}",
)
)
self.streamed_args_for_tool[self.current_tool_id] += "}"
# Find the end position
end_idx = self._buffer.find(self.tool_call_end)
# Remove the processed tool call from buffer
self._buffer = self._buffer[end_idx + len(self.tool_call_end) :]
# Reset state for next tool call
self._reset_streaming_state()
self.current_tool_id += 1
return StreamingParseResult(calls=calls)
def _reset_streaming_state(self):
"""Reset streaming state for the next tool call"""
self._in_tool_call = False
self._function_name_sent = False
self._current_function_name = ""
self._current_parameters = {}
def supports_structural_tag(self) -> bool:
"""Return True if this detector supports structural tag format."""
return False
def structure_info(self) -> _GetInfoFunc:
raise NotImplementedError()
@@ -0,0 +1,43 @@
import logging
from typing import List
from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.function_call.core_types import StreamingParseResult
from sglang.srt.function_call.qwen25_detector import Qwen25Detector
logger = logging.getLogger(__name__)
class TrinityDetector(Qwen25Detector):
"""
Detector for Trinity models using Qwen-style function call format.
This detector extends Qwen25Detector to handle tool calls that may appear
inside <think> sections by stripping the think tags before parsing.
Reference: https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct?chat_template=default
"""
def _strip_think_tags(self, text: str) -> str:
"""Remove <think> and </think> tags, keeping the content inside."""
return text.replace("<think>", "").replace("</think>", "")
def has_tool_call(self, text: str) -> bool:
"""Check if the text contains a tool call."""
return super().has_tool_call(self._strip_think_tags(text))
def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult:
"""
One-time parsing: Detects and parses tool calls in the provided text.
"""
return super().detect_and_parse(self._strip_think_tags(text), tools)
def parse_streaming_increment(
self, new_text: str, tools: List[Tool]
) -> StreamingParseResult:
"""
Streaming incremental parsing for tool calls.
"""
return super().parse_streaming_increment(
self._strip_think_tags(new_text), tools
)
+415
View File
@@ -0,0 +1,415 @@
from json import JSONDecodeError, JSONDecoder
from json.decoder import WHITESPACE
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
import orjson
import partial_json_parser
from partial_json_parser.core.options import Allow
from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice
_STANDARD_JSON_SCHEMA_TYPES = {
"null",
"boolean",
"object",
"array",
"number",
"string",
"integer",
}
# Non-standard ``type`` values commonly emitted by DB/ORM-driven tool-schema
# generators. Mapped to the closest JSON Schema 2020-12 primitive so that
# ``Draft202012Validator.check_schema`` does not reject an otherwise-usable
# tool definition.
_JSON_SCHEMA_TYPE_ALIASES: Dict[str, str] = {
"str": "string",
"text": "string",
"varchar": "string",
"char": "string",
"enum": "string",
"uuid": "string",
"date": "string",
"datetime": "string",
"time": "string",
"timestamp": "string",
"binary": "string",
"blob": "string",
"bytea": "string",
"bytes": "string",
"varbinary": "string",
"bool": "boolean",
"bigint": "integer",
"smallint": "integer",
"tinyint": "integer",
"double": "number",
"decimal": "number",
"real": "number",
"numeric": "number",
"arr": "array",
"tuple": "array",
"set": "array",
"map": "object",
}
# Prefix-based matching so that parameterised names like ``int32`` /
# ``float64`` / ``list[str]`` / ``dict[str, int]`` resolve. A prefix only
# matches when it spans the entire token or is followed by a non-identifier
# char, so "int" does not swallow "internal" and "list" does not swallow
# "list_price".
_PREFIX_BOUNDARY_CHARS = frozenset("0123456789[<( \t")
_PREFIX_RULES: Tuple[Tuple[Tuple[str, ...], str], ...] = (
(("int", "uint", "long", "short", "unsigned"), "integer"),
(("num", "float"), "number"),
(("list",), "array"),
(("dict",), "object"),
)
def _matches_type_prefix(base: str, prefixes: Tuple[str, ...]) -> bool:
for p in prefixes:
if base == p:
return True
if (
len(base) > len(p)
and base.startswith(p)
and base[len(p)] in _PREFIX_BOUNDARY_CHARS
):
return True
return False
def _normalize_single_type(raw: Any) -> Any:
if not isinstance(raw, str):
return raw
if raw in _STANDARD_JSON_SCHEMA_TYPES:
return raw
# ``split("(", 1)[0]`` strips parenthesized params like ``varchar(255)``
# or ``decimal(10,2)`` without the overhead of a regex per call.
base = raw.split("(", 1)[0].strip().lower()
if base in _STANDARD_JSON_SCHEMA_TYPES:
return base
mapped = _JSON_SCHEMA_TYPE_ALIASES.get(base)
if mapped is not None:
return mapped
for prefixes, target in _PREFIX_RULES:
if _matches_type_prefix(base, prefixes):
return target
return raw
def _normalize_type_list(raw_items: List[Any]) -> List[Any]:
normalized_items: List[Any] = []
for item in raw_items:
normalized_item = _normalize_single_type(item)
if normalized_item not in normalized_items:
normalized_items.append(normalized_item)
return normalized_items
def normalize_json_schema_types(schema: Any) -> None:
"""
Walk a JSON Schema in place and rewrite non-standard ``"type"`` values
(e.g. ``"varchar"``, ``"enum"``, ``"int"``) to their standard JSON Schema
equivalents.
Acts as a compatibility layer for tool ``parameters`` schemas exported
from database / ORM tooling, which often uses DB type names rather than
JSON Schema types. Unknown types are left untouched so that downstream
validation can still surface genuine errors.
Mutates the input dict in place; the rewritten schema is also what gets
rendered into the model prompt, so e.g. a user-supplied ``"varchar"``
reaches the model as ``"string"``. ``$ref`` values are not resolved;
callers pass tree-shaped schemas (HTTP JSON input is always a tree).
"""
if isinstance(schema, list):
for item in schema:
normalize_json_schema_types(item)
return
if not isinstance(schema, dict):
return
if "type" in schema:
t = schema["type"]
if isinstance(t, str):
schema["type"] = _normalize_single_type(t)
elif isinstance(t, list):
schema["type"] = _normalize_type_list(t)
for key in (
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
):
nested = schema.get(key)
if isinstance(nested, dict):
for v in nested.values():
normalize_json_schema_types(v)
for key in ("anyOf", "oneOf", "allOf", "prefixItems"):
nested = schema.get(key)
if isinstance(nested, list):
for v in nested:
normalize_json_schema_types(v)
for key in (
"items",
"additionalProperties",
"not",
"if",
"then",
"else",
"contains",
"propertyNames",
"unevaluatedItems",
"unevaluatedProperties",
):
if key in schema:
normalize_json_schema_types(schema[key])
def _find_common_prefix(s1: str, s2: str) -> str:
prefix = ""
min_length = min(len(s1), len(s2))
for i in range(0, min_length):
if s1[i] == s2[i]:
prefix += s1[i]
else:
break
return prefix
def _partial_json_loads(input_str: str, flags: Allow) -> Tuple[Any, int]:
"""
Parse incomplete or partial JSON strings commonly encountered during streaming.
Args:
input_str (str): The potentially incomplete JSON string to parse.
flags (Allow): Bitwise flags controlling what types of partial data are allowed.
Common flags include:
- Allow.STR: Allow partial strings (e.g., '"hello wo' -> 'hello wo')
- Allow.OBJ: Allow partial objects (e.g., '{"key":' -> {'key': None})
- Allow.ARR: Allow partial arrays (e.g., '[1, 2,' -> [1, 2])
- Allow.ALL: Allow all types of partial data
Returns:
Tuple[Any, int]: A tuple containing:
- parsed_object: The Python object parsed from the JSON
- consumed_length: Number of characters consumed from input_str
"""
try:
return (partial_json_parser.loads(input_str, flags), len(input_str))
except (JSONDecodeError, IndexError) as e:
msg = getattr(e, "msg", str(e))
if "Extra data" in msg or "pop from empty list" in msg:
start = WHITESPACE.match(input_str, 0).end()
obj, end = JSONDecoder().raw_decode(input_str, start)
return obj, end
raise
def _is_complete_json(input_str: str) -> bool:
try:
orjson.loads(input_str)
return True
except JSONDecodeError:
return False
def _get_tool_schema_defs(tools: List[Tool]) -> dict:
"""
Get consolidated $defs from all tools, validating for conflicts.
Args:
tools: List of tools to process
Returns:
Dictionary of consolidated $defs from all tools
Raises:
ValueError: If conflicting $defs are found
"""
all_defs = {}
for tool in tools:
if tool.function.parameters is None:
continue
defs = tool.function.parameters.get("$defs", {})
for def_name, def_schema in defs.items():
if def_name in all_defs and all_defs[def_name] != def_schema:
raise ValueError(
f"Tool definition '{def_name}' has "
"multiple schemas, which is not "
"supported."
)
else:
all_defs[def_name] = def_schema
return all_defs
def _get_tool_schema(tool: Tool) -> dict:
return {
"properties": {
"name": {"type": "string", "enum": [tool.function.name]},
"parameters": (
tool.function.parameters
if tool.function.parameters
else {"type": "object", "properties": {}}
),
},
"required": ["name", "parameters"],
}
def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]:
"""
Infer the primary type of a parameter from JSON Schema.
Supports complex JSON Schema structures including:
- Direct type field (including type arrays)
- anyOf/oneOf: parameter can be any of multiple types
- enum: parameter must be one of enum values
- allOf: parameter must satisfy all type definitions
- properties: inferred as object type
- items: inferred as array type
Args:
schema: JSON Schema definition
Returns:
Inferred type ('string', 'number', 'object', 'array', etc.) or None
"""
if not isinstance(schema, dict):
return None
# Priority 1: Direct type field (including type arrays)
if "type" in schema:
type_value = schema["type"]
if isinstance(type_value, str):
return type_value
elif isinstance(type_value, list) and type_value:
# Handle type arrays: return first non-null type
non_null_types = [t for t in type_value if t != "null"]
if non_null_types:
return non_null_types[0]
return "string" # If only null, default to string
# Priority 2: Handle anyOf/oneOf
if "anyOf" in schema or "oneOf" in schema:
schemas = schema.get("anyOf") or schema.get("oneOf")
types = []
if isinstance(schemas, list):
for sub_schema in schemas:
inferred_type = infer_type_from_json_schema(sub_schema)
if inferred_type:
types.append(inferred_type)
if types:
# If all types are the same, return unified type
if len(set(types)) == 1:
return types[0]
# When types differ, prioritize string (safest)
if "string" in types:
return "string"
# Otherwise return first type
return types[0]
# Priority 3: Handle enum (infer type from enum values)
if "enum" in schema and isinstance(schema["enum"], list):
if not schema["enum"]:
return "string"
# Infer type from enum values
enum_types = set()
for value in schema["enum"]:
if value is None:
enum_types.add("null")
elif isinstance(value, bool):
enum_types.add("boolean")
elif isinstance(value, int):
enum_types.add("integer")
elif isinstance(value, float):
enum_types.add("number")
elif isinstance(value, str):
enum_types.add("string")
elif isinstance(value, list):
enum_types.add("array")
elif isinstance(value, dict):
enum_types.add("object")
# If type is uniform, return that type
if len(enum_types) == 1:
return enum_types.pop()
# Mixed types, prioritize string
return "string"
# Priority 4: Handle allOf (must satisfy all types)
if "allOf" in schema and isinstance(schema["allOf"], list):
schemas = schema["allOf"]
for sub_schema in schemas:
inferred_type = infer_type_from_json_schema(sub_schema)
if inferred_type and inferred_type != "string":
return inferred_type
return "string"
# Priority 5: Infer object type
if "properties" in schema:
return "object"
# Priority 6: Infer array type
if "items" in schema:
return "array"
return None
def get_json_schema_constraint(
tools: List[Tool],
tool_choice: Union[ToolChoice, Literal["required"]],
parallel_tool_calls: bool = True,
) -> Optional[dict]:
"""
Get the JSON schema constraint for the specified tool choice.
Args:
tool_choice: The tool choice specification
parallel_tool_calls: If False, constrain to exactly one tool call (maxItems=1)
Returns:
JSON schema dict, or None if no valid tools found
"""
if isinstance(tool_choice, ToolChoice):
# For specific function choice, return the user's parameters schema directly
fn_name = tool_choice.function.name
for tool in tools:
if tool.function.name == fn_name:
schema = {
"type": "array",
"minItems": 1,
"items": _get_tool_schema(tool),
}
if not parallel_tool_calls:
schema["maxItems"] = 1
return schema
return None
elif tool_choice == "required":
json_schema = {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"anyOf": [_get_tool_schema(tool) for tool in tools],
},
}
if not parallel_tool_calls:
json_schema["maxItems"] = 1
json_schema_defs = _get_tool_schema_defs(tools)
if json_schema_defs:
json_schema["$defs"] = json_schema_defs
return json_schema
return None