cd /news/artificial-intelligence/xyntetik-runner-a-gguf-runtime-in-pl… · home topics artificial-intelligence article
[ARTICLE · art-110681] src=github.com ↗ pub= topic=artificial-intelligence verified=true sentiment=· neutral

Xyntetik Runner – a GGUF runtime in plain C that proves what it runs

Xyntetik Runner, a GGUF model runtime written from scratch in plain C, is now available as a free Apache 2.0-licensed engine that serves, verifies, scores, adapts, and trains GGUF models deterministically, with support for CPU (x86 AVX2/FMA, ARM NEON), CUDA, and Metal. The project, built in Sweden and bootstrapped by consulting and enterprise work, emphasizes verifiable claims and compatibility with llama.cpp formats, including adapters that score identically (1.000 on held-out eval) when served by stock llama.cpp.

read77 min views1 publishedAug 25, 2026
Xyntetik Runner – a GGUF runtime in plain C that proves what it runs
Image: Michielbdejong (auto-discovered)

One binary is the whole model runtime: it serves, verifies, scores, adapts and trains GGUF models - deterministically, with every claim tied to a measurement you can re-run. Written from scratch in plain C. No Python, no pip, no third-party runtime, no ggml. CPU (x86 AVX2/FMA, ARM NEON), CUDA, and Metal.

curl -LO https://github.com/Joakimpalm-Zen/xyntetik-runner/releases/latest/download/runner-macos-arm64
curl -LO https://github.com/Joakimpalm-Zen/xyntetik-runner/releases/latest/download/SHA256SUMS
shasum -a 256 --check --ignore-missing SHA256SUMS
chmod +x runner-macos-arm64 && mv runner-macos-arm64 runner
curl -L -o model.gguf https://huggingface.co/ibm-granite/granite-4.1-3b-GGUF/resolve/main/granite-4.1-3b-Q8_0.gguf
./runner -m model.gguf --serve
curl localhost:8080/v1/chat/completions \
  -d '{"messages":[{"role":"user","content":"Say hello in one sentence."}]}'

Linux: the asset is runner-linux-x86_64

and the check command is sha256sum -c --ignore-missing

. Windows: runner-windows-x86_64.exe

. The checksum line is not decoration - this project's whole culture is receipts, and it starts at the download. The model above is the smallest that passes this project's fidelity gate against its own BF16 parent; alternatives and the reasoning are in the quick start below.

macOS note: the binaries are not yet notarized. A curl

download runs as shown; a browser download gets quarantined by Gatekeeper - clear it with xattr -d com.apple.quarantine runner

or right-click → Open once.

Testing Runner? The project is pre-1.0 and hardware coverage is still limited - that is an invitation, not an apology. If you have an NVIDIA/Apple setup, an unusual GGUF, a coding agent, or a model family not in the support matrix, the result is genuinely wanted, success or failure alike: open an issue with runner --version

, runner --caps

, the model's exact filename and the load log. Independent reproductions of the determinism claims get credited in the docs, as the first one already is.

Contents: try it · why Runner · what it adds · training · models · APIs · support matrix · CLI reference

Xyntetik Runner is independent and bootstrapped: the engine is free forever under Apache 2.0 - consulting and enterprise work fund the hardware. Built in Sweden, runs on your hardware; your data never leaves the building.

Use llama.cpp - it is the ecosystem, and Runner deliberately rides its formats rather than competing with them: GGUF in, llama.cpp-convention adapter files in and out. An adapter Runner trains scores identically (1.000 on its held-out eval) served by stock llama.cpp, and community F16 adapters load straight back into Runner - both measured, not assumed.

What Runner adds is not a longer feature list; it is a set of contracts the ecosystem does not make. Determinism as a hard promise: the same executable and inputs reproduce the same sampled tokens across runs and thread counts, and training reproduces the same adapter file sha256, gated in CI. Independent rebuilds are explicitly outside that byte-identity claim because compiler and ISA libm can differ. Scope as a promise: supported architectures are named, unknown ones are refused, and every backend claim is tied to an executable gate and pinned model evidence. Honesty as an artifact: the benchmark tables below include the rows where Runner loses, and the docs keep the failed experiments. If you want maximum architecture coverage and raw speed, use llama.cpp and we mean that sincerely. If you need to prove what your model said, what it learned from, or what you actually shipped - that is what this runtime is for.

For release history and benchmark narratives, see CHANGELOG.md and docs/benchmarks.md.

Download a prebuilt binary from the latest release for Linux, macOS, or Windows, or build from source:

git clone https://github.com/Joakimpalm-Zen/xyntetik-runner
cd xyntetik-runner
make
./runner --version   # -> runner 0.3.0

CUDA builds and releases need only an NVIDIA driver at runtime. The CUDA toolkit is needed only by developers regenerating the embedded PTX.

GPU driver requirement - raised.GPU execution now requires an NVIDIA driver withCUDA 13.0 support or newer(the R580 driver series). The embedded PTX is generated by the CUDA 13.0 toolchain (PTX ISA 9.0) to add the BF16 and Q2_K device kernels; older drivers - the previous floor was the CUDA ~11.8 era - will fail to JIT it, and the runner then reports the failure andfalls back to CPUrather than computing wrong. CPU-only execution is unaffected. Check your driver's CUDA level withnvidia-smi

(top-right "CUDA Version").

Release archives name the binary for their platform - runner-macos-arm64

, runner-linux-x86_64

, runner-windows-x86_64.exe

  • so either rename it to runner

or substitute that name in the commands below. A source build produces runner

directly.

If you have no GGUF handy, the measured recommendation at 8 GB is an 8-bit small model, not a 4-bit larger one. granite-4.1-3b Q8_0 (3.6 GB, first-party IBM file) is the smallest model that passes this project's fidelity gate against its own BF16 parent (100% margin-qualified top-1 / 0.0024 mean KLD, 2026-08-14; every 4- and 5-bit quant measured to date fails on distributional distance):

curl -L -o model.gguf \
  https://huggingface.co/ibm-granite/granite-4.1-3b-GGUF/resolve/main/granite-4.1-3b-Q8_0.gguf

For the fastest possible smoke test on a small machine there is also a 2.63 GB option - know what it is: measured against its own BF16 parent it agrees on 77.75% of tokens (mean KLD 0.286), a try-the-runner artifact, not a faithful gemma-4-E2B; its card carries the full numbers.

curl -L -o model.gguf \
  https://huggingface.co/Joakimpalm-Zen/gemma-4-E2B-it-Q4_0-GGUF/resolve/main/gemma-4-E2B-it-Q4_K_M-Q4_0-mix.gguf

Run a GGUF:

./runner -m model.gguf -i
./runner -m model.gguf -p "Explain prefix caching" --temp 0
./runner -m model.gguf --serve --parallel 2
./runner -m model.gguf -p "Return a status object" --json
./runner -m model.gguf -f big-document.txt -c 8192 -n 200
./runner -m big.gguf --draft small.gguf -p "Continue this code"

Pre-1.0 (APIs, model coverage and certification envelopes may change between releases. CI builds and smoke-tests Linux, macOS, and Windows, but the project still has limited hardware coverage. Include0.3.0

).runner --version

,runner --caps

, the model's exact filename, and the load log in issue reports. Read[SECURITY.md]for the threat model and[CONTRIBUTING.md]for the required correctness gates.

The contracts above, made concrete. The two capabilities that matter most have their own sections below; the rest follow as a list, ordered by how much difference each makes in practice.

When a tool call runs past its token budget, most engines return an empty or malformed tool_calls

  • commonly finish_reason: "length"

with nothing usable, or truncated JSON the caller cannot parse and has to repair or retry. Runner closes the call to the smallest schema-legal document instead, so the arguments still parse. This is forced-truncation recovery, not ordinary JSON-Schema constrained decoding: once a document starts, Runner emits a legal ending when the budget expires. On local models, where context is tight and generation is slow, it is the difference between an agent loop that finishes and one that retries from scratch - burning tokens, time, and context window.

What each engine hands the caller when the token budget cuts a tool call short

  • same box, same tool schema, same prompt, tool_choice:"required"

, temperature 0, budgets 1→64:

engine budget too small (1–16 tokens) enough budget (64, control)
Runner
executable tool_calls , arguments parse
completes
vLLM 0.27.1 no call; protocol framing leaks into content
completes
llama.cpp b10488 no call; leak, then tool_calls with unparseable args
completes
Ollama 0.32.14 no call; empty content, then HTTP 500 completes
TensorRT-LLM 1.2.1 † no call; <tool_call> leak, then empty content
completes
SGLang 0.5.17 † no call; <tool_call> leak, then empty content
completes

The control rung proves the failure is truncation, not misconfiguration: every engine completes at 64. Below that, only Runner returns an executable call; the others each hand back something broken or absent. This is the behaviour across every OpenAI-compatible engine we have measured - not a claim about engines we have not. TensorRT-LLM and SGLang were measured on a Qwen3-1.7B substitute (their model registries did not carry the granite-4.1-3b used for the other four); truncation recovery is a property of the runtime, so this measures the engine, not the model.

The truncation benchmark has the full recipe and raw responses and pins Runner's column as a per-release regression gate (make test-truncation

); the agent-torture gate tests the same failure mode. Tool-call fidelity under quantization is measured too: on a full quant ladder, constrained decoding held schema conformance and tool selection at 100% down to Q4_0 while argument agreement decayed to 50% - it guarantees the SHAPE of a call at any quantization, not its contents (docs/quant-fidelity.md).

Runner trains LoRA adapters directly through the frozen quantized GGUF used for inference. There is no FP16 training copy and no separate training framework: the serving forward pass is the training forward pass, so the policy you sample is the policy you train - the train/infer numerical mismatch that silently breaks on-policy learning cannot occur between two codepaths that are one codepath. And training is deterministic in the strongest sense: same data + same seed + same config produce a byte-identical adapter file, with a machine-written provenance record (base/data/adapter sha256s, seed, full config) beside every adapter - adaptation as an auditable artifact, not a run that is merely repeatable "within tolerance."

Measured, on a public artifact you can download and reproduce (Qwen3-4B-Runner-ToolUse-Q4_K_M):

measured result
base Qwen3-4B Q4_K_M (frozen 4-bit serving weights)
training path directly through the quantized inference artifact, CPU
held-out tool-calling, exact call 0.69 → 1.00
reproducibility two independent runs → byte-identical adapter (same sha256)
precision study adapters trained through BF16 vs Q8_0: cosine 0.9998; through Q4_K_M: 0.9926 - measurably different objects, capability-equivalent on this task
neutral-corpus drift nll/token 4.063 → 4.026 (the adapter leaves unrelated text alone)
merge study --merge-lora into Q8_0/F16 keeps the 1.00 (verified in stock llama.cpp); merging into the 4-bit base erases the fine-tune - 0.69 again, 98.55% of weight bytes round back to the base's codes. Scale sweep: survival is monotone in delta magnitude (erased through 2×, partial at 4×, full at 8× - where the exact 8× adapter breaks the served model, the 4-bit grid filters it back to 1.00)
interop the adapter scores the same 1.00 served by stock llama.cpp; community F16 adapters load back into runner (measured on a third-party adapter, which also found and fixed the F32-only gap)

--score

gives teacher-forced logprobs for evals and rewards, --lora

serves any adapter back, --merge-lora

folds an adapter into the base for a standalone GGUF any runtime can serve (with its own provenance record - and the honest caveat that a quantized merge rounds the delta; --lora

is the exact form), and scripts/train-grpo-lite.py

closes the loop into seeded, replayable reinforcement fine-tuning. Design, gates, failure modes and every number above: docs/adaptation-engine.md.

The rest of what sets Runner apart, ordered by how much difference each makes:

A shared GPU stops being first-come, first-crash. Run a coding agent beside an embeddings model beside a draft model and the usual outcome is that one load kills another. Runner processes on the same GPU share a VRAM registry: a refused load names every live holder by PID, model, bytes, and uptime,--wait-for-vram

turns that refusal into a bounded queue, and records left by dead processes are reaped. It makes a GPU something you can schedule rather than something you hope fits.You can ask what fits before anything. The usual way to find out whether a model fits is to load it and wait for the failure.--caps

needs no model file and returns one JSON document containing live RAM/VRAM, backend and GPU limits, CPU and GPU quant lists, admitted architectures, placement modes, and model-count limits. A supervisor, tray controller, or CI job can reject an incompatible placement before dispatch, which removes a whole class of load-wait-fail-retry loops. For a specific file,--fit

answers the same question from the model's GGUF header - the first few megabytes - so a ranged read decides whether the rest of the download is worth starting.Constrained decisions come with a confidence signal.choice_logprobs

records each JSON-schema branch as legal alternatives, a posterior renormalized over them, and the probed probability mass - how confident the model was choosing one branch over another, which is what routing and calibrated classification actually need. The included calibration tool turns labeled decisions into accuracy, Brier-score, and ECE gates. This is a decision record rather than ordinary token logprobs, and a power-user feature: most workloads will never reach for it.A hardware switch has a correctness contract. If you move a workload between backends and the output quietly changes, that is a bug, not a tuning artifact. CPU/GPU identity here belongs to an exact SHA-256-pinned model and execution path, and faster kernels that reassociate floating-point sums must pass numerical tolerance gates rather than inherit a correctness claim from the backend name. Most users never compare outputs across backends; this is documented because the project treats correctness as a gate, not because it is a headline.

The full compatibility method is in docs/compatibility-program.md and performance measurements are in docs/performance.md. Work that was built, measured and rejected is kept too, so it is not attempted twice: docs/negative-result-expert-cache.md for MoE expert caching, and docs/negative-result-metal-multirow-matvec.md for the multi-row Metal decode matvec - which also records what the CPU/GPU byte-identity contract costs in reachable GPU optimizations.

make          # release build: ./runner or runner.exe
make debug    # ASan/UBSan development build where supported
make test     # unit, fixture, generated-source, and backend gates

Runner uses ordinary platform C, math, threading, mmap/file-mapping, and dynamic- libraries. GGUF is little-endian, so little-endian hosts are required.

Platform Toolchain Accelerated path
Linux x86_64 GCC AVX2/FMA; CUDA on NVIDIA Turing / compute capability 7.5 or newer, driver with CUDA 13.0+ support (R580 series)
macOS arm64 Apple Clang ARM NEON; Metal on Apple Silicon
Windows x86_64 MinGW-w64 via MSYS2 AVX2/FMA; CUDA on NVIDIA Turing / compute capability 7.5 or newer, driver with CUDA 13.0+ support (R580 series)

On Windows, install make

and mingw-w64-ucrt-x86_64-gcc

from an MSYS2 UCRT64 shell, then run make

.

Each release publishes a CPU image - the same binary on a distroless glibc base, nothing else - to ghcr.io/joakimpalm-zen/xyntetik-runner:<version>

(and :latest

). Build it yourself with docker build -t runner .

.

The server binds loopback only by design (there is no --host

/0.0.0.0

flag), so it never exposes itself to a network, even in a container - which shapes how you run it:

docker run --rm -v "$PWD/models:/models" \
  ghcr.io/joakimpalm-zen/xyntetik-runner:latest \
  -m /models/your.gguf -p "hello" -n 128 --gpu off

docker run --rm --network host -v "$PWD/models:/models" \
  ghcr.io/joakimpalm-zen/xyntetik-runner:latest \
  -m /models/your.gguf --serve --port 8080

-p 8080:8080

does not work - the port-proxy cannot reach a server bound to the container's own loopback; use --network host

. There is no auth boundary, so keep any deployment on a trusted host.

The image is CPU by default, but GPU-capable without a separate variant. The binary loads the CUDA driver at runtime (libcuda.so.1

, the driver API) and carries its kernels as embedded PTX, so it needs no CUDA toolkit baked in - run it on an NVIDIA host with the NVIDIA Container Toolkit and --gpus all

and the runner uses the GPU:

docker run --rm --gpus all --network host -v "$PWD/models:/models" \
  ghcr.io/joakimpalm-zen/xyntetik-runner:latest \
  -m /models/your.gguf --serve --port 8080

(A nvidia/cuda

-based image is deliberately not published - it would only add a CUDA runtime the driver-API path never calls.) Verified on an RTX 3070 via WSL2 (2026-08-19, Docker 29.1.3 + NVIDIA Container Toolkit 1.19.1): the same distroless image run with --gpus all

reports "gpu":{"backend":"cuda","name": "NVIDIA GeForce RTX 3070",…}

from runner --caps

and prints gpu: CUDA backend on NVIDIA GeForce RTX 3070

with VRAM accounting at load; the identical image run without --gpus

reports "gpu":null

and runs on CPU (libcuda.so.1

absent), so the flag is what makes the difference. A generation attempt in that case says that the CUDA driver library is unavailable before continuing on the CPU; runtime/device discovery and backend staging failures likewise name the failed stage instead of looking like a successful GPU admission. CUDA shared-weight setup also identifies the tensor upload or per-layer table/field that failed, including architecture-specific recurrent, sink, MoE-bias, and Gemma tables. Metal cannot be containerized (Apple-Silicon only, no passthrough).

Runner accepts GGUF v2/v3. Safetensors checkpoints must be converted to GGUF first. Standard llama.cpp multi-part sets (<prefix>-00001-of-000NN.gguf

) load natively from any part: every part must be present in the same directory, and its split.no

, split.count

, and split.tensors.count

metadata must agree. Missing or inconsistent parts are refused before model binding. Nonstandard filenames and remote/streamed parts are not resolved automatically; merge or rename those sets to the standard layout first.

Fetch the small test model with:

./download-model.sh

For manual downloads, verify both the command exit status and resulting byte size. A partially downloaded GGUF can otherwise look like a model failure.

Repack weight matrices to q8_0

, q4_0

, or f16

:

./runner -m model-f16.gguf --quantize model-q4.gguf --quant q4_0

Norms, biases, and rope factors stay f32; tensors already smaller than the target are retained, as are rows the target type cannot describe (q3_k

needs a row width divisible by 256, q8_0

/q4_0

by 32). MoE router weights (ffn_gate_inp*

) keep their source type on every path, including a --type-plan

that names them: the router selects which expert runs, so an error there swaps a whole FFN, and it is a fraction of a percent of the file. Metadata is copied.

A q4_0

repack is lossless where the source is already on the q4_0 grid, which is the case for quantization-aware-trained checkpoints: every value is one per-block scale times an integer code, so the answer is already in the file. Runner recovers that scale and those codes exactly instead of re-deriving a scale from the block's extreme value - the derived route is correct only when a block's codes actually reach zero, and on a block where they do not it saturates the far end of the range and changes values a pure repack had no need to touch. A candidate is accepted only when the value the dequantizer will produce equals the source float bit for bit across the whole block, so a source that is not on a grid falls through to the derived scale and its output is byte-for-byte what it was before.

--prune-experts

rewrites stacked-layout MoE tensors using an explicit JSON plan. It is a mechanism, not a quality claim: pruning needs a model-specific evaluation against the unpruned parent.

{"layer_0":[0,3,7],"layer_1":[1,2,5]}
./runner -m model.gguf --quantize pruned.gguf --prune-experts keep.json

./runner -m model.gguf --quantize pruned-q4.gguf \
  --prune-experts keep.json --quant q4_0

A layer omitted from the plan keeps all experts. Invalid keys, empty lists, out-of-range IDs, and unsupported tensor layouts fail instead of silently producing a different model. The layer's router (ffn_gate_inp

) and its per-expert selection bias (exp_probs_b

, in either on-disk spelling - the .weight

of the DeepSeek-style GGUFs and the .bias

of nemotron_h_moe

) are sliced along with the expert banks, so the survivors in plan order become the new expert index space with no runtime remapping. Non-uniform coverage-pruned nemotron_h_moe

layers resolve their expert count from each layer's router and run on CPU; CUDA names and declines this layout because its MoE kernels require one model-wide expert count. scripts/moe-prune-plan.py

can build a plan from calibration data.

Artifacts produced by this project are published only after their stated gate against the named parent. Read each repository's provenance before treating a derivative as equivalent to an original checkpoint.

Every fidelity claim below is measured under the adopted dual-column bar (margin-qualified top-1 >= 97% AND mean KLD <= 0.05 vs the named parent, 400 teacher-forced positions, zero point exact; plain top-1 always reported beside it).

Qwen3-30B-A3B selective precision(attention Q8_0 / experts Q4_0, 17.99 GB)passes the bar where the official uniform Q4_K_M fails it, from a byte-verified first-party Q8_0 source. Built with--type-plan

; the exact plan is on the card. The artifact class this project now leads with.Qwen3-Coder-30B keep-120(expert-pruned, 17.5 GB)passes both the original and the current bar- the only published artifact to clear the original bar unaided.gpt-oss-20b-keep30-MXFP4(11.5 GB, 32-to-30-expert derivative)does not pass the current bar; its originally published number did not reproduce and the card leads with the measured status. Kept published as a near-miss with its numbers in the open.gemma-4-E2B-it Q4_K_M/Q4_0 mix(2.63 GB) is the smoke-test artifact from the quickstart: fails the fidelity bar (the card carries the dual-column numbers) and remains the fastest way to try the runner on an 8 GB machine.NVIDIA-Nemotron-Nano-9B-v2 Q8_0(8.81 GB) is not a bar-gated derivative but the firstMamba-2 hybrid ( artifact the runner supports - a plain, near-lossless Q8_0 of NVIDIA's base, quantised by the runner's own canonical (ggml-byte-identical) quantiser and verifiednemotron_h

)5/6 greedy token-identical vs llama.cpp b10353 at Q8_0(the sole miss a quant-noise near-tie). NVIDIA Open Model License; the card leads with the tool-calling differentiator.- Measurement reports over third-party artifacts, no weights republished, every measured file bound by SHA: Hermes-4-14B quant fidelity(the 4-bit size threshold and the split story), theQwen3 speculative pair(measured draft acceptance, and why the engine's printed tok/round must not be tuned on), and the two Mamba-2 hybrid support reports -granite-4.0-h-small(granitehybrid

, 3/5 greedy-identity at the noise floor) andNemotron-3.5-Lightning-30B-A3B(nemotron_h_moe

, 4/5) - each carrying its measured-envelope manifest. Two frontier reports (2026-08-20, no weights republished - nothing cleared the bar AND beat upstream): theLightning-30B prune frontier(keep-126 passes at 99.50%/0.026; the plan is published, the 1.37% saving was not worth an artifact) and theMuse-Glimmer-30B quant frontier(Meta's own Q4_K_M passes the bar; six runner plans measured, none beat it - stated openly).

runner --help

remains authoritative for the binary being executed. This grouped reference makes the complete interface discoverable without mixing flags into unrelated feature sections.

Option Purpose
-m PATH
GGUF path. In serve mode, name=path,name2=path2 enables multi-model swap mode.
-p TEXT
One-shot prompt; escaped sequences such as \n are unescaped.
-f FILE
Append file contents to the prompt.
-i
Stateful interactive chat.
--serve
Start the HTTP server.
--tray
Be the macOS/Windows tray controller instead of running a model. Required where there is no terminal. See

--no-tray

--serve

and -i

.--port N

8080

.--parallel N

1

.--ttl N

300

; 0

disables it.--force-uncertified

outside-envelope

verdict for this runtime (refused by default). See Measured-envelope gate.--json

--json-schema FILE

FILE

.| Option | Purpose | |---|---| -n N | Maximum generated tokens, default 256 ; -1 runs until EOS. | -c N | Context length; default is the smaller of model maximum and 4096. 0 auto-fits with a reservation. | -b N | Prompt batch size, default 64 . Unless --gpu off was given, the default is sized from free RAM instead: 512 above 4 GB free, 256 above 1.5 GB, 64 below that, so the tiled prefill GEMM gets more columns per dispatch (measured on Metal/M1: +9% prompt tok/s at 512 over the flat 64 default). -b always overrides. | -t N | Worker threads; defaults to physical cores and is capped at 64 . | -s N | RNG seed; default is time-based. 0 is refused: it is the sampler RNG's fixed point, so it cannot produce a stream. | --think / --no-think | Request the model family's thinking or non-thinking prompt shape. With neither flag, Runner renders whatever that family's own reference template renders, which is not the same answer for every family. Families without a distinct thinking prompt accept the flag and ignore it rather than approximate one. | --temp F | Temperature; 0 is greedy and disables repeat penalty. | --top-k N | Top-k sampling; 0 disables it. Several presets ship 0 , where setting it is a measured decode-throughput win that also changes the sampled distribution - see --top-k 40 | --top-p F | Nucleus sampling threshold. | --min-p F | Probability floor relative to the top candidate; 0 disables it. | --repeat-penalty F | Recent-token penalty; 1 disables it. | --rope-scale F | Force linear rope position scaling. | --rope-base F | Override the rope frequency base. | --system TEXT | System prompt in interactive chat. | --chat-template NAME | Force chatml , chatml-think , llama2 , llama3 , mistral , mistral-v1 , mistral-nemo , zephyr , phi3 , gemma , gemma4 , gemma4-mainline , apertus , ornith , muse , granite , harmony , or raw ; default is auto-detection. The three Mistral framings are not interchangeable: mistral is the v0.3 / Mistral-Small-2409 form and the fallback for an unrecognised Mistral template, mistral-v1 is v0.1/v0.2, mistral-nemo is Nemo-Instruct-2407. They differ by a space beside each [INST] /[/INST] marker and by which user turn carries the system prompt - one SentencePiece token per divergent space. gemma-4 likewise ships two chat-template revisions that auto-detect and are byte-exact to their own reference: gemma4 is the E-series (E2B/E4B) form, gemma4-mainline is the 12B/26B-A4B/31B form, which pre-seeds an empty thought block on the thinking-off generation prompt where the E-series pre-seeds nothing. Applies to interactive chat and to --serve , including reloads after /unload or a --ttl expiry. An unrecognized name is an error, and the flag is refused with a swap set (-m "name=path,name2=path2" ) because it names one template for a set of models that each detect their own - serve that model on its own instance instead. | --no-bos | Do not add the beginning-of-sequence token. | --ignore-eos | Continue generation past end-of-text tokens. |

Option Purpose
`--gpu auto off`
Auto-detect offload, or force CPU.
--gpu-layers N
Force the first N layers onto the GPU; 0 means no GPU. Omit for auto-fit. On Metal it also overrides the residency veto: a model larger than available RAM is refused for auto-selected partial offload, because nothing pinned can be held resident and the measured result was 8-35x slower decode, but an explicit --gpu-layers N splits it anyway.
`--cpu-moe [N auto]`
CUDA hybrid placement: keep all, the deepest N , or an auto-fit set of expert FFNs in system RAM.
--wait-for-vram [S]
Wait for another registered runner to release VRAM, default 300 seconds, instead of failing immediately.
--vram-priority N
Advisory priority tag on this claim, default 0 (also RUNNER_VRAM_PRIORITY ). See
--yield-on-request
In --serve , release the resident model at the next idle point when another process has asked it to. See the same section.
--reserve P
Limit this process to P percent of total RAM and VRAM.
--reserve-vram P
Override only the VRAM budget.
--reserve-ram P
Override only the RAM budget.
--reserve-cpu P
Size the default thread count as a percentage of cores.
`--kv f16 q8`
KV storage; f16 is default, q8 uses about 53% as much memory and is lossy.
--mlock
Ask the OS to wire mapped weights into RAM; failure is non-fatal.
`--moe-prefetch on off
Prefetch routed expert blocks. Auto enables it only for measured oversubscribed Apple Silicon cases.
--draft PATH
Same-vocabulary draft GGUF for speculative decoding in one-shot, chat, or single-model serve mode.
--draft-k N
Draft tokens per speculative round, default 4 .
Option Purpose
--quantize OUT
Rewrite the loaded model to OUT and exit.
`--quant q8_0 q4_0
Requantization target; default q4_0 , or keep per-tensor types when pruning or merging alone. Requires --quantize or --merge-lora ; without either the flag is refused rather than ignored.
--type-plan PLAN.json
Per-tensor rewrite plan. First substring rule wins; types are keep , q8_0 , q4_0 , q3_k , q4_k , q6_k , f16 , and bf16 . Example: {"default":"keep","rules":[{"match":"_exps.weight","type":"q3_k"}]} . Requires --quantize .
--merge-lora OUT
Fold --lora into the base weights and write a standalone GGUF that runs in any GGUF runtime: W' = W + (alpha/r)·B·A per adapted projection, each tensor requantized to its own type (or --quant T ), untouched tensors copied byte-verbatim, OUT.merge.json provenance (base/adapter/merged sha256s) written beside it. Deterministic: same inputs, byte-identical merged file. Merging into a quantized type rounds the delta through that type's grid - the merged artifact's fidelity is a measurement, not a given; base + --lora remains the exact form.
--prune-experts FILE
Apply a per-layer MoE expert keep-list while rewriting. Requires --quantize .
--bench-json
Run the built-in prompt/decode benchmark and print JSON metrics.
--lora FILE , --lora-scale F
Load a LoRA adapter GGUF beside the frozen quantized base (llama.cpp adapter naming: blk.N.<proj>.weight.lora_a/_b + adapter.lora.alpha ; F32, F16 or BF16 tensors - F16 is what llama.cpp's convert_lora_to_gguf emits, and a community adapter in that format loads and serves, measured). Interop runs the other way too: an adapter runner trained scores identically (1.000 on its held-out eval) when served by stock llama.cpp. Applied as y += scale·B(Ax) on the CPU dense projections (attention q/k/v/output, FFN gate/up/down) - the base weights and kernels are untouched, so every base identity gate still describes the adapted run's substrate. Fails closed by name on shape/rank mismatches, unknown targets, recurrent/gemma-4-MoE architectures, and GPU-resident models (CPU-only for now). A zero adapter is gated byte-identical to the bare base; a real adapter is gated against the merged-weights reference. The adapter id joins the engine's model identity, so cached prefixes never cross an adapter boundary.
--train FILE , --train-steps , --lr , --train-ctx , --train-out , --save-every , --lora-rank
AdamW LoRA training in the serving binary (CPU path, position-batched and threaded under a byte-exact contract - 4B trains at ~20 s/step on a many-core host, 2.3× over the first release, with the adapter bytes gated invariant across binaries, thread counts and the optional RUNNER_TRAIN_GPU=1 CUDA assist): plain-text corpora or .jsonl lines {"prompt","completion","weight"} with the prompt masked from the loss and per-example weights (the policy-gradient hook scripts/train-grpo-lite.py drives). Fresh adapters start as an exact no-op (A seeded, B zero); checkpoints are adapter GGUFs that --lora loads back. Deterministic by default: same data + same seed produce a byte-identical adapter file, gated in make test . Design, gates and measured results:
--score
Teacher-forced scoring: per-token log P(token prefix) over the raw -p /-f text - no template, no sampling - printed as JSON (xyntetik.runner.score.v1 ) with per-position logprobs, NLL and perplexity. The default path scores one forward per position, the exact numerics the sampler sees at decode time; RUNNER_SCORE_CHUNKED=1 opts into a faster batched pass whose deviation from solo is measured and test-pinned (max
--caps
Print machine, backend, quant, architecture, placement, and sampling capabilities as JSON.
--tool-info
With -m , print the model's tool-call protocol as JSON ({"tool_family":…,"native_tool_protocol":…} ) and exit. No manifest required.
--fit PATH
Estimate whether a GGUF fits this machine and exit. Reads only the header, so a partial download answers the question.
--version
Print the version and exit.
--parent-pid N
Exit when process N dies; intended for supervisor cleanup.
-v
Print verbose model and memory information.

--fit

answers "will this run here" from a model's GGUF header, which is the first few megabytes of the file:

$ runner --fit Trinity-Nano-Preview-Q4_K_M.gguf
fit: Trinity-Nano-Preview-Q4_K_M.gguf
  model         afmoe, 56 layers, MoE 128 experts, 8 used
  weights       3.53 GiB
  hot set       0.66 GiB  (only the routed experts a token actually uses)
  kv cache      0.22 GiB at ctx 4096, f16   |  0.12 GiB with --kv q8
  available RAM 3.25 GiB right now
  verdict       FITS — 2.37 GiB to spare at ctx 4096

The verdict is FITS

, FITS WITH --kv q8

, or PAGES

, always with the arithmetic that produced it. -c N

sizes the KV estimate for the context you actually intend to run. For a sparse MoE the verdict uses the hot set, not the file size, because only the routed experts a token selects are touched - which is why a 3.53 GiB file can be a comfortable fit in 3.25 GiB.

The runner does not download anything, and --fit

is not a reason to teach it HTTP. Fetch a header yourself with a ranged read - 16 MiB covers a large vocabulary; smaller models need far less:

curl -r 0-16777215 -L -o head.gguf \
  https://huggingface.co/ORG/REPO/resolve/main/MODEL.gguf
runner --fit head.gguf

The sizes reported from a truncated header are the whole model's, because they come from the tensor descriptors rather than from how many bytes arrived. such a file still fails, as it should: the normal refuses a GGUF whose data section does not cover the tensors it declares, and --fit

reads through a separate path rather than relaxing that check.

Use chat mode or an API chat surface to judge an instruction-tuned model. Raw -p

completion deliberately bypasses chat framing and is primarily useful for benchmarks and deterministic comparison gates.

Sampling defaults come from a per-family preset selected from model metadata and filename. The chosen preset is logged at load, --caps

publishes the full preset table, and explicit sampling flags always win. At --temp 0

, runner returns the model argmax without applying repeat penalty.

Interactive chat keeps its KV state across turns and auto-detects the template from metadata and vocabulary. Thinking channels are displayed separately. The server additionally reuses the longest shared prompt prefix across requests.

On macOS and Windows, a session you sit with - a bare invocation, --serve

, or -i

  • also raises the desktop tray, which is left running afterwards. One-shot -p

runs, tooling modes, pipes, scripts, CI, and Linux keep text-mode behavior, and --no-tray

opts out everywhere. See Desktop tray.

CPU execution has portable scalar kernels plus AVX2/FMA and ARM NEON paths. --gpu auto

selects a usable backend and falls back with a reason when a model layout, tensor type, runtime, or capacity is unsupported.

Backend Tensor formats
CPU F32, F16, BF16, Q8_0, Q4_0, Q4_1, Q5_0, Q5_1, Q2_K, Q3_K, Q4_K, Q5_K, Q6_K, IQ4_NL, IQ4_XS, MXFP4
Metal The full CPU list
CUDA The full CPU list

runner --caps

is the live source of truth for a particular executable and machine. Architecture and MoE layout checks still happen at model load; a listed tensor kernel does not imply that every architecture using that tensor is implemented on that backend.

Metal: Apple Silicon uses zero-copy mapped weights and unified-memory KV. Metal supports f16 and q8 KV, dense and selected MoE layouts, and tiled prefill GEMMs. Full offload is the preferred and default shape. A file above gpu.max_working_set_bytes

in --caps

takes a leading-layer split when the tensor layout allows a contiguous prefix wrap and the whole model still fits in RAM; when it does not, the backend falls back to CPU rather than split, because pinning part of a model that does not fit measured 8–35x slower than CPU-only on an 8 GB M1. --gpu-layers N

forces a split anyway. Multi-part (split) GGUF sets also run on the CPU: the zero-copy wrap addresses one mapping and a split set has one per part, so the backend declines the model at load rather than binding weights it cannot place. The embedded shader gate compiles the library and verifies every kernel the backend looks up, reading that roster out of src/metal.m

rather than restating it.

CUDA: Linux and Windows use the dynamically loaded driver API and embedded sm_75

PTX. The embedded PTX is built by the CUDA 13.0 toolchain (PTX ISA 9.0), so GPU execution requires a driver with CUDA 13.0 support or newer (the R580 series) - see the driver note in the Install section; on an older driver the runner reports the JIT failure and falls back to CPU. Full and partial layer offload are supported. Sparse MoE can keep expert FFNs in RAM with --cpu-moe

while attention and dense tensors remain on the GPU. make ptx

regenerates the embedded header and requires a CUDA toolkit only for that development step.

Scalar-path CPU/GPU identity is an evidence result, not a property inferred from a backend name. CUDA tensor-core and Metal tiled prefill kernels reassociate floating-point sums, so they are promoted by teacher-forced tolerance tests. CUDA currently promotes Q4_K/Q6_K/Q8_0 on the gated dense families and Q4_0 on Gemma 4; the latter was bit-identical over 820 tensor-core dispatches on the real 31B QAT artifact.

On Metal that now covers decode as well as prefill: the cooperative KV attention read was promoted on 2026-08-17 after clearing zero teacher-forced top-1 flips out of 64 on every local model that reaches it - gemma-4 E2B, gemma-3-4B, granite-4.1-8B under a layer split, SmolLM2, and the NoPE / attention-temperature fixtures - in both f16 and q8 KV cache formats, for a measured +3.0–4.3 % decode across 2.3k–8.1k token spans. So Metal decode at long context is a tolerance-gated route, not a byte-identical one.

RUNNER_CUDA_TC=0

, RUNNER_METAL_MM=0

and RUNNER_METAL_ATTN_COOP=0

pin the byte-identical scalar paths for identity investigations; every CPU-vs-GPU byte comparison in the test suite sets them. ./test-attn-tol MODEL.gguf

is the attention gate. Weights are wrapped zero-copy from the model mmap. A file larger than the device's maxBufferLength

  • 4.29 GB on an M1, against a 5.73 GB working set - is wrapped in several buffers instead of being copied or forced into a CPU/GPU layer split. The cuts fall on tensor boundaries, so no tensor spans two buffers and output is byte-identical to a single-buffer wrap; RUNNER_METAL_MAX_BUF

shrinks the per-buffer ceiling so that path can be exercised on a machine whose models all fit one buffer, and make test-metal-multibuf

is the byte-identity gate. A separate pure admission gate simulates a file above maxBufferLength

but below the aggregate working set, ensuring it remains a full offload. A single tensor larger than the per-buffer ceiling still cannot be wrapped and says so.

RUNNER_METAL_ATTN_COOP=0

pins the byte-identical decode attention kernel. The default is the cooperative KV read: one simdgroup owns a KV row and its lanes split head_dim

, so a load covers 32 consecutive elements instead of 32 rows. It reassociates the per-row dot into a simd_sum

, which is why it answers to ./test-attn-tol

rather than to an identity claim.

RUNNER_METAL_MV=1

opts into a reassociating Metal decode matvec (q4_0/q8_0, float4 accumulation and the q4_0 zero-point factored out of the inner loop). It clears the 0/64 teacher-forced flip bar on both formats but measured neutral on an 8-core M1 - −0.16 % bandwidth-bound, −0.01 % dispatch-bound - so it is off by default, leaving the byte-identical kernel on the default path. ./test-mv-tol MODEL.gguf

is the gate; see docs/negative-result-metal-multirow-matvec.md

for why decode on that machine is bound by bytes rather than instructions. The CPU quant dot/dequant module is a separate translation unit compiled with -fno-fast-math

; fast math remains enabled for the rest of the engine.

CPU: the x86 dot kernels read weights in their on-disk quantized form and keep f32 activations, which is token-identical across builds and thread counts. RUNNER_CPU_I8=1

opts into a fused int8 decode dot (AVX-512 VNNI, AVX2 fallback): 2.4-2.5x on the kernel in isolation, but it quantizes the activations, so it is off by default - no format cleared the 0/64 teacher-forced flip bar with a decode gain worth taking on the measurement box. ./test-i8-tol MODEL.gguf

is the gate. RUNNER_TPOOL_SPIN

sets how many relax iterations a pool worker spins before parking (default 3000, roughly 50 us); 0

restores a pure condvar pool. The spin window only changes when threads wake, never which rows they compute, so output is unaffected either way. See docs/performance.md.

Vulkan is not implemented; AMD and Intel GPUs use the CPU path.

  • A requested context above the training length applies model metadata for linear/YaRN/llama-3 rope scaling, or automatic YaRN extension when metadata does not supply a native scheme. --rope-scale

and--rope-base

override that behavior. --kv q8

stores q8_0 blocks when every layer's head dimension is divisible by 32. It works on CPU, CUDA, and Metal, participates in capacity auto-fit, and is intentionally not token-identical to f16 KV. An incompatible head dimension is reported at load and keeps the cache in f16.- Prompt evaluation is batched; -b

controls the batch and-v

prints the KV allocation before inference.

--reserve

and its RAM, VRAM, and CPU variants let runner coexist with other workloads. With -c 0

, the context grows into the remaining reservation up to the model's training context. A cross-process registry prevents a second runner from blindly consuming occupied VRAM; --wait-for-vram

turns that refusal into a bounded queue.

--mlock

can prevent mapped weights from being evicted, but should not be used to force a model larger than available RAM to stay resident. Sparse MoE load logs distinguish total file size from the smaller per-token hot set.

On high-core-count hosts, sparse MoE decode can be memory-bandwidth bound well before the 64-thread cap. Measure -t 12

to -t 16

as well as the default; the project recorded 17.0 tok/s at 12-16 threads versus 7.8 tok/s at 64 on one 128-core gemma-4-26B-A4B run. This is workload evidence, not a universal thread-count rule.

The VRAM registry (above) accounts for who holds what; these three primitives let cooperating processes negotiate around that accounting without turning runner into a scheduler. All of it is advisory: it only has any effect on processes that opt in by passing the flags below, and nothing in the engine can force, signal, or kill an uncooperative one. Fair-share, priority lanes, starvation prevention, and actual preemption are policy, and policy lives in whatever coordinates several runner instances, not in the engine - this is the raw material for that layer, not the layer itself.

Priority tag.--vram-priority N

(default0

, alsoRUNNER_VRAM_PRIORITY

) records a small-integer tag on the claim. It is printed in the refusal listing next to pid, model, bytes, and uptime -pid 4821 holding 5.2GB for Qwen3-4B-Q4_K_M, up 4h39m, priority 3

. A ledger entry written by a runner built before this field has exactly 7 tab-separated columns instead of 8 and is read as priority0

, the same as an explicit--vram-priority 0

.Priority-ordered waiting. Among several--wait-for-vram

waiters queued on the same GPU, a higher-priority one is admitted first once space frees - but only among waiters whose own request currently fits that freed space; a high-priority ask that does not fit yet never blocks a smaller low-priority one out of room it does not need. This is ordering among cooperating waiters, not a reservation: a process that never passes--wait-for-vram

, or that claims VRAM some other way, is invisible to it and can still take memory out of turn.Cooperative yield.--serve --yield-on-request

opts a resident model into releasing itself when asked. The ask is a REQUEST, checked only at the one place--serve

is ever idle between requests - never mid-generation, never by a signal. An opted-in holder that sees one logs why and unloads cleanly, the same path--ttl

andPOST /unload

already use. An unopted-in holder, or one that is busy, never notices. Nothing here is preemption: there is no timeout after which a holder is forced out.

None of the three needs a GPU to exercise - tests/test_vram_registry.c

drives the whole surface, including priority ordering, through the same synthetic free-VRAM callback the rest of the registry's tests use.

Runner already refuses to treat output as correct without a schema contract. The measured-envelope gate extends that one layer down, to the model itself. A certification run records what was actually measured for one artifact on one runtime - the CPU==GPU identity check, the fidelity gate, whether the model fits its memory class - into a <model>.gguf.envelope.json

sidecar. At load Runner reads the sidecar sitting next to the model and resolves it against the runtime it is actually running (runner --version

and the model's active compute backend, exact-match - a manifest measured on a different version or backend does not speak for this one):

The states are distinguished by what Runner actually knows about the model, not just by what they do - two of them load with a banner but mean different things:

State Condition Behavior
certified
the sidecar matches this runtime and its gate passed loads; a banner notes the match
outside-envelope
the sidecar matches this runtime and records a measured refusal (e.g. the model does not fit, or an identity check failed) refused at load with the measured reason; --force-uncertified overrides with a loud warning
experimental
the sidecar matches this runtime and its verdict is literally experimental - a real measurement that came back inconclusive
loads; a banner notes it is not certified
indeterminate
a sidecar is present but cannot be used to judge this run - unreadable, an unknown schema, or measured on a different runtime/backend
loads (fail-open); a banner notes it could not be judged
unclassified
no sidecar at all loads silently - a transitional/legacy state

Two of those distinctions are load-bearing. unclassifiedexperimental: a model with no sidecar predates or sits outside the certification pipeline, so there is nothing measured to report - not a measurement that came back inconclusive - and it does not warrant a banner on every load. indeterminateexperimental either: "we could not read/apply the sidecar" is not the same claim as "we measured this and it was inconclusive." As the pipeline's coverage grows, unclassified is the state that shrinks.

The rest of the wording is deliberate too: a configuration matches a measured envelope, it is not certified as a standing property - the claim is scoped to that exact artifact, backend, and date. The gate is fail-open on doubt: only a matching outside-envelope

verdict ever refuses; anything unreadable, foreign, or unrecognized is indeterminate and loads, because a wrong refusal is worse than none. Runner only ever reads this file; it is produced by the certification pipeline, never at runtime.

A manifest may also carry an optional tool_calling

block: a summary of how the model behaves under tool use - engine truncation-recovery, whether the tool-call schema shape still holds at a low quant, an agent-torture pass/fail, and the model's native tool protocol. This axis is reported-only: it changes no verdict and never refuses a load. When the block is present, Runner prints one extra banner line at load, showing only the sub-fields that were actually measured, for example:

envelope: tool-calling gate=pass — truncation 6/6, schema-shape@Q4_0, agent-torture pass, native granite

A manifest with no tool_calling

block prints nothing extra. You can also query a model's native tool protocol directly, without any manifest, with runner --tool-info -m model.gguf

, which prints {"tool_family":…,"native_tool_protocol":…}

. The full block, the evidence each field comes from, and the honesty caveats (notably that schema-shape holding at Q4_0 is about the call

shape, not the argument values) are documented in

docs/envelope-manifests/README.md.

Start a single-model server:

./runner -m model.gguf --serve --port 8080 --parallel 2

The server is HTTP on loopback only, with no TLS or authentication. Binding to 127.0.0.1

is an invariant rather than a default: there is no host flag, environment variable, config key, or local-network toggle that can expose it. Put it behind an authenticated reverse proxy or tunnel when remote access is needed; do not forward the port directly. Host and Origin validation rejects non-loopback authorities.

Method and path Purpose
POST /v1/chat/completions
OpenAI Chat Completions, including SSE, tools, structured output, logprobs, and stop strings.
POST /v1/responses
OpenAI Responses translation over the same engine and tool envelope.
POST /v1/completions
Legacy raw prompt completions.
POST /v1/embeddings
Mean-pooled, L2-normalized embeddings.
POST /v1/messages
Anthropic Messages translation.
POST /v1/messages/count_tokens
Token count for the matching Messages request.
GET /v1/models
Registered models and current residency.
GET /v1/capabilities
Active model, sampling preset, and optional Xyntetik agent profile.
GET /v1/runner/prefix-cache
Prefix-cache size, limits, and counters.
POST /v1/runner/prefix-cache/clear
Release cached prefixes without un the model.
GET /health
Server and resident-model health, plus this process's rss_bytes /peak_rss_bytes and cumulative tokens_prompt , tokens_generated , generate_seconds , batch_steps and batch_sequences .
POST /unload
Release resident model, draft and prefix-cache memory; the next request reloads on demand. Deferred to the next safe point while a load or generation is in flight (the reply says "deferred":true ). Needs the registry: a server without one refuses with 409 rather than reporting a success it cannot deliver - see the residency note below.

GET /unload

is deliberately refused with 405

; un is a state change.

Buffered generation responses include runner_telemetry

with prompt tokens reused/evaluated, generation timing, paging counters, and structured or speculative mode flags. speculative

reports whether that request used the speculative walk, not merely whether the server has a draft loaded; logprob and choice-logprob capture use the solo walk and therefore report it as false. Set request field "cache_prompt": false

to bypass prefix reuse. Streaming clients whose writes fail cancel generation. An orderly client socket close on any completion surface also cancels at the next complete prefill chunk or decode step, so an abandoned long prompt does not keep its slot busy; the probe is non-consuming, so an alive quiet client or readable pipelined bytes are not a cancellation signal.

Every generating endpoint also accepts a per-request "timeout"

in seconds (0

86400

), which overrides RUNNER_REQUEST_TIMEOUT

for that request; 0

means no limit and an out-of-range value is a 400

. Expiry is a truncation, not an error: generation ends, finish_reason

is "length"

, and constrained output is closed to a legal document exactly as a token-ceiling hit would be.

Prefix reuse lives in this process only. The cache is host RAM bounded by RUNNER_PREFIX_CACHE_MB

, and it is released by POST /unload

, by POST /v1/runner/prefix-cache/clear

, by a keep_alive: 0

request, and at exit. A model swap deliberately keeps it - surviving a swap is the point of snapshotting a prefix rather than holding a slot - and every entry is bound to the model, geometry, tokenizer, context length and KV element type it was taken from, so another model cannot install one. There is no on-disk warm start: a restarted server prefills from cold.

--parallel N

creates independent KV caches and thread pools while sharing mapped weights. Threads are divided across slots. Multi-model swap mode uses one slot because only one model is resident at a time, and accepts up to 16 registered models:

./runner -m "code=qwen3-14b.gguf,fast=qwen3-4b.gguf" \
  --serve --ttl 300

Each request selects the registered name in its model

field.

Residency control - --ttl

, POST /unload

, and the per-request keep_alive

(seconds; 0

unloads at the next safe point, negative pins the model) - needs the model registry, which is not the same line as "swap mode": a single model served with the default --parallel 1

joins the registry as a one-entry set, so all three work there exactly as they do for a swap set. The exception is a multi-slot single-model server (--parallel N

with N > 1

): its slots hold the model directly, with no registry to unload it from. There POST /unload

refuses with 409

and an error naming the configuration - it used to answer {"status":"ok"}

after freeing only the prefix cache, which told an operator reclaiming memory that weights and KV were gone while every byte stayed resident. A completion that carries a keep_alive

field there is refused with 400

for the same reason: the field is well-formed but not satisfiable without a registry, and keep_alive: 0

would free nothing - it used to be range-checked and then silently dropped. A completion with no keep_alive

field is the normal case and is unaffected. POST /v1/runner/prefix-cache/clear

works everywhere and is what both refusals point at; serve with --parallel 1

if you need an unloadable server.

These environment variables are operator controls rather than hidden feature switches:

Variable Default Purpose
RUNNER_MAX_QUEUE
512
Lower the fixed admission queue capacity.
RUNNER_REQUEST_TIMEOUT
0
Default generation wall-clock limit in seconds; 0 disables it.
RUNNER_PREFIX_CACHE_MB
512
Host-RAM budget for shared prompt prefixes; 0 disables storage.
RUNNER_PREFIX_CACHE_TTL
600
Prefix idle lifetime in seconds.
RUNNER_MOE_PREFETCH
per-machine auto Compatibility fallback for --moe-prefetch ; the CLI flag has precedence. 0 /off disables it and other non-empty values enable it.
RUNNER_ALLOW_UNKNOWN_ARCH
unset Admit a GGUF whose general.architecture this binary does not implement, running it through llama-style math. Unset, such a file is refused at load. Set, the load is attempted and a warning says the output may be silently wrong. Experimental, not a supported configuration.
RUNNER_VRAM_PRIORITY
0
Baseline for --vram-priority ; the flag overrides it.

Beyond these, the binary reads a number of development switches - RUNNER_DEBUG_TOKENS

, RUNNER_DEBUG_ACT

, RUNNER_MOE_TRACE

, RUNNER_LAYER_SIM

, RUNNER_GRAMMAR_TRACE

, RUNNER_SCHEMA_TRACE

, the RUNNER_METAL_*

/RUNNER_CUDA_*

kernel knobs and failure injectors. They print or dump internals for the tools under scripts/

(moe-prune-plan.py

consumes RUNNER_MOE_TRACE

, classify-grammar-trace.py

consumes RUNNER_GRAMMAR_TRACE

) and are read at first use. They are instrumentation, not interface: names, formats and defaults change without notice, and nothing outside this repository should depend on them.

GGUF exports may opt into the versioned gridcore.agent.*

profile. Runner validates its protocol/tokenizer versions, schema identity, digest, and required runtime features before allocating model state; unknown requirements fail closed. GET /v1/capabilities

returns the admitted profile. See docs/agent-profile.md.

Chat supports buffered and SSE responses, part-array content, assistant tool_calls

history, role:"tool"

results, stream_options.include_usage

, logprobs

/top_logprobs

, min_p

, repeat_penalty

, up to four stop strings, and keep_alive

on a registry-backed server. Tool declarations are rendered into the model prompt in the resident model's native tool protocol - identically on /v1/chat/completions

, /v1/responses

, and /v1/messages

  • and constrained back into well-formed tool_calls

.

Stop strings and tool declarations cannot be combined: a request carrying both stop

(or Anthropic's stop_sequences

) and tools

is refused with HTTP 400. A stop string is a rule about the model's visible text, but under the tool envelope the model generates protocol - Harmony channel markers and recipient headers, Muse's <atem:invoke>

blocks, gemma-4's <|tool_call>

blocks, or the generic envelope's own JSON syntax - and the caller receives only the demultiplexed result. Matching stop strings against that document fires on framing nobody wrote (["\n\n"]

, ["}"]

and ["<|"]

all hit). Runner refuses the request rather than ignoring the field. The refusal is a semantic one: a stop match no longer corrupts the document - under a plain response_format

it truncates the constraint validator with it, described under structured output below - but a rule the caller wrote about visible text cannot be honoured against protocol the caller never sees.

parallel_tool_calls:true

compiles the generic JSON tool envelope into a bounded {"calls":[...]}

array (up to 8 entries) over the same discriminated union, instead of a single object; a direct answer is just a one-element array holding the final

branch. Buffered and streaming requests map it the same way: each call gets its own tool_calls[].index

, announced and closed before the next one opens, so a client reassembles a parallel SSE turn with the identical per-index accumulation it already uses for one call. A budget that truncates mid-call still closes to a legal, executable document - sval_close

guarantees that - but finish_reason

stays "length"

, never "tool_calls"

, when the closer rather than the model finished the entry.

gpt-oss uses its trained Harmony tool protocol instead of that generic envelope. Runner renders the official TypeScript # Tools

namespace in the Harmony developer turn - the slot the reference reserves for OpenAI function tools, after # Instructions

and separated from it by a blank line, or alone in a developer turn of its own when the caller sent no system message. The system turn has a second # Tools

slot that renders identical bytes, but it is for the model's built-in browser/python tools and Runner never uses it. Declaring tools also appends Calls to these tools must go to the commentary channel: 'functions'.

to the system turn, on the line after # Valid channels

; that channel list is the constant analysis, commentary, final

whether or not tools are declared. Runner then constrains the generated recipient to functions.NAME

, constrains the JSON after <|constrain|>

against that function's declared parameters, and maps the native commentary

/<|call|>

turn back to ordinary OpenAI tool_calls

. tool_choice

(auto

, required

, named, and none

), JSON-schema response_format

on the auto/final branch, buffered replies, SSE, reasoning, visible commentary before a call, tool-result replay, and histories containing several prior calls all use the same native path. Harmony ends one sampled turn at its first <|call|>

; therefore parallel_tool_calls:true

permits a call but does not fabricate several calls inside one Harmony turn. Multiple calls are replayed as consecutive native turns when the client sends them in history. A replayed tool result is spelled <|start|>functions.NAME to=assistant<|channel|>commentary

: the recipient is not decoration, because the reference resolves the author token before the channel and accepts a namespaced author as the tool role only through that to=

branch. It must also be attributable, because the turn is authored by the function that ran: runner resolves that name from the call the result answers - tool_call_id

on Chat, call_id

on Responses, tool_use_id

on Messages - and falls back to the sole declared function when exactly one tool is declared, since there is no other function in the namespace the result could be from. When the lookup finds nothing and two or more tools are declared, the request is refused with a 400 naming the field that would fix it, rather than rendering a turn shape gpt-oss was never trained on or a function name invented from an identifier. Runner is stateless, so a client that keeps its own history has to send the call item back alongside its result. The # Tools

TypeScript follows the openai-harmony reference renderer rather than TypeScript validity, so that one tool schema yields one prompt across engines instead of a per-engine spelling. Only the tool-level description is split into one //

comment per line; an object schema's own description, a property title, and a property description each take a single //

prefix, which leaves a multi-line value's continuation as a bare uncommented line. That is the reference's own quirk, reproduced deliberately and pinned by goldens rendered through openai-harmony 0.0.8 (abd677f7) via DeveloperContent.with_function_tools

  • the function-tool slot, named in each golden's comment because the builtin-tool slot renders the same bytes in the wrong turn - and cross-checked against the chat_template

embedded in the official gpt-oss GGUF. Strict Harmony tool turns bound a pre-call analysis or visible commentary message to 192 UTF-8 bytes; at that boundary the trained assistant handoff is forced, preserving enough output budget for model-generated arguments instead of letting a turn narrate its intent forever. The bound is what ends that narration whenever a call is legal - not only under tool_choice:"required"

  • and it is not free: a tool result the model wants to quote back, typically a JSON document, can be cut mid-quotation, and gpt-oss then repeats the call it was just answered before replying on the next round. A prose tool result fits inside the bound and answers directly. Lifting the bound is measured and worse, not untried: docs/negative-result-harmony-analysis-bound.md.

For example, the usual OpenAI request needs no Runner-specific switch:

{
  "model": "gpt-oss-20b",
  "messages": [{"role": "user", "content": "What is the weather in Oslo?"}],
  "tools": [{"type": "function", "function": {
    "name": "get_weather",
    "description": "Get the current weather",
    "parameters": {"type": "object", "properties": {
      "city": {"type": "string"}
    }, "required": ["city"]}
  }}],
  "tool_choice": "required"
}

Muse's native atem format carries scalar parameter values as raw text rather than JSON strings. Consequently a scalar value cannot contain the literal </atem:parameter>

sequence: atem itself uses that sentinel as the value boundary and its reference template describes the output as regex-parsed, not XML-escaped. Declared parameters retain their schema optionality: members listed in required

are forced, while other members may be omitted in their declared order.

For Muse, the recipient header is part of the constrained turn: to=user

selects a plain answer and a declared tool recipient pins the matching <atem:invoke>

name. Buffered and SSE parsing collect consecutive native calls separated by <|eom|>

into ordered OpenAI tool_calls

; the separator is not treated as a global stop token.

Native atem calling is selected automatically when a loaded Muse Glimmer model receives tools

. Set atem_tool_calling:false

on a Chat Completions request to use Runner's generic JSON-schema tool envelope instead; its payload is still constrained behind Muse's to=user

recipient header, so the override does not leak prompt syntax into content

. tool_choice

(auto

, required

, named, and none

) still controls the allowed recipients. parallel_tool_calls:true

with a required/named native choice constrains a bounded two-call turn. With native tool_choice:"auto"

, the same flag retains the auto turn and therefore permits at most one call. Families without a native constrained-generation protocol keep the generic JSON-schema output path. gpt-oss, Muse Glimmer and gemma-4 have native constrained-generation paths; Apertus uses its reference template's native declarations and history framing, while its generated output remains on the generic strict envelope.

When native tool_choice:"auto"

is combined with a JSON-schema response_format

, the to=user

alternative is compiled against that final schema; choosing not to call a tool therefore does not weaken structured output.

Muse's own protocol tokens satisfy the constraint's spelled markers: when the model emits a control token such as <|message|>

where the automaton expects that literal, the engine advances the automaton and the output stream with its spelling, so constrained generation follows the model's trained header format instead of forcing it to type protocol out as text.

An explicit enable_thinking:true

starts Muse's self-addressed reasoning turn before the recipient constraint. If generation is cut at the token limit, the atem automaton closes the current parameter/invoke/function-call tail; raw scalar recovery uses the declared parameter type so the resulting OpenAI arguments document remains executable, and an incomplete string enum is completed to the member sharing its longest prefix. Missing numeric text is recovered within the parameter's declared bounds. A native to=user

text answer ends at the model's own end-of-turn token and reports finish_reason:"stop"

; only a genuine token-limit cut reports "length"

.

A model that opens a thinking block is bounded differently depending on whether the request asked for a structured deliverable, and the asymmetry is deliberate:

With a constraint-response_format

(json_object

/json_schema

) ortools

  • the thinking prelude is capped at half the token budget. Hitting that cap does not end the turn: the prelude is closed and the remaining budget goes on the payload that was actually requested.finish_reason

is the standard"length"

, andrunner_telemetry.finish_detail

carries"reasoning_limit"

so the specific cause stays recoverable. This is the shape Anthropic's extended thinking uses - thinking has its own budget undermax_tokens

, and the answer is still produced.Without one, there is no prelude cap. The turn runs tomax_tokens

like any other andfinish_reason

is a plain"length"

with nofinish_detail

. This resembles OpenAI's reasoning models, where reasoning and output share one ceiling and a reasoning-heavy turn can return little or nothing.

The reason for the split: under a constraint the caller is owed a document, and returning an empty one is a worse answer than a shorter thought. Measured on gemma-4-E2B, two of four tool prompts opened a thinking block and never closed it - with -n 200

that burned 100 tokens and returned a single newline.

A loaded gemma-4 model that receives tools

declares them the way its own chat template does - <|tool>declaration:NAME{description:<|"|>...<|"|>, parameters:{...}}<tool|>

, inside the caller's system turn rather than in a prepended one - and calls them as <|tool_call>call:NAME{city:<|"|>Oslo<|"|>} <tool_call|>

, with results replayed as <|tool_response>response:NAME{...} <tool_response|>

from inside the model turn that made the call. Those bytes are compared against the reference template case by case in scripts/template-conformance.py

.

The native syntax does not cost the strict envelope. The generated turn is still constrained: the tool name comes from an enumeration of the declared functions, each argument key and type from that function's schema, and tool_choice

(auto

, required

, named, none

) selects which branches exist at all - required

removes the prose branch, which is what enforcement means here. A call cut off by the token limit is closed to the smallest legal ending and still reports finish_reason:"length"

. What the client receives is ordinary JSON: arguments

is translated out of gemma4's <|"|>

spelling on both the buffered and the streamed path, so no native framing reaches an OpenAI client.

Declared parameters retain their schema optionality: members listed in required

are forced, while other members may be omitted without changing gemma4's dict-sorted native order. One limit is worth knowing before you write a schema for this family: a parameter with no declared type

is rejected with a 400 that names it. Gemma4's native call syntax has no spelling for a free-form value, and refusing is better than an unconstrained call the mapper may not be able to read back. This differs from the generic JSON envelope, which can represent a free JSON value.

Apertus tool prompts follow swiss-ai/Apertus-8B-Instruct-2509

's chat_template.jinja

at revision b946d40447b2b597999b9c86d44bee0b452c919f

: declarations are TypeScript under Tool Capabilities:

in the developer turn, assistant calls use <|tools_prefix|>...<|tools_suffix|>

, and raw tool results form a bracketed list inside the assistant turn that made the call. A text-plus-calls turn keeps the text immediately before the call block, and an answer after the result continues that same assistant turn. The conformance gate proves these rendered bytes against the upstream template. No Apertus checkpoint/tokenizer was available for this change, so token identity and checkpoint behavior remain unmeasured.

enable_thinking

, either at the top level or inside chat_template_kwargs

, is the request-level form of --think

/--no-think

. Omitting it is not the same as sending false

: an absent field renders whatever the model family's own reference template renders, and that default differs per family, so collapsing "unspecified" onto one of them would misrender the other.

import openai

client = openai.OpenAI(
    base_url="http://127.0.0.1:8080/v1",
    api_key="none",
)
response = client.chat.completions.create(
    model="runner",
    messages=[{"role": "user", "content": "Return a status object"}],
    response_format={"type": "json_object"},
)
print(response.choices[0].message.content)

Constrained buffered requests can set choice_logprobs:true

. Decision points then include legal alternatives, posterior probability over the probed legal set, raw logprobs, and coverage mass. choice_logprobs_probe

defaults to 32 and is capped at 64; scripts/cl-calibration.py

turns labeled records into an ECE report.

Responses requests are translated to the same prompt, sampler, and one tool per turn envelope as Chat Completions. Supported input includes strings and item arrays, function_call

/function_call_output

loops, flat or nested function tools, tool choice, text.format

for text/JSON/schema, ordinary sampling controls, max_output_tokens

, reasoning

, and store:false

.

Streaming emits ordered typed lifecycle, text-delta, function-argument-delta, done, and terminal events with monotonic sequence_number

values. The terminal event contains usage and runner telemetry.

Runner is stateless and refuses persistence or hosted-service fields rather than accepting them without effect: store:true

, previous_response_id

, background:true

, conversation

, truncation:"auto"

, include[]

, hosted tools, and parallel_tool_calls:true

.

A replayed function_call

item must say which function it called. Runner uses its name

, falls back to the sole declared function when exactly one tool is declared, and otherwise answers 400 naming name

as the field that would fix it. The item is never dropped from the history: a call that silently vanished left the model reading a tool result for a call it never made, with a 200 on the response.

A replayed function_call

and its function_call_output

are serialized in the resident model's own tool protocol - the same serializer Chat Completions uses, not a generic one bolted onto this surface. A gemma-4 call comes back as <|tool_call>call:NAME{...}<tool_call|>

with gemma-4 argument formatting and its result as <|tool_response>response:NAME{...}<tool_response|>

; an ornith call as <tool_call><function=NAME>…

with the result wrapped in <tool_response>

; a muse call as its <atem:invoke>

recipient turn with the result as a named <tool_output>

; Harmony as its to=functions.NAME

turns. The same three-turn conversation therefore renders byte-identically whether it arrives on /v1/chat/completions

, /v1/responses

, or /v1/messages

  • a contract pinned by goldens in tests/test_tool_attribution.c

.

Messages uses the same internal engine and constrained tool envelope. It supports string or block-list system/content values, tool_use

/tool_result

, all tool-choice forms compatible with one call per turn, stop sequences, sampling controls, metadata, thinking-channel blocks, and Anthropic SSE event ordering. max_tokens

is required.

Runner refuses hosted tools, MCP/container execution, image/document blocks, parallel tool use, stop_sequences

sent alongside tools

(see Chat Completions above), and forced thinking on a model with no reasoning channel. It implements protocol translation only; it never executes a tool.

A replayed tool_use

block and its tool_result

are serialized in the resident model's own tool protocol - the same serializer Chat Completions and Responses reach - so a gemma-4, ornith, or muse history is never handed the generic call syntax those models were not trained on. The result turn is named from the tool_use

it answers (by tool_use_id

, falling back to the sole declared tool) exactly as on the other two surfaces.

A generation fault is reported as an Anthropic error object rather than a Message

with a made-up stop_reason

  • HTTP 500 api_error

buffered, the documented event: error

mid-stream. See Constrained output below.

Client compatibility is a dated executable observation, not something inferred from an API name. The 2026-08-03/04 sweep recorded complete local file-read loops for OpenCode 1.18.4, Cline CLI 3.0.46, pi 0.81.1, Continue CLI 1.5.47, Claude Code 2.1.220, and lean-tool-set Codex CLI 0.144.6. Aider 0.86.2 passed transport/inference under --dry-run

but still needs a matching model edit profile.

Codex and other feature-rich agents can declare more than runner's 59-tool constrained envelope. Disable unused app, multi-agent, and hosted-search tools for a local-model session. Exact request shapes and test scope are recorded in docs/agent-compatibility.md and docs/compatibility-program.md.

For Codex CLI, configure a stateless Responses provider:

model = "runner"
model_provider = "runner"

[model_providers.runner]
name = "Xyntetik Runner"
base_url = "http://127.0.0.1:8080/v1"
wire_api = "responses"
env_key = "RUNNER_API_KEY"
export RUNNER_API_KEY=none
./runner -m model.gguf --serve -c 16384
codex "list the files here"

Codex's system prompt and tools can consume roughly 10k input tokens before the user request, so use at least a 16k context for that workflow. Runner does not implement a response store; clients must send history each turn rather than use previous_response_id

.

macOS and Windows ship a menu-bar / notification-area controller. It lists every runner instance live on the machine - however it was started - with the models each has loaded, and lets you stop any of them, pick a GGUF, and start a desktop-managed server. Linux has no tray; --tray

there prints an honest error.

The tray follows a session you sit with, and is left running afterwards so the next model can be loaded from it.

Invocation Tray
runner with no arguments at a terminal, or a double-click
yes
runner -m model.gguf --serve
yes
runner -m model.gguf -i
yes
runner -m model.gguf -p "..."
no
--caps , --quantize , --bench-json , --version
no
anything with --no-tray
no
pipes, scripts, CI, Linux no

A terminal on either stdin or stdout is what counts as "a person launched this", so runner --serve > server.log

still raises one while CI, which usually has neither, does not. A one-shot -p

run raises nothing on purpose: a two-second process should not leave a menu-bar icon behind it.

--tray

means be the tray rather than run a model. It is required wherever there is no terminal - launchd, Task Scheduler, a service wrapper - because every launch in the table above needs one. --no-tray

opts out everywhere.

One tray runs per machine; a second exits naming the pid that owns the icon. The tray is spawned detached with its own session, so stopping a server with Ctrl-C leaves the menu bar alone, and it outlives the run that raised it.

A rounded-square core with a signal motif around it. On macOS it is a template image, so it follows light and dark menu bars.

State Glyph Meaning
Idle hollow core, two opposing sweeps No runner registered.
Model loaded solid core, two opposing sweeps A runner is up with a model resident, nothing in flight.
Running solid core, four-segment ring Inference is in flight.

The ring is segmented rather than closed because a menu-bar template image cannot animate: four gaps read as motion where a circle reads as a badge.

/health

also carries what a supervisor needs to budget several runners on one machine. rss_bytes

is this process's resident set - weights, KV cache, activations and allocator overhead together - which is the number a machine is sized against and which no per-mapping measure accounts for; peak_rss_bytes

is its high-water mark. tokens_prompt

, tokens_generated

and generate_seconds

are cumulative monotonic totals across every API surface. batch_steps

and batch_sequences

count the scheduler's microbatch steps and the sequences cut into them, so batch_sequences / batch_steps

is the mean batch size over your own window; both stay 0

on a server that never started continuous batching (a single slot, or swap mode). On CUDA they also stay 0

for a model whose weights use a quantization the batched path has no bitwise-identical kernel for - a batched step must return, per sequence, the bits a lone step would have, so such a model decodes its sequences one at a time rather than batching them into different numbers. The current CUDA microbatch loop covers gated dense transformer layers; recurrent, MoE, NoPE, attention-gated, and ungated xIELU models use sequential GPU forwards. Within the covered family Q8_0, Q4_0, Q4_K, Q5_K, Q6_K, F32 and F16 batch; the rest do not.

Those are deliberately raw counters rather than a tokens-per-second field: a rate needs an averaging window, and the runner has no business choosing one for a consumer whose window differs. Difference them over your own interval. The endpoint does not count its own requests, so polling it on a timer does not show up as work.

"Loaded" and "running" are told apart by active_requests

from /health

, polled on the same 5-second timer that refreshes the icon - so a request shorter than the tick can pass unseen. It is an indicator, not telemetry. When the count cannot be read the icon shows "model loaded", because a server that is up but unreachable still has a model resident.

Configuration, the instance registry, autostart, uninstall, and the headless validation seams are documented in docs/tray-controller.md.

The macOS release is ad-hoc signed, not Apple-notarized. A browser download may therefore be blocked by Gatekeeper even when its published checksum matches. Verify the SHA-256 checksum first, then remove the quarantine attribute from the extracted binary with xattr -d com.apple.quarantine runner

; obtaining a Developer ID and notarizing releases remains an owner action.

Runner provides two sampler-level guarantees:

--json

or OpenAIresponse_format.type=json_object

emits one valid JSON object.--json-schema FILE

, OpenAIjson_schema

, Responsestext.format

, and tool parameter schemas compile to a streaming conformance validator.

The supported schema subset covers objects, arrays, strings, numbers, integers, booleans, null, enums, const, type unions, numeric bounds on both integer

and number

(minimum

/maximum

and their exclusive forms, with a forced close completing the value inside the declared range), string lengths and supported anchored patterns, array item/count constraints, scalar-const oneOf

/anyOf

, and the tool-discriminated object union used by agent clients. Required properties are present, unknown properties are blocked for closed objects, and tool arguments are generated against the selected tool's schema.

Object schemas may be closed fixed-property records, unconstrained open objects, or homogeneous maps: with no declared properties (or an empty properties

object), a schema-valued additionalProperties

is enforced for every arbitrary-key value. Mixed fixed properties plus open or schema-valued additional properties remain unsupported and are rejected rather than silently weakened.

Anchored pattern

s compile as a sequence of literal runs and repeated ASCII classes ([...]

, \d

, \w

): ^wf_[a-z0-9-]{6,}$

and ^[A-Z]{3}[0-9]{4}$

both enforce, and a forced close mid-string completes to a string the pattern still accepts. Every class before the last carries a fixed count, so which class a byte belongs to follows from its offset; a variable-length class in the middle is refused rather than guessed, as are \s

, negated classes, and escapes inside [...]

.

Unsupported or ambiguous constraints fail at compile/request time. In particular, general overlapping oneOf

branches are not tracked in parallel; branches must diverge at a supported discriminator. This is a subset of JSON Schema 2020-12, not full JSON Schema or GBNF.

If the budget ends after a document starts, runner emits the minimal legal suffix and reports a length finish - on the tool-call path too: a truncated call is still returned as a parseable tool_calls

entry, but the envelope keeps the truncation signal (finish_reason: "length"

, Responses status: "incomplete"

with max_output_tokens

, Anthropic stop_reason: "max_tokens"

) so a caller knows the arguments are minimal closures rather than the model's completed intent. If the model never starts the document, runner returns empty content rather than inventing required values. Syntax and schema shape are guaranteed; semantic correctness and tool selection remain the model's responsibility.

A client stop

sequence is handled as a truncation the caller asked for. The matched bytes are withheld from the response, as they are in unconstrained text, and the constraint validator is truncated with them - re-seated on exactly the document the caller received - so the minimal legal suffix completes that copy rather than the longer one the model had reached. The delivered document parses and conforms; finish_reason

is "stop"

(Anthropic stop_reason: "stop_sequence"

, carrying the matched string). The suffix itself is never stop-matched: it is runner closing the document rather than model text, and ["}"]

or ["\n\n"]

would otherwise eat the very bytes that make it legal.

If an envelope document cannot be mapped back at all, runner reports the fault instead of serving the raw protocol as an answer. On the OpenAI surfaces content is empty, finish_reason

is "error"

with runner_telemetry.finish_detail: "envelope_unmapped"

, and Responses reports status: "incomplete"

with reason envelope_unmapped

. A stream that ends this way is still terminated - a terminal chunk carrying the finish reason, then data: [DONE]

, or Responses response.incomplete

  • so a client is never left waiting on events that will not arrive.

Anthropic Messages reports the same fault as an error object, not a Message

. All seven of its stop_reason

values describe a turn that completed, so none of them can carry a generation fault; a buffered turn answers HTTP 500 with {"type": "error", "error": {"type": "api_error", "message": ...}}

, which is the class the Anthropic SDKs retry with backoff, and a streamed turn - whose 200 is already sent - terminates on the protocol's documented event: error

carrying the same object, in place of message_delta

/message_stop

. An allocation failure during generation is reported the same way on both. Partial text is not returned alongside it: unlike a budget truncation, which is a completion and keeps its content under stop_reason: "max_tokens"

, a fault has no stop_reason

that would not misstate why generation stopped. runner_telemetry.finish_detail

rides on the error object so the two faults stay distinguishable.

Constrained decoding pays for the sampler on every step, and several shipped presets - SmolLM2's, llama3's, mistral's, gpt-oss's - set top_k = 0

, which means no truncation and a pass over the whole vocabulary. Setting --top-k 40

(or "top_k": 40

per request) measured 12–27% higher decode throughput than the same run at the preset's top-k 0

, on an M1 with SmolLM2-135M-Instruct under JSON- and schema-constrained decoding.

It is an option, not a default, and it is not certified. Truncating to 40 candidates changes the sampled distribution - it is different semantics, not a cheaper route to the same tokens - so it stays outside the correctness gates rather than becoming a preset value. Reach for it when decode throughput matters more than reproducing the preset's distribution; leave it off when the run is being compared against a reference. At --temp 0

the question does not arise: greedy argmax bypasses top_k

, top_p

, min_p

and the repeat penalty entirely, so the certified greedy paths are unaffected either way.

runner --caps

publishes the architecture IDs admitted by the current binary:

GGUF general.architecture | Notes | |---|---| llama , mistral , smollm , stablelm | Llama-style dense families with family tokenizers/templates. | qwen2 , qwen3 | QKV-bias and per-head-QK-norm variants. | qwen35 | Dense Qwen3.5/Ornith Gated DeltaNet plus full attention; CPU and CUDA. CPU recurrent folds support speculative decode, grammar fast-forward, and exact shared-prefix restore. Any GPU-backed recurrent instance declines shared-prefix restore; a CUDA-resident recurrent layer also declines speculative decode and grammar fast-forward. | qwen3moe | Fused and legacy split sparse-MoE layouts on CPU/CUDA; supported fused layouts on Metal. | gemma3 | Regular and QAT layouts, sliding-window attention, sandwich norms. | gemma4 | Heterogeneous attention, thinking channels, E-series, supported dense/MoE layouts, and the family's native tool protocol. | phi3 | Fused QKV and gate/up tensors, LongRoPE factors. | gpt-oss | Attention sinks, alpha-sigmoid GLU, expert biases, MXFP4 experts. Tokenizer exact (0/721 differential) and chat renders the real Harmony format (analysis channel as reasoning_content ) as of 2026-08-14; cross-engine greedy identity remains inside the model's own measured KV-precision sensitivity envelope rather than certified. | apertus | xIELU FFN; CPU and CUDA. | afmoe | Arcee Trinity sparse MoE; CPU only. CUDA and Metal refuse it loudly as gated attention plus sparse MoE, rather than misreporting a quantization problem. | muse-glimmer | Meta Muse Glimmer 30B, text path: gated attention, QK and sandwich norms, SWA with NoPE globals, softcapped logits. CPU, CUDA and Metal. Certified; evidence in docs/muse-glimmer-cert-2026-08-11.md and docs/muse-atem-cert-2026-08-11.md . No vision encoder. Native atem definitions/results, recipient-constrained generation, truncation recovery, multi-call mapping, and buffered/SSE parsing are implemented and selected automatically for tool requests. | granite | IBM Granite dense (3.x/4.1): the four muP scalars (embedding, fixed attention, residual, divided logit). CPU, CUDA and Metal. Certified; evidence in docs/granite-cert-2026-08-11.md . granitemoe is a separate arch id and not admitted; granitehybrid is admitted separately, below. | granitehybrid | Granite-4 h-series: a Mamba-2 selective-SSD recurrence (causal conv1d + the input-dependent state-space scan, with the gated RMS norm) interleaved with GQA attention, the layer type read per-layer from the attention.head_count_kv array (0 ⇒ recurrent); the attention layers are NoPE (rope.scaling.finetuned=false ); the four granite muP scalars. Both published FFN layouts are supported: dense h-micro has a gated MLP on every layer and runs on CPU and CUDA; sparse h-small has a routed MoE FFN plus an always-on shared expert and currently runs on CPU because those two branches have no device path. The dense h-micro CUDA path is CPU-token-identical over 600/600 greedy tokens with per-run mean | nemotron_h | NVIDIA Nemotron-H (Nemotron-Nano-9B-v2): a Mamba-2 selective-SSD recurrence interleaved with GQA attention and dense MLP blocks, where each block is EXACTLY ONE of three kinds (SSM | attention | MLP), typed per-layer off attention.head_count_kv (0) and feed_forward_length (0). NON-MoE and no muP scalars - unlike granitehybrid; the MLP is a gate-less squared-ReLU FFN (down(relu(up(x))^2) ), attention is NoPE (rope.scaling.finetuned=false ), and the SSM uses a GROUPED scan (ssm.group_count=8 ): B/C are shared across groups of heads and broadcast (group g covers heads [g·H/G, (g+1)·H/G)) - the same grouped scan nemotron_h_moe (Nemotron-3.5 Lightning) also uses - here first proven WITHOUT MoE, and admitted WITH MoE in the row below. CPU and CUDA: the Mamba-2 SSD scan, causal conv1d, gated RMS norm and squared-ReLU FFN all have device kernels, and full 56-layer offload is greedy byte-identical to the CPU path on the real Nano-9B-v2 Q8_0 (3 prompts x 48 decode steps plus an 88-token multi-tile prefill; re-verified independently post-merge at 32 tokens). Device prefill currently runs the per-token loop (correct, unoptimized); no Metal SSM path. Verified against llama.cpp b10353 on the real Nemotron-Nano-9B-v2 at Q8_0 (same GGUF both engines, CPU): 5/6 greedy completions byte-identical (including both 256-token generations); the sole miss is a single-token near-tie where both engines share the same top-3 candidates and llama.cpp's own top-1/top-2 gap is ~0.075 nats (an FP-summation-order coin-flip), i.e. the quantisation noise floor, not wrong math. Chunked-scan prefill (the grouped scan tiled into chunks, parallel across heads within a chunk, SSD state + conv ring carried across chunk boundaries), bit-identical to the serial per-token sweep and pinned chunked == serial in make test ; the recurrent-state cache seam is wired (fold snapshotted/restored on rewind, and stored beside the KV so an exact CPU prompt-prefix hit restores it in a memcpy). CPU speculative decode and grammar fast-forward use the per-round fold checkpoint; full GPU offload and partial splits with a CUDA-resident recurrent layer decline them. | nemotron_h_moe | NVIDIA Nemotron-3.5-Lightning-30B-A3B: nemotron_h with the dense squared-ReLU MLP replaced by a gate-less squared-ReLU MoE (128 experts / 6 used, no gate branch) plus an always-on gate-less shared expert; the router reuses the general softmax/group/scale/norm path. Same three-way block typing, grouped scan (n_group=8 ), and NoPE attention as nemotron_h . Runs on CPU: the SSM scan has a device kernel, but this family's router (weight-normed, scaled) and gate-less shared expert have no device path, so the backend falls back to CPU there. Greedy vs llama.cpp ea12b27 on the real Lightning-30B Q4_0 (CPU, 8 tok × 5 prompts): 4/5 byte-identical, the one divergence a near-tie on an open-ended counting continuation (noise floor, not wrong math - the coherent Paris. … Berlin. completion matches exactly). Evidence: docs/compat-reports/ssm-greedy-reference-2026-08-20/ . |

Admission remains layout-specific: an unsupported split expert layout, a non-SiLU MoE outside gemma-4's dual-branch form, or an architecture-specific tensor arrangement is refused even when the architecture ID is listed. The always-on shared expert (Qwen2-MoE/DeepSeek form: a dense FFN over the same normed input, summed with the routed output, optionally gated) is supported and is what afmoe uses; its width and tensors are shape-checked at load, and expert_shared_count

set without the tensors present is an error rather than a silently dropped branch.

Area Current support
File format GGUF v2/v3, mmap/file-mapped host weights, including standard local multi-part sets.
Tokenizers SPM and byte-level BPE with llama, qwen2/qwen35, smollm, afmoe, tekken, llama4/gpt-4o, Gemma, and GPT-2-family pre-tokenization rules.
Quantizations --caps lists the admitted tensor formats: the k-quant and legacy families plus MXFP4 and the codebook i-quants (IQ1_S/M, IQ2_XXS/XS/S, IQ3_XXS/S, IQ4_NL/XS). The IQ1, IQ2 and IQ3 families are CPU-only with NEON/AVX2 dequant kernels; CUDA and Metal refuse them loudly, naming the exact tensor and type that caused the CPU fallback.
Transformer RMSNorm, adjacent-pair and NeoX RoPE, grouped-query attention, SwiGLU/GELU/xIELU family paths, tied embeddings, dense and selected sparse MoE.
Sampling Greedy, temperature, top-k, top-p, min-p, repeat penalty, stop strings, JSON/schema constraints, speculative decoding.
Context Batched prefill, f16/q8 KV, linear/YaRN/llama-3 scaling, automatic extension.
Serving Chat Completions, Responses, legacy completions, embeddings, Anthropic Messages, SSE, parallel slots, model swap, prefix reuse.
Desktop macOS menu bar and Windows notification-area controller.

Not implemented: Vulkan; TLS/auth; remote bind; remote/streamed GGUF parts; the qwen2moe

/deepseek2

/kimi

architecture IDs (their shared-expert layout is implemented, as above - the architectures are not admitted) or MLA attention; Mamba/Jamba; MTP/NextN draft-head consumption (those tensors load and are skipped, so dense decoding is unchanged); full GBNF; image/document inputs; hosted tools; response persistence; or parallel tool calls on the Responses and Messages surfaces (Chat Completions supports it, buffered and streaming).

The machine-readable manifest is tests/compatibility/models.json. It pins files by SHA-256 and declares checks independently:

Check Meaning
load
The pinned file hashes and loads.
tokenizer
The committed 721-string corpus is compared with the model's Hugging Face tokenizer; revision-bound ID captures make declared rows replayable offline.
greedy_reference
Greedy tokens are compared with a pinned llama.cpp revision.
cpu_cuda
CPU and CUDA scalar-path greedy output are compared.
chat
A real Chat Completions request answers through the model template.
tool
A function call round-trips as schema-conformant tool output.
long_context
A needle is retrieved from an extended context.

Being present in the manifest does not mean every check passed. Read each entry's declared checks and notes. Current high-signal caveats include:

Every release ships a schema-versioned report under docs/compat-reports/

, and scripts/check-release.py

enforces it: a tag whose version has no <version>-<date>.json

in that directory fails the release check rather than shipping with an unpublished ledger. Generate it against the pinned files available on the release box with:

python3 scripts/compat_matrix.py --models-root /path/to/models \
  --runner ./runner --reference /path/to/llama-server \
  --verify-files --execute-checks --out docs/compat-reports/<release>-<date>.json

The executable classes are SHA/load, tokenizer differential when the manifest declares a reference and the corpus exists, and greedy reference when both binaries and the pinned model are present. Every other declared check, and any check missing a prerequisite, is retained as not_executed

with a machine- readable reason; absence from a run is never presented as a pass.

Tokenizer rows with tokenizer_reference_ids

use the committed capture rather than the network. Each capture names the Hugging Face repository and immutable revision, binds itself to the corpus SHA-256, and contains only token IDs - never credentials or model weights. scripts/difftok.py --ref-ids CAPTURE

is the standalone replay path; --capture CAPTURE --ref-revision COMMIT

creates one during an authenticated evidence run.

  • Qwen3-4B's 2026-08-03 scalar CPU/CUDA recheck passed only 4 of 5 prompts; re-measured 2026-08-20 with the current gate: 9/9 prompts byte-exact at 128 tokens, zero near-ties(the intervening router-bias, tensor-core-identity and canonical-quantizer fixes resolved it). Per-row evidence:docs/compat-reports/cpu-cuda-128/qwen3-4b-q4km-2026-08-20.json

. - Canonical gpt-oss-20b passed an earlier 5-of-5, 16-token partial-offload test on an RTX 3070, but failed CPU/CUDA identity and chat/tokenizer gates on the later Blackwell full-offload matrix. Hardware and test-contract scope matter. Its tokenizer differential is exact (0/721) and its chat gate now passes too: gpt-oss renders through a real Harmony template as of 2026-08-14 (<|channel|>

-structured turns,<|return|>

as the stop), where it previously fell through to llama2's[INST]

markup and ran away. The analysis channel is suppressed fromcontent

and surfaced asreasoning_content

;enable_thinking: false

skips it. Harmony tool calling uses the model's native commentary/recipient protocol and strict declared- argument constraints. Measured transcripts:docs/gpt-oss-harmony-2026-08-14.md. The CPU/CUDA identity row wasre-measured on 2026-08-19 now that the file is on the Blackwell box, after the 2026-08-18 router-bias fix (docs/cuda-gptoss-router-bias-2026-08-18.md).gpt-oss-20b-MXFP4

atfull offload- the deployment configuration - passestest-gpu-identity

at 0.000732 of logit range against the 2e-3 bound. The bound is exceeded only underpartialoffload (0.00356 at 1 GPU layer), where a single device layer's reduction-order rounding is amplified through the remaining CPU layers: the divergence is non-monotonic in GPU-layer count, a shape a systematic wrong op cannot produce. The mechanism is discrete top-4-of-32 expert-routing chaos (two experts tied to four decimals reorder under a sub-ULP perturbation), and the model already disagrees with itself on 3 of 16 prompts under a CPU-only KV-precision change. No CUDA correctness defect remains; gpt-oss is gated at its measured sensitivity floor, not at dense-model logit identity.gpt-oss-120b-MXFP4

(63 GB, unable to fully offload on a 24 GB MIG) reproduces 0.00245 at 4 GPU layers - the same amplification effect at greater depth. Full bisection:docs/cuda-gptoss-divergence-2026-08-19.md. - Gemma-4-26B-A4B QAT's old 16-token CPU/CUDA result is not a substitute for the manifest's pending 128-token re-verification.

  • Numerically sensitive models may use a measured self-sensitivity floor instead of claiming cross-engine token identity.

The full 2026-08-05 pass/fail/refusal matrix, including failed derivatives, is in docs/cert-matrix-status.md. Architecture and model-family additions must update the manifest and executable gates, not only this README.

src/gguf.c            GGUF metadata and tensor-table parser
src/tokenizer.c       SPM/BPE tokenization and family pre-tokenizers
src/quants.c          scalar, AVX2/FMA, and NEON quantized dot kernels
src/model.c           tensor admission, weight wiring, and forward pass
src/sample.c          sampling filters and token selection
src/jsonmode.c        incremental JSON-prefix validation
src/schema.c          JSON-Schema compiler and streaming validator
src/template.c        chat templates, thinking channels, and tool syntax
src/engine.c          prompt feeding, prefix cache, constrained sampling, speculative decode
src/quantize.c        requantization and stacked-MoE expert pruning
src/scheduler.c       persistent worker scheduling
src/cuda.c            CUDA driver backend; kernels.cu becomes embedded PTX
src/metal.m           Metal backend; kernels.metal is embedded at build time
src/server.c          loopback HTTP server, slots, routing, and lifecycle
src/completion.c      shared completion request/response path
src/api_responses.c   OpenAI Responses translation
src/api_anthropic.c   Anthropic Messages translation
src/registry.c        model swap and unload lifecycle
src/vramreg.c         cross-process VRAM ownership and bounded waiting
src/tray*.c           macOS/Windows desktop controller
src/compat.c          platform process, memory, mmap, clock, and socket helpers
src/main.c            CLI parsing, utility modes, and --caps
python/               supported Python client and subprocess integration

Host weights remain quantized in the mapped GGUF and are dequantized while computing. CPU memory is approximately mapped weights plus KV and scratch. CUDA copies selected weights and compute/KV buffers to VRAM; Metal wraps mapped weights in unified memory. The load log and --caps

are the sizing sources for an exact model/machine combination.

Xyntetik Runner is developed independently. If it is useful to you and you want to help fund the hardware and measurement time behind it, you can support the project here.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @xyntetik runner 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/xyntetik-runner-a-gg…] indexed:0 read:77min 2026-08-25 ·