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-07-30
Wherein the Author Catalogues Runtimes, Servers and Quantization Schemes for the Operation of Language Models Upon Apple Silicon, With Cautionary Tales of Tokenizer Corruption and Memory Panic.
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.
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 in tinkering.
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 are:
- Runtime:
llama.cpp, MLX,mlx-lm,vmlx-swift-lm, PyTorch-on-MPS viatransformers, antirez’sds4. - Server:
ollama serve,llama-server,mlx_lm.server, Osaurus, vllm-mlx, oMLX,ds4-server - Harness: Osaurus and Jan both have one built in; pi is a popular standalone one, and it is what drives
ds4below. - Frontend: Osaurus’s chat window, Jan, LM Studio, MLX Studio, Open WebUI, …
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.cppbrings its own hand-written Metal kernels rather than going through MPS. It is the engine under Ollama andllama-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.cppformat. 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-bitIQ2/IQ3imatrix 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
I used to write that the fixed bit width in MLX was sus, and guess that JANG solved it better at the cost of being fringe. Half right: mixed precision did win, but JANG’s specific format seems to have lost. MLX’s own quantization config allows 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, which I cannot say for JANG. Neither declares a custom format, so anything that loads mlx-lm loads them: mlx-lm itself, mlx-vlm, vllm-mlx, LM Studio, oMLX. JANG’s pitch was that mixed precision needs a custom format, a converter, a model zoo and a runtime that understands all three; here are two counter-examples that needed none of it.
2 Where weights live
Almost everything here slurps weights from Hugging Face, in true open-source anarchic style, they all stash weights in different places, so we rapidly end up with many copies of everything. 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 associated weights are land there the first down load and subsequent attempts pull from cache.
The directory-scanning servers — Osaurus, oMLX, LM Studio, MLX Studio — are more chaos. Each uses a folder of model subdirectories and each has its own default location (~/MLXModels, oMLX’s --model-dir, ~/.lmstudio, ~/.mlxstudio/models) and each writes a fresh copy of whatever it is using outside the HF cache. We can make huggingface do so too if we force it — hf download --local-dir <dir>/<name> Thus the same giant blob of neural network weight data can in up in 4 or more places, and be downloaded as many times. We can presumably make this better by picking one folder and point every such server at it: ~/MLXModelsseems ok to me and is Osaurus’s default, so we can tell them all to use that: omlx serve --model-dir ~/MLXModels does that. We can explicitly set it for Osaurus too with OSU_MODELS_DIR=~/MLXModels (but like I said, it’s the default anyway). oMLX will also reuse ~/.lmstudio directly if LM Studio is already our downloader.
Inside that folder, we need mirror each model’s org/repo path — hf download $repo --local-dir ~/MLXModels/$repo — because that is the layout Osaurus’s and LM Studio’s own downloaders use (OsaurusAI/…, mlx-community/…). Hand-pulled weights then land in an intelligible place, and different downloads from different orgs with the same name cease collliding. The servers recurse into the org subdirs.
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.
3 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”.
Sometimes I want the server without GUI bells and whistles instead.
3.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 the intended runtime for a custom mixed-precision MLX quantization format called JANG.
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:
Osaurus is also a first-class server, covered below.
3.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.
3.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 of risk 4042 |
| 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’s good to make sure it doesn’t hog the RAM if it is invoked by accident. Settings → Llama.cpp → Max Concurrent Models defaults to 2,; setting it to 1 makes Jan work on only one current model. And Jan’s downloads per default land in its own tree (~/Library/Application Support/Jan/data/{llamacpp,mlx}/models) don’t use that but rather use its Import button links a file in place rather than copying, to point it at ~/MLXModels..
3.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.
These three 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.
4 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 differ in various important ways: how they handle the model lifecycle: how many models stay resident at once, and what switching between them costs.
4.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. But also, to avoid installing an idiosyncratic stack, or for superior customization, we might want to install the standard Linux server stack.
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).
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, symlink it: ln -sf "/Applications/osaurus.app/Contents/Helpers/osaurus" "$(brew --prefix)/bin/osaurus" or use the special button from the settings menu1 The CLI supports stuff like osaurus serve / stop / status / list / run <model> / mcp, plus a plugin manager.
4.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:
Anything OpenAI-API-compatible can now point at http://localhost:11434/v1. Ollama is the hands-off one on lifecycle: it loads a model on 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). 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 auto-scales to VRAM (4k / 32k / 256k) rather than the old fixed 2048.
Gotchas:
- The
.gguffiles 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.cppwrapper” framing is loosening: the registry now ships-mlxtags for some models (e.g.qwen3.5:35b-mlx).
4.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” it means a model in that family; mlx-vlm is what runs one locally, e.g. DeepSeek-OCR. Osaurus and JANG are inspired by MLX-type Apple-Silicon-friendly execution, but mlx-lm is the OG.
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; 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. 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).
4.4 vllm-mlx
vllm-mlx (uv tool install vllm-mlx, Apache 2.0) is a vLLM-style inference server for Apple Silicon. Active and popular by this page’s standards — 1,300+ stars. 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. It also 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 is not classic draft-model speculation here — it drives the model’s own built-in MTP head via cache snapshot and restore, so the head has to live inside the model directory, and the verifier is bypassed for any non-greedy or multi-request batch, which is to say for exactly the fan-out workload I run. It supports multi-model residency via a --models-config models.yaml registry: named models behind one process, lazy load on first use, LRU eviction under a memory_budget_gb, and a contention_policy (fail / wait / preempt / wait_then_fail / wait_then_preempt) for what happens when a request needs a model that does not currently fit; clients pick one via the normal OpenAI model field.
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.
4.4.1 Serving from ~/MLXModels
vllm-mlx has no folder-scanning flag. A bare local path loads one model the same way a bare mlx-community/<repo> id does:
Multi-model residency happens via a --models-config models.yaml registry: named entries, each with an explicit path:.
manager:
# WEIGHTS only — the KV cache is a separate pool, sized on the serve command.
# 68 + 30 (--cache-memory-mb) + ~14 (prefill/activations) ≈ the 112 GB envelope below.
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: 3We can run the server like this:
vllm-mlx serve --models-config ~/.config/vllm-mlx/models.yaml \
--port 8000 \
--gpu-memory-utilization 0.88 \ # hard process ceiling ≈112 GB (0.88 × 128)
--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 1200This is a chunky-boi config, allocating a hundred gigs of memory to get large context windows, and setting long timeouts so I can flood the server without too much guilt.
Notes
path:needs to be a real filesystem path — YAML does not~-expand.estimated_memory_gbis 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--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 TTFT 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.
manager.memory_budget_gb counts model weights only, lives in the YAML with no command-line override, and 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. I filed a bug report; it is still open.
One config file per memory profile is the workaround I run — 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.
Gothcs:
Mind which mlx-vlm came along for the ride. vllm-mlx pulls it in as a dependency, and as of July 2026 every release after 0.6.3 breaks the models I care about — two symptoms, both listed in the gotchas table, both caused by one deleted guard that lets weight sanitization run a second time over an already-converted checkpoint. So 0.6.3 is the pin, and it still satisfies Laguna’s mlx-vlm>=0.6.3:
This will move: #1718, which I filed, is the one to watch, and something after 0.6.7 may well be fine. Check before upgrading, though — 0.6.5 did close the first bug report, but for only one architecture, not all the ones I care about.
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.
4.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-pathandds4’s--kv-disk-dirare manual slot-save knobs. Aimed 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.
Same caveats as the rest of the page (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 the 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.
4.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 driverLaunch 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 mean to run:
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.
4.6 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.
4.7 Sampling defaults
Sampling — temperature, top-p, top-k, and the output-token budget — is a decode-time choice set on each request. In particular, a server typically not use the model’s recommended sampling settings automatically.
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.
4.8 Which Sampling parameters work
vllm-mlx 0.4.0 accepts parameters that it ignores. 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 | no |
presence_penalty |
yes | no |
The two 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 — is doing nothing here. Two more traps in the same family:
mlx-lm’srepetition_context_sizedefaults 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.jsonfor 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:
Where to pin the 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-kalready 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— aGenerationConfigpassed atgenerate()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 40000for the output budget,PARAMETER num_ctx 65536for 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.
4.9 What this costs us against vllm-mlx
The server-side story above assumes we can send a value with a prompt, and which we can is varies 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.
4.10 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. This matching is likely not optimal in general.
When planning around this we need to be aware that trimming the front of the history will break this caching and require everything to be re-computed.
Different servers clear this cache at different times:
mlx_lm.server— an in-memory LRU, longest-prefix matched, reportingcached_tokensin the usage block; bounded by--prompt-cache-size/--prompt-cache-bytes, held for the process.llama-server—--cache-promptis on by default (one KV per slot;--cache-reuseeven salvages chunks after a mid-prompt edit), and slots can be saved to disk.- Ollama — the same
llama.cppreuse, alive as long as the model stays loaded (OLLAMA_KEEP_ALIVE). - Osaurus — automatic; headless under
osaurus servethe 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.
4.11 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 AFAICT 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:
llama.cpp/llama-servertake 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 are still RAM-constrained.
4.12 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 doesn’t get its own column, because it isn’t really a per-server decision anymore: 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 96The --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.
5 Programmatic access via transformers
When we want to do things to a model — embed text, fine-tune, run interpretability tools, sample from internal layers, 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 — pulls torch with it), a thin wrapper around transformers that exposes the embedding API:
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:
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.
6 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.
6.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 loop — tool-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 mode — Qwen-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).
6.2 For agentic flows
These are the weights a coding harness needs. Sizes are the on-disk figures for the MLX build named in the Run via column, since that is the number that has to clear the weights budget.
| 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. |
| 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?”. |
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.
6.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.
7 The JANG ecosystem
Osaurus wraps osaurus-ai/vmlx-swift-lm, a Swift MLX inference engine. That engine is a Swift port of jjang-ai/vmlx, a Python engine. Both load weights from the JANGQ-AI Hugging Face org, a zoo of mixed-precision quantized models in a custom format called JANG — converted with JANG Studio, a native macOS wizard, with the newer codebook variant branded JANGTQ (“JANG TurboQuant”). The same person wrote each of those — Jinho “Eric” Jang (Irvine, California; also Osaurus’s lead/only engineer). There is a parallel desktop app, MLX Studio, by the same author, running the Python engine and surfacing more experimental features (image generation, agentic tool calling, in-app model conversion). The Jang family of enterprises is a tightly integrated stack: runtime, quant format, model zoo, two GUIs — one developer. That vertical integration buys fast iteration and a coherent feature set across the chain. The downside is that if Jang loses interest, switches jobs, or gets hit by a bus, the lot — JANG quants and JANG-format model files included — becomes abandonware. 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.
Against that, new model architectures land in JANG within days of each release, which is faster than the bigger stacks manage, and at the high end of Apple Silicon he is doing things nobody else is doing.
7.1 Tech stack
JANG (“Jang Adaptive N-bit Grading”) is mixed-precision quantization for MLX. Standard MLX quantization compresses every tensor to the same bit width. JANG classifies tensors by sensitivity — attention and MoE router layers (small share of params, large share of model behaviour) get 6–8 bits; expert MLPs get 2–4. The hybrid network is a mildly extended version of the standard MLX safetensors format with a per-tensor bit-width manifest. At the same total size, accuracy improves, notionally. The pitch is “GGUF for MLX”, which … sounds good? I’m not really competent to judge. Apparently llama.cpp’s K-quants do something similar.
jangq.ai claims impressive performance, regularly beating models with a larger footprint. At least one third-party benchmarker is impressed.
The other part of the GGUF quality story is the calibration data fed in at quantization time — which is why a bartowski/…-GGUF repo (like the Nemotron one) is a slightly different, usually better thing than a bare K-quant of the same weights. I am unsure if JANG does this. OptiQ and oQ both do, and document it, which is why I currently pull an OptiQ or oQ build over a JANG one: they get the same per-tensor bit allocation out of stock MLX config.
Osaurus is JANG native. Pull a model from JANGQ-AI and it loads — usually. The Swift engine’s coverage tracks the JANGTQ path; a plain JANG_* quant of an exotic architecture can fail at weight-load (notably Cascade-2 doesn’t work — the Python jang-tools stack handles those, the Swift engine does not yet). Elsewhere, support is partial: MLX Studio, vMLX, and oMLX all load JANG natively; LM Studio / Ollama / Jan not yet. From Python: uv pip install "jang[mlx]", then jang_tools.loader.load_jang_model(...).
7.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).
8 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.
One thing to be clear about, since the section below reads as though ds4 were the only door: 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.
8.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:
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 think that’s what it means, i.e., 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).
8.2 Manual setup for non-pi harnesses
Outside the pi ecosystem, the manual setup is four commands plus a config edit.
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
--ctxwith 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:
I need clarification on which text to copy-edit — you’ve provided guidelines but no actual blog post content to edit.
Could you please share the text you’d like me to copy-edit?
To use with Hermes, add an OpenAI-compatible provider entry to the Hermes config (sketch — confirm the exact schema with hermes config):
From inside Hermes, /model ds4/deepseek-v4-flash — matching the provider name in the YAML. Done.
Anyway, this gets us a generic token endpoint, so we’re free to plug in whatever 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.
9 Memory management
We need to think about how much memory our machine has overall, and how much of that it will let us use for MLX workloads.
On the first point, 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 point, there are limits on how much of the precious system memory any given process is allowed to take up. 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:
Here’s the edited text:
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.
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 purgeflushes 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.
The weights are a fixed cost. The longer the session runs, though, 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.
How much the session 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.2 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 above.3
10 Feeding PDFs in
See PDF ingestion.
11 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 their 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. The moral of the story: eyeball everything and remember that tokenizers are trouble.
12 Let’s break things
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. Best not to mix and match. For embeddings on this blog I went all-transformers. For chat through a live server — where tokenization stays internal to one stack and we eyeball the output — 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
.ggufwe can pull again in two commands, nor - indexing
.safetensorsfiles 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”), 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. Symptoms are given verbatim, since that is what we search for at 2am.
| Symptom | Stack | Cause | Fix |
|---|---|---|---|
temperature-0 garbage — multilingual token soup — through the multimodal path, while mlx_lm.generate on the same checkpoint is fine |
mlx-vlm 0.6.4 on qwen3_5/qwen3_5_moe |
sanitize_weights() applies its norm-weight shift a second time to an already-converted checkpoint (#1521) |
pin 0.6.3 |
ValueError: Expected shape (256, 3, 3, 1) but received shape (256, 3, 1, 3) for parameter sound_encoder.encoder.subsampling.layers.0.weight |
mlx-vlm 0.6.5–0.6.7 on omni models |
same missing guard, different sanitize() — the 0.6.5 fix covered one architecture (#1718) |
pin 0.6.3 |
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.0 | neither reaches make_sampler (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 Parameter → repetition_penalty |
| hard OOM crash instead of a graceful eviction | vllm-mlx registry | memory_budget_gb counts weights only and ignores the process ceiling (#627, open) |
budget ≤ ceiling − KV − headroom |
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 |
| 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
- antirez’s “DeepSeek-V4-Flash on a MacBook M5 Max” — the demo video for the DwarfStar section.
- Vicki Boykis — Running local models is good now
16 References
Footnotes
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.↩︎MoE does not help here — it cuts the weights read per token, which buys decode speed, but not cache. Every expert still uses RAM.↩︎
On
llama-server,--cache-type-k q8_0 --cache-type-v q8_0 -faroughly halves it; on Ollama,OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0.mlx_lm.serverwill not cap context itself, so the cap goes in the harness vialimit.context/contextWindow.↩︎
