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
506 changes: 499 additions & 7 deletions tests/parser/engine/test_deepseek_v32.py

Large diffs are not rendered by default.

807 changes: 807 additions & 0 deletions tests/parser/engine/test_deepseek_v4.py

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions vllm/parser/deepseek_v32.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
DSML_INVOKE_NAME_END,
DSML_INVOKE_PREFIX,
DSML_PARAM_CLOSE,
DSML_TOOL_END,
DSML_TOOL_START,
_dsml_arg_converter,
_unwrap_wrapper_args,
)
Expand Down Expand Up @@ -59,6 +61,8 @@ def deepseek_v32_config() -> ParserEngineConfig:
"INVOKE_NAME_END": DSML_INVOKE_NAME_END,
"INVOKE_END": DSML_INVOKE_END,
"PARAM_CLOSE": DSML_PARAM_CLOSE,
"FOREIGN_START": DSML_TOOL_START,
"FOREIGN_END": DSML_TOOL_END,
},
token_id_terminals={
"TOOL_START": DSML_FUNC_START,
Expand All @@ -69,6 +73,34 @@ def deepseek_v32_config() -> ParserEngineConfig:
ParserState.TOOL_PREAMBLE,
(),
),
# Orphan invoke: at long context the model may omit the
# <|DSML|function_calls> wrapper and emit the invoke
# directly. The invoke marker has no dedicated special
# token, so hold events and validate the parsed name
# before committing. Only names the request declared are
# accepted.
(ParserState.CONTENT, "INVOKE_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
validate_tool_name=True,
),
# V4-style tool_calls wrapper is foreign to V3.2: pass it
# and its contents through as plain content
(ParserState.CONTENT, "FOREIGN_START"): Transition(
ParserState.FOREIGN_BLOCK,
(EventType.TEXT_CHUNK,),
),
(ParserState.FOREIGN_BLOCK, "FOREIGN_END"): Transition(
ParserState.CONTENT,
(EventType.TEXT_CHUNK,),
),
# The native wrapper always wins over an unclosed foreign
# block, so a stray foreign start cannot disable tool
# parsing for the rest of the response.
(ParserState.FOREIGN_BLOCK, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(),
),
(ParserState.TOOL_PREAMBLE, "INVOKE_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
Expand Down Expand Up @@ -99,6 +131,7 @@ def deepseek_v32_config() -> ParserEngineConfig:
ParserState.CONTENT: EventType.TEXT_CHUNK,
ParserState.TOOL_NAME: EventType.TOOL_NAME,
ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
ParserState.FOREIGN_BLOCK: EventType.TEXT_CHUNK,
},
arg_converter=_dsml_arg_converter,
arg_structural_chars=frozenset(">"),
Expand Down
33 changes: 33 additions & 0 deletions vllm/parser/deepseek_v4.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
DSML_INVOKE_NAME_END = '">'
DSML_INVOKE_END = f"</{_DSML}invoke>"
DSML_PARAM_CLOSE = f"</{_DSML}parameter>"
# DeepSeek V3.2-style wrapper, recognized only to reject it as foreign
DSML_FOREIGN_TOOL_START = f"<{_DSML}function_calls>"
DSML_FOREIGN_TOOL_END = f"</{_DSML}function_calls>"

_ESCAPED_DSML = re.escape(_DSML)
_PARAM_RE = re.compile(
Expand Down Expand Up @@ -135,6 +138,8 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
"INVOKE_NAME_END": DSML_INVOKE_NAME_END,
"INVOKE_END": DSML_INVOKE_END,
"PARAM_CLOSE": DSML_PARAM_CLOSE,
"FOREIGN_START": DSML_FOREIGN_TOOL_START,
"FOREIGN_END": DSML_FOREIGN_TOOL_END,
},
token_id_terminals={
"THINK_START": DSML_THINK_START,
Expand Down Expand Up @@ -170,6 +175,33 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
ParserState.TOOL_PREAMBLE,
(),
),
# Orphan invoke: at long context the model may omit the
# <|DSML|tool_calls> wrapper and emit the invoke directly.
# The invoke marker has no dedicated special token, so hold
# events and validate the parsed name before committing.
# Only names the request declared are accepted.
(ParserState.CONTENT, "INVOKE_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
validate_tool_name=True,
),
# V3.2-style function_calls wrapper is foreign to V4: pass
# it and its contents through as plain content
(ParserState.CONTENT, "FOREIGN_START"): Transition(
ParserState.FOREIGN_BLOCK,
(EventType.TEXT_CHUNK,),
),
(ParserState.FOREIGN_BLOCK, "FOREIGN_END"): Transition(
ParserState.CONTENT,
(EventType.TEXT_CHUNK,),
),
# The native wrapper always wins over an unclosed foreign
# block, so a stray foreign start cannot disable tool
# parsing for the rest of the response.
(ParserState.FOREIGN_BLOCK, "TOOL_START"): Transition(
ParserState.TOOL_PREAMBLE,
(),
),
(ParserState.TOOL_PREAMBLE, "INVOKE_PREFIX"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
Expand Down Expand Up @@ -201,6 +233,7 @@ def deepseek_v4_config(thinking: bool = False) -> ParserEngineConfig:
ParserState.REASONING: EventType.REASONING_CHUNK,
ParserState.TOOL_NAME: EventType.TOOL_NAME,
ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
ParserState.FOREIGN_BLOCK: EventType.TEXT_CHUNK,
},
arg_converter=_dsml_arg_converter,
arg_structural_chars=frozenset(">"),
Expand Down
18 changes: 18 additions & 0 deletions vllm/parser/engine/parser_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
from vllm.tool_parsers.utils import (
coerce_to_schema_type,
collect_tool_names,
extract_types_from_schema,
find_tool_name,
find_tool_properties,
Expand Down Expand Up @@ -107,6 +108,7 @@ def __init__(
self._engine = StreamingParserEngine(
parser_engine_config, tokenizer, vocab=self.vocab
)
self._engine.allowed_tool_names = self._declared_tool_names()

self._has_reasoning = (
"THINK_END" in parser_engine_config.token_id_terminals
Expand Down Expand Up @@ -401,17 +403,33 @@ def _accept_tool_name(self, name: str) -> bool:

# ── Private helpers ─────────────────────────────────────────────

def _declared_tool_names(self) -> frozenset[str] | None:
if not self._tools:
return None
return collect_tool_names(self._tools) or None

def _check_skip_tool_parsing(
self,
request: ChatCompletionRequest | ResponsesRequest,
) -> None:
tools = getattr(request, "tools", None)
if tools:
self._tools = tools
self._engine.allowed_tool_names = self._declared_tool_names()
else:
# The engine is reused across requests and reset() keeps this
# field, so it has to be cleared here. Otherwise a request
# that declares no tools would inherit the names of the
# previous one and could recover a tool it never asked for.
self._engine.allowed_tool_names = None
if not self.skip_tool_parsing and not self._suppress_tool_calls:
tool_choice = getattr(request, "tool_choice", None)
if tool_choice == "none" and tools:
self._suppress_tool_calls = True
# The engine needs the suppression state too: recovery
# transitions must not consume text that will never be allowed
# to become a tool call.
self._engine.suppress_tool_calls = self._suppress_tool_calls

def _strip_content_whitespace(
self,
Expand Down
8 changes: 8 additions & 0 deletions vllm/parser/engine/parser_engine_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,21 @@ class ParserState(Enum):
TOOL_NAME = auto()
TOOL_ARGS = auto()
TOOL_BETWEEN = auto()
# Inside a block belonging to a different model format; terminals
# matched here pass through as plain content.
FOREIGN_BLOCK = auto()


@dataclass(frozen=True, slots=True)
class Transition:
next_state: ParserState
events: tuple[EventType, ...] = field(default_factory=tuple)
skip_in_token_id_mode: bool = False
# Hold this transition's events until the tool name completes, then
# validate the name before committing to the tool call. Set on
# recovery transitions whose trigger marker has no dedicated special
# token, so prose quoting the marker is not misparsed as a tool call.
validate_tool_name: bool = False


@dataclass(frozen=True)
Expand Down
Loading
Loading