Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ on:
branches: [main]
paths:
- "**.py"
- "tests/golden_renderer_outputs.json"
- "pyproject.toml"
- ".github/workflows/test.yml"
pull_request:
branches: [main]
paths:
- "**.py"
- "tests/golden_renderer_outputs.json"
- "pyproject.toml"
- ".github/workflows/test.yml"

jobs:
test:
name: Renderers
unit:
name: Offline unit tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand All @@ -38,4 +40,64 @@ jobs:
run: uv sync

- name: Run tests
run: uv run pytest tests/ -v
run: uv run pytest tests/ -m "not network" -v

model-parity:
name: Pinned model parity
runs-on: ubuntu-latest
timeout-minutes: 45
env:
HF_HUB_DISABLE_PROGRESS_BARS: "1"
RENDERERS_TEST_NETWORK: "1"
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.13
uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: Install uv
uses: astral-sh/setup-uv@v7

- name: Cache pinned Hugging Face assets
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: hf-renderers-${{ hashFiles('tests/model_assets.py') }}

- name: Install dependencies
run: uv sync

- name: Run text model parity tests
run: uv run pytest tests/ -m "network and not multimodal" -v

multimodal-parity:
name: Pinned multimodal parity
runs-on: ubuntu-latest
timeout-minutes: 45
env:
HF_HUB_DISABLE_PROGRESS_BARS: "1"
RENDERERS_TEST_NETWORK: "1"
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.13
uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: Install uv
uses: astral-sh/setup-uv@v7

- name: Cache pinned Hugging Face assets
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: hf-renderers-${{ hashFiles('tests/model_assets.py') }}

- name: Install dependencies
run: uv sync

- name: Run multimodal parity tests
run: uv run pytest tests/ -m "multimodal" -v
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,33 +33,53 @@ parsed = r.parse_response(completion_ids)
For the next turn, extend the previous sampled stream instead of re-rendering history:

```python
next_prompt_ids = r.bridge_to_next_turn(
bridged = r.bridge_to_next_turn(
previous_prompt_ids=prompt_ids,
previous_completion_ids=completion_ids,
new_messages=[{"role": "tool", "content": "..."}],
)
if bridged is None:
# The renderer could not prove that preserving the sampled prefix is safe.
# Fall back to a full render of the conversation in that case.
...
next_prompt_ids = bridged.token_ids
```

Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `nemotron-3`, `nemotron-3-ultra`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `laguna-xs.2`, `laguna-xs-2.1`, `nemotron-3`, `nemotron-3-ultra`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.

## API

```python
class Renderer(Protocol):
def render(messages, *, tools=None, add_generation_prompt=False) -> RenderedTokens: ...
def render_ids(messages, *, tools=None, add_generation_prompt=False) -> list[int]: ...
def parse_response(token_ids) -> ParsedResponse: ...
def parse_response(token_ids, *, tools=None) -> ParsedResponse: ...
def get_stop_token_ids() -> list[int]: ...
def bridge_to_next_turn(prev_prompt_ids, prev_completion_ids, new_messages, *, tools=None) -> list[int] | None: ...
def bridge_to_next_turn(prev_prompt_ids, prev_completion_ids, new_messages, *, tools=None) -> RenderedTokens | None: ...
```

- `RenderedTokens` carries `token_ids` **and** `message_indices` — one entry per token attributing each to its source message (`-1` for structural scaffolding). Lets `build_training_sample` build a per-token loss mask in one render.
- `ParsedResponse` is `(content, reasoning_content, tool_calls)`. It scans token ids for special-token boundaries (e.g. id `151657` for `<tool_call>` on Qwen3) — a literal `"<tool_call>"` in user content tokenizes to ordinary text ids and never matches.
- Round-trip: rendering `[user, assistant(content, reasoning, tool_calls)]`, slicing the assistant completion, and feeding it through `parse_response` returns an equivalent structured message. Tested per-renderer in `tests/test_roundtrip.py`.

### Tool definitions

Renderer entry points accept the common wire shapes for client-executed JSON-schema functions:

- legacy / verifiers and Gemini function declarations: `{name, description, parameters}`
- OpenAI Chat Completions: `{type: "function", function: {...}}`
- OpenAI Responses and Gemini Interactions: `{type: "function", name, description, parameters, strict}`
- Anthropic: `{name, description, input_schema}`
- MCP: `{name, description, inputSchema}`
- Pydantic-style objects exposing `model_dump()`

`normalize_tool_spec()` exposes the conversion directly. All supported shapes become the existing Chat-style envelope before rendering or schema-aware response parsing, so switching provider payloads does not change the model's token stream. Inputs are deep-copied and never mutated.

Hosted and non-JSON function protocols—such as OpenAI web search, file search, remote MCP, custom-text, namespace, shell, and computer tools—raise `UnsupportedToolSpecError`. Those tools need model-specific execution semantics and cannot be faithfully rewritten as ordinary client functions.

### `bridge_to_next_turn` (the core contract)

Given `(prev_prompt_ids, prev_completion_ids)` and new environment messages, return ids for the next turn's prompt such that the result starts with `prev_prompt_ids + prev_completion_ids` byte-for-byte and continues with the new messages plus the next assistant opener. If that cannot be proven safe, return `None` and the caller falls back to a full render.
Given `(prev_prompt_ids, prev_completion_ids)` and new environment messages, return a `RenderedTokens` object for the next turn's prompt whose `token_ids` start with `prev_prompt_ids + prev_completion_ids` byte-for-byte and continue with the new messages plus the next assistant opener. If that cannot be proven safe, return `None` and the caller falls back to a full render. Attribution in a bridge result is relative to `new_messages`; the preserved prefix uses `message_indices=-1` because only its raw token IDs are available.

Each hand-coded bridge:
1. Anchors at the previous turn's canonical close token. On clean stops it's already in `prev_completion_ids`. On truncation, the renderer synthesizes the close as non-loss prompt context.
Expand Down
8 changes: 6 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,9 @@ Each script runs `Qwen/Qwen3.5-4B` with `enable_thinking=True` and `False`, then

## Multimodal Note

Renderers are text-only today. For image/video demos, use the backend's message
or prompt path until renderers grow multimodal placeholder support.
Image rendering is supported for Qwen3-VL, Qwen3.5 / Qwen3.6, and Kimi K2.5 /
K2.6. Their renderers return token IDs plus a framework-agnostic
`multi_modal_data` sidecar containing placeholder ranges and processed image
features. `renderers.client` can serialize the Qwen-VL family sidecar for
vLLM's token-in endpoint; other backends need their own adapter. Video content
parts are represented by the public types but are not yet rendered.
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,10 @@ invalid-key = "warn"
unsupported-operator = "warn"
invalid-return-type = "warn"
invalid-assignment = "warn"

[tool.pytest.ini_options]
markers = [
"network: requires pinned external model assets from Hugging Face",
"model_parity: compares a renderer with a pinned upstream tokenizer or reference encoder",
"multimodal: exercises image/video processors and multimodal sidecars",
]
26 changes: 26 additions & 0 deletions renderers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@
Qwen3VLRendererConfig,
RendererConfig,
)
from renderers.tools import (
AnthropicToolSpec,
CanonicalToolSpec,
ChatCompletionToolSpec,
FunctionSpec,
KnownToolSpec,
MCPToolSpec,
normalize_tool_spec,
normalize_tool_specs,
ResponsesFunctionToolSpec,
ToolSpecError,
ToolSpecInput,
UnsupportedToolSpecError,
)

# Concrete renderer classes are lazy-loaded so that consumers needing
# only the config layer (``RendererConfig`` discriminated union) don't
Expand Down Expand Up @@ -121,9 +135,12 @@ def __dir__() -> list[str]:

__all__ = [
"AutoRendererConfig",
"AnthropicToolSpec",
"BaseRendererConfig",
"Content",
"ContentPart",
"CanonicalToolSpec",
"ChatCompletionToolSpec",
"DeepSeekR1Renderer",
"DeepSeekR1RendererConfig",
"DeepSeekV3Renderer",
Expand All @@ -138,6 +155,8 @@ def __dir__() -> list[str]:
"GLM5RendererConfig",
"GptOssRenderer",
"GptOssRendererConfig",
"FunctionSpec",
"KnownToolSpec",
"Hy3Renderer",
"Hy3RendererConfig",
"ImagePart",
Expand All @@ -154,6 +173,7 @@ def __dir__() -> list[str]:
"MULTIMODAL_MODELS",
"MalformedGenerateResponseError",
"Message",
"MCPToolSpec",
"MiniMaxM2Renderer",
"MiniMaxM2RendererConfig",
"MultiModalData",
Expand Down Expand Up @@ -182,12 +202,16 @@ def __dir__() -> list[str]:
"Renderer",
"RendererConfig",
"RendererPool",
"ResponsesFunctionToolSpec",
"TextPart",
"ThinkingPart",
"ToolCall",
"ToolCallFunction",
"ToolCallParseStatus",
"ToolSpec",
"ToolSpecError",
"ToolSpecInput",
"UnsupportedToolSpecError",
"VideoPart",
"__version__",
"attribute_text_segments",
Expand All @@ -198,6 +222,8 @@ def __dir__() -> list[str]:
"create_renderer_pool",
"extract_message_tool_names",
"is_multimodal",
"normalize_tool_spec",
"normalize_tool_specs",
"reject_assistant_in_extension",
"trim_to_turn_close",
]
36 changes: 22 additions & 14 deletions renderers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
runtime_checkable,
)

from renderers.tools import ToolSpec

if TYPE_CHECKING:
from renderers.configs import (
AutoRendererConfig,
Expand Down Expand Up @@ -97,14 +99,6 @@ class ToolCall(TypedDict, total=False):
function: ToolCallFunction


class ToolSpec(TypedDict):
"""Tool specification (OpenAI function-calling format)."""

name: str
description: str
parameters: dict[str, Any]


class Message(TypedDict, total=False):
"""A single turn in a multi-turn conversation.

Expand Down Expand Up @@ -1190,11 +1184,22 @@ def _tokenizer_source_for(model_name_or_path: str) -> str:
return TOKENIZER_SOURCE_OVERRIDES.get(model_name_or_path, model_name_or_path)


def _tokenizer_load_kwargs(model_name_or_path: str) -> dict[str, Any]:
revision = TRUSTED_REVISIONS.get(model_name_or_path)
def _tokenizer_load_kwargs(
model_name_or_path: str, *, revision: str | None = None
) -> dict[str, Any]:
trusted_revision = TRUSTED_REVISIONS.get(model_name_or_path)
if trusted_revision is not None:
if revision is not None and revision != trusted_revision:
raise ValueError(
f"{model_name_or_path!r} executes trusted remote tokenizer code "
f"only at reviewed revision {trusted_revision}; received "
f"revision={revision!r}."
)
return {"trust_remote_code": True, "revision": trusted_revision}
kwargs: dict[str, Any] = {"trust_remote_code": False}
if revision is not None:
return {"trust_remote_code": True, "revision": revision}
return {"trust_remote_code": False}
kwargs["revision"] = revision
return kwargs


def _preserve_requested_tokenizer_name(
Expand Down Expand Up @@ -1281,13 +1286,16 @@ def _load_tokenizer_via_auto(model_name_or_path: str, **kwargs) -> Any:
return tok


def load_tokenizer(model_name_or_path: str):
def load_tokenizer(model_name_or_path: str, *, revision: str | None = None):
"""Load a tokenizer with the renderers-package security policy.

Default ``trust_remote_code=False``. Models listed in
``TRUSTED_REVISIONS`` (Moonshot Kimi-K2 family) load with
``trust_remote_code=True`` AND a pinned ``revision=<sha>`` so
transformers only executes the reviewed commit's tokenizer Python.
Callers may pin ``revision`` for repositories that do not execute remote
code (for example reproducible parity tests). A caller-supplied revision
may not override a reviewed ``TRUSTED_REVISIONS`` entry.

``AutoTokenizer.from_pretrained`` eagerly builds the model config to
resolve the tokenizer class. If that construction raises on a
Expand All @@ -1302,7 +1310,7 @@ def load_tokenizer(model_name_or_path: str):
the requested Meta ID so auto-resolution still selects ``Llama3Renderer``.
"""
load_name_or_path = _tokenizer_source_for(model_name_or_path)
kwargs = _tokenizer_load_kwargs(load_name_or_path)
kwargs = _tokenizer_load_kwargs(load_name_or_path, revision=revision)
tok = _load_tokenizer_via_auto(load_name_or_path, **kwargs)
return _preserve_requested_tokenizer_name(
tok,
Expand Down
2 changes: 2 additions & 0 deletions renderers/deepseek_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
)
from renderers.configs import DeepSeekV3RendererConfig
from renderers.parsing import parse_deepseek_v3
from renderers.tools import normalize_tool_specs

# Fullwidth vertical bar used in DeepSeek special token names.
_SEP = "\uff5c" # | (U+FF5C)
Expand Down Expand Up @@ -125,6 +126,7 @@ def render(
) -> RenderedTokens:
if not messages:
raise ValueError("No messages provided.")
tools = normalize_tool_specs(tools)

tokens: list[int] = []
indices: list[int] = []
Expand Down
2 changes: 2 additions & 0 deletions renderers/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
get_reasoning_parser,
get_tool_parser,
)
from renderers.tools import normalize_tool_specs


def _decode_tool_call_arguments(messages: list) -> list:
Expand Down Expand Up @@ -124,6 +125,7 @@ def render(
tools: list[ToolSpec] | None = None,
add_generation_prompt: bool = False,
) -> RenderedTokens:
tools = normalize_tool_specs(tools)
# Incremental rendering to get per-token message attribution
token_ids: list[int] = []
message_indices: list[int] = []
Expand Down
2 changes: 2 additions & 0 deletions renderers/glm45.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from renderers.configs import GLM45RendererConfig
from renderers.parsing import parse_glm
from renderers.tools import normalize_tool_specs

_TOOLS_HEADER = (
"\n# Tools\n\n"
Expand Down Expand Up @@ -125,6 +126,7 @@ def render(
) -> RenderedTokens:
if not messages:
raise ValueError("No messages provided.")
tools = normalize_tool_specs(tools)

tokens: list[int] = []
indices: list[int] = []
Expand Down
2 changes: 2 additions & 0 deletions renderers/glm5.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
)
from renderers.configs import GLM5RendererConfig, GLM51RendererConfig
from renderers.parsing import parse_glm
from renderers.tools import normalize_tool_specs

_TOOLS_HEADER = (
"\n# Tools\n\n"
Expand Down Expand Up @@ -151,6 +152,7 @@ def render(
) -> RenderedTokens:
if not messages:
raise ValueError("No messages provided.")
tools = normalize_tool_specs(tools)

tokens: list[int] = []
indices: list[int] = []
Expand Down
Loading
Loading