# 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:: value The argument body consists of ``VALUE`` 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_END = "" TOOL_CALL_START = "" TOOL_CALL_END = "" FUNC_PREFIX = "]*)>" 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 — 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: (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: 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 (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: ````/```` reasoning + ```` XML tool calls in a single engine. - ```` as implicit reasoning end - Unpaired ```` 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