mlx-bun 0.2.0 → 0.4.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 (433) hide show
  1. package/README.md +71 -588
  2. package/docs/README.md +94 -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/archive/investigations/openjarvis-fit.md +198 -0
  8. package/docs/{design → archive/investigations}/optimization_plan.md +1 -1
  9. package/docs/{design → archive/investigations}/pre-colibri-stabilization.md +5 -5
  10. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  11. package/docs/{design → archive/investigations}/the-dreaming-handoff.md +2 -2
  12. package/docs/archive/planning/release-notes-v0.3.0.md +35 -0
  13. package/docs/design/batching.md +1841 -0
  14. package/docs/design/decode-speed-program.md +2346 -169
  15. package/docs/design/dreaming-nightly-pipeline.md +675 -282
  16. package/docs/design/generic-model-support.md +655 -334
  17. package/docs/design/kv-cache.md +824 -0
  18. package/docs/design/orpo-training.md +673 -1239
  19. package/docs/design/speculative-decoding.md +1990 -0
  20. package/docs/design/turboquant.md +2212 -0
  21. package/docs/design/unified-engine-frontier-plan.md +1833 -727
  22. package/docs/design/web-chat-redesign.md +666 -1083
  23. package/docs/planning/PRODUCT_ROADMAP.md +50 -1
  24. package/docs/planning/release-notes-v0.4.0.md +45 -0
  25. package/docs/planning/video-series.md +2 -2
  26. package/docs/reference/benchmarks.md +3613 -0
  27. package/docs/reference/cli.md +434 -160
  28. package/docs/reference/distribution.md +328 -182
  29. package/docs/reference/environment.md +398 -0
  30. package/docs/reference/glossary.md +39 -0
  31. package/docs/reference/library-api.md +566 -168
  32. package/docs/reference/memory.md +227 -64
  33. package/docs/reference/models.md +113 -6
  34. package/docs/reference/server-api.md +944 -429
  35. package/docs/reference/server-config.md +740 -338
  36. package/docs/reference/training.md +393 -254
  37. package/docs/reference/troubleshooting.md +158 -0
  38. package/package.json +9 -4
  39. package/src/audio/decode.ts +2 -2
  40. package/src/audio/features.ts +2 -2
  41. package/src/backends/mlx/assistant-target.ts +46 -0
  42. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  43. package/src/backends/mlx/autoregressive.ts +108 -0
  44. package/src/backends/mlx/batch-group.ts +1790 -0
  45. package/src/backends/mlx/cache-attachment.ts +67 -0
  46. package/src/backends/mlx/cache-layout.ts +31 -0
  47. package/src/backends/mlx/checkpoint-state.ts +53 -0
  48. package/src/backends/mlx/continuation-persistence.ts +106 -0
  49. package/src/backends/mlx/continuation-request.ts +43 -0
  50. package/src/backends/mlx/continuation.ts +77 -0
  51. package/src/backends/mlx/diffusion.ts +39 -0
  52. package/src/backends/mlx/draft-target.ts +32 -0
  53. package/src/backends/mlx/evaluation.ts +105 -0
  54. package/src/backends/mlx/fill-append.ts +45 -0
  55. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  56. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  57. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  58. package/src/backends/mlx/gateway-binding.ts +130 -0
  59. package/src/backends/mlx/graph.ts +38 -0
  60. package/src/backends/mlx/kv-codec.ts +9 -0
  61. package/src/backends/mlx/kv-maintenance.ts +133 -0
  62. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  63. package/src/backends/mlx/memory-client.ts +189 -0
  64. package/src/backends/mlx/methods.ts +90 -0
  65. package/src/backends/mlx/model-prompt.ts +164 -0
  66. package/src/backends/mlx/model-serving.ts +79 -0
  67. package/src/backends/mlx/prefill-cohort.ts +89 -0
  68. package/src/backends/mlx/prefill-rows.ts +200 -0
  69. package/src/backends/mlx/prefill.ts +33 -0
  70. package/src/backends/mlx/projected-context-rows.ts +72 -0
  71. package/src/backends/mlx/request-policy.ts +20 -0
  72. package/src/backends/mlx/request-state-policy.ts +41 -0
  73. package/src/backends/mlx/rollback.ts +89 -0
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +242 -0
  76. package/src/backends/mlx/speculative-group.ts +359 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +114 -0
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +46 -0
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/text-engine.ts +73 -0
  83. package/src/backends/mlx/transitioning-kv-rows.ts +165 -0
  84. package/src/cli.ts +66 -58
  85. package/src/client.ts +66 -0
  86. package/src/config.ts +21 -4
  87. package/src/contracts/completion.ts +16 -0
  88. package/src/contracts/execution.ts +60 -0
  89. package/src/contracts/generation.ts +103 -0
  90. package/src/contracts/host.ts +7 -0
  91. package/src/contracts/jobs.ts +66 -0
  92. package/src/contracts/pi-provider.ts +30 -0
  93. package/src/contracts/pi.ts +259 -0
  94. package/src/contracts/prefix-cache.ts +23 -0
  95. package/src/contracts/resources.ts +25 -0
  96. package/src/contracts/sampling.ts +23 -0
  97. package/src/contracts/scheduling.ts +45 -0
  98. package/src/contracts/tsconfig.json +14 -0
  99. package/src/diffusion/diffusion-generate.ts +218 -153
  100. package/src/embed.ts +1 -1
  101. package/src/engine/admission.ts +59 -0
  102. package/src/engine/cancellation.ts +38 -0
  103. package/src/engine/engine.ts +56 -0
  104. package/src/engine/execution-coordinator.ts +70 -0
  105. package/src/engine/execution-plan.ts +53 -0
  106. package/src/engine/resources.ts +53 -0
  107. package/src/engine/scheduler.ts +72 -0
  108. package/src/engine/session.ts +247 -0
  109. package/src/eval/README.md +44 -1
  110. package/src/eval/kl.ts +236 -3
  111. package/src/eval/reference-logits.ts +207 -0
  112. package/src/eval/runner.ts +8 -96
  113. package/src/fill/echo-index.ts +188 -0
  114. package/src/fill/fill-session.ts +452 -0
  115. package/src/fill/proposal.ts +68 -0
  116. package/src/fill/schema-rows.ts +506 -0
  117. package/src/fill/tool-boundary.ts +103 -0
  118. package/src/fit.ts +30 -16
  119. package/src/generate.ts +709 -417
  120. package/src/grammar.ts +12 -7
  121. package/src/harness-pi.ts +14 -33
  122. package/src/hub-rest.ts +4 -4
  123. package/src/index.ts +7 -0
  124. package/src/inference/denoising.ts +17 -0
  125. package/src/inference/draft-acceptance.ts +41 -0
  126. package/src/inference/draft-output.ts +49 -0
  127. package/src/inference/graph.ts +33 -0
  128. package/src/inference/prefill.ts +39 -0
  129. package/src/inference/rollback.ts +7 -0
  130. package/src/jobs/db.ts +4 -0
  131. package/src/jobs/index.ts +2 -0
  132. package/src/jobs/job-entry.ts +12 -12
  133. package/src/jobs/runner.ts +98 -13
  134. package/src/jobs/task-client.ts +19 -0
  135. package/src/jobs/types.ts +2 -58
  136. package/src/kv-scheme.ts +70 -12
  137. package/src/kv-store.ts +177 -63
  138. package/src/lab/README.md +18 -0
  139. package/src/{assets → lab/curve}/curve-designer.html +2 -2
  140. package/src/{curve-sampler.ts → lab/curve/curve-sampler.ts} +2 -2
  141. package/src/{expert-trace.ts → lab/expert-trace/expert-trace.ts} +5 -5
  142. package/src/lab/paged-kv/paged-kv-rows.ts +83 -0
  143. package/src/{model → lab/paged-kv}/paged-kv.ts +32 -15
  144. package/src/library.ts +40 -0
  145. package/src/lora.ts +18 -0
  146. package/src/media-fetch.ts +1 -1
  147. package/src/memory/article.ts +1 -1
  148. package/src/memory/chunk.ts +1 -1
  149. package/src/memory/model.ts +30 -169
  150. package/src/memory/prompts.ts +1 -1
  151. package/src/memory/rest.ts +1 -1
  152. package/src/memory/schedule.ts +1 -1
  153. package/src/memory/stages.ts +4 -1
  154. package/src/memory/vault.ts +1 -3
  155. package/src/mlx/array.ts +11 -11
  156. package/src/mlx/compile.ts +12 -2
  157. package/src/mlx/ffi.ts +58 -11
  158. package/src/mlx/materialize.ts +23 -0
  159. package/src/mlx/metal-kernel.ts +10 -11
  160. package/src/mlx/ops.ts +33 -13
  161. package/src/mlx/quantized-rows.ts +36 -0
  162. package/src/mlx/token-bitmask.ts +52 -0
  163. package/src/mlx/turboquant-kv-decode.ts +82 -0
  164. package/src/mlx/turboquant-kv-inverse.ts +122 -0
  165. package/src/mlx/turboquant-ops.ts +33 -3
  166. package/src/mlx/turboquant-tables.ts +2 -2
  167. package/src/model/artifact-identity.ts +73 -0
  168. package/src/model/batched-kv.ts +235 -0
  169. package/src/model/batched-mask.ts +20 -6
  170. package/src/model/batched-quant.ts +9 -3
  171. package/src/model/batched-quantized-kv.ts +144 -0
  172. package/src/model/batched-rotating-quant.ts +78 -17
  173. package/src/model/batched-rotating-state.ts +76 -0
  174. package/src/model/batched-rotating.ts +65 -23
  175. package/src/model/batched-row-storage.ts +52 -0
  176. package/src/model/batched-ssm.ts +52 -0
  177. package/src/model/batched-turboquant-kv.ts +93 -0
  178. package/src/model/compiled-decode.ts +8 -2
  179. package/src/model/decoded-kv-donor.ts +12 -0
  180. package/src/model/delayed-quantized-kv.ts +72 -0
  181. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  182. package/src/model/delayed-turboquant-kv.ts +55 -0
  183. package/src/model/diffusion-gemma.ts +8 -8
  184. package/src/model/factory.ts +76 -33
  185. package/src/model/fingerprint.ts +1 -1
  186. package/src/model/flash-attention.ts +3 -3
  187. package/src/model/full-prefill-padding.ts +56 -0
  188. package/src/model/full-prefill-row.ts +105 -0
  189. package/src/model/gemma4-base.ts +179 -80
  190. package/src/model/gemma4.ts +40 -32
  191. package/src/model/generated/gemma4-12b.ts +6 -3
  192. package/src/model/generated/gemma4-26b.ts +6 -3
  193. package/src/model/generated/gemma4-e4b.ts +6 -3
  194. package/src/model/glm52-cache.ts +80 -4
  195. package/src/model/glm52-dsa.ts +13 -9
  196. package/src/model/glm52-memory.ts +1 -1
  197. package/src/model/glm52-mla.ts +12 -6
  198. package/src/model/glm52.ts +19 -40
  199. package/src/model/implementation.ts +69 -0
  200. package/src/model/kv-attention-view.ts +67 -0
  201. package/src/model/minicpm5.ts +17 -15
  202. package/src/model/profile.ts +12 -2
  203. package/src/model/qwen-append.ts +12 -0
  204. package/src/model/qwen-conv.ts +52 -0
  205. package/src/model/qwen3-delta-state.ts +74 -0
  206. package/src/model/qwen3-delta.ts +59 -36
  207. package/src/model/qwen3_5-checkpoint.ts +164 -0
  208. package/src/model/qwen3_5.ts +202 -78
  209. package/src/model/rotating-kv-donor.ts +35 -0
  210. package/src/model/rotating-kv-layout.ts +262 -0
  211. package/src/model/rotating-row-transaction.ts +35 -0
  212. package/src/model/speculative-rotating-kv.ts +101 -0
  213. package/src/model/ssm-prefill-padding.ts +39 -0
  214. package/src/model/support.ts +1 -1
  215. package/src/model/trellis-balanced-scatter.ts +51 -0
  216. package/src/model/trellis-linear.ts +672 -0
  217. package/src/model/trellis-shared-m.ts +151 -0
  218. package/src/model/trellis-shared-scatter.ts +144 -0
  219. package/src/model/trellis-splitk-prefill.ts +158 -0
  220. package/src/model/trellis-tiled-prefill.ts +136 -0
  221. package/src/model/trellis-vector-expand.ts +62 -0
  222. package/src/model/trellis-wide-prefill.ts +94 -0
  223. package/src/model/turboquant-codec.ts +83 -0
  224. package/src/model/universal/dense.ts +15 -14
  225. package/src/model/universal/modules.ts +8 -1
  226. package/src/native-pack.ts +6 -5
  227. package/src/pi-provider.ts +6 -64
  228. package/src/pi-web.ts +8 -315
  229. package/src/preflight.ts +31 -15
  230. package/src/prompt-cache.ts +110 -69
  231. package/src/quantize/allocator.ts +1 -1
  232. package/src/quantize/config-writer.ts +10 -3
  233. package/src/quantize/rotate.ts +4 -4
  234. package/src/quantize/trellis.ts +586 -0
  235. package/src/responses.ts +28 -1
  236. package/src/runtime-config.ts +18 -4
  237. package/src/sampler.ts +40 -29
  238. package/src/serve/batch-scheduler.ts +8 -1671
  239. package/src/serve/chat-request.ts +391 -0
  240. package/src/serve/chat-stage.ts +195 -0
  241. package/src/serve/checkpoint-identity.ts +64 -0
  242. package/src/serve/completion-executor.ts +15 -7
  243. package/src/serve/discovery-routes.ts +12 -13
  244. package/src/serve/generation-gateway.ts +183 -213
  245. package/src/serve/http.ts +159 -0
  246. package/src/serve/inference-request.ts +79 -0
  247. package/src/serve/isolate.ts +297 -105
  248. package/src/serve/lab-routes.ts +3 -0
  249. package/src/serve/lane-registry.ts +2 -1
  250. package/src/serve/model-admin-routes.ts +7 -5
  251. package/src/serve/model-binding.ts +43 -0
  252. package/src/serve/model-host.ts +511 -0
  253. package/src/serve/openai-wire.ts +103 -0
  254. package/src/serve/parent-application.ts +56 -0
  255. package/src/serve/pipeline.ts +33 -0
  256. package/src/serve/preparation.ts +33 -0
  257. package/src/serve/request-plan.ts +23 -18
  258. package/src/serve/request-prep.ts +344 -0
  259. package/src/serve/response-lease.ts +24 -0
  260. package/src/serve/responses-client.ts +69 -0
  261. package/src/serve/session-completion-engine.ts +110 -0
  262. package/src/serve/session-search.ts +1 -1
  263. package/src/serve/static-routes.ts +5 -5
  264. package/src/serve/text-completion-stage.ts +104 -0
  265. package/src/serve/token-streams.ts +519 -0
  266. package/src/server.ts +335 -2711
  267. package/src/spec/assistant-rows.ts +92 -0
  268. package/src/spec/assistant-source.ts +31 -55
  269. package/src/spec/cache-identity.ts +22 -0
  270. package/src/spec/deepspec-rows.ts +29 -0
  271. package/src/spec/deepspec-source.ts +16 -14
  272. package/src/spec/dflash-source.ts +36 -12
  273. package/src/spec/drafter.ts +153 -142
  274. package/src/spec/dspark/ab-stats.ts +3 -3
  275. package/src/spec/dspark/calibration.ts +2 -2
  276. package/src/spec/dspark/deepspec-module.ts +97 -122
  277. package/src/spec/dspark/generate-dflash.ts +2 -2
  278. package/src/spec/dspark/generate.ts +2 -2
  279. package/src/spec/dspark/loader.ts +1 -1
  280. package/src/spec/dspark/module-dflash.ts +129 -172
  281. package/src/spec/dspark/module.ts +3 -3
  282. package/src/spec/dspark/quantize-drafter.ts +1 -1
  283. package/src/spec/generate.ts +1 -1
  284. package/src/spec/glm52-mtp-graph.ts +29 -0
  285. package/src/spec/glm52-mtp-rows.ts +132 -0
  286. package/src/spec/glm52-mtp-source.ts +54 -137
  287. package/src/spec/glm52-mtp-state.ts +33 -0
  288. package/src/spec/ngram-source.ts +93 -21
  289. package/src/spec/projected-draft-rows.ts +73 -0
  290. package/src/spec/qwen-mtp-module.ts +171 -0
  291. package/src/spec/qwen-mtp-rows.ts +219 -0
  292. package/src/spec/qwen-mtp-source.ts +147 -195
  293. package/src/spec/qwen-mtp-state.ts +30 -0
  294. package/src/spec/serve-loop.ts +201 -201
  295. package/src/spec/source.ts +110 -13
  296. package/src/spec/two-model-rows.ts +61 -0
  297. package/src/spec/two-model.ts +44 -102
  298. package/src/ssd-cache.ts +99 -19
  299. package/src/ssd-durability.ts +36 -20
  300. package/src/tool-call.ts +51 -14
  301. package/src/train/flash-cce.ts +2 -2
  302. package/src/train/forward.ts +6 -0
  303. package/src/train/loss.ts +1 -1
  304. package/src/train/prefix-shared.ts +5 -1
  305. package/src/train/segmented.ts +5 -3
  306. package/src/train/trainer.ts +6 -6
  307. package/src/vision/prompt.ts +1 -1
  308. package/src/vision/qwen3vl-tower.ts +52 -10
  309. package/src/web/app.html +1 -1
  310. package/src/web/app.js +62 -60
  311. package/src/web/src/adapters-panel.ts +3 -3
  312. package/src/web/src/assistant.ts +2 -36
  313. package/src/web/src/chat.ts +4 -4
  314. package/src/web/src/composer.ts +4 -4
  315. package/src/web/src/hub.ts +1 -1
  316. package/src/web/src/markdown.ts +0 -0
  317. package/src/web/src/memory-panel.ts +1 -1
  318. package/src/web/src/model-picker.ts +1 -1
  319. package/src/web/src/palette.ts +1 -1
  320. package/src/web/src/protocol.ts +3 -39
  321. package/src/web/src/sessions.ts +1 -1
  322. package/src/web/src/ui-catalog.ts +4 -18
  323. package/src/web-assets.ts +47 -0
  324. package/src/weights.ts +55 -9
  325. package/docs/archive/investigations/curve-runs/bisector-route.json +0 -98
  326. package/docs/archive/investigations/curve-runs/bisector-trace.json +0 -1393
  327. package/docs/archive/investigations/curve-runs/curve-terrain.json +0 -1179
  328. package/docs/archive/investigations/curve-runs/curve-terrain.svg +0 -42
  329. package/docs/archive/investigations/hlg-ab.html +0 -34
  330. package/docs/archive/investigations/hlg-explorer.html +0 -238
  331. package/docs/archive/investigations/hlg-figs/cliff.svg +0 -1
  332. package/docs/archive/investigations/hlg-figs/cross.svg +0 -1
  333. package/docs/archive/investigations/hlg-figs/edges.svg +0 -1
  334. package/docs/archive/investigations/hlg-figs/k20-grids.svg +0 -1
  335. package/docs/archive/investigations/hlg-figs/k20-peaks.svg +0 -1
  336. package/docs/archive/investigations/hlg-figs/lw-curve.svg +0 -1
  337. package/docs/archive/investigations/hlg-figs/terrain.svg +0 -1
  338. package/docs/archive/investigations/hlg-report.html +0 -262
  339. package/docs/archive/investigations/hlg-runs/hlg-ab.json +0 -149
  340. package/docs/archive/investigations/hlg-runs/hlg-blind-prompts.md +0 -87
  341. package/docs/archive/investigations/hlg-runs/hlg-blind.json +0 -107
  342. package/docs/archive/investigations/hlg-runs/hlg-cliff.json +0 -197
  343. package/docs/archive/investigations/hlg-runs/hlg-cross.json +0 -505
  344. package/docs/archive/investigations/hlg-runs/hlg-edge.json +0 -388
  345. package/docs/archive/investigations/hlg-runs/hlg-eval1.json +0 -25
  346. package/docs/archive/investigations/hlg-runs/hlg-frontier.json +0 -86
  347. package/docs/archive/investigations/hlg-runs/hlg-grid.json +0 -242
  348. package/docs/archive/investigations/hlg-runs/hlg-grid2.json +0 -343
  349. package/docs/archive/investigations/hlg-runs/hlg-lw-coarse.json +0 -110
  350. package/docs/archive/investigations/hlg-runs/hlg-map.json +0 -383
  351. package/docs/archive/investigations/hlg-runs/run0-original.log +0 -159
  352. package/docs/archive/investigations/hlg-runs/runA-recommended.log +0 -131
  353. package/docs/archive/investigations/hlg-runs/runAA-cliff-cpm5.log +0 -26
  354. package/docs/archive/investigations/hlg-runs/runAB-grid2-e4b.log +0 -18
  355. package/docs/archive/investigations/hlg-runs/runAC-peak-e4b-partial.log +0 -25
  356. package/docs/archive/investigations/hlg-runs/runAD-ab-samples.log +0 -8
  357. package/docs/archive/investigations/hlg-runs/runAE-edge-e4b.log +0 -51
  358. package/docs/archive/investigations/hlg-runs/runAF-logit-calibration.log +0 -10
  359. package/docs/archive/investigations/hlg-runs/runAG-eval-config2.log +0 -16
  360. package/docs/archive/investigations/hlg-runs/runB-gain-variants.log +0 -192
  361. package/docs/archive/investigations/hlg-runs/runC-pivots.log +0 -162
  362. package/docs/archive/investigations/hlg-runs/runD-oetf.log +0 -132
  363. package/docs/archive/investigations/hlg-runs/runE-eotf.log +0 -162
  364. package/docs/archive/investigations/hlg-runs/runF-shaper-working.log +0 -86
  365. package/docs/archive/investigations/hlg-runs/runG-shaper-sweep.log +0 -101
  366. package/docs/archive/investigations/hlg-runs/runH-wsweep.log +0 -176
  367. package/docs/archive/investigations/hlg-runs/runI-asweep.log +0 -176
  368. package/docs/archive/investigations/hlg-runs/runJ-knobmap.log +0 -49
  369. package/docs/archive/investigations/hlg-runs/runK-frontier.log +0 -19
  370. package/docs/archive/investigations/hlg-runs/runL-widemap.log +0 -65
  371. package/docs/archive/investigations/hlg-runs/runM-frontier-emb.log +0 -15
  372. package/docs/archive/investigations/hlg-runs/runN-verify.log +0 -116
  373. package/docs/archive/investigations/hlg-runs/runO-frontier-seed5000.log +0 -15
  374. package/docs/archive/investigations/hlg-runs/runP-grid-AxSm.log +0 -15
  375. package/docs/archive/investigations/hlg-runs/runQ-grid-tgW.log +0 -15
  376. package/docs/archive/investigations/hlg-runs/runR-blind-transcript.log +0 -79
  377. package/docs/archive/investigations/hlg-runs/runS-cross-coherence.log +0 -33
  378. package/docs/archive/investigations/hlg-runs/runT-cross-cpm5.log +0 -33
  379. package/docs/archive/investigations/hlg-runs/runU-cross-12b.log +0 -33
  380. package/docs/archive/investigations/hlg-runs/runV-lw-e4b.log +0 -22
  381. package/docs/archive/investigations/hlg-runs/runW-lw-12b.log +0 -22
  382. package/docs/archive/investigations/hlg-runs/runX-lw-cpm5.log +0 -22
  383. package/docs/archive/investigations/hlg-runs/runY-cliff-e4b.log +0 -26
  384. package/docs/archive/investigations/hlg-runs/runZ-cliff-12b.log +0 -26
  385. package/docs/archive/mlx-bun-lab-report.html +0 -275
  386. package/docs/archive/wwdc-mlx-bun.html +0 -848
  387. package/docs/design/adapters-end-to-end.md +0 -205
  388. package/docs/design/batching-perf-path.md +0 -255
  389. package/docs/design/batching-v2-plan.md +0 -529
  390. package/docs/design/bucketing-stage.md +0 -197
  391. package/docs/design/compat-cli-surface-design.md +0 -230
  392. package/docs/design/docs-reorg-plan.md +0 -165
  393. package/docs/design/dspark-serving-program.md +0 -371
  394. package/docs/design/dspark-speculative-decoding.md +0 -318
  395. package/docs/design/grammar-spec-batching-integration.md +0 -301
  396. package/docs/design/memory-inference-path.md +0 -422
  397. package/docs/design/memory-synthesis.md +0 -287
  398. package/docs/design/memory-system.md +0 -237
  399. package/docs/design/mlx-lm-serving-execution-seam.md +0 -81
  400. package/docs/design/mlx-lm-tool-parity-plan.md +0 -545
  401. package/docs/design/omlx-adoption-map.md +0 -98
  402. package/docs/design/orpo-dynamic-lambda.md +0 -220
  403. package/docs/design/orpo-future-enhancements.md +0 -340
  404. package/docs/design/paged-kv-cache.md +0 -214
  405. package/docs/design/parallel-slots.md +0 -567
  406. package/docs/design/parity-tier-dag.md +0 -161
  407. package/docs/design/repo-cleanup-plan.md +0 -231
  408. package/docs/design/runtime-isolation.md +0 -263
  409. package/docs/design/segmented-backward-training.md +0 -543
  410. package/docs/design/spec-decode-larger-targets.md +0 -229
  411. package/docs/design/ssd-kv-cold-tier.md +0 -415
  412. package/docs/design/structured-output.md +0 -484
  413. package/docs/design/tauri-desktop-app.md +0 -127
  414. package/docs/design/the-dreaming-master-plan.md +0 -445
  415. package/docs/design/turboquant-kv.md +0 -235
  416. package/docs/design/turboquant-weights.md +0 -467
  417. package/docs/design/w4a16-compute-precision-spike.md +0 -232
  418. package/docs/design/web-chat-beat-matrix.md +0 -315
  419. package/docs/design/write-pipeline-entity.md +0 -468
  420. package/docs/planning/IDEAS.md +0 -69
  421. package/docs/reference/embedding.md +0 -197
  422. package/docs/reference/features-matrix.md +0 -101
  423. package/docs/reference/orpo-quickstart.md +0 -135
  424. package/src/flags.ts +0 -11
  425. /package/docs/{design → archive/investigations}/colibri-glm52-port.md +0 -0
  426. /package/docs/{planning → archive/planning}/chunk-task-roadmap.md +0 -0
  427. /package/docs/{planning → archive/planning}/curve-sampler-research-plan.md +0 -0
  428. /package/docs/{planning → archive/planning}/memory-docs-and-dag-plan.md +0 -0
  429. /package/docs/{planning → archive/planning}/pi-integration-review.md +0 -0
  430. /package/docs/{planning → archive/planning}/release-notes-v0.2.0.md +0 -0
  431. /package/src/{memory-plan.ts → admission-plan.ts} +0 -0
  432. /package/{docs/archive/investigations → src/serve/assets}/curve-terrain.html +0 -0
  433. /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
+ // an explicit operator limit is reached;
121
+ // --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,17 +358,73 @@ 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
243
- through the serial lane (mlx_lm.server parity: `is_batchable = draft is
244
- None`) — speculation and `--batch N` are different modes.
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
+ In the unreleased shared executor, Qwen grouped MTP, prompt lookup and standalone drafting support logprobs and
374
+ bf16, uniform KV4/KV8 or per-layer affine KV, or TurboQuant. Qwen MTP and prompt lookup also support
375
+ positive library conversion thresholds for uniform KV4/KV8. Per-layer mixed KV and
376
+ adapters remain incompatible with speculation. Strict legacy serial execution
377
+ retains its narrower bf16/KV4 policy. The KV4 compatibility control is
378
+ `MLX_BUN_QWEN_SPEC_KV4=0`.
379
+ Ineligible requests decode normally and omit the field. Qwen MTP reuses paired
380
+ target/draft prefill and completed decode state through the shared RAM/SSD cache by default in the
381
+ unreleased source, reported in `cached_tokens`. Configured SSD persistence
382
+ supports reuse after restart; `MLX_BUN_MTP_PROMPT_CACHE=0` disables reuse.
383
+ Generated checkpoints cover processed tokens and use queued SSD persistence.
384
+ Long-conversation performance acceptance remains open. Qwen prompt lookup also
385
+ retains prefill and completed-output state in the common RAM/SSD cache, with
386
+ its committed token history stored as a companion attachment. Standalone drafting
387
+ on Qwen targets stores the draft model’s attention/recurrent state and pending
388
+ last draft token as companion state, including restoration after restart. Qualified Qwen
389
+ MTP, prompt lookup and standalone drafting use shared execution at one or several active rows and
390
+ report `usage.lane: "batched"`. Supported full-attention and rotating targets, including
391
+ Llama, MiniCPM and Gemma, use shared bf16, uniform KV4/KV8 or per-layer affine KV or TurboQuant lookup and standalone drafting, with
392
+ logprobs and generated RAM/SSD checkpoints. Delayed and per-layer affine rotating speculation uses the same row lifecycle; positive conversion thresholds are a library setting, while served schemes default to zero. Gemma assistant drafting also shares execution with bf16, uniform KV4/KV8 or per-layer affine KV or TurboQuant target storage and stores its
393
+ last true target hidden alongside generated RAM/SSD prefixes. Donor attention
394
+ handles row validity independently of the draft graph. DeepSpec and DSpark also use these shared target layouts and the common RAM/SSD cache
395
+ for projected context, with independent confidence-based proposal lengths.
396
+ Other provider/target combinations still
397
+ use the serial executor.
398
+ Prompt-lookup concurrent performance acceptance remains open. Ineligible requests can use continuous ordinary execution
399
+ when their features support it, even while a draft is mounted.
400
+
401
+ `usage.fill` reports **token fast-forwarding** (`MLX_BUN_FILL=strict|echo`).
402
+ Strict mode appends structural spans from the request's tools and chat
403
+ template at qualified parser boundaries, with no draft, verification or
404
+ rollback. The model advances its hidden states and caches through the span,
405
+ using its qualified append chunk size, then resumes sampling
406
+ (design: [speculative-decoding.md](../design/speculative-decoding.md) §"Token
407
+ fast-forwarding"). Injected tokens are billed in `completion_tokens` like any
408
+ other generated token; `decodeSteps` counts ordinary sampling steps, so
409
+ `injected / (injected + decodeSteps)` is the share appended without sampling.
410
+ Injected positions still run through the model layers. The field is absent when the feature is off or the
411
+ request's shape refuses it (fixed `seed`, `logprobs`, structured output,
412
+ media, a mounted draft model, quantized KV).
413
+
414
+ Under `MLX_BUN_FILL=echo` a second, weaker source joins: spans copied from
415
+ earlier in the same session, carried under policy `verify`. Those ride the same
416
+ single forward, are checked against the argmax already in its logits, and have
417
+ their rejected tail rewound — so `verifyAccepted + verifyRejected` is what the
418
+ echo index proposed and `injected` is what survived. `wastedSamples` counts
419
+ only ASSERT fills (a verify fill consumes the in-flight sample as its first
420
+ position's check instead of discarding it).
421
+
422
+ An explicit `seed` controls a request-local random stream and can use
423
+ continuous execution. Repeating a request reproduces sampling at the same
424
+ model arithmetic and batch composition. Different batch shapes can change
425
+ floating-point results even with identical random draws; `--batch 1` retains
426
+ the existing fixed-shape control. This differs from mlx-lm's policy of routing
427
+ explicit seeds to its single-request generator.
245
428
 
246
429
  ### logprobs / top_logprobs
247
430
 
@@ -271,44 +454,68 @@ legacy `{tokens, token_logprobs}` text-completion shape).
271
454
 
272
455
  The distribution matches mlx-lm `generate_step` exactly: full-vocab
273
456
  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`.
457
+ penalties), **before** the sampler's temperature/top-p/top-k/min-p/XTC.
458
+ mlx-lm's top-k order is unspecified (argpartition); ours is sorted
459
+ descending — the same set, so the entry is deterministically the argmax.
460
+ Stream chunks never carry logprobs (mlx-lm's streaming responses don't
461
+ either). Requests with logprobs can use ordinary continuous execution under
462
+ `--batch N`; other feature exclusions still apply. Invalid values are rejected
463
+ with mlx-lm's exact messages (see Errors).
464
+
465
+ ### Streaming
466
+
467
+ `"stream": true` is SSE: `data: <chunk>\n\n` per event, terminated by
468
+ `data: [DONE]`. Chunks are `chat.completion.chunk` objects whose
469
+ `choices[0].delta` carries `{role: "assistant", content: ""}` first, then
470
+ `{reasoning}` increments while the model is inside its `<think>` channel,
471
+ then `{content}` increments (multi-byte sequences are held back until
472
+ decodable; text that could begin a `stop` sequence is held back until
473
+ disambiguated, so no part of a stop sequence is ever streamed), then for
474
+ tool calls a `{tool_calls: [{index, id, type, function}]}` delta; the last
475
+ chunk carries `finish_reason` and `usage`. A generation error mid-stream is
476
+ sent as `data: {"error":{"message":…}}` and the stream closes. While a
477
+ large tool-call body is buffered for parsing, the server sends SSE comment
478
+ heartbeats (`: keep-alive`) so client idle timers do not terminate the call.
479
+
480
+ ### Tool calls
481
+
482
+ Qwen's `<parameter=...>` values are raw text. The parser preserves HTML
483
+ entities, indentation and JSON string escapes, removing only one framing
484
+ newline at either end. Array/object parameters are JSON-decoded according to
485
+ the tool schema. The outer Qwen markup is parsed before generic JSON repair,
486
+ so a nested edit array is not mistaken for the tool-call envelope.
291
487
 
292
488
  Tool round-trip: send the assistant message with its `tool_calls` back,
293
489
  followed by `{ "role": "tool", "tool_call_id": …, "content": … }`
294
490
  messages; multi-turn prompt prefixes reuse the KV prompt cache
295
491
  automatically.
296
492
 
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.
493
+ Tool-call parsing is per model family (`selectToolStreamMode` in
494
+ `src/server.ts`, parsers in `src/tool-call.ts`). Gemma 4 uses its native
495
+ `<|tool_call>`…`<tool_call|>` sentinel tokens. Every other family parses
496
+ **decoded text** once tools are active: OpenAI-JSON
497
+ `<tool_call>{…}</tool_call>`, Qwen-style
498
+ `<tool_call><function=name><parameter=key>value</parameter></function></tool_call>`,
499
+ GLM-5.2's `name<arg_key>…<arg_value>…`, and MiniCPM5's
500
+ `<function name="…"><param name="…">…</param></function>` (CDATA-wrapped
501
+ values supported). Content before the tool markup still streams live; only
502
+ the markup is withheld and converted to `tool_calls`. Argument values are
503
+ decoded against the tool's JSON schema (string-typed params stay strings).
504
+
505
+ When strict parsing fails, a repair pass runs before giving up: a
506
+ ```` ```json ```` fence or prose prefix is stripped, single quotes and
507
+ trailing commas are fixed, truncated braces are closed, a function name
508
+ that landed inside `arguments` is lifted into the envelope, and Python
509
+ literals `True`/`False`/`None` **outside string values** are coerced to
510
+ `true`/`false`/`null` (`pythonLiteralsToJson`, `src/tool-call.ts` — Qwen3.5
511
+ in particular emits Python booleans). The repair is not reported on the
512
+ wire; markup that still fails to parse falls back to plain content.
307
513
 
308
514
  ### Structured output (grammar-constrained decoding)
309
515
 
310
516
  Both `/v1/chat/completions` and `/v1/completions` accept the
311
- OpenAI/oMLX/vLLM structured-output fields (snake_case on the wire):
517
+ OpenAI/oMLX/vLLM structured-output fields (snake_case on the wire; the
518
+ field names are `GrammarRequest` in `src/grammar.ts`):
312
519
 
313
520
  ```jsonc
314
521
  {
@@ -330,9 +537,19 @@ OpenAI/oMLX/vLLM structured-output fields (snake_case on the wire):
330
537
  }
331
538
  ```
332
539
 
333
- Precedence when several are set mirrors oMLX: `guided_grammar` >
334
- `response_format` json_schema > json_object > `structured_outputs` >
335
- `guided_regex` > `guided_choice`.
540
+ | Field | Compiles to (xgrammar) | Output |
541
+ | --- | --- | --- |
542
+ | `response_format: {type:"json_object"}` | builtin JSON grammar | any valid JSON |
543
+ | `response_format: {type:"json_schema", json_schema:{name, schema, strict?, any_whitespace?}}` | `compileJSONSchema(schema)` | schema-conformant JSON |
544
+ | `guided_grammar` | `Grammar.fromEBNF` | text matching the grammar |
545
+ | `guided_regex` | EBNF-wrapped regex | text matching the regex subset |
546
+ | `guided_choice` | `root ::= ("a" \| "b" \| …)` | exactly one of the strings |
547
+ | `structured_outputs` | bare schema (alias for `json_schema.schema`) | schema-conformant JSON |
548
+
549
+ Precedence when several are set (`resolveGrammarRequest`): `guided_grammar`
550
+ > `response_format` json_schema > json_object > `structured_outputs` >
551
+ `guided_regex` > `guided_choice`. `response_format: {type:"text"}` or no
552
+ field at all means no constraint.
336
553
 
337
554
  Enforcement is a per-step token bitmask at the sampler (xgrammar):
338
555
  invalid next tokens are masked to −inf **after** the logits processors,
@@ -346,32 +563,46 @@ Degrade path — a grammar that fails to compile is never rejected (no
346
563
  requested JSON schema, raw grammar, regex, or choice constraint, and the
347
564
  response carries a `Warning` header (`grammar not enforced: …`);
348
565
  `/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).
566
+ `Warning` header only. `MLX_BUN_GRAMMAR=0` disables mask compilation but
567
+ does not silently ignore a requested constraint: it uses this same degrade
568
+ path with the hint `grammar compilation disabled by MLX_BUN_GRAMMAR=0`.
569
+ Design and fidelity notes: [docs/reference/server-api.md](./server-api.md).
355
570
 
356
571
  ### Errors
357
572
 
573
+ Memory estimates do not reject or shorten requests by default. Actual runtime
574
+ failures use the request/stream error path when recoverable; native Metal
575
+ allocation failure can terminate the process. Saved generation checkpoints
576
+ remain available for an identical retry.
577
+
358
578
  All errors are `{ "error": { "message": …, ... } }`.
359
579
 
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
580
+ - `400` — malformed JSON (`invalid JSON body`), empty `messages`
581
+ (`messages required`), unknown adapter id, vision request on a model
582
+ without a sidecar (`model has no vision sidecar`), audio request on a
583
+ model without an audio tower (message names the model), video on a
584
+ non-Qwen3.5 model or combined with audio, prompt build failures
585
+ (`prompt build failed: …`, which includes media fetch/transcode
586
+ errors), non-numeric `logit_bias` keys/values (`logit_bias must be a
587
+ dict of int to float`, mlx-lm's coercion error), invalid logprobs params
366
588
  (mlx-lm's exact validation: `logprobs must be of type bool`,
367
589
  `top_logprobs must be of type int` / `at least 0` / `at most 11`;
368
- `top_logprobs: -1` is the accepted "unset" sentinel).
590
+ `top_logprobs: -1` is the accepted "unset" sentinel), invalid
591
+ `reasoning_effort` (`reasoning_effort must be one of 'none', 'minimal',
592
+ 'low', 'medium', 'high', 'xhigh'`).
369
593
  - `400` with `"type": "memory_admission"`, `"code":
370
594
  "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`.
595
+ within an explicitly configured context/budget limit (`src/serve/request-plan.ts`). A prompt that
596
+ fits is never rejected for a broad `max_tokens`: the upper bound is
597
+ capped to the remaining room (`max_tokens` is a ceiling, not a promise)
598
+ and generation proceeds. This is opt-in via an operator budget/context cap
599
+ (or the fixed GLM-5.2 layout), never enforced from default fit estimates.
600
+ `/stats.admission.enforced_context_tokens` is null when no cap applies.
601
+ - `429` with `"type": "resource_admission"`, `"code": "queue_full"` when
602
+ a preparation or generation queue has 64 waiting requests. If SSE headers
603
+ are already sent, the stream emits its terminal error instead.
604
+ - `500` — a generation failure after admission; the stack is logged
605
+ server-side, the body keeps only the message.
375
606
 
376
607
  ## POST /v1/completions (raw text completion)
377
608
 
@@ -383,17 +614,19 @@ and adapter selection as chat.
383
614
 
384
615
  ```jsonc
385
616
  {
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
617
+ "prompt": "Once upon a time", // REQUIRED, non-empty string only (token
618
+ // arrays rejected, matching mlx_lm.server)
619
+ "max_tokens": 512, // unset = no cap: generate until EOS or the
620
+ // an explicit operator limit (DEVIATION: mlx_lm.server
621
+ // defaults 512 — pass --max-tokens 512 to
622
+ // reproduce); max_completion_tokens wins
390
623
  "stream": false,
391
624
  "stop": "\n\n", // string or array, decoded-text matching
392
625
  // plus every sampling/penalty field from /v1/chat/completions:
393
626
  // temperature, top_p, top_k, seed, min_p, xtc_probability,
394
627
  // xtc_threshold, logit_bias, repetition_penalty,
395
628
  // repetition_context_size, presence_penalty, presence_context_size,
396
- // frequency_penalty, frequency_context_size, adapter,
629
+ // frequency_penalty, frequency_context_size, hlg, adapter,
397
630
  // logprobs (BOOL, mlx-lm's type — not OpenAI's legacy int),
398
631
  // top_logprobs (0-11 or -1) — same response block as chat, see
399
632
  // "logprobs / top_logprobs" above (mlx-lm shares the builder),
@@ -411,9 +644,11 @@ Response is the OpenAI text-completion object:
411
644
  {
412
645
  "id": "cmpl-…", "object": "text_completion", "created": 1760000000,
413
646
  "model": "<loaded model id>",
414
- "choices": [{ "index": 0, "text": "…", "finish_reason": "stop" | "length" }],
647
+ "choices": [{ "index": 0, "text": "…", "finish_reason": "stop" | "length",
648
+ "logprobs": { /* only when requested */ } }],
415
649
  "usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0,
416
- "prompt_tokens_details": { "cached_tokens": 0 } }
650
+ "prompt_tokens_details": { "cached_tokens": 0 },
651
+ "lane": "serial" }
417
652
  // + "speculation" under --draft-model, as on chat
418
653
  }
419
654
  ```
@@ -423,83 +658,102 @@ Streaming (`"stream": true`) is SSE: `text_completion` chunks whose
423
658
  `finish_reason` and `usage` (always attached — mlx-lm gates usage behind
424
659
  `stream_options.include_usage`; ours is an additive superset), then
425
660
  `data: [DONE]`. There is no `echo` parameter (mlx_lm.server has none).
661
+ `400` on a missing/empty/non-string `prompt`, plus the same logit_bias,
662
+ logprobs, and admission errors as chat.
426
663
 
427
664
  ## POST /v1/messages (Anthropic Messages API)
428
665
 
429
666
  Anthropic-protocol surface over the same engine — on by default, like
430
667
  `optiq serve`. Point any Anthropic-SDK tool at the server
431
668
  (`ANTHROPIC_BASE_URL=http://localhost:8080`, any `x-api-key`) — Claude
432
- Code works as a client this way.
669
+ Code works as a client this way. Translation lives in `src/anthropic.ts`;
670
+ the request funnels into the chat handler above.
433
671
 
434
672
  - `system` (string or text blocks), `messages` with string or
435
673
  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`.
674
+ native tool-calling path; `image` blocks (base64 or url source) hit the
675
+ vision path on sidecar models, including image blocks nested inside a
676
+ `tool_result` (the computer-use shape). Other image source types are a
677
+ `400`. The protocol has no audio input block; audio-shaped blocks
678
+ (`input_audio` / `audio` / `audio_url`) are rejected with a `400` pointing
679
+ to `/v1/chat/completions`. Prior-turn `thinking` blocks are dropped on
680
+ re-ingest.
441
681
  - `tools` (`{name, description, input_schema}`) map to function tools;
442
- server-tool types (web_search, …) are dropped silently.
682
+ entries without `name` + `input_schema` (server-tool types such as
683
+ web_search) are dropped silently.
443
684
  - `max_tokens`, `temperature`, `top_p`, `top_k`, `stop_sequences`,
444
685
  `stream` as in the Anthropic spec. The mlx-lm sampler/penalty
445
686
  extensions (`min_p`, `xtc_probability`, `xtc_threshold`,
446
687
  `repetition_penalty`, `presence_penalty`, `frequency_penalty` + their
447
688
  `*_context_size` windows) pass through as extras; `logit_bias` does
448
689
  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.
690
+ - Response: `{id: "msg_…", type: "message", role: "assistant", content:
691
+ [{type: "text"} | {type: "tool_use"}…], stop_reason, stop_sequence,
692
+ usage: {input_tokens, output_tokens, cache_read_input_tokens?}}` —
693
+ `cache_read_input_tokens` is present when the prompt cache contributed.
453
694
  - Streaming follows the Anthropic event grammar exactly:
454
695
  `message_start → content_block_start/delta/stop (text_delta,
455
696
  input_json_delta) → message_delta (stop_reason + usage) →
456
697
  message_stop`. Errors are `event: error` frames.
457
698
  - Errors: `{type: "error", error: {type: "invalid_request_error" |
458
- "api_error", message}}`.
699
+ "api_error", message}}` — the chat handler's `4xx`/`5xx` are re-wrapped
700
+ into this shape.
459
701
 
460
702
  ## POST /v1/responses (OpenAI Responses API)
461
703
 
462
704
  Responses-protocol surface (Codex, Cursor, Continue, Cline, and the
463
- OpenAI SDK speak this now). Oracle: optiq responses shim.
705
+ OpenAI SDK speak this now). Oracle: optiq responses shim; port in
706
+ `src/responses.ts`.
464
707
 
465
708
  - `input` (string or item array: `message`, `function_call`,
466
709
  `function_call_output`), `instructions` (merged with any
467
710
  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.
711
+ `max_output_tokens` (or `max_tokens`), `temperature`, `top_p`, `top_k`,
712
+ flat `tools`/`tool_choice` (built-in tool types — web_search, file_search,
713
+ mcp, computer_use — dropped silently; `{type:"function", name}` maps to
714
+ the chat object form, `"auto"`/`"none"`/`"required"` pass through),
715
+ `stream`. The mlx-lm sampler/penalty extensions (`min_p`,
716
+ `xtc_probability`, `xtc_threshold`, `logit_bias`, `repetition_penalty`,
717
+ `presence_penalty`, `frequency_penalty` + `*_context_size`) pass through
718
+ as extras.
474
719
  - **`previous_response_id` resumption**: pass a prior response id
475
720
  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.
721
+ input + output back in (instructions carry forward when omitted;
722
+ reasoning items are not replayed). Store is per-process, 1 h TTL,
723
+ 32 MiB byte-capped LRU — observable at `GET /stats` (`response_store`).
724
+ Unknown/expired id → `404`.
479
725
  - Response: `{id: "resp_…", object: "response", status: "completed" |
480
- "incomplete", output: [{type: "message"|"function_call"…}], usage}`.
726
+ "incomplete", output: [{type: "reasoning", summary: [{type:
727
+ "summary_text", text}]}?, {type: "message"}, {type: "function_call"}…],
728
+ usage: {input_tokens, output_tokens, total_tokens,
729
+ output_tokens_details: {reasoning_tokens}}, previous_response_id}`. A
730
+ `reasoning` item is present only when the model emitted a think channel.
481
731
  - Streaming event chain: `response.created → response.in_progress →
482
732
  response.output_item.added → response.content_part.added →
483
733
  response.output_text.delta… → response.output_text.done →
484
734
  response.content_part.done → response.output_item.done →
485
735
  response.completed` (+ `response.function_call_arguments.delta/.done`
486
- for tool calls).
736
+ for tool calls; a reasoning output item precedes the message when the
737
+ model thinks).
738
+ - Errors: `{error: {message, type: "invalid_request_error" |
739
+ "server_error", param: null, code: null}}`.
487
740
 
488
741
  ## POST /v1/embeddings (OpenAI Embeddings API)
489
742
 
490
743
  Text embeddings, available when the **served model is an embedding model**
491
744
  (plain Qwen3 / Qwen3-Embedding) — consistent with the single-model server:
492
745
  `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.
746
+ any other served model the route returns `400 invalid_request_error`
747
+ (`src/serve/model-admin-routes.ts`).
748
+
749
+ - `input` (string or array of strings; anything else is a `400`), `model`
750
+ (ignored — the served model is used), and a **non-standard**
751
+ `instruction` (optional): applies Qwen3-Embedding's query format
752
+ `Instruct: {instruction}\nQuery:{text}`, which steers *which* similarity
753
+ axis the geometry reflects. Omit it for plain document embeddings.
500
754
  - Pooling is last-token + L2-normalization (the vectors are unit-length);
501
755
  similarity is the dot product. Bit-exact vs the mlx-lm `qwen3` reference
502
- (`tests/qwen3-embed-parity.test.ts`).
756
+ (`tests/parity/qwen3-embed-parity.test.ts`).
503
757
  - Response: `{ "object": "list", "data": [{ "object": "embedding", "index":
504
758
  0, "embedding": [float…] }, …], "model": "<served id>", "usage": {
505
759
  "prompt_tokens": N, "total_tokens": N } }`. Embedding is a single forward
@@ -510,16 +764,31 @@ curl localhost:8080/v1/embeddings -H 'content-type: application/json' \
510
764
  -d '{"input": ["the cat sat on the mat", "a kitten by the window"]}'
511
765
  ```
512
766
 
767
+ ## GET /v1
768
+
769
+ API index for discovery tooling:
770
+
771
+ ```jsonc
772
+ { "name": "mlx-bun", "version": "<package version>", "model": "<served model id>",
773
+ "endpoints": ["POST /v1/chat/completions", "POST /v1/completions", "POST /v1/messages",
774
+ "POST /v1/responses", "POST /v1/embeddings", "GET /v1/models",
775
+ "GET/POST/DELETE /v1/adapters", "GET /health", "GET /stats", "GET /fit",
776
+ "GET /library", "GET /downloads"] }
777
+ ```
778
+
779
+ (The list is a curated subset; this document is the full inventory.)
780
+
513
781
  ## GET /v1/models
514
782
 
515
783
  `{ "object": "list", "data": [{ "id": "<model id>", "object": "model",
516
- "created": <unix s>, … }] }`
784
+ "created": <unix s>, … }] }` — `created` is the server start time for every
785
+ row.
517
786
 
518
787
  The served model is FIRST, with extra capability fields:
519
788
 
520
789
  ```jsonc
521
790
  {
522
- "id": "<served model id>", "object": "model", "created": 0,
791
+ "id": "<served model id>", "object": "model", "created": 1760000000,
523
792
  "owned_by": "mlx-bun",
524
793
  "context_window": 32768,
525
794
  "reasoning": false, // switchable thinking channel (gates the
@@ -560,7 +829,7 @@ architectures, one row per repo via `listCanonical()` — duplicate snapshots
560
829
  from upstream re-pushes are not separate entries):
561
830
 
562
831
  ```jsonc
563
- { "id": "<repo id>", "object": "model", "created": 0,
832
+ { "id": "<repo id>", "object": "model", "created": 1760000000,
564
833
  "vision": false, // visionCapable(): sidecar OR encoder-free
565
834
  // unified vision config
566
835
  "tier": "targeted" | "generic" // supportTier(): dedicated/generated forward
@@ -596,7 +865,7 @@ vision/audio, adapters, and training are not emulated by the serving port.
596
865
  "server": { "owner": "serve" | "pi-session" | "embedded", "model": "...", "started_at": 0 },
597
866
  "prompt_cache": { "entries": 0, "bytes": 0, "max_bytes": 0, "hits": 0, "misses": 0 },
598
867
  "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",
868
+ "kv_quant": { "mode": "bf16" | "uniform-kv8" | "turbo k8v3" | "mixed (kv_config.json)",
600
869
  "layers": { "kv4": 8, "bf16": 40 }, // turbo: { "turbo-k8v3": 8, "bf16": 40 }
601
870
  "attention": { "global": 10, "sliding_window": 38 } },
602
871
  // present only when --ssd-cache is on:
@@ -607,8 +876,9 @@ vision/audio, adapters, and training are not emulated by the serving port.
607
876
  "pending_spill_bytes": 0, "dropped_spills": 0,
608
877
  "failed_spills": 0, "longest_durable_prefix_tokens": 0 },
609
878
  "admission": {
610
- "max_safe_context": 0, // tokens; requests above this 400
611
- "memory_budget_bytes": null, // explicit budget, or null (machine default)
879
+ "max_safe_context": 0, // advisory fit estimate, tokens
880
+ "enforced_context_tokens": null, // explicit cap or fixed layout; null = none
881
+ "memory_budget_bytes": null, // explicit budget, or null
612
882
  "usable_bytes": 0,
613
883
  "weights_bytes": 0
614
884
  },
@@ -626,18 +896,13 @@ vision/audio, adapters, and training are not emulated by the serving port.
626
896
  "resident_weight_bytes": 0,
627
897
  "main_expert_slab_bytes": 0,
628
898
  "mtp_expert_slab_bytes": 0,
629
- "expert_runtime": {
899
+ "expert_runtime": { // null when the expert runtime is off
630
900
  "main_residency": { "resident": 0, "pinned": 0, "working": 0,
631
901
  "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
- },
902
+ "mtp_residency": { /* same shape */ } | null,
903
+ "last_turn": { "main": { "demand": { "hits": 0, "misses": 0, "readBytes": 0 } },
904
+ "mtp": { "demand": { "hits": 0, "misses": 0, "readBytes": 0 } },
905
+ "pilot": null },
641
906
  "last_repin": []
642
907
  }
643
908
  },
@@ -655,16 +920,126 @@ vision/audio, adapters, and training are not emulated by the serving port.
655
920
  }
656
921
  ```
657
922
 
923
+ `kv_quant.mode` reflects the `--kv-quant` flag the server was started
924
+ with; the default is `bf16` (no KV quantization). `mixed (kv_config.json)`
925
+ appears only when `--kv-quant config` was passed explicitly and the
926
+ checkpoint ships a per-layer `kv_config.json` — per-layer KV quantization
927
+ is never selected automatically.
928
+
658
929
  `pending_snapshots` counts prompt-cache entries scheduled for persistence but
659
930
  not yet confirmed by the atomic SSD store. `longest_durable_prefix_tokens` is
660
931
  the longest prefix already present in the SSD index, not merely queued.
661
932
 
933
+ ## GET /fit
934
+
935
+ Fit assessment for the loaded model on this machine, plus a capability
936
+ matrix across Apple SKUs. Used by the status page. Experts bytes come
937
+ from the registry so MoE active-parameter predictions match the serve
938
+ banner and `mlx-bun fit`. When the eval DB has a real measured decode
939
+ rate for this model snapshot, it is included and takes precedence over
940
+ the prediction.
941
+
942
+ For the direct Colibri GLM-5.2 runtime this endpoint switches to the exact
943
+ serve-time memory plan. The response separates the full streamed artifact on
944
+ disk from resident weights, main/MTP expert slabs, KV, and other reserves.
945
+ `measured_decode_tps` is the G5 warm quality-preserving measurement;
946
+ `glm52.direct_oracle_warm_decode_tps` is the same-machine direct Colibri
947
+ control, while `glm52.aspirational_decode_tps` is a target, not a release
948
+ gate. The single-row SKU result describes the current machine because the
949
+ generic all-resident SKU estimator is inapplicable
950
+ (`report.predicted_decode_tps` is `null` there).
951
+
952
+ ```jsonc
953
+ {
954
+ "machine": { "chip": "M4 Pro", "ram_bytes": 0, "bandwidth_gbs": 0.0 },
955
+ "context_tokens": 8192, // estimated context capacity
956
+ "typical_context_tokens": 8192, // min(8192, context_tokens)
957
+ "typical_decode_tps": 0.0, // predicted at typical_context_tokens
958
+ "measured_decode_tps": null, // real number from eval DB, or null
959
+ "measured_at": null, // unix ms of measurement, or null
960
+ "report": {
961
+ "fits": true,
962
+ "weights_bytes": 0,
963
+ "kv_bytes": 0,
964
+ "transient_bytes": 0,
965
+ "total_bytes": 0,
966
+ "usable_bytes": 0,
967
+ "max_safe_context": 8192,
968
+ "predicted_decode_tps": 0.0
969
+ },
970
+ // GLM-5.2 only:
971
+ "glm52": {
972
+ "artifact_disk_bytes": 0, // exact registry size; null if unavailable
973
+ "main_expert_slab_bytes": 0,
974
+ "mtp_expert_slab_bytes": 0,
975
+ "max_generation_tokens": 128,
976
+ "direct_oracle_warm_decode_tps": 0.27,
977
+ "aspirational_decode_tps": 2.0
978
+ },
979
+ "sku_matrix_ctx": 32768,
980
+ "sku_matrix": [{
981
+ "sku": "M4 Pro 24 GB", "ram_gb": 24,
982
+ "fits": true, "max_context": 32768, "decode_tps": 0.0
983
+ }]
984
+ }
985
+ ```
986
+
987
+ ## Adapters (LoRA hot-swap)
988
+
989
+ Routes in `src/serve/model-admin-routes.ts`; the manager is
990
+ `AdapterManager` in `src/lora.ts`. Requests selecting the same ordered adapter
991
+ set can share a continuous group. Different sets wait for the current group
992
+ to finish and then use the same executor. Prefix-cache identity is resolved
993
+ under the execution lease, so replacing an adapter while a request is queued
994
+ cannot reuse state from its previous weights.
995
+
996
+ - `GET /v1/adapters` — `{ adapters: [{ id, path, rank, scale, size_bytes,
997
+ mounted_layers, ram_bytes }] }` — currently-mounted adapters only.
998
+ `ram_bytes` is the actual resident size of the adapter's mounted
999
+ `lora_a`/`lora_b` arrays (summed `MlxArray.nbytes` — real RAM cost while
1000
+ mounted, not a guess from the on-disk file size).
1001
+ - `GET /v1/adapters/available` — `{ adapters: [{ id, path, rank, scale,
1002
+ base_model, mounted, compatible }] }` — every adapter found on disk
1003
+ (`~/.cache/mlx-bun-finetunes`, `~/.cache/mlx-bun/adapters`), unfiltered.
1004
+ `compatible` is true when the adapter's recorded base model matches the
1005
+ currently-served model (compared by bare repo name) or the adapter
1006
+ recorded no base at all; `mounted` is true when it's already loaded.
1007
+ The web chat's adapter chip uses `compatible` to gray out entries it
1008
+ won't let you select rather than hiding them.
1009
+ - `POST /v1/adapters` — `{ "id": "...", "path": "/dir" }` (both required,
1010
+ else `400 "id and path required"`); mounts through the generation queue
1011
+ (never races a forward pass). Response:
1012
+ `{ id, mounted_layers, rank, scale, ram_bytes }`. `400` on shape/compat
1013
+ mismatch — validation is all-or-nothing.
1014
+ - `DELETE /v1/adapters/<id>` — unmount; `{ id, removed_layers }` on
1015
+ success, `404 {"error":{"message":"adapter <id> not mounted"}}` otherwise.
1016
+
1017
+ Select per request with the `adapter` body field (chat, text completions;
1018
+ the Anthropic and Responses surfaces pass it through as an extra), which
1019
+ also accepts a composed spec — `"a+b"` (or `"a,b"`) stacks two mounted
1020
+ adapters, their LoRA residuals summed in order
1021
+ (`AdapterManager.resolveSpec` / `parseAdapterSpec` in `src/lora.ts`);
1022
+ every named id must already be mounted or the request 400s with the
1023
+ unknown id named. Prompt-cache entries are namespaced per adapter spec
1024
+ (including composed ones), so switching adapters — or switching which
1025
+ ones are stacked — never reuses another combination's KV.
1026
+
1027
+ `serve --adapter <dir>` (alias `--adapter-path`, mlx_lm.server's
1028
+ spelling) mounts an adapter at startup through this same machinery and
1029
+ makes it the default for requests that send no `adapter` field; an
1030
+ explicit `adapter` (including `"none"`) always wins, and hot-swap via
1031
+ these endpoints is unchanged.
1032
+
662
1033
  ## POST /admin/cache/flush
663
1034
 
664
1035
  Forces every pending SSD prompt-cache snapshot through the serial write queue
665
1036
  and waits for the atomic temp-file/fsync/rename boundary. The response is `200`
666
1037
  only when no snapshot or spill remains pending and no write failed during this
667
1038
  flush; otherwise it is `503` with the counters that explain why.
1039
+ A snapshot missing from both RAM and usable SSD coverage remains pending
1040
+ across repeated flushes. Retrying does not turn a missing snapshot into a
1041
+ successful durability result. Already-durable prefixes avoid another clone
1042
+ and write.
668
1043
 
669
1044
  ```jsonc
670
1045
  {
@@ -685,13 +1060,16 @@ flush; otherwise it is `503` with the counters that explain why.
685
1060
  The standard benchmark calls this endpoint before its restart leg and records
686
1061
  the result. Normal `mlx-bun serve` shutdown on `SIGINT` or `SIGTERM` performs
687
1062
  the same flush after active requests drain, with a 120-second default timeout.
1063
+ Without `--ssd-cache` the route still answers (`entries: 0`).
688
1064
 
689
1065
  ## GET /library
690
1066
 
1067
+ `?refresh=1` bypasses the short response cache, including after a parent-managed job finishes.
1068
+
691
1069
  Returns all models found in the local HuggingFace hub cache (via the
692
1070
  registry scan), each annotated with a fit assessment for this machine.
693
1071
  Response is cached for 30 seconds (registry scan + config reads; no
694
- tensor bytes are read).
1072
+ tensor bytes are read). Handler: `src/serve/discovery-routes.ts`.
695
1073
 
696
1074
  ```jsonc
697
1075
  {
@@ -720,6 +1098,203 @@ tensor bytes are read).
720
1098
  One row per repo (`listCanonical()`; duplicate snapshots from upstream
721
1099
  re-pushes surface only via the CLI's `mlx-bun ls --all-revisions`).
722
1100
 
1101
+ ## GET /downloads
1102
+
1103
+ Snapshot of the last 5 model downloads (active, completed, or errored)
1104
+ initiated via `mlx-bun download`, the web library panel, or
1105
+ `POST /api/hub/download`.
1106
+
1107
+ ```jsonc
1108
+ {
1109
+ "downloads": [{
1110
+ "repo_id": "…",
1111
+ "state": "active" | "done" | "error",
1112
+ "current_file": "model.safetensors" | null,
1113
+ "received_bytes": 0,
1114
+ "total_bytes": 0,
1115
+ "files_done": 0,
1116
+ "files_total": 0,
1117
+ "bytes_per_sec": 0, // rolling ~5 s window
1118
+ "started_at": 1760000000,
1119
+ "finished_at": null,
1120
+ "error": "…" // present on state "error"
1121
+ }]
1122
+ }
1123
+ ```
1124
+
1125
+ ## Jobs (`/api/jobs`)
1126
+
1127
+ The web wizards (quantize, fine-tune, dataset) run as jobs: a SQLite row
1128
+ plus an NDJSON event log (`src/jobs/`). Submit routes return a `job_id`;
1129
+ these routes read progress. Loopback-served admin routes, no separate auth
1130
+ (same posture as every other `/api/*` route). Handler:
1131
+ `src/serve/admin-routes.ts`.
1132
+
1133
+ `GET /api/jobs?limit=50&kind=quantize|finetune|dataset` — most recent
1134
+ jobs, newest first: `{ "ok": true, "jobs": [JobRow, …] }`.
1135
+
1136
+ `GET /api/jobs/<id>` — one row, or `404 { "ok": false, "error": "job not
1137
+ found" }`:
1138
+
1139
+ ```jsonc
1140
+ { "ok": true, "job": {
1141
+ "id": "…", "kind": "quantize" | "finetune" | "dataset",
1142
+ "status": "queued" | "running" | "done" | "failed" | "zombie",
1143
+ "config_json": "{…}", // the submit body as stored
1144
+ "progress": 0.0, // 0..1, from the latest stage/metric event
1145
+ "message": "…" | null,
1146
+ "log_path": "…", "output_path": "…" | null, "error": "…" | null,
1147
+ "started_at": "<ISO>", "ended_at": "<ISO>" | null
1148
+ } }
1149
+ ```
1150
+
1151
+ `GET /api/jobs/<id>/stream` — EventSource-compatible SSE tail of the job's
1152
+ log: a `retry: 1500` line first, then one `data: <JobEvent>` frame per
1153
+ event, polling while the job is non-terminal, then `event: end` +
1154
+ `data: {}` and close. An unknown id streams a single `failed` event then
1155
+ `end`. Events:
1156
+
1157
+ ```jsonc
1158
+ { "type": "started", "ts": 0 }
1159
+ { "type": "log", "line": "…" }
1160
+ { "type": "stage", "stage": "…", "progress": 0.5, "message": "…" }
1161
+ { "type": "metric", "kind": "train" | "val", "step": 0, "loss": 0.0 } // + grad_norm, learning_rate, tokens_per_sec, …
1162
+ { "type": "done", "ts": 0, "output_dir": "…", "summary": { } }
1163
+ { "type": "failed", "error": "…", "ts": 0 }
1164
+ ```
1165
+
1166
+ ## Fine-tune (`/api/finetune/*`)
1167
+
1168
+ The HTTP entry point to LoRA training (`src/serve/lab-routes.ts` →
1169
+ `src/train/job.ts`). It runs the same runner the CLI's `mlx-bun train`
1170
+ drives, as a subprocess job (crash isolation + one GPU lease across
1171
+ concurrent server requests). Data formats, every config knob, and the
1172
+ methodology are in [training.md](./training.md); this section is the wire
1173
+ shape.
1174
+
1175
+ `POST /api/finetune/submit` — body is `FinetuneSubmit` (`src/train/job.ts`).
1176
+ `model_dir` and `data_dir` are required at the route (`400 "model_dir and
1177
+ data_dir required"`); `adapter_path` defaults to
1178
+ `~/.cache/mlx-bun/adapters/adapter-<timestamp>` when omitted. Everything
1179
+ else is optional and falls back to `DEFAULT_TRAIN_CONFIG`:
1180
+
1181
+ ```jsonc
1182
+ {
1183
+ "model_dir": "/path/to/snapshot", // required
1184
+ "data_dir": "/path/to/dataset", // required: train.jsonl (+ optional valid.jsonl)
1185
+ "adapter_path": "/path/to/output", // optional (default above)
1186
+ "method": "sft" | "dpo" | "orpo",
1187
+ "rank": 16, "scale": 20, "rank_scaling": "…", "target_modules": ["…"], "num_layers": 0,
1188
+ "iters": 300, "learning_rate": 2e-4, "max_seq_length": 2048, "batch_size": 1,
1189
+ "grad_accumulation_steps": 1, "seed": 0, "steps_per_report": 1, "steps_per_eval": 0,
1190
+ "weight_decay": 0.0, "lora_dropout": 0.0, "rs_lora": false, "lora_plus_ratio": 1,
1191
+ "grad_checkpoint": false, "mlp_split": false, "segment_size": 0,
1192
+ "save_checkpoints": true, "grad_clip_norm": 0, "val_max_examples": 0,
1193
+ "dpo_beta": 0.1, "dpo_warmup_iters": 0, "dpo_lr_schedule": "constant" | "cosine",
1194
+ "orpo_lambda": 0.1, "orpo_warmup_iters": 0, "orpo_lr_schedule": "constant" | "cosine",
1195
+ "orpo_chunk_size": 0, "orpo_fused_ce": true, "orpo_flash_ce": true, "orpo_prefix_shared": true,
1196
+ "sft_scope": "full" | "response",
1197
+ "warm_start_adapter": "/path/to/adapter"
1198
+ }
1199
+ ```
1200
+
1201
+ Response: `{ "ok": true, "job_id": "…", "adapter_path": "…" }`. Then
1202
+ follow `GET /api/jobs/<job_id>/stream` for `metric` events (`kind:
1203
+ "train"|"val"`, `step`, `loss`, …) and the terminal `done`/`failed`.
1204
+ Config validation errors (an invalid `sft_scope`, a missing `adapter_path`
1205
+ at the runner) surface as the job's `failed` event, not as a `4xx`.
1206
+
1207
+ ```bash
1208
+ curl -s localhost:8080/api/finetune/submit -X POST -H 'content-type: application/json' -d '{
1209
+ "model_dir": "/path/to/snapshot", "data_dir": "/path/to/dataset",
1210
+ "adapter_path": "/path/to/output-adapter",
1211
+ "method": "sft", "rank": 16, "iters": 300, "learning_rate": 2e-4, "max_seq_length": 2048
1212
+ }'
1213
+ # → {"ok":true,"job_id":"…","adapter_path":"/path/to/output-adapter"}
1214
+ curl -N localhost:8080/api/jobs/<job_id>/stream
1215
+ ```
1216
+
1217
+ `POST /api/finetune/inspect-dataset` with `{"path": "<data_dir>"}` — probe
1218
+ a dataset directory before submitting: `{ "ok": true, "n_train": N,
1219
+ "n_valid": N, "format": "messages" | "prompt-completion" | "text" | "dpo" |
1220
+ "unknown" }`, or `{ "ok": false, …, "error": "<train.jsonl> not found" }`
1221
+ (HTTP 200 either way).
1222
+
1223
+ `POST /api/finetune/merge` with `{"adapter_a": "/dir", "adapter_b":
1224
+ "/dir", "scales"?: [a, b]}` — fold two adapters into one (`mergeAdapters`
1225
+ in `src/train/merge.ts`; per-source scales default to each adapter's own
1226
+ config). Output goes to `~/.cache/mlx-bun/adapters/merged-<timestamp>`:
1227
+ `{ "ok": true, "merged_path": "…", "stats": { "layersMerged", "layersOnlyInOne",
1228
+ "totalKeysOut", "sources", "scales" } }`; `400 { "ok": false, "error" }` on
1229
+ a missing field or merge failure.
1230
+
1231
+ `POST /api/finetune/export` with `{"base_model": "…", "adapter_path":
1232
+ "/dir", "method"?: "…"}` — writes a `manifest.json` describing the adapter
1233
+ into `~/.cache/mlx-bun/exports/export-<timestamp>`:
1234
+ `{ "ok": true, "export_path": "…", "manifest": { "version": 1, "base_model",
1235
+ "adapter_path", "method"?, "created_at" } }`; `400` on a missing field.
1236
+
1237
+ ## Quantize (`/api/quantize/*`)
1238
+
1239
+ `POST /api/quantize/inspect` with `{"model_id": "<repo id | path>"}` —
1240
+ resolve a model through the registry and report whether the quantizer can
1241
+ walk it (`inspectModel`, `src/quantize/job.ts`): `{ "ok": true, "model_id",
1242
+ "arch": "<model_type>" | null, "support": true, "size_gb": 0.0 }`, or
1243
+ `{ "ok": false, …, "error" }` (HTTP 200 either way).
1244
+
1245
+ `POST /api/quantize/submit` — starts a quantize job whose output lands in
1246
+ the HF hub cache as a synthetic repo `<org>/<name>-OptiQ-<suffix>`
1247
+ (`refs/main` written so the registry picks it up; `<suffix>` is
1248
+ `<bits>bit` or `mixed-<target_bpw>bpw`, plus `-rot<seed>` when rotating):
1249
+
1250
+ ```jsonc
1251
+ {
1252
+ "model_id": "org/name | /path", // required (400 "model_id required")
1253
+ "bits": 4, "group_size": 64,
1254
+ "target_bpw": 3.5, // mixed-precision target; wins over bits in the name
1255
+ "candidate_bits": [2, 3, 4, 8],
1256
+ "reference": "…", "calibration_mix": "…", "n_calibration": 0,
1257
+ "rotate_weights": false, "rotation_seed": 42
1258
+ }
1259
+ ```
1260
+
1261
+ Response: `{ "ok": true, "job_id": "…", "output_dir": "…" }`. The library
1262
+ cache is invalidated when the job completes.
1263
+
1264
+ `POST /api/quantize/resolve-folder` and `POST /api/model/resolve-folder`
1265
+ (same handler) with `{"folder_name"?: "…", "rel_path"?: "…"}` — turn a
1266
+ folder the browser picked (a `models--org--name` hub dir, a snapshot hash
1267
+ dir, or a bare model dir under the hub cache / `~/.cache/mlx-bun`) into a
1268
+ servable path: `{ "ok": true, "path": "…", "repo_id"?: "…", "model_type"?:
1269
+ "…" }`, or `{ "ok": false, "error": "Couldn't locate this folder on disk —
1270
+ paste the path instead." }` (HTTP 200).
1271
+
1272
+ ## Dataset (`/api/dataset/*`)
1273
+
1274
+ `GET /api/dataset/templates` — the dataset-builder template registry
1275
+ (`src/dataset/registry.ts`): `{ "templates": [{ "id", "label",
1276
+ "description", "output_format": "messages" | "prompt_completion" | "dpo" |
1277
+ "text", "needs_llm": bool, "fields": [{ "name", "type": "text" | "textarea" |
1278
+ "number", "label", "hint"?, "required"?, "default"? }] }] }`.
1279
+
1280
+ `POST /api/dataset/submit` with `{"template_id": "…", "inputs": {…},
1281
+ "model_name"?: "…"}` — runs the template in-process as a job; LLM-backed
1282
+ templates call back into this server's own `/v1` (`api_url` =
1283
+ `http://127.0.0.1:<port>`). Output dir is
1284
+ `~/.cache/mlx-bun/datasets/dataset-<template>-<timestamp>`. Response:
1285
+ `{ "ok": true, "job_id": "…", "output_dir": "…" }`; `400 { "ok": false,
1286
+ "error": "unknown template …" }` for an unknown id.
1287
+
1288
+ ## POST /api/{quantize,finetune,dataset}/push
1289
+
1290
+ Upload a job's output (or any folder) to the Hugging Face Hub with the
1291
+ saved write token (`src/hf-push.ts`). Body: `{"repo_id": "org/name",
1292
+ "job_id"?: "…", "source_path"?: "/dir", "private"?: false}` — `repo_id` is
1293
+ required; the source is `source_path`, else the job's `output_path`. The
1294
+ `dataset` variant pushes as a dataset repo, the others as model repos.
1295
+ Response `{ "ok": true, "url": "…" }`; `400 { "ok": false, "error" }` when
1296
+ no token is saved, no source directory resolves, or the upload fails.
1297
+
723
1298
  ## GET /api/gc/plan · POST /api/gc/execute
724
1299
 
725
1300
  Reclaim disk from superseded snapshots + orphaned blobs in the local HF
@@ -764,13 +1339,12 @@ Errors follow the same `{ "ok": false, "error": "…" }` shape as the other
764
1339
  ## GET /api/memory/\* · POST /api/memory/init
765
1340
 
766
1341
  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.
1342
+ Memory panel — same loopback-only admin-route posture as `/api/gc/*`, no
1343
+ separate auth. Handlers live in `src/memory/rest.ts`; they never touch the
1344
+ agent-tool surface (`src/memory/tools.ts`), which stays read-only. Every
1345
+ route below degrades gracefully — a missing vault, an unknown article, or
1346
+ a bad param returns `{ "ok": false, ... }` (with an appropriate HTTP
1347
+ status), never a 500.
774
1348
 
775
1349
  When no vault exists yet, every `GET` route (except `/init`) returns:
776
1350
 
@@ -792,25 +1366,23 @@ separate (mirrors `memory_list`):
792
1366
  ```
793
1367
 
794
1368
  `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:
1369
+ wraps `searchArticles` (`limit` capped at 200; `400` without `q`); same
1370
+ summaries/hits shape the `memory_search` tool formats from:
797
1371
 
798
1372
  ```jsonc
799
1373
  { "ok": true, "summaries": [{ "article": "…", "occurrences": 3, "matched_terms": ["…"] }], "hits": [{ "article": "…", "anchor": "…", "line": 10, "excerpt": "…" }] }
800
1374
  ```
801
1375
 
802
1376
  `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:
1377
+ deterministic metadata the panel needs (infobox, lead, series banner,
1378
+ section skeleton); `404 { "ok": false }` for an unknown name:
806
1379
 
807
1380
  ```jsonc
808
1381
  { "ok": true, "name": "…", "path": "…", "content": "# …", "infobox": { "type": "…", "entityKind": "thing", "fields": [] }, "lead": "…", "series": null, "structure": [] }
809
1382
  ```
810
1383
 
811
1384
  `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):
1385
+ data `memory_links` resolves); articles only, not `Reference/*`:
814
1386
 
815
1387
  ```jsonc
816
1388
  { "ok": true, "name": "…", "outbound": ["…"], "inbound": ["…"] }
@@ -818,7 +1390,7 @@ the tool's own scope):
818
1390
 
819
1391
  `GET /api/memory/history?name=<article>&limit=50` — the article's git log
820
1392
  (read-only `git log` plumbing on the vault's own repo, via `Bun.spawn` —
821
- never a shell string):
1393
+ never a shell string; `limit` capped at 200):
822
1394
 
823
1395
  ```jsonc
824
1396
  { "ok": true, "name": "…", "isGitRepo": true, "entries": [{ "hash": "…", "date": "YYYY-MM-DD", "subject": "…" }] }
@@ -827,99 +1399,78 @@ never a shell string):
827
1399
  `GET /api/memory/diff?name=<article>&rev=<hash>` — that commit's diff for
828
1400
  the article (`git show <rev> -- <path>`). `rev` must match
829
1401
  `/^[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:
1402
+ `HEAD~1`) or the request 400s before touching git; an unknown `name` or a
1403
+ revision with no diff for it 404s:
834
1404
 
835
1405
  ```jsonc
836
1406
  { "ok": true, "name": "…", "rev": "…", "diff": "diff --git a/articles/…" }
837
1407
  ```
838
1408
 
839
1409
  `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:
1410
+ first-run consent-card backend. Delegates to the same `setupVault()` the
1411
+ CLI's `mlx-bun memory init` calls (idempotent: create dirs, write README +
1412
+ Meta pages only if missing, git init + initial commit); omits the CLI's
1413
+ interactive extras. `path`, if given, must resolve under the default vault
1414
+ root or the OS temp directory, else `400`:
849
1415
 
850
1416
  ```jsonc
851
1417
  { "ok": true, "result": { "root": "…", "created": ["…"], "gitInitialized": true, "alreadySetUp": false }, "status": { "root": "…", "exists": true, "articleCount": 0, "referenceCount": 9, "isGitRepo": true, "recentArticles": [] } }
852
1418
  ```
853
1419
 
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.
1420
+ ## GET /v1/memory/synthesize
867
1421
 
868
- `GET /api/settings/tool-approvals`:
1422
+ Runs the memory synthesis pipeline (`runSynthesis`, `src/memory/pipeline.ts`
1423
+ — the same job `mlx-bun memory synthesize` runs) and streams its progress
1424
+ as SSE. `?dry=1` plans the stages without model calls or writes. This is a
1425
+ long GPU job over the whole store on a real run; it is registered under
1426
+ `/v1/` for historical reasons and is not part of the OpenAI surface.
869
1427
 
870
- ```jsonc
871
- { "ok": true, "alwaysAllow": ["bash", "edit"] }
872
1428
  ```
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"] }
1429
+ data: {"type":"stage","stage":"ingest","message":"…"}
1430
+ data: {"type":"log","message":"…"}
1431
+ data: {"type":"done","message":"synthesis done — …"}
1432
+ data: {"type":"summary","implemented":true,"stages":["ingest","segment","extract","route","create","section-route","patch","link","wikify"],"note":"…"}
1433
+ data: [DONE]
880
1434
  ```
881
1435
 
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).
1436
+ A failure emits `data: {"type":"error","message":"…"}` and closes without
1437
+ `[DONE]`.
886
1438
 
887
- ## GET /downloads
1439
+ ## Settings (`/api/settings/*`)
888
1440
 
889
- Snapshot of the last 5 model downloads (active, completed, or errored)
890
- initiated via `mlx-bun download` or the web library panel.
1441
+ `GET /api/settings/hf-token` → `{ "ok": true, "hasToken": bool }`;
1442
+ `POST /api/settings/hf-token` with `{"token": "hf_…"}` saves the Hugging
1443
+ Face write token to `~/.mlx-bun/hf.json` (mode 0600) →
1444
+ `{ "ok": true }`; `400 "token required"` when empty. The token drives the
1445
+ push routes above and the `upload` CLI command ([cli.md](./cli.md)).
1446
+
1447
+ `GET /api/settings/tool-approvals` / `DELETE /api/settings/tool-approvals`
1448
+ — the web chat's durable "always allow this tool" list for gated tools
1449
+ (`bash`/`edit`/`write`), backed by `~/.mlx-bun/tool-approvals.json`
1450
+ (`src/tool-approvals.ts`), keyed by stable tool NAME, not per-call
1451
+ arguments. These two routes are read/forget only: granting an always-allow
1452
+ happens through the approval card itself (the `approval` WS frame on
1453
+ `/ws/chat` with `alwaysAllow: true`), never a bare settings POST.
891
1454
 
892
1455
  ```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
- }
1456
+ // GET
1457
+ { "ok": true, "alwaysAllow": ["bash", "edit"] }
1458
+ // DELETE with body {"tool": "bash"} — idempotent; returns the updated set
1459
+ { "ok": true, "alwaysAllow": ["edit"] }
908
1460
  ```
909
1461
 
1462
+ `DELETE` without `tool` is `400 "tool required"`.
1463
+
910
1464
  ## Model Hub (`/api/hub/*`)
911
1465
 
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.
1466
+ The web chat's Model Hub panel: browse downloaded models, search Hugging
1467
+ Face, and kick off downloads — all loopback-served, no separate auth.
1468
+ Handlers live in `src/hub-rest.ts` (pure functions, no loaded-model
1469
+ dependency).
918
1470
 
919
1471
  `GET /api/hub/local` — every model in the local registry (same source
920
1472
  `/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:
1473
+ 8k context:
923
1474
 
924
1475
  ```jsonc
925
1476
  {
@@ -939,11 +1490,10 @@ Hub lacks:
939
1490
  ```
940
1491
 
941
1492
  `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:
1493
+ filtered to the `mlx` library tag so results are MLX-compatible by
1494
+ construction. Never downloads anything. `400` without `q`. Degrades on any
1495
+ network failure (DNS, timeout, non-2xx) to an explicit offline state
1496
+ rather than a 500:
947
1497
 
948
1498
  ```jsonc
949
1499
  { "ok": true, "offline": false, "results": [{ "id": "mlx-community/…", "downloads": 0, "likes": 0, "size_estimate": null }] }
@@ -953,69 +1503,51 @@ explicit offline state rather than a 500:
953
1503
  { "ok": true, "offline": true, "error": "…", "results": [] }
954
1504
  ```
955
1505
 
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`).
1506
+ `size_estimate` is always `null` — the search endpoint doesn't return
1507
+ per-repo file sizes; the fit badge lands once a model is actually
1508
+ downloaded (`/api/hub/local`).
959
1509
 
960
1510
  `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:
1511
+ background download via `downloadModel()` and returns immediately;
1512
+ progress is visible via [`GET /downloads`](#get-downloads). Refuses a
1513
+ duplicate kick-off for a repo that's already downloading:
965
1514
 
966
1515
  ```jsonc
967
1516
  { "ok": true, "repo": "org/name", "started": true }
968
1517
  ```
969
1518
 
970
1519
  ```jsonc
971
- { "ok": false, "error": "a download for org/name is already in progress" }
1520
+ { "ok": false, "error": "a download for org/name is already in progress" } // 409
972
1521
  ```
973
- (409)
974
1522
 
975
1523
  `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:
1524
+ `restart_required`, never performs a live in-process swap.** The process a
1525
+ web chat session is attached to has exactly one model loaded and no
1526
+ drop-weights-and-reload seam reachable from `/ws/chat` (the `--isolate`
1527
+ proxy's `ModelPool` is a real spawn-overlap swap, but it lives on the
1528
+ isolated `/v1/*` surface only — `src/serve/isolate.ts`). The honest answer
1529
+ is the restart command (HTTP 200, `ok: false`):
986
1530
 
987
1531
  ```jsonc
988
1532
  { "ok": false, "restart_required": true, "command": "mlx-bun serve org/name" }
989
1533
  ```
990
1534
 
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
1535
  ## GET /api/sessions/search · GET /api/sessions/export
996
1536
 
997
1537
  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):
1538
+ export. Same loopback-only, no-separate-auth posture as `/api/memory/*`.
1539
+ Handlers live in `src/serve/session-search.ts` (pure, read-only, no
1540
+ loaded-model dependency). Both routes read the same session directory the
1541
+ web chat's `PiWebSession` writes to (`~/.mlx-bun/sessions`, pi's own
1542
+ JSONL-per-session format) and are JSONL-tolerant — a corrupt or mid-write
1543
+ line is skipped, never thrown.
1544
+
1545
+ `GET /api/sessions/search?q=<query>` — case-insensitive substring scan (no
1546
+ index — personal-chat-corpus scale) over every session file's
1547
+ user/assistant message text, capped at 50 matches total (10 per session).
1548
+ Each match reports a plain-text ±60-char snippet plus the match's own
1549
+ `[start,end)` offsets **into that snippet** — offsets, not pre-rendered
1550
+ HTML, so the frontend escapes the snippet then inserts its own `<mark>`:
1019
1551
 
1020
1552
  ```jsonc
1021
1553
  {
@@ -1031,15 +1563,8 @@ highlighting instead):
1031
1563
  400s `{"ok": false, "error": "q is required"}` without a query.
1032
1564
 
1033
1565
  `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:
1566
+ parsed into an array of entries (one per line, in file order). `path` must
1567
+ resolve under the session directory:
1043
1568
 
1044
1569
  ```jsonc
1045
1570
  { "ok": true, "path": "…", "entries": [{ "type": "session", "id": "…", "cwd": "…" }, { "type": "message", "message": { "role": "user", "content": "…" } }] }
@@ -1050,153 +1575,118 @@ path inside the directory that doesn't exist on disk.
1050
1575
 
1051
1576
  ## Web app static routes + PWA installability
1052
1577
 
1053
- The web chat (`GET /`) and everything it loads same-origin, no CDN, ever:
1578
+ The web chat (`GET /`) and everything it loads same-origin, no CDN, ever
1579
+ (`src/serve/static-routes.ts`; GET only):
1054
1580
 
1055
1581
  | Route | Content | Notes |
1056
1582
  | --- | --- | --- |
1057
1583
  | `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.
1584
+ | `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. |
1585
+ | `GET /assets/hljs.js`, `GET /assets/hljs.css` | `src/web/vendor/hljs*` | Vendored syntax highlighting, no CDN. |
1586
+ | `GET /manifest.webmanifest` | `src/web/manifest.webmanifest` | PWA manifest. |
1587
+ | `GET /assets/icon.svg` | `src/web/icon.svg` | A single inline SVG app icon; no binary PNGs are shipped. |
1588
+ | `GET /sw.js` | `src/web/sw.js` | Service worker, served `cache-control: no-store` at the root scope. |
1589
+ | `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`. |
1590
+ | `GET /curve-terrain` | `src/serve/assets/curve-terrain.html` | Curve terrain visualization; `404` when the artifact isn't on disk. |
1591
+ | `GET /dag` | `src/serve/assets/training-inference-map.html` | Training/inference DAG map; `404` when the artifact isn't on disk. |
1592
+ | `GET /status`, `/chat`, `/quantize`, `/finetune`, `/dataset` | — | Legacy page paths: `302` to `/#<path>` (the SPA's hash routes). |
1593
+
1594
+ `/assets/*` and the manifest are served with `cache-control: public,
1595
+ max-age=3600`; `/sw.js`, `/curves`, `/curve-terrain`, `/dag` are `no-store`.
1596
+
1597
+ **PWA scope:** the manifest + service worker exist for *installability and
1598
+ an instant static-shell paint* — "Add to Home Screen" / a browser "Install
1599
+ app" prompt, and the next visit's HTML/JS/CSS loading from cache while the
1600
+ WebSocket connects. This is explicitly **not** offline chat:
1601
+ `src/web/sw.js`'s fetch handler only intercepts the exact shell files
1602
+ (`/`, `/assets/app.js`, `/assets/hljs.js`, `/assets/hljs.css`) and lets
1603
+ every dynamic route (`/api/*`, `/v1/*`, `/ws/chat`, `/downloads`, …) fall
1604
+ straight through to the network unconditionally. Registration is guarded
1605
+ to secure contexts (`https:` or `localhost`/`127.0.0.1`).
1606
+
1607
+ ## Curve Designer: POST /signal · POST /generate
1608
+
1609
+ Backends for the `/curves` page (HLG tone-curve sampling research tool,
1610
+ `src/lab/curve/curve-sampler.ts`). Both answer `OPTIONS` and send permissive CORS
1611
+ headers (`access-control-allow-origin: *`) so the page can be opened from
1612
+ a file or another origin. Both take the GPU lock (`gateway.runExclusive`)
1613
+ — they never interleave with a serving request.
1614
+
1615
+ `POST /signal` with `{"prompt": "…"}` — one prefill forward of the prompt
1616
+ rendered as a single user turn; bins the next-token log-prob distribution
1617
+ into 80 buckets over the curve's x-axis so the editor can draw the signal
1618
+ under the curve: `{ "bins": [80 ints], "vocab": V }`. `400 {"error":
1619
+ "invalid JSON"}`; `500 {"error": "signal failed: …"}`.
1620
+
1621
+ `POST /generate` with `{"prompt", "curve"?: {points: [...]}, "n"?: 3,
1622
+ "max_tokens"?: 80, "seed"?: 0, "default"?: false}` — samples `n` (1–8)
1623
+ completions of up to `max_tokens` (1–256) tokens. When `curve` has ≥2
1624
+ points and `default` is not `true`, the drawn log-prob transfer curve
1625
+ replaces temperature+softmax entirely; otherwise the model's default chat
1626
+ recipe (temperature/top-p/top-k) is used. A non-monotone curve is a `400`.
1096
1627
 
1097
1628
  ```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
- }
1629
+ { "mode": "curve" | "default",
1630
+ "recipe": { "temperature": 0.7, "topP": 0, "topK": 0 }, // only in default mode
1631
+ "n": 3, "seed": 0,
1632
+ "samples": [{ "text": "…", "junk": false }] } // junk = coherence heuristic tripped
1130
1633
  ```
1131
1634
 
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`):
1635
+ ## Web chat WebSocket (`/ws/chat`)
1636
+
1637
+ `GET /ws/chat` upgrades to a WebSocket (`426` when the upgrade header is
1638
+ missing; `501` under `--isolate`). It carries the web chat's pi
1639
+ `AgentSession` — the model is driven through pi, which calls back into
1640
+ this server's own loopback `/v1`. Frames are JSON objects with a `type`
1641
+ field; the exact TypeScript unions are `ClientMessage` and `ServerMessage`
1642
+ in `src/pi-web.ts` (the web frontend imports them type-only, so both ends
1643
+ are compile-checked).
1644
+
1645
+ Client → server: `prompt {text, images?: [{data, mimeType}]}`, `abort`,
1646
+ `approval {callId, decision, editedArgs?, alwaysAllow?}`, `set_thinking
1647
+ {enabled}`, `set_coding_tools {enabled}`, `set_adapter {id | null}`,
1648
+ `set_sampling {scope?: "session" | "next_turn", temperature?, top_p?,
1649
+ top_k?, min_p?, xtc_probability?, xtc_threshold?, repetition_penalty?,
1650
+ repetition_context_size?, presence_penalty?, frequency_penalty?, seed?}`
1651
+ (null = server default), `set_system_prompt {text | null}`, `new_session`,
1652
+ `list_sessions`, `open_session {path}`, `fork_session {path}`,
1653
+ `delete_session {path}`, `regenerate`, `edit_resend {text}`,
1654
+ `switch_sibling {entryId}`, `context {context}` (app-aware assistant, below).
1655
+
1656
+ Server → client: `ready {model, vision, audio, thinking, genDefaults}`,
1657
+ `turn_start`, `text_delta {delta}`, `thinking_delta {delta}`, `tool_start
1658
+ {callId, tool, args}`, `tool_approval_request {callId, tool, args}`,
1659
+ `tool_update {callId, chunk}`, `tool_end {callId, ok, result}`, `turn_end
1660
+ {lane?}` (the serving lane the turn ran on, from `usage.lane` via the lane
1661
+ registry — never guessed client-side), `queue_update {steering, followUp}`,
1662
+ `history {items}`, `sessions {items, activePath?}`, `siblings {entryId?,
1663
+ index, count, siblingIds}`, `context {tokens, contextWindow, percent}`
1664
+ (context-window usage — a different frame from the client→server
1665
+ `context`), `coding_tools {active, pending}`, `tool_approvals
1666
+ {alwaysAllow}`, `ui_navigate {route}`, `ui_spotlight {ref?, label?,
1667
+ selector?, target?, message?, route?}`, `error {message}`.
1668
+
1669
+ ### App-aware assistant
1670
+
1671
+ The web chat agent can see and act on the app it lives in. No screenshots
1672
+ and no vision model are involved: the browser sends a structured DOM
1673
+ snapshot as a WS frame, and the model gets three tools that
1674
+ read/navigate/highlight that snapshot. The three tools are always in the
1675
+ web chat's tool allowlist (read-only-on-the-machine, never gated by the
1676
+ approval card; `APP_AWARE_TOOL_NAMES` in `src/pi-web.ts`):
1184
1677
 
1185
1678
  - **`get_current_app_context`** — returns the last context the browser
1186
1679
  pushed: `{ route, view?, step?, snapshot }`. `snapshot` is a capped
1187
1680
  (~120) list of visible interactive elements as `{ref, label, kind,
1188
1681
  role?, selector, spotlightId?}`, agent chrome excluded.
1189
1682
  - **`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.
1683
+ catalog (`APP_ROUTE_IDS`: `chat`, `quantize`, `finetune`, `dataset`,
1684
+ `status`; unknown routes are a tool error, never sent to the browser)
1685
+ and emits a `ui_navigate` frame.
1686
+ - **`spotlight_ui({ ref | label | selector | target, route?, message? })`**
1687
+ — emits a `ui_spotlight` frame; the browser resolves the target (ref
1688
+ from its last snapshot, then live selector, then fuzzy label match, then
1689
+ a curated catalog id) and shows a brief non-blocking highlight.
1200
1690
 
1201
1691
  Client → server WS frame (pushed by the browser on every route change and
1202
1692
  every wizard-step change, never on a timer):
@@ -1214,10 +1704,8 @@ Server → client frames the two action tools produce:
1214
1704
 
1215
1705
  The server also auto-prepends a compact one-line ambient context (e.g.
1216
1706
  `[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.
1707
+ never a full snapshot dump. Approval-gated form-fill / job-start actions
1708
+ are not wired; only navigate/spotlight are.
1221
1709
 
1222
1710
  ## Client setup: pi
1223
1711
 
@@ -1258,3 +1746,30 @@ solve.
1258
1746
 
1259
1747
  Any OpenAI SDK works the same way: `baseURL: "http://127.0.0.1:8080/v1"`,
1260
1748
  any non-empty `apiKey`.
1749
+
1750
+ Under `--isolate`, the parent owns Responses continuation history and exposes its
1751
+ bounded store counters in `GET /engine` as `response_store`. Model-worker restart
1752
+ or eviction preserves that history; restarting the parent clears it. Direct
1753
+ serving retains the same process-local TTL and byte limits.
1754
+
1755
+
1756
+ Supported grouped TurboQuant drafting retains `usage.speculation`, seeded
1757
+ sampling, logprobs, structured output and generated RAM/SSD continuations.
1758
+ The cache codec does not select the scheduler or sampler implementation.
1759
+ Composition coverage: [batching design](../design/batching.md).
1760
+
1761
+ With paged KV enabled, supported Gemma4 bf16 text requests report the batched lane when submitted through continuous scheduling. Seeded sampling, logprobs and grammar use the same interfaces. These requests bypass prompt-cache lookup/publication; paging does not provide SSD persistence.
1762
+
1763
+ Ordinary adapter checkpoints are isolated by ordered adapter IDs and mounted
1764
+ content revisions. Base-model requests cannot restore or remove an adapter checkpoint.
1765
+ With generation checkpointing enabled, repeating an identical eligible request
1766
+ replays its saved completion prefix and resumes from the saved pending token.
1767
+ Cancellation retains queued snapshots; successful completion removes them.
1768
+ `POST /admin/cache/flush` drains both prompt spills and generation checkpoints,
1769
+ and its elapsed time and durability statistics include both queues. A hard
1770
+ process exit can lose snapshots that have not drained.
1771
+
1772
+ For resumed generation checkpoints, `usage.prompt_tokens_details.cached_tokens`
1773
+ counts only the reused original prompt, bounded by `usage.prompt_tokens`. Saved
1774
+ completion tokens are replayed as completion output and do not increase that
1775
+ prompt-cache usage counter.