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
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:
@@ -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:
|
||||
```
|
||||
<|DSML|function_calls>
|
||||
<|DSML|invoke name="function_name">
|
||||
<|DSML|parameter name="param_name" string="true">value</|DSML|parameter>
|
||||
...
|
||||
</|DSML|invoke>
|
||||
</|DSML|function_calls>
|
||||
```
|
||||
|
||||
Format 2 - Direct JSON:
|
||||
```
|
||||
<|DSML|function_calls>
|
||||
<|DSML|invoke name="function_name">
|
||||
{
|
||||
"param_name": "value"
|
||||
}
|
||||
</|DSML|invoke>
|
||||
</|DSML|function_calls>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
<|DSML|function_calls>
|
||||
<|DSML|invoke name="get_favorite_tourist_spot">
|
||||
<|DSML|parameter name="city" string="true">San Francisco</|DSML|parameter>
|
||||
</|DSML|invoke>
|
||||
</|DSML|function_calls>
|
||||
|
||||
<|DSML|function_calls>
|
||||
<|DSML|invoke name="get_favorite_tourist_spot">
|
||||
{ "city": "San Francisco" }
|
||||
</|DSML|invoke>
|
||||
</|DSML|function_calls>
|
||||
```
|
||||
|
||||
Key Components:
|
||||
- Tool Calls Section: Wrapped between `<|DSML|function_calls>` and `</|DSML|function_calls>`
|
||||
- Individual Tool Call: Wrapped between `<|DSML|invoke name="...">` and `</|DSML|invoke>`
|
||||
- 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 = "<|DSML|function_calls>"
|
||||
self.eot_token = "</|DSML|function_calls>"
|
||||
self.invoke_end_token = "</|DSML|invoke>"
|
||||
self.parameter_regex = r'<|DSML|parameter\s+name="([^"]+)"\s+string="([^"]+)"\s*>(.*?)</|DSML|parameter>'
|
||||
self.partial_parameter_regex = (
|
||||
r'<|DSML|parameter\s+name="([^"]+)"\s+string="([^"]+)"\s*>(.*)$'
|
||||
)
|
||||
self.function_calls_regex = (
|
||||
r"<|DSML|function_calls>(.*?)</|DSML|function_calls>"
|
||||
)
|
||||
# Long-form `<|DSML|invoke name="x">...</|DSML|invoke>` and the
|
||||
# self-closing `<|DSML|invoke 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'<|DSML|invoke\s+name="(?P<name>[^"]+)"\s*'
|
||||
r"(?:(?P<self_close>/>)"
|
||||
r"|>(?P<body>.*?)(?P<end>(?:</|DSML|invoke>|$)))"
|
||||
)
|
||||
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 "<|DSML|invoke" 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: <|DSML|parameter name="..." string="...">value</|DSML|parameter>
|
||||
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 </|DSML|invoke>
|
||||
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'<|DSML|invoke name="{name}">',
|
||||
end="</|DSML|invoke>",
|
||||
trigger="<|DSML|invoke",
|
||||
)
|
||||
|
||||
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:
|
||||
```
|
||||
<|DSML|tool_calls>
|
||||
<|DSML|invoke name="function_name">
|
||||
<|DSML|parameter name="param_name" string="true">value</|DSML|parameter>
|
||||
...
|
||||
</|DSML|invoke>
|
||||
</|DSML|tool_calls>
|
||||
```
|
||||
|
||||
Format 2 - Direct JSON:
|
||||
```
|
||||
<|DSML|tool_calls>
|
||||
<|DSML|invoke name="function_name">
|
||||
{
|
||||
"param_name": "value"
|
||||
}
|
||||
</|DSML|invoke>
|
||||
</|DSML|tool_calls>
|
||||
```
|
||||
|
||||
Examples:
|
||||
```
|
||||
<|DSML|tool_calls>
|
||||
<|DSML|invoke name="get_favorite_tourist_spot">
|
||||
<|DSML|parameter name="city" string="true">San Francisco</|DSML|parameter>
|
||||
</|DSML|invoke>
|
||||
</|DSML|tool_calls>
|
||||
|
||||
<|DSML|tool_calls>
|
||||
<|DSML|invoke name="get_favorite_tourist_spot">
|
||||
{ "city": "San Francisco" }
|
||||
</|DSML|invoke>
|
||||
</|DSML|tool_calls>
|
||||
```
|
||||
|
||||
Key Components:
|
||||
- Tool Calls Section: Wrapped between `<|DSML|tool_calls>` and `</|DSML|tool_calls>`
|
||||
- Individual Tool Call: Wrapped between `<|DSML|invoke name="...">` and `</|DSML|invoke>`
|
||||
- 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 = "<|DSML|tool_calls>"
|
||||
self.eot_token = "</|DSML|tool_calls>"
|
||||
self.function_calls_regex = r"<|DSML|tool_calls>(.*?)</|DSML|tool_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
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user