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)