mlx-bun 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/README.md +71 -588
  2. package/docs/README.md +91 -0
  3. package/docs/{design → archive/investigations}/audio-input-plan.md +4 -4
  4. package/docs/{design → archive/investigations}/diffusion-gemma-port.md +3 -3
  5. package/docs/{design → archive/investigations}/faithful-l1-consolidation.md +4 -4
  6. package/docs/{design → archive/investigations}/minicpm5-decode-megakernel.md +11 -11
  7. package/docs/{design → archive/investigations}/optimization_plan.md +1 -1
  8. package/docs/{design → archive/investigations}/pre-colibri-stabilization.md +5 -5
  9. package/docs/{design → archive/investigations}/the-dreaming-handoff.md +2 -2
  10. package/docs/design/batching.md +569 -0
  11. package/docs/design/decode-speed-program.md +3178 -169
  12. package/docs/design/dreaming-nightly-pipeline.md +675 -282
  13. package/docs/design/generic-model-support.md +579 -334
  14. package/docs/design/kv-cache.md +680 -0
  15. package/docs/design/orpo-training.md +673 -1239
  16. package/docs/design/speculative-decoding.md +1627 -0
  17. package/docs/design/turboquant.md +2102 -0
  18. package/docs/design/unified-engine-frontier-plan.md +1831 -727
  19. package/docs/design/web-chat-redesign.md +666 -1083
  20. package/docs/planning/PRODUCT_ROADMAP.md +34 -1
  21. package/docs/planning/release-notes-v0.3.0.md +35 -0
  22. package/docs/planning/video-series.md +2 -2
  23. package/docs/reference/benchmarks.md +1162 -0
  24. package/docs/reference/cli.md +394 -159
  25. package/docs/reference/distribution.md +321 -182
  26. package/docs/reference/environment.md +348 -0
  27. package/docs/reference/glossary.md +39 -0
  28. package/docs/reference/library-api.md +566 -168
  29. package/docs/reference/memory.md +227 -64
  30. package/docs/reference/models.md +104 -6
  31. package/docs/reference/server-api.md +886 -425
  32. package/docs/reference/server-config.md +638 -330
  33. package/docs/reference/training.md +393 -254
  34. package/docs/reference/troubleshooting.md +166 -0
  35. package/package.json +9 -4
  36. package/src/audio/decode.ts +2 -2
  37. package/src/audio/features.ts +2 -2
  38. package/src/backends/mlx/autoregressive.ts +108 -0
  39. package/src/backends/mlx/batch-group.ts +1646 -0
  40. package/src/backends/mlx/diffusion.ts +39 -0
  41. package/src/backends/mlx/draft-target.ts +47 -0
  42. package/src/backends/mlx/evaluation.ts +105 -0
  43. package/src/backends/mlx/fill-append.ts +45 -0
  44. package/src/backends/mlx/gateway-binding.ts +71 -0
  45. package/src/backends/mlx/graph.ts +38 -0
  46. package/src/backends/mlx/memory-client.ts +189 -0
  47. package/src/backends/mlx/memory-guard.ts +40 -0
  48. package/src/backends/mlx/methods.ts +90 -0
  49. package/src/backends/mlx/model-prompt.ts +164 -0
  50. package/src/backends/mlx/model-serving.ts +79 -0
  51. package/src/backends/mlx/prefill.ts +33 -0
  52. package/src/backends/mlx/request-policy.ts +20 -0
  53. package/src/backends/mlx/rollback.ts +51 -0
  54. package/src/backends/mlx/serial-executor.ts +273 -0
  55. package/src/backends/mlx/speculative.ts +114 -0
  56. package/src/backends/mlx/state-views.ts +26 -0
  57. package/src/backends/mlx/text-engine.ts +73 -0
  58. package/src/cli.ts +58 -25
  59. package/src/client.ts +66 -0
  60. package/src/config.ts +21 -4
  61. package/src/contracts/completion.ts +16 -0
  62. package/src/contracts/execution.ts +49 -0
  63. package/src/contracts/generation.ts +103 -0
  64. package/src/contracts/host.ts +7 -0
  65. package/src/contracts/jobs.ts +66 -0
  66. package/src/contracts/pi-provider.ts +30 -0
  67. package/src/contracts/pi.ts +259 -0
  68. package/src/contracts/resources.ts +18 -0
  69. package/src/contracts/scheduling.ts +27 -0
  70. package/src/contracts/tsconfig.json +14 -0
  71. package/src/diffusion/diffusion-generate.ts +218 -153
  72. package/src/embed.ts +1 -1
  73. package/src/engine/admission.ts +59 -0
  74. package/src/engine/cancellation.ts +38 -0
  75. package/src/engine/engine.ts +56 -0
  76. package/src/engine/execution-coordinator.ts +70 -0
  77. package/src/engine/execution-plan.ts +51 -0
  78. package/src/engine/resources.ts +42 -0
  79. package/src/engine/scheduler.ts +67 -0
  80. package/src/engine/session.ts +247 -0
  81. package/src/eval/README.md +44 -1
  82. package/src/eval/kl.ts +236 -3
  83. package/src/eval/reference-logits.ts +207 -0
  84. package/src/eval/runner.ts +8 -96
  85. package/src/fill/echo-index.ts +188 -0
  86. package/src/fill/fill-session.ts +452 -0
  87. package/src/fill/proposal.ts +68 -0
  88. package/src/fill/schema-rows.ts +506 -0
  89. package/src/fill/tool-boundary.ts +103 -0
  90. package/src/fit.ts +1 -1
  91. package/src/generate.ts +656 -268
  92. package/src/grammar.ts +12 -7
  93. package/src/harness-pi.ts +14 -33
  94. package/src/hub-rest.ts +4 -4
  95. package/src/index.ts +7 -0
  96. package/src/inference/denoising.ts +17 -0
  97. package/src/inference/graph.ts +33 -0
  98. package/src/inference/prefill.ts +39 -0
  99. package/src/inference/rollback.ts +7 -0
  100. package/src/jobs/db.ts +4 -0
  101. package/src/jobs/index.ts +2 -0
  102. package/src/jobs/job-entry.ts +12 -12
  103. package/src/jobs/runner.ts +98 -13
  104. package/src/jobs/task-client.ts +19 -0
  105. package/src/jobs/types.ts +2 -58
  106. package/src/kv-store.ts +109 -49
  107. package/src/lab/README.md +18 -0
  108. package/src/{assets → lab/curve}/curve-designer.html +2 -2
  109. package/src/{curve-sampler.ts → lab/curve/curve-sampler.ts} +2 -2
  110. package/src/{expert-trace.ts → lab/expert-trace/expert-trace.ts} +5 -5
  111. package/src/{model → lab/paged-kv}/paged-kv.ts +9 -6
  112. package/src/library.ts +40 -0
  113. package/src/lora.ts +18 -0
  114. package/src/media-fetch.ts +1 -1
  115. package/src/memory/article.ts +1 -1
  116. package/src/memory/chunk.ts +1 -1
  117. package/src/memory/model.ts +30 -169
  118. package/src/memory/prompts.ts +1 -1
  119. package/src/memory/rest.ts +1 -1
  120. package/src/memory/schedule.ts +1 -1
  121. package/src/memory/stages.ts +4 -1
  122. package/src/memory/vault.ts +1 -3
  123. package/src/mlx/array.ts +9 -10
  124. package/src/mlx/compile.ts +12 -2
  125. package/src/mlx/ffi.ts +58 -11
  126. package/src/mlx/materialize.ts +23 -0
  127. package/src/mlx/metal-kernel.ts +10 -11
  128. package/src/mlx/ops.ts +33 -13
  129. package/src/mlx/quantized-rows.ts +36 -0
  130. package/src/mlx/token-bitmask.ts +52 -0
  131. package/src/mlx/turboquant-kv-decode.ts +82 -0
  132. package/src/mlx/turboquant-kv-inverse.ts +122 -0
  133. package/src/mlx/turboquant-ops.ts +33 -3
  134. package/src/mlx/turboquant-tables.ts +2 -2
  135. package/src/model/batched-mask.ts +8 -2
  136. package/src/model/batched-quant.ts +1 -1
  137. package/src/model/batched-rotating-quant.ts +2 -2
  138. package/src/model/batched-rotating.ts +1 -1
  139. package/src/model/compiled-decode.ts +1 -1
  140. package/src/model/diffusion-gemma.ts +8 -8
  141. package/src/model/factory.ts +76 -33
  142. package/src/model/fingerprint.ts +1 -1
  143. package/src/model/flash-attention.ts +3 -3
  144. package/src/model/gemma4-base.ts +26 -9
  145. package/src/model/gemma4.ts +6 -6
  146. package/src/model/generated/gemma4-12b.ts +2 -2
  147. package/src/model/generated/gemma4-26b.ts +2 -2
  148. package/src/model/generated/gemma4-e4b.ts +2 -2
  149. package/src/model/glm52-dsa.ts +13 -9
  150. package/src/model/glm52-memory.ts +1 -1
  151. package/src/model/implementation.ts +69 -0
  152. package/src/model/minicpm5.ts +6 -8
  153. package/src/model/profile.ts +12 -2
  154. package/src/model/qwen-append.ts +12 -0
  155. package/src/model/qwen-conv.ts +52 -0
  156. package/src/model/qwen3_5-checkpoint.ts +164 -0
  157. package/src/model/qwen3_5.ts +140 -56
  158. package/src/model/support.ts +1 -1
  159. package/src/model/trellis-balanced-scatter.ts +51 -0
  160. package/src/model/trellis-linear.ts +672 -0
  161. package/src/model/trellis-shared-m.ts +151 -0
  162. package/src/model/trellis-shared-scatter.ts +144 -0
  163. package/src/model/trellis-splitk-prefill.ts +158 -0
  164. package/src/model/trellis-tiled-prefill.ts +136 -0
  165. package/src/model/trellis-vector-expand.ts +62 -0
  166. package/src/model/trellis-wide-prefill.ts +94 -0
  167. package/src/model/universal/dense.ts +1 -1
  168. package/src/model/universal/modules.ts +8 -1
  169. package/src/native-pack.ts +6 -5
  170. package/src/pi-provider.ts +6 -64
  171. package/src/pi-web.ts +8 -315
  172. package/src/prompt-cache.ts +96 -53
  173. package/src/quantize/allocator.ts +1 -1
  174. package/src/quantize/config-writer.ts +10 -3
  175. package/src/quantize/rotate.ts +4 -4
  176. package/src/quantize/trellis.ts +586 -0
  177. package/src/responses.ts +28 -1
  178. package/src/runtime-config.ts +18 -4
  179. package/src/sampler.ts +4 -4
  180. package/src/serve/batch-scheduler.ts +8 -1671
  181. package/src/serve/chat-request.ts +391 -0
  182. package/src/serve/chat-stage.ts +195 -0
  183. package/src/serve/checkpoint-identity.ts +64 -0
  184. package/src/serve/completion-executor.ts +15 -7
  185. package/src/serve/discovery-routes.ts +12 -13
  186. package/src/serve/generation-gateway.ts +154 -193
  187. package/src/serve/http.ts +159 -0
  188. package/src/serve/inference-request.ts +79 -0
  189. package/src/serve/isolate.ts +297 -105
  190. package/src/serve/lab-routes.ts +3 -0
  191. package/src/serve/lane-registry.ts +2 -1
  192. package/src/serve/model-admin-routes.ts +7 -5
  193. package/src/serve/model-binding.ts +43 -0
  194. package/src/serve/model-host.ts +512 -0
  195. package/src/serve/openai-wire.ts +103 -0
  196. package/src/serve/parent-application.ts +56 -0
  197. package/src/serve/pipeline.ts +33 -0
  198. package/src/serve/preparation.ts +33 -0
  199. package/src/serve/request-plan.ts +19 -15
  200. package/src/serve/request-prep.ts +346 -0
  201. package/src/serve/response-lease.ts +24 -0
  202. package/src/serve/responses-client.ts +69 -0
  203. package/src/serve/session-completion-engine.ts +110 -0
  204. package/src/serve/session-search.ts +1 -1
  205. package/src/serve/static-routes.ts +5 -5
  206. package/src/serve/text-completion-stage.ts +104 -0
  207. package/src/serve/token-streams.ts +519 -0
  208. package/src/server.ts +317 -2649
  209. package/src/spec/assistant-source.ts +10 -44
  210. package/src/spec/deepspec-source.ts +6 -6
  211. package/src/spec/dflash-source.ts +8 -8
  212. package/src/spec/dspark/ab-stats.ts +3 -3
  213. package/src/spec/dspark/calibration.ts +2 -2
  214. package/src/spec/dspark/deepspec-module.ts +2 -2
  215. package/src/spec/dspark/generate-dflash.ts +2 -2
  216. package/src/spec/dspark/generate.ts +2 -2
  217. package/src/spec/dspark/loader.ts +1 -1
  218. package/src/spec/dspark/module-dflash.ts +4 -3
  219. package/src/spec/dspark/module.ts +3 -3
  220. package/src/spec/dspark/quantize-drafter.ts +1 -1
  221. package/src/spec/generate.ts +1 -1
  222. package/src/spec/glm52-mtp-source.ts +1 -1
  223. package/src/spec/ngram-source.ts +1 -1
  224. package/src/spec/prefix-state.ts +45 -0
  225. package/src/spec/qwen-mtp-source.ts +166 -69
  226. package/src/spec/serve-loop.ts +154 -156
  227. package/src/spec/source.ts +42 -10
  228. package/src/spec/two-model.ts +1 -1
  229. package/src/ssd-cache.ts +80 -12
  230. package/src/ssd-durability.ts +25 -13
  231. package/src/tool-call.ts +51 -14
  232. package/src/train/flash-cce.ts +2 -2
  233. package/src/train/forward.ts +6 -0
  234. package/src/train/loss.ts +1 -1
  235. package/src/train/prefix-shared.ts +5 -1
  236. package/src/train/segmented.ts +5 -3
  237. package/src/train/trainer.ts +6 -6
  238. package/src/vision/prompt.ts +1 -1
  239. package/src/vision/qwen3vl-tower.ts +52 -10
  240. package/src/web/app.html +1 -1
  241. package/src/web/app.js +5 -3
  242. package/src/web/src/adapters-panel.ts +3 -3
  243. package/src/web/src/assistant.ts +2 -36
  244. package/src/web/src/chat.ts +4 -4
  245. package/src/web/src/composer.ts +4 -4
  246. package/src/web/src/hub.ts +1 -1
  247. package/src/web/src/markdown.ts +0 -0
  248. package/src/web/src/memory-panel.ts +1 -1
  249. package/src/web/src/model-picker.ts +1 -1
  250. package/src/web/src/palette.ts +1 -1
  251. package/src/web/src/protocol.ts +3 -39
  252. package/src/web/src/sessions.ts +1 -1
  253. package/src/web/src/ui-catalog.ts +4 -18
  254. package/src/web-assets.ts +47 -0
  255. package/src/weights.ts +55 -9
  256. package/docs/archive/investigations/curve-runs/bisector-route.json +0 -98
  257. package/docs/archive/investigations/curve-runs/bisector-trace.json +0 -1393
  258. package/docs/archive/investigations/curve-runs/curve-terrain.json +0 -1179
  259. package/docs/archive/investigations/curve-runs/curve-terrain.svg +0 -42
  260. package/docs/archive/investigations/hlg-ab.html +0 -34
  261. package/docs/archive/investigations/hlg-explorer.html +0 -238
  262. package/docs/archive/investigations/hlg-figs/cliff.svg +0 -1
  263. package/docs/archive/investigations/hlg-figs/cross.svg +0 -1
  264. package/docs/archive/investigations/hlg-figs/edges.svg +0 -1
  265. package/docs/archive/investigations/hlg-figs/k20-grids.svg +0 -1
  266. package/docs/archive/investigations/hlg-figs/k20-peaks.svg +0 -1
  267. package/docs/archive/investigations/hlg-figs/lw-curve.svg +0 -1
  268. package/docs/archive/investigations/hlg-figs/terrain.svg +0 -1
  269. package/docs/archive/investigations/hlg-report.html +0 -262
  270. package/docs/archive/investigations/hlg-runs/hlg-ab.json +0 -149
  271. package/docs/archive/investigations/hlg-runs/hlg-blind-prompts.md +0 -87
  272. package/docs/archive/investigations/hlg-runs/hlg-blind.json +0 -107
  273. package/docs/archive/investigations/hlg-runs/hlg-cliff.json +0 -197
  274. package/docs/archive/investigations/hlg-runs/hlg-cross.json +0 -505
  275. package/docs/archive/investigations/hlg-runs/hlg-edge.json +0 -388
  276. package/docs/archive/investigations/hlg-runs/hlg-eval1.json +0 -25
  277. package/docs/archive/investigations/hlg-runs/hlg-frontier.json +0 -86
  278. package/docs/archive/investigations/hlg-runs/hlg-grid.json +0 -242
  279. package/docs/archive/investigations/hlg-runs/hlg-grid2.json +0 -343
  280. package/docs/archive/investigations/hlg-runs/hlg-lw-coarse.json +0 -110
  281. package/docs/archive/investigations/hlg-runs/hlg-map.json +0 -383
  282. package/docs/archive/investigations/hlg-runs/run0-original.log +0 -159
  283. package/docs/archive/investigations/hlg-runs/runA-recommended.log +0 -131
  284. package/docs/archive/investigations/hlg-runs/runAA-cliff-cpm5.log +0 -26
  285. package/docs/archive/investigations/hlg-runs/runAB-grid2-e4b.log +0 -18
  286. package/docs/archive/investigations/hlg-runs/runAC-peak-e4b-partial.log +0 -25
  287. package/docs/archive/investigations/hlg-runs/runAD-ab-samples.log +0 -8
  288. package/docs/archive/investigations/hlg-runs/runAE-edge-e4b.log +0 -51
  289. package/docs/archive/investigations/hlg-runs/runAF-logit-calibration.log +0 -10
  290. package/docs/archive/investigations/hlg-runs/runAG-eval-config2.log +0 -16
  291. package/docs/archive/investigations/hlg-runs/runB-gain-variants.log +0 -192
  292. package/docs/archive/investigations/hlg-runs/runC-pivots.log +0 -162
  293. package/docs/archive/investigations/hlg-runs/runD-oetf.log +0 -132
  294. package/docs/archive/investigations/hlg-runs/runE-eotf.log +0 -162
  295. package/docs/archive/investigations/hlg-runs/runF-shaper-working.log +0 -86
  296. package/docs/archive/investigations/hlg-runs/runG-shaper-sweep.log +0 -101
  297. package/docs/archive/investigations/hlg-runs/runH-wsweep.log +0 -176
  298. package/docs/archive/investigations/hlg-runs/runI-asweep.log +0 -176
  299. package/docs/archive/investigations/hlg-runs/runJ-knobmap.log +0 -49
  300. package/docs/archive/investigations/hlg-runs/runK-frontier.log +0 -19
  301. package/docs/archive/investigations/hlg-runs/runL-widemap.log +0 -65
  302. package/docs/archive/investigations/hlg-runs/runM-frontier-emb.log +0 -15
  303. package/docs/archive/investigations/hlg-runs/runN-verify.log +0 -116
  304. package/docs/archive/investigations/hlg-runs/runO-frontier-seed5000.log +0 -15
  305. package/docs/archive/investigations/hlg-runs/runP-grid-AxSm.log +0 -15
  306. package/docs/archive/investigations/hlg-runs/runQ-grid-tgW.log +0 -15
  307. package/docs/archive/investigations/hlg-runs/runR-blind-transcript.log +0 -79
  308. package/docs/archive/investigations/hlg-runs/runS-cross-coherence.log +0 -33
  309. package/docs/archive/investigations/hlg-runs/runT-cross-cpm5.log +0 -33
  310. package/docs/archive/investigations/hlg-runs/runU-cross-12b.log +0 -33
  311. package/docs/archive/investigations/hlg-runs/runV-lw-e4b.log +0 -22
  312. package/docs/archive/investigations/hlg-runs/runW-lw-12b.log +0 -22
  313. package/docs/archive/investigations/hlg-runs/runX-lw-cpm5.log +0 -22
  314. package/docs/archive/investigations/hlg-runs/runY-cliff-e4b.log +0 -26
  315. package/docs/archive/investigations/hlg-runs/runZ-cliff-12b.log +0 -26
  316. package/docs/archive/mlx-bun-lab-report.html +0 -275
  317. package/docs/archive/wwdc-mlx-bun.html +0 -848
  318. package/docs/design/adapters-end-to-end.md +0 -205
  319. package/docs/design/batching-perf-path.md +0 -255
  320. package/docs/design/batching-v2-plan.md +0 -529
  321. package/docs/design/bucketing-stage.md +0 -197
  322. package/docs/design/compat-cli-surface-design.md +0 -230
  323. package/docs/design/docs-reorg-plan.md +0 -165
  324. package/docs/design/dspark-serving-program.md +0 -371
  325. package/docs/design/dspark-speculative-decoding.md +0 -318
  326. package/docs/design/grammar-spec-batching-integration.md +0 -301
  327. package/docs/design/memory-inference-path.md +0 -422
  328. package/docs/design/memory-synthesis.md +0 -287
  329. package/docs/design/memory-system.md +0 -237
  330. package/docs/design/mlx-lm-serving-execution-seam.md +0 -81
  331. package/docs/design/mlx-lm-tool-parity-plan.md +0 -545
  332. package/docs/design/omlx-adoption-map.md +0 -98
  333. package/docs/design/orpo-dynamic-lambda.md +0 -220
  334. package/docs/design/orpo-future-enhancements.md +0 -340
  335. package/docs/design/paged-kv-cache.md +0 -214
  336. package/docs/design/parallel-slots.md +0 -567
  337. package/docs/design/parity-tier-dag.md +0 -161
  338. package/docs/design/repo-cleanup-plan.md +0 -231
  339. package/docs/design/runtime-isolation.md +0 -263
  340. package/docs/design/segmented-backward-training.md +0 -543
  341. package/docs/design/spec-decode-larger-targets.md +0 -229
  342. package/docs/design/ssd-kv-cold-tier.md +0 -415
  343. package/docs/design/structured-output.md +0 -484
  344. package/docs/design/tauri-desktop-app.md +0 -127
  345. package/docs/design/the-dreaming-master-plan.md +0 -445
  346. package/docs/design/turboquant-kv.md +0 -235
  347. package/docs/design/turboquant-weights.md +0 -467
  348. package/docs/design/w4a16-compute-precision-spike.md +0 -232
  349. package/docs/design/web-chat-beat-matrix.md +0 -315
  350. package/docs/design/write-pipeline-entity.md +0 -468
  351. package/docs/planning/IDEAS.md +0 -69
  352. package/docs/reference/embedding.md +0 -197
  353. package/docs/reference/features-matrix.md +0 -101
  354. package/docs/reference/orpo-quickstart.md +0 -135
  355. package/src/flags.ts +0 -11
  356. /package/docs/{design → archive/investigations}/colibri-glm52-port.md +0 -0
  357. /package/docs/{planning → archive/planning}/chunk-task-roadmap.md +0 -0
  358. /package/docs/{planning → archive/planning}/curve-sampler-research-plan.md +0 -0
  359. /package/docs/{planning → archive/planning}/memory-docs-and-dag-plan.md +0 -0
  360. /package/docs/{planning → archive/planning}/pi-integration-review.md +0 -0
  361. /package/docs/{planning → archive/planning}/release-notes-v0.2.0.md +0 -0
  362. /package/src/{memory-plan.ts → admission-plan.ts} +0 -0
  363. /package/{docs/archive/investigations → src/serve/assets}/curve-terrain.html +0 -0
  364. /package/{docs/dag → src/serve/assets}/training-inference-map.html +0 -0
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,89 +12,30 @@
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 { existsSync, readFileSync } from "node:fs";
42
- const APP_PAGE = appHtml as unknown as string;
43
- const HLJS_JS = hljsJs as unknown as string;
44
- const HLJS_CSS = hljsCss as unknown as string;
45
- const APP_JS = appJs as unknown as string;
46
- const MANIFEST_WEBMANIFEST = manifestWebmanifest as unknown as string;
47
- const ICON_SVG = iconSvg as unknown as string;
48
- const SW_JS = swJs as unknown as string;
49
- import { loadModelConfig, type KvQuantSpec, type ModelConfig, type TurboQuantScheme } from "./config";
50
- import { Weights } from "./weights";
51
- import { Gemma4Model } from "./model/gemma4";
52
- import { DiffusionGemmaModel } from "./model/diffusion-gemma";
53
- import { spliceImageTokens } from "./vision/diffusion-vision";
54
- import {
55
- createModel,
56
- openGlm52RuntimeModel,
57
- type Glm52RuntimeOpenOptions,
58
- type RuntimeModel,
59
- } from "./model/factory";
60
- import { Glm52Model } from "./model/glm52";
15
+ import { STATIC_ROUTE_ASSETS } from "./web-assets";
16
+ import { readFileSync } from "node:fs";
17
+ import { type TurboQuantScheme } from "./config";
61
18
  import {
62
19
  GLM52_G5_ASPIRATIONAL_DECODE_TPS,
63
20
  GLM52_G5_DIRECT_ORACLE_WARM_DECODE_TPS,
64
21
  GLM52_G5_MEASURED_AT,
65
22
  GLM52_G5_MEASURED_WARM_DECODE_TPS,
66
- type Glm52MemoryPlan,
67
23
  } from "./model/glm52-memory";
68
- import { isMiniCPM5Config } from "./model/support";
69
- import { resolveModelProfile, type ResolvedModelProfile } from "./model/profile";
70
- import { Glm52NativeMtpProvider } from "./spec/glm52-mtp-source";
71
24
  import {
72
- generate,
73
25
  type GenerateOptions,
74
- type TokenLogprobs,
75
- withModelUsageFlush,
76
- withModelWiredLimit,
77
26
  } from "./generate";
78
- 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";
79
30
  import { resolveKvScheme } from "./kv-scheme";
80
31
  import { runtimeValue } from "./runtime-config";
81
32
  import { TURBOQUANT_HEAD_DIMS } from "./mlx/turboquant-tables";
82
- import {
83
- compileGrammarRequest, grammarEnabled, type GrammarRequest,
84
- } from "./grammar";
85
33
  import type { HlgConfig } from "./sampler";
86
- import { isMonotone, CURVE_UMIN, type CurveParams } from "./curve-sampler";
87
- const CURVE_PAGE = curveDesignerHtml as unknown as string;
88
- import { GenerationGateway } from "./serve/generation-gateway";
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
38
  CompletionExecutor,
91
- CompletionRejected,
92
- prepareCompletion,
93
- type CompletionSummary,
94
- type PreparedCompletion,
95
39
  } from "./serve/completion-executor";
96
40
  import {
97
41
  createPromptResponseTrace,
@@ -99,34 +43,11 @@ import {
99
43
  } from "./serve/prompt-response-trace";
100
44
  import { handleAdminRoute } from "./serve/admin-routes";
101
45
  import { handleAuxiliaryRoute } from "./serve/aux-routes";
102
- import {
103
- createTimedFlowControl,
104
- type CompletionEvent,
105
- type CompletionStreamProtocol,
106
- } from "./serve/completion-sink";
107
46
  import { createDiscoveryRoutes } from "./serve/discovery-routes";
108
47
  import { handleLabRoute } from "./serve/lab-routes";
109
48
  import { handleModelAdminRoute } from "./serve/model-admin-routes";
110
- import { RequestOwnership } from "./serve/request-plan";
111
49
  import { handleStaticRoute } from "./serve/static-routes";
112
- const STATIC_ROUTE_ASSETS = {
113
- appPage: APP_PAGE,
114
- appJs: APP_JS,
115
- hljsJs: HLJS_JS,
116
- hljsCss: HLJS_CSS,
117
- manifest: MANIFEST_WEBMANIFEST,
118
- iconSvg: ICON_SVG,
119
- serviceWorker: SW_JS,
120
- curvePage: CURVE_PAGE,
121
- };
122
- import {
123
- ChatTemplate, type ChatMessage, type ToolDefinition,
124
- } from "./chat-template";
125
- import { loadTokenizer, type LoadedTokenizer } from "./tokenizer";
126
- import {
127
- CHANNEL_END, CHANNEL_START, parseGeneratedToolCalls, parseToolCalls,
128
- TOOL_CALL_END, TOOL_CALL_START,
129
- } from "./tool-call";
50
+
130
51
  import { PromptCache, cacheBytes } from "./prompt-cache";
131
52
  import { SsdCacheStore } from "./ssd-cache";
132
53
  import {
@@ -140,27 +61,46 @@ import {
140
61
  type AnthropicRequest,
141
62
  } from "./anthropic";
142
63
  import {
143
- ResponseStore, chatJsonToResponses, outputItemsToInputItems,
64
+ ResponseStore, chatJsonToResponses, resolveResponsesConversation,
144
65
  responsesToChatBody, createResponsesStreamProtocol,
145
66
  type ResponsesRequest,
146
67
  } from "./responses";
147
- import { AdapterManager } from "./lora";
148
68
  import { fit } from "./fit";
149
69
  import { setMemoryLimit } from "./mlx/ffi";
150
- import { VisionTower } from "./vision/embedder";
151
- import { SiglipVisionTower, parseSiglipConfig } from "./vision/siglip";
152
- import { Qwen3VLVisionTower } from "./vision/qwen3vl-tower";
153
- import { buildQwen3VLVisionPrompt } from "./vision/qwen3vl-prompt";
154
- import { Qwen35Model } from "./model/qwen3_5";
155
- import type { MropeRequestState } from "./model/qwen3-mrope";
156
- import {
157
- buildMultimodalPrompt, buildVisionPrompt, extractAudio, extractImages, extractVideos,
158
- type AudioTokenIds, type VisionTokenIds, type VisionEncoder,
159
- } from "./vision/prompt";
160
- import { AudioTower, parseAudioConfig } from "./audio/conformer";
161
- import { ensureWav } from "./audio/transcode";
162
- import { sidecarShipsAudioTower } from "./registry";
163
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
+ };
164
104
 
165
105
  export interface ServerOptions {
166
106
  /** Byte cap for the prompt (KV) cache. Default 8 GB. */
@@ -176,12 +116,12 @@ export interface ServerOptions {
176
116
  * compose with continuous scheduling. A number forces uniform bits
177
117
  * (group size 64, start 0) and uses the preserved serial executor. */
178
118
  kvQuant?: "off" | "config" | number;
179
- /** TurboQuant scheme (docs/design/turboquant-kv.md): a separate axis from
119
+ /** TurboQuant scheme (docs/design/turboquant.md): a separate axis from
180
120
  * kvQuant above, mutually exclusive with it (`--kv-quant turbo[:k<bits>v
181
121
  * <bits>]` sets this instead of kvQuant). Solo-only in v1 — see
182
122
  * GenerationGateway's explicit refusal. */
183
123
  turboQuant?: TurboQuantScheme;
184
- /** OPTIONAL paged KV cache (`--paged-kv`, docs/design/paged-kv-cache.md):
124
+ /** OPTIONAL paged KV cache (`--paged-kv`, docs/design/kv-cache.md):
185
125
  * vLLM-style block-pool storage for full-attention layers,
186
126
  * gather-to-contiguous before the stock SDPA. Default off (unset = the
187
127
  * plain KVCache path, byte-identical). v1 scope: serial batch=1,
@@ -204,7 +144,7 @@ export interface ServerOptions {
204
144
  * warn before attaching to a server that may vanish. */
205
145
  owner?: "serve" | "pi-session" | "embedded";
206
146
  /** Listen on a UNIX DOMAIN SOCKET instead of a TCP port — the engine-
207
- * child mode of the isolation architecture (runtime-isolation.md): the
147
+ * child mode of the isolation architecture (docs/reference/server-config.md): the
208
148
  * parent process reverse-proxies HTTP to this socket. Stale socket
209
149
  * files are unlinked before bind. When set, `hostname`/port are ignored. */
210
150
  unixSocket?: string;
@@ -244,10 +184,10 @@ export interface ServerOptions {
244
184
  defaultAdapter?: string;
245
185
  /** SSD cold tier for the prompt/KV cache (`--ssd-cache <dir>`): prefix
246
186
  * KV survives RAM eviction AND server restarts — the coding-agent
247
- * 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
248
188
  * a directory is given. Serial lane only (where the prompt cache lives). */
249
189
  ssdCacheDir?: string;
250
- /** Byte cap for the SSD tier (default 32 GiB). */
190
+ /** Optional byte cap for the SSD tier (default unbounded). */
251
191
  ssdCacheMaxBytes?: number;
252
192
  /** Idle-demotion threshold in seconds (`--ssd-demote-idle`): prompt-cache
253
193
  * entries unused this long spill to the SSD tier and free their GPU
@@ -257,9 +197,16 @@ export interface ServerOptions {
257
197
  /** Verify every tensor hash on restore (`--ssd-cache-verify`) — reads all
258
198
  * bytes eagerly, defeating lazy fault-in; integrity paranoia only. */
259
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;
260
205
  }
261
206
 
262
207
  interface ServerLifecycle {
208
+ stopJobs: () => Promise<void>;
209
+ close: () => Promise<void>;
263
210
  flush: () => Promise<DurabilityFlushResult>;
264
211
  stats: () => DurabilitySnapshotStats;
265
212
  stopTimers: () => void;
@@ -302,8 +249,10 @@ export async function shutdownServer(
302
249
  lifecycle?.stopTimers();
303
250
  const stopped = Promise.resolve(server.stop(false));
304
251
  const work = (async (): Promise<ServerShutdownResult> => {
252
+ await lifecycle?.stopJobs();
305
253
  await stopped;
306
254
  const durability = lifecycle ? await lifecycle.flush() : emptyDurabilityResult();
255
+ await lifecycle?.close();
307
256
  return { stopped: true, timedOut: false, durability };
308
257
  })();
309
258
  let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
@@ -330,548 +279,6 @@ export async function shutdownServer(
330
279
  }
331
280
  }
332
281
 
333
- export interface ServerContext {
334
- model: RuntimeModel;
335
- /** Declared external artifact/family profile that selected model
336
- * construction. Request-level methods are resolved separately. */
337
- profile: ResolvedModelProfile;
338
- tokenizer: LoadedTokenizer;
339
- template: ChatTemplate;
340
- modelId: string;
341
- /** Lazily-loaded vision tower cache — null until the first image request
342
- * (see `getVisionTower`). The tower (SigLIP ~hundreds of MB, encoder-free
343
- * smaller) is not loaded for text-only sessions. */
344
- vision: VisionEncoder | null;
345
- /** Loads + selects the vision tower on demand; null when the model has no
346
- * (supported) vision sidecar. Invoked at most once, then cached in
347
- * `vision`. */
348
- loadVision: (() => VisionEncoder) | null;
349
- visionTokenIds: VisionTokenIds;
350
- /** Lazily-loaded Conformer audio tower — null until the first audio
351
- * request (see `getAudioTower`). Same sidecar file as vision
352
- * (optiq_vision.safetensors), separate tower; text-only sessions never
353
- * pay for it. */
354
- audio: AudioTower | null;
355
- /** Loads the audio tower on demand; null when the model can't do audio
356
- * (no `audio_config` in config.json or no sidecar). No flags — audio
357
- * auto-enables exactly like vision. */
358
- loadAudio: (() => AudioTower) | null;
359
- /** null when the model has no `audio_config` (audio-incapable). */
360
- audioTokenIds: AudioTokenIds | null;
361
- adapters: AdapterManager;
362
- /** Per-layer KV quantization from the repo's kv_config.json (null if
363
- * absent). Applied by default — optiq serve's headline behavior;
364
- * ServerOptions.kvQuant overrides ("off" | uniform bits). */
365
- kvConfig: KvQuantSpec[] | null;
366
- /** Model-author recommended sampling from generation_config.json —
367
- * optiq serve injects these as server defaults (gen_config.py);
368
- * explicit request fields always win. */
369
- genDefaults: GenSamplingDefaults;
370
- /** Speculative decoding (`serve --draft-model`, mlx_lm.server parity).
371
- * Server-level: when set, EVERY request routes to the serial lane
372
- * (upstream: is_batchable = draft is None) and spec-eligible ones decode
373
- * through src/spec/serve-loop.ts. null = no draft configured. */
374
- draft?: {
375
- provider: import("./spec/source").DraftProvider;
376
- numDraftTokens: number;
377
- } | null;
378
- /** Present only for the direct Colibri GLM runtime. This is the exact
379
- * header-derived process equation used before opening resident state. */
380
- glmMemoryPlan?: Glm52MemoryPlan | null;
381
- }
382
-
383
- export interface LoadContextOptions {
384
- memoryBudgetBytes?: number;
385
- /** Direct Colibri runtime/resource overrides. Ignored by other models. */
386
- glm?: Glm52RuntimeOpenOptions;
387
- /** Snapshot dir of a draft model for speculative decoding
388
- * (`--draft-model`). Loaded alongside the target; the pair must share
389
- * a tokenizer family. */
390
- draftModelDir?: string;
391
- /** Drafts per round (`--num-draft-tokens`, mlx_lm.server default 3). */
392
- numDraftTokens?: number;
393
- /** Draft-provider kind override (`--draft-kind`). */
394
- draftKind?: DraftKind;
395
- ngramMax?: number;
396
- ngramMin?: number;
397
- }
398
-
399
- export interface GenSamplingDefaults {
400
- temperature?: number;
401
- topP?: number;
402
- topK?: number;
403
- repetitionPenalty?: number;
404
- }
405
-
406
- export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram" | "mtp";
407
-
408
- /** Detect the draft artifact's kind so the right provider is loaded. All
409
- * providers share ONE serve loop (src/spec/serve-loop.ts). Exported for the
410
- * bench harness (scripts/bench-feature-matrix.ts) — one detection, no drift.
411
- * "ngram" is never detected — it has no artifact (model-free prompt lookup,
412
- * src/spec/ngram-source.ts) and mounts via an explicit `--draft-kind ngram`. */
413
- export async function detectDraftKind(dir: string): Promise<DraftKind> {
414
- if (await Bun.file(`${dir}/dspark.json`).exists()) return "dspark"; // our trained module
415
- try {
416
- const cfg = (await Bun.file(`${dir}/config.json`).json()) as {
417
- model_type?: string;
418
- architectures?: string[];
419
- };
420
- // DeepSeek's released DSpark drafters (DeepSpec reference): no
421
- // dspark.json, plain HF config stamped Gemma4DSparkModel.
422
- if (cfg.architectures?.[0] === "Gemma4DSparkModel") return "deepspec";
423
- if (String(cfg.model_type ?? "").includes("assistant")) return "assistant";
424
- // Native MTP heads split from a qwen3_5-family release
425
- // (mlx-community/Qwen3.8-27B-MTP-*): model_type "qwen3_5_mtp". The
426
- // target's recurrent DeltaNet caches roll back via the serve loop's
427
- // spec-round snapshot/replay contract (SSMCache.specRound*).
428
- if (String(cfg.model_type ?? "").endsWith("_mtp")) return "mtp";
429
- } catch {
430
- // no/unreadable config → fall through to a full second model
431
- }
432
- return "two-model";
433
- }
434
-
435
- async function loadGenSamplingDefaults(modelDir: string): Promise<GenSamplingDefaults> {
436
- const file = Bun.file(`${modelDir}/generation_config.json`);
437
- if (!(await file.exists())) return {};
438
- try {
439
- const raw = (await file.json()) as Record<string, unknown>;
440
- const num = (v: unknown) => (typeof v === "number" ? v : undefined);
441
- return {
442
- temperature: num(raw.temperature),
443
- topP: num(raw.top_p),
444
- topK: num(raw.top_k),
445
- repetitionPenalty: num(raw.repetition_penalty),
446
- };
447
- } catch {
448
- return {};
449
- }
450
- }
451
-
452
- export async function loadContext(
453
- modelDir: string, modelId?: string,
454
- opts: LoadContextOptions = {},
455
- ): Promise<ServerContext> {
456
- const config = await loadModelConfig(modelDir);
457
- const profile = resolveModelProfile(config);
458
- const glm = profile.profile.execution.loader === "colibri";
459
- // Bundled MTP companion: `--draft-kind mtp` with no --draft-model resolves
460
- // to the artifact's own mtp/ subfolder (single-repo packaging — the
461
- // companion is a complete model dir the provider already loads). Explicit
462
- // --draft-model still wins; a missing bundle is a clear refusal below.
463
- if (opts.draftKind === "mtp" && !opts.draftModelDir) {
464
- const bundled = `${modelDir}/mtp`;
465
- if (await Bun.file(`${bundled}/config.json`).exists()) {
466
- opts = { ...opts, draftModelDir: bundled };
467
- } else {
468
- throw new Error(
469
- `--draft-kind mtp needs a companion: pass --draft-model <dir> or use ` +
470
- `an artifact that bundles one at <model>/mtp/ (none at ${bundled})`,
471
- );
472
- }
473
- }
474
- const externalDraft = opts.draftModelDir !== undefined || opts.draftKind !== undefined;
475
- const resolvedDraftKind = opts.draftModelDir
476
- ? opts.draftKind ?? await detectDraftKind(opts.draftModelDir)
477
- : opts.draftKind;
478
- if (glm && externalDraft && opts.glm?.enableMtp === true)
479
- throw new Error("native GLM MTP and --draft-model/--draft-kind are mutually exclusive");
480
- let weights: Weights | null = null;
481
- let model!: RuntimeModel;
482
- let glmMemoryPlan: Glm52MemoryPlan | null = null;
483
- if (glm) {
484
- const enableNativeMtp = !externalDraft && opts.glm?.enableMtp !== false;
485
- const opened = await openGlm52RuntimeModel(modelDir, {
486
- ...opts.glm,
487
- memoryBudgetBytes: opts.memoryBudgetBytes ?? opts.glm?.memoryBudgetBytes,
488
- // Every drafter owns the serial speculative lane. Do not reserve a
489
- // simultaneous ordinary batch that cannot be admitted while it is live.
490
- batchSize: enableNativeMtp || externalDraft ? 1 : opts.glm?.batchSize,
491
- // An explicit alternate drafter owns the one speculative lane.
492
- enableMtp: enableNativeMtp,
493
- });
494
- model = opened.model;
495
- glmMemoryPlan = opened.plan;
496
- } else {
497
- weights = await Weights.open(modelDir);
498
- }
499
- // memoryBudget enforcement at load (Phase 5): Weights.open only mmaps
500
- // (no GPU allocation yet), so a model whose weights can never serve
501
- // within the budget is refused HERE — before any unified-memory
502
- // commitment — with an actionable error instead of a Metal OOM later.
503
- if (!glm && opts.memoryBudgetBytes) {
504
- const weightsBytes = [...weights!.shards.files.values()]
505
- .reduce((a, f) => a + f.mmap.size, 0);
506
- const report = fit(config, weightsBytes, 1, undefined, undefined, 0, opts.memoryBudgetBytes);
507
- if (report.maxSafeContext < 1)
508
- throw new Error(
509
- `model does not fit the memory budget: weights ${(weightsBytes / 1e9).toFixed(2)} GB ` +
510
- `+ prefill transient leave no room for any context within ` +
511
- `${(opts.memoryBudgetBytes / 1e9).toFixed(2)} GB`,
512
- );
513
- }
514
- if (!glm) model = createModel(weights!, config, profile);
515
- const tokenizer = await loadTokenizer(modelDir);
516
- // Generation must stop on the tokenizer's eos_token — the chat turn
517
- // terminator (e.g. Qwen <|im_end|> = 248046). Some configs (Qwen3.5-4B)
518
- // declare a different eos_token_id in config.json than the chat format
519
- // emits, so without this a turn never ends and generation runs away,
520
- // hallucinating both sides of the dialogue until max_tokens. mlx-lm stops on
521
- // the tokenizer eos; union it in. No-op when already present (Gemma, 27B).
522
- if (tokenizer.eosTokenId != null && !config.eosTokenIds.includes(tokenizer.eosTokenId))
523
- config.eosTokenIds = [...config.eosTokenIds, tokenizer.eosTokenId];
524
-
525
- // Speculative decoding: load the draft (mlx_lm.server --draft-model). The
526
- // draft artifact's KIND selects the provider — all three share ONE serve
527
- // loop (src/spec/serve-loop.ts): dspark.json → DSpark (KV-injected), a
528
- // *_assistant config → the optiq KV-borrowing Gemma drafter, otherwise a
529
- // full second model (mlx-lm parity). `--draft-kind` overrides the detect.
530
- let draft: ServerContext["draft"] = null;
531
- if (opts.draftKind === "ngram") {
532
- // Model-free prompt lookup: no artifact, no dir, no probe/budget concerns
533
- // (weightsBytes 0, open() never throws). Default γ=10 per the reference
534
- // implementation — drafting is free, so wide blocks cost only verify-window
535
- // width when wrong.
536
- if (opts.draftModelDir)
537
- throw new Error(
538
- "--draft-kind ngram is model-free — drop --draft-model (it would be ignored)",
539
- );
540
- const { NgramProvider } = await import("./spec/ngram-source");
541
- draft = {
542
- provider: new NgramProvider({ max: opts.ngramMax, min: opts.ngramMin }),
543
- numDraftTokens: Math.max(1, opts.numDraftTokens ?? 10),
544
- };
545
- } else if (opts.draftModelDir) {
546
- const dir = opts.draftModelDir;
547
- const kind = resolvedDraftKind!;
548
- let provider: import("./spec/source").DraftProvider;
549
- let numDraftTokens = Math.max(1, opts.numDraftTokens ?? 3);
550
- if (kind === "dspark") {
551
- const { DflashProvider } = await import("./spec/dflash-source");
552
- const p = await DflashProvider.load(dir);
553
- provider = p;
554
- // Pin to the trained block width — the serve loop must never ask for
555
- // more positions than the DSpark block was trained for (n ≤ cfg.gamma).
556
- numDraftTokens = Math.max(1, Math.min(opts.numDraftTokens ?? p.gamma, p.gamma));
557
- } else if (kind === "deepspec") {
558
- const { DeepspecProvider } = await import("./spec/deepspec-source");
559
- const p = await DeepspecProvider.load(dir);
560
- provider = p;
561
- // Same pin, from their config's block_size (e.g. 7 for the released
562
- // dspark_gemma4_12b_block7).
563
- numDraftTokens = Math.max(1, Math.min(opts.numDraftTokens ?? p.gamma, p.gamma));
564
- } else if (kind === "assistant") {
565
- const { AssistantProvider } = await import("./spec/assistant-source");
566
- provider = await AssistantProvider.load(dir);
567
- } else if (kind === "mtp") {
568
- const { QwenMtpProvider } = await import("./spec/qwen-mtp-source");
569
- const p = await QwenMtpProvider.load(dir);
570
- provider = p;
571
- // Default the round width to the head's trained block (block_size 3 →
572
- // 2 recursive drafts + the pending row per round; the head was trained
573
- // multi-step, so an explicit larger --num-draft-tokens is allowed but
574
- // acceptance decides whether it pays).
575
- const block = (await Bun.file(`${dir}/config.json`).json() as { block_size?: number }).block_size;
576
- if (opts.numDraftTokens === undefined && typeof block === "number")
577
- numDraftTokens = Math.max(1, block - 1);
578
- } else {
579
- const { TwoModelProvider } = await import("./spec/two-model");
580
- provider = await TwoModelProvider.load(dir, config.text.vocabSize);
581
- // Tokenizer-family hard check — two-model ONLY (it ships its own
582
- // tokenizer). Exact-token-match acceptance is meaningful only when both
583
- // models tokenize identically; a probe that ENCODES differently means
584
- // different families — refuse instead of silently accepting ~0% of
585
- // drafts. The KV-borrowing drafters share the target's tokenization by
586
- // construction, so this check does not apply to them.
587
- const draftTok = await loadTokenizer(dir);
588
- const probe = "The 3 quick brown foxes jumped över the lazy dog?! 🦊";
589
- if (JSON.stringify(tokenizer.encode(probe)) !== JSON.stringify(draftTok.encode(probe))) {
590
- provider.dispose();
591
- throw new Error(
592
- `--draft-model tokenizer differs from the target's (probe string encodes ` +
593
- `differently) — speculation needs the same tokenizer family`,
594
- );
595
- }
596
- }
597
- // Fail-fast pairing validation (2026-07-07 review): the KV-borrowing
598
- // sources validate the (target, drafter) pairing in open() — non-Gemma4
599
- // target, DeepSpec target-layer-count mismatch — which used to surface
600
- // as a 500 from inside specServeRun on EVERY text request. Probe-open
601
- // once with throwaway caches here so a mismatch refuses at load; open()
602
- // allocates no per-request tensors before prefill/draft, so this is
603
- // free. The probe sampler is never called during open().
604
- {
605
- const probeCaches = model.makeCache();
606
- try {
607
- provider
608
- .open({
609
- sampler: () => { throw new Error("probe sampler never samples"); },
610
- target: { model, caches: probeCaches },
611
- })
612
- .dispose();
613
- } catch (err) {
614
- provider.dispose();
615
- throw new Error(
616
- `--draft-model is incompatible with this target: ${err instanceof Error ? err.message : String(err)}`,
617
- );
618
- } finally {
619
- for (const c of probeCaches) c.dispose();
620
- }
621
- }
622
- if (opts.memoryBudgetBytes) {
623
- const targetBytes = weights
624
- ? [...weights.shards.files.values()].reduce((a, f) => a + f.mmap.size, 0)
625
- : model.weightsBytes;
626
- // Draft weights shrink the target's envelope. Draft KV is not modeled
627
- // (small relative to its weights at serve contexts); admission stays
628
- // approximately conservative via the combined-weights term.
629
- const report = fit(config, targetBytes + provider.weightsBytes, 1, undefined, undefined, 0, opts.memoryBudgetBytes);
630
- if (report.maxSafeContext < 1) {
631
- provider.dispose();
632
- throw new Error(
633
- `target + draft do not fit the memory budget (draft adds ` +
634
- `${(provider.weightsBytes / 1e9).toFixed(2)} GB)`,
635
- );
636
- }
637
- }
638
- draft = { provider, numDraftTokens };
639
- } else if (opts.draftKind) {
640
- // Every other kind names an artifact to load — refuse instead of silently
641
- // serving without speculation.
642
- throw new Error(`--draft-kind ${opts.draftKind} requires --draft-model`);
643
- }
644
-
645
- // GLM's checkpoint-native MTP row is the production default. It uses the
646
- // already-planned bounded auxiliary expert tier and the same tokenizer, so
647
- // there is no second artifact or compatibility probe to load.
648
- if (!draft && model instanceof Glm52Model && glmMemoryPlan?.enableMtp) {
649
- draft = {
650
- provider: new Glm52NativeMtpProvider(model),
651
- numDraftTokens: glmMemoryPlan.mtpDraftTokens,
652
- };
653
- }
654
-
655
- return {
656
- draft,
657
- model,
658
- profile,
659
- glmMemoryPlan,
660
- adapters: new AdapterManager(model),
661
- kvConfig: config.kvQuant,
662
- genDefaults: await loadGenSamplingDefaults(modelDir),
663
- tokenizer,
664
- template: await ChatTemplate.load(modelDir),
665
- modelId: modelId ?? modelDir.split("/").filter(Boolean).at(-1)!,
666
- // Vision is loaded lazily (getVisionTower) — text-only sessions never
667
- // pay for the tower. The loader picks the encoder-free gemma4_unified
668
- // (12B) tower vs the SigLIP encoder (e2b/e4b/26B/31B) by the sidecar's
669
- // vision_config.model_type. Vision sidecars are a Gemma4 feature;
670
- // MiniCPM5 never ships one.
671
- vision: null,
672
- loadVision: makeVisionLoader(modelDir, model, config),
673
- visionTokenIds: {
674
- imageTokenId: (config.raw.image_token_id as number) ?? 258880,
675
- boiTokenId: (config.raw.boi_token_id as number) ?? 255999,
676
- eoiTokenId: (config.raw.eoi_token_id as number) ?? 258882,
677
- },
678
- // Audio mirrors vision: lazy tower from the same sidecar, loaded on the
679
- // first audio request only (docs/design/audio-input-plan.md A4).
680
- audio: null,
681
- loadAudio: makeAudioLoader(modelDir, model, config),
682
- audioTokenIds: config.raw.audio_config
683
- ? {
684
- audioTokenId: (config.raw.audio_token_id as number) ?? 258881,
685
- boaTokenId: (config.raw.boa_token_id as number) ?? 256000,
686
- eoaTokenId: (config.raw.eoa_token_id as number) ?? 258883,
687
- }
688
- : null,
689
- };
690
- }
691
-
692
- /** Build the on-demand vision-tower loader, selecting the encoder-free
693
- * (gemma4_unified, 12B) tower vs the SigLIP encoder (gemma4_vision:
694
- * e2b/e4b/26B/31B) by the sidecar's vision_config.model_type. Returns null
695
- * when the model has no usable vision sidecar. */
696
- function makeVisionLoader(
697
- modelDir: string, model: RuntimeModel, config: ModelConfig,
698
- ): (() => VisionEncoder) | null {
699
- // Qwen3.5/3.8: the tower ships as optiq/optiq_vision.safetensors (the
700
- // artifact's bf16 sidecar). The returned tower is a Qwen3VLVisionTower —
701
- // it rides the same lazy slot/capability flags; the qwen chat branch is
702
- // the only consumer and casts it back (the gemma branches are gated on
703
- // `instanceof Gemma4Model`, so the union never crosses).
704
- if (model instanceof Qwen35Model) {
705
- // Vision weights arrive either as the OptiQ-convention sidecar OR in-main
706
- // (mlx-vlm convention; our artifacts ship one copy in-main since
707
- // 2026-08-18 — the tower loader handles both).
708
- const hasSidecar = existsSync(`${modelDir}/optiq/optiq_vision.safetensors`);
709
- const hasInMain = config.raw.vision_config !== undefined;
710
- if (!hasSidecar && !hasInMain) return null;
711
- return () =>
712
- Qwen3VLVisionTower.load(modelDir) as unknown as VisionEncoder;
713
- }
714
- if (!(config.hasVisionSidecar && model instanceof Gemma4Model)) return null;
715
- const vc = config.raw.vision_config as Record<string, any> | undefined;
716
- if (vc?.model_type === "gemma4_vision") {
717
- const sigCfg = parseSiglipConfig(vc);
718
- return () => SiglipVisionTower.load(modelDir, sigCfg, model.embedScale);
719
- }
720
- // gemma4_unified_vision (or unlabelled): the encoder-free patch embedder.
721
- return () => VisionTower.load(modelDir, model.embedScale, config.text.rmsNormEps);
722
- }
723
-
724
- /** Lazily load + cache the vision tower on first use. A sidecar that fails
725
- * to load is a capability gap, not a fatal error: returns null and the
726
- * request is answered with a 400 (the loader is cleared so we don't retry
727
- * a known-bad load every request). */
728
- function getVisionTower(ctx: ServerContext): VisionEncoder | null {
729
- if (ctx.vision) return ctx.vision;
730
- if (!ctx.loadVision) return null;
731
- try {
732
- ctx.vision = ctx.loadVision();
733
- return ctx.vision;
734
- } catch (e) {
735
- console.warn(`vision sidecar not loadable (${(e as Error).message}) — serving text-only`);
736
- ctx.loadVision = null;
737
- return null;
738
- }
739
- }
740
-
741
- /** Build the on-demand audio-tower loader (gemma-4 Conformer, A4 of
742
- * docs/design/audio-input-plan.md). Auto-enables — no flags — when
743
- * config.json carries an `audio_config` AND the optiq_vision.safetensors
744
- * sidecar exists (the audio tensors ship in the same sidecar as vision).
745
- * Returns null when the model can't do audio (no audio_config: 26B-A4B,
746
- * DiffusionGemma, bf16 assistants — architectural, not a porting gap). */
747
- function makeAudioLoader(
748
- modelDir: string, model: RuntimeModel, config: ModelConfig,
749
- ): (() => AudioTower) | null {
750
- if (!(config.hasVisionSidecar && config.raw.audio_config && model instanceof Gemma4Model))
751
- return null;
752
- // The sidecar header must actually name the Conformer tensors: the local
753
- // 12B snapshot pairs audio_config with a STUB sidecar (embed_audio only),
754
- // and a non-null loader here is what advertises `audio: true` on every
755
- // capability surface (ws handshake, /v1/models). Header-only read.
756
- if (!sidecarShipsAudioTower(`${modelDir}/optiq_vision.safetensors`)) return null;
757
- const audioCfg = parseAudioConfig(config.raw.audio_config as Record<string, any>);
758
- return () => AudioTower.load(modelDir, audioCfg, model.embedScale);
759
- }
760
-
761
- /** Lazily load + cache the audio tower on first use. Unlike vision's
762
- * warn-and-continue (text-only degrade is correct for requests WITHOUT
763
- * images), this is only ever consulted for requests WITH audio — the
764
- * caller turns null into an explicit 400, never a silent text-only
765
- * degrade. A failed load is not retried every request. (The stub-sidecar
766
- * case — the local 12B state — never gets here: makeAudioLoader checks the
767
- * sidecar header and returns a null loader.) */
768
- function getAudioTower(ctx: ServerContext): AudioTower | null {
769
- if (ctx.audio) return ctx.audio;
770
- if (!ctx.loadAudio) return null;
771
- try {
772
- ctx.audio = ctx.loadAudio();
773
- return ctx.audio;
774
- } catch (e) {
775
- console.warn(`audio sidecar not loadable (${(e as Error).message})`);
776
- ctx.loadAudio = null;
777
- return null;
778
- }
779
- }
780
-
781
- interface ChatRequest {
782
- messages: ChatMessage[];
783
- stream?: boolean;
784
- max_tokens?: number;
785
- max_completion_tokens?: number;
786
- temperature?: number;
787
- top_p?: number;
788
- top_k?: number;
789
- seed?: number;
790
- repetition_penalty?: number;
791
- /** mlx-lm extension: recent-token window for repetition_penalty
792
- * (default 20; 0 = whole history, Python `[-0:]` semantics). */
793
- repetition_context_size?: number;
794
- /** min-p sampling (mlx_lm.server's `min_p`): keep tokens whose probability
795
- * is ≥ min_p · p(top token). 0 = off. */
796
- min_p?: number;
797
- /** XTC sampling (mlx_lm.server names): with probability `xtc_probability`
798
- * per step, remove every token above `xtc_threshold` except the least
799
- * likely of them. EOS + the newline token are always exempt (the server
800
- * injects them as xtc special tokens, matching mlx_lm.server). */
801
- xtc_probability?: number;
802
- xtc_threshold?: number;
803
- /** OpenAI logit_bias: {tokenId: additive bias}. JSON object keys arrive as
804
- * strings; coerced to int keys / float values like mlx-lm (400 on failure). */
805
- logit_bias?: Record<string, number>;
806
- /** OpenAI presence/frequency penalties + mlx-lm's context-size extensions
807
- * (window of recent tokens the penalty looks at; default 20). */
808
- presence_penalty?: number;
809
- presence_context_size?: number;
810
- frequency_penalty?: number;
811
- frequency_context_size?: number;
812
- /** mlx_lm.server logprobs: `logprobs` is a BOOL (even on /v1/completions —
813
- * not OpenAI's legacy int), `top_logprobs` an int in [0, 11] or the -1
814
- * "unset" sentinel (server.py validates exactly that; OpenAI's cap is 20,
815
- * mlx-lm's is 11 — we copy the reference). Non-stream responses carry
816
- * mlx-lm's logprobs block; stream chunks never do (reference behavior). */
817
- logprobs?: boolean;
818
- top_logprobs?: number;
819
- /** OpenAI stop sequences: plain string or array (spec allows up to 4).
820
- * Matched on DECODED text, not token ids — see StopMatcher. */
821
- stop?: string | string[];
822
- tools?: ToolDefinition[];
823
- tool_choice?: "auto" | "none" | { type: string; function?: { name: string } };
824
- /** Forwarded to HF chat templates, matching optiq serve. MiniCPM5 uses
825
- * enable_thinking to select direct answers vs the <think> channel; Qwen3.8
826
- * adds preserve_thinking (keep think blocks from history; template default
827
- * true — better prompt-cache reuse in agent loops). */
828
- chat_template_kwargs?: {
829
- enable_thinking?: boolean;
830
- preserve_thinking?: boolean;
831
- [key: string]: unknown;
832
- };
833
- /** OpenAI reasoning control. For models with a switchable <think> channel
834
- * (Qwen3.5/MiniCPM5) it gates enable_thinking: "none" → off, any level → on.
835
- * This is what Pi sends when the provider advertises reasoning. For
836
- * templates that read a reasoning-depth variable (Qwen3.8: xhigh/medium/
837
- * low), the level ALSO maps into the template via qwenReasoningEffort —
838
- * "xhigh" accepted as a first-class value (Qwen3.8's default/deepest). */
839
- reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
840
- /** Mounted LoRA adapter selection: "id", "a+b" (stacked), or "none". */
841
- adapter?: string;
842
- /** HLG tone-curve sampling override (per request). Snake_case wire fields,
843
- * merged over the server's --hlg-sampling config. docs/design/hlg-sampling.md. */
844
- hlg?: {
845
- enabled?: boolean;
846
- width?: number;
847
- shoulder?: number;
848
- toe?: number;
849
- pivot_offset?: number;
850
- };
851
- /** OpenAI structured output: {type:"json_object"} | {type:"json_schema",
852
- * json_schema:{name,schema,strict?}} | {type:"text"}. Enforced at the
853
- * sampler via xgrammar token-bitmasks (src/grammar.ts). L2-class (oMLX
854
- * oracle). On compile failure, degrades to a system-prompt injection +
855
- * Warning header (oMLX parity), never 500. */
856
- response_format?: unknown;
857
- /** vLLM/oMLX grammar aliases (all compiled via xgrammar): raw EBNF/LARK
858
- * grammar, regex, enum choice, and a bare JSON-schema object. Precedence
859
- * (guided_grammar > json_schema > json_object > structured_outputs >
860
- * guided_regex > guided_choice) mirrors oMLX _effective_guided_grammar. */
861
- guided_grammar?: string;
862
- guided_regex?: string;
863
- guided_choice?: string[];
864
- structured_outputs?: unknown;
865
- }
866
-
867
- /** POST /v1/completions body (mlx_lm.server's raw text completion — no chat
868
- * template). Sampling/penalty/stop fields are the same names as ChatRequest;
869
- * `prompt` replaces `messages`. mlx_lm.server accepts only a STRING prompt
870
- * (it calls `tokenizer.encode(request.prompt)` directly) — token-array
871
- * prompts are rejected there too, so we match. No `echo` (mlx-lm has none). */
872
- type TextCompletionRequest = Omit<ChatRequest, "messages" | "tools" | "tool_choice"> & {
873
- prompt?: unknown;
874
- };
875
282
 
876
283
  export interface ContextAdmissionDecision {
877
284
  /** Effective completion ceiling after admission. */
@@ -902,744 +309,7 @@ export function admitRequestContext(
902
309
  return { maxTokens: available, clamped: true };
903
310
  }
904
311
 
905
- /** Per-field default HLG knobs when enabling without specifying them. */
906
- const HLG_DEFAULTS = { width: 4, shoulder: 4, toe: 6, pivotOffset: 6 } as const;
907
-
908
- /** Resolve the effective HLG config: a per-request `hlg` object overrides the
909
- * server's --hlg-sampling default field-by-field. Returns undefined (HLG off)
910
- * unless enabled by the request or the server. */
911
- function resolveHlg(
912
- reqHlg: ChatRequest["hlg"],
913
- serverHlg: HlgConfig | undefined,
914
- ): HlgConfig | undefined {
915
- const enabled = reqHlg?.enabled ?? serverHlg?.enabled ?? false;
916
- if (!enabled) return undefined;
917
- const base = serverHlg ?? HLG_DEFAULTS;
918
- return {
919
- enabled: true,
920
- width: reqHlg?.width ?? base.width,
921
- shoulder: reqHlg?.shoulder ?? base.shoulder,
922
- toe: reqHlg?.toe ?? base.toe,
923
- pivotOffset: reqHlg?.pivot_offset ?? base.pivotOffset,
924
- pivot: "top",
925
- };
926
- }
927
-
928
- interface OpenAIToolCall {
929
- id: string;
930
- type: "function";
931
- function: { name: string; arguments: string };
932
- }
933
-
934
- export type ToolStreamMode = "gemma-sentinel" | "plain" | "buffered-text";
935
-
936
- /** Pick the stream router for a model family. The token-id sentinel router is
937
- * Gemma-4-ONLY: ids 48/49 (<|tool_call>/<tool_call|>) and 100/101
938
- * (<|channel>/<channel|>) are special tokens of that tokenizer family
939
- * (src/tool-call.ts). On every other tokenizer — MiniCPM5, Qwen3/3.5, and
940
- * the Tier-0 generics (llama, qwen2, phi3, …) — those ids are ordinary
941
- * low-id vocab, so the sentinel router would silently swallow output into a
942
- * phantom tool/reasoning segment. Everyone else parses tool calls from
943
- * decoded text (buffered-text; parseGeneratedToolCalls covers the
944
- * OpenAI-JSON <tool_call>, Qwen <function=…>, and MiniCPM5 <function name=…>
945
- * shapes) when tools are present, and streams plain otherwise. Models whose
946
- * markup isn't covered fail soft: the markup stays in content. */
947
- export function selectToolStreamMode(modelType: string, hasTools: boolean): ToolStreamMode {
948
- if (modelType.startsWith("gemma4")) return "gemma-sentinel";
949
- return hasTools ? "buffered-text" : "plain";
950
- }
951
-
952
- /** Routes generated tokens. Gemma uses family-specific sentinel token ids;
953
- * MiniCPM5 and other text-template models use decoded-text parsing so
954
- * ordinary tokenizer ids like "<" are never swallowed globally. Exported for
955
- * unit tests (gemma-channel reasoning split). */
956
- export class ToolAwareStream {
957
- readonly #decoder: StreamDecoder;
958
- #inTool = false;
959
- #toolTokens: number[] = [];
960
- #text = "";
961
- /** Chars of #text already returned as content. */
962
- #sent = 0;
963
- /** Index where tool markup starts; content emission stops there. */
964
- #frozen = -1;
965
- #textToolCalls: OpenAIToolCall[] | null = null;
966
- #textToolParseFailed = false;
967
- readonly toolSegments: number[][] = [];
968
-
969
- /** Gemma reasoning-channel state (gemma-sentinel mode). The model wraps
970
- * chain-of-thought as `<|channel>thought\n…<channel|>` using special tokens
971
- * 100/101 that the content decoder strips, so reasoning is captured here at
972
- * the token level. A SEPARATE decoder keeps the reasoning byte-stream's
973
- * incremental state independent of content's. The `thought` channel-name
974
- * word is stripped before the reasoning text (mlx-lm's think-start marker
975
- * is `<|channel>thought`; the "\n" after it is reasoning content). */
976
- readonly #channelDecoder: StreamDecoder;
977
- #inChannel = false;
978
- #channelNamePending = "";
979
- #channelNameDone = false;
980
- #reasoning = "";
981
312
 
982
- /** Decoded-text markers that open tool markup (oracle: the streaming
983
- * parser buffers from `<tool_call`/`<function` on, never the whole
984
- * response — content before a tool call still streams live). */
985
- static readonly TOOL_MARKERS = ["<tool_call>", "<function"];
986
-
987
- constructor(
988
- readonly tokenizer: LoadedTokenizer,
989
- readonly mode: ToolStreamMode,
990
- readonly tools: ToolDefinition[] | null,
991
- ) {
992
- this.#decoder = new StreamDecoder(tokenizer, mode !== "buffered-text");
993
- this.#channelDecoder = new StreamDecoder(tokenizer, true);
994
- }
995
-
996
- /** Feed decoded channel text, stripping the leading `thought` channel-name
997
- * word (which may arrive across tokens). Returns the reasoning delta.
998
- * mlx-lm parity: its think-start marker is exactly `<|channel>thought`
999
- * (tokenizer_utils.py `_infer_thinking`) and only the MARKER tokens get
1000
- * their text blanked (server.py `_process_control_tokens`) — the "\n"
1001
- * after the name is an ordinary generated token, so it is the FIRST byte
1002
- * of the reasoning stream and must be kept, not swallowed with the name. */
1003
- #feedChannel(text: string): string {
1004
- if (this.#channelNameDone) return text;
1005
- this.#channelNamePending += text;
1006
- const nl = this.#channelNamePending.indexOf("\n");
1007
- if (nl === -1) return ""; // still inside the channel-name word
1008
- this.#channelNameDone = true;
1009
- const rest = this.#channelNamePending.slice(nl); // keep the "\n" (mlx-lm does)
1010
- this.#channelNamePending = "";
1011
- return rest;
1012
- }
1013
-
1014
- /** Drain reasoning captured since the last call (gemma-channel thinking). */
1015
- takeReasoning(): string {
1016
- const r = this.#reasoning;
1017
- this.#reasoning = "";
1018
- return r;
1019
- }
1020
-
1021
- /** Emit the longest #text prefix that cannot be (the start of) tool
1022
- * markup; hold back ambiguous tails until disambiguated. */
1023
- #textDelta(): string {
1024
- if (this.#frozen >= 0) return "";
1025
- let markerAt = -1;
1026
- for (const mk of ToolAwareStream.TOOL_MARKERS) {
1027
- const i = this.#text.indexOf(mk, this.#sent);
1028
- if (i !== -1 && (markerAt === -1 || i < markerAt)) markerAt = i;
1029
- }
1030
- if (markerAt !== -1) {
1031
- this.#frozen = markerAt;
1032
- const out = this.#text.slice(this.#sent, markerAt);
1033
- this.#sent = markerAt;
1034
- return out;
1035
- }
1036
- let hold = 0;
1037
- for (const mk of ToolAwareStream.TOOL_MARKERS) {
1038
- const max = Math.min(mk.length - 1, this.#text.length - this.#sent);
1039
- for (let k = max; k > hold; k--) {
1040
- if (this.#text.endsWith(mk.slice(0, k))) { hold = k; break; }
1041
- }
1042
- }
1043
- const limit = this.#text.length - hold;
1044
- if (limit <= this.#sent) return "";
1045
- const out = this.#text.slice(this.#sent, limit);
1046
- this.#sent = limit;
1047
- return out;
1048
- }
1049
-
1050
- /** Returns the content text delta for this token ("" while capturing). */
1051
- push(token: number): string {
1052
- if (this.mode !== "gemma-sentinel") {
1053
- this.#text += this.#decoder.push(token);
1054
- if (this.mode === "plain") {
1055
- const out = this.#text.slice(this.#sent);
1056
- this.#sent = this.#text.length;
1057
- return out;
1058
- }
1059
- return this.#textDelta();
1060
- }
1061
- if (this.#inTool) {
1062
- if (token === TOOL_CALL_END) {
1063
- this.#inTool = false;
1064
- this.toolSegments.push(this.#toolTokens);
1065
- this.#toolTokens = [];
1066
- } else {
1067
- this.#toolTokens.push(token);
1068
- }
1069
- return "";
1070
- }
1071
- // Reasoning channel: tokens between <|channel> and <channel|> are thought,
1072
- // captured to #reasoning (drained via takeReasoning), never content. An
1073
- // empty block (<|channel>thought\n<channel|>, emitted by larger Gemmas even
1074
- // with thinking off) yields only the "\n" as reasoning (mlx-lm parity) and
1075
- // leaks nothing into content.
1076
- if (this.#inChannel) {
1077
- if (token === CHANNEL_END) {
1078
- this.#inChannel = false;
1079
- this.#reasoning += this.#feedChannel(this.#channelDecoder.flush());
1080
- } else {
1081
- this.#reasoning += this.#feedChannel(this.#channelDecoder.push(token));
1082
- }
1083
- return "";
1084
- }
1085
- if (token === CHANNEL_START) {
1086
- this.#inChannel = true;
1087
- this.#channelNameDone = false;
1088
- this.#channelNamePending = "";
1089
- return "";
1090
- }
1091
- if (token === TOOL_CALL_START) {
1092
- this.#inTool = true;
1093
- return "";
1094
- }
1095
- return this.#decoder.push(token);
1096
- }
1097
-
1098
- flush(): string {
1099
- if (this.mode !== "gemma-sentinel") {
1100
- this.#text += this.#decoder.flush();
1101
- if (this.mode === "buffered-text") {
1102
- const calls = this.toolCalls();
1103
- if (calls.length && !this.#textToolParseFailed && this.#frozen >= 0) {
1104
- // markup parsed into tool_calls — emit any prose still held
1105
- // before it; the markup itself never reaches content
1106
- const out = this.#text.slice(this.#sent, this.#frozen);
1107
- this.#sent = this.#text.length;
1108
- return out;
1109
- }
1110
- // no tool call (or parse fallback): release everything withheld
1111
- const out = this.#text.slice(this.#sent);
1112
- this.#sent = this.#text.length;
1113
- return out;
1114
- }
1115
- const out = this.#text.slice(this.#sent);
1116
- this.#sent = this.#text.length;
1117
- return out;
1118
- }
1119
- if (this.#inChannel) {
1120
- // truncated mid-reasoning (hit max_tokens); surface the partial thought
1121
- this.#reasoning += this.#feedChannel(this.#channelDecoder.flush());
1122
- this.#inChannel = false;
1123
- return "";
1124
- }
1125
- if (this.#inTool && this.#toolTokens.length) {
1126
- // truncated mid-tool-call (hit max_tokens); surface what we have
1127
- this.toolSegments.push(this.#toolTokens);
1128
- this.#toolTokens = [];
1129
- }
1130
- return this.#decoder.flush();
1131
- }
1132
-
1133
- toolCalls(): OpenAIToolCall[] {
1134
- if (this.mode !== "gemma-sentinel") {
1135
- if (this.#textToolCalls) return this.#textToolCalls;
1136
- try {
1137
- this.#textToolCalls = parseGeneratedToolCalls(this.#text, this.tools ?? []).map((c) => ({
1138
- id: `call_${crypto.randomUUID().slice(0, 8)}`,
1139
- type: "function",
1140
- function: { name: c.name, arguments: JSON.stringify(c.arguments) },
1141
- }));
1142
- } catch {
1143
- this.#textToolParseFailed = true;
1144
- this.#textToolCalls = [];
1145
- }
1146
- return this.#textToolCalls;
1147
- }
1148
- const out: OpenAIToolCall[] = [];
1149
- for (const seg of this.toolSegments) {
1150
- const text = this.tokenizer.decode(seg, false); // keep <|"|> markers
1151
- for (const c of parseToolCalls(text)) {
1152
- out.push({
1153
- id: `call_${crypto.randomUUID().slice(0, 8)}`,
1154
- type: "function",
1155
- function: { name: c.name, arguments: JSON.stringify(c.arguments) },
1156
- });
1157
- }
1158
- }
1159
- return out;
1160
- }
1161
- }
1162
-
1163
- /** Decoded-text stop-sequence matcher with streaming hold-back. Matching
1164
- * on text (not token ids) catches sequences that span token boundaries
1165
- * or tokenize differently in context; current mlx-lm uses token-id
1166
- * state machines and misses those. Text that could be the start of a
1167
- * stop sequence is withheld until disambiguated, so SSE clients never
1168
- * see any part of the stop sequence itself. */
1169
- export class StopMatcher {
1170
- #pending = "";
1171
- stopped = false;
1172
-
1173
- constructor(readonly sequences: string[]) {}
1174
-
1175
- /** Feed a text delta; returns the prefix that is now safe to emit.
1176
- * After a match fires (`stopped`), text before the match is returned
1177
- * and everything from the match on is discarded. */
1178
- push(text: string): string {
1179
- if (this.stopped) return "";
1180
- if (this.sequences.length === 0) return text;
1181
- this.#pending += text;
1182
- // earliest full match wins
1183
- let cut = -1;
1184
- for (const seq of this.sequences) {
1185
- const i = this.#pending.indexOf(seq);
1186
- if (i !== -1 && (cut === -1 || i < cut)) cut = i;
1187
- }
1188
- if (cut !== -1) {
1189
- this.stopped = true;
1190
- const out = this.#pending.slice(0, cut);
1191
- this.#pending = "";
1192
- return out;
1193
- }
1194
- // hold back the longest tail that is a proper prefix of any sequence
1195
- let hold = 0;
1196
- for (const seq of this.sequences) {
1197
- const max = Math.min(seq.length - 1, this.#pending.length);
1198
- for (let k = max; k > hold; k--) {
1199
- if (this.#pending.endsWith(seq.slice(0, k))) {
1200
- hold = k;
1201
- break;
1202
- }
1203
- }
1204
- }
1205
- if (hold === 0) {
1206
- const out = this.#pending;
1207
- this.#pending = "";
1208
- return out;
1209
- }
1210
- const out = this.#pending.slice(0, -hold);
1211
- this.#pending = this.#pending.slice(-hold);
1212
- return out;
1213
- }
1214
-
1215
- /** Generation ended without a match — release any held-back text. */
1216
- flush(): string {
1217
- const out = this.#pending;
1218
- this.#pending = "";
1219
- return out;
1220
- }
1221
- }
1222
-
1223
- /** Split Qwen-style inline <think>...</think> markup into OpenAI reasoning
1224
- * deltas/content. This keeps raw tags out of normal chat text while giving
1225
- * pi (TUI + web) proper thinking_delta events. It is streaming-safe: partial
1226
- * tag prefixes are held until disambiguated. */
1227
- export class ThinkingTagSplitter {
1228
- #pending = "";
1229
- #inThinking: boolean;
1230
- reasoning = "";
1231
- content = "";
1232
-
1233
- /** `startInThinking` seeds the parser INSIDE a <think> block. Needed for
1234
- * templates (Qwen3.5, MiniCPM5) that prime an OPEN `<think>` in the
1235
- * generation prompt when thinking is enabled: the model's output then
1236
- * starts mid-reasoning and emits only the closing `</think>`, never an
1237
- * opening tag. Without this seed the whole chain-of-thought leaks into
1238
- * `content` and the `reasoning` field stays empty. */
1239
- constructor(private readonly enabled: boolean, startInThinking = false) {
1240
- this.#inThinking = startInThinking;
1241
- }
1242
-
1243
- #safePrefixUntilTag(tag: string): string {
1244
- const i = this.#pending.indexOf(tag);
1245
- if (i !== -1) return this.#pending.slice(0, i);
1246
- let hold = 0;
1247
- for (let k = Math.min(tag.length - 1, this.#pending.length); k > 0; k--) {
1248
- if (this.#pending.endsWith(tag.slice(0, k))) { hold = k; break; }
1249
- }
1250
- return this.#pending.slice(0, this.#pending.length - hold);
1251
- }
1252
-
1253
- push(text: string): { content: string; reasoning: string } {
1254
- if (!this.enabled) {
1255
- this.content += text;
1256
- return { content: text, reasoning: "" };
1257
- }
1258
- this.#pending += text;
1259
- let content = "";
1260
- let reasoning = "";
1261
- while (this.#pending) {
1262
- const tag = this.#inThinking ? "</think>" : "<think>";
1263
- const i = this.#pending.indexOf(tag);
1264
- const emit = i === -1 ? this.#safePrefixUntilTag(tag) : this.#pending.slice(0, i);
1265
- if (!emit && i === -1) break;
1266
- if (emit) {
1267
- if (this.#inThinking) reasoning += emit;
1268
- else content += emit;
1269
- this.#pending = this.#pending.slice(emit.length);
1270
- }
1271
- if (i !== -1 && this.#pending.startsWith(tag)) {
1272
- this.#pending = this.#pending.slice(tag.length);
1273
- this.#inThinking = !this.#inThinking;
1274
- continue;
1275
- }
1276
- if (i === -1) break;
1277
- }
1278
- this.content += content;
1279
- this.reasoning += reasoning;
1280
- return { content, reasoning };
1281
- }
1282
-
1283
- flush(): { content: string; reasoning: string } {
1284
- if (!this.enabled) return { content: "", reasoning: "" };
1285
- const out = this.#inThinking
1286
- ? { content: "", reasoning: this.#pending }
1287
- : { content: this.#pending, reasoning: "" };
1288
- this.#pending = "";
1289
- this.content += out.content;
1290
- this.reasoning += out.reasoning;
1291
- return out;
1292
- }
1293
- }
1294
-
1295
- /** True when a rendered prompt ends INSIDE an unclosed `<think>` block — the
1296
- * generation prompt primed reasoning (Qwen3.5 / MiniCPM5 with thinking on),
1297
- * so the model continues the chain-of-thought and emits only the closing
1298
- * `</think>`. Seeds ThinkingTagSplitter so reasoning is split out correctly.
1299
- * Thinking-off primes a CLOSED empty block (`<think>\n\n</think>`), and
1300
- * no-thinking templates have no `<think>` at all — both return false. */
1301
- export function promptEndsInOpenThink(rendered: string): boolean {
1302
- const open = rendered.lastIndexOf("<think>");
1303
- return open !== -1 && open > rendered.lastIndexOf("</think>");
1304
- }
1305
-
1306
- /** Concatenate the text of an OpenAI content-part array, ignoring non-text
1307
- * parts. Tolerant of the part shapes clients actually send: `{type:"text",
1308
- * text}` (OpenAI/pi), `{type:"input_text", text}` (Responses-style), or any
1309
- * part carrying a string `text`. */
1310
- function contentPartsToText(parts: Array<Record<string, unknown>>): string {
1311
- return parts
1312
- .map((p) => (p && typeof p.text === "string" ? p.text : ""))
1313
- .join("");
1314
- }
1315
-
1316
- /** True if any content part is media — image OR audio — so the multimodal
1317
- * path must keep the array form for extractImages/extractAudio. */
1318
- function hasMediaPart(parts: Array<Record<string, unknown>>): boolean {
1319
- return parts.some(
1320
- (p) =>
1321
- p &&
1322
- (p.type === "image" || p.type === "image_url" ||
1323
- p.type === "audio" || p.type === "input_audio" || p.type === "audio_url" ||
1324
- p.type === "video" || p.type === "video_url"),
1325
- );
1326
- }
1327
-
1328
- /** OpenAI sends assistant tool_call arguments as JSON strings; the
1329
- * template renders the object form natively — normalize before render.
1330
- *
1331
- * Two more wire-format → template-format fixes, both "match the format the
1332
- * model's chat template expects":
1333
- *
1334
- * 1. Map the OpenAI reasoning-model "developer" role to "system": pi-ai (and
1335
- * OpenAI's own SDKs) rename the system prompt to `developer` whenever a
1336
- * model advertises reasoning, but our chat templates only know
1337
- * system/user/assistant/tool and raise "Unexpected message role." This is
1338
- * why Qwen3.5/MiniCPM5 chat got no messages while Gemma (non-reasoning, so
1339
- * pi keeps `system`) worked. `developer` IS the system prompt, so the remap
1340
- * is semantics-preserving.
1341
- *
1342
- * 2. Flatten text-only content-part arrays to a plain string. pi (and any
1343
- * OpenAI multimodal client) sends user content as `[{type:"text",text}]`,
1344
- * but non-vision chat templates expect `content` to be a STRING and render
1345
- * nothing for an array — so the user's turn silently vanishes and the model
1346
- * replies "I don't see any message." Arrays that carry a media part
1347
- * (image or audio) are left intact for the multimodal path
1348
- * (extractImages/extractAudio); only text-only arrays are collapsed
1349
- * here, which is a no-op for the vision templates too. */
1350
- export function normalizeMessages(messages: ChatMessage[]): ChatMessage[] {
1351
- return messages.map((raw) => {
1352
- let m = raw.role === "developer" ? { ...raw, role: "system" } : raw;
1353
- if (Array.isArray(m.content) && !hasMediaPart(m.content)) {
1354
- m = { ...m, content: contentPartsToText(m.content) };
1355
- }
1356
- if (!m.tool_calls) return m;
1357
- return {
1358
- ...m,
1359
- tool_calls: m.tool_calls.map((tc) => ({
1360
- ...tc,
1361
- function: {
1362
- ...tc.function,
1363
- arguments:
1364
- typeof tc.function.arguments === "string"
1365
- ? (JSON.parse(tc.function.arguments) as Record<string, unknown>)
1366
- : tc.function.arguments,
1367
- },
1368
- })),
1369
- };
1370
- });
1371
- }
1372
-
1373
- /** Coerce a wire `logit_bias` ({"tokenId": bias}) to numeric keys/values —
1374
- * mlx_lm.server's `{int(k): float(v)}` coercion; throws its exact error
1375
- * message on anything non-numeric (surfaced as a 400). JSON object keys
1376
- * always arrive as strings, hence the coercion. */
1377
- export function parseLogitBias(
1378
- raw: Record<string, number> | undefined | null,
1379
- ): Record<number, number> | undefined {
1380
- if (raw == null) return undefined;
1381
- if (typeof raw !== "object" || Array.isArray(raw))
1382
- throw new Error("logit_bias must be a dict of int to float");
1383
- const out: Record<number, number> = {};
1384
- for (const [k, v] of Object.entries(raw)) {
1385
- const id = Number(k);
1386
- const bias = Number(v);
1387
- if (!Number.isInteger(id) || !Number.isFinite(bias))
1388
- throw new Error("logit_bias must be a dict of int to float");
1389
- out[id] = bias;
1390
- }
1391
- return Object.keys(out).length ? out : undefined;
1392
- }
1393
-
1394
- /** Default seed for a request that didn't pin one. `Date.now()` alone is NOT
1395
- * request-unique: under `--batch N` the batch lane serves ONLY default-seed
1396
- * requests (explicit-seed requests use the serial mechanism, see
1397
- * GenerationGateway.place),
1398
- * so two identical prompts arriving in the same millisecond would share a seed
1399
- * and — with per-row RNG keyed as stepKey(seed, generatedCount) — produce
1400
- * byte-identical completions, silently collapsing best-of-N diversity. Mix a
1401
- * per-process Weyl counter (golden-ratio increment, period 2^32) into the
1402
- * timestamp so every call yields a distinct uint32 within any given ms.
1403
- * Determinism contract unchanged: reproducibility is only promised for an
1404
- * EXPLICIT request seed (`req.seed ?? nextDefaultSeed()` — explicit wins,
1405
- * byte-identical to before); a default seed is fresh entropy per request. */
1406
- let seedWeyl = 0;
1407
- export function nextDefaultSeed(): number {
1408
- seedWeyl = (seedWeyl + 0x9e3779b9) >>> 0;
1409
- return ((Date.now() & 0xffffffff) ^ seedWeyl) >>> 0;
1410
- }
1411
-
1412
- /** mlx_lm.server's logprobs request validation, copied exactly (server.py
1413
- * APIHandler.validate_model_parameters: `_validate("logprobs", bool)` and
1414
- * `_validate("top_logprobs", int, min_val=0, max_val=11, whitelist=[-1])`
1415
- * with defaults logprobs=False / top_logprobs=-1). Returns the reference's
1416
- * error message (→ 400) or null when valid. Note mlx-lm caps top_logprobs
1417
- * at 11, not OpenAI's 20 — we mirror the reference. */
1418
- /** Build a degrade-path system-prompt instruction for JSON output, mirroring
1419
- * oMLX's api.tool_calling.build_json_system_prompt (used when xgrammar
1420
- * compile fails — the response_format degrades to prompt injection rather
1421
- * than a 500, oMLX parity). Returns null for {type:"text"} / unset. */
1422
- export function degradeJsonSystemPrompt(body: ChatRequest): string | null {
1423
- if (body.guided_grammar) {
1424
- return "You must respond with text matching this grammar:\n\n" + body.guided_grammar;
1425
- }
1426
- const rf = body.response_format as { type?: string; json_schema?: { name?: string; description?: string; schema?: unknown } } | undefined;
1427
- if (rf && typeof rf === "object" && rf.type === "json_object") {
1428
- return "You must respond with valid JSON only. " +
1429
- "Do not include any explanation or text outside the JSON object.";
1430
- }
1431
- const schemaSpec = rf && typeof rf === "object" && rf.type === "json_schema"
1432
- ? rf.json_schema
1433
- : body.structured_outputs && typeof body.structured_outputs === "object"
1434
- ? { schema: body.structured_outputs }
1435
- : null;
1436
- if (schemaSpec) {
1437
- const spec = schemaSpec;
1438
- const schema = spec.schema ?? body.structured_outputs ?? {};
1439
- const name = spec.name ?? "response";
1440
- const description = spec.description ?? "";
1441
- let prompt = `You must respond with valid JSON matching the '${name}' schema.`;
1442
- if (description) prompt += ` ${description}`;
1443
- prompt += `\n\nJSON Schema:\n\`\`\`json\n${JSON.stringify(schema, null, 2)}\n\`\`\`\n\n` +
1444
- "Respond with only the JSON object, no additional text or explanation.";
1445
- return prompt;
1446
- }
1447
- if (body.guided_regex) {
1448
- return "You must respond with text matching this regular expression:\n\n" +
1449
- body.guided_regex;
1450
- }
1451
- if (body.guided_choice?.length) {
1452
- return "You must respond with exactly one of these choices:\n\n" +
1453
- body.guided_choice.map((choice) => JSON.stringify(choice)).join("\n");
1454
- }
1455
- return null;
1456
- }
1457
-
1458
- export function applyGrammarDegrade(
1459
- body: ChatRequest,
1460
- degradeHint: string,
1461
- ): { body: ChatRequest; warning: string } {
1462
- const content = degradeJsonSystemPrompt(body) ??
1463
- "Follow the requested output constraint exactly.";
1464
- return {
1465
- warning:
1466
- `grammar not enforced: ${degradeHint} - falling back to prompt injection`,
1467
- body: {
1468
- ...body,
1469
- messages: [{ role: "system", content }, ...body.messages],
1470
- },
1471
- };
1472
- }
1473
-
1474
- export function validateLogprobsParams(body: {
1475
- logprobs?: unknown;
1476
- top_logprobs?: unknown;
1477
- }): string | null {
1478
- const lp = body.logprobs ?? false;
1479
- if (typeof lp !== "boolean") return "logprobs must be of type bool";
1480
- const tl = body.top_logprobs ?? -1;
1481
- if (typeof tl !== "number" || !Number.isInteger(tl))
1482
- return "top_logprobs must be of type int";
1483
- if (tl === -1) return null; // the "unset" whitelist sentinel
1484
- if (tl < 0) return "top_logprobs must be at least 0";
1485
- if (tl > 11) return "top_logprobs must be at most 11";
1486
- return null;
1487
- }
1488
-
1489
- const REASONING_EFFORT_LEVELS =
1490
- ["none", "minimal", "low", "medium", "high", "xhigh"] as const;
1491
-
1492
- /** Validate `reasoning_effort` against the accepted level names. An invalid
1493
- * string must NOT pass through: resolveEnableThinking treats any defined
1494
- * value ≠ "none" as thinking-ON, so a typo like "hihg" silently flipped the
1495
- * thinking channel (and its hotter temperature) on thinking-off models and
1496
- * burned a full default-depth reasoning budget (2026-08-18 review). Same
1497
- * contract style as validateLogprobsParams above. */
1498
- export function validateReasoningEffort(body: {
1499
- reasoning_effort?: unknown;
1500
- }): string | null {
1501
- const v = body.reasoning_effort;
1502
- if (v === undefined || v === null) return null;
1503
- if (typeof v !== "string" || !(REASONING_EFFORT_LEVELS as readonly string[]).includes(v))
1504
- return "reasoning_effort must be one of " +
1505
- "'none', 'minimal', 'low', 'medium', 'high', 'xhigh'";
1506
- return null;
1507
- }
1508
-
1509
- /** Incremental detokenizer: emits the longest stable decoded prefix.
1510
- *
1511
- * Byte parity with mlx-lm's streaming detokenizers (the drop-in contract is
1512
- * rendered BYTES, not just token ids). For BPE/ByteLevel tokenizers, two
1513
- * mlx-lm 0.31.3 BPEStreamingDetokenizer behaviors our full-sequence decode
1514
- * lacks (tokenizer_utils.py:195-226):
1515
- *
1516
- * 1. `trimsLeadingSpace` — mlx-lm drops ONE leading " " at the start of the
1517
- * generated sequence (`_maybe_trim_space`); trim it here. SPM decode
1518
- * already matches (see LoadedTokenizer.trimsLeadingSpace).
1519
- * 2. `bareSpaceTokenId` — add_token WITHHOLDS a single-char byte-32 token
1520
- * ("Ġ") in `_unflushed` ("For single spaces wait until the next token"),
1521
- * flushing it together with the NEXT token — and mlx_lm.server NEVER
1522
- * calls detokenizer.finalize() (zero hits in server.py 0.31.3), so a
1523
- * generation ENDING on bare-space token(s) silently drops their spaces
1524
- * from the served bytes. push() withholds those spaces; flush() drops a
1525
- * trailing bare-space run (the held text dies with the request, exactly
1526
- * like mlx-lm serve). Re-check both if upstream ever adds a finalize()
1527
- * call. LATENT HAZARD (deliberately not emulated): models with
1528
- * clean_up_tokenization_spaces=true get an ADDITIONAL mid-stream rule
1529
- * (`_space_matches`: held space dropped before "." "," "'s" …) — both
1530
- * current BPE targets have it false (MiniCPM5), so it never fires here.
1531
- *
1532
- * Exported for unit tests (serve-detok mlx-lm byte parity). */
1533
- export class StreamDecoder {
1534
- static readonly #WINDOW_TOKENS = 32;
1535
- #ids: number[] = [];
1536
- #emitted = "";
1537
- #warnedRevision = false;
1538
- /** Token index where the bounded decode suffix begins. */
1539
- #windowStart = 0;
1540
- /** Exact decoded text before #windowStart, established by suffix matching. */
1541
- #windowPrefix = "";
1542
- readonly #trimLeadingSpace: boolean;
1543
- readonly #bareSpaceId: number | undefined;
1544
-
1545
- constructor(
1546
- readonly tokenizer: LoadedTokenizer,
1547
- readonly skipSpecialTokens = true,
1548
- ) {
1549
- this.#trimLeadingSpace = tokenizer.trimsLeadingSpace === true;
1550
- this.#bareSpaceId = tokenizer.bareSpaceTokenId;
1551
- }
1552
-
1553
- #decode(ids: number[]): string {
1554
- const full = this.#decodeRaw(ids);
1555
- return this.#trimLeadingSpace && full.startsWith(" ") ? full.slice(1) : full;
1556
- }
1557
-
1558
- #decodeRaw(ids: number[]): string {
1559
- return this.tokenizer.decode(ids, this.skipSpecialTokens);
1560
- }
1561
-
1562
- /** Decode only a bounded suffix once an exact text anchor can be proven.
1563
- *
1564
- * At each rebase, decode a candidate suffix independently and accept it only
1565
- * when it is literally the suffix of the exact text decoded so far. This
1566
- * preserves ByteLevel/SPM boundary behavior while reducing steady-state
1567
- * decode work from the whole generation to 32–64 tokens. If no suffix can be
1568
- * anchored (an unusual/global decoder), leave #windowStart at zero and keep
1569
- * the full-history correctness path. */
1570
- #decodeIncremental(): string {
1571
- const full = this.#windowStart === 0
1572
- ? this.#decode(this.#ids)
1573
- : this.#windowPrefix + this.#decodeRaw(this.#ids.slice(this.#windowStart));
1574
-
1575
- if (this.#ids.length - this.#windowStart < StreamDecoder.#WINDOW_TOKENS * 2)
1576
- return full;
1577
-
1578
- const preferred = this.#ids.length - StreamDecoder.#WINDOW_TOKENS;
1579
- const oldest = Math.max(this.#windowStart, preferred - StreamDecoder.#WINDOW_TOKENS);
1580
- for (let start = preferred; start >= oldest; start--) {
1581
- const tail = this.#decodeRaw(this.#ids.slice(start));
1582
- if ((tail.length > 0 || full.length === 0) && full.endsWith(tail)) {
1583
- this.#windowStart = start;
1584
- this.#windowPrefix = full.slice(0, full.length - tail.length);
1585
- break;
1586
- }
1587
- }
1588
- return full;
1589
- }
1590
-
1591
- push(token: number): string {
1592
- this.#ids.push(token);
1593
- // Bare-space hold-back (mlx-lm add_token keeps "Ġ" in _unflushed): don't
1594
- // advance #emitted; the held space(s) flush as part of the next
1595
- // non-bare-space token's delta — consecutive bare spaces accumulate.
1596
- if (token === this.#bareSpaceId) return "";
1597
- const full = this.#decodeIncremental();
1598
- // hold back a trailing replacement char (partial multi-byte sequence)
1599
- const stable = full.endsWith("�") ? full.slice(0, -1) : full;
1600
- if (!stable.startsWith(this.#emitted)) {
1601
- // The decoder revised already-streamed text (cleanup rules — never
1602
- // fires for the shipped tokenizers, which all have
1603
- // clean_up_tokenization_spaces=false; see the LATENT HAZARD note in
1604
- // the class doc). An SSE client cannot un-receive bytes, so the old
1605
- // "re-emit from scratch" answer DUPLICATED the whole stream
1606
- // (2026-07-07 review). Truncate-safe resync instead: emit only the
1607
- // length-extension and keep the emitted watermark monotone — byte
1608
- // drift is confined to the revised span, and the once-per-stream
1609
- // warning makes the tokenizer that needs real _space_matches
1610
- // emulation loud instead of silently wrong.
1611
- if (!this.#warnedRevision) {
1612
- this.#warnedRevision = true;
1613
- console.warn(
1614
- "[detok] decoder revised already-streamed text (clean_up_tokenization_spaces?) — " +
1615
- "resyncing without re-emit; add mlx-lm's cleanup-rule emulation for this tokenizer",
1616
- );
1617
- }
1618
- if (stable.length <= this.#emitted.length) return "";
1619
- const out = stable.slice(this.#emitted.length);
1620
- this.#emitted = stable;
1621
- return out;
1622
- }
1623
- const delta = stable.slice(this.#emitted.length);
1624
- this.#emitted = stable;
1625
- return delta;
1626
- }
1627
-
1628
- flush(): string {
1629
- // mlx_lm.server never finalize()s: a trailing bare-space run stays
1630
- // withheld forever, so its text is dropped from the served bytes.
1631
- let ids = this.#ids;
1632
- if (this.#bareSpaceId !== undefined) {
1633
- let n = ids.length;
1634
- while (n > 0 && ids[n - 1] === this.#bareSpaceId) n--;
1635
- if (n < ids.length) ids = ids.slice(0, n);
1636
- }
1637
- const full = this.#decode(ids);
1638
- const delta = full.slice(this.#emitted.length);
1639
- this.#emitted = full;
1640
- return delta;
1641
- }
1642
- }
1643
313
 
1644
314
  // v2 curve designer: CORS-open so a file:// editor can call a localhost engine.
1645
315
  const CURVE_CORS: Record<string, string> = {
@@ -1654,8 +324,9 @@ function curveJunk(s: string): boolean {
1654
324
  return nonLatin >= 0.02 || /�/.test(s) || /<unused\d+>/.test(s);
1655
325
  }
1656
326
 
327
+
1657
328
  export function createServer(
1658
- ctx: ServerContext, port = 0, serverOptions: ServerOptions = {},
329
+ ctx: ServingContext, port = 0, serverOptions: ServerOptions = {},
1659
330
  ): Server<unknown> {
1660
331
  // --batch N is a concurrency cap: N===1 pins the strict serial executor;
1661
332
  // N>1 admits supported execution compositions to the continuous scheduler.
@@ -1672,7 +343,18 @@ export function createServer(
1672
343
  // only changes behavior when concurrent requests actually arrive — the
1673
344
  // agentic sub-agent workload. --batch 1 pins strict serial for
1674
345
  // arrival-independent numerics. 8 = optiq's Mac-safe concurrency.
346
+ const serving = modelServingBinding(ctx);
347
+ ctx = { ...ctx, serving };
1675
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
+ }
1676
358
  const defaultGeneratedTokens =
1677
359
  serverOptions.defaultMaxTokens ?? ctx.glmMemoryPlan?.maxGenerationTokens;
1678
360
 
@@ -1714,7 +396,7 @@ export function createServer(
1714
396
  console.warn(
1715
397
  `[batch] --batch ${batch} with --kv-quant turbo: TurboQuant is serial-only in v1 ` +
1716
398
  `— those requests won't batch. Omit --kv-quant to batch in bf16. ` +
1717
- `(docs/design/turboquant-kv.md)`,
399
+ `(docs/design/turboquant.md)`,
1718
400
  );
1719
401
  // Quantized KV (any axis) excludes the spec lane: drafted requests fall to
1720
402
  // the normal serial path WITH the KV scheme applied rather than losing it
@@ -1723,9 +405,9 @@ export function createServer(
1723
405
  if (ctx.draft && (kvScheme.turboQuant || kvScheme.kvBits || kvScheme.kvConfig?.length))
1724
406
  console.warn(
1725
407
  `[spec] --draft-model with quantized KV (--kv-quant ${serverOptions.turboQuant ? "turbo" : String(serverOptions.kvQuant)}): ` +
1726
- `the speculative lane is bf16-KV-only in v1 — requests keep the KV scheme and ` +
1727
- `decode serially WITHOUT speculation. Omit --kv-quant to speculate. ` +
1728
- `(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)`,
1729
411
  );
1730
412
  // TurboQuant head-dim fail-fast (2026-07-07 review): the cache class only
1731
413
  // supports {64,128,256,512} (sign-vector + Lloyd-Max table coverage) and
@@ -1740,10 +422,10 @@ export function createServer(
1740
422
  throw new Error(
1741
423
  `--kv-quant turbo: this model's full-attention head_dim is ${dim}; ` +
1742
424
  `TurboQuant supports {${TURBOQUANT_HEAD_DIMS.join(",")}} ` +
1743
- `(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`,
1744
426
  );
1745
427
  }
1746
- // Paged KV v1 (docs/design/paged-kv-cache.md): explicit refusals, not
428
+ // Paged KV v1 (docs/design/kv-cache.md): explicit refusals, not
1747
429
  // silent downgrades — the incompatible combos would otherwise degrade
1748
430
  // quietly (batch: paged caches can't merge; kv-quant: the swap would
1749
431
  // drop the scheme, the exact composition bug the kvQuant gate above
@@ -1752,22 +434,22 @@ export function createServer(
1752
434
  if (batch > 1)
1753
435
  throw new Error(
1754
436
  `--paged-kv is serial-only in v1 — add --batch 1 (got --batch ${batch}). ` +
1755
- `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).`,
1756
438
  );
1757
439
  if (kvScheme.kvBits || kvScheme.kvConfig?.length || kvScheme.turboQuant)
1758
440
  throw new Error(
1759
441
  `--paged-kv is bf16-only in v1 — omit --kv-quant (quantized paged ` +
1760
- `blocks are a documented follow-up, docs/design/paged-kv-cache.md).`,
442
+ `blocks are a documented follow-up, docs/design/kv-cache.md).`,
1761
443
  );
1762
444
  if (ctx.draft)
1763
445
  throw new Error(
1764
446
  `--paged-kv cannot combine with --draft-model in v1 ` +
1765
- `(docs/design/paged-kv-cache.md non-goals).`,
447
+ `(docs/design/kv-cache.md non-goals).`,
1766
448
  );
1767
449
  if (!ctx.model.config.modelType.startsWith("gemma4"))
1768
450
  throw new Error(
1769
451
  `--paged-kv v1 supports Gemma4-family models only ` +
1770
- `(this model: ${ctx.model.config.modelType}) — docs/design/paged-kv-cache.md.`,
452
+ `(this model: ${ctx.model.config.modelType}) — docs/design/kv-cache.md.`,
1771
453
  );
1772
454
  const bs = serverOptions.pagedKv.blockSize;
1773
455
  if (bs !== undefined && (!Number.isInteger(bs) || bs <= 0))
@@ -1779,11 +461,12 @@ export function createServer(
1779
461
  );
1780
462
  }
1781
463
 
1782
- // 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
1783
465
  // eviction and restarts. Compatibility key = configFingerprint (graph
1784
466
  // shape) + the EFFECTIVE kv scheme (flags pick bf16 vs config vs uniform
1785
467
  // on the same model — restored caches must match what serving produces) +
1786
- // 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.
1787
470
  // RAM prompt-cache cap: 8 GB default (Josh's call, 2026-07-06). The old
1788
471
  // flat 2e9 was an anti-OOM reflex sized for a 1B model in 24 GB — a
1789
472
  // single full-context 12B entry is ~0.6 GB, so it silently shrank to
@@ -1791,24 +474,33 @@ export function createServer(
1791
474
  // entries still demote to SSD and LRU eviction bounds pressure.
1792
475
  // --prompt-cache <GB> overrides; 0 disables.
1793
476
  const promptCacheCap = serverOptions.promptCacheBytes ?? 8e9;
477
+ const stateCodecs = ctx.stateCodecs ?? legacyCacheCodecs;
478
+ const cloneState = (caches: Cache[]) => cloneKvCaches(caches, stateCodecs);
1794
479
  let ssdStore: SsdCacheStore | null = null;
1795
480
  if (serverOptions.ssdCacheDir) {
1796
481
  if (promptCacheCap <= 0)
1797
482
  throw new Error("--ssd-cache requires the RAM prompt cache (--prompt-cache 0 disables it)");
483
+ if (typeof serving.stateCompatibility !== "string" || !serving.stateCompatibility.length)
484
+ throw new Error("--ssd-cache requires the model binding's stateCompatibility identity");
1798
485
  const schemeKey = resolvedKvScheme.cacheKey;
486
+ const stateKey = Bun.hash(serving.stateCompatibility).toString(16);
1799
487
  const tokJson = readFileSync(`${ctx.model.config.modelDir}/tokenizer.json`);
1800
488
  ssdStore = new SsdCacheStore({
489
+ codecs: stateCodecs,
1801
490
  dir: serverOptions.ssdCacheDir,
1802
- maxBytes: serverOptions.ssdCacheMaxBytes ?? 32 * 2 ** 30,
1803
- configFingerprint: `${configFingerprint(ctx.model.config)}-${schemeKey}`,
491
+ maxBytes: serverOptions.ssdCacheMaxBytes ?? Number.POSITIVE_INFINITY,
492
+ configFingerprint: `${configFingerprint(ctx.model.config)}-${schemeKey}-${stateKey}`,
1804
493
  tokenizerHash: Bun.hash(tokJson).toString(16),
1805
494
  modelId: ctx.modelId,
1806
495
  verify: serverOptions.ssdCacheVerify,
1807
496
  });
1808
497
  const recovered = ssdStore.scan();
498
+ const capacity = Number.isFinite(ssdStore.maxBytes)
499
+ ? `${(ssdStore.maxBytes / 2 ** 30).toFixed(0)} GiB cap`
500
+ : "unlimited";
1809
501
  console.log(
1810
502
  `[ssd-cache] ${serverOptions.ssdCacheDir} — ${recovered} entr${recovered === 1 ? "y" : "ies"} recovered, ` +
1811
- `${(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}`,
1812
504
  );
1813
505
  }
1814
506
 
@@ -1823,7 +515,7 @@ export function createServer(
1823
515
  return h ? { prefixLen: h.prefixLen, handle: h.entry } : null;
1824
516
  },
1825
517
  restore: (handle: unknown) => {
1826
- const loaded = ssdStore!.restore(handle as import("./ssd-cache").SsdIndexEntry, ctx.model);
518
+ const loaded = serving.restore(ssdStore!, handle as import("./ssd-cache").SsdIndexEntry);
1827
519
  if (!loaded) return null;
1828
520
  // Restore is a STREAMED COPY (2026-07-07 A7-restore): the caches
1829
521
  // own their bytes and no mapping outlives loadKvCache — nothing
@@ -1832,7 +524,8 @@ export function createServer(
1832
524
  return { tokens: loaded.tokens, caches: loaded.caches, retain: () => {} };
1833
525
  },
1834
526
  store: (tokens: number[], caches: import("./model/gemma4").Cache[], ns: string) => {
1835
- ssdStore!.store(tokens, caches, ns);
527
+ if (ssdStore!.hasDurablePrefix(tokens, ns)) return true;
528
+ return ssdStore!.store(tokens, caches, ns);
1836
529
  },
1837
530
  }
1838
531
  : null;
@@ -1843,7 +536,7 @@ export function createServer(
1843
536
  // cache hands us OWNED zero-copy clones + copied tokens (made under
1844
537
  // the generation lock — microseconds; entries are immutable so the
1845
538
  // clones stay consistent), and the flush goes through the BOUNDED
1846
- // SpillQueue -> storeAsync (idle-gated per tensor, see below) ->
539
+ // SpillQueue -> storeAsync (generation-locked per tensor, see below) ->
1847
540
  // clone disposal on every settle/drop path (that dispose is what
1848
541
  // actually frees the demoted GPU memory; the queue's byte cap keeps
1849
542
  // starved-gate retention bounded — 2026-07-07 review fix). spillQueue
@@ -1857,6 +550,7 @@ export function createServer(
1857
550
  }
1858
551
  : null,
1859
552
  coldTier,
553
+ cloneState,
1860
554
  );
1861
555
  // Responses-API store for previous_response_id resumption (Phase 11):
1862
556
  // TTL + byte-capped LRU, port of optiq/response_store.py. Pairs with
@@ -1864,175 +558,56 @@ export function createServer(
1864
558
  // so its KV prefill is already cached.
1865
559
  const responseStore = new ResponseStore();
1866
560
 
1867
- /** Run one generation with prompt-cache reuse. Must be called under the
1868
- * gateway's exclusive lock — the gateway invokes it on the serial lane;
1869
- * the curve /generate endpoint wraps it in gateway.runExclusive. (The old
1870
- * `enqueue` promise queue is gone; the gateway's AsyncMutex is THE lock.)
1871
- * onToken returning `false` halts generation early (stop
1872
- * sequence fired); the cache snapshot stays valid and is still kept.
1873
- * Media (vision/audio embeddings-prefill) requests bypass the prompt
1874
- * cache: soft tokens are identical placeholder ids, so prefix matching
1875
- * across different images/clips would false-hit. */
1876
- const runGeneration = async (
1877
- promptIds: number[],
1878
- options: GenerateOptions,
1879
- onToken: (token: number, logprobs?: TokenLogprobs) => void | boolean | Promise<void | boolean>,
1880
- vision?: {
1881
- embeddings: import("./mlx/array").MlxArray;
1882
- imageMask?: import("./mlx/array").MlxArray;
1883
- multimodalMask?: import("./mlx/array").MlxArray;
1884
- mrope?: MropeRequestState;
1885
- },
1886
- trace?: PromptResponseTrace,
1887
- ) => {
1888
- // Qwen vision: install the request's mRoPE state for every forward of
1889
- // this serial run (prefill AND decode use the 3D interleaved positions +
1890
- // delta). Scoped inside the try below — under the serial mutex — so
1891
- // queued text requests never see it and a throw between here and the
1892
- // try (makeCache under memory pressure, spec-path early return) can't
1893
- // leave a DEAD request's positions installed for the next generation
1894
- // (2026-08-18 review).
1895
- // Speculative decoding (serve --draft-model): spec-ELIGIBLE requests
1896
- // decode through the verify loop; the rest fall through to the normal
1897
- // serial path (never wrong results, just no speedup — logged once per
1898
- // combination class would be noise, so silent). Eligibility v1:
1899
- // text-only, base weights, no logprobs capture, bf16 KV — ALL quantized
1900
- // KV axes excluded (affine kvBits/kvConfig AND turboQuant; the spec loop
1901
- // builds fresh bf16 caches and never calls maybeQuantizeKv, so routing a
1902
- // turbo request here would silently drop the operator's KV scheme —
1903
- // 2026-07-07 review). Grammar COMPOSES (Phase C constrained verify walk
1904
- // — see the serve-loop.ts header). Prompt-cache reuse is bypassed on
1905
- // the spec path v1.
1906
- if (
1907
- ctx.draft &&
1908
- !vision &&
1909
- !options.adapters?.length &&
1910
- !options.logprobs &&
1911
- !options.kvBits &&
1912
- !options.kvConfig &&
1913
- !options.turboQuant &&
1914
- // Belt: --paged-kv + --draft-model is refused at createServer; this
1915
- // keeps the spec lane paged-free even for programmatic callers.
1916
- !options.pagedKv
1917
- ) {
1918
- const { specServeRun } = await import("./spec/serve-loop");
1919
- return withModelWiredLimit(
1920
- ctx.model,
1921
- () => withModelUsageFlush(
1922
- ctx.model,
1923
- () => specServeRun(
1924
- ctx.model, ctx.draft!.provider, ctx.draft!.numDraftTokens,
1925
- promptIds, options, onToken,
1926
- ),
1927
- ),
1928
- );
1929
- }
1930
- // Cache entries are adapter-specific: KV computed under one adapter
1931
- // must never seed another's (or the base's) prefill.
1932
- const cacheNs = options.adapters?.join("+") ?? "";
1933
- // Paged-KV request scope (docs/design/paged-kv-cache.md): media
1934
- // prompts (bidir overlay) and LoRA-adapter requests are v1 non-goals —
1935
- // they run the PLAIN cache path even under --paged-kv (scope the flag
1936
- // per request, never 400). Effective value computed ONCE so the
1937
- // prompt-cache bypass below and the generate() options can't disagree.
1938
- const pagedKv = vision || options.adapters?.length ? undefined : options.pagedKv;
1939
- // Paged requests bypass the prompt cache entirely (v1 non-goal:
1940
- // PagedKVCache has no cloneKvCaches/restore path — the vision
1941
- // precedent). Fresh caches per request, disposed on completion.
1942
- const skipPromptCache = Boolean(vision || pagedKv);
1943
- // Both tiers in one call (Layer 0): take() prefers a strictly-longer
1944
- // SSD prefix, restores it zero-copy, and trims — see PromptCache.take.
1945
- const closeCacheLookup = trace?.begin("cache.lookup_restore", {
1946
- mechanism: "serial",
1947
- bypassed: skipPromptCache,
1948
- });
1949
- const entry = skipPromptCache ? null : promptCache.take(promptIds, cacheNs);
1950
- closeCacheLookup?.();
1951
- const caches = entry?.caches ?? ctx.model.makeCache();
1952
- // Prompt-boundary snapshot (the multi-turn agent fix, 2026-07-04): the
1953
- // prompt+gen entry put() below is UNTRIMMABLE at context > sliding
1954
- // window (wrapped rings) and under quantized KV (mid-group), so any
1955
- // decode→encode roundtrip drift in the reply the client sends back
1956
- // turns the next turn into a total miss (measured: 12B turn-2 TTFT
1957
- // 8.9 s instead of ~0.2 s). A prompt-ONLY entry is always an exact
1958
- // prefix of the next turn's rendering regardless of reply drift.
1959
- // Zero-copy (cloneKvCaches = slice views); only for substantial cold
1960
- // prefills, where the re-prefill it saves is worth an extra entry.
1961
- // The oracle invariant (mlx-lm insert_segments): a trim-free STRICT
1962
- // prefix of the prompt exists for EVERY substantial request — cap the
1963
- // boundary at len-1 so even a stableLen == len prompt (e4b: the
1964
- // template tail survives the probe render) snapshots prompt[:-1]. An
1965
- // exact repeat then matches with trimNeeded == 0, bypassing
1966
- // isTrimmable() entirely — the only reuse path a wrapped ring has.
1967
- const boundary = Math.min(options.snapshotAt ?? promptIds.length, promptIds.length - 1);
1968
- // Re-snapshot on EVERY substantial request whose stable boundary extends
1969
- // past the cached prefix; the clone is zero-copy views, so re-putting
1970
- // is ~free.
1971
- const snapshotBoundary =
1972
- !skipPromptCache && boundary >= 256 && boundary > (entry?.tokens.length ?? 0);
1973
- try {
1974
- if (vision?.mrope && ctx.model instanceof Qwen35Model)
1975
- ctx.model.mrope = vision.mrope;
1976
- const gen = generate(ctx.model, promptIds, {
1977
- ...options,
1978
- pagedKv, // request-scoped (undefined strips the server-wide flag)
1979
- cache: caches,
1980
- ...(snapshotBoundary
1981
- ? {
1982
- // snapshotAt MUST travel with the hook: generate() splits the
1983
- // prefill at exactly this many tokens and fires the hook while
1984
- // the caches hold exactly that prefix — putting boundary
1985
- // tokens against caches at any other offset is silent KV
1986
- // corruption.
1987
- snapshotAt: boundary,
1988
- onPrefillDone: () => {
1989
- try {
1990
- promptCache.put(promptIds.slice(0, boundary), cloneKvCaches(caches), cacheNs);
1991
- } catch (e) {
1992
- console.warn(`prompt-boundary snapshot skipped: ${(e as Error).message}`);
1993
- }
1994
- },
1995
- }
1996
- : {}),
1997
- ...(vision
1998
- ? {
1999
- promptEmbeddings: vision.embeddings,
2000
- ...(vision.imageMask ? { imageMask: vision.imageMask } : {}),
2001
- ...(vision.multimodalMask ? { multimodalMask: vision.multimodalMask } : {}),
2002
- }
2003
- : {}),
2004
- }, { trace, mechanism: "serial" });
2005
- for await (const t of gen) {
2006
- if ((await onToken(t.token, t.logprobs)) === false) break;
2007
- }
2008
- const s = gen.stats!; // set on completion AND on early break
2009
- if (skipPromptCache) {
2010
- // Vision and paged-KV requests own their caches for exactly one
2011
- // generation (paged: v1 non-goal — no PromptCache integration).
2012
- for (const c of caches) c.dispose();
2013
- } else {
2014
- // put() fires onPut → the debounced write-behind SSD snapshot
2015
- // (wired below), covering the batch lane's puts too.
2016
- promptCache.put(s.cacheTokens, caches, cacheNs, entry?.retain);
2017
- }
2018
- return s;
2019
- } catch (e) {
2020
- for (const c of caches) c.dispose();
2021
- entry?.retain?.();
2022
- // A throw BEFORE generate()/the gateway took ownership (promptCache
2023
- // take / makeCache) would leak the grammar's WASM matcher; dispose()
2024
- // is idempotent, so this is safe when the throw came from inside run
2025
- // (whose finally already disposed it).
2026
- options.grammar?.dispose();
2027
- throw e;
2028
- } finally {
2029
- if (ctx.model instanceof Qwen35Model) ctx.model.mrope = null;
2030
- vision?.embeddings.dispose();
2031
- vision?.imageMask?.dispose();
2032
- vision?.multimodalMask?.dispose();
2033
- options.visionPixels?.dispose();
2034
- }
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,
2035
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
+ });
2036
611
 
2037
612
  // Write-behind persistence (restart survival — the oMLX boundary-snapshot
2038
613
  // idea at whole-entry granularity, spill-on-evict alone can't survive a
@@ -2062,17 +637,17 @@ export function createServer(
2062
637
  // pacing interleaved those slices exactly between decode tokens. A ~16k
2063
638
  // entry's flush overlapping the bench's ctx repeats depressed decode@ctx
2064
639
  // ~9% on e4b (mlx-lm runs no equivalent background work). Now every step
2065
- // — including the first — awaits gateway.onIdle() (ssdFlushGate), so the
640
+ // — including the first — runs through gateway.runExclusive(), so the
2066
641
  // flush only progresses while NOTHING is generating and pauses when a
2067
642
  // request arrives mid-flush. Tradeoffs, accepted: durability waits for a
2068
643
  // quiet moment (single-user serving quiesces constantly; sustained
2069
644
  // hammering defers the flush AND the spill clones' GPU-memory release —
2070
- // bounded by the chain), and one in-flight tensor step (~10-15 MB) can
2071
- // still land ahead of a just-arrived request. MLX_BUN_SSD_WRITEBEHIND=0
645
+ // bounded by the chain). MLX_BUN_SSD_WRITEBEHIND=0
2072
646
  // disables write-behind snapshots entirely — the paired-A/B lever + kill
2073
647
  // switch (restart survival then degrades to spill-on-evict only).
2074
648
  const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
2075
- const ssdFlushGate = (): Promise<void> => gateway.onIdle();
649
+ const ssdFlushStep = <T>(step: () => T): Promise<T> =>
650
+ gateway.runExclusive(async () => step());
2076
651
  // Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
2077
652
  // kv-store.ts): pending clones pin their entries' GPU buffers while the
2078
653
  // idle gate starves under sustained traffic, so QUEUED bytes are capped —
@@ -2087,10 +662,14 @@ export function createServer(
2087
662
  // never drops the item just enqueued).
2088
663
  // The gateway exists before the spill queue because the SSD writer uses
2089
664
  // its idle boundary to avoid competing with generation.
2090
- const gateway = new GenerationGateway(ctx.model, batch, runGeneration, {
665
+ const gateway = new GenerationGateway(serving.gateway, batch, runGeneration, {
2091
666
  kvBudgetBytes: serverOptions.kvBudgetBytes,
667
+ memoryBudget,
668
+ checkpoints: !!(serverOptions.generationCheckpointTokens && ssdStore),
669
+ stateCodecs,
2092
670
  kvScheme: resolvedKvScheme,
2093
671
  promptCache,
672
+ beforeSerial: async () => { await flushDurability(); },
2094
673
  });
2095
674
  const spillQueueGbRaw = Number(runtimeValue("MLX_BUN_SSD_SPILL_QUEUE_GB"));
2096
675
  const spillQueueCapBytes =
@@ -2099,7 +678,7 @@ export function createServer(
2099
678
  ? new SpillQueue(
2100
679
  spillQueueCapBytes,
2101
680
  cacheBytes,
2102
- (item) => ssdStore!.storeAsync(item.tokens, item.caches, item.ns, ssdFlushGate),
681
+ (item) => ssdStore!.storeAsync(item.tokens, item.caches, item.ns, ssdFlushStep),
2103
682
  (caches) => { for (const c of caches) c.dispose(); },
2104
683
  )
2105
684
  : null;
@@ -2108,7 +687,7 @@ export function createServer(
2108
687
  gateway,
2109
688
  promptCache,
2110
689
  spillQueue,
2111
- cloneKvCaches,
690
+ cloneState,
2112
691
  (tokens, ns) => ssdStore.hasDurablePrefix(tokens, ns),
2113
692
  )
2114
693
  : null;
@@ -2150,7 +729,7 @@ export function createServer(
2150
729
  let demoteTimer: ReturnType<typeof setInterval> | null = null;
2151
730
  if (ssdStore && demoteIdleMs > 0) {
2152
731
  demoteTimer = setInterval(() => {
2153
- if (gateway.activeRows > 0 || gateway.pendingRows > 0) return;
732
+ if (gateway.busy) return;
2154
733
  void gateway.runExclusive(async () => {
2155
734
  const n = promptCache.demoteIdle(demoteIdleMs);
2156
735
  if (n > 0) console.log(`[ssd-cache] demoted ${n} idle entr${n === 1 ? "y" : "ies"} to disk`);
@@ -2159,45 +738,8 @@ export function createServer(
2159
738
  demoteTimer.unref?.();
2160
739
  }
2161
740
 
2162
- const completionExecutor = new CompletionExecutor(gateway);
2163
- const completionProtocolUsage = (usage: CompletionSummary["usage"]) => ({
2164
- prompt_tokens: usage.promptTokens,
2165
- completion_tokens: usage.completionTokens,
2166
- total_tokens: usage.totalTokens,
2167
- prompt_tokens_details: { cached_tokens: usage.cachedTokens },
2168
- ...(usage.speculation ? { speculation: usage.speculation } : {}),
2169
- });
2170
- const completionUsage = (summary: CompletionSummary) => ({
2171
- ...completionProtocolUsage(summary.usage),
2172
- lane: summary.lane,
2173
- });
2174
-
2175
- // Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
2176
- // fit() solves max safe context from weights + KV growth + prefill
2177
- // transient. The active kv-quant scheme (uniform kvBits / per-layer
2178
- // kvConfig) is billed at its true bytes/element so a quantized cache
2179
- // advertises and admits the larger window it actually enables; only
2180
- // TurboQuant still bills bf16 (conservative — no projector for its
2181
- // layout yet, and it is solo-only in v1).
2182
- const admission = ctx.glmMemoryPlan ?? fit(
2183
- ctx.model.config, ctx.model.weightsBytes, 1,
2184
- undefined, undefined, 0, serverOptions.memoryBudgetBytes,
2185
- resolvedKvScheme.fitOptions,
2186
- );
2187
- // A zero ceiling is a warning, never a startup refusal: killing the
2188
- // server here can only parrot the per-request admission message (which
2189
- // still fires, with this same ceiling) or be a false positive from the
2190
- // fit model itself — it can never save anything the request gate
2191
- // doesn't. Serve until physically incapable.
2192
- if (admission.maxSafeContext < 1)
2193
- console.warn(
2194
- `[admission] memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB leaves no ` +
2195
- `safe context for ${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB) ` +
2196
- `— serving anyway; generation requests will be refused until the budget is raised`,
2197
- );
2198
- const allocatorLimit =
2199
- admission.allocatorLimitBytes ?? serverOptions.memoryBudgetBytes;
2200
- if (allocatorLimit) setMemoryLimit(allocatorLimit);
741
+ const sessionEngine = createSessionCompletionEngine(gateway, disposeUnstartedRequest);
742
+ const completionExecutor = new CompletionExecutor(sessionEngine);
2201
743
 
2202
744
  // /library response cache (30 s) — registry + config reads only.
2203
745
  const startedAt = Date.now();
@@ -2224,207 +766,37 @@ export function createServer(
2224
766
  return jobStore;
2225
767
  };
2226
768
 
2227
- // (kvScheme is resolved earlier, above the prompt-cache construction —
2228
- // the SSD tier's compatibility fingerprint folds the effective scheme in.)
2229
-
2230
- // XTC never removes EOS or the newline token: mlx_lm.server passes
2231
- // [tokenizer.eos_token_id, tokenizer.encode("\n")] as xtc_special_tokens.
2232
- // We pass the flat equivalent — ALL configured EOS ids plus the bare newline
2233
- // id(s), encoded WITHOUT special tokens (mlx-lm's encode("\n") can drag a
2234
- // BOS along on some tokenizers; protecting BOS from XTC is a no-op, so the
2235
- // flat/no-BOS form is behaviorally identical and cleaner).
2236
- const xtcSpecialTokens = [
2237
- ...ctx.model.config.eosTokenIds,
2238
- ...ctx.tokenizer.encode("\n", false),
2239
- ];
2240
-
2241
- // Effective enable_thinking for a request, with the same precedence the chat
2242
- // template uses (extracted so sampling and template rendering can't disagree):
2243
- // explicit chat_template_kwargs.enable_thinking → reasoning_effort ("none" =
2244
- // off) → server --thinking default → model default (MiniCPM5 → off). undefined
2245
- // means "not a switchable-thinking model / leave the template default".
2246
- const resolveEnableThinking = (req: ChatRequest): boolean | undefined => {
2247
- const explicit = req.chat_template_kwargs?.enable_thinking;
2248
- if (typeof explicit === "boolean") return explicit;
2249
- const effort = req.reasoning_effort;
2250
- if (effort !== undefined) return effort !== "none";
2251
- if (serverOptions.defaultThinking !== undefined) return serverOptions.defaultThinking;
2252
- return isMiniCPM5Config(ctx.model.config) ? false : undefined;
2253
- };
2254
-
2255
- const toOptions = (req: ChatRequest): GenerateOptions & { stopSequences: string[] } => {
2256
- // Sampling follows the thinking state — which the web UI's thinking button
2257
- // drives via enable_thinking. Model authors publish a SINGLE
2258
- // generation_config temperature (the think-mode value) but recommend a
2259
- // cooler one for direct, no-think replies (MiniCPM5 card: 0.9 think / 0.7
2260
- // no-think, top_p 0.95 for both). So with no explicit temperature set, a
2261
- // no-think turn runs at most NO_THINK_TEMPERATURE while a think turn keeps
2262
- // the model's hotter configured default. An explicit request/CLI
2263
- // temperature always wins. top_p is unchanged by mode.
2264
- const NO_THINK_TEMPERATURE = 0.7;
2265
- const genTemp = ctx.genDefaults.temperature ?? 0.7;
2266
- const defaultTemp = resolveEnableThinking(req) === false
2267
- ? Math.min(genTemp, NO_THINK_TEMPERATURE)
2268
- : genTemp;
2269
- return {
2270
- // A thinking turn emits <think>…</think> AND the answer, so a tight cap
2271
- // truncates the visible reply. Default very generously (the model's
2272
- // context is far larger); only an explicit max_tokens narrows it. The
2273
- // model still stops at its eos_token well before this in normal replies.
2274
- maxTokens: req.max_completion_tokens ?? req.max_tokens ??
2275
- defaultGeneratedTokens ?? 65_536,
2276
- temperature: req.temperature ?? serverOptions.defaultTemperature ?? defaultTemp,
2277
- topP: req.top_p ?? serverOptions.defaultTopP ?? ctx.genDefaults.topP ?? 0,
2278
- topK: req.top_k ?? serverOptions.defaultTopK ?? ctx.genDefaults.topK ?? 0,
2279
- seed: req.seed ?? nextDefaultSeed(),
2280
- repetitionPenalty: req.repetition_penalty ?? ctx.genDefaults.repetitionPenalty,
2281
- // mlx_lm.server sampling extensions (defaults mirror server.py: min_p /
2282
- // xtc off, context windows 20). parseLogitBias throws on non-numeric
2283
- // input — callers surface it as a 400 (mlx-lm's coercion error).
2284
- minP: req.min_p ?? 0,
2285
- xtcProbability: req.xtc_probability ?? 0,
2286
- xtcThreshold: req.xtc_threshold ?? 0,
2287
- ...(req.xtc_probability ? { xtcSpecialTokens } : {}),
2288
- logitBias: parseLogitBias(req.logit_bias),
2289
- repetitionContextSize: req.repetition_context_size ?? 20,
2290
- presencePenalty: req.presence_penalty ?? 0,
2291
- presenceContextSize: req.presence_context_size ?? 20,
2292
- frequencyPenalty: req.frequency_penalty ?? 0,
2293
- frequencyContextSize: req.frequency_context_size ?? 20,
2294
- hlg: resolveHlg(req.hlg, serverOptions.hlg),
2295
- // generate() yields token ids; stop sequences match on decoded text
2296
- // (they can span token boundaries), so the StopMatcher sits at the
2297
- // decode layer below and halts the loop via onToken → false.
2298
- stopSequences: (typeof req.stop === "string" ? [req.stop] : req.stop ?? [])
2299
- .filter((s) => typeof s === "string" && s.length > 0),
2300
- ...kvScheme,
2301
- // Paged KV is a server-wide mode like the kv scheme above (v1:
2302
- // serial bf16 gemma4 — the createServer refusals hold the invariants).
2303
- ...(serverOptions.pagedKv ? { pagedKv: serverOptions.pagedKv } : {}),
2304
- };
2305
- };
2306
-
2307
- /** Compile a grammar controller from the request's structured-output fields
2308
- * (response_format / guided_* / structured_outputs). Returns null when no
2309
- * constraint is requested OR when compile failed (degrade path). On degrade,
2310
- * `degradeHint` carries a human description for the system-prompt injection
2311
- * + Warning header (oMLX parity — never 500). Honors MLX_BUN_GRAMMAR=0. */
2312
- const compileGrammarForRequest = async (
2313
- req: ChatRequest,
2314
- ): Promise<{ controller: import("./grammar").GrammarController | null; degradeHint: string | null }> => {
2315
- if (!grammarEnabled()) {
2316
- return {
2317
- controller: null,
2318
- degradeHint: "grammar compilation disabled by MLX_BUN_GRAMMAR=0",
2319
- };
2320
- }
2321
- const r = await compileGrammarRequest(
2322
- req as GrammarRequest,
2323
- ctx.tokenizer,
2324
- ctx.model.config.text.vocabSize,
2325
- );
2326
- if (!r) return { controller: null, degradeHint: null };
2327
- return { controller: r.controller, degradeHint: r.degradeHint };
2328
- };
2329
-
2330
- // Map OpenAI reasoning_effort levels onto the Qwen3.8 template's supported
2331
- // set (xhigh|medium|low — the template raises on anything else). "none"
2332
- // means thinking off (handled by resolveEnableThinking), so no depth is
2333
- // passed. Only consumed for templates with readsReasoningEffort.
2334
- const qwenReasoningEffort = (
2335
- effort: ChatRequest["reasoning_effort"],
2336
- ): "xhigh" | "medium" | "low" | undefined => {
2337
- switch (effort) {
2338
- case "minimal":
2339
- case "low":
2340
- return "low";
2341
- case "medium":
2342
- return "medium";
2343
- case "high":
2344
- case "xhigh":
2345
- return "xhigh";
2346
- default:
2347
- return undefined; // "none" / unset → template default (xhigh)
2348
- }
2349
- };
2350
-
2351
- const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
2352
- // enableThinking resolution (and its precedence) lives in
2353
- // resolveEnableThinking so template rendering and sampling stay in sync.
2354
- return {
2355
- tools,
2356
- enableThinking: resolveEnableThinking(req),
2357
- reasoningEffort: qwenReasoningEffort(req.reasoning_effort),
2358
- preserveThinking: req.chat_template_kwargs?.preserve_thinking,
2359
- };
2360
- };
2361
-
2362
- const promptIdsFor = (
2363
- req: ChatRequest,
2364
- tools: ToolDefinition[] | null,
2365
- ): { ids: number[]; startInThinking: boolean } => {
2366
- const opts = templateOptionsFor(req, tools);
2367
- const rendered = ctx.template.render(normalizeMessages(req.messages), opts);
2368
- const ids = ctx.tokenizer.encode(rendered);
2369
- // template includes <bos>; tokenizer post-processor also prepends one
2370
- const trimmed = ids[0] === ids[1] && ids[0] === ctx.tokenizer.bosTokenId ? ids.slice(1) : ids;
2371
- return { ids: trimmed, startInThinking: promptEndsInOpenThink(rendered) };
2372
- };
2373
-
2374
- /** STABLE cache boundary: the prompt's tail is a generation primer (e.g.
2375
- * 12B's `<|channel>thought` tokens) that the NEXT turn's re-render does
2376
- * not contain — a cache entry ending there can never prefix-match again
2377
- * once the rings wrap. Probe by rendering this conversation as if a
2378
- * reply existed; the common prefix is what every future turn preserves.
2379
- * The probe is EXPENSIVE (a second full render + encode — ~150 ms at
2380
- * 16k with our tokenizer; 30-50% of the 12B ctx-repeat TTFT,
2381
- * 2026-07-06b), but the PRIMER LENGTH it measures is constant per
2382
- * template mode: the divergence sits after a special-token turn
2383
- * delimiter, and special tokens break BPE merges, so message content
2384
- * can't shift it. So the full probe runs ONCE per mode and later
2385
- * requests pay a subtraction. (A wrong boundary is a QUALITY knob, not
2386
- * a correctness one — any prefix ≤ len-1 is a valid snapshot point.) */
2387
- const primerLenByMode = new Map<string, number>();
2388
- const stableLenFor = (
2389
- req: ChatRequest,
2390
- tools: ToolDefinition[] | null,
2391
- trimmed: number[],
2392
- ): number => {
2393
- const opts = templateOptionsFor(req, tools);
2394
- const mode = `${opts.enableThinking}|${!!tools?.length}|${opts.reasoningEffort ?? ""}|${opts.preserveThinking ?? ""}`;
2395
- const primer = primerLenByMode.get(mode);
2396
- if (primer !== undefined) return Math.max(0, trimmed.length - primer);
2397
- 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");
2398
789
  try {
2399
- const probe = ctx.tokenizer.encode(
2400
- ctx.template.render(
2401
- [...normalizeMessages(req.messages), { role: "assistant", content: "x" }],
2402
- opts,
2403
- ),
2404
- );
2405
- const probeTrimmed =
2406
- probe[0] === probe[1] && probe[0] === ctx.tokenizer.bosTokenId ? probe.slice(1) : probe;
2407
- let i = 0;
2408
- const n = Math.min(trimmed.length, probeTrimmed.length);
2409
- while (i < n && trimmed[i] === probeTrimmed[i]) i++;
2410
- stableLen = i;
2411
- // Memoize the mode's primer length (sanity-capped: a "primer" longer
2412
- // than 64 tokens means the probe diverged for content reasons —
2413
- // don't generalize that).
2414
- const p = trimmed.length - stableLen;
2415
- if (p >= 0 && p <= 64) primerLenByMode.set(mode, p);
2416
- } catch { /* probe is best-effort; full boundary is the fallback */ }
2417
- 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
+ }
2418
797
  };
2419
798
 
2420
- const toolStreamMode = (tools: ToolDefinition[] | null): ToolStreamMode =>
2421
- // Gemma-4 keeps the token-level sentinel path; every other family
2422
- // (MiniCPM5, Qwen3/3.5, Tier-0 generics) emits tool calls as DECODED TEXT
2423
- // — see selectToolStreamMode for why sentinel ids must stay family-gated.
2424
- selectToolStreamMode(ctx.model.config.modelType, !!tools?.length);
2425
799
 
2426
- const toolRouter = (tools: ToolDefinition[] | null): ToolAwareStream =>
2427
- new ToolAwareStream(ctx.tokenizer, toolStreamMode(tools), tools);
2428
800
 
2429
801
  if (serverOptions.unixSocket) {
2430
802
  try { require("node:fs").unlinkSync(serverOptions.unixSocket); } catch {}
@@ -2616,7 +988,7 @@ export function createServer(
2616
988
  let kvMode = "bf16";
2617
989
  if (kvScheme.turboQuant) {
2618
990
  // v1: full-attention layers only (sliding-window stays bf16 —
2619
- // docs/design/turboquant-kv.md non-goal).
991
+ // docs/design/turboquant.md non-goal).
2620
992
  const fullAttn = layerTypes.filter((l) => l !== "sliding_attention").length;
2621
993
  kvMode = `turbo k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`;
2622
994
  kvLayers[`turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`] = fullAttn;
@@ -2648,7 +1020,7 @@ export function createServer(
2648
1020
  dir: serverOptions.ssdCacheDir,
2649
1021
  entries: ssdStore.entries,
2650
1022
  bytes: ssdStore.totalBytes,
2651
- max_bytes: ssdStore.maxBytes,
1023
+ max_bytes: Number.isFinite(ssdStore.maxBytes) ? ssdStore.maxBytes : null,
2652
1024
  restores: ssdStore.stats.restores,
2653
1025
  spills: ssdStore.stats.spills,
2654
1026
  restore_ms_last: Math.round(ssdStore.stats.restoreMsLast),
@@ -2683,33 +1055,7 @@ export function createServer(
2683
1055
  usable_bytes: admission.usableBytes,
2684
1056
  weights_bytes: ctx.model.weightsBytes,
2685
1057
  },
2686
- ...(ctx.glmMemoryPlan ? {
2687
- glm52: {
2688
- preset: ctx.glmMemoryPlan.preset,
2689
- planned_process_bytes: ctx.glmMemoryPlan.plannedProcessBytes,
2690
- process_limit_bytes: ctx.glmMemoryPlan.processLimitBytes,
2691
- context_tokens: ctx.glmMemoryPlan.contextTokens,
2692
- max_generation_tokens: ctx.glmMemoryPlan.maxGenerationTokens,
2693
- batch_size: ctx.glmMemoryPlan.batchSize,
2694
- dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
2695
- mtp: ctx.draft?.provider.id === "glm52-native-mtp",
2696
- mtp_draft_tokens: ctx.glmMemoryPlan.mtpDraftTokens,
2697
- resident_weight_bytes: ctx.glmMemoryPlan.lineItems.residentWeightsBytes,
2698
- main_expert_slab_bytes: ctx.glmMemoryPlan.lineItems.mainExpertSlabBytes,
2699
- mtp_expert_slab_bytes: ctx.glmMemoryPlan.lineItems.mtpExpertSlabBytes,
2700
- expert_runtime: ctx.model instanceof Glm52Model &&
2701
- ctx.model.expertRuntime
2702
- ? {
2703
- main_residency:
2704
- ctx.model.expertRuntime.manager.snapshot(),
2705
- mtp_residency:
2706
- ctx.model.expertRuntime.mtp?.manager.snapshot() ?? null,
2707
- last_turn: ctx.model.expertRuntime.lastTelemetry,
2708
- last_repin: ctx.model.expertRuntime.lastRepin,
2709
- }
2710
- : null,
2711
- },
2712
- } : {}),
1058
+ ...serving.diagnostics(),
2713
1059
  // --batch: configured cap, whether batching is live for this model,
2714
1060
  // and rows currently decoding in the batch.
2715
1061
  batch: {
@@ -2725,6 +1071,26 @@ export function createServer(
2725
1071
  });
2726
1072
  }
2727
1073
 
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
+
2728
1094
  // Engine-mode admin (unix-socket children only — never exposed on
2729
1095
  // TCP): drain = quiesce the gateway + demote the whole prompt cache
2730
1096
  // to the SSD tier. The pool calls this before evicting a model
@@ -2749,27 +1115,14 @@ export function createServer(
2749
1115
  let sbody: { prompt?: string };
2750
1116
  try { sbody = (await request.json()) as typeof sbody; }
2751
1117
  catch { return Response.json({ error: "invalid JSON" }, { status: 400, headers: CURVE_CORS }); }
2752
- 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)));
2753
1119
  if (sids[0] === sids[1] && sids[0] === ctx.tokenizer.bosTokenId) sids = sids.slice(1);
2754
1120
  try {
2755
1121
  const NB = 80;
2756
1122
  // gateway.runExclusive: this raw forward must not run concurrently
2757
1123
  // with batched decode steps or a serial generation (GPU + shared
2758
1124
  // model state are single-owner — D3, one lock).
2759
- const result = await gateway.runExclusive(async () => {
2760
- const cache = ctx.model.makeCache();
2761
- try {
2762
- const logits = ctx.model.forward(sids, cache); // [1, L, V]
2763
- const [, Ln, V] = logits.shape as [number, number, number];
2764
- const last = logits.slice([0, Ln - 1, 0], [1, Ln, V]);
2765
- const f = last.toFloat32(); logits.dispose(); last.dispose();
2766
- let mx = -Infinity; for (const v of f) if (v > mx) mx = v;
2767
- let Z = 0; for (const v of f) Z += Math.exp(v - mx); const lse = mx + Math.log(Z);
2768
- const bins = new Array<number>(NB).fill(0);
2769
- 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; }
2770
- return { bins, vocab: V };
2771
- } finally { for (const c of cache) c.dispose(); }
2772
- });
1125
+ const result = await gateway.runExclusive(() => serving.signal(sids, NB, CURVE_UMIN));
2773
1126
  return Response.json(result, { headers: CURVE_CORS });
2774
1127
  } catch (e) {
2775
1128
  return Response.json({ error: `signal failed: ${(e as Error).message}` }, { status: 500, headers: CURVE_CORS });
@@ -2778,7 +1131,7 @@ export function createServer(
2778
1131
 
2779
1132
  // ---- v2 HLG Curve Designer: POST /generate {prompt, curve, n, max_tokens, seed} ----
2780
1133
  // The drawn log-prob transfer curve REPLACES temperature+softmax entirely
2781
- // (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
2782
1135
  // tool's "Copy values" emits is the one the sampler consumes — one contract.
2783
1136
  if (url.pathname === "/generate" && request.method === "OPTIONS")
2784
1137
  return new Response(null, { headers: CURVE_CORS });
@@ -2802,7 +1155,7 @@ export function createServer(
2802
1155
  const n = Math.max(1, Math.min(8, Math.floor(Number(body.n) || 3)));
2803
1156
  const maxTokens = Math.max(1, Math.min(256, Math.floor(Number(body.max_tokens) || 80)));
2804
1157
  const baseSeed = Number.isFinite(body.seed) ? Number(body.seed) >>> 0 : (Date.now() & 0xffffffff);
2805
- 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)));
2806
1159
  if (ids[0] === ids[1] && ids[0] === ctx.tokenizer.bosTokenId) ids = ids.slice(1);
2807
1160
  const samples: { text: string; junk: boolean }[] = [];
2808
1161
  try {
@@ -2811,9 +1164,14 @@ export function createServer(
2811
1164
  const genOpts: GenerateOptions = useCurve
2812
1165
  ? { curve, seed: baseSeed + i, maxTokens, ...kvScheme }
2813
1166
  : { temperature: recipe.temperature, topP: recipe.topP, topK: recipe.topK, seed: baseSeed + i, maxTokens, ...kvScheme };
2814
- // runExclusive: runGeneration touches the GPU + prompt cache, so
2815
- // it needs the gateway lock (this endpoint bypasses gateway.run).
2816
- 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);
2817
1175
  const text = ctx.tokenizer.decode(toks, true).trim();
2818
1176
  samples.push({ text, junk: curveJunk(text) });
2819
1177
  }
@@ -2823,478 +1181,6 @@ export function createServer(
2823
1181
  return Response.json({ mode: useCurve ? "curve" : "default", recipe: useCurve ? undefined : recipe, n, seed: baseSeed, samples }, { headers: CURVE_CORS });
2824
1182
  }
2825
1183
 
2826
- // The chat-completions core, shared by both protocol surfaces:
2827
- // /v1/chat/completions calls it directly; /v1/messages (Anthropic)
2828
- // translates its body into this shape and the Response back —
2829
- // generation, tools, vision, stop sequences, prompt cache, and
2830
- // admission control all live here exactly once.
2831
- const handleChat = async (
2832
- body: ChatRequest,
2833
- signal: AbortSignal,
2834
- streamProtocol?: CompletionStreamProtocol,
2835
- requestId?: string,
2836
- trace?: PromptResponseTrace,
2837
- ): Promise<Response> => {
2838
- if (!Array.isArray(body.messages) || body.messages.length === 0)
2839
- return Response.json({ error: { message: "messages required" } }, { status: 400 });
2840
- // mlx-lm validates logprobs params up front (ValueError → 400)
2841
- const lpParamError = validateLogprobsParams(body);
2842
- if (lpParamError)
2843
- return Response.json({ error: { message: lpParamError } }, { status: 400 });
2844
- // Covers /v1/messages and /v1/responses too — both funnel through
2845
- // this handler after translation.
2846
- const effortError = validateReasoningEffort(body);
2847
- if (effortError)
2848
- return Response.json({ error: { message: effortError } }, { status: 400 });
2849
-
2850
- const id = requestId ?? `chatcmpl-${crypto.randomUUID()}`;
2851
- const closePromptPrepare = trace?.begin("request.prompt_prepare");
2852
- const created = Math.floor(Date.now() / 1000);
2853
- const tools =
2854
- body.tool_choice === "none" ? null : (body.tools?.length ? body.tools : null);
2855
- const hasImages = body.messages.some(
2856
- (m) => Array.isArray(m.content) &&
2857
- m.content.some((p: any) => p.type === "image_url" || p.type === "image"),
2858
- );
2859
- // Same shapes extractAudio accepts: OpenAI-canonical input_audio plus
2860
- // optiq's audio / audio_url aliases (audio-input-plan.md §3.2).
2861
- const hasAudio = body.messages.some(
2862
- (m) => Array.isArray(m.content) &&
2863
- m.content.some(
2864
- (p: any) => p.type === "input_audio" || p.type === "audio" || p.type === "audio_url",
2865
- ),
2866
- );
2867
- // Video content parts (video_url / video with base64 data) —
2868
- // Qwen3.5-family only (decoded to sampled frames via the
2869
- // AVFoundation sidecar, vision/video-frames.ts).
2870
- const hasVideos = body.messages.some(
2871
- (m) => Array.isArray(m.content) &&
2872
- m.content.some((p: any) => p.type === "video_url" || p.type === "video"),
2873
- );
2874
- let promptIds: number[];
2875
- let stableLen: number | null = null;
2876
- // Whether the prompt primed an open <think> (Qwen3.5/MiniCPM5 thinking
2877
- // on) so the model's output starts mid-reasoning — seeds the splitter.
2878
- // Vision is Gemma4-only (no switchable thinking channel), so it's false.
2879
- let startInThinking = false;
2880
- let vision: Parameters<typeof runGeneration>[3];
2881
- let diffusionPixels: import("./mlx/array").MlxArray | null = null;
2882
- const ownership = new RequestOwnership();
2883
- // Grammar-constrained decoding (src/grammar.ts). Compile BEFORE prompt
2884
- // rendering: on the degrade path (compile failed but a constraint was
2885
- // requested) inject a system message instructing valid JSON so the
2886
- // model still best-efforts schema-conformant output (oMLX parity —
2887
- // _compile_grammar_for_request returning None + build_json_system_prompt).
2888
- let grammarCtrl: import("./grammar").GrammarController | null = null;
2889
- let grammarWarning: string | null = null;
2890
- const grammarReq =
2891
- body.response_format != null || !!body.guided_grammar ||
2892
- !!body.guided_regex || !!body.guided_choice?.length ||
2893
- body.structured_outputs != null;
2894
- if (grammarReq) {
2895
- const g = await compileGrammarForRequest(body);
2896
- grammarCtrl = ownership.own(g.controller);
2897
- if (!g.controller && g.degradeHint) {
2898
- const degraded = applyGrammarDegrade(body, g.degradeHint);
2899
- grammarWarning = degraded.warning;
2900
- body = degraded.body;
2901
- }
2902
- }
2903
- const rejectBeforeRun = (response: Response): Response => {
2904
- ownership.dispose();
2905
- return response;
2906
- };
2907
- try {
2908
- // Video is Qwen3.5-family only and never composes with audio —
2909
- // one early guard so no downstream branch can silently drop a
2910
- // video part (the gemma/diffusion builders don't know the type).
2911
- if (hasVideos && (hasAudio || !(ctx.model instanceof Qwen35Model))) {
2912
- return rejectBeforeRun(Response.json(
2913
- { error: { message: hasAudio
2914
- ? "video and audio content parts cannot be combined"
2915
- : `model ${ctx.modelId} does not accept video input — video ` +
2916
- `content parts need a Qwen3.5-family model (e.g. Qwen3.8-27B)` } },
2917
- { status: 400 },
2918
- ));
2919
- }
2920
- if (hasAudio) {
2921
- // Audio (and MIXED image+audio) input — A4 of
2922
- // docs/design/audio-input-plan.md. One buildMultimodalPrompt call
2923
- // splices both media kinds in document order. A request WITH
2924
- // audio on a model whose tower is unavailable is an explicit 400
2925
- // — never a silent text-only degrade (that leniency is only for
2926
- // requests without the media, getVisionTower's contract).
2927
- const audioTower = getAudioTower(ctx);
2928
- if (!audioTower || !ctx.audioTokenIds) {
2929
- return rejectBeforeRun(Response.json(
2930
- {
2931
- error: {
2932
- message:
2933
- `model ${ctx.modelId} has no audio tower — audio input needs ` +
2934
- `a model whose config.json carries audio_config and whose ` +
2935
- `sidecar ships the audio tensors (e.g. gemma-4 e4b OptiQ)`,
2936
- },
2937
- },
2938
- { status: 400 },
2939
- ));
2940
- }
2941
- let visionSide:
2942
- | { tower: VisionEncoder; tokenIds: VisionTokenIds }
2943
- | undefined;
2944
- if (hasImages) {
2945
- const tower = getVisionTower(ctx);
2946
- if (!tower) {
2947
- return rejectBeforeRun(Response.json(
2948
- { error: { message: "model has no vision sidecar" } }, { status: 400 },
2949
- ));
2950
- }
2951
- visionSide = { tower, tokenIds: ctx.visionTokenIds };
2952
- }
2953
- const { messages: withAudioParts, images } =
2954
- await extractImages(normalizeMessages(body.messages));
2955
- const { messages, audio } = await extractAudio(withAudioParts);
2956
- // Non-WAV containers (mp3/m4a/flac/ogg/aiff/…) transcode to
2957
- // 16 kHz WAV via CoreAudio; RIFF bytes pass through untouched.
2958
- // Failures throw into the prompt-build 400 below.
2959
- const wavs = await Promise.all(audio.map(ensureWav));
2960
- // The towers are only ever non-null for Gemma4 (loader gates).
2961
- const mp = await buildMultimodalPrompt(
2962
- ctx.model as Gemma4Model,
2963
- {
2964
- ...(visionSide ? { vision: visionSide } : {}),
2965
- audio: { tower: audioTower, tokenIds: ctx.audioTokenIds },
2966
- },
2967
- ctx.tokenizer, ctx.template, messages, images, wavs, tools,
2968
- );
2969
- promptIds = mp.ids;
2970
- // bidirMask is null whenever audio is present (§3.3 Q1: mixed
2971
- // prompts run fully causal); the union mask does the per-layer
2972
- // id zeroing either way.
2973
- vision = {
2974
- embeddings: mp.embeddings,
2975
- ...(mp.bidirMask ? { imageMask: mp.bidirMask } : {}),
2976
- multimodalMask: mp.multimodalMask,
2977
- };
2978
- } else if (hasImages && ctx.model instanceof DiffusionGemmaModel) {
2979
- // DiffusionGemma image-text-to-text: its OWN dedicated SigLIP tower +
2980
- // encoder vision merge feed the denoising engine (NOT the AR
2981
- // forwardEmbeddings path). v1 supports a single image.
2982
- const dm = ctx.model;
2983
- if (!dm.visionTower) {
2984
- return rejectBeforeRun(Response.json(
2985
- { error: { message: "this checkpoint has no vision tower" } }, { status: 400 },
2986
- ));
2987
- }
2988
- const { messages, images } = await extractImages(normalizeMessages(body.messages));
2989
- if (images.length !== 1) {
2990
- return rejectBeforeRun(Response.json(
2991
- { error: { message: "DiffusionGemma image input supports exactly one image" } },
2992
- { status: 400 },
2993
- ));
2994
- }
2995
- const rendered = ctx.template.render(messages, { tools, addGenerationPrompt: true });
2996
- const rawIds = ctx.tokenizer.encode(rendered, /* addSpecialTokens */ false);
2997
- const { pixels, softTokens } = await dm.visionTower.preprocess(images[0]!);
2998
- diffusionPixels = ownership.own(pixels);
2999
- promptIds = spliceImageTokens(rawIds, [softTokens], {
3000
- image: ctx.visionTokenIds.imageTokenId,
3001
- boi: ctx.visionTokenIds.boiTokenId,
3002
- eoi: ctx.visionTokenIds.eoiTokenId,
3003
- });
3004
- } else if ((hasImages || hasVideos) && ctx.model instanceof Qwen35Model) {
3005
- // Qwen3.8 vision + video: the tower is a Qwen3VLVisionTower
3006
- // riding the shared lazy slot (makeVisionLoader's qwen branch);
3007
- // image/video spans splice into input embeddings and the request
3008
- // carries mRoPE positions + delta (PLAN 14v/14w). Videos decode
3009
- // to sampled frames via the AVFoundation sidecar.
3010
- const tower = getVisionTower(ctx) as unknown as Qwen3VLVisionTower | null;
3011
- if (!tower) {
3012
- return rejectBeforeRun(Response.json(
3013
- { error: { message: "model has no vision sidecar" } }, { status: 400 },
3014
- ));
3015
- }
3016
- const { messages: withVideos, images } =
3017
- await extractImages(normalizeMessages(body.messages));
3018
- const { messages, videos } = await extractVideos(withVideos);
3019
- const vp = await buildQwen3VLVisionPrompt(
3020
- ctx.model, tower, ctx.tokenizer, ctx.template, messages, images,
3021
- {
3022
- imageTokenId: (ctx.model.config.raw.image_token_id as number) ?? 248056,
3023
- videoTokenId: (ctx.model.config.raw.video_token_id as number) ?? 248057,
3024
- visionStartId: (ctx.model.config.raw.vision_start_token_id as number) ?? 248053,
3025
- visionEndId: (ctx.model.config.raw.vision_end_token_id as number) ?? 248054,
3026
- },
3027
- templateOptionsFor(body, tools),
3028
- videos,
3029
- );
3030
- promptIds = vp.ids;
3031
- vision = { embeddings: vp.embeddings, mrope: vp.mrope };
3032
- } else if (hasImages) {
3033
- // Loads (and caches) the tower on first image request — text-only
3034
- // sessions never pay for it.
3035
- const tower = getVisionTower(ctx);
3036
- if (!tower) {
3037
- return rejectBeforeRun(Response.json(
3038
- { error: { message: "model has no vision sidecar" } }, { status: 400 },
3039
- ));
3040
- }
3041
- const { messages, images } = await extractImages(normalizeMessages(body.messages));
3042
- // The tower is only ever non-null for Gemma4 (sidecar gate in
3043
- // makeVisionLoader), so the model narrow is safe here.
3044
- const vp = await buildVisionPrompt(
3045
- ctx.model as Gemma4Model, tower, ctx.tokenizer, ctx.template,
3046
- messages, images, ctx.visionTokenIds, tools,
3047
- );
3048
- promptIds = vp.ids;
3049
- vision = { embeddings: vp.embeddings, imageMask: vp.imageMask };
3050
- } else {
3051
- const built = promptIdsFor(body, tools);
3052
- promptIds = built.ids;
3053
- startInThinking = built.startInThinking;
3054
- stableLen = -1; // marker: chat path, probe lazily below
3055
- }
3056
- } catch (e) {
3057
- return rejectBeforeRun(Response.json(
3058
- { error: { message: `prompt build failed: ${(e as Error).message}` } },
3059
- { status: 400 },
3060
- ));
3061
- }
3062
- ownership.own(vision?.embeddings);
3063
- ownership.own(vision?.imageMask);
3064
- ownership.own(vision?.multimodalMask);
3065
- ownership.own(diffusionPixels);
3066
- let options: ReturnType<typeof toOptions>;
3067
- try {
3068
- options = toOptions(body);
3069
- // Stable cache boundary for the prompt-boundary snapshot (chat
3070
- // prompts end in a generation primer the next turn won't contain).
3071
- // The probe costs a second full render+encode (~150 ms at 16k), so
3072
- // run it ONLY when a snapshot can actually be taken: the cache
3073
- // must not already hold the strict prefix (warm repeats peek at
3074
- // len-1 → skip; the snapshot gate needs boundary > cached prefix).
3075
- if (stableLen !== null) {
3076
- const cachePeek = promptCache.peekPrefixLen(
3077
- promptIds, options.adapters?.join("+") ?? "");
3078
- if (cachePeek < promptIds.length - 1)
3079
- options.snapshotAt = stableLenFor(body, tools, promptIds);
3080
- }
3081
- } catch (e) {
3082
- // bad logit_bias (non-numeric keys/values) — mlx-lm's coercion error
3083
- return rejectBeforeRun(
3084
- Response.json({ error: { message: (e as Error).message } }, { status: 400 }),
3085
- );
3086
- }
3087
- if (diffusionPixels) options.visionPixels = diffusionPixels;
3088
- // Attach the compiled grammar controller (null when no constraint /
3089
- // degrade — generate() runs the unmasked fast pipelined loop).
3090
- if (grammarCtrl) options.grammar = grammarCtrl;
3091
- const requestedMaxTokens = options.maxTokens ?? 1024;
3092
- let adapterIds: string[];
3093
- try {
3094
- // A request's explicit `adapter` (incl. "none") wins over the
3095
- // startup default from `serve --adapter <dir>`.
3096
- adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3097
- } catch (e) {
3098
- return rejectBeforeRun(
3099
- Response.json({ error: { message: (e as Error).message } }, { status: 400 }),
3100
- );
3101
- }
3102
-
3103
- const wantLogprobs = body.logprobs === true;
3104
- const topLogprobs =
3105
- typeof body.top_logprobs === "number" && body.top_logprobs > 0
3106
- ? body.top_logprobs : 0;
3107
- let prepared: PreparedCompletion;
3108
- try {
3109
- prepared = prepareCompletion({
3110
- requestId: id,
3111
- plan: {
3112
- promptIds,
3113
- options,
3114
- requestedMaxTokens,
3115
- maxSafeContext: admission.maxSafeContext,
3116
- stream: body.stream === true,
3117
- wantLogprobs,
3118
- topLogprobs,
3119
- adapterIds,
3120
- hasVision: !!vision,
3121
- userSeed: body.seed !== undefined,
3122
- hasGrammar: !!grammarCtrl,
3123
- hasDraft: !!ctx.draft,
3124
- ownership,
3125
- },
3126
- vision,
3127
- pipeline: {
3128
- router: toolRouter(tools),
3129
- stopper: new StopMatcher(options.stopSequences),
3130
- thinking: new ThinkingTagSplitter(
3131
- ctx.template.thinkingFormat === "think-tag",
3132
- startInThinking,
3133
- ),
3134
- collectToolCalls: true,
3135
- },
3136
- ...(body.stream
3137
- ? {
3138
- createFlowControl: ({ mechanism }) =>
3139
- createTimedFlowControl(mechanism === "serial"),
3140
- }
3141
- : {}),
3142
- onPlacement: ({ mechanism, shape }) => {
3143
- if (runtimeValue("MLX_BUN_LANE_DEBUG") === "1")
3144
- console.error(
3145
- `[scheduling] mechanism=${mechanism} shape=${JSON.stringify(shape)} ` +
3146
- `t=${Date.now() % 100000}`,
3147
- );
3148
- },
3149
- idToToken: (tokenId) => ctx.tokenizer.idToToken(tokenId),
3150
- });
3151
- } catch (error) {
3152
- closePromptPrepare?.();
3153
- trace?.finish("error", { stage: "prepare_completion" });
3154
- if (!(error instanceof CompletionRejected)) throw error;
3155
- return Response.json({ error: error.error }, { status: error.status });
3156
- }
3157
- closePromptPrepare?.();
3158
-
3159
- if (body.stream) {
3160
- const streamAbort = new AbortController();
3161
- const generationSignal = AbortSignal.any([signal, streamAbort.signal]);
3162
- let cancelled = false;
3163
- const stream = new ReadableStream<Uint8Array>({
3164
- start(controller) {
3165
- const enc = new TextEncoder();
3166
- const send = (obj: unknown) => {
3167
- if (!generationSignal.aborted)
3168
- controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3169
- };
3170
- const emitFrames = (frames: string[]) => {
3171
- if (generationSignal.aborted) return;
3172
- for (const frame of frames) controller.enqueue(enc.encode(frame));
3173
- };
3174
- let latestUsage: Readonly<CompletionSummary["usage"]> | null = null;
3175
- let wroteFirstEvent = false;
3176
- let traceOutcome: "success" | "error" | "abort" = "success";
3177
- const chunk = (delta: Record<string, unknown>, finish: string | null) => ({
3178
- id, object: "chat.completion.chunk", created, model: ctx.modelId,
3179
- choices: [{ index: 0, delta, finish_reason: finish }],
3180
- });
3181
- void (async () => {
3182
- try {
3183
- // The gateway owns lane selection + GPU exclusivity; this body
3184
- // runs per-request (concurrently in batched mode, each writing
3185
- // its own SSE stream — the per-row fan-out).
3186
- if (streamProtocol) emitFrames(streamProtocol.start());
3187
- else send(chunk({ role: "assistant", content: "" }, null));
3188
- const sendEvents = (events: readonly CompletionEvent[]) => {
3189
- if (events.length && !wroteFirstEvent) {
3190
- wroteFirstEvent = true;
3191
- trace?.mark("response.first_write");
3192
- }
3193
- if (streamProtocol) {
3194
- emitFrames(streamProtocol.addEvents([...events]));
3195
- return;
3196
- }
3197
- for (const event of events) {
3198
- if (event.type === "reasoning") {
3199
- send(chunk({ reasoning: event.text }, null));
3200
- } else if (event.type === "content") {
3201
- send(chunk({ content: event.text }, null));
3202
- } else {
3203
- send(chunk({
3204
- tool_calls: event.calls.map((call, index) => ({ index, ...call })),
3205
- }, null));
3206
- }
3207
- }
3208
- };
3209
- const summary = await completionExecutor.execute(prepared, {
3210
- signal: generationSignal,
3211
- trace,
3212
- onEvents: sendEvents,
3213
- ...(streamProtocol
3214
- ? { onUsageProgress: (usage) => { latestUsage = usage; } }
3215
- : {}),
3216
- });
3217
- const usage = completionUsage(summary);
3218
- if (streamProtocol) {
3219
- emitFrames(streamProtocol.finish(summary.finishReason, usage));
3220
- } else {
3221
- send({ ...chunk({}, summary.finishReason), usage });
3222
- // bare sentinel per the OpenAI spec — strict SDK clients
3223
- // require the unquoted terminator.
3224
- controller.enqueue(enc.encode("data: [DONE]\n\n"));
3225
- }
3226
- trace?.mark("response.final_write");
3227
- } catch (e) {
3228
- traceOutcome = generationSignal.aborted ? "abort" : "error";
3229
- if (!generationSignal.aborted) {
3230
- const message = (e as Error).message;
3231
- if (streamProtocol) {
3232
- emitFrames([
3233
- ...streamProtocol.error(message),
3234
- ...streamProtocol.finish(
3235
- "stop",
3236
- latestUsage ? completionProtocolUsage(latestUsage) : {},
3237
- ),
3238
- ]);
3239
- } else {
3240
- send({ error: { message } });
3241
- }
3242
- }
3243
- } finally {
3244
- trace?.finish(traceOutcome);
3245
- if (!cancelled) {
3246
- if (generationSignal.aborted) controller.error(generationSignal.reason);
3247
- else controller.close();
3248
- }
3249
- }
3250
- })();
3251
- },
3252
- cancel(reason) {
3253
- cancelled = true;
3254
- streamAbort.abort(reason);
3255
- },
3256
- });
3257
- return new Response(stream, {
3258
- headers: {
3259
- "content-type": "text/event-stream",
3260
- "cache-control": "no-cache",
3261
- connection: "keep-alive",
3262
- ...(grammarWarning ? { Warning: grammarWarning } : {}),
3263
- },
3264
- });
3265
- }
3266
-
3267
- try {
3268
- {
3269
- const summary = await completionExecutor.execute(prepared, { signal, trace });
3270
- trace?.mark("response.final_write");
3271
- const response = Response.json({
3272
- id, object: "chat.completion", created, model: ctx.modelId,
3273
- choices: [{
3274
- index: 0,
3275
- message: {
3276
- role: "assistant",
3277
- content: summary.content || (summary.toolCalls.length ? null : ""),
3278
- ...(summary.reasoning ? { reasoning: summary.reasoning } : {}),
3279
- ...(summary.toolCalls.length ? { tool_calls: summary.toolCalls } : {}),
3280
- },
3281
- ...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
3282
- finish_reason: summary.finishReason,
3283
- }],
3284
- usage: completionUsage(summary),
3285
- }, grammarWarning ? { headers: { Warning: grammarWarning } } : undefined);
3286
- trace?.finish("success");
3287
- return response;
3288
- }
3289
- } catch (e) {
3290
- trace?.finish(signal.aborted ? "abort" : "error");
3291
- // A 500 with no server-side trace is undebuggable in the field —
3292
- // always log the stack (the JSON body keeps only the message).
3293
- console.error(`[serve] 500 on chat request:\n${(e as Error).stack ?? e}`);
3294
- return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
3295
- }
3296
- };
3297
-
3298
1184
  if (url.pathname === "/v1/chat/completions" && request.method === "POST") {
3299
1185
  const id = `chatcmpl-${crypto.randomUUID()}`;
3300
1186
  const trace = createPromptResponseTrace({
@@ -3302,25 +1188,19 @@ export function createServer(
3302
1188
  requestId: id,
3303
1189
  route: url.pathname,
3304
1190
  });
3305
- const closeBodyParse = trace?.begin("request.body_parse");
3306
- let body: ChatRequest;
3307
- try {
3308
- body = (await request.json()) as ChatRequest;
3309
- } catch {
3310
- closeBodyParse?.();
3311
- trace?.finish("error", { stage: "body_parse" });
3312
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3313
- }
3314
- closeBodyParse?.();
3315
- return handleChat(body, request.signal, undefined, id, trace);
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);
3316
1200
  }
3317
1201
 
3318
- // Raw text completion (mlx_lm.server's /v1/completions, request_type
3319
- // "text"): NO chat template — the prompt string is tokenized directly
3320
- // (tokenizer.encode with the tokenizer's own special-token handling,
3321
- // exactly mlx-lm's `tokenizer.encode(request.prompt)`). Rides the same
3322
- // GenerationGateway + admission + adapter path as chat; no tool router
3323
- // or thinking splitter (raw text in, raw text out).
1202
+ // Raw text completion (mlx_lm.server's /v1/completions): no chat
1203
+ // template — TextCompletionStage tokenizes the prompt string directly.
3324
1204
  if (url.pathname === "/v1/completions" && request.method === "POST") {
3325
1205
  const id = `cmpl-${crypto.randomUUID()}`;
3326
1206
  const trace = createPromptResponseTrace({
@@ -3328,203 +1208,15 @@ export function createServer(
3328
1208
  requestId: id,
3329
1209
  route: url.pathname,
3330
1210
  });
3331
- const closeBodyParse = trace?.begin("request.body_parse");
3332
- let body: TextCompletionRequest;
3333
- try {
3334
- body = (await request.json()) as TextCompletionRequest;
3335
- } catch {
3336
- closeBodyParse?.();
3337
- trace?.finish("error", { stage: "body_parse" });
3338
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3339
- }
3340
- closeBodyParse?.();
3341
- const closePromptPrepare = trace?.begin("request.prompt_prepare");
3342
- if (typeof body.prompt !== "string" || body.prompt.length === 0)
3343
- return Response.json(
3344
- {
3345
- error: {
3346
- message: "prompt (a non-empty string) is required " +
3347
- "(token-array prompts are not accepted, matching mlx_lm.server)",
3348
- },
3349
- },
3350
- { status: 400 },
3351
- );
3352
- // mlx-lm validates logprobs params up front (ValueError → 400)
3353
- const lpParamError = validateLogprobsParams(body);
3354
- if (lpParamError)
3355
- return Response.json({ error: { message: lpParamError } }, { status: 400 });
3356
- const created = Math.floor(Date.now() / 1000);
3357
- const promptIds = ctx.tokenizer.encode(body.prompt);
3358
- const ownership = new RequestOwnership();
3359
- let options: ReturnType<typeof toOptions>;
3360
- try {
3361
- options = toOptions(body as unknown as ChatRequest);
3362
- } catch (e) {
3363
- // bad logit_bias (non-numeric keys/values) — mlx-lm's coercion error
3364
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3365
- }
3366
- // Grammar-constrained decoding on raw completions too (response_format /
3367
- // guided_* / structured_outputs). The degrade path for /v1/completions
3368
- // has no chat template to inject a system message into, so a degrade
3369
- // only emits the Warning header (no prompt injection) — documented gap
3370
- // vs the chat lane, which mirrors oMLX's text-completions behavior.
3371
- let textGrammarWarning: string | null = null;
3372
- const textGrammarReq =
3373
- body.response_format != null || !!body.guided_grammar ||
3374
- !!body.guided_regex || !!body.guided_choice?.length ||
3375
- body.structured_outputs != null;
3376
- if (textGrammarReq) {
3377
- const g = await compileGrammarForRequest(body as unknown as ChatRequest);
3378
- if (g.controller) options.grammar = ownership.own(g.controller);
3379
- else if (g.degradeHint)
3380
- textGrammarWarning =
3381
- `grammar not enforced: ${g.degradeHint} - no prompt injection on /v1/completions`;
3382
- }
3383
- // mlx_lm.server's default max_tokens is 512 (its --max-tokens CLI
3384
- // default). The chat lane's very generous default is wrong for raw
3385
- // completion: with no template an EOS may never come.
3386
- const requestedMaxTokens = body.max_completion_tokens ?? body.max_tokens ??
3387
- defaultGeneratedTokens ?? 512;
3388
- let adapterIds: string[];
3389
- try {
3390
- adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3391
- } catch (e) {
3392
- ownership.dispose();
3393
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3394
- }
3395
- const wantLogprobs = body.logprobs === true;
3396
- const topLogprobs =
3397
- typeof body.top_logprobs === "number" && body.top_logprobs > 0
3398
- ? body.top_logprobs : 0;
3399
- let prepared: PreparedCompletion;
3400
- try {
3401
- prepared = prepareCompletion({
3402
- requestId: id,
3403
- plan: {
3404
- promptIds,
3405
- options,
3406
- requestedMaxTokens,
3407
- maxSafeContext: admission.maxSafeContext,
3408
- stream: body.stream === true,
3409
- wantLogprobs,
3410
- topLogprobs,
3411
- adapterIds,
3412
- hasVision: false,
3413
- userSeed: body.seed !== undefined,
3414
- hasGrammar: !!options.grammar,
3415
- hasDraft: !!ctx.draft,
3416
- ownership,
3417
- },
3418
- pipeline: {
3419
- router: new ToolAwareStream(ctx.tokenizer, "plain", null),
3420
- stopper: new StopMatcher(options.stopSequences),
3421
- thinking: new ThinkingTagSplitter(false),
3422
- collectToolCalls: false,
3423
- },
3424
- ...(body.stream
3425
- ? { createFlowControl: () => createTimedFlowControl(true) }
3426
- : {}),
3427
- idToToken: (tokenId) => ctx.tokenizer.idToToken(tokenId),
3428
- });
3429
- } catch (error) {
3430
- closePromptPrepare?.();
3431
- trace?.finish("error", { stage: "prepare_completion" });
3432
- if (!(error instanceof CompletionRejected)) throw error;
3433
- return Response.json({ error: error.error }, { status: error.status });
3434
- }
3435
- closePromptPrepare?.();
3436
-
3437
- if (body.stream) {
3438
- const streamAbort = new AbortController();
3439
- const generationSignal = AbortSignal.any([request.signal, streamAbort.signal]);
3440
- let cancelled = false;
3441
- const stream = new ReadableStream<Uint8Array>({
3442
- start(controller) {
3443
- const enc = new TextEncoder();
3444
- const send = (obj: unknown) => {
3445
- if (!generationSignal.aborted)
3446
- controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3447
- };
3448
- const chunk = (text: string, finish: string | null) => ({
3449
- id, object: "text_completion", created, model: ctx.modelId,
3450
- choices: [{ index: 0, text, finish_reason: finish }],
3451
- });
3452
- let wroteFirstEvent = false;
3453
- let traceOutcome: "success" | "error" | "abort" = "success";
3454
- void (async () => {
3455
- try {
3456
- const sendEvents = (events: readonly CompletionEvent[]) => {
3457
- if (events.length && !wroteFirstEvent) {
3458
- wroteFirstEvent = true;
3459
- trace?.mark("response.first_write");
3460
- }
3461
- for (const event of events) {
3462
- if (event.type === "content") send(chunk(event.text, null));
3463
- }
3464
- };
3465
- const summary = await completionExecutor.execute(prepared, {
3466
- signal: generationSignal,
3467
- trace,
3468
- onEvents: sendEvents,
3469
- });
3470
- // final chunk: finish_reason + usage (mlx-lm gates usage behind
3471
- // stream_options.include_usage; we always attach it, matching
3472
- // our chat lane — an additive superset OpenAI clients ignore)
3473
- send({
3474
- ...chunk("", summary.finishReason),
3475
- usage: completionUsage(summary),
3476
- });
3477
- controller.enqueue(enc.encode("data: [DONE]\n\n"));
3478
- trace?.mark("response.final_write");
3479
- } catch (e) {
3480
- traceOutcome = generationSignal.aborted ? "abort" : "error";
3481
- if (!generationSignal.aborted)
3482
- send({ error: { message: (e as Error).message } });
3483
- } finally {
3484
- trace?.finish(traceOutcome);
3485
- if (!cancelled) {
3486
- if (generationSignal.aborted) controller.error(generationSignal.reason);
3487
- else controller.close();
3488
- }
3489
- }
3490
- })();
3491
- },
3492
- cancel(reason) {
3493
- cancelled = true;
3494
- streamAbort.abort(reason);
3495
- },
3496
- });
3497
- return new Response(stream, {
3498
- headers: {
3499
- "content-type": "text/event-stream",
3500
- "cache-control": "no-cache",
3501
- connection: "keep-alive",
3502
- ...(textGrammarWarning ? { Warning: textGrammarWarning } : {}),
3503
- },
3504
- });
3505
- }
3506
-
3507
- try {
3508
- const summary = await completionExecutor.execute(prepared, {
3509
- signal: request.signal,
3510
- trace,
3511
- });
3512
- trace?.mark("response.final_write");
3513
- const response = Response.json({
3514
- id, object: "text_completion", created, model: ctx.modelId,
3515
- choices: [{
3516
- index: 0, text: summary.content,
3517
- ...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
3518
- finish_reason: summary.finishReason,
3519
- }],
3520
- usage: completionUsage(summary),
3521
- }, textGrammarWarning ? { headers: { Warning: textGrammarWarning } } : undefined);
3522
- trace?.finish("success");
3523
- return response;
3524
- } catch (e) {
3525
- trace?.finish(request.signal.aborted ? "abort" : "error");
3526
- return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
3527
- }
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);
3528
1220
  }
3529
1221
 
3530
1222
  // Anthropic Messages API (Phase 11) — on by default, mirroring
@@ -3533,41 +1225,35 @@ export function createServer(
3533
1225
  // src/anthropic.ts. Point Claude Code at this port via
3534
1226
  // ANTHROPIC_BASE_URL for a fully local backend.
3535
1227
  if (url.pathname === "/v1/messages" && request.method === "POST") {
3536
- const anthropicError = (status: number, type: string, message: string) =>
3537
- 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
+ );
3538
1233
  let anthropicBody: AnthropicRequest;
3539
1234
  try {
3540
1235
  anthropicBody = (await request.json()) as AnthropicRequest;
3541
1236
  } catch {
3542
- return anthropicError(400, "invalid_request_error", "invalid JSON body");
1237
+ return anthropicError(400, "invalid JSON body", {});
3543
1238
  }
3544
- let chatBody: ChatRequest;
1239
+ let chatBody: ChatRequestParams;
3545
1240
  try {
3546
- chatBody = anthropicToChatBody(anthropicBody) as unknown as ChatRequest;
1241
+ chatBody = anthropicToChatBody(anthropicBody) as unknown as ChatRequestParams;
3547
1242
  } catch (e) {
3548
- return anthropicError(400, "invalid_request_error", (e as Error).message);
3549
- }
3550
- const resp = await handleChat(
3551
- chatBody,
3552
- request.signal,
3553
- anthropicBody.stream
3554
- ? createAnthropicStreamProtocol(ctx.modelId)
3555
- : undefined,
3556
- );
3557
- if (!resp.ok) {
3558
- const err = (await resp.json().catch(() => null)) as
3559
- | { error?: { message?: string } }
3560
- | null;
3561
- return anthropicError(
3562
- resp.status,
3563
- resp.status >= 500 ? "api_error" : "invalid_request_error",
3564
- err?.error?.message ?? "request failed",
3565
- );
1243
+ return anthropicError(400, (e as Error).message, {});
3566
1244
  }
3567
- if (anthropicBody.stream) {
3568
- return resp;
3569
- }
3570
- 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);
3571
1257
  }
3572
1258
 
3573
1259
  // OpenAI Responses API (Phase 11) — Codex/Cursor/Continue speak
@@ -3575,7 +1261,7 @@ export function createServer(
3575
1261
  // ported in src/responses.ts. previous_response_id resumes a prior
3576
1262
  // conversation from the in-process store (TTL + byte-capped LRU).
3577
1263
  if (url.pathname === "/v1/responses" && request.method === "POST") {
3578
- const responsesError = (status: number, message: string) =>
1264
+ const responsesError: ErrorFormatter = (status, message) =>
3579
1265
  Response.json(
3580
1266
  {
3581
1267
  error: {
@@ -3590,80 +1276,55 @@ export function createServer(
3590
1276
  try {
3591
1277
  responsesBody = (await request.json()) as ResponsesRequest;
3592
1278
  } catch {
3593
- return responsesError(400, "invalid JSON body");
1279
+ return responsesError(400, "invalid JSON body", {});
3594
1280
  }
3595
1281
 
3596
- // previous_response_id: prepend the prior conversation's input
3597
- // + output (as input items); carry instructions forward only if
3598
- // the new request omits them (oracle semantics).
3599
- const prevId = responsesBody.previous_response_id ?? null;
3600
- if (prevId) {
3601
- const prior = responseStore.get(prevId);
3602
- if (!prior)
3603
- return responsesError(404, `previous_response_id '${prevId}' not found or expired`);
3604
- const prepended = [...prior.input, ...outputItemsToInputItems(prior.output)];
3605
- const newInput =
3606
- typeof responsesBody.input === "string"
3607
- ? responsesBody.input
3608
- ? [{ type: "message", role: "user", content: responsesBody.input }]
3609
- : []
3610
- : responsesBody.input ?? [];
3611
- responsesBody = {
3612
- ...responsesBody,
3613
- input: [...prepended, ...newInput] as Array<Record<string, unknown>>,
3614
- instructions: responsesBody.instructions ?? prior.instructions ?? undefined,
3615
- };
3616
- }
3617
- // Remember the effective input so a later follow-up that chains
3618
- // off THIS response sees the full history.
3619
- const capturedInput: unknown[] =
3620
- typeof responsesBody.input === "string"
3621
- ? [{ type: "message", role: "user", content: responsesBody.input }]
3622
- : [...(responsesBody.input ?? [])];
3623
- const capturedInstructions = responsesBody.instructions ?? null;
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 });
1292
+ };
3624
1293
 
3625
- let chatBody: ChatRequest;
1294
+ let chatBody: ChatRequestParams;
3626
1295
  try {
3627
- chatBody = responsesToChatBody(responsesBody) as unknown as ChatRequest;
1296
+ chatBody = responsesToChatBody(responsesBody) as unknown as ChatRequestParams;
3628
1297
  } catch (e) {
3629
- return responsesError(400, (e as Error).message);
3630
- }
3631
- const resp = await handleChat(
3632
- chatBody,
3633
- request.signal,
3634
- responsesBody.stream
3635
- ? createResponsesStreamProtocol(
3636
- ctx.modelId,
3637
- prevId,
3638
- (final) =>
3639
- responseStore.put(final.id as string, {
3640
- input: capturedInput,
3641
- output: final.output as unknown[],
3642
- instructions: capturedInstructions,
3643
- }),
3644
- )
3645
- : undefined,
3646
- );
3647
- if (!resp.ok) {
3648
- const err = (await resp.json().catch(() => null)) as
3649
- | { error?: { message?: string } }
3650
- | null;
3651
- return responsesError(resp.status, err?.error?.message ?? "request failed");
3652
- }
3653
- if (responsesBody.stream) {
3654
- return resp;
1298
+ return responsesError(400, (e as Error).message, {});
3655
1299
  }
3656
- const responses = chatJsonToResponses(await resp.json(), ctx.modelId, prevId);
3657
- responseStore.put(responses.id as string, {
3658
- input: capturedInput,
3659
- output: responses.output as unknown[],
3660
- instructions: capturedInstructions,
3661
- });
3662
- return Response.json(responses);
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);
3663
1323
  }
3664
1324
 
3665
1325
  const labResponse = await handleLabRoute(url, request, {
3666
1326
  ensureJobs,
1327
+ acquireGpu: (signal) => gateway.acquireExecutionLease(signal),
3667
1328
  serverPort: () => server.port,
3668
1329
  invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
3669
1330
  });
@@ -3679,6 +1340,13 @@ export function createServer(
3679
1340
  },
3680
1341
  });
3681
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(); },
3682
1350
  flush: flushDurability,
3683
1351
  stats: durabilityStats,
3684
1352
  stopTimers: () => {