mlx-bun 0.0.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +40 -24
  2. package/bin/mlx-bun.mjs +1 -1
  3. package/docs/{design → archive}/hlg-sampling.md +3 -3
  4. package/docs/{investigations → archive/investigations}/hlg-sampling-investigation.md +1 -1
  5. package/docs/archive/investigations/pareto-specialized-runtime-findings.md +92 -0
  6. package/docs/{planning → archive/planning}/chat-ui-vision.md +1 -1
  7. package/docs/archive/planning/release-notes-v0.0.13.md +77 -0
  8. package/docs/{planning → archive/planning}/release-notes-v0.0.9.md +1 -1
  9. package/docs/{planning → archive/planning}/website-readme-pass-plan.md +1 -1
  10. package/docs/design/colibri-glm52-port.md +8 -1
  11. package/docs/design/compat-cli-surface-design.md +1 -1
  12. package/docs/design/decode-speed-program.md +1 -1
  13. package/docs/design/docs-reorg-plan.md +1 -1
  14. package/docs/design/dspark-serving-program.md +1 -1
  15. package/docs/design/dspark-speculative-decoding.md +53 -3
  16. package/docs/design/grammar-spec-batching-integration.md +1 -1
  17. package/docs/design/mlx-lm-serving-execution-seam.md +81 -0
  18. package/docs/design/orpo-dynamic-lambda.md +1 -1
  19. package/docs/design/orpo-future-enhancements.md +1 -1
  20. package/docs/design/orpo-training.md +2 -2
  21. package/docs/design/parity-tier-dag.md +1 -1
  22. package/docs/design/repo-cleanup-plan.md +46 -4
  23. package/docs/design/segmented-backward-training.md +2 -2
  24. package/docs/design/ssd-kv-cold-tier.md +127 -0
  25. package/docs/design/turboquant-weights.md +467 -0
  26. package/docs/design/w4a16-compute-precision-spike.md +1 -1
  27. package/docs/design/web-chat-redesign.md +5 -5
  28. package/docs/planning/chunk-task-roadmap.md +1 -1
  29. package/docs/planning/curve-sampler-research-plan.md +4 -4
  30. package/docs/planning/memory-docs-and-dag-plan.md +1 -1
  31. package/docs/planning/release-notes-v0.2.0.md +49 -0
  32. package/docs/planning/video-series.md +421 -0
  33. package/docs/reference/cli.md +15 -4
  34. package/docs/reference/distribution.md +1 -0
  35. package/docs/reference/features-matrix.md +3 -1
  36. package/docs/reference/library-api.md +41 -1
  37. package/docs/reference/models.md +33 -0
  38. package/docs/reference/server-api.md +86 -19
  39. package/docs/reference/server-config.md +57 -33
  40. package/package.json +2 -2
  41. package/src/anthropic.ts +92 -3
  42. package/src/chat-template.ts +34 -1
  43. package/src/cli.ts +131 -36
  44. package/src/config.ts +10 -0
  45. package/src/eval/kl.ts +27 -20
  46. package/src/eval/runner.ts +23 -4
  47. package/src/eval/tasks/bfcl.ts +2 -1
  48. package/src/eval/tasks/gsm8k.ts +2 -1
  49. package/src/eval/tasks/hashhop.ts +2 -1
  50. package/src/eval/tasks/humaneval.ts +5 -4
  51. package/src/eval/tasks/ifeval.ts +2 -1
  52. package/src/eval/tasks/mmlu.ts +2 -1
  53. package/src/expert-io.ts +3 -1
  54. package/src/expert-offload.ts +3 -1
  55. package/src/expert-trace.ts +3 -1
  56. package/src/fit.ts +24 -43
  57. package/src/flags.ts +7 -9
  58. package/src/generate.ts +107 -92
  59. package/src/grammar.ts +2 -1
  60. package/src/harness-pi.ts +1 -1
  61. package/src/index.ts +20 -0
  62. package/src/jobs/job-entry.ts +3 -2
  63. package/src/kv-scheme.ts +199 -0
  64. package/src/kv-store.ts +370 -205
  65. package/src/media-fetch.ts +19 -3
  66. package/src/memory/model.ts +2 -1
  67. package/src/memory/vault.ts +2 -1
  68. package/src/memory-plan.ts +18 -0
  69. package/src/mlx/ffi.ts +8 -11
  70. package/src/mlx/ops.ts +31 -6
  71. package/src/model/batched-mask.ts +1 -1
  72. package/src/model/batched-quant.ts +1 -1
  73. package/src/model/batched-rotating-quant.ts +43 -97
  74. package/src/model/batched-rotating-state.ts +73 -0
  75. package/src/model/batched-rotating.ts +56 -105
  76. package/src/model/batched-row-storage.ts +127 -0
  77. package/src/model/compiled-decode.ts +3 -2
  78. package/src/model/factory.ts +44 -49
  79. package/src/model/gemma4-base.ts +163 -10
  80. package/src/model/generated/gemma4-12b.ts +50 -146
  81. package/src/model/generated/gemma4-26b.ts +32 -92
  82. package/src/model/generated/gemma4-e4b.ts +26 -74
  83. package/src/model/glm52-cache.ts +19 -9
  84. package/src/model/glm52-memory.ts +28 -1
  85. package/src/model/minicpm5.ts +2 -1
  86. package/src/model/profile.ts +433 -0
  87. package/src/model/qwen3-delta.ts +98 -2
  88. package/src/model/qwen3-mrope.ts +252 -0
  89. package/src/model/qwen3_5.ts +217 -25
  90. package/src/native/frame_extract.swift +140 -0
  91. package/src/native-pack.ts +13 -6
  92. package/src/pi-web.ts +6 -5
  93. package/src/prompt-cache.ts +2 -2
  94. package/src/quantize/atomic-output.ts +38 -0
  95. package/src/quantize/calibration.ts +2 -1
  96. package/src/quantize/config-writer.ts +6 -0
  97. package/src/quantize/index.ts +28 -1
  98. package/src/quantize/job.ts +11 -0
  99. package/src/quantize/quantizer.ts +265 -100
  100. package/src/quantize/rotate.ts +603 -0
  101. package/src/quantize/safetensors-writer.ts +79 -1
  102. package/src/quantize/sensitivity.ts +41 -59
  103. package/src/quantize/weight-transform.ts +292 -0
  104. package/src/responses.ts +102 -2
  105. package/src/runtime-config.ts +67 -0
  106. package/src/sampler.ts +272 -0
  107. package/src/serve/admin-routes.ts +184 -0
  108. package/src/serve/aux-routes.ts +132 -0
  109. package/src/serve/batch-scheduler.ts +200 -87
  110. package/src/serve/completion-executor.ts +346 -0
  111. package/src/serve/completion-sink.ts +157 -0
  112. package/src/serve/discovery-routes.ts +205 -0
  113. package/src/serve/generation-gateway.ts +116 -109
  114. package/src/serve/kv-budget.ts +14 -0
  115. package/src/serve/lab-routes.ts +338 -0
  116. package/src/serve/model-admin-routes.ts +153 -0
  117. package/src/serve/prompt-response-trace.ts +147 -0
  118. package/src/serve/request-plan.ts +158 -0
  119. package/src/serve/static-routes.ts +101 -0
  120. package/src/server.ts +768 -1367
  121. package/src/spec/dflash-source.ts +2 -1
  122. package/src/spec/dspark/module-dflash.ts +1 -1
  123. package/src/spec/glm52-mtp-source.ts +8 -0
  124. package/src/spec/qwen-mtp-source.ts +431 -0
  125. package/src/spec/serve-loop.ts +36 -68
  126. package/src/ssd-cache.ts +17 -14
  127. package/src/ssd-durability.ts +195 -0
  128. package/src/train/flash-cce.ts +12 -11
  129. package/src/train/forward.ts +32 -1
  130. package/src/train/loss.ts +2 -1
  131. package/src/train/segmented.ts +4 -3
  132. package/src/train/trainer.ts +3 -2
  133. package/src/vision/preprocess.ts +1 -1
  134. package/src/vision/prompt.ts +47 -1
  135. package/src/vision/qwen3vl-preprocess.ts +409 -0
  136. package/src/vision/qwen3vl-prompt.ts +196 -0
  137. package/src/vision/qwen3vl-tower.ts +513 -0
  138. package/src/vision/video-frames.ts +148 -0
  139. package/src/web/app.js +10 -3
  140. package/src/web/src/chat.ts +12 -5
  141. package/src/weights.ts +81 -26
  142. /package/docs/{investigations → archive/investigations}/chunk-finetune-experiment.md +0 -0
  143. /package/docs/{investigations → archive/investigations}/colibri-oracle-pin.md +0 -0
  144. /package/docs/{investigations → archive/investigations}/curve-bisector-routing.md +0 -0
  145. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-route.json +0 -0
  146. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-trace.json +0 -0
  147. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.json +0 -0
  148. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.svg +0 -0
  149. /package/docs/{investigations → archive/investigations}/curve-terrain.html +0 -0
  150. /package/docs/{investigations → archive/investigations}/decode-roofline-lookagain.md +0 -0
  151. /package/docs/{investigations → archive/investigations}/dspark-handoff.md +0 -0
  152. /package/docs/{investigations → archive/investigations}/expert-offload-single-user-moe.md +0 -0
  153. /package/docs/{investigations → archive/investigations}/hlg-ab.html +0 -0
  154. /package/docs/{investigations → archive/investigations}/hlg-explorer.html +0 -0
  155. /package/docs/{investigations → archive/investigations}/hlg-figs/cliff.svg +0 -0
  156. /package/docs/{investigations → archive/investigations}/hlg-figs/cross.svg +0 -0
  157. /package/docs/{investigations → archive/investigations}/hlg-figs/edges.svg +0 -0
  158. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-grids.svg +0 -0
  159. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-peaks.svg +0 -0
  160. /package/docs/{investigations → archive/investigations}/hlg-figs/lw-curve.svg +0 -0
  161. /package/docs/{investigations → archive/investigations}/hlg-figs/terrain.svg +0 -0
  162. /package/docs/{investigations → archive/investigations}/hlg-report.html +0 -0
  163. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-ab.json +0 -0
  164. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind-prompts.md +0 -0
  165. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind.json +0 -0
  166. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cliff.json +0 -0
  167. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cross.json +0 -0
  168. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-edge.json +0 -0
  169. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-eval1.json +0 -0
  170. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-frontier.json +0 -0
  171. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid.json +0 -0
  172. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid2.json +0 -0
  173. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-lw-coarse.json +0 -0
  174. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-map.json +0 -0
  175. /package/docs/{investigations → archive/investigations}/hlg-runs/run0-original.log +0 -0
  176. /package/docs/{investigations → archive/investigations}/hlg-runs/runA-recommended.log +0 -0
  177. /package/docs/{investigations → archive/investigations}/hlg-runs/runAA-cliff-cpm5.log +0 -0
  178. /package/docs/{investigations → archive/investigations}/hlg-runs/runAB-grid2-e4b.log +0 -0
  179. /package/docs/{investigations → archive/investigations}/hlg-runs/runAC-peak-e4b-partial.log +0 -0
  180. /package/docs/{investigations → archive/investigations}/hlg-runs/runAD-ab-samples.log +0 -0
  181. /package/docs/{investigations → archive/investigations}/hlg-runs/runAE-edge-e4b.log +0 -0
  182. /package/docs/{investigations → archive/investigations}/hlg-runs/runAF-logit-calibration.log +0 -0
  183. /package/docs/{investigations → archive/investigations}/hlg-runs/runAG-eval-config2.log +0 -0
  184. /package/docs/{investigations → archive/investigations}/hlg-runs/runB-gain-variants.log +0 -0
  185. /package/docs/{investigations → archive/investigations}/hlg-runs/runC-pivots.log +0 -0
  186. /package/docs/{investigations → archive/investigations}/hlg-runs/runD-oetf.log +0 -0
  187. /package/docs/{investigations → archive/investigations}/hlg-runs/runE-eotf.log +0 -0
  188. /package/docs/{investigations → archive/investigations}/hlg-runs/runF-shaper-working.log +0 -0
  189. /package/docs/{investigations → archive/investigations}/hlg-runs/runG-shaper-sweep.log +0 -0
  190. /package/docs/{investigations → archive/investigations}/hlg-runs/runH-wsweep.log +0 -0
  191. /package/docs/{investigations → archive/investigations}/hlg-runs/runI-asweep.log +0 -0
  192. /package/docs/{investigations → archive/investigations}/hlg-runs/runJ-knobmap.log +0 -0
  193. /package/docs/{investigations → archive/investigations}/hlg-runs/runK-frontier.log +0 -0
  194. /package/docs/{investigations → archive/investigations}/hlg-runs/runL-widemap.log +0 -0
  195. /package/docs/{investigations → archive/investigations}/hlg-runs/runM-frontier-emb.log +0 -0
  196. /package/docs/{investigations → archive/investigations}/hlg-runs/runN-verify.log +0 -0
  197. /package/docs/{investigations → archive/investigations}/hlg-runs/runO-frontier-seed5000.log +0 -0
  198. /package/docs/{investigations → archive/investigations}/hlg-runs/runP-grid-AxSm.log +0 -0
  199. /package/docs/{investigations → archive/investigations}/hlg-runs/runQ-grid-tgW.log +0 -0
  200. /package/docs/{investigations → archive/investigations}/hlg-runs/runR-blind-transcript.log +0 -0
  201. /package/docs/{investigations → archive/investigations}/hlg-runs/runS-cross-coherence.log +0 -0
  202. /package/docs/{investigations → archive/investigations}/hlg-runs/runT-cross-cpm5.log +0 -0
  203. /package/docs/{investigations → archive/investigations}/hlg-runs/runU-cross-12b.log +0 -0
  204. /package/docs/{investigations → archive/investigations}/hlg-runs/runV-lw-e4b.log +0 -0
  205. /package/docs/{investigations → archive/investigations}/hlg-runs/runW-lw-12b.log +0 -0
  206. /package/docs/{investigations → archive/investigations}/hlg-runs/runX-lw-cpm5.log +0 -0
  207. /package/docs/{investigations → archive/investigations}/hlg-runs/runY-cliff-e4b.log +0 -0
  208. /package/docs/{investigations → archive/investigations}/hlg-runs/runZ-cliff-12b.log +0 -0
  209. /package/docs/{investigations → archive/investigations}/indexshare-performance-spike.md +0 -0
  210. /package/docs/{investigations → archive/investigations}/kernel-perf-review-2026-07.md +0 -0
  211. /package/docs/{investigations → archive/investigations}/lab-build-journal.md +0 -0
  212. /package/docs/{investigations → archive/investigations}/openwiki-evaluation.md +0 -0
  213. /package/docs/{investigations → archive/investigations}/orpo-base-uf-experiment-and-directions.md +0 -0
  214. /package/docs/{investigations → archive/investigations}/orpo-flash-cce-pin-leak.md +0 -0
  215. /package/docs/{investigations → archive/investigations}/orpo-uf-testing-handoff.md +0 -0
  216. /package/docs/{investigations → archive/investigations}/pi-builtin-investigation.md +0 -0
  217. /package/docs/{investigations → archive/investigations}/segmented-backward-handoff.md +0 -0
  218. /package/docs/{investigations → archive/investigations}/starter-model-port-handoff.md +0 -0
  219. /package/docs/{investigations → archive/investigations}/steel-flash-cce-handoff.md +0 -0
  220. /package/docs/{investigations → archive/investigations}/trainer-validation-experiment.md +0 -0
  221. /package/docs/{planning → archive/planning}/chat-ui-vision-memory-first.md +0 -0
  222. /package/docs/{planning → archive/planning}/journal.md +0 -0
  223. /package/docs/{planning → archive/planning}/release-notes-v0.0.10.md +0 -0
  224. /package/docs/{planning → archive/planning}/release-notes-v0.0.11.md +0 -0
  225. /package/docs/{planning → archive/planning}/release-notes-v0.0.12.md +0 -0
  226. /package/docs/{planning → archive/planning}/web-ui-pass-plan.md +0 -0
@@ -50,14 +50,27 @@ Request body (OpenAI chat schema; unknown fields ignored):
50
50
  "tools": [ /* OpenAI function tools */ ],
51
51
  "tool_choice": "auto", // "none" disables tools
52
52
  "chat_template_kwargs": { // forwarded to the chat template
53
- "enable_thinking": false // MiniCPM5 / Qwen3.5 / GLM-5.2: think channel
53
+ "enable_thinking": false, // MiniCPM5 / Qwen3.5/3.8 / GLM-5.2: <think>
54
+ // channel on/off (Qwen3.8 default: ON)
55
+ "preserve_thinking": true // Qwen3.8: keep think blocks from earlier
56
+ // assistant turns in the rendered prompt
57
+ // (template default true — better prompt-
58
+ // cache reuse in agent loops). Ignored by
59
+ // templates that don't read it.
54
60
  },
55
- "reasoning_effort": "medium", // "none"|"minimal"|"low"|"medium"|"high"
56
- // gates enable_thinking on Qwen3.5/MiniCPM5:
57
- // "none" → off, any other level → on. Only
58
- // consulted when chat_template_kwargs
59
- // .enable_thinking is not explicitly set —
60
- // an explicit enable_thinking always wins.
61
+ "reasoning_effort": "medium", // "none"|"minimal"|"low"|"medium"|"high"|"xhigh"
62
+ // gates enable_thinking on Qwen3.5/3.8/
63
+ // MiniCPM5: "none" → off, any other level →
64
+ // on. Only consulted when chat_template_
65
+ // kwargs.enable_thinking is not explicitly
66
+ // set — an explicit enable_thinking always
67
+ // wins. On templates with a reasoning-depth
68
+ // variable (Qwen3.8) the level also maps
69
+ // into the template: minimal/low → low,
70
+ // medium → medium, high/xhigh → xhigh.
71
+ // Any OTHER string is a 400 — a typo used
72
+ // to silently force thinking ON
73
+ // (Qwen3.8's default depth is xhigh).
61
74
  "hlg": { // HLG tone-curve sampling (per request).
62
75
  "enabled": true, // merged over --hlg-sampling server defaults.
63
76
  "width": 4, // logit-width of the tone plateau
@@ -95,16 +108,37 @@ Message `content` is a string or an array of parts:
95
108
  (http/https URLs also accepted; PNG, JPEG, HEIC, AVIF, WebP, TIFF, GIF,
96
109
  BMP via native OS codecs; requires a model with the vision sidecar).
97
110
 
98
- Remote (http/https) media URLs — image and audio alike — go through a
99
- destination policy: **private/loopback/link-local hosts are refused by
100
- default** (the request URL is attacker-controlled input — SSRF), every
101
- redirect hop is re-validated, and the fetch has a **10 s timeout** and a
102
- **64 MB response cap**. Violations are clean `400`s
111
+ Remote (http/https) media URLs — image, audio, and video alike — go
112
+ through a destination policy: **private/loopback/link-local hosts are
113
+ refused by default** (the request URL is attacker-controlled input —
114
+ SSRF), every redirect hop is re-validated, and the fetch has a **10 s
115
+ timeout** and a **64 MB response cap** (video: **256 MB** — clips are
116
+ legitimately larger; frame sampling truncates long clips server-side).
117
+ Violations are clean `400`s
103
118
  (`prompt build failed: image url rejected: …`). Serving media from a LAN
104
119
  host (a NAS, another machine) is the opt-in
105
120
  [`--allow-private-media`](server-config.md#start-flags) flag. `data:`
106
121
  URLs are decoded locally and never policy-checked.
107
122
 
123
+ ### Video input (Qwen3.5-family models)
124
+
125
+ Video rides the same content-part array:
126
+
127
+ ```jsonc
128
+ { "type": "video_url", "video_url": { "url": "data:video/mp4;base64,…|http(s)://…" } }
129
+ { "type": "video", "data": "<base64>" } // alias
130
+ ```
131
+
132
+ The container decodes through macOS's own AVFoundation codecs (mp4/mov,
133
+ H.264/HEVC/ProRes/… — whatever this macOS plays) via the
134
+ `mlx-bun-frame-extract` helper that ships beside the binary and in the
135
+ native pack; from a source tree it compiles on first use with the Xcode
136
+ CLT. Frames are sampled at **2 fps, capped at 768 frames**, then run the
137
+ gated Qwen3-VL video pipeline (temporal frame pairs, per-frame-group
138
+ attention, 3D mRoPE positions). Qwen3.5-family models only; video never
139
+ combines with audio parts, and a non-Qwen model answers video parts with
140
+ a clean `400`.
141
+
108
142
  ### Audio input
109
143
 
110
144
  Audio rides the same content-part array (OpenAI-canonical shape plus two
@@ -177,7 +211,7 @@ Non-streaming response:
177
211
  "prompt_tokens_details": { "cached_tokens": 0 }, // prompt-cache reuse
178
212
  "lane": "serial", // "serial" | "serial+spec" | "batched" —
179
213
  // which execution lane served THIS
180
- // request (GenerationGateway.willBatch
214
+ // request (GenerationGateway.place
181
215
  // + whether a draft actually contributed
182
216
  // accepted tokens). Chat completions
183
217
  // only (streaming: final usage chunk).
@@ -333,11 +367,11 @@ All errors are `{ "error": { "message": …, ... } }`.
333
367
  `top_logprobs must be of type int` / `at least 0` / `at most 11`;
334
368
  `top_logprobs: -1` is the accepted "unset" sentinel).
335
369
  - `400` with `"type": "memory_admission"`, `"code":
336
- "context_over_budget"` — `prompt + max_tokens` exceeds the memory
337
- budget's max safe context (only when serving with `--memory-budget`;
338
- the GPU OOM this prevents would kill the process, so it is refused
339
- up front). Lower `max_tokens` or shorten the prompt; the ceiling is
340
- visible at `/stats`.
370
+ "context_over_budget"` — the PROMPT itself leaves no generation slot
371
+ within the max safe context. A prompt that fits is never rejected for a
372
+ broad `max_tokens`: the upper bound is capped to the remaining room
373
+ (`max_tokens` is a ceiling, not a promise) and generation proceeds. The
374
+ ceiling is visible at `/stats`.
341
375
 
342
376
  ## POST /v1/completions (raw text completion)
343
377
 
@@ -569,7 +603,9 @@ vision/audio, adapters, and training are not emulated by the serving port.
569
603
  "ssd_cache": { "dir": "...", "entries": 0, "bytes": 0, "max_bytes": 0,
570
604
  "restores": 0, "spills": 0, "restore_ms_last": 0, "demotions": 0,
571
605
  // bounded write-behind queue (MLX_BUN_SSD_SPILL_QUEUE_GB):
572
- "pending_spills": 0, "pending_spill_bytes": 0, "dropped_spills": 0 },
606
+ "pending_snapshots": 0, "pending_spills": 0,
607
+ "pending_spill_bytes": 0, "dropped_spills": 0,
608
+ "failed_spills": 0, "longest_durable_prefix_tokens": 0 },
573
609
  "admission": {
574
610
  "max_safe_context": 0, // tokens; requests above this 400
575
611
  "memory_budget_bytes": null, // explicit budget, or null (machine default)
@@ -619,6 +655,37 @@ vision/audio, adapters, and training are not emulated by the serving port.
619
655
  }
620
656
  ```
621
657
 
658
+ `pending_snapshots` counts prompt-cache entries scheduled for persistence but
659
+ not yet confirmed by the atomic SSD store. `longest_durable_prefix_tokens` is
660
+ the longest prefix already present in the SSD index, not merely queued.
661
+
662
+ ## POST /admin/cache/flush
663
+
664
+ Forces every pending SSD prompt-cache snapshot through the serial write queue
665
+ and waits for the atomic temp-file/fsync/rename boundary. The response is `200`
666
+ only when no snapshot or spill remains pending and no write failed during this
667
+ flush; otherwise it is `503` with the counters that explain why.
668
+
669
+ ```jsonc
670
+ {
671
+ "durable": true,
672
+ "flushedSnapshots": 1,
673
+ "missingSnapshots": 0,
674
+ "pendingSnapshots": 0,
675
+ "pendingSpills": 0,
676
+ "pendingSpillBytes": 0,
677
+ "droppedSpills": 0,
678
+ "failedSpills": 0,
679
+ "elapsedMs": 842,
680
+ "entries": 2,
681
+ "longest_durable_prefix_tokens": 16384
682
+ }
683
+ ```
684
+
685
+ The standard benchmark calls this endpoint before its restart leg and records
686
+ the result. Normal `mlx-bun serve` shutdown on `SIGINT` or `SIGTERM` performs
687
+ the same flush after active requests drain, with a 120-second default timeout.
688
+
622
689
  ## GET /library
623
690
 
624
691
  Returns all models found in the local HuggingFace hub cache (via the
@@ -9,8 +9,8 @@ adapters admin) see [server-api.md](./server-api.md); this doc is about
9
9
 
10
10
  The flags are parsed in `serverRuntimeFlags()`
11
11
  ([src/cli.ts](../../src/cli.ts)); the runtime knobs live in
12
- `ServerOptions` ([src/server.ts](../../src/server.ts)). The two execution
13
- lanes (serial vs. `--batch N`) and the lane picker are in
12
+ `ServerOptions` ([src/server.ts](../../src/server.ts)). The serial and
13
+ continuous scheduling mechanisms and their declaration are in
14
14
  [src/serve/generation-gateway.ts](../../src/serve/generation-gateway.ts);
15
15
  the design rationale is in
16
16
  [docs/design/parallel-slots.md](../design/parallel-slots.md).
@@ -21,21 +21,21 @@ the design rationale is in
21
21
  | --- | --- | --- | --- |
22
22
  | `--host` | addr | `127.0.0.1` | Interface to bind. Loopback-only by default (mlx_lm.server parity); pass `--host 0.0.0.0` to expose the server on your network. |
23
23
  | `--port` | n | `8080` | Listen port. A pre-flight probe refuses to start if the port is already serving. |
24
- | `--memory-budget` | GB | machine RAM × 0.75; GLM-5.2: min(25 GiB, physical RAM) | Admission ceiling. Requests whose `prompt + max_tokens` exceed the budget's max safe context are **rejected with 400** (`type: memory_admission`) instead of risking an uncatchable GPU OOM. GLM-5.2 runs its header-only exact process equation before opening resident weights or expert slabs, and caps MLX to that plan's allocator reserve. **Decimal GB (×10⁹).** |
24
+ | `--memory-budget` | GB | machine RAM × 0.75; GLM-5.2: min(25 GiB, physical RAM) | Admission ceiling. A request whose prompt fits is always admitted: a broad client `max_tokens` is capped to the room remaining under the safe context (`max_tokens` is a ceiling, not a promise), so agent clients with family-wide defaults never 400 over a small overshoot. Only a prompt that leaves no generation slot is rejected with **400** (`type: memory_admission`) — the alternative is an uncatchable GPU OOM. The safe-context ceiling bills the active `--kv-quant` scheme at its true bytes/element (a 4-bit cache admits the ~3.5× larger window it actually enables; `turbo` is still billed bf16, conservatively). Admission never refuses to *start*: a budget too small for any context logs a warning and serves anyway (requests then get the per-request 400). GLM-5.2 additionally runs its exact process equation before opening resident weights or expert slabs and caps MLX to the plan's allocator reserve. **Decimal GB (×10⁹).** |
25
25
  | `--prompt-cache` | GB | `8` | RAM prefix-KV cache (byte-capped LRU; `0` disables). **PREFIX SHARING (2026-07-05): serves are NON-CONSUMING** — a hit hands out zero-copy clones and leaves the donor entry intact, so N agents (or a brand-new session) sharing a system prompt all reuse ONE prefill without destroying each other's conversation entries (the old consume-and-trim take cannibalized the donor). Extended entries supersede their prefix-ancestors (when trimmable), so a conversation stays one entry. Works on BOTH lanes and tiers over the SSD store (Layer 0). |
26
26
  | `--isolate` | (bool) | off | Runtime isolation ([runtime-isolation.md](../design/runtime-isolation.md)): the inference engine runs as a CHILD process on a unix socket; this process stays a pure UI/API reverse proxy — instant under any GPU load, survives engine crashes (auto-respawn, in-flight requests get a clean 502). Measured cost ≈0 (−0.4% tok/s, +2 ms TTFT, per-token SSE granularity preserved). `/ws/chat` is not proxied yet (501). `/engine` reports child pid/restarts. |
27
27
  | `--model-pool` | n | `1` | With `--isolate`: max RESIDENT model engines. Requests naming another model (**exact `/v1/models` id** — fuzzy strings keep mlx-lm's ignored-field semantics) spawn/route to that model's own engine child (SPAWN-OVERLAP: the new model loads while the old one keeps serving; nobody's stream is interrupted). Over the cap, the LRU engine drains, **demotes its prompt cache to the SSD tier**, and exits — switching back respawns it with state restored from disk. Measured (M1 Max, cpm5⇄qwen0.8b, pool 1): switch 1.5 s, switch-back 1.2 s with `cached_tokens` 103/104 — the conversation survived the eviction. The pool drives this eviction via `POST /admin/drain` — unix-socket-only (never exposed on the public TCP listener) — which quiesces the gateway and demotes the whole prompt cache to the SSD tier before eviction, responding `{ drained: true, demotions: N }`. |
28
- | `--ssd-cache` | dir | off | SSD cold tier under the prompt cache ([docs/design/ssd-kv-cold-tier.md](../design/ssd-kv-cold-tier.md)): prefix KV spills to disk on RAM eviction AND idle demotion, is snapshotted after requests settle (debounced 1 s; the flush is idle-gated per tensor so it never taxes an active decode — `MLX_BUN_SSD_WRITEBEHIND=0` disables), and **survives restarts** — a long-context agent re-attach restores via a bounded streamed copy instead of re-prefilling (measured 13.7k-token prefix: 12 s → ~0.25 s TTFT; 0% decode overhead; restore transient = live entry + one tensor, no mapping outlives the load). Entries are keyed by model fingerprint **+ effective kv scheme** + tokenizer hash + adapter ns; incompatible/corrupt files self-quarantine. **Layer 0 (2026-07-05): the tiering lives inside PromptCache.take(), so BOTH lanes restore from disk — batch-scheduler joiners included — and every put (batch rows too) schedules the write-behind snapshot.** Requires the RAM cache. |
28
+ | `--ssd-cache` | dir | off | SSD cold tier under the prompt cache ([docs/design/ssd-kv-cold-tier.md](../design/ssd-kv-cold-tier.md)): prefix KV spills to disk on RAM eviction AND idle demotion, is snapshotted after requests settle (debounced 1 s; the flush is idle-gated per tensor so it never taxes an active decode — `MLX_BUN_SSD_WRITEBEHIND=0` disables), and **survives restarts** — a long-context agent re-attach restores via a bounded streamed copy instead of re-prefilling (measured 13.7k-token prefix: 12 s → ~0.25 s TTFT; 0% decode overhead; restore transient = live entry + one tensor, no mapping outlives the load). Dirty snapshots remain tracked until the atomic SSD store confirms them; `SIGINT`/`SIGTERM` drains active requests and flushes them before exit, and `POST /admin/cache/flush` exposes the same explicit boundary. Entries are keyed by model fingerprint **+ effective kv scheme** + tokenizer hash + adapter ns; incompatible/corrupt files self-quarantine. **Layer 0 (2026-07-05): the tiering lives inside PromptCache.take(), so BOTH lanes restore from disk — batch-scheduler joiners included — and every put (batch rows too) schedules the write-behind snapshot.** Requires the RAM cache. |
29
29
  | `--ssd-cache-max` | GB | `32` | SSD tier byte cap (binary GiB); oldest-mtime entries evicted at the cap. |
30
30
  | `--ssd-cache-verify` | (bool) | off | Verify every tensor hash on restore before it is copied in (roughly doubles restore reads) — integrity paranoia only; the header hash is always verified. |
31
31
  | `--ssd-demote-idle` | sec | `300` (with `--ssd-cache`) | Idle demotion (Layer 0): prompt-cache entries unused this long spill to the SSD tier and **free their GPU memory** — unified memory drains between agent bursts while every prefix stays reachable (the next hit restores via the bounded streamed copy, ~0.25 s for a 13.7k-token entry vs a 12 s re-prefill). Swept only when the engine is fully idle (never drains a running batch). `0` disables. |
32
32
  | `--batch` | n | `8` | Max concurrent requests decoded together. **Default flipped 1→8 (2026-07-05)** after GATE-B1-SPEED: a LONE request through the unified engine IS the serial engine — adopted serial-class caches, compiled decode, prompt cache + SSD restore, byte-identical output, 0.992–0.996 paired decode ratios — so the cap only changes behavior when concurrent requests actually arrive (the agentic sub-agent workload: 4–8 coding agents against one local server). `--batch 1` pins strict serial (arrival-independent numerics). See [Execution modes](#execution-modes-serial-vs---batch-n). `--decode-concurrency` is accepted for drop-in compatibility (mlx_lm.server's cap, default 32). |
33
- | `--kv-quant` | `config`\|`off`\|`4`\|`8`\|`turbo[:k<bits>v<bits>]` | `off` (bf16) | KV-cache quantization. **Default flipped to bf16 2026-07-05** (naked = L1): quantized KV measured 5–20% slower decode than bf16 at ≤16k on every model — on mlx-lm too — so it pays only in **memory headroom** (e.g. ~1.3 GB on the 12B @16k) and is an explicit opt-in (`--kv-quant …` or `--l2`, whose preset passes `config`). `off` = bf16 (L1). `4`/`8` = **uniform** bits (group 64, start 0) — the scheme mlx-lm exposes as `--kv-bits`/`--kv-group-size`/`--quantized-kv-start`. In the L1 kernel set (`--l1 --kv-quant 8` = fused-sdpa off) our unfused quantized SDPA is **op-for-op identical** to mlx-lm's `quantized_scaled_dot_product_attention`, so uniform-quant is **bit-exact L1** (the fused-sdpa-ON path is optiq-aligned). `config` = **per-layer mixed-precision** from `kv_config.json` — optiq-only, no mlx-lm analog → **L2**. `turbo[:k<bits>v<bits>]` = **TurboQuant** ([docs/design/turboquant-kv.md](../design/turboquant-kv.md)): rotation-based KV quantization (asymmetric-affine int8/int4/int2/int5 keys, FWHT + Lloyd-Max values) — default `k8v3` (2.56× compression at head_dim 128); `kBits` ∈ {2,4,5,8}, `vBits` ∈ {2,3,4,5,8}. A separate axis from the scheme above (mutually exclusive with `config`/`4`/`8`); v1 is dequantize-on-fetch (stock `ops.sdpa`, no fused kernel) and **full-attention layers only** — sliding-window layers stay bf16 (one-time warning, never a throw). Under `--batch N`, a per-layer `config` scheme **batches** — full-attention layers since Phase 3.1 (MiniCPM5), rotating/sliding layers since milestone 2 (gemma's whole kv_config), so every shipped `kv_config.json` now batches. The scheduler applies the mixed scheme per row, gated bit-exact per row vs the serial composition (`tests/batched-kv-quant-parity.test.ts` — compositions no other stack ships). Uniform `4`/`8` still routes those requests serial (quantizedKvStart threshold semantics). **`turbo` is solo-only, unconditionally** — `TurboQuantKVCache` is a novel `Cache` implementation (no merge/filter/temporalView), so it's excluded from the batch scheduler by construction; `GenerationGateway.willBatch` also refuses it explicitly (belt + braces — both layers exist on purpose). |
33
+ | `--kv-quant` | `config`\|`off`\|`4`\|`8`\|`turbo[:k<bits>v<bits>]` | `off` (bf16) | KV-cache quantization. **Default flipped to bf16 2026-07-05** (naked = L1): quantized KV measured 5–20% slower decode than bf16 at ≤16k on every model — on mlx-lm too — so it pays only in **memory headroom** (e.g. ~1.3 GB on the 12B @16k) and is an explicit opt-in (`--kv-quant …` or `--l2`, whose preset passes `config`). `off` = bf16 (L1). `4`/`8` = **uniform** bits (group 64, start 0) — the scheme mlx-lm exposes as `--kv-bits`/`--kv-group-size`/`--quantized-kv-start`. In the L1 kernel set (`--l1 --kv-quant 8` = fused-sdpa off) our unfused quantized SDPA is **op-for-op identical** to mlx-lm's `quantized_scaled_dot_product_attention`, so uniform-quant is **bit-exact L1** (the fused-sdpa-ON path is optiq-aligned). `config` = **per-layer mixed-precision** from `kv_config.json` — optiq-only, no mlx-lm analog → **L2**. `turbo[:k<bits>v<bits>]` = **TurboQuant** ([docs/design/turboquant-kv.md](../design/turboquant-kv.md)): rotation-based KV quantization (asymmetric-affine int8/int4/int2/int5 keys, FWHT + Lloyd-Max values) — default `k8v3` (2.56× compression at head_dim 128); `kBits` ∈ {2,4,5,8}, `vBits` ∈ {2,3,4,5,8}. A separate axis from the scheme above (mutually exclusive with `config`/`4`/`8`); v1 is dequantize-on-fetch (stock `ops.sdpa`, no fused kernel) and **full-attention layers only** — sliding-window layers stay bf16 (one-time warning, never a throw). Under `--batch N`, a per-layer `config` scheme **batches** — full-attention layers since Phase 3.1 (MiniCPM5), rotating/sliding layers since milestone 2 (gemma's whole kv_config), so every shipped `kv_config.json` now batches. The scheduler applies the mixed scheme per row, gated bit-exact per row vs the serial composition (`tests/batched-kv-quant-parity.test.ts` — compositions no other stack ships). Uniform `4`/`8` still routes those requests serial (quantizedKvStart threshold semantics). **`turbo` is solo-only, unconditionally** — `TurboQuantKVCache` is a novel `Cache` implementation (no merge/filter/temporalView), so it's excluded from the batch scheduler by construction; `GenerationGateway.place` also refuses continuous scheduling explicitly (belt + braces — both layers exist on purpose). |
34
34
  | `--paged-kv` | (bool) | off | **OPTIONAL vLLM-style paged KV cache** ([docs/design/paged-kv-cache.md](../design/paged-kv-cache.md)): full-attention layers store K/V in fixed-size **block pools** (host-side block table, `ops.takeAxis` gather back to contiguous before the stock SDPA — no new attention math, no fused kernel in v1). Env: `MLX_BUN_PAGED_KV=1`. **Gated bit-exact vs the plain path** (`tests/paged-kv-parity.test.ts`: identical single-forward logits + identical 48-token greedy trajectory on the 12B). **v1 scope: serial batch=1, Gemma4-family, bf16** — with no explicit `--batch` the CLI pins `--batch 1`; the server **refuses** (fails startup, never silently downgrades) `--batch N>1`, any `--kv-quant`, and `--draft-model`; sliding-window layers keep the rotating scheme (mixed paged-full + rotating-sliding is the supported shape). Media (vision/audio) and LoRA-adapter **requests** are scoped OUT per request — they run the plain cache path (v1 non-goal cells), never a 400. Paged requests **bypass the prompt cache** (fresh caches per request; `--ssd-cache` sees nothing) and run **uncompiled decode** (a data-dependent block-list length can't shapeless-replay). Expect a small decode cost at batch=1 — the per-step gather copy buys nothing until the batched/CoW follow-ups; this ships the correctness-proven abstraction, honestly labeled. Pool sized from prompt+`max_tokens` at request setup; exhaustion is a typed error (accounting tripwire), never truncation. |
35
35
  | `--paged-kv-block-size` | n | `256` | Tokens per KV block (`--paged-kv` only). 256 = the plain cache's growth step, so v1's allocation granularity is a permutation of today's into reusable slots, not a new tuning axis. |
36
36
  | `--adapter` | dir | none | Mount a LoRA adapter at startup (same machinery as `POST /v1/adapters`; the adapter id is the directory's basename) and make it the **default** for requests that send no `adapter` field. A request's explicit `adapter` — including `"none"` — always wins, and hot-swap via `/v1/adapters` is unchanged. `--adapter-path` is accepted as the mlx_lm.server-named alias. A bad adapter fails startup loudly rather than silently serving the base model. This is the flag `mlx-bun train`'s completion message points at. |
37
37
  | `--draft-model` | path/query | none | **Speculative decoding**: a drafter proposes tokens the main model verifies in one forward — exact results, faster decode when drafts land. Resolves like the main model. The artifact's **kind is auto-detected**: a full same-tokenizer model (mlx_lm.server parity — L1: token-for-token vs mlx-lm's spec path; tokenizer-family mismatch fails startup, upstream silently accepts ~0%), a Gemma `-assistant` KV-borrowing drafter (L2 vs optiq spec_generate; ~1.09× γ=1 on 12B, no training), a locally-trained **DSpark** checkpoint (`dspark.json`), or one of **DeepSeek's released DSpark drafters** (DeepSpec `Gemma4DSparkModel` config stamp, e.g. `deepseek-ai/dspark_gemma4_12b_block7` for the 12B — oracle: DeepSpec's reference at temperature 0, token-for-token). All four share ONE serve loop. A confidence-calibrated DSpark checkpoint additionally self-schedules its draft length per round (threshold pruning — fewer wasted verify positions; uncalibrated checkpoints draft fixed-length). Mounting a draft routes **every** request to the serial lane (upstream `is_batchable = draft is None`). Pays on slow targets (12B+); fast small models lose to the draft overhead. Composes with structured output (the constrained verify walk). Prompt-cache reuse is bypassed on the spec path (v1). Telemetry: `usage.speculation` (`drafted`/`accepted`/`targetCalls`). |
38
- | `--draft-kind` | `two-model`\|`assistant`\|`dspark`\|`deepspec`\|`ngram` | auto | Override the draft-artifact kind detection (`dspark.json` → dspark; `Gemma4DSparkModel` architecture stamp → deepspec; `*_assistant` config → assistant; else two-model). **`ngram` is never auto-detected — it has no artifact**: MODEL-FREE prompt lookup (drafts copied from the request's own prompt+generation when its trailing k-gram occurred earlier in the stream; port of prompt-lookup decoding / vLLM's `ngram` proposer). Mount it ALONE — `--draft-kind ngram` with a `--draft-model` is refused, as is any other kind without one. Zero weights/memory, lossless by the same verify (gated: serve-loop output token-identical to non-spec greedy on e4b, tests/spec-ngram.test.ts); a no-match round degrades to one plain target step. Best on agentic/RAG/code-edit traffic that re-emits context spans. |
38
+ | `--draft-kind` | `two-model`\|`assistant`\|`dspark`\|`deepspec`\|`mtp`\|`ngram` | auto | Override the draft-artifact kind detection (`dspark.json` → dspark; `Gemma4DSparkModel` architecture stamp → deepspec; `*_assistant` config → assistant; `*_mtp` model_type → mtp, the target-release-native multi-token-prediction head — it shares the target's embeddings/lm-head, defaults `--num-draft-tokens` to its trained `block_size - 1`, and partial rejects roll the target's recurrent DeltaNet caches back via bit-exact snapshot/replay; else two-model). `mtp` alone (no `--draft-model`) mounts the companion bundled at `<model>/mtp/` when present — single-repo artifacts — and refuses otherwise. **`ngram` is never auto-detected — it has no artifact**: MODEL-FREE prompt lookup (drafts copied from the request's own prompt+generation when its trailing k-gram occurred earlier in the stream; port of prompt-lookup decoding / vLLM's `ngram` proposer). Mount it ALONE — `--draft-kind ngram` with a `--draft-model` is refused, as is any other kind without one. Zero weights/memory, lossless by the same verify (gated: serve-loop output token-identical to non-spec greedy on e4b, tests/spec-ngram.test.ts); a no-match round degrades to one plain target step. Best on agentic/RAG/code-edit traffic that re-emits context spans. |
39
39
  | `--num-draft-tokens` | n | `3` (`ngram`: `10`) | Drafts per verify round (mlx_lm.server's default; `mlx_lm.generate`'s is 2). A DSpark draft pins this to its trained block width (`cfg.gamma`); ngram drafting is free so its default is the reference implementation's 10. |
40
40
  | `--ngram-max` / `--ngram-min` | k | `3` / `1` | `--draft-kind ngram` only: longest/shortest trailing k-gram searched for a match (the reference values — longest first, first occurrence wins). |
41
41
  | `--mtp` | `on`\|`off` | on for GLM-5.2 | Mount the checkpoint-native GLM MTP row as the server's drafter. It uses the bounded auxiliary expert tier and exact serial verify loop (`usage.lane: "serial+spec"`). `off` removes the draft so ordinary GLM requests can use continuous batching; per-row batched MTP is post-release. Mutually exclusive with an explicit `--draft-model`/`--draft-kind`. |
@@ -47,7 +47,7 @@ the design rationale is in
47
47
  | `--max-tokens` | n | GLM-5.2: `128`; otherwise `65536` chat / `512` raw completion | Completion cap when a request omits `max_tokens` (mlx_lm.server flag; its default there is 512 — `--max-tokens 512` reproduces mlx_lm.server exactly). For GLM this is also reserved by the pre-open resource equation and must fit inside `--context-length`. |
48
48
  | `--allow-private-media` | (bool) | off | Let `image_url`/`audio_url` content parts fetch from **private/loopback/link-local** hosts (a NAS, another LAN box). Off by default: a request's URL is attacker-controlled input, so remote media fetches (`src/media-fetch.ts`) refuse non-http(s) schemes and destinations in private/loopback/link-local/CGNAT ranges — including hosts that *resolve* there and every **redirect hop** (each hop re-passes the whole policy) — which keeps the server from being steered at cloud metadata (`169.254.169.254`) or LAN-internal services (SSRF). Independent of the flag, every remote fetch has a **10 s wall-clock timeout** and a **64 MB response cap** (streaming-enforced, not just Content-Length); violations surface as clean `400`s (`prompt build failed: …`). `data:` URLs decode locally and are never policy-checked. |
49
49
  | `--no-open` | (bool) | off | Skip the automatic browser open on start. By default an interactive terminal session opens `http://<host>:<port>/#/chat` once the server is ready; pass this flag to suppress it (e.g. headless or non-TTY environments already skip it). |
50
- | `--hlg-sampling` | `on`\|`off` | off | Piecewise tone-curve (HLG) sampling: rolls off the top-token region, boosts the mids, gentles the tail. The overall gain folds from `--temperature`. See [docs/design/hlg-sampling.md](../design/hlg-sampling.md). |
50
+ | `--hlg-sampling` | `on`\|`off` | off | Piecewise tone-curve (HLG) sampling: rolls off the top-token region, boosts the mids, gentles the tail. The overall gain folds from `--temperature`. See [docs/archive/hlg-sampling.md](../design/hlg-sampling.md). |
51
51
  | `--hlg-width` | nats | `4` | HLG mid-region half-width (nats). Only meaningful with `--hlg-sampling on`. |
52
52
  | `--hlg-shoulder` | nats | `4` | HLG highlight rolloff scale (nats). Only meaningful with `--hlg-sampling on`. |
53
53
  | `--hlg-toe` | nats | `6` | HLG shadow rolloff scale (nats). Only meaningful with `--hlg-sampling on`. |
@@ -81,6 +81,22 @@ model loads, so they apply to `mlx-bun pi` too. They affect the
81
81
  3.2) — see
82
82
  [Levers that don't reach the batched lane](#--batch-n-is-compat-mode--perf-flags-dont-apply-by-design).
83
83
 
84
+ ## Near-ceiling models on small machines (24 GB)
85
+
86
+ A model whose weights approach the DEFAULT macOS GPU wired ceiling (~75% of
87
+ RAM — e.g. a 17 GB model on a 24 GB machine) loads and serves short prompts,
88
+ but a long prefill can exceed the ceiling and die with an **uncatchable**
89
+ Metal OOM (the failure is asynchronous; no server error is possible).
90
+ `mlx-bun serve` detects this at startup and prints the remedy:
91
+
92
+ ```
93
+ sudo sysctl iogpu.wired_limit_mb=<RAM_MB - ~2500> # resets on reboot
94
+ ```
95
+
96
+ For very long contexts on such machines, additionally prefer `--kv-quant`
97
+ (e.g. `turbo`) and expect the engine to use smaller prefill chunks — the
98
+ prefill transient scales with chunk size × context depth.
99
+
84
100
  ## Per-request overrides
85
101
 
86
102
  Most quality knobs can be set per request in the chat body and override
@@ -117,7 +133,8 @@ for paired A/B harnesses).
117
133
  | `MLX_BUN_GRAMMAR` | — | on (`!=="0"`) | `=0` disables structured-output mask compilation. Requested constraints still take the documented graceful-degrade route: chat prompt injection plus a `Warning` header; raw completions emit the header only. |
118
134
  | `MLX_BUN_GRAMMAR_JUMP` | — | **off** (`==="1"`) | Jump-forward decoding for structured output (SGLang's technique via xgrammar's `findJumpForwardString`): when the grammar forces a unique continuation (JSON punctuation/keys; longest under `any_whitespace:false` fixed formatting), the SERIAL lane emits its retokenized ids with ONE multi-token forward instead of one masked forward per token. Lossless in STRING space and always grammar-valid, but the token stream (and, conditioning on it, content after a forced span) can legally differ from an unjumped run — no oracle, hence opt-in. Excluded automatically when either `logprobs` or `top_logprobs` is requested, because jumped tokens have no sampled distribution row; SentencePiece-family tokenizers whose raw encode can't reproduce a mid-stream span simply never jump (guard degrades to normal decode). The batch lane's per-row grammar (`#stepGrammar`) does not jump yet. Tests: tests/grammar-jump.test.ts (contract + Llama-3.2-1B e2e). |
119
135
  | `MLX_BUN_SSD_WRITEBEHIND` | — | on (`!=="0"`) | `--ssd-cache`'s debounced write-behind snapshot (restart survival). `=0` disables it entirely — the paired-A/B lever + kill switch for the 2026-07-07 idle-gating fix (eviction/demotion spills still write). The flush is **idle-gated**: every per-tensor step (blocking GPU sync + `writeSync`) waits for the engine to go idle and pauses when a request arrives mid-flush, so durability work never taxes an active decode (pre-fix: a ~16k entry's flush overlapping cached ctx repeats cost e4b ~9% decode@ctx vs mlx-lm). The snapshot timer also never holds batch admission — while rows are active it re-arms instead of grabbing the exclusive. |
120
- | `MLX_BUN_SSD_SPILL_QUEUE_GB` | — | 2 | Byte cap on the write-behind queue's PENDING spill clones (they pin the evicted entries' GPU memory until the idle-gated flush runs). Over cap, the oldest queued spill drops — its clones free immediately and the entry becomes a future cache miss (never a wrong result). `=0` keeps only the newest + in-flight clone pinned (the soft cap never drops the item just enqueued). `/stats.ssd_cache` reports `pending_spills` / `pending_spill_bytes` / `dropped_spills`. Note: there is deliberately **no shutdown flush** — exiting under traffic loses queued write-behind entries (best-effort tier; cost = one re-prefill). |
136
+ | `MLX_BUN_SSD_SPILL_QUEUE_GB` | — | 2 | Byte cap on the write-behind queue's PENDING spill clones (they pin the evicted entries' GPU memory until the idle-gated flush runs). Over cap, the oldest queued spill drops and its clones free immediately. The durability coordinator keeps the source snapshot dirty so an explicit or shutdown flush can retry it. `=0` keeps only the newest + in-flight clone pinned (the soft cap never drops the item just enqueued). `/stats.ssd_cache` reports pending, dropped, failed, and longest-durable-prefix counters. |
137
+ | `MLX_BUN_SHUTDOWN_TIMEOUT_MS` | — | `120000` | Maximum time `mlx-bun serve` gives active requests plus the SSD durability flush after `SIGINT` or `SIGTERM`. On timeout the process logs the remaining snapshot/spill counters and exits. |
121
138
  | `MLX_BUN_BATCH_NO_PIPELINE` | — | off (`==="1"`) | Disables the batch lane's pipelined token readback (the A/B lever for the shipped pipelining, like its peers `MLX_BUN_BATCH_EXTEND` / `MLX_BUN_BATCH_SSM` / `MLX_BUN_BATCH_VEC_SAMPLE`). Numerically equivalent, slower steps. |
122
139
  | `MLX_BUN_DSPARK_MINCONF` | — | checkpoint-driven | Overrides the DSpark/DeepSpec draft scheduler's minimum-confidence threshold (Alg-1 draft-length pruning). Spec lane only; no CLI flag — a calibration/A/B lever (see [dspark-speculative-decoding.md](../design/dspark-speculative-decoding.md)). |
123
140
  | `MLX_BUN_EXPERT_OFFLOAD` | `--expert-offload` | off (`==="1"`) | Env mirror of the flag for direct/library runs (scripts that never parse serve flags). |
@@ -130,12 +147,12 @@ exporting them now does nothing.)
130
147
 
131
148
  ## Execution modes: serial vs. `--batch N`
132
149
 
133
- Batching is **concurrency-driven** (default cap 8, flipped 2026-07-05): a
134
- lone request runs the exact serial engine — its caches are ADOPTED
135
- serial-class objects, compiled decode replays, the prompt cache and SSD
136
- tier serve it — and only a second concurrent request causes a batch
137
- layout to exist. "How many requests you send" is the batching decision;
138
- the flag is just the cap.
150
+ Scheduling is **concurrency-driven** (default cap 8, flipped 2026-07-05): a
151
+ lone scheduler request uses the B=1 fast path — its caches are adopted
152
+ serial-class objects, compiled decode replays, and the prompt cache and SSD
153
+ tier serve it. Only a second concurrent request causes a batch layout to
154
+ exist. The flag declares the concurrency cap; active rows select B=1 or B=N
155
+ inside the scheduler.
139
156
 
140
157
  `--batch 1` pins the strict serialized single-queue path: one generation
141
158
  at a time, arrival-independent numerics (a request's bits never depend on
@@ -159,23 +176,29 @@ row for bf16, and per-row oracle-gated for the quantized compositions
159
176
  serial lane (quantizedKvStart threshold semantics; a startup warning
160
177
  is printed).
161
178
 
162
- ### The lane picker (`GenerationGateway.willBatch`)
179
+ ### Scheduling declaration (`GenerationGateway.place`)
180
+
181
+ `--batch` declares a concurrency cap. With a cap greater than one,
182
+ `GenerationGateway.place()` freezes one scheduling mechanism for the exact
183
+ resolved execution composition. `continuous` admits it to the scheduler; that
184
+ scheduler chooses its B=1 fast path or B=N step from active rows. `serial`
185
+ preserves the strict or dedicated executor. The support check never removes,
186
+ downgrades, or substitutes MTP, KV schemes, TurboQuant, grammar, adapters, or
187
+ sampling. A composition the scheduler does not implement drains it and runs
188
+ unchanged through the serial mechanism.
163
189
 
164
- Under `--batch N`, each request is routed per-request. It joins the
165
- batch only if **all** of these hold; otherwise it drains the batch and
166
- runs solo (mlx-lm's `_is_batchable` behavior). The two lanes are
167
- mutually exclusive on the GPU (one `AsyncMutex`), so a serial-fallback
168
- request never runs alongside a batched step — but batched requests run
169
- concurrently with each other.
190
+ The two mechanisms are mutually exclusive on the GPU (one `AsyncMutex`), so a
191
+ serial request never runs alongside a continuous step, while requests admitted
192
+ to the continuous scheduler can run concurrently with each other.
170
193
 
171
- | Request property | Batches? |
194
+ | Request property | Continuous scheduler? |
172
195
  | --- | --- |
173
196
  | vision (image parts) | ❌ serial — needs offset-0 single-seq prefill + bidirectional image mask |
174
197
  | LoRA `adapter` (resolves to ≥1) | ❌ serial — `loraState.active` is one per-generation field; per-row adapters unsupported |
175
198
  | `logprobs` / `top_logprobs` | ❌ serial — the batched sampler doesn't capture logprob arrays yet |
176
199
  | explicit `seed` | ❌ serial — reproducibility ⇒ solo (matches mlx-lm) |
177
200
  | KV quant active (explicit `--kv-quant`) | ✅ batches for per-layer `config` schemes — full-attention layers (Phase 3.1) AND rotating layers (milestone 2: gemma's kv_config; unpadded row gated KL-0 vs serial); uniform bits → serial |
178
- | `--kv-quant turbo` (TurboQuant) | ❌ serial, unconditionally — solo-only in v1 (novel `Cache` class, no batched merge/filter/temporalView; belt-and-braces refusal in `willBatch` on top of the automatic capability-gate exclusion) |
201
+ | `--kv-quant turbo` (TurboQuant) | ❌ serial, unconditionally — solo-only in v1 (novel `Cache` class, no batched merge/filter/temporalView; belt-and-braces refusal in placement on top of the automatic capability-gate exclusion) |
179
202
  | `--draft-model` mounted | ❌ serial, server-wide — speculation is a B=1 latency mode (upstream `is_batchable = draft is None`) |
180
203
  | `repetition_penalty` / `min_p` / `xtc_*` / `logit_bias` / presence+frequency penalties | ✅ batches — per-row logits processors over a per-row device-side history (since 2026-07-02; some models — Qwen3.5 — ship a *default* repetition penalty, which used to route everything serial) |
181
204
  | structured output (`response_format` / `guided_*`) | ✅ batches — per-row grammar matchers driven by the scheduler (`MLX_BUN_GRAMMAR_BATCH=0` forces serial) |
@@ -185,7 +208,7 @@ concurrently with each other.
185
208
  | `--thinking` / `enable_thinking` | ✅ batches (template-render concern, lane-independent) |
186
209
  | multi-turn / long prompt | ✅ batches **with prompt-cache reuse** (Phase 3.2): a joiner restores the longest usable cached prefix and prefills only the suffix (`cached_tokens` reported); a request that finishes without ever sharing the batch puts its caches back. **Prefix sharing (2026-07-05): serves are non-consuming clones**, so concurrent agents with a shared system prompt all reuse one prefill and nobody's entry is destroyed. A row that merges with others is not re-put (its entry ages out) — heavy concurrency reduces hit rate, never correctness |
187
210
 
188
- **Which models batch:** full-attention (CPM), sliding-window (Gemma),
211
+ **Which models use continuous scheduling:** full-attention (CPM), sliding-window (Gemma),
189
212
  hybrid gated-DeltaNet (Qwen3.5 — the SSM batched path, token-exact vs the
190
213
  mlx-lm B=2 oracle, landed 2026-07-02; `MLX_BUN_BATCH_SSM=0` reverts it to
191
214
  serial routing), and **plain full-attention Tier-0 universal archs**
@@ -194,23 +217,24 @@ Still serial by the model-level capability gate: gemma2-family and
194
217
  sliding-window *universal* archs (unvalidated cells) and DiffusionGemma
195
218
  (non-autoregressive).
196
219
 
197
- A non-batchable request **drains** the batch: while it waits, the
220
+ A request requiring the serial mechanism **drains** the scheduler: while it waits, the
198
221
  scheduler stops admitting new rows, finishes the running ones, and
199
222
  releases the GPU so the serial request runs (mlx-lm's `drain_batch`);
200
223
  admission then resumes. So a steady stream of batchable traffic cannot
201
224
  starve a serial-lane request.
202
225
 
203
- `--batch N` is a **mode switch, not a load-dependent fallback**:
204
- auto-batching "when >1 request arrives" was considered and rejected —
205
- an idle vs. loaded server would produce different numerics for the same
206
- request, breaking determinism and the drop-in-for-`mlx_lm.server`
207
- promise.
226
+ Placement is not a load-dependent fallback. A request admitted to the
227
+ continuous scheduler stays there whether it is the only active row or one of
228
+ many; arrivals select B=1 versus B=N *inside* that mechanism. `--batch 1`
229
+ remains the arrival-independent numerics pin. This matches mlx-lm's
230
+ concurrency-driven serving contract while keeping the strict serial route for
231
+ goldens and compositions that need it.
208
232
 
209
233
  ## Compatibility matrix
210
234
 
211
- How each option behaves in each mode. "serial" = `--batch 1`; "`--batch N`"
212
- = the batched mode (a given request may still take the serial *lane*
213
- inside it per the table above).
235
+ How each option behaves under each configured mechanism. "serial" =
236
+ `--batch 1`; `--batch N` permits continuous scheduling, although a declared
237
+ composition may still require the serial mechanism as shown above.
214
238
 
215
239
  | Option | serial (`--batch 1`) | `--batch N` (N>1) |
216
240
  | --- | --- | --- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlx-bun",
3
- "version": "0.0.12",
3
+ "version": "0.2.0",
4
4
  "description": "Native MLX inference for Bun on Apple Silicon — local LLM server (OpenAI/Anthropic-compatible) and TypeScript library. No Python.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "THIRD_PARTY_LICENSES.md"
38
38
  ],
39
39
  "engines": {
40
- "bun": ">=1.3.14"
40
+ "bun": ">=1.4.0"
41
41
  },
42
42
  "os": [
43
43
  "darwin"
package/src/anthropic.ts CHANGED
@@ -23,6 +23,10 @@
23
23
  // semantics) instead of the oracle's json.dumps fallback.
24
24
 
25
25
  import type { ChatMessage, ToolDefinition } from "./chat-template";
26
+ import type {
27
+ CompletionEvent,
28
+ CompletionStreamProtocol,
29
+ } from "./serve/completion-sink";
26
30
 
27
31
  // ---------------------------------------------------------------------
28
32
  // Request translation: Anthropic → OpenAI (our ChatRequest shape)
@@ -128,7 +132,15 @@ export function anthropicToChatBody(body: AnthropicRequest): Record<string, unkn
128
132
  break; // prior-turn thinking is not re-fed (Anthropic semantics)
129
133
  case "image": {
130
134
  const part = imagePartFromBlock(block);
131
- if (part) imageParts.push(part);
135
+ if (!part)
136
+ // e.g. a Files-API {type:"file"} source we cannot fetch —
137
+ // reject loudly, never answer about an image the model can't
138
+ // see (2026-08-18 review; the audio-doctrine below).
139
+ throw new Error(
140
+ `unsupported image source "${block.source?.type ?? "missing"}" — ` +
141
+ `send base64 or url image sources`,
142
+ );
143
+ imageParts.push(part);
132
144
  break;
133
145
  }
134
146
  case "tool_use":
@@ -141,15 +153,52 @@ export function anthropicToChatBody(body: AnthropicRequest): Record<string, unkn
141
153
  },
142
154
  });
143
155
  break;
144
- case "tool_result":
156
+ case "tool_result": {
145
157
  // Anthropic packs tool results into the next user message;
146
158
  // OpenAI (and the gemma template) want role:"tool" messages.
159
+ // Nested image blocks (screenshot-returning tools — the
160
+ // computer-use shape) are PRESERVED as image parts: flattening
161
+ // them away had the model answering about screenshots it never
162
+ // saw (2026-08-18 review). An unrenderable pairing downstream
163
+ // fails loudly on the media-count check, never silently.
164
+ const trImages: Record<string, unknown>[] = [];
165
+ if (Array.isArray(block.content)) {
166
+ for (const b of block.content as AnthropicContentBlock[]) {
167
+ if (b && typeof b === "object" && b.type === "image") {
168
+ const part = imagePartFromBlock(b);
169
+ if (!part)
170
+ throw new Error(
171
+ `unsupported image source "${b.source?.type ?? "missing"}" ` +
172
+ `in tool_result — send base64 or url image sources`,
173
+ );
174
+ trImages.push(part);
175
+ }
176
+ }
177
+ }
178
+ const txt = flattenText(block.content);
147
179
  messages.push({
148
180
  role: "tool",
149
181
  tool_call_id: block.tool_use_id,
150
- content: flattenText(block.content),
182
+ content: trImages.length
183
+ ? [
184
+ ...(txt ? [{ type: "text", text: txt }] : []),
185
+ ...trImages,
186
+ ] as never
187
+ : txt,
151
188
  });
152
189
  break;
190
+ }
191
+ case "video":
192
+ case "video_url":
193
+ // Not an Anthropic Messages block type — reject loudly instead of
194
+ // silently answering about a video the model never saw (same
195
+ // doctrine as audio below; the OpenAI surface serves video on
196
+ // Qwen3.5-family models).
197
+ throw new Error(
198
+ `"${block.type}" content blocks are not part of the Anthropic ` +
199
+ `Messages protocol — send video to the OpenAI endpoint ` +
200
+ `/v1/chat/completions as a video_url content part`,
201
+ );
153
202
  case "audio":
154
203
  case "input_audio":
155
204
  case "audio_url":
@@ -168,6 +217,11 @@ export function anthropicToChatBody(body: AnthropicRequest): Record<string, unkn
168
217
  }
169
218
  }
170
219
  if (role === "assistant") {
220
+ if (imageParts.length)
221
+ // Assistants don't produce images in the Anthropic protocol; a
222
+ // client that puts one here would previously have it silently
223
+ // discarded (2026-08-18 review — loud beats silent).
224
+ throw new Error("image content blocks are not supported in assistant messages");
171
225
  const msg: ChatMessage = { role, content: textParts.join("\n") || null };
172
226
  if (toolCalls.length) msg.tool_calls = toolCalls;
173
227
  if (msg.content || toolCalls.length) messages.push(msg);
@@ -457,6 +511,41 @@ export class AnthropicStreamTranslator {
457
511
  }
458
512
  }
459
513
 
514
+ /** Direct semantic-event adapter used by the server. This avoids encoding
515
+ * OpenAI SSE only to parse those bytes back into protocol events. */
516
+ export function createAnthropicStreamProtocol(model: string): CompletionStreamProtocol {
517
+ const translator = new AnthropicStreamTranslator(model);
518
+ const chunk = (delta: Record<string, unknown>) => ({ choices: [{ delta }] });
519
+ return {
520
+ start: () => translator.addChunk(chunk({ role: "assistant", content: "" })),
521
+ addEvents(events: CompletionEvent[]) {
522
+ return events.flatMap((event) => {
523
+ if (event.type === "reasoning") {
524
+ return translator.addChunk(chunk({ reasoning: event.text }));
525
+ }
526
+ if (event.type === "content") {
527
+ return translator.addChunk(chunk({ content: event.text }));
528
+ }
529
+ return translator.addChunk(chunk({
530
+ tool_calls: event.calls.map((call, index) => ({ index, ...call })),
531
+ }));
532
+ });
533
+ },
534
+ finish(reason, usage) {
535
+ return [
536
+ ...translator.addChunk({ choices: [{ delta: {}, finish_reason: reason }], usage }),
537
+ ...translator.finalize(),
538
+ ];
539
+ },
540
+ error(message) {
541
+ return [sse("error", {
542
+ type: "error",
543
+ error: { type: "api_error", message },
544
+ })];
545
+ },
546
+ };
547
+ }
548
+
460
549
  /** Wrap our OpenAI SSE byte stream as an Anthropic SSE byte stream.
461
550
  * Frames translate as they arrive (per-chunk flush preserved — the
462
551
  * upstream already rate-limits socket flushes). */
@@ -40,6 +40,14 @@ export interface RenderOptions {
40
40
  addGenerationPrompt?: boolean;
41
41
  tools?: ToolDefinition[] | null;
42
42
  enableThinking?: boolean;
43
+ /** Template-level reasoning depth (Qwen3.8: xhigh|medium|low — the template
44
+ * RAISES on other values, so callers must map OpenAI levels first and only
45
+ * set this for templates that read it; see ChatTemplate.readsReasoningEffort). */
46
+ reasoningEffort?: "xhigh" | "medium" | "low";
47
+ /** Keep think blocks from historical assistant turns in the rendered prompt
48
+ * (Qwen3.8 `preserve_thinking`, template default true — better prompt-cache
49
+ * reuse and agent-trace continuity). Only passed when the template reads it. */
50
+ preserveThinking?: boolean;
43
51
  }
44
52
 
45
53
  type ChatRenderer = (
@@ -240,6 +248,14 @@ export class ChatTemplate {
240
248
  readonly #renderer: ChatRenderer | null;
241
249
  readonly #bosToken: string | null;
242
250
  readonly #eosToken: string | null;
251
+ /** True when the template reads `reasoning_effort` (Qwen3.8's xhigh/medium/
252
+ * low depth control). Callers must not pass the variable otherwise — and
253
+ * must never pass unmapped OpenAI level names; this template family raises
254
+ * on values outside its supported set. */
255
+ readonly readsReasoningEffort: boolean;
256
+ /** True when the template reads `preserve_thinking` (Qwen3.8: keep think
257
+ * blocks from historical turns; template default true). */
258
+ readonly readsPreserveThinking: boolean;
243
259
  /** Which reasoning format the template's `enable_thinking` channel uses, or
244
260
  * null if the model has no switchable reasoning:
245
261
  * - "think-tag": `<think>…</think>` text markers (Qwen3.5, MiniCPM5) — split
@@ -267,6 +283,8 @@ export class ChatTemplate {
267
283
  this.#bosToken = bosToken;
268
284
  this.#eosToken = eosToken;
269
285
  const gatesThinking = source?.includes("enable_thinking") ?? false;
286
+ this.readsReasoningEffort = source?.includes("reasoning_effort") ?? false;
287
+ this.readsPreserveThinking = source?.includes("preserve_thinking") ?? false;
270
288
  // `forceNoThinking` suppresses the switchable channel even when the template
271
289
  // carries the gemma markers. DiffusionGemma ships the shared gemma-family
272
290
  // template (with the `<|channel>thought…<channel|>` reasoning channel), but
@@ -334,7 +352,13 @@ export class ChatTemplate {
334
352
 
335
353
  render(messages: ChatMessage[], options: RenderOptions = {}): string {
336
354
  if (this.#renderer) return this.#renderer(messages, options);
337
- const { addGenerationPrompt = true, tools = null, enableThinking } = options;
355
+ const {
356
+ addGenerationPrompt = true,
357
+ tools = null,
358
+ enableThinking,
359
+ reasoningEffort,
360
+ preserveThinking,
361
+ } = options;
338
362
  return this.#template!.render({
339
363
  messages,
340
364
  add_generation_prompt: addGenerationPrompt,
@@ -342,6 +366,15 @@ export class ChatTemplate {
342
366
  // `value['type'] | upper` (Gemma) never see UndefinedValue.
343
367
  tools: normalizeToolSchemas(tools),
344
368
  ...(enableThinking !== undefined ? { enable_thinking: enableThinking } : {}),
369
+ // Only set for templates that read them: reasoning_effort values are
370
+ // template-validated (Qwen3.8 raises on unknown levels), and an unread
371
+ // variable in other templates is dead context at best.
372
+ ...(reasoningEffort !== undefined && this.readsReasoningEffort
373
+ ? { reasoning_effort: reasoningEffort }
374
+ : {}),
375
+ ...(preserveThinking !== undefined && this.readsPreserveThinking
376
+ ? { preserve_thinking: preserveThinking }
377
+ : {}),
345
378
  bos_token: this.#bosToken,
346
379
  eos_token: this.#eosToken,
347
380
  });