Created
April 13, 2026 09:47
-
-
Save Reithan/b815e4f07160330c4ddc49ee76729dd2 to your computer and use it in GitHub Desktop.
Creates a new copy of a gguf file with a different jinja chat template.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| update_gguf_jinja.py — Replace a metadata string key in a GGUF model file. | |
| By default replaces tokenizer.chat_template with the contents of a .jinja | |
| file. Writes a new output file; the source is never modified. | |
| Usage examples: | |
| python update_gguf_jinja.py model.gguf template.jinja | |
| python update_gguf_jinja.py model.gguf template.jinja -o patched.gguf | |
| python update_gguf_jinja.py model.gguf template.jinja --key tokenizer.chat_template | |
| python update_gguf_jinja.py model.gguf --show-template | |
| Requirements: | |
| pip install gguf | |
| pip install rich # optional, nicer progress bar | |
| pip install argcomplete # optional, enables tab completion | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import sys | |
| import threading | |
| import time | |
| from pathlib import Path | |
| try: | |
| from rich.progress import ( | |
| Progress, BarColumn, FileSizeColumn, TotalFileSizeColumn, | |
| TransferSpeedColumn, TimeRemainingColumn, TextColumn, | |
| ) | |
| _HAS_RICH = True | |
| except ImportError: | |
| _HAS_RICH = False | |
| # ── optional tab-completion ─────────────────────────────────────────────────── | |
| try: | |
| import argcomplete | |
| _HAS_ARGCOMPLETE = True | |
| except ImportError: | |
| _HAS_ARGCOMPLETE = False | |
| # ── required dependency ─────────────────────────────────────────────────────── | |
| try: | |
| from gguf import GGUFReader, GGUFWriter, GGUFValueType | |
| except ImportError: | |
| print("Error: 'gguf' package is required. Install with:\n pip install gguf", | |
| file=sys.stderr) | |
| sys.exit(1) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Progress bar (file-size based, runs in background thread) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| class _ProgressMonitor: | |
| """Polls output file size and updates a progress display every 0.33 s.""" | |
| BAR_WIDTH = 42 | |
| def __init__(self, output_path: Path, total_bytes: int) -> None: | |
| self._path = output_path | |
| self._total = total_bytes | |
| self._stop = threading.Event() | |
| if _HAS_RICH: | |
| self._progress = Progress( | |
| TextColumn("[bold cyan]{task.description}"), | |
| BarColumn(bar_width=None), | |
| FileSizeColumn(), | |
| TextColumn("/"), | |
| TotalFileSizeColumn(), | |
| TransferSpeedColumn(), | |
| TimeRemainingColumn(), | |
| ) | |
| self._task = self._progress.add_task("Writing tensors", total=total_bytes) | |
| else: | |
| self._progress = None | |
| self._task = None | |
| self._thread = threading.Thread(target=self._run, daemon=True) | |
| def start(self) -> None: | |
| if self._progress: | |
| self._progress.start() | |
| self._thread.start() | |
| def stop(self) -> None: | |
| self._stop.set() | |
| self._thread.join() | |
| if self._progress: | |
| self._progress.update(self._task, completed=self._total) | |
| self._progress.stop() | |
| else: | |
| self._draw(self._total) | |
| print(file=sys.stderr) | |
| def _run(self) -> None: | |
| while not self._stop.is_set(): | |
| try: | |
| current = self._path.stat().st_size | |
| except FileNotFoundError: | |
| current = 0 | |
| if self._progress: | |
| self._progress.update(self._task, completed=current) | |
| else: | |
| self._draw(current) | |
| time.sleep(0.33) | |
| def _draw(self, current: int) -> None: | |
| """Fallback ASCII bar used when Rich is not installed.""" | |
| pct = min(current / self._total, 1.0) if self._total > 0 else 1.0 | |
| filled = int(self.BAR_WIDTH * pct) | |
| bar = "█" * filled + "░" * (self.BAR_WIDTH - filled) | |
| written_gb = current / 1024 ** 3 | |
| total_gb = self._total / 1024 ** 3 | |
| print( | |
| f"\r [{bar}] {written_gb:.2f} / {total_gb:.2f} GB ({pct:.1%})", | |
| end="", | |
| flush=True, | |
| file=sys.stderr, | |
| ) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Field copy helpers | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def _copy_field(writer: GGUFWriter, field) -> None: | |
| """Copy one metadata field from a GGUFReader field to a GGUFWriter.""" | |
| key = field.name | |
| vtype = field.types[0] | |
| if vtype == GGUFValueType.STRING: | |
| writer.add_string(key, str(bytes(field.parts[-1]), encoding="utf-8")) | |
| elif vtype == GGUFValueType.BOOL: | |
| writer.add_bool(key, bool(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.UINT8: | |
| writer.add_uint8(key, int(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.INT8: | |
| writer.add_int8(key, int(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.UINT16: | |
| writer.add_uint16(key, int(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.INT16: | |
| writer.add_int16(key, int(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.UINT32: | |
| writer.add_uint32(key, int(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.INT32: | |
| writer.add_int32(key, int(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.UINT64: | |
| writer.add_uint64(key, int(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.INT64: | |
| writer.add_int64(key, int(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.FLOAT32: | |
| writer.add_float32(key, float(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.FLOAT64: | |
| writer.add_float64(key, float(field.parts[-1][0])) | |
| elif vtype == GGUFValueType.ARRAY: | |
| elem_type = field.types[1] if len(field.types) > 1 else None | |
| if elem_type == GGUFValueType.STRING: | |
| # field.data holds part-indices pointing at each string's byte data | |
| values = [str(bytes(field.parts[i]), encoding="utf-8") for i in field.data] | |
| writer.add_array(key, values) | |
| elif elem_type == GGUFValueType.BOOL: | |
| writer.add_array(key, [bool(field.parts[i][0]) for i in field.data]) | |
| else: | |
| # Numeric arrays: GGUFReader stores each element as its own | |
| # 1-element part; field.data has one index per element — same | |
| # layout as string/bool arrays. Collect element [0] from each. | |
| if field.data: | |
| writer.add_array(key, [field.parts[i][0].item() for i in field.data]) | |
| else: | |
| writer.add_array(key, field.parts[-1].tolist()) | |
| else: | |
| print(f" Warning: skipping field '{key}' — unsupported type {vtype}", | |
| file=sys.stderr) | |
| def _get_arch(reader: GGUFReader) -> str: | |
| f = reader.fields.get("general.architecture") | |
| if f is None: | |
| return "unknown" | |
| return str(bytes(f.parts[-1]), encoding="utf-8") | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Core operations | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def show_template(gguf_path: Path, key: str) -> None: | |
| reader = GGUFReader(str(gguf_path), "r") | |
| field = reader.fields.get(key) | |
| if field is None: | |
| print(f"Key '{key}' not found in {gguf_path.name}.", file=sys.stderr) | |
| sys.exit(1) | |
| vtype = field.types[0] | |
| if vtype != GGUFValueType.STRING: | |
| print(f"Key '{key}' is type {vtype.name}, not STRING.", file=sys.stderr) | |
| sys.exit(1) | |
| print(str(bytes(field.parts[-1]), encoding="utf-8")) | |
| def patch( | |
| input_path: Path, | |
| template_path: Path, | |
| output_path: Path, | |
| key: str, | |
| dry_run: bool, | |
| ) -> None: | |
| new_value = template_path.read_text(encoding="utf-8") | |
| print(f" Source : {input_path}", file=sys.stderr) | |
| print(f" Template: {template_path} ({len(new_value):,} chars)", file=sys.stderr) | |
| print(f" Key : {key}", file=sys.stderr) | |
| print(f" Output : {output_path}", file=sys.stderr) | |
| if dry_run: | |
| print("\nDry run — nothing written.", file=sys.stderr) | |
| return | |
| if output_path.exists(): | |
| print(f"\nError: output file already exists: {output_path}", file=sys.stderr) | |
| print("Delete it first, or choose a different --output path.", file=sys.stderr) | |
| sys.exit(1) | |
| print("\nReading metadata …", file=sys.stderr) | |
| reader = GGUFReader(str(input_path), "r") | |
| arch = _get_arch(reader) | |
| if key not in reader.fields: | |
| print(f"\nWarning: key '{key}' not found in source; it will be added.", | |
| file=sys.stderr) | |
| # Estimate output size ≈ input size + delta of template string | |
| source_size = input_path.stat().st_size | |
| old_len = 0 | |
| if key in reader.fields: | |
| f = reader.fields[key] | |
| old_len = len(bytes(f.parts[-1])) | |
| size_estimate = source_size - old_len + len(new_value.encode("utf-8")) | |
| print(f" Architecture: {arch}", file=sys.stderr) | |
| print(f" Tensors : {len(reader.tensors):,}", file=sys.stderr) | |
| print(f" Source size : {source_size / 1024**3:.2f} GB", file=sys.stderr) | |
| print(f" Est. output : {size_estimate / 1024**3:.2f} GB", file=sys.stderr) | |
| print("\nBuilding writer …", file=sys.stderr) | |
| writer = GGUFWriter(str(output_path), arch) | |
| # ── KV metadata ────────────────────────────────────────────────────────── | |
| replaced = False | |
| for field_key, field in reader.fields.items(): | |
| if field_key == key: | |
| writer.add_string(key, new_value) | |
| replaced = True | |
| elif field_key == "general.architecture": | |
| pass # already set by GGUFWriter constructor | |
| else: | |
| _copy_field(writer, field) | |
| if not replaced: | |
| writer.add_string(key, new_value) | |
| # ── Tensor metadata (shapes / types) ───────────────────────────────────── | |
| print("Registering tensors …", file=sys.stderr) | |
| for tensor in reader.tensors: | |
| writer.add_tensor(tensor.name, tensor.data, raw_dtype=tensor.tensor_type) | |
| # ── Write header + KV ──────────────────────────────────────────────────── | |
| print("Writing header and metadata …", file=sys.stderr) | |
| writer.write_header_to_file() | |
| writer.write_kv_data_to_file() | |
| # ── Write tensor data (the slow part) ──────────────────────────────────── | |
| print("Writing tensor data …", file=sys.stderr) | |
| monitor = _ProgressMonitor(output_path, size_estimate) | |
| monitor.start() | |
| try: | |
| writer.write_tensors_to_file() | |
| finally: | |
| monitor.stop() | |
| writer.close() | |
| actual_size = output_path.stat().st_size | |
| print(f"\nDone. Output: {output_path} ({actual_size / 1024**3:.2f} GB)", | |
| file=sys.stderr) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # CLI | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def _build_parser() -> argparse.ArgumentParser: | |
| p = argparse.ArgumentParser( | |
| prog="update_gguf_jinja", | |
| description="Replace a metadata string key (e.g. chat template) in a GGUF file.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| examples: | |
| # Replace the chat template and auto-name the output: | |
| python update_gguf_jinja.py model.gguf Gemma-4-31b-it.v3.jinja | |
| # Specify the output path explicitly: | |
| python update_gguf_jinja.py model.gguf template.jinja -o model_v3.gguf | |
| # Print the current embedded template and exit: | |
| python update_gguf_jinja.py model.gguf --show-template | |
| # Preview what would happen without writing anything: | |
| python update_gguf_jinja.py model.gguf template.jinja --dry-run | |
| tab completion (bash/zsh): | |
| pip install argcomplete | |
| eval "$(register-python-argcomplete update_gguf_jinja.py)" | |
| """, | |
| ) | |
| arg_input = p.add_argument( | |
| "input", | |
| metavar="INPUT.gguf", | |
| type=Path, | |
| help="Source GGUF model file.", | |
| ) | |
| arg_template = p.add_argument( | |
| "template", | |
| metavar="TEMPLATE.jinja", | |
| type=Path, | |
| nargs="?", | |
| help="Jinja template file to embed. Required unless --show-template is used.", | |
| ) | |
| arg_output = p.add_argument( | |
| "-o", "--output", | |
| metavar="OUTPUT.gguf", | |
| type=Path, | |
| default=None, | |
| help="Destination GGUF file. Defaults to <input>_updated.gguf beside the source.", | |
| ) | |
| if _HAS_ARGCOMPLETE: | |
| arg_input.completer = argcomplete.completers.FilesCompleter(["*.gguf"]) | |
| arg_template.completer = argcomplete.completers.FilesCompleter(["*.jinja", "*.j2"]) | |
| arg_output.completer = argcomplete.completers.FilesCompleter(["*.gguf"]) | |
| p.add_argument( | |
| "-k", "--key", | |
| metavar="KEY", | |
| default="tokenizer.chat_template", | |
| help="GGUF metadata key to replace (default: tokenizer.chat_template).", | |
| ) | |
| p.add_argument( | |
| "--show-template", | |
| action="store_true", | |
| help="Print the current value of --key from INPUT.gguf and exit.", | |
| ) | |
| p.add_argument( | |
| "--dry-run", | |
| action="store_true", | |
| help="Show what would be done without writing any output.", | |
| ) | |
| if _HAS_ARGCOMPLETE: | |
| argcomplete.autocomplete(p) | |
| return p | |
| def main() -> None: | |
| parser = _build_parser() | |
| args = parser.parse_args() | |
| # ── Validate input ──────────────────────────────────────────────────────── | |
| if not args.input.exists(): | |
| parser.error(f"Input file not found: {args.input}") | |
| if not args.input.suffix.lower() == ".gguf": | |
| print(f"Warning: '{args.input}' does not have a .gguf extension.", | |
| file=sys.stderr) | |
| if args.show_template: | |
| show_template(args.input, args.key) | |
| return | |
| if args.template is None: | |
| parser.error("TEMPLATE.jinja is required (or use --show-template).") | |
| if not args.template.exists(): | |
| parser.error(f"Template file not found: {args.template}") | |
| # ── Resolve output path ─────────────────────────────────────────────────── | |
| output = args.output | |
| if output is None: | |
| stem = args.input.stem | |
| output = args.input.with_name(f"{stem}_updated.gguf") | |
| # ── Run ─────────────────────────────────────────────────────────────────── | |
| patch( | |
| input_path=args.input, | |
| template_path=args.template, | |
| output_path=output, | |
| key=args.key, | |
| dry_run=args.dry_run, | |
| ) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment