[model] Apertus Tool/Function and Reasoning parser (#25100)
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"\n",
|
"\n",
|
||||||
"| Model | Reasoning tags | Parser | Notes |\n",
|
"| Model | Reasoning tags | Parser | Notes |\n",
|
||||||
"|---------|-----------------------------|------------------|-------|\n",
|
"|---------|-----------------------------|------------------|-------|\n",
|
||||||
|
"| [Apertus 2509 models](https://huggingface.co/swiss-ai/Apertus-8B-Instruct-2509) | `<\\|inner_prefix\\|>` … `<\\|inner_suffix\\|>` | `apertus2509` | For tool use, also set `--tool-call-parser apertus2509`. |\n",
|
||||||
"| [DeepSeek‑R1 series](https://huggingface.co/collections/deepseek-ai/deepseek-r1-678e1e131c0169c0bc89728d) | `<think>` … `</think>` | `deepseek-r1` | Supports all variants (R1, R1-0528, R1-Distill) |\n",
|
"| [DeepSeek‑R1 series](https://huggingface.co/collections/deepseek-ai/deepseek-r1-678e1e131c0169c0bc89728d) | `<think>` … `</think>` | `deepseek-r1` | Supports all variants (R1, R1-0528, R1-Distill) |\n",
|
||||||
"| [DeepSeek‑V3 series](https://huggingface.co/deepseek-ai/DeepSeek-V3.1) | `<think>` … `</think>` | `deepseek-v3` | Including [DeepSeek‑V3.2](https://huggingface.co/deepseek-ai/DeepSeek-V3.2-Exp). Supports `thinking` parameter |\n",
|
"| [DeepSeek‑V3 series](https://huggingface.co/deepseek-ai/DeepSeek-V3.1) | `<think>` … `</think>` | `deepseek-v3` | Including [DeepSeek‑V3.2](https://huggingface.co/deepseek-ai/DeepSeek-V3.2-Exp). Supports `thinking` parameter |\n",
|
||||||
"| [Standard Qwen3 models](https://huggingface.co/collections/Qwen/qwen3-67dd247413f0e2e4f653967f) | `<think>` … `</think>` | `qwen3` | Supports `enable_thinking` parameter |\n",
|
"| [Standard Qwen3 models](https://huggingface.co/collections/Qwen/qwen3-67dd247413f0e2e4f653967f) | `<think>` … `</think>` | `qwen3` | Supports `enable_thinking` parameter |\n",
|
||||||
@@ -20,6 +21,9 @@
|
|||||||
"| [GPT OSS](https://huggingface.co/openai/gpt-oss-120b) | `<\\|channel\\|>analysis<\\|message\\|>` … `<\\|end\\|>` | `gpt-oss` | N/A |\n",
|
"| [GPT OSS](https://huggingface.co/openai/gpt-oss-120b) | `<\\|channel\\|>analysis<\\|message\\|>` … `<\\|end\\|>` | `gpt-oss` | N/A |\n",
|
||||||
"### Model-Specific Behaviors\n",
|
"### Model-Specific Behaviors\n",
|
||||||
"\n",
|
"\n",
|
||||||
|
"**Apertus 2509:**\n",
|
||||||
|
"- Uses `<|inner_prefix|>` and `<|inner_suffix|>` to delimit reasoning content. For agentic tool use, also specify `--tool-call-parser apertus2509`.\n",
|
||||||
|
"\n",
|
||||||
"**DeepSeek-R1 Family:**\n",
|
"**DeepSeek-R1 Family:**\n",
|
||||||
"- DeepSeek-R1: No `<think>` start tag, jumps directly to thinking content\n",
|
"- DeepSeek-R1: No `<think>` start tag, jumps directly to thinking content\n",
|
||||||
"- DeepSeek-R1-0528: Generates both `<think>` start and `</think>` end tags\n",
|
"- DeepSeek-R1-0528: Generates both `<think>` start and `</think>` end tags\n",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Reasoning Parser"
|
title: "Reasoning Parser"
|
||||||
metatags:
|
metatags:
|
||||||
description: "SGLang reasoning parser: separate thinking content from output for DeepSeek R1, Qwen3, Kimi K2, GPT-OSS reasoning models."
|
description: "SGLang reasoning parser: separate thinking content from output for DeepSeek R1, Qwen3, Kimi K2, GPT-OSS, and Apertus reasoning models."
|
||||||
---
|
---
|
||||||
SGLang supports parsing reasoning content out from "normal" content for reasoning models such as [DeepSeek R1](https://huggingface.co/deepseek-ai/DeepSeek-R1).
|
SGLang supports parsing reasoning content out from "normal" content for reasoning models such as [DeepSeek R1](https://huggingface.co/deepseek-ai/DeepSeek-R1).
|
||||||
|
|
||||||
@@ -23,6 +23,12 @@ SGLang supports parsing reasoning content out from "normal" content for reasonin
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>[Apertus 2509 models](https://huggingface.co/swiss-ai/Apertus-8B-Instruct-2509)</td>
|
||||||
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`<|inner_prefix|>` … `<|inner_suffix|>`</td>
|
||||||
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>`apertus2509`</td>
|
||||||
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Supports `enable_thinking` parameter</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>[DeepSeek‑R1 series](https://huggingface.co/collections/deepseek-ai/deepseek-r1-678e1e131c0169c0bc89728d)</td>
|
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>[DeepSeek‑R1 series](https://huggingface.co/collections/deepseek-ai/deepseek-r1-678e1e131c0169c0bc89728d)</td>
|
||||||
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`<think>` … `</think>`</td>
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>`<think>` … `</think>`</td>
|
||||||
@@ -63,6 +69,9 @@ SGLang supports parsing reasoning content out from "normal" content for reasonin
|
|||||||
</table>
|
</table>
|
||||||
### Model-Specific Behaviors
|
### Model-Specific Behaviors
|
||||||
|
|
||||||
|
**Apertus 2509:**
|
||||||
|
- Uses `<|inner_prefix|>` and `<|inner_suffix|>` to delimit reasoning content. For agentic tool use, also specify `--tool-call-parser apertus2509`.
|
||||||
|
|
||||||
**DeepSeek-R1 Family:**
|
**DeepSeek-R1 Family:**
|
||||||
- DeepSeek-R1: No `<think>` start tag, jumps directly to thinking content
|
- DeepSeek-R1: No `<think>` start tag, jumps directly to thinking content
|
||||||
- DeepSeek-R1-0528: Generates both `<think>` start and `</think>` end tags
|
- DeepSeek-R1-0528: Generates both `<think>` start and `</think>` end tags
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"\n",
|
"\n",
|
||||||
"| Parser | Supported Models | Notes |\n",
|
"| Parser | Supported Models | Notes |\n",
|
||||||
"|---|---|---|\n",
|
"|---|---|---|\n",
|
||||||
|
"| `apertus2509` | Apertus 2509 (e.g., `swiss-ai/Apertus-{8,70}B-Instruct-2509`) | Tool calls are emitted as a JSON list of single-key objects: `<\\|tools_prefix\\|>[{\"tool\": {...}}]<\\|tools_suffix\\|>`. |\n",
|
||||||
"| `deepseekv3` | DeepSeek-v3 (e.g., `deepseek-ai/DeepSeek-V3-0324`) | Recommend adding `--chat-template ./examples/chat_template/tool_chat_template_deepseekv3.jinja` to launch command. |\n",
|
"| `deepseekv3` | DeepSeek-v3 (e.g., `deepseek-ai/DeepSeek-V3-0324`) | Recommend adding `--chat-template ./examples/chat_template/tool_chat_template_deepseekv3.jinja` to launch command. |\n",
|
||||||
"| `deepseekv31` | DeepSeek-V3.1 and DeepSeek-V3.2-Exp (e.g. `deepseek-ai/DeepSeek-V3.1`, `deepseek-ai/DeepSeek-V3.2-Exp`) | Recommend adding `--chat-template ./examples/chat_template/tool_chat_template_deepseekv31.jinja` (Or ..deepseekv32.jinja for DeepSeek-V3.2) to launch command. |\n",
|
"| `deepseekv31` | DeepSeek-V3.1 and DeepSeek-V3.2-Exp (e.g. `deepseek-ai/DeepSeek-V3.1`, `deepseek-ai/DeepSeek-V3.2-Exp`) | Recommend adding `--chat-template ./examples/chat_template/tool_chat_template_deepseekv31.jinja` (Or ..deepseekv32.jinja for DeepSeek-V3.2) to launch command. |\n",
|
||||||
"| `deepseekv32` | DeepSeek-V3.2 (`deepseek-ai/DeepSeek-V3.2`) | |\n",
|
"| `deepseekv32` | DeepSeek-V3.2 (`deepseek-ai/DeepSeek-V3.2`) | |\n",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Tool Parser"
|
title: "Tool Parser"
|
||||||
metatags:
|
metatags:
|
||||||
description: "SGLang function calling: tool parsers for DeepSeek, Llama, Qwen, Mistral, GLM, Kimi K2. OpenAI-compatible tool use API."
|
description: "SGLang function calling: tool parsers for DeepSeek, Llama, Qwen, Mistral, GLM, Kimi K2, and Apertus. OpenAI-compatible tool use API."
|
||||||
---
|
---
|
||||||
This guide demonstrates how to use SGLang’s [Function calling](https://platform.openai.com/docs/guides/function-calling) functionality.
|
This guide demonstrates how to use SGLang’s [Function calling](https://platform.openai.com/docs/guides/function-calling) functionality.
|
||||||
|
|
||||||
@@ -22,6 +22,11 @@ This guide demonstrates how to use SGLang’s [Function calling](https://platfor
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`apertus2509`</td>
|
||||||
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>Apertus 2509 (e.g., `swiss-ai/Apertus-{8,70}B-Instruct-2509`)</td>
|
||||||
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.02)"}}>Tool calls are emitted as a JSON list of single-key objects: `<|tools_prefix|>[{"tool": {...}}]<|tools_suffix|>`.</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`deepseekv3`</td>
|
<td style={{padding: "9px 12px", fontWeight: 500, backgroundColor: "rgba(255,255,255,0.02)"}}>`deepseekv3`</td>
|
||||||
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>DeepSeek-v3 (e.g., `deepseek-ai/DeepSeek-V3-0324`)</td>
|
<td style={{padding: "9px 12px", backgroundColor: "rgba(255,255,255,0.05)"}}>DeepSeek-v3 (e.g., `deepseek-ai/DeepSeek-V3-0324`)</td>
|
||||||
|
|||||||
@@ -1463,13 +1463,24 @@ async def separate_reasoning_request(obj: SeparateReasoningReqInput, request: Re
|
|||||||
parser = ReasoningParser(model_type=obj.reasoning_parser, request=request)
|
parser = ReasoningParser(model_type=obj.reasoning_parser, request=request)
|
||||||
|
|
||||||
# 2) Call the non-stream parsing method (non-stream)
|
# 2) Call the non-stream parsing method (non-stream)
|
||||||
reasoning_text, normal_text = parser.parse_non_stream(obj.text)
|
if getattr(obj, "return_blocks", False):
|
||||||
|
blocks = parser.parse_non_stream_blocks(obj.text)
|
||||||
|
reasoning_blocks = [b["text"] for b in blocks if b["type"] == "reasoning"]
|
||||||
|
text_blocks = [b["text"] for b in blocks if b["type"] == "text"]
|
||||||
|
reasoning_text = "".join(reasoning_blocks)
|
||||||
|
normal_text = "".join(text_blocks)
|
||||||
|
else:
|
||||||
|
reasoning_text, normal_text = parser.parse_non_stream(obj.text)
|
||||||
|
|
||||||
# 3) Organize the response content
|
# 3) Organize the response content
|
||||||
response_data = {
|
response_data = {
|
||||||
"reasoning_text": reasoning_text,
|
"reasoning_text": reasoning_text,
|
||||||
"text": normal_text,
|
"text": normal_text,
|
||||||
}
|
}
|
||||||
|
if getattr(obj, "return_blocks", False):
|
||||||
|
response_data["reasoning_blocks"] = reasoning_blocks
|
||||||
|
response_data["text_blocks"] = text_blocks
|
||||||
|
response_data["blocks"] = blocks
|
||||||
|
|
||||||
return ORJSONResponse(content=response_data, status_code=200)
|
return ORJSONResponse(content=response_data, status_code=200)
|
||||||
|
|
||||||
|
|||||||
@@ -564,7 +564,7 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
if self.is_gpt_oss or self.is_gemma4:
|
if self.is_gpt_oss or self.is_gemma4:
|
||||||
request.skip_special_tokens = False
|
request.skip_special_tokens = False
|
||||||
|
|
||||||
self._patch_mistral_skip_special_tokens(request)
|
self._patch_reasoning_skip_special_tokens(request)
|
||||||
|
|
||||||
thinking_mode = self._get_reasoning_from_request(request)
|
thinking_mode = self._get_reasoning_from_request(request)
|
||||||
# SGLang's ReasonerGrammarBackend owns the reasoning prefix
|
# SGLang's ReasonerGrammarBackend owns the reasoning prefix
|
||||||
@@ -1546,11 +1546,17 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
idx += len(list(tool_calls)) if tool_calls is not None else 0 # noqa
|
idx += len(list(tool_calls)) if tool_calls is not None else 0 # noqa
|
||||||
return idx
|
return idx
|
||||||
|
|
||||||
def _patch_mistral_skip_special_tokens(
|
def _patch_reasoning_skip_special_tokens(
|
||||||
self, request: ChatCompletionRequest
|
self, request: ChatCompletionRequest
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mistral uses special tokens ([THINK]/[/THINK]) for reasoning markers,
|
"""Keep parser-specific reasoning markers in the decoded text.
|
||||||
which get stripped when skip_special_tokens=True."""
|
|
||||||
|
Some reasoning parsers rely on special-token delimiters that would be
|
||||||
|
removed during detokenization when ``skip_special_tokens=True``.
|
||||||
|
"""
|
||||||
|
if self.reasoning_parser == "apertus2509":
|
||||||
|
request.skip_special_tokens = False
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.reasoning_parser in ["mistral"]
|
self.reasoning_parser in ["mistral"]
|
||||||
and request.reasoning_effort is not None
|
and request.reasoning_effort is not None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,6 +10,7 @@ from sglang.srt.entrypoints.openai.protocol import (
|
|||||||
ToolChoice,
|
ToolChoice,
|
||||||
)
|
)
|
||||||
from sglang.srt.environ import ToolStrictLevel, envs
|
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.base_format_detector import BaseFormatDetector
|
||||||
from sglang.srt.function_call.cohere_command4_detector import CohereCommand4Detector
|
from sglang.srt.function_call.cohere_command4_detector import CohereCommand4Detector
|
||||||
from sglang.srt.function_call.core_types import ToolCallItem
|
from sglang.srt.function_call.core_types import ToolCallItem
|
||||||
@@ -56,6 +57,7 @@ class FunctionCallParser:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
ToolCallParserEnum: Dict[str, Type[BaseFormatDetector]] = {
|
ToolCallParserEnum: Dict[str, Type[BaseFormatDetector]] = {
|
||||||
|
"apertus2509": Apertus2509Detector,
|
||||||
"cohere_command4": CohereCommand4Detector,
|
"cohere_command4": CohereCommand4Detector,
|
||||||
"deepseekv3": DeepSeekV3Detector,
|
"deepseekv3": DeepSeekV3Detector,
|
||||||
"deepseekv31": DeepSeekV31Detector,
|
"deepseekv31": DeepSeekV31Detector,
|
||||||
|
|||||||
@@ -1866,6 +1866,7 @@ class ParseFunctionCallReq(BaseReq):
|
|||||||
class SeparateReasoningReqInput(BaseReq):
|
class SeparateReasoningReqInput(BaseReq):
|
||||||
text: str # The text to parse.
|
text: str # The text to parse.
|
||||||
reasoning_parser: str # Specify the parser type, e.g., "deepseek-r1".
|
reasoning_parser: str # Specify the parser type, e.g., "deepseek-r1".
|
||||||
|
return_blocks: bool = False # If True, also return segmented reasoning blocks.
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ REASONING_MODE_RULES = (
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _is_apertus2509(ctx):
|
||||||
|
return ctx.has_vocab("<|inner_prefix|>")
|
||||||
|
|
||||||
|
|
||||||
def _is_gemma4(ctx):
|
def _is_gemma4(ctx):
|
||||||
return ctx.has_text("<|channel>")
|
return ctx.has_text("<|channel>")
|
||||||
|
|
||||||
@@ -250,6 +254,7 @@ def _is_deepseek_r1_think_tags(ctx):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
REASONING_PARSER_RULES = (
|
REASONING_PARSER_RULES = (
|
||||||
|
DetectionRule(name="apertus2509", value="apertus2509", predicate=_is_apertus2509),
|
||||||
DetectionRule(name="gemma4", value="gemma4", predicate=_is_gemma4),
|
DetectionRule(name="gemma4", value="gemma4", predicate=_is_gemma4),
|
||||||
DetectionRule(name="kimi", value="kimi", predicate=_is_kimi),
|
DetectionRule(name="kimi", value="kimi", predicate=_is_kimi),
|
||||||
DetectionRule(name="interns1", value="interns1", predicate=_is_interns1),
|
DetectionRule(name="interns1", value="interns1", predicate=_is_interns1),
|
||||||
@@ -277,6 +282,7 @@ REASONING_PARSER_RULES = (
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
TOOL_CALL_PARSER_RULES = (
|
TOOL_CALL_PARSER_RULES = (
|
||||||
|
DetectionRule(name="apertus2509", value="apertus2509", predicate=_is_apertus2509),
|
||||||
DetectionRule(name="gemma4", value="gemma4", predicate=_is_gemma4),
|
DetectionRule(name="gemma4", value="gemma4", predicate=_is_gemma4),
|
||||||
DetectionRule(name="gpt_oss", value="gpt-oss", predicate=_is_gpt_oss),
|
DetectionRule(name="gpt_oss", value="gpt-oss", predicate=_is_gpt_oss),
|
||||||
DetectionRule(name="kimi_k2", value="kimi_k2", predicate=_is_kimi_k2),
|
DetectionRule(name="kimi_k2", value="kimi_k2", predicate=_is_kimi_k2),
|
||||||
|
|||||||
@@ -597,6 +597,221 @@ class _PoolsideV1Detector(Qwen3Detector):
|
|||||||
self.reasoning_default = "explicit_enable_thinking"
|
self.reasoning_default = "explicit_enable_thinking"
|
||||||
|
|
||||||
|
|
||||||
|
class Apertus2509Detector(BaseReasoningFormatDetector):
|
||||||
|
"""
|
||||||
|
Detector for Apertus 2509 models
|
||||||
|
|
||||||
|
Reasoning blocks are delimited by:
|
||||||
|
<|inner_prefix|> ... <|inner_suffix|>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
stream_reasoning: bool = True,
|
||||||
|
force_reasoning: bool = False,
|
||||||
|
continue_final_message: bool = False,
|
||||||
|
previous_content: str = "",
|
||||||
|
force_nonempty_content: bool = False,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
"<|inner_prefix|>",
|
||||||
|
"<|inner_suffix|>",
|
||||||
|
force_reasoning=False,
|
||||||
|
stream_reasoning=stream_reasoning,
|
||||||
|
continue_final_message=continue_final_message,
|
||||||
|
previous_content=previous_content,
|
||||||
|
)
|
||||||
|
self._force_reasoning = force_reasoning
|
||||||
|
self._force_nonempty_content = force_nonempty_content
|
||||||
|
self._tool_start_token = "<|tools_prefix|>["
|
||||||
|
self._tool_end_token = "<|tools_suffix|>"
|
||||||
|
self._reasoning_acc: str = ""
|
||||||
|
self._in_inner_tool: bool = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ends_with_partial_token(buffer: str, token: str) -> int:
|
||||||
|
for i in range(1, min(len(buffer) + 1, len(token))):
|
||||||
|
if token.startswith(buffer[-i:]):
|
||||||
|
return i
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def detect_and_parse(self, text: str) -> StreamingParseResult:
|
||||||
|
blocks = self.detect_and_parse_block_sequence(text)
|
||||||
|
reasoning_parts = [t for k, t in blocks if k == "reasoning"]
|
||||||
|
text_parts = [t for k, t in blocks if k == "text"]
|
||||||
|
ret = StreamingParseResult(
|
||||||
|
normal_text="".join(text_parts),
|
||||||
|
reasoning_text="".join(reasoning_parts),
|
||||||
|
)
|
||||||
|
if self._force_nonempty_content and not ret.normal_text:
|
||||||
|
ret.normal_text, ret.reasoning_text = ret.reasoning_text, ret.normal_text
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def detect_and_parse_block_sequence(self, text: str) -> list[tuple[str, str]]:
|
||||||
|
"""Return an ordered sequence of blocks: [("reasoning"|"text", content), ...]"""
|
||||||
|
start_tok = self.think_start_token
|
||||||
|
end_tok = self.think_end_token
|
||||||
|
blocks: list[tuple[str, str]] = []
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
# continue_final_message can resume inside an existing inner
|
||||||
|
if self._in_reasoning:
|
||||||
|
if (e := text.find(end_tok, cursor)) == -1:
|
||||||
|
blocks.extend(self._split_inner_reasoning(text[cursor:]))
|
||||||
|
blocks.append(("text", ""))
|
||||||
|
return blocks
|
||||||
|
blocks.extend(self._split_inner_reasoning(text[cursor:e]))
|
||||||
|
cursor = e + len(end_tok)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if (s := text.find(start_tok, cursor)) == -1:
|
||||||
|
# Always include the trailing text block (may be empty)
|
||||||
|
blocks.append(("text", text[cursor:]))
|
||||||
|
break
|
||||||
|
if s > cursor:
|
||||||
|
blocks.append(("text", text[cursor:s]))
|
||||||
|
|
||||||
|
cursor = s + len(start_tok)
|
||||||
|
if (e := text.find(end_tok, cursor)) == -1:
|
||||||
|
blocks.extend(self._split_inner_reasoning(text[cursor:]))
|
||||||
|
blocks.append(("text", ""))
|
||||||
|
break
|
||||||
|
blocks.extend(self._split_inner_reasoning(text[cursor:e]))
|
||||||
|
cursor = e + len(end_tok)
|
||||||
|
|
||||||
|
last_idx = len(blocks) - 1
|
||||||
|
blocks = [
|
||||||
|
(k, t)
|
||||||
|
for i, (k, t) in enumerate(blocks)
|
||||||
|
if not (k == "text" and t == "" and i != last_idx)
|
||||||
|
]
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def _split_inner_reasoning(self, inner_text: str) -> list[tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
Split content inside <|inner_prefix|>...<|inner_suffix|> into:
|
||||||
|
- ("reasoning", <thoughts text>)
|
||||||
|
- ("text", <|tools_prefix|>[...]<|tools_suffix|>) for any tool calls inside reasoning
|
||||||
|
"""
|
||||||
|
tool_start = self._tool_start_token
|
||||||
|
tool_end = self._tool_end_token
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if (s := inner_text.find(tool_start, cursor)) == -1:
|
||||||
|
if (tail := inner_text[cursor:]) != "":
|
||||||
|
out.append(("reasoning", tail))
|
||||||
|
break
|
||||||
|
if s > cursor:
|
||||||
|
out.append(("reasoning", inner_text[cursor:s]))
|
||||||
|
|
||||||
|
if (e := inner_text.find(tool_end, s)) == -1:
|
||||||
|
out.append(("text", inner_text[s:]))
|
||||||
|
break
|
||||||
|
|
||||||
|
out.append(("text", inner_text[s : e + len(tool_end)]))
|
||||||
|
cursor = e + len(tool_end)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def parse_streaming_increment(self, new_text: str) -> StreamingParseResult:
|
||||||
|
self._buffer += new_text
|
||||||
|
|
||||||
|
out_reasoning = ""
|
||||||
|
out_normal = ""
|
||||||
|
|
||||||
|
start_tok = self.think_start_token
|
||||||
|
end_tok = self.think_end_token
|
||||||
|
tool_start = self._tool_start_token
|
||||||
|
tool_end = self._tool_end_token
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if not self._in_reasoning:
|
||||||
|
if (s := self._buffer.find(start_tok)) == -1:
|
||||||
|
if partial := self._ends_with_partial_token(
|
||||||
|
self._buffer, start_tok
|
||||||
|
):
|
||||||
|
out_normal += self._buffer[:-partial]
|
||||||
|
self._buffer = self._buffer[-partial:]
|
||||||
|
else:
|
||||||
|
out_normal += self._buffer
|
||||||
|
self._buffer = ""
|
||||||
|
return StreamingParseResult(
|
||||||
|
normal_text=out_normal, reasoning_text=out_reasoning
|
||||||
|
)
|
||||||
|
|
||||||
|
out_normal += self._buffer[:s]
|
||||||
|
self._buffer = self._buffer[s + len(start_tok) :]
|
||||||
|
self._in_reasoning = True
|
||||||
|
self._reasoning_acc = ""
|
||||||
|
self._in_inner_tool = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self._in_inner_tool:
|
||||||
|
if (end_pos := self._buffer.find(tool_end)) == -1:
|
||||||
|
if (
|
||||||
|
hold := self._ends_with_partial_token(self._buffer, tool_end)
|
||||||
|
) != 0:
|
||||||
|
out_normal += self._buffer[:-hold]
|
||||||
|
self._buffer = self._buffer[-hold:]
|
||||||
|
else:
|
||||||
|
out_normal += self._buffer
|
||||||
|
self._buffer = ""
|
||||||
|
return StreamingParseResult(
|
||||||
|
normal_text=out_normal, reasoning_text=out_reasoning
|
||||||
|
)
|
||||||
|
|
||||||
|
out_normal += self._buffer[: end_pos + len(tool_end)]
|
||||||
|
self._buffer = self._buffer[end_pos + len(tool_end) :]
|
||||||
|
self._in_inner_tool = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
pos_tool = self._buffer.find(tool_start)
|
||||||
|
pos_end = self._buffer.find(end_tok)
|
||||||
|
|
||||||
|
if pos_tool == -1 and pos_end == -1:
|
||||||
|
if self.stream_reasoning:
|
||||||
|
if (
|
||||||
|
hold := max(
|
||||||
|
self._ends_with_partial_token(self._buffer, end_tok),
|
||||||
|
self._ends_with_partial_token(self._buffer, tool_start),
|
||||||
|
)
|
||||||
|
) != 0:
|
||||||
|
out_reasoning += self._buffer[:-hold]
|
||||||
|
self._buffer = self._buffer[-hold:]
|
||||||
|
else:
|
||||||
|
out_reasoning += self._buffer
|
||||||
|
self._buffer = ""
|
||||||
|
return StreamingParseResult(
|
||||||
|
normal_text=out_normal, reasoning_text=out_reasoning
|
||||||
|
)
|
||||||
|
|
||||||
|
next_pos = min(p for p in [pos_tool, pos_end] if p != -1)
|
||||||
|
|
||||||
|
if pos_end != -1 and pos_end == next_pos:
|
||||||
|
reasoning_chunk = self._buffer[:pos_end]
|
||||||
|
if self.stream_reasoning:
|
||||||
|
out_reasoning += reasoning_chunk
|
||||||
|
else:
|
||||||
|
self._reasoning_acc += reasoning_chunk
|
||||||
|
out_reasoning += self._reasoning_acc
|
||||||
|
self._reasoning_acc = ""
|
||||||
|
self._buffer = self._buffer[pos_end + len(end_tok) :]
|
||||||
|
self._in_reasoning = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
reasoning_chunk = self._buffer[:pos_tool]
|
||||||
|
if self.stream_reasoning:
|
||||||
|
out_reasoning += reasoning_chunk
|
||||||
|
else:
|
||||||
|
self._reasoning_acc += reasoning_chunk
|
||||||
|
self._buffer = self._buffer[pos_tool:]
|
||||||
|
self._in_inner_tool = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
class CohereCommand4Detector(BaseReasoningFormatDetector):
|
class CohereCommand4Detector(BaseReasoningFormatDetector):
|
||||||
"""Detector for Cohere Command4 / Command-A family (incl. cohere2_moe and
|
"""Detector for Cohere Command4 / Command-A family (incl. cohere2_moe and
|
||||||
cohere2_vision Command-A-Plus).
|
cohere2_vision Command-A-Plus).
|
||||||
@@ -848,6 +1063,7 @@ class ReasoningParser:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
DetectorMap: Dict[str, Type[BaseReasoningFormatDetector]] = {
|
DetectorMap: Dict[str, Type[BaseReasoningFormatDetector]] = {
|
||||||
|
"apertus2509": Apertus2509Detector,
|
||||||
"deepseek-r1": DeepSeekR1Detector,
|
"deepseek-r1": DeepSeekR1Detector,
|
||||||
"deepseek-v3": _DeepSeekV3Detector,
|
"deepseek-v3": _DeepSeekV3Detector,
|
||||||
"deepseek-v4": _DeepSeekV3Detector,
|
"deepseek-v4": _DeepSeekV3Detector,
|
||||||
@@ -918,6 +1134,19 @@ class ReasoningParser:
|
|||||||
ret = self.detector.detect_and_parse(full_text)
|
ret = self.detector.detect_and_parse(full_text)
|
||||||
return ret.reasoning_text, ret.normal_text
|
return ret.reasoning_text, ret.normal_text
|
||||||
|
|
||||||
|
def parse_non_stream_blocks(self, full_text: str) -> list[dict]:
|
||||||
|
"""Non-streaming call: return an ordered sequence of reasoning/text blocks"""
|
||||||
|
if hasattr(self.detector, "detect_and_parse_block_sequence"):
|
||||||
|
seq = self.detector.detect_and_parse_block_sequence(full_text)
|
||||||
|
return [{"type": k, "text": t} for k, t in seq]
|
||||||
|
|
||||||
|
ret = self.detector.detect_and_parse(full_text)
|
||||||
|
blocks: list[dict] = []
|
||||||
|
if ret.reasoning_text:
|
||||||
|
blocks.append({"type": "reasoning", "text": ret.reasoning_text})
|
||||||
|
blocks.append({"type": "text", "text": ret.normal_text or ""})
|
||||||
|
return blocks
|
||||||
|
|
||||||
def parse_stream_chunk(
|
def parse_stream_chunk(
|
||||||
self, chunk_text: str
|
self, chunk_text: str
|
||||||
) -> Tuple[Optional[str], Optional[str]]:
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ class TestTemplateDetectionRuleMatrix(unittest.TestCase):
|
|||||||
|
|
||||||
PARSER_RULES_MATRIX = [
|
PARSER_RULES_MATRIX = [
|
||||||
# (name, template_snippet, vocab, expected_parser, expected_toggle_param)
|
# (name, template_snippet, vocab, expected_parser, expected_toggle_param)
|
||||||
|
(
|
||||||
|
"apertus2509_via_unique_vocab_token",
|
||||||
|
"{% set enable_thinking = enable_thinking if enable_thinking is defined else true %}\n",
|
||||||
|
["<|inner_prefix|>"],
|
||||||
|
"apertus2509",
|
||||||
|
"enable_thinking",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"deepseek_r1_think_tags",
|
"deepseek_r1_think_tags",
|
||||||
"<think>\nLet me reason about this\n</think>\nAnswer here",
|
"<think>\nLet me reason about this\n</think>\nAnswer here",
|
||||||
|
|||||||
Reference in New Issue
Block a user