mlx-bun 0.2.0 → 0.3.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 (364) hide show
  1. package/README.md +71 -588
  2. package/docs/README.md +91 -0
  3. package/docs/{design → archive/investigations}/audio-input-plan.md +4 -4
  4. package/docs/{design → archive/investigations}/diffusion-gemma-port.md +3 -3
  5. package/docs/{design → archive/investigations}/faithful-l1-consolidation.md +4 -4
  6. package/docs/{design → archive/investigations}/minicpm5-decode-megakernel.md +11 -11
  7. package/docs/{design → archive/investigations}/optimization_plan.md +1 -1
  8. package/docs/{design → archive/investigations}/pre-colibri-stabilization.md +5 -5
  9. package/docs/{design → archive/investigations}/the-dreaming-handoff.md +2 -2
  10. package/docs/design/batching.md +569 -0
  11. package/docs/design/decode-speed-program.md +3178 -169
  12. package/docs/design/dreaming-nightly-pipeline.md +675 -282
  13. package/docs/design/generic-model-support.md +579 -334
  14. package/docs/design/kv-cache.md +680 -0
  15. package/docs/design/orpo-training.md +673 -1239
  16. package/docs/design/speculative-decoding.md +1627 -0
  17. package/docs/design/turboquant.md +2102 -0
  18. package/docs/design/unified-engine-frontier-plan.md +1831 -727
  19. package/docs/design/web-chat-redesign.md +666 -1083
  20. package/docs/planning/PRODUCT_ROADMAP.md +34 -1
  21. package/docs/planning/release-notes-v0.3.0.md +35 -0
  22. package/docs/planning/video-series.md +2 -2
  23. package/docs/reference/benchmarks.md +1162 -0
  24. package/docs/reference/cli.md +394 -159
  25. package/docs/reference/distribution.md +321 -182
  26. package/docs/reference/environment.md +348 -0
  27. package/docs/reference/glossary.md +39 -0
  28. package/docs/reference/library-api.md +566 -168
  29. package/docs/reference/memory.md +227 -64
  30. package/docs/reference/models.md +104 -6
  31. package/docs/reference/server-api.md +886 -425
  32. package/docs/reference/server-config.md +638 -330
  33. package/docs/reference/training.md +393 -254
  34. package/docs/reference/troubleshooting.md +166 -0
  35. package/package.json +9 -4
  36. package/src/audio/decode.ts +2 -2
  37. package/src/audio/features.ts +2 -2
  38. package/src/backends/mlx/autoregressive.ts +108 -0
  39. package/src/backends/mlx/batch-group.ts +1646 -0
  40. package/src/backends/mlx/diffusion.ts +39 -0
  41. package/src/backends/mlx/draft-target.ts +47 -0
  42. package/src/backends/mlx/evaluation.ts +105 -0
  43. package/src/backends/mlx/fill-append.ts +45 -0
  44. package/src/backends/mlx/gateway-binding.ts +71 -0
  45. package/src/backends/mlx/graph.ts +38 -0
  46. package/src/backends/mlx/memory-client.ts +189 -0
  47. package/src/backends/mlx/memory-guard.ts +40 -0
  48. package/src/backends/mlx/methods.ts +90 -0
  49. package/src/backends/mlx/model-prompt.ts +164 -0
  50. package/src/backends/mlx/model-serving.ts +79 -0
  51. package/src/backends/mlx/prefill.ts +33 -0
  52. package/src/backends/mlx/request-policy.ts +20 -0
  53. package/src/backends/mlx/rollback.ts +51 -0
  54. package/src/backends/mlx/serial-executor.ts +273 -0
  55. package/src/backends/mlx/speculative.ts +114 -0
  56. package/src/backends/mlx/state-views.ts +26 -0
  57. package/src/backends/mlx/text-engine.ts +73 -0
  58. package/src/cli.ts +58 -25
  59. package/src/client.ts +66 -0
  60. package/src/config.ts +21 -4
  61. package/src/contracts/completion.ts +16 -0
  62. package/src/contracts/execution.ts +49 -0
  63. package/src/contracts/generation.ts +103 -0
  64. package/src/contracts/host.ts +7 -0
  65. package/src/contracts/jobs.ts +66 -0
  66. package/src/contracts/pi-provider.ts +30 -0
  67. package/src/contracts/pi.ts +259 -0
  68. package/src/contracts/resources.ts +18 -0
  69. package/src/contracts/scheduling.ts +27 -0
  70. package/src/contracts/tsconfig.json +14 -0
  71. package/src/diffusion/diffusion-generate.ts +218 -153
  72. package/src/embed.ts +1 -1
  73. package/src/engine/admission.ts +59 -0
  74. package/src/engine/cancellation.ts +38 -0
  75. package/src/engine/engine.ts +56 -0
  76. package/src/engine/execution-coordinator.ts +70 -0
  77. package/src/engine/execution-plan.ts +51 -0
  78. package/src/engine/resources.ts +42 -0
  79. package/src/engine/scheduler.ts +67 -0
  80. package/src/engine/session.ts +247 -0
  81. package/src/eval/README.md +44 -1
  82. package/src/eval/kl.ts +236 -3
  83. package/src/eval/reference-logits.ts +207 -0
  84. package/src/eval/runner.ts +8 -96
  85. package/src/fill/echo-index.ts +188 -0
  86. package/src/fill/fill-session.ts +452 -0
  87. package/src/fill/proposal.ts +68 -0
  88. package/src/fill/schema-rows.ts +506 -0
  89. package/src/fill/tool-boundary.ts +103 -0
  90. package/src/fit.ts +1 -1
  91. package/src/generate.ts +656 -268
  92. package/src/grammar.ts +12 -7
  93. package/src/harness-pi.ts +14 -33
  94. package/src/hub-rest.ts +4 -4
  95. package/src/index.ts +7 -0
  96. package/src/inference/denoising.ts +17 -0
  97. package/src/inference/graph.ts +33 -0
  98. package/src/inference/prefill.ts +39 -0
  99. package/src/inference/rollback.ts +7 -0
  100. package/src/jobs/db.ts +4 -0
  101. package/src/jobs/index.ts +2 -0
  102. package/src/jobs/job-entry.ts +12 -12
  103. package/src/jobs/runner.ts +98 -13
  104. package/src/jobs/task-client.ts +19 -0
  105. package/src/jobs/types.ts +2 -58
  106. package/src/kv-store.ts +109 -49
  107. package/src/lab/README.md +18 -0
  108. package/src/{assets → lab/curve}/curve-designer.html +2 -2
  109. package/src/{curve-sampler.ts → lab/curve/curve-sampler.ts} +2 -2
  110. package/src/{expert-trace.ts → lab/expert-trace/expert-trace.ts} +5 -5
  111. package/src/{model → lab/paged-kv}/paged-kv.ts +9 -6
  112. package/src/library.ts +40 -0
  113. package/src/lora.ts +18 -0
  114. package/src/media-fetch.ts +1 -1
  115. package/src/memory/article.ts +1 -1
  116. package/src/memory/chunk.ts +1 -1
  117. package/src/memory/model.ts +30 -169
  118. package/src/memory/prompts.ts +1 -1
  119. package/src/memory/rest.ts +1 -1
  120. package/src/memory/schedule.ts +1 -1
  121. package/src/memory/stages.ts +4 -1
  122. package/src/memory/vault.ts +1 -3
  123. package/src/mlx/array.ts +9 -10
  124. package/src/mlx/compile.ts +12 -2
  125. package/src/mlx/ffi.ts +58 -11
  126. package/src/mlx/materialize.ts +23 -0
  127. package/src/mlx/metal-kernel.ts +10 -11
  128. package/src/mlx/ops.ts +33 -13
  129. package/src/mlx/quantized-rows.ts +36 -0
  130. package/src/mlx/token-bitmask.ts +52 -0
  131. package/src/mlx/turboquant-kv-decode.ts +82 -0
  132. package/src/mlx/turboquant-kv-inverse.ts +122 -0
  133. package/src/mlx/turboquant-ops.ts +33 -3
  134. package/src/mlx/turboquant-tables.ts +2 -2
  135. package/src/model/batched-mask.ts +8 -2
  136. package/src/model/batched-quant.ts +1 -1
  137. package/src/model/batched-rotating-quant.ts +2 -2
  138. package/src/model/batched-rotating.ts +1 -1
  139. package/src/model/compiled-decode.ts +1 -1
  140. package/src/model/diffusion-gemma.ts +8 -8
  141. package/src/model/factory.ts +76 -33
  142. package/src/model/fingerprint.ts +1 -1
  143. package/src/model/flash-attention.ts +3 -3
  144. package/src/model/gemma4-base.ts +26 -9
  145. package/src/model/gemma4.ts +6 -6
  146. package/src/model/generated/gemma4-12b.ts +2 -2
  147. package/src/model/generated/gemma4-26b.ts +2 -2
  148. package/src/model/generated/gemma4-e4b.ts +2 -2
  149. package/src/model/glm52-dsa.ts +13 -9
  150. package/src/model/glm52-memory.ts +1 -1
  151. package/src/model/implementation.ts +69 -0
  152. package/src/model/minicpm5.ts +6 -8
  153. package/src/model/profile.ts +12 -2
  154. package/src/model/qwen-append.ts +12 -0
  155. package/src/model/qwen-conv.ts +52 -0
  156. package/src/model/qwen3_5-checkpoint.ts +164 -0
  157. package/src/model/qwen3_5.ts +140 -56
  158. package/src/model/support.ts +1 -1
  159. package/src/model/trellis-balanced-scatter.ts +51 -0
  160. package/src/model/trellis-linear.ts +672 -0
  161. package/src/model/trellis-shared-m.ts +151 -0
  162. package/src/model/trellis-shared-scatter.ts +144 -0
  163. package/src/model/trellis-splitk-prefill.ts +158 -0
  164. package/src/model/trellis-tiled-prefill.ts +136 -0
  165. package/src/model/trellis-vector-expand.ts +62 -0
  166. package/src/model/trellis-wide-prefill.ts +94 -0
  167. package/src/model/universal/dense.ts +1 -1
  168. package/src/model/universal/modules.ts +8 -1
  169. package/src/native-pack.ts +6 -5
  170. package/src/pi-provider.ts +6 -64
  171. package/src/pi-web.ts +8 -315
  172. package/src/prompt-cache.ts +96 -53
  173. package/src/quantize/allocator.ts +1 -1
  174. package/src/quantize/config-writer.ts +10 -3
  175. package/src/quantize/rotate.ts +4 -4
  176. package/src/quantize/trellis.ts +586 -0
  177. package/src/responses.ts +28 -1
  178. package/src/runtime-config.ts +18 -4
  179. package/src/sampler.ts +4 -4
  180. package/src/serve/batch-scheduler.ts +8 -1671
  181. package/src/serve/chat-request.ts +391 -0
  182. package/src/serve/chat-stage.ts +195 -0
  183. package/src/serve/checkpoint-identity.ts +64 -0
  184. package/src/serve/completion-executor.ts +15 -7
  185. package/src/serve/discovery-routes.ts +12 -13
  186. package/src/serve/generation-gateway.ts +154 -193
  187. package/src/serve/http.ts +159 -0
  188. package/src/serve/inference-request.ts +79 -0
  189. package/src/serve/isolate.ts +297 -105
  190. package/src/serve/lab-routes.ts +3 -0
  191. package/src/serve/lane-registry.ts +2 -1
  192. package/src/serve/model-admin-routes.ts +7 -5
  193. package/src/serve/model-binding.ts +43 -0
  194. package/src/serve/model-host.ts +512 -0
  195. package/src/serve/openai-wire.ts +103 -0
  196. package/src/serve/parent-application.ts +56 -0
  197. package/src/serve/pipeline.ts +33 -0
  198. package/src/serve/preparation.ts +33 -0
  199. package/src/serve/request-plan.ts +19 -15
  200. package/src/serve/request-prep.ts +346 -0
  201. package/src/serve/response-lease.ts +24 -0
  202. package/src/serve/responses-client.ts +69 -0
  203. package/src/serve/session-completion-engine.ts +110 -0
  204. package/src/serve/session-search.ts +1 -1
  205. package/src/serve/static-routes.ts +5 -5
  206. package/src/serve/text-completion-stage.ts +104 -0
  207. package/src/serve/token-streams.ts +519 -0
  208. package/src/server.ts +317 -2649
  209. package/src/spec/assistant-source.ts +10 -44
  210. package/src/spec/deepspec-source.ts +6 -6
  211. package/src/spec/dflash-source.ts +8 -8
  212. package/src/spec/dspark/ab-stats.ts +3 -3
  213. package/src/spec/dspark/calibration.ts +2 -2
  214. package/src/spec/dspark/deepspec-module.ts +2 -2
  215. package/src/spec/dspark/generate-dflash.ts +2 -2
  216. package/src/spec/dspark/generate.ts +2 -2
  217. package/src/spec/dspark/loader.ts +1 -1
  218. package/src/spec/dspark/module-dflash.ts +4 -3
  219. package/src/spec/dspark/module.ts +3 -3
  220. package/src/spec/dspark/quantize-drafter.ts +1 -1
  221. package/src/spec/generate.ts +1 -1
  222. package/src/spec/glm52-mtp-source.ts +1 -1
  223. package/src/spec/ngram-source.ts +1 -1
  224. package/src/spec/prefix-state.ts +45 -0
  225. package/src/spec/qwen-mtp-source.ts +166 -69
  226. package/src/spec/serve-loop.ts +154 -156
  227. package/src/spec/source.ts +42 -10
  228. package/src/spec/two-model.ts +1 -1
  229. package/src/ssd-cache.ts +80 -12
  230. package/src/ssd-durability.ts +25 -13
  231. package/src/tool-call.ts +51 -14
  232. package/src/train/flash-cce.ts +2 -2
  233. package/src/train/forward.ts +6 -0
  234. package/src/train/loss.ts +1 -1
  235. package/src/train/prefix-shared.ts +5 -1
  236. package/src/train/segmented.ts +5 -3
  237. package/src/train/trainer.ts +6 -6
  238. package/src/vision/prompt.ts +1 -1
  239. package/src/vision/qwen3vl-tower.ts +52 -10
  240. package/src/web/app.html +1 -1
  241. package/src/web/app.js +5 -3
  242. package/src/web/src/adapters-panel.ts +3 -3
  243. package/src/web/src/assistant.ts +2 -36
  244. package/src/web/src/chat.ts +4 -4
  245. package/src/web/src/composer.ts +4 -4
  246. package/src/web/src/hub.ts +1 -1
  247. package/src/web/src/markdown.ts +0 -0
  248. package/src/web/src/memory-panel.ts +1 -1
  249. package/src/web/src/model-picker.ts +1 -1
  250. package/src/web/src/palette.ts +1 -1
  251. package/src/web/src/protocol.ts +3 -39
  252. package/src/web/src/sessions.ts +1 -1
  253. package/src/web/src/ui-catalog.ts +4 -18
  254. package/src/web-assets.ts +47 -0
  255. package/src/weights.ts +55 -9
  256. package/docs/archive/investigations/curve-runs/bisector-route.json +0 -98
  257. package/docs/archive/investigations/curve-runs/bisector-trace.json +0 -1393
  258. package/docs/archive/investigations/curve-runs/curve-terrain.json +0 -1179
  259. package/docs/archive/investigations/curve-runs/curve-terrain.svg +0 -42
  260. package/docs/archive/investigations/hlg-ab.html +0 -34
  261. package/docs/archive/investigations/hlg-explorer.html +0 -238
  262. package/docs/archive/investigations/hlg-figs/cliff.svg +0 -1
  263. package/docs/archive/investigations/hlg-figs/cross.svg +0 -1
  264. package/docs/archive/investigations/hlg-figs/edges.svg +0 -1
  265. package/docs/archive/investigations/hlg-figs/k20-grids.svg +0 -1
  266. package/docs/archive/investigations/hlg-figs/k20-peaks.svg +0 -1
  267. package/docs/archive/investigations/hlg-figs/lw-curve.svg +0 -1
  268. package/docs/archive/investigations/hlg-figs/terrain.svg +0 -1
  269. package/docs/archive/investigations/hlg-report.html +0 -262
  270. package/docs/archive/investigations/hlg-runs/hlg-ab.json +0 -149
  271. package/docs/archive/investigations/hlg-runs/hlg-blind-prompts.md +0 -87
  272. package/docs/archive/investigations/hlg-runs/hlg-blind.json +0 -107
  273. package/docs/archive/investigations/hlg-runs/hlg-cliff.json +0 -197
  274. package/docs/archive/investigations/hlg-runs/hlg-cross.json +0 -505
  275. package/docs/archive/investigations/hlg-runs/hlg-edge.json +0 -388
  276. package/docs/archive/investigations/hlg-runs/hlg-eval1.json +0 -25
  277. package/docs/archive/investigations/hlg-runs/hlg-frontier.json +0 -86
  278. package/docs/archive/investigations/hlg-runs/hlg-grid.json +0 -242
  279. package/docs/archive/investigations/hlg-runs/hlg-grid2.json +0 -343
  280. package/docs/archive/investigations/hlg-runs/hlg-lw-coarse.json +0 -110
  281. package/docs/archive/investigations/hlg-runs/hlg-map.json +0 -383
  282. package/docs/archive/investigations/hlg-runs/run0-original.log +0 -159
  283. package/docs/archive/investigations/hlg-runs/runA-recommended.log +0 -131
  284. package/docs/archive/investigations/hlg-runs/runAA-cliff-cpm5.log +0 -26
  285. package/docs/archive/investigations/hlg-runs/runAB-grid2-e4b.log +0 -18
  286. package/docs/archive/investigations/hlg-runs/runAC-peak-e4b-partial.log +0 -25
  287. package/docs/archive/investigations/hlg-runs/runAD-ab-samples.log +0 -8
  288. package/docs/archive/investigations/hlg-runs/runAE-edge-e4b.log +0 -51
  289. package/docs/archive/investigations/hlg-runs/runAF-logit-calibration.log +0 -10
  290. package/docs/archive/investigations/hlg-runs/runAG-eval-config2.log +0 -16
  291. package/docs/archive/investigations/hlg-runs/runB-gain-variants.log +0 -192
  292. package/docs/archive/investigations/hlg-runs/runC-pivots.log +0 -162
  293. package/docs/archive/investigations/hlg-runs/runD-oetf.log +0 -132
  294. package/docs/archive/investigations/hlg-runs/runE-eotf.log +0 -162
  295. package/docs/archive/investigations/hlg-runs/runF-shaper-working.log +0 -86
  296. package/docs/archive/investigations/hlg-runs/runG-shaper-sweep.log +0 -101
  297. package/docs/archive/investigations/hlg-runs/runH-wsweep.log +0 -176
  298. package/docs/archive/investigations/hlg-runs/runI-asweep.log +0 -176
  299. package/docs/archive/investigations/hlg-runs/runJ-knobmap.log +0 -49
  300. package/docs/archive/investigations/hlg-runs/runK-frontier.log +0 -19
  301. package/docs/archive/investigations/hlg-runs/runL-widemap.log +0 -65
  302. package/docs/archive/investigations/hlg-runs/runM-frontier-emb.log +0 -15
  303. package/docs/archive/investigations/hlg-runs/runN-verify.log +0 -116
  304. package/docs/archive/investigations/hlg-runs/runO-frontier-seed5000.log +0 -15
  305. package/docs/archive/investigations/hlg-runs/runP-grid-AxSm.log +0 -15
  306. package/docs/archive/investigations/hlg-runs/runQ-grid-tgW.log +0 -15
  307. package/docs/archive/investigations/hlg-runs/runR-blind-transcript.log +0 -79
  308. package/docs/archive/investigations/hlg-runs/runS-cross-coherence.log +0 -33
  309. package/docs/archive/investigations/hlg-runs/runT-cross-cpm5.log +0 -33
  310. package/docs/archive/investigations/hlg-runs/runU-cross-12b.log +0 -33
  311. package/docs/archive/investigations/hlg-runs/runV-lw-e4b.log +0 -22
  312. package/docs/archive/investigations/hlg-runs/runW-lw-12b.log +0 -22
  313. package/docs/archive/investigations/hlg-runs/runX-lw-cpm5.log +0 -22
  314. package/docs/archive/investigations/hlg-runs/runY-cliff-e4b.log +0 -26
  315. package/docs/archive/investigations/hlg-runs/runZ-cliff-12b.log +0 -26
  316. package/docs/archive/mlx-bun-lab-report.html +0 -275
  317. package/docs/archive/wwdc-mlx-bun.html +0 -848
  318. package/docs/design/adapters-end-to-end.md +0 -205
  319. package/docs/design/batching-perf-path.md +0 -255
  320. package/docs/design/batching-v2-plan.md +0 -529
  321. package/docs/design/bucketing-stage.md +0 -197
  322. package/docs/design/compat-cli-surface-design.md +0 -230
  323. package/docs/design/docs-reorg-plan.md +0 -165
  324. package/docs/design/dspark-serving-program.md +0 -371
  325. package/docs/design/dspark-speculative-decoding.md +0 -318
  326. package/docs/design/grammar-spec-batching-integration.md +0 -301
  327. package/docs/design/memory-inference-path.md +0 -422
  328. package/docs/design/memory-synthesis.md +0 -287
  329. package/docs/design/memory-system.md +0 -237
  330. package/docs/design/mlx-lm-serving-execution-seam.md +0 -81
  331. package/docs/design/mlx-lm-tool-parity-plan.md +0 -545
  332. package/docs/design/omlx-adoption-map.md +0 -98
  333. package/docs/design/orpo-dynamic-lambda.md +0 -220
  334. package/docs/design/orpo-future-enhancements.md +0 -340
  335. package/docs/design/paged-kv-cache.md +0 -214
  336. package/docs/design/parallel-slots.md +0 -567
  337. package/docs/design/parity-tier-dag.md +0 -161
  338. package/docs/design/repo-cleanup-plan.md +0 -231
  339. package/docs/design/runtime-isolation.md +0 -263
  340. package/docs/design/segmented-backward-training.md +0 -543
  341. package/docs/design/spec-decode-larger-targets.md +0 -229
  342. package/docs/design/ssd-kv-cold-tier.md +0 -415
  343. package/docs/design/structured-output.md +0 -484
  344. package/docs/design/tauri-desktop-app.md +0 -127
  345. package/docs/design/the-dreaming-master-plan.md +0 -445
  346. package/docs/design/turboquant-kv.md +0 -235
  347. package/docs/design/turboquant-weights.md +0 -467
  348. package/docs/design/w4a16-compute-precision-spike.md +0 -232
  349. package/docs/design/web-chat-beat-matrix.md +0 -315
  350. package/docs/design/write-pipeline-entity.md +0 -468
  351. package/docs/planning/IDEAS.md +0 -69
  352. package/docs/reference/embedding.md +0 -197
  353. package/docs/reference/features-matrix.md +0 -101
  354. package/docs/reference/orpo-quickstart.md +0 -135
  355. package/src/flags.ts +0 -11
  356. /package/docs/{design → archive/investigations}/colibri-glm52-port.md +0 -0
  357. /package/docs/{planning → archive/planning}/chunk-task-roadmap.md +0 -0
  358. /package/docs/{planning → archive/planning}/curve-sampler-research-plan.md +0 -0
  359. /package/docs/{planning → archive/planning}/memory-docs-and-dag-plan.md +0 -0
  360. /package/docs/{planning → archive/planning}/pi-integration-review.md +0 -0
  361. /package/docs/{planning → archive/planning}/release-notes-v0.2.0.md +0 -0
  362. /package/src/{memory-plan.ts → admission-plan.ts} +0 -0
  363. /package/{docs/archive/investigations → src/serve/assets}/curve-terrain.html +0 -0
  364. /package/{docs/dag → src/serve/assets}/training-inference-map.html +0 -0
@@ -1,19 +1,111 @@
1
1
  # Server API
2
2
 
3
- `mlx-bun serve` exposes an OpenAI-compatible
4
- HTTP API on one model. The request's `model` field is ignored; the
5
- loaded model's id is echoed back. By default generation is serialized
6
- through a single queue (one GPU, batch = 1); `--batch N` switches the
7
- server into bf16 continuous batching.
8
-
9
- For the full set of start flags (`--port`, `--memory-budget`,
3
+ `mlx-bun serve` exposes an OpenAI-compatible HTTP API on one model. The
4
+ request's `model` field is ignored; the loaded model's id is echoed back.
5
+ The default concurrency cap is eight. Eligible requests use continuous batching;
6
+ `--batch 1` pins serial execution. Both use the shared generation session for
7
+ output, cancellation and cleanup. Each model implementation supplies its own
8
+ execution methods; protocol handlers do not select a concrete model class.
9
+ Embedding execution also acquires the shared model/GPU lease, so it waits for
10
+ active generation or model mutation before running.
11
+
12
+ This is the canonical wire reference: every route the server registers is
13
+ listed below, and every field documented here exists in the code that
14
+ serves it (`src/server.ts`, `src/serve/*-routes.ts`, `src/serve/isolate.ts`,
15
+ `src/pi-web.ts`). Start flags (`--port`, `--memory-budget`,
10
16
  `--prompt-cache`, `--batch`, `--kv-quant`, `--thinking`, sampling
11
- defaults, and the perf levers) and a compatibility matrix of which
12
- combinations compose, see [server-config.md](./server-config.md). This
13
- doc covers the request/response wire format. LoRA adapters are mounted at
14
- runtime via `POST /v1/adapters` (below), or at startup with
15
- `serve --adapter <dir>` (which also makes that adapter the default for
16
- requests that don't select one).
17
+ defaults) and which combinations compose live in
18
+ [server-config.md](./server-config.md); the CLI in [cli.md](./cli.md).
19
+
20
+ ## Route index
21
+
22
+ Dispatch order matters only for `/ws/chat` (upgrade) and the static shell;
23
+ every other path is unique. Unmatched paths return
24
+ `404 {"error":{"message":"not found"}}`.
25
+
26
+ | Method | Path | Section |
27
+ | --- | --- | --- |
28
+ | POST | `/v1/chat/completions` | [Chat completions](#post-v1chatcompletions) |
29
+ | POST | `/v1/completions` | [Text completions](#post-v1completions-raw-text-completion) |
30
+ | POST | `/v1/messages` | [Anthropic Messages](#post-v1messages-anthropic-messages-api) |
31
+ | POST | `/v1/responses` | [OpenAI Responses](#post-v1responses-openai-responses-api) |
32
+ | POST | `/v1/embeddings` | [Embeddings](#post-v1embeddings-openai-embeddings-api) |
33
+ | GET | `/v1` | [API index](#get-v1) |
34
+ | GET | `/v1/models`, `/v1/models/<id>` | [Models](#get-v1models) |
35
+ | GET / POST | `/v1/adapters` | [Adapters](#adapters-lora-hot-swap) |
36
+ | GET | `/v1/adapters/available` | [Adapters](#adapters-lora-hot-swap) |
37
+ | DELETE | `/v1/adapters/<id>` | [Adapters](#adapters-lora-hot-swap) |
38
+ | GET | `/health` | [Health](#get-health) |
39
+ | GET | `/stats` | [Stats](#get-stats) |
40
+ | GET | `/fit` | [Fit](#get-fit) |
41
+ | GET | `/library` | [Library](#get-library) |
42
+ | GET | `/downloads` | [Downloads](#get-downloads) |
43
+ | POST | `/admin/cache/flush` | [SSD cache flush](#post-admincacheflush) |
44
+ | GET | `/api/jobs`, `/api/jobs/<id>`, `/api/jobs/<id>/stream` | [Jobs](#jobs-apijobs) |
45
+ | POST | `/api/finetune/submit`, `/inspect-dataset`, `/merge`, `/export` | [Fine-tune](#fine-tune-apifinetune) |
46
+ | POST | `/api/quantize/inspect`, `/submit`, `/resolve-folder` | [Quantize](#quantize-apiquantize) |
47
+ | GET / POST | `/api/dataset/templates`, `/api/dataset/submit` | [Dataset](#dataset-apidataset) |
48
+ | POST | `/api/model/resolve-folder` | [Quantize](#quantize-apiquantize) |
49
+ | POST | `/api/{quantize,finetune,dataset}/push` | [Push to Hub](#post-apiquantizefinetunedatasetpush) |
50
+ | GET / POST | `/api/gc/plan`, `/api/gc/execute` | [GC](#get-apigcplan--post-apigcexecute) |
51
+ | GET / POST | `/api/memory/*` | [Memory](#get-apimemory--post-apimemoryinit) |
52
+ | GET | `/v1/memory/synthesize` | [Memory synthesis](#get-v1memorysynthesize) |
53
+ | GET / POST / DELETE | `/api/settings/hf-token`, `/api/settings/tool-approvals` | [Settings](#settings-apisettings) |
54
+ | GET / POST | `/api/hub/local`, `/search`, `/download`, `/serve` | [Model Hub](#model-hub-apihub) |
55
+ | GET | `/api/sessions/search`, `/api/sessions/export` | [Sessions](#get-apisessionssearch--get-apisessionsexport) |
56
+ | GET | `/`, `/assets/*`, `/manifest.webmanifest`, `/sw.js`, `/curves`, `/curve-terrain`, `/dag` | [Static](#web-app-static-routes--pwa-installability) |
57
+ | GET | `/status`, `/chat`, `/quantize`, `/finetune`, `/dataset` | 302 to `/#<path>` ([Static](#web-app-static-routes--pwa-installability)) |
58
+ | POST | `/signal`, `/generate` | [Curve Designer](#curve-designer-post-signal--post-generate) |
59
+ | WS | `/ws/chat` | [Web chat WebSocket](#web-chat-websocket-wschat) |
60
+
61
+ Internal routes (not part of the client contract):
62
+
63
+ | Method | Path | Where | Notes |
64
+ | --- | --- | --- | --- |
65
+ | POST | `/admin/drain` | `src/server.ts` | Only registered when the server is bound to a unix socket (`--unix`, i.e. an `--isolate` engine child). Quiesces the gateway and demotes the whole prompt cache to the SSD tier; returns `{ "drained": true, "demotions": N }`. Never exposed on TCP. |
66
+ | POST | `/admin/lease` | `src/server.ts` | Unix-socket workers only. Flushes durability, then holds the gateway's native execution lease while the response connection stays open. The isolation parent uses it for managed GPU jobs; disconnect releases the lease. Never exposed on TCP. |
67
+ | GET | `/engine` | `src/serve/isolate.ts` | Only on the `--isolate` parent proxy: `{ isolated: true, pid, restarts, socket, response_store, pool?: { resident, default } }`. Worker fields describe the current default worker, or are `null` while it is evicted; inspection never loads it. |
68
+
69
+ Under `--isolate`, the parent proxies everything to the engine child;
70
+ `/ws/chat` is answered `501` there (web chat needs a non-isolated server),
71
+ `/v1/chat/completions`, `/v1/completions`, `/v1/messages`, `/v1/responses`,
72
+ and `/v1/embeddings` are routed by the body's `model` field when a
73
+ `--model-pool` is configured, an unreachable child is
74
+ `502 {"error":{"type":"engine_unavailable"}}`, and a client disconnect
75
+ mid-proxy is `499`.
76
+
77
+ ## Diagnostics: `x-mlx-bun-trace-id` and `MLX_BUN_P2R_TRACE`
78
+
79
+ `/v1/chat/completions` and `/v1/completions` read an optional
80
+ `x-mlx-bun-trace-id` request header. With `MLX_BUN_P2R_TRACE=1` set on the
81
+ server, each of those requests emits one JSON record to stderr, prefixed
82
+ `[p2r] `, when it finishes (`src/serve/prompt-response-trace.ts`). Without
83
+ the env var nothing is allocated and the header is ignored.
84
+
85
+ ```jsonc
86
+ {
87
+ "version": 1,
88
+ "traceId": "<x-mlx-bun-trace-id, or the request id when absent>",
89
+ "requestId": "chatcmpl-…" | "cmpl-…",
90
+ "route": "/v1/chat/completions" | "/v1/completions",
91
+ "clock": "monotonic-ms",
92
+ "outcome": "success" | "error" | "abort",
93
+ "totalMs": 0.0,
94
+ "events": [ // sorted by startMs
95
+ { "phase": "request.body_parse", "startMs": 0.0, "durationMs": 0.0,
96
+ "attributes": { /* optional string|number|boolean|null map */ } }
97
+ ],
98
+ "attributes": { "stage": "body_parse" | "prepare_completion" } // on some errors
99
+ }
100
+ ```
101
+
102
+ Phases: `request.body_parse`, `request.prompt_prepare`, `completion.total`,
103
+ `completion.placement`, `engine.admission_wait`, `cache.lookup_restore`,
104
+ `prefill.total`, `prefill.batch_setup`, `prefill.chunk`,
105
+ `prefill.kv_maintenance`, `token_zero.total`, `token_zero.forward`,
106
+ `token_zero.head`, `token_zero.sample`, `response.first_write`,
107
+ `response.final_write`. Marks (`response.first_write`,
108
+ `response.final_write`) have `durationMs: 0`.
17
109
 
18
110
  ## POST /v1/chat/completions
19
111
 
@@ -23,7 +115,10 @@ Request body (OpenAI chat schema; unknown fields ignored):
23
115
  {
24
116
  "messages": [ /* role: system | user | assistant | tool */ ],
25
117
  "stream": false,
26
- "max_tokens": 1024, // or max_completion_tokens (wins)
118
+ "max_tokens": 1024, // or max_completion_tokens (wins); unset =
119
+ // no cap — generate until EOS or the
120
+ // admitted context is exhausted (admission
121
+ // clamps; --max-tokens sets a server default)
27
122
  "temperature": 0.7, // 0 = greedy
28
123
  "top_p": 0, "top_k": 0, // 0 = off
29
124
  "seed": 1234, // omit for time-derived
@@ -48,7 +143,9 @@ Request body (OpenAI chat schema; unknown fields ignored):
48
143
  // (token, logprob) pairs per position
49
144
  "stop": "\n\n", // or ["###", "\n\n"] (spec: up to 4)
50
145
  "tools": [ /* OpenAI function tools */ ],
51
- "tool_choice": "auto", // "none" disables tools
146
+ "tool_choice": "auto", // "none" disables tools; "auto" and the
147
+ // {type:"function", function:{name}}
148
+ // object are accepted and treated as auto
52
149
  "chat_template_kwargs": { // forwarded to the chat template
53
150
  "enable_thinking": false, // MiniCPM5 / Qwen3.5/3.8 / GLM-5.2: <think>
54
151
  // channel on/off (Qwen3.8 default: ON)
@@ -68,9 +165,7 @@ Request body (OpenAI chat schema; unknown fields ignored):
68
165
  // variable (Qwen3.8) the level also maps
69
166
  // into the template: minimal/low → low,
70
167
  // 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).
168
+ // Any OTHER string is a 400.
74
169
  "hlg": { // HLG tone-curve sampling (per request).
75
170
  "enabled": true, // merged over --hlg-sampling server defaults.
76
171
  "width": 4, // logit-width of the tone plateau
@@ -91,6 +186,13 @@ Sampling defaults follow the model author's `generation_config.json`
91
186
  when a field is omitted (optiq serve's gen_config behavior); explicit
92
187
  request values always win. MiniCPM5 defaults to the no-think direct
93
188
  answer mode unless `chat_template_kwargs.enable_thinking` is `true`.
189
+
190
+ Assistant history accepts `reasoning` as an alias for `reasoning_content`,
191
+ including Pi's reasoning on tool-call messages. An explicit `reasoning_content`
192
+ string takes precedence, even when empty. Normalization preserves the original
193
+ message and exposes the canonical field to templates, so Qwen's
194
+ `preserve_thinking: true` retains that history across tool turns.
195
+
94
196
  GLM-5.2 also defaults to no-think: its generation primer is
95
197
  `<think></think>`. Enabling thinking renders `Reasoning Effort: Max` in the
96
198
  system turn and leaves an open `<think>` primer for generation. The same
@@ -105,20 +207,22 @@ and `finish_reason` is `"stop"`.
105
207
  Message `content` is a string or an array of parts:
106
208
  `{ "type": "text", "text": ... }` and
107
209
  `{ "type": "image_url", "image_url": { "url": "data:image/png;base64,..." } }`
108
- (http/https URLs also accepted; PNG, JPEG, HEIC, AVIF, WebP, TIFF, GIF,
109
- BMP via native OS codecs; requires a model with the vision sidecar).
210
+ (`{ "type": "image", ... }` is accepted as an alias; http/https URLs also
211
+ accepted; PNG, JPEG, HEIC, AVIF, WebP, TIFF, GIF, BMP via native OS codecs;
212
+ requires a model with the vision sidecar).
110
213
 
111
214
  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
215
+ through a destination policy (`src/media-fetch.ts`): **private/loopback/
216
+ link-local hosts are refused by default** (the request URL is
217
+ attacker-controlled input — SSRF), every redirect hop is re-validated, and
218
+ the fetch has a **10 s timeout** and a **64 MB response cap** (video:
219
+ **256 MB** — clips are legitimately larger; frame sampling truncates long
220
+ clips server-side). Violations are clean `400`s
118
221
  (`prompt build failed: image url rejected: …`). Serving media from a LAN
119
222
  host (a NAS, another machine) is the opt-in
120
- [`--allow-private-media`](server-config.md#start-flags) flag. `data:`
121
- URLs are decoded locally and never policy-checked.
223
+ [`--allow-private-media`](server-config.md#start-flags) flag (or
224
+ `MLX_BUN_ALLOW_PRIVATE_MEDIA=1`). `data:` URLs are decoded locally and
225
+ never policy-checked.
122
226
 
123
227
  ### Video input (Qwen3.5-family models)
124
228
 
@@ -133,11 +237,12 @@ The container decodes through macOS's own AVFoundation codecs (mp4/mov,
133
237
  H.264/HEVC/ProRes/… — whatever this macOS plays) via the
134
238
  `mlx-bun-frame-extract` helper that ships beside the binary and in the
135
239
  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`.
240
+ CLT. Frames are sampled at **2 fps, capped at 768 frames**
241
+ (`src/vision/video-frames.ts`), then run the gated Qwen3-VL video pipeline
242
+ (temporal frame pairs, per-frame-group attention, 3D mRoPE positions).
243
+ Qwen3.5-family models only; video never combines with audio parts
244
+ (`400 "video and audio content parts cannot be combined"`), and a non-Qwen
245
+ model answers video parts with a `400` naming the model.
141
246
 
142
247
  ### Audio input
143
248
 
@@ -172,12 +277,13 @@ curl -s http://localhost:8080/v1/chat/completions \
172
277
  `400` naming the model — never a silent text-only answer.
173
278
  - **Formats**: WAV is decoded natively (PCM 16/24/32-bit + float32, any
174
279
  channel count / sample rate). mp3, m4a/AAC, FLAC, ogg(-CAF), AIFF and
175
- anything else CoreAudio reads transcode via macOS `afconvert`;
176
- undecodable bytes are a `400` (`audio transcode failed …`).
280
+ anything else CoreAudio reads transcode via macOS `afconvert`
281
+ (`src/audio/transcode.ts`); undecodable bytes are a `400`.
177
282
  - **Internal format**: 16 kHz mono — multi-channel input is mean-mixed,
178
283
  other rates resample. Clips truncate at **30 s** (the oracle
179
284
  processor's 480 000-sample cap); one clip costs
180
- `ceil(duration_ms / 40)` prompt tokens, at most **750**.
285
+ `ceil(duration_ms / 40)` prompt tokens, at most **750**
286
+ (`src/audio/decode.ts`).
181
287
  - Multiple clips per message and **mixed image+audio** requests work;
182
288
  media splices in document order.
183
289
  - **Routing**: audio requests always run on the **serial lane** (never
@@ -198,12 +304,16 @@ Non-streaming response:
198
304
  "index": 0,
199
305
  "message": {
200
306
  "role": "assistant",
201
- "content": "…", // "" when only tool calls
307
+ "content": "…", // null when the reply is ONLY tool calls
308
+ "reasoning": "…", // present only when the model emitted a
309
+ // <think> channel (thinking-capable
310
+ // templates with thinking on)
202
311
  "tool_calls": [{ // present when the model called tools
203
312
  "id": "call_…", "type": "function",
204
313
  "function": { "name": "…", "arguments": "{…json…}" }
205
314
  }]
206
315
  },
316
+ "logprobs": { /* see below; only when requested */ },
207
317
  "finish_reason": "stop" | "length" | "tool_calls"
208
318
  }],
209
319
  "usage": {
@@ -211,14 +321,31 @@ Non-streaming response:
211
321
  "prompt_tokens_details": { "cached_tokens": 0 }, // prompt-cache reuse
212
322
  "lane": "serial", // "serial" | "serial+spec" | "batched" —
213
323
  // which execution lane served THIS
214
- // request (GenerationGateway.place
215
- // + whether a draft actually contributed
216
- // accepted tokens). Chat completions
217
- // only (streaming: final usage chunk).
324
+ // request. Chat completions and text
325
+ // completions (streaming: final usage
326
+ // chunk).
218
327
  "speculation": { // only when a draft source is mounted
219
328
  "drafted": 0, // draft tokens proposed
220
329
  "accepted": 0, // drafts accepted by the verify step
221
330
  "targetCalls": 0 // target-model forward calls
331
+ },
332
+ "fill": { // only under MLX_BUN_FILL=strict, when a
333
+ // fill table was armed for the request
334
+ "events": 0, // injected spans
335
+ "injected": 0, // tokens appended by the engine
336
+ "strict": 0, // …of those, from schema/template rows
337
+ "echo": 0, // …from the echo index (MLX_BUN_FILL=echo)
338
+ "spanLens": [], // length of each span, in order
339
+ "wastedSamples": 0, // in-flight samples dropped at an ASSERT fill
340
+ "parseFallback": 0, // tool-call parses rejected after a fill
341
+ "indexTruncated": 0, // spans cut by EOS / delimiter / budget / span
342
+ "decodeSteps": 0, // tokens the model actually sampled
343
+ "verifyEvents": 0, // verify-policy spans that reached the forward
344
+ "verifyAccepted": 0, // …tokens the model's own argmax agreed with
345
+ "verifyRejected": 0, // …tokens dropped and rewound
346
+ "verifyUnsupported": 0, // verify spans dropped: caches cannot rewind
347
+ "checkpointMs": 0, // time in cache checkpoint / rollback
348
+ "branchStops": 0 // echo spans stopped by a history fork
222
349
  }
223
350
  }
224
351
  }
@@ -231,18 +358,48 @@ contributed accepted draft tokens to this reply, `"serial"` otherwise. It
231
358
  rides the SAME usage block on both streaming (final chunk before `[DONE]`) and
232
359
  non-streaming responses.
233
360
 
361
+ `usage.completion_tokens` counts the stopping EOS token in both native and
362
+ speculative serial generation, matching mlx-lm. EOS is excluded from response
363
+ content. An early callback stop or token budget does not count a later EOS
364
+ that happened to be evaluated in the same speculative burst.
365
+ An observed EOS reports `finish_reason: "stop"`, including when its counted
366
+ position equals `max_tokens`.
367
+
234
368
  Speculative decoding is a **server-level mode** (`serve --draft-model`,
235
369
  or the model-free `serve --draft-kind ngram`, which mounts no draft
236
370
  model); there is no per-request draft field. The `speculation` usage
237
- extension
238
- appears on chat and text completions alike, non-streaming and on the
239
- final stream chunk. Spec-eligible requests are text-only on base weights
240
- (no adapter, no logprobs capture, bf16 KV); ineligible ones decode
241
- normally and omit the field. The spec path bypasses the prompt cache
242
- (`cached_tokens` 0), and while a draft is mounted every request routes
371
+ extension appears on chat and text completions alike, non-streaming and on
372
+ the final stream chunk. Spec-eligible requests are text-only on base weights
373
+ (no adapter or logprobs capture, bf16 KV by default); experimental Qwen
374
+ uniform KV4 start=0 can qualify through `MLX_BUN_QWEN_SPEC_KV4=1`.
375
+ Ineligible requests decode normally and omit the field. The spec path bypasses
376
+ the ordinary prompt cache. Qwen MTP with `MLX_BUN_MTP_PROMPT_CACHE=1` can reuse
377
+ a paired target/draft RAM prefix, reported in `cached_tokens`; the default is
378
+ zero. These prefixes do not persist across restarts. While a draft is mounted every request routes
243
379
  through the serial lane (mlx_lm.server parity: `is_batchable = draft is
244
380
  None`) — speculation and `--batch N` are different modes.
245
381
 
382
+ `usage.fill` reports **token fast-forwarding** (`MLX_BUN_FILL=strict|echo`).
383
+ Strict mode appends structural spans from the request's tools and chat
384
+ template at qualified parser boundaries, with no draft, verification or
385
+ rollback. The model advances its hidden states and caches through the span,
386
+ using its qualified append chunk size, then resumes sampling
387
+ (design: [speculative-decoding.md](../design/speculative-decoding.md) §"Token
388
+ fast-forwarding"). Injected tokens are billed in `completion_tokens` like any
389
+ other generated token; `decodeSteps` counts ordinary sampling steps, so
390
+ `injected / (injected + decodeSteps)` is the share appended without sampling.
391
+ Injected positions still run through the model layers. The field is absent when the feature is off or the
392
+ request's shape refuses it (fixed `seed`, `logprobs`, structured output,
393
+ media, a mounted draft model, quantized KV).
394
+
395
+ Under `MLX_BUN_FILL=echo` a second, weaker source joins: spans copied from
396
+ earlier in the same session, carried under policy `verify`. Those ride the same
397
+ single forward, are checked against the argmax already in its logits, and have
398
+ their rejected tail rewound — so `verifyAccepted + verifyRejected` is what the
399
+ echo index proposed and `injected` is what survived. `wastedSamples` counts
400
+ only ASSERT fills (a verify fill consumes the in-flight sample as its first
401
+ position's check instead of discarding it).
402
+
246
403
  ### logprobs / top_logprobs
247
404
 
248
405
  With `"logprobs": true` and/or `"top_logprobs": k` (1–11), the
@@ -271,44 +428,68 @@ legacy `{tokens, token_logprobs}` text-completion shape).
271
428
 
272
429
  The distribution matches mlx-lm `generate_step` exactly: full-vocab
273
430
  log-softmax of the logits **after** logits processors (logit_bias,
274
- penalties), **before** the sampler's temperature/top-p/top-k/min-p/XTC
275
- (mlx_lm/generate.py L409–422). mlx-lm's top-k order is unspecified
276
- (argpartition); ours is sorted descending — the same set, so the entry
277
- is deterministically the argmax. Stream chunks never carry logprobs
278
- (mlx-lm's streaming responses don't either). Requests with logprobs
279
- run on the serial lane under `--batch N` (like the other mlx-lm
280
- sampler extensions). Invalid values are rejected with mlx-lm's exact
281
- messages (see Errors).
282
-
283
- Streaming (`"stream": true`) is SSE: `data: <chunk>\n\n` per event,
284
- terminated by `data: "[DONE]"`. Chunks are `chat.completion.chunk`
285
- objects whose `choices[0].delta` carries `{role}`, then `{content}`
286
- increments (multi-byte sequences are held back until decodable; text
287
- that could begin a `stop` sequence is held back until disambiguated,
288
- so no part of a stop sequence is ever streamed), then
289
- for tool calls a final `{tool_calls: [{index, id, type, function}]}`
290
- delta; the last chunk carries `finish_reason` and `usage`.
431
+ penalties), **before** the sampler's temperature/top-p/top-k/min-p/XTC.
432
+ mlx-lm's top-k order is unspecified (argpartition); ours is sorted
433
+ descending — the same set, so the entry is deterministically the argmax.
434
+ Stream chunks never carry logprobs (mlx-lm's streaming responses don't
435
+ either). Requests with logprobs run on the serial lane under `--batch N`
436
+ (like the other mlx-lm sampler extensions). Invalid values are rejected
437
+ with mlx-lm's exact messages (see Errors).
438
+
439
+ ### Streaming
440
+
441
+ `"stream": true` is SSE: `data: <chunk>\n\n` per event, terminated by
442
+ `data: [DONE]`. Chunks are `chat.completion.chunk` objects whose
443
+ `choices[0].delta` carries `{role: "assistant", content: ""}` first, then
444
+ `{reasoning}` increments while the model is inside its `<think>` channel,
445
+ then `{content}` increments (multi-byte sequences are held back until
446
+ decodable; text that could begin a `stop` sequence is held back until
447
+ disambiguated, so no part of a stop sequence is ever streamed), then for
448
+ tool calls a `{tool_calls: [{index, id, type, function}]}` delta; the last
449
+ chunk carries `finish_reason` and `usage`. A generation error mid-stream is
450
+ sent as `data: {"error":{"message":…}}` and the stream closes. While a
451
+ large tool-call body is buffered for parsing, the server sends SSE comment
452
+ heartbeats (`: keep-alive`) so client idle timers do not terminate the call.
453
+
454
+ ### Tool calls
455
+
456
+ Qwen's `<parameter=...>` values are raw text. The parser preserves HTML
457
+ entities, indentation and JSON string escapes, removing only one framing
458
+ newline at either end. Array/object parameters are JSON-decoded according to
459
+ the tool schema. The outer Qwen markup is parsed before generic JSON repair,
460
+ so a nested edit array is not mistaken for the tool-call envelope.
291
461
 
292
462
  Tool round-trip: send the assistant message with its `tool_calls` back,
293
463
  followed by `{ "role": "tool", "tool_call_id": …, "content": … }`
294
464
  messages; multi-turn prompt prefixes reuse the KV prompt cache
295
465
  automatically.
296
466
 
297
- Tool-call parsing is per model family. Gemma 4 uses its native
298
- `<|tool_call>`…`<tool_call|>` sentinel tokens. Qwen3.5 emits decoded
299
- text wrapped in `<tool_call><function=name><parameter=key>value` XML
300
- equals-style blocks. MiniCPM5 emits XML in decoded text
301
- (`<function name="…"><param name="…">…`, CDATA-wrapped values
302
- supported). For both Qwen3.5 and MiniCPM5 content before the tool
303
- markup still streams live, only the markup is withheld and converted to
304
- `tool_calls`. Argument values are decoded against the tool's JSON schema
305
- (string-typed params stay strings); markup that fails to parse falls
306
- back to plain content.
467
+ Tool-call parsing is per model family (`selectToolStreamMode` in
468
+ `src/server.ts`, parsers in `src/tool-call.ts`). Gemma 4 uses its native
469
+ `<|tool_call>`…`<tool_call|>` sentinel tokens. Every other family parses
470
+ **decoded text** once tools are active: OpenAI-JSON
471
+ `<tool_call>{…}</tool_call>`, Qwen-style
472
+ `<tool_call><function=name><parameter=key>value</parameter></function></tool_call>`,
473
+ GLM-5.2's `name<arg_key>…<arg_value>…`, and MiniCPM5's
474
+ `<function name="…"><param name="…">…</param></function>` (CDATA-wrapped
475
+ values supported). Content before the tool markup still streams live; only
476
+ the markup is withheld and converted to `tool_calls`. Argument values are
477
+ decoded against the tool's JSON schema (string-typed params stay strings).
478
+
479
+ When strict parsing fails, a repair pass runs before giving up: a
480
+ ```` ```json ```` fence or prose prefix is stripped, single quotes and
481
+ trailing commas are fixed, truncated braces are closed, a function name
482
+ that landed inside `arguments` is lifted into the envelope, and Python
483
+ literals `True`/`False`/`None` **outside string values** are coerced to
484
+ `true`/`false`/`null` (`pythonLiteralsToJson`, `src/tool-call.ts` — Qwen3.5
485
+ in particular emits Python booleans). The repair is not reported on the
486
+ wire; markup that still fails to parse falls back to plain content.
307
487
 
308
488
  ### Structured output (grammar-constrained decoding)
309
489
 
310
490
  Both `/v1/chat/completions` and `/v1/completions` accept the
311
- OpenAI/oMLX/vLLM structured-output fields (snake_case on the wire):
491
+ OpenAI/oMLX/vLLM structured-output fields (snake_case on the wire; the
492
+ field names are `GrammarRequest` in `src/grammar.ts`):
312
493
 
313
494
  ```jsonc
314
495
  {
@@ -330,9 +511,19 @@ OpenAI/oMLX/vLLM structured-output fields (snake_case on the wire):
330
511
  }
331
512
  ```
332
513
 
333
- Precedence when several are set mirrors oMLX: `guided_grammar` >
334
- `response_format` json_schema > json_object > `structured_outputs` >
335
- `guided_regex` > `guided_choice`.
514
+ | Field | Compiles to (xgrammar) | Output |
515
+ | --- | --- | --- |
516
+ | `response_format: {type:"json_object"}` | builtin JSON grammar | any valid JSON |
517
+ | `response_format: {type:"json_schema", json_schema:{name, schema, strict?, any_whitespace?}}` | `compileJSONSchema(schema)` | schema-conformant JSON |
518
+ | `guided_grammar` | `Grammar.fromEBNF` | text matching the grammar |
519
+ | `guided_regex` | EBNF-wrapped regex | text matching the regex subset |
520
+ | `guided_choice` | `root ::= ("a" \| "b" \| …)` | exactly one of the strings |
521
+ | `structured_outputs` | bare schema (alias for `json_schema.schema`) | schema-conformant JSON |
522
+
523
+ Precedence when several are set (`resolveGrammarRequest`): `guided_grammar`
524
+ > `response_format` json_schema > json_object > `structured_outputs` >
525
+ `guided_regex` > `guided_choice`. `response_format: {type:"text"}` or no
526
+ field at all means no constraint.
336
527
 
337
528
  Enforcement is a per-step token bitmask at the sampler (xgrammar):
338
529
  invalid next tokens are masked to −inf **after** the logits processors,
@@ -346,32 +537,45 @@ Degrade path — a grammar that fails to compile is never rejected (no
346
537
  requested JSON schema, raw grammar, regex, or choice constraint, and the
347
538
  response carries a `Warning` header (`grammar not enforced: …`);
348
539
  `/v1/completions` has no chat template to inject into, so it emits the
349
- `Warning` header only.
350
- `MLX_BUN_GRAMMAR=0` disables mask compilation but does not silently
351
- ignore a requested constraint: it uses this same degrade path (chat
352
- prompt injection plus `Warning`; raw completions `Warning` only).
353
- Design and fidelity notes:
354
- [structured-output.md](../design/structured-output.md).
540
+ `Warning` header only. `MLX_BUN_GRAMMAR=0` disables mask compilation but
541
+ does not silently ignore a requested constraint: it uses this same degrade
542
+ path with the hint `grammar compilation disabled by MLX_BUN_GRAMMAR=0`.
543
+ Design and fidelity notes: [docs/reference/server-api.md](./server-api.md).
355
544
 
356
545
  ### Errors
357
546
 
547
+ Runtime memory pressure can fail a request even after prompt-length admission.
548
+ The serial memory guard first reclaims older prompt-cache entries, persisting
549
+ them to the SSD tier when configured. If insufficient GPU headroom remains,
550
+ the normal request/stream error path reports the failure. Saved in-flight
551
+ generation checkpoints remain available for an identical retry.
552
+
358
553
  All errors are `{ "error": { "message": …, ... } }`.
359
554
 
360
- - `400` — malformed JSON, empty `messages`, unknown adapter id, vision
361
- request on a model without a sidecar, audio request on a model
362
- without an audio tower (message names the model), audio transcode
363
- failures, prompt build failures,
364
- non-numeric `logit_bias` keys/values (`logit_bias must be a dict of
365
- int to float`, mlx-lm's coercion error), invalid logprobs params
555
+ - `400` — malformed JSON (`invalid JSON body`), empty `messages`
556
+ (`messages required`), unknown adapter id, vision request on a model
557
+ without a sidecar (`model has no vision sidecar`), audio request on a
558
+ model without an audio tower (message names the model), video on a
559
+ non-Qwen3.5 model or combined with audio, prompt build failures
560
+ (`prompt build failed: …`, which includes media fetch/transcode
561
+ errors), non-numeric `logit_bias` keys/values (`logit_bias must be a
562
+ dict of int to float`, mlx-lm's coercion error), invalid logprobs params
366
563
  (mlx-lm's exact validation: `logprobs must be of type bool`,
367
564
  `top_logprobs must be of type int` / `at least 0` / `at most 11`;
368
- `top_logprobs: -1` is the accepted "unset" sentinel).
565
+ `top_logprobs: -1` is the accepted "unset" sentinel), invalid
566
+ `reasoning_effort` (`reasoning_effort must be one of 'none', 'minimal',
567
+ 'low', 'medium', 'high', 'xhigh'`).
369
568
  - `400` with `"type": "memory_admission"`, `"code":
370
569
  "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`.
570
+ within the max safe context (`src/serve/request-plan.ts`). A prompt that
571
+ fits is never rejected for a broad `max_tokens`: the upper bound is
572
+ capped to the remaining room (`max_tokens` is a ceiling, not a promise)
573
+ and generation proceeds. The ceiling is visible at `/stats`.
574
+ - `429` with `"type": "resource_admission"`, `"code": "queue_full"` when
575
+ a preparation or generation queue has 64 waiting requests. If SSE headers
576
+ are already sent, the stream emits its terminal error instead.
577
+ - `500` — a generation failure after admission; the stack is logged
578
+ server-side, the body keeps only the message.
375
579
 
376
580
  ## POST /v1/completions (raw text completion)
377
581
 
@@ -383,17 +587,19 @@ and adapter selection as chat.
383
587
 
384
588
  ```jsonc
385
589
  {
386
- "prompt": "Once upon a time", // REQUIRED, string only (token arrays
387
- // rejected, matching mlx_lm.server)
388
- "max_tokens": 512, // default 512 (mlx_lm.server's default) —
389
- // NOT the chat lane's generous default
590
+ "prompt": "Once upon a time", // REQUIRED, non-empty string only (token
591
+ // arrays rejected, matching mlx_lm.server)
592
+ "max_tokens": 512, // unset = no cap: generate until EOS or the
593
+ // admitted context (DEVIATION: mlx_lm.server
594
+ // defaults 512 — pass --max-tokens 512 to
595
+ // reproduce); max_completion_tokens wins
390
596
  "stream": false,
391
597
  "stop": "\n\n", // string or array, decoded-text matching
392
598
  // plus every sampling/penalty field from /v1/chat/completions:
393
599
  // temperature, top_p, top_k, seed, min_p, xtc_probability,
394
600
  // xtc_threshold, logit_bias, repetition_penalty,
395
601
  // repetition_context_size, presence_penalty, presence_context_size,
396
- // frequency_penalty, frequency_context_size, adapter,
602
+ // frequency_penalty, frequency_context_size, hlg, adapter,
397
603
  // logprobs (BOOL, mlx-lm's type — not OpenAI's legacy int),
398
604
  // top_logprobs (0-11 or -1) — same response block as chat, see
399
605
  // "logprobs / top_logprobs" above (mlx-lm shares the builder),
@@ -411,9 +617,11 @@ Response is the OpenAI text-completion object:
411
617
  {
412
618
  "id": "cmpl-…", "object": "text_completion", "created": 1760000000,
413
619
  "model": "<loaded model id>",
414
- "choices": [{ "index": 0, "text": "…", "finish_reason": "stop" | "length" }],
620
+ "choices": [{ "index": 0, "text": "…", "finish_reason": "stop" | "length",
621
+ "logprobs": { /* only when requested */ } }],
415
622
  "usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0,
416
- "prompt_tokens_details": { "cached_tokens": 0 } }
623
+ "prompt_tokens_details": { "cached_tokens": 0 },
624
+ "lane": "serial" }
417
625
  // + "speculation" under --draft-model, as on chat
418
626
  }
419
627
  ```
@@ -423,83 +631,102 @@ Streaming (`"stream": true`) is SSE: `text_completion` chunks whose
423
631
  `finish_reason` and `usage` (always attached — mlx-lm gates usage behind
424
632
  `stream_options.include_usage`; ours is an additive superset), then
425
633
  `data: [DONE]`. There is no `echo` parameter (mlx_lm.server has none).
634
+ `400` on a missing/empty/non-string `prompt`, plus the same logit_bias,
635
+ logprobs, and admission errors as chat.
426
636
 
427
637
  ## POST /v1/messages (Anthropic Messages API)
428
638
 
429
639
  Anthropic-protocol surface over the same engine — on by default, like
430
640
  `optiq serve`. Point any Anthropic-SDK tool at the server
431
641
  (`ANTHROPIC_BASE_URL=http://localhost:8080`, any `x-api-key`) — Claude
432
- Code works as a client this way.
642
+ Code works as a client this way. Translation lives in `src/anthropic.ts`;
643
+ the request funnels into the chat handler above.
433
644
 
434
645
  - `system` (string or text blocks), `messages` with string or
435
646
  content-block arrays; `tool_use` / `tool_result` blocks map to the
436
- native gemma tool-calling path (better than the optiq shim, which
437
- inlines them as text); `image` blocks (base64 or url source) hit the
438
- vision path on sidecar models. The protocol has no audio input block;
439
- audio-shaped blocks (`input_audio` / `audio` / `audio_url`) are
440
- rejected with a `400` pointing to `/v1/chat/completions`.
647
+ native tool-calling path; `image` blocks (base64 or url source) hit the
648
+ vision path on sidecar models, including image blocks nested inside a
649
+ `tool_result` (the computer-use shape). Other image source types are a
650
+ `400`. The protocol has no audio input block; audio-shaped blocks
651
+ (`input_audio` / `audio` / `audio_url`) are rejected with a `400` pointing
652
+ to `/v1/chat/completions`. Prior-turn `thinking` blocks are dropped on
653
+ re-ingest.
441
654
  - `tools` (`{name, description, input_schema}`) map to function tools;
442
- server-tool types (web_search, …) are dropped silently.
655
+ entries without `name` + `input_schema` (server-tool types such as
656
+ web_search) are dropped silently.
443
657
  - `max_tokens`, `temperature`, `top_p`, `top_k`, `stop_sequences`,
444
658
  `stream` as in the Anthropic spec. The mlx-lm sampler/penalty
445
659
  extensions (`min_p`, `xtc_probability`, `xtc_threshold`,
446
660
  `repetition_penalty`, `presence_penalty`, `frequency_penalty` + their
447
661
  `*_context_size` windows) pass through as extras; `logit_bias` does
448
662
  not exist in this protocol and is not accepted here.
449
- - Response: `{id: "msg_…", type: "message", content: [{type: "text"} |
450
- {type: "tool_use"}…], stop_reason, usage: {input_tokens,
451
- output_tokens, cache_read_input_tokens}}` —
452
- `cache_read_input_tokens` comes from the prompt cache.
663
+ - Response: `{id: "msg_…", type: "message", role: "assistant", content:
664
+ [{type: "text"} | {type: "tool_use"}…], stop_reason, stop_sequence,
665
+ usage: {input_tokens, output_tokens, cache_read_input_tokens?}}` —
666
+ `cache_read_input_tokens` is present when the prompt cache contributed.
453
667
  - Streaming follows the Anthropic event grammar exactly:
454
668
  `message_start → content_block_start/delta/stop (text_delta,
455
669
  input_json_delta) → message_delta (stop_reason + usage) →
456
670
  message_stop`. Errors are `event: error` frames.
457
671
  - Errors: `{type: "error", error: {type: "invalid_request_error" |
458
- "api_error", message}}`.
672
+ "api_error", message}}` — the chat handler's `4xx`/`5xx` are re-wrapped
673
+ into this shape.
459
674
 
460
675
  ## POST /v1/responses (OpenAI Responses API)
461
676
 
462
677
  Responses-protocol surface (Codex, Cursor, Continue, Cline, and the
463
- OpenAI SDK speak this now). Oracle: optiq responses shim.
678
+ OpenAI SDK speak this now). Oracle: optiq responses shim; port in
679
+ `src/responses.ts`.
464
680
 
465
681
  - `input` (string or item array: `message`, `function_call`,
466
682
  `function_call_output`), `instructions` (merged with any
467
683
  system/developer items into one leading system message),
468
- `max_output_tokens`, `temperature`, `top_p`, `top_k`, flat
469
- `tools`/`tool_choice` (built-in tool types dropped), `stream`. The
470
- mlx-lm sampler/penalty extensions (`min_p`, `xtc_probability`,
471
- `xtc_threshold`, `logit_bias`, `repetition_penalty`,
472
- `presence_penalty`, `frequency_penalty` + `*_context_size`) pass
473
- through as extras.
684
+ `max_output_tokens` (or `max_tokens`), `temperature`, `top_p`, `top_k`,
685
+ flat `tools`/`tool_choice` (built-in tool types — web_search, file_search,
686
+ mcp, computer_use — dropped silently; `{type:"function", name}` maps to
687
+ the chat object form, `"auto"`/`"none"`/`"required"` pass through),
688
+ `stream`. The mlx-lm sampler/penalty extensions (`min_p`,
689
+ `xtc_probability`, `xtc_threshold`, `logit_bias`, `repetition_penalty`,
690
+ `presence_penalty`, `frequency_penalty` + `*_context_size`) pass through
691
+ as extras.
474
692
  - **`previous_response_id` resumption**: pass a prior response id
475
693
  instead of resending the conversation; the server splices the stored
476
- input + output back in (instructions carry forward when omitted).
477
- Store is per-process, 1 h TTL, 32 MiB byte-capped LRU — observable
478
- at `GET /stats` (`response_store`). Unknown/expired id → 404.
694
+ input + output back in (instructions carry forward when omitted;
695
+ reasoning items are not replayed). Store is per-process, 1 h TTL,
696
+ 32 MiB byte-capped LRU — observable at `GET /stats` (`response_store`).
697
+ Unknown/expired id → `404`.
479
698
  - Response: `{id: "resp_…", object: "response", status: "completed" |
480
- "incomplete", output: [{type: "message"|"function_call"…}], usage}`.
699
+ "incomplete", output: [{type: "reasoning", summary: [{type:
700
+ "summary_text", text}]}?, {type: "message"}, {type: "function_call"}…],
701
+ usage: {input_tokens, output_tokens, total_tokens,
702
+ output_tokens_details: {reasoning_tokens}}, previous_response_id}`. A
703
+ `reasoning` item is present only when the model emitted a think channel.
481
704
  - Streaming event chain: `response.created → response.in_progress →
482
705
  response.output_item.added → response.content_part.added →
483
706
  response.output_text.delta… → response.output_text.done →
484
707
  response.content_part.done → response.output_item.done →
485
708
  response.completed` (+ `response.function_call_arguments.delta/.done`
486
- for tool calls).
709
+ for tool calls; a reasoning output item precedes the message when the
710
+ model thinks).
711
+ - Errors: `{error: {message, type: "invalid_request_error" |
712
+ "server_error", param: null, code: null}}`.
487
713
 
488
714
  ## POST /v1/embeddings (OpenAI Embeddings API)
489
715
 
490
716
  Text embeddings, available when the **served model is an embedding model**
491
717
  (plain Qwen3 / Qwen3-Embedding) — consistent with the single-model server:
492
718
  `mlx-bun serve mlx-community/Qwen3-Embedding-4B-4bit-DWQ`, then POST here. On
493
- any other served model the route returns `400 invalid_request_error`.
494
-
495
- - `input` (string or array of strings), `model` (ignored — the served model
496
- is used), and a **non-standard** `instruction` (optional): applies
497
- Qwen3-Embedding's query format `Instruct: {instruction}\nQuery:{text}`,
498
- which steers *which* similarity axis the geometry reflects. Omit it for
499
- plain document embeddings.
719
+ any other served model the route returns `400 invalid_request_error`
720
+ (`src/serve/model-admin-routes.ts`).
721
+
722
+ - `input` (string or array of strings; anything else is a `400`), `model`
723
+ (ignored — the served model is used), and a **non-standard**
724
+ `instruction` (optional): applies Qwen3-Embedding's query format
725
+ `Instruct: {instruction}\nQuery:{text}`, which steers *which* similarity
726
+ axis the geometry reflects. Omit it for plain document embeddings.
500
727
  - Pooling is last-token + L2-normalization (the vectors are unit-length);
501
728
  similarity is the dot product. Bit-exact vs the mlx-lm `qwen3` reference
502
- (`tests/qwen3-embed-parity.test.ts`).
729
+ (`tests/parity/qwen3-embed-parity.test.ts`).
503
730
  - Response: `{ "object": "list", "data": [{ "object": "embedding", "index":
504
731
  0, "embedding": [float…] }, …], "model": "<served id>", "usage": {
505
732
  "prompt_tokens": N, "total_tokens": N } }`. Embedding is a single forward
@@ -510,16 +737,31 @@ curl localhost:8080/v1/embeddings -H 'content-type: application/json' \
510
737
  -d '{"input": ["the cat sat on the mat", "a kitten by the window"]}'
511
738
  ```
512
739
 
740
+ ## GET /v1
741
+
742
+ API index for discovery tooling:
743
+
744
+ ```jsonc
745
+ { "name": "mlx-bun", "version": "<package version>", "model": "<served model id>",
746
+ "endpoints": ["POST /v1/chat/completions", "POST /v1/completions", "POST /v1/messages",
747
+ "POST /v1/responses", "POST /v1/embeddings", "GET /v1/models",
748
+ "GET/POST/DELETE /v1/adapters", "GET /health", "GET /stats", "GET /fit",
749
+ "GET /library", "GET /downloads"] }
750
+ ```
751
+
752
+ (The list is a curated subset; this document is the full inventory.)
753
+
513
754
  ## GET /v1/models
514
755
 
515
756
  `{ "object": "list", "data": [{ "id": "<model id>", "object": "model",
516
- "created": <unix s>, … }] }`
757
+ "created": <unix s>, … }] }` — `created` is the server start time for every
758
+ row.
517
759
 
518
760
  The served model is FIRST, with extra capability fields:
519
761
 
520
762
  ```jsonc
521
763
  {
522
- "id": "<served model id>", "object": "model", "created": 0,
764
+ "id": "<served model id>", "object": "model", "created": 1760000000,
523
765
  "owned_by": "mlx-bun",
524
766
  "context_window": 32768,
525
767
  "reasoning": false, // switchable thinking channel (gates the
@@ -560,7 +802,7 @@ architectures, one row per repo via `listCanonical()` — duplicate snapshots
560
802
  from upstream re-pushes are not separate entries):
561
803
 
562
804
  ```jsonc
563
- { "id": "<repo id>", "object": "model", "created": 0,
805
+ { "id": "<repo id>", "object": "model", "created": 1760000000,
564
806
  "vision": false, // visionCapable(): sidecar OR encoder-free
565
807
  // unified vision config
566
808
  "tier": "targeted" | "generic" // supportTier(): dedicated/generated forward
@@ -596,7 +838,7 @@ vision/audio, adapters, and training are not emulated by the serving port.
596
838
  "server": { "owner": "serve" | "pi-session" | "embedded", "model": "...", "started_at": 0 },
597
839
  "prompt_cache": { "entries": 0, "bytes": 0, "max_bytes": 0, "hits": 0, "misses": 0 },
598
840
  "response_store": { "entries": 0, "bytes": 0, "max_bytes": 33554432, "ttl_ms": 3600000 },
599
- "kv_quant": { "mode": "mixed (kv_config.json)" | "uniform-kv8" | "turbo k8v3" | "bf16",
841
+ "kv_quant": { "mode": "bf16" | "uniform-kv8" | "turbo k8v3" | "mixed (kv_config.json)",
600
842
  "layers": { "kv4": 8, "bf16": 40 }, // turbo: { "turbo-k8v3": 8, "bf16": 40 }
601
843
  "attention": { "global": 10, "sliding_window": 38 } },
602
844
  // present only when --ssd-cache is on:
@@ -626,18 +868,13 @@ vision/audio, adapters, and training are not emulated by the serving port.
626
868
  "resident_weight_bytes": 0,
627
869
  "main_expert_slab_bytes": 0,
628
870
  "mtp_expert_slab_bytes": 0,
629
- "expert_runtime": {
871
+ "expert_runtime": { // null when the expert runtime is off
630
872
  "main_residency": { "resident": 0, "pinned": 0, "working": 0,
631
873
  "hits": 0, "misses": 0, "evictions": 0 },
632
- "mtp_residency": { "resident": 0, "pinned": 0, "working": 0,
633
- "hits": 0, "misses": 0, "evictions": 0 },
634
- "last_turn": {
635
- "main": { "demand": { "hits": 0, "misses": 0,
636
- "readBytes": 0 } },
637
- "mtp": { "demand": { "hits": 0, "misses": 0,
638
- "readBytes": 0 } },
639
- "pilot": null
640
- },
874
+ "mtp_residency": { /* same shape */ } | null,
875
+ "last_turn": { "main": { "demand": { "hits": 0, "misses": 0, "readBytes": 0 } },
876
+ "mtp": { "demand": { "hits": 0, "misses": 0, "readBytes": 0 } },
877
+ "pilot": null },
641
878
  "last_repin": []
642
879
  }
643
880
  },
@@ -655,16 +892,122 @@ vision/audio, adapters, and training are not emulated by the serving port.
655
892
  }
656
893
  ```
657
894
 
895
+ `kv_quant.mode` reflects the `--kv-quant` flag the server was started
896
+ with; the default is `bf16` (no KV quantization). `mixed (kv_config.json)`
897
+ appears only when `--kv-quant config` was passed explicitly and the
898
+ checkpoint ships a per-layer `kv_config.json` — per-layer KV quantization
899
+ is never selected automatically.
900
+
658
901
  `pending_snapshots` counts prompt-cache entries scheduled for persistence but
659
902
  not yet confirmed by the atomic SSD store. `longest_durable_prefix_tokens` is
660
903
  the longest prefix already present in the SSD index, not merely queued.
661
904
 
905
+ ## GET /fit
906
+
907
+ Fit assessment for the loaded model on this machine, plus a capability
908
+ matrix across Apple SKUs. Used by the status page. Experts bytes come
909
+ from the registry so MoE active-parameter predictions match the serve
910
+ banner and `mlx-bun fit`. When the eval DB has a real measured decode
911
+ rate for this model snapshot, it is included and takes precedence over
912
+ the prediction.
913
+
914
+ For the direct Colibri GLM-5.2 runtime this endpoint switches to the exact
915
+ serve-time memory plan. The response separates the full streamed artifact on
916
+ disk from resident weights, main/MTP expert slabs, KV, and other reserves.
917
+ `measured_decode_tps` is the G5 warm quality-preserving measurement;
918
+ `glm52.direct_oracle_warm_decode_tps` is the same-machine direct Colibri
919
+ control, while `glm52.aspirational_decode_tps` is a target, not a release
920
+ gate. The single-row SKU result describes the current machine because the
921
+ generic all-resident SKU estimator is inapplicable
922
+ (`report.predicted_decode_tps` is `null` there).
923
+
924
+ ```jsonc
925
+ {
926
+ "machine": { "chip": "M4 Pro", "ram_bytes": 0, "bandwidth_gbs": 0.0 },
927
+ "context_tokens": 8192, // current admission ceiling
928
+ "typical_context_tokens": 8192, // min(8192, context_tokens)
929
+ "typical_decode_tps": 0.0, // predicted at typical_context_tokens
930
+ "measured_decode_tps": null, // real number from eval DB, or null
931
+ "measured_at": null, // unix ms of measurement, or null
932
+ "report": {
933
+ "fits": true,
934
+ "weights_bytes": 0,
935
+ "kv_bytes": 0,
936
+ "transient_bytes": 0,
937
+ "total_bytes": 0,
938
+ "usable_bytes": 0,
939
+ "max_safe_context": 8192,
940
+ "predicted_decode_tps": 0.0
941
+ },
942
+ // GLM-5.2 only:
943
+ "glm52": {
944
+ "artifact_disk_bytes": 0, // exact registry size; null if unavailable
945
+ "main_expert_slab_bytes": 0,
946
+ "mtp_expert_slab_bytes": 0,
947
+ "max_generation_tokens": 128,
948
+ "direct_oracle_warm_decode_tps": 0.27,
949
+ "aspirational_decode_tps": 2.0
950
+ },
951
+ "sku_matrix_ctx": 32768,
952
+ "sku_matrix": [{
953
+ "sku": "M4 Pro 24 GB", "ram_gb": 24,
954
+ "fits": true, "max_context": 32768, "decode_tps": 0.0
955
+ }]
956
+ }
957
+ ```
958
+
959
+ ## Adapters (LoRA hot-swap)
960
+
961
+ Routes in `src/serve/model-admin-routes.ts`; the manager is
962
+ `AdapterManager` in `src/lora.ts`.
963
+
964
+ - `GET /v1/adapters` — `{ adapters: [{ id, path, rank, scale, size_bytes,
965
+ mounted_layers, ram_bytes }] }` — currently-mounted adapters only.
966
+ `ram_bytes` is the actual resident size of the adapter's mounted
967
+ `lora_a`/`lora_b` arrays (summed `MlxArray.nbytes` — real RAM cost while
968
+ mounted, not a guess from the on-disk file size).
969
+ - `GET /v1/adapters/available` — `{ adapters: [{ id, path, rank, scale,
970
+ base_model, mounted, compatible }] }` — every adapter found on disk
971
+ (`~/.cache/mlx-bun-finetunes`, `~/.cache/mlx-bun/adapters`), unfiltered.
972
+ `compatible` is true when the adapter's recorded base model matches the
973
+ currently-served model (compared by bare repo name) or the adapter
974
+ recorded no base at all; `mounted` is true when it's already loaded.
975
+ The web chat's adapter chip uses `compatible` to gray out entries it
976
+ won't let you select rather than hiding them.
977
+ - `POST /v1/adapters` — `{ "id": "...", "path": "/dir" }` (both required,
978
+ else `400 "id and path required"`); mounts through the generation queue
979
+ (never races a forward pass). Response:
980
+ `{ id, mounted_layers, rank, scale, ram_bytes }`. `400` on shape/compat
981
+ mismatch — validation is all-or-nothing.
982
+ - `DELETE /v1/adapters/<id>` — unmount; `{ id, removed_layers }` on
983
+ success, `404 {"error":{"message":"adapter <id> not mounted"}}` otherwise.
984
+
985
+ Select per request with the `adapter` body field (chat, text completions;
986
+ the Anthropic and Responses surfaces pass it through as an extra), which
987
+ also accepts a composed spec — `"a+b"` (or `"a,b"`) stacks two mounted
988
+ adapters, their LoRA residuals summed in order
989
+ (`AdapterManager.resolveSpec` / `parseAdapterSpec` in `src/lora.ts`);
990
+ every named id must already be mounted or the request 400s with the
991
+ unknown id named. Prompt-cache entries are namespaced per adapter spec
992
+ (including composed ones), so switching adapters — or switching which
993
+ ones are stacked — never reuses another combination's KV.
994
+
995
+ `serve --adapter <dir>` (alias `--adapter-path`, mlx_lm.server's
996
+ spelling) mounts an adapter at startup through this same machinery and
997
+ makes it the default for requests that send no `adapter` field; an
998
+ explicit `adapter` (including `"none"`) always wins, and hot-swap via
999
+ these endpoints is unchanged.
1000
+
662
1001
  ## POST /admin/cache/flush
663
1002
 
664
1003
  Forces every pending SSD prompt-cache snapshot through the serial write queue
665
1004
  and waits for the atomic temp-file/fsync/rename boundary. The response is `200`
666
1005
  only when no snapshot or spill remains pending and no write failed during this
667
1006
  flush; otherwise it is `503` with the counters that explain why.
1007
+ A snapshot missing from both RAM and usable SSD coverage remains pending
1008
+ across repeated flushes. Retrying does not turn a missing snapshot into a
1009
+ successful durability result. Already-durable prefixes avoid another clone
1010
+ and write.
668
1011
 
669
1012
  ```jsonc
670
1013
  {
@@ -685,13 +1028,16 @@ flush; otherwise it is `503` with the counters that explain why.
685
1028
  The standard benchmark calls this endpoint before its restart leg and records
686
1029
  the result. Normal `mlx-bun serve` shutdown on `SIGINT` or `SIGTERM` performs
687
1030
  the same flush after active requests drain, with a 120-second default timeout.
1031
+ Without `--ssd-cache` the route still answers (`entries: 0`).
688
1032
 
689
1033
  ## GET /library
690
1034
 
1035
+ `?refresh=1` bypasses the short response cache, including after a parent-managed job finishes.
1036
+
691
1037
  Returns all models found in the local HuggingFace hub cache (via the
692
1038
  registry scan), each annotated with a fit assessment for this machine.
693
1039
  Response is cached for 30 seconds (registry scan + config reads; no
694
- tensor bytes are read).
1040
+ tensor bytes are read). Handler: `src/serve/discovery-routes.ts`.
695
1041
 
696
1042
  ```jsonc
697
1043
  {
@@ -720,6 +1066,203 @@ tensor bytes are read).
720
1066
  One row per repo (`listCanonical()`; duplicate snapshots from upstream
721
1067
  re-pushes surface only via the CLI's `mlx-bun ls --all-revisions`).
722
1068
 
1069
+ ## GET /downloads
1070
+
1071
+ Snapshot of the last 5 model downloads (active, completed, or errored)
1072
+ initiated via `mlx-bun download`, the web library panel, or
1073
+ `POST /api/hub/download`.
1074
+
1075
+ ```jsonc
1076
+ {
1077
+ "downloads": [{
1078
+ "repo_id": "…",
1079
+ "state": "active" | "done" | "error",
1080
+ "current_file": "model.safetensors" | null,
1081
+ "received_bytes": 0,
1082
+ "total_bytes": 0,
1083
+ "files_done": 0,
1084
+ "files_total": 0,
1085
+ "bytes_per_sec": 0, // rolling ~5 s window
1086
+ "started_at": 1760000000,
1087
+ "finished_at": null,
1088
+ "error": "…" // present on state "error"
1089
+ }]
1090
+ }
1091
+ ```
1092
+
1093
+ ## Jobs (`/api/jobs`)
1094
+
1095
+ The web wizards (quantize, fine-tune, dataset) run as jobs: a SQLite row
1096
+ plus an NDJSON event log (`src/jobs/`). Submit routes return a `job_id`;
1097
+ these routes read progress. Loopback-served admin routes, no separate auth
1098
+ (same posture as every other `/api/*` route). Handler:
1099
+ `src/serve/admin-routes.ts`.
1100
+
1101
+ `GET /api/jobs?limit=50&kind=quantize|finetune|dataset` — most recent
1102
+ jobs, newest first: `{ "ok": true, "jobs": [JobRow, …] }`.
1103
+
1104
+ `GET /api/jobs/<id>` — one row, or `404 { "ok": false, "error": "job not
1105
+ found" }`:
1106
+
1107
+ ```jsonc
1108
+ { "ok": true, "job": {
1109
+ "id": "…", "kind": "quantize" | "finetune" | "dataset",
1110
+ "status": "queued" | "running" | "done" | "failed" | "zombie",
1111
+ "config_json": "{…}", // the submit body as stored
1112
+ "progress": 0.0, // 0..1, from the latest stage/metric event
1113
+ "message": "…" | null,
1114
+ "log_path": "…", "output_path": "…" | null, "error": "…" | null,
1115
+ "started_at": "<ISO>", "ended_at": "<ISO>" | null
1116
+ } }
1117
+ ```
1118
+
1119
+ `GET /api/jobs/<id>/stream` — EventSource-compatible SSE tail of the job's
1120
+ log: a `retry: 1500` line first, then one `data: <JobEvent>` frame per
1121
+ event, polling while the job is non-terminal, then `event: end` +
1122
+ `data: {}` and close. An unknown id streams a single `failed` event then
1123
+ `end`. Events:
1124
+
1125
+ ```jsonc
1126
+ { "type": "started", "ts": 0 }
1127
+ { "type": "log", "line": "…" }
1128
+ { "type": "stage", "stage": "…", "progress": 0.5, "message": "…" }
1129
+ { "type": "metric", "kind": "train" | "val", "step": 0, "loss": 0.0 } // + grad_norm, learning_rate, tokens_per_sec, …
1130
+ { "type": "done", "ts": 0, "output_dir": "…", "summary": { } }
1131
+ { "type": "failed", "error": "…", "ts": 0 }
1132
+ ```
1133
+
1134
+ ## Fine-tune (`/api/finetune/*`)
1135
+
1136
+ The HTTP entry point to LoRA training (`src/serve/lab-routes.ts` →
1137
+ `src/train/job.ts`). It runs the same runner the CLI's `mlx-bun train`
1138
+ drives, as a subprocess job (crash isolation + one GPU lease across
1139
+ concurrent server requests). Data formats, every config knob, and the
1140
+ methodology are in [training.md](./training.md); this section is the wire
1141
+ shape.
1142
+
1143
+ `POST /api/finetune/submit` — body is `FinetuneSubmit` (`src/train/job.ts`).
1144
+ `model_dir` and `data_dir` are required at the route (`400 "model_dir and
1145
+ data_dir required"`); `adapter_path` defaults to
1146
+ `~/.cache/mlx-bun/adapters/adapter-<timestamp>` when omitted. Everything
1147
+ else is optional and falls back to `DEFAULT_TRAIN_CONFIG`:
1148
+
1149
+ ```jsonc
1150
+ {
1151
+ "model_dir": "/path/to/snapshot", // required
1152
+ "data_dir": "/path/to/dataset", // required: train.jsonl (+ optional valid.jsonl)
1153
+ "adapter_path": "/path/to/output", // optional (default above)
1154
+ "method": "sft" | "dpo" | "orpo",
1155
+ "rank": 16, "scale": 20, "rank_scaling": "…", "target_modules": ["…"], "num_layers": 0,
1156
+ "iters": 300, "learning_rate": 2e-4, "max_seq_length": 2048, "batch_size": 1,
1157
+ "grad_accumulation_steps": 1, "seed": 0, "steps_per_report": 1, "steps_per_eval": 0,
1158
+ "weight_decay": 0.0, "lora_dropout": 0.0, "rs_lora": false, "lora_plus_ratio": 1,
1159
+ "grad_checkpoint": false, "mlp_split": false, "segment_size": 0,
1160
+ "save_checkpoints": true, "grad_clip_norm": 0, "val_max_examples": 0,
1161
+ "dpo_beta": 0.1, "dpo_warmup_iters": 0, "dpo_lr_schedule": "constant" | "cosine",
1162
+ "orpo_lambda": 0.1, "orpo_warmup_iters": 0, "orpo_lr_schedule": "constant" | "cosine",
1163
+ "orpo_chunk_size": 0, "orpo_fused_ce": true, "orpo_flash_ce": true, "orpo_prefix_shared": true,
1164
+ "sft_scope": "full" | "response",
1165
+ "warm_start_adapter": "/path/to/adapter"
1166
+ }
1167
+ ```
1168
+
1169
+ Response: `{ "ok": true, "job_id": "…", "adapter_path": "…" }`. Then
1170
+ follow `GET /api/jobs/<job_id>/stream` for `metric` events (`kind:
1171
+ "train"|"val"`, `step`, `loss`, …) and the terminal `done`/`failed`.
1172
+ Config validation errors (an invalid `sft_scope`, a missing `adapter_path`
1173
+ at the runner) surface as the job's `failed` event, not as a `4xx`.
1174
+
1175
+ ```bash
1176
+ curl -s localhost:8080/api/finetune/submit -X POST -H 'content-type: application/json' -d '{
1177
+ "model_dir": "/path/to/snapshot", "data_dir": "/path/to/dataset",
1178
+ "adapter_path": "/path/to/output-adapter",
1179
+ "method": "sft", "rank": 16, "iters": 300, "learning_rate": 2e-4, "max_seq_length": 2048
1180
+ }'
1181
+ # → {"ok":true,"job_id":"…","adapter_path":"/path/to/output-adapter"}
1182
+ curl -N localhost:8080/api/jobs/<job_id>/stream
1183
+ ```
1184
+
1185
+ `POST /api/finetune/inspect-dataset` with `{"path": "<data_dir>"}` — probe
1186
+ a dataset directory before submitting: `{ "ok": true, "n_train": N,
1187
+ "n_valid": N, "format": "messages" | "prompt-completion" | "text" | "dpo" |
1188
+ "unknown" }`, or `{ "ok": false, …, "error": "<train.jsonl> not found" }`
1189
+ (HTTP 200 either way).
1190
+
1191
+ `POST /api/finetune/merge` with `{"adapter_a": "/dir", "adapter_b":
1192
+ "/dir", "scales"?: [a, b]}` — fold two adapters into one (`mergeAdapters`
1193
+ in `src/train/merge.ts`; per-source scales default to each adapter's own
1194
+ config). Output goes to `~/.cache/mlx-bun/adapters/merged-<timestamp>`:
1195
+ `{ "ok": true, "merged_path": "…", "stats": { "layersMerged", "layersOnlyInOne",
1196
+ "totalKeysOut", "sources", "scales" } }`; `400 { "ok": false, "error" }` on
1197
+ a missing field or merge failure.
1198
+
1199
+ `POST /api/finetune/export` with `{"base_model": "…", "adapter_path":
1200
+ "/dir", "method"?: "…"}` — writes a `manifest.json` describing the adapter
1201
+ into `~/.cache/mlx-bun/exports/export-<timestamp>`:
1202
+ `{ "ok": true, "export_path": "…", "manifest": { "version": 1, "base_model",
1203
+ "adapter_path", "method"?, "created_at" } }`; `400` on a missing field.
1204
+
1205
+ ## Quantize (`/api/quantize/*`)
1206
+
1207
+ `POST /api/quantize/inspect` with `{"model_id": "<repo id | path>"}` —
1208
+ resolve a model through the registry and report whether the quantizer can
1209
+ walk it (`inspectModel`, `src/quantize/job.ts`): `{ "ok": true, "model_id",
1210
+ "arch": "<model_type>" | null, "support": true, "size_gb": 0.0 }`, or
1211
+ `{ "ok": false, …, "error" }` (HTTP 200 either way).
1212
+
1213
+ `POST /api/quantize/submit` — starts a quantize job whose output lands in
1214
+ the HF hub cache as a synthetic repo `<org>/<name>-OptiQ-<suffix>`
1215
+ (`refs/main` written so the registry picks it up; `<suffix>` is
1216
+ `<bits>bit` or `mixed-<target_bpw>bpw`, plus `-rot<seed>` when rotating):
1217
+
1218
+ ```jsonc
1219
+ {
1220
+ "model_id": "org/name | /path", // required (400 "model_id required")
1221
+ "bits": 4, "group_size": 64,
1222
+ "target_bpw": 3.5, // mixed-precision target; wins over bits in the name
1223
+ "candidate_bits": [2, 3, 4, 8],
1224
+ "reference": "…", "calibration_mix": "…", "n_calibration": 0,
1225
+ "rotate_weights": false, "rotation_seed": 42
1226
+ }
1227
+ ```
1228
+
1229
+ Response: `{ "ok": true, "job_id": "…", "output_dir": "…" }`. The library
1230
+ cache is invalidated when the job completes.
1231
+
1232
+ `POST /api/quantize/resolve-folder` and `POST /api/model/resolve-folder`
1233
+ (same handler) with `{"folder_name"?: "…", "rel_path"?: "…"}` — turn a
1234
+ folder the browser picked (a `models--org--name` hub dir, a snapshot hash
1235
+ dir, or a bare model dir under the hub cache / `~/.cache/mlx-bun`) into a
1236
+ servable path: `{ "ok": true, "path": "…", "repo_id"?: "…", "model_type"?:
1237
+ "…" }`, or `{ "ok": false, "error": "Couldn't locate this folder on disk —
1238
+ paste the path instead." }` (HTTP 200).
1239
+
1240
+ ## Dataset (`/api/dataset/*`)
1241
+
1242
+ `GET /api/dataset/templates` — the dataset-builder template registry
1243
+ (`src/dataset/registry.ts`): `{ "templates": [{ "id", "label",
1244
+ "description", "output_format": "messages" | "prompt_completion" | "dpo" |
1245
+ "text", "needs_llm": bool, "fields": [{ "name", "type": "text" | "textarea" |
1246
+ "number", "label", "hint"?, "required"?, "default"? }] }] }`.
1247
+
1248
+ `POST /api/dataset/submit` with `{"template_id": "…", "inputs": {…},
1249
+ "model_name"?: "…"}` — runs the template in-process as a job; LLM-backed
1250
+ templates call back into this server's own `/v1` (`api_url` =
1251
+ `http://127.0.0.1:<port>`). Output dir is
1252
+ `~/.cache/mlx-bun/datasets/dataset-<template>-<timestamp>`. Response:
1253
+ `{ "ok": true, "job_id": "…", "output_dir": "…" }`; `400 { "ok": false,
1254
+ "error": "unknown template …" }` for an unknown id.
1255
+
1256
+ ## POST /api/{quantize,finetune,dataset}/push
1257
+
1258
+ Upload a job's output (or any folder) to the Hugging Face Hub with the
1259
+ saved write token (`src/hf-push.ts`). Body: `{"repo_id": "org/name",
1260
+ "job_id"?: "…", "source_path"?: "/dir", "private"?: false}` — `repo_id` is
1261
+ required; the source is `source_path`, else the job's `output_path`. The
1262
+ `dataset` variant pushes as a dataset repo, the others as model repos.
1263
+ Response `{ "ok": true, "url": "…" }`; `400 { "ok": false, "error" }` when
1264
+ no token is saved, no source directory resolves, or the upload fails.
1265
+
723
1266
  ## GET /api/gc/plan · POST /api/gc/execute
724
1267
 
725
1268
  Reclaim disk from superseded snapshots + orphaned blobs in the local HF
@@ -764,13 +1307,12 @@ Errors follow the same `{ "ok": false, "error": "…" }` shape as the other
764
1307
  ## GET /api/memory/\* · POST /api/memory/init
765
1308
 
766
1309
  Thin loopback JSON wrappers over `src/memory/vault.ts` for the web chat's
767
- Memory panel (docs/design/web-chat-redesign.md §5.5) — same loopback-only
768
- admin-route posture as `/api/gc/*`, no separate auth. Handlers live in
769
- `src/memory/rest.ts`; they never touch the agent-tool surface
770
- (`src/memory/tools.ts`), which stays read-only and unchanged. Every route
771
- below degrades gracefully — a missing vault, an unknown article, or a bad
772
- param returns `{ "ok": false, ... }` (with an appropriate HTTP status), never
773
- a 500 or an unhandled throw.
1310
+ Memory panel — same loopback-only admin-route posture as `/api/gc/*`, no
1311
+ separate auth. Handlers live in `src/memory/rest.ts`; they never touch the
1312
+ agent-tool surface (`src/memory/tools.ts`), which stays read-only. Every
1313
+ route below degrades gracefully — a missing vault, an unknown article, or
1314
+ a bad param returns `{ "ok": false, ... }` (with an appropriate HTTP
1315
+ status), never a 500.
774
1316
 
775
1317
  When no vault exists yet, every `GET` route (except `/init`) returns:
776
1318
 
@@ -792,25 +1334,23 @@ separate (mirrors `memory_list`):
792
1334
  ```
793
1335
 
794
1336
  `GET /api/memory/search?q=<query>&scope=all|articles|reference&limit=50` —
795
- wraps `searchArticles`; same summaries/hits shape the `memory_search` tool
796
- formats from:
1337
+ wraps `searchArticles` (`limit` capped at 200; `400` without `q`); same
1338
+ summaries/hits shape the `memory_search` tool formats from:
797
1339
 
798
1340
  ```jsonc
799
1341
  { "ok": true, "summaries": [{ "article": "…", "occurrences": 3, "matched_terms": ["…"] }], "hits": [{ "article": "…", "anchor": "…", "line": 10, "excerpt": "…" }] }
800
1342
  ```
801
1343
 
802
1344
  `GET /api/memory/article?name=<article>` — rendered source plus the
803
- deterministic metadata the panel needs to render it (infobox, lead, series
804
- banner, section skeleton) without re-parsing client-side; 404s
805
- `{ "ok": false }` for an unknown name:
1345
+ deterministic metadata the panel needs (infobox, lead, series banner,
1346
+ section skeleton); `404 { "ok": false }` for an unknown name:
806
1347
 
807
1348
  ```jsonc
808
1349
  { "ok": true, "name": "…", "path": "…", "content": "# …", "infobox": { "type": "…", "entityKind": "thing", "fields": [] }, "lead": "…", "series": null, "structure": [] }
809
1350
  ```
810
1351
 
811
1352
  `GET /api/memory/links?name=<article>` — inbound/outbound wikilinks (same
812
- data `memory_links` resolves); articles only, not `Reference/*` (matching
813
- the tool's own scope):
1353
+ data `memory_links` resolves); articles only, not `Reference/*`:
814
1354
 
815
1355
  ```jsonc
816
1356
  { "ok": true, "name": "…", "outbound": ["…"], "inbound": ["…"] }
@@ -818,7 +1358,7 @@ the tool's own scope):
818
1358
 
819
1359
  `GET /api/memory/history?name=<article>&limit=50` — the article's git log
820
1360
  (read-only `git log` plumbing on the vault's own repo, via `Bun.spawn` —
821
- never a shell string):
1361
+ never a shell string; `limit` capped at 200):
822
1362
 
823
1363
  ```jsonc
824
1364
  { "ok": true, "name": "…", "isGitRepo": true, "entries": [{ "hash": "…", "date": "YYYY-MM-DD", "subject": "…" }] }
@@ -827,99 +1367,78 @@ never a shell string):
827
1367
  `GET /api/memory/diff?name=<article>&rev=<hash>` — that commit's diff for
828
1368
  the article (`git show <rev> -- <path>`). `rev` must match
829
1369
  `/^[0-9a-f]{4,40}$/` (a bare commit hash — never a ref expression like
830
- `HEAD~1` or `main^`) or the request 400s before touching git; `name` is
831
- resolved through the same existence-checked normalization `readArticle`
832
- uses, so a path-traversal or otherwise-invalid name 404s rather than
833
- reaching the `git` argv:
1370
+ `HEAD~1`) or the request 400s before touching git; an unknown `name` or a
1371
+ revision with no diff for it 404s:
834
1372
 
835
1373
  ```jsonc
836
1374
  { "ok": true, "name": "…", "rev": "…", "diff": "diff --git a/articles/…" }
837
1375
  ```
838
1376
 
839
1377
  `POST /api/memory/init` with optional body `{"path"?: string}` — the
840
- first-run consent-card backend. Delegates to the exact same `setupVault()`
841
- the CLI's `mlx-bun memory init` calls (idempotent: create dirs, write
842
- README + Meta pages only if missing, git init + initial commit); omits only
843
- the CLI's interactive extras (seed-from-existing-vault prompt, nightly
844
- schedule prompt), which stay TTY-only. `path`, if given, must resolve under
845
- the default vault root or the OS temp directory — `setupVault()` mkdir's
846
- into it and `git init`s it if it isn't already a repo, so anything outside
847
- those trees 400s before touching the filesystem rather than letting a
848
- caller redirect vault setup at an arbitrary directory:
1378
+ first-run consent-card backend. Delegates to the same `setupVault()` the
1379
+ CLI's `mlx-bun memory init` calls (idempotent: create dirs, write README +
1380
+ Meta pages only if missing, git init + initial commit); omits the CLI's
1381
+ interactive extras. `path`, if given, must resolve under the default vault
1382
+ root or the OS temp directory, else `400`:
849
1383
 
850
1384
  ```jsonc
851
1385
  { "ok": true, "result": { "root": "…", "created": ["…"], "gitInitialized": true, "alreadySetUp": false }, "status": { "root": "…", "exists": true, "articleCount": 0, "referenceCount": 9, "isGitRepo": true, "recentArticles": [] } }
852
1386
  ```
853
1387
 
854
- ## GET /api/settings/tool-approvals · DELETE /api/settings/tool-approvals
855
-
856
- The web chat's durable "always allow this tool" list for gated tools
857
- (`bash`/`edit`/`write`) — docs/design/web-chat-redesign.md §5.4/§6.5/§9
858
- Phase 2, risk #6. Backed by `~/.mlx-bun/tool-approvals.json`
859
- (`src/tool-approvals.ts`), a versioned `{version, allows}` file (same
860
- plain-JSON-at-0600 convention as `~/.mlx-bun/hf.json`) keyed by stable tool
861
- NAME, not per-call arguments — "always allow bash" means every future
862
- `bash` call skips the browser's approval card, on every chat and every
863
- browser tab, until forgotten. These two routes are read/forget only:
864
- granting an always-allow happens through the approval card itself (the
865
- `approval` WS frame on `/ws/chat`, `alwaysAllow: true`), never a bare
866
- settings-form POST.
1388
+ ## GET /v1/memory/synthesize
867
1389
 
868
- `GET /api/settings/tool-approvals`:
1390
+ Runs the memory synthesis pipeline (`runSynthesis`, `src/memory/pipeline.ts`
1391
+ — the same job `mlx-bun memory synthesize` runs) and streams its progress
1392
+ as SSE. `?dry=1` plans the stages without model calls or writes. This is a
1393
+ long GPU job over the whole store on a real run; it is registered under
1394
+ `/v1/` for historical reasons and is not part of the OpenAI surface.
869
1395
 
870
- ```jsonc
871
- { "ok": true, "alwaysAllow": ["bash", "edit"] }
872
1396
  ```
873
-
874
- `DELETE /api/settings/tool-approvals` with body `{"tool": "bash"}` —
875
- revokes one tool's always-allow (idempotent; forgetting an entry that
876
- isn't present is not an error). Returns the updated set:
877
-
878
- ```jsonc
879
- { "ok": true, "alwaysAllow": ["edit"] }
1397
+ data: {"type":"stage","stage":"ingest","message":"…"}
1398
+ data: {"type":"log","message":"…"}
1399
+ data: {"type":"done","message":"synthesis done — …"}
1400
+ data: {"type":"summary","implemented":true,"stages":["ingest","segment","extract","route","create","section-route","patch","link","wikify"],"note":"…"}
1401
+ data: [DONE]
880
1402
  ```
881
1403
 
882
- Separately, `GET /api/settings/hf-token` / `POST /api/settings/hf-token`
883
- manage the Hugging Face write token the same settings modal's other
884
- section uses (see the `upload` CLI command in [cli.md](./cli.md) for the
885
- push-to-hub flow this token drives).
1404
+ A failure emits `data: {"type":"error","message":"…"}` and closes without
1405
+ `[DONE]`.
886
1406
 
887
- ## GET /downloads
1407
+ ## Settings (`/api/settings/*`)
888
1408
 
889
- Snapshot of the last 5 model downloads (active, completed, or errored)
890
- initiated via `mlx-bun download` or the web library panel.
1409
+ `GET /api/settings/hf-token` → `{ "ok": true, "hasToken": bool }`;
1410
+ `POST /api/settings/hf-token` with `{"token": "hf_…"}` saves the Hugging
1411
+ Face write token to `~/.mlx-bun/hf.json` (mode 0600) →
1412
+ `{ "ok": true }`; `400 "token required"` when empty. The token drives the
1413
+ push routes above and the `upload` CLI command ([cli.md](./cli.md)).
1414
+
1415
+ `GET /api/settings/tool-approvals` / `DELETE /api/settings/tool-approvals`
1416
+ — the web chat's durable "always allow this tool" list for gated tools
1417
+ (`bash`/`edit`/`write`), backed by `~/.mlx-bun/tool-approvals.json`
1418
+ (`src/tool-approvals.ts`), keyed by stable tool NAME, not per-call
1419
+ arguments. These two routes are read/forget only: granting an always-allow
1420
+ happens through the approval card itself (the `approval` WS frame on
1421
+ `/ws/chat` with `alwaysAllow: true`), never a bare settings POST.
891
1422
 
892
1423
  ```jsonc
893
- {
894
- "downloads": [{
895
- "repo_id": "…",
896
- "state": "active" | "done" | "error",
897
- "current_file": "model.safetensors" | null,
898
- "received_bytes": 0,
899
- "total_bytes": 0,
900
- "files_done": 0,
901
- "files_total": 0,
902
- "bytes_per_sec": 0, // rolling ~5 s window
903
- "started_at": 1760000000,
904
- "finished_at": null,
905
- "error": "…" // present on state "error"
906
- }]
907
- }
1424
+ // GET
1425
+ { "ok": true, "alwaysAllow": ["bash", "edit"] }
1426
+ // DELETE with body {"tool": "bash"} — idempotent; returns the updated set
1427
+ { "ok": true, "alwaysAllow": ["edit"] }
908
1428
  ```
909
1429
 
1430
+ `DELETE` without `tool` is `400 "tool required"`.
1431
+
910
1432
  ## Model Hub (`/api/hub/*`)
911
1433
 
912
- The web chat's Model Hub panel (docs/design/web-chat-redesign.md §9 Phase 3,
913
- beat-matrix Axis 3): browse downloaded models, search Hugging Face, and kick
914
- off downloads — all loopback-served, no separate auth, same posture as
915
- `/api/gc/*` and `/api/memory/*`. Handlers live in `src/hub-rest.ts` (pure
916
- functions, no loaded-model dependency); route dispatch in `src/server.ts`
917
- just matches path + method.
1434
+ The web chat's Model Hub panel: browse downloaded models, search Hugging
1435
+ Face, and kick off downloads — all loopback-served, no separate auth.
1436
+ Handlers live in `src/hub-rest.ts` (pure functions, no loaded-model
1437
+ dependency).
918
1438
 
919
1439
  `GET /api/hub/local` — every model in the local registry (same source
920
1440
  `/library` reads), each with a `/fit`-computed hardware verdict at a fixed
921
- 8k context — the beat matrix's "real hardware verdict per row" that optiq's
922
- Hub lacks:
1441
+ 8k context:
923
1442
 
924
1443
  ```jsonc
925
1444
  {
@@ -939,11 +1458,10 @@ Hub lacks:
939
1458
  ```
940
1459
 
941
1460
  `GET /api/hub/search?q=<query>` — server-side Hugging Face model search,
942
- filtered to the `mlx` library tag (the tag every mlx-community / MLX-format
943
- repo carries) so results are MLX-compatible by construction, normalized to
944
- the fields the Hub panel needs. Never downloads anything — search only.
945
- Degrades gracefully on any network failure (DNS, timeout, non-2xx) to an
946
- explicit offline state rather than a 500:
1461
+ filtered to the `mlx` library tag so results are MLX-compatible by
1462
+ construction. Never downloads anything. `400` without `q`. Degrades on any
1463
+ network failure (DNS, timeout, non-2xx) to an explicit offline state
1464
+ rather than a 500:
947
1465
 
948
1466
  ```jsonc
949
1467
  { "ok": true, "offline": false, "results": [{ "id": "mlx-community/…", "downloads": 0, "likes": 0, "size_estimate": null }] }
@@ -953,69 +1471,51 @@ explicit offline state rather than a 500:
953
1471
  { "ok": true, "offline": true, "error": "…", "results": [] }
954
1472
  ```
955
1473
 
956
- `size_estimate` is always `null` in v1 — the search endpoint doesn't return
957
- per-repo file sizes and a second per-repo call would slow the search down;
958
- the fit badge lands once a model is actually downloaded (`/api/hub/local`).
1474
+ `size_estimate` is always `null` — the search endpoint doesn't return
1475
+ per-repo file sizes; the fit badge lands once a model is actually
1476
+ downloaded (`/api/hub/local`).
959
1477
 
960
1478
  `POST /api/hub/download` with body `{"repo": "org/name"}` — starts a
961
- background download via the existing `downloadModel()` +
962
- `src/download.ts` process tracker and returns immediately; progress is
963
- already visible via [`GET /downloads`](#get-downloads). Refuses a duplicate
964
- kick-off for a repo that's already downloading:
1479
+ background download via `downloadModel()` and returns immediately;
1480
+ progress is visible via [`GET /downloads`](#get-downloads). Refuses a
1481
+ duplicate kick-off for a repo that's already downloading:
965
1482
 
966
1483
  ```jsonc
967
1484
  { "ok": true, "repo": "org/name", "started": true }
968
1485
  ```
969
1486
 
970
1487
  ```jsonc
971
- { "ok": false, "error": "a download for org/name is already in progress" }
1488
+ { "ok": false, "error": "a download for org/name is already in progress" } // 409
972
1489
  ```
973
- (409)
974
1490
 
975
1491
  `POST /api/hub/serve` with body `{"model": "org/name"}` — **always answers
976
- `restart_required`, never performs a live in-process swap.** Investigated
977
- against `docs/design/runtime-isolation.md`: the web chat's `/ws/chat` path
978
- is explicitly not proxied even under `--isolate` (`src/serve/isolate.ts`
979
- answers it 501), and `--isolate` itself is opt-in, not the default — so the
980
- process a web chat session is actually attached to always has exactly one
981
- model loaded with no drop-weights-and-reload seam reachable from that path.
982
- (The `--isolate` proxy's `ModelPool`, P2 of that doc, *is* a real
983
- spawn-overlap live-swap mechanism — it just lives entirely on the isolated
984
- `/v1/*` HTTP surface, not on `/ws/chat`.) Faking a swap here would leave the
985
- server in a half-state; the honest answer is the restart command:
1492
+ `restart_required`, never performs a live in-process swap.** The process a
1493
+ web chat session is attached to has exactly one model loaded and no
1494
+ drop-weights-and-reload seam reachable from `/ws/chat` (the `--isolate`
1495
+ proxy's `ModelPool` is a real spawn-overlap swap, but it lives on the
1496
+ isolated `/v1/*` surface only — `src/serve/isolate.ts`). The honest answer
1497
+ is the restart command (HTTP 200, `ok: false`):
986
1498
 
987
1499
  ```jsonc
988
1500
  { "ok": false, "restart_required": true, "command": "mlx-bun serve org/name" }
989
1501
  ```
990
1502
 
991
- Wiring `/ws/chat` through the isolate proxy (or otherwise reaching
992
- `ModelPool`'s spawn-overlap swap from the web chat) is the Hub's live-swap
993
- follow-up, not yet scheduled.
994
-
995
1503
  ## GET /api/sessions/search · GET /api/sessions/export
996
1504
 
997
1505
  Full-text search across web-chat session message **bodies** and chat
998
- export (docs/design/web-chat-redesign.md §9 Phase 3, beat-matrix Axis
999
- 10/11's full-text-search BEAT: Claude's own session search is title-only —
1000
- a widely-cited annoyance — and local storage removes any server-cost
1001
- excuse to skip real body search). Same loopback-only, no-separate-auth
1002
- posture as `/api/memory/*`/`/api/hub/*`. Handlers live in
1003
- `src/serve/session-search.ts` (pure, read-only, no loaded-model
1004
- dependency); route dispatch in `src/server.ts` just matches path + method.
1005
- Both routes read the same session directory the web chat's `PiWebSession`
1006
- writes to (`~/.mlx-bun/sessions`, pi's own JSONL-per-session format) and
1007
- are JSONL-tolerant — a corrupt or mid-write line is skipped, never thrown.
1008
-
1009
- `GET /api/sessions/search?q=<query>` — case-insensitive substring scan (v1;
1010
- no index — personal-chat-corpus scale, see the module header comment for
1011
- the upgrade path if the corpus ever grows enough to matter) over every
1012
- session file's user/assistant message text, capped at 50 matches total (10
1013
- per session). Each match reports a plain-text ±60-char snippet plus the
1014
- match's own `[start,end)` offsets **into that snippet** — offsets, not
1015
- pre-rendered HTML highlighting, so the frontend escapes the snippet then
1016
- inserts its own `<mark>` (the same escape-then-restore discipline the
1017
- markdown renderer uses for code spans, applied here to search
1018
- highlighting instead):
1506
+ export. Same loopback-only, no-separate-auth posture as `/api/memory/*`.
1507
+ Handlers live in `src/serve/session-search.ts` (pure, read-only, no
1508
+ loaded-model dependency). Both routes read the same session directory the
1509
+ web chat's `PiWebSession` writes to (`~/.mlx-bun/sessions`, pi's own
1510
+ JSONL-per-session format) and are JSONL-tolerant — a corrupt or mid-write
1511
+ line is skipped, never thrown.
1512
+
1513
+ `GET /api/sessions/search?q=<query>` — case-insensitive substring scan (no
1514
+ index — personal-chat-corpus scale) over every session file's
1515
+ user/assistant message text, capped at 50 matches total (10 per session).
1516
+ Each match reports a plain-text ±60-char snippet plus the match's own
1517
+ `[start,end)` offsets **into that snippet** — offsets, not pre-rendered
1518
+ HTML, so the frontend escapes the snippet then inserts its own `<mark>`:
1019
1519
 
1020
1520
  ```jsonc
1021
1521
  {
@@ -1031,15 +1531,8 @@ highlighting instead):
1031
1531
  400s `{"ok": false, "error": "q is required"}` without a query.
1032
1532
 
1033
1533
  `GET /api/sessions/export?path=<session file>` — the raw session JSONL,
1034
- parsed into an array of entries (one per line, in file order) — the
1035
- frontend's Markdown/JSON export renders from this for a session that isn't
1036
- the currently-open one (the open session's own history is already loaded
1037
- client-side, but this endpoint is used uniformly for both cases so there's
1038
- one code path, not two). `path` must resolve under the session directory —
1039
- exactly the same guard shape as `PiWebSession`'s private
1040
- `isUnderSessionDir` (the root itself, or a path prefixed by `root + "/"`)
1041
- — reimplemented as a small standalone check in `session-search.ts` rather
1042
- than exported from that class:
1534
+ parsed into an array of entries (one per line, in file order). `path` must
1535
+ resolve under the session directory:
1043
1536
 
1044
1537
  ```jsonc
1045
1538
  { "ok": true, "path": "…", "entries": [{ "type": "session", "id": "…", "cwd": "…" }, { "type": "message", "message": { "role": "user", "content": "…" } }] }
@@ -1050,153 +1543,118 @@ path inside the directory that doesn't exist on disk.
1050
1543
 
1051
1544
  ## Web app static routes + PWA installability
1052
1545
 
1053
- The web chat (`GET /`) and everything it loads same-origin, no CDN, ever:
1546
+ The web chat (`GET /`) and everything it loads same-origin, no CDN, ever
1547
+ (`src/serve/static-routes.ts`; GET only):
1054
1548
 
1055
1549
  | Route | Content | Notes |
1056
1550
  | --- | --- | --- |
1057
1551
  | `GET /` | `src/web/app.html` | The unified SPA shell. |
1058
- | `GET /assets/app.js` | `src/web/app.js` | GENERATED from `src/web/src/*.ts` by `bun scripts/build-web.ts` — see `tests/web-build.test.ts`'s freshness gate. |
1059
- | `GET /assets/hljs.js`, `GET /assets/hljs.css` | `src/web/vendor/hljs*` | Vendored syntax highlighting, no CDN (see that dir's README). |
1060
- | `GET /manifest.webmanifest` | `src/web/manifest.webmanifest` | PWA manifest — name/short_name/theme colors match the app's design tokens. |
1061
- | `GET /assets/icon.svg` | `src/web/icon.svg` | A single inline SVG app icon. No binary PNGs are shipped (the hygiene gate forbids tracked binaries beyond its allowlist); some browsers don't render an SVG as an install icon — accepted, noted in [features-matrix.md](features-matrix.md). |
1062
- | `GET /sw.js` | `src/web/sw.js` | Service worker, served `cache-control: no-store` at the root scope (required for a worker to control `/`). |
1063
-
1064
- **PWA scope (beat-matrix Axis 10 "PWA installability"):** the manifest +
1065
- service worker exist for *installability and an instant static-shell
1066
- paint* — "Add to Home Screen" / a browser "Install app" prompt, and the
1067
- next visit's HTML/JS/CSS loading from cache while the WebSocket connects.
1068
- This is explicitly **not** offline chat: `src/web/sw.js`'s fetch handler
1069
- only intercepts the exact shell files listed above (`/`, `/assets/app.js`,
1070
- `/assets/hljs.js`, `/assets/hljs.css`) and lets every dynamic route
1071
- (`/api/*`, `/v1/*`, `/ws/chat`, `/downloads`, …) fall straight through to
1072
- the network unconditionally — caching a chat API response would be
1073
- actively misleading given the app is a thin client over a local model
1074
- process that has to actually be running. Registration
1075
- (`src/web/src/shell.ts`'s `initServiceWorker()`) is guarded to secure
1076
- contexts: `https:` or `localhost`/`127.0.0.1`, matching the browser's own
1077
- service-worker eligibility rule.
1078
-
1079
- ## GET /fit
1080
-
1081
- Fit assessment for the loaded model on this machine, plus a capability
1082
- matrix across Apple SKUs. Used by the status page. Experts bytes come
1083
- from the registry so MoE active-parameter predictions match the serve
1084
- banner and `mlx-bun fit`. When the eval DB has a real measured decode
1085
- rate for this model snapshot, it is included and takes precedence over
1086
- the prediction.
1087
-
1088
- For the direct Colibri GLM-5.2 runtime this endpoint switches to the exact
1089
- serve-time memory plan. The response separates the full streamed artifact on
1090
- disk from resident weights, main/MTP expert slabs, KV, and other reserves.
1091
- `measured_decode_tps` is the G5 warm quality-preserving measurement;
1092
- `glm52.direct_oracle_warm_decode_tps` is the same-machine direct Colibri
1093
- control, while `glm52.aspirational_decode_tps` is a target, not a release
1094
- gate. The single-row SKU result describes the current machine because the
1095
- generic all-resident SKU estimator is inapplicable.
1552
+ | `GET /assets/app.js` | `src/web/app.js` | GENERATED from `src/web/src/*.ts` by `bun scripts/build-web.ts` — see `tests/using/web-build.test.ts`'s freshness gate. |
1553
+ | `GET /assets/hljs.js`, `GET /assets/hljs.css` | `src/web/vendor/hljs*` | Vendored syntax highlighting, no CDN. |
1554
+ | `GET /manifest.webmanifest` | `src/web/manifest.webmanifest` | PWA manifest. |
1555
+ | `GET /assets/icon.svg` | `src/web/icon.svg` | A single inline SVG app icon; no binary PNGs are shipped. |
1556
+ | `GET /sw.js` | `src/web/sw.js` | Service worker, served `cache-control: no-store` at the root scope. |
1557
+ | `GET /curves` | `src/lab/curve/curve-designer.html` | The HLG Curve Designer page (drives `/signal` + `/generate` below); read from disk when available, embedded copy in compiled binaries. `no-store`. |
1558
+ | `GET /curve-terrain` | `src/serve/assets/curve-terrain.html` | Curve terrain visualization; `404` when the artifact isn't on disk. |
1559
+ | `GET /dag` | `src/serve/assets/training-inference-map.html` | Training/inference DAG map; `404` when the artifact isn't on disk. |
1560
+ | `GET /status`, `/chat`, `/quantize`, `/finetune`, `/dataset` | — | Legacy page paths: `302` to `/#<path>` (the SPA's hash routes). |
1561
+
1562
+ `/assets/*` and the manifest are served with `cache-control: public,
1563
+ max-age=3600`; `/sw.js`, `/curves`, `/curve-terrain`, `/dag` are `no-store`.
1564
+
1565
+ **PWA scope:** the manifest + service worker exist for *installability and
1566
+ an instant static-shell paint* — "Add to Home Screen" / a browser "Install
1567
+ app" prompt, and the next visit's HTML/JS/CSS loading from cache while the
1568
+ WebSocket connects. This is explicitly **not** offline chat:
1569
+ `src/web/sw.js`'s fetch handler only intercepts the exact shell files
1570
+ (`/`, `/assets/app.js`, `/assets/hljs.js`, `/assets/hljs.css`) and lets
1571
+ every dynamic route (`/api/*`, `/v1/*`, `/ws/chat`, `/downloads`, …) fall
1572
+ straight through to the network unconditionally. Registration is guarded
1573
+ to secure contexts (`https:` or `localhost`/`127.0.0.1`).
1574
+
1575
+ ## Curve Designer: POST /signal · POST /generate
1576
+
1577
+ Backends for the `/curves` page (HLG tone-curve sampling research tool,
1578
+ `src/lab/curve/curve-sampler.ts`). Both answer `OPTIONS` and send permissive CORS
1579
+ headers (`access-control-allow-origin: *`) so the page can be opened from
1580
+ a file or another origin. Both take the GPU lock (`gateway.runExclusive`)
1581
+ — they never interleave with a serving request.
1582
+
1583
+ `POST /signal` with `{"prompt": "…"}` — one prefill forward of the prompt
1584
+ rendered as a single user turn; bins the next-token log-prob distribution
1585
+ into 80 buckets over the curve's x-axis so the editor can draw the signal
1586
+ under the curve: `{ "bins": [80 ints], "vocab": V }`. `400 {"error":
1587
+ "invalid JSON"}`; `500 {"error": "signal failed: …"}`.
1588
+
1589
+ `POST /generate` with `{"prompt", "curve"?: {points: [...]}, "n"?: 3,
1590
+ "max_tokens"?: 80, "seed"?: 0, "default"?: false}` — samples `n` (1–8)
1591
+ completions of up to `max_tokens` (1–256) tokens. When `curve` has ≥2
1592
+ points and `default` is not `true`, the drawn log-prob transfer curve
1593
+ replaces temperature+softmax entirely; otherwise the model's default chat
1594
+ recipe (temperature/top-p/top-k) is used. A non-monotone curve is a `400`.
1096
1595
 
1097
1596
  ```jsonc
1098
- {
1099
- "machine": { "chip": "M4 Pro", "ram_bytes": 0, "bandwidth_gbs": 0.0 },
1100
- "context_tokens": 8192, // current admission ceiling
1101
- "typical_context_tokens": 8192, // min(8192, context_tokens)
1102
- "typical_decode_tps": 0.0, // predicted at typical_context_tokens
1103
- "measured_decode_tps": null, // real number from eval DB, or null
1104
- "measured_at": null, // unix ms of measurement, or null
1105
- "report": {
1106
- "fits": true,
1107
- "weights_bytes": 0,
1108
- "kv_bytes": 0,
1109
- "transient_bytes": 0,
1110
- "total_bytes": 0,
1111
- "usable_bytes": 0,
1112
- "max_safe_context": 8192,
1113
- "predicted_decode_tps": 0.0
1114
- },
1115
- // GLM-5.2 only:
1116
- "glm52": {
1117
- "artifact_disk_bytes": 0, // exact registry size; null if unavailable
1118
- "main_expert_slab_bytes": 0,
1119
- "mtp_expert_slab_bytes": 0,
1120
- "max_generation_tokens": 128,
1121
- "direct_oracle_warm_decode_tps": 0.27,
1122
- "aspirational_decode_tps": 2.0
1123
- },
1124
- "sku_matrix_ctx": 32768,
1125
- "sku_matrix": [{
1126
- "sku": "M4 Pro 24 GB", "ram_gb": 24,
1127
- "fits": true, "max_context": 32768, "decode_tps": 0.0
1128
- }]
1129
- }
1597
+ { "mode": "curve" | "default",
1598
+ "recipe": { "temperature": 0.7, "topP": 0, "topK": 0 }, // only in default mode
1599
+ "n": 3, "seed": 0,
1600
+ "samples": [{ "text": "…", "junk": false }] } // junk = coherence heuristic tripped
1130
1601
  ```
1131
1602
 
1132
- ## Adapters (LoRA hot-swap)
1133
-
1134
- - `GET /v1/adapters` — `{ adapters: [{ id, path, rank, scale, size_bytes,
1135
- mounted_layers, ram_bytes }] }` — currently-mounted adapters only.
1136
- `ram_bytes` is the actual resident size of the adapter's mounted
1137
- `lora_a`/`lora_b` arrays (summed `MlxArray.nbytes` — real RAM cost while
1138
- mounted, not a guess from the on-disk file size); the web chat's adapter
1139
- routing table (§5.6 of `docs/design/web-chat-redesign.md`) shows this per
1140
- loaded adapter.
1141
- - `GET /v1/adapters/available` — `{ adapters: [{ id, path, rank, scale,
1142
- base_model, mounted, compatible }] }` — every adapter found on disk
1143
- (`~/.cache/mlx-bun-finetunes`, `~/.cache/mlx-bun/adapters`), unfiltered.
1144
- `compatible` is true when the adapter's recorded base model matches the
1145
- currently-served model (compared by bare repo name) or the adapter
1146
- recorded no base at all; `mounted` is true when it's already loaded.
1147
- The web chat's adapter chip uses `compatible` to gray out entries it
1148
- won't let you select rather than hiding them.
1149
- - `POST /v1/adapters` — `{ "id": "...", "path": "/dir" }`; mounts
1150
- through the generation queue (never races a forward pass). Response:
1151
- `{ id, mounted_layers, rank, scale, ram_bytes }`. 400 on shape/compat
1152
- mismatch — validation is all-or-nothing.
1153
- - `DELETE /v1/adapters/<id>` — unmount; 404 if not mounted.
1154
-
1155
- Select per request with the `adapter` body field, which also accepts a
1156
- composed spec — `"a+b"` (or `"a,b"`) stacks two mounted adapters, their
1157
- LoRA residuals summed in order (`AdapterManager.resolveSpec` /
1158
- `parseAdapterSpec` in `src/lora.ts`); every named id must already be
1159
- mounted or the request 400s with the unknown id named. The web chat's
1160
- adapter routing table exposes this as a "stack" action on top of the
1161
- composer's single-select quick-switcher. Prompt-cache entries are
1162
- namespaced per adapter spec (including composed ones), so switching
1163
- adapters — or switching which ones are stacked — never reuses another
1164
- combination's KV.
1165
-
1166
- `serve --adapter <dir>` (alias `--adapter-path`, mlx_lm.server's
1167
- spelling) mounts an adapter at startup through this same machinery and
1168
- makes it the default for requests that send no `adapter` field; an
1169
- explicit `adapter` (including `"none"`) always wins, and hot-swap via
1170
- these endpoints is unchanged.
1171
-
1172
- ## App-aware assistant (`/ws/chat`)
1173
-
1174
- The web chat agent can see and act on the app it lives in —
1175
- `docs/design/web-chat-redesign.md` §6.6, beat matrix Axis 12. No
1176
- screenshots and no vision model are involved: the browser sends a
1177
- structured DOM snapshot as a WS frame, and the model gets three tools that
1178
- read/navigate/highlight that snapshot.
1179
-
1180
- Three tools join the web chat's tool allowlist unconditionally (same
1181
- class as `web_search`/`read` — read-only-on-the-machine, never gated by
1182
- the approval card, regardless of memory or `codingTools` state; see
1183
- `APP_AWARE_TOOL_NAMES` in `src/pi-web.ts`):
1603
+ ## Web chat WebSocket (`/ws/chat`)
1604
+
1605
+ `GET /ws/chat` upgrades to a WebSocket (`426` when the upgrade header is
1606
+ missing; `501` under `--isolate`). It carries the web chat's pi
1607
+ `AgentSession` — the model is driven through pi, which calls back into
1608
+ this server's own loopback `/v1`. Frames are JSON objects with a `type`
1609
+ field; the exact TypeScript unions are `ClientMessage` and `ServerMessage`
1610
+ in `src/pi-web.ts` (the web frontend imports them type-only, so both ends
1611
+ are compile-checked).
1612
+
1613
+ Client → server: `prompt {text, images?: [{data, mimeType}]}`, `abort`,
1614
+ `approval {callId, decision, editedArgs?, alwaysAllow?}`, `set_thinking
1615
+ {enabled}`, `set_coding_tools {enabled}`, `set_adapter {id | null}`,
1616
+ `set_sampling {scope?: "session" | "next_turn", temperature?, top_p?,
1617
+ top_k?, min_p?, xtc_probability?, xtc_threshold?, repetition_penalty?,
1618
+ repetition_context_size?, presence_penalty?, frequency_penalty?, seed?}`
1619
+ (null = server default), `set_system_prompt {text | null}`, `new_session`,
1620
+ `list_sessions`, `open_session {path}`, `fork_session {path}`,
1621
+ `delete_session {path}`, `regenerate`, `edit_resend {text}`,
1622
+ `switch_sibling {entryId}`, `context {context}` (app-aware assistant, below).
1623
+
1624
+ Server → client: `ready {model, vision, audio, thinking, genDefaults}`,
1625
+ `turn_start`, `text_delta {delta}`, `thinking_delta {delta}`, `tool_start
1626
+ {callId, tool, args}`, `tool_approval_request {callId, tool, args}`,
1627
+ `tool_update {callId, chunk}`, `tool_end {callId, ok, result}`, `turn_end
1628
+ {lane?}` (the serving lane the turn ran on, from `usage.lane` via the lane
1629
+ registry — never guessed client-side), `queue_update {steering, followUp}`,
1630
+ `history {items}`, `sessions {items, activePath?}`, `siblings {entryId?,
1631
+ index, count, siblingIds}`, `context {tokens, contextWindow, percent}`
1632
+ (context-window usage — a different frame from the client→server
1633
+ `context`), `coding_tools {active, pending}`, `tool_approvals
1634
+ {alwaysAllow}`, `ui_navigate {route}`, `ui_spotlight {ref?, label?,
1635
+ selector?, target?, message?, route?}`, `error {message}`.
1636
+
1637
+ ### App-aware assistant
1638
+
1639
+ The web chat agent can see and act on the app it lives in. No screenshots
1640
+ and no vision model are involved: the browser sends a structured DOM
1641
+ snapshot as a WS frame, and the model gets three tools that
1642
+ read/navigate/highlight that snapshot. The three tools are always in the
1643
+ web chat's tool allowlist (read-only-on-the-machine, never gated by the
1644
+ approval card; `APP_AWARE_TOOL_NAMES` in `src/pi-web.ts`):
1184
1645
 
1185
1646
  - **`get_current_app_context`** — returns the last context the browser
1186
1647
  pushed: `{ route, view?, step?, snapshot }`. `snapshot` is a capped
1187
1648
  (~120) list of visible interactive elements as `{ref, label, kind,
1188
1649
  role?, selector, spotlightId?}`, agent chrome excluded.
1189
1650
  - **`navigate_app({ route | page })`** — validates against the route
1190
- catalog (`chat`, `quantize`, `finetune`, `dataset`, `status`; unknown
1191
- routes 400 as a tool error, never silently sent to the browser) and
1192
- emits a `ui_navigate` frame. Reversible; no approval needed.
1193
- - **`spotlight_ui({ ref | label | selector | target, route?, message?
1194
- })`** — emits a `ui_spotlight` frame; the browser resolves the target
1195
- (ref from its last snapshot, then live selector, then fuzzy label
1196
- match, then a curated catalog id) and shows a brief highlight. Never
1197
- blocks: the overlay traps no focus and passes clicks straight through
1198
- to the highlighted control, auto-dismisses in ~3s, and any keystroke,
1199
- click, or scroll dismisses it instantly.
1651
+ catalog (`APP_ROUTE_IDS`: `chat`, `quantize`, `finetune`, `dataset`,
1652
+ `status`; unknown routes are a tool error, never sent to the browser)
1653
+ and emits a `ui_navigate` frame.
1654
+ - **`spotlight_ui({ ref | label | selector | target, route?, message? })`**
1655
+ — emits a `ui_spotlight` frame; the browser resolves the target (ref
1656
+ from its last snapshot, then live selector, then fuzzy label match, then
1657
+ a curated catalog id) and shows a brief non-blocking highlight.
1200
1658
 
1201
1659
  Client → server WS frame (pushed by the browser on every route change and
1202
1660
  every wizard-step change, never on a timer):
@@ -1214,10 +1672,8 @@ Server → client frames the two action tools produce:
1214
1672
 
1215
1673
  The server also auto-prepends a compact one-line ambient context (e.g.
1216
1674
  `[user is on: Quantize · step 2/4]`) to the system prompt on every turn —
1217
- never a full snapshot dump — so the model is never answering completely
1218
- blind even when it doesn't call a tool. `ui_act` (approval-gated form-fill
1219
- and job-start actions) is explicitly out of scope for this v1; only
1220
- navigate/spotlight are wired.
1675
+ never a full snapshot dump. Approval-gated form-fill / job-start actions
1676
+ are not wired; only navigate/spotlight are.
1221
1677
 
1222
1678
  ## Client setup: pi
1223
1679
 
@@ -1258,3 +1714,8 @@ solve.
1258
1714
 
1259
1715
  Any OpenAI SDK works the same way: `baseURL: "http://127.0.0.1:8080/v1"`,
1260
1716
  any non-empty `apiKey`.
1717
+
1718
+ Under `--isolate`, the parent owns Responses continuation history and exposes its
1719
+ bounded store counters in `GET /engine` as `response_store`. Model-worker restart
1720
+ or eviction preserves that history; restarting the parent clears it. Direct
1721
+ serving retains the same process-local TTL and byte limits.