# Find a model's real context window in the GGUF header (no llama.cpp)

> Read context_length and RoPE/YaRN scaling keys from any GGUF's header with a 66-line stdlib Python script, then check your server uses that window.

Published 2026-10-24. HTML version: https://guides.tsvinc.dev/read-gguf-trained-context/


A stdlib-only Python script reads a GGUF file's metadata header and prints the context length the model was trained for, plus its RoPE scaling keys (YaRN factor, original context), so you can set the server's context to match.

> **Scope** — Verified on: GEEKOM A9 Mega, Fedora 45, Python 3.15.0rc3; GEEKOM AE8, Fedora 44, Python 3.14.8 (2026-10-16). GGUF versions 2 and 3, single-file and split models.
> **Needs:** Python 3 on the machine that holds the `.gguf` file (or SSH access to it). No llama.cpp, no pip packages, no GPU.
> **Time:** 5 min. **Risk:** none. The script opens the file read-only and reads only the header; the model file is not modified (checked in Verify).

## Before you start

- Python 3 is installed where the model lives:
  ```console
  $ ssh a9 'python3 --version; cat /etc/fedora-release'
  Python 3.15.0rc3
  Fedora release 45 (Forty Five)
  ```
- Placeholders used below:
  - `<FILE.gguf>` — path to the model file. For a split model, the shard named `…-00001-of-<N>.gguf`.
  - `<HOST>` — SSH host that stores the model, if it is not the machine you type on.
  - `<SERVER_LOG>` — your inference server's log file.
  - `<ORG>/<MODEL>` — the upstream Hugging Face repo of the original (non-GGUF) model, for the optional cross-check.
- Backups: none needed. Nothing is written.

## Steps

1. **Save the script as `ggufctx.py`.**
   ```python
   #!/usr/bin/env python3
   """Print a GGUF file's trained context length and RoPE scaling keys.

   Reads only the metadata header (no tensor data), stdlib only, never writes.
   Usage: python3 -I ggufctx.py <FILE.gguf>
   """
   import struct
   import sys

   # GGUF metadata value types (ggml docs/gguf.md, enum gguf_metadata_value_type)
   FIXED = {0: "<B", 1: "<b", 2: "<H", 3: "<h", 4: "<I", 5: "<i", 6: "<f",
            7: "<?", 10: "<Q", 11: "<q", 12: "<d"}
   STRING, ARRAY = 8, 9


   def wanted(key):
       return (key in ("general.architecture", "general.name")
               or key.endswith(".context_length")
               or ".rope.scaling." in key
               or key.startswith("split."))


   def main(path):
       with open(path, "rb") as f:
           def read(n):
               b = f.read(n)
               if len(b) != n:
                   sys.exit(f"{path}: header ends early (truncated file?)")
               return b

           def num(fmt):
               return struct.unpack(fmt, read(struct.calcsize(fmt)))[0]

           def string():
               return read(num("<Q")).decode("utf-8", "replace")

           def value(t):
               if t == STRING:
                   return string()
               if t == ARRAY:
                   et, n = num("<I"), num("<Q")
                   if et in FIXED:
                       read(n * struct.calcsize(FIXED[et]))  # skip, works on pipes too
                   else:
                       for _ in range(n):
                           value(et)
                   return f"<array, {n} items>"
               if t not in FIXED:
                   sys.exit(f"{path}: unknown value type {t}")
               return num(FIXED[t])

           if read(4) != b"GGUF":
               sys.exit(f"{path}: not a GGUF file (first 4 bytes are not 'GGUF')")
           version, n_tensors, n_kv = num("<I"), num("<Q"), num("<Q")
           print(f"gguf version {version}, {n_tensors} tensors, {n_kv} metadata keys")
           for _ in range(n_kv):
               key = string()
               v = value(num("<I"))
               if wanted(key):
                   print(key, v)


   if __name__ == "__main__":
       if len(sys.argv) != 2:
           sys.exit("usage: python3 -I ggufctx.py <FILE.gguf>")
       main(sys.argv[1])
   ```
   Check that it parses:
   ```console
   $ python3 -I ggufctx.py
   usage: python3 -I ggufctx.py <FILE.gguf>
   ```
   Expected: the usage line (exit code 1). A `SyntaxError` means the paste lost indentation.

2. **Run it on the model file.** On the machine that has the file:
   ```bash
   python3 -I ggufctx.py <FILE.gguf>
   ```
   ```console
   $ python3 -I ggufctx.py ~/models/llama-2-7b.Q4_0.gguf
   gguf version 2, 291 tensors, 19 metadata keys
   general.architecture llama
   general.name LLaMA v2
   llama.context_length 4096
   ```
   If the file is on another box, pipe the script over SSH instead of copying it there (`-` makes Python read the script from stdin; the file path is the remote one):
   ```bash
   ssh <HOST> 'python3 -I - <FILE.gguf>' < ggufctx.py
   ```
   ```console
   $ ssh a9 'python3 -I - ~/models/Laguna-XS-2.1-GGUF/Laguna-XS-2.1-Q8_0.gguf' < ggufctx.py
   gguf version 3, 678 tensors, 61 metadata keys
   general.architecture laguna
   general.name Laguna XS 2.1
   laguna.context_length 262144
   laguna.rope.scaling.type yarn
   laguna.rope.scaling.factor 32.0
   laguna.rope.scaling.original_context_length 8192
   laguna.rope.scaling.yarn_attn_factor 1.3465735912322998
   laguna.rope.scaling.yarn_beta_fast 64.0
   laguna.rope.scaling.yarn_beta_slow 1.0
   ```
   Expected: a `gguf version` line, then `general.architecture` and one `<arch>.context_length` line. On the 35.6 GB Laguna file the run took 0.070 s wall time, because only the header is read. Errors: see Failure modes, rows 1-4.

3. **For a split model, run it on shard 1.** Only the first shard carries the model metadata:
   ```console
   $ ssh a9 'python3 -I - ~/models/Laguna-S-2.1-GGUF/Laguna-S-2.1-UD-Q5_K_XL-00001-of-00003.gguf' < ggufctx.py
   gguf version 3, 0 tensors, 72 metadata keys
   general.architecture laguna
   general.name Laguna-S-2.1
   laguna.context_length 262144
   laguna.rope.scaling.type yarn
   laguna.rope.scaling.factor 32.0
   laguna.rope.scaling.original_context_length 8192
   laguna.rope.scaling.yarn_attn_factor 1.0
   laguna.rope.scaling.yarn_beta_fast 32.0
   laguna.rope.scaling.yarn_beta_slow 1.0
   split.no 0
   split.tensors.count 814
   split.count 3
   ```
   Expected: `split.no 0` (numbering starts at 0) and the `context_length` line. In this file shard 1 holds no tensors at all, only metadata. If you see only `split.*` lines, you ran a later shard: see Failure modes, row 5.

4. **Read the numbers.**
   - `<arch>.context_length` is the model's context window. The GGUF spec defines it as the "length of the context (in tokens) that the model was trained on". In both files cross-checked here (Laguna-XS, Qwen3-30B-A3B) it equals `max_position_embeddings` in the original model's `config.json`. That is usually the ceiling to serve at, but a model card can state a smaller native window (Qwen3 example below), so read the card too (Verify).
   - `<arch>.rope.scaling.type yarn` with `factor` and `original_context_length` means the window was reached by YaRN-extending a shorter base: Laguna-XS is 32.0 × 8192 = 262144. In all seven YaRN files on this box (factors 16 to 128: DeepSeek-V4-Flash, Mistral-Small-4, three gpt-oss, two Laguna), `factor × original_context_length` equals `context_length`; gpt-oss-120b is 32.0 × 4096 = 131072:
     ```console
     $ ssh a9 'python3 -I - ~/models/gpt-oss-120b-MXFP4.gguf' < ggufctx.py
     gguf version 3, 687 tensors, 36 metadata keys
     general.architecture gpt-oss
     general.name gpt-oss-120b
     gpt-oss.context_length 131072
     gpt-oss.rope.scaling.type yarn
     gpt-oss.rope.scaling.factor 32.0
     gpt-oss.rope.scaling.original_context_length 4096
     gpt-oss.rope.scaling.yarn_beta_fast 32.0
     gpt-oss.rope.scaling.yarn_beta_slow 1.0
     ```
   - No `rope.scaling` lines: the file stores no scaling. `context_length` is then whatever the config declares, which is not always the native window the card names:
     ```console
     $ ssh a9 'python3 -I - ~/models/Qwen3-30B-A3B-Q4_K_M.gguf' < ggufctx.py
     gguf version 3, 579 tensors, 37 metadata keys
     general.architecture qwen3moe
     general.name Qwen3 30B A3B
     qwen3moe.context_length 40960
     ```
     The Qwen3-30B-A3B card says "32,768 natively" and explains that `max_position_embeddings` = 40,960 reserves 32,768 tokens for output plus 8,192 for prompts; its `config.json` has `max_position_embeddings` 40960 and `rope_scaling` null.
   - The `yarn_*` keys are inputs your inference engine has to read. A build that ignores them runs the model with its own defaults (see Notes, llama.cpp #30331).

5. **Compare with the context your server actually uses.** The configured value is not proof; read the number the server prints at load. With llama-server (`-c` / `--ctx-size`; `0` means "loaded from model"), it is the `n_ctx_slot` line in the server log:
   ```console
   $ ssh a9 'grep -H n_ctx_slot ~/bench-archive/serve/laguna-xs-q8-vulkan-new-2026101*.log'
   /home/user/bench-archive/serve/laguna-xs-q8-vulkan-new-20261016T050201.log:0.09.628.876 I srv    load_model: initializing, n_slots = 1, n_ctx_slot = 131072, kv_unified = 'false'
   /home/user/bench-archive/serve/laguna-xs-q8-vulkan-new-20261016T143358.log:0.10.328.547 I srv    load_model: initializing, n_slots = 1, n_ctx_slot = 262144, kv_unified = 'false'
   ```
   For your own server: `grep n_ctx_slot <SERVER_LOG>`.
   Expected: the served value equals `context_length` from step 2. The first log above is a server started at 131072 for a model whose header says 262144: half the trained window. The second is the same model restarted at 262144. A lower served value is legitimate only when you chose it (memory budget); otherwise raise `-c` (or your engine's equivalent) to `context_length`.

## Verify

- The script did not touch the model file (modification time and size before and after a run):
  ```console
  $ ssh a9 'stat -c "%y %s" ~/models/Laguna-XS-2.1-GGUF/Laguna-XS-2.1-Q8_0.gguf' && ssh a9 'python3 -I - ~/models/Laguna-XS-2.1-GGUF/Laguna-XS-2.1-Q8_0.gguf > /dev/null' < ggufctx.py && ssh a9 'stat -c "%y %s" ~/models/Laguna-XS-2.1-GGUF/Laguna-XS-2.1-Q8_0.gguf'
  2026-10-11 02:48:44.038754178 +0300 35597116800
  2026-10-11 02:48:44.038754178 +0300 35597116800
  ```
- Optional cross-check against the original model's `config.json` (`max_position_embeddings` and the rope block should agree with the header):
  ```bash
  curl -sL https://huggingface.co/<ORG>/<MODEL>/resolve/main/config.json | python3 -I -c 'import json,sys; c=json.load(sys.stdin); print("max_position_embeddings", c.get("max_position_embeddings")); print("rope", c.get("rope_scaling") or c.get("rope_parameters"))'
  ```
  ```console
  $ curl -sL https://huggingface.co/poolside/Laguna-XS-2.1/resolve/main/config.json | python3 -I -c 'import json,sys; c=json.load(sys.stdin); print("max_position_embeddings", c.get("max_position_embeddings")); print("rope", c.get("rope_scaling") or c.get("rope_parameters"))'
  max_position_embeddings 262144
  rope {'full_attention': {'rope_theta': 500000.0, 'rope_type': 'yarn', 'factor': 32.0, 'original_max_position_embeddings': 8192, 'beta_slow': 1.0, 'beta_fast': 64.0, 'attention_factor': 1.3465735902799727, 'partial_rotary_factor': 0.5}, 'sliding_attention': {'rope_type': 'default', 'rope_theta': 10000.0, 'partial_rotary_factor': 1.0}}
  ```
  Expected: same context, factor, original length and betas as the GGUF. Also read the model card's context section: it can name a native window below `max_position_embeddings` (Qwen3-30B-A3B, step 4). `attention_factor` differs in the 9th decimal (1.3465735902799727 vs 1.3465735912322998) because GGUF stores it as float32. Gated repos need a Hugging Face token; this one did not.

## Undo / rollback

- Nothing to undo: the script only reads (Verify shows the file's mtime and size unchanged). Remove the script with `rm ggufctx.py`.

## Failure modes

| Symptom (exact message) | Cause | Fix |
|---|---|---|
| `<FILE>: not a GGUF file (first 4 bytes are not 'GGUF')` | The path is not a GGUF (seen on a `MANIFEST.jsonl` next to the model). | Point it at the `.gguf` file. |
| `FileNotFoundError: [Errno 2] No such file or directory: '<FILE>'` (after a traceback) | Wrong path. Over SSH the path is resolved on `<HOST>`, not locally. | Check the path with `ls` on the box that holds the file. |
| Some keys print, then `<FILE>: header ends early (truncated file?)` | The file ends inside the header: a partial download (reproduced by feeding the script the first 4096 bytes of the Laguna file). | Compare the file size with the source's size; re-download. |
| `usage: python3 -I ggufctx.py <FILE.gguf>` | No file argument (over SSH: the path is missing after `-`). | Pass exactly one path. |
| Only `split.no 1`, `split.tensors.count …`, `split.count …` lines; no `context_length` | You ran shard 2 or later of a split model; those shards carry 3 keys only. | Run it on `…-00001-of-<N>.gguf` (step 3). |
| Server: `error: request (132149 tokens) exceeds the available context size (131072 tokens), try increasing it` | The server was started below the trained window (131072 served, 262144 in the header). | Restart with the context set to `context_length` if memory allows (step 5). |

## Field notes

- **2026-10-16, a9-mega (GEEKOM A9 Mega, Fedora 45, Python 3.15.0rc3; models in `~/models`):**
  - The A9 model benchmark had served Laguna-XS-2.1 Q8_0 at 131072 since its intake screen on 2026-10-11, the default for every screen there. On the 2026-10-16 arm (llama.cpp master, Vulkan) two test runs stopped on the server's "exceeds the available context size (131072 tokens)" error, at 132149 and 131338 requested tokens. Reading the header showed `context_length 262144` (YaRN 32 × 8192), so the server had been running the model at half its trained window.
  - A filter on keys ending in `context_length` alone shows 262144 and `original_context_length 8192` but hides the scaling type and factor; a filter on `context`/`rope` shows the full YaRN block. The script above prints `rope.scaling.*` for that reason.
  - Skipping arrays with `seek()` fails with `io.UnsupportedOperation: File or stream is not seekable.` when given a process substitution (`<(head -c 4096 …)`). Skipping by reading fixed that; the truncated input now ends with "header ends early".
  - The two cells were re-run at 262144 the same afternoon (server log at 14:33 shows `n_ctx_slot = 262144`). Both finished: t14 PASS at 99 points, t15 FAIL at 92 points on its code, not on the cap.
  - Split check on Laguna-S-2.1 UD-Q5_K_XL: shard 1 has 0 tensors and 72 keys, shard 2 has 468 tensors and 3 keys.
- **2026-10-16, AE8 (GEEKOM AE8, Fedora 44, Python 3.14.8):** local run on `llama-2-7b.Q4_0.gguf` (GGUF version 2): `llama.context_length 4096`, no scaling keys.

## Notes

- `context_length` is what the model was trained for. Serving above it needs extra RoPE scaling set on the server, which is outside this manual. Absent `rope.scaling` keys do not forbid that: the same benchmark ran Qwen3-30B-A3B (header 40960, no scaling keys) at 128k with YaRN x4 set by the server configuration, not by the file.
- llama.cpp before release b11598 (2026-10-13) did not load Laguna's `yarn_beta_fast` / `yarn_beta_slow` keys; #30331 ("model: load YaRN beta metadata for laguna models") added that. Laguna-XS stores 64.0 for `yarn_beta_fast`. If a header carries `yarn_*` keys, check that your engine version reads them for that architecture.
- llama.cpp master defaults `n_ctx` to 0 ("context the model was trained with"), but `--fit` (on by default) may lower an unset context to fit device memory, down to `--fit-ctx` (default 4096). An explicit `-c 0` also switches that reduction off (it sets the fit minimum to the maximum), so `-c 0` or `-c <context_length>` both pin the trained window. Either way, read the `n_ctx_slot` line. [UNVERIFIED: read from source (`common/arg.cpp`), not observed on the A9.]
- The script assumes little-endian files, the GGUF default. The spec says a big-endian file cannot be detected from the header; read as little-endian, its version 3 would print as 50331648. [UNVERIFIED: no big-endian file tested.]
- Alternative: `gguf-dump` from the `gguf` Python package prints the whole header. [UNVERIFIED: not run here; needs pip.]

## Sources

- [GGUF spec, ggml `docs/gguf.md`](https://github.com/ggml-org/ggml/blob/master/docs/gguf.md) — header layout, value-type enum (the script's type table), `[llm].context_length` and `rope.scaling.*` definitions, endianness note.
- [llama.cpp `common/arg.cpp`](https://github.com/ggml-org/llama.cpp/blob/master/common/arg.cpp) and [`common/common.h`](https://github.com/ggml-org/llama.cpp/blob/master/common/common.h) — `-c/--ctx-size` ("0 = loaded from model"), `--fit`, `--fit-ctx` defaults (master, read 2026-10-16).
- [llama.cpp #30331](https://github.com/ggml-org/llama.cpp/pull/30331) — load YaRN beta metadata for Laguna; shipped in [release b11598](https://github.com/ggml-org/llama.cpp/releases/tag/b11598).
- [Qwen/Qwen3-30B-A3B model card](https://huggingface.co/Qwen/Qwen3-30B-A3B) and [`config.json`](https://huggingface.co/Qwen/Qwen3-30B-A3B/blob/main/config.json) — "32,768 natively"; `max_position_embeddings` 40,960 = output reserve + prompt.
- [poolside/Laguna-XS-2.1 `config.json`](https://huggingface.co/poolside/Laguna-XS-2.1/blob/main/config.json) — upstream context and YaRN parameters used in Verify; GGUF from [bartowski/Laguna-XS-2.1-GGUF](https://huggingface.co/bartowski/Laguna-XS-2.1-GGUF).

