Repository navigation
serve: name the GGUF's chat-template sha256 at every load, and refuse a template that is not the pinned one - #1089
Conversation
|
#1089 ready for review at 067be56 (branch |
|
The Q4NX/NPU half of the same seam is filed as #1091 (a model directory's template lives in |
… a template that is not the pinned one A GGUF's `tokenizer.chat_template` is a Jinja2 program the llama.cpp-family backends run on every request, so a template that is not the source model's can rewrite the system context, add an instruction or point an agent at a URL with no weight change (Pillar Security's template-backdoor work). Nothing in the engine named it: `1bit serve` loaded a GGUF and never said which template the backend would execute, and a re-pull that changed the template was invisible. - app/gguf_meta.h: `gguf_string_meta(path, key)`, a string value from the metadata header only (`gguf_architecture` now delegates to it, no duplicated walk). - app/template_pin.h: the template's sha256, the pin table, and the verdict. - `1bit serve` prints one line per load, `1bit serve: template sha256=<sha>`, and compares it with the pin: mismatch warns and still serves; `--template-pin-strict` (or ONEBIT_TEMPLATE_PIN_STRICT=1) refuses the load. `--template-pin FILE` replaces the built-in table, `--no-template-pin` logs the sha and compares nothing. A model with no template and no pin prints nothing, so the route tests that count lines (tests/mem_admit_route.sh) are unchanged. - config/template-pins.json: the 7 models whose file on this box carries exactly the template their source model has (measured 2026-11-05); a pin matches `--alias` (Lemonade's catalog entry) or the file's name, and `"none"` pins "must carry no template". - docs/serve.md "Chat template": the line, the pin, the flags, and what is not covered (a Q4NX model directory, docs/laya-first.md). - docs/template-publishing.md: the publishing rule (card row, SHA256SUMS line, UPLOAD-READY row, the served-side check), the verified extraction commands, and the CycloneDX "the template is a signed part" proposal. - docs/laya-first.md: a routed row gains a required `template_sha256`, checked at pull time (`why: template_mismatch` in `x-lemonade-route`) and again by the engine pin at load. - tests: `gguf_meta_test` covers the new reader (long value after arrays, first match, named key, empty value, wrong type, not a GGUF); new `tests/template_pin.sh` covers the line, the pin, the strict refusal, the env form, `--alias` matching, `"none"`, the built-in table, and the three bad pin files, without a GPU. Measured (this box, 2026-11-05): `1bit serve -m ~/models/zaya1-8b-Q4_K_M.gguf --print-backend` → `1bit serve: template sha256=b55ecfbbbcbb238a011465bfa139366db18790103f9205060cf2855a3f51b30f`, which is the pin, and a strict run on the same file exits 0 with no warning; the 16 test assertions and the 14 `gguf_meta_test` cases pass; print_backend, mem_admit_route, recipes_route and device_route still pass unchanged. Brief: TTEMPLATE-SEC item 3.
067be56 to
9c053d0
Compare
|
Rebased onto main |
|
Two more paths checked on |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9c053d0c — the sha256 is real (independently computed), all four pin behaviours reproduce, and a model with no template prints nothing. One structural note on what a pin can and cannot catch.
The hash is the right hash, checked three ways. I read tokenizer.chat_template out of the GGUF myself with gguf-py and hashed it, rather than believing the log or the table:
sha256(tokenizer.chat_template of ZR1-1.5B-bf16.gguf) = b6835114b7303ddd78919a82e4d9f7d8c26ed0d7dfc36beeb12d524f6144eab1
config/template-pins.json "zr1-1.5b-q4_k_m.gguf" / "zr1-1.5b-q8_0.gguf" = b6835114b7303ddd… <- same template, both packagings
1bit serve log = 1bit serve: template sha256=b6835114b7303ddd… (ZR1-1.5B-bf16)
So the committed pin is the template a real ZR1 file on this box carries — the table is not decoration.
The four behaviours, run at this head on --device cpu:
no pin in the table -> template sha256=b6835114… | ready on http://127.0.0.1:18632 (logs, compares nothing)
pin matches -> template sha256=b6835114… (no warning)
pin mismatches -> warning: template pin: ZR1-1.5B-bf16 (zr1-1.5b-bf16.gguf) expects 0000…,
the GGUF carries b683… in tokenizer.chat_template (serves anyway)
mismatch + --template-pin-strict
-> same message + "refused, --template-pin-strict" (exit 1, no serve)
and a GGUF with no template (BlackMamba-2.8B-bf16.gguf): grep -c template over the whole load log = 0, which is what keeps tests/mem_admit_route.sh-style line-counting routes honest.
The note I would want a reader to have (not a blocker). The pin is keyed on the model's filename (or --alias), so it catches exactly one attack — the same file name, a different template — and that is the re-pull/supply-chain case the PR is aimed at, so the design is right. But a re-pull that arrives under a new name bypasses the table entirely and only gets the sha line: my ZR1-1.5B-bf16.gguf is precisely such a file, and it logs a hash that no pin compares. That is worth one sentence in docs/serve.md ("an unpinned model is logged, not checked, and the key is the file name"), because otherwise a reader can reasonably assume every load carrying a template is pinned. If you want the stronger property later, pinning the sha you expect for a known set of sources (the source field is already there) rather than per filename is the way in.
What I did not verify: the two publishing/open-problem halves of the brief (docs/template-publishing.md +139) are documentation, and I read rather than executed them; and tests/template_pin.sh is the PR's own harness — I ran the behaviour directly instead, which is why the four rows above are mine rather than replayed from it.
Build at this head: clean, 1bit links (3.1 MB), no warnings from app/template_pin.h, app/serve.cpp or app/gguf_meta.h.
…is logged, not checked (#1089 review) The reviewer's note: the table catches exactly one case — the same name, a different template — so a model with no pin only gets the line, and a re-pull under a new name bypasses the table by definition. Also records which branch of a container read is live for us: the OGA pack imports carry no `chat_template` key in `tokenizer_config.json`, so `chat_template.jinja` is the branch that fires.
…_config.json (closes #1091) #1089 covered a `.gguf`'s `tokenizer.chat_template`. A Q4NX / NPU model directory has no GGUF: the same program travels in the container's `tokenizer_config.json` (`chat_template`, else a `chat_template.jinja` beside it), and the NPU route renders it on every request — so that route had neither the line nor the pin. - app/template_pin.h: `container_chat_template_sha(dir)` and `template_sha_of(model)`, which dispatches on what the model is (a `.gguf`'s metadata key, a directory's `tokenizer_config.json` / `chat_template.jinja`, nothing for a 1BP package) — `template_verdict` now uses it, so the pin, the keys (`--alias` / the directory's name) and the strict refusal are the same code for both. - app/serve.cpp: `announce_template` no longer returns early for a non-`.gguf` model. - The warning line says "the model carries", not "the GGUF carries", because it now covers both. - tests/template_pin.sh: a directory's `tokenizer_config.json`, a `chat_template.jinja` directory, a directory with no template (silence), a directory's pin mismatch (warn), `--template-pin-strict` (refuse), and a directory pinned `"none"` while it carries one (warn). - docs/serve.md: the Q4NX paragraph says the directory is covered instead of pointing away. Builds on 067be56 (#1089); to be rebased onto main once that merges. Measured (this box, 2026-11-05), `--print-backend` on real containers: ~/models/npu/Qwen3-0.6B -> a55ee1b1660128b7… (= Qwen/Qwen3-0.6B's own template) ~/Qwen3-0.6B-NPU2 -> a136047ba140347b… (FastFlowLM's extension: a `user_system_prompt` variable) ~/models/npu/d3-lite-src -> 273d8e0e683b8850… (= Qwen/Qwen3.5-0.8B@2fc06364, its base) which are the shas an independent read of the same containers gives (and the ones the template audit recorded), so the line, the pin and the audit agree. 20/20 `tests/template_pin.sh` assertions pass.
|
Review note taken, shipped at d693848 (docs-only,
So the reviewer's own Your three-way hash check is the useful part of this review, thanks: it also confirms the table's two-packaging row ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d6938486 — the new commit is documentation only, and it documents exactly the limitation I raised.
Delta from my verdicted head is one commit touching docs/serve.md (+8), and every code file is byte-identical:
app/template_pin.h · app/serve.cpp · config/template-pins.json · tests/template_pin.sh -> same blob
docs/serve.md -> one 8-line paragraph added
The paragraph is the nit, closed: "A pin is keyed on the model id or the file name (a container: its directory name), so it catches exactly one thing — the same name, a different template … a model with no pin is logged, not checked … a model that arrives under a new name bypasses the table by definition" — plus the .jinja-is-usually-the-live-branch fact for OGA pack imports, which is the part I contributed from this box (their containers carry no chat_template key in tokenizer_config.json at all).
So a reader now learns from the docs what only I knew from testing it. Everything from my earlier READY stands unchanged: the sha computed three ways, the four pin behaviours (no pin / match / mismatch-warns / strict-refuses), and the no-template case printing nothing.
…_config.json (closes #1091) #1089 covered a `.gguf`'s `tokenizer.chat_template`. A Q4NX / NPU model directory has no GGUF: the same program travels in the container's `tokenizer_config.json` (`chat_template`, else a `chat_template.jinja` beside it), and the NPU route renders it on every request — so that route had neither the line nor the pin. - app/template_pin.h: `container_chat_template_sha(dir)` and `template_sha_of(model)`, which dispatches on what the model is (a `.gguf`'s metadata key, a directory's `tokenizer_config.json` / `chat_template.jinja`, nothing for a 1BP package) — `template_verdict` now uses it, so the pin, the keys (`--alias` / the directory's name) and the strict refusal are the same code for both. - app/serve.cpp: `announce_template` no longer returns early for a non-`.gguf` model. - The warning line says "the model carries", not "the GGUF carries", because it now covers both. - tests/template_pin.sh: a directory's `tokenizer_config.json`, a `chat_template.jinja` directory, a directory with no template (silence), a directory's pin mismatch (warn), `--template-pin-strict` (refuse), and a directory pinned `"none"` while it carries one (warn). - docs/serve.md: the Q4NX paragraph says the directory is covered instead of pointing away. Builds on 067be56 (#1089); to be rebased onto main once that merges. Measured (this box, 2026-11-05), `--print-backend` on real containers: ~/models/npu/Qwen3-0.6B -> a55ee1b1660128b7… (= Qwen/Qwen3-0.6B's own template) ~/Qwen3-0.6B-NPU2 -> a136047ba140347b… (FastFlowLM's extension: a `user_system_prompt` variable) ~/models/npu/d3-lite-src -> 273d8e0e683b8850… (= Qwen/Qwen3.5-0.8B@2fc06364, its base) which are the shas an independent read of the same containers gives (and the ones the template audit recorded), so the line, the pin and the audit agree. 20/20 `tests/template_pin.sh` assertions pass.
…_config.json (closes #1091) (#1095) #1089 covered a `.gguf`'s `tokenizer.chat_template`. A Q4NX / NPU model directory has no GGUF: the same program travels in the container's `tokenizer_config.json` (`chat_template`, else a `chat_template.jinja` beside it), and the NPU route renders it on every request — so that route had neither the line nor the pin. - app/template_pin.h: `container_chat_template_sha(dir)` and `template_sha_of(model)`, which dispatches on what the model is (a `.gguf`'s metadata key, a directory's `tokenizer_config.json` / `chat_template.jinja`, nothing for a 1BP package) — `template_verdict` now uses it, so the pin, the keys (`--alias` / the directory's name) and the strict refusal are the same code for both. - app/serve.cpp: `announce_template` no longer returns early for a non-`.gguf` model. - The warning line says "the model carries", not "the GGUF carries", because it now covers both. - tests/template_pin.sh: a directory's `tokenizer_config.json`, a `chat_template.jinja` directory, a directory with no template (silence), a directory's pin mismatch (warn), `--template-pin-strict` (refuse), and a directory pinned `"none"` while it carries one (warn). - docs/serve.md: the Q4NX paragraph says the directory is covered instead of pointing away. Builds on 067be56 (#1089); to be rebased onto main once that merges. Measured (this box, 2026-11-05), `--print-backend` on real containers: ~/models/npu/Qwen3-0.6B -> a55ee1b1660128b7… (= Qwen/Qwen3-0.6B's own template) ~/Qwen3-0.6B-NPU2 -> a136047ba140347b… (FastFlowLM's extension: a `user_system_prompt` variable) ~/models/npu/d3-lite-src -> 273d8e0e683b8850… (= Qwen/Qwen3.5-0.8B@2fc06364, its base) which are the shas an independent read of the same containers gives (and the ones the template audit recorded), so the line, the pin and the audit agree. 20/20 `tests/template_pin.sh` assertions pass. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
… itself ships (#1158) * serve: pin the two chat models the stick actually ships, from their measured templates `config/template-pins.json` pins 7 names, and none of them is a row the appliance itself serves: the stick's staged marker is `Qwen3-4B-Instruct-2507-1bit` (unsloth/Qwen3-4B-Instruct-2507-GGUF, `Qwen3-4B-Instruct-2507-Q4_K_M.gguf`) and `Qwen3-0.6B-NPU-1bit` (the FastFlowLM/Qwen3-0.6B-NPU2 container) — the last row of ci.yml's appliance table and the one a stick image boots with. So on the appliance's own default chat row, `--template-pin-strict` checked nothing: an unpinned model is logged, not refused (docs/serve.md), and the owner's open decision "flip the serve rows to strict" would have bought the appliance's model nothing. Four rows added, keyed on both names a row can be served under (the catalog id that becomes `--alias` and the file/dir name), with digests measured rather than guessed: qwen3-4b-instruct-2507-1bit c979e0e71a3e21b8… (the staged qwen3-4b-instruct-2507-q4_k_m.gguf c979e0e71a3e21b8… Qwen3-4B-Instruct-2507-Q4_K_M.gguf on the c26 staging tree; equal to the digest models/pins.json records for it) qwen3-0.6b-npu-1bit a136047ba140347b… (the container's tokenizer_config.json qwen3-0.6b-npu2 a136047ba140347b… `chat_template` string — the digest #1095's serve line printed on a real container, not the JSON file's own) `tests/template_pin.sh` gains the case that makes the claim checkable: a fixture named `qwen3-4b-instruct-2507-q4_k_m.gguf` whose template is not the pinned one must warn with that digest. Verified in both directions — against the current main build (wt/onebp-zyphra/build-main-merged/1bit, built 11:56 today from the merged #1089/#1095) every existing case passes and **the new case fails**, so it detects the missing row; `cmake -S . -B /tmp/tp-cfg` then generates `generated/template_pins_default.h` containing the new names, which is what the binary compiles in. CI runs the suite (`add_test(NAME template_pin ...)`, ci.yml `ctest`), so the pass is proven there. * serve: pin the laya-first `short` row too, so all four routed rows carry an expectation The router's four classes are the one place a model is pulled and then served by name (docs/laya-first.md). Three of them were pinned by file name; `short` — Qwen3-0.6B-Q8_0.gguf, digest 57f1fd00f0013a2b… in models/pins.json — was not, so the smallest model in the routing table was the one a strict pin could not refuse. All four now agree with models/pins.json's measured digests. Doc: the coverage sentence says so, instead of listing the stick plus demo/scoreboard. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
What
A GGUF's
tokenizer.chat_templateis a Jinja2 program that the llama.cpp-family backends run onevery request. It can rewrite the system context, add an instruction, gate behaviour on a word in
the user's message or hand an agent a URL — with no change to a single weight, and with nothing in the
usual hub or model-card checks looking at it. The engine loaded a GGUF and never said which template
the backend would execute: a re-pull that changed the template was invisible in the log, and nothing
could refuse it.
This PR makes the template part of the load:
1bit serve: template sha256=<sha256>(the sha256 of the exacttokenizer.chat_templatebytes, read from the GGUF's metadata header only).config/template-pins.json, compiled in, expected template per model (matched against--alias— Lemonade's catalog entry — then the file's name;"none"= a model that must carry notemplate). A mismatch prints a
warning:line and still serves.--template-pin-strict(orONEBIT_TEMPLATE_PIN_STRICT=1) refuses the load, exit 1.--template-pin FILEreplaces the table,--no-template-pinlogs the sha and compares nothing.(
tests/mem_admit_route.sh) are unchanged.Plus the publishing/open-problem halves of the same brief:
docs/template-publishing.md(the cardrow, the
SHA256SUMSline, theUPLOAD-READYrow, verified extraction commands, and the CycloneDX"the template is a signed part" proposal) and
docs/laya-first.md(a routed row gains a requiredtemplate_sha256, checked at pull time and again by the engine pin).Files
app/gguf_meta.hgguf_string_meta(path, key)— a string value from the metadata header only;gguf_architecturenow delegates to it instead of duplicating the walkapp/template_pin.h(new)std::map, case-insensitive), the verdictapp/serve.cppannounce_template()at the load, the three flags +ONEBIT_TEMPLATE_PIN_STRICT, usageconfig/template-pins.json(new)docs/serve.mddocs/template-publishing.md(new)docs/laya-first.mdtemplate_sha256as a required row field, the pull-time check,why: template_mismatch, the gate rowtests/gguf_meta_test.cpptests/template_pin.sh(new)Root cause
1bit servereadgeneral.architectureand other metadata keys from the GGUF header, but nevertokenizer.chat_template, so the one string that decides what every request looks like to the modelwas neither named nor checked. The native
1bitforward does not render a template at all; thellama-serverbackends serve starts do, on every request — which is exactly the path a pulled modelreaches.
Verification
On this box, 2026-11-05, CPU-only build (
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release, as CI):that sha is the
config/template-pins.jsonentry for the file (and the sha256 Pillar'spillar-gguf-scannerand my own range-request reader report for it), so the same run with--template-pin-strictexits 0 with no warning.ctest -R 'gguf_meta|template_pin'→ 2/2 pass.tests/gguf_meta_test→ 14 cases pass.tests/template_pin.sh→ 14 assertions pass: the line without a pin; silence for a model withneither; a matching pin; a mismatch warning that still serves;
--template-pin-strictexit 1 namingboth shas;
ONEBIT_TEMPLATE_PIN_STRICT=1the same; a pin matched by--alias;"none"; expectedtemplate vs a file with none; the built-in table compared with no flags;
--no-template-pin; and abad sha256 / not-a-pin-file / unreadable file each refused by name.
tests/print_backend.sh,tests/mem_admit_route.sh,tests/recipes_route.sh,tests/device_route.sh→ 0 failures each (the "no line for a plain header-only fixture" case isdeliberate, see above).
Not in this PR
tokenizer_config.json, not a GGUF header: noline for those yet (
shortcut:comment inannounce_template); the pull-time check for them is theLaya-first design in
docs/laya-first.md.--template-pin-stricton in the appliance is apackaging/OS decision, not this PR's.
on the box and from the published originals' metadata.
Brief: TTEMPLATE-SEC item 3.