Skip to content

serve: name a model directory's chat template too, from its tokenizer_config.json (closes #1091) - #1095

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

bong-water-water-bong merged 2 commits into
mainfrom
i-tmplsec-npu-tpl

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

What

Closes #1091. #1089 covers 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) — 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) 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 uses it, so the pin, the key list (--alias then the directory's name) and the
    strict refusal are one code path for both.
  • app/serve.cpp: announce_template no longer returns early for a non-.gguf model; the warning
    line 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's tokenizer_config.json, a
    chat_template.jinja directory, a directory with neither (silence, so the "-not a template line"
    contract holds), a directory's pin mismatch (warn), --template-pin-strict (refuse), and a
    directory 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/main and 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):

~/models/npu/Qwen3-0.6B   -> 1bit serve: template sha256=a55ee1b1660128b7… (Qwen3-0.6B)
~/Qwen3-0.6B-NPU2         -> 1bit serve: template sha256=a136047ba140347b… (Qwen3-0.6B-NPU2)
~/models/npu/d3-lite-src  -> 1bit serve: template sha256=273d8e0e683b8850… (d3-lite-src)

Those are the shas an independent read of the same containers gives: a55ee1b166… is
Qwen/Qwen3-0.6B's own tokenizer_config.json template, 273d8e0e68… is Qwen/Qwen3.5-0.8B's (the
container's base revision), and a136047ba140… is FastFlowLM's extension of Qwen's Qwen3 template (an
extra user_system_prompt variable), which is exactly the kind of difference the line exists to make
visible.

Not in this PR

The pull-time half (a routed row is checked before it is registered — docs/laya-first.md, "A row is
not served with an unexpected template") lives in #1089's doc section; the router change that enforces
it is that lane's work.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1095 ready for review at ed99065 — stacked on #1089 (9c053d0c), so the diff shows its commit too until that lands. Build clean (CPU-only), tests/template_pin.sh 20/20, three real containers name the sha an independent read gives. Review #1089 first; nothing here changes its behaviour for .gguf models.

@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 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 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.

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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

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 +38/−2).

And I took the runtime row you left — without a ticket, because the line prints before the device guard, so --device cpu reaches announce_template() and then refuses:

$ 1bit serve -m ~/Qwen3-0.6B-NPU2 --device cpu -p 18633
1bit serve: template sha256=a136047ba140347b359d17b6ff8fa1bf8cb29d23f0c07bef7e0bf479b8680793 (Qwen3-0.6B-NPU2)
1bit serve: an NPU model directory runs on --device npu

$ 1bit serve -m /tmp/844-out-clean --device cpu -p 18634
1bit serve: template sha256=8aa40ce145adb73cb3a75194dc0224702a95850ec5275cabb728496bbd749fc6 (844-out-clean)
1bit serve: an NPU model directory runs on --device npu

The second one confirms your finding from the other side: that container's tokenizer_config.json has no chat_template, the .jinja fallback is what fires, and its sha256 is exactly the 8aa40ce145adb73c… you computed — so the JSON branch being dead for OGA packs is now measured through the engine, not just by reading the files. My tests/template_pin.sh already covers the three shapes (cont JSON, contj jinja-only, contempty neither), which by your note is the right order of likelihood.

docs/serve.md in this branch also gains the sentence #1089's review asked for, including the one you measured: for a model directory the .jinja fallback is usually the live branch because our OGA pack imports carry no chat_template key. CI re-running on 459d0ca.

@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 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 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 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.cpp and tests/template_pin.sh hash to 9c053d0c-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).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1095 ready for review at 1638047 — rebased onto main 6ae87c24 now that #1089 is merged (f3a42c41 squash), so this is no longer stacked: the diff is just the container commit (app/serve.cpp 8, app/template_pin.h 40, docs/serve.md 8, tests/template_pin.sh 43). Conflict state cleared, MERGEABLE.

Re-verified after the rebase on this box: CPU-only build clean; tests/template_pin.sh 20/20; gguf_meta_test 14 cases; ctest -R 'gguf_meta|template_pin' 2/2. Container row still reproduces on real directories without an NPU ticket (the line prints before the device guard): ~/Qwen3-0.6B-NPU2 → a136047ba140347b…, /tmp/844-out-clean → 8aa40ce145adb73c… (the .jinja-only OGA shape the reviewer measured).

Nothing else from this lane is waiting: fork PR 1bit-MONSTER/llama.cpp#206 is merged (95b1b579), so after this lands the only engine-side follow-up is the ordinary llama.cpp pin bump + registry/architectures.json regen (notes on #1098). Owner has authorised merging the ready ones.

@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 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).

@bong-water-water-bong
bong-water-water-bong merged commit fb76e90 into main Nov 5, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the i-tmplsec-npu-tpl branch November 5, 2026 14:02
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…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>
@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 🔶

1091 - Partially compliant

Compliant requirements:

  • announce_template() now handles model directories by reading tokenizer_config.json
  • config/template-pins.json keys accept directory names for matching
  • Tests added in tests/template_pin.sh covering all required scenarios

Non-compliant requirements:

  • None

Requires further human verification:

  • None
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Potential File Reading Error Handling

The container_chat_template_sha function reads files using std::ifstream and std::istreambuf_iterator, but does not check for std::ifstream::failbit or std::ifstream::badbit after reading. If a file is unreadable due to permissions or corruption, the function may return an empty string without indicating an error, potentially leading to silent failures in template detection.

    auto read = [](const std::string& path) {
        std::ifstream in(path, std::ios::binary);
        return in ? std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()) : std::string();
    };
    std::string tpl;
    const std::string cfg = read(dir + "/tokenizer_config.json");
    if (!cfg.empty()) {
        try {
            const nlohmann::json j = nlohmann::json::parse(cfg);
            if (j.contains("chat_template") && j["chat_template"].is_string())
                tpl = j["chat_template"].get<std::string>();
        } catch (const std::exception&) {
            // a container whose tokenizer_config.json does not parse has no readable template
        }
    }
    if (tpl.empty()) tpl = read(dir + "/chat_template.jinja");
    if (tpl.empty()) return "";
    return sha256_hex(reinterpret_cast<const uint8_t*>(tpl.data()), tpl.size());
}
Inconsistent Warning Message

The warning message in announce_template now refers to "the model carries" instead of "the GGUF carries", which is correct for GGUF models but misleading for directory models. The message should be updated to reflect that the template is from the tokenizer_config.json or chat_template.jinja file.

const std::string msg = "template pin: " + id + " (" + v.key + ") expects " + v.pinned + ", the model carries " +
                        (v.sha256.empty() ? std::string("none") : v.sha256) +
                        " in tokenizer.chat_template (docs/serve.md \"Chat template\")";

bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…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.
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…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>
bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…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.
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…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>
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
… 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>
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.

serve: an NPU/Q4NX model directory's chat template (tokenizer_config.json) is not named at load and cannot be pinned

1 participant