FoolDev Claude Opus 5 (1M context) commited on
Commit
2de4ce0
·
1 Parent(s): edc32fb

feat(build): stamp chat_template.jinja into the GGUF at build time

Browse files

--chat-template-file is a llama.cpp flag. LM Studio, Jan, KoboldCpp, Open WebUI
and llama-cpp-python read the template embedded in the GGUF and have nowhere to
put it, so they were still getting the upstream XML tool-call form and the empty
completions it causes.

strip_mtp.py gains --chat-template FILE, which swaps tokenizer.chat_template in
the same pass that drops the MTP block; build.sh passes chat_template.jinja
automatically when present. A GGUF built here now needs no flags on any loader.

With --chat-template on an already-clean input the file is rewritten purely to
carry the new template, since the hardlink passthrough cannot. Tensor data is
copied byte-for-byte in every path. An empty template file is refused.

Verified on synthetic GGUFs across all six paths, including that a clean input
with no stamp still hardlinks (same inode) and that the real chat_template.jinja
reads back byte-identical after stamping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ans2THL2VJutsXi6RSFe4Y

Files changed (4) hide show
  1. CHANGELOG.md +24 -0
  2. README.md +17 -1
  3. scripts/build.sh +12 -1
  4. scripts/strip_mtp.py +57 -10
CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ track the **tooling and documentation**, not the underlying base model.
8
 
9
  ## [Unreleased]
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ### Fixed (provenance, and a CLI-detection footgun)
12
  - **`base_model_relation: finetune` → `quantized`.** Nothing is finetuned in this
13
  repo; it repackages and quantizes an upstream abliteration. A new
 
8
 
9
  ## [Unreleased]
10
 
11
+ ### Added (chat template is stamped into the GGUF at build time)
12
+ - **`scripts/strip_mtp.py` gained `--chat-template FILE`**, which replaces the
13
+ GGUF's `tokenizer.chat_template` in the same pass that drops the MTP block.
14
+ `scripts/build.sh` now passes `chat_template.jinja` automatically when the file
15
+ is present. This matters because `--chat-template-file` is a llama.cpp flag:
16
+ LM Studio, Jan, KoboldCpp, Open WebUI and llama-cpp-python all read the template
17
+ **embedded in the GGUF** and have nowhere to put that flag, so they were still
18
+ getting the upstream XML tool-call form and the empty completions that come with
19
+ it. A GGUF built here now carries the working template, and those loaders need
20
+ no flags at all.
21
+ - The script stays a strip tool: with `--chat-template` on an already-MTP-clean
22
+ input it rewrites the file purely to swap the template (the usual hardlink
23
+ passthrough cannot carry one), and still hardlinks when neither a strip nor a
24
+ stamp is needed. Tensor data is copied byte-for-byte in every path — no
25
+ re-quantization, no quality change. An empty template file is refused rather
26
+ than stamped.
27
+ - Verified against synthetic GGUFs across all six paths: MTP-strip alone;
28
+ MTP-strip plus stamp; clean input plus stamp (rewrites, block_count preserved,
29
+ tensor bytes intact); clean input with no stamp (hardlink, same inode); empty
30
+ template file rejected; and this repo's real `chat_template.jinja` stamped and
31
+ read back byte-identical.
32
+ - Documented in the README: a locally built GGUF needs no flag, and there is a
33
+ one-liner to restamp a GGUF you already have.
34
+
35
  ### Fixed (provenance, and a CLI-detection footgun)
36
  - **`base_model_relation: finetune` → `quantized`.** Nothing is finetuned in this
37
  repo; it repackages and quantizes an upstream abliteration. A new
README.md CHANGED
@@ -126,7 +126,7 @@ baked in, so context past ~262K degrades). See
126
  | `scripts/build.sh` | Pulls a GGUF from `llmfan46/Qwen3.8-27B-Ultra-Uncensored-Heretic-Native-MTP-Preserved-GGUF` (default Q4_K_M), runs it through `strip_mtp.py`, then runs `ollama create janus`. Quants published there: BF16, Q3_K_L, Q3_K_M, Q4_K_M, Q4_K_S, Q5_K_M, Q5_K_S, Q6_K, Q8_0. The bundled Q4_K_M is already this Heretic quant (stripped); use this to build the others locally. |
127
  | `scripts/check_bridge_sync.py` | Run before pushing a `Modelfile` / `template` / `system` / `params` edit to verify the four configurations remain in sync. Exits 0 if in sync, 1 with a per-key diff if not. |
128
  | `scripts/verify_arch.py` | Cross-checks the README Architecture bullets (layer count, hidden size, dense FFN width, native context, vocab) plus the underlying forward-pass structure (Gated Attention + Gated DeltaNet head dims, partial RoPE, full-attention interval) against the bundled GGUF's `qwen35` metadata. Run on demand (`python3 scripts/verify_arch.py`); loads the ~17 GB GGUF (LFS smudge required) and exits non-zero on any mismatch. `block_count` is checked against **64** — the MTP-stripped value — so an unstripped bundle reports 65 and fails. |
129
- | `scripts/strip_mtp.py` | Drops the MTP / NextN layer from a `qwen35` GGUF (`python3 scripts/strip_mtp.py IN.gguf OUT.gguf`): removes the extra `blk.<last>.*` block, decrements `block_count`, drops `nextn_predict_layers`. Kept tensors are copied byte-for-byte (no re-quant); a conditional no-op (hardlink) on already-clean quants. **Required, not optional:** the base is MTP-preserved, so quants from the source repo carry a NextN block at index 64 that stock llama.cpp / Ollama cannot load. `build.sh` runs this on every fetched quant, and the bundled Q4_K_M ships already stripped (`block_count` 64). Parity with the dense sibling `FoolDev/Thanatos-27B-HERETIC`. |
130
  | `scripts/smoke_test.sh` | Integration smoke test against a running Ollama daemon: server reachable, model loaded, `tools` capability present, chat round-trip, and no control-token leakage. `TOOLS_TEST=1` adds a tool-call round-trip. Defaults to `MODEL=janus`. |
131
  | `scripts/bench.sh` | Measures tok/s from Ollama's `eval_count` / `eval_duration` over a short/medium/long prompt mix (with a discarded warmup). Defaults to `MODEL=janus`. |
132
  | `scripts/load_bundle.sh` | Loads the bundled `Janus-27B.Q4_K_M.gguf` into Ollama as a local `janus` tag without an upstream pull (smudges the LFS pointer via `hf download` if needed, checks the arch is `qwen35`). |
@@ -201,6 +201,7 @@ Drop the GGUF into your loader of choice. The chat template is embedded in the G
201
  llama-server -m Janus-27B.Q4_K_M.gguf --jinja --chat-template-file chat_template.jinja
202
  ```
203
 
 
204
  See [Tool / function calling](#tool--function-calling) for what differs and why.
205
 
206
  ### Ollama
@@ -504,6 +505,21 @@ llama-server -m Janus-27B.Q4_K_M.gguf --jinja --chat-template-file chat_template
504
 
505
  With that flag the wire format is identical on Ollama and llama.cpp, so the same client code works against either.
506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  #### Example (Ollama, OpenAI-compatible API)
508
 
509
  ```python
 
126
  | `scripts/build.sh` | Pulls a GGUF from `llmfan46/Qwen3.8-27B-Ultra-Uncensored-Heretic-Native-MTP-Preserved-GGUF` (default Q4_K_M), runs it through `strip_mtp.py`, then runs `ollama create janus`. Quants published there: BF16, Q3_K_L, Q3_K_M, Q4_K_M, Q4_K_S, Q5_K_M, Q5_K_S, Q6_K, Q8_0. The bundled Q4_K_M is already this Heretic quant (stripped); use this to build the others locally. |
127
  | `scripts/check_bridge_sync.py` | Run before pushing a `Modelfile` / `template` / `system` / `params` edit to verify the four configurations remain in sync. Exits 0 if in sync, 1 with a per-key diff if not. |
128
  | `scripts/verify_arch.py` | Cross-checks the README Architecture bullets (layer count, hidden size, dense FFN width, native context, vocab) plus the underlying forward-pass structure (Gated Attention + Gated DeltaNet head dims, partial RoPE, full-attention interval) against the bundled GGUF's `qwen35` metadata. Run on demand (`python3 scripts/verify_arch.py`); loads the ~17 GB GGUF (LFS smudge required) and exits non-zero on any mismatch. `block_count` is checked against **64** — the MTP-stripped value — so an unstripped bundle reports 65 and fails. |
129
+ | `scripts/strip_mtp.py` | Drops the MTP / NextN layer from a `qwen35` GGUF (`python3 scripts/strip_mtp.py IN.gguf OUT.gguf`): removes the extra `blk.<last>.*` block, decrements `block_count`, drops `nextn_predict_layers`. Kept tensors are copied byte-for-byte (no re-quant); a conditional no-op (hardlink) on already-clean quants. **Required, not optional:** the base is MTP-preserved, so quants from the source repo carry a NextN block at index 64 that stock llama.cpp / Ollama cannot load. With `--chat-template FILE` it also restamps the GGUF's embedded chat template in the same pass (see [Tool / function calling](#tool--function-calling)). `build.sh` runs this on every fetched quant, and the bundled Q4_K_M ships already stripped (`block_count` 64). Parity with the dense sibling `FoolDev/Thanatos-27B-HERETIC`. |
130
  | `scripts/smoke_test.sh` | Integration smoke test against a running Ollama daemon: server reachable, model loaded, `tools` capability present, chat round-trip, and no control-token leakage. `TOOLS_TEST=1` adds a tool-call round-trip. Defaults to `MODEL=janus`. |
131
  | `scripts/bench.sh` | Measures tok/s from Ollama's `eval_count` / `eval_duration` over a short/medium/long prompt mix (with a discarded warmup). Defaults to `MODEL=janus`. |
132
  | `scripts/load_bundle.sh` | Loads the bundled `Janus-27B.Q4_K_M.gguf` into Ollama as a local `janus` tag without an upstream pull (smudges the LFS pointer via `hf download` if needed, checks the arch is `qwen35`). |
 
201
  llama-server -m Janus-27B.Q4_K_M.gguf --jinja --chat-template-file chat_template.jinja
202
  ```
203
 
204
+ A GGUF built by `./scripts/build.sh` has that template baked in and needs no flag.
205
  See [Tool / function calling](#tool--function-calling) for what differs and why.
206
 
207
  ### Ollama
 
505
 
506
  With that flag the wire format is identical on Ollama and llama.cpp, so the same client code works against either.
507
 
508
+ > **A locally built GGUF needs no flag.** `./scripts/build.sh` (`make build`) now
509
+ > stamps `chat_template.jinja` into the GGUF it produces, in the same pass that
510
+ > strips the MTP block — so LM Studio, Jan, KoboldCpp and anything else that reads
511
+ > the *embedded* template get working tool calls with no flags at all. The
512
+ > `--chat-template-file` flag above is only needed for a GGUF that was not built
513
+ > that way: the bundled blob, or a quant pulled straight from upstream. To restamp
514
+ > one you already have:
515
+ >
516
+ > ```bash
517
+ > python3 scripts/strip_mtp.py IN.gguf OUT.gguf --chat-template chat_template.jinja
518
+ > ```
519
+ >
520
+ > On an already-MTP-clean input that rewrites the file purely to swap the template;
521
+ > tensor data is copied byte-for-byte either way, so there is no re-quantization.
522
+
523
  #### Example (Ollama, OpenAI-compatible API)
524
 
525
  ```python
scripts/build.sh CHANGED
@@ -105,7 +105,18 @@ fi
105
  # upstream safetensors under vLLM / SGLang, or the unstripped GGUF straight from
106
  # the source repo — not this path.
107
  STRIPPED_PATH="${GGUF_PATH%.gguf}.stripped.gguf"
108
- python3 "${ROOT}/scripts/strip_mtp.py" "${GGUF_PATH}" "${STRIPPED_PATH}"
 
 
 
 
 
 
 
 
 
 
 
109
  GGUF_PATH="${STRIPPED_PATH}"
110
  echo "[*] gguf (post-strip): ${GGUF_PATH}"
111
 
 
105
  # upstream safetensors under vLLM / SGLang, or the unstripped GGUF straight from
106
  # the source repo — not this path.
107
  STRIPPED_PATH="${GGUF_PATH%.gguf}.stripped.gguf"
108
+ # Stamp this repo's chat template into the GGUF in the same pass. llama.cpp,
109
+ # LM Studio, Jan and KoboldCpp all read the template embedded in the GGUF, and
110
+ # the upstream one instructs the model to emit tool calls in an XML dialect that
111
+ # llama.cpp's --jinja grammar rejects — which surfaces as an empty completion the
112
+ # moment a `tools` array is present. Stamping it here means those loaders get
113
+ # working tool calls with no extra flags. Skipped if the file is absent.
114
+ STAMP_ARGS=()
115
+ if [[ -f "${ROOT}/chat_template.jinja" ]]; then
116
+ STAMP_ARGS=(--chat-template "${ROOT}/chat_template.jinja")
117
+ echo "[*] stamping chat_template.jinja into the built GGUF"
118
+ fi
119
+ python3 "${ROOT}/scripts/strip_mtp.py" "${GGUF_PATH}" "${STRIPPED_PATH}" ${STAMP_ARGS[@]+"${STAMP_ARGS[@]}"}
120
  GGUF_PATH="${STRIPPED_PATH}"
121
  echo "[*] gguf (post-strip): ${GGUF_PATH}"
122
 
scripts/strip_mtp.py CHANGED
@@ -21,11 +21,27 @@ or taking the unstripped GGUF straight from the source repo.
21
  Every KEPT tensor is copied byte-for-byte (raw quantized data) — no
22
  re-quantization, so no quality change to the model itself.
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  Usage:
25
  python3 scripts/strip_mtp.py IN.gguf OUT.gguf
 
26
  """
27
  from __future__ import annotations
28
 
 
29
  import sys
30
 
31
  import numpy as np
@@ -47,10 +63,27 @@ def decode_field(field):
47
 
48
 
49
  def main() -> int:
50
- if len(sys.argv) != 3:
51
- print("usage: strip_mtp.py IN.gguf OUT.gguf", file=sys.stderr)
52
- return 2
53
- src, dst = sys.argv[1], sys.argv[2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  reader = GGUFReader(src, "r")
56
  arch = decode_field(reader.get_field("general.architecture"))
@@ -68,7 +101,10 @@ def main() -> int:
68
  # caller always gets a valid output without touching real transformer layers.
69
  has_nextn = decode_field(reader.get_field(f"{arch}.nextn_predict_layers")) or 0
70
  last_is_mtp = any(t.name.startswith(prefix) and "nextn" in t.name.lower() for t in reader.tensors)
71
- if not has_nextn and not last_is_mtp:
 
 
 
72
  print(f"[=] no MTP/NextN layer (block_count={block_count}); copying through unchanged")
73
  if src != dst:
74
  import os
@@ -84,12 +120,15 @@ def main() -> int:
84
  # We only ever drop the last block, so it MUST be the MTP/NextN block. If
85
  # nextn_predict_layers is set but blk.<last>.* holds no NextN tensor, the MTP
86
  # block isn't where we assume — refuse rather than drop a real transformer layer.
87
- if not last_is_mtp:
88
  print(f"[!] {arch}.nextn_predict_layers is set but blk.{last}.* has no NextN tensor; "
89
  "refusing to strip (would drop a real transformer layer).", file=sys.stderr)
90
  return 1
91
 
92
- print(f"[*] arch={arch} block_count={block_count} -> stripping MTP block {last}, new block_count={block_count-1}")
 
 
 
93
 
94
  # Writer manages general.architecture itself; block_count/nextn we override/drop.
95
  skip_keys = {
@@ -97,9 +136,11 @@ def main() -> int:
97
  f"{arch}.block_count",
98
  f"{arch}.nextn_predict_layers",
99
  }
 
 
100
 
101
  writer = GGUFWriter(dst, arch=arch)
102
- writer.add_block_count(block_count - 1)
103
 
104
  n_kv = 0
105
  for name, field in reader.fields.items():
@@ -117,8 +158,14 @@ def main() -> int:
117
  writer.add_key_value(name, val, vtype)
118
  n_kv += 1
119
 
120
- kept = [t for t in reader.tensors if not t.name.startswith(prefix)]
121
- dropped = [t.name for t in reader.tensors if t.name.startswith(prefix)]
 
 
 
 
 
 
122
  print(f"[*] copied {n_kv} KV keys; keeping {len(kept)} tensors, dropping {len(dropped)} (block {last})")
123
 
124
  for t in kept:
 
21
  Every KEPT tensor is copied byte-for-byte (raw quantized data) — no
22
  re-quantization, so no quality change to the model itself.
23
 
24
+ Optionally also restamp the embedded chat template in the same pass:
25
+
26
+ --chat-template FILE replace tokenizer.chat_template with FILE's contents
27
+
28
+ That matters because llama.cpp, LM Studio, Jan and KoboldCpp all read the
29
+ template baked into the GGUF, and the upstream one emits tool calls in an XML
30
+ dialect that llama.cpp's --jinja grammar rejects — the caller just gets an empty
31
+ completion. Stamping this repo's chat_template.jinja in at build time fixes tool
32
+ calling for every one of those loaders with no extra flags. build.sh does this
33
+ automatically when chat_template.jinja is present.
34
+
35
+ With --chat-template and no MTP block to strip, the file is still rewritten (the
36
+ usual hardlink passthrough would not carry the new template).
37
+
38
  Usage:
39
  python3 scripts/strip_mtp.py IN.gguf OUT.gguf
40
+ python3 scripts/strip_mtp.py IN.gguf OUT.gguf --chat-template chat_template.jinja
41
  """
42
  from __future__ import annotations
43
 
44
+ import argparse
45
  import sys
46
 
47
  import numpy as np
 
63
 
64
 
65
  def main() -> int:
66
+ ap = argparse.ArgumentParser(
67
+ description="Strip the MTP/NextN block from a GGUF; optionally restamp its chat template.")
68
+ ap.add_argument("src", metavar="IN.gguf")
69
+ ap.add_argument("dst", metavar="OUT.gguf")
70
+ ap.add_argument("--chat-template", metavar="FILE", default=None,
71
+ help="replace tokenizer.chat_template with the contents of FILE")
72
+ args = ap.parse_args()
73
+ src, dst = args.src, args.dst
74
+
75
+ new_template = None
76
+ if args.chat_template:
77
+ try:
78
+ with open(args.chat_template, encoding="utf-8") as fh:
79
+ new_template = fh.read()
80
+ except OSError as exc:
81
+ print(f"[!] cannot read {args.chat_template}: {exc}", file=sys.stderr)
82
+ return 1
83
+ if not new_template.strip():
84
+ print(f"[!] {args.chat_template} is empty; refusing to stamp a blank template",
85
+ file=sys.stderr)
86
+ return 1
87
 
88
  reader = GGUFReader(src, "r")
89
  arch = decode_field(reader.get_field("general.architecture"))
 
101
  # caller always gets a valid output without touching real transformer layers.
102
  has_nextn = decode_field(reader.get_field(f"{arch}.nextn_predict_layers")) or 0
103
  last_is_mtp = any(t.name.startswith(prefix) and "nextn" in t.name.lower() for t in reader.tensors)
104
+ strip = bool(has_nextn or last_is_mtp)
105
+ # The hardlink passthrough cannot carry a new chat template, so it only
106
+ # applies when there is nothing at all to rewrite.
107
+ if not strip and new_template is None:
108
  print(f"[=] no MTP/NextN layer (block_count={block_count}); copying through unchanged")
109
  if src != dst:
110
  import os
 
120
  # We only ever drop the last block, so it MUST be the MTP/NextN block. If
121
  # nextn_predict_layers is set but blk.<last>.* holds no NextN tensor, the MTP
122
  # block isn't where we assume — refuse rather than drop a real transformer layer.
123
+ if strip and not last_is_mtp:
124
  print(f"[!] {arch}.nextn_predict_layers is set but blk.{last}.* has no NextN tensor; "
125
  "refusing to strip (would drop a real transformer layer).", file=sys.stderr)
126
  return 1
127
 
128
+ if strip:
129
+ print(f"[*] arch={arch} block_count={block_count} -> stripping MTP block {last}, new block_count={block_count-1}")
130
+ else:
131
+ print(f"[=] arch={arch} block_count={block_count}; no MTP block, rewriting to restamp the chat template")
132
 
133
  # Writer manages general.architecture itself; block_count/nextn we override/drop.
134
  skip_keys = {
 
136
  f"{arch}.block_count",
137
  f"{arch}.nextn_predict_layers",
138
  }
139
+ if new_template is not None:
140
+ skip_keys.add("tokenizer.chat_template")
141
 
142
  writer = GGUFWriter(dst, arch=arch)
143
+ writer.add_block_count(block_count - 1 if strip else block_count)
144
 
145
  n_kv = 0
146
  for name, field in reader.fields.items():
 
158
  writer.add_key_value(name, val, vtype)
159
  n_kv += 1
160
 
161
+ if new_template is not None:
162
+ writer.add_string("tokenizer.chat_template", new_template)
163
+ n_kv += 1
164
+ print(f"[*] chat template restamped from {args.chat_template} "
165
+ f"({len(new_template)} chars)")
166
+
167
+ kept = [t for t in reader.tensors if not (strip and t.name.startswith(prefix))]
168
+ dropped = [t.name for t in reader.tensors if strip and t.name.startswith(prefix)]
169
  print(f"[*] copied {n_kv} KV keys; keeping {len(kept)} tensors, dropping {len(dropped)} (block {last})")
170
 
171
  for t in kept: