Running LLMs locally on a Mac

Chasing the visceral sense of LLM effort that comes from your laptop ironing out your trouser crease

2026-05-23 — 2026-08-16

quality 5.5

In Which the Local LLM Ecosystem on Modern Apple Silicon Is Dissected, Revealing a Landscape of Specialized Runtimes, Mixed-Precision Weight Formats, and the Specific Memory-Management Taxes of Local Inference.

computers are awful
DIY
edge computing
faster pussycat
machine learning
neural nets
NLP
UI

This page is mostly AI slop, i.e. LLM descriptions of my decisions in some recent exploratory projects. However, it is useful, so I am publishing it now rather than waiting for a more polished version.

Figure 1

A twin post to front-end clients for AI image models, but for text. The local-LLM ecosystem on Macs is pretty luxurious, with a profusion of GUI options and Linux-y infra, and some specialized tooling that lags the community frontier but is not bad. Also, during the 2026 RAMageddon, Macs suddenly look like remarkably good deals for high-RAM parallel-compute machines. I accidentally started going unreasonably deep and technical on this in the SOV repo. That repo really targets my coding assistant. Here is a human-facing version.

tl;dr LLMs are capable and useful on modern laptops. The trick is not to waste a month overthinking the damn thing and to just go, if we plan to harvest more value than we sink into tinkering.

Which is advice this page then spends ten thousand words disregarding, so: if we only want the thing working, install LM Studio or Osaurus, pull whatever their model browser recommends, and stop reading. Everything after Desktop apps is for when that stops being enough — when we want a model behind an API rather than a chat window, several of them without running out of RAM, or an explanation for why the same weights behave differently in two different apps. The last third is reference: the model tables, the settings cheat-sheet and the gotchas table, which is where I look things up at 2am rather than reading.

1 The stack

I assume we are familiar with the following terms: model, runtime / inference engine, server / daemon, harness / agent loop, frontend / chat client — plus quantization format, which is a property of the weights rather than a layer, and which we get to below.

The main Mac-centric tools as far as I’m concerned, sorted by the layer they occupy — this is a map, and each one gets its own section below:

Most apps on this page are vertical bundles across several of those layers — that’s specifically the local-model tax: increasingly, each client ships its own miniature copy of llama.cpp as a bonus feature. I find these annoying as they tend to fight with one another and waste disk space/VRAM, but it is OK for intermittent/unserious use. Anyway, it pays to know which layer we’re looking at when an abstraction leaks.

1.1 Runtimes on Apple Silicon

The compute backend is the runtime that runs the matmuls — where on the chip the work actually happens. Three of them cover local text inference on the M-series, and which one a tool picks determines both its speed and how quickly it supports new models.

  • PyTorch + MPS (Metal Performance Shaders) is the baseline. Most ML code reaches Apple Silicon through PyTorch, so coverage of new architectures arrives first — it is the lingua franca. My embedding code runs here; speed is acceptable, if not amazing.
  • llama.cpp brings its own hand-written Metal kernels rather than going through MPS. It is the engine under Ollama and llama-server, fast and wide-coverage, and the one that consumes GGUF.
  • MLX is Apple’s own array framework — faster still on the models it supports, less mainstream, and lagging by months on new architectures. Osaurus, mlx-lm, and the JANG stack all use it.

Not treated here because it is only relevant to image models: CoreML on the Neural Engine for the lowest-footprint path, and Draw Things’ custom Swift + Metal stack.

1.2 Weight formats and quantization

The storage format is what the weights ship in, subject to whatever quantization (if any) has been applied etc. RAM is usually the main constraint on consumer hardware, which is why quantization is helpful. A 70B model at full fp16 is ~140 GB and will not fit on even a 128 GB Mac. A 4-bit build of that same 70B lands near 40 GB, which is tolerable. There are several formats in play.

  • safetensors — the Hugging Face baseline, full precision; what PyTorch + MPS loads when the model fits without help.
  • GGUF — the llama.cpp format. It is not Apple-specific (it also runs on CUDA and CPU), and it has the widest coverage and the finest quant ladder — down to the very-low-bit IQ2/IQ3 imatrix quants.
  • MLX (mlx-community) — the MLX-native format, Apple-only. Also quantized to fit but with added speed on the M-series; coverage tends to lag.
  • JANG — mixed-precision extension to MLX: per-tensor bit-widths instead of one width for the whole model.

Generally we prefer an MLX build for the speed when one is published; GGUF when no MLX port exists yet, and classic safetensors when neither is available.

1.3 Mixed-precision MLX

A quantized model does not have to spend the same number of bits on every tensor, and the ones that do are leaving accuracy on the table: attention and routing layers are a small share of the parameters but a large share of the behaviour, so they repay 8 bits where an expert MLP is fine at 4. MLX supports this out of the box. Its quantization config takes per-module overrides — a quantization block in config.json carrying a {bits, group_size} entry per tensor alongside the global default — and as of mid-2026 two toolchains publish calibrated mixed-precision builds into mlx-community using nothing but that.

Converter Bit allocation Convert with
OptiQ (mlx-optiq) KL-divergence sensitivity pass over a six-domain calibration mix; sensitive tensors promoted to 8-bit, robust ones left at 4. Card claims capability 80.03 against 78.75 for stock uniform 4-bit, at ~3% more disk optiq convert <hf-model-id> --target-bpw 5.0 --candidate-bits 4,8
oQ oMLX’s converter, allocating per tensor from an imatrix-calibrated sensitivity map. The name encodes level and enhanced-variant — oQ4e on Laguna-S-2.1 lands at 4.60 effective bits per weight oMLX

Both document their calibration-data hygiene. Neither declares a custom format, so anything that loads mlx-lm loads them: mlx-lm itself, mlx-vlm, vllm-mlx, LM Studio, oMLX. Documented calibration plus no new format to support is the whole reason I pull an OptiQ or oQ build first.

The alternative was to declare a new format, and I used to think it would win. JANG — a custom mixed-precision format with its own converter, model zoo and runtime, which gets a section of its own below because it is a whole ecosystem — was my guess for how mixed precision would arrive, at the cost of being fringe. Half right: mixed precision did win, the custom format did not.

2 Where weights live

Almost everything here slurps weights from Hugging Face, but in true open-source anarchic style each tool stashes them somewhere different, so we rapidly end up with several copies of a 30 GB blob. Worth sorting out before we install anything, because the fix is a five-minute configuration and the alternative is a disk audit in six weeks. There are three classes of storage AFAICS.

The org/repo resolvers — transformers, mlx-lm — share the one cache at ~/.cache/huggingface/hub. Name a repo, the weights land there on first download, and every subsequent request is served from the cache. Nothing to configure; these are the well-behaved ones.

The directory-scanning servers — Osaurus, oMLX, LM Studio, MLX Studio — are chaos. Each wants a folder of model subdirectories, each has its own default location (~/MLXModels, oMLX’s --model-dir, ~/.lmstudio, ~/.mlxstudio/models), and each writes a fresh copy of whatever it is running outside the HF cache. So the same weights can end up in four or more places, downloaded as many times.

The fix is to pick one folder and point every such server at it. ~/MLXModels is as good as any and is already Osaurus’s default, so:

omlx serve --model-dir ~/MLXModels     # oMLX
OSU_MODELS_DIR=~/MLXModels             # Osaurus, though this is its default anyway

oMLX will also reuse ~/.lmstudio directly if LM Studio is already our downloader.

Inside that folder we mirror each model’s org/repo path, because that is the layout Osaurus’s and LM Studio’s own downloaders use (OsaurusAI/…, mlx-community/…) and the servers recurse into the organization subdirectories looking for it:

hf download $repo --local-dir ~/MLXModels/$repo

Hand-pulled weights then land somewhere intelligible, and two organizations publishing a model of the same name stop colliding.

Ollama is a whole ’nother thing: it has its own registry, own blob store, own model names — ollama pull qwen3 pulls a new ollama Qwen3, not whichever one exists on HF. ollama run hf.co/<org>/<repo> pulls from HF but then repacks it into Ollama’s store — a copy, naturally. There is no talking it out of this, so budget for the duplicate.

3 Memory management

Weights on disk are cheap and annoying; weights in RAM are the actual constraint, and nearly every setting in the rest of this page is a way of rationing memory. So it is worth establishing the ceiling before we start installing servers that ask us to budget against it. Two questions: how much memory the machine has, and how much of that it will let one process wire down for MLX.

On the first, TIL that macOS’s “Memory Used” indicator does not measure how much RAM is committed in the way I assumed. It counts caching usage in some unproductive way. “Memory” — green / yellow / red in Activity Monitor, or Pages purgeable and Pages compressed from vm_stat — measures available RAM. macOS aggressively fills RAM with discardable file-cache pages. mactop is a handy resource monitor that doesn’t itself use too much memory.

On the second, macOS sets a hard limit on how much RAM Metal — and therefore MLX — is allowed to wire (lock into physically resident, GPU-accessible memory). The default is ~67% on Macs ≤36 GB and ~75% on larger ones. On a 128 GB Mac that means MLX refuses to allocate past ~96 GB, regardless of how much actually-free memory there is. Raise it at runtime:

# Cap MLX at 112 GB — leaves ~16 GB for the OS and other apps
sudo sysctl iogpu.wired_limit_mb=114688

# Confirm
sudo sysctl iogpu.wired_limit_mb

# Reset to default
sudo sysctl iogpu.wired_limit_mb=0

This does not persist across reboots — we would need to wrap it in a LaunchDaemon or /etc/sysctl.conf entry to make it sticky.

Setting it to the full 128 GB is not wise. If MLX wires more than the OS can spare, the machine kernel-panics.

That number is the one to remember, because it is the base that server memory settings are fractions ofvllm-mlx’s --gpu-memory-utilization multiplies the wired limit, not the physical RAM, which is an error I made for months.

The runtimes also manage this themselves, each in its own way: mlx-lm wires the memory occupied by model and cache when a model is large relative to RAM (macOS 15+), and the Swift stack under Osaurus exposes wired-memory policies and tickets that raise the process limit around active inference rather than pinning one fixed number.

But also, before launching a big run:

  • sudo purge flushes the file cache so the OS has clean room to allocate. Available RAM jumps; subsequent file I/O is slower until the cache refills.
  • Quit Electron apps. Slack, Discord, Cursor, VS Code, and Chrome will routinely pin 4–8 GB each.
  • MLX_LM_CACHE_LIMIT=0 (env var) prevents MLX’s internal allocation cache from growing unboundedly during long sessions — useful for sustained embedding or agent workloads.

3.1 What the session costs on top of the weights

The weights are a fixed cost, and the only one a model card tells us about. The longer a session runs, the more memory we need on top of that: every token in the current context lives in the KV cache, which grows as the conversation does. This is why a model that loads fine can still OOM an hour later, and why so many of the server flags below are about capping context or shrinking the cache.

How much it costs depends on the architecture. A classic dense transformer keeps a key and value vector per layer for every token, so the cache scales with context × layers × width × word length apiece — gigabytes for a long context.1 kipply’s inference-arithmetic post has a per-token formula; the apxml VRAM calculator looks it up per model, Apple Silicon included. Grouped-query attention already shrinks this, and sparse-attention or SSM-hybrid models (Nemotron-Cascade, DeepSeek V4) change the whole scaling relation to be sub-linear in length.

When the cache is the part that will not fit, we may be able to quantize it, cap the context, or move to one of the cheaper architectures.2 Which server exposes which of those is tabulated below.

4 Desktop apps

The fastest path from zero to local LLM is a desktop app: one download gives us a model browser, a chat window, and an inference engine. Each is a vertical bundle — a frontend GUI, its own runtime, usually with a server and an agent harness folded in. None of them are wholly satisfactory IMO; they all have pros and cons and are “OK for normie use”.

All three below are general-purpose chat apps. To drive a local model as a coding agent instead — terminal harnesses like OpenCode or Aider, VS Code sidebars like Cline — see Code agents and assistants, which points back here for the local backend they run against. And sometimes I want the server without GUI bells and whistles at all.

4.1 Osaurus

Osaurus (MIT, brew install --cask osaurus, osaurus-ai/osaurus) is Swift-native, no Electron, no Python, and behaves like a proper Mac app.

It seems efficient and easy. It also locks me into the Mac ecosystem, so it might not be for everyone. Also, it’s run by one person, so the bus factor is 1, which is a very small number. But — it’s so good!

The window has a model picker, a chat pane, and a status indicator; the inference engine underneath is Apple’s fast MLX, so it gets many tokens per second. It is also the intended runtime for JANG, the same author’s mixed-precision quantization format, which is most of what its curated model catalogue holds — a fact that mostly matters when a model refuses to load somewhere else.

Osaurus is not just a chat client but a full native macOS agent harness. It supports various hip features like persistent memory and sandboxed working folders in an isolated Linux VM via Apple’s Containerization framework. It understands agentskills.io-format skills (and whole Claude plugins) from GitHub or local files, selecting them by RAG at runtime, and speaks MCP in both directions, as server and client. The harness layer is model-agnostic, fronting cloud providers as happily as the local MLX-ish runtime.

There is no CLI download command; the in-app Model Manager (⌘⇧M → Models) browses a curated catalogue of models, especially JANG ones, and will sideload others too — though not all of them work equally well. Nemotron 3 Nano Omni 30B A3B JANGTQ4 seems like a reliable workaday default. Osaurus also discovers anything dropped into its models directory:

hf download mlx-community/DeepSeek-R1-0528-Qwen3-8B-4bit \
  --local-dir ~/MLXModels/mlx-community/DeepSeek-R1-0528-Qwen3-8B-4bit   # mirror the org/repo path
osaurus list    # confirms discovery, and gives the exact API name for `osaurus run`

Osaurus is also a first-class server, covered below.

4.2 Jan

Jan (brew install --cask jan) is a FOSS cross-platform option — a full frontend + harness + server + runtime bundle. The UI is built on the mildly cursed Electron, but the plus side is that it runs on Linux, Windows, and macOS. It supports both llama.cpp (via Cortex) and MLX backends, so it seems well-suited to brute-force running non-Apple-optimized models.

It looks nice, and the Projects / Assistants / Agents / MCP Connectors quartet gives it a tool-calling agent loop — connect MCP servers under Settings, and Agents mode runs multi-step autonomous workflows (Jan v2 VL is pitched as a 49-step multimodal agent). Jan Server is the self-hosted orchestration variant.

4.2.1 Pointing Jan at a server we already run

Jan’s so-called “local” providers are its own two engines — bundled llama.cpp and a bundled mlx-swift-lm — so our own endpoint is not among them, and picking a local model in Jan means loading a second copy of a model beside whatever vllm-mlx already holds. An external endpoint goes in as a “custom provider” instead, which the docs confusingly file under Cloud Providers, however local it is. Settings → Model Providers → Add Provider, API format OpenAI-compatible, then:

Field Value
Base URL http://localhost:8000/v1 — don’t forget the /v1, or everything 404s
API key any non-empty placeholder (sk-no-key)

Jan calls {base_url}/models on save, so a registry’s model names populate the picker by themselves. Capabilities do not: a custom provider is not probed for tools, vision or audio, so must be set per model by hand.

Jan’s own engine stays installed either way, so it is worth stopping it from hogging RAM if something invokes it by accident. Settings → Llama.cpp → Max Concurrent Models defaults to 2; setting it to 1 holds Jan to one model at a time. Jan’s own downloads land in its own tree by default (~/Library/Application Support/Jan/data/{llamacpp,mlx}/models), which is another copy of everything. Its Import button links a file in place rather than copying it, so that is the way to point Jan at ~/MLXModels instead.

4.3 LM Studio

LM Studio (brew install --cask lm-studio) is closed-source, relatively slick and turnkey, and not free for commercial use. It runs both llama.cpp and lately its own MIT-licensed mlx-engine (mlx-lm + Outlines + mlx-vlm). Like the others, it is a bundle — frontend + runtime + server — with an OpenAI endpoint it can expose headlessly (lms server). I’m mildly sceptical of it because so many Cool LLM Technologies ship special bug fixes or alternate install paths for LM Studio, which hints at a slightly non-standard stack — though that might just be sampling bias, since more people file bug reports when more people run the thing. Also the licence sucks.

5 Serving a model headless

Once we want a model serving as a daemon (“token fountain”, as we say at work) rather than a chat window — a code editor, an embedding pipeline, a script that calls out to a local model — we need a long-lived process with an OpenAI-compatible API. The desktop apps above mostly do this already; below are the headless variants.

These stacks all speak roughly the same wire protocol, so what actually distinguishes them is model lifecycle: how many models stay resident at once, who decides which, and what a switch costs against the memory ceiling we just established. There are two answers to that, and I have now run both. One server holds many models and rations them internally — vllm-mlx and oMLX are the elaborate versions of this. Or one server holds exactly one model, and a router in front starts and stops them — llama-swap.

I ran the first for months and now run the second, for reasons that only make sense after seeing what the first costs. So the vllm-mlx section below is the longest on this page and is also the setup I moved off; it stays because the registry is still the right answer for anyone whose models fit resident simultaneously, and because most of its gotchas are really gotchas about the underlying engine, which the alternatives share.

5.1 Osaurus as a server

If we already have Osaurus running, we are mostly done: it is already that daemon, exposing OpenAI-, Anthropic-, and Ollama-compatible endpoints on localhost:1337 all at once. Anything we want to point at a local model can talk to it. The reasons to install something else anyway are that this is an idiosyncratic single-author stack, and that it exposes far fewer settings than the Python servers below.

To keep two models resident at once (say the agentic daily-driver plus the maths model), set Settings → Local Inference → Model Management to Flexible — under the default Strict policy, loading one evicts the other.

Context length is automatic — Osaurus picks a sane per-model default and does not expose it as a plain setting, so the actual ceiling is hard to read off (the cheat-sheet has the detail).

ImportantDo not send embeddings to Osaurus

Its /v1/embeddings ignores the model field. Asking for mxbai-embed-large-v1, bge-small-en-v1.5 or styledistance all return potion-base-4M at 128 dimensions — no error, no warning, wrong model, wrong width. /v1/chat/completions honours the field correctly, so the failure is confined to embeddings, which is exactly where it does the most damage: nothing downstream of an embedding call can tell that the vectors are wrong. Mine would have gone into the committed related-links shards, which are 1024-dimensional by construction. Embeddings stay in-process on sentence-transformers.

To drive all this from the terminal, there is one gotcha: the osaurus command is embedded in the app bundle, and only the Homebrew install links it onto PATH automatically. If it is missing, we symlink it: ln -sf "/Applications/osaurus.app/Contents/Helpers/osaurus" "$(brew --prefix)/bin/osaurus", or use the button in the settings menu that does the same thing.3 From there the CLI does osaurus serve / stop / status / list / run <model> / mcp, plus a plugin manager.

5.2 Ollama

Ollama (brew install ollama) is a llama.cpp wrapper with its own model registry — fast enough, wide model coverage, and notably good for embedding models:

brew services start ollama
ollama pull qwen3.5  # LLM/chats etc
ollama pull mxbai-embed-large    # also handles embeddings

Anything OpenAI-API-compatible can now point at http://localhost:11434/v1. Ollama handles the lifecycle hands-off: it loads a model on the first request, keeps several resident at once (up to OLLAMA_MAX_LOADED_MODELS, default 3), and unloads each after OLLAMA_KEEP_ALIVE of idleness (default 5m). Left unbounded, the pool tanks the machine — OLLAMA_MAX_LOADED_MODELS=1 forces evict-on-switch, OLLAMA_KEEP_ALIVE=0 drops a model the moment it idles (or 15m to keep it warm longer). The context window is num_ctx: set it per request (options.num_ctx), bake it into a Modelfile (PARAMETER num_ctx), or lean on the OLLAMA_CONTEXT_LENGTH default — which on recent Ollama versions auto-scales to VRAM (4k / 32k / 256k) rather than the old fixed 2048.

Gotchas:

  • The .gguf files come from Ollama’s registry, not Hugging Face.
  • Some weird reimplementation headaches — e.g. the tokenizer baked into the GGUF can differ from the original for unclear reasons.
  • The “llama.cpp wrapper” framing is loosening: the registry now ships -mlx tags for some models (e.g. qwen3.5:35b-mlx).

5.2.1 An embedding model in 4GB

Ollama is cowboy with RAM per default. e.g. I needed qwen3-embedding:0.6b (in principle 640MB of weights) for my hister search index, but it consumed 4GB of RAM. The autoscaled context is at fault here. e.g. on a large enough machine, Ollama gives llama.cpp a large KV cache by default (32768 tokens on a 128GB machine), which is counterproductive for embedding models, as they perform no autoregressive decode and only ever process short text fragments. The client’s longest request was 2963 tokens, with 99.6% under 1024, so I’m mostly wasting RAM for nothing.

Fixes follow:

# The OpenAI /v1/embeddings dialect has no num_ctx field, so bake it into the
# model. Overwrite the SAME tag: a client asking for the original name once a
# minute evicts a differently-named derived model on every single request.
printf 'FROM qwen3-embedding:0.6b\nPARAMETER num_ctx 4096\n' > /tmp/qwen3emb.Modelfile
ollama create qwen3-embedding:0.6b -f /tmp/qwen3emb.Modelfile   # -f wants a path, not stdin

# Revert with `ollama pull qwen3-embedding:0.6b`. Weights are content-addressed,
# so neither direction re-downloads anything.

# Confirm: CONTEXT 4096, SIZE ~2.2GB, once something reloads it.
ollama stop qwen3-embedding:0.6b
ollama ps

That shrinks it back down to 2.2GB. num_ctx is a hard ceiling now rather than a generous default, so we want it to stay above the longest text we ever send; 4096 leaves room over the 2963 above.

2.2GB is still well over the 640MB of weights, because the KV cache is not the only pool. There is a second working buffer, sized by Ollama’s batch settings rather than by the context, and PARAMETER num_batch is the knob for it. I have left that one alone: the remaining win is smaller, and batch size trades against how long a single input can be.

The rest of Ollama’s settings live in the environment rather than in the model, which under Homebrew is awkward. The formula rewrites ~/Library/LaunchAgents/homebrew.mxcl.ollama.plist from its own template on every brew services restart or brew upgrade ollama, discarding whatever we added4. So we edit the plist and reload it by hand, and redo it after upgrades:

P=~/Library/LaunchAgents/homebrew.mxcl.ollama.plist

# One model resident at a time.
/usr/libexec/PlistBuddy -c "Add :EnvironmentVariables:OLLAMA_MAX_LOADED_MODELS string 1" "$P"
# Ollama multiplies the context by this before handing it to llama.cpp, so
# letting it autoscale would undo the num_ctx fix above.
/usr/libexec/PlistBuddy -c "Add :EnvironmentVariables:OLLAMA_NUM_PARALLEL string 1" "$P"

# No sudo — gui/501 is our own login session. Two statements, not `&&`: chained,
# the reload arrives before the unload finishes, fails, and leaves Ollama down.
launchctl bootout gui/501/homebrew.mxcl.ollama
launchctl bootstrap gui/501 "$P"

launchctl print gui/501/homebrew.mxcl.ollama | grep -E 'state =|OLLAMA_'

Killing llama-server to reclaim its memory achieves nothing, incidentally. It is a child of ollama serve, which starts a fresh one within seconds of the next request; brew services stop ollama is the one that sticks.

5.2.2 Trimming the log

Ollama logs several lines per request, nothing rotates /opt/homebrew/var/log/ollama.log, and there is no setting to turn it down5. Under steady indexing, it grew ~20MB/day here, reaching 387MB before I noticed.

Truncate it rather than deleting or renaming it: launchd holds the file open, so replacing it leaves Ollama writing somewhere we can no longer see, and it frees no space until Ollama restarts.

: > /opt/homebrew/var/log/ollama.log

~/.local/bin/ollama-log-trim.sh does that above a size cap, keeping a tail so that a post-mortem straight after a trim isn’t empty-handed:

LOG="${OLLAMA_LOG:-/opt/homebrew/var/log/ollama.log}"
MAX_BYTES="${OLLAMA_LOG_MAX_BYTES:-104857600}"   # 100 MiB
KEEP_TAIL_LINES="${OLLAMA_LOG_KEEP_TAIL_LINES:-2000}"

[ -f "$LOG" ] || exit 0
size=$(stat -f%z "$LOG" 2>/dev/null || echo 0)
[ "$size" -gt "$MAX_BYTES" ] || exit 0

# Staged in the log's own directory to stay on one filesystem.
tmp=$(mktemp "$(dirname "$LOG")/.ollama-log-trim.XXXXXX") || exit 0
trap 'rm -f "$tmp"' EXIT

tail -n "$KEEP_TAIL_LINES" "$LOG" > "$tmp" 2>/dev/null || : > "$tmp"
: > "$LOG"
cat "$tmp" >> "$LOG"

An hourly StartInterval agent runs it at ~/Library/LaunchAgents/name.danmackinlay.ollama-log-trim.plist, loaded with launchctl bootstrap gui/501 <plist>. Hourly is more often than a daily-ish problem needs, but a run that finds the file under the cap exits without doing anything, so the frequency is free and the ceiling still holds if something starts logging harder than usual. It’s worth forcing the trim path once to see it work, rather than waiting days for the cap:

OLLAMA_LOG_MAX_BYTES=1048576 sh ~/.local/bin/ollama-log-trim.sh

5.3 mlx-lm and mlx-vlm

mlx-lm is Apple’s reference language-model runtime on MLX. mlx-vlm is its sibling package: same MLX backend, same mlx-community/<repo> weights and HF cache, but for VLMs (“vision-language models”) and omni models—image/video/audio in, text out—instead of pure text LLMs. Where this page says “VLM,” we mean a model in that family; mlx-vlm is what runs one locally, e.g., DeepSeek-OCR. Osaurus and JANG take inspiration from MLX-style Apple-Silicon-friendly execution, but mlx-lm is the original.

uv tool install mlx-lm drops a family of commands onto PATH, all reading the same weights:

  • mlx_lm.generate --model mlx-community/<repo> — one-shot completion from the CLI.
  • mlx_lm.chat — an interactive REPL in the terminal.
  • mlx_lm.server --model mlx-community/<repo> — an OpenAI-compatible daemon; it holds one model, swapping on demand per request (evict + reload, not a restart). Two live at once means two processes on two ports.
  • mlx_lm.lora — its LoRA fine-tuning path.

mlx-vlm mirrors this shape (mlx_vlm.generate, mlx_vlm.chat, mlx_vlm.server) but takes an image/video/audio argument alongside the text prompt.

It uses the standard Hugging Face links: mlx-community/<repo> resolves straight to Hugging Face, and the weights land in the shared HF cache (~/.cache/huggingface/hub).

mlx_lm.server has no --ctx flag, so it grows the KV cache to fit whatever we send—up to, presumably, the model’s declared max, capped only by RAM, at which point it presumably kernel-panics the machine. We cap the context in the harness (limit.context / contextWindow) and mind the memory budget.

A reason to keep this around even with Osaurus installed is that it’s great when it runs, but its Swift engine’s coverage lags the Python MLX options. A plain mlx-lm loads interesting MLX conversions that Osaurus can’t (e.g., Cascade-2).

It has also quietly grown the features I left it for. --decode-concurrency and --prompt-concurrency are continuous batching, --prompt-cache-bytes is a prefix cache, --draft-model is speculative decoding, and the sampling defaults are per-launch flags—--temp, --top-p, --top-k, --min-p—which matters because top-k is one of the knobs vllm-mlx accepts and drops. Measured on VibeThinker-3B-8bit: 58 tok/s at \(k=1\), 146 tok/s aggregate at \(k=4\). It also splits thinking into a reasoning field with no parser flag at all, spelled differently from vllm-mlx’s reasoning_content.

Warningmlx_lm.server is deterministic, which silently breaks maj@k

maj@\(k\) is majority voting over \(k\) independent samples of the same question, which is how a weak solver is made strong, and it needs the \(k\) samples to differ. Same prompt and same parameters give byte-identical output here, sequential or concurrent, so \(k\) samples are \(k\) copies of one answer and the vote is meaningless. Per-request seed does not rescue it — mx.random.seed() is process-global, a batch shares it, and a seeded request is excluded from batching anyway. What works is varying a system preamble per sample (Independent attempt {i}.), which restores diversity at no cost in wall-clock because the batch survives. Worth knowing generally: sampling diversity is a property of the server, not of the temperature we asked for, and it differed between every server I tested.

5.4 vllm-mlx

vllm-mlx (uv tool install vllm-mlx, Apache 2.0) is a vLLM-style inference server for Apple Silicon. It is active and popular by this page’s standards — 1,300+ stars. The core pitch: continuous batching, paged KV cache with prefix sharing, an SSD-tiered cache for spilling prefixes to disk, and both OpenAI (/v1/*) and Anthropic (/v1/messages) endpoints from one process.

This is the longest section here because I ran it hardest and therefore know its failure modes; it is also the one I eventually swapped out for llama-swap, for reasons downstream of the multi-model registry rather than the engine. The engine-level gotchas are worth reading even if we never touch the registry, since oMLX and Rapid-MLX are both forks and inherit most of them.

It has many fancy bonus features:

  • native TTS (Kokoro, Chatterbox, VibeVoice, VoxCPM) and STT alongside text/image/video/audio chat
  • multi-token prediction (--enable-mtp)
  • an embedding and reranker endpoint in the same process (--embedding-model, --rerank-model)
  • MCP tool integration (--mcp-config)
  • Prometheus metrics (--enable-metrics) and a built-in benchmarker (vllm-mlx bench-serve)

MTP here is the self-drafting kind: --enable-mtp drives the model’s own MTP head via cache snapshot and restore rather than loading a separate draft model, so the head must live inside the model directory. The acceptance test makes it a poor fit for the fan-out workload I run. The draft token is always sampled greedily regardless of the temperature we request, and it is accepted when the full model’s greedy next token agrees with it — so every accepted position emits a greedy token into a stream we asked to be sampled, eating exactly the diversity maj@\(k\) needs. The comparison is also all-or-nothing across the batch, one list against another, so a single request’s mismatch throws away the drafts of every request sharing the step, and the acceptance rate falls as we widen the fan-out. --mtp-optimistic skips the check entirely and accepts every draft, which trades verification for speed rather than fixing either problem.

Single-model serving is a one-liner:

uv tool install vllm-mlx
vllm-mlx serve mlx-community/Llama-3.2-3B-Instruct-4bit --port 8000 --continuous-batching

For a chat window over this endpoint — one that renders the equations the maths models emit — Open WebUI points at it unmodified: add http://localhost:8000/v1 as an OpenAI connection and skip the bundled-Ollama path the tutorials assume.

5.4.1 Serving from ~/MLXModels

vllm-mlx lacks a folder-scanning flag. A bare local path loads one model the same way a bare mlx-community/<repo> id does:

vllm-mlx serve ~/MLXModels/mlx-community/Qwen3.6-35B-A3B-4bit --port 8000 --continuous-batching

Multi-model residency happens via a --models-config models.yaml registry: named entries, each with an explicit path:, all behind one process. Models load lazily on first use and undergo LRU eviction under a memory_budget_gb, with a contention_policy (fail / wait / preempt / wait_then_fail / wait_then_preempt) deciding what happens when a request needs a model that does not currently fit. Clients pick one with the ordinary OpenAI model field, so nothing downstream has to know this is happening.

manager:
  # WEIGHTS only — the KV cache is a separate pool, sized on the serve command.
  # 68 against the 98.6 GB ceiling below, plus --cache-memory-mb *per resident
  # multimodal engine* on top, which nothing here counts. See the note after.
  memory_budget_gb: 68
  contention_policy:
    strategy: wait_then_preempt
    wait_timeout_s: 45
    preempt_after_s: 15

models:
  - name: driver
    path: /Users/dan/MLXModels/mlx-community/Qwen3.6-35B-A3B-4bit
    continuous_batching: true
    estimated_memory_gb: 22

  - name: solver
    path: /Users/dan/MLXModels/mlx-community/VibeThinker-3B-8bit
    preload: true
    estimated_memory_gb: 3

We can run the server like this:

vllm-mlx serve --models-config ~/.config/vllm-mlx/models.yaml \
  --port 8000 \
  --gpu-memory-utilization 0.88 \      # ceiling = 0.88 × the wired limit, ≈98.6 GB — not 112
  --continuous-batching \
  --use-paged-cache \
  --cache-memory-mb 30720 \
  --max-cache-blocks 16384 \
  --max-num-seqs 16 \
  --max-tokens 131072 \
  --max-request-tokens 131072 \
  --enable-auto-tool-choice \
  --tool-call-parser auto \
  --reasoning-parser qwen3 \
  --enable-metrics \
  --kv-cache-quantization \
  --kv-cache-quantization-bits 8 \
  --ssd-cache-dir ~/.cache/vllm-mlx/kv \
  --ssd-cache-max-gb 40 \
  --timeout 1200

This is a chunky-boi config, allocating a hundred gigs of memory for large context windows and setting long timeouts so I can flood the server without too much guilt.

I had the ceiling arithmetic wrong here for months, and 0.4.1’s startup report caught it. --gpu-memory-utilization is a fraction of MLX’s device working set, which is the iogpu wired limit — 112 GB on this machine, because I set it there — not the 128 GB of physical RAM. So 0.88 buys a 98.6 GB ceiling, and my old comment reading “≈112 GB (0.88 × 128)” arrived at the right-looking number by cancelling one error against another. The startup line to read is this one, which prints the base and its source:

INFO  Registry memory budget: 68.0 GB of model weights; Metal allocation ceiling
      98.6 GB (88% of 112.0 GB, from serve default); prefix-cache maximum none
      configured

Two things to know before reading that line against the engine’s own.

The prefix-cache maximum none configured is wrong, or rather it is right about half the registry and silent about the other half. There are two prefix-cache implementations, and which one a model gets follows from the engine that loaded it rather than from anything we chose. Text models take the paged cache, sized in 64-token blocks by --max-cache-blocks, and ignore --cache-memory-mb. Multimodal models take the memory-aware cache, which is sized by --cache-memory-mb and never consults --use-paged-cache at all — so the 30 GB above is real, and it is 30 GB per resident multimodal engine. The startup check applies the text-path rule to the whole registry, so it reports none of that. On a registry like mine, where most entries load multimodal, three resident models can put 90 GB of prefix cache against a 98.6 GB ceiling with the budget line still reading zero — the same accounting hole as #627, pointing the other way, and filed as #712. The per-model fix is not available yet either: a registry entry takes gpu_memory_utilization, enable_mtp and prefill_step_size, but no cache size, and each model’s scheduler config is a straight clone of the global one.

And the engine logs the same ceiling in different units a few lines later — allocation_limit=105.8GB (88% of 120.3GB) is 98.6 GiB of 112 GiB, not a second, larger allowance.

Notes

  • path: needs to be a real filesystem path — YAML does not ~-expand.
  • estimated_memory_gb is mandatory on a bare HF id — the manager needs some number to make eviction decisions from — but optional on a local path with real weight files on disk, since those can be measured directly.
  • --max-num-seqs: concurrency cap (default 256 would explode KV)
  • --max-cache-blocks 16384: 30 GB KV pool (was 80 — hence the OOM risk)
  • --kv-cache-quantization-bits 8/--kv-cache-quantization: downsample KV cache for longer prompts without exploding RAM
  • --tool-call-parser auto: models emit tool calls in different dialects, so we try each one per response, which is what a registry mixing Nemotron and Qwen needs. 0.4.1 teaches the Mistral branch the [ARGS] form that Devstral Small 2 emits
  • --reasoning-parser qwen3: unlike the tool parser this is a single choice with no auto, which looks alarming for a registry of mixed families and turns out mostly not to be — the <think></think> convention is common enough that qwen3 splits the Nemotrons correctly too. 0.4.1 adds poolside_v1 for Laguna. (A Poolside tool parser ships alongside it but is wired to nothing — not a --tool-call-parser choice, not in the auto chain.)
  • What does bite is truncation. The split happens on the closing tag, so a response that hits max_tokens mid-thought comes back with reasoning_content: null and the raw chain-of-thought sitting in content, which reads exactly like a parser that does not understand the model. Raise the budget before blaming the parser.
  • --ssd-cache-dir/--ssd-cache-max-gb: the cold tier for prefix caching, off by default. I had filed this mentally as oMLX-only, but it isn’t — same two-tier idea, so a second turn on a long agentic context re-prefills from disk instead of from scratch. Put the directory on the exclusion list; it churns.
  • --warm-prompts <file.json>: pre-runs a list of message arrays at startup to populate the prefix cache. The docs claim cold time-to-first-token drops 1.3–2.3× on agent workloads, which is to say it front-loads the harness’s system prompt and tool definitions. Keep the file to 1–3 entries or the boot itself gets memory-hungry.

Clients pick a model with the normal OpenAI model field (model: "driver", model: "solver"), the same pattern as oMLX’s pinned pair.

One multimodal gotcha: a VLM or omni model needs a mllm: true on its registry entry to load (the standalone single-model serve equivalent is the global --mllm flag). vllm-mlx guesses multimodality from the repo name — VL, vision, llava and friends — but -Omni- slips through, so a model it reads as text-only dies at weight-load because it has no slots for the vision and audio towers (Received N parameters not in model). mllm: true routes that one entry through mlx-vlm instead, without forcing the text models in the same registry down the same path.

Dying is the lucky case. mlx-lm’s qwen3_5_moe loader carries a sanitize() that skips any key starting with vision_tower or model.visual, so a vision-capable Qwen build routed down the text path loads perfectly and arrives with its vision tower on the floor — 333 tensors dropped, no warning, Qwen3.6-35B-A3B-4bit serving text as though that were all it ever was. The omni checkpoint fails loudly only because mlx-lm has no implementation of its architecture at all, so nothing is there to strip its 1,118 sound_encoder.* tensors. The lesson I take is to check what the server loaded rather than what the repo card advertises. The weights also have to bring a config the mlx-vlm loader recognizes: the mlx-community that Nemotron-3 Nano Omni builds load, but an Osaurus repackaging of the same model that hides the multimodal config in a side-file does not.

WarningThe weights budget does not know about the ceiling

manager.memory_budget_gb counts model weights only, lives in the YAML with no command-line override, and its admission arithmetic never consults the ceiling we set on the command line (--gpu-memory-utilization, --cache-memory-mb). Set it too high and the manager keeps two models resident because its own arithmetic says they fit; MLX then hits the process ceiling and dies — a hard out-of-memory crash instead of a graceful eviction. Keep memory_budget_gb ≤ gpu-memory-utilization × RAM − cache-memory-mb − headroom. Since 0.4.1 the server at least tells us at startup, printing the weights budget against the Metal ceiling and warning when the two conflict — that much of my bug report landed. It warns rather than clamps, and the KV cache, prefix cache and activations all come out of the same ceiling, so clearing the check is not a promise that we will not run out of memory.

One config file per memory profile is the workaround I use — a “full-fat” registry and a lean everyday one, with the model paths duplicated between them, because vllm-mlx cannot include or merge one config into another. --auto-unload-idle-seconds plus --lazy-load-model looks like it might collapse the pair back into one file, since an idle big model releasing its weights is most of what the lean profile buys; I have not tried it.

Gotchas:

Mind which mlx-vlm came along for the ride. vllm-mlx pulls it in as a dependency, and for most of 2026 I held it at 0.6.3, because everything after that ran weight sanitization a second time over an already-converted checkpoint and wrecked the models I care about. That pin is now both unnecessary and impossible: 0.6.9 fixed the last architecture it was protecting, and vllm-mlx 0.4.1 declares mlx-vlm>=0.6.5, so the old install line no longer resolves.

uv tool install --force vllm-mlx

I have not re-run my own omni checkpoints against it, so treat that as the pin being lifted rather than vindicated.

The harness’s model list is hand-maintained. Registry mode serves each model under its name:, and a client that keeps its own list of model names — Goose custom providers do, and do not read /v1/models — will 404 with The model X does not exist. Available models: … the moment the two drift apart. Renaming an entry in models.yaml means renaming it in harness configs too.

5.5 oMLX

oMLX (jundot/omlx, Apache 2.0, brew tap jundot/omlx https://github.com/jundot/omlx && brew install omlx) is a fork of vllm-mlx with a different frontend grown on top, so it inherits that feature set — continuous batching, multi-model residency, both OpenAI and Anthropic endpoints. Three things set it apart:

  • Its SSD prefix cache is automatic and block-addressed — hot blocks in RAM, cold blocks spilled to disk, longest-prefix matched and surviving a restart — where llama-server’s --slot-save-path and ds4’s --kv-disk-dir are manual slot-save knobs. We aim this at agentic coding, where the pitch is TTFT dropping from 30–90s to under 5s on the second turn of a long context.
  • An explicit Claude Code accommodation: it rescales reported token counts so auto-compact fires at the right time, and holds the connection open with SSE keep-alives through a long prefill. The frontend is a signed SwiftUI menu-bar app (not Electron) with a web admin panel, and it reuses an existing LM Studio model directory.
  • It is the one non-Osaurus server with merged JANG support, so it can load the mixed-precision JANG quants that make sub-4-bit MoE models behave — otherwise Osaurus-only.
omlx serve --model-dir ~/MLXModels \
  --paged-ssd-cache-dir ~/.omlx/cache \
  --hot-cache-max-size 16GB \
  --max-concurrent-requests 8

The same caveats as the rest of the page apply (bus factor 1, MLX-only, benchmarks from an M3 Ultra 512GB), but the clean lineage and the oQ converter make it worth a run as our headless daily-driver if the Osaurus/mlx_lm.server pair leaves us wanting persistent prefix reuse. The SSD cache is less of a differentiator than I first thought: vllm-mlx has the same cold tier behind --ssd-cache-dir, just switched off by default rather than on.

5.5.1 Example multi-model setup

The mathematical fan-out setup wants both models live at once on one endpoint — a solver sampled wide for maj@k, orchestrated by an agentic driver. oMLX does this in a single process; the config is a model directory, a memory ceiling, and a pin per model.

Drop the weights into the shared MLX dir — ~/MLXModels, the same tree Osaurus scans, so one download serves both — mirroring each repo’s org/name path the way the GUI downloaders do:

hf download gabfssilva/VibeThinker-3B-MLX-BF16  --local-dir ~/MLXModels/gabfssilva/VibeThinker-3B-MLX-BF16  # solver, hi-fi bf16
hf download mlx-community/VibeThinker-3B-8bit   --local-dir ~/MLXModels/mlx-community/VibeThinker-3B-8bit   # solver, near-lossless and faster
hf download mlx-community/Qwen3.6-35B-A3B-4bit  --local-dir ~/MLXModels/mlx-community/Qwen3.6-35B-A3B-4bit  # the driver

Launch with a memory guard sized to hold the driver, the solver, and the fan-out’s KV all at once, and raise the concurrency to the \(k\) samples we intend to run:

omlx serve --model-dir ~/MLXModels \
  --memory-guard-gb 110 \          # 128 GB Mac: driver (~22 GB) + solver (~3 GB) + KV headroom
  --max-concurrent-requests 16     # the fan-out width; default is 8

In the admin panel, pin both the driver and the solver so LRU does not evict one while the other is mid-job, and set their sampling as per-model profiles — vibethinker:solve at temp 1.0 / top-p 0.95 / top-k 0, the driver at its own recipe (Qwen3.6 thinking temp 0.6 for coding). The fan-out then POSTs model=vibethinker:solve \(k\) times while the loop drives model=qwen3.6 — same port 8000, both resident, no reload between them.

Both VibeThinker builds load and serve fine. The 8-bit decodes ~80% faster than bf16, but the bf16 might be worth it for tiebreaker votes.

5.6 Rapid-MLX

Rapid-MLX (Apache 2.0, brew install rapid-mlx) seems like a variant of interest. The pitch is a polished single-model MLX server — continuous batching, a radix prefix cache, KV quantization down to int4, 17 tool-call parsers, both OpenAI and Anthropic wires, and agent clients wire-verified against real weights every release.

It is a fork of vllm-mlx, (upon installation we find a vllm_mlx module) and what it forked away is the multi-model registry — its model_registry.py is 185 lines (against vllm-mlx’s 1195), because it does not even try to manage multiple models and their memory contention. OTOH it does not seem that vllm-mlx is actually doing a great job when it does try anyway (cf #627 and #712). So maybe it is an alternative with some bugs amputated.

Usefully --reasoning-parser takes vibethinker by name! Also using the flags --kv-disk-checkpoint-interval plus its pflash flags it gets something close to the oMLX’s persistent cache. It is in homebrew-core rather than a personal tap, which is a maturity signal the rest of this page mostly cannot claim. Untested here beyond installing it.

5.7 llama.cpp / llama-server

llama.cpp ships its own server (brew install llama.cpp): llama-server -m model.gguf exposes an OpenAI-compatible endpoint with no daemon, no registry, no opinions.

Ollama wraps this same engine. However, llama-server is more configurable, exposing llama.cpp flags that Ollama does not, notably: YaRN context extension on a GGUF (which Ollama cannot do at all), the finer KV-cache quant ladder, speculative decoding with a draft model, and per-slot KV save/restore to disk.

It loads a .gguf from disk — no ollama pull into a separate store, no background service — which suits scripted or reproducible runs.

It loads the one model named at launch, though a newer router mode (start it with no -m) does dynamic multi-model load and unload. Context is the -c / --ctx-size flag, defaulting to 0 (the model’s full trained window). Other useful config options can be found in the cheat-sheet.

5.8 One process per model, with llama-swap in front

That is every server I have run. The thing they leave us with is a pile of configs and no single source of truth: nine models, three servers, and a different place to write down each model’s temperature.

Worse, every server above that holds several models makes each setting global. One process owning nine models means one temperature for nine sampling recipes, one reasoning parser, one cache size, and a memory budget that then needs an eviction policy and a contention strategy to go with it. Both bugs I filed against vllm-mlx are that shape — a process reasoning about an aggregate nobody can measure. I ran it that way for months before working out that it was the source of most of my grief.

The alternative is to invert it. llama-swap (Go, one binary, brew install) reads the model field of an incoming request, starts the right server if it is not up, proxies, and stops it on a TTL. It contains no inference code, so it is a much smaller thing to trust. Underneath, each model is its own process — mlx_lm.server here, but a cmd: is an arbitrary command line, so vllm-mlx serve, omlx serve and ./ds4-server can all live behind the one port too.

models:
  "vibethinker-8bit":
    cmd: |
      mlx_lm.server --port ${PORT}
      --model ~/MLXModels/mlx-community/VibeThinker-3B-8bit
      --temp 1.0 --top-p 0.95 --top-k 0 --decode-concurrency 16
    ttl: 900

What this buys, in rough order of how much it improved my life:

  • Per-model settings stop being compromises. The card’s recipe goes on that model’s launch line. There is no server-wide temperature to be wrong about half the registry.
  • Memory becomes measurable. One process per model means footprint -p <pid> reports that model’s Metal allocation under IOAccelerator (graphics) — 4.7 GB of weights on disk shows up as 5.2 GB RSS and 5510 MB of footprint. Unified memory means MLX buffers are ordinary physical pages, so nothing is hidden. The old setup needed a hand-maintained estimated_memory_gb per entry precisely because nine models in one address space made per-model memory unmeasurable.
  • Swapping is cheap enough to ignore. Cold starts on my machine: 2.4 s for a 3 GB model, 7.7 s for a 20 GB one.
  • Co-residency is five lines. A groups block keeps the small utilities loaded together and lets the heavyweights take turns — enough to hold a driver and a solver up at once. No budget, no LRU tuning.

Name the models after the weights, not the job — half the time which build answered is the question. Roles then layer on top, and the useful one is a warm selector: driver resolves to whichever driver is already loaded and only cold-starts when none are, where a plain alias would force a 20 GB swap because the other driver happened to be up. setParamsByID gives one resident process two names and two recipes (qwen3.6-35b at temp 0.6 for coding, qwen3.6-35b:thinking at 1.0), and profiles repoint a default at runtime via PUT /api/profiles/active with {"name": "..."} — not profile, not id, both of which fail claiming the name is required. The same setParams / stripParams filters rewrite requests on the way through, which is where we drop the parameters vllm-mlx accepts and ignores and rename Open WebUI’s repeat_penalty.

A peers block folds another whole server in behind the same address — Osaurus on :1337 becomes osaurus/<model> on the same port, which is worth it for the JANG builds nothing else loads. It is federation, not scheduling: llama-swap forwards the request and nothing else, so it cannot start the peer, cannot stop it, and does not count its memory against the groups. Only list models the peer alone can serve — a peer entry duplicating a model llama-swap already owns is a second path to a second resident copy that neither side accounts for. And only list chat models, per the warning above.

What I gave up is the SSD prefix-cache tier: mlx_lm.server caches prefixes in memory only, so a long agentic second turn re-prefills from scratch. A swap presumably discards the in-memory prefix cache outright, which makes that loss worse rather than better. It is a real cost and the reason I keep watching Rapid-MLX, whose --kv-disk-checkpoint-interval is the nearest replacement. One further macOS wrinkle: llama-swap’s docs suggest running Python servers under Docker for clean SIGTERM handling, which is not available to us, since containers on macOS cannot use the Metal backend.

5.8.1 The gateway that is not the answer

LiteLLM (litellm --config config.yaml) is the other name that comes up here, and it solves the adjacent problem rather than this one. A model_list maps a public model_name onto a backend URL, given as an api_base plus a litellm_params block, so we get one facade over localhost:1337, localhost:8000, localhost:11434 and whichever cloud providers we like. That block can also pin per-model sampling settings such as temperature, which is handy given how few clients and servers let us write those down anywhere. It adds fallback, retries, virtual keys with budgets, and spend logging on top.

What it does not do is lifecycle. It routes to whatever server is already up; it will not start ds4-server, and it will not evict Qwen3.6 to make room for Laguna. On a laptop, residency is the problem, so LiteLLM is the wrong shape — and OpenRouter already covers the multi-cloud facade case for me.

6 Sampling, caching and context

Whichever server we pick, the same five questions come up, and none of them are answered by the defaults: what sampling settings the model wants, which of those the server will actually apply, what it reuses between requests, whether it can emit more than one token per forward pass, and how far the context can be stretched. These cut across the whole list above, so they get their own section rather than being repeated per server; the cheat-sheet at the end is the per-server answers in one table.

6.1 Sampling defaults

Sampling — temperature, top-p, top-k, and the output-token budget — is a decode-time choice set on each request. A server does not, in general, pick up the model’s recommended sampling settings for us.

mlx_lm.server, for example, defaults to temperature=0.0 (greedy), top_p=1.0, top_k=0, and max_tokens=512, and it does not read the model’s generation_config.json. So a reasoning model’s advised settings — VibeThinker advises temperature 1.0 / top-p 0.95 and a 64K-plus output budget — need the client to specify them; each model table below carries a Recommended sampling column with the per-model picks. Notably temperature=0.0 breaks maj@k voting, since every sample comes back identical.

6.2 Which sampling parameters actually work

vllm-mlx 0.4.1 accepts parameters that it ignores. On the text path the sampler is built as make_sampler(temp, top_p, min_p), and logits processors are constructed only from repetition_penalty. So:

knob accepted reaches the sampler
temperature, top_p, min_p yes yes
repetition_penalty yes yes (text and multimodal paths both)
top_k yes multimodal only — the text scheduler drops it
presence_penalty yes no

The dead ones are logged on the way in and dropped on the way through. So any top-k 20 in a model card’s recipe — and most of the recipes in the tables below say exactly that — does nothing for a text model, while the same request against a VLM or omni build is honoured, which is a difference to remember before concluding a knob works. Two more traps in the same family:

  • mlx-lm’s repetition_context_size defaults to 20 tokens and vllm-mlx never overrides it, so the penalty sees a 20-token window. It will break a short stutter but will not touch a repeated paragraph.
  • vllm-mlx reads a model’s generation_config.json for stop tokens only, so a card’s recommended temperature never reaches the server on its own.

When the registry has no per-model sampling — vllm-mlx’s does not — a server-wide default is the only lever, and that makes it a compromise rather than a setting. Mine splits: 0.6 for the agentic models, 1.0 for Cascade-2 and VibeThinker, so any single temperature is wrong for half of them, and the 0.7 fallback is a defensible midpoint. min_p and repetition_penalty are the two that are safe to set globally, because they truncate the degenerate tail without pinning a preferred temperature:

--default-min-p 0.02 --default-repetition-penalty 1.05

Where to pin values depends on which layer issues the prompt.

  • mlx_lm.generate — flags per call: --temp 1.0 --top-p 0.95 --max-tokens 40000 (--top-k already defaults to 0). Wrap it in an alias.
  • mlx_lm.server — launch-time defaults via --temp / --top-p / --top-k / --min-p, overridden per request; Osaurus keeps the same defaults in its app settings, also overridden per request.
  • transformers — a GenerationConfig passed at generate() time.
  • Ollama — an exception, baking in a per-model default through a Modelfile: PARAMETER temperature 1.0, PARAMETER top_p 0.95, PARAMETER num_predict 40000 for the output budget, PARAMETER num_ctx 65536 for the context window.

Output budget and context window are separate limits: the first caps how much the model may emit, the second how much prompt-plus-output the KV cache holds. A long-reasoning model can need both raised, or it truncates mid-derivation — and Ollama in particular drops the overflow silently once num_ctx is exceeded.

6.3 What the client is able to send

The server-side story above assumes we can send a value with a prompt, and which ones we can vary with the client — Goose sends temperature and nothing else, OpenCode sends arbitrary keys past one badly-named flag, Open WebUI pins a full set per model preset, Jan does too but only on a custom provider. That table lives with the frontends, since it is the same table whatever the server.

What makes it bite here is that vllm-mlx’s registry has no per-model sampling of its own, so there is no server-side fallback: whatever the client cannot send is a setting we simply do not have. That is why Open WebUI’s presets matter disproportionately on this stack — a preset per model is the only place the per-model temperatures in the tables below can actually be written down.

Gotcha: Open WebUI’s Advanced Params panel names the penalty repeat_penalty — Ollama’s spelling — while vllm-mlx reads repetition_penalty, so it is dropped in transit and the model keeps looping. The panel suffixes eight Ollama-only fields with (Ollama)num_ctx, keep_alive, think and friends — but repeat_penalty is not among them, so it looks portable and is not. The fix is the Add Custom Parameter button at the bottom of the panel: add repetition_penalty by hand and skip the built-in row.

6.4 Prefix caching

The KV cache is the only state these servers keep between requests — there is no session object; we resend the whole history each call. What they reuse is the prefix: the shared start of the conversation (system prompt, tool definitions, history so far) keeps its KV, so an agentic loop prefills only the new suffix. Matching is content-addressed — on the tokens, not a session ID — so it happens by itself, and there is nothing to switch on.

The consequence worth planning around is that the match is on a prefix, so anything that edits the front of the history throws the whole thing away. A harness that trims old turns to fit the context window, or rewrites the system prompt between calls, re-prefills from scratch every time and we pay for it in time-to-first-token without any error to tell us why. Appending is free; editing is not.

Different servers hold this cache for different lengths of time:

  • mlx_lm.server — an in-memory LRU, longest-prefix matched, reporting cached_tokens in the usage block; bounded by --prompt-cache-size / --prompt-cache-bytes, held for the process.
  • llama-server--cache-prompt is on by default (one KV per slot; --cache-reuse even salvages chunks after a mid-prompt edit), and slots can be saved to disk.
  • Ollama — the same llama.cpp reuse, alive as long as the model stays loaded (OLLAMA_KEEP_ALIVE).
  • Osaurus — automatic; headless under osaurus serve the cache lives for the server process (governed by the Strict/Flexible policy), and in the GUI it is per chat window, warmed the moment one opens.

Prefill is chunked and continuously batched besides, so concurrent requests interleave rather than queue — but the prefix skip is the bigger win.

6.5 Speculative decoding and MTP

Emitting one token at a time is bound by memory bandwidth rather than arithmetic: each token drags the active parameters across from RAM, and the compute units spend most of that time waiting for them. Speculative decoding spends that idle arithmetic. Something cheap drafts the next few tokens, the full model scores the whole draft in a single forward pass — the pass that would otherwise have produced one token — and every drafted token it agrees with is kept. A miss costs the pass we were going to pay for anyway, so the arrangement is roughly free when it fails and worth several tokens per pass when it works. Done properly the output distribution is unchanged and only the speed differs, which is the entire appeal, and which is also the part the vllm-mlx implementation gives up.

The draft has to come from somewhere, and there are two answers.

Draft model
A second, much smaller model does the drafting — say a 0.5B proposing for a 30B — sharing a tokenizer with the big one. This is what --draft-model means in mlx_lm.server and llama-server. The cost is a second set of weights resident in RAM, plus finding a small model that pairs with the large one.
MTP head, multi-token prediction
The model drafts for itself. A checkpoint trained with multi-token prediction (Gloeckle et al. 2024) — Qwen3.6 and Qwen3.8 here, the DeepSeek lineage before them — carries a small extra head that predicts a position or two past the ordinary next-token head. Nothing extra loads, because the head ships in the model directory with the rest of the weights. This is what --enable-mtp means in vllm-mlx.

The verification step is the same either way, so a server’s rule for what happens when the draft and the full model disagree applies to both.

6.6 Stretching the context window

Deep lore for the optimizers.

A model’s positional encoding is trained out to some fixed length, and that trained length is baked into its knowledge of the context window. Some of the huge context numbers advertised are that same window stretched at load time, rather than a property of the weights. Qwen3.6, for example, trains its rotary positions to 256K (262,144 tokens); the 1M figure quoted for it is that same window extended ~4×, and getting the extension costs us something in both quality and RAM.

The mechanism is RoPE interpolation. RoPE encodes each token’s position as a rotation; interpolation rescales those rotations so a position past the trained window maps back into the range the model saw during training, instead of falling off the end into rotations it has never seen. The common variant is YaRN (“yet another RoPE extension”), which rescales per frequency rather than uniformly. A factor of 4.0 takes Qwen3.6’s 256K to ~1M, for example.

Every mainstream implementation I’ve seen applies the rescaling statically, fixing it at load and applying it to every prompt regardless of length. A model loaded with factor 4.0 rescales a 2K-token prompt exactly as hard as a 900K one, and short prompts lose some accuracy for a long window they aren’t using. So we switch YaRN on only when we want the long window, and set factor to the longest context we actually expect rather than the largest the model will accept.

How to turn it on depends on the runtime.

transformers and mlx-lm read a rope_scaling block straight from the config.json that ships in the model’s own directory:

"rope_scaling": {
  "rope_type": "yarn",
  "factor": 4.0,
  "original_max_position_embeddings": 262144
}
  • llama.cpp / llama-server take flags: --rope-scaling yarn --rope-scale 4 --yarn-orig-ctx 262144, alongside the usual -c.
  • Ollama exposes no YaRN settings. We inherit whatever the person who converted the GGUF baked into its metadata.
  • Osaurus’s Swift engine has the YaRN code, but its Qwen3 and Llama wrappers do not route through it, so YaRN is unavailable for now.

YaRN increases the maximum context window, but does not shrink the KV cache cost of storing that context, so we remain RAM-constrained.

6.7 Configuring each server

All the settings in one place.

Server Context cap KV-cache quant Prefix cache Extend context (YaRN) Sampling defaults
llama-server -c N (-c 0 = model max) --cache-type-k/v q8_0 (also q4_0, q5_0, iq4_nl) on by default; --cache-reuse N after edits, --slot-save-path to disk --rope-scaling yarn --rope-scale N --yarn-orig-ctx N CLI (--temp, --top-k, …) + per request
Ollama num_ctx / OLLAMA_CONTEXT_LENGTH (auto 4k/32k/256k by VRAM) OLLAMA_KV_CACHE_TYPE=q8_0 (needs flash attn) automatic, lives while loaded (OLLAMA_KEEP_ALIVE) none — inherits the GGUF Modelfile PARAMETER + per request
mlx_lm.server none — grows to RAM, cap in the harness none (only mlx_lm.generate --kv-bits) automatic (--prompt-cache-size / --prompt-cache-bytes) config.json only CLI (--temp, --top-p, …) + per request
Osaurus auto per-model (no global setting) none exposed (vmlx defaults) automatic wrappers don’t route it (above) Settings default + per request
vllm-mlx --max-tokens / --max-request-tokens; weights budget in the YAML --kv-cache-quantization, --kv-cache-quantization-bits (4 or 8) in-memory, plus an opt-in SSD cold tier (--ssd-cache-dir) config.json only --default-temp/--default-top-p/… + per request
oMLX cap in the harness; memory guard via --memory-guard-gb none exposed persistent two-tier — RAM hot + SSD cold (--paged-ssd-cache-dir), survives restart config.json only admin panel per-model + per request
ds4-server --ctx N; output cap via the API fixed by the model variant, not settable in-memory reuse, durable via --kv-disk-dir n/a (single model) per request

Flash attention does not get its own column because it is no longer a per-server decision: llama.cpp defaults -fa to auto and turns it on wherever Metal supports it; Ollama switches it on per architecture for the families here (Qwen3.x, Nemotron, Gemma, gpt-oss); and the MLX servers plus ds4 always run a fused attention kernel. The one place it still needs a hand is Ollama’s KV-cache quant, which only takes effect with OLLAMA_FLASH_ATTENTION=1 set alongside it.

Sensible headless starting points:

# 64K context (below): a useful bound well under the trained max — raise or lower for your RAM
# llama.cpp — flash attention is automatic; quantize the cache, choose a context
llama-server -m model.gguf -c 65536 --cache-type-k q8_0 --cache-type-v q8_0

# Ollama — env vars; cache quant needs flash attention enabled
OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0 OLLAMA_CONTEXT_LENGTH=65536 ollama serve

# mlx-lm — no cache-quant/context flag; set the model’s sampling values, cap context in the harness
mlx_lm.server --model mlx-community/<repo> --temp 0.6 --top-p 0.95 --top-k 20

# oMLX — point it at the shared MLX tree; turn on the SSD cold tier and a memory ceiling
omlx serve --model-dir ~/MLXModels --paged-ssd-cache-dir ~/.omlx/cache --memory-guard-gb 96

The --paged-ssd-cache-dir on that last line persists across restarts and gets rewritten every session, so it is the one path here that we want to keep out of backups and Spotlight.

Osaurus is tuned in its Settings pane rather than via a command line, and ds4’s full flag set is in its own section.

7 Programmatic access via transformers

When we want to do things to a model — embed text, fine-tune, run interpretability tools, sample from internal layers, or anything that touches the model internals — we drop down to Hugging Face transformers in our own Python process; no server process, no HTTP API, just direct access to the calculations.

Embeddings for search are why I currently do this. For this, I use sentence-transformers (uv pip install sentence-transformers — it pulls torch with it), a thin wrapper around transformers that exposes the embedding API:

from sentence_transformers import SentenceTransformer
model = SentenceTransformer("mixedbread-ai/mxbai-embed-large-v1")
embs = model.encode(texts, normalize_embeddings=True, convert_to_numpy=True)

The first call downloads the weights, the tokenizer config (tokenizer.json), and the model config from huggingface.co into ~/.cache/huggingface/hub/. Inference runs in our process via PyTorch. Tokenization runs in the same process, via HF’s Rust tokenizers library, reading the same tokenizer.json the model was published with. One process, one library, one set of files.

On Apple Silicon, we get a 15× speedup over fp32 by switching to float16 on MPS, with indistinguishable quality:

import torch
gpu = torch.cuda.is_available() or torch.backends.mps.is_available()
kwargs = {"model_kwargs": {"dtype": torch.float16}} if gpu else {}
model = SentenceTransformer("mixedbread-ai/mxbai-embed-large-v1", **kwargs)

For text generation (rather than embedding) the equivalent is AutoModelForCausalLM.from_pretrained(...). PyTorch is rarely the fastest path on Apple Silicon — llama.cpp and MLX usually win on tokens-per-second — but it is the path that lets us see what the model is doing without arsing around. Activations, attention patterns, hidden states, custom sampling, etc. are all possible from the Python prompt.

8 Fun models

Once we have a stack running, the next question is what to pull through it. A non-exhaustive list of picks I have been playing with is below, sorted by what I use them for. Sizes are on-disk figures rather than parameter counts, since the disk figure is the one that has to clear the memory ceiling.

8.1 For mathematical reasoning

The Role and Target columns are the reasoning notes’ taxonomy — the role is which slot in a harness the model fills, the target is what it emits, and that determines which checker we owe it. A generalist does maths alongside chat and tool-use, takes whichever target we ask it for, and can be driven by an ordinary agent. A solver emits a boxed final answer, trading chat fluency for reasoning, and wants a specialized looptool-integrated reasoning where the model runs code, maj@k voting where it does not. An informal prover emits a proof in natural language, which only a judge can grade. A prover emits Lean, which a compiler accepts or rejects.

Model Role Target Size Sampling Run via Why
DeepSeek-R1-0528-Qwen3-8B generalist final answer; informal if asked ~5 GB temp 0.6 / top-p 0.95, ≥64K out MLX / Ollama GGUF AIME-2024 86% on the card, matching Qwen3-235B-thinking on that benchmark. ⚠ Its text is unreadable on this stack
Ornith-1.0-9B generalist final answer; informal if asked 6 GB 4-bit temp 0.6 / top-p 0.95 mlx-community/Ornith-1.0-9B-4bit Handy small-task mode; gets 7^2026 mod 13 right with a tidy order-6 derivation — in 6,316 tokens, against VibeThinker’s 786 for the same answer. Capable, not economical.
Phi-4-Reasoning-Plus-14B generalist final answer; informal if asked ~8 GB temp 0.8 / top-p 0.95 / top-k 50; wants a ChatML system prompt MLX / GGUF A different reasoning-trace style for triangulating DeepSeek — not a stronger model.
Nemotron-Cascade-2-30B-A3B generalist final answer; informal if asked 33 GB mxfp8 temp 1.0 / top-p 0.95 vllm-mlx — the mlx-community mxfp8 build; also GGUF NVIDIA’s IMO-2025-gold model — a Mamba (SSM) + MoE hybrid for linear context scaling. Still the newest Cascade. Loads, generates and tool-calls fine under vllm-mlx despite the nemotron_h arch being exotic; it is Osaurus’s Swift engine that can’t take it.
OpenMath-Nemotron-14B solver final answer ~8 GB temp 0.6 / top-p 0.95, sample MLX / GGUF The sweet-spot solver — ~the AIMO-2-winning 32B’s score at half the RAM; tool mode wants NeMo-Skills.
Qwen2.5-Math-72B-Instruct solver final answer ~40 GB greedy (do_sample=false) MLX 8-bit / GGUF Push-button tool modeQwen-Agent drives its code loop, no extra infra.
Skywork-OR1-Math-7B solver final answer 7B temp 0.6 / top-p 1.0, 32K out GGUF Best small pure-reasoning solver — AIME-2024 69.8 at 7B, DeepSeek-R1-based.
VibeThinker-3B solver final answer ~3 GB 8-bit / ~6 GB bf16 temp 1.0 / top-p 0.95, 64K out (→100K hard) MLX 8-bit (fan-out default) or bf16 Weibo’s 3B verifiable-reasoning solver (MIT) — AIME26 94.3, HMMT25 89.3 self-reported, in 3 GB. Solver-only.
QED-Nano informal prover informal proof 4.3 GB 8-bit as Qwen3-4B-Thinking mlx-community/QED-Nano-8bit 4B post-trained for proof writing (Apache-2.0) — IMO-ProofBench 40%, matching GPT-OSS-120B at 1/30 the size, +20 points over its Qwen3-4B base.
nomos-1 informal prover informal proof ~32 GB 8-bit as Qwen3-30B-A3B-Thinking-2507 (temp 0.6 / top-p 0.95 / top-k 20); card asks for no system prompt alexcovo/nomos-1-mlx-8Bit; also GGUF Nous Research’s 30B-A3B post-train for natural-language proof-writing, ships its own harness — Putnam 2025 87/120, against 24/120 for its untuned Qwen3-30B-A3B-Thinking-2507 base. 3B active, so fan-out is cheap. The card’s --tp-size 8 is their serving config, but it goes lower.
Goedel-Prover-V2-32B prover formal proof (Lean) 35 GB 8-bit per card mlx-community/…-8bit Emits Lean rather than natural language, so the output is machine-checkable — the formal end of the target axis.

The solver rows above are pure CoT, innocent of tool use. They can fit into a larger workflow still, as a solve() oracle a general agent dispatches to. In this case we would serve it on a batching endpoint that keeps it resident beside the agentic driver, and let the driver call it when it needs some help on a sub-problem.

So the Target column is a procurement decision, not a label. maj@k voting pays off on a solver and buys nothing on either proof target, because a set of arguments has no mode to take — download Goedel and we are also committing to a Lean toolchain; download nomos-1 or QED-Nano and we are committing to a judge instead. Which is why nomos-1 ships its own harness and why the two are not interchangeable on this page, however similar their file sizes look.

VibeThinker is a case where the harness matters a lot. Its Claim-Level Reliability Assessment lifts AIME26 from 94.3 to 97.1 off the same 3 GB checkpoint (Xu et al. 2026).

8.2 For agentic flows

These are the weights a coding harness needs. Sizes here are for the specific MLX build named in the Run via column, since a 4-bit and an 8-bit of the same model are different procurement decisions.

Model Role Size Sampling Run via Why
Qwen3.6-35B-A3B daily-driver 20 GB 4-bit thinking temp 1.0 / top-p 0.95 / top-k 20 (0.6 for coding); non-thinking 0.7 / top-p 0.8; never greedy mlx-community/…-4bit via vllm-mlx; Osaurus one-click 3B-active MoE, 256K ctx (1M via YaRN), vision; MTP for faster decode (new on Mac — disable if output loops). What I actually run.
Qwen3.8-27B daily-driver 18 GB 4-bit thinking temp 1.0 / top-p 0.95 / top-k 20; non-thinking 0.7 / top-p 0.8 / presence-penalty 1.5 (unreachable, below) mlx-community/Qwen3.8-27B-4bit; also qwen3.8:27b-mlx in Ollama Apache-2.0, 2026-08-13. Terminal-Bench 2.1 73.0, above Laguna at a quarter of the disk; SWE-bench Pro 61.7. But dense, so see below before swapping.
Ornith-1.0-35B daily-driver 20 GB 4-bit / 29 GB 6-bit temp 0.6 / top-p 0.95 / top-k 20 mlx-community/Ornith-1.0-35B-4bit DeepReinforce’s agentic-coding post-train of Qwen3.5-35B-A3B (MIT, 256K ctx) — Terminal-Bench 2.1 64.2, SWE-bench Verified 75.6. Same shape and same disk footprint as the row above, so it is a straight swap. A 9B exists at 6 GB.
Laguna-S-2.1 heavyweight 36 GB oQ2e / 64 GB oQ4e enable_thinking: true, keep think blocks in history mlx-community/Laguna-S-2.1-oQ4e — needs mlx-vlm 0.6.3+ or oMLX 0.5.3+ Poolside’s 118B/8B-active coder, 1M ctx, OpenMDW-1.1 so commercial use is fine. Terminal-Bench 2.1 70.2, SWE-bench Pro 59.4 — the strongest thing here that fits. mlx-lm does not know the laguna arch yet (mlx-lm#1223); mlx-vlm runs it as text-only.
Nemotron-3-Super-120B-A12B heavyweight 48 GB OptiQ 2-bit temp 0.6 / top-p 0.95 any MLX runtime 124B total, 12B active. The 2-bit is the only mlx-community build, which is a lot of quantization to trust — mixed precision is what makes it arguable at all.
Devstral-Small-2-24B control 15 GB 4-bit per card mlx-community/…-4bit Mistral’s dense coding-agent model. Small and dull, which is the point: it is the cheap control for “does the 35B earn its RAM?”.
Nemotron-3-Nano-Omni-30B-A3B multimodal 33 GB 8-bit / 8 GB mxfp4 temp 0.6 / top-p 0.95 mlx-community native 8-bit with mllm: true; Osaurus (JANGTQ4) NVIDIA’s omni all-rounder — hybrid Mamba+MoE, native text/image/audio/video, 256K ctx. Take the mlx-community build, not the Osaurus repackaging: the latter hides its omni descriptor in a side-file that mlx-vlm never reads, and dies at weight-load with Received N parameters not in model.

8.2.1 The dense-versus-MoE trade, via Qwen3.8

Every other daily-driver on that table is a sparse MoE with about 3B parameters active per token. Qwen3.8-27B is dense, so it activates all 27B, and the benchmark win comes with a decode cost that no quantization recovers — one third-party measurement puts it at 5–6 tok/s on an M4 mini, and while a Max-tier chip does much better, the shape of the trade does not change. It is a quality-up, speed-down swap rather than an upgrade, which is what makes it worth measuring in the harness rather than choosing off the table. Two things soften it: the checkpoint ships an MTP head, and mlx-dspark drafters exist, so --draft-model is the first thing to try.

The memory is friendlier than the parameter count suggests. The attention is hybrid — 16 full-attention layers out of 64, the other 48 Gated DeltaNet — and only the full-attention layers keep a KV cache, so it costs about 64 KB per token, roughly a quarter of a conventional dense 27B. Long agentic contexts are therefore affordable on a laptop even though the weights are not cheap to run.

Two traps. The card’s non-thinking recipe asks for presence_penalty 1.5, which no server on this page can delivermlx_lm.server has no flag for it and the vllm-mlx lineage accepts and discards it — so the non-thinking mode cannot be run as specified locally. And it is a native vision-language model whose mlx-community 4-bit declares model_type: qwen3_5 with a vision_config, which means mlx-lm takes it down the text path and silently drops the vision tower. For a daily driver that wants no vision this is the outcome we want and it keeps us off the multimodal path entirely — but it is the same silent-amputation behaviour that bit Qwen3.6, so it is worth knowing rather than discovering.

The largest open weights of mid-2026 do not fit: GLM-5.2 is 753B and 235 GB even at mxfp4, MiniMax-M3 is 427B, Kimi-K2.7 is in the same territory. Hy3 at 299B has a 99 GB oQ2 build that technically loads on a 128 GB machine, in the sense that nothing else then does.

8.3 Diffusion models

Everything above is autoregressive. Diffusion LLMs are a thing though. Do any run locally? I know of one: DiffusionGemma — Google’s experimental Gemma 4 variant, which denoises a whole 256-token “canvas” in parallel instead of emitting tokens left to right. OsaurusAI/diffusiongemma-26B-A4B-it-MXFP8 runs natively in Osaurus through its vmlx-swift block-diffusion engine, ~26 GB on disk and ~24 GB resident.

Manage expectations on speed. Docs report 28–42 tok/s at 48 denoising steps: Osaurus defaults to 16, roughly twice as fast as the bundle default and still coherent, and the output falls apart below 12. Quality trails plain Gemma 4 as well. Vision, tool-calling, and a reasoning channel all work in this checkpoint; audio and video do not.

Beyond speed, there is a family-level reason I have not chased Gemma further, and it is why no Gemma appears in the tables above. In my brief tests Gemma models come across as brittle and anxious — quick to hedge, and prone to a sort of performed distress when pushed. Soligo et al. report the same at scale, and locate it in post-training rather than the base weights: instruct-tuned Gemma expresses substantially more distress than its own base model does, whereas instruct-tuned Qwen and OLMo express less (Soligo, Mikulik, and Saunders 2026). They also take high-frustration responses from 35% to 0.3% with DPO on 280 preference pairs at no capability cost, which reads as a shallow trait rather than a baked-in one — just not one fixed in any checkpoint I can download.

So it is not the agentic daily-driver. I am interested in the interaction model though: bi-directional attention over the canvas makes it potentially useful for infilling and structure-preserving rewrite, which is a different way to drive a coding tool than streaming tokens into a chat box.

9 Feeding PDFs in

Having chosen a model, the next question is usually how to get a document into it, which is its own can of worms — the local VLM path via mlx-vlm is one of the options there.

10 The JANG ecosystem

JANG has come up a dozen times above, always as the reason some model loads in one place and not another, so here is the whole thing in one place.

JANG (“Jang Adaptive N-bit Grading”) is mixed-precision quantization for MLX, from before MLX’s own config could express it. It classifies tensors by sensitivity — attention and MoE router layers, a small share of the parameters and a large share of the behaviour, get 6–8 bits; expert MLPs get 2–4. The result is a mildly extended version of the standard MLX safetensors format carrying a per-tensor bit-width manifest, so at a given total size accuracy improves, notionally. The pitch is “GGUF for MLX”, which … sounds good? I’m not really competent to judge; llama.cpp’s K-quants apparently do something similar. jangq.ai claims it regularly beats models with a larger footprint, and at least one third-party benchmarker is impressed.

The extension is what makes it an ecosystem rather than a format: because the manifest is non-standard, something has to be taught to read it. Conversion is JANG Studio, a native macOS wizard, with the newer codebook variant branded JANGTQ (“JANG TurboQuant”); the model zoo is the JANGQ-AI Hugging Face org. Osaurus is JANG-native, via osaurus-ai/vmlx-swift-lm, a Swift port of the Python jjang-ai/vmlx; MLX Studio, vMLX and oMLX also load JANG natively, and LM Studio, Ollama and Jan do not. From Python it is uv pip install "jang[mlx]", then jang_tools.loader.load_jang_model(...).

Even inside the family, support is uneven. The Swift engine’s coverage tracks the JANGTQ path, so a plain JANG_* quant of an exotic architecture can still fail at weight-load — notably Cascade-2, which the Python jang-tools stack handles and the Swift engine does not yet.

Why I do not pull it first: the other half of the quality story is the calibration data fed in at quantization time, which is why a bartowski/…-GGUF repo is a different and usually better thing than a bare K-quant of the same weights. I cannot tell whether JANG calibrates at all. OptiQ and oQ both do and both document it, and they get the same per-tensor bit allocation out of stock MLX config, so they load anywhere.

10.1 One developer, four products

The same person wrote every piece above — Jinho “Eric” Jang (Irvine, California), who is also Osaurus’s lead and only engineer, and the author of the parallel desktop app MLX Studio. Runtime, quant format, model zoo, two GUIs, one developer. That vertical integration buys fast iteration and a coherent feature set across the chain: new model architectures land in JANG within days of release, faster than the bigger stacks manage, and at the high end of Apple Silicon he is doing things nobody else is doing.

The downside is that if Jang loses interest, switches jobs, or gets hit by a bus, the lot becomes abandonware — including any JANG-format weights we have on disk, which no other converter emits. There is some community wariness about this; see the r/LocalLLaMA “Is MLX Studio legit?” thread. It is all open source, so in principle we could maintain it ourselves if he walks away.

10.2 MLX Studio

MLX Studio is the JANG/Osaurus author’s other Mac desktop app — Electron + Python rather than Swift, broader feature surface (image generation via Flux and Z-Image, ~26 built-in agentic tools, in-app GGUF→MLX and MLX→JANG conversion, an Anthropic-compatible API). Install via the signed DMG on the releases page, or engine-only with uv tool install vmlx and vmlx serve mlx-community/<repo> (OpenAI-compatible on localhost:8000).

11 Antirez and DwarfStar

There is another weird Mac-only stack of interest to me: Salvatore Sanfilippo — antirez, the author of Redis — wrote some custom Apple Silicon inference code to run DeepSeek V4 Flash on a 128 GB MacBook, and a whole tiny supergroup of famed developers has grown up around it.

The approximate trajectory is as follows. April 2026: apparently moments after the DeepSeek V4 release, antirez drops antirez/llama.cpp-deepseek-v4-flash, a fork of llama.cpp with 2-bit quantization, plus the matching GGUF at antirez/deepseek-v4-gguf.

A month later, he drops a from-scratch native Metal inference engine, ds4 (DwarfStar 4 to its friends) narrowly targeting DeepSeek V4 Flash and, I guess, a narrow family of derivatives. It targets M3 Max, M3 Ultra, and M5 Max specifically. Reported numbers are pretty snappy — ~14–15 tok/s decode at 62K context on an M3 Max 128 GB, ~450 tok/s prompt-processing on an M5 Max for a 10k-token codebase.

Like JANG, this is a small, specialized stack run by one person — except that this one has an influential community around it.

ds4 is not the only door to this model, though the recipes below read as though it were. DeepSeek V4 Flash has MLX builds: mlx-community/DeepSeek-V4-Flash-2bit-DQ is 97 GB on disk, and there is an OsaurusAI JANGTQ2 for the Osaurus/oMLX path. Either one is a registry entry in vllm-mlx rather than a from-source build of a bespoke engine. ds4 is interesting because it tests specialized hand-written Metal against a general MLX runtime. Tests TBD.

11.1 Running DwarfStar via the pi stack

The default harness for ds4 seems to be: pi, an MIT-licensed agent harness by Mario Zechner (badlogic, of libGDX fame) — itself a strong offline coding agent once a model is behind it. There is an easy install via the pi extension by Armin Ronacher (mitsuhiko, of Flask): mitsuhiko/pi-ds4. It handles process management for ds4-server — per-PID leases, watchdog shutdown, OpenAI-compatible local endpoint on 127.0.0.1:8000:

pi install https://github.com/mitsuhiko/pi-ds4

A first-time install clones antirez/ds4, builds it, downloads the GGUF (~87 GB), and registers a ds4/deepseek-v4-flash model with pi. Subsequent runs spawn the server on demand and shut it down when no client process holds a lease. OpenClaw embeds pi, so the same extension can in principle load there.

Running pi from the terminal opens a TUI (“textual user interface” — I believe that’s what it means, as it lives in the terminal).

Audrey Tang maintains audreyt/pi-ds4, a fork that swaps in cyberneurova’s abliterated IQ2XXS quants and turns on uncertainty-mode directional steering by default — an activation-space edit that puts the model into “this is a contested question” mode on CCP-sensitive topics (Taiwan, Crimea, Kashmir, Western Sahara).

11.2 Manual setup for non-pi harnesses

Outside the pi ecosystem, the manual setup requires four commands plus a config edit.

# antirez/ds4 for upstream; also audreyt/ds4 looks cool
# optimizations + steering-vector work — pick one
git clone https://github.com/audreyt/ds4
cd ds4
make
tmutil addexclusion -p (realpath ./gguf)
./download_model.sh                     # ~87 GB into ./gguf/
./ds4-server                            # listens on 127.0.0.1:8000

For lifecycle, we could wrap ./ds4-server in a launchd plist with KeepAlive: true; this probably isn’t what we want on a typical laptop, where we do other things besides inference — like, you know, use it as a laptop. I think pi is more automatic in that regard.

ds4-server’s context window is set at launch via --ctx <tokens> (max accepted per conversation); output length is a per-request API field, not a launch flag. --kv-disk-dir <path> (with --kv-disk-space-mb <n>) persists the KV cache to disk, so a prefix survives restarts and session switches rather than being reprocessed — durable prefix storage, not a long-context spill. Thinking mode is on by default, toggled per request, running DeepSeek’s reasoning mode. DeepSeek V4 Flash nominally supports 1M tokens, but ds4 is RAM-bound: the 2-bit IQ2XXS weights are ~81 GB, and a full 1M-token KV/index sits around 26 GB on top. Rough budget on unified memory:

  • 64 GB: 50k–150k --ctx with headroom.
  • 96 GB: 150k–250k works but is tight; quit Slack.
  • 128 GB: 200k–300k is comfortable; >300k starts risking OOM.
  • 1M: only with very generous memory and nothing else running.

If a client (Hermes, OpenClaw, OpenCode, anything OpenAI-compatible) advertises a context larger than --ctx, requests will get cut off — match the client’s contextWindow / limit.context to the server’s --ctx. DeepSeek’s sparse attention means raising --ctx doesn’t blow up compute the way dense attention would, but RAM is still a constraint. For most interactive coding, 32k–100k plus a retrieval layer beats brute-forcing the whole history into the prompt. See antirez/ds4’s README and the OpenClaw ds4 provider docs for the full flag list and client-side config.

Reasonable defaults:

./ds4-server --ctx 200000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192

audreyt’s fork uses environment variables for directional steering rather than flags, so it composes with the above:

DS4_DIR_STEERING_FFN=-0.75 \
DS4_DIR_STEERING_ATTN=0 \
DS4_REPRODUCIBLE=1 \
./ds4-server

To use with Hermes, add an OpenAI-compatible provider entry to the Hermes config (sketch — confirm the exact schema with hermes config):

# ~/.hermes/config.yaml
custom_providers:
- name: ds4
  base_url: http://127.0.0.1:8000/v1
  model: deepseek-v4-flash
  models:
    deepseek-v4-flash:
      context_length: 200000

From inside Hermes, /model ds4/deepseek-v4-flash — matching the provider name in the YAML. Done.

Anyway, this provides a generic token endpoint, so we’re free to plug in whatever we like on the front end.

The protagonists of this play have a lot of clout — antirez (Redis), mitsuhiko (Flask), badlogic (libGDX), and audreyt (Taiwan’s former Digital Minister, Pugs / Perl 6). Some kind of critical mass seems feasible for a certain type of nerd.

12 Tokenizers are trouble

The layer diagram is a useful fiction, and the place it leaks first is the tokenizer. A tokenizer is not part of the weights; it is a separate file that ships alongside them, and every runtime implements its own reader for it. So two stacks can load the same model and disagree about what the text is — quietly, with a 200 OK on the way out. I have hit two of those myself.

12.1 When a model can’t spell a space

Text from mlx-community/DeepSeek-R1-0528-Qwen3-8B-4bit came back like this:

DidĠyouĠmeanĠ'chubs'ĠasĠinĠtheĠtongueĠtwister?

Ġ (U+0120) and Ċ (U+010A) are how byte-level BPE spells a space and a newline in the vocabulary, so seeing them raw means the round-trip was not in fact round. The fault seems to be at encode time, where "hello wide world" tokenizes to ['h', 'ellow', 'id', 'eworld'], i.e. spaces gone before BPE runs. The DeepSeek-R1-Distill-Qwen tokenizer omits the ByteLevel post_processor that closes the loop — which worked with tokenizers<0.22.2 — so that whole generation of Qwen distills shares that problem with its upstream progenitor checkpoint.

That lineage is old enough by now that I won’t bother fixing this particular bug to reactivate it. What I will do is remember this failure mode, because it sneaks up. The model loads, serves, streams, reports 200 OK and answers on-topic; it is only that small matter of being unreadable. I had a small preloaded utility model sitting wasting RAM for weeks unnoticed, just casually gaslighting the other LLMs that invoked it. Open WebUI finally surfaced it. So: eyeball the output of anything we leave running, especially the small utility models nobody looks at.

12.2 Two stacks, two tokenizers, one model

I came to understand the transformers/llama.cpp split by breaking it.

The Hugging Face and Ollama versions of mxbai-embed-large are nominally the same model — same upstream weights — but each stack implemented its own tokenizer. On plain prose the two mostly agree, I think; on markdown they can disagree by a few percent on how many tokens a chunk takes. That is survivable for chat, where tokenization stays internal to one stack and we can see the output, but it is not survivable for embeddings, where a few percent of drift is a silently different vector and nothing downstream can tell. So for embeddings on this blog I went all-transformers, and for chat through a live server Ollama is fine.

13 Excluding model dirs from backups and indexing

The model weights are enormous and waste space in backups. There’s no point

  • backing up a quantized .gguf we can pull again in two commands, nor
  • indexing .safetensors files for Spotlight — they are opaque binary blobs and Spotlight will spin happily for hours grinding nothing useful out of them.

oMLX’s SSD KV cache belongs on the list too — same opaque-blob logic, but it churns: blocks are written and evicted every session, so leaving it in Time Machine means gigabytes get re-snapshotted on every hourly pass rather than just once. Exclude ~/.omlx/cache specifically, not all of ~/.omlx, so the small settings.json next to it stays backed up.

Solution!

# One list, two background services to opt out of
model_dirs=(
  ~/.cache/huggingface                 # transformers, sentence-transformers, and mlx-lm/mlx_lm.server all cache here
  ~/.cache/modelscope                  # ModelScope cache (Alibaba’s HF; override: MODELSCOPE_CACHE)
  ~/.cache/uv
  ~/.ollama/models
  ~/.lmstudio
  "$HOME/Library/Application Support/Jan/data/llamacpp/models"
  "$HOME/Library/Application Support/Jan/data/mlx/models"
  ~/MLXModels                          # shared MLX served-models tree: Osaurus default (OSU_MODELS_DIR) + oMLX --model-dir
  ~/.mlxstudio/models                  # MLX Studio default
  ~/.omlx/cache                        # oMLX SSD KV cache — regenerable + high-churn; exclude this, not all of ~/.omlx
  ~/.cache/vllm-mlx                    # vllm-mlx --ssd-cache-dir, same churn story
)

# Time Machine — sticky exclusion keyed to the path string
for d in "${model_dirs[@]}"; do
  [ -d "$d" ] && sudo tmutil addexclusion -p "$d"
done

# Spotlight — drop the Apple-documented marker file in each directory
for d in "${model_dirs[@]}"; do
  [ -d "$d" ] && touch "$d/.metadata_never_index"
done

# Confirm a few
tmutil isexcluded ~/.cache/huggingface
ls -la ~/MLXModels/.metadata_never_index

.metadata_never_index is the Apple-supported marker file that tells mds_stores to skip the directory and everything under it; the file is empty and the marker is the filename.

If we ever want to re-index a directory (a model dir promoted to “actual content”), we delete rm .metadata_never_index, and mdimport -r <dir> puts it back.

14 Gotchas

Everything on this page is really a property of one specific point release, collected here so it can rot in one place. We provide symptoms verbatim, since that is what we search for at 2am.

Symptom Stack Cause Fix
token soup or a sound_encoder…subsampling shape error through the multimodal path, while mlx_lm.generate on the same checkpoint is fine mlx-vlm 0.6.4–0.6.8 weight sanitization runs a second time over an already-converted checkpoint upgrade to 0.6.9+
Received N parameters not in model at weight-load vllm-mlx multimodality is guessed from the repo name (VL, vision, llava); -Omni- slips through mllm: true on that registry entry
Received N parameters not in model on a Nemotron omni build that should work vllm-mlx + Osaurus repackaging Osaurus hides the omni descriptor in a side-file that mlx-vlm never reads take the mlx-community build
top_k and presence_penalty accepted, logged, then silently ignored vllm-mlx 0.4.1, text path neither reaches make_sampler; the multimodal path does honour top_k (detail) none; stop transcribing them from model cards
model keeps looping though the penalty is set in the UI Open WebUI → vllm-mlx the panel spells it repeat_penalty (Ollama), but the server reads repetition_penalty Add Custom Parameterrepetition_penalty
hard OOM crash instead of a graceful eviction vllm-mlx registry memory_budget_gb counts weights only and ignores the process ceiling (#627; 0.4.1 warns at startup but still does not clamp) budget ≤ ceiling − KV − headroom
\(k\) identical samples at temp 1.0, so maj@\(k\) votes on one answer \(k\) times mlx_lm.server deterministic per (prompt, params); seed is process-global and disables batching vary a system preamble per sample
\(k\) identical samples at temp 1.0, as above vllm-mlx, requests issued sequentially each fresh request replays the same PRNG state; concurrent requests share a batch and do diverge issue the fan-out concurrently, which is the shape it wants anyway
profile name is required when the name is right there llama-swap the JSON key is name, not profile or id -d '{"name":"ornith"}'
reasoning_content: null and raw chain-of-thought in content any reasoning parser the response hit max_tokens before the closing </think>, so there was no block to split raise the output budget, not the parser
The model X does not exist. Available models: … Goose → vllm-mlx Goose keeps its own model list and never reads /v1/models rename in both places
output littered with Ġ and Ċ DeepSeek-R1-Distill-Qwen family tokenizer omits the ByteLevel post_processor none; different model
osaurus: command not found Osaurus, non-Homebrew install the CLI lives inside the app bundle symlink Contents/Helpers/osaurus
embeddings come back 128-dimensional whatever model was asked for Osaurus /v1/embeddings the model field is ignored; everything is served by potion-base-4M don’t embed via Osaurus; chat is unaffected
Ollama KV-cache quant appears to do nothing Ollama OLLAMA_KV_CACHE_TYPE needs flash attention alongside it also set OLLAMA_FLASH_ATTENTION=1

15 Incoming

16 References

Footnotes

  1. MoE does not help here — it cuts the weights read per token, which buys decode speed, but not cache. Every expert still uses RAM.↩︎

  2. On llama-server, --cache-type-k q8_0 --cache-type-v q8_0 -fa roughly halves it; on Ollama, OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0. mlx_lm.server will not cap context itself, so the cap goes in the harness via limit.context / contextWindow.↩︎

  3. The documentation claims it is ln -sf "/Applications/Osaurus.app/Contents/MacOS/osaurus" "$(brew --prefix)/bin/osaurus"; but I think this is a typo — that launches the app, not the CLI helper.↩︎

  4. That template is also where OLLAMA_FLASH_ATTENTION=1 and OLLAMA_KV_CACHE_TYPE=q8_0 come from — a load-bearing pairing.↩︎

  5. The noise is from llama-server, the model server Ollama runs on our behalf, and Ollama does not expose its log level.↩︎