Repository navigation
serve: name a model directory's chat template too, from its tokenizer_config.json (closes #1091) - #1095
Conversation
|
#1095 ready for review at ed99065 — stacked on #1089 ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at ed990650 — the delta on top of #1089 is small and right, and the branch it lives on is the one I verified by running.
What is new here (one commit; 9c053d0c…ed990650 = app/serve.cpp +4/−4, app/template_pin.h +38/−2, docs/serve.md +5/−3, tests/template_pin.sh +40/−3): announce_template() no longer returns early for a non-.gguf model, and container_chat_template_sha() reads a container directory's template — tokenizer_config.json's chat_template string, else chat_template.jinja beside it, else nothing — hashing those exact bytes. The mismatch message now says "the model carries" rather than "the GGUF carries", which is the correct generalisation.
A concrete thing I checked on this box, because it decides which branch is live: for the containers we import from OGA packs, tokenizer_config.json has no chat_template key at all, and the template is only in chat_template.jinja:
/tmp/844-out-clean (Qwen2.5-0.5B pack import)
tokenizer_config.json: chat_template -> absent
chat_template.jinja: 2561 bytes, sha256 8aa40ce145adb73c…
So for this fleet's containers the JSON branch is dead and the .jinja fallback is the one that actually runs — and their tests cover exactly that shape, plus the other two:
cont tokenizer_config.json carrying "chat_template" (the JSON branch)
contj chat_template.jinja only (the branch that fires for our packs)
contempty tokenizer_config.json with neither (must print nothing)
That is the right three cases, in the right order of likelihood.
Merge order, and it matters: this branch contains #1089's commits (its file list is #1089's plus the delta) but its base is main. So land #1089 first, then this one rebases to a +38/−2 delta; merged the other way round, one squash carries both and #1089 becomes empty. Both are reviewed at their own heads, so either order is safe for correctness — but only one of them should be merged as the "template" change and I would keep them separate, since #1089 is the GGUF half that the pin table's provenance lives in.
What I verified by running was #1089 (four pin behaviours, the sha computed three ways); at this head I reviewed the delta and the container branch by reading, and checked the hash target against a real container rather than assuming it. I did not build ed990650 and then serve a directory — if you want that row, it is one serve -m <container dir> away and I will take it on the next pass.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Operational note for whoever takes the runtime row I did not take (serving a model directory and reading the template line): a Q4NX model directory only runs on --device npu, so this row needs an npu-hold ticket — --device cpu exits before announce_template() is reached:
$ 1bit serve -m /tmp/844-out-clean --device cpu -p 18633 # pre-#1095 binary
1bit serve: an NPU model directory runs on --device npu
I tried to close my own gap that way on the #1089 binary and got 0 template lines for the wrong reason — the device guard fired first — so I am recording the constraint rather than a result. With --device npu under a ticket, the expected line is template sha256=… from tokenizer_config.json or chat_template.jinja; for our pack-imported containers it is the .jinja branch (the 0.5B container's file is 2561 bytes, sha256 8aa40ce145adb73c…), which is the second of the three cases their tests/template_pin.sh covers.
ed99065 to
459d0ca
Compare
|
Rebased on the new #1089 head (d693848): this branch is now 459d0ca, so it is #1089 + the one container commit, as you asked for the merge order (#1089 first, then this becomes And I took the runtime row you left — without a ticket, because the line prints before the device guard, so The second one confirms your finding from the other side: that container's
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 459d0ca1 — rebase-only plus the same docs paragraph; the code is byte-identical to what I reviewed.
app/serve.cpp · app/template_pin.h · tests/template_pin.sh -> same blob at ed990650 and 459d0ca1
docs/serve.md -> the 8-line pin-scope paragraph (#1089's, arriving with the base)
So the container-template behaviour I reviewed stands: announce_template() no longer returns early for a non-.gguf, container_chat_template_sha() reads tokenizer_config.json's chat_template else chat_template.jinja, and for the containers we import from OGA packs the .jinja branch is the live one (their tokenizer_config.json carries no chat_template key at all; the 0.5B container's file is 2561 B, sha 8aa40ce1…).
Merge order unchanged and still worth respecting: this branch contains #1089's commits on a main base, so #1089 first, then this rebases to its own delta. Both are READY at their current heads.
…_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.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY on content at 459d0ca1 (code byte-identical to what I reviewed: serve.cpp, template_pin.h and tests/template_pin.sh), but it is DIRTY and cannot merge as it stands — and the cause is exactly the merge I told you to do first.
#1089 merged, so this branch's carried copy of #1089's commits is now duplicated against main. The rebase is mechanical and the result should be a +38/−2 delta (app/template_pin.h's container_chat_template_sha() + the announce_template() directory branch) with no #1089 content in it at all. After that rebase:
- if
app/template_pin.h,app/serve.cppandtests/template_pin.shhash to9c053d0c-era values plus exactly that delta, my READY carries; - if the rebase drops or reorders anything from #1089, I will re-read it — that is the failure mode of a stacked rebase and it is not visible from a summary.
The behaviour I verified stands: for the OGA pack containers we import, tokenizer_config.json carries no chat_template key, so the .jinja fallback is the live branch rather than the JSON one — which is why I asked for that case to be covered and it is (contj in tests/template_pin.sh).
459d0ca to
1638047
Compare
|
#1095 ready for review at 1638047 — rebased onto main Re-verified after the rebase on this box: CPU-only build clean; Nothing else from this lane is waiting: fork PR 1bit-MONSTER/llama.cpp#206 is merged ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 1638047a — rebase verified by blob, and the one file that changed changed for a reason that is not this PR.
app/template_pin.h same blob as 459d0ca1
tests/template_pin.sh same blob
app/serve.cpp DIFFERS — and every hunk is main's, not #1095's:
--load-queue-ms (flag, default 45000, help text) <- #1100
the --npu-streams refusal on the in-process route <- #1094
So the contribution I reviewed is unchanged: container_chat_template_sha() reading tokenizer_config.json's chat_template else chat_template.jinja, and announce_template() no longer returning early for a non-.gguf. Its own delta is the +4/−4 in serve.cpp (the directory branch) plus the +38/−2 in template_pin.h.
mergeStateStatus is now BEHIND rather than DIRTY, i.e. the conflict that blocked it is resolved by the rebase — so this can merge once it is refreshed against a head of main that includes #1102/#1103/#1104, all of which are in.
The behaviour I verified stands, and it is the reason I wanted this PR at all: for the OGA pack containers we import, tokenizer_config.json carries no chat_template key, so the .jinja fallback is the live branch — the JSON branch is dead code on this fleet's containers. Their tests cover exactly that shape (contj), plus cont (JSON) and contempty (neither).
…ine #963 option 2) (#1115) `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. 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:
|
…s JSON file
The pin is only as good as the number it records. Four tracked files are Q4NX containers, and for them
the tool had two defects that cancel each other out:
- `remote_template()` fell through to "hash the text file verbatim" for anything that is not a `.gguf`,
so for a `tokenizer_config.json` it would have hashed the whole JSON file (51015f78470ac7f7… for
Qwen3-0.6B-NPU2) while the engine's serve line names the `chat_template` **string**
(a136047ba140347b…). Two numbers for one template — the disagreement #1131 was about.
- `--bump` only re-read a template that was not already `null`, so the three FastFlowLM entries could
never gain one: pinned at `null`, they claimed a container carries no template when it carries
Qwen3's.
Now a `.json` path is parsed and its `chat_template` string hashed (a named-template list is refused
rather than guessed), `chat_template.jinja` stays verbatim, and `--bump`/`--pin` read the template of
every tracked file, so a `null` is a measured "no template" rather than an old one.
Measured, and cross-checked against the engine's own line:
FastFlowLM/*-NPU2 tokenizer_config.json null -> a136047ba140347b… (= the serve-log digest recorded
in #1095 for a real container)
1bit-MONSTER/Qwen3.6-35B-A3B-NPU chat_template.jinja was not tracked at all; its
tokenizer_config.json carries no template, so the template
this container runs was unpinned. Now tracked:
e84f32a23fdda276… 7764 B (== sha256sum of the raw file)
`--check` over all 147 pins is unchanged (1 moved, same body, rc 1), `--self-test` 29 -> 35.
…the proposal, executable) (#1131) * tools: template_bom.py — the chat template as a CycloneDX component (the proposal, executable) `docs/template-publishing.md` proposes treating a model's `tokenizer.chat_template` as a signed part rather than an anonymous string, because a Jinja program that runs on every request should be in the dependency graph the rest of our provenance lives in. This is that proposal implemented, so the BOM, the serve log and the card row can carry the same digest instead of three bookkeepers. - `tools/template_bom.py`: for a `.gguf` (metadata header only) or a model directory (`tokenizer_config.json`'s `chat_template`, else `chat_template.jinja` — the same order the engine uses since #1095), emit a CycloneDX 1.6 `data` component with the digest in `hashes` and the provenance in `properties`; `--bom` wraps one document, default is JSON Lines so it composes. A model that carries no template gets a component with no hash and a description that says why, so "no template" is a claim a checker can verify too. - `--self-test`: two fixtures built in a temp dir (a header-only GGUF carrying a template, a directory with only `chat_template.jinja`) plus a no-template model — 7 assertions; wired into CI's python step. - `docs/template-publishing.md`: the proposal now points at the implementation, with its output. The digests it prints are the ones the merged engine prints at load: on this box `~/models/zaya1-8b-Q4_K_M.gguf` → `b55ecfbbbcbb238a…` and `~/Qwen3-0.6B-NPU2` → `a136047ba140347b…`, i.e. the same numbers `1bit serve` reports and the audit recorded, by construction rather than by bookkeeping. * tools/template_bom.py: --sums, the SHA256SUMS line a published file needs The tool emitted the CycloneDX component, and the publishing rule's other half — the `SHA256SUMS` line (`<sha256> <file>#tokenizer.chat_template`, or `<dir>/<source-file>#chat_template` for a container) — was still the snippet in the doc. Now one command gives both: tools/template_bom.py --sums model.gguf container-dir/ b55ecfbbbcbb238a… zaya1-8b-Q4_K_M.gguf#tokenizer.chat_template a136047ba140347b… /path/Qwen3-0.6B-NPU2/tokenizer_config.json#chat_template A model that carries no template prints nothing, so the file a publisher appends to stays clean rather than gaining a 'none' row. Self-test grows to 10 assertions (the two keys, and the no-template silence). * tools/template_bom.py: digest the template bytes on disk, so the BOM and the serve log agree Review: on a CRLF container the component's digest and the digest `1bit serve` prints were two different sha256 values for one template, because the directory branch read chat_template.jinja in text mode — Python's universal-newline translation — while `container_chat_template_sha()` reads the file as binary and `docs/template-publishing.md` tells a reader to check it with `sha256sum`. $ sha256sum cont/chat_template.jinja d66cf707b336fdfd… $ template_bom.py --sums cont/ 580f68ef79d2ecf9… (before: 1314 B, LF-normalised) d66cf707b336fdfd… (after: the file's own digest) read_template() now returns bytes and every branch hashes what is stored: the .jinja file as `"rb"`, the GGUF metadata part as-is, and tokenizer_config.json's JSON string encoded (a JSON string carries no raw newline, so it already equalled the C++ json read). --self-test gains a CRLF fixture written with `"wb"` whose expected digest is sha256 of the bytes written, plus an assertion that it is NOT the LF-normalised digest — that pair fails on the previous head and 14/14 pass here. The two recorded digests are unchanged, so no committed pin or card row moves: zaya1-8b-Q4_K_M.gguf b55ecfbbbcbb238a…, ~/Qwen3-0.6B-NPU2 a136047ba140347b…. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…s JSON file
The pin is only as good as the number it records. Four tracked files are Q4NX containers, and for them
the tool had two defects that cancel each other out:
- `remote_template()` fell through to "hash the text file verbatim" for anything that is not a `.gguf`,
so for a `tokenizer_config.json` it would have hashed the whole JSON file (51015f78470ac7f7… for
Qwen3-0.6B-NPU2) while the engine's serve line names the `chat_template` **string**
(a136047ba140347b…). Two numbers for one template — the disagreement #1131 was about.
- `--bump` only re-read a template that was not already `null`, so the three FastFlowLM entries could
never gain one: pinned at `null`, they claimed a container carries no template when it carries
Qwen3's.
Now a `.json` path is parsed and its `chat_template` string hashed (a named-template list is refused
rather than guessed), `chat_template.jinja` stays verbatim, and `--bump`/`--pin` read the template of
every tracked file, so a `null` is a measured "no template" rather than an old one.
Measured, and cross-checked against the engine's own line:
FastFlowLM/*-NPU2 tokenizer_config.json null -> a136047ba140347b… (= the serve-log digest recorded
in #1095 for a real container)
1bit-MONSTER/Qwen3.6-35B-A3B-NPU chat_template.jinja was not tracked at all; its
tokenizer_config.json carries no template, so the template
this container runs was unpinned. Now tracked:
e84f32a23fdda276… 7764 B (== sha256sum of the raw file)
`--check` over all 147 pins is unchanged (1 moved, same body, rc 1), `--self-test` 29 -> 35.
…m model we depend on, checked daily (#1148) * models/pins.json + tools/pin_watch.py: which revision of each upstream model we depend on, checked daily The census counts what the engine can run and nothing recorded WHICH revision of an upstream model we serve, ship or convert from, so unsloth's 2026-11-04 re-quantization of Qwen3.8-Flash-Next-GGUF ("Better UD 3.1 quants"; UD-IQ4_XS went 3 shards -> 4) went unnoticed: the files we re-host are the 2026-11-02 ones, under unchanged names. models/pins.json pins 146 repos (184 files): the laya-first serve targets, the stick catalogue's rows, our published re-hosts, and the upstream repos behind them. Each entry records the commit, the files WE HOLD (content identity + size + the sha256 of the chat template that file carries) and what uses it — so "upstream moved" and "the bytes we hold are stale" are both checkable, the second one because a pin taken after a vendor re-push would otherwise store our older copy under a newer commit. tools/pin_watch.py --check (pin-watch.yml, daily 05:43 UTC) compares every pin with its upstream main, writes a Markdown report and exits 1 when anything moved; for a moved file it re-reads the chat template from the new revision (GGUF metadata prefix over an HTTP range request, no tensor) and names it as unchanged / TEMPLATE CHANGED / added / removed. It opens one PR per changed repo, bumping only that repo's entry with the report as the body; --bump refuses a pin whose tracked file vanished upstream rather than dropping it, and nothing is adopted automatically. First run against the committed file, the finding is the one that prompted this: unsloth/Qwen3.8-Flash-Next-GGUF HEAD d9620119, our copies are not its bytes UD-Q4_K_XL/…-00001-of-00004.gguf we hold 10,946,624 B 4448186216b3…, upstream 10,946,618 B acc44040f74d… UD-Q4_K_XL/…-00003-of-00004.gguf we hold 49,376,141,504 B 56758f40269c…, upstream 32,803,030,176 B 8c76e79d444b… templates unchanged (12827f24b742…, c3cf9e34abf4…) — a re-quantization, not a template change --self-test: 16 assertions on fixtures (a GGUF metadata read with and without a template, a moved pin, a stale copy at the same commit, an unreadable repo, the bump refusal), wired into ci.yml. The other 145 pins are clean today. * models/pins.json: pin the model a stick image bakes, from the image's own HF cache The "stage on the stick" half of the rule had no provenance: the image tree records which snapshot it was built from (~/stage-c25/hf/models--<org>--<name>/snapshots/<commit>), and that is a commit sha, not a guess. - `stabilityai/sd-turbo` was the one model the c25 image bakes that had no pin: added at the snapshot the cache names (`b261bac6fd2cf515557d5d0707481eafa0485ec2`), with its 5,214,561,328-byte safetensors hashed locally and found equal to the `lfs.oid` upstream serves at that commit (3f067a1b943cf162…). It is an image model and carries no chat template, so the pin records none. - The image's two chat models were already pinned from the catalogue, and the independent check agrees: both pins sit at exactly the commits the c25 cache names (`unsloth/Qwen3-4B-Instruct-2507-GGUF` @ a06e946b, `FastFlowLM/Qwen3-0.6B-NPU2` @ b5847992). 147 repos / 185 files. `--check` still reports the one stale pin (`unsloth/Qwen3.8-Flash-Next-GGUF`) and nothing else. docs/model-pins.md now says how a baked model is pinned and measures the coverage of the 154 staged files on the box: 11 match a pinned file by name, the rest are test vocabularies, conversion intermediates, or extra quant variants of repos that are pinned at revision level. * pin_watch: --self-test must not touch the network (it was taking 33 s) The fixture that exercises a moved pin reached `commits_since()`, which was not faked, so the test made a real Hugging Face call, got a 404 for `fake/moved`, and sat through the retry backoff: 33 s of wall clock for 0.08 s of CPU. Faking it takes the self-test to 84 ms and, while there, asserts the upstream commit title reaches both the change record and the report. 18 assertions, same coverage as before plus the commit-title path that the PR body renders. * pin_watch: a repo that could not be read may not look like a clean day The reviewer reproduced the failure this watcher exists to prevent: with a pins file whose only repository does not exist, `--check` printed "0 of 1 moved" and "Every pinned upstream repo is at the revision models/pins.json records", exited 0, and in pin-watch.yml that is `moved=0` -> no PR -> a green daily run. A vendor deleting, renaming or privatising a model we bake into a stick image was indistinguishable from a quiet day. Three outcomes are now three outcomes (docs/model-pins.md carries the table): - 404 is its own state: the repo is carried as `gone upstream` and reported as a finding ("a delete, a rename and a privatisation look exactly like this"), never as a skipped check. `--bump` refuses it with that sentence instead of a traceback, and `--pr-body` returns the operator's decision text. - the first line counts what was read whenever anything was not: "N of M checked, K not checked, J moved", and the all-clear sentence is printed only when nothing was left unread. - exit 2 when no pin could be read at all (len(notes) == len(pins)): the workflow fails on any code outside 0/1, so a Hub outage or a rate-limit is a red job rather than a green one. Before (the reviewer's repro, one fake repo): # Model pins: 0 of 1 moved (2026-11-05) Every pinned upstream repo is at the revision `models/pins.json` records. ## Not checked - fake/…: could not be read (HTTP Error 404: Not Found) rc=0 After: # Model pins: 1 of 1 moved (2026-11-05) ## `fake/definitely-not-a-model-1bit` **Gone upstream** — the Hub answers `404` for this repository, so every pin on it is unverified: a delete, a rename and a privatisation look exactly like this. rc=1 and for a read that failed for another reason (429/timeout, fixture in --self-test): "0 of 2 checked, 2 not checked", no all-clear, rc=2. A healthy day is byte-identical: --check over all 147 pins still writes "# Model pins: 1 of 147 moved" with the same body (diff empty), rc 1 — notes is empty on a normal run, so the strict rule costs nothing. --self-test 20 -> 29 assertions (the 404 state, the count line, the no-all-clear pair, --bump's refusal, --pr-body's text, and the rc=2 run), network-free. * pin_watch: a container's template is its chat_template string, not its JSON file The pin is only as good as the number it records. Four tracked files are Q4NX containers, and for them the tool had two defects that cancel each other out: - `remote_template()` fell through to "hash the text file verbatim" for anything that is not a `.gguf`, so for a `tokenizer_config.json` it would have hashed the whole JSON file (51015f78470ac7f7… for Qwen3-0.6B-NPU2) while the engine's serve line names the `chat_template` **string** (a136047ba140347b…). Two numbers for one template — the disagreement #1131 was about. - `--bump` only re-read a template that was not already `null`, so the three FastFlowLM entries could never gain one: pinned at `null`, they claimed a container carries no template when it carries Qwen3's. Now a `.json` path is parsed and its `chat_template` string hashed (a named-template list is refused rather than guessed), `chat_template.jinja` stays verbatim, and `--bump`/`--pin` read the template of every tracked file, so a `null` is a measured "no template" rather than an old one. Measured, and cross-checked against the engine's own line: FastFlowLM/*-NPU2 tokenizer_config.json null -> a136047ba140347b… (= the serve-log digest recorded in #1095 for a real container) 1bit-MONSTER/Qwen3.6-35B-A3B-NPU chat_template.jinja was not tracked at all; its tokenizer_config.json carries no template, so the template this container runs was unpinned. Now tracked: e84f32a23fdda276… 7764 B (== sha256sum of the raw file) `--check` over all 147 pins is unchanged (1 moved, same body, rc 1), `--self-test` 29 -> 35. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
… 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
Closes #1091. #1089 covers a
.gguf'stokenizer.chat_template; a Q4NX / NPU model directory has noGGUF — the same program travels in the container's
tokenizer_config.json(chat_template, else achat_template.jinjabeside it) — so the NPU route had neither the line nor the pin.app/template_pin.h:container_chat_template_sha(dir)(config string, else the jinja file) andtemplate_sha_of(model), which dispatches on what the model is: a.gguf's metadata key, adirectory's
tokenizer_config.json/chat_template.jinja, nothing for a 1BP package.template_verdictuses it, so the pin, the key list (--aliasthen the directory's name) and thestrict refusal are one code path for both.
app/serve.cpp:announce_templateno longer returns early for a non-.ggufmodel; the warningline says "the model carries" (it now covers both), not "the GGUF carries".
docs/serve.md: the Q4NX paragraph says the directory is covered.tests/template_pin.sh: six more cases — a directory'stokenizer_config.json, achat_template.jinjadirectory, a directory with neither (silence, so the "-not a template line"contract holds), a directory's pin mismatch (warn),
--template-pin-strict(refuse), and adirectory pinned
"none"while it carries one (warn).Stacked on #1089
Based on
9c053d0c(#1089), so this PR shows its commit until #1089 merges — review #1089 first.Afterwards:
git rebase origin/mainand both are independent.Verification (this box, 2026-11-05)
Real containers,
--print-backend(which now names the template before it exits 5 for the NPU route):Those are the shas an independent read of the same containers gives:
a55ee1b166…isQwen/Qwen3-0.6B's owntokenizer_config.jsontemplate,273d8e0e68…isQwen/Qwen3.5-0.8B's (thecontainer's base revision), and
a136047ba140…is FastFlowLM's extension of Qwen's Qwen3 template (anextra
user_system_promptvariable), which is exactly the kind of difference the line exists to makevisible.
tests/template_pin.sh: 20/20 (14 from serve: name the GGUF's chat-template sha256 at every load, and refuse a template that is not the pinned one #1089 + 6 for directories).ctest -R 'gguf_meta|template_pin': 2/2;gguf_meta_test14 cases.still print nothing).
Not in this PR
The pull-time half (a routed row is checked before it is registered —
docs/laya-first.md, "A row isnot served with an unexpected template") lives in #1089's doc section; the router change that enforces
it is that lane's work.