MTPLX 2.11

Released 2026-09-04. Faster decode at every context on both flagship models, coding-agent sessions without dead time between tool turns, a local server that only its own pages can reach, a light appearance, the app in twelve languages, and the largest fix batch of any release so far.

The artifact number is 2.11.1: the 2.11.0 number was consumed by a mis-stamped upload to PyPI on 2026-09-01 that was retracted and contained nothing beyond 2.10.2. pip install --upgrade mtplx moves every install, including that one, to 2.11.1.

Measured on an M5 Max with 128 GB against 2.10.2:

2.10.2 2.11
Flash-Next decode, 16k context, 1,024-token answer 53.2 tok/s 68.4 tok/s (+29%)
Flash-Next decode, 100k context 47.5 tok/s 60.9 tok/s (+28%)
Flash-Next decode, 206k context 32.2 tok/s 44.2 tok/s (+37%)
27B decode, 16k context 38.6 tok/s 41.4 tok/s (+7%)
27B decode, 88k context 20.5 tok/s 30.9 tok/s (+51%)
Agent tool turn after a file write, tokens re-prefilled 3,535 (3.7 s) 20 (0.12 s)
Agent tool turn, dead time before the first token 5 to 7.5 s 0.01 s
Forced tool round in a 41k-token session 85 s (two cold re-prefills) 0.58 s
First token after a few seconds of quiet, Flash-Next 1.06 s 0.08 s
Copy-lane streaming, longest pause on screen 767 ms 233 ms
macOS 27, 70k-token prompt HTTP 500 64.4 s

The Flash-Next rows are same-hour pairs at temperature 1 with the copy lane on in both arms, on a 16,384-token coding prompt with a 1,024-token turn and on the 100k and 206k coding prompts; the 206k pair was taken before the second half of the Flash-Next stack landed, and at that rung the compiled lane is memory-gated on 128 GB, so it is the shipped default there. The 27B rows are die-matched pairs from the September 1 kernel window against the tree before it, which is 2.10.2's engine; the 88k pair carries the new flash-decoding route together with the two long-context settings named in its section, and the route alone measures +16% there. The agent rows come from a 43k-token OpenCode session on the live daemon, replayed through the new agent-session gate. The macOS 27 row is the reporter's receipt on the 27 beta; everything else was measured here. Each section below says what was measured and how.

Qwen 3.8 Flash-Next: faster at every context

The Flash-Next decode lane in this release comes from two sources. A compiled verify lane and a set of kernel and prefill items were adapted from @davidtai's PR #391, ported item by item with each of his commits authored by him. Around them sits MTPLX's own work that makes the lane fit a real machine, stay exact and stay on: the 32-row rows-gather fence, the pack-checked FR-Spec and stage-3 rules, the per-request memory gate, the allocator-cache gate fix, the n-gram pre-read reservation, the greedy pre-scatter fix, the verify-glue bound fix, the install receipts in /health, the house names, the decision to ship each item on by default, and every measurement below. Each part is credited where it appears.

Built here

  • The rows-gather fence is 32 rows. The 24-token copy blocks used to run dense attention because the old 8-row fence excluded them, which cost 5 to 10 ms per round at 206k and about 3 ms at 100k whenever the copy lane fired. Found on the shipped lane in the September 2 overnight window; it is the family default now.
  • FR-Spec drafting turns on only when the pack's native lm_head is Q8 group-64, and the stage-3 kernels only when every MoE layer carries the stage-3 quantization contract. Both are read from the pack's config.json at load instead of stamped blindly, so the Bare Speed packs, whose lm_head is 4-bit, never boot into a lane that cannot serve them. The fused K/V gather is derived from the resolved verifier and gather pair, so MTPLX_QSA_GATHER=0 never leaves it armed.
  • A per-request memory gate keeps the compiled lane off prompts whose bank promotion would not fit. The strict verifier promotes every QSA layer's state into padded banks at install, about 28 KB per context token that the plain lane never pays, and at 250k on a 128 GB Mac that adder had pushed a request into a 507 while the plain lane ran. The gate projects live memory plus the promotion against the Metal limit before any bank exists; on a 128 GB Mac that is roughly the last 20 percent of the context window, and such requests run the plain verify with the rest of the defaults. MTPLX_QWEN4_FIXED_M4_MAX_CONTEXT is the operator belt.
  • The gate no longer skips a long prompt because freed prefill scratch held by the allocator looked like live memory. After a 100k prefill the allocator cache sat at the 96 GiB cap and the gate read it as live, so 100k prompts on a 128 GB Mac fell to the plain verify. The gate now releases that cache when it stands between the request and the lane and reads again.
  • The pre-scatter draft read serves greedy requests; as ported, its claim read the runtime flag rather than the installed reader condition and declined under the relaxed-ties default. The verify glue's indexer contract carries its head-dim bound locally instead of importing a module the PR had and this tree does not, which had failed model load on the first port arm.
  • Every knob honors an explicit export, including =0, under house names (MTPLX_QWEN4_*, MTPLX_SESSION_BANK_*), and /health carries qwen4_install_reports so a default's engagement is readable without the serve log.

Adapted from PR #391 by @davidtai: the compiled verify lane

The compiled fixed-M4 verifier, batched target distributions, compiled MTP prepare, relaxed draft ties and the fused K/V gather are the family defaults for the Flash-Next geometry. Measured here against 2.10.2 on the same hour, copy lane on in both arms, at temperature 1, with the fence and the FR-Spec rule above and before the items below:

Context round time tok/s
16k (1,024 generated) -12% 52.6 to 60.3
100k -18.5% 47.5 to 51.8
206k -14% 32.2 to 44.2

Adapted from PR #391 by @davidtai: the decode and prefill items

The second half of the PR, ported onto the 2.11 tree and measured here on the same coding cells (ABBA pairs at temperature 1, copy lane on). Every item is exact: the port is token-identical to the tree before it at temperature 0, and every key yields to an explicit export, =0 included.

Item What it does His receipt
Two-kernel MoE routing head (MTPLX_QWEN4_ROUTE_KERNEL) folds the ten-dispatch router (q8 GEMV, softmax, top-10, renormalise, shared gate) into two Metal kernels, checked bit-exact per layer at install cycle -2.4% at 16k, every seed
Exact op diet (MTPLX_QWEN4_OPDIET) half-width RoPE table for the pooled-K bank write, one read-modify-write instead of slice_update plus where composed set -10.7% cycle
Pre-scatter draft read (MTPLX_QWEN4_DRAFT_K20_PRESCATTER) the draft's K20 support from the FR-Spec head's 65,536-row compact output instead of the 248,320-row scatter; serves greedy requests, since sampled drafts keep the relaxed-tie reader that the temperature-1 pair showed is worth more (2.68 to 2.75 tokens per round against 2.55) draft 5.1 to 4.9 ms
Block verification (MTPLX_QWEN4_BLOCK_VERIFY) the accept loop runs the block law (Sun et al. 2024) with a water-filled reach budget and the scaled residual; same target distribution, same number of uniforms +1.6 to 2.0% tokens per window
Fused QSA rope glue (MTPLX_QWEN4_VERIFY_GLUE) the RoPE table and both rotations of a QSA layer as one dispatch inside the compiled verify body; the indexer's query prep through the shipped fused kernel; bit-exact by construction and probed at install 312 dispatches fewer per cycle, -1.2%
M4 kernel trio (MTPLX_QWEN4_M4_ROUTED_DOWN_REDUCE, _RESIDUAL_TAIL, _ROUTED_GLU) routed-down reduction, residual-tail store and paired routed GLU as Metal kernels on the stage-3 lane about +2% together, bit-exact
PLE prefill lookahead (MTPLX_QWEN4_PLE_PREFILL_LOOKAHEAD) a worker hashes and page-warms chunk k+1's n-gram rows while chunk k's forward owns the GPU; also on the restored-suffix prefill every warm agent turn takes -2.0 s of a 14.7 s 16k prefill
First-chunk gather at arrival (MTPLX_QWEN4_PLE_FIRST_GATHER_EARLY) the first chunk's rows are gathered when the request arrives first-chunk gather 0.62 to 0.013 s
n-gram table pre-read (--ngram-prewarm auto) reads the hottest part of the 30 GiB sidecar table into the page cache at model load, within a budget that leaves the engine's growth and 6 GiB free cold page cache decode 56 to 68.8 tok/s
Session bank: boundary shedding and protected terminal (MTPLX_SESSION_BANK_SHED_BOUNDARIES, _PROTECTED_TERMINAL) a re-rendered agent turn (bullets swapped, fences trimmed) restores from the boundary snapshots instead of a cold re-prefill; the live session's newest entry is never the eviction victim re-rendered turn restores warm

Combined, same hour, temperature 1, copy lane on, against 2.10.2 and against 2.11 as first built:

Context 2.10.2 2.11 before this set 2.11 peak memory
16k + 1,024 48.2 ms, 53.2 tok/s 41.8 ms, 63.2 tok/s 39.2 ms, 68.4 tok/s (-18.6% round against 2.10.2, -6.2% against 2.11 as built); cold-prefill TTFT 15.1 to 14.3 s 89.4 GB, flat
100k + 512 54.5 ms, 47.5 tok/s 46.7 ms, 54.0 tok/s (plain verify: the allocator-cache defect above skipped the compiled lane) 43.4 ms, 60.9 tok/s (-20.4% round against 2.10.2, -7.2% against 2.11 as built, compiled lane engaged); cold-prefill TTFT 117.0 to 113.2 s 97.9 GB against 98.1 GB
206k + 512 32.2 tok/s 56.2 ms, 44.8 tok/s not re-measured after the release-night harness crash; at the 96 GiB envelope the ported tree ran the same per-round pace after a stall right after prefill that is still open, so 2.11 claims the first-half number at 206k and nothing more 105.3 GB on 2.11 as built

Held back from this release, with thanks and a note to @davidtai: the QSA sparse-decode kernel (a source-tree native extension; packaging), the HC_M4 hyper-connection read and the prefill mask fuse (rounding class, greedy divergence), the PCG64 uniform tape (a numpy pin), the graph-build overlap (it rides his D3 split-graph lane, which is not the shipped lane) and the expert-major weight census.

Qwen 3.8 27B: flash-decoding verify

On September 1 the 88k decode round on the 27B Optimized Speed pack was taken apart with the kernel clock. Of a 128 ms round, 38 ms was attention in the packed verify kernel (0.7 ms per layer at 16k, 2.4 ms at 88k), 54 ms was weight streaming, which does not move with context, and the rest was the head, small ops and the draft chain. Attention was the term that grew with context, so it got a new kernel.

  • sdpa_nax_flash is a TensorOps flash-decoding verify walk: the PV product reads V straight from device memory through the tensor unit's NN descriptor with no V-transpose staging, and the keys are split inside the threadgroup. A dim-split variant serves verify windows up to 32 rows and the key-split variant the wider ones; block counts step 64, 128, 256 and 512 with context. On the 72.7k walk bench the dim-split kernel runs 0.917 ms per layer against the shipped packed kernel's 1.421 (-35%) at half the power (24 W against 47 W); across 16k, 72.7k and 128k it is 2.07x, 1.58x and 1.58x faster per layer.
  • Serve receipts, die-matched pairs on the same prompts: 38.6 to 41.4 tok/s at 16k (+7%) and 20.5 to 23.8 tok/s at 88k (+16%) from the route alone. With the copy lane off at long context (MTPLX_CONTEXT_COPY=0) and batched target rows on (MTPLX_LAZY_TARGET_DISTRIBUTIONS=0 MTPLX_BATCH_TARGET_ARRAYS=1), the two long-context settings the same window measured, the 88k pair reads 20.5 to 30.9 tok/s (+51%) at a 101.8 ms round. Those two stay operator settings for now because both are null or slightly negative at 16k; the product shape for them is a context threshold, and that is a follow-up.
  • The route is on by default in the turbo profile for the dense 27B family (MTPLX_NAX_FLASH_ROUTE). Its contract gates (batch 1, head_dim 256, bf16 or fp16, up to 64 rows and a q_len of 10) bail to the previous routes unchanged, so other families and shapes are untouched, and MTPLX_NAX_FLASH_ROUTE=0 restores the old route.
  • The kernel changes the reduction order, not the math: its deviation from an fp32 reference is 1.0e-4 against the packed kernel's 8.7e-5. At temperature 0 that can move a near-tie argmax, the same class of difference any kernel change carries; the acceptance math is unchanged at every temperature.
  • Long answers can stay on the compiled bank with MTPLX_COMPILED_VERIFY_GROWTH_RESERVE=4096: a 900-token turn at 88k ran every round compiled at 28.6 tok/s against 24.8 with the default reserve, which demotes after about 510 generated tokens. Opt-in, because the reserve costs memory.
  • Closed in the same window, each with its receipt: draft depth 3 is the optimum on this head (a real depth 4 keeps 3.1 tokens per round and adds 11 percent to the round, depth 5 adds 24 percent); the host sync chain was never the cost (null at 16k, negative at 88k); eight exact five-row matmul kernels were written and none beat MLX's at five rows, because the 4-bit dequant is instruction-bound on this GPU; command-buffer count, K/V copies, weight formats and MLX buffer limits are null; the GPU clock follows the decode's own occupancy and no keeper lifts it.

Agent sessions: the dead time between tool turns is gone

A 43k-token OpenCode session on Flash-Next, measured on the live daemon before this release, spent 146 seconds of a 14-minute task waiting on the engine while its decode rounds ran at 65 to 70 tok/s the whole time: 5 to 8 seconds before the first token of every tool turn, hidden waits on the previous turn's cache snapshot, and two whole-turn re-prefills of 38 s and 18 s. That is what "21 tok/s" on a tool turn was made of. Five engine defects, all harness-independent, are fixed:

  • A request no longer starts a first-chunk n-gram gather it will never use and then waits on it when the session bank already holds the prompt. On warm turns whose prefill is 20 to 400 tokens the gather and the page-warm behind it (650k rows at 43k) were all waste, and the owner thread blocked on them at scope exit. Warm-turn dead time went from 5 to 7.5 s to 0.01 s, and the memory-pressure notices went with the page-warm storm.
  • The bank no longer decodes its own SSD copy on every warm turn (0.6 s each): the exact-prefix entry was excluded from the "RAM already serves this" bar, so the bar read zero and the cold row was hydrated unread.
  • A tool-call turn's final state is banked in place instead of being re-prefilled on the GPU behind the next request. Tool arguments are not byte-stable through parse, client and re-render (a file ending in a newline came back one token short), so 83 of 83 snapshots in the daemon's life had been refused; the committed post-think body is now substituted into the re-render when the turn's calls match, and a turn that ends in a tool call advances the session's committed stream like any other. On the same task the follow-up after a file write went from 3,535 tokens re-prefilled (3.7 s) to 20 tokens (0.12 s).
  • A pending snapshot that is still prefilling when its wait bound expires is followed to completion instead of being aborted and redone (MTPLX_POSTCOMMIT_WAIT_STALL_S, 15 s; ceiling MTPLX_POSTCOMMIT_WAIT_CEILING_S, 600 s). One expired bound had cost 30 s of waiting plus a 38 s re-prefill of the same 25k tokens.
  • A forced tool_choice, or any other per-request instruction, no longer changes the session's cache identity; it rides a trailing turn and the bank keys on token content alone. One forced round in a 41k session had re-prefilled it cold twice (41 s and 44 s); it takes 0.58 s.

A cold prompt of about 30k tokens could also end with finish_reason: "error" after its whole prefill: a benchmark-arm assertion in the prefill lookahead raised inside a user request when the sidecar declined a low-entropy first chunk. Serving records the verdict instead; MTPLX_QWEN4_PLE_PREFILL_LOOKAHEAD_STRICT=1 keeps the raise for measurement arms.

The new scripts/agent_session_gate.py drives this loop against a serving daemon (a long real-code prompt, short same-session turns, an auto tool round and a forced-choice round with their tool results) and fails on warm-turn dead time over 1 s, a warm first token over 1.5 s, a bank restore under 90 percent of the prompt, a postcommit wait over 2 s, a tool-call turn not banked in O(1), a decode drop past 20 percent, or any stream error. The release script runs it after the pillar gate. At 40k it passes end to end: warm turns 0.15 s to first token, 0.01 s dead time, 66 to 84 tok/s. docs/perf/agent-loop-cache-vs-vllm-20260903.md records what vLLM does differently and the architectural step that follows.

First token after a pause

macOS drops an idle process's GPU residency about 2.5 seconds after its last Metal command and rebuilds it lazily on the next one, at roughly 9 ms per GiB. On Flash-Next, with 77 GiB of weights, that put 0.75 to 1.0 s in front of the first token after any pause of a few seconds: every chat turn, every agent tool-call round trip, and every earlier release paid it too (about 0.2 s on the 27B). Measured from the app, "hi" took 1.17 s after a pause and 0.08 s back to back. The engine now keeps its working set resident with a sub-millisecond kernel on the model queue once per second while it is attentive (a request completed in the last 10 minutes, MTPLX_GPU_KEEPALIVE_ATTENTIVE_S), then parks. The same "hi" after 3 to 90 s of quiet: 0.08 s server time to first token, first text on screen in 0.26 s (was 0.87 s). /health reports gpu_keepalive, every request record carries whether it started warm, and MTPLX_GPU_KEEPALIVE=0 disables it.

Smooth streaming in the copy lane

When the model re-emits a previous answer (edit a file, change a color), the context-copy lane commits blocks of 24 tokens per round. The app's typewriter used to paste each block whole and then wait for the next round: two lines, a freeze, two lines. Three changes:

  • The server releases a block's tokens piece by piece across the round instead of one frame per block, so every client sees a flow (StreamScope copy-lane arm: 95th-percentile burst 58 characters to 9). MTPLX_STREAM_PACER=0 restores the old cadence.
  • The app's typewriter rates arrivals over a wall-clock window and types each block across the round, carrying a few characters into the next one. On the same two-turn flow the copy turn went from 65-character pastes at the 95th percentile to 25, with the longest on-screen pause down from 767 ms to 233 ms.
  • Frames that land within one display frame count as one arrival. The engine writes one frame per committed token, so a round landed as two frames 3 ms apart every 40 ms; the estimator read the 3 ms gaps as the cadence and revealed each round on the next tick, then idled. Text updated on 42 percent of display frames in chunks; it now updates on 75 percent at one frame per flush, and a 100-character copy block types over four frames instead of one.

The engine no longer replays the recurrent state after a fully accepted block, and the Route Tape records every copy-lane round on this family.

Long prompts on a 128 GB Mac

  • The n-gram table pre-read at model load reserves the engine's whole growth to its budget instead of the KV estimate alone. On a 128 GB Mac the automatic pre-read drops from 23.4 GiB to 14.2 GiB, which is what the page cache can still hold once a long prefill has grown the engine to its envelope; the old number left the kernel with no free pages at 206k and took the machine down during this release's measurements. The reservation is printed on the pre-read plan line and pinned by tests/test_ngram_prewarm_reservation.py.
  • An explicit MTPLX_MEMORY_LIMIT_BYTES is the engine budget both ways (#443, @yermakovm). The planner clamped the configured Metal limit under its own 75 percent envelope, so a 96 GB Mac serving the 69.2 GiB Flash-Next Bare Speed pack was refused as "does not fit" under a limit of 80G that runs it. The banner reads "(Metal limit)" and the refusal note names the lever; --memory-budget below the machine still wins, so a simulated smaller seat stays small.
  • mtplx serve --allow-swap (or MTPLX_ALLOW_SWAP=1 for the app and mtplx start) restores serving past the machine's memory fit for operators who accept swap (#427, @localbylocal). The default window is the model's own maximum again and prompts past the fit are admitted instead of refused with 507; the banner and /health report the overcommit.
  • The pre-prefill memory guard from 2.10.2 (#415) estimated a prompt's reusable prefix by exact match only. A follow-up turn that the session bank serves by block prefix, such as the turn after a forced tool call, read as a full miss near the memory line, and the guard evicted the session's own entry as superseded before the restore could use it: a 41,901-token turn re-prefilled cold in 54 s with a 41,391-token restore available. The guard now asks the bank the same question the restore does and pins such entries instead; the receipt names the estimate's mode. Found by the release script's own agent-session gate.
  • What did not change: the engine envelope on a 128 GB Mac stays at three quarters of the machine. A larger envelope was measured and rejected in this release. A stable 256k session on 128 GB is the next release's target, with the session bank yielding earlier under a long-context load.

macOS 27: long prompts no longer fail with a 500 (#404, #405, #407)

The macOS 27 Metal Performance Primitives header rejects the address-space-qualified cooperative-tensor operands the QSA sparse prefill kernels and the 27B flash-decoding route used, so 2.10.x on the macOS 27 betas answered every prompt past the 32k sparse-prefill crossover with a mid-request HTTP 500 ("Unable to build metal library from source"), on Flash-Next and the 27B Optimized packs alike. All seven kernel sites now use the address-space-neutral operand types, the pattern mlx's own Steel kernels use, and the server dispatches the real sparse-prefill pipeline once at startup: an SDK that refuses it degrades to dense prefill with a diagnostic instead of failing the request, and MTPLX_QSA_PREFILL=1 on such an SDK is reported rather than honored into a guaranteed failure. Credit: @mrmurphy for the first working patch, @sunnybluesea for the root cause, the three-site sweep and the macOS 27 receipts (a 70k prompt: 500 to 64.4 s; 95k: 500 to 84.9 s; bit-stable, 25 to 28 percent faster than the dense workaround), and reporters @DigiJoe79 (#405) and @rameshn007 (#407). Verified here on macOS 26 with the kernel parity suites; macOS 27 verification rests on the reporters' receipts until 27 enters release QA.

Light appearance (#428)

The app gains a light appearance: a cream ground with warm ink type, brighter cream cards and raised surfaces, graphite chrome that keeps the bevel shape, and a matching code palette. It is a curated palette, not an inversion of the dark one; every color pair is checked against WCAG AA (4.5:1 for body and semantic text, 3:1 for the quiet tier and chrome), and the dark palette ships byte-identical to 2.10. Settings > Behavior > Appearance offers System, Dark and Light. The default stays Dark, so an existing install looks exactly as before until you switch.

The app in twelve languages

  • Onboarding opens with a language step: a searchable list with flags covering English, Simplified Chinese, Spanish, Hindi, Arabic, Brazilian Portuguese, French, Russian, Japanese, German, Korean and Indonesian. The same picker lives in Settings, switches the whole app live, and Arabic runs the layout right-to-left.
  • Every string in the app goes through the localization layer; the English table is the source of truth and the fallback for anything a table lacks. Tests pin exact key parity across the twelve tables, placeholder parity, and that every key the code asks for exists in English.
  • Installs that finished onboarding on an earlier version are asked once, on the first launch after updating, so they learn the app speaks their language and where to change it. The pick applies live, dismissing the sheet by any route records the answer, and fresh installs never see it.
  • The Simplified Chinese table had a second pass: mistranslated technical terms and positional format arguments were repaired, and onboarding progress, the welcome pills, accessibility states and the updater command are localized.
  • Conversations are auto-titled in every language. The title guard compared against the English literal "New Chat", so users in the other eleven languages kept the placeholder forever; rows the old guard left untitled are named at launch from their first message.

App

  • ⌘V in the composer attaches what is on the clipboard: a screenshot or an image copied from a browser or Preview becomes an image attachment, a file copied in Finder becomes a document attachment (PDF, docx, md, txt, or an image file), exactly like the paperclip and drag-and-drop. Text still pastes as text, and ⌥⇧⌘V keeps the text rendition one keystroke away. Before this, an image-only clipboard left Paste disabled and ⌘V did nothing.
  • On a model without vision, a pasted or dropped image shows a card that says the model cannot see images instead of riding along silently.
  • The model picker's add row takes a local folder as well as a Hugging Face org/repo: a native folder chooser (or a typed path) is checked for a complete MTPLX install, remembered as a row in the picker, and selected in one step, so switching between models never means typing the directory again. The onboarding local-folder step gets the same chooser, and a folder that carries a catalog model's name is recognised as that model at a different location.
  • Settings gains a Memory card: a memory limit in GB and an allow-swap switch, carried into the daemon as MTPLX_MEMORY_LIMIT_BYTES and MTPLX_ALLOW_SWAP; the card shows the plan the engine computed for this Mac (#431 @Journey0723, #427 @localbylocal).
  • The Performance mode picked in Settings survives a model restart, Settings shows "Running now", and the log pane carries the scheduling the daemon launched with (#398, @variablefate).
  • The chat composer keeps its full height past ten lines and scrolls instead of jumping to the top (#424, PR #437 by @MohammedThowfiq).
  • A failed reply shows the server's error and offers Retry. The daemon's error frame (memory guard, context overflow, tool-loop exception) was decoded as an ordinary finish, so the turn read "Interrupted reply" with no message and no Retry. The message is shown, persisted with the turn, and labels the settled bubble.
  • A reply the daemon never finished is filed as incomplete. A stream that ended without its terminal chunk (process death, dropped connection) was persisted as a complete answer; it is now shown as interrupted, with Retry.
  • A failed web search or fetch is recorded as a failure. Offline or blocked providers came back as an empty result set marked success, the model was told "No results", and a failed fetch still added a source. Failures are marked on the live strip and the persisted trace, the model is told the tool failed, and no phantom source is added.
  • Attaching a file no longer freezes the app. Extraction (PDF page walk, docx unzip, image decode) runs off the main actor with a per-card spinner; a file that cannot be read stays on the strip with the reason instead of vanishing. New caps of 500 PDF pages and 200,000 characters per attachment are noted on the card and in the text the model sees.
  • The model-pack Update button no longer freezes the window while it resolves the runtime, walks the pack and spawns the CLI.
  • Play, Restart and first launch no longer wait on mtplx.com. Every daemon launch awaited the release manifest on a 60 s timeout, so offline, firewalled and captive-portal Macs sat in "Launching" for a minute per Play. The runtime decision is local, the manifest fetch is bounded to 3 s and refreshes the About card in the background, and first-run onboarding is decided from the saved settings alone.
  • A settings file with one bad value keeps every other setting. One wrong-typed field in settings.json used to make the whole file undecodable, re-run onboarding, and overwrite custom models, the API key and tuned records with defaults. Bad fields now fall back individually, a malformed custom model or tune record is skipped while its siblings load, and a file that cannot be read at all is kept beside itself as settings.json.unreadable-<stamp> with a banner and Reveal in Finder.
  • An unopenable chat store is kept, not silently swapped for memory. chats.store and its sidecars are renamed beside themselves, a fresh store starts, and the sidebar says so with Reveal in Finder; only if no store can be created does the session run in memory, and then the sidebar says that too. The chat models carry a versioned schema so the next model change has a migration path.
  • Unsaved Settings edits survive switching tabs. An "Unsaved changes" row offers Save, Apply and Restart, and Revert; Mode still saves on pick.
  • Esc on the chat surface does one thing: it stops a streaming reply, otherwise it closes the chat. Stop Generating in the menu moves to ⌘⇧. so the two bindings no longer race.
  • The download panel counts only the files the repo ships. It used to count every byte in the model folder, so a folder holding shards from a superseded revision or staging leftovers from an interrupted Hugging Face transfer showed more bytes than the model has, at 100 percent, while still downloading (a 47.38 GB of 18.52 GB reading on the 27B). The daemon's progress events, the resume decision and the disk headroom check now use the repo's manifest, and the app never prints past the repo size.
  • The app publishes its decode chip only when the reading changes, slows the metrics snapshots to 500 ms while a turn streams, and no longer schedules a redundant auto-scroll task, which removes several whole-transcript re-evaluations per second during generation.
  • The daemon reads its API key from a user-only file under Application Support instead of --api-key <key> on the command line, and the launched command line the app logs masks every key, token, secret and password value. The key used to be visible in the in-app Logs pane.
  • The browser dashboard opens through a single-use 60 s ticket the app asks the daemon for, instead of the API key in the URL, which had landed the key in browser history. The old form is used only when a daemon predates the route.

Downloads and packs

  • A resumed download can no longer splice a new commit's tail onto a stale partial. mtplx pull, and the app's downloader behind it, range-resumed any *.incomplete partial next to the target whichever commit had written it and accepted the result on size alone, so a pack repaired in place on the Hub could come back as a corrupt file that loaded. A transfer marker now records the blob every file is fetched from: a partial whose blob changed, or one nothing vouches for, is discarded; a landed file whose recorded blob changed is refetched; and every LFS file is hashed as it lands against the sha256 the Hub publishes.
  • Leftovers are reported instead of counted (stale_bytes and stale_files on the progress events, a leftovers line in mtplx pull), and a stray *.incomplete next to landed weights no longer keeps a byte-complete folder "partial" through every Retry.
  • mtplx models --update never removes a live file before the new pack is complete. Changed files are set aside as .stale, removed only after a complete download, and restored on any failure or interruption; a kill mid-update is recovered on the next run.
  • One Hugging Face token policy. mtplx pull, update checks, inspect and doctor agree: HF_TOKEN or HUGGING_FACE_HUB_TOKEN, then the token stored by hf auth login, else anonymous; a stored token the Hub refuses is retried anonymously so a stale login never breaks a public pull. Pulls previously ignored the login token that doctor reported as present, and doctor now prints which token a pull will use.
  • Published packs no longer name the machine that forged them. mtplx forge stamped the local trunk directory it was pointed at into mtplx_runtime.json, and the scrubber written to prevent that had no caller, so the flagship 27B packs carried a home directory. The stamper names a local trunk by its Hub identity, forge publish uploads scrubbed copies of every top-level JSON document that carries a local path, and mtplx model publish-check gains a no_local_paths gate with --scrub. The three affected packs on the Hub were re-pushed clean.

The local server is same-origin now

  • The server ran CORS with a wildcard origin and credentials, so on the default keyless localhost bind a page in any browser tab could POST to /v1/*, read the answers, list and clear sessions under /admin/*, and keep the GPU busy. Browser requests are now same-origin by default: the page's Origin must match the Host it used. --cors-origin (repeatable) or MTPLX_CORS_ORIGINS allowlists a front-end on another origin for the API, but never for /admin or sign-in; every other origin gets a 403 with no CORS headers. Requests without an Origin header (the app, OpenCode, Pi, Claude Code, Cline, curl) are untouched.
  • The dashboard's own sign-in posts the key in a same-origin body instead of a query string, the browser-auth cookie is Secure over https, and a 401 shows a sign-in prompt instead of "Connection to MTPLX lost".

Server and API

  • Flash-Next under the app's ar_batch lane no longer fails concurrent requests with a 500 (#420). mlx-lm's batch generator merges every batched prompt's caches and the family's QSA cache has no merge, so two concurrent requests raised an unhandled error while sequential ones were fine. The lane probes the model's cache family at startup, says so in the banner and in /health (scheduler.ar_batch_unavailable_reason), and serves concurrent requests one at a time on the solo lane. The 27B keeps batching; true batched decode for Flash-Next needs a QSA cache merge and is tracked in #420.
  • The Anthropic bridge carries image blocks (base64 or URL, also inside tool_result) to the vision tower instead of rendering them as base64 text (#441, reported with a patch by @pitcany). Claude Code's pasted screenshots and its Read tool on image files are seen instead of hallucinated at 13x to 52x the token cost; text-only requests are byte-identical to before.
  • A one-token answer capped by the client is diagnosable from the serve log (#436). Pi caps max_tokens at its model contextWindow minus a chars/4 estimate of the transcript and sends max_tokens=1 when the estimate overflows; the answer then stops after one token, often inside a tool call. The server logs one WARNING naming the client's cap, and the generation trace line carries max_tokens, effective_max_tokens and finish_reason. The fix on Pi's side is a larger contextWindow, not a smaller one.
  • The OpenAI endpoint accepts the off-ish reasoning_effort values some clients send (PR #433 by @sypsyp97).
  • A marathon postcommit is no longer aborted by a small request from another session: MTPLX_POSTCOMMIT_MARATHON_PROTECT_TOKENS reaches the cross-session abort with a bounded grace (#432, @nomishbhardwaj).
  • A streamed response closes within 30 s of its last token even when another request's prefill is queued ahead of its session snapshot (#425, @66duke66). MTPLX_STREAM_COMMIT_WAIT_MAX_S bounds the wait; the snapshot lands in the background and the next turn waits for it through the pending-postcommit path.
  • A stopping MTPLX daemon is no longer mistaken for a foreign process on its port, and a port you configured is never moved to +1 on a transient occupant (#409, @kmei3560; CLI lane, the app's own port handling is unchanged in this release).
  • finish_stop_origin reaches the public stats and the request log, so a stop's commit path is diagnosable from the log alone (#414, PR #426 by @atirna).
  • /health carries qwen4_install_reports: the stage-3 kernel report, the rope glue's per-item verdicts and the n-gram pre-read plan, so a default's engagement is readable without the serve log.
  • The flight recorder records the outcome of every generation-final snapshot attempt (mode, reason, both stream lengths, the divergence token), the committed-reasoning canonicalizer records why it stood aside, and MTPLX_DEBUG_POSTCOMMIT_MISMATCH_DIR=<dir> dumps the token windows around a refused snapshot.
  • Prose that quotes [Calling tool: no longer swallows the rest of the answer. The streaming filter held everything after the marker until a ] arrived and then dropped it at finish; the hold is now bounded by the call's own grammar, so real calls, including multi-line JSON arguments, are still hidden and ordinary text streams through.

CLI

  • A badly typed value in ~/.mtplx/config.toml no longer bricks every command. context_window = "64k" made status, list, config show and everything else exit with a traceback; that one key now falls back with a one-line warning naming it, and mtplx config set refuses a bad value plainly without writing.
  • Ctrl-C at a prompt or in a poll loop exits quietly with status 130 instead of a traceback.
  • mtplx bench works from any directory. Prompt suites resolve inside the installed package; a bare mtplx bench lists its actions.
  • First-run routing refuses what cannot run. An Intel Mac was told "selected because this is not Apple Silicon" and handed a 27B download, a Mac whose memory could not be read got the 27B, and every Mac under 32 GiB got the 9B. Intel and too-small Macs now get one plain sentence and exit 1 before any download, unreadable memory selects the smallest pack and says why, and under 32 GiB the CLI names the same pack the app's picker lists first (the 9B from 16 GiB, the 4B below that).
  • The curl installer no longer writes into Homebrew's bin, where it used to copy over the mtplx symlink and replace the Cellar program. The global launcher is opt-in (MTPLX_GLOBAL_BIN), and a launcher this installer did not create is never replaced.
  • An opencode.json or Pi models.json MTPLX cannot read is left alone. Both apps accept JSONC (comments, trailing commas), and MTPLX now reads them the same way and merges; a file that still does not parse is left untouched with a message naming the file and position instead of being moved aside and replaced. A rewrite keeps the previous file as <name>.before-mtplx-<stamp>.bak and connect prints where it wrote.
  • The attached terminal chat gives up on a daemon that stops responding (5 s to connect, 120 s with nothing on the wire) with a plain error and exit 1 instead of hanging forever.
  • Passwordless-sudo setup validates the rule before installing it. The rule is character-checked (an unescaped space silently turned the rest of a path into an argument, and visudo accepted it), parsed by visudo -c -f on a temp file, and installed by one privileged script that names the thermalforge binary fan control actually runs; a missing binary is reported plainly, every sudo call is bounded, and a no-tty session says to run mtplx max --grant-sudo in a terminal.
  • The fan-restore sidecar clears its marker only after the fans verifiably return to auto, and writes what it did to ~/.mtplx/logs/thermal-sidecar.log. After a SIGKILL of a --max session the fans could stay at maximum with no message.

Browser dashboard

  • The Speculative tab's hard-coded vLLM comparison is gone, and the Thermal tab no longer shows an internal benchmarking rule during every generation on a default install.
  • Settings sliders send only the keys you moved and follow the server otherwise; they used to write a stale draft back over values changed from the native app.
  • The live TPS gauge goes idle when the server does instead of pinning the last request's speed.
  • A 401 is a sign-in prompt, not a lost connection.

Community

Five community pull requests landed this cycle, each adapted and measured before shipping: the Flash-Next compiled verify lane and the kernel and prefill items adapted from PR #391 by @davidtai (above), a vendored Steel sparse-GQA prefill consumer for M3 machines (PR #423 by @humanrouter, opt-in below), finish_stop_origin in the public stats (PR #426 by @atirna), the off-ish reasoning_effort values (PR #433 by @sypsyp97), and the composer height fix (PR #437 by @MohammedThowfiq). The macOS 27 fix rests on @mrmurphy's patch and @sunnybluesea's root cause, and the image-block fix on @pitcany's report. Thank you.

Experimental, off by default

Everything here changes nothing unless exported.

  • MTPLX_CONTEXT_COPY=0 and MTPLX_LAZY_TARGET_DISTRIBUTIONS=0 MTPLX_BATCH_TARGET_ARRAYS=1: the two long-context 27B settings behind the 88k pair above; null or slightly negative at 16k.
  • MTPLX_COMPILED_VERIFY_GROWTH_RESERVE=4096: keeps 27B answers past about 510 tokens on the compiled bank, at a memory cost.
  • The Steel sparse-GQA prefill consumer (PR #423 by @humanrouter) ships as an opt-in native extension, not yet in the app bundle. On this M5 Max it prefilled a 71k prompt in 93 s against the flash tier's 105 s; the tier order and a temperature-0 identity check are the next step before any default.
  • --ngram-prewarm all|off|<GiB>: pins the n-gram pre-read instead of the automatic budget.
  • MTPLX_QWEN4_PLE_PREFILL_LOOKAHEAD_STRICT=1: turns the prefill lookahead's engagement verdicts back into hard failures for measurement arms.
  • MTPLX_QWEN4_FIXED_M4_MAX_CONTEXT: pins the compiled lane's memory gate to a context instead of the per-request projection.
  • The items held back from PR #391 (listed in its section) remain on David's branch, not in this tree.

Updating

  • App: Sparkle offers 2.11 automatically, or download the DMG at mtplx.com.
  • CLI: pip install -U mtplx or brew upgrade mtplx.
Get it. Download the current DMG, or brew upgrade mtplx / pip install -U mtplx. Every published speed number with its conditions is on the benchmarks page; the archive of every version is on the releases page.