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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`
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) -> RenderedTokens | None: ...
```
Expand All @@ -62,6 +62,21 @@ class Renderer(Protocol):
- `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 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.
Expand Down
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",
]
10 changes: 2 additions & 8 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
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
2 changes: 2 additions & 0 deletions renderers/gpt_oss.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
)
from renderers.configs import GptOssRendererConfig
from renderers.parsing import parse_gpt_oss
from renderers.tools import normalize_tool_specs


def _reasoning_effort(effort: str | None) -> ReasoningEffort:
Expand Down Expand Up @@ -269,6 +270,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/hy3.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
)
from renderers.configs import Hy3RendererConfig, ResolvedThinkingRetention
from renderers.parsing import parse_hy3
from renderers.tools import normalize_tool_specs

# Special-token strings, constructed exactly as the Jinja template does
# (``'<|hy_eos{}|>'.format(':opensource')`` etc.) so ``convert_tokens_to_ids``
Expand Down Expand Up @@ -277,6 +278,7 @@ def render(
) -> RenderedTokens:
if not messages:
raise ValueError("No messages provided.")
tools = normalize_tool_specs(tools)

# fallback_strategy="reasoning_toolcall_retry" suppresses the gen prompt.
if self._force_no_gen_prompt:
Expand Down
2 changes: 2 additions & 0 deletions renderers/kimi_k2.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
)
from renderers.configs import KimiK2RendererConfig
from renderers.parsing import parse_kimi_k2
from renderers.tools import normalize_tool_specs

_DEFAULT_SYSTEM = "You are Kimi, an AI assistant created by Moonshot AI."

Expand Down Expand Up @@ -122,6 +123,7 @@ def render(
) -> RenderedTokens:
if not messages:
raise ValueError("No messages provided.")
tools = normalize_tool_specs(tools)

# Preserve the caller's list — ``message_roles`` and per-token
# attribution refer to this frame (not the post-normalisation
Expand Down
6 changes: 4 additions & 2 deletions renderers/kimi_k25.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

import json
import re
from typing import Any
from typing import Any, cast

from transformers.tokenization_utils import PreTrainedTokenizer

Expand All @@ -44,6 +44,7 @@
)
from renderers.configs import KimiK25RendererConfig
from renderers.parsing import _reasoning_end_token_index, parse_kimi_k2_section
from renderers.tools import normalize_tool_specs
from renderers.qwen3_vl import (
_image_hash,
_is_image_part,
Expand Down Expand Up @@ -393,7 +394,7 @@ def _encode_tools_typescript(tools: list[ToolSpec]) -> str:
func_def_dict = tool
if not func_def_dict:
continue
func_def = _function_to_typescript(func_def_dict)
func_def = _function_to_typescript(cast(dict[str, Any], func_def_dict))
if func_def:
functions.append(func_def)
if not functions:
Expand Down Expand Up @@ -749,6 +750,7 @@ def render(
"""
if not messages:
raise ValueError("No messages provided.")
tools = normalize_tool_specs(tools)

# Hist/suffix split — assistants up to and including the last
# non-tool-call assistant strip reasoning_content, those after
Expand Down
3 changes: 3 additions & 0 deletions renderers/laguna_xs2.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
)
from renderers.configs import LagunaXS2RendererConfig, LagunaXS21RendererConfig
from renderers.parsing import parse_laguna_xs2
from renderers.tools import normalize_tool_specs

_DEFAULT_SYSTEM_MESSAGE = (
"You are a helpful, conversationally-fluent assistant made by Poolside. "
Expand Down Expand Up @@ -177,6 +178,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 Expand Up @@ -646,6 +648,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/llama_3.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
)
from renderers.configs import Llama3RendererConfig
from renderers.parsing import parse_llama_3
from renderers.tools import normalize_tool_specs

# ---------------------------------------------------------------------------
# Constants — must match the Jinja chat template's literal strings exactly.
Expand Down Expand Up @@ -183,6 +184,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/minimax_m2.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
)
from renderers.configs import MiniMaxM2RendererConfig
from renderers.parsing import parse_minimax
from renderers.tools import normalize_tool_specs

_TOOLS_HEADER = (
"\n\n# Tools\n"
Expand Down Expand Up @@ -112,6 +113,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
9 changes: 6 additions & 3 deletions renderers/nemotron3.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
from __future__ import annotations

import json
from typing import Any
from collections.abc import Mapping
from typing import Any, cast

from transformers.tokenization_utils import PreTrainedTokenizer

Expand All @@ -33,6 +34,7 @@
)
from renderers.configs import Nemotron3RendererConfig, Nemotron3UltraRendererConfig
from renderers.parsing import parse_qwen35
from renderers.tools import normalize_tool_specs

# ---------------------------------------------------------------------------
# Tool system prompt constants
Expand Down Expand Up @@ -60,7 +62,7 @@
)


def _render_extra_keys(obj: dict[str, Any], handled_keys: set[str]) -> list[str]:
def _render_extra_keys(obj: Mapping[str, Any], handled_keys: set[str]) -> list[str]:
"""Render extra dict keys as XML, mirroring the HF template's render_extra_keys macro.

Dicts and lists are JSON-encoded; scalars are string-coerced.
Expand Down Expand Up @@ -208,7 +210,7 @@ def _format_tool_declaration(tool: ToolSpec) -> str:
# Accept the OpenAI-style ``{"type":"function","function":{...}}``
# envelope by unwrapping before formatting.
if "function" in tool and isinstance(tool["function"], dict):
tool = tool["function"]
tool = cast(ToolSpec, tool["function"])
lines = [
"<function>",
f"<name>{tool['name']}</name>",
Expand Down Expand Up @@ -279,6 +281,7 @@ def render(
) -> RenderedTokens:
if not messages:
raise ValueError("No messages provided.")
tools = normalize_tool_specs(tools)

original_messages = list(messages)
# Always ensure an empty system message is present.
Expand Down
Loading
Loading