Skip to content

Commit 4878ea7

Browse files
Boris PeyriguereBoris Peyriguere
authored andcommitted
Render MLX chat prompts from standalone Jinja
1 parent bb084ea commit 4878ea7

7 files changed

Lines changed: 143 additions & 7 deletions

File tree

complexity/inference/chat_template.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from pathlib import Path
77
from typing import Any, Iterable
88

9+
from jinja2 import Environment, StrictUndefined
10+
911
CHAT_TEMPLATE_ID = "complexity-chat-v2"
1012
LEGACY_CHAT_TEMPLATE_ID = "complexity-chat-v1"
1113
SUPPORTED_CHAT_TEMPLATE_IDS = {LEGACY_CHAT_TEMPLATE_ID, CHAT_TEMPLATE_ID}
@@ -89,6 +91,61 @@ def load_chat_template(path: str | Path | None = None) -> dict[str, Any]:
8991
return validate_chat_template(json.loads(candidate.read_text(encoding="utf-8")))
9092

9193

94+
def load_chat_template_jinja(path: str | Path) -> str:
95+
"""Load the standalone Jinja contract shipped beside a model bundle."""
96+
97+
candidate = Path(path)
98+
if candidate.is_dir():
99+
candidate = candidate / "chat_template.jinja"
100+
if not candidate.is_file():
101+
raise FileNotFoundError(
102+
f"Model bundle has no standalone Jinja chat template: {candidate}"
103+
)
104+
source = candidate.read_text(encoding="utf-8")
105+
if not source.strip():
106+
raise ValueError(f"Jinja chat template is empty: {candidate}")
107+
return source
108+
109+
110+
def render_jinja_messages(
111+
messages: Iterable[dict[str, Any]],
112+
jinja_source: str,
113+
*,
114+
eos_token: str,
115+
add_generation_prompt: bool,
116+
) -> str:
117+
"""Render the same standalone Jinja contract used by HF and vLLM."""
118+
119+
if not isinstance(eos_token, str) or not eos_token:
120+
raise ValueError("A non-empty tokenizer EOS token is required for Jinja rendering")
121+
environment = Environment(
122+
autoescape=False,
123+
undefined=StrictUndefined,
124+
keep_trailing_newline=True,
125+
)
126+
return environment.from_string(jinja_source).render(
127+
messages=list(messages),
128+
eos_token=eos_token,
129+
add_generation_prompt=add_generation_prompt,
130+
)
131+
132+
133+
def render_jinja_inference_prompt(
134+
user_content: str,
135+
jinja_source: str,
136+
*,
137+
eos_token: str,
138+
) -> str:
139+
"""Render one user turn and the assistant generation prefix via Jinja."""
140+
141+
return render_jinja_messages(
142+
[{"role": "user", "content": user_content}],
143+
jinja_source,
144+
eos_token=eos_token,
145+
add_generation_prompt=True,
146+
)
147+
148+
92149
def render_system_prefix(template: dict[str, Any]) -> str:
93150
system_prompt = str(template["system_prompt"]).strip()
94151
if not system_prompt:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ dependencies = [
5252
"tqdm>=4.0.0",
5353
"wandb>=0.15.0",
5454
"safetensors>=0.4.0", # save_pretrained + checkpointing
55+
"jinja2>=3.0.0", # standalone HF/vLLM/MLX chat-template rendering
5556
"pyyaml>=6.0", # imported at module load in config/model_config.py
5657
"typer>=0.9.0", # CLI (complexity.cli)
5758
]

scripts/convert_pt_to_mlx.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from complexity.inference.chat_template import (
2323
default_chat_template,
24+
huggingface_chat_template,
2425
validate_chat_template,
2526
)
2627

@@ -186,6 +187,10 @@ def write_chat_template(checkpoint: dict, output: Path) -> dict:
186187
json.dumps(template, indent=2, sort_keys=True) + "\n",
187188
encoding="utf-8",
188189
)
190+
(output / "chat_template.jinja").write_text(
191+
huggingface_chat_template(template) + "\n",
192+
encoding="utf-8",
193+
)
189194
return template
190195

191196

@@ -213,6 +218,14 @@ def copy_bundle_chat_template(source: Path, output: Path) -> dict:
213218
json.dumps(template, indent=2, sort_keys=True) + "\n",
214219
encoding="utf-8",
215220
)
221+
source_jinja = source / "chat_template.jinja"
222+
if source_jinja.is_file():
223+
shutil.copy2(source_jinja, output / "chat_template.jinja")
224+
else:
225+
(output / "chat_template.jinja").write_text(
226+
huggingface_chat_template(template) + "\n",
227+
encoding="utf-8",
228+
)
216229
return template
217230

218231

scripts/eval_mlx_chat_panel.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ def generate_one(
5656
) -> str:
5757
rendered = prompt
5858
if not raw_prompt:
59-
rendered, _ = render_bundle_prompt(Path(generation["model_dir"]), prompt)
59+
rendered, _ = render_bundle_prompt(
60+
Path(generation["model_dir"]),
61+
prompt,
62+
eos_token=tokenizer.eos_token,
63+
)
6064
mx.random.seed(0)
6165
sampler = make_sampler(
6266
temp=float(generation["temperature"]),

scripts/export_tr_hash_vllm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def build_config(raw: dict, chat_template: dict | None = None) -> dict:
147147
config["use_shared_routed_gates"] = False
148148
if chat_template is not None:
149149
config["chat_template_id"] = chat_template["id"]
150-
config["chat_template_file"] = "chat_template.json"
150+
config["chat_template_file"] = "chat_template.jinja"
151151
return config
152152

153153

scripts/mlx_generate.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,28 @@
1818

1919
from complexity.inference.chat_template import (
2020
load_chat_template,
21-
render_inference_prompt,
21+
load_chat_template_jinja,
22+
render_jinja_inference_prompt,
2223
)
2324

2425

25-
def render_bundle_prompt(model_dir: Path, user_message: str) -> tuple[str, str]:
26+
def render_bundle_prompt(
27+
model_dir: Path,
28+
user_message: str,
29+
*,
30+
eos_token: str,
31+
) -> tuple[str, str]:
2632
template_path = model_dir / "chat_template.json"
2733
if not template_path.is_file():
2834
raise FileNotFoundError(f"MLX bundle has no chat template: {template_path}")
2935
template = load_chat_template(template_path)
30-
return render_inference_prompt(user_message, template), str(template["id"])
36+
jinja_source = load_chat_template_jinja(model_dir)
37+
rendered = render_jinja_inference_prompt(
38+
user_message,
39+
jinja_source,
40+
eos_token=eos_token,
41+
)
42+
return rendered, str(template["id"])
3143

3244

3345
def main():
@@ -75,7 +87,11 @@ def main():
7587
tokenizer = load_tokenizer(args.model_dir)
7688
print(f"Ready. eos_token_id={tokenizer.eos_token_id}\n")
7789
if args.raw_prompt is None:
78-
rendered_prompt, template_id = render_bundle_prompt(args.model_dir, args.prompt)
90+
rendered_prompt, template_id = render_bundle_prompt(
91+
args.model_dir,
92+
args.prompt,
93+
eos_token=tokenizer.eos_token,
94+
)
7995
print(f"Chat template: {template_id}")
8096
else:
8197
rendered_prompt = args.raw_prompt

tests/test_chat_template.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
import json
44
from pathlib import Path
55

6+
import pytest
7+
68
from complexity.inference.chat_template import (
79
CHAT_TEMPLATE_ID,
810
LEGACY_CHAT_TEMPLATE_ID,
911
THINK_FINAL_ENVELOPE,
1012
default_chat_template,
1113
huggingface_chat_template,
14+
load_chat_template_jinja,
1215
render_assistant_envelope,
1316
render_inference_prompt,
17+
render_jinja_inference_prompt,
18+
render_jinja_messages,
1419
render_messages_before_assistant,
1520
validate_chat_template,
1621
)
@@ -144,6 +149,43 @@ def test_huggingface_template_matches_explicit_system_message() -> None:
144149
assert rendered == render_messages_before_assistant(messages, contract)
145150

146151

152+
def test_standalone_jinja_renderer_matches_hf_and_vllm_contract(tmp_path) -> None:
153+
contract = default_chat_template()
154+
source = huggingface_chat_template(contract)
155+
(tmp_path / "chat_template.jinja").write_text(source, encoding="utf-8")
156+
157+
rendered = render_jinja_messages(
158+
[
159+
{"role": "user", "content": "First"},
160+
{"role": "assistant", "content": "Answer"},
161+
{"role": "user", "content": "Follow-up"},
162+
],
163+
load_chat_template_jinja(tmp_path),
164+
eos_token="</s>",
165+
add_generation_prompt=True,
166+
)
167+
168+
assert rendered == (
169+
"User:\nFirst\n\nAssistant:\nAnswer</s>"
170+
"User:\nFollow-up\n\nAssistant:\n"
171+
)
172+
173+
174+
def test_standalone_jinja_renderer_builds_mlx_generation_prompt() -> None:
175+
rendered = render_jinja_inference_prompt(
176+
"Hello",
177+
huggingface_chat_template(default_chat_template()),
178+
eos_token="</s>",
179+
)
180+
181+
assert rendered == "User:\nHello\n\nAssistant:\n"
182+
183+
184+
def test_standalone_jinja_loader_rejects_incomplete_mlx_bundle(tmp_path) -> None:
185+
with pytest.raises(FileNotFoundError, match="standalone Jinja"):
186+
load_chat_template_jinja(tmp_path)
187+
188+
147189
def test_vllm_config_declares_exported_template() -> None:
148190
template = default_chat_template()
149191
config = build_config(
@@ -158,7 +200,7 @@ def test_vllm_config_declares_exported_template() -> None:
158200
template,
159201
)
160202
assert config["chat_template_id"] == CHAT_TEMPLATE_ID
161-
assert config["chat_template_file"] == "chat_template.json"
203+
assert config["chat_template_file"] == "chat_template.jinja"
162204

163205

164206
def test_vllm_export_preserves_legacy_modulo_cyclic_routing() -> None:
@@ -304,6 +346,9 @@ def test_mlx_export_preserves_checkpoint_chat_template(tmp_path) -> None:
304346

305347
assert written == template
306348
assert (tmp_path / "chat_template.json").read_text().endswith("\n")
349+
assert (tmp_path / "chat_template.jinja").read_text(encoding="utf-8") == (
350+
huggingface_chat_template(template) + "\n"
351+
)
307352
assert (
308353
__import__("json").loads(
309354
(tmp_path / "chat_template.json").read_text()

0 commit comments

Comments
 (0)