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
268 lines
9.7 KiB
Python
268 lines
9.7 KiB
Python
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
|