Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 126 additions & 32 deletions tensorrt_llm/llmapi/reasoning_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,128 @@ def finish(self) -> ReasoningParserResult:
return ReasoningParserResult(content=remaining)


def _trailing_partial_marker(text: str, *markers: str) -> int:
"""Length of the longest suffix of `text` that is a proper prefix of a marker.

A streaming parser withholds that many characters: they may still grow
into a marker split across deltas.
"""
best = 0
for marker in markers:
for length in range(min(len(text), len(marker) - 1), best, -1):
if marker.startswith(text[-length:]):
best = length
break
return best


@register_reasoning_parser("glm", reasoning_at_start=True)
@register_reasoning_parser("glm45")
@register_reasoning_parser("glm47", reasoning_at_start=True)
@register_reasoning_parser("glm_moe_dsa", reasoning_at_start=True)
class GlmReasoningParser(BaseReasoningParser):
"""Reasoning parser for the GLM family.

GLM-4.7 and GLM-5 templates prefill `<think>` (`reasoning_at_start`), or
`</think>` when `enable_thinking` is false. GLM-4.5/4.6 (`glm45`) do not
prefill `<think>`; the model opens the block at the start of its output.

The block ends at the first `</think>`, or at a `<tool_call>` opened
before it; a later `<think>` is text. A `</think>` before any content, or
directly before the first `<tool_call>`, closes the block again and is
dropped. Text from the first `<tool_call>` on is kept verbatim.
"""

reasoning_start = "<think>"
reasoning_end = "</think>"
_tool_call_start = "<tool_call>"

def __init__(self,
*,
reasoning_at_start: bool = False,
chat_template_kwargs: Optional[dict[str, Any]] = None) -> None:
super().__init__(chat_template_kwargs=chat_template_kwargs)
# The templates close the block in the prompt when `enable_thinking`
# is passed and false.
if not (chat_template_kwargs or {}).get("enable_thinking", True):
reasoning_at_start = False
self.reasoning_at_start = reasoning_at_start
# "start": the block may still open after leading whitespace;
# "closed": the block ended and no content followed yet.
self._state = "reasoning" if reasoning_at_start else "start"
self._buffer = ""

def parse(self, text: str) -> ReasoningParserResult:
# The whole text as one delta to a fresh parser, so both paths agree.
parser = GlmReasoningParser(reasoning_at_start=self.reasoning_at_start)
result, tail = parser.parse_delta(text), parser.finish()
result.content += tail.content
result.reasoning_content += tail.reasoning_content
return result

def parse_delta(self, delta_text: str) -> ReasoningParserResult:
data, self._buffer = self._buffer + delta_text, ""
content, reasoning = [], []
while data:
state = self._state
if state == "tool_calls":
content.append(data)
break
if state == "start":
stripped = data.lstrip()
if stripped.startswith(self.reasoning_start):
data = stripped[len(self.reasoning_start):]
self._state = "reasoning"
elif self.reasoning_start.startswith(stripped):
self._buffer = data
break
else:
self._state = "closed"
continue
out = reasoning if state == "reasoning" else content
end = data.find(self.reasoning_end)
call = data.find(self._tool_call_start)
if call != -1 and (end == -1 or call < end):
# A tool call ends the block; everything after it is content.
out.append(data[:call])
content.append(data[call:])
self._state = "tool_calls"
break
if end == -1:
cut = len(data) - _trailing_partial_marker(
data, self.reasoning_end, self._tool_call_start)
out.append(data[:cut])
self._buffer = data[cut:]
if cut and state == "closed":
self._state = "content"
break
out.append(data[:end])
rest = data[end + len(self.reasoning_end):]
if state == "reasoning":
self._state = "closed"
elif end:
self._state = "content"
if self._state == "closed" or rest.startswith(
self._tool_call_start):
# The tag ends the block or closes it again: drop it.
data = rest
elif self._tool_call_start.startswith(rest):
# Hold the tag until it is known whether a tool call follows.
self._buffer = data[end:]
break
else:
content.append(self.reasoning_end)
data = rest
return ReasoningParserResult(content="".join(content),
reasoning_content="".join(reasoning))

def finish(self) -> ReasoningParserResult:
remaining, self._buffer = self._buffer, ""
if self._state == "reasoning":
return ReasoningParserResult(reasoning_content=remaining)
return ReasoningParserResult(content=remaining)


@register_reasoning_parser("deepseek_v4")
class DeepSeekV4ReasoningParser(BaseReasoningParser):
"""DeepSeek-V4 parser selected by thinking-mode chat template kwargs."""
Expand Down Expand Up @@ -724,18 +846,6 @@ def parse(self, text: str) -> ReasoningParserResult:
reasoning_content="".join(reasoning_parts),
)

@staticmethod
def _partial_suffix_len(buf: str, tag: str) -> int:
"""Return length of the longest suffix of ``buf`` that is a prefix of ``tag``.

Used to hold back potential partial delimiters during streaming.
"""
max_len = min(len(buf), len(tag) - 1)
for k in range(max_len, 0, -1):
if tag.startswith(buf[-k:]):
return k
return 0

def parse_delta(self, delta_text: str) -> ReasoningParserResult:
self._buffer += delta_text
content_parts: list[str] = []
Expand All @@ -744,7 +854,7 @@ def parse_delta(self, delta_text: str) -> ReasoningParserResult:
if not self.in_reasoning:
idx = self._buffer.find(self.CHANNEL_OPEN)
if idx == -1:
hold = self._partial_suffix_len(self._buffer,
hold = _trailing_partial_marker(self._buffer,
self.CHANNEL_OPEN)
emit_len = len(self._buffer) - hold
content_parts.append(self._buffer[:emit_len])
Expand All @@ -756,7 +866,7 @@ def parse_delta(self, delta_text: str) -> ReasoningParserResult:
else:
idx = self._buffer.find(self.CHANNEL_CLOSE)
if idx == -1:
hold = self._partial_suffix_len(self._buffer,
hold = _trailing_partial_marker(self._buffer,
self.CHANNEL_CLOSE)
emit_len = len(self._buffer) - hold
reasoning_parts.append(self._buffer[:emit_len])
Expand Down Expand Up @@ -1004,22 +1114,6 @@ def _reset(self) -> None:
self._state = "body"
self._channel: Optional[str] = self._initial_channel

@staticmethod
def _partial_suffix_len(text: str, markers: tuple[str, ...]) -> int:
"""Length of the longest text suffix that is a proper prefix of any marker.

Markers may contain internal ``<`` (e.g. ``<|close|>tools<|sep|>``),
so every suffix length up to ``len(marker) - 1`` must be checked, not
just the one starting at the last ``<``.
"""
best = 0
for marker in markers:
for length in range(min(len(text), len(marker) - 1), best, -1):
if marker.startswith(text[-length:]):
best = length
break
return best

def _emit(self, text: str, content: list, reasoning: list) -> None:
if not text:
return
Expand All @@ -1043,7 +1137,7 @@ def _step(self, content: list, reasoning: list) -> bool:
indices = [(buf.find(m), m) for m in markers]
indices = [(i, m) for i, m in indices if i != -1]
if not indices:
hold = self._partial_suffix_len(buf, markers)
hold = _trailing_partial_marker(buf, *markers)
emit_len = len(buf) - hold
self._emit(buf[:emit_len], content, reasoning)
self._buffer = buf[emit_len:]
Expand Down Expand Up @@ -1091,7 +1185,7 @@ def _step(self, content: list, reasoning: list) -> bool:
if self._state == "tools_pass":
idx = buf.find(self.TOOLS_END)
if idx == -1:
hold = self._partial_suffix_len(buf, (self.TOOLS_END, ))
hold = _trailing_partial_marker(buf, self.TOOLS_END)
emit_len = len(buf) - hold
self._emit(buf[:emit_len], content, reasoning)
self._buffer = buf[emit_len:]
Expand Down
51 changes: 51 additions & 0 deletions tensorrt_llm/serve/tool_parser/base_tool_parser.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Adapted from https://github.com/sgl-project/sglang/blob/083629c23564e1a64deaa052f1df5c5d914358d8/python/sglang/srt/function_call/base_format_detector.py
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import json
import re
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional

Expand All @@ -12,6 +15,15 @@
from .core_types import StreamingParseResult, ToolCallItem, _GetInfoFunc
from .utils import find_common_prefix, is_complete_json, partial_json_loads

# A tool name, optionally qualified by the groups it was declared under:
# `apply_patch`, `functions.apply_patch`, `functions.collaboration.send_message`.
_QUALIFIED_NAME = re.compile(
r"^[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*$")

# An opening or closing tag of the XML-ish tool-call markup, e.g. `<tool_call>`
# or `</arg_value>`, which models sometimes emit unbalanced into a tool name.
_MARKUP_TAG = re.compile(r"</?[A-Za-z_][A-Za-z0-9_-]*>")


def warn_if_tool_call_unparsed(parser_name: str, tool_parser: "BaseToolParser",
text: str, calls: List[ToolCallItem]) -> None:
Expand Down Expand Up @@ -59,6 +71,9 @@ class BaseToolParser(ABC):
# JSON arguments) set this to True so the serving layer runs extraction
# on the named-choice path instead of passing raw text through.
extracts_forced_tool_calls: bool = False
# True when parse_streaming_increment plus finish deliver exactly the calls
# detect_and_parse finds, however the text is split into increments.
streaming_matches_whole_parse: bool = False

def __init__(self):
# Streaming state management
Expand Down Expand Up @@ -103,6 +118,42 @@ def _get_tool_indices(self, tools: List[Tool]) -> Dict[str, int]:
for i, tool in enumerate(tools) if tool.function.name
}

@staticmethod
def resolve_tool_name(name: Optional[str],
tool_indices: Dict[str, int]) -> Optional[str]:
"""Map an emitted tool name onto a declared tool; None if none fits.

Recovers a group qualifier the declaration lacks (``functions.exec``
for ``exec``), a bare name for a qualified declaration (``exec`` for
``functions.exec``, only when that tail is unambiguous), and markup
fused onto the name (``apply_patch</arg_value>``). A name that merely
mentions a declared tool inside prose is never mapped onto it.
"""
if not name:
return None
if name in tool_indices:
return name

# Declared tools by their bare tail; an ambiguous tail maps nowhere.
by_tail: Dict[str, List[str]] = {}
for declared in tool_indices:
by_tail.setdefault(declared.rsplit(".", 1)[-1], []).append(declared)

# The raw name, then the name with markup removed. Anything that is not
# a dotted identifier after that is prose or code, not a mangled name.
for candidate in (name, _MARKUP_TAG.sub("", name).strip()):
if not _QUALIFIED_NAME.match(candidate):
continue
if candidate in tool_indices:
return candidate
tail = candidate.rsplit(".", 1)[-1]
if tail in tool_indices:
return tail
qualified = by_tail.get(tail)
if qualified is not None and len(qualified) == 1:
return qualified[0]
return None

def parse_base_json(self, action: Any,
tools: List[Tool]) -> List[ToolCallItem]:
tool_indices = self._get_tool_indices(tools)
Expand Down
Loading