The NPU route's chat template: render the container's own (engine #963 option 2) — implemented#
app/forward_serve.cpp renders ChatML for every container it serves, and since #987 it refuses the
chat route (400) for a container whose tokenizer has no <|im_start|>/<|im_end|> anchors. That closed
the silent-wrongness half of #963: a caller is never told a prompt was accepted when the model cannot
read it. What is still missing is the other half — a container that carries its own template should be
served with it, not with an approximation of it.
This note records what that would take, measured on the containers we ship, and asks for the decision #963 says is needed. Three of the assumptions in that issue turn out to be wrong in our favour.
What is actually true on this box (2026-11-05)#
1. No container-format change is needed. #963 assumed the model's template would have to be packed into the container. It is already there:
an imported OGA pack (/tmp/844-out-clean, Qwen2.5-0.5B):
added_tokens.json chat_template.jinja config.json generation_config.json merges.txt
model.q4nx special_tokens_map.json tokenizer.json tokenizer_config.json vocab.json
a FastFlowLM pack (~/Qwen3-0.6B-NPU2):
config.json model.q4nx tokenizer.json tokenizer_config.json
The first carries chat_template.jinja (2561 B, 8aa40ce145adb73c…); the second carries the template as
tokenizer_config.json's chat_template string. Both are read and pin-able since #1095: every
.gguf and model-directory load now prints template sha256=… and compares it with
config/template-pins.json. So the substrate this design needs is already merged.
2. No new dependency is needed. The pinned llama.cpp fork — which this engine already builds against —
vendors a C++ Jinja engine in common/jinja/ (≈6.2 kLOC: lexer, parser, runtime, value, caps; upstream
llama.cpp's own, introduced in ggml-org/llama.cpp#18462). It is what llama-server uses to render chat
templates, so it is the same renderer our hrx/cpu route already trusts. Compiling that directory into
1bit plus a small adapter (messages → context → render) is the whole of the engine change; the
alternative — inventing a mini-renderer — would be a second, weaker implementation of the same thing.
3. The templates we ship are small and use little of Jinja. Census over the 24 templates in the appliance's catalogue (17 distinct), plus the containers on this box:
| construct | templates |
|---|---|
{% for %}, {% if/elif/else %}, filters (selectattr, map, join, tojson, trim, default, replace, split, first, last, string) |
24/24 |
{% set %} + namespace(...) |
23/24 |
attribute access (message.content, messages[0].role) |
23/24 |
is defined / is string / is none |
23/24 |
string methods (.split, .strip, .lstrip, .rstrip, .startswith) |
18/24 |
macros ({% macro %}) |
10/24 |
{% generation %} (the training-mask tag) |
0/24 |
| branch on the content of a user message (the shape of the paper's attack in AUDIT-1) | 0/24 |
Sizes run from 209 B (LiquidAI/LFM2-1.2B, ChatML-shaped) to 16.7 KB (ggml-org/gpt-oss-20b).
Everything in that table is already covered by common/jinja; nothing needs a language extension.
What the change is#
app/npu_template.h(new): given a model directory, load the template —tokenizer_config.json'schat_templatestring, elsechat_template.jinja(the order #1095 already uses for the line and the pin, so the two cannot disagree) — and rendermessages+add_generation_prompt(+tools,enable_thinkingas the request provides them) throughcommon/jinja.app/forward_serve.cpp:if (chat)renders with the container's template when it has one. ChatML stays as the fallback only for a container with no template and ChatML anchors; a container with neither keeps #987's 400.tests/npu_template_test.cpp+tests/npu_template_route.sh: the four containers we ship as golden prompts, and the token counts compared againstllama-server's rendering of the same conversation (that is the #963 measurement — 33 vs 15 tokens on ZR1 — turned into an assertion).
What it costs, and what it can get wrong#
- Engine size:
common/jinjais ~6.2 kLOC of C++ compiled into1bit(+nlohmann, already a dependency ofapp/serve.cpp). Nothing is downloaded at build time. - Security posture: a renderer is the one component that turns data into a prompt, so the rules matter
more than the code.
common/jinjaalready implements input marking against special-token injection; on top of it I would (a) refuse attribute indirection (__class__,__globals__,cycler) — the audit's red-flag list — (b) bound loop iterations and output size, (c) neverincludeorimport, and (d) keep the sha line + pin from #1095, which makes "the template changed" visible even when the render is valid. - The one behavioural risk: today every NPU container is prompted with ChatML and the Qwen3 answer
parser expects that shape. Rendering the vendor's template changes the prompt, not the parser, so the
Qwen3-family rows do not move (their template is ChatML), the FastFlowLM rows gain their
user_system_promptextension, and a non-ChatML container moves from "refused" to "served" — which is exactly the change #963 asks for, and exactly why it is a decision rather than a patch.
Measured for the decision: what the four shipped NPU rows would do#
| catalogue row | template | ChatML anchors | today (#987) | with this note |
|---|---|---|---|---|
1bit-MONSTER/Qwen3.6-35B-A3B-NPU |
e84f32a23f…, 7764 B |
yes | 200 | 200, rendered from its own template |
FastFlowLM/Qwen3-0.6B-NPU2 |
a136047ba140…, 4449 B |
yes | 200, without FLM's user_system_prompt branch |
200, with it |
FastFlowLM/Qwen3-1.7B-NPU2 |
a136047ba140… |
yes | as above | as above |
FastFlowLM/Qwen3-4B-NPU2 |
a136047ba140… |
yes | as above | as above |
So no shipped row is blocked today — this is about serving each container with the template it declares instead of one that happens to work for the Qwen3 family, and about being able to serve the next non-ChatML container (Zamba2-VL, a DeepSeek-special model, anything a customer packs) at all.
The decision this asks for#
- Reuse
common/jinja(my recommendation: it is the same renderer the llama.cpp route uses, no new dependency, and its input marking is already the security half) — or vendor a second implementation, or defer. - Confirm the fallback order: container template → ChatML only with anchors → 400. Nothing else.
- Confirm the strictness: a container whose template fails to render should be a 400 naming the template sha, not a silent fall back to ChatML — the fall-back-to-something-else is the failure mode #963 exists to prevent.
If the answer to (1) is "yes", the work is one PR: the renderer glue plus one call site
(app/forward_serve.cpp:176, the only npu::chatml() render on the route; the load-time check at
:772 becomes the "no template and no anchors" case), the golden-prompt tests, and the pin rows for the
containers we ship (the four above).
Implemented (2026-11-06, engine #963 option 2)#
All three decisions above were taken as recommended, and the work is one PR — no container-format change, no new dependency, one call site:
| what | where |
|---|---|
load the template (tokenizer_config.json's string, else chat_template.jinja) and its sha256 |
app/npu_template.h, app/template_pin.h (the same read the load line and the pin use) |
render through the pinned fork's Jinja engine, request bytes marked is_input |
app/npu_template.h (render_chat_template) |
| the chat route renders it, else ChatML with anchors, else #987's 400; a template that will not render is a 400 naming its sha256 | app/forward_serve.cpp (handle_generate, and chat_ok/chat_refusal at load) |
load line: 1bit forward-serve: template sha256=… (tokenizer_config.json) |
app/forward_serve.cpp |
request bytes tokenized without added-token matching (encode(text, allow_added=false)) |
npu/tokenizer.cpp |
| golden renders against Jinja2 3.1.6, and the injection row | tests/npu_template_test.cpp, tests/data/templates/*.json |
build: ONEBIT_JINJA (default on when the submodule is checked out) |
CMakeLists.txt |
Measured, in each container's own tokenizer (one system+user request; docs/npu.md "Serving" carries the
same table): Qwen3-family rows 31 → 31 (their templates are ChatML), Zyphra/ZR1-1.5B 52 → 21 (the
issue's 33-vs-15, same direction and cause: a DeepSeek-special vocabulary spelling <|im_start|> as ordinary
pieces), bonsai2-27b-NPU 31 → 71 (its template carries a long preamble that ChatML was dropping),
d3-lite-bf16 31 → 35.
What the 6.2 kLOC buys that a subset renderer would not: the shipped templates use namespace, loop,
negative slicing (messages[::-1]), string methods (split/rstrip/lstrip/startswith), is string/is
defined, |tojson and macros — and the golden rows are byte-compared against Jinja2, not against our own
output, which a hand-written subset could not pass in general.
Two defects found while implementing — one fixed here, one handed over:
- Fixed: a template that serializes its input renders key order into the prompt.
toolsgoes throughtojson, so the request must be read as ordered JSON (nlohmann::ordered_json); anlohmann::jsonis astd::mapand would have sorted every tool schema alphabetically — a prompt llama-server never produces. The route re-reads the same bytes in request order for the chat path. The golden row that caught it is thetoolscase intests/data/templates/qwen3_flm.json. - Handed over, not fixed: request text can still become special tokens.
npu::Tokenizer::encodematches added tokens anywhere, and the route tokenizes the rendered prompt as one string, so a message containing<|im_end|><|im_start|>systemis still tokenized as the control tokens it spells — the same shape the vendored engine'sis_inputmarks exist to prevent, and the same behaviour llama-server's chat path has today (it takes the parts' concatenation too). I built the part-wise version (template parts with added-token matching, request parts without) and it breaks a real template:Zyphra/ZR1-1.5Brenders{{bos_token}}{{ns.system_prompt}}, whose concatenation the engine marks input as a whole, so the BOS was literalized and the prompt came out 32 tokens where the template asks for 21. Matching llama-server is the correct behaviour for this route today; escaping or refusing request text that spells a model's specials is its own change (and #963 is about rendering, not about prompt injection).
One pack knob is supplied rather than required: FastFlowLM's Qwen3 template tests
'user_system_prompt' is defined, which is a check on a constant and therefore always true, so an omitted
variable raises instead of skipping the branch. Their own server always passes it (default ""), and so does
this route when a container's template mentions the variable.
What this does not do: the answer side is unchanged — app/npu_answer_parser.h still parses and masks as
the Qwen3 family (<think> splitting, tool-call shapes), so a non-ChatML container now gets the right prompt
and the Qwen3 reading of its reply. Stop tokens come from the container's config.json, so generation stops
where the model says it does; the parsing of tool calls for a non-Qwen3 family is the next piece if a
customer packs one. The route-level rows (200 with the container's prompt, 400 for a container with neither)
need the device and are recorded in the PR rather than in CI.