Files
wehub-resource-sync 94057c3d3e
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
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

543 lines
20 KiB
Python

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