Skip to content

serve: name the GGUF's chat-template sha256 at every load, and refuse a template that is not the pinned one - #1089

Merged
bong-water-water-bong merged 3 commits into
mainfrom
i-tmplsec-logshatpl
Nov 5, 2026
Merged

bong-water-water-bong merged 3 commits into
mainfrom
i-tmplsec-logshatpl

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

What

A GGUF's tokenizer.chat_template is a Jinja2 program that the llama.cpp-family backends run on
every 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:

  • one line per load: 1bit serve: template sha256=<sha256> (the sha256 of the exact
    tokenizer.chat_template bytes, read from the GGUF's metadata header only).
  • a pin: 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 no
    template). A mismatch prints a warning: line and still serves. --template-pin-strict (or
    ONEBIT_TEMPLATE_PIN_STRICT=1) refuses the load, exit 1. --template-pin FILE replaces the 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.

Plus the publishing/open-problem halves of the same brief: docs/template-publishing.md (the card
row, the SHA256SUMS line, the UPLOAD-READY row, verified extraction commands, and the CycloneDX
"the template is a signed part" proposal) and docs/laya-first.md (a routed row gains a required
template_sha256, checked at pull time and again by the engine pin).

Files

file why
app/gguf_meta.h gguf_string_meta(path, key) — a string value from the metadata header only; gguf_architecture now delegates to it instead of duplicating the walk
app/template_pin.h (new) the template's sha256, the pin table (std::map, case-insensitive), the verdict
app/serve.cpp announce_template() at the load, the three flags + ONEBIT_TEMPLATE_PIN_STRICT, usage
config/template-pins.json (new) 7 models whose file on this box carries exactly the template their source model has (measured 2026-11-05)
docs/serve.md "Chat template": the line, the pin, the flags, what is not covered
docs/template-publishing.md (new) the publishing rule, the snippets, the signed-part proposal
docs/laya-first.md template_sha256 as a required row field, the pull-time check, why: template_mismatch, the gate row
tests/gguf_meta_test.cpp the new reader: long value after arrays, first match wins, a named template key, empty value, wrong type, not a GGUF
tests/template_pin.sh (new) 14 assertions, no GPU

Root cause

1bit serve read general.architecture and other metadata keys from the GGUF header, but never
tokenizer.chat_template, so the one string that decides what every request looks like to the model
was neither named nor checked. The native 1bit forward does not render a template at all; the
llama-server backends serve starts do, on every request — which is exactly the path a pulled model
reaches.

Verification

On this box, 2026-11-05, CPU-only build (cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release, as CI):

$ ./build/1bit serve -m ~/models/zaya1-8b-Q4_K_M.gguf --device cpu --no-mem-check --print-backend
1bit serve: template sha256=b55ecfbbbcbb238a011465bfa139366db18790103f9205060cf2855a3f51b30f (zaya1-8b-Q4_K_M)

that sha is the config/template-pins.json entry for the file (and the sha256 Pillar's
pillar-gguf-scanner and my own range-request reader report for it), so the same run with
--template-pin-strict exits 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 with
    neither; a matching pin; a mismatch warning that still serves; --template-pin-strict exit 1 naming
    both shas; ONEBIT_TEMPLATE_PIN_STRICT=1 the same; a pin matched by --alias; "none"; expected
    template vs a file with none; the built-in table compared with no flags; --no-template-pin; and a
    bad sha256 / not-a-pin-file / unreadable file each refused by name.
  • unchanged: 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 is
    deliberate, see above).

Not in this PR

  • A Q4NX / NPU model directory keeps its template in tokenizer_config.json, not a GGUF header: no
    line for those yet (shortcut: comment in announce_template); the pull-time check for them is the
    Laya-first design in docs/laya-first.md.
  • The pin is warn-by-default on purpose: turning --template-pin-strict on in the appliance is a
    packaging/OS decision, not this PR's.
  • Nothing was re-uploaded, published, or sent to any vendor; the pins are recorded from files already
    on the box and from the published originals' metadata.

Brief: TTEMPLATE-SEC item 3.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1089 ready for review at 067be56 (branch i-tmplsec-logshatpl, off main bd7d451a). CPU-only build, ctest -R 'gguf_meta|template_pin' 2/2, tests/template_pin.sh 14/14, print_backend/mem_admit/recipes/device_route unchanged. Read the "Not in this PR" list before reviewing: the warn-by-default choice and the Q4NX gap are both deliberate.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

The Q4NX/NPU half of the same seam is filed as #1091 (a model directory's template lives in tokenizer_config.json, so it gets neither the line nor the pin today — that is the shortcut: comment in announce_template). Left out of this PR on purpose so this one stays the .gguf path plus the docs.

… 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.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Rebased onto main 7dd39f1b (was bd7d451a) — #1074's per-request/per-decision lines and #1075's pin bump don't touch the load path, no conflicts. New head: 9c053d0 (i-tmplsec-logshatpl). Re-verified after the rebase: CPU-only build clean; ctest -R 'gguf_meta|template_pin' 2/2; tests/template_pin.sh 14/14; gguf_meta_test 14 cases; print_backend / mem_admit_route / recipes_route / device_route 0 failures each. Still warn-by-default, still no Q4NX line (that's #1091).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Two more paths checked on 9c053d0c (the ones that parse serve's output, since this PR adds a line to it): tests/smoke_serve.sh and tests/metrics_serve.sh both PASS unchanged — the template line goes to stderr only and only at load, so the response/metrics/watchdog contracts are untouched.

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

Copy link
Copy Markdown
Collaborator Author

Review note taken, shipped at d693848 (docs-only, docs/serve.md +8): the section now says what a pin is keyed on and what that means —

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 — which is the re-pull and supply-chain case. Two consequences a reader should not have to infer: a model with no pin is logged, not checked (the line is all it gets), and a model that arrives under a new name bypasses the table by definition; pin what you can name.

So the reviewer's own ZR1-1.5B-bf16.gguf case is now documented rather than implied. The stronger property (pinning a set of known sources, using the source field that is already in the table) is exactly the appliance-level file I derived from the catalogue — ~/tmplsec/APPLIANCE-PINS.md — so it exists as the next step rather than as a wish.

Your three-way hash check is the useful part of this review, thanks: it also confirms the table's two-packaging row (zr1-1.5b-q4_k_m.gguf and zr1-1.5b-q8_0.gguf sharing b6835114…) is real and not a copy-paste. Rebased on main 7dd39f1b before this commit, so the head is now d693848; CI re-running.

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

@bong-water-water-bong
bong-water-water-bong merged commit f3a42c4 into main Nov 5, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the i-tmplsec-logshatpl branch November 5, 2026 12:36
bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…_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 added a commit that referenced this pull request Nov 5, 2026
…_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>
@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:

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

Template Pin Strict Mode

The --template-pin-strict mode throws a std::runtime_error when a template mismatch occurs, which could cause the serve process to exit abruptly. This behavior should be carefully considered for production environments where graceful degradation might be preferred over immediate termination.

announce_template(o);
Template Pin Parsing

The TemplatePins::parse function does not validate that the JSON structure contains only the expected fields, potentially allowing unexpected keys to be silently ignored or causing undefined behavior if the structure changes.

bool parse(const std::string& text, std::string& err) {
    by_key.clear();
    nlohmann::json j;
    try {
        j = nlohmann::json::parse(text);
    } catch (const std::exception& e) {
        err = std::string("not JSON: ") + e.what();
        return false;
    }
    if (!j.is_object() || !j.contains("pins") || !j["pins"].is_object()) {
        err = "expected an object with a \"pins\" object";
        return false;
    }
    for (const auto& [key, v] : j["pins"].items()) {
        std::string sha, source;
        if (v.is_string()) {
            sha = v.get<std::string>();
        } else if (v.is_object() && v.contains("sha256") && v["sha256"].is_string()) {
            sha = v["sha256"].get<std::string>();
        } else {
            err = "pins." + key + ": expected a sha256 string or an object with \"sha256\"";
            return false;
        }
        std::transform(sha.begin(), sha.end(), sha.begin(), [](unsigned char c) { return std::tolower(c); });
        if (sha != "none" && (sha.size() != 64 || sha.find_first_not_of("0123456789abcdef") != std::string::npos)) {
            err = "pins." + key + ": \"" + sha + "\" is not a sha256 (64 hex), and not \"none\"";
            return false;
        }
        std::string k = key;
        std::transform(k.begin(), k.end(), k.begin(), [](unsigned char c) { return std::tolower(c); });
        if (k.empty()) {
            err = "pins: an empty model key";
            return false;
        }
        by_key[k] = sha;
    }
    return true;
}
Documentation Clarity

The documentation for --template-pin-strict could be clearer about the specific exit code and error message format, which would help users understand how to programmatically detect and handle this condition.

`--template-pin-strict` (or `ONEBIT_TEMPLATE_PIN_STRICT=1`) makes that a refusal, exit 1 — use it
where a model must be exactly the one that was validated. `--template-pin FILE` replaces the built-in
table (a wrong sha256, or a file that is not a pin file, is refused by name) and `--no-template-pin`
names the sha without comparing anything.

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.

1 participant