Skip to content

docs: what rendering the NPU container's own chat template takes (engine #963 option 2) - #1115

Merged
bong-water-water-bong merged 2 commits into
mainfrom
i-tmplsec-npu-template-note
Nov 5, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
i-tmplsec-npu-template-note

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

What

A design note for #963 option 2 (render the NPU container's own chat_template), which that issue
left as an owner decision because it looked like a container-format change plus a new Jinja dependency.
Measured on this box, it is neither — so the decision is much smaller than it looked.

Doc only: docs/npu-chat-template.md. No code, no behaviour change.

The three findings that shrink it

  1. The containers already carry the template. An imported OGA pack has chat_template.jinja; a
    FastFlowLM pack has tokenizer_config.json's chat_template. serve: name a model directory's chat template too, from its tokenizer_config.json (closes #1091) #1095 (merged) already reads it to log
    and pin its sha256, so the substrate exists.
  2. The renderer is already in the tree we pin. llama.cpp's fork vendors a C++ Jinja engine in
    common/jinja/ (~6.2 kLOC, the one llama-server renders with). Reusing it means no new dependency and
    the same renderer our hrx/cpu route already trusts.
  3. The templates use little of Jinja. Census over the 24 templates in the appliance catalogue:
    for/if/set+namespace/filters/attribute access/string methods/macros cover all of them;
    {% generation %} and content-gated branches on a user message appear in none. Sizes 209 B – 16.7 KB.

What the note records

Why this lane

The template audit (AUDIT-1, and the load-time line + pin in #1089/#1095) established what the
containers actually carry and made the template visible and checkable. This is the other end of the same
seam: the route that runs them.

…ine #963 option 2)

`app/forward_serve.cpp:176` renders ChatML for every container, and #987 refuses the chat route for a
container whose tokenizer has no ChatML anchors — so a non-ChatML container cannot be chatted with at
all today. #963's option 2 (render the container's own template) was left as an owner decision because
it looked like a container-format change plus a new Jinja dependency. Measured on this box, it is
neither:

- the containers already carry their template (`chat_template.jinja` in an imported OGA pack,
  `tokenizer_config.json`'s `chat_template` in a FastFlowLM pack), and #1095 already reads it to log and
  pin its sha256;
- the pinned llama.cpp fork already vendors a C++ Jinja engine (`common/jinja/`, ~6.2 kLOC, the one
  `llama-server` renders with), so the route can reuse it instead of growing a second implementation;
- the templates we ship use a small part of Jinja: `for`/`if`/`set`+`namespace`/filters/attribute
  access/string methods/macros in 24/24, 23/24, 18/24 and 10/24 templates; `{% generation %}` and
  content-gated branches in **none**. Sizes 209 B - 16.7 KB.

The note records that, the four shipped NPU catalogue rows with what changes for each (all four are
ChatML-anchored today, so nothing is blocked — this is about serving each container the template it
declares, including FastFlowLM's `user_system_prompt` branch, and about the next non-ChatML container),
the security rules the renderer must carry, and the three decisions it asks for: reuse `common/jinja`,
container-template -> ChatML-only-with-anchors -> 400 fallback order, and a template that fails to render
being a 400 naming its sha rather than a silent fall back.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1115 ready for review at 0a3d328 — doc-only (1 file, +125), no code and no behaviour change. It is the design note #963's option 2 says it needs: the three costs the issue assumed (container-format change, new Jinja dependency, a big subset of Jinja) measured against this box and against the containers we actually ship. Commented on #963 with the same summary so the two are linked.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 0a3d3280 — docs only, and the one finding I could check independently is true.

Finding 1 — "the containers already carry the template" — is the one I have evidence for, from verifying #1095 on this box: for the OGA packs we import, tokenizer_config.json carries no chat_template key at all and the template lives only in chat_template.jinja beside it (I ran the tool over the imported Qwen2.5-0.5B container: JSON branch dead, .jinja 2 561 B, sha 8aa40ce1…). So the substrate for #963 option 2 does exist, and the note's premise that this is "neither a container-format change nor a new Jinja dependency" starts from a fact rather than from an assumption.

A design note that exists to shrink an owner decision — "it looked like a format change plus a dependency; measured, it is neither" — is worth having even when it ships no code, because the cost of the decision is the thing that was blocking it.

No code, no behaviour change, one new file (docs/npu-chat-template.md), so there is nothing here to regress. I did not verify findings 2 and 3 (the renderer already being in the tree) beyond reading them.

@bong-water-water-bong
bong-water-water-bong merged commit e13900a into main Nov 5, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the i-tmplsec-npu-template-note branch November 5, 2026 15:18
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

963 - Partially compliant

Compliant requirements:

  • The chat route now refuses containers whose tokenizer has no ChatML anchors with a 400 error
  • The fix is minimal and doesn't change working Qwen3/Qwen2-Chat rows
  • The solution checks for ChatML anchors in the tokenizer and refuses the chat route if they're missing

Non-compliant requirements:

  • The full implementation of rendering the container's own chat template (option 2) is not included in this PR

Requires further human verification:

  • The decision on whether to implement option 2 (rendering container's own chat template) needs to be made by the owners
⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Design Note Completeness

The document describes the potential implementation of rendering the container's own chat template but is presented as a design note rather than an actual implementation. While it provides a detailed analysis of what would be needed, it doesn't actually implement the feature. This could be misleading as it suggests a complete solution when it's only a proposal.

<!--
Copyright 2026 bong-water-water-bong
SPDX-License-Identifier: Apache-2.0

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
# The NPU route's chat template: render the container's own (engine #963 option 2)

`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

1. `app/npu_template.h` (new): given a model directory, load the template — `tokenizer_config.json`'s
   `chat_template` string, else `chat_template.jinja` (the order #1095 already uses for the line and the
   pin, so the two cannot disagree) — and render `messages` + `add_generation_prompt` (+ `tools`,
   `enable_thinking` as the request provides them) through `common/jinja`.
2. `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.
3. `tests/npu_template_test.cpp` + `tests/npu_template_route.sh`: the four containers we ship as golden
   prompts, and the token counts compared against `llama-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/jinja` is ~6.2 kLOC of C++ compiled into `1bit` (+nlohmann, already a
  dependency of `app/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/jinja` already 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) never `include` or `import`, 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_prompt` extension, 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

1. **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.
2. **Confirm the fallback order**: container template → ChatML only with anchors → 400. Nothing else.
3. **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).

bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…pen PR would add

The reviewer's blocker, and it is the same class as the text being fixed: the paragraph said the digests
for the four rows "are already measured — models/pins.json records the template of every file we serve and
publish", and `models/pins.json` does not exist in this tree (`ls` fails, `git cat-file -e
origin/main:models/pins.json` fails). It is engine #1148, open, so a reader on `main` was sent to a path
that is not there, and the "already measured" claim rested on an unmerged working tree — including the one
digest that exists nowhere else yet (`short`'s 57f1fd00f0…).

Now the paragraph says what a reader can check today:

  - three of the four rows are in `config/template-pins.json` **now** — zaya1-8b-q4_k_m.gguf
    b55ecfbbbc…, qwen3-coder-30b-a3b-instruct-q4_k_m.gguf 87710339d2…, qwen3.8-27b-ud-q4_k_xl.gguf
    12827f24b7… — because they are the same files the load-time check already pins (verified against the
    tree at this head);
  - the fourth, `short`'s Qwen3-0.6B-Q8_0.gguf 57f1fd00f0…, is **not in any table in this tree yet**; it is
    measured, it is added by #1158, and it is recorded with every other served/published file in #1148's
    models/pins.json — said as such rather than implied to be here.

The Q4NX sentence now links docs/npu-chat-template.md, which is in this tree (#1115). Nothing else in the
paragraph changed: what runs today, what it catches, and what step 3 adds all verified in the review.
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…one that does not (#1157)

* docs/laya-first: say which template check runs today, and that the per-row digest is step 3

The section ended with a present-tense claim the tree does not meet: that
`~/.local/share/1bit/laya-first.json` "ships with the digests measured on
2026-11-05" and that "the table's validator (§8 step 3) fails" a row that skips
one. Measured on main: `config/laya-first.json` — the file compiled in as
`laya_first_default.h` — has no `template_sha256` in any of its four rows,
nothing in `app/`, `src/`, `tests/`, `include/` or `os/` reads one, and no
installed `<laya home>/laya-first.json` exists on the c25/c26 media, the staging
trees or the box (§8 step 3 is where that field and the pull-time check are
scheduled). A design note that asserts a check which does not run is the exact
failure this row is supposed to prevent, so the paragraph now states what is
true:

  - the one template check on a routed pull today is the load-time one, against
    the engine's `config/template-pins.json` — 7 names, three of them these rows;
  - that check warns unless the row ships `--template-pin-strict` (§9), and it
    catches one thing: the same name carrying a different template;
  - the field, the per-row requirement and the pull-time refusal are §8 step 3,
    and the digests they will need are already measured — `models/pins.json`
    records 57f1fd00f0… (short), b55ecfbbbc… (prose), 87710339d2… (code),
    12827f24b7… (long_doc).

Doc only; no code, no table and no operand changed.

* docs/laya-first: cite the digests that are in this tree, not one an open PR would add

The reviewer's blocker, and it is the same class as the text being fixed: the paragraph said the digests
for the four rows "are already measured — models/pins.json records the template of every file we serve and
publish", and `models/pins.json` does not exist in this tree (`ls` fails, `git cat-file -e
origin/main:models/pins.json` fails). It is engine #1148, open, so a reader on `main` was sent to a path
that is not there, and the "already measured" claim rested on an unmerged working tree — including the one
digest that exists nowhere else yet (`short`'s 57f1fd00f0…).

Now the paragraph says what a reader can check today:

  - three of the four rows are in `config/template-pins.json` **now** — zaya1-8b-q4_k_m.gguf
    b55ecfbbbc…, qwen3-coder-30b-a3b-instruct-q4_k_m.gguf 87710339d2…, qwen3.8-27b-ud-q4_k_xl.gguf
    12827f24b7… — because they are the same files the load-time check already pins (verified against the
    tree at this head);
  - the fourth, `short`'s Qwen3-0.6B-Q8_0.gguf 57f1fd00f0…, is **not in any table in this tree yet**; it is
    measured, it is added by #1158, and it is recorded with every other served/published file in #1148's
    models/pins.json — said as such rather than implied to be here.

The Q4NX sentence now links docs/npu-chat-template.md, which is in this tree (#1115). Nothing else in the
paragraph changed: what runs today, what it catches, and what step 3 adds all verified in the review.

---------

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
bong-water-water-bong added a commit that referenced this pull request Nov 6, 2026
…te, not ChatML for every container (#1298)

`app/forward_serve.cpp` rendered `npu::chatml()` for every NPU container; #987 made that honest
(a container whose tokenizer has no `<|im_start|>`/`<|im_end|>` anchors gets a 400 instead of a
prompt its vocabulary cannot read), but a container that *declares* its template was still served an
approximation of it.  This is #963 option 2, the shape #1115 asked a decision for: no container-format
change, no new dependency, one call site.

The template is already in the container (`tokenizer_config.json`'s `chat_template` string, else
`chat_template.jinja`), and the renderer is the pinned llama.cpp fork's own Jinja engine
(`third_party/llama.cpp/common/jinja`, the one `llama-server --jinja` uses).  `app/npu_template.h`
loads the template with the same read `app/template_pin.h` already did for the load line and the pin
(so the rendered prompt, the printed sha256 and the pin cannot disagree) and renders it; a template
that cannot be rendered is a 400 naming its sha256, never a fall back to ChatML.

Measured in each container's own tokenizer, one system+user request, before -> after:

  Qwen3-0.6B-NPU2 (a136047ba140) 31 -> 31     ZR1-1.5B (b6835114b730) 52 -> 21
  Qwen3-0.6B     (a55ee1b16601) 31 -> 31     bonsai2-27b (c3cf9e34abf4) 31 -> 71
  qwen3-coder    (87710339d25b) 31 -> 31     d3-lite-bf16 (273d8e0e683b) 31 -> 35

and served, on the NPU (npu-hold, one ticket, released clean):

  before (build with ONEBIT_JINJA=OFF, i.e. main's behaviour): ZR1 chat -> 400 "no <|im_start|>"
  after:                                                      ZR1 chat -> 200, prompt_tokens 21

Two defects found on the way, both in this PR: the request must be read as *ordered* JSON for the chat
path (a `nlohmann::json` is a std::map, so a `tools` block serialized through `tojson` would have been
alphabetized -- a prompt llama-server never produces), and `add_bos` must not prepend a second BOS when
the template already emitted it.

Tests: `tests/npu_template_test.cpp` compares the rendered bytes against **Jinja2 3.1.6 itself** for 11
cases across two shipped templates (Qwen3/FLM's and ZR1's DeepSeek-special one), plus the two refusal
rows; `--self-test`-style, device-free, no model.  `tests/data/templates/*.json` carry the fixtures and
where each byte came from.

Build: `ONEBIT_JINJA`, default on when `third_party/llama.cpp` is checked out (a tree without submodules
still builds, keeps #987's behaviour and says so at load); ci.yml checks out that submodule (shallow,
8 s) and builds the test, so the renderer cannot reach a green build unbuilt.  Docs: `docs/npu.md`
("Serving") and `docs/npu-chat-template.md`, which also records what this does *not* do: the answer
side still parses as the Qwen3 family, and request text that spells a model's control tokens is still
tokenized as those tokens (llama.cpp's own behaviour; the part-wise fix I built literalizes a real
template's BOS, measured 32 vs 21).

Fixes #963

Co-authored-by: x <x@x>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant