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
package/src/server.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { modelServingBinding } from "./backends/mlx/model-serving";
2
+ import type { ServingContext } from "./serve/model-host";
3
+ export { generationCheckpointKey } from "./serve/checkpoint-identity";
1
4
  // OpenAI-compatible HTTP server: /v1/chat/completions (+ SSE streaming)
2
5
  // and /v1/models. Phase 4 core — tool calling, vision, and the
3
6
  // byte-capped prompt cache land on top of this.
@@ -9,117 +12,95 @@
9
12
  // Generation is serialized through a single queue (one GPU, batch=1).
10
13
 
11
14
  import type { Server } from "bun";
12
- // Embedded web app — the unified SPA (chat / quantize / finetune /
13
- // dataset / status). `with { type: "text" }` inlines the file in both
14
- // `bun run` and the compiled single binary. bun-types types *.html
15
- // imports as HTMLBundle (the html loader), but the text attribute makes
16
- // the runtime value a string — hence the double cast.
17
- import appHtml from "./web/app.html" with { type: "text" };
18
- import curveDesignerHtml from "./assets/curve-designer.html" with { type: "text" };
19
- // Vendored, no-CDN static assets referenced by app.html (convention: any
20
- // self-contained JS/CSS too big to inline gets `with { type: "text" }`
21
- // imported here and served under /assets/<name>; see src/web/vendor/README
22
- // for how hljs.js was built). Add new vendored assets the same way.
23
- import hljsJs from "./web/vendor/hljs.js" with { type: "text" };
24
- import hljsCss from "./web/vendor/hljs-theme.css" with { type: "text" };
25
- // The frontend bundle (plan §7/§9 Phase 2 module split): GENERATED from
26
- // src/web/src/*.ts by `bun scripts/build-web.ts` — see that file's header
27
- // and tests/web-build.test.ts (the freshness gate). Same
28
- // with { type: "text" } + /assets/<name> pattern as the vendored assets
29
- // above; app.html's <script defer src="/assets/app.js"> loads it.
30
- import appJs from "./web/app.js" with { type: "text" };
31
- // PWA installability (plan §9 Phase 3, beat-matrix Axis 10): a manifest +
32
- // a single inline SVG icon (no binary PNGs — the hygiene gate forbids
33
- // tracked binary files; some browsers won't show an SVG app icon, which is
34
- // an accepted tradeoff, see docs/reference/features-matrix.md) + a shell-
35
- // only service worker. Same with { type: "text" } + /assets/<name>-shaped
36
- // pattern as the vendored assets above; see src/web/sw.js's header for why
37
- // it deliberately does NOT cache API/WS traffic.
38
- import manifestWebmanifest from "./web/manifest.webmanifest" with { type: "text" };
39
- import iconSvg from "./web/icon.svg" with { type: "text" };
40
- import swJs from "./web/sw.js" with { type: "text" };
41
- import pkgJson from "../package.json" with { type: "json" };
15
+ import { STATIC_ROUTE_ASSETS } from "./web-assets";
42
16
  import { readFileSync } from "node:fs";
43
- const APP_PAGE = appHtml as unknown as string;
44
- const HLJS_JS = hljsJs as unknown as string;
45
- const HLJS_CSS = hljsCss as unknown as string;
46
- const APP_JS = appJs as unknown as string;
47
- const MANIFEST_WEBMANIFEST = manifestWebmanifest as unknown as string;
48
- const ICON_SVG = iconSvg as unknown as string;
49
- const SW_JS = swJs as unknown as string;
50
- const pkgVersion = (pkgJson as { version: string }).version;
51
- import { loadModelConfig, type KvQuantSpec, type ModelConfig, type TurboQuantScheme } from "./config";
52
- import { Weights } from "./weights";
53
- import { Gemma4Model } from "./model/gemma4";
54
- import { DiffusionGemmaModel } from "./model/diffusion-gemma";
55
- import { spliceImageTokens } from "./vision/diffusion-vision";
56
- import {
57
- createModel,
58
- openGlm52RuntimeModel,
59
- type Glm52RuntimeOpenOptions,
60
- type RuntimeModel,
61
- } from "./model/factory";
62
- import { Glm52Model } from "./model/glm52";
17
+ import { type TurboQuantScheme } from "./config";
63
18
  import {
64
19
  GLM52_G5_ASPIRATIONAL_DECODE_TPS,
65
20
  GLM52_G5_DIRECT_ORACLE_WARM_DECODE_TPS,
66
21
  GLM52_G5_MEASURED_AT,
67
22
  GLM52_G5_MEASURED_WARM_DECODE_TPS,
68
- type Glm52MemoryPlan,
69
23
  } from "./model/glm52-memory";
70
- import { isGlm52Config, isMiniCPM5Config } from "./model/support";
71
- import { Glm52NativeMtpProvider } from "./spec/glm52-mtp-source";
72
24
  import {
73
- generate,
74
25
  type GenerateOptions,
75
- type TokenLogprobs,
76
- withModelUsageFlush,
77
- withModelWiredLimit,
78
26
  } from "./generate";
79
- import { cloneKvCaches, SpillQueue } from "./kv-store";
27
+ import { createPreparationExecutor } from "./serve/preparation";
28
+ import { cloneKvCaches, legacyCacheCodecs, SpillQueue } from "./kv-store";
29
+ import type { Cache } from "./model/gemma4";
30
+ import { resolveKvScheme } from "./kv-scheme";
31
+ import { runtimeValue } from "./runtime-config";
80
32
  import { TURBOQUANT_HEAD_DIMS } from "./mlx/turboquant-tables";
81
- import {
82
- compileGrammarRequest, grammarEnabled, type GrammarRequest,
83
- } from "./grammar";
84
33
  import type { HlgConfig } from "./sampler";
85
- import { isMonotone, CURVE_UMIN, type CurveParams } from "./curve-sampler";
86
- const CURVE_PAGE = curveDesignerHtml as unknown as string;
87
- import { GenerationGateway } from "./serve/generation-gateway";
88
- import { recordLane, type Lane } from "./serve/lane-registry";
34
+ import { isMonotone, CURVE_UMIN, type CurveParams } from "./lab/curve/curve-sampler";
35
+ import { GenerationGateway, disposeUnstartedRequest } from "./serve/generation-gateway";
36
+ import { createSessionCompletionEngine } from "./serve/session-completion-engine";
89
37
  import {
90
- ChatTemplate, type ChatMessage, type ToolDefinition,
91
- } from "./chat-template";
92
- import { loadTokenizer, type LoadedTokenizer } from "./tokenizer";
38
+ CompletionExecutor,
39
+ } from "./serve/completion-executor";
93
40
  import {
94
- CHANNEL_END, CHANNEL_START, parseGeneratedToolCalls, parseToolCalls,
95
- TOOL_CALL_END, TOOL_CALL_START,
96
- } from "./tool-call";
41
+ createPromptResponseTrace,
42
+ type PromptResponseTrace,
43
+ } from "./serve/prompt-response-trace";
44
+ import { handleAdminRoute } from "./serve/admin-routes";
45
+ import { handleAuxiliaryRoute } from "./serve/aux-routes";
46
+ import { createDiscoveryRoutes } from "./serve/discovery-routes";
47
+ import { handleLabRoute } from "./serve/lab-routes";
48
+ import { handleModelAdminRoute } from "./serve/model-admin-routes";
49
+ import { handleStaticRoute } from "./serve/static-routes";
50
+
97
51
  import { PromptCache, cacheBytes } from "./prompt-cache";
98
52
  import { SsdCacheStore } from "./ssd-cache";
53
+ import {
54
+ SsdDurabilityCoordinator,
55
+ type DurabilityFlushResult,
56
+ type DurabilitySnapshotStats,
57
+ } from "./ssd-durability";
99
58
  import { configFingerprint } from "./model/fingerprint";
100
59
  import {
101
- anthropicToChatBody, chatJsonToAnthropic, translateOpenAiSse,
60
+ anthropicToChatBody, chatJsonToAnthropic, createAnthropicStreamProtocol,
102
61
  type AnthropicRequest,
103
62
  } from "./anthropic";
104
63
  import {
105
- ResponseStore, chatJsonToResponses, outputItemsToInputItems,
106
- responsesToChatBody, translateOpenAiSseToResponses,
64
+ ResponseStore, chatJsonToResponses, resolveResponsesConversation,
65
+ responsesToChatBody, createResponsesStreamProtocol,
107
66
  type ResponsesRequest,
108
67
  } from "./responses";
109
- import { AdapterManager, listAvailableAdapters } from "./lora";
110
- import { embedMany, isEmbeddingModel } from "./embed";
111
68
  import { fit } from "./fit";
112
69
  import { setMemoryLimit } from "./mlx/ffi";
113
- import { VisionTower } from "./vision/embedder";
114
- import { SiglipVisionTower, parseSiglipConfig } from "./vision/siglip";
115
- import {
116
- buildMultimodalPrompt, buildVisionPrompt, extractAudio, extractImages,
117
- type AudioTokenIds, type VisionTokenIds, type VisionEncoder,
118
- } from "./vision/prompt";
119
- import { AudioTower, parseAudioConfig } from "./audio/conformer";
120
- import { ensureWav } from "./audio/transcode";
121
- import { sidecarShipsAudioTower } from "./registry";
122
70
  import { makePiWsHandler, type PiWsData } from "./pi-web";
71
+ import { ChatStage } from "./serve/chat-stage";
72
+ import { TextCompletionStage } from "./serve/text-completion-stage";
73
+ import { InferenceStage } from "./serve/inference-request";
74
+ import { admit, respondJson, respondStream, type ErrorFormatter } from "./serve/http";
75
+ import {
76
+ chatCompletionJson, chatCompletionStream, textCompletionJson, textCompletionStream,
77
+ } from "./serve/openai-wire";
78
+ import { createRequestPrep } from "./serve/request-prep";
79
+ import {
80
+ detectDraftKind,
81
+ loadContext,
82
+ type DraftKind,
83
+ type GenSamplingDefaults,
84
+ type LoadContextOptions,
85
+ type ServerContext,
86
+ } from "./serve/model-host";
87
+ import {
88
+ ChatRequest,
89
+ TextCompletionRequest,
90
+ type ChatRequestParams,
91
+ type TextCompletionParams,
92
+ } from "./serve/chat-request";
93
+
94
+ // The serving facade: createServer's companion loaders stay importable from
95
+ // here (public library API via src/index.ts; bench scripts; parity tests).
96
+ export {
97
+ detectDraftKind,
98
+ loadContext,
99
+ type DraftKind,
100
+ type GenSamplingDefaults,
101
+ type LoadContextOptions,
102
+ type ServerContext,
103
+ };
123
104
 
124
105
  export interface ServerOptions {
125
106
  /** Byte cap for the prompt (KV) cache. Default 8 GB. */
@@ -130,19 +111,17 @@ export interface ServerOptions {
130
111
  * evict; a request over the budget alone is rejected. Unset = no
131
112
  * aggregate cap (per-request admission via memoryBudget still applies). */
132
113
  kvBudgetBytes?: number;
133
- /** KV quantization override. When unset: apply ctx.kvConfig (mixed
134
- * per-layer) for serial serving, but bf16 under `--batch N` (the batched
135
- * engine is bf16-only — a mode switch, see `batch` below). "config" forces
136
- * the model's kv_config even under batching (those requests then route to
137
- * the serial path); "off" forces bf16; a number forces uniform bits
138
- * (group size 64, start 0) ignoring the config file. */
114
+ /** KV quantization override. Unset/"off" is bf16. "config" applies the
115
+ * model's declared mixed-precision kv_config; supported per-layer schemes
116
+ * compose with continuous scheduling. A number forces uniform bits
117
+ * (group size 64, start 0) and uses the preserved serial executor. */
139
118
  kvQuant?: "off" | "config" | number;
140
- /** TurboQuant scheme (docs/design/turboquant-kv.md): a separate axis from
119
+ /** TurboQuant scheme (docs/design/turboquant.md): a separate axis from
141
120
  * kvQuant above, mutually exclusive with it (`--kv-quant turbo[:k<bits>v
142
121
  * <bits>]` sets this instead of kvQuant). Solo-only in v1 — see
143
122
  * GenerationGateway's explicit refusal. */
144
123
  turboQuant?: TurboQuantScheme;
145
- /** OPTIONAL paged KV cache (`--paged-kv`, docs/design/paged-kv-cache.md):
124
+ /** OPTIONAL paged KV cache (`--paged-kv`, docs/design/kv-cache.md):
146
125
  * vLLM-style block-pool storage for full-attention layers,
147
126
  * gather-to-contiguous before the stock SDPA. Default off (unset = the
148
127
  * plain KVCache path, byte-identical). v1 scope: serial batch=1,
@@ -165,7 +144,7 @@ export interface ServerOptions {
165
144
  * warn before attaching to a server that may vanish. */
166
145
  owner?: "serve" | "pi-session" | "embedded";
167
146
  /** Listen on a UNIX DOMAIN SOCKET instead of a TCP port — the engine-
168
- * child mode of the isolation architecture (runtime-isolation.md): the
147
+ * child mode of the isolation architecture (docs/reference/server-config.md): the
169
148
  * parent process reverse-proxies HTTP to this socket. Stale socket
170
149
  * files are unlinked before bind. When set, `hostname`/port are ignored. */
171
150
  unixSocket?: string;
@@ -191,12 +170,9 @@ export interface ServerOptions {
191
170
  * mlx_lm.server's flag; its default there is 512 — ours stays 65,536 so
192
171
  * thinking traces never truncate. `--max-tokens 512` = mlx-lm behavior. */
193
172
  defaultMaxTokens?: number;
194
- /** Max concurrent requests batched through the mlx-lm-parity engine
195
- * (`--batch N`). Default 8 (continuous batching, B floats 1..N,
196
- * bit-parity with mlx-lm B=N); `--batch 1` pins the serialized
197
- * single-queue path — a mode switch, not a load-dependent
198
- * fallback. See docs/design/parallel-slots.md. NOTE: the batched executor
199
- * is mid-build; until it lands, >1 warns and runs serially. */
173
+ /** Continuous-scheduler concurrency cap (`--batch N`). Default 8; the
174
+ * scheduler specializes active B=1 and B=N with mlx-lm parity at the same
175
+ * composition. `--batch 1` pins the preserved strict serial executor. */
200
176
  batch?: number;
201
177
  /** HLG tone-curve sampling default (set via --hlg-sampling on + sub-knobs).
202
178
  * A per-request `hlg` object overrides it field-by-field. Off when unset. */
@@ -208,10 +184,10 @@ export interface ServerOptions {
208
184
  defaultAdapter?: string;
209
185
  /** SSD cold tier for the prompt/KV cache (`--ssd-cache <dir>`): prefix
210
186
  * KV survives RAM eviction AND server restarts — the coding-agent
211
- * long-context TTFT win (docs/design/ssd-kv-cold-tier.md). Off unless
187
+ * long-context TTFT win (docs/design/kv-cache.md). Off unless
212
188
  * a directory is given. Serial lane only (where the prompt cache lives). */
213
189
  ssdCacheDir?: string;
214
- /** Byte cap for the SSD tier (default 32 GiB). */
190
+ /** Optional byte cap for the SSD tier (default unbounded). */
215
191
  ssdCacheMaxBytes?: number;
216
192
  /** Idle-demotion threshold in seconds (`--ssd-demote-idle`): prompt-cache
217
193
  * entries unused this long spill to the SSD tier and free their GPU
@@ -221,1328 +197,119 @@ export interface ServerOptions {
221
197
  /** Verify every tensor hash on restore (`--ssd-cache-verify`) — reads all
222
198
  * bytes eagerly, defeating lazy fault-in; integrity paranoia only. */
223
199
  ssdCacheVerify?: boolean;
200
+ /** Periodically persist an in-flight serial generation every N emitted
201
+ * tokens. An identical request after restart replays the saved assistant
202
+ * prefix and continues from the already-sampled next token. Requires the
203
+ * SSD cache and --batch 1. */
204
+ generationCheckpointTokens?: number;
224
205
  }
225
206
 
226
- export interface ServerContext {
227
- model: RuntimeModel;
228
- tokenizer: LoadedTokenizer;
229
- template: ChatTemplate;
230
- modelId: string;
231
- /** Lazily-loaded vision tower cache — null until the first image request
232
- * (see `getVisionTower`). The tower (SigLIP ~hundreds of MB, encoder-free
233
- * smaller) is not loaded for text-only sessions. */
234
- vision: VisionEncoder | null;
235
- /** Loads + selects the vision tower on demand; null when the model has no
236
- * (supported) vision sidecar. Invoked at most once, then cached in
237
- * `vision`. */
238
- loadVision: (() => VisionEncoder) | null;
239
- visionTokenIds: VisionTokenIds;
240
- /** Lazily-loaded Conformer audio tower — null until the first audio
241
- * request (see `getAudioTower`). Same sidecar file as vision
242
- * (optiq_vision.safetensors), separate tower; text-only sessions never
243
- * pay for it. */
244
- audio: AudioTower | null;
245
- /** Loads the audio tower on demand; null when the model can't do audio
246
- * (no `audio_config` in config.json or no sidecar). No flags — audio
247
- * auto-enables exactly like vision. */
248
- loadAudio: (() => AudioTower) | null;
249
- /** null when the model has no `audio_config` (audio-incapable). */
250
- audioTokenIds: AudioTokenIds | null;
251
- adapters: AdapterManager;
252
- /** Per-layer KV quantization from the repo's kv_config.json (null if
253
- * absent). Applied by default — optiq serve's headline behavior;
254
- * ServerOptions.kvQuant overrides ("off" | uniform bits). */
255
- kvConfig: KvQuantSpec[] | null;
256
- /** Model-author recommended sampling from generation_config.json —
257
- * optiq serve injects these as server defaults (gen_config.py);
258
- * explicit request fields always win. */
259
- genDefaults: GenSamplingDefaults;
260
- /** Speculative decoding (`serve --draft-model`, mlx_lm.server parity).
261
- * Server-level: when set, EVERY request routes to the serial lane
262
- * (upstream: is_batchable = draft is None) and spec-eligible ones decode
263
- * through src/spec/serve-loop.ts. null = no draft configured. */
264
- draft?: {
265
- provider: import("./spec/source").DraftProvider;
266
- numDraftTokens: number;
267
- } | null;
268
- /** Present only for the direct Colibri GLM runtime. This is the exact
269
- * header-derived process equation used before opening resident state. */
270
- glmMemoryPlan?: Glm52MemoryPlan | null;
271
- }
272
-
273
- export interface LoadContextOptions {
274
- memoryBudgetBytes?: number;
275
- /** Direct Colibri runtime/resource overrides. Ignored by other models. */
276
- glm?: Glm52RuntimeOpenOptions;
277
- /** Snapshot dir of a draft model for speculative decoding
278
- * (`--draft-model`). Loaded alongside the target; the pair must share
279
- * a tokenizer family. */
280
- draftModelDir?: string;
281
- /** Drafts per round (`--num-draft-tokens`, mlx_lm.server default 3). */
282
- numDraftTokens?: number;
283
- /** Draft-provider kind override (`--draft-kind`). */
284
- draftKind?: DraftKind;
285
- ngramMax?: number;
286
- ngramMin?: number;
287
- }
288
-
289
- export interface GenSamplingDefaults {
290
- temperature?: number;
291
- topP?: number;
292
- topK?: number;
293
- repetitionPenalty?: number;
294
- }
295
-
296
- export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram" | "mtp";
297
-
298
- /** Detect the draft artifact's kind so the right provider is loaded. All
299
- * providers share ONE serve loop (src/spec/serve-loop.ts). Exported for the
300
- * bench harness (scripts/bench-feature-matrix.ts) — one detection, no drift.
301
- * "ngram" is never detected — it has no artifact (model-free prompt lookup,
302
- * src/spec/ngram-source.ts) and mounts via an explicit `--draft-kind ngram`. */
303
- export async function detectDraftKind(dir: string): Promise<DraftKind> {
304
- if (await Bun.file(`${dir}/dspark.json`).exists()) return "dspark"; // our trained module
305
- try {
306
- const cfg = (await Bun.file(`${dir}/config.json`).json()) as {
307
- model_type?: string;
308
- architectures?: string[];
309
- };
310
- // DeepSeek's released DSpark drafters (DeepSpec reference): no
311
- // dspark.json, plain HF config stamped Gemma4DSparkModel.
312
- if (cfg.architectures?.[0] === "Gemma4DSparkModel") return "deepspec";
313
- if (String(cfg.model_type ?? "").includes("assistant")) return "assistant";
314
- // Detect Qwen's companion head so loadContext can refuse it precisely.
315
- // The target's 48 recurrent DeltaNet caches cannot yet roll back rejected
316
- // verification rows, so treating this as a generic two-model draft would
317
- // fail later and obscure the actual unsupported composition.
318
- if (String(cfg.model_type ?? "").endsWith("_mtp")) return "mtp";
319
- } catch {
320
- // no/unreadable config → fall through to a full second model
321
- }
322
- return "two-model";
207
+ interface ServerLifecycle {
208
+ stopJobs: () => Promise<void>;
209
+ close: () => Promise<void>;
210
+ flush: () => Promise<DurabilityFlushResult>;
211
+ stats: () => DurabilitySnapshotStats;
212
+ stopTimers: () => void;
323
213
  }
324
214
 
325
- export const QWEN_MTP_UNSUPPORTED_REASON =
326
- "Qwen native MTP is not supported yet: its target has recurrent DeltaNet " +
327
- "caches that cannot roll back rejected verification tokens. Serve Qwen3.8 " +
328
- "without --draft-model; MTP remains a gated follow-up.";
329
-
330
- async function loadGenSamplingDefaults(modelDir: string): Promise<GenSamplingDefaults> {
331
- const file = Bun.file(`${modelDir}/generation_config.json`);
332
- if (!(await file.exists())) return {};
333
- try {
334
- const raw = (await file.json()) as Record<string, unknown>;
335
- const num = (v: unknown) => (typeof v === "number" ? v : undefined);
336
- return {
337
- temperature: num(raw.temperature),
338
- topP: num(raw.top_p),
339
- topK: num(raw.top_k),
340
- repetitionPenalty: num(raw.repetition_penalty),
341
- };
342
- } catch {
343
- return {};
344
- }
215
+ export interface ServerShutdownResult {
216
+ stopped: boolean;
217
+ timedOut: boolean;
218
+ durability: DurabilityFlushResult;
345
219
  }
346
220
 
347
- export async function loadContext(
348
- modelDir: string, modelId?: string,
349
- opts: LoadContextOptions = {},
350
- ): Promise<ServerContext> {
351
- const config = await loadModelConfig(modelDir);
352
- const glm = isGlm52Config(config);
353
- const externalDraft = opts.draftModelDir !== undefined || opts.draftKind !== undefined;
354
- // Refuse the known-impossible composition before opening the 20 GB target
355
- // or the companion head. The shared speculative loop requires target-cache
356
- // rollback after a partial reject; Qwen's DeltaNet state is recurrent and
357
- // deliberately non-trimmable. Silently degrading would make --draft-model
358
- // a lie, while forcing it would corrupt the next target step.
359
- const resolvedDraftKind = opts.draftModelDir
360
- ? opts.draftKind ?? await detectDraftKind(opts.draftModelDir)
361
- : opts.draftKind;
362
- if (resolvedDraftKind === "mtp") throw new Error(QWEN_MTP_UNSUPPORTED_REASON);
363
- if (glm && externalDraft && opts.glm?.enableMtp === true)
364
- throw new Error("native GLM MTP and --draft-model/--draft-kind are mutually exclusive");
365
- let weights: Weights | null = null;
366
- let model!: RuntimeModel;
367
- let glmMemoryPlan: Glm52MemoryPlan | null = null;
368
- if (glm) {
369
- const enableNativeMtp = !externalDraft && opts.glm?.enableMtp !== false;
370
- const opened = await openGlm52RuntimeModel(modelDir, {
371
- ...opts.glm,
372
- memoryBudgetBytes: opts.memoryBudgetBytes ?? opts.glm?.memoryBudgetBytes,
373
- // Every drafter owns the serial speculative lane. Do not reserve a
374
- // simultaneous ordinary batch that cannot be admitted while it is live.
375
- batchSize: enableNativeMtp || externalDraft ? 1 : opts.glm?.batchSize,
376
- // An explicit alternate drafter owns the one speculative lane.
377
- enableMtp: enableNativeMtp,
378
- });
379
- model = opened.model;
380
- glmMemoryPlan = opened.plan;
381
- } else {
382
- weights = await Weights.open(modelDir);
383
- }
384
- // memoryBudget enforcement at load (Phase 5): Weights.open only mmaps
385
- // (no GPU allocation yet), so a model whose weights can never serve
386
- // within the budget is refused HERE — before any unified-memory
387
- // commitment — with an actionable error instead of a Metal OOM later.
388
- if (!glm && opts.memoryBudgetBytes) {
389
- const weightsBytes = [...weights!.shards.files.values()]
390
- .reduce((a, f) => a + f.mmap.size, 0);
391
- const report = fit(config, weightsBytes, 1, undefined, undefined, 0, opts.memoryBudgetBytes);
392
- if (report.maxSafeContext < 1)
393
- throw new Error(
394
- `model does not fit the memory budget: weights ${(weightsBytes / 1e9).toFixed(2)} GB ` +
395
- `+ prefill transient leave no room for any context within ` +
396
- `${(opts.memoryBudgetBytes / 1e9).toFixed(2)} GB`,
397
- );
398
- }
399
- // generated-specialization dispatch by config fingerprint (Phase C);
400
- // unmatched configs run the monolith — slow, never broken
401
- if (!glm) model = createModel(weights!, config);
402
- const tokenizer = await loadTokenizer(modelDir);
403
- // Generation must stop on the tokenizer's eos_token — the chat turn
404
- // terminator (e.g. Qwen <|im_end|> = 248046). Some configs (Qwen3.5-4B)
405
- // declare a different eos_token_id in config.json than the chat format
406
- // emits, so without this a turn never ends and generation runs away,
407
- // hallucinating both sides of the dialogue until max_tokens. mlx-lm stops on
408
- // the tokenizer eos; union it in. No-op when already present (Gemma, 27B).
409
- if (tokenizer.eosTokenId != null && !config.eosTokenIds.includes(tokenizer.eosTokenId))
410
- config.eosTokenIds = [...config.eosTokenIds, tokenizer.eosTokenId];
411
-
412
- // Speculative decoding: load the draft (mlx_lm.server --draft-model). The
413
- // draft artifact's KIND selects the provider — all three share ONE serve
414
- // loop (src/spec/serve-loop.ts): dspark.json → DSpark (KV-injected), a
415
- // *_assistant config → the optiq KV-borrowing Gemma drafter, otherwise a
416
- // full second model (mlx-lm parity). `--draft-kind` overrides the detect.
417
- let draft: ServerContext["draft"] = null;
418
- if (opts.draftKind === "ngram") {
419
- // Model-free prompt lookup: no artifact, no dir, no probe/budget concerns
420
- // (weightsBytes 0, open() never throws). Default γ=10 per the reference
421
- // implementation — drafting is free, so wide blocks cost only verify-window
422
- // width when wrong.
423
- if (opts.draftModelDir)
424
- throw new Error(
425
- "--draft-kind ngram is model-free — drop --draft-model (it would be ignored)",
426
- );
427
- const { NgramProvider } = await import("./spec/ngram-source");
428
- draft = {
429
- provider: new NgramProvider({ max: opts.ngramMax, min: opts.ngramMin }),
430
- numDraftTokens: Math.max(1, opts.numDraftTokens ?? 10),
431
- };
432
- } else if (opts.draftModelDir) {
433
- const dir = opts.draftModelDir;
434
- const kind = resolvedDraftKind!;
435
- let provider: import("./spec/source").DraftProvider;
436
- let numDraftTokens = Math.max(1, opts.numDraftTokens ?? 3);
437
- if (kind === "dspark") {
438
- const { DflashProvider } = await import("./spec/dflash-source");
439
- const p = await DflashProvider.load(dir);
440
- provider = p;
441
- // Pin to the trained block width — the serve loop must never ask for
442
- // more positions than the DSpark block was trained for (n ≤ cfg.gamma).
443
- numDraftTokens = Math.max(1, Math.min(opts.numDraftTokens ?? p.gamma, p.gamma));
444
- } else if (kind === "deepspec") {
445
- const { DeepspecProvider } = await import("./spec/deepspec-source");
446
- const p = await DeepspecProvider.load(dir);
447
- provider = p;
448
- // Same pin, from their config's block_size (e.g. 7 for the released
449
- // dspark_gemma4_12b_block7).
450
- numDraftTokens = Math.max(1, Math.min(opts.numDraftTokens ?? p.gamma, p.gamma));
451
- } else if (kind === "assistant") {
452
- const { AssistantProvider } = await import("./spec/assistant-source");
453
- provider = await AssistantProvider.load(dir);
454
- } else {
455
- const { TwoModelProvider } = await import("./spec/two-model");
456
- provider = await TwoModelProvider.load(dir, config.text.vocabSize);
457
- // Tokenizer-family hard check — two-model ONLY (it ships its own
458
- // tokenizer). Exact-token-match acceptance is meaningful only when both
459
- // models tokenize identically; a probe that ENCODES differently means
460
- // different families — refuse instead of silently accepting ~0% of
461
- // drafts. The KV-borrowing drafters share the target's tokenization by
462
- // construction, so this check does not apply to them.
463
- const draftTok = await loadTokenizer(dir);
464
- const probe = "The 3 quick brown foxes jumped över the lazy dog?! 🦊";
465
- if (JSON.stringify(tokenizer.encode(probe)) !== JSON.stringify(draftTok.encode(probe))) {
466
- provider.dispose();
467
- throw new Error(
468
- `--draft-model tokenizer differs from the target's (probe string encodes ` +
469
- `differently) — speculation needs the same tokenizer family`,
470
- );
471
- }
472
- }
473
- // Fail-fast pairing validation (2026-07-07 review): the KV-borrowing
474
- // sources validate the (target, drafter) pairing in open() — non-Gemma4
475
- // target, DeepSpec target-layer-count mismatch — which used to surface
476
- // as a 500 from inside specServeRun on EVERY text request. Probe-open
477
- // once with throwaway caches here so a mismatch refuses at load; open()
478
- // allocates no per-request tensors before prefill/draft, so this is
479
- // free. The probe sampler is never called during open().
480
- {
481
- const probeCaches = model.makeCache();
482
- try {
483
- provider
484
- .open({
485
- sampler: () => { throw new Error("probe sampler never samples"); },
486
- target: { model, caches: probeCaches },
487
- })
488
- .dispose();
489
- } catch (err) {
490
- provider.dispose();
491
- throw new Error(
492
- `--draft-model is incompatible with this target: ${err instanceof Error ? err.message : String(err)}`,
493
- );
494
- } finally {
495
- for (const c of probeCaches) c.dispose();
496
- }
497
- }
498
- if (opts.memoryBudgetBytes) {
499
- const targetBytes = weights
500
- ? [...weights.shards.files.values()].reduce((a, f) => a + f.mmap.size, 0)
501
- : model.weightsBytes;
502
- // Draft weights shrink the target's envelope. Draft KV is not modeled
503
- // (small relative to its weights at serve contexts); admission stays
504
- // approximately conservative via the combined-weights term.
505
- const report = fit(config, targetBytes + provider.weightsBytes, 1, undefined, undefined, 0, opts.memoryBudgetBytes);
506
- if (report.maxSafeContext < 1) {
507
- provider.dispose();
508
- throw new Error(
509
- `target + draft do not fit the memory budget (draft adds ` +
510
- `${(provider.weightsBytes / 1e9).toFixed(2)} GB)`,
511
- );
512
- }
513
- }
514
- draft = { provider, numDraftTokens };
515
- } else if (opts.draftKind) {
516
- // Every other kind names an artifact to load — refuse instead of silently
517
- // serving without speculation.
518
- throw new Error(`--draft-kind ${opts.draftKind} requires --draft-model`);
519
- }
520
-
521
- // GLM's checkpoint-native MTP row is the production default. It uses the
522
- // already-planned bounded auxiliary expert tier and the same tokenizer, so
523
- // there is no second artifact or compatibility probe to load.
524
- if (!draft && model instanceof Glm52Model && glmMemoryPlan?.enableMtp) {
525
- draft = {
526
- provider: new Glm52NativeMtpProvider(model),
527
- numDraftTokens: glmMemoryPlan.mtpDraftTokens,
528
- };
529
- }
221
+ const serverLifecycles = new WeakMap<Server<unknown>, ServerLifecycle>();
530
222
 
223
+ function emptyDurabilityResult(): DurabilityFlushResult {
531
224
  return {
532
- draft,
533
- model,
534
- glmMemoryPlan,
535
- adapters: new AdapterManager(model),
536
- kvConfig: config.kvQuant,
537
- genDefaults: await loadGenSamplingDefaults(modelDir),
538
- tokenizer,
539
- template: await ChatTemplate.load(modelDir),
540
- modelId: modelId ?? modelDir.split("/").filter(Boolean).at(-1)!,
541
- // Vision is loaded lazily (getVisionTower) — text-only sessions never
542
- // pay for the tower. The loader picks the encoder-free gemma4_unified
543
- // (12B) tower vs the SigLIP encoder (e2b/e4b/26B/31B) by the sidecar's
544
- // vision_config.model_type. Vision sidecars are a Gemma4 feature;
545
- // MiniCPM5 never ships one.
546
- vision: null,
547
- loadVision: makeVisionLoader(modelDir, model, config),
548
- visionTokenIds: {
549
- imageTokenId: (config.raw.image_token_id as number) ?? 258880,
550
- boiTokenId: (config.raw.boi_token_id as number) ?? 255999,
551
- eoiTokenId: (config.raw.eoi_token_id as number) ?? 258882,
552
- },
553
- // Audio mirrors vision: lazy tower from the same sidecar, loaded on the
554
- // first audio request only (docs/design/audio-input-plan.md A4).
555
- audio: null,
556
- loadAudio: makeAudioLoader(modelDir, model, config),
557
- audioTokenIds: config.raw.audio_config
558
- ? {
559
- audioTokenId: (config.raw.audio_token_id as number) ?? 258881,
560
- boaTokenId: (config.raw.boa_token_id as number) ?? 256000,
561
- eoaTokenId: (config.raw.eoa_token_id as number) ?? 258883,
562
- }
563
- : null,
225
+ durable: true,
226
+ flushedSnapshots: 0,
227
+ missingSnapshots: 0,
228
+ pendingSnapshots: 0,
229
+ pendingSpills: 0,
230
+ pendingSpillBytes: 0,
231
+ droppedSpills: 0,
232
+ failedSpills: 0,
233
+ elapsedMs: 0,
564
234
  };
565
235
  }
566
236
 
567
- /** Build the on-demand vision-tower loader, selecting the encoder-free
568
- * (gemma4_unified, 12B) tower vs the SigLIP encoder (gemma4_vision:
569
- * e2b/e4b/26B/31B) by the sidecar's vision_config.model_type. Returns null
570
- * when the model has no usable vision sidecar. */
571
- function makeVisionLoader(
572
- modelDir: string, model: RuntimeModel, config: ModelConfig,
573
- ): (() => VisionEncoder) | null {
574
- if (!(config.hasVisionSidecar && model instanceof Gemma4Model)) return null;
575
- const vc = config.raw.vision_config as Record<string, any> | undefined;
576
- if (vc?.model_type === "gemma4_vision") {
577
- const sigCfg = parseSiglipConfig(vc);
578
- return () => SiglipVisionTower.load(modelDir, sigCfg, model.embedScale);
579
- }
580
- // gemma4_unified_vision (or unlabelled): the encoder-free patch embedder.
581
- return () => VisionTower.load(modelDir, model.embedScale, config.text.rmsNormEps);
582
- }
583
-
584
- /** Lazily load + cache the vision tower on first use. A sidecar that fails
585
- * to load is a capability gap, not a fatal error: returns null and the
586
- * request is answered with a 400 (the loader is cleared so we don't retry
587
- * a known-bad load every request). */
588
- function getVisionTower(ctx: ServerContext): VisionEncoder | null {
589
- if (ctx.vision) return ctx.vision;
590
- if (!ctx.loadVision) return null;
591
- try {
592
- ctx.vision = ctx.loadVision();
593
- return ctx.vision;
594
- } catch (e) {
595
- console.warn(`vision sidecar not loadable (${(e as Error).message}) — serving text-only`);
596
- ctx.loadVision = null;
597
- return null;
598
- }
599
- }
600
-
601
- /** Build the on-demand audio-tower loader (gemma-4 Conformer, A4 of
602
- * docs/design/audio-input-plan.md). Auto-enables — no flags — when
603
- * config.json carries an `audio_config` AND the optiq_vision.safetensors
604
- * sidecar exists (the audio tensors ship in the same sidecar as vision).
605
- * Returns null when the model can't do audio (no audio_config: 26B-A4B,
606
- * DiffusionGemma, bf16 assistants — architectural, not a porting gap). */
607
- function makeAudioLoader(
608
- modelDir: string, model: RuntimeModel, config: ModelConfig,
609
- ): (() => AudioTower) | null {
610
- if (!(config.hasVisionSidecar && config.raw.audio_config && model instanceof Gemma4Model))
611
- return null;
612
- // The sidecar header must actually name the Conformer tensors: the local
613
- // 12B snapshot pairs audio_config with a STUB sidecar (embed_audio only),
614
- // and a non-null loader here is what advertises `audio: true` on every
615
- // capability surface (ws handshake, /v1/models). Header-only read.
616
- if (!sidecarShipsAudioTower(`${modelDir}/optiq_vision.safetensors`)) return null;
617
- const audioCfg = parseAudioConfig(config.raw.audio_config as Record<string, any>);
618
- return () => AudioTower.load(modelDir, audioCfg, model.embedScale);
237
+ /** Flush prompt-cache snapshots without stopping the HTTP server. */
238
+ export function flushServerCacheDurability(
239
+ server: Server<unknown>,
240
+ ): Promise<DurabilityFlushResult> {
241
+ return serverLifecycles.get(server)?.flush() ?? Promise.resolve(emptyDurabilityResult());
619
242
  }
620
243
 
621
- /** Lazily load + cache the audio tower on first use. Unlike vision's
622
- * warn-and-continue (text-only degrade is correct for requests WITHOUT
623
- * images), this is only ever consulted for requests WITH audio — the
624
- * caller turns null into an explicit 400, never a silent text-only
625
- * degrade. A failed load is not retried every request. (The stub-sidecar
626
- * case — the local 12B state — never gets here: makeAudioLoader checks the
627
- * sidecar header and returns a null loader.) */
628
- function getAudioTower(ctx: ServerContext): AudioTower | null {
629
- if (ctx.audio) return ctx.audio;
630
- if (!ctx.loadAudio) return null;
244
+ /** Stop admission, flush cache durability, then finish the Bun server. */
245
+ export async function shutdownServer(
246
+ server: Server<unknown>, timeoutMs = 120_000,
247
+ ): Promise<ServerShutdownResult> {
248
+ const lifecycle = serverLifecycles.get(server);
249
+ lifecycle?.stopTimers();
250
+ const stopped = Promise.resolve(server.stop(false));
251
+ const work = (async (): Promise<ServerShutdownResult> => {
252
+ await lifecycle?.stopJobs();
253
+ await stopped;
254
+ const durability = lifecycle ? await lifecycle.flush() : emptyDurabilityResult();
255
+ await lifecycle?.close();
256
+ return { stopped: true, timedOut: false, durability };
257
+ })();
258
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
259
+ const timeout = new Promise<ServerShutdownResult>((resolve) => {
260
+ timeoutHandle = setTimeout(() => {
261
+ const stats = lifecycle?.stats();
262
+ resolve({
263
+ stopped: false,
264
+ timedOut: true,
265
+ durability: {
266
+ ...emptyDurabilityResult(),
267
+ durable: false,
268
+ ...(stats ?? {}),
269
+ elapsedMs: timeoutMs,
270
+ },
271
+ });
272
+ }, timeoutMs);
273
+ timeoutHandle.unref?.();
274
+ });
631
275
  try {
632
- ctx.audio = ctx.loadAudio();
633
- return ctx.audio;
634
- } catch (e) {
635
- console.warn(`audio sidecar not loadable (${(e as Error).message})`);
636
- ctx.loadAudio = null;
637
- return null;
276
+ return await Promise.race([work, timeout]);
277
+ } finally {
278
+ if (timeoutHandle) clearTimeout(timeoutHandle);
638
279
  }
639
280
  }
640
281
 
641
- interface ChatRequest {
642
- messages: ChatMessage[];
643
- stream?: boolean;
644
- max_tokens?: number;
645
- max_completion_tokens?: number;
646
- temperature?: number;
647
- top_p?: number;
648
- top_k?: number;
649
- seed?: number;
650
- repetition_penalty?: number;
651
- /** mlx-lm extension: recent-token window for repetition_penalty
652
- * (default 20; 0 = whole history, Python `[-0:]` semantics). */
653
- repetition_context_size?: number;
654
- /** min-p sampling (mlx_lm.server's `min_p`): keep tokens whose probability
655
- * is ≥ min_p · p(top token). 0 = off. */
656
- min_p?: number;
657
- /** XTC sampling (mlx_lm.server names): with probability `xtc_probability`
658
- * per step, remove every token above `xtc_threshold` except the least
659
- * likely of them. EOS + the newline token are always exempt (the server
660
- * injects them as xtc special tokens, matching mlx_lm.server). */
661
- xtc_probability?: number;
662
- xtc_threshold?: number;
663
- /** OpenAI logit_bias: {tokenId: additive bias}. JSON object keys arrive as
664
- * strings; coerced to int keys / float values like mlx-lm (400 on failure). */
665
- logit_bias?: Record<string, number>;
666
- /** OpenAI presence/frequency penalties + mlx-lm's context-size extensions
667
- * (window of recent tokens the penalty looks at; default 20). */
668
- presence_penalty?: number;
669
- presence_context_size?: number;
670
- frequency_penalty?: number;
671
- frequency_context_size?: number;
672
- /** mlx_lm.server logprobs: `logprobs` is a BOOL (even on /v1/completions —
673
- * not OpenAI's legacy int), `top_logprobs` an int in [0, 11] or the -1
674
- * "unset" sentinel (server.py validates exactly that; OpenAI's cap is 20,
675
- * mlx-lm's is 11 — we copy the reference). Non-stream responses carry
676
- * mlx-lm's logprobs block; stream chunks never do (reference behavior). */
677
- logprobs?: boolean;
678
- top_logprobs?: number;
679
- /** OpenAI stop sequences: plain string or array (spec allows up to 4).
680
- * Matched on DECODED text, not token ids — see StopMatcher. */
681
- stop?: string | string[];
682
- tools?: ToolDefinition[];
683
- tool_choice?: "auto" | "none" | { type: string; function?: { name: string } };
684
- /** Forwarded to HF chat templates, matching optiq serve. MiniCPM5 uses
685
- * enable_thinking to select direct answers vs the <think> channel; Qwen3.8
686
- * adds preserve_thinking (keep think blocks from history; template default
687
- * true — better prompt-cache reuse in agent loops). */
688
- chat_template_kwargs?: {
689
- enable_thinking?: boolean;
690
- preserve_thinking?: boolean;
691
- [key: string]: unknown;
692
- };
693
- /** OpenAI reasoning control. For models with a switchable <think> channel
694
- * (Qwen3.5/MiniCPM5) it gates enable_thinking: "none" → off, any level → on.
695
- * This is what Pi sends when the provider advertises reasoning. For
696
- * templates that read a reasoning-depth variable (Qwen3.8: xhigh/medium/
697
- * low), the level ALSO maps into the template via qwenReasoningEffort —
698
- * "xhigh" accepted as a first-class value (Qwen3.8's default/deepest). */
699
- reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
700
- /** Mounted LoRA adapter selection: "id", "a+b" (stacked), or "none". */
701
- adapter?: string;
702
- /** HLG tone-curve sampling override (per request). Snake_case wire fields,
703
- * merged over the server's --hlg-sampling config. docs/design/hlg-sampling.md. */
704
- hlg?: {
705
- enabled?: boolean;
706
- width?: number;
707
- shoulder?: number;
708
- toe?: number;
709
- pivot_offset?: number;
710
- };
711
- /** OpenAI structured output: {type:"json_object"} | {type:"json_schema",
712
- * json_schema:{name,schema,strict?}} | {type:"text"}. Enforced at the
713
- * sampler via xgrammar token-bitmasks (src/grammar.ts). L2-class (oMLX
714
- * oracle). On compile failure, degrades to a system-prompt injection +
715
- * Warning header (oMLX parity), never 500. */
716
- response_format?: unknown;
717
- /** vLLM/oMLX grammar aliases (all compiled via xgrammar): raw EBNF/LARK
718
- * grammar, regex, enum choice, and a bare JSON-schema object. Precedence
719
- * (guided_grammar > json_schema > json_object > structured_outputs >
720
- * guided_regex > guided_choice) mirrors oMLX _effective_guided_grammar. */
721
- guided_grammar?: string;
722
- guided_regex?: string;
723
- guided_choice?: string[];
724
- structured_outputs?: unknown;
725
- }
726
-
727
- /** POST /v1/completions body (mlx_lm.server's raw text completion — no chat
728
- * template). Sampling/penalty/stop fields are the same names as ChatRequest;
729
- * `prompt` replaces `messages`. mlx_lm.server accepts only a STRING prompt
730
- * (it calls `tokenizer.encode(request.prompt)` directly) — token-array
731
- * prompts are rejected there too, so we match. No `echo` (mlx-lm has none). */
732
- type TextCompletionRequest = Omit<ChatRequest, "messages" | "tools" | "tool_choice"> & {
733
- prompt?: unknown;
734
- };
735
282
 
736
283
  export interface ContextAdmissionDecision {
737
284
  /** Effective completion ceiling after admission. */
738
285
  maxTokens: number;
739
- /** True when a fixed-context runtime reduced the client's upper bound. */
286
+ /** True when admission reduced the client's upper bound to fit. */
740
287
  clamped: boolean;
741
288
  }
742
289
 
743
290
  /** Resolve a request's completion upper bound against the admitted context.
744
291
  *
745
- * Generic memory-budget serving preserves the established fail-fast contract:
746
- * an oversized reservation is rejected. A fixed-context runtime such as GLM
747
- * may instead clamp the client's upper bound to the remaining planned context.
748
- * `max_tokens` is a ceiling, not a promise that every token will be emitted;
749
- * clamping keeps clients that send a broad family-wide default usable without
750
- * weakening the prompt/context OOM guard. A prompt that leaves no generation
751
- * slot is always rejected. */
292
+ * `max_tokens` is a ceiling, not a promise that every token will be emitted:
293
+ * when a prompt fits but a broad client-wide `max_tokens` would push the
294
+ * reservation past the safe context, the bound is CLAMPED to the remaining
295
+ * room instead of rejecting an otherwise valid request (the pre-v0.0.13
296
+ * behavior 400'd a prompt with thousands of tokens of generation room over a
297
+ * 17-token overshoot). This never weakens the OOM guard — generation stops at
298
+ * the clamped ceiling, inside the admitted context. Only a prompt that leaves
299
+ * no generation slot at all is rejected. */
752
300
  export function admitRequestContext(
753
301
  promptTokens: number,
754
302
  requestedMaxTokens: number,
755
303
  maxSafeContext: number,
756
- clampToFit: boolean,
757
304
  ): ContextAdmissionDecision | null {
758
305
  const available = maxSafeContext - promptTokens;
759
306
  if (available < 1) return null;
760
307
  if (requestedMaxTokens <= available)
761
308
  return { maxTokens: requestedMaxTokens, clamped: false };
762
- if (!clampToFit) return null;
763
309
  return { maxTokens: available, clamped: true };
764
310
  }
765
311
 
766
- /** Per-field default HLG knobs when enabling without specifying them. */
767
- const HLG_DEFAULTS = { width: 4, shoulder: 4, toe: 6, pivotOffset: 6 } as const;
768
-
769
- /** Resolve the effective HLG config: a per-request `hlg` object overrides the
770
- * server's --hlg-sampling default field-by-field. Returns undefined (HLG off)
771
- * unless enabled by the request or the server. */
772
- function resolveHlg(
773
- reqHlg: ChatRequest["hlg"],
774
- serverHlg: HlgConfig | undefined,
775
- ): HlgConfig | undefined {
776
- const enabled = reqHlg?.enabled ?? serverHlg?.enabled ?? false;
777
- if (!enabled) return undefined;
778
- const base = serverHlg ?? HLG_DEFAULTS;
779
- return {
780
- enabled: true,
781
- width: reqHlg?.width ?? base.width,
782
- shoulder: reqHlg?.shoulder ?? base.shoulder,
783
- toe: reqHlg?.toe ?? base.toe,
784
- pivotOffset: reqHlg?.pivot_offset ?? base.pivotOffset,
785
- pivot: "top",
786
- };
787
- }
788
-
789
- interface OpenAIToolCall {
790
- id: string;
791
- type: "function";
792
- function: { name: string; arguments: string };
793
- }
794
-
795
- export type ToolStreamMode = "gemma-sentinel" | "plain" | "buffered-text";
796
-
797
- /** Pick the stream router for a model family. The token-id sentinel router is
798
- * Gemma-4-ONLY: ids 48/49 (<|tool_call>/<tool_call|>) and 100/101
799
- * (<|channel>/<channel|>) are special tokens of that tokenizer family
800
- * (src/tool-call.ts). On every other tokenizer — MiniCPM5, Qwen3/3.5, and
801
- * the Tier-0 generics (llama, qwen2, phi3, …) — those ids are ordinary
802
- * low-id vocab, so the sentinel router would silently swallow output into a
803
- * phantom tool/reasoning segment. Everyone else parses tool calls from
804
- * decoded text (buffered-text; parseGeneratedToolCalls covers the
805
- * OpenAI-JSON <tool_call>, Qwen <function=…>, and MiniCPM5 <function name=…>
806
- * shapes) when tools are present, and streams plain otherwise. Models whose
807
- * markup isn't covered fail soft: the markup stays in content. */
808
- export function selectToolStreamMode(modelType: string, hasTools: boolean): ToolStreamMode {
809
- if (modelType.startsWith("gemma4")) return "gemma-sentinel";
810
- return hasTools ? "buffered-text" : "plain";
811
- }
812
-
813
- /** Routes generated tokens. Gemma uses family-specific sentinel token ids;
814
- * MiniCPM5 and other text-template models use decoded-text parsing so
815
- * ordinary tokenizer ids like "<" are never swallowed globally. Exported for
816
- * unit tests (gemma-channel reasoning split). */
817
- export class ToolAwareStream {
818
- readonly #decoder: StreamDecoder;
819
- #inTool = false;
820
- #toolTokens: number[] = [];
821
- #text = "";
822
- /** Chars of #text already returned as content. */
823
- #sent = 0;
824
- /** Index where tool markup starts; content emission stops there. */
825
- #frozen = -1;
826
- #textToolCalls: OpenAIToolCall[] | null = null;
827
- #textToolParseFailed = false;
828
- readonly toolSegments: number[][] = [];
829
-
830
- /** Gemma reasoning-channel state (gemma-sentinel mode). The model wraps
831
- * chain-of-thought as `<|channel>thought\n…<channel|>` using special tokens
832
- * 100/101 that the content decoder strips, so reasoning is captured here at
833
- * the token level. A SEPARATE decoder keeps the reasoning byte-stream's
834
- * incremental state independent of content's. The `thought` channel-name
835
- * word is stripped before the reasoning text (mlx-lm's think-start marker
836
- * is `<|channel>thought`; the "\n" after it is reasoning content). */
837
- readonly #channelDecoder: StreamDecoder;
838
- #inChannel = false;
839
- #channelNamePending = "";
840
- #channelNameDone = false;
841
- #reasoning = "";
842
-
843
- /** Decoded-text markers that open tool markup (oracle: the streaming
844
- * parser buffers from `<tool_call`/`<function` on, never the whole
845
- * response — content before a tool call still streams live). */
846
- static readonly TOOL_MARKERS = ["<tool_call>", "<function"];
847
-
848
- constructor(
849
- readonly tokenizer: LoadedTokenizer,
850
- readonly mode: ToolStreamMode,
851
- readonly tools: ToolDefinition[] | null,
852
- ) {
853
- this.#decoder = new StreamDecoder(tokenizer, mode !== "buffered-text");
854
- this.#channelDecoder = new StreamDecoder(tokenizer, true);
855
- }
856
-
857
- /** Feed decoded channel text, stripping the leading `thought` channel-name
858
- * word (which may arrive across tokens). Returns the reasoning delta.
859
- * mlx-lm parity: its think-start marker is exactly `<|channel>thought`
860
- * (tokenizer_utils.py `_infer_thinking`) and only the MARKER tokens get
861
- * their text blanked (server.py `_process_control_tokens`) — the "\n"
862
- * after the name is an ordinary generated token, so it is the FIRST byte
863
- * of the reasoning stream and must be kept, not swallowed with the name. */
864
- #feedChannel(text: string): string {
865
- if (this.#channelNameDone) return text;
866
- this.#channelNamePending += text;
867
- const nl = this.#channelNamePending.indexOf("\n");
868
- if (nl === -1) return ""; // still inside the channel-name word
869
- this.#channelNameDone = true;
870
- const rest = this.#channelNamePending.slice(nl); // keep the "\n" (mlx-lm does)
871
- this.#channelNamePending = "";
872
- return rest;
873
- }
874
-
875
- /** Drain reasoning captured since the last call (gemma-channel thinking). */
876
- takeReasoning(): string {
877
- const r = this.#reasoning;
878
- this.#reasoning = "";
879
- return r;
880
- }
881
-
882
- /** Emit the longest #text prefix that cannot be (the start of) tool
883
- * markup; hold back ambiguous tails until disambiguated. */
884
- #textDelta(): string {
885
- if (this.#frozen >= 0) return "";
886
- let markerAt = -1;
887
- for (const mk of ToolAwareStream.TOOL_MARKERS) {
888
- const i = this.#text.indexOf(mk, this.#sent);
889
- if (i !== -1 && (markerAt === -1 || i < markerAt)) markerAt = i;
890
- }
891
- if (markerAt !== -1) {
892
- this.#frozen = markerAt;
893
- const out = this.#text.slice(this.#sent, markerAt);
894
- this.#sent = markerAt;
895
- return out;
896
- }
897
- let hold = 0;
898
- for (const mk of ToolAwareStream.TOOL_MARKERS) {
899
- const max = Math.min(mk.length - 1, this.#text.length - this.#sent);
900
- for (let k = max; k > hold; k--) {
901
- if (this.#text.endsWith(mk.slice(0, k))) { hold = k; break; }
902
- }
903
- }
904
- const limit = this.#text.length - hold;
905
- if (limit <= this.#sent) return "";
906
- const out = this.#text.slice(this.#sent, limit);
907
- this.#sent = limit;
908
- return out;
909
- }
910
-
911
- /** Returns the content text delta for this token ("" while capturing). */
912
- push(token: number): string {
913
- if (this.mode !== "gemma-sentinel") {
914
- this.#text += this.#decoder.push(token);
915
- if (this.mode === "plain") {
916
- const out = this.#text.slice(this.#sent);
917
- this.#sent = this.#text.length;
918
- return out;
919
- }
920
- return this.#textDelta();
921
- }
922
- if (this.#inTool) {
923
- if (token === TOOL_CALL_END) {
924
- this.#inTool = false;
925
- this.toolSegments.push(this.#toolTokens);
926
- this.#toolTokens = [];
927
- } else {
928
- this.#toolTokens.push(token);
929
- }
930
- return "";
931
- }
932
- // Reasoning channel: tokens between <|channel> and <channel|> are thought,
933
- // captured to #reasoning (drained via takeReasoning), never content. An
934
- // empty block (<|channel>thought\n<channel|>, emitted by larger Gemmas even
935
- // with thinking off) yields only the "\n" as reasoning (mlx-lm parity) and
936
- // leaks nothing into content.
937
- if (this.#inChannel) {
938
- if (token === CHANNEL_END) {
939
- this.#inChannel = false;
940
- this.#reasoning += this.#feedChannel(this.#channelDecoder.flush());
941
- } else {
942
- this.#reasoning += this.#feedChannel(this.#channelDecoder.push(token));
943
- }
944
- return "";
945
- }
946
- if (token === CHANNEL_START) {
947
- this.#inChannel = true;
948
- this.#channelNameDone = false;
949
- this.#channelNamePending = "";
950
- return "";
951
- }
952
- if (token === TOOL_CALL_START) {
953
- this.#inTool = true;
954
- return "";
955
- }
956
- return this.#decoder.push(token);
957
- }
958
-
959
- flush(): string {
960
- if (this.mode !== "gemma-sentinel") {
961
- this.#text += this.#decoder.flush();
962
- if (this.mode === "buffered-text") {
963
- const calls = this.toolCalls();
964
- if (calls.length && !this.#textToolParseFailed && this.#frozen >= 0) {
965
- // markup parsed into tool_calls — emit any prose still held
966
- // before it; the markup itself never reaches content
967
- const out = this.#text.slice(this.#sent, this.#frozen);
968
- this.#sent = this.#text.length;
969
- return out;
970
- }
971
- // no tool call (or parse fallback): release everything withheld
972
- const out = this.#text.slice(this.#sent);
973
- this.#sent = this.#text.length;
974
- return out;
975
- }
976
- const out = this.#text.slice(this.#sent);
977
- this.#sent = this.#text.length;
978
- return out;
979
- }
980
- if (this.#inChannel) {
981
- // truncated mid-reasoning (hit max_tokens); surface the partial thought
982
- this.#reasoning += this.#feedChannel(this.#channelDecoder.flush());
983
- this.#inChannel = false;
984
- return "";
985
- }
986
- if (this.#inTool && this.#toolTokens.length) {
987
- // truncated mid-tool-call (hit max_tokens); surface what we have
988
- this.toolSegments.push(this.#toolTokens);
989
- this.#toolTokens = [];
990
- }
991
- return this.#decoder.flush();
992
- }
993
-
994
- toolCalls(): OpenAIToolCall[] {
995
- if (this.mode !== "gemma-sentinel") {
996
- if (this.#textToolCalls) return this.#textToolCalls;
997
- try {
998
- this.#textToolCalls = parseGeneratedToolCalls(this.#text, this.tools ?? []).map((c) => ({
999
- id: `call_${crypto.randomUUID().slice(0, 8)}`,
1000
- type: "function",
1001
- function: { name: c.name, arguments: JSON.stringify(c.arguments) },
1002
- }));
1003
- } catch {
1004
- this.#textToolParseFailed = true;
1005
- this.#textToolCalls = [];
1006
- }
1007
- return this.#textToolCalls;
1008
- }
1009
- const out: OpenAIToolCall[] = [];
1010
- for (const seg of this.toolSegments) {
1011
- const text = this.tokenizer.decode(seg, false); // keep <|"|> markers
1012
- for (const c of parseToolCalls(text)) {
1013
- out.push({
1014
- id: `call_${crypto.randomUUID().slice(0, 8)}`,
1015
- type: "function",
1016
- function: { name: c.name, arguments: JSON.stringify(c.arguments) },
1017
- });
1018
- }
1019
- }
1020
- return out;
1021
- }
1022
- }
1023
-
1024
- /** Decoded-text stop-sequence matcher with streaming hold-back. Matching
1025
- * on text (not token ids) catches sequences that span token boundaries
1026
- * or tokenize differently in context; current mlx-lm uses token-id
1027
- * state machines and misses those. Text that could be the start of a
1028
- * stop sequence is withheld until disambiguated, so SSE clients never
1029
- * see any part of the stop sequence itself. */
1030
- export class StopMatcher {
1031
- #pending = "";
1032
- stopped = false;
1033
-
1034
- constructor(readonly sequences: string[]) {}
1035
-
1036
- /** Feed a text delta; returns the prefix that is now safe to emit.
1037
- * After a match fires (`stopped`), text before the match is returned
1038
- * and everything from the match on is discarded. */
1039
- push(text: string): string {
1040
- if (this.stopped) return "";
1041
- if (this.sequences.length === 0) return text;
1042
- this.#pending += text;
1043
- // earliest full match wins
1044
- let cut = -1;
1045
- for (const seq of this.sequences) {
1046
- const i = this.#pending.indexOf(seq);
1047
- if (i !== -1 && (cut === -1 || i < cut)) cut = i;
1048
- }
1049
- if (cut !== -1) {
1050
- this.stopped = true;
1051
- const out = this.#pending.slice(0, cut);
1052
- this.#pending = "";
1053
- return out;
1054
- }
1055
- // hold back the longest tail that is a proper prefix of any sequence
1056
- let hold = 0;
1057
- for (const seq of this.sequences) {
1058
- const max = Math.min(seq.length - 1, this.#pending.length);
1059
- for (let k = max; k > hold; k--) {
1060
- if (this.#pending.endsWith(seq.slice(0, k))) {
1061
- hold = k;
1062
- break;
1063
- }
1064
- }
1065
- }
1066
- if (hold === 0) {
1067
- const out = this.#pending;
1068
- this.#pending = "";
1069
- return out;
1070
- }
1071
- const out = this.#pending.slice(0, -hold);
1072
- this.#pending = this.#pending.slice(-hold);
1073
- return out;
1074
- }
1075
-
1076
- /** Generation ended without a match — release any held-back text. */
1077
- flush(): string {
1078
- const out = this.#pending;
1079
- this.#pending = "";
1080
- return out;
1081
- }
1082
- }
1083
-
1084
- /** Split Qwen-style inline <think>...</think> markup into OpenAI reasoning
1085
- * deltas/content. This keeps raw tags out of normal chat text while giving
1086
- * pi (TUI + web) proper thinking_delta events. It is streaming-safe: partial
1087
- * tag prefixes are held until disambiguated. */
1088
- export class ThinkingTagSplitter {
1089
- #pending = "";
1090
- #inThinking: boolean;
1091
- reasoning = "";
1092
- content = "";
1093
-
1094
- /** `startInThinking` seeds the parser INSIDE a <think> block. Needed for
1095
- * templates (Qwen3.5, MiniCPM5) that prime an OPEN `<think>` in the
1096
- * generation prompt when thinking is enabled: the model's output then
1097
- * starts mid-reasoning and emits only the closing `</think>`, never an
1098
- * opening tag. Without this seed the whole chain-of-thought leaks into
1099
- * `content` and the `reasoning` field stays empty. */
1100
- constructor(private readonly enabled: boolean, startInThinking = false) {
1101
- this.#inThinking = startInThinking;
1102
- }
1103
-
1104
- #safePrefixUntilTag(tag: string): string {
1105
- const i = this.#pending.indexOf(tag);
1106
- if (i !== -1) return this.#pending.slice(0, i);
1107
- let hold = 0;
1108
- for (let k = Math.min(tag.length - 1, this.#pending.length); k > 0; k--) {
1109
- if (this.#pending.endsWith(tag.slice(0, k))) { hold = k; break; }
1110
- }
1111
- return this.#pending.slice(0, this.#pending.length - hold);
1112
- }
1113
-
1114
- push(text: string): { content: string; reasoning: string } {
1115
- if (!this.enabled) {
1116
- this.content += text;
1117
- return { content: text, reasoning: "" };
1118
- }
1119
- this.#pending += text;
1120
- let content = "";
1121
- let reasoning = "";
1122
- while (this.#pending) {
1123
- const tag = this.#inThinking ? "</think>" : "<think>";
1124
- const i = this.#pending.indexOf(tag);
1125
- const emit = i === -1 ? this.#safePrefixUntilTag(tag) : this.#pending.slice(0, i);
1126
- if (!emit && i === -1) break;
1127
- if (emit) {
1128
- if (this.#inThinking) reasoning += emit;
1129
- else content += emit;
1130
- this.#pending = this.#pending.slice(emit.length);
1131
- }
1132
- if (i !== -1 && this.#pending.startsWith(tag)) {
1133
- this.#pending = this.#pending.slice(tag.length);
1134
- this.#inThinking = !this.#inThinking;
1135
- continue;
1136
- }
1137
- if (i === -1) break;
1138
- }
1139
- this.content += content;
1140
- this.reasoning += reasoning;
1141
- return { content, reasoning };
1142
- }
1143
-
1144
- flush(): { content: string; reasoning: string } {
1145
- if (!this.enabled) return { content: "", reasoning: "" };
1146
- const out = this.#inThinking
1147
- ? { content: "", reasoning: this.#pending }
1148
- : { content: this.#pending, reasoning: "" };
1149
- this.#pending = "";
1150
- this.content += out.content;
1151
- this.reasoning += out.reasoning;
1152
- return out;
1153
- }
1154
- }
1155
-
1156
- /** True when a rendered prompt ends INSIDE an unclosed `<think>` block — the
1157
- * generation prompt primed reasoning (Qwen3.5 / MiniCPM5 with thinking on),
1158
- * so the model continues the chain-of-thought and emits only the closing
1159
- * `</think>`. Seeds ThinkingTagSplitter so reasoning is split out correctly.
1160
- * Thinking-off primes a CLOSED empty block (`<think>\n\n</think>`), and
1161
- * no-thinking templates have no `<think>` at all — both return false. */
1162
- export function promptEndsInOpenThink(rendered: string): boolean {
1163
- const open = rendered.lastIndexOf("<think>");
1164
- return open !== -1 && open > rendered.lastIndexOf("</think>");
1165
- }
1166
-
1167
- /** Concatenate the text of an OpenAI content-part array, ignoring non-text
1168
- * parts. Tolerant of the part shapes clients actually send: `{type:"text",
1169
- * text}` (OpenAI/pi), `{type:"input_text", text}` (Responses-style), or any
1170
- * part carrying a string `text`. */
1171
- function contentPartsToText(parts: Array<Record<string, unknown>>): string {
1172
- return parts
1173
- .map((p) => (p && typeof p.text === "string" ? p.text : ""))
1174
- .join("");
1175
- }
1176
-
1177
- /** True if any content part is media — image OR audio — so the multimodal
1178
- * path must keep the array form for extractImages/extractAudio. */
1179
- function hasMediaPart(parts: Array<Record<string, unknown>>): boolean {
1180
- return parts.some(
1181
- (p) =>
1182
- p &&
1183
- (p.type === "image" || p.type === "image_url" ||
1184
- p.type === "audio" || p.type === "input_audio" || p.type === "audio_url"),
1185
- );
1186
- }
1187
-
1188
- /** OpenAI sends assistant tool_call arguments as JSON strings; the
1189
- * template renders the object form natively — normalize before render.
1190
- *
1191
- * Two more wire-format → template-format fixes, both "match the format the
1192
- * model's chat template expects":
1193
- *
1194
- * 1. Map the OpenAI reasoning-model "developer" role to "system": pi-ai (and
1195
- * OpenAI's own SDKs) rename the system prompt to `developer` whenever a
1196
- * model advertises reasoning, but our chat templates only know
1197
- * system/user/assistant/tool and raise "Unexpected message role." This is
1198
- * why Qwen3.5/MiniCPM5 chat got no messages while Gemma (non-reasoning, so
1199
- * pi keeps `system`) worked. `developer` IS the system prompt, so the remap
1200
- * is semantics-preserving.
1201
- *
1202
- * 2. Flatten text-only content-part arrays to a plain string. pi (and any
1203
- * OpenAI multimodal client) sends user content as `[{type:"text",text}]`,
1204
- * but non-vision chat templates expect `content` to be a STRING and render
1205
- * nothing for an array — so the user's turn silently vanishes and the model
1206
- * replies "I don't see any message." Arrays that carry a media part
1207
- * (image or audio) are left intact for the multimodal path
1208
- * (extractImages/extractAudio); only text-only arrays are collapsed
1209
- * here, which is a no-op for the vision templates too. */
1210
- export function normalizeMessages(messages: ChatMessage[]): ChatMessage[] {
1211
- return messages.map((raw) => {
1212
- let m = raw.role === "developer" ? { ...raw, role: "system" } : raw;
1213
- if (Array.isArray(m.content) && !hasMediaPart(m.content)) {
1214
- m = { ...m, content: contentPartsToText(m.content) };
1215
- }
1216
- if (!m.tool_calls) return m;
1217
- return {
1218
- ...m,
1219
- tool_calls: m.tool_calls.map((tc) => ({
1220
- ...tc,
1221
- function: {
1222
- ...tc.function,
1223
- arguments:
1224
- typeof tc.function.arguments === "string"
1225
- ? (JSON.parse(tc.function.arguments) as Record<string, unknown>)
1226
- : tc.function.arguments,
1227
- },
1228
- })),
1229
- };
1230
- });
1231
- }
1232
-
1233
- /** Coerce a wire `logit_bias` ({"tokenId": bias}) to numeric keys/values —
1234
- * mlx_lm.server's `{int(k): float(v)}` coercion; throws its exact error
1235
- * message on anything non-numeric (surfaced as a 400). JSON object keys
1236
- * always arrive as strings, hence the coercion. */
1237
- export function parseLogitBias(
1238
- raw: Record<string, number> | undefined | null,
1239
- ): Record<number, number> | undefined {
1240
- if (raw == null) return undefined;
1241
- if (typeof raw !== "object" || Array.isArray(raw))
1242
- throw new Error("logit_bias must be a dict of int to float");
1243
- const out: Record<number, number> = {};
1244
- for (const [k, v] of Object.entries(raw)) {
1245
- const id = Number(k);
1246
- const bias = Number(v);
1247
- if (!Number.isInteger(id) || !Number.isFinite(bias))
1248
- throw new Error("logit_bias must be a dict of int to float");
1249
- out[id] = bias;
1250
- }
1251
- return Object.keys(out).length ? out : undefined;
1252
- }
1253
-
1254
- /** Default seed for a request that didn't pin one. `Date.now()` alone is NOT
1255
- * request-unique: under `--batch N` the batch lane serves ONLY default-seed
1256
- * requests (explicit-seed requests route serial, see GenerationGateway.willBatch),
1257
- * so two identical prompts arriving in the same millisecond would share a seed
1258
- * and — with per-row RNG keyed as stepKey(seed, generatedCount) — produce
1259
- * byte-identical completions, silently collapsing best-of-N diversity. Mix a
1260
- * per-process Weyl counter (golden-ratio increment, period 2^32) into the
1261
- * timestamp so every call yields a distinct uint32 within any given ms.
1262
- * Determinism contract unchanged: reproducibility is only promised for an
1263
- * EXPLICIT request seed (`req.seed ?? nextDefaultSeed()` — explicit wins,
1264
- * byte-identical to before); a default seed is fresh entropy per request. */
1265
- let seedWeyl = 0;
1266
- export function nextDefaultSeed(): number {
1267
- seedWeyl = (seedWeyl + 0x9e3779b9) >>> 0;
1268
- return ((Date.now() & 0xffffffff) ^ seedWeyl) >>> 0;
1269
- }
1270
-
1271
- /** mlx_lm.server's logprobs request validation, copied exactly (server.py
1272
- * APIHandler.validate_model_parameters: `_validate("logprobs", bool)` and
1273
- * `_validate("top_logprobs", int, min_val=0, max_val=11, whitelist=[-1])`
1274
- * with defaults logprobs=False / top_logprobs=-1). Returns the reference's
1275
- * error message (→ 400) or null when valid. Note mlx-lm caps top_logprobs
1276
- * at 11, not OpenAI's 20 — we mirror the reference. */
1277
- /** Build a degrade-path system-prompt instruction for JSON output, mirroring
1278
- * oMLX's api.tool_calling.build_json_system_prompt (used when xgrammar
1279
- * compile fails — the response_format degrades to prompt injection rather
1280
- * than a 500, oMLX parity). Returns null for {type:"text"} / unset. */
1281
- export function degradeJsonSystemPrompt(body: ChatRequest): string | null {
1282
- if (body.guided_grammar) {
1283
- return "You must respond with text matching this grammar:\n\n" + body.guided_grammar;
1284
- }
1285
- const rf = body.response_format as { type?: string; json_schema?: { name?: string; description?: string; schema?: unknown } } | undefined;
1286
- if (rf && typeof rf === "object" && rf.type === "json_object") {
1287
- return "You must respond with valid JSON only. " +
1288
- "Do not include any explanation or text outside the JSON object.";
1289
- }
1290
- const schemaSpec = rf && typeof rf === "object" && rf.type === "json_schema"
1291
- ? rf.json_schema
1292
- : body.structured_outputs && typeof body.structured_outputs === "object"
1293
- ? { schema: body.structured_outputs }
1294
- : null;
1295
- if (schemaSpec) {
1296
- const spec = schemaSpec;
1297
- const schema = spec.schema ?? body.structured_outputs ?? {};
1298
- const name = spec.name ?? "response";
1299
- const description = spec.description ?? "";
1300
- let prompt = `You must respond with valid JSON matching the '${name}' schema.`;
1301
- if (description) prompt += ` ${description}`;
1302
- prompt += `\n\nJSON Schema:\n\`\`\`json\n${JSON.stringify(schema, null, 2)}\n\`\`\`\n\n` +
1303
- "Respond with only the JSON object, no additional text or explanation.";
1304
- return prompt;
1305
- }
1306
- if (body.guided_regex) {
1307
- return "You must respond with text matching this regular expression:\n\n" +
1308
- body.guided_regex;
1309
- }
1310
- if (body.guided_choice?.length) {
1311
- return "You must respond with exactly one of these choices:\n\n" +
1312
- body.guided_choice.map((choice) => JSON.stringify(choice)).join("\n");
1313
- }
1314
- return null;
1315
- }
1316
-
1317
- export function applyGrammarDegrade(
1318
- body: ChatRequest,
1319
- degradeHint: string,
1320
- ): { body: ChatRequest; warning: string } {
1321
- const content = degradeJsonSystemPrompt(body) ??
1322
- "Follow the requested output constraint exactly.";
1323
- return {
1324
- warning:
1325
- `grammar not enforced: ${degradeHint} - falling back to prompt injection`,
1326
- body: {
1327
- ...body,
1328
- messages: [{ role: "system", content }, ...body.messages],
1329
- },
1330
- };
1331
- }
1332
-
1333
- export function validateLogprobsParams(body: {
1334
- logprobs?: unknown;
1335
- top_logprobs?: unknown;
1336
- }): string | null {
1337
- const lp = body.logprobs ?? false;
1338
- if (typeof lp !== "boolean") return "logprobs must be of type bool";
1339
- const tl = body.top_logprobs ?? -1;
1340
- if (typeof tl !== "number" || !Number.isInteger(tl))
1341
- return "top_logprobs must be of type int";
1342
- if (tl === -1) return null; // the "unset" whitelist sentinel
1343
- if (tl < 0) return "top_logprobs must be at least 0";
1344
- if (tl > 11) return "top_logprobs must be at most 11";
1345
- return null;
1346
- }
1347
-
1348
- /** Collects per-token logprob info and shapes mlx_lm.server's response block
1349
- * (server.py generate_response L1317-1327). NOT OpenAI's shape — entries
1350
- * carry token *ids* (and raw vocab token strings in the top-k form), and the
1351
- * SAME block is attached under choices[0].logprobs for chat AND text
1352
- * completions:
1353
- * - top_logprobs > 0 → {content: [{id, token, logprob,
1354
- * top_logprobs: [{id, token, logprob}, …]}, …]} — each entry is the
1355
- * top-1 candidate merged with its own top-k list (`dict(i[0],
1356
- * top_logprobs=i)`); mlx-lm leaves argpartition order unspecified, we
1357
- * sort descending, so the entry is deterministically the argmax.
1358
- * - logprobs=true (and top_logprobs ≤ 0) → {content: [{id, logprob}, …]}
1359
- * with the SAMPLED token's logprob.
1360
- * When both are set, only the top_logprobs form is emitted (the reference's
1361
- * if/elif). Stream chunks never carry logprobs — mlx-lm's streaming
1362
- * generate_response calls pass no token_logprobs/top_tokens. */
1363
- class LogprobsCollector {
1364
- readonly #tokens: number[] = [];
1365
- readonly #tokenLogprobs: number[] = [];
1366
- readonly #topTokens: { id: number; token: string; logprob: number }[][] = [];
1367
-
1368
- constructor(
1369
- private readonly wantLogprobs: boolean,
1370
- private readonly topK: number,
1371
- private readonly idToToken: (id: number) => string,
1372
- ) {}
1373
-
1374
- get active(): boolean {
1375
- return this.wantLogprobs || this.topK > 0;
1376
- }
1377
-
1378
- push(token: number, info?: TokenLogprobs): void {
1379
- if (!this.active) return;
1380
- this.#tokens.push(token);
1381
- if (this.wantLogprobs) this.#tokenLogprobs.push(info?.logprob ?? NaN);
1382
- if (this.topK > 0)
1383
- this.#topTokens.push(
1384
- (info?.top ?? []).map((t) => ({
1385
- id: t.id,
1386
- token: this.idToToken(t.id),
1387
- logprob: t.logprob,
1388
- })),
1389
- );
1390
- }
1391
-
1392
- /** choices[0].logprobs value, or null when nothing to attach (mirrors the
1393
- * reference: the key is omitted entirely for zero collected tokens). */
1394
- payload(): { content: Record<string, unknown>[] } | null {
1395
- if (this.#topTokens.length)
1396
- return {
1397
- content: this.#topTokens.map((t) =>
1398
- t.length ? { ...t[0]!, top_logprobs: t } : {},
1399
- ),
1400
- };
1401
- if (this.#tokenLogprobs.length)
1402
- return {
1403
- content: this.#tokens.map((id, i) => ({
1404
- id,
1405
- logprob: this.#tokenLogprobs[i]!,
1406
- })),
1407
- };
1408
- return null;
1409
- }
1410
- }
1411
-
1412
- /** Incremental detokenizer: emits the longest stable decoded prefix.
1413
- *
1414
- * Byte parity with mlx-lm's streaming detokenizers (the drop-in contract is
1415
- * rendered BYTES, not just token ids). For BPE/ByteLevel tokenizers, two
1416
- * mlx-lm 0.31.3 BPEStreamingDetokenizer behaviors our full-sequence decode
1417
- * lacks (tokenizer_utils.py:195-226):
1418
- *
1419
- * 1. `trimsLeadingSpace` — mlx-lm drops ONE leading " " at the start of the
1420
- * generated sequence (`_maybe_trim_space`); trim it here. SPM decode
1421
- * already matches (see LoadedTokenizer.trimsLeadingSpace).
1422
- * 2. `bareSpaceTokenId` — add_token WITHHOLDS a single-char byte-32 token
1423
- * ("Ġ") in `_unflushed` ("For single spaces wait until the next token"),
1424
- * flushing it together with the NEXT token — and mlx_lm.server NEVER
1425
- * calls detokenizer.finalize() (zero hits in server.py 0.31.3), so a
1426
- * generation ENDING on bare-space token(s) silently drops their spaces
1427
- * from the served bytes. push() withholds those spaces; flush() drops a
1428
- * trailing bare-space run (the held text dies with the request, exactly
1429
- * like mlx-lm serve). Re-check both if upstream ever adds a finalize()
1430
- * call. LATENT HAZARD (deliberately not emulated): models with
1431
- * clean_up_tokenization_spaces=true get an ADDITIONAL mid-stream rule
1432
- * (`_space_matches`: held space dropped before "." "," "'s" …) — both
1433
- * current BPE targets have it false (MiniCPM5), so it never fires here.
1434
- *
1435
- * Exported for unit tests (serve-detok mlx-lm byte parity). */
1436
- export class StreamDecoder {
1437
- static readonly #WINDOW_TOKENS = 32;
1438
- #ids: number[] = [];
1439
- #emitted = "";
1440
- #warnedRevision = false;
1441
- /** Token index where the bounded decode suffix begins. */
1442
- #windowStart = 0;
1443
- /** Exact decoded text before #windowStart, established by suffix matching. */
1444
- #windowPrefix = "";
1445
- readonly #trimLeadingSpace: boolean;
1446
- readonly #bareSpaceId: number | undefined;
1447
-
1448
- constructor(
1449
- readonly tokenizer: LoadedTokenizer,
1450
- readonly skipSpecialTokens = true,
1451
- ) {
1452
- this.#trimLeadingSpace = tokenizer.trimsLeadingSpace === true;
1453
- this.#bareSpaceId = tokenizer.bareSpaceTokenId;
1454
- }
1455
-
1456
- #decode(ids: number[]): string {
1457
- const full = this.#decodeRaw(ids);
1458
- return this.#trimLeadingSpace && full.startsWith(" ") ? full.slice(1) : full;
1459
- }
1460
-
1461
- #decodeRaw(ids: number[]): string {
1462
- return this.tokenizer.decode(ids, this.skipSpecialTokens);
1463
- }
1464
-
1465
- /** Decode only a bounded suffix once an exact text anchor can be proven.
1466
- *
1467
- * At each rebase, decode a candidate suffix independently and accept it only
1468
- * when it is literally the suffix of the exact text decoded so far. This
1469
- * preserves ByteLevel/SPM boundary behavior while reducing steady-state
1470
- * decode work from the whole generation to 32–64 tokens. If no suffix can be
1471
- * anchored (an unusual/global decoder), leave #windowStart at zero and keep
1472
- * the full-history correctness path. */
1473
- #decodeIncremental(): string {
1474
- const full = this.#windowStart === 0
1475
- ? this.#decode(this.#ids)
1476
- : this.#windowPrefix + this.#decodeRaw(this.#ids.slice(this.#windowStart));
1477
-
1478
- if (this.#ids.length - this.#windowStart < StreamDecoder.#WINDOW_TOKENS * 2)
1479
- return full;
1480
-
1481
- const preferred = this.#ids.length - StreamDecoder.#WINDOW_TOKENS;
1482
- const oldest = Math.max(this.#windowStart, preferred - StreamDecoder.#WINDOW_TOKENS);
1483
- for (let start = preferred; start >= oldest; start--) {
1484
- const tail = this.#decodeRaw(this.#ids.slice(start));
1485
- if ((tail.length > 0 || full.length === 0) && full.endsWith(tail)) {
1486
- this.#windowStart = start;
1487
- this.#windowPrefix = full.slice(0, full.length - tail.length);
1488
- break;
1489
- }
1490
- }
1491
- return full;
1492
- }
1493
-
1494
- push(token: number): string {
1495
- this.#ids.push(token);
1496
- // Bare-space hold-back (mlx-lm add_token keeps "Ġ" in _unflushed): don't
1497
- // advance #emitted; the held space(s) flush as part of the next
1498
- // non-bare-space token's delta — consecutive bare spaces accumulate.
1499
- if (token === this.#bareSpaceId) return "";
1500
- const full = this.#decodeIncremental();
1501
- // hold back a trailing replacement char (partial multi-byte sequence)
1502
- const stable = full.endsWith("�") ? full.slice(0, -1) : full;
1503
- if (!stable.startsWith(this.#emitted)) {
1504
- // The decoder revised already-streamed text (cleanup rules — never
1505
- // fires for the shipped tokenizers, which all have
1506
- // clean_up_tokenization_spaces=false; see the LATENT HAZARD note in
1507
- // the class doc). An SSE client cannot un-receive bytes, so the old
1508
- // "re-emit from scratch" answer DUPLICATED the whole stream
1509
- // (2026-07-07 review). Truncate-safe resync instead: emit only the
1510
- // length-extension and keep the emitted watermark monotone — byte
1511
- // drift is confined to the revised span, and the once-per-stream
1512
- // warning makes the tokenizer that needs real _space_matches
1513
- // emulation loud instead of silently wrong.
1514
- if (!this.#warnedRevision) {
1515
- this.#warnedRevision = true;
1516
- console.warn(
1517
- "[detok] decoder revised already-streamed text (clean_up_tokenization_spaces?) — " +
1518
- "resyncing without re-emit; add mlx-lm's cleanup-rule emulation for this tokenizer",
1519
- );
1520
- }
1521
- if (stable.length <= this.#emitted.length) return "";
1522
- const out = stable.slice(this.#emitted.length);
1523
- this.#emitted = stable;
1524
- return out;
1525
- }
1526
- const delta = stable.slice(this.#emitted.length);
1527
- this.#emitted = stable;
1528
- return delta;
1529
- }
1530
312
 
1531
- flush(): string {
1532
- // mlx_lm.server never finalize()s: a trailing bare-space run stays
1533
- // withheld forever, so its text is dropped from the served bytes.
1534
- let ids = this.#ids;
1535
- if (this.#bareSpaceId !== undefined) {
1536
- let n = ids.length;
1537
- while (n > 0 && ids[n - 1] === this.#bareSpaceId) n--;
1538
- if (n < ids.length) ids = ids.slice(0, n);
1539
- }
1540
- const full = this.#decode(ids);
1541
- const delta = full.slice(this.#emitted.length);
1542
- this.#emitted = full;
1543
- return delta;
1544
- }
1545
- }
1546
313
 
1547
314
  // v2 curve designer: CORS-open so a file:// editor can call a localhost engine.
1548
315
  const CURVE_CORS: Record<string, string> = {
@@ -1557,16 +324,18 @@ function curveJunk(s: string): boolean {
1557
324
  return nonLatin >= 0.02 || /�/.test(s) || /<unused\d+>/.test(s);
1558
325
  }
1559
326
 
327
+
1560
328
  export function createServer(
1561
- ctx: ServerContext, port = 0, serverOptions: ServerOptions = {},
329
+ ctx: ServingContext, port = 0, serverOptions: ServerOptions = {},
1562
330
  ): Server<unknown> {
1563
- // --batch N (mode switch): N===1 is the serialized path below; N>1 routes
1564
- // batchable requests through the continuous-batching scheduler (the
1565
- // GenerationGateway picks the lane). Both full-attention (CPM) and
331
+ // --batch N is a concurrency cap: N===1 pins the strict serial executor;
332
+ // N>1 admits supported execution compositions to the continuous scheduler.
333
+ // The scheduler chooses its B=1 fast path or B=N step from active rows.
334
+ // Both full-attention (CPM) and
1566
335
  // sliding-window (Gemma) models batch — the scheduler assembles each layer's
1567
336
  // cache by attention type. Non-batchable requests (vision / adapters /
1568
- // repetition penalty / user seed / explicit kv-quant) drain to the serial
1569
- // lane (see GenerationGateway.willBatch).
337
+ // user seed / unsupported explicit kv-quant) drain to the serial executor
338
+ // (see GenerationGateway.place). No inference setting is rewritten.
1570
339
  // DEFAULT 8 (flipped 2026-07-05, Josh's call, after GATE-B1-SPEED): a
1571
340
  // lone request through the batch lane IS the serial engine (adopted
1572
341
  // serial-class caches, compiled decode, prompt cache + SSD restore;
@@ -1574,7 +343,18 @@ export function createServer(
1574
343
  // only changes behavior when concurrent requests actually arrive — the
1575
344
  // agentic sub-agent workload. --batch 1 pins strict serial for
1576
345
  // arrival-independent numerics. 8 = optiq's Mac-safe concurrency.
346
+ const serving = modelServingBinding(ctx);
347
+ ctx = { ...ctx, serving };
1577
348
  const batch = Math.max(1, Math.floor(serverOptions.batch ?? 8));
349
+ if (serverOptions.generationCheckpointTokens !== undefined) {
350
+ if (!Number.isInteger(serverOptions.generationCheckpointTokens) ||
351
+ serverOptions.generationCheckpointTokens < 1)
352
+ throw new Error("--generation-checkpoint expects a positive integer token interval");
353
+ if (!serverOptions.ssdCacheDir)
354
+ throw new Error("--generation-checkpoint requires --ssd-cache <dir>");
355
+ if (batch !== 1)
356
+ throw new Error("--generation-checkpoint requires --batch 1 (in-flight resume is serial-only)");
357
+ }
1578
358
  const defaultGeneratedTokens =
1579
359
  serverOptions.defaultMaxTokens ?? ctx.glmMemoryPlan?.maxGenerationTokens;
1580
360
 
@@ -1585,7 +365,6 @@ export function createServer(
1585
365
  // and must be an explicit opt-in (--kv-quant config|4|8, or --l2
1586
366
  // whose presets pass it explicitly). The CLI always passes kvQuant now;
1587
367
  // this fallback is the library-user default and matches the CLI's.
1588
- const configScheme = ctx.kvConfig?.length ? { kvConfig: ctx.kvConfig } : {};
1589
368
  // Mutually exclusive by contract (GenerateOptions.turboQuant doc): a
1590
369
  // programmatic caller setting both gets turboQuant — say so, like the
1591
370
  // other risky-combination warnings below.
@@ -1594,13 +373,12 @@ export function createServer(
1594
373
  `[kv-quant] both turboQuant and --kv-quant ${serverOptions.kvQuant} are set; ` +
1595
374
  `turboQuant wins (they are mutually exclusive).`,
1596
375
  );
1597
- const kvScheme: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart" | "turboQuant"> =
1598
- serverOptions.turboQuant ? { turboQuant: serverOptions.turboQuant, quantizedKvStart: 0 }
1599
- : serverOptions.kvQuant === "off" ? {}
1600
- : serverOptions.kvQuant === "config" ? configScheme
1601
- : typeof serverOptions.kvQuant === "number"
1602
- ? { kvBits: serverOptions.kvQuant, quantizedKvStart: 0 }
1603
- : {}; // unset → bf16 (L1 default; quantized KV is opt-in)
376
+ const resolvedKvScheme = resolveKvScheme({
377
+ override: serverOptions.kvQuant,
378
+ turboQuant: serverOptions.turboQuant,
379
+ config: ctx.kvConfig,
380
+ });
381
+ const kvScheme = resolvedKvScheme.generationOptions;
1604
382
  // Phase 3.1: kvConfig whose layers are all full-attention BATCHES (the
1605
383
  // scheduler applies the mixed scheme per row); uniform kvBits and configs
1606
384
  // touching rotating layers still route those requests serial. The warning
@@ -1613,12 +391,12 @@ export function createServer(
1613
391
  `--kv-quant to batch in bf16. (docs/design/unified-engine-frontier-plan.md)`,
1614
392
  );
1615
393
  // TurboQuant is solo-only in v1 (novel cache class, not batchable by
1616
- // construction — see GenerationGateway#modelCachesBatchable/willBatch).
394
+ // construction — see GenerationGateway placement's cache-capability gate).
1617
395
  if (batch > 1 && kvScheme.turboQuant)
1618
396
  console.warn(
1619
397
  `[batch] --batch ${batch} with --kv-quant turbo: TurboQuant is serial-only in v1 ` +
1620
398
  `— those requests won't batch. Omit --kv-quant to batch in bf16. ` +
1621
- `(docs/design/turboquant-kv.md)`,
399
+ `(docs/design/turboquant.md)`,
1622
400
  );
1623
401
  // Quantized KV (any axis) excludes the spec lane: drafted requests fall to
1624
402
  // the normal serial path WITH the KV scheme applied rather than losing it
@@ -1627,9 +405,9 @@ export function createServer(
1627
405
  if (ctx.draft && (kvScheme.turboQuant || kvScheme.kvBits || kvScheme.kvConfig?.length))
1628
406
  console.warn(
1629
407
  `[spec] --draft-model with quantized KV (--kv-quant ${serverOptions.turboQuant ? "turbo" : String(serverOptions.kvQuant)}): ` +
1630
- `the speculative lane is bf16-KV-only in v1 — requests keep the KV scheme and ` +
1631
- `decode serially WITHOUT speculation. Omit --kv-quant to speculate. ` +
1632
- `(docs/design/dspark-serving-program.md Phase 4)`,
408
+ `requests keep the KV scheme; the model execution plan enables speculation ` +
409
+ `only for a qualified combination. Check response usage for the active method. ` +
410
+ `(docs/design/speculative-decoding.md Phase 4)`,
1633
411
  );
1634
412
  // TurboQuant head-dim fail-fast (2026-07-07 review): the cache class only
1635
413
  // supports {64,128,256,512} (sign-vector + Lloyd-Max table coverage) and
@@ -1644,10 +422,10 @@ export function createServer(
1644
422
  throw new Error(
1645
423
  `--kv-quant turbo: this model's full-attention head_dim is ${dim}; ` +
1646
424
  `TurboQuant supports {${TURBOQUANT_HEAD_DIMS.join(",")}} ` +
1647
- `(docs/design/turboquant-kv.md) — use --kv-quant config|4|8 or omit it`,
425
+ `(docs/design/turboquant.md) — use --kv-quant config|4|8 or omit it`,
1648
426
  );
1649
427
  }
1650
- // Paged KV v1 (docs/design/paged-kv-cache.md): explicit refusals, not
428
+ // Paged KV v1 (docs/design/kv-cache.md): explicit refusals, not
1651
429
  // silent downgrades — the incompatible combos would otherwise degrade
1652
430
  // quietly (batch: paged caches can't merge; kv-quant: the swap would
1653
431
  // drop the scheme, the exact composition bug the kvQuant gate above
@@ -1656,22 +434,22 @@ export function createServer(
1656
434
  if (batch > 1)
1657
435
  throw new Error(
1658
436
  `--paged-kv is serial-only in v1 — add --batch 1 (got --batch ${batch}). ` +
1659
- `Batched paging is the follow-up PR (docs/design/paged-kv-cache.md).`,
437
+ `Batched paging is the follow-up PR (docs/design/kv-cache.md).`,
1660
438
  );
1661
439
  if (kvScheme.kvBits || kvScheme.kvConfig?.length || kvScheme.turboQuant)
1662
440
  throw new Error(
1663
441
  `--paged-kv is bf16-only in v1 — omit --kv-quant (quantized paged ` +
1664
- `blocks are a documented follow-up, docs/design/paged-kv-cache.md).`,
442
+ `blocks are a documented follow-up, docs/design/kv-cache.md).`,
1665
443
  );
1666
444
  if (ctx.draft)
1667
445
  throw new Error(
1668
446
  `--paged-kv cannot combine with --draft-model in v1 ` +
1669
- `(docs/design/paged-kv-cache.md non-goals).`,
447
+ `(docs/design/kv-cache.md non-goals).`,
1670
448
  );
1671
449
  if (!ctx.model.config.modelType.startsWith("gemma4"))
1672
450
  throw new Error(
1673
451
  `--paged-kv v1 supports Gemma4-family models only ` +
1674
- `(this model: ${ctx.model.config.modelType}) — docs/design/paged-kv-cache.md.`,
452
+ `(this model: ${ctx.model.config.modelType}) — docs/design/kv-cache.md.`,
1675
453
  );
1676
454
  const bs = serverOptions.pagedKv.blockSize;
1677
455
  if (bs !== undefined && (!Number.isInteger(bs) || bs <= 0))
@@ -1683,11 +461,12 @@ export function createServer(
1683
461
  );
1684
462
  }
1685
463
 
1686
- // SSD cold tier (docs/design/ssd-kv-cold-tier.md): prefix KV survives RAM
464
+ // SSD cold tier (docs/design/kv-cache.md): prefix KV survives RAM
1687
465
  // eviction and restarts. Compatibility key = configFingerprint (graph
1688
466
  // shape) + the EFFECTIVE kv scheme (flags pick bf16 vs config vs uniform
1689
467
  // on the same model — restored caches must match what serving produces) +
1690
- // tokenizer hash (ids must keep meaning the same text).
468
+ // tokenizer hash (ids must keep meaning the same text). The model binding
469
+ // also identifies the runtime numerics that produced the persisted state.
1691
470
  // RAM prompt-cache cap: 8 GB default (Josh's call, 2026-07-06). The old
1692
471
  // flat 2e9 was an anti-OOM reflex sized for a 1B model in 24 GB — a
1693
472
  // single full-context 12B entry is ~0.6 GB, so it silently shrank to
@@ -1695,28 +474,33 @@ export function createServer(
1695
474
  // entries still demote to SSD and LRU eviction bounds pressure.
1696
475
  // --prompt-cache <GB> overrides; 0 disables.
1697
476
  const promptCacheCap = serverOptions.promptCacheBytes ?? 8e9;
477
+ const stateCodecs = ctx.stateCodecs ?? legacyCacheCodecs;
478
+ const cloneState = (caches: Cache[]) => cloneKvCaches(caches, stateCodecs);
1698
479
  let ssdStore: SsdCacheStore | null = null;
1699
480
  if (serverOptions.ssdCacheDir) {
1700
481
  if (promptCacheCap <= 0)
1701
482
  throw new Error("--ssd-cache requires the RAM prompt cache (--prompt-cache 0 disables it)");
1702
- const schemeKey = kvScheme.turboQuant
1703
- ? `turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`
1704
- : kvScheme.kvBits
1705
- ? `kv${kvScheme.kvBits}`
1706
- : kvScheme.kvConfig?.length ? "config" : "bf16";
483
+ if (typeof serving.stateCompatibility !== "string" || !serving.stateCompatibility.length)
484
+ throw new Error("--ssd-cache requires the model binding's stateCompatibility identity");
485
+ const schemeKey = resolvedKvScheme.cacheKey;
486
+ const stateKey = Bun.hash(serving.stateCompatibility).toString(16);
1707
487
  const tokJson = readFileSync(`${ctx.model.config.modelDir}/tokenizer.json`);
1708
488
  ssdStore = new SsdCacheStore({
489
+ codecs: stateCodecs,
1709
490
  dir: serverOptions.ssdCacheDir,
1710
- maxBytes: serverOptions.ssdCacheMaxBytes ?? 32 * 2 ** 30,
1711
- configFingerprint: `${configFingerprint(ctx.model.config)}-${schemeKey}`,
491
+ maxBytes: serverOptions.ssdCacheMaxBytes ?? Number.POSITIVE_INFINITY,
492
+ configFingerprint: `${configFingerprint(ctx.model.config)}-${schemeKey}-${stateKey}`,
1712
493
  tokenizerHash: Bun.hash(tokJson).toString(16),
1713
494
  modelId: ctx.modelId,
1714
495
  verify: serverOptions.ssdCacheVerify,
1715
496
  });
1716
497
  const recovered = ssdStore.scan();
498
+ const capacity = Number.isFinite(ssdStore.maxBytes)
499
+ ? `${(ssdStore.maxBytes / 2 ** 30).toFixed(0)} GiB cap`
500
+ : "unlimited";
1717
501
  console.log(
1718
502
  `[ssd-cache] ${serverOptions.ssdCacheDir} — ${recovered} entr${recovered === 1 ? "y" : "ies"} recovered, ` +
1719
- `${(ssdStore.totalBytes / 2 ** 30).toFixed(2)} GiB of ${(ssdStore.maxBytes / 2 ** 30).toFixed(0)} GiB cap`,
503
+ `${(ssdStore.totalBytes / 2 ** 30).toFixed(2)} GiB, ${capacity}`,
1720
504
  );
1721
505
  }
1722
506
 
@@ -1731,7 +515,7 @@ export function createServer(
1731
515
  return h ? { prefixLen: h.prefixLen, handle: h.entry } : null;
1732
516
  },
1733
517
  restore: (handle: unknown) => {
1734
- const loaded = ssdStore!.restore(handle as import("./ssd-cache").SsdIndexEntry, ctx.model);
518
+ const loaded = serving.restore(ssdStore!, handle as import("./ssd-cache").SsdIndexEntry);
1735
519
  if (!loaded) return null;
1736
520
  // Restore is a STREAMED COPY (2026-07-07 A7-restore): the caches
1737
521
  // own their bytes and no mapping outlives loadKvCache — nothing
@@ -1740,7 +524,8 @@ export function createServer(
1740
524
  return { tokens: loaded.tokens, caches: loaded.caches, retain: () => {} };
1741
525
  },
1742
526
  store: (tokens: number[], caches: import("./model/gemma4").Cache[], ns: string) => {
1743
- ssdStore!.store(tokens, caches, ns);
527
+ if (ssdStore!.hasDurablePrefix(tokens, ns)) return true;
528
+ return ssdStore!.store(tokens, caches, ns);
1744
529
  },
1745
530
  }
1746
531
  : null;
@@ -1751,7 +536,7 @@ export function createServer(
1751
536
  // cache hands us OWNED zero-copy clones + copied tokens (made under
1752
537
  // the generation lock — microseconds; entries are immutable so the
1753
538
  // clones stay consistent), and the flush goes through the BOUNDED
1754
- // SpillQueue -> storeAsync (idle-gated per tensor, see below) ->
539
+ // SpillQueue -> storeAsync (generation-locked per tensor, see below) ->
1755
540
  // clone disposal on every settle/drop path (that dispose is what
1756
541
  // actually frees the demoted GPU memory; the queue's byte cap keeps
1757
542
  // starved-gate retention bounded — 2026-07-07 review fix). spillQueue
@@ -1765,6 +550,7 @@ export function createServer(
1765
550
  }
1766
551
  : null,
1767
552
  coldTier,
553
+ cloneState,
1768
554
  );
1769
555
  // Responses-API store for previous_response_id resumption (Phase 11):
1770
556
  // TTL + byte-capped LRU, port of optiq/response_store.py. Pairs with
@@ -1772,158 +558,56 @@ export function createServer(
1772
558
  // so its KV prefill is already cached.
1773
559
  const responseStore = new ResponseStore();
1774
560
 
1775
- /** Run one generation with prompt-cache reuse. Must be called under the
1776
- * gateway's exclusive lock — the gateway invokes it on the serial lane;
1777
- * the curve /generate endpoint wraps it in gateway.runExclusive. (The old
1778
- * `enqueue` promise queue is gone; the gateway's AsyncMutex is THE lock.)
1779
- * onToken returning `false` halts generation early (stop
1780
- * sequence fired); the cache snapshot stays valid and is still kept.
1781
- * Media (vision/audio embeddings-prefill) requests bypass the prompt
1782
- * cache: soft tokens are identical placeholder ids, so prefix matching
1783
- * across different images/clips would false-hit. */
1784
- const runGeneration = async (
1785
- promptIds: number[],
1786
- options: GenerateOptions,
1787
- onToken: (token: number, logprobs?: TokenLogprobs) => void | boolean | Promise<void | boolean>,
1788
- vision?: {
1789
- embeddings: import("./mlx/array").MlxArray;
1790
- imageMask?: import("./mlx/array").MlxArray;
1791
- multimodalMask?: import("./mlx/array").MlxArray;
1792
- },
1793
- ) => {
1794
- // Speculative decoding (serve --draft-model): spec-ELIGIBLE requests
1795
- // decode through the verify loop; the rest fall through to the normal
1796
- // serial path (never wrong results, just no speedup — logged once per
1797
- // combination class would be noise, so silent). Eligibility v1:
1798
- // text-only, base weights, no logprobs capture, bf16 KV — ALL quantized
1799
- // KV axes excluded (affine kvBits/kvConfig AND turboQuant; the spec loop
1800
- // builds fresh bf16 caches and never calls maybeQuantizeKv, so routing a
1801
- // turbo request here would silently drop the operator's KV scheme —
1802
- // 2026-07-07 review). Grammar COMPOSES (Phase C constrained verify walk
1803
- // — see the serve-loop.ts header). Prompt-cache reuse is bypassed on
1804
- // the spec path v1.
1805
- if (
1806
- ctx.draft &&
1807
- !vision &&
1808
- !options.adapters?.length &&
1809
- !options.logprobs &&
1810
- !options.kvBits &&
1811
- !options.kvConfig &&
1812
- !options.turboQuant &&
1813
- // Belt: --paged-kv + --draft-model is refused at createServer; this
1814
- // keeps the spec lane paged-free even for programmatic callers.
1815
- !options.pagedKv
1816
- ) {
1817
- const { specServeRun } = await import("./spec/serve-loop");
1818
- return withModelWiredLimit(
1819
- ctx.model,
1820
- () => withModelUsageFlush(
1821
- ctx.model,
1822
- () => specServeRun(
1823
- ctx.model, ctx.draft!.provider, ctx.draft!.numDraftTokens,
1824
- promptIds, options, onToken,
1825
- ),
1826
- ),
1827
- );
1828
- }
1829
- // Cache entries are adapter-specific: KV computed under one adapter
1830
- // must never seed another's (or the base's) prefill.
1831
- const cacheNs = options.adapters?.join("+") ?? "";
1832
- // Paged-KV request scope (docs/design/paged-kv-cache.md): media
1833
- // prompts (bidir overlay) and LoRA-adapter requests are v1 non-goals —
1834
- // they run the PLAIN cache path even under --paged-kv (scope the flag
1835
- // per request, never 400). Effective value computed ONCE so the
1836
- // prompt-cache bypass below and the generate() options can't disagree.
1837
- const pagedKv = vision || options.adapters?.length ? undefined : options.pagedKv;
1838
- // Paged requests bypass the prompt cache entirely (v1 non-goal:
1839
- // PagedKVCache has no cloneKvCaches/restore path — the vision
1840
- // precedent). Fresh caches per request, disposed on completion.
1841
- const skipPromptCache = Boolean(vision || pagedKv);
1842
- // Both tiers in one call (Layer 0): take() prefers a strictly-longer
1843
- // SSD prefix, restores it zero-copy, and trims — see PromptCache.take.
1844
- const entry = skipPromptCache ? null : promptCache.take(promptIds, cacheNs);
1845
- const caches = entry?.caches ?? ctx.model.makeCache();
1846
- // Prompt-boundary snapshot (the multi-turn agent fix, 2026-07-04): the
1847
- // prompt+gen entry put() below is UNTRIMMABLE at context > sliding
1848
- // window (wrapped rings) and under quantized KV (mid-group), so any
1849
- // decode→encode roundtrip drift in the reply the client sends back
1850
- // turns the next turn into a total miss (measured: 12B turn-2 TTFT
1851
- // 8.9 s instead of ~0.2 s). A prompt-ONLY entry is always an exact
1852
- // prefix of the next turn's rendering regardless of reply drift.
1853
- // Zero-copy (cloneKvCaches = slice views); only for substantial cold
1854
- // prefills, where the re-prefill it saves is worth an extra entry.
1855
- // The oracle invariant (mlx-lm insert_segments): a trim-free STRICT
1856
- // prefix of the prompt exists for EVERY substantial request — cap the
1857
- // boundary at len-1 so even a stableLen == len prompt (e4b: the
1858
- // template tail survives the probe render) snapshots prompt[:-1]. An
1859
- // exact repeat then matches with trimNeeded == 0, bypassing
1860
- // isTrimmable() entirely — the only reuse path a wrapped ring has.
1861
- const boundary = Math.min(options.snapshotAt ?? promptIds.length, promptIds.length - 1);
1862
- // Re-snapshot on EVERY substantial request whose stable boundary extends
1863
- // past the cached prefix; the clone is zero-copy views, so re-putting
1864
- // is ~free.
1865
- const snapshotBoundary =
1866
- !skipPromptCache && boundary >= 256 && boundary > (entry?.tokens.length ?? 0);
1867
- try {
1868
- const gen = generate(ctx.model, promptIds, {
1869
- ...options,
1870
- pagedKv, // request-scoped (undefined strips the server-wide flag)
1871
- cache: caches,
1872
- ...(snapshotBoundary
1873
- ? {
1874
- // snapshotAt MUST travel with the hook: generate() splits the
1875
- // prefill at exactly this many tokens and fires the hook while
1876
- // the caches hold exactly that prefix — putting boundary
1877
- // tokens against caches at any other offset is silent KV
1878
- // corruption.
1879
- snapshotAt: boundary,
1880
- onPrefillDone: () => {
1881
- try {
1882
- promptCache.put(promptIds.slice(0, boundary), cloneKvCaches(caches), cacheNs);
1883
- } catch (e) {
1884
- console.warn(`prompt-boundary snapshot skipped: ${(e as Error).message}`);
1885
- }
1886
- },
1887
- }
1888
- : {}),
1889
- ...(vision
1890
- ? {
1891
- promptEmbeddings: vision.embeddings,
1892
- ...(vision.imageMask ? { imageMask: vision.imageMask } : {}),
1893
- ...(vision.multimodalMask ? { multimodalMask: vision.multimodalMask } : {}),
1894
- }
1895
- : {}),
1896
- });
1897
- for await (const t of gen) {
1898
- if ((await onToken(t.token, t.logprobs)) === false) break;
1899
- }
1900
- const s = gen.stats!; // set on completion AND on early break
1901
- if (skipPromptCache) {
1902
- // Vision and paged-KV requests own their caches for exactly one
1903
- // generation (paged: v1 non-goal — no PromptCache integration).
1904
- for (const c of caches) c.dispose();
1905
- } else {
1906
- // put() fires onPut → the debounced write-behind SSD snapshot
1907
- // (wired below), covering the batch lane's puts too.
1908
- promptCache.put(s.cacheTokens, caches, cacheNs, entry?.retain);
1909
- }
1910
- return s;
1911
- } catch (e) {
1912
- for (const c of caches) c.dispose();
1913
- entry?.retain?.();
1914
- // A throw BEFORE generate()/the gateway took ownership (promptCache
1915
- // take / makeCache) would leak the grammar's WASM matcher; dispose()
1916
- // is idempotent, so this is safe when the throw came from inside run
1917
- // (whose finally already disposed it).
1918
- options.grammar?.dispose();
1919
- throw e;
1920
- } finally {
1921
- vision?.embeddings.dispose();
1922
- vision?.imageMask?.dispose();
1923
- vision?.multimodalMask?.dispose();
1924
- options.visionPixels?.dispose();
1925
- }
561
+ // Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
562
+ // fit() solves max safe context from weights + KV growth + prefill
563
+ // transient. The active kv-quant scheme (uniform kvBits / per-layer
564
+ // kvConfig) is billed at its true bytes/element so a quantized cache
565
+ // advertises and admits the larger window it actually enables; only
566
+ // TurboQuant still bills bf16 (conservative — no projector for its
567
+ // layout yet, and it is solo-only in v1).
568
+ const memoryAdmission = ctx.glmMemoryPlan ?? fit(
569
+ ctx.model.config, ctx.model.weightsBytes, 1,
570
+ undefined, undefined, 0, serverOptions.memoryBudgetBytes,
571
+ resolvedKvScheme.fitOptions,
572
+ );
573
+ // Isolated benchmark profile cap. It may narrow physical admission, never
574
+ // enlarge it. The normal request planner still clamps prompt + output.
575
+ const profileContext = runtimeValue("MLX_BUN_RD_CONTEXT_LIMIT");
576
+ const contextLimit = profileContext === undefined ? null : Number(profileContext);
577
+ if (contextLimit !== null && (!Number.isSafeInteger(contextLimit) || contextLimit < 1))
578
+ throw new Error("MLX_BUN_RD_CONTEXT_LIMIT must be a positive integer");
579
+ const admission = contextLimit === null ? memoryAdmission : {
580
+ ...memoryAdmission, maxSafeContext: Math.min(contextLimit, memoryAdmission.maxSafeContext),
581
+ };
582
+ // A zero ceiling is a warning, never a startup refusal: killing the
583
+ // server here can only parrot the per-request admission message (which
584
+ // still fires, with this same ceiling) or be a false positive from the
585
+ // fit model itself — it can never save anything the request gate
586
+ // doesn't. Serve until physically incapable.
587
+ if (admission.maxSafeContext < 1)
588
+ console.warn(
589
+ `[admission] memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB leaves no ` +
590
+ `safe context for ${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB) ` +
591
+ `— serving anyway; generation requests will be refused until the budget is raised`,
592
+ );
593
+ const allocatorLimit =
594
+ admission.allocatorLimitBytes ?? serverOptions.memoryBudgetBytes;
595
+ if (allocatorLimit) setMemoryLimit(allocatorLimit);
596
+
597
+ const memoryBudget = {
598
+ usableBytes: admission.usableBytes,
599
+ kvOptions: resolvedKvScheme.fitOptions,
600
+ promptCache,
1926
601
  };
602
+ const runGeneration = serving.createSerial({
603
+ promptCache, checkpoints: ssdStore,
604
+ memoryBudget,
605
+ checkpointEveryTokens: serverOptions.generationCheckpointTokens,
606
+ identity: { artifact: ctx.profile.artifact, implementation: ctx.profile.profile.execution,
607
+ stateAbi: "legacy-cache-array-v1", codecs: stateCodecs.id },
608
+ adapterNamespace: (adapters) => ctx.adapters.cacheNamespace(adapters),
609
+ cloneState,
610
+ });
1927
611
 
1928
612
  // Write-behind persistence (restart survival — the oMLX boundary-snapshot
1929
613
  // idea at whole-entry granularity, spill-on-evict alone can't survive a
@@ -1934,12 +618,10 @@ export function createServer(
1934
618
  // extraction must not race a generation mutating the entry); if the entry
1935
619
  // was evicted (already spilled) or extended (a newer schedule pending)
1936
620
  // meanwhile, findExact misses and nothing is written.
1937
- // Keyed by ns AND entry length: a sub-second final [prompt+gen] put must
1938
- // not cancel the pending boundary-snapshot write (they are DIFFERENT
1939
- // entries; for wrapped-ring models the boundary file is the only one a
1940
- // restart can use). Same-length reschedules still coalesce; stale keys
1941
- // self-clean at fire time (findExact misses once the RAM tier superseded
1942
- // the entry, so nothing extra is written).
621
+ // Keyed by namespace and exact tokens: a sub-second final [prompt+gen] put
622
+ // must not cancel the pending boundary snapshot, and unrelated prompts of
623
+ // equal length must not cancel each other. Exact reschedules still
624
+ // coalesce; stale keys self-clean when findExact misses.
1943
625
  //
1944
626
  // NON-BLOCKING (2026-07-06, the write-behind persistence contract): the
1945
627
  // gateway lock is held only for a zero-copy SNAPSHOT (findExact +
@@ -1955,74 +637,84 @@ export function createServer(
1955
637
  // pacing interleaved those slices exactly between decode tokens. A ~16k
1956
638
  // entry's flush overlapping the bench's ctx repeats depressed decode@ctx
1957
639
  // ~9% on e4b (mlx-lm runs no equivalent background work). Now every step
1958
- // — including the first — awaits gateway.onIdle() (ssdFlushGate), so the
640
+ // — including the first — runs through gateway.runExclusive(), so the
1959
641
  // flush only progresses while NOTHING is generating and pauses when a
1960
642
  // request arrives mid-flush. Tradeoffs, accepted: durability waits for a
1961
643
  // quiet moment (single-user serving quiesces constantly; sustained
1962
644
  // hammering defers the flush AND the spill clones' GPU-memory release —
1963
- // bounded by the chain), and one in-flight tensor step (~10-15 MB) can
1964
- // still land ahead of a just-arrived request. MLX_BUN_SSD_WRITEBEHIND=0
645
+ // bounded by the chain). MLX_BUN_SSD_WRITEBEHIND=0
1965
646
  // disables write-behind snapshots entirely — the paired-A/B lever + kill
1966
647
  // switch (restart survival then degrades to spill-on-evict only).
1967
- const writeBehindOn = process.env.MLX_BUN_SSD_WRITEBEHIND !== "0";
1968
- const ssdFlushGate = (): Promise<void> => gateway.onIdle();
1969
- const ssdPending = new Map<string, ReturnType<typeof setTimeout>>();
648
+ const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
649
+ const ssdFlushStep = <T>(step: () => T): Promise<T> =>
650
+ gateway.runExclusive(async () => step());
1970
651
  // Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
1971
652
  // kv-store.ts): pending clones pin their entries' GPU buffers while the
1972
653
  // idle gate starves under sustained traffic, so QUEUED bytes are capped —
1973
654
  // over cap the oldest queued spill drops (clone disposed immediately, a
1974
655
  // future cache miss, never a wrong result) instead of accumulating past
1975
656
  // the prompt-cache cap. Default 2 GB (a quarter of the 8 GB RAM-cache
1976
- // default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides. There is deliberately
1977
- // NO shutdown flush: exiting under traffic loses queued write-behind
1978
- // entries (restart survival degrades to whatever flushed) — accepted for
1979
- // a best-effort cache tier; drops/pending are visible in /stats.
657
+ // default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides. The durability
658
+ // coordinator retains a dirty record until the queue confirms the atomic
659
+ // store. Explicit flush and graceful shutdown can therefore retry a drop.
1980
660
  // `|| 2` would coerce an explicit "0" back to 2 GB — parse so 0 works
1981
661
  // (cap 0 = keep only the newest + in-flight clone pinned; the soft cap
1982
662
  // never drops the item just enqueued).
1983
- const spillQueueGbRaw = Number(process.env.MLX_BUN_SSD_SPILL_QUEUE_GB);
663
+ // The gateway exists before the spill queue because the SSD writer uses
664
+ // its idle boundary to avoid competing with generation.
665
+ const gateway = new GenerationGateway(serving.gateway, batch, runGeneration, {
666
+ kvBudgetBytes: serverOptions.kvBudgetBytes,
667
+ memoryBudget,
668
+ checkpoints: !!(serverOptions.generationCheckpointTokens && ssdStore),
669
+ stateCodecs,
670
+ kvScheme: resolvedKvScheme,
671
+ promptCache,
672
+ beforeSerial: async () => { await flushDurability(); },
673
+ });
674
+ const spillQueueGbRaw = Number(runtimeValue("MLX_BUN_SSD_SPILL_QUEUE_GB"));
1984
675
  const spillQueueCapBytes =
1985
676
  (Number.isFinite(spillQueueGbRaw) && spillQueueGbRaw >= 0 ? spillQueueGbRaw : 2) * 1024 ** 3;
1986
677
  const spillQueue = ssdStore
1987
678
  ? new SpillQueue(
1988
679
  spillQueueCapBytes,
1989
680
  cacheBytes,
1990
- (item) => ssdStore!.storeAsync(item.tokens, item.caches, item.ns, ssdFlushGate),
681
+ (item) => ssdStore!.storeAsync(item.tokens, item.caches, item.ns, ssdFlushStep),
1991
682
  (caches) => { for (const c of caches) c.dispose(); },
1992
683
  )
1993
684
  : null;
1994
- const scheduleSsdSnapshot = (tokens: number[], ns: string): void => {
1995
- if (!ssdStore || !writeBehindOn || tokens.length === 0) return;
1996
- const key = `${ns}|${tokens.length}`;
1997
- const prev = ssdPending.get(key);
1998
- if (prev) clearTimeout(prev);
1999
- const fire = (): void => {
2000
- ssdPending.delete(key);
2001
- // Same guard as demoteIdle below (2026-07-07 review fix): the
2002
- // runExclusive registers a serial waiter, which HOLDS BATCH ADMISSION
2003
- // until every running row drains — a background durability timer must
2004
- // never do that. Busy → re-arm and try again in 5 s (entries are
2005
- // immutable; a late snapshot is just as valid).
2006
- if (gateway.activeRows > 0 || gateway.pendingRows > 0) {
2007
- const t = setTimeout(fire, 5000);
2008
- t.unref?.();
2009
- ssdPending.set(key, t);
2010
- return;
2011
- }
2012
- void gateway.runExclusive(async () => {
2013
- const e = promptCache.findExact(tokens, ns);
2014
- if (!e) return null;
2015
- return { tokens: e.tokens, caches: cloneKvCaches(e.caches) };
2016
- }).then((snap) => {
2017
- if (!snap) return;
2018
- spillQueue!.enqueue({ tokens: snap.tokens, caches: snap.caches, ns });
2019
- }).catch(() => { /* cold tier is best-effort */ });
685
+ const durability = ssdStore && spillQueue && writeBehindOn
686
+ ? new SsdDurabilityCoordinator(
687
+ gateway,
688
+ promptCache,
689
+ spillQueue,
690
+ cloneState,
691
+ (tokens, ns) => ssdStore.hasDurablePrefix(tokens, ns),
692
+ )
693
+ : null;
694
+ const durabilityStats = (): DurabilitySnapshotStats =>
695
+ durability?.stats ?? {
696
+ pendingSnapshots: 0,
697
+ pendingSpills: spillQueue?.pendingCount ?? 0,
698
+ pendingSpillBytes: spillQueue?.pendingBytes ?? 0,
699
+ droppedSpills: spillQueue?.droppedCount ?? 0,
700
+ failedSpills: spillQueue?.failedCount ?? 0,
701
+ };
702
+ const flushDurability = async (): Promise<DurabilityFlushResult> => {
703
+ if (durability) return durability.flush();
704
+ const started = performance.now();
705
+ await spillQueue?.drain();
706
+ const stats = durabilityStats();
707
+ return {
708
+ ...stats,
709
+ durable: stats.pendingSpills === 0,
710
+ flushedSnapshots: 0,
711
+ missingSnapshots: 0,
712
+ elapsedMs: performance.now() - started,
2020
713
  };
2021
- ssdPending.set(key, setTimeout(fire, 1000));
2022
714
  };
2023
715
  // Every put() — serial lane AND batch scheduler — schedules the snapshot
2024
716
  // (Layer 0: batch-lane entries survive restarts too, not just evictions).
2025
- promptCache.onPut = (tokens, ns) => scheduleSsdSnapshot(tokens, ns);
717
+ promptCache.onPut = durability ? (tokens, ns) => durability.schedule(tokens, ns) : null;
2026
718
 
2027
719
  // Idle demotion (Layer 0): entries unused for --ssd-demote-idle seconds
2028
720
  // spill to SSD and free their GPU memory — the RAM tier drains between
@@ -2037,7 +729,7 @@ export function createServer(
2037
729
  let demoteTimer: ReturnType<typeof setInterval> | null = null;
2038
730
  if (ssdStore && demoteIdleMs > 0) {
2039
731
  demoteTimer = setInterval(() => {
2040
- if (gateway.activeRows > 0 || gateway.pendingRows > 0) return;
732
+ if (gateway.busy) return;
2041
733
  void gateway.runExclusive(async () => {
2042
734
  const n = promptCache.demoteIdle(demoteIdleMs);
2043
735
  if (n > 0) console.log(`[ssd-cache] demoted ${n} idle entr${n === 1 ? "y" : "ies"} to disk`);
@@ -2046,52 +738,12 @@ export function createServer(
2046
738
  demoteTimer.unref?.();
2047
739
  }
2048
740
 
2049
- // The lane picker: routes each request to the serial path (runGeneration,
2050
- // above) or the continuous-batching scheduler, keeping the two off the GPU
2051
- // (and shared loraState) at the same time. See src/serve/generation-gateway.ts.
2052
- const gateway = new GenerationGateway(ctx.model, batch, runGeneration, {
2053
- kvBudgetBytes: serverOptions.kvBudgetBytes,
2054
- // Phase 3.1: the server-wide scheme travels to the gateway once; the
2055
- // gateway batches kv-quant requests when the scheme is the batchable
2056
- // kvConfig/full-attention composition (see #kvBatchable) and the
2057
- // scheduler applies it — otherwise those requests route serial as before.
2058
- kvScheme,
2059
- // Phase 3.2: batch-lane prompt-cache reuse — joiners take() the longest
2060
- // usable prefix (multi-turn chat TTFT under --batch N); never-merged
2061
- // lone rows put() back on finish. Vision/adapter requests never batch,
2062
- // so the serial lane's bypass rules are preserved by routing.
2063
- promptCache,
2064
- });
2065
-
2066
- // Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
2067
- // fit() solves max safe context from weights + KV growth + prefill
2068
- // transient; the KV term assumes bf16 (a kv-quant scheme stretches the
2069
- // real ceiling, never shrinks it — admission stays conservative).
2070
- const genericAdmission = fit(
2071
- ctx.model.config, ctx.model.weightsBytes, 1,
2072
- undefined, undefined, 0, serverOptions.memoryBudgetBytes,
2073
- );
2074
- const admission = ctx.glmMemoryPlan
2075
- ? {
2076
- ...genericAdmission,
2077
- maxSafeContext: ctx.glmMemoryPlan.contextTokens,
2078
- usableBytes: ctx.glmMemoryPlan.processLimitBytes,
2079
- }
2080
- : genericAdmission;
2081
- if (admission.maxSafeContext < 1)
2082
- throw new Error(
2083
- `memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB cannot serve ` +
2084
- `${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB): ` +
2085
- `no context fits — raise the budget or pick a smaller model`,
2086
- );
2087
- if (ctx.glmMemoryPlan)
2088
- setMemoryLimit(ctx.glmMemoryPlan.lineItems.allocatorReserveBytes);
2089
- else if (serverOptions.memoryBudgetBytes)
2090
- setMemoryLimit(serverOptions.memoryBudgetBytes);
741
+ const sessionEngine = createSessionCompletionEngine(gateway, disposeUnstartedRequest);
742
+ const completionExecutor = new CompletionExecutor(sessionEngine);
2091
743
 
2092
744
  // /library response cache (30 s) — registry + config reads only.
2093
- let libraryCache: { at: number; rows: unknown[] } | null = null;
2094
745
  const startedAt = Date.now();
746
+ const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt);
2095
747
 
2096
748
  // Captured so the WebSocket handler can resolve the bound (possibly
2097
749
  // ephemeral) port lazily for the loopback pi provider.
@@ -2114,207 +766,37 @@ export function createServer(
2114
766
  return jobStore;
2115
767
  };
2116
768
 
2117
- // (kvScheme is resolved earlier, above the prompt-cache construction —
2118
- // the SSD tier's compatibility fingerprint folds the effective scheme in.)
2119
-
2120
- // XTC never removes EOS or the newline token: mlx_lm.server passes
2121
- // [tokenizer.eos_token_id, tokenizer.encode("\n")] as xtc_special_tokens.
2122
- // We pass the flat equivalent — ALL configured EOS ids plus the bare newline
2123
- // id(s), encoded WITHOUT special tokens (mlx-lm's encode("\n") can drag a
2124
- // BOS along on some tokenizers; protecting BOS from XTC is a no-op, so the
2125
- // flat/no-BOS form is behaviorally identical and cleaner).
2126
- const xtcSpecialTokens = [
2127
- ...ctx.model.config.eosTokenIds,
2128
- ...ctx.tokenizer.encode("\n", false),
2129
- ];
2130
-
2131
- // Effective enable_thinking for a request, with the same precedence the chat
2132
- // template uses (extracted so sampling and template rendering can't disagree):
2133
- // explicit chat_template_kwargs.enable_thinking → reasoning_effort ("none" =
2134
- // off) → server --thinking default → model default (MiniCPM5 → off). undefined
2135
- // means "not a switchable-thinking model / leave the template default".
2136
- const resolveEnableThinking = (req: ChatRequest): boolean | undefined => {
2137
- const explicit = req.chat_template_kwargs?.enable_thinking;
2138
- if (typeof explicit === "boolean") return explicit;
2139
- const effort = req.reasoning_effort;
2140
- if (effort !== undefined) return effort !== "none";
2141
- if (serverOptions.defaultThinking !== undefined) return serverOptions.defaultThinking;
2142
- return isMiniCPM5Config(ctx.model.config) ? false : undefined;
2143
- };
2144
-
2145
- const toOptions = (req: ChatRequest): GenerateOptions & { stopSequences: string[] } => {
2146
- // Sampling follows the thinking state — which the web UI's thinking button
2147
- // drives via enable_thinking. Model authors publish a SINGLE
2148
- // generation_config temperature (the think-mode value) but recommend a
2149
- // cooler one for direct, no-think replies (MiniCPM5 card: 0.9 think / 0.7
2150
- // no-think, top_p 0.95 for both). So with no explicit temperature set, a
2151
- // no-think turn runs at most NO_THINK_TEMPERATURE while a think turn keeps
2152
- // the model's hotter configured default. An explicit request/CLI
2153
- // temperature always wins. top_p is unchanged by mode.
2154
- const NO_THINK_TEMPERATURE = 0.7;
2155
- const genTemp = ctx.genDefaults.temperature ?? 0.7;
2156
- const defaultTemp = resolveEnableThinking(req) === false
2157
- ? Math.min(genTemp, NO_THINK_TEMPERATURE)
2158
- : genTemp;
2159
- return {
2160
- // A thinking turn emits <think>…</think> AND the answer, so a tight cap
2161
- // truncates the visible reply. Default very generously (the model's
2162
- // context is far larger); only an explicit max_tokens narrows it. The
2163
- // model still stops at its eos_token well before this in normal replies.
2164
- maxTokens: req.max_completion_tokens ?? req.max_tokens ??
2165
- defaultGeneratedTokens ?? 65_536,
2166
- temperature: req.temperature ?? serverOptions.defaultTemperature ?? defaultTemp,
2167
- topP: req.top_p ?? serverOptions.defaultTopP ?? ctx.genDefaults.topP ?? 0,
2168
- topK: req.top_k ?? serverOptions.defaultTopK ?? ctx.genDefaults.topK ?? 0,
2169
- seed: req.seed ?? nextDefaultSeed(),
2170
- repetitionPenalty: req.repetition_penalty ?? ctx.genDefaults.repetitionPenalty,
2171
- // mlx_lm.server sampling extensions (defaults mirror server.py: min_p /
2172
- // xtc off, context windows 20). parseLogitBias throws on non-numeric
2173
- // input — callers surface it as a 400 (mlx-lm's coercion error).
2174
- minP: req.min_p ?? 0,
2175
- xtcProbability: req.xtc_probability ?? 0,
2176
- xtcThreshold: req.xtc_threshold ?? 0,
2177
- ...(req.xtc_probability ? { xtcSpecialTokens } : {}),
2178
- logitBias: parseLogitBias(req.logit_bias),
2179
- repetitionContextSize: req.repetition_context_size ?? 20,
2180
- presencePenalty: req.presence_penalty ?? 0,
2181
- presenceContextSize: req.presence_context_size ?? 20,
2182
- frequencyPenalty: req.frequency_penalty ?? 0,
2183
- frequencyContextSize: req.frequency_context_size ?? 20,
2184
- hlg: resolveHlg(req.hlg, serverOptions.hlg),
2185
- // generate() yields token ids; stop sequences match on decoded text
2186
- // (they can span token boundaries), so the StopMatcher sits at the
2187
- // decode layer below and halts the loop via onToken → false.
2188
- stopSequences: (typeof req.stop === "string" ? [req.stop] : req.stop ?? [])
2189
- .filter((s) => typeof s === "string" && s.length > 0),
2190
- ...kvScheme,
2191
- // Paged KV is a server-wide mode like the kv scheme above (v1:
2192
- // serial bf16 gemma4 — the createServer refusals hold the invariants).
2193
- ...(serverOptions.pagedKv ? { pagedKv: serverOptions.pagedKv } : {}),
2194
- };
2195
- };
2196
-
2197
- /** Compile a grammar controller from the request's structured-output fields
2198
- * (response_format / guided_* / structured_outputs). Returns null when no
2199
- * constraint is requested OR when compile failed (degrade path). On degrade,
2200
- * `degradeHint` carries a human description for the system-prompt injection
2201
- * + Warning header (oMLX parity — never 500). Honors MLX_BUN_GRAMMAR=0. */
2202
- const compileGrammarForRequest = async (
2203
- req: ChatRequest,
2204
- ): Promise<{ controller: import("./grammar").GrammarController | null; degradeHint: string | null }> => {
2205
- if (!grammarEnabled()) {
2206
- return {
2207
- controller: null,
2208
- degradeHint: "grammar compilation disabled by MLX_BUN_GRAMMAR=0",
2209
- };
2210
- }
2211
- const r = await compileGrammarRequest(
2212
- req as GrammarRequest,
2213
- ctx.tokenizer,
2214
- ctx.model.config.text.vocabSize,
2215
- );
2216
- if (!r) return { controller: null, degradeHint: null };
2217
- return { controller: r.controller, degradeHint: r.degradeHint };
2218
- };
2219
-
2220
- // Map OpenAI reasoning_effort levels onto the Qwen3.8 template's supported
2221
- // set (xhigh|medium|low — the template raises on anything else). "none"
2222
- // means thinking off (handled by resolveEnableThinking), so no depth is
2223
- // passed. Only consumed for templates with readsReasoningEffort.
2224
- const qwenReasoningEffort = (
2225
- effort: ChatRequest["reasoning_effort"],
2226
- ): "xhigh" | "medium" | "low" | undefined => {
2227
- switch (effort) {
2228
- case "minimal":
2229
- case "low":
2230
- return "low";
2231
- case "medium":
2232
- return "medium";
2233
- case "high":
2234
- case "xhigh":
2235
- return "xhigh";
2236
- default:
2237
- return undefined; // "none" / unset → template default (xhigh)
2238
- }
2239
- };
2240
-
2241
- const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
2242
- // enableThinking resolution (and its precedence) lives in
2243
- // resolveEnableThinking so template rendering and sampling stay in sync.
2244
- return {
2245
- tools,
2246
- enableThinking: resolveEnableThinking(req),
2247
- reasoningEffort: qwenReasoningEffort(req.reasoning_effort),
2248
- preserveThinking: req.chat_template_kwargs?.preserve_thinking,
2249
- };
2250
- };
2251
-
2252
- const promptIdsFor = (
2253
- req: ChatRequest,
2254
- tools: ToolDefinition[] | null,
2255
- ): { ids: number[]; startInThinking: boolean } => {
2256
- const opts = templateOptionsFor(req, tools);
2257
- const rendered = ctx.template.render(normalizeMessages(req.messages), opts);
2258
- const ids = ctx.tokenizer.encode(rendered);
2259
- // template includes <bos>; tokenizer post-processor also prepends one
2260
- const trimmed = ids[0] === ids[1] && ids[0] === ctx.tokenizer.bosTokenId ? ids.slice(1) : ids;
2261
- return { ids: trimmed, startInThinking: promptEndsInOpenThink(rendered) };
2262
- };
2263
-
2264
- /** STABLE cache boundary: the prompt's tail is a generation primer (e.g.
2265
- * 12B's `<|channel>thought` tokens) that the NEXT turn's re-render does
2266
- * not contain — a cache entry ending there can never prefix-match again
2267
- * once the rings wrap. Probe by rendering this conversation as if a
2268
- * reply existed; the common prefix is what every future turn preserves.
2269
- * The probe is EXPENSIVE (a second full render + encode — ~150 ms at
2270
- * 16k with our tokenizer; 30-50% of the 12B ctx-repeat TTFT,
2271
- * 2026-07-06b), but the PRIMER LENGTH it measures is constant per
2272
- * template mode: the divergence sits after a special-token turn
2273
- * delimiter, and special tokens break BPE merges, so message content
2274
- * can't shift it. So the full probe runs ONCE per mode and later
2275
- * requests pay a subtraction. (A wrong boundary is a QUALITY knob, not
2276
- * a correctness one — any prefix ≤ len-1 is a valid snapshot point.) */
2277
- const primerLenByMode = new Map<string, number>();
2278
- const stableLenFor = (
2279
- req: ChatRequest,
2280
- tools: ToolDefinition[] | null,
2281
- trimmed: number[],
2282
- ): number => {
2283
- const opts = templateOptionsFor(req, tools);
2284
- const mode = `${opts.enableThinking}|${!!tools?.length}|${opts.reasoningEffort ?? ""}|${opts.preserveThinking ?? ""}`;
2285
- const primer = primerLenByMode.get(mode);
2286
- if (primer !== undefined) return Math.max(0, trimmed.length - primer);
2287
- let stableLen = trimmed.length;
769
+ // Per-request preparation (options/template/prompt-ids/grammar/router)
770
+ // and the chat core, each built once with their collaborators injected.
771
+ // The request pipeline, composed once:
772
+ // new ChatRequest(body) → chatStage.run → InferenceRequest
773
+ // → inferenceStage.admit → inferenceStage.run → InferenceResult → wire
774
+ // (/v1/completions substitutes textStage; Anthropic and Responses reuse
775
+ // chatStage with their own wire formats.)
776
+ const prep = createRequestPrep({ ctx, serverOptions, kvScheme, defaultGeneratedTokens });
777
+ const { templateOptionsFor } = prep;
778
+ const preparation = createPreparationExecutor((work, signal) => gateway.runExclusive(work, undefined, signal), batch);
779
+ const chatStage = new ChatStage(
780
+ ctx, prep, promptCache, admission.maxSafeContext, serverOptions.defaultAdapter,
781
+ preparation, serving.buildPrompt);
782
+ const textStage = new TextCompletionStage(
783
+ ctx, prep, admission.maxSafeContext, defaultGeneratedTokens, serverOptions.defaultAdapter);
784
+ const inferenceStage = new InferenceStage(completionExecutor);
785
+ const openAiMeta = (id: string) => ({ id, created: Math.floor(Date.now() / 1000), model: ctx.modelId });
786
+ /** Parse the JSON body under the request's trace; a bad body is a 400. */
787
+ const parseBody = async <T,>(request: Request, trace: PromptResponseTrace | undefined): Promise<T | Response> => {
788
+ const closeBodyParse = trace?.begin("request.body_parse");
2288
789
  try {
2289
- const probe = ctx.tokenizer.encode(
2290
- ctx.template.render(
2291
- [...normalizeMessages(req.messages), { role: "assistant", content: "x" }],
2292
- opts,
2293
- ),
2294
- );
2295
- const probeTrimmed =
2296
- probe[0] === probe[1] && probe[0] === ctx.tokenizer.bosTokenId ? probe.slice(1) : probe;
2297
- let i = 0;
2298
- const n = Math.min(trimmed.length, probeTrimmed.length);
2299
- while (i < n && trimmed[i] === probeTrimmed[i]) i++;
2300
- stableLen = i;
2301
- // Memoize the mode's primer length (sanity-capped: a "primer" longer
2302
- // than 64 tokens means the probe diverged for content reasons —
2303
- // don't generalize that).
2304
- const p = trimmed.length - stableLen;
2305
- if (p >= 0 && p <= 64) primerLenByMode.set(mode, p);
2306
- } catch { /* probe is best-effort; full boundary is the fallback */ }
2307
- return stableLen;
790
+ return (await request.json()) as T;
791
+ } catch {
792
+ trace?.finish("error", { stage: "body_parse" });
793
+ return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
794
+ } finally {
795
+ closeBodyParse?.();
796
+ }
2308
797
  };
2309
798
 
2310
- const toolStreamMode = (tools: ToolDefinition[] | null): ToolStreamMode =>
2311
- // Gemma-4 keeps the token-level sentinel path; every other family
2312
- // (MiniCPM5, Qwen3/3.5, Tier-0 generics) emits tool calls as DECODED TEXT
2313
- // — see selectToolStreamMode for why sentinel ids must stay family-gated.
2314
- selectToolStreamMode(ctx.model.config.modelType, !!tools?.length);
2315
799
 
2316
- const toolRouter = (tools: ToolDefinition[] | null): ToolAwareStream =>
2317
- new ToolAwareStream(ctx.tokenizer, toolStreamMode(tools), tools);
2318
800
 
2319
801
  if (serverOptions.unixSocket) {
2320
802
  try { require("node:fs").unlinkSync(serverOptions.unixSocket); } catch {}
@@ -2355,250 +837,36 @@ export function createServer(
2355
837
  async fetch(request, server) {
2356
838
  const url = new URL(request.url);
2357
839
 
840
+ if (url.pathname === "/admin/cache/flush" && request.method === "POST") {
841
+ const result = await flushDurability();
842
+ return Response.json(
843
+ {
844
+ ...result,
845
+ entries: ssdStore?.entries ?? 0,
846
+ longest_durable_prefix_tokens:
847
+ ssdStore?.longestDurablePrefixTokens ?? 0,
848
+ },
849
+ { status: result.durable ? 200 : 503 },
850
+ );
851
+ }
852
+
2358
853
  if (url.pathname === "/ws/chat") {
2359
854
  if (server.upgrade(request, { data: { sessionId: crypto.randomUUID() } as PiWsData }))
2360
855
  return undefined;
2361
856
  return new Response("expected websocket", { status: 426 });
2362
857
  }
2363
858
 
2364
- // Memory REST wrappers (web-chat-redesign.md §2.3/§9 Phase 2): thin
2365
- // loopback JSON routes over src/memory/vault.ts for the web chat's
2366
- // Memory panel. Handlers live in src/memory/rest.ts (pure functions,
2367
- // no `ctx` dependency) so they're unit-testable without a loaded
2368
- // model; dispatch here just matches path+method. The agent-tool
2369
- // surface (src/memory/tools.ts) is untouched by this block.
2370
- if (url.pathname === "/api/memory/status" && request.method === "GET") {
2371
- const { handleMemoryStatus } = await import("./memory/rest");
2372
- return handleMemoryStatus();
2373
- }
2374
- if (url.pathname === "/api/memory/list" && request.method === "GET") {
2375
- const { handleMemoryList } = await import("./memory/rest");
2376
- return handleMemoryList();
2377
- }
2378
- if (url.pathname === "/api/memory/search" && request.method === "GET") {
2379
- const { handleMemorySearch } = await import("./memory/rest");
2380
- return handleMemorySearch(url);
2381
- }
2382
- if (url.pathname === "/api/memory/article" && request.method === "GET") {
2383
- const { handleMemoryArticle } = await import("./memory/rest");
2384
- return handleMemoryArticle(url);
2385
- }
2386
- if (url.pathname === "/api/memory/links" && request.method === "GET") {
2387
- const { handleMemoryLinks } = await import("./memory/rest");
2388
- return handleMemoryLinks(url);
2389
- }
2390
- if (url.pathname === "/api/memory/history" && request.method === "GET") {
2391
- const { handleMemoryHistory } = await import("./memory/rest");
2392
- return handleMemoryHistory(url);
2393
- }
2394
- if (url.pathname === "/api/memory/diff" && request.method === "GET") {
2395
- const { handleMemoryDiff } = await import("./memory/rest");
2396
- return handleMemoryDiff(url);
2397
- }
2398
- if (url.pathname === "/api/memory/init" && request.method === "POST") {
2399
- const { handleMemoryInit } = await import("./memory/rest");
2400
- return handleMemoryInit(request);
2401
- }
2402
-
2403
- // Model Hub (web-chat-redesign.md §9 Phase 3, beat-matrix Axis 3):
2404
- // thin loopback JSON routes over the registry/download/fit machinery
2405
- // for the web chat's Hub panel. Handlers live in src/hub-rest.ts
2406
- // (pure functions, no `ctx` dependency); dispatch here just matches
2407
- // path+method, same convention as the memory wrappers above.
2408
- if (url.pathname === "/api/hub/local" && request.method === "GET") {
2409
- const { handleHubLocal } = await import("./hub-rest");
2410
- return handleHubLocal();
2411
- }
2412
- if (url.pathname === "/api/hub/search" && request.method === "GET") {
2413
- const { handleHubSearch } = await import("./hub-rest");
2414
- return handleHubSearch(url);
2415
- }
2416
- if (url.pathname === "/api/hub/download" && request.method === "POST") {
2417
- const { handleHubDownload } = await import("./hub-rest");
2418
- return handleHubDownload(request);
2419
- }
2420
- if (url.pathname === "/api/hub/serve" && request.method === "POST") {
2421
- const { handleHubServe } = await import("./hub-rest");
2422
- return handleHubServe(request);
2423
- }
859
+ const auxiliaryResponse = await handleAuxiliaryRoute(url, request);
860
+ if (auxiliaryResponse) return auxiliaryResponse;
2424
861
 
2425
- // Session full-text search + export (docs/design/web-chat-redesign.md
2426
- // §9 Phase 3, beat-matrix Axis 10/11 — the full-text-search BEAT and
2427
- // Markdown/JSON chat export). Handlers live in src/serve/session-search.ts
2428
- // (pure, read-only, no `ctx` dependency), dispatched here by
2429
- // path+method, same convention as the memory/hub blocks above.
2430
- if (url.pathname === "/api/sessions/search" && request.method === "GET") {
2431
- const { handleSessionsSearch } = await import("./serve/session-search");
2432
- return handleSessionsSearch(url);
2433
- }
2434
- if (url.pathname === "/api/sessions/export" && request.method === "GET") {
2435
- const { handleSessionsExport } = await import("./serve/session-search");
2436
- return handleSessionsExport(url);
2437
- }
862
+ const staticResponse = handleStaticRoute(url, request, STATIC_ROUTE_ASSETS);
863
+ if (staticResponse) return staticResponse;
2438
864
 
2439
- // The unified SPA is served at "/"; legacy deep links redirect into
2440
- // the hash router so old bookmarks still land on the right section.
2441
- if (url.pathname === "/" && request.method === "GET") {
2442
- return new Response(APP_PAGE, {
2443
- headers: { "content-type": "text/html; charset=utf-8" },
2444
- });
2445
- }
2446
- // Vendored syntax-highlighting assets (src/web/vendor/*, no CDN — see
2447
- // that dir's README). Long cache: content is versioned by the vendor
2448
- // rebuild step, not by URL, but a hard reload always wins during dev.
2449
- if (url.pathname === "/assets/hljs.js" && request.method === "GET") {
2450
- return new Response(HLJS_JS, {
2451
- headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
2452
- });
2453
- }
2454
- if (url.pathname === "/assets/hljs.css" && request.method === "GET") {
2455
- return new Response(HLJS_CSS, {
2456
- headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=3600" },
2457
- });
2458
- }
2459
- // The frontend bundle (see the import comment above) — cache header
2460
- // matches the other /assets/* entries; content is versioned by the
2461
- // build step, not the URL, and a hard reload always wins during dev.
2462
- if (url.pathname === "/assets/app.js" && request.method === "GET") {
2463
- return new Response(APP_JS, {
2464
- headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
2465
- });
2466
- }
2467
- // PWA manifest + icon (plan §9 Phase 3): linked from app.html's
2468
- // <link rel="manifest">. Same versioned-by-content cache posture as
2469
- // the other /assets/* entries above.
2470
- if (url.pathname === "/manifest.webmanifest" && request.method === "GET") {
2471
- return new Response(MANIFEST_WEBMANIFEST, {
2472
- headers: { "content-type": "application/manifest+json; charset=utf-8", "cache-control": "public, max-age=3600" },
2473
- });
2474
- }
2475
- if (url.pathname === "/assets/icon.svg" && request.method === "GET") {
2476
- return new Response(ICON_SVG, {
2477
- headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "public, max-age=3600" },
2478
- });
2479
- }
2480
- // Service worker MUST be served at the root scope (/sw.js, not
2481
- // /assets/sw.js) — a worker's default scope is the directory it's
2482
- // served from, and the shell it manages includes "/" itself.
2483
- // no-store: the browser's own update check needs a fresh byte
2484
- // comparison every time to notice a new worker, not a cached 304.
2485
- if (url.pathname === "/sw.js" && request.method === "GET") {
2486
- return new Response(SW_JS, {
2487
- headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" },
2488
- });
2489
- }
2490
- // v2 HLG Curve Designer — served same-origin so /generate + /signal need no CORS.
2491
- // Read fresh from disk in dev (edits show on reload, no restart); fall back to the
2492
- // embedded copy when running as the compiled single binary.
2493
- if (url.pathname === "/curves" && request.method === "GET") {
2494
- let html = CURVE_PAGE;
2495
- try { html = readFileSync(new URL("./assets/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
2496
- return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
2497
- }
2498
- // Training/inference DAG map — self-contained cytoscape artifact, served
2499
- // same-origin so the Routes tab can embed it in an <iframe src="/dag">.
2500
- if (url.pathname === "/dag" && request.method === "GET") {
2501
- try {
2502
- const html = readFileSync(new URL("../docs/dag/training-inference-map.html", import.meta.url), "utf8");
2503
- return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
2504
- } catch {
2505
- return new Response("DAG map artifact not found at docs/dag/training-inference-map.html", { status: 404 });
2506
- }
2507
- }
2508
- if (url.pathname === "/curve-terrain" && request.method === "GET") {
2509
- try {
2510
- const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
2511
- return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
2512
- } catch {
2513
- return new Response("curve terrain artifact not found; run scripts/experiments/curve-terrain.ts first", { status: 404 });
2514
- }
2515
- }
2516
- if (request.method === "GET" &&
2517
- ["/status", "/chat", "/quantize", "/finetune", "/dataset"].includes(url.pathname)) {
2518
- return Response.redirect(`/#${url.pathname}`, 302);
2519
- }
865
+ const discoveryResponse = await discoveryRoutes.handle(url, request);
866
+ if (discoveryResponse) return discoveryResponse;
2520
867
 
2521
- if (url.pathname === "/library" && request.method === "GET") {
2522
- // Everything on disk, each with a fit assessment for THIS machine
2523
- // (30 s cache — registry scan + config reads, no tensor bytes).
2524
- if (!libraryCache || Date.now() - libraryCache.at > 30_000) {
2525
- const { Registry } = await import("./registry");
2526
- const { loadModelConfig } = await import("./config");
2527
- const reg = new Registry();
2528
- // Always rescan when (re)building the 30s cache so models that appeared
2529
- // after boot — fresh downloads AND quants written into the HF cache —
2530
- // surface on their own. (scan() is INSERT-OR-REPLACE + prunes deleted,
2531
- // so it's idempotent and cheap for a local cache.)
2532
- await reg.scan();
2533
- const { visionCapable, audioCapable } = await import("./registry");
2534
- const { supportTier } = await import("./model/support");
2535
- const rows = [];
2536
- // listCanonical: one row per repo (refs/main) — duplicate snapshots
2537
- // from upstream re-pushes stay visible only in `ls --all-revisions`.
2538
- for (const m of reg.listCanonical()) {
2539
- const tier = supportTier(m.modelType, m.repoId);
2540
- const supported = tier !== null;
2541
- let assessment = null;
2542
- try {
2543
- const config = await loadModelConfig(m.path);
2544
- const r = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
2545
- assessment = {
2546
- fits: r.fits,
2547
- max_safe_context: r.maxSafeContext,
2548
- predicted_decode_tps: r.predictedDecodeTps,
2549
- };
2550
- } catch {}
2551
- rows.push({
2552
- repo_id: m.repoId, model_type: m.modelType,
2553
- size_bytes: m.sizeBytes, quant_bits: m.quantBits,
2554
- vision: visionCapable(m), audio: audioCapable(m),
2555
- supported, support_tier: tier,
2556
- serving: m.repoId === ctx.modelId,
2557
- assessment,
2558
- });
2559
- }
2560
- libraryCache = { at: Date.now(), rows };
2561
- }
2562
- return Response.json({ models: libraryCache.rows });
2563
- }
2564
-
2565
- if (url.pathname === "/downloads" && request.method === "GET") {
2566
- const { downloadsSnapshot } = await import("./download");
2567
- return Response.json({ downloads: downloadsSnapshot() });
2568
- }
2569
-
2570
- // Memory synthesis progress (P8-T5). The same DAG the nightly launchd job
2571
- // runs (`mlx-bun memory synthesize`), streamed as Server-Sent Events so the
2572
- // status page can show live stage/log/done progress. `?dry=1` plans the DAG
2573
- // without any model call or vault write — the safe wiring-verification path
2574
- // (the FULL-corpus run is USER-ACTION, P6-T5). GET so EventSource can drive it.
2575
- if (url.pathname === "/v1/memory/synthesize" && request.method === "GET") {
2576
- const dryRun = url.searchParams.get("dry") === "1";
2577
- const { runSynthesis } = await import("./memory/pipeline");
2578
- const enc = new TextEncoder();
2579
- const stream = new ReadableStream({
2580
- async start(controller) {
2581
- const send = (e: unknown) =>
2582
- controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
2583
- try {
2584
- const summary = await runSynthesis({ dryRun }, (ev) => send(ev));
2585
- send({ type: "summary", ...summary });
2586
- controller.enqueue(enc.encode("data: [DONE]\n\n"));
2587
- } catch (e) {
2588
- send({ type: "error", message: (e as Error).message });
2589
- } finally {
2590
- controller.close();
2591
- }
2592
- },
2593
- });
2594
- return new Response(stream, {
2595
- headers: {
2596
- "content-type": "text/event-stream",
2597
- "cache-control": "no-cache",
2598
- connection: "keep-alive",
2599
- },
2600
- });
2601
- }
868
+ const modelAdminResponse = await handleModelAdminRoute(url, request, ctx, gateway);
869
+ if (modelAdminResponse) return modelAdminResponse;
2602
870
 
2603
871
  if (url.pathname === "/fit" && request.method === "GET") {
2604
872
  // Fit assessment for the status page: this-machine report at the
@@ -2674,6 +942,7 @@ export function createServer(
2674
942
  const report = fit(
2675
943
  ctx.model.config, ctx.model.weightsBytes, admission.maxSafeContext,
2676
944
  machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
945
+ resolvedKvScheme.fitOptions,
2677
946
  );
2678
947
  return Response.json({
2679
948
  machine: { chip: chip.name, ram_bytes: machine.ramBytes, bandwidth_gbs: machine.bandwidthGBs },
@@ -2686,6 +955,7 @@ export function createServer(
2686
955
  ctx.model.config, ctx.model.weightsBytes,
2687
956
  Math.min(8192, admission.maxSafeContext),
2688
957
  machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
958
+ resolvedKvScheme.fitOptions,
2689
959
  ).predictedDecodeTps,
2690
960
  measured_decode_tps: measured?.decodeTps ?? null,
2691
961
  measured_at: measured?.ts ?? null,
@@ -2707,18 +977,6 @@ export function createServer(
2707
977
  });
2708
978
  }
2709
979
 
2710
- if (url.pathname === "/v1" && request.method === "GET") {
2711
- return Response.json({
2712
- name: "mlx-bun", version: pkgVersion, model: ctx.modelId,
2713
- endpoints: [
2714
- "POST /v1/chat/completions", "POST /v1/completions", "POST /v1/messages",
2715
- "POST /v1/responses", "POST /v1/embeddings", "GET /v1/models",
2716
- "GET/POST/DELETE /v1/adapters", "GET /health",
2717
- "GET /stats", "GET /fit", "GET /library", "GET /downloads",
2718
- ],
2719
- });
2720
- }
2721
-
2722
980
  if (url.pathname === "/stats" && request.method === "GET") {
2723
981
  // Active KV scheme across ALL layers. Since Phase 9 rotating
2724
982
  // (sliding-window) caches quantize too, so every layer the
@@ -2730,7 +988,7 @@ export function createServer(
2730
988
  let kvMode = "bf16";
2731
989
  if (kvScheme.turboQuant) {
2732
990
  // v1: full-attention layers only (sliding-window stays bf16 —
2733
- // docs/design/turboquant-kv.md non-goal).
991
+ // docs/design/turboquant.md non-goal).
2734
992
  const fullAttn = layerTypes.filter((l) => l !== "sliding_attention").length;
2735
993
  kvMode = `turbo k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`;
2736
994
  kvLayers[`turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`] = fullAttn;
@@ -2762,17 +1020,17 @@ export function createServer(
2762
1020
  dir: serverOptions.ssdCacheDir,
2763
1021
  entries: ssdStore.entries,
2764
1022
  bytes: ssdStore.totalBytes,
2765
- max_bytes: ssdStore.maxBytes,
1023
+ max_bytes: Number.isFinite(ssdStore.maxBytes) ? ssdStore.maxBytes : null,
2766
1024
  restores: ssdStore.stats.restores,
2767
1025
  spills: ssdStore.stats.spills,
2768
1026
  restore_ms_last: Math.round(ssdStore.stats.restoreMsLast),
2769
1027
  demotions: promptCache.demotions,
2770
- // Bounded write-behind queue (2026-07-07): clones waiting for
2771
- // the idle-gated flush, and spills the byte cap dropped
2772
- // (each = one future cache miss, never a wrong result).
1028
+ pending_snapshots: durability?.stats.pendingSnapshots ?? 0,
2773
1029
  pending_spills: spillQueue?.pendingCount ?? 0,
2774
1030
  pending_spill_bytes: spillQueue?.pendingBytes ?? 0,
2775
1031
  dropped_spills: spillQueue?.droppedCount ?? 0,
1032
+ failed_spills: spillQueue?.failedCount ?? 0,
1033
+ longest_durable_prefix_tokens: ssdStore.longestDurablePrefixTokens,
2776
1034
  },
2777
1035
  } : {}),
2778
1036
  response_store: {
@@ -2797,33 +1055,7 @@ export function createServer(
2797
1055
  usable_bytes: admission.usableBytes,
2798
1056
  weights_bytes: ctx.model.weightsBytes,
2799
1057
  },
2800
- ...(ctx.glmMemoryPlan ? {
2801
- glm52: {
2802
- preset: ctx.glmMemoryPlan.preset,
2803
- planned_process_bytes: ctx.glmMemoryPlan.plannedProcessBytes,
2804
- process_limit_bytes: ctx.glmMemoryPlan.processLimitBytes,
2805
- context_tokens: ctx.glmMemoryPlan.contextTokens,
2806
- max_generation_tokens: ctx.glmMemoryPlan.maxGenerationTokens,
2807
- batch_size: ctx.glmMemoryPlan.batchSize,
2808
- dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
2809
- mtp: ctx.draft?.provider.id === "glm52-native-mtp",
2810
- mtp_draft_tokens: ctx.glmMemoryPlan.mtpDraftTokens,
2811
- resident_weight_bytes: ctx.glmMemoryPlan.lineItems.residentWeightsBytes,
2812
- main_expert_slab_bytes: ctx.glmMemoryPlan.lineItems.mainExpertSlabBytes,
2813
- mtp_expert_slab_bytes: ctx.glmMemoryPlan.lineItems.mtpExpertSlabBytes,
2814
- expert_runtime: ctx.model instanceof Glm52Model &&
2815
- ctx.model.expertRuntime
2816
- ? {
2817
- main_residency:
2818
- ctx.model.expertRuntime.manager.snapshot(),
2819
- mtp_residency:
2820
- ctx.model.expertRuntime.mtp?.manager.snapshot() ?? null,
2821
- last_turn: ctx.model.expertRuntime.lastTelemetry,
2822
- last_repin: ctx.model.expertRuntime.lastRepin,
2823
- }
2824
- : null,
2825
- },
2826
- } : {}),
1058
+ ...serving.diagnostics(),
2827
1059
  // --batch: configured cap, whether batching is live for this model,
2828
1060
  // and rows currently decoding in the batch.
2829
1061
  batch: {
@@ -2839,8 +1071,26 @@ export function createServer(
2839
1071
  });
2840
1072
  }
2841
1073
 
2842
- // mlx_lm.server parity: GET /health → the exact body it writes
2843
- // ('{"status": "ok"}', note the space) so byte-for-byte health checks pass.
1074
+ // A parent-managed GPU job holds this response open. The connection owns
1075
+ // the native lease, so parent death cannot strand the worker lock.
1076
+ if (serverOptions.unixSocket && url.pathname === "/admin/lease" && request.method === "POST") {
1077
+ await flushDurability();
1078
+ const lease = await gateway.acquireExecutionLease(request.signal);
1079
+ let released = false;
1080
+ const release = () => {
1081
+ if (released) return;
1082
+ released = true;
1083
+ request.signal.removeEventListener("abort", release);
1084
+ lease.dispose();
1085
+ };
1086
+ request.signal.addEventListener("abort", release, { once: true });
1087
+ if (request.signal.aborted) release();
1088
+ return new Response(new ReadableStream({
1089
+ start(controller) { controller.enqueue(new TextEncoder().encode("leased\n")); },
1090
+ cancel() { release(); },
1091
+ }), { headers: { "content-type": "application/octet-stream" } });
1092
+ }
1093
+
2844
1094
  // Engine-mode admin (unix-socket children only — never exposed on
2845
1095
  // TCP): drain = quiesce the gateway + demote the whole prompt cache
2846
1096
  // to the SSD tier. The pool calls this before evicting a model
@@ -2856,207 +1106,6 @@ export function createServer(
2856
1106
  return Response.json({ drained: true, demotions: promptCache.demotions });
2857
1107
  }
2858
1108
 
2859
- if (url.pathname === "/health" && request.method === "GET") {
2860
- return new Response('{"status": "ok"}', {
2861
- headers: { "content-type": "application/json" },
2862
- });
2863
- }
2864
-
2865
- // GET /v1/models — the served model FIRST (with our capability extras),
2866
- // then every other servable model the registry knows (mlx-lm scans the
2867
- // HF cache here; our registry is that scan, filtered to supported
2868
- // architectures). GET /v1/models/<id> filters to that id — same list
2869
- // shape, matching mlx_lm.server's handle_models_request.
2870
- if (
2871
- (url.pathname === "/v1/models" || url.pathname.startsWith("/v1/models/")) &&
2872
- request.method === "GET"
2873
- ) {
2874
- const filterId = url.pathname.length > "/v1/models/".length - 1
2875
- ? decodeURIComponent(url.pathname.slice("/v1/models/".length))
2876
- : null;
2877
- const created = Math.floor(startedAt / 1000);
2878
- // Model-author sampling defaults (generation_config.json), resolved once
2879
- // at load (web-ui-pass-plan.md #14 groundwork): lets a client (the web
2880
- // chat's sampling popover, external pi) show the SERVED model's actual
2881
- // recommended values instead of one hardcoded shape.
2882
- const genDefaultsOut = {
2883
- temperature: ctx.genDefaults.temperature ?? null,
2884
- top_p: ctx.genDefaults.topP ?? null,
2885
- top_k: ctx.genDefaults.topK ?? null,
2886
- };
2887
- const data: Array<Record<string, unknown>> = [{
2888
- id: ctx.modelId, object: "model", created, owned_by: "mlx-bun",
2889
- context_window:
2890
- ctx.glmMemoryPlan?.contextTokens ??
2891
- ctx.model.config.text.maxPositionEmbeddings,
2892
- // Capability flags for clients (CLI/external pi) that build a
2893
- // provider from discovery — `reasoning` gates the thinking toggle,
2894
- // `vision` the image input declaration, `audio` whether
2895
- // `input_audio` content parts are accepted (tower loaded or lazily
2896
- // loadable; same signals the web embed uses).
2897
- reasoning: ctx.template.supportsThinking,
2898
- vision: !!(ctx.vision || ctx.loadVision),
2899
- audio: !!(ctx.audio || ctx.loadAudio),
2900
- batch_mode: gateway.batchMode,
2901
- tools: true,
2902
- structured_output: true,
2903
- embeddings: isEmbeddingModel(ctx.model),
2904
- adapters: !(ctx.model instanceof Glm52Model),
2905
- training: !(ctx.model instanceof Glm52Model),
2906
- dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
2907
- mtp: ctx.draft?.provider.id === "glm52-native-mtp",
2908
- capabilities: {
2909
- chat_completions: true,
2910
- text_completions: true,
2911
- anthropic_messages: true,
2912
- responses: true,
2913
- streaming: true,
2914
- tools: true,
2915
- structured_output: true,
2916
- logprobs: true,
2917
- embeddings: isEmbeddingModel(ctx.model),
2918
- vision: !!(ctx.vision || ctx.loadVision),
2919
- audio: !!(ctx.audio || ctx.loadAudio),
2920
- adapters: !(ctx.model instanceof Glm52Model),
2921
- training: !(ctx.model instanceof Glm52Model),
2922
- },
2923
- gen_defaults: genDefaultsOut,
2924
- }];
2925
- try {
2926
- const { Registry, visionCapable } = await import("./registry");
2927
- const { supportTier } = await import("./model/support");
2928
- const reg = new Registry();
2929
- try {
2930
- if (reg.list().length === 0) await reg.scan();
2931
- // Canonical rows only — a repo with N snapshots is ONE model id.
2932
- for (const m of reg.listCanonical()) {
2933
- if (m.repoId === ctx.modelId) continue;
2934
- const tier = supportTier(m.modelType, m.repoId);
2935
- if (tier === null) continue;
2936
- data.push({
2937
- id: m.repoId, object: "model", created,
2938
- vision: visionCapable(m), tier,
2939
- });
2940
- }
2941
- } finally {
2942
- reg.close();
2943
- }
2944
- } catch { /* registry unavailable → served model only */ }
2945
- return Response.json({
2946
- object: "list",
2947
- data: filterId ? data.filter((m) => m.id === filterId) : data,
2948
- });
2949
- }
2950
-
2951
- // OpenAI embeddings API. Works when the SERVED model is an embedding model
2952
- // (plain Qwen3 / Qwen3-Embedding) — consistent with the single-model server
2953
- // design: `mlx-bun serve <embedding-model>` to use this. Optional non-standard
2954
- // `instruction` applies Qwen3-Embedding's query format. Embedding is a pure
2955
- // forward (no decode loop / gateway), so it runs inline.
2956
- if (url.pathname === "/v1/embeddings" && request.method === "POST") {
2957
- if (!isEmbeddingModel(ctx.model))
2958
- return Response.json({
2959
- error: {
2960
- message: `served model "${ctx.modelId}" is not an embedding model; ` +
2961
- `serve an embedding model (e.g. Qwen3-Embedding) to use /v1/embeddings`,
2962
- type: "invalid_request_error",
2963
- },
2964
- }, { status: 400 });
2965
- let body: { input?: string | string[]; instruction?: string };
2966
- try {
2967
- body = (await request.json()) as typeof body;
2968
- } catch {
2969
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
2970
- }
2971
- const inputs = Array.isArray(body.input) ? body.input : body.input != null ? [body.input] : [];
2972
- if (inputs.length === 0 || !inputs.every((s) => typeof s === "string"))
2973
- return Response.json({
2974
- error: { message: "`input` must be a string or array of strings", type: "invalid_request_error" },
2975
- }, { status: 400 });
2976
- const instruction = typeof body.instruction === "string" ? body.instruction : undefined;
2977
- const results = embedMany(ctx.model, ctx.tokenizer, inputs, instruction);
2978
- let totalTokens = 0;
2979
- const data = results.map((r, index) => {
2980
- totalTokens += r.tokens;
2981
- return { object: "embedding", index, embedding: Array.from(r.vector) };
2982
- });
2983
- return Response.json({
2984
- object: "list",
2985
- data,
2986
- model: ctx.modelId,
2987
- usage: { prompt_tokens: totalTokens, total_tokens: totalTokens },
2988
- });
2989
- }
2990
-
2991
- // Adapter admin (port of optiq registry semantics): list / mount /
2992
- // unmount. Mount and unmount go through the generation queue so
2993
- // they never race an in-flight forward pass.
2994
- if (url.pathname === "/v1/adapters/available" && request.method === "GET") {
2995
- // On-disk adapters that can be mounted (the chat selector's source).
2996
- // Every on-disk adapter is returned — the web chat routing chip (§5.2)
2997
- // needs incompatible entries visible-but-grayed, not silently dropped,
2998
- // so a user understands "10 adapters exist, 2 apply here" rather than
2999
- // seeing a shorter list with no explanation. `compatible` = the
3000
- // adapter's recorded base repo id matches the served model (compared
3001
- // on the bare name, lenient about the org); an adapter with no
3002
- // recorded base is treated as compatible (mount validates it for
3003
- // real). `mounted` flags ones already loaded so the UI auto-loads on
3004
- // select only if needed.
3005
- const { homedir } = await import("node:os");
3006
- const stores = [
3007
- `${homedir()}/.cache/mlx-bun-finetunes`,
3008
- `${homedir()}/.cache/mlx-bun/adapters`,
3009
- ];
3010
- const mounted = new Set(ctx.adapters.list().map((a) => a.id));
3011
- const bareName = (s: string) => s.split("/").pop()!.toLowerCase();
3012
- const servedName = bareName(ctx.modelId);
3013
- const adapters = (await listAvailableAdapters(stores))
3014
- .map((a) => ({
3015
- id: a.id, path: a.path, rank: a.rank, scale: a.scale,
3016
- base_model: a.baseModel, mounted: mounted.has(a.id),
3017
- compatible: a.baseModel == null || bareName(a.baseModel) === servedName,
3018
- }));
3019
- return Response.json({ adapters });
3020
- }
3021
- if (url.pathname === "/v1/adapters" && request.method === "GET") {
3022
- return Response.json({
3023
- adapters: ctx.adapters.list().map((a) => ({
3024
- id: a.id, path: a.path, rank: a.rank, scale: a.scale,
3025
- size_bytes: a.sizeBytes, mounted_layers: a.mountedLayers,
3026
- ram_bytes: a.ramBytes,
3027
- })),
3028
- });
3029
- }
3030
- if (url.pathname === "/v1/adapters" && request.method === "POST") {
3031
- let body: { id?: string; path?: string };
3032
- try {
3033
- body = (await request.json()) as typeof body;
3034
- } catch {
3035
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3036
- }
3037
- if (!body.id || !body.path)
3038
- return Response.json({ error: { message: "id and path required" } }, { status: 400 });
3039
- try {
3040
- // Under the gateway lock: mount/unmount mutate the shared adapter
3041
- // registry (unmount disposes arrays a running generation could still
3042
- // hold) — one mutual-exclusion domain with generation (D3).
3043
- const info = await gateway.runExclusive(() => ctx.adapters.mount(body.id!, body.path!));
3044
- return Response.json({
3045
- id: info.id, mounted_layers: info.mountedLayers,
3046
- rank: info.rank, scale: info.scale, ram_bytes: info.ramBytes,
3047
- });
3048
- } catch (e) {
3049
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3050
- }
3051
- }
3052
- if (url.pathname.startsWith("/v1/adapters/") && request.method === "DELETE") {
3053
- const id = decodeURIComponent(url.pathname.slice("/v1/adapters/".length));
3054
- const removed = await gateway.runExclusive(async () => ctx.adapters.unmount(id));
3055
- return removed > 0
3056
- ? Response.json({ id, removed_layers: removed })
3057
- : Response.json({ error: { message: `adapter ${id} not mounted` } }, { status: 404 });
3058
- }
3059
-
3060
1109
  // ---- Curve Designer: POST /signal {prompt} → next-token histogram over the curve's x-axis ----
3061
1110
  // One prefill forward; bins the real log-prob distribution so the editor can draw the
3062
1111
  // signal under the curve (you shape where the tokens actually are).
@@ -3066,27 +1115,14 @@ export function createServer(
3066
1115
  let sbody: { prompt?: string };
3067
1116
  try { sbody = (await request.json()) as typeof sbody; }
3068
1117
  catch { return Response.json({ error: "invalid JSON" }, { status: 400, headers: CURVE_CORS }); }
3069
- let sids = ctx.tokenizer.encode(ctx.template.render([{ role: "user", content: typeof sbody.prompt === "string" ? sbody.prompt : "" }], templateOptionsFor({} as ChatRequest, null)));
1118
+ let sids = ctx.tokenizer.encode(ctx.template.render([{ role: "user", content: typeof sbody.prompt === "string" ? sbody.prompt : "" }], templateOptionsFor({} as ChatRequestParams, null)));
3070
1119
  if (sids[0] === sids[1] && sids[0] === ctx.tokenizer.bosTokenId) sids = sids.slice(1);
3071
1120
  try {
3072
1121
  const NB = 80;
3073
1122
  // gateway.runExclusive: this raw forward must not run concurrently
3074
1123
  // with batched decode steps or a serial generation (GPU + shared
3075
1124
  // model state are single-owner — D3, one lock).
3076
- const result = await gateway.runExclusive(async () => {
3077
- const cache = ctx.model.makeCache();
3078
- try {
3079
- const logits = ctx.model.forward(sids, cache); // [1, L, V]
3080
- const [, Ln, V] = logits.shape as [number, number, number];
3081
- const last = logits.slice([0, Ln - 1, 0], [1, Ln, V]);
3082
- const f = last.toFloat32(); logits.dispose(); last.dispose();
3083
- let mx = -Infinity; for (const v of f) if (v > mx) mx = v;
3084
- let Z = 0; for (const v of f) Z += Math.exp(v - mx); const lse = mx + Math.log(Z);
3085
- const bins = new Array<number>(NB).fill(0);
3086
- for (const v of f) { const t = Math.max(0, Math.min(1, (v - lse - CURVE_UMIN) / (-CURVE_UMIN))); const bi = Math.min(NB - 1, Math.floor(t * NB)); bins[bi] = (bins[bi] ?? 0) + 1; }
3087
- return { bins, vocab: V };
3088
- } finally { for (const c of cache) c.dispose(); }
3089
- });
1125
+ const result = await gateway.runExclusive(() => serving.signal(sids, NB, CURVE_UMIN));
3090
1126
  return Response.json(result, { headers: CURVE_CORS });
3091
1127
  } catch (e) {
3092
1128
  return Response.json({ error: `signal failed: ${(e as Error).message}` }, { status: 500, headers: CURVE_CORS });
@@ -3095,7 +1131,7 @@ export function createServer(
3095
1131
 
3096
1132
  // ---- v2 HLG Curve Designer: POST /generate {prompt, curve, n, max_tokens, seed} ----
3097
1133
  // The drawn log-prob transfer curve REPLACES temperature+softmax entirely
3098
- // (src/curve-sampler.ts). The browser editor calls this; same curve object the
1134
+ // (src/lab/curve/curve-sampler.ts). The browser editor calls this; same curve object the
3099
1135
  // tool's "Copy values" emits is the one the sampler consumes — one contract.
3100
1136
  if (url.pathname === "/generate" && request.method === "OPTIONS")
3101
1137
  return new Response(null, { headers: CURVE_CORS });
@@ -3119,7 +1155,7 @@ export function createServer(
3119
1155
  const n = Math.max(1, Math.min(8, Math.floor(Number(body.n) || 3)));
3120
1156
  const maxTokens = Math.max(1, Math.min(256, Math.floor(Number(body.max_tokens) || 80)));
3121
1157
  const baseSeed = Number.isFinite(body.seed) ? Number(body.seed) >>> 0 : (Date.now() & 0xffffffff);
3122
- let ids = ctx.tokenizer.encode(ctx.template.render([{ role: "user", content: prompt }], templateOptionsFor({} as ChatRequest, null)));
1158
+ let ids = ctx.tokenizer.encode(ctx.template.render([{ role: "user", content: prompt }], templateOptionsFor({} as ChatRequestParams, null)));
3123
1159
  if (ids[0] === ids[1] && ids[0] === ctx.tokenizer.bosTokenId) ids = ids.slice(1);
3124
1160
  const samples: { text: string; junk: boolean }[] = [];
3125
1161
  try {
@@ -3128,9 +1164,14 @@ export function createServer(
3128
1164
  const genOpts: GenerateOptions = useCurve
3129
1165
  ? { curve, seed: baseSeed + i, maxTokens, ...kvScheme }
3130
1166
  : { temperature: recipe.temperature, topP: recipe.topP, topK: recipe.topK, seed: baseSeed + i, maxTokens, ...kvScheme };
3131
- // runExclusive: runGeneration touches the GPU + prompt cache, so
3132
- // it needs the gateway lock (this endpoint bypasses gateway.run).
3133
- await gateway.runExclusive(() => runGeneration(ids, genOpts, (t) => { toks.push(t); }));
1167
+ // The same plan and execution lease cover the Lab comparison endpoint.
1168
+ const shape = { hasVision: false, hasAdapters: false, hasRepetitionPenalty: false,
1169
+ hasLogitsExtras: false, wantsLogprobs: false, userSeed: true,
1170
+ kvQuant: !!(genOpts.kvBits || genOpts.kvConfig?.length), turboQuant: !!genOpts.turboQuant,
1171
+ hasGrammar: false, hasDraft: !!ctx.draft };
1172
+ const placement = gateway.place(shape, genOpts);
1173
+ await gateway.run(ids, genOpts, (t) => { toks.push(t); }, undefined,
1174
+ shape, placement, request.signal);
3134
1175
  const text = ctx.tokenizer.decode(toks, true).trim();
3135
1176
  samples.push({ text, junk: curveJunk(text) });
3136
1177
  }
@@ -3140,770 +1181,42 @@ export function createServer(
3140
1181
  return Response.json({ mode: useCurve ? "curve" : "default", recipe: useCurve ? undefined : recipe, n, seed: baseSeed, samples }, { headers: CURVE_CORS });
3141
1182
  }
3142
1183
 
3143
- // The chat-completions core, shared by both protocol surfaces:
3144
- // /v1/chat/completions calls it directly; /v1/messages (Anthropic)
3145
- // translates its body into this shape and the Response back —
3146
- // generation, tools, vision, stop sequences, prompt cache, and
3147
- // admission control all live here exactly once.
3148
- const handleChat = async (body: ChatRequest, signal: AbortSignal): Promise<Response> => {
3149
- if (!Array.isArray(body.messages) || body.messages.length === 0)
3150
- return Response.json({ error: { message: "messages required" } }, { status: 400 });
3151
- // mlx-lm validates logprobs params up front (ValueError → 400)
3152
- const lpParamError = validateLogprobsParams(body);
3153
- if (lpParamError)
3154
- return Response.json({ error: { message: lpParamError } }, { status: 400 });
3155
-
3156
- const id = `chatcmpl-${crypto.randomUUID()}`;
3157
- const created = Math.floor(Date.now() / 1000);
3158
- const tools =
3159
- body.tool_choice === "none" ? null : (body.tools?.length ? body.tools : null);
3160
- const hasImages = body.messages.some(
3161
- (m) => Array.isArray(m.content) &&
3162
- m.content.some((p: any) => p.type === "image_url" || p.type === "image"),
3163
- );
3164
- // Same shapes extractAudio accepts: OpenAI-canonical input_audio plus
3165
- // optiq's audio / audio_url aliases (audio-input-plan.md §3.2).
3166
- const hasAudio = body.messages.some(
3167
- (m) => Array.isArray(m.content) &&
3168
- m.content.some(
3169
- (p: any) => p.type === "input_audio" || p.type === "audio" || p.type === "audio_url",
3170
- ),
3171
- );
3172
- let promptIds: number[];
3173
- let stableLen: number | null = null;
3174
- // Whether the prompt primed an open <think> (Qwen3.5/MiniCPM5 thinking
3175
- // on) so the model's output starts mid-reasoning — seeds the splitter.
3176
- // Vision is Gemma4-only (no switchable thinking channel), so it's false.
3177
- let startInThinking = false;
3178
- let vision: Parameters<typeof runGeneration>[3];
3179
- let diffusionPixels: import("./mlx/array").MlxArray | null = null;
3180
- // Grammar-constrained decoding (src/grammar.ts). Compile BEFORE prompt
3181
- // rendering: on the degrade path (compile failed but a constraint was
3182
- // requested) inject a system message instructing valid JSON so the
3183
- // model still best-efforts schema-conformant output (oMLX parity —
3184
- // _compile_grammar_for_request returning None + build_json_system_prompt).
3185
- let grammarCtrl: import("./grammar").GrammarController | null = null;
3186
- let grammarWarning: string | null = null;
3187
- const grammarReq =
3188
- body.response_format != null || !!body.guided_grammar ||
3189
- !!body.guided_regex || !!body.guided_choice?.length ||
3190
- body.structured_outputs != null;
3191
- if (grammarReq) {
3192
- const g = await compileGrammarForRequest(body);
3193
- grammarCtrl = g.controller;
3194
- if (!g.controller && g.degradeHint) {
3195
- const degraded = applyGrammarDegrade(body, g.degradeHint);
3196
- grammarWarning = degraded.warning;
3197
- body = degraded.body;
3198
- }
3199
- }
3200
- // Free what a rejected request has already allocated (grammar WASM
3201
- // matcher/compiled-grammar, vision embeddings, diffusion pixels).
3202
- // Every early return between here and gateway.run() must call this —
3203
- // generate()/the gateway only take disposal ownership once running
3204
- // (leaked one matcher per SSRF-blocked-media + response_format
3205
- // request before the 2026-07-07 sweep).
3206
- const disposeRejected = () => {
3207
- grammarCtrl?.dispose();
3208
- vision?.embeddings.dispose();
3209
- vision?.imageMask?.dispose();
3210
- vision?.multimodalMask?.dispose();
3211
- diffusionPixels?.dispose();
3212
- };
3213
- try {
3214
- if (hasAudio) {
3215
- // Audio (and MIXED image+audio) input — A4 of
3216
- // docs/design/audio-input-plan.md. One buildMultimodalPrompt call
3217
- // splices both media kinds in document order. A request WITH
3218
- // audio on a model whose tower is unavailable is an explicit 400
3219
- // — never a silent text-only degrade (that leniency is only for
3220
- // requests without the media, getVisionTower's contract).
3221
- const audioTower = getAudioTower(ctx);
3222
- if (!audioTower || !ctx.audioTokenIds) {
3223
- disposeRejected();
3224
- return Response.json(
3225
- {
3226
- error: {
3227
- message:
3228
- `model ${ctx.modelId} has no audio tower — audio input needs ` +
3229
- `a model whose config.json carries audio_config and whose ` +
3230
- `sidecar ships the audio tensors (e.g. gemma-4 e4b OptiQ)`,
3231
- },
3232
- },
3233
- { status: 400 },
3234
- );
3235
- }
3236
- let visionSide:
3237
- | { tower: VisionEncoder; tokenIds: VisionTokenIds }
3238
- | undefined;
3239
- if (hasImages) {
3240
- const tower = getVisionTower(ctx);
3241
- if (!tower) {
3242
- disposeRejected();
3243
- return Response.json(
3244
- { error: { message: "model has no vision sidecar" } }, { status: 400 },
3245
- );
3246
- }
3247
- visionSide = { tower, tokenIds: ctx.visionTokenIds };
3248
- }
3249
- const { messages: withAudioParts, images } =
3250
- await extractImages(normalizeMessages(body.messages));
3251
- const { messages, audio } = await extractAudio(withAudioParts);
3252
- // Non-WAV containers (mp3/m4a/flac/ogg/aiff/…) transcode to
3253
- // 16 kHz WAV via CoreAudio; RIFF bytes pass through untouched.
3254
- // Failures throw into the prompt-build 400 below.
3255
- const wavs = await Promise.all(audio.map(ensureWav));
3256
- // The towers are only ever non-null for Gemma4 (loader gates).
3257
- const mp = await buildMultimodalPrompt(
3258
- ctx.model as Gemma4Model,
3259
- {
3260
- ...(visionSide ? { vision: visionSide } : {}),
3261
- audio: { tower: audioTower, tokenIds: ctx.audioTokenIds },
3262
- },
3263
- ctx.tokenizer, ctx.template, messages, images, wavs, tools,
3264
- );
3265
- promptIds = mp.ids;
3266
- // bidirMask is null whenever audio is present (§3.3 Q1: mixed
3267
- // prompts run fully causal); the union mask does the per-layer
3268
- // id zeroing either way.
3269
- vision = {
3270
- embeddings: mp.embeddings,
3271
- ...(mp.bidirMask ? { imageMask: mp.bidirMask } : {}),
3272
- multimodalMask: mp.multimodalMask,
3273
- };
3274
- } else if (hasImages && ctx.model instanceof DiffusionGemmaModel) {
3275
- // DiffusionGemma image-text-to-text: its OWN dedicated SigLIP tower +
3276
- // encoder vision merge feed the denoising engine (NOT the AR
3277
- // forwardEmbeddings path). v1 supports a single image.
3278
- const dm = ctx.model;
3279
- if (!dm.visionTower) {
3280
- disposeRejected();
3281
- return Response.json(
3282
- { error: { message: "this checkpoint has no vision tower" } }, { status: 400 },
3283
- );
3284
- }
3285
- const { messages, images } = await extractImages(normalizeMessages(body.messages));
3286
- if (images.length !== 1) {
3287
- disposeRejected();
3288
- return Response.json(
3289
- { error: { message: "DiffusionGemma image input supports exactly one image" } },
3290
- { status: 400 },
3291
- );
3292
- }
3293
- const rendered = ctx.template.render(messages, { tools, addGenerationPrompt: true });
3294
- const rawIds = ctx.tokenizer.encode(rendered, /* addSpecialTokens */ false);
3295
- const { pixels, softTokens } = await dm.visionTower.preprocess(images[0]!);
3296
- promptIds = spliceImageTokens(rawIds, [softTokens], {
3297
- image: ctx.visionTokenIds.imageTokenId,
3298
- boi: ctx.visionTokenIds.boiTokenId,
3299
- eoi: ctx.visionTokenIds.eoiTokenId,
3300
- });
3301
- diffusionPixels = pixels;
3302
- } else if (hasImages) {
3303
- // Loads (and caches) the tower on first image request — text-only
3304
- // sessions never pay for it.
3305
- const tower = getVisionTower(ctx);
3306
- if (!tower) {
3307
- disposeRejected();
3308
- return Response.json(
3309
- { error: { message: "model has no vision sidecar" } }, { status: 400 },
3310
- );
3311
- }
3312
- const { messages, images } = await extractImages(normalizeMessages(body.messages));
3313
- // The tower is only ever non-null for Gemma4 (sidecar gate in
3314
- // makeVisionLoader), so the model narrow is safe here.
3315
- const vp = await buildVisionPrompt(
3316
- ctx.model as Gemma4Model, tower, ctx.tokenizer, ctx.template,
3317
- messages, images, ctx.visionTokenIds, tools,
3318
- );
3319
- promptIds = vp.ids;
3320
- vision = { embeddings: vp.embeddings, imageMask: vp.imageMask };
3321
- } else {
3322
- const built = promptIdsFor(body, tools);
3323
- promptIds = built.ids;
3324
- startInThinking = built.startInThinking;
3325
- stableLen = -1; // marker: chat path, probe lazily below
3326
- }
3327
- } catch (e) {
3328
- disposeRejected();
3329
- return Response.json(
3330
- { error: { message: `prompt build failed: ${(e as Error).message}` } },
3331
- { status: 400 },
3332
- );
3333
- }
3334
- let options: ReturnType<typeof toOptions>;
3335
- try {
3336
- options = toOptions(body);
3337
- // Stable cache boundary for the prompt-boundary snapshot (chat
3338
- // prompts end in a generation primer the next turn won't contain).
3339
- // The probe costs a second full render+encode (~150 ms at 16k), so
3340
- // run it ONLY when a snapshot can actually be taken: the cache
3341
- // must not already hold the strict prefix (warm repeats peek at
3342
- // len-1 → skip; the snapshot gate needs boundary > cached prefix).
3343
- if (stableLen !== null) {
3344
- const cachePeek = promptCache.peekPrefixLen(
3345
- promptIds, options.adapters?.join("+") ?? "");
3346
- if (cachePeek < promptIds.length - 1)
3347
- options.snapshotAt = stableLenFor(body, tools, promptIds);
3348
- }
3349
- } catch (e) {
3350
- // bad logit_bias (non-numeric keys/values) — mlx-lm's coercion error
3351
- disposeRejected();
3352
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3353
- }
3354
- if (diffusionPixels) options.visionPixels = diffusionPixels;
3355
- // Attach the compiled grammar controller (null when no constraint /
3356
- // degrade — generate() runs the unmasked fast pipelined loop).
3357
- if (grammarCtrl) options.grammar = grammarCtrl;
3358
- // Admission: reject what cannot finish within the memory budget
3359
- // (the GPU OOM it would otherwise hit is uncatchable and kills
3360
- // the process — Phase 6 finding).
3361
- const requestedMaxTokens = options.maxTokens ?? 1024;
3362
- const requiredCtx = promptIds.length + requestedMaxTokens;
3363
- const contextDecision = admitRequestContext(
3364
- promptIds.length,
3365
- requestedMaxTokens,
3366
- admission.maxSafeContext,
3367
- ctx.glmMemoryPlan !== null,
3368
- );
3369
- if (!contextDecision) {
3370
- disposeRejected();
3371
- return Response.json(
3372
- {
3373
- error: {
3374
- message:
3375
- `request needs ${requiredCtx} tokens of context ` +
3376
- `(prompt ${promptIds.length} + max_tokens ${requestedMaxTokens}) but the ` +
3377
- `memory budget caps safe context at ${admission.maxSafeContext} — ` +
3378
- `shorten the prompt or lower max_tokens`,
3379
- type: "memory_admission",
3380
- code: "context_over_budget",
3381
- },
3382
- },
3383
- { status: 400 },
3384
- );
3385
- }
3386
- options.maxTokens = contextDecision.maxTokens;
3387
- try {
3388
- // A request's explicit `adapter` (incl. "none") wins over the
3389
- // startup default from `serve --adapter <dir>`.
3390
- const adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3391
- if (adapterIds.length) options.adapters = adapterIds;
3392
- } catch (e) {
3393
- disposeRejected();
3394
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3395
- }
3396
-
3397
- // logprobs capture: non-stream only — stream chunks never carry
3398
- // logprobs (mirroring mlx_lm.server, whose streaming generate_response
3399
- // calls pass no token_logprobs/top_tokens), so streaming requests skip
3400
- // the capture cost entirely and stay batchable.
3401
- const wantLogprobs = body.logprobs === true;
3402
- const topLogprobs =
3403
- typeof body.top_logprobs === "number" && body.top_logprobs > 0
3404
- ? body.top_logprobs : 0;
3405
- const captureLogprobs = !body.stream && (wantLogprobs || topLogprobs > 0);
3406
- if (captureLogprobs) {
3407
- options.logprobs = wantLogprobs;
3408
- options.topLogprobs = topLogprobs;
3409
- }
3410
-
3411
- // What lane this request takes (vision/audio media / adapters /
3412
- // logprobs / seed / explicit kv-quant / a mounted draft → serial;
3413
- // sampler extras, repetition penalty, and grammar all BATCH — see
3414
- // willBatch). `vision` is the embeddings-prefill payload for BOTH
3415
- // media kinds, so hasVision routes audio serial too — batching is a
3416
- // mode, not a fallback, and batched audio has no oracle (§3.2).
3417
- const shape = {
3418
- hasVision: !!vision,
3419
- hasAdapters: !!options.adapters?.length,
3420
- hasRepetitionPenalty: !!options.repetitionPenalty,
3421
- // Informational since 2026-07-02: per-row logits processors batch;
3422
- // willBatch no longer gates on these fields.
3423
- hasLogitsExtras: !!(
3424
- options.minP || options.xtcProbability || options.logitBias ||
3425
- options.presencePenalty || options.frequencyPenalty
3426
- ),
3427
- wantsLogprobs: captureLogprobs,
3428
- userSeed: body.seed !== undefined,
3429
- kvQuant: !!(options.kvConfig?.length || options.kvBits),
3430
- turboQuant: !!options.turboQuant,
3431
- // grammarCtrl is null on the degrade path (prompt injection) —
3432
- // those stay batchable. A real controller batches via per-row
3433
- // matchers (MLX_BUN_GRAMMAR_BATCH=0 forces it serial).
3434
- hasGrammar: !!grammarCtrl,
3435
- hasDraft: !!ctx.draft,
3436
- };
3437
- const batched = gateway.willBatch(shape);
3438
- if (process.env.MLX_BUN_LANE_DEBUG === "1")
3439
- console.error(`[lane] batched=${batched} shape=${JSON.stringify(shape)} t=${Date.now() % 100000}`);
3440
- // Per-turn lane (docs/design/web-chat-redesign.md §2.3 caveat / risk #5):
3441
- // reported on usage AND recorded in the in-process lane registry (keyed
3442
- // by this response's `id`) so pi-web's WS bridge — which the pi SDK's
3443
- // usage parsing can't carry a custom field through — can correlate it
3444
- // via AssistantMessage.responseId. batched wins over hasDraft (mutually
3445
- // exclusive by construction: willBatch already excludes hasDraft), so
3446
- // the only ambiguity is serial vs serial+spec, resolved once s.spec is
3447
- // known post-generation.
3448
- const lane: Lane = batched ? "batched" : shape.hasDraft ? "serial+spec" : "serial";
3449
- recordLane(id, lane);
3450
-
3451
- if (body.stream) {
3452
- const streamAbort = new AbortController();
3453
- const generationSignal = AbortSignal.any([signal, streamAbort.signal]);
3454
- let cancelled = false;
3455
- const stream = new ReadableStream<Uint8Array>({
3456
- start(controller) {
3457
- const enc = new TextEncoder();
3458
- const send = (obj: unknown) => {
3459
- if (!generationSignal.aborted)
3460
- controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3461
- };
3462
- const chunk = (delta: Record<string, unknown>, finish: string | null) => ({
3463
- id, object: "chat.completion.chunk", created, model: ctx.modelId,
3464
- choices: [{ index: 0, delta, finish_reason: finish }],
3465
- });
3466
- void (async () => {
3467
- try {
3468
- // The gateway owns lane selection + GPU exclusivity; this body
3469
- // runs per-request (concurrently in batched mode, each writing
3470
- // its own SSE stream — the per-row fan-out).
3471
- send(chunk({ role: "assistant", content: "" }, null));
3472
- const router = toolRouter(tools);
3473
- const stopper = new StopMatcher(options.stopSequences);
3474
- // <think>-text splitting is only for text-marker models; gemma's
3475
- // reasoning is already split at the token level by the router.
3476
- const thinking = new ThinkingTagSplitter(ctx.template.thinkingFormat === "think-tag", startInThinking);
3477
- // Serial decode is an unbroken microtask chain (FFI + generator
3478
- // resumes) — without a macrotask hop, Bun never services the
3479
- // socket and the whole SSE response flushes in one burst at the
3480
- // end (Phase 15: "687k tok/s decode"). Hopping EVERY token cost
3481
- // ~23% decode; rate-limited to ≥25 ms keeps the flush smooth and
3482
- // hides behind the next GPU step. Batched mode doesn't need it —
3483
- // the scheduler yields to the event loop between steps.
3484
- let lastFlush = performance.now();
3485
- const s = await gateway.run(promptIds, options, (token) => {
3486
- const rawContent = router.push(token);
3487
- // gemma-channel reasoning, split at the token level by the router
3488
- const rReason = router.takeReasoning();
3489
- if (rReason) send(chunk({ reasoning: rReason }, null));
3490
- const text = stopper.push(rawContent);
3491
- const parts = thinking.push(text);
3492
- if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
3493
- if (parts.content) send(chunk({ content: parts.content }, null));
3494
- if (stopper.stopped) return false; // halt generation
3495
- if (!batched && (parts.content || parts.reasoning || rReason)) {
3496
- const now = performance.now();
3497
- if (now - lastFlush >= 25) {
3498
- lastFlush = now;
3499
- return new Promise<void>((r) => setImmediate(r));
3500
- }
3501
- }
3502
- }, vision, shape, generationSignal);
3503
- if (generationSignal.aborted) return;
3504
- // a stop match discards everything from the match on,
3505
- // including text still held by the decoders
3506
- let tail = "";
3507
- if (!stopper.stopped) {
3508
- const flushed = router.flush();
3509
- const rReason = router.takeReasoning();
3510
- if (rReason) send(chunk({ reasoning: rReason }, null));
3511
- tail = stopper.push(flushed);
3512
- if (!stopper.stopped) tail += stopper.flush();
3513
- }
3514
- if (tail) {
3515
- const parts = thinking.push(tail);
3516
- if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
3517
- if (parts.content) send(chunk({ content: parts.content }, null));
3518
- }
3519
- {
3520
- const parts = thinking.flush();
3521
- if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
3522
- if (parts.content) send(chunk({ content: parts.content }, null));
3523
- }
3524
- const toolCalls = router.toolCalls();
3525
- if (toolCalls.length) {
3526
- send(chunk({
3527
- tool_calls: toolCalls.map((tc, i) => ({ index: i, ...tc })),
3528
- }, null));
3529
- }
3530
- const finish = toolCalls.length
3531
- ? "tool_calls"
3532
- : stopper.stopped ? "stop"
3533
- : s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
3534
- // Refine serial vs serial+spec now that s.spec is known (a mounted
3535
- // draft can still decode zero speculative tokens on a very short
3536
- // reply) and re-record for pi-web's WS correlation.
3537
- const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
3538
- if (finalLane !== lane) recordLane(id, finalLane);
3539
- send({
3540
- ...chunk({}, finish),
3541
- usage: {
3542
- prompt_tokens: s.promptTokens,
3543
- completion_tokens: s.generatedTokens,
3544
- total_tokens: s.promptTokens + s.generatedTokens,
3545
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3546
- lane: finalLane,
3547
- ...(s.spec ? { speculation: s.spec } : {}),
3548
- },
3549
- });
3550
- // bare sentinel per the OpenAI spec — JSON.stringify would
3551
- // quote it and strict SDK clients never see the terminator
3552
- controller.enqueue(enc.encode("data: [DONE]\n\n"));
3553
- } catch (e) {
3554
- if (!generationSignal.aborted)
3555
- send({ error: { message: (e as Error).message } });
3556
- } finally {
3557
- if (!cancelled) {
3558
- if (generationSignal.aborted) controller.error(generationSignal.reason);
3559
- else controller.close();
3560
- }
3561
- }
3562
- })();
3563
- },
3564
- cancel(reason) {
3565
- cancelled = true;
3566
- streamAbort.abort(reason);
3567
- },
3568
- });
3569
- return new Response(stream, {
3570
- headers: {
3571
- "content-type": "text/event-stream",
3572
- "cache-control": "no-cache",
3573
- connection: "keep-alive",
3574
- ...(grammarWarning ? { Warning: grammarWarning } : {}),
3575
- },
3576
- });
3577
- }
3578
-
3579
- try {
3580
- {
3581
- const router = toolRouter(tools);
3582
- const stopper = new StopMatcher(options.stopSequences);
3583
- const thinking = new ThinkingTagSplitter(ctx.template.thinkingFormat === "think-tag", startInThinking);
3584
- // mlx-lm collects logprobs across EVERY generated token (reasoning
3585
- // and tool tokens included), not just visible content — same here.
3586
- const lpc = captureLogprobs
3587
- ? new LogprobsCollector(wantLogprobs, topLogprobs, (tid) => ctx.tokenizer.idToToken(tid))
3588
- : null;
3589
- let content = "";
3590
- let reasoning = "";
3591
- const s = await gateway.run(promptIds, options, (token, lpInfo) => {
3592
- lpc?.push(token, lpInfo);
3593
- const rawContent = router.push(token);
3594
- reasoning += router.takeReasoning(); // gemma-channel thinking
3595
- const parts = thinking.push(stopper.push(rawContent));
3596
- content += parts.content;
3597
- reasoning += parts.reasoning;
3598
- if (stopper.stopped) return false; // halt generation
3599
- }, vision, shape, signal);
3600
- if (!stopper.stopped) {
3601
- const flushed = router.flush();
3602
- reasoning += router.takeReasoning();
3603
- let tail = stopper.push(flushed);
3604
- if (!stopper.stopped) tail += stopper.flush();
3605
- const parts = thinking.push(tail);
3606
- content += parts.content;
3607
- reasoning += parts.reasoning;
3608
- }
3609
- {
3610
- const parts = thinking.flush();
3611
- content += parts.content;
3612
- reasoning += parts.reasoning;
3613
- }
3614
- const toolCalls = router.toolCalls();
3615
- const finish = toolCalls.length
3616
- ? "tool_calls"
3617
- : stopper.stopped ? "stop"
3618
- : s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
3619
- const logprobsBlock = lpc?.payload() ?? null;
3620
- // Refine serial vs serial+spec now that s.spec is known; re-record
3621
- // for pi-web's WS correlation (see the streaming branch above).
3622
- const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
3623
- if (finalLane !== lane) recordLane(id, finalLane);
3624
- return Response.json({
3625
- id, object: "chat.completion", created, model: ctx.modelId,
3626
- choices: [{
3627
- index: 0,
3628
- message: {
3629
- role: "assistant",
3630
- content: content || (toolCalls.length ? null : ""),
3631
- ...(reasoning ? { reasoning } : {}),
3632
- ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
3633
- },
3634
- ...(logprobsBlock ? { logprobs: logprobsBlock } : {}),
3635
- finish_reason: finish,
3636
- }],
3637
- usage: {
3638
- prompt_tokens: s.promptTokens,
3639
- completion_tokens: s.generatedTokens,
3640
- total_tokens: s.promptTokens + s.generatedTokens,
3641
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3642
- lane: finalLane,
3643
- ...(s.spec ? { speculation: s.spec } : {}),
3644
- },
3645
- }, grammarWarning ? { headers: { Warning: grammarWarning } } : undefined);
3646
- }
3647
- } catch (e) {
3648
- return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
3649
- }
3650
- };
3651
-
3652
1184
  if (url.pathname === "/v1/chat/completions" && request.method === "POST") {
3653
- let body: ChatRequest;
3654
- try {
3655
- body = (await request.json()) as ChatRequest;
3656
- } catch {
3657
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3658
- }
3659
- return handleChat(body, request.signal);
3660
- }
3661
-
3662
- // Raw text completion (mlx_lm.server's /v1/completions, request_type
3663
- // "text"): NO chat template — the prompt string is tokenized directly
3664
- // (tokenizer.encode with the tokenizer's own special-token handling,
3665
- // exactly mlx-lm's `tokenizer.encode(request.prompt)`). Rides the same
3666
- // GenerationGateway + admission + adapter path as chat; no tool router
3667
- // or thinking splitter (raw text in, raw text out).
1185
+ const id = `chatcmpl-${crypto.randomUUID()}`;
1186
+ const trace = createPromptResponseTrace({
1187
+ traceId: request.headers.get("x-mlx-bun-trace-id") ?? id,
1188
+ requestId: id,
1189
+ route: url.pathname,
1190
+ });
1191
+ const body = await parseBody<ChatRequestParams>(request, trace);
1192
+ if (body instanceof Response) return body;
1193
+ const meta = openAiMeta(id);
1194
+ const a = await admit(
1195
+ inferenceStage, () => chatStage.run(new ChatRequest(body), id, request.signal), trace, "chat request");
1196
+ if ("response" in a) return a.response;
1197
+ return body.stream
1198
+ ? respondStream(inferenceStage, a.admitted, chatCompletionStream(meta), request.signal, trace)
1199
+ : respondJson(inferenceStage, a.admitted, (r) => chatCompletionJson(r, meta), request.signal, trace);
1200
+ }
1201
+
1202
+ // Raw text completion (mlx_lm.server's /v1/completions): no chat
1203
+ // template — TextCompletionStage tokenizes the prompt string directly.
3668
1204
  if (url.pathname === "/v1/completions" && request.method === "POST") {
3669
- let body: TextCompletionRequest;
3670
- try {
3671
- body = (await request.json()) as TextCompletionRequest;
3672
- } catch {
3673
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3674
- }
3675
- if (typeof body.prompt !== "string" || body.prompt.length === 0)
3676
- return Response.json(
3677
- {
3678
- error: {
3679
- message: "prompt (a non-empty string) is required " +
3680
- "(token-array prompts are not accepted, matching mlx_lm.server)",
3681
- },
3682
- },
3683
- { status: 400 },
3684
- );
3685
- // mlx-lm validates logprobs params up front (ValueError → 400)
3686
- const lpParamError = validateLogprobsParams(body);
3687
- if (lpParamError)
3688
- return Response.json({ error: { message: lpParamError } }, { status: 400 });
3689
1205
  const id = `cmpl-${crypto.randomUUID()}`;
3690
- const created = Math.floor(Date.now() / 1000);
3691
- const promptIds = ctx.tokenizer.encode(body.prompt);
3692
- let options: ReturnType<typeof toOptions>;
3693
- try {
3694
- options = toOptions(body as unknown as ChatRequest);
3695
- } catch (e) {
3696
- // bad logit_bias (non-numeric keys/values) — mlx-lm's coercion error
3697
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3698
- }
3699
- // Grammar-constrained decoding on raw completions too (response_format /
3700
- // guided_* / structured_outputs). The degrade path for /v1/completions
3701
- // has no chat template to inject a system message into, so a degrade
3702
- // only emits the Warning header (no prompt injection) — documented gap
3703
- // vs the chat lane, which mirrors oMLX's text-completions behavior.
3704
- let textGrammarWarning: string | null = null;
3705
- const textGrammarReq =
3706
- body.response_format != null || !!body.guided_grammar ||
3707
- !!body.guided_regex || !!body.guided_choice?.length ||
3708
- body.structured_outputs != null;
3709
- if (textGrammarReq) {
3710
- const g = await compileGrammarForRequest(body as unknown as ChatRequest);
3711
- if (g.controller) options.grammar = g.controller;
3712
- else if (g.degradeHint)
3713
- textGrammarWarning =
3714
- `grammar not enforced: ${g.degradeHint} - no prompt injection on /v1/completions`;
3715
- }
3716
- // Mirrors the chat lane: a real controller (not the degrade path)
3717
- // shapes the request for per-row grammar batching.
3718
- const textGrammarCtrl = options.grammar ?? null;
3719
- // mlx_lm.server's default max_tokens is 512 (its --max-tokens CLI
3720
- // default). The chat lane's very generous default is wrong for raw
3721
- // completion: with no template an EOS may never come.
3722
- options.maxTokens = body.max_completion_tokens ?? body.max_tokens ??
3723
- defaultGeneratedTokens ?? 512;
3724
- const requestedMaxTokens = options.maxTokens;
3725
- const requiredCtx = promptIds.length + requestedMaxTokens;
3726
- const contextDecision = admitRequestContext(
3727
- promptIds.length,
3728
- requestedMaxTokens,
3729
- admission.maxSafeContext,
3730
- ctx.glmMemoryPlan !== null,
3731
- );
3732
- if (!contextDecision) {
3733
- // Pre-run reject: the gateway only takes grammar disposal ownership
3734
- // once run — free the WASM matcher here (chat lane: disposeRejected).
3735
- textGrammarCtrl?.dispose();
3736
- return Response.json(
3737
- {
3738
- error: {
3739
- message:
3740
- `request needs ${requiredCtx} tokens of context ` +
3741
- `(prompt ${promptIds.length} + max_tokens ${options.maxTokens}) but the ` +
3742
- `memory budget caps safe context at ${admission.maxSafeContext} — ` +
3743
- `shorten the prompt or lower max_tokens`,
3744
- type: "memory_admission",
3745
- code: "context_over_budget",
3746
- },
3747
- },
3748
- { status: 400 },
3749
- );
3750
- }
3751
- options.maxTokens = contextDecision.maxTokens;
3752
- try {
3753
- const adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3754
- if (adapterIds.length) options.adapters = adapterIds;
3755
- } catch (e) {
3756
- textGrammarCtrl?.dispose();
3757
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3758
- }
3759
- // logprobs capture — same mlx-lm block as chat (generate_response is
3760
- // shared in the reference too); non-stream only, stream chunks never
3761
- // carry logprobs.
3762
- const wantLogprobs = body.logprobs === true;
3763
- const topLogprobs =
3764
- typeof body.top_logprobs === "number" && body.top_logprobs > 0
3765
- ? body.top_logprobs : 0;
3766
- const captureLogprobs = !body.stream && (wantLogprobs || topLogprobs > 0);
3767
- if (captureLogprobs) {
3768
- options.logprobs = wantLogprobs;
3769
- options.topLogprobs = topLogprobs;
3770
- }
3771
- const shape = {
3772
- hasVision: false,
3773
- hasAdapters: !!options.adapters?.length,
3774
- hasRepetitionPenalty: !!options.repetitionPenalty,
3775
- hasLogitsExtras: !!(
3776
- options.minP || options.xtcProbability || options.logitBias ||
3777
- options.presencePenalty || options.frequencyPenalty
3778
- ),
3779
- wantsLogprobs: captureLogprobs,
3780
- userSeed: body.seed !== undefined,
3781
- kvQuant: !!(options.kvConfig?.length || options.kvBits),
3782
- turboQuant: !!options.turboQuant,
3783
- // /v1/completions grammar (textGrammarCtrl). Same null-on-degrade
3784
- // contract as the chat lane.
3785
- hasGrammar: !!textGrammarCtrl,
3786
- hasDraft: !!ctx.draft,
3787
- };
3788
- const finishReason = (stopped: boolean, generated: number): "stop" | "length" =>
3789
- stopped ? "stop" : generated >= (options.maxTokens ?? 512) ? "length" : "stop";
3790
-
3791
- if (body.stream) {
3792
- const streamAbort = new AbortController();
3793
- const generationSignal = AbortSignal.any([request.signal, streamAbort.signal]);
3794
- let cancelled = false;
3795
- const stream = new ReadableStream<Uint8Array>({
3796
- start(controller) {
3797
- const enc = new TextEncoder();
3798
- const send = (obj: unknown) => {
3799
- if (!generationSignal.aborted)
3800
- controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3801
- };
3802
- const chunk = (text: string, finish: string | null) => ({
3803
- id, object: "text_completion", created, model: ctx.modelId,
3804
- choices: [{ index: 0, text, finish_reason: finish }],
3805
- });
3806
- void (async () => {
3807
- try {
3808
- const decoder = new StreamDecoder(ctx.tokenizer);
3809
- const stopper = new StopMatcher(options.stopSequences);
3810
- // ≥25 ms macrotask hop, same reason as the chat lane: keep the
3811
- // serial decode loop from starving the socket (SSE bursts).
3812
- let lastFlush = performance.now();
3813
- const s = await gateway.run(promptIds, options, (token) => {
3814
- const text = stopper.push(decoder.push(token));
3815
- if (text) send(chunk(text, null));
3816
- if (stopper.stopped) return false; // halt generation
3817
- if (text) {
3818
- const now = performance.now();
3819
- if (now - lastFlush >= 25) {
3820
- lastFlush = now;
3821
- return new Promise<void>((r) => setImmediate(r));
3822
- }
3823
- }
3824
- }, undefined, shape, generationSignal);
3825
- if (generationSignal.aborted) return;
3826
- if (!stopper.stopped) {
3827
- let tail = stopper.push(decoder.flush());
3828
- if (!stopper.stopped) tail += stopper.flush();
3829
- if (tail) send(chunk(tail, null));
3830
- }
3831
- // final chunk: finish_reason + usage (mlx-lm gates usage behind
3832
- // stream_options.include_usage; we always attach it, matching
3833
- // our chat lane — an additive superset OpenAI clients ignore)
3834
- send({
3835
- ...chunk("", finishReason(stopper.stopped, s.generatedTokens)),
3836
- usage: {
3837
- prompt_tokens: s.promptTokens,
3838
- completion_tokens: s.generatedTokens,
3839
- total_tokens: s.promptTokens + s.generatedTokens,
3840
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3841
- ...(s.spec ? { speculation: s.spec } : {}),
3842
- },
3843
- });
3844
- controller.enqueue(enc.encode("data: [DONE]\n\n"));
3845
- } catch (e) {
3846
- if (!generationSignal.aborted)
3847
- send({ error: { message: (e as Error).message } });
3848
- } finally {
3849
- if (!cancelled) {
3850
- if (generationSignal.aborted) controller.error(generationSignal.reason);
3851
- else controller.close();
3852
- }
3853
- }
3854
- })();
3855
- },
3856
- cancel(reason) {
3857
- cancelled = true;
3858
- streamAbort.abort(reason);
3859
- },
3860
- });
3861
- return new Response(stream, {
3862
- headers: {
3863
- "content-type": "text/event-stream",
3864
- "cache-control": "no-cache",
3865
- connection: "keep-alive",
3866
- ...(textGrammarWarning ? { Warning: textGrammarWarning } : {}),
3867
- },
3868
- });
3869
- }
3870
-
3871
- try {
3872
- const decoder = new StreamDecoder(ctx.tokenizer);
3873
- const stopper = new StopMatcher(options.stopSequences);
3874
- const lpc = captureLogprobs
3875
- ? new LogprobsCollector(wantLogprobs, topLogprobs, (tid) => ctx.tokenizer.idToToken(tid))
3876
- : null;
3877
- let text = "";
3878
- const s = await gateway.run(promptIds, options, (token, lpInfo) => {
3879
- lpc?.push(token, lpInfo);
3880
- text += stopper.push(decoder.push(token));
3881
- if (stopper.stopped) return false; // halt generation
3882
- }, undefined, shape, request.signal);
3883
- if (!stopper.stopped) {
3884
- let tail = stopper.push(decoder.flush());
3885
- if (!stopper.stopped) tail += stopper.flush();
3886
- text += tail;
3887
- }
3888
- const logprobsBlock = lpc?.payload() ?? null;
3889
- return Response.json({
3890
- id, object: "text_completion", created, model: ctx.modelId,
3891
- choices: [{
3892
- index: 0, text,
3893
- ...(logprobsBlock ? { logprobs: logprobsBlock } : {}),
3894
- finish_reason: finishReason(stopper.stopped, s.generatedTokens),
3895
- }],
3896
- usage: {
3897
- prompt_tokens: s.promptTokens,
3898
- completion_tokens: s.generatedTokens,
3899
- total_tokens: s.promptTokens + s.generatedTokens,
3900
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3901
- ...(s.spec ? { speculation: s.spec } : {}),
3902
- },
3903
- }, textGrammarWarning ? { headers: { Warning: textGrammarWarning } } : undefined);
3904
- } catch (e) {
3905
- return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
3906
- }
1206
+ const trace = createPromptResponseTrace({
1207
+ traceId: request.headers.get("x-mlx-bun-trace-id") ?? id,
1208
+ requestId: id,
1209
+ route: url.pathname,
1210
+ });
1211
+ const body = await parseBody<TextCompletionParams>(request, trace);
1212
+ if (body instanceof Response) return body;
1213
+ const meta = openAiMeta(id);
1214
+ const a = await admit(
1215
+ inferenceStage, () => textStage.run(new TextCompletionRequest(body), id), trace, "text completion");
1216
+ if ("response" in a) return a.response;
1217
+ return body.stream
1218
+ ? respondStream(inferenceStage, a.admitted, textCompletionStream(meta), request.signal, trace)
1219
+ : respondJson(inferenceStage, a.admitted, (r) => textCompletionJson(r, meta), request.signal, trace);
3907
1220
  }
3908
1221
 
3909
1222
  // Anthropic Messages API (Phase 11) — on by default, mirroring
@@ -3912,41 +1225,35 @@ export function createServer(
3912
1225
  // src/anthropic.ts. Point Claude Code at this port via
3913
1226
  // ANTHROPIC_BASE_URL for a fully local backend.
3914
1227
  if (url.pathname === "/v1/messages" && request.method === "POST") {
3915
- const anthropicError = (status: number, type: string, message: string) =>
3916
- Response.json({ type: "error", error: { type, message } }, { status });
1228
+ const anthropicError: ErrorFormatter = (status, message) =>
1229
+ Response.json(
1230
+ { type: "error", error: { type: status >= 500 ? "api_error" : "invalid_request_error", message } },
1231
+ { status },
1232
+ );
3917
1233
  let anthropicBody: AnthropicRequest;
3918
1234
  try {
3919
1235
  anthropicBody = (await request.json()) as AnthropicRequest;
3920
1236
  } catch {
3921
- return anthropicError(400, "invalid_request_error", "invalid JSON body");
1237
+ return anthropicError(400, "invalid JSON body", {});
3922
1238
  }
3923
- let chatBody: ChatRequest;
1239
+ let chatBody: ChatRequestParams;
3924
1240
  try {
3925
- chatBody = anthropicToChatBody(anthropicBody) as unknown as ChatRequest;
1241
+ chatBody = anthropicToChatBody(anthropicBody) as unknown as ChatRequestParams;
3926
1242
  } catch (e) {
3927
- return anthropicError(400, "invalid_request_error", (e as Error).message);
1243
+ return anthropicError(400, (e as Error).message, {});
3928
1244
  }
3929
- const resp = await handleChat(chatBody, request.signal);
3930
- if (!resp.ok) {
3931
- const err = (await resp.json().catch(() => null)) as
3932
- | { error?: { message?: string } }
3933
- | null;
3934
- return anthropicError(
3935
- resp.status,
3936
- resp.status >= 500 ? "api_error" : "invalid_request_error",
3937
- err?.error?.message ?? "request failed",
3938
- );
3939
- }
3940
- if (anthropicBody.stream) {
3941
- return new Response(translateOpenAiSse(resp.body!, ctx.modelId), {
3942
- headers: {
3943
- "content-type": "text/event-stream",
3944
- "cache-control": "no-cache",
3945
- connection: "keep-alive",
3946
- },
3947
- });
3948
- }
3949
- return Response.json(chatJsonToAnthropic(await resp.json(), ctx.modelId));
1245
+ const id = `chatcmpl-${crypto.randomUUID()}`;
1246
+ const a = await admit(
1247
+ inferenceStage, () => chatStage.run(new ChatRequest(chatBody), id, request.signal), undefined,
1248
+ "anthropic request", anthropicError);
1249
+ if ("response" in a) return a.response;
1250
+ if (anthropicBody.stream)
1251
+ return respondStream(
1252
+ inferenceStage, a.admitted, createAnthropicStreamProtocol(ctx.modelId), request.signal);
1253
+ return respondJson(
1254
+ inferenceStage, a.admitted,
1255
+ (r) => chatJsonToAnthropic(chatCompletionJson(r, openAiMeta(id)), ctx.modelId),
1256
+ request.signal, undefined, anthropicError);
3950
1257
  }
3951
1258
 
3952
1259
  // OpenAI Responses API (Phase 11) — Codex/Cursor/Continue speak
@@ -3954,7 +1261,7 @@ export function createServer(
3954
1261
  // ported in src/responses.ts. previous_response_id resumes a prior
3955
1262
  // conversation from the in-process store (TTL + byte-capped LRU).
3956
1263
  if (url.pathname === "/v1/responses" && request.method === "POST") {
3957
- const responsesError = (status: number, message: string) =>
1264
+ const responsesError: ErrorFormatter = (status, message) =>
3958
1265
  Response.json(
3959
1266
  {
3960
1267
  error: {
@@ -3969,418 +1276,83 @@ export function createServer(
3969
1276
  try {
3970
1277
  responsesBody = (await request.json()) as ResponsesRequest;
3971
1278
  } catch {
3972
- return responsesError(400, "invalid JSON body");
3973
- }
3974
-
3975
- // previous_response_id: prepend the prior conversation's input
3976
- // + output (as input items); carry instructions forward only if
3977
- // the new request omits them (oracle semantics).
3978
- const prevId = responsesBody.previous_response_id ?? null;
3979
- if (prevId) {
3980
- const prior = responseStore.get(prevId);
3981
- if (!prior)
3982
- return responsesError(404, `previous_response_id '${prevId}' not found or expired`);
3983
- const prepended = [...prior.input, ...outputItemsToInputItems(prior.output)];
3984
- const newInput =
3985
- typeof responsesBody.input === "string"
3986
- ? responsesBody.input
3987
- ? [{ type: "message", role: "user", content: responsesBody.input }]
3988
- : []
3989
- : responsesBody.input ?? [];
3990
- responsesBody = {
3991
- ...responsesBody,
3992
- input: [...prepended, ...newInput] as Array<Record<string, unknown>>,
3993
- instructions: responsesBody.instructions ?? prior.instructions ?? undefined,
3994
- };
3995
- }
3996
- // Remember the effective input so a later follow-up that chains
3997
- // off THIS response sees the full history.
3998
- const capturedInput: unknown[] =
3999
- typeof responsesBody.input === "string"
4000
- ? [{ type: "message", role: "user", content: responsesBody.input }]
4001
- : [...(responsesBody.input ?? [])];
4002
- const capturedInstructions = responsesBody.instructions ?? null;
4003
-
4004
- let chatBody: ChatRequest;
4005
- try {
4006
- chatBody = responsesToChatBody(responsesBody) as unknown as ChatRequest;
4007
- } catch (e) {
4008
- return responsesError(400, (e as Error).message);
4009
- }
4010
- const resp = await handleChat(chatBody, request.signal);
4011
- if (!resp.ok) {
4012
- const err = (await resp.json().catch(() => null)) as
4013
- | { error?: { message?: string } }
4014
- | null;
4015
- return responsesError(resp.status, err?.error?.message ?? "request failed");
4016
- }
4017
- if (responsesBody.stream) {
4018
- const body = translateOpenAiSseToResponses(
4019
- resp.body!, ctx.modelId, prevId,
4020
- (final) =>
4021
- responseStore.put(final.id as string, {
4022
- input: capturedInput,
4023
- output: final.output as unknown[],
4024
- instructions: capturedInstructions,
4025
- }),
4026
- );
4027
- return new Response(body, {
4028
- headers: {
4029
- "content-type": "text/event-stream",
4030
- "cache-control": "no-cache",
4031
- connection: "keep-alive",
4032
- },
4033
- });
1279
+ return responsesError(400, "invalid JSON body", {});
4034
1280
  }
4035
- const responses = chatJsonToResponses(await resp.json(), ctx.modelId, prevId);
4036
- responseStore.put(responses.id as string, {
4037
- input: capturedInput,
4038
- output: responses.output as unknown[],
4039
- instructions: capturedInstructions,
4040
- });
4041
- return Response.json(responses);
4042
- }
4043
1281
 
4044
- // --- Lab API: dataset builder + quantize + finetune + jobs -------
4045
- if (url.pathname === "/api/dataset/templates" && request.method === "GET") {
4046
- const { TEMPLATES } = await import("./dataset");
4047
- return Response.json({ templates: TEMPLATES });
4048
- }
4049
- if (url.pathname === "/api/dataset/submit" && request.method === "POST") {
4050
- const body = (await request.json().catch(() => ({}))) as {
4051
- template_id?: string; inputs?: Record<string, unknown>; model_name?: string;
1282
+ let conversation: ReturnType<typeof resolveResponsesConversation>;
1283
+ try { conversation = resolveResponsesConversation(responsesBody, responseStore); }
1284
+ catch (error) { return responsesError(404, (error as Error).message, {}); }
1285
+ responsesBody = conversation.body;
1286
+ const prevId = conversation.previousId;
1287
+ const capturedInput = conversation.input;
1288
+ const capturedInstructions = conversation.instructions;
1289
+ const storeHere = !(serverOptions.unixSocket && request.headers.get("x-mlx-bun-response-owner") === "parent");
1290
+ const remember = (id: string, output: unknown[]) => {
1291
+ if (storeHere) responseStore.put(id, { input: capturedInput, output, instructions: capturedInstructions });
4052
1292
  };
4053
- const { getTemplate } = await import("./dataset");
4054
- if (!body.template_id || !getTemplate(body.template_id))
4055
- return Response.json({ ok: false, error: `unknown template ${body.template_id}` }, { status: 400 });
4056
- const store = await ensureJobs();
4057
- const { submitInProcess } = await import("./jobs");
4058
- const { homedir } = await import("node:os");
4059
- const safe = body.template_id.replace(/[^a-z0-9_-]/gi, "");
4060
- const outDir = `${homedir()}/.cache/mlx-bun/datasets/dataset-${safe}-${Date.now()}`;
4061
- const { jobId } = submitInProcess(store, "dataset", {
4062
- template_id: body.template_id, inputs: body.inputs ?? {},
4063
- output_dir: outDir, api_url: `http://127.0.0.1:${server.port}`,
4064
- model_name: body.model_name ?? "local",
4065
- }, outDir);
4066
- return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
4067
- }
4068
1293
 
4069
- if (url.pathname === "/api/quantize/inspect" && request.method === "POST") {
4070
- const body = (await request.json().catch(() => ({}))) as { model_id?: string };
4071
- const { inspectModel } = await import("./quantize");
4072
- return Response.json(await inspectModel(body.model_id ?? ""));
4073
- }
4074
- // Turn an OS-picked folder into the absolute snapshot path on disk. The
4075
- // browser can't reveal a filesystem path (security) and the HF cache
4076
- // stores real bytes in blobs/ with symlinked snapshots — so we resolve by
4077
- // the folder's NAME (which encodes the repo id), never by reading files.
4078
- // No upload, no dependence on the cache's symlink layout, and a
4079
- // just-downloaded model resolves before it's ever been indexed.
4080
- if ((url.pathname === "/api/quantize/resolve-folder" || url.pathname === "/api/model/resolve-folder") && request.method === "POST") {
4081
- const body = (await request.json().catch(() => ({}))) as { folder_name?: string; rel_path?: string };
4082
- const { statSync, readdirSync, readFileSync } = await import("node:fs");
4083
- const { join } = await import("node:path");
4084
- const { homedir } = await import("node:os");
4085
- const hubRoot = process.env.HF_HUB_CACHE
4086
- ?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
4087
- const roots = [hubRoot, join(homedir(), ".cache/mlx-bun")];
4088
- const hasConfig = (d: string) => { try { return statSync(join(d, "config.json")).isFile(); } catch { return false; } };
4089
- // basename of the picked folder; the rel path's last-but-one segment is
4090
- // the <hash> dir if they drilled into a snapshot.
4091
- const folder = (body.folder_name ?? "").replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
4092
- const relSegs = (body.rel_path ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
4093
- const configDir = relSegs.length >= 2 ? relSegs[relSegs.length - 2] : "";
4094
- const repoIdOf = (modelsDir: string) => modelsDir.slice("models--".length).replaceAll("--", "/");
4095
- // Pick a repo dir's canonical snapshot: the one refs/main points at, else
4096
- // the first snapshot that actually has a config.json.
4097
- const pickSnapshot = (repoDir: string): string | null => {
4098
- const snaps = join(repoDir, "snapshots");
4099
- let head = "";
4100
- try { head = readFileSync(join(repoDir, "refs", "main"), "utf8").trim(); } catch {}
4101
- if (head && hasConfig(join(snaps, head))) return join(snaps, head);
4102
- try { for (const h of readdirSync(snaps)) if (hasConfig(join(snaps, h))) return join(snaps, h); } catch {}
4103
- return null;
4104
- };
4105
-
4106
- // (1) HF-cache folder picked — its name IS the repo id.
4107
- if (folder.startsWith("models--")) {
4108
- const p = pickSnapshot(join(hubRoot, folder));
4109
- if (p) return Response.json({ ok: true, path: p, repo_id: repoIdOf(folder) });
4110
- }
4111
- // (2) A bare <hash> snapshot folder picked directly (or carried in rel).
4112
- const hashDir = configDir || folder;
4113
- if (hashDir) {
4114
- try {
4115
- for (const repo of readdirSync(hubRoot)) {
4116
- if (!repo.startsWith("models--")) continue;
4117
- const cand = join(hubRoot, repo, "snapshots", hashDir);
4118
- if (hasConfig(cand)) return Response.json({ ok: true, path: cand, repo_id: repoIdOf(repo) });
4119
- }
4120
- } catch {}
4121
- }
4122
- // (3) Flat local snapshot under a known root (folder name == model dir).
4123
- for (const root of roots) {
4124
- if (folder && hasConfig(join(root, folder))) return Response.json({ ok: true, path: join(root, folder) });
4125
- }
4126
- // (4) Registry rescan — last resort (also covers customized cache layouts).
4127
- const { Registry } = await import("./registry");
4128
- const reg = new Registry();
1294
+ let chatBody: ChatRequestParams;
4129
1295
  try {
4130
- await reg.scan();
4131
- const all = reg.list();
4132
- const rec = (folder.startsWith("models--")
4133
- ? all.find((m) => m.repoId === repoIdOf(folder))
4134
- : undefined)
4135
- ?? all.find((m) => m.path.split("/").pop() === hashDir)
4136
- ?? all.find((m) => m.repoId.split("/").pop() === folder);
4137
- if (rec) return Response.json({ ok: true, path: rec.path, repo_id: rec.repoId, model_type: rec.modelType });
4138
- } finally {
4139
- reg.close();
4140
- }
4141
- return Response.json({
4142
- ok: false,
4143
- error: "Couldn't locate this folder on disk — paste the path instead.",
4144
- });
4145
- }
4146
- if (url.pathname === "/api/quantize/submit" && request.method === "POST") {
4147
- const body = (await request.json().catch(() => ({}))) as {
4148
- model_id?: string; bits?: number; group_size?: number;
4149
- target_bpw?: number; candidate_bits?: number[]; reference?: string;
4150
- calibration_mix?: string; n_calibration?: number;
4151
- };
4152
- if (!body.model_id)
4153
- return Response.json({ ok: false, error: "model_id required" }, { status: 400 });
4154
- const store = await ensureJobs();
4155
- const { submitSubprocess } = await import("./jobs");
4156
- const { homedir } = await import("node:os");
4157
- const { join } = await import("node:path");
4158
- const { mkdirSync, writeFileSync } = await import("node:fs");
4159
- const { createHash } = await import("node:crypto");
4160
- const bits = body.bits ?? 4, gs = body.group_size ?? 64;
4161
- // Derive org/name from the source so the quant is named readably (the
4162
- // source is usually an HF-cache snapshot path whose basename is a hash).
4163
- const snapMatch = body.model_id.match(/(models--[^/]+)\/snapshots\//);
4164
- let org = "local", name: string;
4165
- if (snapMatch) {
4166
- const parts = snapMatch[1]!.split("--"); // ["models", org, ...name]
4167
- org = parts[1] ?? "local";
4168
- name = parts.slice(2).join("--");
4169
- } else if (body.model_id.includes("/") && !body.model_id.startsWith("/") && !body.model_id.startsWith("~")) {
4170
- const seg = body.model_id.split("/"); // a repo id "org/name"
4171
- org = seg[0]!; name = seg.slice(1).join("-");
4172
- } else {
4173
- name = body.model_id.split("/").filter(Boolean).at(-1) ?? "model"; // a bare path
4174
- }
4175
- name = (name || "model").replace(/[^a-z0-9_.-]/gi, "");
4176
- org = (org || "local").replace(/[^a-z0-9_.-]/gi, "");
4177
- const suffix = body.target_bpw ? `mixed-${body.target_bpw}bpw` : `${bits}bit`;
4178
- // Write the quant INTO the HF hub cache as a normal models--org--name/
4179
- // snapshots/<hash> entry, so the standard registry scan + every other
4180
- // tool discovers it alongside downloaded models. refs/main makes it a
4181
- // well-formed cache entry.
4182
- const quantRepo = `${name}-OptiQ-${suffix}`;
4183
- const hubRoot = process.env.HF_HUB_CACHE
4184
- ?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
4185
- const repoDir = join(hubRoot, `models--${org}--${quantRepo}`);
4186
- const snapHash = createHash("sha1").update(`${org}/${quantRepo}`).digest("hex");
4187
- const outDir = join(repoDir, "snapshots", snapHash);
4188
- try {
4189
- mkdirSync(join(repoDir, "refs"), { recursive: true });
4190
- writeFileSync(join(repoDir, "refs", "main"), snapHash);
4191
- } catch {}
4192
- const { jobId } = submitSubprocess(store, "quantize", {
4193
- model_id: body.model_id, out_dir: outDir, bits, group_size: gs,
4194
- // forwarded to the mixed-precision path when target_bpw is set
4195
- target_bpw: body.target_bpw, candidate_bits: body.candidate_bits,
4196
- reference: body.reference, calibration_mix: body.calibration_mix,
4197
- n_calibration: body.n_calibration,
4198
- }, outDir, {
4199
- // The model is in the HF cache now; drop the Library cache so the next
4200
- // poll rescans and shows it — no `mlx-bun scan`, no restart.
4201
- onComplete: () => { libraryCache = null; },
4202
- });
4203
- return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
4204
- }
4205
-
4206
- if (url.pathname === "/api/finetune/inspect-dataset" && request.method === "POST") {
4207
- const body = (await request.json().catch(() => ({}))) as { path?: string };
4208
- const { inspectDataset } = await import("./train");
4209
- return Response.json(await inspectDataset(body.path ?? ""));
4210
- }
4211
- if (url.pathname === "/api/finetune/submit" && request.method === "POST") {
4212
- const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
4213
- if (!body.model_dir || !body.data_dir)
4214
- return Response.json({ ok: false, error: "model_dir and data_dir required" }, { status: 400 });
4215
- const store = await ensureJobs();
4216
- const { submitSubprocess } = await import("./jobs");
4217
- const { homedir } = await import("node:os");
4218
- const adapterPath = (body.adapter_path as string) ||
4219
- `${homedir()}/.cache/mlx-bun/adapters/adapter-${Date.now()}`;
4220
- const { jobId } = submitSubprocess(store, "finetune",
4221
- { ...body, adapter_path: adapterPath }, adapterPath);
4222
- return Response.json({ ok: true, job_id: jobId, adapter_path: adapterPath });
4223
- }
4224
- if (url.pathname === "/api/finetune/merge" && request.method === "POST") {
4225
- const body = (await request.json().catch(() => ({}))) as {
4226
- adapter_a?: string; adapter_b?: string; scales?: number[];
4227
- };
4228
- if (!body.adapter_a || !body.adapter_b)
4229
- return Response.json({ ok: false, error: "adapter_a and adapter_b required" }, { status: 400 });
4230
- try {
4231
- const { mergeAdapters } = await import("./train");
4232
- const { homedir } = await import("node:os");
4233
- const mergedPath = `${homedir()}/.cache/mlx-bun/adapters/merged-${Date.now()}`;
4234
- const stats = await mergeAdapters([body.adapter_a, body.adapter_b], mergedPath, body.scales);
4235
- return Response.json({ ok: true, merged_path: mergedPath, stats });
4236
- } catch (e) {
4237
- return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
4238
- }
4239
- }
4240
- if (url.pathname === "/api/finetune/export" && request.method === "POST") {
4241
- const body = (await request.json().catch(() => ({}))) as {
4242
- base_model?: string; adapter_path?: string; method?: string;
4243
- };
4244
- if (!body.base_model || !body.adapter_path)
4245
- return Response.json({ ok: false, error: "base_model and adapter_path required" }, { status: 400 });
4246
- try {
4247
- const { exportAdapter } = await import("./train");
4248
- const { homedir } = await import("node:os");
4249
- const exportPath = `${homedir()}/.cache/mlx-bun/exports/export-${Date.now()}`;
4250
- const manifest = await exportAdapter(exportPath, body.base_model, body.adapter_path, body.method);
4251
- return Response.json({ ok: true, export_path: exportPath, manifest });
1296
+ chatBody = responsesToChatBody(responsesBody) as unknown as ChatRequestParams;
4252
1297
  } catch (e) {
4253
- return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
4254
- }
4255
- }
4256
-
4257
- // --- HF token settings + push-to-hub (model & dataset repos) ------
4258
- if (url.pathname === "/api/settings/hf-token" && request.method === "GET") {
4259
- const { hasHfToken } = await import("./hf-push");
4260
- return Response.json({ ok: true, hasToken: hasHfToken() });
4261
- }
4262
- if (url.pathname === "/api/settings/hf-token" && request.method === "POST") {
4263
- const body = (await request.json().catch(() => ({}))) as { token?: string };
4264
- if (!body.token) return Response.json({ ok: false, error: "token required" }, { status: 400 });
4265
- const { saveHfToken } = await import("./hf-push");
4266
- saveHfToken(body.token);
4267
- return Response.json({ ok: true });
4268
- }
4269
-
4270
- // --- Durable tool-approvals settings (plan §5.4/§6.5/§9 Phase 2) ---
4271
- // The web chat's own "always allow this tool" list
4272
- // (~/.mlx-bun/tool-approvals.json, src/tool-approvals.ts). The
4273
- // approval card itself grants an always-allow via the `approval` WS
4274
- // frame (alwaysAllow:true) — these REST routes are read/forget only,
4275
- // for the settings panel's list view (matches the hf-token GET/POST
4276
- // pattern's separation of "the card that grants" vs "settings that
4277
- // manage" — there is no POST here on purpose, granting always goes
4278
- // through the approval card, never a bare settings form).
4279
- if (url.pathname === "/api/settings/tool-approvals" && request.method === "GET") {
4280
- const { listAlwaysAllowedTools } = await import("./tool-approvals");
4281
- return Response.json({ ok: true, alwaysAllow: listAlwaysAllowedTools() });
4282
- }
4283
- if (url.pathname === "/api/settings/tool-approvals" && request.method === "DELETE") {
4284
- const body = (await request.json().catch(() => ({}))) as { tool?: string };
4285
- if (!body.tool) return Response.json({ ok: false, error: "tool required" }, { status: 400 });
4286
- const { revokeToolAlwaysAllowed } = await import("./tool-approvals");
4287
- const file = revokeToolAlwaysAllowed(body.tool);
4288
- return Response.json({ ok: true, alwaysAllow: Object.keys(file.allows).sort() });
4289
- }
4290
- {
4291
- const m = url.pathname.match(/^\/api\/(quantize|finetune|dataset)\/push$/);
4292
- if (m && request.method === "POST") {
4293
- const kind = m[1]!;
4294
- const body = (await request.json().catch(() => ({}))) as {
4295
- job_id?: string; repo_id?: string; private?: boolean; source_path?: string;
4296
- };
4297
- if (!body.repo_id) return Response.json({ ok: false, error: "repo_id required" }, { status: 400 });
4298
- const { getHfToken, uploadFolder } = await import("./hf-push");
4299
- const token = getHfToken();
4300
- if (!token)
4301
- return Response.json({ ok: false, error: "no HF token saved — add one in Settings → Hugging Face" }, { status: 400 });
4302
- const store = await ensureJobs();
4303
- let dir = body.source_path;
4304
- if (!dir && body.job_id) dir = store.get(body.job_id)?.output_path ?? undefined;
4305
- if (!dir) return Response.json({ ok: false, error: "no source dir (pass job_id or source_path)" }, { status: 400 });
4306
- try {
4307
- const r = await uploadFolder(dir, body.repo_id, {
4308
- repoType: kind === "dataset" ? "dataset" : "model",
4309
- private: !!body.private, token,
4310
- });
4311
- return Response.json({ ok: true, url: r.url });
4312
- } catch (e) {
4313
- return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
4314
- }
1298
+ return responsesError(400, (e as Error).message, {});
4315
1299
  }
1300
+ const id = `chatcmpl-${crypto.randomUUID()}`;
1301
+ const a = await admit(
1302
+ inferenceStage, () => chatStage.run(new ChatRequest(chatBody), id, request.signal), undefined,
1303
+ "responses request", responsesError);
1304
+ if ("response" in a) return a.response;
1305
+ if (responsesBody.stream)
1306
+ return respondStream(
1307
+ inferenceStage, a.admitted,
1308
+ createResponsesStreamProtocol(
1309
+ ctx.modelId,
1310
+ prevId,
1311
+ (final) =>
1312
+ remember(final.id as string, final.output as unknown[]),
1313
+ ),
1314
+ request.signal);
1315
+ return respondJson(
1316
+ inferenceStage, a.admitted,
1317
+ (r) => {
1318
+ const responses = chatJsonToResponses(chatCompletionJson(r, openAiMeta(id)), ctx.modelId, prevId);
1319
+ remember(responses.id as string, responses.output as unknown[]);
1320
+ return responses;
1321
+ },
1322
+ request.signal, undefined, responsesError);
4316
1323
  }
4317
1324
 
4318
- // gc plan/execute (web-ui-pass-plan.md #9): thin wrappers over the CLI's
4319
- // own `mlx-bun gc` planner/executor (src/registry.ts planGc/executeGc).
4320
- // Loopback-served admin route — no auth beyond the server's own bind
4321
- // (matches every other /api/* route here). GET plans (read-only, cheap:
4322
- // config.json + safetensors index reads, no tensor bytes); POST executes
4323
- // and requires an explicit {yes:true} body (mirrors the CLI's --yes gate)
4324
- // so a stray call can't delete anything by accident.
4325
- if (url.pathname === "/api/gc/plan" && request.method === "GET") {
4326
- const { planGc } = await import("./registry");
4327
- const plans = planGc().filter(
4328
- (p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
4329
- );
4330
- const superseded = plans.map((p) => ({
4331
- repo_id: p.repoId,
4332
- prune_snapshots: p.pruneSnapshots.length,
4333
- skipped_snapshots: p.skippedSnapshots.length,
4334
- dead_blobs: p.deadBlobs.length,
4335
- reclaim_bytes: p.reclaimBytes,
4336
- }));
4337
- const reclaim_bytes = plans.reduce((a, p) => a + p.reclaimBytes, 0);
4338
- return Response.json({ ok: true, superseded, reclaim_bytes });
4339
- }
4340
- if (url.pathname === "/api/gc/execute" && request.method === "POST") {
4341
- const body = (await request.json().catch(() => ({}))) as { yes?: boolean };
4342
- if (body.yes !== true)
4343
- return Response.json({ ok: false, error: "pass {\"yes\": true} to confirm deletion" }, { status: 400 });
4344
- const { planGc, executeGc, Registry } = await import("./registry");
4345
- const plans = planGc().filter(
4346
- (p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
4347
- );
4348
- const res = executeGc(plans);
4349
- const reg = new Registry();
4350
- try {
4351
- await reg.scan(); // reap deleted snapshots from the registry
4352
- } finally {
4353
- reg.close();
4354
- }
4355
- libraryCache = null; // force /library to reflect the deletion
4356
- return Response.json({
4357
- ok: true,
4358
- snapshots: res.snapshots, blobs: res.blobs, reclaimed_bytes: res.reclaimedBytes,
4359
- });
4360
- }
1325
+ const labResponse = await handleLabRoute(url, request, {
1326
+ ensureJobs,
1327
+ acquireGpu: (signal) => gateway.acquireExecutionLease(signal),
1328
+ serverPort: () => server.port,
1329
+ invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
1330
+ });
1331
+ if (labResponse) return labResponse;
4361
1332
 
4362
- if (url.pathname === "/api/jobs" && request.method === "GET") {
4363
- const store = await ensureJobs();
4364
- const limit = Number(url.searchParams.get("limit") ?? "50");
4365
- const kind = url.searchParams.get("kind") ?? undefined;
4366
- return Response.json({ ok: true, jobs: store.recent(limit, kind) });
4367
- }
4368
- {
4369
- const m = url.pathname.match(/^\/api\/jobs\/([^/]+?)(\/stream)?$/);
4370
- if (m && request.method === "GET") {
4371
- const store = await ensureJobs();
4372
- if (m[2]) {
4373
- const { streamJobResponse } = await import("./jobs");
4374
- return streamJobResponse(store, m[1]!);
4375
- }
4376
- const job = store.get(m[1]!);
4377
- if (!job) return Response.json({ ok: false, error: "job not found" }, { status: 404 });
4378
- return Response.json({ ok: true, job });
4379
- }
4380
- }
1333
+ const adminResponse = await handleAdminRoute(url, request, {
1334
+ ensureJobs,
1335
+ invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
1336
+ });
1337
+ if (adminResponse) return adminResponse;
4381
1338
 
4382
1339
  return Response.json({ error: { message: "not found" } }, { status: 404 });
4383
1340
  },
4384
1341
  });
1342
+ serverLifecycles.set(serverRef, {
1343
+ stopJobs: async () => {
1344
+ if (jobStore) {
1345
+ const jobs = await import("./jobs");
1346
+ await Promise.all([jobs.closeSubprocessJobs(jobStore), jobs.closeInProcessJobs(jobStore)]);
1347
+ }
1348
+ },
1349
+ close: async () => { preparation.close(); await sessionEngine.close(); await gateway.close(); },
1350
+ flush: flushDurability,
1351
+ stats: durabilityStats,
1352
+ stopTimers: () => {
1353
+ if (demoteTimer) clearInterval(demoteTimer);
1354
+ demoteTimer = null;
1355
+ },
1356
+ });
4385
1357
  return serverRef;
4386
1358
  }