Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
64 changes: 63 additions & 1 deletion tests/parser/engine/test_parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,9 @@ def test_parser_manager_uses_shared_engine_directly(monkeypatch):
)

assert parser_cls is not None
assert parser_cls is _CombinedTestEngine
assert issubclass(parser_cls, _CombinedTestEngine)
assert parser_cls.reasoning_parser_cls is _CombinedReasoningAdapter
assert parser_cls.tool_parser_cls is _CombinedToolAdapter
parser = parser_cls(make_mock_tokenizer(_VOCAB))
request = _make_delegating_request()
reasoning, content, _ = parser.parse(
Expand All @@ -925,6 +927,66 @@ def test_parser_manager_uses_shared_engine_directly(monkeypatch):
assert parser.count_reasoning_tokens([]) == 2


def test_parser_manager_shared_engine_strict_tool_calling(monkeypatch):
"""When reasoning and tool parser share an engine, strict tool calling
attaches the structural tag to the request during adjust_request."""
from vllm.parser.engine.registered_adapters import Qwen3ParserReasoningAdapter
from vllm.tool_parsers.qwen3_engine_tool_parser import Qwen3EngineToolParser

monkeypatch.setattr(
ParserManager,
"get_reasoning_parser",
classmethod(lambda cls, name: Qwen3ParserReasoningAdapter),
)
monkeypatch.setattr(
ParserManager,
"get_tool_parser",
classmethod(lambda cls, name, enabled, model: Qwen3EngineToolParser),
)

parser_cls = ParserManager.get_parser(
tool_parser_name="qwen3_coder",
reasoning_parser_name="qwen3",
enable_auto_tools=True,
)
assert parser_cls is not None
assert parser_cls.tool_parser_cls is Qwen3EngineToolParser

tokenizer = make_mock_tokenizer(
{"<|im_start|>": 1, "<|im_end|>": 2, "<think>": 3, "</think>": 4}
)
tools = [
{
"type": "function",
"function": {
"name": "assign",
"strict": True,
"parameters": {
"type": "object",
"additionalProperties": False,
"properties": {
"member": {"type": "string"},
"objective": {"type": "string"},
},
"required": ["member", "objective"],
},
},
}
]

for tool_choice in ("auto", "required"):
req = ChatCompletionRequest(
model="m",
messages=[{"role": "user", "content": "hi"}],
tools=tools,
tool_choice=tool_choice,
)
adjusted = parser_cls(tokenizer, req.tools).adjust_request(req)
assert adjusted.structured_outputs is not None
assert adjusted.structured_outputs.structural_tag is not None
assert "assign" in adjusted.structured_outputs.structural_tag


def test_parser_manager_preserves_reasoning_only_adapter(monkeypatch):
monkeypatch.setattr(
ParserManager,
Expand Down
79 changes: 41 additions & 38 deletions vllm/parser/abstract_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,47 @@ def extract_reasoning_streaming(

# ========== Tool Parser Methods ==========

def _apply_structural_tag(
self,
request: ChatCompletionRequest | ResponsesRequest,
tool_parser: ToolParser | None = None,
) -> ChatCompletionRequest | ResponsesRequest:
tp = tool_parser if tool_parser is not None else self._tool_parser
if (
tp is None
or tp.structural_tag_model is None
or not request.tools
):
return request

need_tool_calling = (
request.tool_choice == "auto"
or request.tool_choice == "required"
or isinstance(
request.tool_choice,
(ChatCompletionNamedToolChoiceParam, ToolChoiceFunction),
)
)
if not need_tool_calling:
return request

structure_tag = tp.get_structural_tag(
request,
reasoning=False,
)
if structure_tag is None:
return request

structural_tag = json.dumps(structure_tag.model_dump())
request.structured_outputs = StructuredOutputsParams(
structural_tag=structural_tag,
)
if isinstance(request, ResponsesRequest):
request.text = None
else:
request.response_format = None
return request

def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
Expand Down Expand Up @@ -523,44 +564,6 @@ def adjust_request(
request = self._tool_parser.adjust_request(request)
return request

def _apply_structural_tag(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
if (
self._tool_parser is None
or self._tool_parser.structural_tag_model is None
or not request.tools
):
return request

need_tool_calling = (
request.tool_choice == "auto"
or request.tool_choice == "required"
or isinstance(
request.tool_choice,
(ChatCompletionNamedToolChoiceParam, ToolChoiceFunction),
)
)
if not need_tool_calling:
return request

structure_tag = self._tool_parser.get_structural_tag(
request,
reasoning=False,
)
if structure_tag is None:
return request

structural_tag = json.dumps(structure_tag.model_dump())
request.structured_outputs = StructuredOutputsParams(
structural_tag=structural_tag,
)
if isinstance(request, ResponsesRequest):
request.text = None
else:
request.response_format = None
return request

def extract_reasoning_streaming(
self,
previous_text: str,
Expand Down
6 changes: 6 additions & 0 deletions vllm/parser/engine/parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
request.skip_special_tokens = False
if self.tool_parser_cls is not None and request.tools:
from vllm.tool_parsers.abstract_tool_parser import ToolParser

tool_parser = self.tool_parser_cls(self.model_tokenizer, self._tools)
request = self._apply_structural_tag(request, tool_parser=tool_parser)
request = ToolParser.adjust_request(tool_parser, request)
return request

def _preprocess_feed(
Expand Down
9 changes: 8 additions & 1 deletion vllm/parser/parser_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,14 @@ def get_parser(
reasoning_engine_cls = cls._get_parser_engine_cls(reasoning_parser_cls)
tool_engine_cls = cls._get_parser_engine_cls(tool_parser_cls)
if reasoning_engine_cls is not None and reasoning_engine_cls is tool_engine_cls:
return reasoning_engine_cls
r_cls = reasoning_parser_cls
t_cls = tool_parser_cls

class _EngineParser(reasoning_engine_cls):
reasoning_parser_cls = r_cls
tool_parser_cls = t_cls

return _EngineParser

if reasoning_parser_name == "kimi_k3" or tool_parser_name == "kimi_k3":
from vllm.parser.kimi_k3 import KimiK3Parser
Expand Down
Loading