Skip to content
Open
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
101 changes: 101 additions & 0 deletions tests/parser/engine/test_parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
Transition,
)
from vllm.parser.parser_manager import ParserManager
from vllm.sampling_params import StructuredOutputsParams

# ── Shared test configs ──────────────────────────────────────────────

Expand Down Expand Up @@ -1314,6 +1315,106 @@ def test_kwargs_forwarded_to_parser_engine(self, enable_thinking, expected_state
assert engine.parser_engine_config.initial_state == expected_state


class TestAdapterRequestAdjustment:
@staticmethod
def _request() -> MagicMock:
request = MagicMock(spec=ChatCompletionRequest)
request.tools = [{"type": "function", "function": {"name": "f"}}]
request.tool_choice = "auto"
request.skip_special_tokens = True
request.structured_outputs = None
request.response_format = MagicMock()
return request

def test_direct_engine_applies_structural_tag(self, monkeypatch):
class StructuralEngine(_CombinedTestEngine):
structural_tag_model = "test-model"

tag = MagicMock(model_dump=lambda: {"type": "structural_tag"})
get_tag = MagicMock(return_value=tag)
monkeypatch.setattr(
"vllm.tool_parsers.structural_tag_registry.get_model_structural_tag",
get_tag,
)
monkeypatch.setattr("vllm.envs.VLLM_ENFORCE_STRICT_TOOL_CALLING", True)

request = StructuralEngine(make_mock_tokenizer(_VOCAB)).adjust_request(
self._request()
)

assert request.skip_special_tokens is True
assert json.loads(request.structured_outputs.structural_tag) == {
"type": "structural_tag"
}
assert request.response_format is None
get_tag.assert_called_once_with(
model="test-model",
tools=request.tools,
tool_choice="auto",
reasoning=False,
)

def test_reasoning_adapter_does_not_adjust_request(self, monkeypatch):
class ReasoningStructuralEngine(_CombinedTestEngine):
structural_tag_model = "reasoning-model"

ReasoningAdapter, _ = make_adapters(ReasoningStructuralEngine)
get_tag = MagicMock()
monkeypatch.setattr(
"vllm.tool_parsers.structural_tag_registry.get_model_structural_tag",
get_tag,
)

request = ReasoningAdapter(make_mock_tokenizer(_VOCAB)).adjust_request(
self._request()
)

assert request.skip_special_tokens is True
assert request.structured_outputs is None
get_tag.assert_not_called()

def test_existing_structural_tag_is_not_overwritten(self, monkeypatch):
class StructuralEngine(_CombinedTestEngine):
structural_tag_model = "test-model"

get_tag = MagicMock()
monkeypatch.setattr(
"vllm.tool_parsers.structural_tag_registry.get_model_structural_tag",
get_tag,
)
request = self._request()
request.structured_outputs = StructuredOutputsParams(
structural_tag='{"existing": true}'
)

adjusted = StructuralEngine(make_mock_tokenizer(_VOCAB)).adjust_request(request)

assert adjusted.structured_outputs.structural_tag == '{"existing": true}'
get_tag.assert_not_called()

def test_engine_capabilities_are_copied_to_tool_adapter(self):
class EngineCapabilities(_CombinedTestEngine):
structural_tag_model = "test-model"
supports_required_and_named = False

_, ToolAdapter = make_adapters(EngineCapabilities)

assert ToolAdapter.structural_tag_model == "test-model"
assert ToolAdapter.supports_required_and_named is False

def test_qwen3_subclasses_disable_inherited_structural_tag(self):
from vllm.parser.nemotron_v3 import NemotronV3Parser
from vllm.parser.qwen3 import Qwen3Parser
from vllm.parser.seed_oss import SeedOssParser

assert Qwen3Parser.structural_tag_model == "qwen_3_coder"
assert Qwen3Parser.supports_required_and_named is False
assert SeedOssParser.structural_tag_model is None
assert SeedOssParser.supports_required_and_named is True
assert NemotronV3Parser.structural_tag_model is None
assert NemotronV3Parser.supports_required_and_named is True


# ── TestExtractContentIdsNoEmptyReturn ─────────────────────────────


Expand Down
3 changes: 3 additions & 0 deletions vllm/parser/abstract_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,11 @@ def _apply_structural_tag(
) -> ChatCompletionRequest | ResponsesRequest:
if (
self._tool_parser is None
or self._tool_parser.engine_based_streaming
or self._tool_parser.structural_tag_model is None
or not request.tools
or request.structured_outputs is not None
and request.structured_outputs.structural_tag is not None
):
return request

Expand Down
9 changes: 9 additions & 0 deletions vllm/parser/deepseek_v32.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
)

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

Expand Down Expand Up @@ -108,6 +110,13 @@ def deepseek_v32_config() -> ParserEngineConfig:


class DeepSeekV32Parser(ParserEngine):
structural_tag_model = "deepseek_v3_2"
supports_required_and_named = False

def adjust_request(self, request: ChatCompletionRequest | ResponsesRequest):
request.skip_special_tokens = False
return super().adjust_request(request)

def __init__(
self,
tokenizer: TokenizerLike,
Expand Down
9 changes: 9 additions & 0 deletions vllm/parser/deepseek_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
from vllm.tool_parsers.utils import find_tool_properties

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

Expand Down Expand Up @@ -210,6 +212,13 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:


class DeepSeekV4Parser(ParserEngine):
structural_tag_model = "deepseek_v4"
supports_required_and_named = False

def adjust_request(self, request: ChatCompletionRequest | ResponsesRequest):
request.skip_special_tokens = False
return super().adjust_request(request)

def __init__(
self,
tokenizer: TokenizerLike,
Expand Down
13 changes: 9 additions & 4 deletions vllm/parser/engine/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def adjust_request(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> ChatCompletionRequest | ResponsesRequest:
return self._parser_engine.adjust_request(request)
return request

def has_engine_confirmed_reasoning_end(self) -> bool:
return self._parser_engine.reasoning_ended
Expand Down Expand Up @@ -170,8 +170,8 @@ def adjust_request(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> ChatCompletionRequest | ResponsesRequest:
request = super().adjust_request(request)
return self._parser_engine.adjust_request(request)
request = self._parser_engine.adjust_request(request)
return super().adjust_request(request)

def extract_tool_calls(
self,
Expand Down Expand Up @@ -216,10 +216,15 @@ def make_adapters(
(ParserEngineReasoningAdapter,),
{"_parser_engine_cls": parser_engine_cls},
)
tool_attrs = {
"_parser_engine_cls": parser_engine_cls,
"structural_tag_model": parser_engine_cls.structural_tag_model,
"supports_required_and_named": parser_engine_cls.supports_required_and_named,
}
tool_adapter = type(
f"{parser_engine_cls.__name__}ToolAdapter",
(ParserEngineToolAdapter,),
{"_parser_engine_cls": parser_engine_cls},
tool_attrs,
)
# Let the serving layer find the adapters and call adjust_request(),
# which sets skip_special_tokens=False for the detokenizer.
Expand Down
42 changes: 41 additions & 1 deletion vllm/parser/engine/parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ class ParserEngine(Parser):
complete output format for a model (reasoning + tool calls).
"""

structural_tag_model: str | None = None
supports_required_and_named: bool = True

def __init__(
self,
tokenizer: TokenizerLike,
Expand Down Expand Up @@ -204,7 +207,44 @@ def _reset(self, initial_state: ParserState | None = None) -> None:
def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
request.skip_special_tokens = False
return self._apply_structural_tag(request)

def _apply_structural_tag(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
if (
self.structural_tag_model is None
or not request.tools
or (structured_outputs := getattr(request, "structured_outputs", None))
is not None
and structured_outputs.structural_tag is not None
):
return request

from vllm import envs
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.sampling_params import StructuredOutputsParams
from vllm.tool_parsers.structural_tag_registry import (
get_model_structural_tag,
)

if not envs.VLLM_ENFORCE_STRICT_TOOL_CALLING:
return request
structural_tag = get_model_structural_tag(
model=self.structural_tag_model,
tools=request.tools,
tool_choice=request.tool_choice,
reasoning=False,
)
if structural_tag is None:
return request
request.structured_outputs = StructuredOutputsParams(
structural_tag=json.dumps(structural_tag.model_dump())
)
if isinstance(request, ResponsesRequest):
request.text = None
else:
request.response_format = None
return request

def _preprocess_feed(
Expand Down
16 changes: 12 additions & 4 deletions vllm/parser/engine/registered_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,23 @@
(
DeepSeekV32ParserReasoningAdapter,
DeepSeekV32ParserToolAdapter,
) = make_adapters(DeepSeekV32Parser)
) = make_adapters(
DeepSeekV32Parser,
)

(
DeepSeekV4ParserReasoningAdapter,
DeepSeekV4ParserToolAdapter,
) = make_adapters(DeepSeekV4Parser)
) = make_adapters(
DeepSeekV4Parser,
)

(
MinimaxM2ParserReasoningAdapter,
MinimaxM2ParserToolAdapter,
) = make_adapters(MinimaxM2Parser)
) = make_adapters(
MinimaxM2Parser,
)

(
Gemma4ParserReasoningAdapter,
Expand All @@ -58,7 +64,9 @@
(
Glm47MoeParserReasoningAdapter,
Glm47MoeParserToolAdapter,
) = make_adapters(Glm47MoeParser)
) = make_adapters(
Glm47MoeParser,
)

(
KimiK2ParserReasoningAdapter,
Expand Down
8 changes: 8 additions & 0 deletions vllm/parser/gemma4.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,14 @@ class Gemma4Parser(ParserEngine):
- Detects ``<|tool_call>`` token as implicit reasoning end
"""

supports_required_and_named = False

def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
request.skip_special_tokens = False
return super().adjust_request(request)

def __init__(
self,
tokenizer: TokenizerLike,
Expand Down
9 changes: 9 additions & 0 deletions vllm/parser/glm47_moe.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ def glm47_moe_config(thinking: bool = True) -> ParserEngineConfig:
class Glm47MoeParser(ParserEngine):
"""GLM-4.7 parser backed by the declarative parser engine."""

structural_tag_model = "glm_4_7"
supports_required_and_named = False

def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
request.skip_special_tokens = False
return super().adjust_request(request)

def __init__(
self,
tokenizer: TokenizerLike,
Expand Down
9 changes: 9 additions & 0 deletions vllm/parser/inkling.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
)

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

Expand Down Expand Up @@ -304,6 +306,13 @@ def inkling_config() -> ParserEngineConfig:

class InklingParser(ParserEngine):
CONFIG_NAME = "inkling"
supports_required_and_named = False

def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
request.skip_special_tokens = False
return super().adjust_request(request)

def __init__(
self,
Expand Down
9 changes: 9 additions & 0 deletions vllm/parser/kimi_k2.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ def kimi_k2_config(thinking: bool = True) -> ParserEngineConfig:
class KimiK2Parser(ParserEngine):
"""Kimi K2 parser backed by the declarative parser engine."""

structural_tag_model = "kimi"
supports_required_and_named = False

def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
request.skip_special_tokens = False
return super().adjust_request(request)

def __init__(
self,
tokenizer: TokenizerLike,
Expand Down
7 changes: 7 additions & 0 deletions vllm/parser/minimax_m2.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ def minimax_m2_config() -> ParserEngineConfig:
class MinimaxM2Parser(ParserEngine):
"""MiniMax M2 parser backed by the declarative parser engine."""

structural_tag_model = "minimax"
supports_required_and_named = False

def adjust_request(self, request):
request.skip_special_tokens = False
return super().adjust_request(request)

def __init__(self, tokenizer, tools=None, **kwargs) -> None:
kwargs.setdefault("parser_engine_config", minimax_m2_config())
super().__init__(tokenizer, tools, **kwargs)
Expand Down
Loading
Loading