Files
vllm-project--vllm/vllm/parser/qwen3.py
T
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

268 lines
8.9 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Qwen3 parser for tool calls and reasoning.
Qwen3 XML tool call format::
<tool_call>
<function=func_name>
<parameter=key>value</parameter>
</function>
</tool_call>
The argument body consists of ``<parameter=NAME>VALUE</parameter>`` tags.
The ``_qwen3_arg_converter`` parses these into a JSON object.
"""
from __future__ import annotations
import functools
import json
from typing import TYPE_CHECKING
import regex as re
from vllm.parser.engine.events import EventType
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
if TYPE_CHECKING:
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionRequest,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import Tool
THINK_START = "<think>"
THINK_END = "</think>"
TOOL_CALL_START = "<tool_call>"
TOOL_CALL_END = "</tool_call>"
FUNC_PREFIX = "<function="
FUNC_END = "</function>"
PARAM_START = "<parameter="
PARAM_END = "</parameter>"
_PARAM_RE = re.compile(
r"<\s*parameter\s*=\s*([^>]*)>"
r"(.*?)"
r"(?:<\s*/\s*parameter\s*>|(?=<\s*parameter\s*=))",
re.DOTALL,
)
_PARTIAL_PARAM_RE = re.compile(r"<\s*parameter\s*=\s*([^>]+)>(.*)$", re.DOTALL)
def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
params: dict[str, object] = {}
for match in _PARAM_RE.finditer(raw_args):
name = match.group(1)
value = match.group(2)
params[name] = value.strip()
if partial:
remaining = _PARAM_RE.sub("", raw_args)
m = _PARTIAL_PARAM_RE.search(remaining)
if m:
name = m.group(1)
value = m.group(2)
if name:
params[name] = value.strip()
return json.dumps(params, ensure_ascii=False)
@functools.cache
def qwen3_config(
thinking: bool = True,
*,
name: str = "qwen3",
think_start: str = THINK_START,
think_end: str = THINK_END,
tool_start: str = TOOL_CALL_START,
tool_end: str = TOOL_CALL_END,
) -> ParserEngineConfig:
return ParserEngineConfig(
name=name,
initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
terminals={
# Reasoning terminals
"THINK_START": think_start,
"THINK_END": think_end,
# Tool call terminals
"TOOL_START": tool_start,
"TOOL_END": tool_end,
"FUNC_PREFIX": FUNC_PREFIX,
"FUNC_END": FUNC_END,
"PARAM_START": PARAM_START,
"PARAM_END": PARAM_END,
"CLOSE_ANGLE": ">",
},
token_id_terminals={
"THINK_START": think_start,
"THINK_END": think_end,
"TOOL_START": tool_start,
"TOOL_END": tool_end,
},
transitions={
# -- Reasoning transitions --
(ParserState.REASONING, "THINK_START"): Transition(
ParserState.REASONING,
(),
),
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
# Absorb duplicate </think> — model may emit it after
# already transitioning to CONTENT; drop it silently.
(ParserState.CONTENT, "THINK_END"): Transition(
ParserState.CONTENT,
(),
),
# Tool call directly from reasoning (implicit end)
(ParserState.REASONING, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.REASONING_END, EventType.TOOL_CALL_START),
),
# -- Tool call transitions --
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.REASONING_END, EventType.TOOL_CALL_START),
),
# Fallback: <function= without a preceding <tool_call>
(ParserState.CONTENT, "FUNC_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition(
ParserState.CONTENT,
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_PREAMBLE, "FUNC_PREFIX"): Transition(
ParserState.TOOL_NAME,
(),
),
(ParserState.TOOL_NAME, "CLOSE_ANGLE"): Transition(
ParserState.TOOL_ARGS,
(),
),
# Malformed: </function> while still in TOOL_NAME (no closing >)
(ParserState.TOOL_NAME, "FUNC_END"): Transition(
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_ARGS, "FUNC_END"): Transition(
ParserState.TOOL_BETWEEN,
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_ARGS, "PARAM_START"): Transition(
ParserState.TOOL_ARGS,
(EventType.ARG_VALUE_CHUNK,),
),
(ParserState.TOOL_ARGS, "PARAM_END"): Transition(
ParserState.TOOL_ARGS,
(EventType.ARG_VALUE_CHUNK,),
),
(ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
ParserState.CONTENT,
(),
),
# Consecutive tool call without closing </tool_call>
(ParserState.TOOL_BETWEEN, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_BETWEEN, "FUNC_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
},
arg_converter=_qwen3_arg_converter,
stream_arg_deltas=True,
strip_trailing_reasoning_whitespace=False,
tool_args_json=False,
)
class Qwen3Parser(ParserEngine):
"""Qwen3 parser: ``<think>``/``</think>`` reasoning +
``<tool_call>`` XML tool calls in a single engine.
- ``<tool_call>`` as implicit reasoning end
- Unpaired ``<tool_call>`` token ID detection for ``is_reasoning_end``
Subclasses that share the grammar but differ only in the four wrapper
token strings (reasoning + tool-call) override the class attributes
below; everything else is inherited unchanged.
"""
CONFIG_NAME = "qwen3"
THINK_START = THINK_START
THINK_END = THINK_END
TOOL_START = TOOL_CALL_START
TOOL_END = TOOL_CALL_END
def __init__(
self,
tokenizer: TokenizerLike,
tools: list[Tool] | None = None,
**kwargs,
) -> None:
chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
kwargs.setdefault(
"parser_engine_config",
qwen3_config(
thinking=self.thinking_enabled,
name=self.CONFIG_NAME,
think_start=self.THINK_START,
think_end=self.THINK_END,
tool_start=self.TOOL_START,
tool_end=self.TOOL_END,
),
)
super().__init__(
tokenizer,
tools,
**kwargs,
)
vocab = self.vocab
self._tool_call_token_id: int | None = vocab.get(self.TOOL_START)
self._tool_call_end_token_id: int | None = vocab.get(self.TOOL_END)
def extract_reasoning(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> tuple[str | None, str | None]:
if not self.thinking_enabled:
return None, model_output
return super().extract_reasoning(model_output, request)
def is_reasoning_end(self, input_ids: list[int]) -> bool:
if super().is_reasoning_end(input_ids):
return True
tool_call_id = self._tool_call_token_id
tool_call_end_id = self._tool_call_end_token_id
reasoning_start_id = self._reasoning_start_token_id
if tool_call_id is not None:
for i in range(len(input_ids) - 1, -1, -1):
if (
reasoning_start_id is not None
and input_ids[i] == reasoning_start_id
):
return False
if input_ids[i] == tool_call_id:
if tool_call_end_id is not None and any(
input_ids[j] == tool_call_end_id
for j in range(i + 1, len(input_ids))
):
continue
return True
return False