diff --git a/renderers/__init__.py b/renderers/__init__.py
index fa6dd75e..c37d08a1 100644
--- a/renderers/__init__.py
+++ b/renderers/__init__.py
@@ -51,6 +51,7 @@
DeepSeekR1RendererConfig,
DeepSeekV3RendererConfig,
DeepSeekV4RendererConfig,
+ DeepSeekV41RendererConfig,
DefaultRendererConfig,
Gemma4RendererConfig,
GLM5RendererConfig,
@@ -99,6 +100,7 @@
"DeepSeekR1Renderer": "renderers.deepseek_r1",
"DeepSeekV3Renderer": "renderers.deepseek_v3",
"DeepSeekV4Renderer": "renderers.deepseek_v4",
+ "DeepSeekV41Renderer": "renderers.deepseek_v41",
"DefaultRenderer": "renderers.default",
"GLM45Renderer": "renderers.glm45",
"GLM51Renderer": "renderers.glm5",
@@ -160,6 +162,8 @@ def __dir__() -> list[str]:
"DeepSeekV3RendererConfig",
"DeepSeekV4Renderer",
"DeepSeekV4RendererConfig",
+ "DeepSeekV41Renderer",
+ "DeepSeekV41RendererConfig",
"DefaultRenderer",
"DefaultRendererConfig",
"GLM45Renderer",
diff --git a/renderers/base.py b/renderers/base.py
index f3418841..f86ea1d2 100644
--- a/renderers/base.py
+++ b/renderers/base.py
@@ -1355,6 +1355,7 @@ def _populate_registry():
from renderers.deepseek_r1 import DeepSeekR1Renderer
from renderers.deepseek_v3 import DeepSeekV3Renderer
from renderers.deepseek_v4 import DeepSeekV4Renderer
+ from renderers.deepseek_v41 import DeepSeekV41Renderer
from renderers.default import DefaultRenderer
from renderers.gemma4 import Gemma4Renderer
from renderers.glm5 import (
@@ -1409,6 +1410,7 @@ def _populate_registry():
"deepseek-v3": DeepSeekV3Renderer,
"deepseek-r1": DeepSeekR1Renderer,
"deepseek-v4": DeepSeekV4Renderer,
+ "deepseek-v4.1": DeepSeekV41Renderer,
"hy3": Hy3Renderer,
"inkling": InklingRenderer,
"kimi-k2": KimiK2Renderer,
diff --git a/renderers/configs.py b/renderers/configs.py
index d6013132..9a444d15 100644
--- a/renderers/configs.py
+++ b/renderers/configs.py
@@ -1028,6 +1028,34 @@ def _check_thinking_retention(self) -> Self:
return self
+class DeepSeekV41RendererConfig(DeepSeekV4RendererConfig):
+ """DeepSeek-V4.1-Flash reference-encoder configuration.
+
+ V4.1's encoder is V4's with a numeric reasoning-effort budget, a
+ ``<|System|>`` token in front of the conversation's opening effort prefix
+ or system message, and thinking mode on by default.
+ """
+
+ name: Literal["deepseek-v4.1"] = "deepseek-v4.1"
+
+ enable_thinking: bool = True
+ """Select thinking mode, the V4.1 encoder's default."""
+
+ reasoning_effort: Literal["low", "high", "max"] | int = "high"
+ """Thinking-only budget: an int in [1, 100], or ``low`` / ``high`` / ``max`` (50 / 75 / 100)."""
+
+ @model_validator(mode="after")
+ def _check_reasoning_effort(self) -> Self:
+ if (
+ isinstance(self.reasoning_effort, int)
+ and not 1 <= self.reasoning_effort <= 100
+ ):
+ raise ValueError(
+ f"reasoning_effort must be in [1, 100], got {self.reasoning_effort}"
+ )
+ return self
+
+
class CustomRendererConfig(BaseRendererConfig):
"""Config for a renderer that lives outside this package.
@@ -1096,6 +1124,7 @@ def _validate_renderer_fields(self) -> Self:
DeepSeekV3RendererConfig,
DeepSeekR1RendererConfig,
DeepSeekV4RendererConfig,
+ DeepSeekV41RendererConfig,
CustomRendererConfig,
],
Field(discriminator="name"),
@@ -1147,6 +1176,7 @@ def _validate_renderer_fields(self) -> Self:
"deepseek-v3": DeepSeekV3RendererConfig,
"deepseek-r1": DeepSeekR1RendererConfig,
"deepseek-v4": DeepSeekV4RendererConfig,
+ "deepseek-v4.1": DeepSeekV41RendererConfig,
}
@@ -1180,6 +1210,7 @@ def config_from_name(name: str) -> BaseRendererConfig | None:
"DeepSeekR1RendererConfig",
"DeepSeekV3RendererConfig",
"DeepSeekV4RendererConfig",
+ "DeepSeekV41RendererConfig",
"GLM45RendererConfig",
"GLM51RendererConfig",
"GLM53FlashRendererConfig",
diff --git a/renderers/deepseek_v4.py b/renderers/deepseek_v4.py
index e3ba16ca..749ea84d 100644
--- a/renderers/deepseek_v4.py
+++ b/renderers/deepseek_v4.py
@@ -40,6 +40,7 @@
from renderers.configs import DeepSeekV4RendererConfig
from renderers.parsing import parse_deepseek_v4
from renderers.reasoning import prompt_ends_in_reasoning
+from renderers.tagged import DEEPSEEK_V4_GRAMMAR
_BOS = "<|begin▁of▁sentence|>"
_EOS = "<|end▁of▁sentence|>"
@@ -87,17 +88,17 @@
_TOOLS_TEMPLATE = """## Tools
-You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml}tool_calls>" block like the following:
+You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{calls}>" block like the following:
-<{dsml}tool_calls>
-<{dsml}invoke name="$TOOL_NAME">
-<{dsml}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE{dsml}parameter>
+<{calls}>
+<{invoke} name="$TOOL_NAME">
+<{parameter} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE{parameter}>
...
-{dsml}invoke>
-<{dsml}invoke name="$TOOL_NAME2">
+{invoke}>
+<{invoke} name="$TOOL_NAME2">
...
-{dsml}invoke>
-{dsml}tool_calls>
+{invoke}>
+{calls}>
String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
@@ -300,6 +301,12 @@ def is_prefix_stable(self) -> bool:
_implied_thinking_retention = "tool_cycle"
+ # Full DSML tag names, as in ``<{_invoke_tag} name="...">``.
+ _calls_tag = f"{_DSML}tool_calls"
+ _invoke_tag = f"{_DSML}invoke"
+ _parameter_tag = f"{_DSML}parameter"
+ _grammar = DEEPSEEK_V4_GRAMMAR
+
def __init__(
self,
tokenizer: Tokenizer,
@@ -335,46 +342,69 @@ def _special_id(self, token: str) -> int:
raise ValueError(f"Expected one token for {token!r}, got {ids}")
return ids[0]
- def _reasoning_effort_prompt(self) -> str:
- """Text emitted after BOS in thinking mode; subclasses can replace it."""
+ def _preamble(self, *, opens_with_system: bool) -> str: # noqa: ARG002
+ """Text emitted after BOS, before the first message.
+
+ ``opens_with_system`` tells whether the rendered conversation starts
+ with a system message (including the synthetic one carrying tools).
+ """
+ if not self.config.enable_thinking:
+ return ""
return _REASONING_EFFORT_PROMPTS[self.config.reasoning_effort]
+ def _is_turn_start(self, message: _LogicalMessage) -> bool:
+ """Whether ``message`` opens a turn, bounding dropped historical reasoning."""
+ return message.role in _QUERY_ROLES
+
+ def _tool_target(self, messages: list[_LogicalMessage]) -> int | None:
+ """Index of the message carrying the tools block; ``None`` adds a leading one."""
+ for role in ("developer", "system"):
+ for index, message in enumerate(messages):
+ if message.role == role:
+ return index
+ return None
+
@staticmethod
- def _render_tools(tools: list[ToolSpec]) -> str:
+ def _tool_call_arguments(raw_arguments: Any) -> Any:
+ if isinstance(raw_arguments, str):
+ try:
+ return json.loads(raw_arguments)
+ except (json.JSONDecodeError, TypeError):
+ return {"arguments": raw_arguments}
+ if isinstance(raw_arguments, Mapping):
+ return dict(raw_arguments)
+ return {"arguments": raw_arguments}
+
+ def _render_tools(self, tools: list[ToolSpec]) -> str:
schemas = [_json(dict(_tool_function(tool))) for tool in tools]
return _TOOLS_TEMPLATE.format(
- dsml=_DSML,
+ calls=self._calls_tag,
+ invoke=self._invoke_tag,
+ parameter=self._parameter_tag,
think_start=_THINK_START,
think_end=_THINK_END,
tool_schemas="\n".join(schemas),
)
- @staticmethod
- def _render_tool_call(tool_call: Mapping[str, Any]) -> str:
+ def _render_tool_call(self, tool_call: Mapping[str, Any]) -> str:
function = _tool_call_function(tool_call)
name = function.get("name")
- raw_arguments = function.get("arguments", {})
- if isinstance(raw_arguments, str):
- try:
- arguments = json.loads(raw_arguments)
- except (json.JSONDecodeError, TypeError):
- arguments = {"arguments": raw_arguments}
- elif isinstance(raw_arguments, Mapping):
- arguments = dict(raw_arguments)
- else:
- arguments = {"arguments": raw_arguments}
+ arguments = self._tool_call_arguments(function.get("arguments", {}))
params: list[str] = []
for key, value in arguments.items():
is_string = isinstance(value, str)
rendered_value = value if is_string else _json(value)
params.append(
- f'<{_DSML}parameter name="{key}" '
+ f'<{self._parameter_tag} name="{key}" '
f'string="{str(is_string).lower()}">{rendered_value}'
- f"{_DSML}parameter>"
+ f"{self._parameter_tag}>"
)
arguments_text = "\n".join(params)
- return f'<{_DSML}invoke name="{name}">\n{arguments_text}\n{_DSML}invoke>'
+ return (
+ f'<{self._invoke_tag} name="{name}">\n{arguments_text}\n'
+ f"{self._invoke_tag}>"
+ )
def render(
self,
@@ -388,7 +418,7 @@ def render(
tools=tools,
add_generation_prompt=add_generation_prompt,
add_bos=True,
- add_effort_prompt=True,
+ add_preamble=True,
)
def _render(
@@ -398,7 +428,7 @@ def _render(
tools: list[ToolSpec] | None,
add_generation_prompt: bool,
add_bos: bool,
- add_effort_prompt: bool,
+ add_preamble: bool,
) -> RenderedTokens:
if not messages:
raise ValueError("No messages provided.")
@@ -413,7 +443,7 @@ def _render(
(
index
for index, message in enumerate(logical_messages)
- if message.role in _QUERY_ROLES
+ if self._is_turn_start(message)
),
default=-1,
)
@@ -578,39 +608,27 @@ def emit_text(
if text:
pending_text.append((text, message_index, sampled, content))
- if add_bos:
- emit_special(self._bos, -1)
- if add_effort_prompt and self.config.enable_thinking:
- emit_text(self._reasoning_effort_prompt(), -1)
-
last_query_index = -1
for index, message in enumerate(logical_messages):
- if message.role in _QUERY_ROLES:
+ if self._is_turn_start(message):
last_query_index = index
- tool_target: int | None = None
- if tools:
- tool_target = next(
- (
- index
- for index, message in enumerate(logical_messages)
- if message.role == "developer"
+ tool_target = self._tool_target(logical_messages) if tools else None
+
+ if add_bos:
+ emit_special(self._bos, -1)
+ if add_preamble:
+ emit_text(
+ self._preamble(
+ opens_with_system=logical_messages[0].role == "system"
+ or bool(tools and tool_target is None)
),
- None,
+ -1,
)
- if tool_target is None:
- tool_target = next(
- (
- index
- for index, message in enumerate(logical_messages)
- if message.role == "system"
- ),
- None,
- )
- if tool_target is None:
- # Equivalent to an empty synthetic system message carrying
- # tools in the reference encoder.
- emit_text("\n\n" + self._render_tools(tools), -1)
+ if tools and tool_target is None:
+ # Equivalent to an empty synthetic system message carrying
+ # tools in the reference encoder.
+ emit_text("\n\n" + self._render_tools(tools), -1)
for index, message in enumerate(logical_messages):
role = message.role
@@ -683,8 +701,8 @@ def emit_text(
self._render_tool_call(call) for call in message.tool_calls
)
emit_text(
- f"\n\n<{_DSML}tool_calls>\n{rendered_calls}\n"
- f"{_DSML}tool_calls>",
+ f"\n\n<{self._calls_tag}>\n{rendered_calls}\n"
+ f"{self._calls_tag}>",
msg_idx,
sampled=True,
content=True,
@@ -808,6 +826,9 @@ def parse_response(
),
think_end_id=self._think_end,
think_start_id=self._think_start,
+ grammar=self._grammar,
+ invoke_tag=self._invoke_tag,
+ parameter_tag=self._parameter_tag,
)
def get_stop_token_ids(self) -> list[int]:
@@ -863,7 +884,7 @@ def bridge_to_next_turn(
tools=None,
add_generation_prompt=True,
add_bos=False,
- add_effort_prompt=False,
+ add_preamble=False,
)
except ValueError:
return None
diff --git a/renderers/deepseek_v41.py b/renderers/deepseek_v41.py
new file mode 100644
index 00000000..c758fc47
--- /dev/null
+++ b/renderers/deepseek_v41.py
@@ -0,0 +1,141 @@
+"""DeepSeek-V4.1-Flash renderer.
+
+The checkpoint's source of truth is ``encoding/encoding.py`` in the model
+repository. It is V4's encoder with these changes, applied here on top of
+:class:`DeepSeekV4Renderer`:
+
+* a ``<|System|>`` token leads the conversation whenever it opens with a
+ reasoning-effort prefix or a system message (including the empty one that
+ carries tools when the conversation has no leading system message);
+* the reasoning effort is a numeric budget,
+ ``Reasoning Effort: {budget} (range 1-100, ...)``, rendered in thinking mode
+ only and defaulting to ``high`` (75);
+* thinking mode is the default;
+* DSML tags have space-prefixed names: ``<|DSML| calls>``,
+ ``<|DSML| invoke>`` and ``<|DSML| parameter>``;
+* tools attach only to a leading system message;
+* a user turn holding only tool results does not start a new turn, so
+ dropped historical reasoning stops at the last real user message;
+* JSON-string tool arguments may be double-encoded, and arguments that do not
+ decode to an object render as a single ``arguments`` parameter.
+
+Mid-conversation system messages are not supported yet.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Mapping
+
+from renderers.base import Message, RenderedTokens, ToolSpec
+from renderers.configs import DeepSeekV41RendererConfig
+from renderers.deepseek_v4 import _DSML, DeepSeekV4Renderer, _LogicalMessage
+from renderers.tagged import DEEPSEEK_V41_GRAMMAR
+
+_SYSTEM = "<|System|>"
+_THINK_START = ""
+_THINK_END = ""
+_EFFORT_BUDGETS = {"low": 50, "high": 75, "max": 100}
+
+
+def _split_inline_reasoning(message: Message) -> Message:
+ """Move a leading ``...`` out of an assistant's content into ``reasoning_content``.
+
+ Many SFT datasets keep the reasoning inline in the content; rendered as is,
+ it would follow an empty think block instead of filling it.
+ """
+ content = message.get("content")
+ if (
+ message["role"] != "assistant"
+ or message.get("reasoning_content") is not None
+ or not isinstance(content, str)
+ ):
+ return message
+ if not content.startswith(_THINK_START) or _THINK_END not in content:
+ return message
+ reasoning, content = content.removeprefix(_THINK_START).split(_THINK_END, 1)
+ split = message.copy()
+ split["reasoning_content"] = reasoning
+ split["content"] = content
+ return split
+
+
+class DeepSeekV41Renderer(DeepSeekV4Renderer):
+ """Renderer for ``deepseek-ai/DeepSeek-V4.1-Flash``."""
+
+ _calls_tag = f"{_DSML} calls"
+ _invoke_tag = f"{_DSML} invoke"
+ _parameter_tag = f"{_DSML} parameter"
+ _grammar = DEEPSEEK_V41_GRAMMAR
+
+ def __init__(self, tokenizer, config: DeepSeekV41RendererConfig | None = None):
+ super().__init__(tokenizer, config or DeepSeekV41RendererConfig())
+
+ def _preamble(self, *, opens_with_system: bool) -> str:
+ if not self.config.enable_thinking:
+ return _SYSTEM if opens_with_system else ""
+ effort = self.config.reasoning_effort
+ budget = _EFFORT_BUDGETS[effort] if isinstance(effort, str) else effort
+ return (
+ f"{_SYSTEM}Reasoning Effort: {budget} (range 1-100, "
+ "the higher the value, the more thorough the reasoning)\n\n"
+ )
+
+ def _is_turn_start(self, message: _LogicalMessage) -> bool:
+ if message.role == "user":
+ return any(block.kind != "tool_result" for block in message.blocks)
+ return super()._is_turn_start(message)
+
+ def _tool_target(self, messages: list[_LogicalMessage]) -> int | None:
+ return 0 if messages[0].role == "system" else None
+
+ @staticmethod
+ def _tool_call_arguments(raw_arguments: Any) -> Any:
+ arguments = raw_arguments
+ # A JSON string, possibly double-encoded.
+ for _ in range(2):
+ if not isinstance(arguments, str):
+ break
+ try:
+ arguments = json.loads(arguments)
+ except json.JSONDecodeError:
+ break
+ if isinstance(arguments, Mapping):
+ return dict(arguments)
+ return {"arguments": raw_arguments}
+
+ def render(
+ self,
+ messages: list[Message],
+ *,
+ tools: list[ToolSpec] | None = None,
+ add_generation_prompt: bool = False,
+ ) -> RenderedTokens:
+ if any(message["role"] == "system" for message in messages[1:]):
+ raise NotImplementedError(
+ "the DeepSeek-V4.1 renderer does not render mid-conversation system messages"
+ )
+ messages = [_split_inline_reasoning(message) for message in messages]
+ return super().render(
+ messages, tools=tools, add_generation_prompt=add_generation_prompt
+ )
+
+ def bridge_to_next_turn(
+ self,
+ previous_prompt_ids: list[int],
+ previous_completion_ids: list[int],
+ new_messages: list[Message],
+ *,
+ tools: list[ToolSpec] | None = None,
+ ) -> RenderedTokens | None:
+ if any(message["role"] == "system" for message in new_messages):
+ return None
+ return super().bridge_to_next_turn(
+ previous_prompt_ids,
+ previous_completion_ids,
+ new_messages,
+ tools=tools,
+ )
+
+
+__all__ = ["DeepSeekV41Renderer"]
diff --git a/renderers/parsing.py b/renderers/parsing.py
index 352ae817..86feefca 100644
--- a/renderers/parsing.py
+++ b/renderers/parsing.py
@@ -29,6 +29,7 @@
from renderers.tagged import (
DEEPSEEK_V4_GRAMMAR,
GLM_GRAMMAR,
+ TaggedGrammar,
TaggedToolBlock,
scan_tagged_response,
)
@@ -1204,6 +1205,9 @@ def parse_deepseek_v4(
thinking_enabled: bool,
think_end_id: int,
think_start_id: int,
+ grammar: TaggedGrammar = DEEPSEEK_V4_GRAMMAR,
+ invoke_tag: str = "|DSML|invoke",
+ parameter_tag: str = "|DSML|parameter",
) -> ParsedResponse:
"""Parse a DeepSeek V4 completion.
@@ -1213,6 +1217,8 @@ def parse_deepseek_v4(
decoded grammar carries an explicit ``string=`` flag for lossless argument
type recovery. ``thinking_enabled`` describes the prompt's initial channel;
reasoning opened in the completion is recognized independently.
+ DeepSeek-V4.1 spells its DSML tags differently and passes its own
+ ``grammar`` and tag names.
"""
ids = _strip_stop_tokens(token_ids, stop_ids)
parsed = scan_tagged_response(
@@ -1220,7 +1226,7 @@ def parse_deepseek_v4(
ids,
thinking=thinking_enabled,
atomic_markers={think_start_id: "think_start", think_end_id: "think_end"},
- grammar=DEEPSEEK_V4_GRAMMAR,
+ grammar=grammar,
)
reasoning = parsed.boundary.text
return ParsedResponse(
@@ -1230,19 +1236,24 @@ def parse_deepseek_v4(
if parsed.boundary.is_open
else (reasoning or "").rstrip() or None
),
- tool_calls=[_parse_deepseek_v4_tool_call(block) for block in parsed.tools],
+ tool_calls=[
+ _parse_deepseek_v4_tool_call(block, invoke_tag, parameter_tag)
+ for block in parsed.tools
+ ],
reasoning_complete=not parsed.boundary.is_open,
)
-def _parse_deepseek_v4_tool_call(region: TaggedToolBlock) -> ParsedToolCall:
+def _parse_deepseek_v4_tool_call(
+ region: TaggedToolBlock, invoke_tag: str, parameter_tag: str
+) -> ParsedToolCall:
"""Convert one DSML attempt; the shared grammar owns its boundaries."""
import re
- invoke_end = "|DSML|invoke>"
+ invoke_end = f"{invoke_tag}>"
+ parameter = re.escape(parameter_tag)
parameter_pattern = re.compile(
- r'<|DSML|parameter name="(.*?)" string="(true|false)">'
- r"(.*?)|DSML|parameter>",
+ rf'<{parameter} name="(.*?)" string="(true|false)">(.*?){parameter}>',
re.DOTALL,
)
raw = region.text
@@ -1255,7 +1266,7 @@ def _parse_deepseek_v4_tool_call(region: TaggedToolBlock) -> ParsedToolCall:
else:
header = raw[: header_end + 2]
name_match = re.fullmatch(
- r'<|DSML|invoke name="(.*?)">\n',
+ rf'<{re.escape(invoke_tag)} name="(.*?)">\n',
header,
flags=re.DOTALL,
)
diff --git a/renderers/tagged.py b/renderers/tagged.py
index ccb4b7f8..8c850d5a 100644
--- a/renderers/tagged.py
+++ b/renderers/tagged.py
@@ -190,3 +190,15 @@ def scan_tagged_response(
"|DSML|invoke>": "tool_end",
},
)
+
+# vLLM's ``deepseek_v41`` parser: V4's rules with space-prefixed tag names and
+# no misspelled section openers.
+DEEPSEEK_V41_GRAMMAR = TaggedGrammar(
+ DEEPSEEK_V4_GRAMMAR.transitions,
+ {
+ "<|DSML| calls>": "section_start",
+ "|DSML| calls>": "section_end",
+ "<|DSML| invoke": "tool_start",
+ "|DSML| invoke>": "tool_end",
+ },
+)
diff --git a/tests/test_deepseek_v41.py b/tests/test_deepseek_v41.py
new file mode 100644
index 00000000..7d77eb58
--- /dev/null
+++ b/tests/test_deepseek_v41.py
@@ -0,0 +1,392 @@
+"""DeepSeek-V4.1-Flash parity against the checkpoint's shipped Python encoder."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+from functools import lru_cache
+
+import pytest
+from huggingface_hub import hf_hub_download
+from pydantic import TypeAdapter, ValidationError
+from renderers import (
+ DeepSeekV41Renderer,
+ DeepSeekV41RendererConfig,
+ RendererConfig,
+ ToolCallParseStatus,
+ create_renderer,
+)
+from renderers.base import load_tokenizer
+from renderers.configs import config_from_name
+
+MODEL = "deepseek-ai/DeepSeek-V4.1-Flash"
+
+CONVERSATIONS = {
+ "user": [{"role": "user", "content": "What is 2+2?"}],
+ "system_user": [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "user", "content": "What is 2+2?"},
+ ],
+ "multi_turn": [
+ {"role": "user", "content": "Hi"},
+ {"role": "assistant", "content": "Hello!", "reasoning_content": "greet"},
+ {"role": "user", "content": "2+2?"},
+ ],
+ "system_multi_turn_ending_in_assistant": [
+ {"role": "system", "content": "Be terse."},
+ {"role": "user", "content": "Hi"},
+ {"role": "assistant", "content": "Hello!", "reasoning_content": "greet"},
+ {"role": "user", "content": "2+2?"},
+ {"role": "assistant", "content": "4", "reasoning_content": "add"},
+ ],
+}
+
+
+@lru_cache(maxsize=1)
+def _tokenizer():
+ return load_tokenizer(MODEL)
+
+
+@lru_cache(maxsize=1)
+def _reference_encoder():
+ path = hf_hub_download(MODEL, "encoding/encoding.py")
+ spec = importlib.util.spec_from_file_location("deepseek_v41_encoding", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _decode(renderer, messages, **kwargs):
+ return _tokenizer().decode(
+ renderer.render_ids(messages, **kwargs), skip_special_tokens=False
+ )
+
+
+def test_registration_and_native_defaults():
+ # Selected by name only: the shared parity suites need tool support for auto-mapping.
+ renderer = create_renderer(_tokenizer(), config_from_name("deepseek-v4.1"))
+
+ assert isinstance(renderer, DeepSeekV41Renderer)
+ assert renderer.config.enable_thinking is True
+ assert renderer.config.reasoning_effort == "high"
+
+
+def test_config_discriminator_and_effort_validation():
+ parsed = TypeAdapter(RendererConfig).validate_python(
+ {"name": "deepseek-v4.1", "reasoning_effort": 30}
+ )
+ assert isinstance(parsed, DeepSeekV41RendererConfig)
+ assert parsed.reasoning_effort == 30
+
+ with pytest.raises(ValidationError):
+ DeepSeekV41RendererConfig(reasoning_effort=101)
+
+
+@pytest.mark.parametrize("name", CONVERSATIONS)
+@pytest.mark.parametrize("enable_thinking", [True, False])
+@pytest.mark.parametrize("reasoning_effort", ["low", "high", "max", 30])
+def test_matches_reference_encoder(name, enable_thinking, reasoning_effort):
+ messages = CONVERSATIONS[name]
+ add_generation_prompt = messages[-1]["role"] != "assistant"
+ renderer = DeepSeekV41Renderer(
+ _tokenizer(),
+ DeepSeekV41RendererConfig(
+ enable_thinking=enable_thinking, reasoning_effort=reasoning_effort
+ ),
+ )
+
+ expected = _reference_encoder().encode_messages(
+ [dict(message) for message in messages],
+ thinking_mode="thinking" if enable_thinking else "chat",
+ reasoning_effort=reasoning_effort,
+ )
+
+ assert (
+ _decode(renderer, messages, add_generation_prompt=add_generation_prompt)
+ == expected
+ )
+
+
+def test_inline_reasoning_moves_into_think_block():
+ renderer = DeepSeekV41Renderer(_tokenizer())
+ inline = [
+ {"role": "user", "content": "2+2?"},
+ {"role": "assistant", "content": "add4"},
+ ]
+ split = [
+ {"role": "user", "content": "2+2?"},
+ {"role": "assistant", "content": "4", "reasoning_content": "add"},
+ ]
+
+ assert renderer.render_ids(inline) == renderer.render_ids(split)
+
+
+def test_rejects_mid_conversation_system():
+ renderer = DeepSeekV41Renderer(_tokenizer())
+ user = [{"role": "user", "content": "Hi"}]
+
+ with pytest.raises(NotImplementedError):
+ renderer.render([*user, {"role": "system", "content": "Be terse."}])
+
+
+TOOLS = [
+ {
+ "type": "function",
+ "function": {
+ "name": "weather",
+ "description": "Get weather",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {"type": "string"},
+ "days": {"type": "integer"},
+ },
+ "required": ["city"],
+ },
+ },
+ },
+ {
+ "type": "function",
+ "function": {
+ "name": "search",
+ "description": "Search the web",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"},
+ "filters": {"type": "object"},
+ },
+ "required": ["query"],
+ },
+ },
+ },
+]
+
+
+def _call(id, name, arguments):
+ return {
+ "id": id,
+ "type": "function",
+ "function": {"name": name, "arguments": arguments},
+ }
+
+
+AGENT_TRAJECTORY = [
+ {"role": "system", "content": "You are a travel agent."},
+ {"role": "user", "content": "Weather in Paris and Rome?"},
+ {
+ "role": "assistant",
+ "reasoning_content": "Call weather for both cities.",
+ "content": "",
+ "tool_calls": [
+ _call("a", "weather", {"city": "Paris"}),
+ _call("b", "weather", {"city": "Rome", "days": 2}),
+ ],
+ },
+ {"role": "tool", "tool_call_id": "b", "content": "Rome: sunny"},
+ {"role": "tool", "tool_call_id": "a", "content": "Paris: rain"},
+ {
+ "role": "assistant",
+ "reasoning_content": "Look for indoor activities in Paris.",
+ "content": "Let me search.",
+ "tool_calls": [
+ _call(
+ "c",
+ "search",
+ {"query": "Paris museums", "filters": {"open": True, "max": 3}},
+ )
+ ],
+ },
+ {"role": "tool", "tool_call_id": "c", "content": "Louvre, Orsay"},
+ {
+ "role": "assistant",
+ "reasoning_content": "Summarize.",
+ "content": "Rome is sunny; Paris is rainy, try the Louvre.",
+ },
+ {"role": "user", "content": "And Berlin?"},
+ {
+ "role": "assistant",
+ "reasoning_content": "One more call.",
+ "content": "",
+ "tool_calls": [_call("d", "weather", {"city": "Berlin"})],
+ },
+ {"role": "tool", "tool_call_id": "d", "content": "Berlin: snow"},
+]
+
+TOOL_CONVERSATIONS = {
+ "tool_call_without_system": [
+ {"role": "user", "content": "Weather in Oslo?"},
+ {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [_call("x", "weather", {"city": "Oslo"})],
+ },
+ {"role": "tool", "tool_call_id": "x", "content": '{"temp": -3}'},
+ ],
+ "tool_result_then_user_text": [
+ {"role": "user", "content": "Weather in Oslo?"},
+ {
+ "role": "assistant",
+ "reasoning_content": "check",
+ "tool_calls": [_call("x", "weather", {"city": "Oslo"})],
+ },
+ {"role": "tool", "tool_call_id": "x", "content": "cold"},
+ {"role": "user", "content": "Also, be brief."},
+ ],
+ "argument_encodings": [
+ {"role": "system", "content": "Call tools."},
+ {"role": "user", "content": "Go"},
+ {
+ "role": "assistant",
+ "reasoning_content": "Many calls.",
+ "tool_calls": [
+ _call("1", "weather", '{"city": "Oslo", "days": 3}'),
+ _call("2", "weather", json.dumps('{"city": "Lima"}')),
+ _call("3", "search", "[1, 2]"),
+ _call("4", "search", "not json"),
+ _call("5", "search", {"query": "", "filters": None}),
+ ],
+ },
+ ],
+ "agent_trajectory": AGENT_TRAJECTORY,
+ "agent_trajectory_answered": AGENT_TRAJECTORY
+ + [
+ {
+ "role": "assistant",
+ "reasoning_content": "Report.",
+ "content": "Berlin has snow.",
+ }
+ ],
+}
+
+
+def _reference_messages(messages, tools):
+ messages = [dict(message) for message in messages]
+ if tools:
+ if messages[0]["role"] != "system":
+ messages.insert(0, {"role": "system", "content": ""})
+ messages[0]["tools"] = tools
+ return messages
+
+
+@pytest.mark.parametrize("name", TOOL_CONVERSATIONS)
+@pytest.mark.parametrize("with_tools", [True, False])
+@pytest.mark.parametrize("enable_thinking", [True, False])
+@pytest.mark.parametrize("drop_thinking", [True, False])
+def test_tool_conversations_match_reference_encoder(
+ name, with_tools, enable_thinking, drop_thinking
+):
+ messages = TOOL_CONVERSATIONS[name]
+ tools = TOOLS if with_tools else None
+ add_generation_prompt = messages[-1]["role"] != "assistant"
+ renderer = DeepSeekV41Renderer(
+ _tokenizer(),
+ DeepSeekV41RendererConfig(
+ enable_thinking=enable_thinking, drop_thinking=drop_thinking
+ ),
+ )
+
+ expected = _reference_encoder().encode_messages(
+ _reference_messages(messages, tools),
+ thinking_mode="thinking" if enable_thinking else "chat",
+ drop_thinking=drop_thinking,
+ )
+
+ assert (
+ _decode(
+ renderer,
+ messages,
+ tools=tools,
+ add_generation_prompt=add_generation_prompt,
+ )
+ == expected
+ )
+
+
+@pytest.mark.parametrize("enable_thinking", [True, False])
+def test_agent_trajectory_extends_by_prefix_and_bridge(enable_thinking):
+ renderer = DeepSeekV41Renderer(
+ _tokenizer(), DeepSeekV41RendererConfig(enable_thinking=enable_thinking)
+ )
+ messages = TOOL_CONVERSATIONS["agent_trajectory_answered"]
+ assistant_indices = [
+ index
+ for index, message in enumerate(messages)
+ if message["role"] == "assistant"
+ ]
+
+ for index, next_index in zip(assistant_indices, assistant_indices[1:]):
+ prompt = renderer.render_ids(
+ messages[:index], tools=TOOLS, add_generation_prompt=True
+ )
+ completed = renderer.render_ids(messages[: index + 1], tools=TOOLS)
+ assert completed[: len(prompt)] == prompt
+
+ new_messages = messages[index + 1 : next_index]
+ bridged = renderer.bridge_to_next_turn(
+ prompt, completed[len(prompt) :], new_messages, tools=TOOLS
+ )
+ # Parallel results need the issuing call order; a new user query
+ # drops tool-cycle reasoning. Both re-render instead.
+ declines = sum(m["role"] == "tool" for m in new_messages) > 1 or (
+ enable_thinking and any(m["role"] == "user" for m in new_messages)
+ )
+ if declines:
+ assert bridged is None
+ else:
+ assert bridged is not None
+ assert bridged.token_ids == renderer.render_ids(
+ messages[:next_index], tools=TOOLS, add_generation_prompt=True
+ )
+
+
+@pytest.mark.parametrize("enable_thinking", [True, False])
+def test_tool_calls_roundtrip_through_parse_response(enable_thinking):
+ renderer = DeepSeekV41Renderer(
+ _tokenizer(), DeepSeekV41RendererConfig(enable_thinking=enable_thinking)
+ )
+ messages = AGENT_TRAJECTORY[:3]
+ prompt = renderer.render_ids(messages[:2], tools=TOOLS, add_generation_prompt=True)
+ completed = renderer.render_ids(messages, tools=TOOLS)
+ completion = completed[len(prompt) :]
+
+ parsed = renderer.parse_response(completion, prompt_ids=prompt)
+
+ expected_reasoning = messages[2]["reasoning_content"] if enable_thinking else None
+ assert parsed.reasoning_content == expected_reasoning
+ assert parsed.content == ""
+ assert [(call.name, call.arguments, call.status) for call in parsed.tool_calls] == [
+ ("weather", {"city": "Paris"}, ToolCallParseStatus.OK),
+ ("weather", {"city": "Rome", "days": 2}, ToolCallParseStatus.OK),
+ ]
+ for call in parsed.tool_calls:
+ start, end = call.token_span
+ assert (
+ _tokenizer()
+ .decode(completion[start:end], skip_special_tokens=False)
+ .rstrip("\n")
+ == call.raw
+ )
+
+
+def test_parses_vllm_tool_block_without_leading_newlines():
+ text = (
+ "<|DSML| calls>\n"
+ '<|DSML| invoke name="search">\n'
+ '<|DSML| parameter name="query" string="true">a "b"|DSML| parameter>\n'
+ '<|DSML| parameter name="filters" string="false">{"k": [1]}|DSML| parameter>\n'
+ "|DSML| invoke>\n"
+ "|DSML| calls><|end▁of▁sentence|>"
+ )
+ renderer = DeepSeekV41Renderer(
+ _tokenizer(), DeepSeekV41RendererConfig(enable_thinking=False)
+ )
+
+ parsed = renderer.parse_response(
+ _tokenizer().encode(text, add_special_tokens=False)
+ )
+
+ assert parsed.content == ""
+ assert [(call.name, call.arguments, call.status) for call in parsed.tool_calls] == [
+ ("search", {"query": 'a "b"', "filters": {"k": [1]}}, ToolCallParseStatus.OK)
+ ]