mlx-bun 0.0.13 → 0.3.0

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