[Bugfix] Tolerate misspelled DSML tool_calls wrapper (#56141)

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

Flora Feng committed Sep 10, 2026 at 03:04 UTC 9e2570656013a90cb601b391723af4bf9b3207d4
9 files changed +262 -14
tests/parser/engine/test_deepseek_v4.py
+162
@@ -30,6 +30,7 @@ from vllm.parser.deepseek_v4 import (
30 DSML_THINK_START,
31 DSML_TOOL_END,
32 DSML_TOOL_START,
33 + DSML_TOOL_START_VARIANTS,
34 DeepSeekV4Parser,
35 _dsml_arg_converter,
36 _unwrap_wrapper_args,
@@ -1229,3 +1230,164 @@ class TestDelegatingParserLargeDelta:
1230 assert eos_text not in output.reasoning
1231 assert output.content == ""
1232 assert output.tool_calls == []
1233 +
1234 +
1235 +# ── Malformed DSML markup must not leak into content (#51914) ─────────
1236 +
1237 +_WRAPPER_VARIANTS = list(DSML_TOOL_START_VARIANTS)
1238 +
1239 +
1240 +class TestMalformedDsmlNoise:
1241 + """DeepSeek-V4-Flash intermittently misspells the tool-call opener, e.g.
1242 + ``<|DSML|toolcalls>``. Known corrupted openers open a tool block like
1243 + the real wrapper so the markup never surfaces as assistant content.
1244 + """
1245 +
1246 + _INNER = (
1247 + f"{DSML_INVOKE_PREFIX}get_weather{DSML_INVOKE_NAME_END}\n"
1248 + f"{_param('city', 'true', 'Seoul')}\n"
1249 + f"{DSML_INVOKE_END}\n"
1250 + f"{DSML_TOOL_END}"
1251 + )
1252 +
1253 + @staticmethod
1254 + def _parser(mock_tokenizer, thinking: bool = False):
1255 + return DeepSeekV4Parser(
1256 + mock_tokenizer, chat_template_kwargs={"thinking": thinking}
1257 + )
1258 +
1259 + @pytest.mark.parametrize("wrapper", _WRAPPER_VARIANTS)
1260 + @pytest.mark.parametrize("preamble", ["", "Let me check.\n"])
1261 + def test_corrupted_wrapper_behaves_like_real_one(
1262 + self, mock_tokenizer, mock_request, wrapper, preamble
1263 + ):
1264 + text = f"{preamble}{wrapper}\n{self._INNER}"
1265 + reference = f"{preamble}{DSML_TOOL_START}\n{self._INNER}"
1266 +
1267 + result = self._parser(mock_tokenizer).extract_tool_calls(text, mock_request)
1268 + expected = self._parser(mock_tokenizer).extract_tool_calls(
1269 + reference, mock_request
1270 + )
1271 + assert [tc.function.name for tc in result.tool_calls] == ["get_weather"]
1272 + assert json.loads(result.tool_calls[0].function.arguments) == {"city": "Seoul"}
1273 + assert result.content == expected.content
1274 + assert "DSML" not in (result.content or "")
1275 +
1276 + results = simulate_tool_streaming(
1277 + self._parser(mock_tokenizer), mock_request, list(text)
1278 + )
1279 + assert collect_function_name(results) == "get_weather"
1280 + assert json.loads(collect_tool_arguments(results)) == {"city": "Seoul"}
1281 + assert collect_content(results) == (expected.content or "")
1282 +
1283 + @pytest.mark.parametrize("wrapper", _WRAPPER_VARIANTS)
1284 + def test_corrupted_wrapper_ends_reasoning(
1285 + self, mock_tokenizer, mock_request, wrapper
1286 + ):
1287 + """Like the real wrapper, a corrupted one inside ``<think>`` closes
1288 + the reasoning block and starts the tool call."""
1289 + text = f"thinking{wrapper}\n{self._INNER}"
1290 + parser = self._parser(mock_tokenizer, thinking=True)
1291 + reasoning, content = parser.extract_reasoning(text, mock_request)
1292 + assert reasoning == "thinking"
1293 + assert "DSML" not in (content or "")
1294 +
1295 + result = self._parser(mock_tokenizer, thinking=True).extract_tool_calls(
1296 + text, mock_request
1297 + )
1298 + assert [tc.function.name for tc in result.tool_calls] == ["get_weather"]
1299 +
1300 + @pytest.mark.parametrize(
1301 + "text",
1302 + [
1303 + # Fullwidth bars, DSML namespace, but not a registered spelling.
1304 + "see <|DSML|foo> and <|DSML|tool_call> here",
1305 + # Unclosed prefix.
1306 + "see <|DSML| here\nnext line",
1307 + # ASCII pipes are not the DSML sigil.
1308 + "see <|DSML|tool> and <|DSML|toolcalls> there",
1309 + ],
1310 + )
1311 + def test_unregistered_markup_is_ordinary_content(
1312 + self, mock_tokenizer, mock_request, text
1313 + ):
1314 + """Only the registered spellings are treated as wrappers."""
1315 + result = self._parser(mock_tokenizer).extract_tool_calls(text, mock_request)
1316 + assert result.tools_called is False
1317 + assert result.content == text
1318 +
1319 + results = simulate_tool_streaming(
1320 + self._parser(mock_tokenizer), mock_request, list(text)
1321 + )
1322 + assert collect_content(results) == text
1323 +
1324 + @pytest.mark.parametrize("wrapper", _WRAPPER_VARIANTS)
1325 + def test_prose_mention_matches_canonical_wrapper(
1326 + self, mock_tokenizer, mock_request, wrapper
1327 + ):
1328 + """A registered spelling mentioned in prose behaves exactly like a
1329 + prose mention of the real wrapper: the opener starts a tool block and
1330 + the rest of the message is dropped. That is pre-existing behavior of
1331 + ``<|DSML|tool_calls>``; the variants must not differ from it either
1332 + way."""
1333 + template = "The opener looks like {} and then params follow. Done."
1334 + text = template.format(wrapper)
1335 + reference = template.format(DSML_TOOL_START)
1336 +
1337 + result = self._parser(mock_tokenizer).extract_tool_calls(text, mock_request)
1338 + expected = self._parser(mock_tokenizer).extract_tool_calls(
1339 + reference, mock_request
1340 + )
1341 + assert result.tools_called == expected.tools_called
1342 + assert result.content == expected.content
1343 +
1344 + results = simulate_tool_streaming(
1345 + self._parser(mock_tokenizer), mock_request, list(text)
1346 + )
1347 + expected_results = simulate_tool_streaming(
1348 + self._parser(mock_tokenizer), mock_request, list(reference)
1349 + )
1350 + assert collect_content(results) == collect_content(expected_results)
1351 +
1352 + @pytest.mark.parametrize("wrapper", _WRAPPER_VARIANTS)
1353 + @pytest.mark.parametrize("chunk_size", [1, 3, None], ids=lambda c: f"chunk={c}")
1354 + def test_delegating_parser_corrupted_wrapper(self, wrapper, chunk_size):
1355 + """The serving-layer shape: reasoning and tool adapters on separate
1356 + engines, with the corrupted opener arriving as plain text."""
1357 + tokens = _dsv4_tokens(
1358 + reasoning="Checking the weather.",
1359 + tool_name="get_weather",
1360 + params=[("city", "true", "Seoul")],
1361 + )
1362 + start_id = _DSV4_FULL_VOCAB[DSML_TOOL_START]
1363 + tokens = [
1364 + (900, wrapper) if tid == start_id else (tid, text) for tid, text in tokens
1365 + ]
1366 + # Only the think markers are single tokens in the real
1367 + # DeepSeek-V4-Flash vocab; the DSML wrappers are ordinary text and
1368 + # must be matched by the lexer. Mirror that here.
1369 + vocab = {
1370 + DSML_THINK_START: _DSV4_FULL_VOCAB[DSML_THINK_START],
1371 + DSML_THINK_END: _DSV4_FULL_VOCAB[DSML_THINK_END],
1372 + }
1373 + tokenizer = MockTokenizer(vocab=vocab, tokens=tokens)
1374 + parser = _DeepSeekV4Delegating(
1375 + tokenizer, chat_template_kwargs={"thinking": True}
1376 + )
1377 +
1378 + deltas = replay_streaming(
1379 + parser,
1380 + tokens,
1381 + chunk_size=chunk_size,
1382 + finished_on_last=True,
1383 + tools=DUMMY_TOOLS,
1384 + )
1385 + output = collect_output(deltas)
1386 +
1387 + assert "Checking" in output.reasoning
1388 + assert len(output.tool_calls) == 1, (
1389 + f"reasoning={output.reasoning!r}, content={output.content!r}"
1390 + )
1391 + assert output.tool_calls[0]["name"] == "get_weather"
1392 + assert json.loads(output.tool_calls[0]["arguments"]) == {"city": "Seoul"}
1393 + assert "DSML" not in (output.content or "")
tests/parser/engine/test_delegating_replay.py
+1 -1
@@ -197,7 +197,7 @@ def test_delegating_parse_tool_choice_none(parser_cls, parser_name, sample):
197 cfg = parser._tool_parser._parser_engine.parser_engine_config
198 terminals = sorted(
199 v
200 - for v in set(cfg.terminals.values()) | set(cfg.token_id_terminals.values())
200 + for v in cfg.terminal_literals | set(cfg.token_id_terminals.values())
201 if len(v) > 1
202 )
203 assert_no_terminal_leakage(
tests/parser/engine/test_engine.py
+58
@@ -953,3 +953,61 @@ class TestSkipToolParsingFromMessageHeader:
953 assert types[0] == EventType.TEXT_CHUNK
954 assert events[0].value == "<tool_call>"
955 assert engine._message_header_buffer == ""
956 +
957 +
958 +# ── Terminals with several spellings ──────────────────────────────────
959 +
960 +
961 +def _alias_config() -> ParserEngineConfig:
962 + """``TOOL_START`` has a canonical spelling and one corrupted alias."""
963 + return ParserEngineConfig(
964 + name="alias_test",
965 + terminals={"TOOL_START": ("<tc>", "<tcx>"), "TOOL_END": "</tc>"},
966 + transitions={
967 + (ParserState.CONTENT, "TOOL_START"): Transition(
968 + ParserState.TOOL_ARGS,
969 + (EventType.TOOL_CALL_START,),
970 + ),
971 + (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
972 + ParserState.CONTENT,
973 + (EventType.TOOL_CALL_END,),
974 + ),
975 + },
976 + content_events={
977 + ParserState.CONTENT: EventType.TEXT_CHUNK,
978 + ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
979 + },
980 + tool_args_json=False,
981 + )
982 +
983 +
984 +class TestTerminalAliases:
985 + def test_terminal_defs_expand_every_spelling(self):
986 + defs = terminals_from_literals({"A": ("x", "y"), "B": "z"})
987 + assert [(d.name, d.literal) for d in defs] == [
988 + ("A", "x"),
989 + ("A", "y"),
990 + ("B", "z"),
991 + ]
992 +
993 + def test_terminal_literal_is_canonical_spelling(self):
994 + cfg = _alias_config()
995 + assert cfg.terminal_literal("TOOL_START") == "<tc>"
996 + assert cfg.terminal_literal("TOOL_END") == "</tc>"
997 + assert cfg.terminal_literal("MISSING") is None
998 + assert cfg.terminal_literals == {"<tc>", "<tcx>", "</tc>"}
999 +
1000 + @pytest.mark.parametrize("spelling", ["<tc>", "<tcx>"])
1001 + @pytest.mark.parametrize("char_by_char", [False, True])
1002 + def test_every_spelling_shares_transitions(self, spelling, char_by_char):
1003 + engine = StreamingParserEngine(_alias_config(), None)
1004 + text = f"{spelling}a</tc>"
1005 + events = []
1006 + for chunk in list(text) if char_by_char else [text]:
1007 + events.extend(engine.feed(chunk, []))
1008 + events.extend(engine.finish())
1009 + assert [e.type for e in events] == [
1010 + EventType.TOOL_CALL_START,
1011 + EventType.ARG_VALUE_CHUNK,
1012 + EventType.TOOL_CALL_END,
1013 + ]
tests/parser/engine/test_replay.py
+6 -5
@@ -83,7 +83,7 @@ def _discover_parsers() -> list[_ParserInfo]:
83 raise RuntimeError(
84 f"{obj.__name__} config missing 'TOOL_END' in token_id_terminals"
85 )
86 - all_vals = set(cfg.terminals.values()) | set(cfg.token_id_terminals.values())
86 + all_vals = cfg.terminal_literals | set(cfg.token_id_terminals.values())
87 found.append(
88 _ParserInfo(
89 parser_cls=obj,
@@ -91,12 +91,13 @@ def _discover_parsers() -> list[_ParserInfo]:
91 samples=build_samples(cfg.name),
92 terminals=sorted(v for v in all_vals if len(v) > 1),
93 tool_end=tool_end,
94 - think_end=cfg.terminals.get("THINK_END", ""),
94 + think_end=cfg.terminal_literal("THINK_END") or "",
95 tool_start=(
96 - cfg.terminals["TOOL_SECTION_START"]
96 + cfg.terminal_literal("TOOL_SECTION_START")
97 if (ParserState.CONTENT, "TOOL_SECTION_START") in cfg.transitions
98 - else cfg.terminals.get("TOOL_START", "")
99 - ),
98 + else cfg.terminal_literal("TOOL_START")
99 + )
100 + or "",
101 )
102 )
103 if missing_builders:
vllm/parser/deepseek_v4.py
+7 -1
@@ -50,6 +50,12 @@ DSML_INVOKE_END = f"</{_DSML}invoke>"
50 DSML_PARAM_START = f"<{_DSML}parameter"
51 DSML_PARAM_CLOSE = f"</{_DSML}parameter>"
52
53 +# Spellings variants of ``DSML_TOOL_START`` observed in production.
54 +DSML_TOOL_START_VARIANTS: tuple[str, ...] = (
55 + f"<{_DSML}toolcalls>",
56 + f"<{_DSML}tool>",
57 +)
58 +
59 _ESCAPED_DSML = re.escape(_DSML)
60 _PARAM_RE = re.compile(
61 rf'<{_ESCAPED_DSML}parameter\s+name="([^"]+)"\s+string="(true|false)">'
@@ -131,7 +137,7 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
137 terminals={
138 "THINK_START": DSML_THINK_START,
139 "THINK_END": DSML_THINK_END,
134 - "TOOL_START": DSML_TOOL_START,
140 + "TOOL_START": (DSML_TOOL_START, *DSML_TOOL_START_VARIANTS),
141 "TOOL_END": DSML_TOOL_END,
142 "INVOKE_PREFIX": DSML_INVOKE_PREFIX,
143 "INVOKE_NAME_END": DSML_INVOKE_NAME_END,
vllm/parser/engine/incremental_lexer.py
+6 -2
@@ -5,6 +5,7 @@ tokens, with prefix-match buffering for ambiguous boundaries."""
5
6 from __future__ import annotations
7
8 +from collections.abc import Mapping
9 from dataclasses import dataclass
10
11 import regex as re
@@ -284,7 +285,9 @@ class IncrementalLexer:
285 return n
286
287
287 -def terminals_from_literals(literals: dict[str, str]) -> list[TerminalDef]:
288 +def terminals_from_literals(
289 + literals: Mapping[str, str | tuple[str, ...]],
290 +) -> list[TerminalDef]:
291 return [
292 TerminalDef(
293 name=name,
@@ -292,5 +295,6 @@ def terminals_from_literals(literals: dict[str, str]) -> list[TerminalDef]:
295 is_literal=True,
296 literal=lit,
297 )
295 - for name, lit in literals.items()
298 + for name, value in literals.items()
299 + for lit in ((value,) if isinstance(value, str) else value)
300 ]
vllm/parser/engine/parser_engine.py
+2 -2
@@ -158,11 +158,11 @@ class ParserEngine(Parser):
158
159 @property
160 def reasoning_start_str(self) -> str | None:
161 - return self.parser_engine_config.terminals.get("THINK_START")
161 + return self.parser_engine_config.terminal_literal("THINK_START")
162
163 @property
164 def reasoning_end_str(self) -> str | None:
165 - return self.parser_engine_config.terminals.get("THINK_END")
165 + return self.parser_engine_config.terminal_literal("THINK_END")
166
167 @cached_property
168 def vocab(self) -> dict[str, int]:
vllm/parser/engine/parser_engine_config.py
+19 -2
@@ -17,7 +17,7 @@ Each model format is described by a :class:`ParserEngineConfig` that specifies:
17
18 from __future__ import annotations
19
20 -from collections.abc import Callable
20 +from collections.abc import Callable, Mapping
21 from dataclasses import dataclass, field
22 from enum import Enum, auto
23 from functools import cached_property
@@ -54,7 +54,8 @@ class ParserEngineConfig:
54
55 name: str
56
57 - terminals: dict[str, str] = field(default_factory=dict)
57 + # A terminal may carry several spellings; the first spelling is the canonical one.
58 + terminals: Mapping[str, str | tuple[str, ...]] = field(default_factory=dict)
59
60 token_id_terminals: dict[str, str] = field(default_factory=dict)
61
@@ -100,6 +101,22 @@ class ParserEngineConfig:
101 # Reject tool calls whose names are absent from the request tools.
102 validate_tool_names: bool = False
103
104 + def terminal_literal(self, name: str) -> str | None:
105 + """Canonical spelling of terminal *name*, or ``None`` if undeclared."""
106 + value = self.terminals.get(name)
107 + if isinstance(value, tuple):
108 + return value[0] if value else None
109 + return value
110 +
111 + @property
112 + def terminal_literals(self) -> frozenset[str]:
113 + """Every declared spelling across all terminals."""
114 + return frozenset(
115 + lit
116 + for value in self.terminals.values()
117 + for lit in ((value,) if isinstance(value, str) else value)
118 + )
119 +
120 @cached_property
121 def terminal_defs(self):
122 from vllm.parser.engine.incremental_lexer import terminals_from_literals
vllm/parser/engine/streaming_parser_engine.py
+1 -1
@@ -51,7 +51,7 @@ def _build_drop_info(
51
52 configured_texts = (
53 set(config.token_id_terminals.values())
54 - | set(config.terminals.values())
54 + | config.terminal_literals
55 | config.preserve_tokens
56 )
57