[Bugfix][Structured Output] Preserve reasoning adapters for shared parser engines (#52830)

Signed-off-by: sfeng33 <4florafeng@gmail.com>

Flora Feng committed Aug 26, 2026 at 00:37 UTC 46638857fdbb30e0c232c9e8f9cb1ff6d6f545c3
2 files changed +27 -49
tests/parser/engine/test_parser_engine.py
+25 -19
@@ -893,7 +893,7 @@ class _CombinedDelegating(DelegatingParser):
893 tool_parser_cls = _CombinedToolAdapter
894
895
896 -def test_parser_manager_uses_shared_engine_directly(monkeypatch):
896 +def test_parser_manager_preserves_shared_engine_adapters(monkeypatch):
897 monkeypatch.setattr(
898 ParserManager,
899 "get_reasoning_parser",
@@ -912,17 +912,38 @@ def test_parser_manager_uses_shared_engine_directly(monkeypatch):
912 )
913
914 assert parser_cls is not None
915 - assert parser_cls is _CombinedTestEngine
915 + assert issubclass(parser_cls, DelegatingParser)
916 parser = parser_cls(make_mock_tokenizer(_VOCAB))
917 + assert parser.reasoning_parser is not None
918 + assert parser.tool_parser is not None
919 + assert parser.reasoning_parser._parser_engine_cls is _CombinedTestEngine
920 + assert parser.tool_parser._parser_engine_cls is _CombinedTestEngine
921 request = _make_delegating_request()
922 + token_ids = [ord("a"), ord("b"), 201, ord("c")]
923 reasoning, content, _ = parser.parse(
924 "ab</think>c",
925 request,
921 - model_output_token_ids=[ord("a"), ord("b"), 201, ord("c")],
926 + model_output_token_ids=token_ids,
927 )
928 assert reasoning == "ab"
929 assert content == "c"
925 - assert parser.count_reasoning_tokens([]) == 2
930 + assert parser.count_reasoning_tokens(token_ids) == 2
931 +
932 +
933 +def test_parser_manager_preserves_shared_engine_reasoning_wiring():
934 + parser_cls = ParserManager.get_parser(
935 + tool_parser_name="qwen3_xml",
936 + reasoning_parser_name="qwen3",
937 + enable_auto_tools=True,
938 + )
939 +
940 + assert parser_cls is not None
941 + parser = parser_cls(
942 + make_mock_tokenizer(_VOCAB),
943 + chat_template_kwargs={"enable_thinking": False},
944 + )
945 + assert parser.reasoning_parser is not None
946 + assert parser.reasoning_parser._parser_engine.thinking_enabled is False
947
948
949 def test_parser_manager_preserves_reasoning_only_adapter(monkeypatch):
@@ -983,21 +1004,6 @@ def test_parser_manager_preserves_tool_only_adapter(monkeypatch):
1004 assert tool_calls == []
1005
1006
986 -def test_parser_manager_rejects_non_engine_adapter_metadata():
987 - class TraditionalParser:
988 - pass
989 -
990 - class InvalidEngineAdapter:
991 - _parser_engine_cls = TraditionalParser
992 -
993 - assert ParserManager._get_parser_engine_cls(TraditionalParser) is None
994 - assert ParserManager._get_parser_engine_cls(InvalidEngineAdapter) is None
995 - assert (
996 - ParserManager._get_parser_engine_cls(_CombinedReasoningAdapter)
997 - is _CombinedTestEngine
998 - )
999 -
1000 -
1007 def test_reasoning_adapter_counts_after_final_non_streaming_parse():
1008 parser = _CombinedReasoningAdapter(make_mock_tokenizer(_VOCAB))
1009 request = _make_delegating_request()
vllm/parser/parser_manager.py
+2 -30
@@ -9,7 +9,6 @@ from vllm.logger import init_logger
9
10 if TYPE_CHECKING:
11 from vllm.parser.abstract_parser import Parser
12 - from vllm.parser.engine.parser_engine import ParserEngine
12 from vllm.reasoning import ReasoningParser
13 from vllm.tool_parsers import ToolParser
14
@@ -18,30 +17,9 @@ logger = init_logger(__name__)
17
18 class ParserManager:
19 """
21 - Provides a unified Parser from the reasoning and tool parser registries.
22 -
23 - Parser engine adapters backed by the same engine are collapsed back into
24 - that engine. Other parser pairs are composed through ``DelegatingParser``.
20 + Provides a unified Parser by composing reasoning and tool parser adapters.
21 """
22
27 - @staticmethod
28 - def _get_parser_engine_cls(
29 - parser_cls: type[object] | None,
30 - ) -> type[ParserEngine] | None:
31 - if parser_cls is None:
32 - return None
33 - parser_engine_cls = getattr(parser_cls, "_parser_engine_cls", None)
34 - if parser_engine_cls is None:
35 - return None
36 -
37 - from vllm.parser.engine.parser_engine import ParserEngine
38 -
39 - if not isinstance(parser_engine_cls, type) or not issubclass(
40 - parser_engine_cls, ParserEngine
41 - ):
42 - return None
43 - return parser_engine_cls
44 -
23 @classmethod
24 def get_tool_parser(
25 cls,
@@ -105,8 +83,7 @@ class ParserManager:
83 """
84 Get a Parser that handles both reasoning and tool parsing.
85
108 - Reuses a shared parser engine when possible, otherwise composes the
109 - individual parsers into a ``DelegatingParser`` subclass.
86 + Composes the individual parsers into a ``DelegatingParser`` subclass.
87
88 Args:
89 tool_parser_name: The name of the tool parser.
@@ -137,11 +114,6 @@ class ParserManager:
114 HarmonyParser.tool_parser_cls = tool_parser_cls
115 return HarmonyParser
116
140 - reasoning_engine_cls = cls._get_parser_engine_cls(reasoning_parser_cls)
141 - tool_engine_cls = cls._get_parser_engine_cls(tool_parser_cls)
142 - if reasoning_engine_cls is not None and reasoning_engine_cls is tool_engine_cls:
143 - return reasoning_engine_cls
144 -
117 if reasoning_parser_name == "kimi_k3" or tool_parser_name == "kimi_k3":
118 from vllm.parser.kimi_k3 import KimiK3Parser
119