From c949a28cd90f5d60c2cee1ecd607ca8d459d1045 Mon Sep 17 00:00:00 2001 From: sami jaghouar Date: Thu, 15 Oct 2026 01:42:40 +0000 Subject: [PATCH 1/3] feat: add DeepSeek-V4.1-Flash renderer V4's encoder with V4.1's System token, numeric reasoning-effort budget and thinking-by-default, for plain chats (no tools, no mid-conversation system messages). Selected by name; tested against the checkpoint's shipped encoding/encoding.py. Co-Authored-By: Claude Opus 5.5 --- renderers/__init__.py | 4 ++ renderers/base.py | 2 + renderers/configs.py | 31 +++++++++ renderers/deepseek_v41.py | 106 ++++++++++++++++++++++++++++++ tests/test_deepseek_v41.py | 130 +++++++++++++++++++++++++++++++++++++ 5 files changed, 273 insertions(+) create mode 100644 renderers/deepseek_v41.py create mode 100644 tests/test_deepseek_v41.py diff --git a/renderers/__init__.py b/renderers/__init__.py index ad3e6e609..6d0e634ff 100644 --- a/renderers/__init__.py +++ b/renderers/__init__.py @@ -50,6 +50,7 @@ DeepSeekR1RendererConfig, DeepSeekV3RendererConfig, DeepSeekV4RendererConfig, + DeepSeekV41RendererConfig, DefaultRendererConfig, Gemma4RendererConfig, GLM5RendererConfig, @@ -93,6 +94,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", @@ -151,6 +153,8 @@ def __dir__() -> list[str]: "DeepSeekV3RendererConfig", "DeepSeekV4Renderer", "DeepSeekV4RendererConfig", + "DeepSeekV41Renderer", + "DeepSeekV41RendererConfig", "DefaultRenderer", "DefaultRendererConfig", "GLM45Renderer", diff --git a/renderers/base.py b/renderers/base.py index ef168924b..96aa7023e 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -1353,6 +1353,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 ( @@ -1406,6 +1407,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 74ca511ac..43603d215 100644 --- a/renderers/configs.py +++ b/renderers/configs.py @@ -1000,6 +1000,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" # type: ignore[assignment] + + enable_thinking: bool = True + """Select thinking mode, the V4.1 encoder's default.""" + + reasoning_effort: Literal["low", "high", "max"] | int = "high" # type: ignore[assignment] + """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. @@ -1067,6 +1095,7 @@ def _validate_renderer_fields(self) -> Self: DeepSeekV3RendererConfig, DeepSeekR1RendererConfig, DeepSeekV4RendererConfig, + DeepSeekV41RendererConfig, CustomRendererConfig, ], Field(discriminator="name"), @@ -1117,6 +1146,7 @@ def _validate_renderer_fields(self) -> Self: "deepseek-v3": DeepSeekV3RendererConfig, "deepseek-r1": DeepSeekR1RendererConfig, "deepseek-v4": DeepSeekV4RendererConfig, + "deepseek-v4.1": DeepSeekV41RendererConfig, } @@ -1150,6 +1180,7 @@ def config_from_name(name: str) -> BaseRendererConfig | None: "DeepSeekR1RendererConfig", "DeepSeekV3RendererConfig", "DeepSeekV4RendererConfig", + "DeepSeekV41RendererConfig", "GLM45RendererConfig", "GLM51RendererConfig", "GLM53FlashRendererConfig", diff --git a/renderers/deepseek_v41.py b/renderers/deepseek_v41.py new file mode 100644 index 000000000..acf24486e --- /dev/null +++ b/renderers/deepseek_v41.py @@ -0,0 +1,106 @@ +"""DeepSeek-V4.1-Flash renderer, for text conversations without tools. + +The checkpoint's source of truth is ``encoding/encoding.py`` in the model +repository. For plain chats it is V4's encoder with three 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; +* 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. + +Tool calls (V4.1 renamed the DSML tags) and mid-conversation system messages +are not supported yet. +""" + +from __future__ import annotations + +from renderers.base import Message, RenderedTokens, ToolSpec +from renderers.configs import DeepSeekV41RendererConfig +from renderers.deepseek_v4 import DeepSeekV4Renderer + +_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) + return {**message, "reasoning_content": reasoning, "content": content} + + +class DeepSeekV41Renderer(DeepSeekV4Renderer): + """Renderer for ``deepseek-ai/DeepSeek-V4.1-Flash``.""" + + def __init__(self, tokenizer, config: DeepSeekV41RendererConfig | None = None): + super().__init__(tokenizer, config or DeepSeekV41RendererConfig()) + + def _reasoning_effort_prompt(self) -> str: + 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 render( + self, + messages: list[Message], + *, + tools: list[ToolSpec] | None = None, + add_generation_prompt: bool = False, + ) -> RenderedTokens: + if tools: + raise NotImplementedError( + "the DeepSeek-V4.1 renderer does not render tools" + ) + 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] + if ( + messages + and messages[0]["role"] == "system" + and not self.config.enable_thinking + ): + # Without an effort prefix, the system message itself opens with the System token. + first = { + **messages[0], + "content": _SYSTEM + (messages[0].get("content") or ""), + } + messages = [first, *messages[1:]] + 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: + # V4's extension path renders the new turn with V4's tags; fall back to a full render. + return None + + +__all__ = ["DeepSeekV41Renderer"] diff --git a/tests/test_deepseek_v41.py b/tests/test_deepseek_v41.py new file mode 100644 index 000000000..76d75ec10 --- /dev/null +++ b/tests/test_deepseek_v41.py @@ -0,0 +1,130 @@ +"""DeepSeek-V4.1-Flash parity against the checkpoint's shipped Python encoder.""" + +from __future__ import annotations + +import importlib.util +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, + 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_tools_and_mid_conversation_system(): + renderer = DeepSeekV41Renderer(_tokenizer()) + user = [{"role": "user", "content": "Hi"}] + tool = {"type": "function", "function": {"name": "f", "parameters": {}}} + + with pytest.raises(NotImplementedError): + renderer.render(user, tools=[tool]) + with pytest.raises(NotImplementedError): + renderer.render([*user, {"role": "system", "content": "Be terse."}]) From 9401d773f896291d489bf6c64b5ac601c7ad91b7 Mon Sep 17 00:00:00 2001 From: sami jaghouar Date: Thu, 15 Oct 2026 01:46:33 +0000 Subject: [PATCH 2/3] fix: satisfy ty for the DeepSeek-V4.1 renderer Copy messages instead of unpacking them into a TypedDict literal, reject non-text system content, and drop unneeded type-ignore comments. Co-Authored-By: Claude Opus 5.5 --- renderers/configs.py | 4 ++-- renderers/deepseek_v41.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/renderers/configs.py b/renderers/configs.py index 43603d215..5f460eaf2 100644 --- a/renderers/configs.py +++ b/renderers/configs.py @@ -1008,12 +1008,12 @@ class DeepSeekV41RendererConfig(DeepSeekV4RendererConfig): or system message, and thinking mode on by default. """ - name: Literal["deepseek-v4.1"] = "deepseek-v4.1" # type: ignore[assignment] + 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" # type: ignore[assignment] + 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") diff --git a/renderers/deepseek_v41.py b/renderers/deepseek_v41.py index acf24486e..8ddcb0c39 100644 --- a/renderers/deepseek_v41.py +++ b/renderers/deepseek_v41.py @@ -43,7 +43,10 @@ def _split_inline_reasoning(message: Message) -> 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) - return {**message, "reasoning_content": reasoning, "content": content} + split = message.copy() + split["reasoning_content"] = reasoning + split["content"] = content + return split class DeepSeekV41Renderer(DeepSeekV4Renderer): @@ -82,10 +85,12 @@ def render( and not self.config.enable_thinking ): # Without an effort prefix, the system message itself opens with the System token. - first = { - **messages[0], - "content": _SYSTEM + (messages[0].get("content") or ""), - } + first = messages[0].copy() + if not isinstance(first["content"], str): + raise NotImplementedError( + "the DeepSeek-V4.1 renderer renders text-only system messages" + ) + first["content"] = _SYSTEM + first["content"] messages = [first, *messages[1:]] return super().render( messages, tools=tools, add_generation_prompt=add_generation_prompt From 73591a104e907cce2ba82349de80476d81778a00 Mon Sep 17 00:00:00 2001 From: sami jaghouar Date: Thu, 22 Oct 2026 06:45:08 +0000 Subject: [PATCH 3/3] feat: render and parse tool calls in the DeepSeek-V4.1 renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V4.1 renderer now renders tools, assistant tool calls and tool results like the checkpoint's encoding/encoding.py, and parses completions with vLLM's deepseek_v41 DSML format: - DSML tags are space-prefixed: <|DSML| calls>, <|DSML| invoke>, <|DSML| parameter>, in the tools block, the rendered calls and the parser. - Tools attach to a leading system message, or to an empty one that the <|System|> token opens. - 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 arguments may be double-encoded; arguments that do not decode to an object render as a single "arguments" parameter. - The prefix-extension bridge is enabled again. DeepSeekV4Renderer gains small hooks for these (tag names, grammar, preamble, turn start, tool target, argument decoding); its output is unchanged. Co-Authored-By: Claude Opus 5.5 --- renderers/deepseek_v4.py | 141 ++++++++++--------- renderers/deepseek_v41.py | 88 ++++++++---- renderers/parsing.py | 25 +++- renderers/tagged.py | 12 ++ tests/test_deepseek_v41.py | 270 ++++++++++++++++++++++++++++++++++++- 5 files changed, 436 insertions(+), 100 deletions(-) diff --git a/renderers/deepseek_v4.py b/renderers/deepseek_v4.py index e3ba16ca4..749ea84d7 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 +<{calls}> +<{invoke} name="$TOOL_NAME"> +<{parameter} name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE ... - -<{dsml}invoke name="$TOOL_NAME2"> + +<{invoke} name="$TOOL_NAME2"> ... - - + + 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"" + f"" ) arguments_text = "\n".join(params) - return f'<{_DSML}invoke name="{name}">\n{arguments_text}\n' + return ( + f'<{self._invoke_tag} name="{name}">\n{arguments_text}\n' + f"" + ) 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"", + f"\n\n<{self._calls_tag}>\n{rendered_calls}\n" + f"", 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 index 8ddcb0c39..c758fc47a 100644 --- a/renderers/deepseek_v41.py +++ b/renderers/deepseek_v41.py @@ -1,25 +1,36 @@ -"""DeepSeek-V4.1-Flash renderer, for text conversations without tools. +"""DeepSeek-V4.1-Flash renderer. The checkpoint's source of truth is ``encoding/encoding.py`` in the model -repository. For plain chats it is V4's encoder with three changes, applied -here on top of :class:`DeepSeekV4Renderer`: +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; + 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. - -Tool calls (V4.1 renamed the DSML tags) and mid-conversation system messages -are not supported yet. +* 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 DeepSeekV4Renderer +from renderers.deepseek_v4 import _DSML, DeepSeekV4Renderer, _LogicalMessage +from renderers.tagged import DEEPSEEK_V41_GRAMMAR _SYSTEM = "<|System|>" _THINK_START = "" @@ -52,10 +63,17 @@ def _split_inline_reasoning(message: Message) -> Message: 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 _reasoning_effort_prompt(self) -> str: + 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 ( @@ -63,6 +81,29 @@ def _reasoning_effort_prompt(self) -> str: "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], @@ -70,28 +111,11 @@ def render( tools: list[ToolSpec] | None = None, add_generation_prompt: bool = False, ) -> RenderedTokens: - if tools: - raise NotImplementedError( - "the DeepSeek-V4.1 renderer does not render tools" - ) 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] - if ( - messages - and messages[0]["role"] == "system" - and not self.config.enable_thinking - ): - # Without an effort prefix, the system message itself opens with the System token. - first = messages[0].copy() - if not isinstance(first["content"], str): - raise NotImplementedError( - "the DeepSeek-V4.1 renderer renders text-only system messages" - ) - first["content"] = _SYSTEM + first["content"] - messages = [first, *messages[1:]] return super().render( messages, tools=tools, add_generation_prompt=add_generation_prompt ) @@ -104,8 +128,14 @@ def bridge_to_next_turn( *, tools: list[ToolSpec] | None = None, ) -> RenderedTokens | None: - # V4's extension path renders the new turn with V4's tags; fall back to a full render. - return 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 352ae8171..86feefca8 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 = "" + invoke_end = f"" + parameter = re.escape(parameter_tag) parameter_pattern = re.compile( - r'<|DSML|parameter name="(.*?)" string="(true|false)">' - r"(.*?)", + rf'<{parameter} name="(.*?)" string="(true|false)">(.*?)', 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 ccb4b7f8c..8c850d5a1 100644 --- a/renderers/tagged.py +++ b/renderers/tagged.py @@ -190,3 +190,15 @@ def scan_tagged_response( "": "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", + "": "section_end", + "<|DSML| invoke": "tool_start", + "": "tool_end", + }, +) diff --git a/tests/test_deepseek_v41.py b/tests/test_deepseek_v41.py index 76d75ec10..7d77eb587 100644 --- a/tests/test_deepseek_v41.py +++ b/tests/test_deepseek_v41.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util +import json from functools import lru_cache import pytest @@ -12,6 +13,7 @@ DeepSeekV41Renderer, DeepSeekV41RendererConfig, RendererConfig, + ToolCallParseStatus, create_renderer, ) from renderers.base import load_tokenizer @@ -119,12 +121,272 @@ def test_inline_reasoning_moves_into_think_block(): assert renderer.render_ids(inline) == renderer.render_ids(split) -def test_rejects_tools_and_mid_conversation_system(): +def test_rejects_mid_conversation_system(): renderer = DeepSeekV41Renderer(_tokenizer()) user = [{"role": "user", "content": "Hi"}] - tool = {"type": "function", "function": {"name": "f", "parameters": {}}} - with pytest.raises(NotImplementedError): - renderer.render(user, tools=[tool]) 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"\n' + '<|DSML| parameter name="filters" string="false">{"k": [1]}\n' + "\n" + "<|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) + ]