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
@@ -0,0 +1,519 @@
1
+ // Token → text streaming stages shared by every completion surface:
2
+ // StreamDecoder (mlx-lm byte-parity incremental detokenizer), ToolAwareStream
3
+ // (tool-call / reasoning-channel routing), StopMatcher (decoded-text stop
4
+ // sequences), ThinkingTagSplitter (<think> markup → reasoning deltas).
5
+ // Extracted from src/server.ts (repo-taming Phase 4).
6
+ import type { ToolDefinition } from "../chat-template";
7
+ import type { LoadedTokenizer } from "../tokenizer";
8
+ import {
9
+ CHANNEL_END, CHANNEL_START, parseGeneratedToolCalls, parseToolCalls,
10
+ TOOL_CALL_END, TOOL_CALL_START,
11
+ } from "../tool-call";
12
+
13
+ export interface OpenAIToolCall {
14
+ id: string;
15
+ type: "function";
16
+ function: { name: string; arguments: string };
17
+ }
18
+
19
+ export type ToolStreamMode = "gemma-sentinel" | "plain" | "buffered-text";
20
+
21
+ /** Pick the stream router for a model family. The token-id sentinel router is
22
+ * Gemma-4-ONLY: ids 48/49 (<|tool_call>/<tool_call|>) and 100/101
23
+ * (<|channel>/<channel|>) are special tokens of that tokenizer family
24
+ * (src/tool-call.ts). On every other tokenizer — MiniCPM5, Qwen3/3.5, and
25
+ * the Tier-0 generics (llama, qwen2, phi3, …) — those ids are ordinary
26
+ * low-id vocab, so the sentinel router would silently swallow output into a
27
+ * phantom tool/reasoning segment. Everyone else parses tool calls from
28
+ * decoded text (buffered-text; parseGeneratedToolCalls covers the
29
+ * OpenAI-JSON <tool_call>, Qwen <function=…>, and MiniCPM5 <function name=…>
30
+ * shapes) when tools are present, and streams plain otherwise. Models whose
31
+ * markup isn't covered fail soft: the markup stays in content. */
32
+ export function selectToolStreamMode(modelType: string, hasTools: boolean): ToolStreamMode {
33
+ if (modelType.startsWith("gemma4")) return "gemma-sentinel";
34
+ return hasTools ? "buffered-text" : "plain";
35
+ }
36
+
37
+ /** Routes generated tokens. Gemma uses family-specific sentinel token ids;
38
+ * MiniCPM5 and other text-template models use decoded-text parsing so
39
+ * ordinary tokenizer ids like "<" are never swallowed globally. Exported for
40
+ * unit tests (gemma-channel reasoning split). */
41
+ export class ToolAwareStream {
42
+ readonly #decoder: StreamDecoder;
43
+ #inTool = false;
44
+ #toolTokens: number[] = [];
45
+ #text = "";
46
+ /** Chars of #text already returned as content. */
47
+ #sent = 0;
48
+ /** Index where tool markup starts; content emission stops there. */
49
+ #frozen = -1;
50
+ #textToolCalls: OpenAIToolCall[] | null = null;
51
+ #textToolParseFailed = false;
52
+ readonly toolSegments: number[][] = [];
53
+
54
+ /** Gemma reasoning-channel state (gemma-sentinel mode). The model wraps
55
+ * chain-of-thought as `<|channel>thought\n…<channel|>` using special tokens
56
+ * 100/101 that the content decoder strips, so reasoning is captured here at
57
+ * the token level. A SEPARATE decoder keeps the reasoning byte-stream's
58
+ * incremental state independent of content's. The `thought` channel-name
59
+ * word is stripped before the reasoning text (mlx-lm's think-start marker
60
+ * is `<|channel>thought`; the "\n" after it is reasoning content). */
61
+ readonly #channelDecoder: StreamDecoder;
62
+ #inChannel = false;
63
+ #channelNamePending = "";
64
+ #channelNameDone = false;
65
+ #reasoning = "";
66
+
67
+ /** Decoded-text markers that open tool markup (oracle: the streaming
68
+ * parser buffers from `<tool_call`/`<function` on, never the whole
69
+ * response — content before a tool call still streams live). */
70
+ static readonly TOOL_MARKERS = ["<tool_call>", "<function"];
71
+
72
+ constructor(
73
+ readonly tokenizer: LoadedTokenizer,
74
+ readonly mode: ToolStreamMode,
75
+ readonly tools: ToolDefinition[] | null,
76
+ /** Fired when parseGeneratedToolCalls rejects the emitted markup. The
77
+ * fill table (src/fill/) subscribes: a request whose tool call the
78
+ * parser refuses is a request whose template rendering disagreed with
79
+ * what the model emits, so its strict rows are disarmed. */
80
+ private readonly onParseFailure?: () => void,
81
+ ) {
82
+ this.#decoder = new StreamDecoder(tokenizer, mode !== "buffered-text");
83
+ this.#channelDecoder = new StreamDecoder(tokenizer, true);
84
+ }
85
+
86
+ /** Feed decoded channel text, stripping the leading `thought` channel-name
87
+ * word (which may arrive across tokens). Returns the reasoning delta.
88
+ * mlx-lm parity: its think-start marker is exactly `<|channel>thought`
89
+ * (tokenizer_utils.py `_infer_thinking`) and only the MARKER tokens get
90
+ * their text blanked (server.py `_process_control_tokens`) — the "\n"
91
+ * after the name is an ordinary generated token, so it is the FIRST byte
92
+ * of the reasoning stream and must be kept, not swallowed with the name. */
93
+ #feedChannel(text: string): string {
94
+ if (this.#channelNameDone) return text;
95
+ this.#channelNamePending += text;
96
+ const nl = this.#channelNamePending.indexOf("\n");
97
+ if (nl === -1) return ""; // still inside the channel-name word
98
+ this.#channelNameDone = true;
99
+ const rest = this.#channelNamePending.slice(nl); // keep the "\n" (mlx-lm does)
100
+ this.#channelNamePending = "";
101
+ return rest;
102
+ }
103
+
104
+ /** Drain reasoning captured since the last call (gemma-channel thinking). */
105
+ takeReasoning(): string {
106
+ const r = this.#reasoning;
107
+ this.#reasoning = "";
108
+ return r;
109
+ }
110
+
111
+ /** Emit the longest #text prefix that cannot be (the start of) tool
112
+ * markup; hold back ambiguous tails until disambiguated. */
113
+ #textDelta(): string {
114
+ if (this.#frozen >= 0) return "";
115
+ let markerAt = -1;
116
+ for (const mk of ToolAwareStream.TOOL_MARKERS) {
117
+ const i = this.#text.indexOf(mk, this.#sent);
118
+ if (i !== -1 && (markerAt === -1 || i < markerAt)) markerAt = i;
119
+ }
120
+ if (markerAt !== -1) {
121
+ this.#frozen = markerAt;
122
+ const out = this.#text.slice(this.#sent, markerAt);
123
+ this.#sent = markerAt;
124
+ return out;
125
+ }
126
+ let hold = 0;
127
+ for (const mk of ToolAwareStream.TOOL_MARKERS) {
128
+ const max = Math.min(mk.length - 1, this.#text.length - this.#sent);
129
+ for (let k = max; k > hold; k--) {
130
+ if (this.#text.endsWith(mk.slice(0, k))) { hold = k; break; }
131
+ }
132
+ }
133
+ const limit = this.#text.length - hold;
134
+ if (limit <= this.#sent) return "";
135
+ const out = this.#text.slice(this.#sent, limit);
136
+ this.#sent = limit;
137
+ return out;
138
+ }
139
+
140
+ /** Returns the content text delta for this token ("" while capturing). */
141
+ push(token: number): string {
142
+ if (this.mode !== "gemma-sentinel") {
143
+ this.#text += this.#decoder.push(token);
144
+ if (this.mode === "plain") {
145
+ const out = this.#text.slice(this.#sent);
146
+ this.#sent = this.#text.length;
147
+ return out;
148
+ }
149
+ return this.#textDelta();
150
+ }
151
+ if (this.#inTool) {
152
+ if (token === TOOL_CALL_END) {
153
+ this.#inTool = false;
154
+ this.toolSegments.push(this.#toolTokens);
155
+ this.#toolTokens = [];
156
+ } else {
157
+ this.#toolTokens.push(token);
158
+ }
159
+ return "";
160
+ }
161
+ // Reasoning channel: tokens between <|channel> and <channel|> are thought,
162
+ // captured to #reasoning (drained via takeReasoning), never content. An
163
+ // empty block (<|channel>thought\n<channel|>, emitted by larger Gemmas even
164
+ // with thinking off) yields only the "\n" as reasoning (mlx-lm parity) and
165
+ // leaks nothing into content.
166
+ if (this.#inChannel) {
167
+ if (token === CHANNEL_END) {
168
+ this.#inChannel = false;
169
+ this.#reasoning += this.#feedChannel(this.#channelDecoder.flush());
170
+ } else {
171
+ this.#reasoning += this.#feedChannel(this.#channelDecoder.push(token));
172
+ }
173
+ return "";
174
+ }
175
+ if (token === CHANNEL_START) {
176
+ this.#inChannel = true;
177
+ this.#channelNameDone = false;
178
+ this.#channelNamePending = "";
179
+ return "";
180
+ }
181
+ if (token === TOOL_CALL_START) {
182
+ this.#inTool = true;
183
+ return "";
184
+ }
185
+ return this.#decoder.push(token);
186
+ }
187
+
188
+ flush(): string {
189
+ if (this.mode !== "gemma-sentinel") {
190
+ this.#text += this.#decoder.flush();
191
+ if (this.mode === "buffered-text") {
192
+ const calls = this.toolCalls();
193
+ if (calls.length && !this.#textToolParseFailed && this.#frozen >= 0) {
194
+ // markup parsed into tool_calls — emit any prose still held
195
+ // before it; the markup itself never reaches content
196
+ const out = this.#text.slice(this.#sent, this.#frozen);
197
+ this.#sent = this.#text.length;
198
+ return out;
199
+ }
200
+ // no tool call (or parse fallback): release everything withheld
201
+ const out = this.#text.slice(this.#sent);
202
+ this.#sent = this.#text.length;
203
+ return out;
204
+ }
205
+ const out = this.#text.slice(this.#sent);
206
+ this.#sent = this.#text.length;
207
+ return out;
208
+ }
209
+ if (this.#inChannel) {
210
+ // truncated mid-reasoning (hit max_tokens); surface the partial thought
211
+ this.#reasoning += this.#feedChannel(this.#channelDecoder.flush());
212
+ this.#inChannel = false;
213
+ return "";
214
+ }
215
+ if (this.#inTool && this.#toolTokens.length) {
216
+ // truncated mid-tool-call (hit max_tokens); surface what we have
217
+ this.toolSegments.push(this.#toolTokens);
218
+ this.#toolTokens = [];
219
+ }
220
+ return this.#decoder.flush();
221
+ }
222
+
223
+ toolCalls(): OpenAIToolCall[] {
224
+ if (this.mode !== "gemma-sentinel") {
225
+ if (this.#textToolCalls) return this.#textToolCalls;
226
+ try {
227
+ this.#textToolCalls = parseGeneratedToolCalls(this.#text, this.tools ?? []).map((c) => ({
228
+ id: `call_${crypto.randomUUID().slice(0, 8)}`,
229
+ type: "function",
230
+ function: { name: c.name, arguments: JSON.stringify(c.arguments) },
231
+ }));
232
+ } catch {
233
+ this.#textToolParseFailed = true;
234
+ this.#textToolCalls = [];
235
+ this.onParseFailure?.();
236
+ }
237
+ return this.#textToolCalls;
238
+ }
239
+ const out: OpenAIToolCall[] = [];
240
+ for (const seg of this.toolSegments) {
241
+ const text = this.tokenizer.decode(seg, false); // keep <|"|> markers
242
+ for (const c of parseToolCalls(text)) {
243
+ out.push({
244
+ id: `call_${crypto.randomUUID().slice(0, 8)}`,
245
+ type: "function",
246
+ function: { name: c.name, arguments: JSON.stringify(c.arguments) },
247
+ });
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+ }
253
+
254
+ /** Decoded-text stop-sequence matcher with streaming hold-back. Matching
255
+ * on text (not token ids) catches sequences that span token boundaries
256
+ * or tokenize differently in context; current mlx-lm uses token-id
257
+ * state machines and misses those. Text that could be the start of a
258
+ * stop sequence is withheld until disambiguated, so SSE clients never
259
+ * see any part of the stop sequence itself. */
260
+ export class StopMatcher {
261
+ #pending = "";
262
+ stopped = false;
263
+
264
+ constructor(readonly sequences: string[]) {}
265
+
266
+ /** Feed a text delta; returns the prefix that is now safe to emit.
267
+ * After a match fires (`stopped`), text before the match is returned
268
+ * and everything from the match on is discarded. */
269
+ push(text: string): string {
270
+ if (this.stopped) return "";
271
+ if (this.sequences.length === 0) return text;
272
+ this.#pending += text;
273
+ // earliest full match wins
274
+ let cut = -1;
275
+ for (const seq of this.sequences) {
276
+ const i = this.#pending.indexOf(seq);
277
+ if (i !== -1 && (cut === -1 || i < cut)) cut = i;
278
+ }
279
+ if (cut !== -1) {
280
+ this.stopped = true;
281
+ const out = this.#pending.slice(0, cut);
282
+ this.#pending = "";
283
+ return out;
284
+ }
285
+ // hold back the longest tail that is a proper prefix of any sequence
286
+ let hold = 0;
287
+ for (const seq of this.sequences) {
288
+ const max = Math.min(seq.length - 1, this.#pending.length);
289
+ for (let k = max; k > hold; k--) {
290
+ if (this.#pending.endsWith(seq.slice(0, k))) {
291
+ hold = k;
292
+ break;
293
+ }
294
+ }
295
+ }
296
+ if (hold === 0) {
297
+ const out = this.#pending;
298
+ this.#pending = "";
299
+ return out;
300
+ }
301
+ const out = this.#pending.slice(0, -hold);
302
+ this.#pending = this.#pending.slice(-hold);
303
+ return out;
304
+ }
305
+
306
+ /** Generation ended without a match — release any held-back text. */
307
+ flush(): string {
308
+ const out = this.#pending;
309
+ this.#pending = "";
310
+ return out;
311
+ }
312
+ }
313
+
314
+ /** Split Qwen-style inline <think>...</think> markup into OpenAI reasoning
315
+ * deltas/content. This keeps raw tags out of normal chat text while giving
316
+ * pi (TUI + web) proper thinking_delta events. It is streaming-safe: partial
317
+ * tag prefixes are held until disambiguated. */
318
+ export class ThinkingTagSplitter {
319
+ #pending = "";
320
+ #inThinking: boolean;
321
+ reasoning = "";
322
+ content = "";
323
+
324
+ /** `startInThinking` seeds the parser INSIDE a <think> block. Needed for
325
+ * templates (Qwen3.5, MiniCPM5) that prime an OPEN `<think>` in the
326
+ * generation prompt when thinking is enabled: the model's output then
327
+ * starts mid-reasoning and emits only the closing `</think>`, never an
328
+ * opening tag. Without this seed the whole chain-of-thought leaks into
329
+ * `content` and the `reasoning` field stays empty. */
330
+ constructor(private readonly enabled: boolean, startInThinking = false) {
331
+ this.#inThinking = startInThinking;
332
+ }
333
+
334
+ #safePrefixUntilTag(tag: string): string {
335
+ const i = this.#pending.indexOf(tag);
336
+ if (i !== -1) return this.#pending.slice(0, i);
337
+ let hold = 0;
338
+ for (let k = Math.min(tag.length - 1, this.#pending.length); k > 0; k--) {
339
+ if (this.#pending.endsWith(tag.slice(0, k))) { hold = k; break; }
340
+ }
341
+ return this.#pending.slice(0, this.#pending.length - hold);
342
+ }
343
+
344
+ push(text: string): { content: string; reasoning: string } {
345
+ if (!this.enabled) {
346
+ this.content += text;
347
+ return { content: text, reasoning: "" };
348
+ }
349
+ this.#pending += text;
350
+ let content = "";
351
+ let reasoning = "";
352
+ while (this.#pending) {
353
+ const tag = this.#inThinking ? "</think>" : "<think>";
354
+ const i = this.#pending.indexOf(tag);
355
+ const emit = i === -1 ? this.#safePrefixUntilTag(tag) : this.#pending.slice(0, i);
356
+ if (!emit && i === -1) break;
357
+ if (emit) {
358
+ if (this.#inThinking) reasoning += emit;
359
+ else content += emit;
360
+ this.#pending = this.#pending.slice(emit.length);
361
+ }
362
+ if (i !== -1 && this.#pending.startsWith(tag)) {
363
+ this.#pending = this.#pending.slice(tag.length);
364
+ this.#inThinking = !this.#inThinking;
365
+ continue;
366
+ }
367
+ if (i === -1) break;
368
+ }
369
+ this.content += content;
370
+ this.reasoning += reasoning;
371
+ return { content, reasoning };
372
+ }
373
+
374
+ flush(): { content: string; reasoning: string } {
375
+ if (!this.enabled) return { content: "", reasoning: "" };
376
+ const out = this.#inThinking
377
+ ? { content: "", reasoning: this.#pending }
378
+ : { content: this.#pending, reasoning: "" };
379
+ this.#pending = "";
380
+ this.content += out.content;
381
+ this.reasoning += out.reasoning;
382
+ return out;
383
+ }
384
+ }
385
+
386
+ /** Incremental detokenizer: emits the longest stable decoded prefix.
387
+ *
388
+ * Byte parity with mlx-lm's streaming detokenizers (the drop-in contract is
389
+ * rendered BYTES, not just token ids). For BPE/ByteLevel tokenizers, two
390
+ * mlx-lm 0.31.3 BPEStreamingDetokenizer behaviors our full-sequence decode
391
+ * lacks (tokenizer_utils.py:195-226):
392
+ *
393
+ * 1. `trimsLeadingSpace` — mlx-lm drops ONE leading " " at the start of the
394
+ * generated sequence (`_maybe_trim_space`); trim it here. SPM decode
395
+ * already matches (see LoadedTokenizer.trimsLeadingSpace).
396
+ * 2. `bareSpaceTokenId` — add_token WITHHOLDS a single-char byte-32 token
397
+ * ("Ġ") in `_unflushed` ("For single spaces wait until the next token"),
398
+ * flushing it together with the NEXT token — and mlx_lm.server NEVER
399
+ * calls detokenizer.finalize() (zero hits in server.py 0.31.3), so a
400
+ * generation ENDING on bare-space token(s) silently drops their spaces
401
+ * from the served bytes. push() withholds those spaces; flush() drops a
402
+ * trailing bare-space run (the held text dies with the request, exactly
403
+ * like mlx-lm serve). Re-check both if upstream ever adds a finalize()
404
+ * call. LATENT HAZARD (deliberately not emulated): models with
405
+ * clean_up_tokenization_spaces=true get an ADDITIONAL mid-stream rule
406
+ * (`_space_matches`: held space dropped before "." "," "'s" …) — both
407
+ * current BPE targets have it false (MiniCPM5), so it never fires here.
408
+ *
409
+ * Exported for unit tests (serve-detok mlx-lm byte parity). */
410
+ export class StreamDecoder {
411
+ static readonly #WINDOW_TOKENS = 32;
412
+ #ids: number[] = [];
413
+ #emitted = "";
414
+ #warnedRevision = false;
415
+ /** Token index where the bounded decode suffix begins. */
416
+ #windowStart = 0;
417
+ /** Exact decoded text before #windowStart, established by suffix matching. */
418
+ #windowPrefix = "";
419
+ readonly #trimLeadingSpace: boolean;
420
+ readonly #bareSpaceId: number | undefined;
421
+
422
+ constructor(
423
+ readonly tokenizer: LoadedTokenizer,
424
+ readonly skipSpecialTokens = true,
425
+ ) {
426
+ this.#trimLeadingSpace = tokenizer.trimsLeadingSpace === true;
427
+ this.#bareSpaceId = tokenizer.bareSpaceTokenId;
428
+ }
429
+
430
+ #decode(ids: number[]): string {
431
+ const full = this.#decodeRaw(ids);
432
+ return this.#trimLeadingSpace && full.startsWith(" ") ? full.slice(1) : full;
433
+ }
434
+
435
+ #decodeRaw(ids: number[]): string {
436
+ return this.tokenizer.decode(ids, this.skipSpecialTokens);
437
+ }
438
+
439
+ /** Decode only a bounded suffix once an exact text anchor can be proven.
440
+ *
441
+ * At each rebase, decode a candidate suffix independently and accept it only
442
+ * when it is literally the suffix of the exact text decoded so far. This
443
+ * preserves ByteLevel/SPM boundary behavior while reducing steady-state
444
+ * decode work from the whole generation to 32–64 tokens. If no suffix can be
445
+ * anchored (an unusual/global decoder), leave #windowStart at zero and keep
446
+ * the full-history correctness path. */
447
+ #decodeIncremental(): string {
448
+ const full = this.#windowStart === 0
449
+ ? this.#decode(this.#ids)
450
+ : this.#windowPrefix + this.#decodeRaw(this.#ids.slice(this.#windowStart));
451
+
452
+ if (this.#ids.length - this.#windowStart < StreamDecoder.#WINDOW_TOKENS * 2)
453
+ return full;
454
+
455
+ const preferred = this.#ids.length - StreamDecoder.#WINDOW_TOKENS;
456
+ const oldest = Math.max(this.#windowStart, preferred - StreamDecoder.#WINDOW_TOKENS);
457
+ for (let start = preferred; start >= oldest; start--) {
458
+ const tail = this.#decodeRaw(this.#ids.slice(start));
459
+ if ((tail.length > 0 || full.length === 0) && full.endsWith(tail)) {
460
+ this.#windowStart = start;
461
+ this.#windowPrefix = full.slice(0, full.length - tail.length);
462
+ break;
463
+ }
464
+ }
465
+ return full;
466
+ }
467
+
468
+ push(token: number): string {
469
+ this.#ids.push(token);
470
+ // Bare-space hold-back (mlx-lm add_token keeps "Ġ" in _unflushed): don't
471
+ // advance #emitted; the held space(s) flush as part of the next
472
+ // non-bare-space token's delta — consecutive bare spaces accumulate.
473
+ if (token === this.#bareSpaceId) return "";
474
+ const full = this.#decodeIncremental();
475
+ // hold back a trailing replacement char (partial multi-byte sequence)
476
+ const stable = full.endsWith("�") ? full.slice(0, -1) : full;
477
+ if (!stable.startsWith(this.#emitted)) {
478
+ // The decoder revised already-streamed text (cleanup rules — never
479
+ // fires for the shipped tokenizers, which all have
480
+ // clean_up_tokenization_spaces=false; see the LATENT HAZARD note in
481
+ // the class doc). An SSE client cannot un-receive bytes, so the old
482
+ // "re-emit from scratch" answer DUPLICATED the whole stream
483
+ // (2026-07-07 review). Truncate-safe resync instead: emit only the
484
+ // length-extension and keep the emitted watermark monotone — byte
485
+ // drift is confined to the revised span, and the once-per-stream
486
+ // warning makes the tokenizer that needs real _space_matches
487
+ // emulation loud instead of silently wrong.
488
+ if (!this.#warnedRevision) {
489
+ this.#warnedRevision = true;
490
+ console.warn(
491
+ "[detok] decoder revised already-streamed text (clean_up_tokenization_spaces?) — " +
492
+ "resyncing without re-emit; add mlx-lm's cleanup-rule emulation for this tokenizer",
493
+ );
494
+ }
495
+ if (stable.length <= this.#emitted.length) return "";
496
+ const out = stable.slice(this.#emitted.length);
497
+ this.#emitted = stable;
498
+ return out;
499
+ }
500
+ const delta = stable.slice(this.#emitted.length);
501
+ this.#emitted = stable;
502
+ return delta;
503
+ }
504
+
505
+ flush(): string {
506
+ // mlx_lm.server never finalize()s: a trailing bare-space run stays
507
+ // withheld forever, so its text is dropped from the served bytes.
508
+ let ids = this.#ids;
509
+ if (this.#bareSpaceId !== undefined) {
510
+ let n = ids.length;
511
+ while (n > 0 && ids[n - 1] === this.#bareSpaceId) n--;
512
+ if (n < ids.length) ids = ids.slice(0, n);
513
+ }
514
+ const full = this.#decode(ids);
515
+ const delta = full.slice(this.#emitted.length);
516
+ this.#emitted = full;
517
+ return delta;
518
+ }
519
+ }