From d23cd8e1ad2c1e73c79e0411efc24006bf80db23 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 16 May 2026 14:17:54 -0700 Subject: [PATCH] feat(base): RenderStability declarative API + Qwen3 / Qwen3-VL / DefaultRenderer values #41 asked for a declarative way to mark renderer stability and to populate the values for the Qwen3 family and the default renderer. Adds the RenderStability enum, a stability attribute on the base renderer, and the values for Qwen3, Qwen3-VL, Qwen3.5, Qwen3.6, DeepSeek-V3, GLM 4.5 / 5, GPT-OSS, Kimi K2 / K2.5, MiniMax-M2, Nemotron-3, and DefaultRenderer. Fixes #41 --- renderers/__init__.py | 12 ++++ renderers/base.py | 7 ++ renderers/deepseek_v3.py | 6 ++ renderers/default.py | 5 ++ renderers/glm45.py | 6 ++ renderers/glm5.py | 7 ++ renderers/gpt_oss.py | 6 ++ renderers/kimi_k2.py | 6 ++ renderers/kimi_k25.py | 6 ++ renderers/minimax_m2.py | 6 ++ renderers/nemotron3.py | 6 ++ renderers/qwen3.py | 7 ++ renderers/qwen35.py | 7 ++ renderers/qwen36.py | 6 ++ renderers/qwen3_vl.py | 7 ++ renderers/stability.py | 18 +++++ tests/test_stability.py | 146 +++++++++++++++++++++++++++++++++++++++ 17 files changed, 264 insertions(+) create mode 100644 renderers/stability.py create mode 100644 tests/test_stability.py diff --git a/renderers/__init__.py b/renderers/__init__.py index 6b2f225..d3b1248 100644 --- a/renderers/__init__.py +++ b/renderers/__init__.py @@ -19,6 +19,13 @@ reject_assistant_in_extension, trim_to_turn_close, ) +from renderers.stability import ( + FULLY_STABLE, + OPAQUE, + STABLE_IN_TOOL_CYCLE, + Boundary, + RenderStability, +) from renderers.deepseek_v3 import DeepSeekV3Renderer from renderers.default import DefaultRenderer from renderers.glm5 import GLM5Renderer @@ -38,6 +45,7 @@ "ContentPart", "DeepSeekV3Renderer", "DefaultRenderer", + "FULLY_STABLE", "GLM45Renderer", "GLM5Renderer", "GptOssRenderer", @@ -46,6 +54,7 @@ "Message", "MiniMaxM2Renderer", "Nemotron3Renderer", + "OPAQUE", "ParsedResponse", "Qwen3Renderer", "Qwen3VLRenderer", @@ -55,11 +64,14 @@ "RenderedTokens", "Renderer", "RendererPool", + "RenderStability", + "STABLE_IN_TOOL_CYCLE", "TextPart", "ThinkingPart", "ToolCall", "ToolCallFunction", "ToolSpec", + "Boundary", "build_training_sample", "build_trajectory_step", "create_renderer", diff --git a/renderers/base.py b/renderers/base.py index 7e2a958..2f68d67 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -6,6 +6,8 @@ from dataclasses import dataclass, field from typing import Any, Callable, Literal, Protocol, TypedDict, runtime_checkable +from renderers.stability import RenderStability + logger = logging.getLogger("renderers.base") @@ -133,6 +135,11 @@ def with_completion( class Renderer(Protocol): """Owns message ↔ token conversion for a specific model family.""" + @property + def stability(self) -> RenderStability: + """Declared boundaries where appending a message preserves the prefix.""" + ... + def render( self, messages: list[Message], diff --git a/renderers/deepseek_v3.py b/renderers/deepseek_v3.py index e476bb4..8c499c6 100644 --- a/renderers/deepseek_v3.py +++ b/renderers/deepseek_v3.py @@ -25,6 +25,7 @@ trim_to_turn_close, ) from renderers.parsing import parse_deepseek_v3 +from renderers.stability import OPAQUE, RenderStability # Fullwidth vertical bar used in DeepSeek special token names. _SEP = "\uff5c" # | (U+FF5C) @@ -81,6 +82,11 @@ def __init__( self._tool_output_begin = self._get_special_token(f"tool{_US}output{_US}begin") self._tool_output_end = self._get_special_token(f"tool{_US}output{_US}end") + @property + def stability(self) -> RenderStability: + # TODO(#41): audit DeepSeek V3 before tightening this declaration. + return OPAQUE + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ diff --git a/renderers/default.py b/renderers/default.py index c42b929..0264cbf 100644 --- a/renderers/default.py +++ b/renderers/default.py @@ -25,6 +25,7 @@ get_reasoning_parser, get_tool_parser, ) +from renderers.stability import OPAQUE, RenderStability def _decode_tool_call_arguments(messages: list) -> list: @@ -114,6 +115,10 @@ def __init__( preserve_thinking_between_tool_calls ) + @property + def stability(self) -> RenderStability: + return OPAQUE + @property def supports_tools(self) -> bool: return self._tool_parser is not None diff --git a/renderers/glm45.py b/renderers/glm45.py index 5a6dfed..074ec69 100644 --- a/renderers/glm45.py +++ b/renderers/glm45.py @@ -24,6 +24,7 @@ should_preserve_past_thinking, ) from renderers.parsing import parse_glm +from renderers.stability import OPAQUE, RenderStability _TOOLS_HEADER = ( "\n# Tools\n\n" @@ -80,6 +81,11 @@ def __init__( self._arg_value = self._token_id("") self._arg_value_end = self._token_id("") + @property + def stability(self) -> RenderStability: + # TODO(#41): audit GLM-4.5 before tightening this declaration. + return OPAQUE + def _token_id(self, token: str) -> int: tid = self._tokenizer.convert_tokens_to_ids(token) assert isinstance(tid, int) and tid != self._tokenizer.unk_token_id, ( diff --git a/renderers/glm5.py b/renderers/glm5.py index 4cc2b80..fec58dd 100644 --- a/renderers/glm5.py +++ b/renderers/glm5.py @@ -25,6 +25,7 @@ should_preserve_past_thinking, ) from renderers.parsing import parse_glm +from renderers.stability import FULLY_STABLE, STABLE_IN_TOOL_CYCLE, RenderStability _TOOLS_HEADER = ( "\n# Tools\n\n" @@ -86,6 +87,12 @@ def __init__( self._arg_value = self._token_id("") self._arg_value_end = self._token_id("") + @property + def stability(self) -> RenderStability: + if self._preserve_all_thinking: + return FULLY_STABLE + return STABLE_IN_TOOL_CYCLE + def _token_id(self, token: str) -> int: tid = self._tokenizer.convert_tokens_to_ids(token) assert isinstance(tid, int) and tid != self._tokenizer.unk_token_id, ( diff --git a/renderers/gpt_oss.py b/renderers/gpt_oss.py index 81d6cd7..9e1e4b7 100644 --- a/renderers/gpt_oss.py +++ b/renderers/gpt_oss.py @@ -61,6 +61,7 @@ trim_to_turn_close, ) from renderers.parsing import parse_gpt_oss +from renderers.stability import OPAQUE, RenderStability def _reasoning_effort(effort: str | None) -> ReasoningEffort: @@ -170,6 +171,11 @@ def __init__( self._message = self._token_id("<|message|>") self._constrain = self._token_id("<|constrain|>") + @property + def stability(self) -> RenderStability: + # TODO(#41): audit GPT-OSS harmony before tightening this declaration. + return OPAQUE + # ── token utilities ────────────────────────────────────────────────────── def _token_id(self, token: str) -> int: diff --git a/renderers/kimi_k2.py b/renderers/kimi_k2.py index f4223d4..e101644 100644 --- a/renderers/kimi_k2.py +++ b/renderers/kimi_k2.py @@ -27,6 +27,7 @@ trim_to_turn_close, ) from renderers.parsing import parse_kimi_k2 +from renderers.stability import OPAQUE, RenderStability _DEFAULT_SYSTEM = "You are Kimi, an AI assistant created by Moonshot AI." @@ -63,6 +64,11 @@ def __init__( self._tool_call_argument_begin = self._token_id("<|tool_call_argument_begin|>") self._tool_call_end = self._token_id("<|tool_call_end|>") + @property + def stability(self) -> RenderStability: + # TODO(#41): audit Kimi K2 before tightening this declaration. + return OPAQUE + def _token_id(self, token: str) -> int: tid = self._tokenizer.convert_tokens_to_ids(token) assert isinstance(tid, int) and tid != self._tokenizer.unk_token_id, ( diff --git a/renderers/kimi_k25.py b/renderers/kimi_k25.py index 5f0efbb..ab3f249 100644 --- a/renderers/kimi_k25.py +++ b/renderers/kimi_k25.py @@ -36,6 +36,7 @@ should_preserve_past_thinking, trim_to_turn_close, ) +from renderers.stability import OPAQUE, RenderStability # --------------------------------------------------------------------------- # Constants @@ -545,6 +546,11 @@ def __init__( # The stop token for generation self._endoftext: int | None = self._try_token_id("<|endoftext|>") + @property + def stability(self) -> RenderStability: + # TODO(#41): audit Kimi K2.5 before tightening this declaration. + return OPAQUE + # ------------------------------------------------------------------ # Token helpers # ------------------------------------------------------------------ diff --git a/renderers/minimax_m2.py b/renderers/minimax_m2.py index 239e15d..cc5a7d3 100644 --- a/renderers/minimax_m2.py +++ b/renderers/minimax_m2.py @@ -26,6 +26,7 @@ trim_to_turn_close, ) from renderers.parsing import parse_minimax +from renderers.stability import OPAQUE, RenderStability _DEFAULT_SYSTEM = ( "You are a helpful assistant. Your name is MiniMax-M2.5 and is built by MiniMax." @@ -78,6 +79,11 @@ def __init__( self._tool_call_tok = self._token_id("") self._tool_call_end_tok = self._token_id("") + @property + def stability(self) -> RenderStability: + # TODO(#41): audit MiniMax M2 before tightening this declaration. + return OPAQUE + def _token_id(self, token: str) -> int: tid = self._tokenizer.convert_tokens_to_ids(token) assert isinstance(tid, int) and tid != self._tokenizer.unk_token_id, ( diff --git a/renderers/nemotron3.py b/renderers/nemotron3.py index 2895069..c869143 100644 --- a/renderers/nemotron3.py +++ b/renderers/nemotron3.py @@ -29,6 +29,7 @@ trim_to_turn_close, ) from renderers.parsing import parse_qwen35 +from renderers.stability import OPAQUE, RenderStability # --------------------------------------------------------------------------- # Tool system prompt constants @@ -104,6 +105,11 @@ def __init__( self._tool_response = self._token_id("") self._tool_response_end = self._token_id("") + @property + def stability(self) -> RenderStability: + # TODO(#41): audit Nemotron 3 before tightening this declaration. + return OPAQUE + def _token_id(self, token: str, *, optional: bool = False) -> int | None: tid = self._tokenizer.convert_tokens_to_ids(token) if not isinstance(tid, int) or tid == self._tokenizer.unk_token_id: diff --git a/renderers/qwen3.py b/renderers/qwen3.py index 5615783..e6f18e9 100644 --- a/renderers/qwen3.py +++ b/renderers/qwen3.py @@ -23,6 +23,7 @@ trim_to_turn_close, ) from renderers.parsing import parse_qwen3 +from renderers.stability import FULLY_STABLE, STABLE_IN_TOOL_CYCLE, RenderStability _TOOLS_HEADER = ( "# Tools\n\n" @@ -67,6 +68,12 @@ def __init__( self._tool_response = self._token_id("") self._tool_response_end = self._token_id("") + @property + def stability(self) -> RenderStability: + if self._preserve_all_thinking: + return FULLY_STABLE + return STABLE_IN_TOOL_CYCLE + def _token_id(self, token: str) -> int: tid = self._tokenizer.convert_tokens_to_ids(token) assert isinstance(tid, int) and tid != self._tokenizer.unk_token_id, ( diff --git a/renderers/qwen35.py b/renderers/qwen35.py index 9afc0d4..55c71e6 100644 --- a/renderers/qwen35.py +++ b/renderers/qwen35.py @@ -21,6 +21,7 @@ trim_to_turn_close, ) from renderers.parsing import parse_qwen35 +from renderers.stability import FULLY_STABLE, STABLE_IN_TOOL_CYCLE, RenderStability # --------------------------------------------------------------------------- # Tool system prompt constants (must match the Jinja template exactly) @@ -114,6 +115,12 @@ def __init__( self._tool_response = self._token_id("") self._tool_response_end = self._token_id("") + @property + def stability(self) -> RenderStability: + if self._preserve_all_thinking: + return FULLY_STABLE + return STABLE_IN_TOOL_CYCLE + def _token_id(self, token: str) -> int: tid = self._tokenizer.convert_tokens_to_ids(token) assert isinstance(tid, int) and tid != self._tokenizer.unk_token_id, ( diff --git a/renderers/qwen36.py b/renderers/qwen36.py index 5848194..9f53d62 100644 --- a/renderers/qwen36.py +++ b/renderers/qwen36.py @@ -24,11 +24,17 @@ from typing import Any from renderers.qwen35 import Qwen35Renderer +from renderers.stability import OPAQUE, RenderStability class Qwen36Renderer(Qwen35Renderer): """Deterministic message → token renderer for Qwen3.6 models.""" + @property + def stability(self) -> RenderStability: + # TODO(#41): audit Qwen3.6 before tightening this declaration. + return OPAQUE + @staticmethod def _render_arg_value(arg_value: Any) -> str: if isinstance(arg_value, str): diff --git a/renderers/qwen3_vl.py b/renderers/qwen3_vl.py index 2f5da8e..4c0a96e 100644 --- a/renderers/qwen3_vl.py +++ b/renderers/qwen3_vl.py @@ -22,6 +22,7 @@ trim_to_turn_close, ) from renderers.parsing import parse_qwen3 +from renderers.stability import FULLY_STABLE, STABLE_IN_TOOL_CYCLE, RenderStability _TOOLS_HEADER = ( "# Tools\n\n" @@ -67,6 +68,12 @@ def __init__( self._tool_response = self._token_id("") self._tool_response_end = self._token_id("") + @property + def stability(self) -> RenderStability: + if self._preserve_all_thinking: + return FULLY_STABLE + return STABLE_IN_TOOL_CYCLE + def _token_id(self, token: str) -> int: tid = self._tokenizer.convert_tokens_to_ids(token) assert isinstance(tid, int) and tid != self._tokenizer.unk_token_id, ( diff --git a/renderers/stability.py b/renderers/stability.py new file mode 100644 index 0000000..e28db77 --- /dev/null +++ b/renderers/stability.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +Boundary = Literal["user", "assistant", "tool"] + + +@dataclass(frozen=True) +class RenderStability: + """Declared append-boundaries that preserve an existing rendered prefix.""" + + preserves_through: frozenset[Boundary] + + +FULLY_STABLE = RenderStability(frozenset({"user", "assistant", "tool"})) +STABLE_IN_TOOL_CYCLE = RenderStability(frozenset({"tool"})) +OPAQUE = RenderStability(frozenset()) diff --git a/tests/test_stability.py b/tests/test_stability.py new file mode 100644 index 0000000..12c8e6f --- /dev/null +++ b/tests/test_stability.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import pytest + +from renderers import ( + FULLY_STABLE, + OPAQUE, + STABLE_IN_TOOL_CYCLE, + create_renderer, +) +from renderers.base import Message +from renderers.glm5 import GLM5Renderer +from renderers.qwen3 import Qwen3Renderer +from renderers.qwen3_vl import Qwen3VLRenderer +from renderers.qwen35 import Qwen35Renderer +from renderers.qwen36 import Qwen36Renderer +from renderers.stability import Boundary + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + } +] + +BOUNDARY_CASES: dict[Boundary, tuple[list[Message], Message]] = { + "user": ( + [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "What is 2+2?"}, + { + "role": "assistant", + "reasoning_content": "Simple arithmetic.", + "content": "4", + }, + ], + {"role": "user", "content": "Now answer 3+5."}, + ), + "assistant": ( + [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "What is 2+2?"}, + ], + { + "role": "assistant", + "reasoning_content": "Simple arithmetic.", + "content": "4", + }, + ), + "tool": ( + [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Weather in Paris?"}, + { + "role": "assistant", + "content": "Let me check.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": {"city": "Paris"}, + }, + } + ], + }, + ], + { + "role": "tool", + "tool_call_id": "call_1", + "name": "get_weather", + "content": '{"temp": 20}', + }, + ), +} + + +def _has_known_dynamic_stability(renderer) -> bool: + return isinstance( + renderer, + (Qwen3Renderer, Qwen3VLRenderer, Qwen35Renderer, GLM5Renderer), + ) and not isinstance(renderer, Qwen36Renderer) + + +def _assert_declared_boundaries_preserve_prefix(renderer) -> None: + for boundary in sorted(renderer.stability.preserves_through): + messages, appended = BOUNDARY_CASES[boundary] + before = renderer.render(messages, tools=TOOLS).token_ids + after = renderer.render(messages + [appended], tools=TOOLS).token_ids + assert after[: len(before)] == before, ( + f"{renderer.__class__.__name__}: appending {boundary!r} did not " + "preserve the rendered prefix" + ) + + +def test_renderer_default_stability_values(renderer): + if _has_known_dynamic_stability(renderer): + assert renderer.stability == STABLE_IN_TOOL_CYCLE + else: + assert renderer.stability == OPAQUE + + +def test_preserve_all_thinking_makes_known_renderers_fully_stable( + tokenizer, renderer_name, renderer +): + if not _has_known_dynamic_stability(renderer): + pytest.skip("renderer does not declare preserve_all_thinking stability yet") + + all_thinking_renderer = create_renderer( + tokenizer, + renderer=renderer_name, + preserve_all_thinking=True, + ) + + assert all_thinking_renderer.stability == FULLY_STABLE + + +def test_declared_stability_boundaries_preserve_prefix(renderer): + if not renderer.stability.preserves_through: + pytest.skip("renderer declares opaque stability") + + _assert_declared_boundaries_preserve_prefix(renderer) + + +def test_preserve_all_declared_boundaries_preserve_prefix( + tokenizer, renderer_name, renderer +): + if not _has_known_dynamic_stability(renderer): + pytest.skip("renderer does not declare preserve_all_thinking stability yet") + + all_thinking_renderer = create_renderer( + tokenizer, + renderer=renderer_name, + preserve_all_thinking=True, + ) + + _assert_declared_boundaries_preserve_prefix(all_thinking_renderer)