mlx-bun 0.0.12 → 0.2.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 (226) hide show
  1. package/README.md +40 -24
  2. package/bin/mlx-bun.mjs +1 -1
  3. package/docs/{design → archive}/hlg-sampling.md +3 -3
  4. package/docs/{investigations → archive/investigations}/hlg-sampling-investigation.md +1 -1
  5. package/docs/archive/investigations/pareto-specialized-runtime-findings.md +92 -0
  6. package/docs/{planning → archive/planning}/chat-ui-vision.md +1 -1
  7. package/docs/archive/planning/release-notes-v0.0.13.md +77 -0
  8. package/docs/{planning → archive/planning}/release-notes-v0.0.9.md +1 -1
  9. package/docs/{planning → archive/planning}/website-readme-pass-plan.md +1 -1
  10. package/docs/design/colibri-glm52-port.md +8 -1
  11. package/docs/design/compat-cli-surface-design.md +1 -1
  12. package/docs/design/decode-speed-program.md +1 -1
  13. package/docs/design/docs-reorg-plan.md +1 -1
  14. package/docs/design/dspark-serving-program.md +1 -1
  15. package/docs/design/dspark-speculative-decoding.md +53 -3
  16. package/docs/design/grammar-spec-batching-integration.md +1 -1
  17. package/docs/design/mlx-lm-serving-execution-seam.md +81 -0
  18. package/docs/design/orpo-dynamic-lambda.md +1 -1
  19. package/docs/design/orpo-future-enhancements.md +1 -1
  20. package/docs/design/orpo-training.md +2 -2
  21. package/docs/design/parity-tier-dag.md +1 -1
  22. package/docs/design/repo-cleanup-plan.md +46 -4
  23. package/docs/design/segmented-backward-training.md +2 -2
  24. package/docs/design/ssd-kv-cold-tier.md +127 -0
  25. package/docs/design/turboquant-weights.md +467 -0
  26. package/docs/design/w4a16-compute-precision-spike.md +1 -1
  27. package/docs/design/web-chat-redesign.md +5 -5
  28. package/docs/planning/chunk-task-roadmap.md +1 -1
  29. package/docs/planning/curve-sampler-research-plan.md +4 -4
  30. package/docs/planning/memory-docs-and-dag-plan.md +1 -1
  31. package/docs/planning/release-notes-v0.2.0.md +49 -0
  32. package/docs/planning/video-series.md +421 -0
  33. package/docs/reference/cli.md +15 -4
  34. package/docs/reference/distribution.md +1 -0
  35. package/docs/reference/features-matrix.md +3 -1
  36. package/docs/reference/library-api.md +41 -1
  37. package/docs/reference/models.md +33 -0
  38. package/docs/reference/server-api.md +86 -19
  39. package/docs/reference/server-config.md +57 -33
  40. package/package.json +2 -2
  41. package/src/anthropic.ts +92 -3
  42. package/src/chat-template.ts +34 -1
  43. package/src/cli.ts +131 -36
  44. package/src/config.ts +10 -0
  45. package/src/eval/kl.ts +27 -20
  46. package/src/eval/runner.ts +23 -4
  47. package/src/eval/tasks/bfcl.ts +2 -1
  48. package/src/eval/tasks/gsm8k.ts +2 -1
  49. package/src/eval/tasks/hashhop.ts +2 -1
  50. package/src/eval/tasks/humaneval.ts +5 -4
  51. package/src/eval/tasks/ifeval.ts +2 -1
  52. package/src/eval/tasks/mmlu.ts +2 -1
  53. package/src/expert-io.ts +3 -1
  54. package/src/expert-offload.ts +3 -1
  55. package/src/expert-trace.ts +3 -1
  56. package/src/fit.ts +24 -43
  57. package/src/flags.ts +7 -9
  58. package/src/generate.ts +107 -92
  59. package/src/grammar.ts +2 -1
  60. package/src/harness-pi.ts +1 -1
  61. package/src/index.ts +20 -0
  62. package/src/jobs/job-entry.ts +3 -2
  63. package/src/kv-scheme.ts +199 -0
  64. package/src/kv-store.ts +370 -205
  65. package/src/media-fetch.ts +19 -3
  66. package/src/memory/model.ts +2 -1
  67. package/src/memory/vault.ts +2 -1
  68. package/src/memory-plan.ts +18 -0
  69. package/src/mlx/ffi.ts +8 -11
  70. package/src/mlx/ops.ts +31 -6
  71. package/src/model/batched-mask.ts +1 -1
  72. package/src/model/batched-quant.ts +1 -1
  73. package/src/model/batched-rotating-quant.ts +43 -97
  74. package/src/model/batched-rotating-state.ts +73 -0
  75. package/src/model/batched-rotating.ts +56 -105
  76. package/src/model/batched-row-storage.ts +127 -0
  77. package/src/model/compiled-decode.ts +3 -2
  78. package/src/model/factory.ts +44 -49
  79. package/src/model/gemma4-base.ts +163 -10
  80. package/src/model/generated/gemma4-12b.ts +50 -146
  81. package/src/model/generated/gemma4-26b.ts +32 -92
  82. package/src/model/generated/gemma4-e4b.ts +26 -74
  83. package/src/model/glm52-cache.ts +19 -9
  84. package/src/model/glm52-memory.ts +28 -1
  85. package/src/model/minicpm5.ts +2 -1
  86. package/src/model/profile.ts +433 -0
  87. package/src/model/qwen3-delta.ts +98 -2
  88. package/src/model/qwen3-mrope.ts +252 -0
  89. package/src/model/qwen3_5.ts +217 -25
  90. package/src/native/frame_extract.swift +140 -0
  91. package/src/native-pack.ts +13 -6
  92. package/src/pi-web.ts +6 -5
  93. package/src/prompt-cache.ts +2 -2
  94. package/src/quantize/atomic-output.ts +38 -0
  95. package/src/quantize/calibration.ts +2 -1
  96. package/src/quantize/config-writer.ts +6 -0
  97. package/src/quantize/index.ts +28 -1
  98. package/src/quantize/job.ts +11 -0
  99. package/src/quantize/quantizer.ts +265 -100
  100. package/src/quantize/rotate.ts +603 -0
  101. package/src/quantize/safetensors-writer.ts +79 -1
  102. package/src/quantize/sensitivity.ts +41 -59
  103. package/src/quantize/weight-transform.ts +292 -0
  104. package/src/responses.ts +102 -2
  105. package/src/runtime-config.ts +67 -0
  106. package/src/sampler.ts +272 -0
  107. package/src/serve/admin-routes.ts +184 -0
  108. package/src/serve/aux-routes.ts +132 -0
  109. package/src/serve/batch-scheduler.ts +200 -87
  110. package/src/serve/completion-executor.ts +346 -0
  111. package/src/serve/completion-sink.ts +157 -0
  112. package/src/serve/discovery-routes.ts +205 -0
  113. package/src/serve/generation-gateway.ts +116 -109
  114. package/src/serve/kv-budget.ts +14 -0
  115. package/src/serve/lab-routes.ts +338 -0
  116. package/src/serve/model-admin-routes.ts +153 -0
  117. package/src/serve/prompt-response-trace.ts +147 -0
  118. package/src/serve/request-plan.ts +158 -0
  119. package/src/serve/static-routes.ts +101 -0
  120. package/src/server.ts +768 -1367
  121. package/src/spec/dflash-source.ts +2 -1
  122. package/src/spec/dspark/module-dflash.ts +1 -1
  123. package/src/spec/glm52-mtp-source.ts +8 -0
  124. package/src/spec/qwen-mtp-source.ts +431 -0
  125. package/src/spec/serve-loop.ts +36 -68
  126. package/src/ssd-cache.ts +17 -14
  127. package/src/ssd-durability.ts +195 -0
  128. package/src/train/flash-cce.ts +12 -11
  129. package/src/train/forward.ts +32 -1
  130. package/src/train/loss.ts +2 -1
  131. package/src/train/segmented.ts +4 -3
  132. package/src/train/trainer.ts +3 -2
  133. package/src/vision/preprocess.ts +1 -1
  134. package/src/vision/prompt.ts +47 -1
  135. package/src/vision/qwen3vl-preprocess.ts +409 -0
  136. package/src/vision/qwen3vl-prompt.ts +196 -0
  137. package/src/vision/qwen3vl-tower.ts +513 -0
  138. package/src/vision/video-frames.ts +148 -0
  139. package/src/web/app.js +10 -3
  140. package/src/web/src/chat.ts +12 -5
  141. package/src/weights.ts +81 -26
  142. /package/docs/{investigations → archive/investigations}/chunk-finetune-experiment.md +0 -0
  143. /package/docs/{investigations → archive/investigations}/colibri-oracle-pin.md +0 -0
  144. /package/docs/{investigations → archive/investigations}/curve-bisector-routing.md +0 -0
  145. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-route.json +0 -0
  146. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-trace.json +0 -0
  147. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.json +0 -0
  148. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.svg +0 -0
  149. /package/docs/{investigations → archive/investigations}/curve-terrain.html +0 -0
  150. /package/docs/{investigations → archive/investigations}/decode-roofline-lookagain.md +0 -0
  151. /package/docs/{investigations → archive/investigations}/dspark-handoff.md +0 -0
  152. /package/docs/{investigations → archive/investigations}/expert-offload-single-user-moe.md +0 -0
  153. /package/docs/{investigations → archive/investigations}/hlg-ab.html +0 -0
  154. /package/docs/{investigations → archive/investigations}/hlg-explorer.html +0 -0
  155. /package/docs/{investigations → archive/investigations}/hlg-figs/cliff.svg +0 -0
  156. /package/docs/{investigations → archive/investigations}/hlg-figs/cross.svg +0 -0
  157. /package/docs/{investigations → archive/investigations}/hlg-figs/edges.svg +0 -0
  158. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-grids.svg +0 -0
  159. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-peaks.svg +0 -0
  160. /package/docs/{investigations → archive/investigations}/hlg-figs/lw-curve.svg +0 -0
  161. /package/docs/{investigations → archive/investigations}/hlg-figs/terrain.svg +0 -0
  162. /package/docs/{investigations → archive/investigations}/hlg-report.html +0 -0
  163. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-ab.json +0 -0
  164. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind-prompts.md +0 -0
  165. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind.json +0 -0
  166. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cliff.json +0 -0
  167. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cross.json +0 -0
  168. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-edge.json +0 -0
  169. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-eval1.json +0 -0
  170. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-frontier.json +0 -0
  171. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid.json +0 -0
  172. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid2.json +0 -0
  173. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-lw-coarse.json +0 -0
  174. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-map.json +0 -0
  175. /package/docs/{investigations → archive/investigations}/hlg-runs/run0-original.log +0 -0
  176. /package/docs/{investigations → archive/investigations}/hlg-runs/runA-recommended.log +0 -0
  177. /package/docs/{investigations → archive/investigations}/hlg-runs/runAA-cliff-cpm5.log +0 -0
  178. /package/docs/{investigations → archive/investigations}/hlg-runs/runAB-grid2-e4b.log +0 -0
  179. /package/docs/{investigations → archive/investigations}/hlg-runs/runAC-peak-e4b-partial.log +0 -0
  180. /package/docs/{investigations → archive/investigations}/hlg-runs/runAD-ab-samples.log +0 -0
  181. /package/docs/{investigations → archive/investigations}/hlg-runs/runAE-edge-e4b.log +0 -0
  182. /package/docs/{investigations → archive/investigations}/hlg-runs/runAF-logit-calibration.log +0 -0
  183. /package/docs/{investigations → archive/investigations}/hlg-runs/runAG-eval-config2.log +0 -0
  184. /package/docs/{investigations → archive/investigations}/hlg-runs/runB-gain-variants.log +0 -0
  185. /package/docs/{investigations → archive/investigations}/hlg-runs/runC-pivots.log +0 -0
  186. /package/docs/{investigations → archive/investigations}/hlg-runs/runD-oetf.log +0 -0
  187. /package/docs/{investigations → archive/investigations}/hlg-runs/runE-eotf.log +0 -0
  188. /package/docs/{investigations → archive/investigations}/hlg-runs/runF-shaper-working.log +0 -0
  189. /package/docs/{investigations → archive/investigations}/hlg-runs/runG-shaper-sweep.log +0 -0
  190. /package/docs/{investigations → archive/investigations}/hlg-runs/runH-wsweep.log +0 -0
  191. /package/docs/{investigations → archive/investigations}/hlg-runs/runI-asweep.log +0 -0
  192. /package/docs/{investigations → archive/investigations}/hlg-runs/runJ-knobmap.log +0 -0
  193. /package/docs/{investigations → archive/investigations}/hlg-runs/runK-frontier.log +0 -0
  194. /package/docs/{investigations → archive/investigations}/hlg-runs/runL-widemap.log +0 -0
  195. /package/docs/{investigations → archive/investigations}/hlg-runs/runM-frontier-emb.log +0 -0
  196. /package/docs/{investigations → archive/investigations}/hlg-runs/runN-verify.log +0 -0
  197. /package/docs/{investigations → archive/investigations}/hlg-runs/runO-frontier-seed5000.log +0 -0
  198. /package/docs/{investigations → archive/investigations}/hlg-runs/runP-grid-AxSm.log +0 -0
  199. /package/docs/{investigations → archive/investigations}/hlg-runs/runQ-grid-tgW.log +0 -0
  200. /package/docs/{investigations → archive/investigations}/hlg-runs/runR-blind-transcript.log +0 -0
  201. /package/docs/{investigations → archive/investigations}/hlg-runs/runS-cross-coherence.log +0 -0
  202. /package/docs/{investigations → archive/investigations}/hlg-runs/runT-cross-cpm5.log +0 -0
  203. /package/docs/{investigations → archive/investigations}/hlg-runs/runU-cross-12b.log +0 -0
  204. /package/docs/{investigations → archive/investigations}/hlg-runs/runV-lw-e4b.log +0 -0
  205. /package/docs/{investigations → archive/investigations}/hlg-runs/runW-lw-12b.log +0 -0
  206. /package/docs/{investigations → archive/investigations}/hlg-runs/runX-lw-cpm5.log +0 -0
  207. /package/docs/{investigations → archive/investigations}/hlg-runs/runY-cliff-e4b.log +0 -0
  208. /package/docs/{investigations → archive/investigations}/hlg-runs/runZ-cliff-12b.log +0 -0
  209. /package/docs/{investigations → archive/investigations}/indexshare-performance-spike.md +0 -0
  210. /package/docs/{investigations → archive/investigations}/kernel-perf-review-2026-07.md +0 -0
  211. /package/docs/{investigations → archive/investigations}/lab-build-journal.md +0 -0
  212. /package/docs/{investigations → archive/investigations}/openwiki-evaluation.md +0 -0
  213. /package/docs/{investigations → archive/investigations}/orpo-base-uf-experiment-and-directions.md +0 -0
  214. /package/docs/{investigations → archive/investigations}/orpo-flash-cce-pin-leak.md +0 -0
  215. /package/docs/{investigations → archive/investigations}/orpo-uf-testing-handoff.md +0 -0
  216. /package/docs/{investigations → archive/investigations}/pi-builtin-investigation.md +0 -0
  217. /package/docs/{investigations → archive/investigations}/segmented-backward-handoff.md +0 -0
  218. /package/docs/{investigations → archive/investigations}/starter-model-port-handoff.md +0 -0
  219. /package/docs/{investigations → archive/investigations}/steel-flash-cce-handoff.md +0 -0
  220. /package/docs/{investigations → archive/investigations}/trainer-validation-experiment.md +0 -0
  221. /package/docs/{planning → archive/planning}/chat-ui-vision-memory-first.md +0 -0
  222. /package/docs/{planning → archive/planning}/journal.md +0 -0
  223. /package/docs/{planning → archive/planning}/release-notes-v0.0.10.md +0 -0
  224. /package/docs/{planning → archive/planning}/release-notes-v0.0.11.md +0 -0
  225. /package/docs/{planning → archive/planning}/release-notes-v0.0.12.md +0 -0
  226. /package/docs/{planning → archive/planning}/web-ui-pass-plan.md +0 -0
package/src/server.ts CHANGED
@@ -38,8 +38,7 @@ import appJs from "./web/app.js" with { type: "text" };
38
38
  import manifestWebmanifest from "./web/manifest.webmanifest" with { type: "text" };
39
39
  import iconSvg from "./web/icon.svg" with { type: "text" };
40
40
  import swJs from "./web/sw.js" with { type: "text" };
41
- import pkgJson from "../package.json" with { type: "json" };
42
- import { readFileSync } from "node:fs";
41
+ import { existsSync, readFileSync } from "node:fs";
43
42
  const APP_PAGE = appHtml as unknown as string;
44
43
  const HLJS_JS = hljsJs as unknown as string;
45
44
  const HLJS_CSS = hljsCss as unknown as string;
@@ -47,7 +46,6 @@ const APP_JS = appJs as unknown as string;
47
46
  const MANIFEST_WEBMANIFEST = manifestWebmanifest as unknown as string;
48
47
  const ICON_SVG = iconSvg as unknown as string;
49
48
  const SW_JS = swJs as unknown as string;
50
- const pkgVersion = (pkgJson as { version: string }).version;
51
49
  import { loadModelConfig, type KvQuantSpec, type ModelConfig, type TurboQuantScheme } from "./config";
52
50
  import { Weights } from "./weights";
53
51
  import { Gemma4Model } from "./model/gemma4";
@@ -67,7 +65,8 @@ import {
67
65
  GLM52_G5_MEASURED_WARM_DECODE_TPS,
68
66
  type Glm52MemoryPlan,
69
67
  } from "./model/glm52-memory";
70
- import { isGlm52Config, isMiniCPM5Config } from "./model/support";
68
+ import { isMiniCPM5Config } from "./model/support";
69
+ import { resolveModelProfile, type ResolvedModelProfile } from "./model/profile";
71
70
  import { Glm52NativeMtpProvider } from "./spec/glm52-mtp-source";
72
71
  import {
73
72
  generate,
@@ -77,6 +76,8 @@ import {
77
76
  withModelWiredLimit,
78
77
  } from "./generate";
79
78
  import { cloneKvCaches, SpillQueue } from "./kv-store";
79
+ import { resolveKvScheme } from "./kv-scheme";
80
+ import { runtimeValue } from "./runtime-config";
80
81
  import { TURBOQUANT_HEAD_DIMS } from "./mlx/turboquant-tables";
81
82
  import {
82
83
  compileGrammarRequest, grammarEnabled, type GrammarRequest,
@@ -85,7 +86,39 @@ import type { HlgConfig } from "./sampler";
85
86
  import { isMonotone, CURVE_UMIN, type CurveParams } from "./curve-sampler";
86
87
  const CURVE_PAGE = curveDesignerHtml as unknown as string;
87
88
  import { GenerationGateway } from "./serve/generation-gateway";
88
- import { recordLane, type Lane } from "./serve/lane-registry";
89
+ import {
90
+ CompletionExecutor,
91
+ CompletionRejected,
92
+ prepareCompletion,
93
+ type CompletionSummary,
94
+ type PreparedCompletion,
95
+ } from "./serve/completion-executor";
96
+ import {
97
+ createPromptResponseTrace,
98
+ type PromptResponseTrace,
99
+ } from "./serve/prompt-response-trace";
100
+ import { handleAdminRoute } from "./serve/admin-routes";
101
+ import { handleAuxiliaryRoute } from "./serve/aux-routes";
102
+ import {
103
+ createTimedFlowControl,
104
+ type CompletionEvent,
105
+ type CompletionStreamProtocol,
106
+ } from "./serve/completion-sink";
107
+ import { createDiscoveryRoutes } from "./serve/discovery-routes";
108
+ import { handleLabRoute } from "./serve/lab-routes";
109
+ import { handleModelAdminRoute } from "./serve/model-admin-routes";
110
+ import { RequestOwnership } from "./serve/request-plan";
111
+ 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
+ };
89
122
  import {
90
123
  ChatTemplate, type ChatMessage, type ToolDefinition,
91
124
  } from "./chat-template";
@@ -96,24 +129,32 @@ import {
96
129
  } from "./tool-call";
97
130
  import { PromptCache, cacheBytes } from "./prompt-cache";
98
131
  import { SsdCacheStore } from "./ssd-cache";
132
+ import {
133
+ SsdDurabilityCoordinator,
134
+ type DurabilityFlushResult,
135
+ type DurabilitySnapshotStats,
136
+ } from "./ssd-durability";
99
137
  import { configFingerprint } from "./model/fingerprint";
100
138
  import {
101
- anthropicToChatBody, chatJsonToAnthropic, translateOpenAiSse,
139
+ anthropicToChatBody, chatJsonToAnthropic, createAnthropicStreamProtocol,
102
140
  type AnthropicRequest,
103
141
  } from "./anthropic";
104
142
  import {
105
143
  ResponseStore, chatJsonToResponses, outputItemsToInputItems,
106
- responsesToChatBody, translateOpenAiSseToResponses,
144
+ responsesToChatBody, createResponsesStreamProtocol,
107
145
  type ResponsesRequest,
108
146
  } from "./responses";
109
- import { AdapterManager, listAvailableAdapters } from "./lora";
110
- import { embedMany, isEmbeddingModel } from "./embed";
147
+ import { AdapterManager } from "./lora";
111
148
  import { fit } from "./fit";
112
149
  import { setMemoryLimit } from "./mlx/ffi";
113
150
  import { VisionTower } from "./vision/embedder";
114
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";
115
156
  import {
116
- buildMultimodalPrompt, buildVisionPrompt, extractAudio, extractImages,
157
+ buildMultimodalPrompt, buildVisionPrompt, extractAudio, extractImages, extractVideos,
117
158
  type AudioTokenIds, type VisionTokenIds, type VisionEncoder,
118
159
  } from "./vision/prompt";
119
160
  import { AudioTower, parseAudioConfig } from "./audio/conformer";
@@ -130,12 +171,10 @@ export interface ServerOptions {
130
171
  * evict; a request over the budget alone is rejected. Unset = no
131
172
  * aggregate cap (per-request admission via memoryBudget still applies). */
132
173
  kvBudgetBytes?: number;
133
- /** KV quantization override. When unset: apply ctx.kvConfig (mixed
134
- * per-layer) for serial serving, but bf16 under `--batch N` (the batched
135
- * engine is bf16-only — a mode switch, see `batch` below). "config" forces
136
- * the model's kv_config even under batching (those requests then route to
137
- * the serial path); "off" forces bf16; a number forces uniform bits
138
- * (group size 64, start 0) ignoring the config file. */
174
+ /** KV quantization override. Unset/"off" is bf16. "config" applies the
175
+ * model's declared mixed-precision kv_config; supported per-layer schemes
176
+ * compose with continuous scheduling. A number forces uniform bits
177
+ * (group size 64, start 0) and uses the preserved serial executor. */
139
178
  kvQuant?: "off" | "config" | number;
140
179
  /** TurboQuant scheme (docs/design/turboquant-kv.md): a separate axis from
141
180
  * kvQuant above, mutually exclusive with it (`--kv-quant turbo[:k<bits>v
@@ -191,12 +230,9 @@ export interface ServerOptions {
191
230
  * mlx_lm.server's flag; its default there is 512 — ours stays 65,536 so
192
231
  * thinking traces never truncate. `--max-tokens 512` = mlx-lm behavior. */
193
232
  defaultMaxTokens?: number;
194
- /** Max concurrent requests batched through the mlx-lm-parity engine
195
- * (`--batch N`). Default 8 (continuous batching, B floats 1..N,
196
- * bit-parity with mlx-lm B=N); `--batch 1` pins the serialized
197
- * single-queue path — a mode switch, not a load-dependent
198
- * fallback. See docs/design/parallel-slots.md. NOTE: the batched executor
199
- * is mid-build; until it lands, >1 warns and runs serially. */
233
+ /** Continuous-scheduler concurrency cap (`--batch N`). Default 8; the
234
+ * scheduler specializes active B=1 and B=N with mlx-lm parity at the same
235
+ * composition. `--batch 1` pins the preserved strict serial executor. */
200
236
  batch?: number;
201
237
  /** HLG tone-curve sampling default (set via --hlg-sampling on + sub-knobs).
202
238
  * A per-request `hlg` object overrides it field-by-field. Off when unset. */
@@ -223,8 +259,82 @@ export interface ServerOptions {
223
259
  ssdCacheVerify?: boolean;
224
260
  }
225
261
 
262
+ interface ServerLifecycle {
263
+ flush: () => Promise<DurabilityFlushResult>;
264
+ stats: () => DurabilitySnapshotStats;
265
+ stopTimers: () => void;
266
+ }
267
+
268
+ export interface ServerShutdownResult {
269
+ stopped: boolean;
270
+ timedOut: boolean;
271
+ durability: DurabilityFlushResult;
272
+ }
273
+
274
+ const serverLifecycles = new WeakMap<Server<unknown>, ServerLifecycle>();
275
+
276
+ function emptyDurabilityResult(): DurabilityFlushResult {
277
+ return {
278
+ durable: true,
279
+ flushedSnapshots: 0,
280
+ missingSnapshots: 0,
281
+ pendingSnapshots: 0,
282
+ pendingSpills: 0,
283
+ pendingSpillBytes: 0,
284
+ droppedSpills: 0,
285
+ failedSpills: 0,
286
+ elapsedMs: 0,
287
+ };
288
+ }
289
+
290
+ /** Flush prompt-cache snapshots without stopping the HTTP server. */
291
+ export function flushServerCacheDurability(
292
+ server: Server<unknown>,
293
+ ): Promise<DurabilityFlushResult> {
294
+ return serverLifecycles.get(server)?.flush() ?? Promise.resolve(emptyDurabilityResult());
295
+ }
296
+
297
+ /** Stop admission, flush cache durability, then finish the Bun server. */
298
+ export async function shutdownServer(
299
+ server: Server<unknown>, timeoutMs = 120_000,
300
+ ): Promise<ServerShutdownResult> {
301
+ const lifecycle = serverLifecycles.get(server);
302
+ lifecycle?.stopTimers();
303
+ const stopped = Promise.resolve(server.stop(false));
304
+ const work = (async (): Promise<ServerShutdownResult> => {
305
+ await stopped;
306
+ const durability = lifecycle ? await lifecycle.flush() : emptyDurabilityResult();
307
+ return { stopped: true, timedOut: false, durability };
308
+ })();
309
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
310
+ const timeout = new Promise<ServerShutdownResult>((resolve) => {
311
+ timeoutHandle = setTimeout(() => {
312
+ const stats = lifecycle?.stats();
313
+ resolve({
314
+ stopped: false,
315
+ timedOut: true,
316
+ durability: {
317
+ ...emptyDurabilityResult(),
318
+ durable: false,
319
+ ...(stats ?? {}),
320
+ elapsedMs: timeoutMs,
321
+ },
322
+ });
323
+ }, timeoutMs);
324
+ timeoutHandle.unref?.();
325
+ });
326
+ try {
327
+ return await Promise.race([work, timeout]);
328
+ } finally {
329
+ if (timeoutHandle) clearTimeout(timeoutHandle);
330
+ }
331
+ }
332
+
226
333
  export interface ServerContext {
227
334
  model: RuntimeModel;
335
+ /** Declared external artifact/family profile that selected model
336
+ * construction. Request-level methods are resolved separately. */
337
+ profile: ResolvedModelProfile;
228
338
  tokenizer: LoadedTokenizer;
229
339
  template: ChatTemplate;
230
340
  modelId: string;
@@ -293,7 +403,7 @@ export interface GenSamplingDefaults {
293
403
  repetitionPenalty?: number;
294
404
  }
295
405
 
296
- export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram";
406
+ export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram" | "mtp";
297
407
 
298
408
  /** Detect the draft artifact's kind so the right provider is loaded. All
299
409
  * providers share ONE serve loop (src/spec/serve-loop.ts). Exported for the
@@ -311,6 +421,11 @@ export async function detectDraftKind(dir: string): Promise<DraftKind> {
311
421
  // dspark.json, plain HF config stamped Gemma4DSparkModel.
312
422
  if (cfg.architectures?.[0] === "Gemma4DSparkModel") return "deepspec";
313
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";
314
429
  } catch {
315
430
  // no/unreadable config → fall through to a full second model
316
431
  }
@@ -339,8 +454,27 @@ export async function loadContext(
339
454
  opts: LoadContextOptions = {},
340
455
  ): Promise<ServerContext> {
341
456
  const config = await loadModelConfig(modelDir);
342
- const glm = isGlm52Config(config);
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
+ }
343
474
  const externalDraft = opts.draftModelDir !== undefined || opts.draftKind !== undefined;
475
+ const resolvedDraftKind = opts.draftModelDir
476
+ ? opts.draftKind ?? await detectDraftKind(opts.draftModelDir)
477
+ : opts.draftKind;
344
478
  if (glm && externalDraft && opts.glm?.enableMtp === true)
345
479
  throw new Error("native GLM MTP and --draft-model/--draft-kind are mutually exclusive");
346
480
  let weights: Weights | null = null;
@@ -377,9 +511,7 @@ export async function loadContext(
377
511
  `${(opts.memoryBudgetBytes / 1e9).toFixed(2)} GB`,
378
512
  );
379
513
  }
380
- // generated-specialization dispatch by config fingerprint (Phase C);
381
- // unmatched configs run the monolith — slow, never broken
382
- if (!glm) model = createModel(weights!, config);
514
+ if (!glm) model = createModel(weights!, config, profile);
383
515
  const tokenizer = await loadTokenizer(modelDir);
384
516
  // Generation must stop on the tokenizer's eos_token — the chat turn
385
517
  // terminator (e.g. Qwen <|im_end|> = 248046). Some configs (Qwen3.5-4B)
@@ -412,7 +544,7 @@ export async function loadContext(
412
544
  };
413
545
  } else if (opts.draftModelDir) {
414
546
  const dir = opts.draftModelDir;
415
- const kind = opts.draftKind ?? (await detectDraftKind(dir));
547
+ const kind = resolvedDraftKind!;
416
548
  let provider: import("./spec/source").DraftProvider;
417
549
  let numDraftTokens = Math.max(1, opts.numDraftTokens ?? 3);
418
550
  if (kind === "dspark") {
@@ -432,6 +564,17 @@ export async function loadContext(
432
564
  } else if (kind === "assistant") {
433
565
  const { AssistantProvider } = await import("./spec/assistant-source");
434
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);
435
578
  } else {
436
579
  const { TwoModelProvider } = await import("./spec/two-model");
437
580
  provider = await TwoModelProvider.load(dir, config.text.vocabSize);
@@ -512,6 +655,7 @@ export async function loadContext(
512
655
  return {
513
656
  draft,
514
657
  model,
658
+ profile,
515
659
  glmMemoryPlan,
516
660
  adapters: new AdapterManager(model),
517
661
  kvConfig: config.kvQuant,
@@ -552,6 +696,21 @@ export async function loadContext(
552
696
  function makeVisionLoader(
553
697
  modelDir: string, model: RuntimeModel, config: ModelConfig,
554
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
+ }
555
714
  if (!(config.hasVisionSidecar && model instanceof Gemma4Model)) return null;
556
715
  const vc = config.raw.vision_config as Record<string, any> | undefined;
557
716
  if (vc?.model_type === "gemma4_vision") {
@@ -663,15 +822,21 @@ interface ChatRequest {
663
822
  tools?: ToolDefinition[];
664
823
  tool_choice?: "auto" | "none" | { type: string; function?: { name: string } };
665
824
  /** Forwarded to HF chat templates, matching optiq serve. MiniCPM5 uses
666
- * enable_thinking to select direct answers vs the <think> channel. */
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). */
667
828
  chat_template_kwargs?: {
668
829
  enable_thinking?: boolean;
830
+ preserve_thinking?: boolean;
669
831
  [key: string]: unknown;
670
832
  };
671
833
  /** OpenAI reasoning control. For models with a switchable <think> channel
672
834
  * (Qwen3.5/MiniCPM5) it gates enable_thinking: "none" → off, any level → on.
673
- * This is what Pi sends when the provider advertises reasoning. */
674
- reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high";
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";
675
840
  /** Mounted LoRA adapter selection: "id", "a+b" (stacked), or "none". */
676
841
  adapter?: string;
677
842
  /** HLG tone-curve sampling override (per request). Snake_case wire fields,
@@ -708,6 +873,35 @@ type TextCompletionRequest = Omit<ChatRequest, "messages" | "tools" | "tool_choi
708
873
  prompt?: unknown;
709
874
  };
710
875
 
876
+ export interface ContextAdmissionDecision {
877
+ /** Effective completion ceiling after admission. */
878
+ maxTokens: number;
879
+ /** True when admission reduced the client's upper bound to fit. */
880
+ clamped: boolean;
881
+ }
882
+
883
+ /** Resolve a request's completion upper bound against the admitted context.
884
+ *
885
+ * `max_tokens` is a ceiling, not a promise that every token will be emitted:
886
+ * when a prompt fits but a broad client-wide `max_tokens` would push the
887
+ * reservation past the safe context, the bound is CLAMPED to the remaining
888
+ * room instead of rejecting an otherwise valid request (the pre-v0.0.13
889
+ * behavior 400'd a prompt with thousands of tokens of generation room over a
890
+ * 17-token overshoot). This never weakens the OOM guard — generation stops at
891
+ * the clamped ceiling, inside the admitted context. Only a prompt that leaves
892
+ * no generation slot at all is rejected. */
893
+ export function admitRequestContext(
894
+ promptTokens: number,
895
+ requestedMaxTokens: number,
896
+ maxSafeContext: number,
897
+ ): ContextAdmissionDecision | null {
898
+ const available = maxSafeContext - promptTokens;
899
+ if (available < 1) return null;
900
+ if (requestedMaxTokens <= available)
901
+ return { maxTokens: requestedMaxTokens, clamped: false };
902
+ return { maxTokens: available, clamped: true };
903
+ }
904
+
711
905
  /** Per-field default HLG knobs when enabling without specifying them. */
712
906
  const HLG_DEFAULTS = { width: 4, shoulder: 4, toe: 6, pivotOffset: 6 } as const;
713
907
 
@@ -1126,7 +1320,8 @@ function hasMediaPart(parts: Array<Record<string, unknown>>): boolean {
1126
1320
  (p) =>
1127
1321
  p &&
1128
1322
  (p.type === "image" || p.type === "image_url" ||
1129
- p.type === "audio" || p.type === "input_audio" || p.type === "audio_url"),
1323
+ p.type === "audio" || p.type === "input_audio" || p.type === "audio_url" ||
1324
+ p.type === "video" || p.type === "video_url"),
1130
1325
  );
1131
1326
  }
1132
1327
 
@@ -1198,7 +1393,8 @@ export function parseLogitBias(
1198
1393
 
1199
1394
  /** Default seed for a request that didn't pin one. `Date.now()` alone is NOT
1200
1395
  * request-unique: under `--batch N` the batch lane serves ONLY default-seed
1201
- * requests (explicit-seed requests route serial, see GenerationGateway.willBatch),
1396
+ * requests (explicit-seed requests use the serial mechanism, see
1397
+ * GenerationGateway.place),
1202
1398
  * so two identical prompts arriving in the same millisecond would share a seed
1203
1399
  * and — with per-row RNG keyed as stepKey(seed, generatedCount) — produce
1204
1400
  * byte-identical completions, silently collapsing best-of-N diversity. Mix a
@@ -1290,68 +1486,24 @@ export function validateLogprobsParams(body: {
1290
1486
  return null;
1291
1487
  }
1292
1488
 
1293
- /** Collects per-token logprob info and shapes mlx_lm.server's response block
1294
- * (server.py generate_response L1317-1327). NOT OpenAI's shape — entries
1295
- * carry token *ids* (and raw vocab token strings in the top-k form), and the
1296
- * SAME block is attached under choices[0].logprobs for chat AND text
1297
- * completions:
1298
- * - top_logprobs > 0 → {content: [{id, token, logprob,
1299
- * top_logprobs: [{id, token, logprob}, …]}, …]} — each entry is the
1300
- * top-1 candidate merged with its own top-k list (`dict(i[0],
1301
- * top_logprobs=i)`); mlx-lm leaves argpartition order unspecified, we
1302
- * sort descending, so the entry is deterministically the argmax.
1303
- * - logprobs=true (and top_logprobs ≤ 0) → {content: [{id, logprob}, …]}
1304
- * with the SAMPLED token's logprob.
1305
- * When both are set, only the top_logprobs form is emitted (the reference's
1306
- * if/elif). Stream chunks never carry logprobs — mlx-lm's streaming
1307
- * generate_response calls pass no token_logprobs/top_tokens. */
1308
- class LogprobsCollector {
1309
- readonly #tokens: number[] = [];
1310
- readonly #tokenLogprobs: number[] = [];
1311
- readonly #topTokens: { id: number; token: string; logprob: number }[][] = [];
1312
-
1313
- constructor(
1314
- private readonly wantLogprobs: boolean,
1315
- private readonly topK: number,
1316
- private readonly idToToken: (id: number) => string,
1317
- ) {}
1318
-
1319
- get active(): boolean {
1320
- return this.wantLogprobs || this.topK > 0;
1321
- }
1322
-
1323
- push(token: number, info?: TokenLogprobs): void {
1324
- if (!this.active) return;
1325
- this.#tokens.push(token);
1326
- if (this.wantLogprobs) this.#tokenLogprobs.push(info?.logprob ?? NaN);
1327
- if (this.topK > 0)
1328
- this.#topTokens.push(
1329
- (info?.top ?? []).map((t) => ({
1330
- id: t.id,
1331
- token: this.idToToken(t.id),
1332
- logprob: t.logprob,
1333
- })),
1334
- );
1335
- }
1336
-
1337
- /** choices[0].logprobs value, or null when nothing to attach (mirrors the
1338
- * reference: the key is omitted entirely for zero collected tokens). */
1339
- payload(): { content: Record<string, unknown>[] } | null {
1340
- if (this.#topTokens.length)
1341
- return {
1342
- content: this.#topTokens.map((t) =>
1343
- t.length ? { ...t[0]!, top_logprobs: t } : {},
1344
- ),
1345
- };
1346
- if (this.#tokenLogprobs.length)
1347
- return {
1348
- content: this.#tokens.map((id, i) => ({
1349
- id,
1350
- logprob: this.#tokenLogprobs[i]!,
1351
- })),
1352
- };
1353
- return null;
1354
- }
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;
1355
1507
  }
1356
1508
 
1357
1509
  /** Incremental detokenizer: emits the longest stable decoded prefix.
@@ -1505,13 +1657,14 @@ function curveJunk(s: string): boolean {
1505
1657
  export function createServer(
1506
1658
  ctx: ServerContext, port = 0, serverOptions: ServerOptions = {},
1507
1659
  ): Server<unknown> {
1508
- // --batch N (mode switch): N===1 is the serialized path below; N>1 routes
1509
- // batchable requests through the continuous-batching scheduler (the
1510
- // GenerationGateway picks the lane). Both full-attention (CPM) and
1660
+ // --batch N is a concurrency cap: N===1 pins the strict serial executor;
1661
+ // N>1 admits supported execution compositions to the continuous scheduler.
1662
+ // The scheduler chooses its B=1 fast path or B=N step from active rows.
1663
+ // Both full-attention (CPM) and
1511
1664
  // sliding-window (Gemma) models batch — the scheduler assembles each layer's
1512
1665
  // cache by attention type. Non-batchable requests (vision / adapters /
1513
- // repetition penalty / user seed / explicit kv-quant) drain to the serial
1514
- // lane (see GenerationGateway.willBatch).
1666
+ // user seed / unsupported explicit kv-quant) drain to the serial executor
1667
+ // (see GenerationGateway.place). No inference setting is rewritten.
1515
1668
  // DEFAULT 8 (flipped 2026-07-05, Josh's call, after GATE-B1-SPEED): a
1516
1669
  // lone request through the batch lane IS the serial engine (adopted
1517
1670
  // serial-class caches, compiled decode, prompt cache + SSD restore;
@@ -1530,7 +1683,6 @@ export function createServer(
1530
1683
  // and must be an explicit opt-in (--kv-quant config|4|8, or --l2
1531
1684
  // whose presets pass it explicitly). The CLI always passes kvQuant now;
1532
1685
  // this fallback is the library-user default and matches the CLI's.
1533
- const configScheme = ctx.kvConfig?.length ? { kvConfig: ctx.kvConfig } : {};
1534
1686
  // Mutually exclusive by contract (GenerateOptions.turboQuant doc): a
1535
1687
  // programmatic caller setting both gets turboQuant — say so, like the
1536
1688
  // other risky-combination warnings below.
@@ -1539,13 +1691,12 @@ export function createServer(
1539
1691
  `[kv-quant] both turboQuant and --kv-quant ${serverOptions.kvQuant} are set; ` +
1540
1692
  `turboQuant wins (they are mutually exclusive).`,
1541
1693
  );
1542
- const kvScheme: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart" | "turboQuant"> =
1543
- serverOptions.turboQuant ? { turboQuant: serverOptions.turboQuant, quantizedKvStart: 0 }
1544
- : serverOptions.kvQuant === "off" ? {}
1545
- : serverOptions.kvQuant === "config" ? configScheme
1546
- : typeof serverOptions.kvQuant === "number"
1547
- ? { kvBits: serverOptions.kvQuant, quantizedKvStart: 0 }
1548
- : {}; // unset → bf16 (L1 default; quantized KV is opt-in)
1694
+ const resolvedKvScheme = resolveKvScheme({
1695
+ override: serverOptions.kvQuant,
1696
+ turboQuant: serverOptions.turboQuant,
1697
+ config: ctx.kvConfig,
1698
+ });
1699
+ const kvScheme = resolvedKvScheme.generationOptions;
1549
1700
  // Phase 3.1: kvConfig whose layers are all full-attention BATCHES (the
1550
1701
  // scheduler applies the mixed scheme per row); uniform kvBits and configs
1551
1702
  // touching rotating layers still route those requests serial. The warning
@@ -1558,7 +1709,7 @@ export function createServer(
1558
1709
  `--kv-quant to batch in bf16. (docs/design/unified-engine-frontier-plan.md)`,
1559
1710
  );
1560
1711
  // TurboQuant is solo-only in v1 (novel cache class, not batchable by
1561
- // construction — see GenerationGateway#modelCachesBatchable/willBatch).
1712
+ // construction — see GenerationGateway placement's cache-capability gate).
1562
1713
  if (batch > 1 && kvScheme.turboQuant)
1563
1714
  console.warn(
1564
1715
  `[batch] --batch ${batch} with --kv-quant turbo: TurboQuant is serial-only in v1 ` +
@@ -1644,11 +1795,7 @@ export function createServer(
1644
1795
  if (serverOptions.ssdCacheDir) {
1645
1796
  if (promptCacheCap <= 0)
1646
1797
  throw new Error("--ssd-cache requires the RAM prompt cache (--prompt-cache 0 disables it)");
1647
- const schemeKey = kvScheme.turboQuant
1648
- ? `turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`
1649
- : kvScheme.kvBits
1650
- ? `kv${kvScheme.kvBits}`
1651
- : kvScheme.kvConfig?.length ? "config" : "bf16";
1798
+ const schemeKey = resolvedKvScheme.cacheKey;
1652
1799
  const tokJson = readFileSync(`${ctx.model.config.modelDir}/tokenizer.json`);
1653
1800
  ssdStore = new SsdCacheStore({
1654
1801
  dir: serverOptions.ssdCacheDir,
@@ -1734,8 +1881,17 @@ export function createServer(
1734
1881
  embeddings: import("./mlx/array").MlxArray;
1735
1882
  imageMask?: import("./mlx/array").MlxArray;
1736
1883
  multimodalMask?: import("./mlx/array").MlxArray;
1884
+ mrope?: MropeRequestState;
1737
1885
  },
1886
+ trace?: PromptResponseTrace,
1738
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).
1739
1895
  // Speculative decoding (serve --draft-model): spec-ELIGIBLE requests
1740
1896
  // decode through the verify loop; the rest fall through to the normal
1741
1897
  // serial path (never wrong results, just no speedup — logged once per
@@ -1786,7 +1942,12 @@ export function createServer(
1786
1942
  const skipPromptCache = Boolean(vision || pagedKv);
1787
1943
  // Both tiers in one call (Layer 0): take() prefers a strictly-longer
1788
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
+ });
1789
1949
  const entry = skipPromptCache ? null : promptCache.take(promptIds, cacheNs);
1950
+ closeCacheLookup?.();
1790
1951
  const caches = entry?.caches ?? ctx.model.makeCache();
1791
1952
  // Prompt-boundary snapshot (the multi-turn agent fix, 2026-07-04): the
1792
1953
  // prompt+gen entry put() below is UNTRIMMABLE at context > sliding
@@ -1810,6 +1971,8 @@ export function createServer(
1810
1971
  const snapshotBoundary =
1811
1972
  !skipPromptCache && boundary >= 256 && boundary > (entry?.tokens.length ?? 0);
1812
1973
  try {
1974
+ if (vision?.mrope && ctx.model instanceof Qwen35Model)
1975
+ ctx.model.mrope = vision.mrope;
1813
1976
  const gen = generate(ctx.model, promptIds, {
1814
1977
  ...options,
1815
1978
  pagedKv, // request-scoped (undefined strips the server-wide flag)
@@ -1838,7 +2001,7 @@ export function createServer(
1838
2001
  ...(vision.multimodalMask ? { multimodalMask: vision.multimodalMask } : {}),
1839
2002
  }
1840
2003
  : {}),
1841
- });
2004
+ }, { trace, mechanism: "serial" });
1842
2005
  for await (const t of gen) {
1843
2006
  if ((await onToken(t.token, t.logprobs)) === false) break;
1844
2007
  }
@@ -1863,6 +2026,7 @@ export function createServer(
1863
2026
  options.grammar?.dispose();
1864
2027
  throw e;
1865
2028
  } finally {
2029
+ if (ctx.model instanceof Qwen35Model) ctx.model.mrope = null;
1866
2030
  vision?.embeddings.dispose();
1867
2031
  vision?.imageMask?.dispose();
1868
2032
  vision?.multimodalMask?.dispose();
@@ -1879,12 +2043,10 @@ export function createServer(
1879
2043
  // extraction must not race a generation mutating the entry); if the entry
1880
2044
  // was evicted (already spilled) or extended (a newer schedule pending)
1881
2045
  // meanwhile, findExact misses and nothing is written.
1882
- // Keyed by ns AND entry length: a sub-second final [prompt+gen] put must
1883
- // not cancel the pending boundary-snapshot write (they are DIFFERENT
1884
- // entries; for wrapped-ring models the boundary file is the only one a
1885
- // restart can use). Same-length reschedules still coalesce; stale keys
1886
- // self-clean at fire time (findExact misses once the RAM tier superseded
1887
- // the entry, so nothing extra is written).
2046
+ // Keyed by namespace and exact tokens: a sub-second final [prompt+gen] put
2047
+ // must not cancel the pending boundary snapshot, and unrelated prompts of
2048
+ // equal length must not cancel each other. Exact reschedules still
2049
+ // coalesce; stale keys self-clean when findExact misses.
1888
2050
  //
1889
2051
  // NON-BLOCKING (2026-07-06, the write-behind persistence contract): the
1890
2052
  // gateway lock is held only for a zero-copy SNAPSHOT (findExact +
@@ -1909,23 +2071,28 @@ export function createServer(
1909
2071
  // still land ahead of a just-arrived request. MLX_BUN_SSD_WRITEBEHIND=0
1910
2072
  // disables write-behind snapshots entirely — the paired-A/B lever + kill
1911
2073
  // switch (restart survival then degrades to spill-on-evict only).
1912
- const writeBehindOn = process.env.MLX_BUN_SSD_WRITEBEHIND !== "0";
2074
+ const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
1913
2075
  const ssdFlushGate = (): Promise<void> => gateway.onIdle();
1914
- const ssdPending = new Map<string, ReturnType<typeof setTimeout>>();
1915
2076
  // Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
1916
2077
  // kv-store.ts): pending clones pin their entries' GPU buffers while the
1917
2078
  // idle gate starves under sustained traffic, so QUEUED bytes are capped —
1918
2079
  // over cap the oldest queued spill drops (clone disposed immediately, a
1919
2080
  // future cache miss, never a wrong result) instead of accumulating past
1920
2081
  // the prompt-cache cap. Default 2 GB (a quarter of the 8 GB RAM-cache
1921
- // default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides. There is deliberately
1922
- // NO shutdown flush: exiting under traffic loses queued write-behind
1923
- // entries (restart survival degrades to whatever flushed) — accepted for
1924
- // a best-effort cache tier; drops/pending are visible in /stats.
2082
+ // default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides. The durability
2083
+ // coordinator retains a dirty record until the queue confirms the atomic
2084
+ // store. Explicit flush and graceful shutdown can therefore retry a drop.
1925
2085
  // `|| 2` would coerce an explicit "0" back to 2 GB — parse so 0 works
1926
2086
  // (cap 0 = keep only the newest + in-flight clone pinned; the soft cap
1927
2087
  // never drops the item just enqueued).
1928
- const spillQueueGbRaw = Number(process.env.MLX_BUN_SSD_SPILL_QUEUE_GB);
2088
+ // The gateway exists before the spill queue because the SSD writer uses
2089
+ // its idle boundary to avoid competing with generation.
2090
+ const gateway = new GenerationGateway(ctx.model, batch, runGeneration, {
2091
+ kvBudgetBytes: serverOptions.kvBudgetBytes,
2092
+ kvScheme: resolvedKvScheme,
2093
+ promptCache,
2094
+ });
2095
+ const spillQueueGbRaw = Number(runtimeValue("MLX_BUN_SSD_SPILL_QUEUE_GB"));
1929
2096
  const spillQueueCapBytes =
1930
2097
  (Number.isFinite(spillQueueGbRaw) && spillQueueGbRaw >= 0 ? spillQueueGbRaw : 2) * 1024 ** 3;
1931
2098
  const spillQueue = ssdStore
@@ -1936,38 +2103,39 @@ export function createServer(
1936
2103
  (caches) => { for (const c of caches) c.dispose(); },
1937
2104
  )
1938
2105
  : null;
1939
- const scheduleSsdSnapshot = (tokens: number[], ns: string): void => {
1940
- if (!ssdStore || !writeBehindOn || tokens.length === 0) return;
1941
- const key = `${ns}|${tokens.length}`;
1942
- const prev = ssdPending.get(key);
1943
- if (prev) clearTimeout(prev);
1944
- const fire = (): void => {
1945
- ssdPending.delete(key);
1946
- // Same guard as demoteIdle below (2026-07-07 review fix): the
1947
- // runExclusive registers a serial waiter, which HOLDS BATCH ADMISSION
1948
- // until every running row drains — a background durability timer must
1949
- // never do that. Busy → re-arm and try again in 5 s (entries are
1950
- // immutable; a late snapshot is just as valid).
1951
- if (gateway.activeRows > 0 || gateway.pendingRows > 0) {
1952
- const t = setTimeout(fire, 5000);
1953
- t.unref?.();
1954
- ssdPending.set(key, t);
1955
- return;
1956
- }
1957
- void gateway.runExclusive(async () => {
1958
- const e = promptCache.findExact(tokens, ns);
1959
- if (!e) return null;
1960
- return { tokens: e.tokens, caches: cloneKvCaches(e.caches) };
1961
- }).then((snap) => {
1962
- if (!snap) return;
1963
- spillQueue!.enqueue({ tokens: snap.tokens, caches: snap.caches, ns });
1964
- }).catch(() => { /* cold tier is best-effort */ });
2106
+ const durability = ssdStore && spillQueue && writeBehindOn
2107
+ ? new SsdDurabilityCoordinator(
2108
+ gateway,
2109
+ promptCache,
2110
+ spillQueue,
2111
+ cloneKvCaches,
2112
+ (tokens, ns) => ssdStore.hasDurablePrefix(tokens, ns),
2113
+ )
2114
+ : null;
2115
+ const durabilityStats = (): DurabilitySnapshotStats =>
2116
+ durability?.stats ?? {
2117
+ pendingSnapshots: 0,
2118
+ pendingSpills: spillQueue?.pendingCount ?? 0,
2119
+ pendingSpillBytes: spillQueue?.pendingBytes ?? 0,
2120
+ droppedSpills: spillQueue?.droppedCount ?? 0,
2121
+ failedSpills: spillQueue?.failedCount ?? 0,
2122
+ };
2123
+ const flushDurability = async (): Promise<DurabilityFlushResult> => {
2124
+ if (durability) return durability.flush();
2125
+ const started = performance.now();
2126
+ await spillQueue?.drain();
2127
+ const stats = durabilityStats();
2128
+ return {
2129
+ ...stats,
2130
+ durable: stats.pendingSpills === 0,
2131
+ flushedSnapshots: 0,
2132
+ missingSnapshots: 0,
2133
+ elapsedMs: performance.now() - started,
1965
2134
  };
1966
- ssdPending.set(key, setTimeout(fire, 1000));
1967
2135
  };
1968
2136
  // Every put() — serial lane AND batch scheduler — schedules the snapshot
1969
2137
  // (Layer 0: batch-lane entries survive restarts too, not just evictions).
1970
- promptCache.onPut = (tokens, ns) => scheduleSsdSnapshot(tokens, ns);
2138
+ promptCache.onPut = durability ? (tokens, ns) => durability.schedule(tokens, ns) : null;
1971
2139
 
1972
2140
  // Idle demotion (Layer 0): entries unused for --ssd-demote-idle seconds
1973
2141
  // spill to SSD and free their GPU memory — the RAM tier drains between
@@ -1991,52 +2159,49 @@ export function createServer(
1991
2159
  demoteTimer.unref?.();
1992
2160
  }
1993
2161
 
1994
- // The lane picker: routes each request to the serial path (runGeneration,
1995
- // above) or the continuous-batching scheduler, keeping the two off the GPU
1996
- // (and shared loraState) at the same time. See src/serve/generation-gateway.ts.
1997
- const gateway = new GenerationGateway(ctx.model, batch, runGeneration, {
1998
- kvBudgetBytes: serverOptions.kvBudgetBytes,
1999
- // Phase 3.1: the server-wide scheme travels to the gateway once; the
2000
- // gateway batches kv-quant requests when the scheme is the batchable
2001
- // kvConfig/full-attention composition (see #kvBatchable) and the
2002
- // scheduler applies it — otherwise those requests route serial as before.
2003
- kvScheme,
2004
- // Phase 3.2: batch-lane prompt-cache reuse — joiners take() the longest
2005
- // usable prefix (multi-turn chat TTFT under --batch N); never-merged
2006
- // lone rows put() back on finish. Vision/adapter requests never batch,
2007
- // so the serial lane's bypass rules are preserved by routing.
2008
- promptCache,
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,
2009
2173
  });
2010
2174
 
2011
2175
  // Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
2012
2176
  // fit() solves max safe context from weights + KV growth + prefill
2013
- // transient; the KV term assumes bf16 (a kv-quant scheme stretches the
2014
- // real ceiling, never shrinks it — admission stays conservative).
2015
- const genericAdmission = fit(
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(
2016
2183
  ctx.model.config, ctx.model.weightsBytes, 1,
2017
2184
  undefined, undefined, 0, serverOptions.memoryBudgetBytes,
2185
+ resolvedKvScheme.fitOptions,
2018
2186
  );
2019
- const admission = ctx.glmMemoryPlan
2020
- ? {
2021
- ...genericAdmission,
2022
- maxSafeContext: ctx.glmMemoryPlan.contextTokens,
2023
- usableBytes: ctx.glmMemoryPlan.processLimitBytes,
2024
- }
2025
- : genericAdmission;
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.
2026
2192
  if (admission.maxSafeContext < 1)
2027
- throw new Error(
2028
- `memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB cannot serve ` +
2029
- `${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB): ` +
2030
- `no context fits — raise the budget or pick a smaller model`,
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`,
2031
2197
  );
2032
- if (ctx.glmMemoryPlan)
2033
- setMemoryLimit(ctx.glmMemoryPlan.lineItems.allocatorReserveBytes);
2034
- else if (serverOptions.memoryBudgetBytes)
2035
- setMemoryLimit(serverOptions.memoryBudgetBytes);
2198
+ const allocatorLimit =
2199
+ admission.allocatorLimitBytes ?? serverOptions.memoryBudgetBytes;
2200
+ if (allocatorLimit) setMemoryLimit(allocatorLimit);
2036
2201
 
2037
2202
  // /library response cache (30 s) — registry + config reads only.
2038
- let libraryCache: { at: number; rows: unknown[] } | null = null;
2039
2203
  const startedAt = Date.now();
2204
+ const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt);
2040
2205
 
2041
2206
  // Captured so the WebSocket handler can resolve the bound (possibly
2042
2207
  // ephemeral) port lazily for the loopback pi provider.
@@ -2162,10 +2327,36 @@ export function createServer(
2162
2327
  return { controller: r.controller, degradeHint: r.degradeHint };
2163
2328
  };
2164
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
+
2165
2351
  const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
2166
2352
  // enableThinking resolution (and its precedence) lives in
2167
2353
  // resolveEnableThinking so template rendering and sampling stay in sync.
2168
- return { tools, enableThinking: resolveEnableThinking(req) };
2354
+ return {
2355
+ tools,
2356
+ enableThinking: resolveEnableThinking(req),
2357
+ reasoningEffort: qwenReasoningEffort(req.reasoning_effort),
2358
+ preserveThinking: req.chat_template_kwargs?.preserve_thinking,
2359
+ };
2169
2360
  };
2170
2361
 
2171
2362
  const promptIdsFor = (
@@ -2200,7 +2391,7 @@ export function createServer(
2200
2391
  trimmed: number[],
2201
2392
  ): number => {
2202
2393
  const opts = templateOptionsFor(req, tools);
2203
- const mode = `${opts.enableThinking}|${!!tools?.length}`;
2394
+ const mode = `${opts.enableThinking}|${!!tools?.length}|${opts.reasoningEffort ?? ""}|${opts.preserveThinking ?? ""}`;
2204
2395
  const primer = primerLenByMode.get(mode);
2205
2396
  if (primer !== undefined) return Math.max(0, trimmed.length - primer);
2206
2397
  let stableLen = trimmed.length;
@@ -2274,250 +2465,36 @@ export function createServer(
2274
2465
  async fetch(request, server) {
2275
2466
  const url = new URL(request.url);
2276
2467
 
2468
+ if (url.pathname === "/admin/cache/flush" && request.method === "POST") {
2469
+ const result = await flushDurability();
2470
+ return Response.json(
2471
+ {
2472
+ ...result,
2473
+ entries: ssdStore?.entries ?? 0,
2474
+ longest_durable_prefix_tokens:
2475
+ ssdStore?.longestDurablePrefixTokens ?? 0,
2476
+ },
2477
+ { status: result.durable ? 200 : 503 },
2478
+ );
2479
+ }
2480
+
2277
2481
  if (url.pathname === "/ws/chat") {
2278
2482
  if (server.upgrade(request, { data: { sessionId: crypto.randomUUID() } as PiWsData }))
2279
2483
  return undefined;
2280
2484
  return new Response("expected websocket", { status: 426 });
2281
2485
  }
2282
2486
 
2283
- // Memory REST wrappers (web-chat-redesign.md §2.3/§9 Phase 2): thin
2284
- // loopback JSON routes over src/memory/vault.ts for the web chat's
2285
- // Memory panel. Handlers live in src/memory/rest.ts (pure functions,
2286
- // no `ctx` dependency) so they're unit-testable without a loaded
2287
- // model; dispatch here just matches path+method. The agent-tool
2288
- // surface (src/memory/tools.ts) is untouched by this block.
2289
- if (url.pathname === "/api/memory/status" && request.method === "GET") {
2290
- const { handleMemoryStatus } = await import("./memory/rest");
2291
- return handleMemoryStatus();
2292
- }
2293
- if (url.pathname === "/api/memory/list" && request.method === "GET") {
2294
- const { handleMemoryList } = await import("./memory/rest");
2295
- return handleMemoryList();
2296
- }
2297
- if (url.pathname === "/api/memory/search" && request.method === "GET") {
2298
- const { handleMemorySearch } = await import("./memory/rest");
2299
- return handleMemorySearch(url);
2300
- }
2301
- if (url.pathname === "/api/memory/article" && request.method === "GET") {
2302
- const { handleMemoryArticle } = await import("./memory/rest");
2303
- return handleMemoryArticle(url);
2304
- }
2305
- if (url.pathname === "/api/memory/links" && request.method === "GET") {
2306
- const { handleMemoryLinks } = await import("./memory/rest");
2307
- return handleMemoryLinks(url);
2308
- }
2309
- if (url.pathname === "/api/memory/history" && request.method === "GET") {
2310
- const { handleMemoryHistory } = await import("./memory/rest");
2311
- return handleMemoryHistory(url);
2312
- }
2313
- if (url.pathname === "/api/memory/diff" && request.method === "GET") {
2314
- const { handleMemoryDiff } = await import("./memory/rest");
2315
- return handleMemoryDiff(url);
2316
- }
2317
- if (url.pathname === "/api/memory/init" && request.method === "POST") {
2318
- const { handleMemoryInit } = await import("./memory/rest");
2319
- return handleMemoryInit(request);
2320
- }
2321
-
2322
- // Model Hub (web-chat-redesign.md §9 Phase 3, beat-matrix Axis 3):
2323
- // thin loopback JSON routes over the registry/download/fit machinery
2324
- // for the web chat's Hub panel. Handlers live in src/hub-rest.ts
2325
- // (pure functions, no `ctx` dependency); dispatch here just matches
2326
- // path+method, same convention as the memory wrappers above.
2327
- if (url.pathname === "/api/hub/local" && request.method === "GET") {
2328
- const { handleHubLocal } = await import("./hub-rest");
2329
- return handleHubLocal();
2330
- }
2331
- if (url.pathname === "/api/hub/search" && request.method === "GET") {
2332
- const { handleHubSearch } = await import("./hub-rest");
2333
- return handleHubSearch(url);
2334
- }
2335
- if (url.pathname === "/api/hub/download" && request.method === "POST") {
2336
- const { handleHubDownload } = await import("./hub-rest");
2337
- return handleHubDownload(request);
2338
- }
2339
- if (url.pathname === "/api/hub/serve" && request.method === "POST") {
2340
- const { handleHubServe } = await import("./hub-rest");
2341
- return handleHubServe(request);
2342
- }
2343
-
2344
- // Session full-text search + export (docs/design/web-chat-redesign.md
2345
- // §9 Phase 3, beat-matrix Axis 10/11 — the full-text-search BEAT and
2346
- // Markdown/JSON chat export). Handlers live in src/serve/session-search.ts
2347
- // (pure, read-only, no `ctx` dependency), dispatched here by
2348
- // path+method, same convention as the memory/hub blocks above.
2349
- if (url.pathname === "/api/sessions/search" && request.method === "GET") {
2350
- const { handleSessionsSearch } = await import("./serve/session-search");
2351
- return handleSessionsSearch(url);
2352
- }
2353
- if (url.pathname === "/api/sessions/export" && request.method === "GET") {
2354
- const { handleSessionsExport } = await import("./serve/session-search");
2355
- return handleSessionsExport(url);
2356
- }
2357
-
2358
- // The unified SPA is served at "/"; legacy deep links redirect into
2359
- // the hash router so old bookmarks still land on the right section.
2360
- if (url.pathname === "/" && request.method === "GET") {
2361
- return new Response(APP_PAGE, {
2362
- headers: { "content-type": "text/html; charset=utf-8" },
2363
- });
2364
- }
2365
- // Vendored syntax-highlighting assets (src/web/vendor/*, no CDN — see
2366
- // that dir's README). Long cache: content is versioned by the vendor
2367
- // rebuild step, not by URL, but a hard reload always wins during dev.
2368
- if (url.pathname === "/assets/hljs.js" && request.method === "GET") {
2369
- return new Response(HLJS_JS, {
2370
- headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
2371
- });
2372
- }
2373
- if (url.pathname === "/assets/hljs.css" && request.method === "GET") {
2374
- return new Response(HLJS_CSS, {
2375
- headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=3600" },
2376
- });
2377
- }
2378
- // The frontend bundle (see the import comment above) — cache header
2379
- // matches the other /assets/* entries; content is versioned by the
2380
- // build step, not the URL, and a hard reload always wins during dev.
2381
- if (url.pathname === "/assets/app.js" && request.method === "GET") {
2382
- return new Response(APP_JS, {
2383
- headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
2384
- });
2385
- }
2386
- // PWA manifest + icon (plan §9 Phase 3): linked from app.html's
2387
- // <link rel="manifest">. Same versioned-by-content cache posture as
2388
- // the other /assets/* entries above.
2389
- if (url.pathname === "/manifest.webmanifest" && request.method === "GET") {
2390
- return new Response(MANIFEST_WEBMANIFEST, {
2391
- headers: { "content-type": "application/manifest+json; charset=utf-8", "cache-control": "public, max-age=3600" },
2392
- });
2393
- }
2394
- if (url.pathname === "/assets/icon.svg" && request.method === "GET") {
2395
- return new Response(ICON_SVG, {
2396
- headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "public, max-age=3600" },
2397
- });
2398
- }
2399
- // Service worker MUST be served at the root scope (/sw.js, not
2400
- // /assets/sw.js) — a worker's default scope is the directory it's
2401
- // served from, and the shell it manages includes "/" itself.
2402
- // no-store: the browser's own update check needs a fresh byte
2403
- // comparison every time to notice a new worker, not a cached 304.
2404
- if (url.pathname === "/sw.js" && request.method === "GET") {
2405
- return new Response(SW_JS, {
2406
- headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" },
2407
- });
2408
- }
2409
- // v2 HLG Curve Designer — served same-origin so /generate + /signal need no CORS.
2410
- // Read fresh from disk in dev (edits show on reload, no restart); fall back to the
2411
- // embedded copy when running as the compiled single binary.
2412
- if (url.pathname === "/curves" && request.method === "GET") {
2413
- let html = CURVE_PAGE;
2414
- try { html = readFileSync(new URL("./assets/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
2415
- return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
2416
- }
2417
- // Training/inference DAG map — self-contained cytoscape artifact, served
2418
- // same-origin so the Routes tab can embed it in an <iframe src="/dag">.
2419
- if (url.pathname === "/dag" && request.method === "GET") {
2420
- try {
2421
- const html = readFileSync(new URL("../docs/dag/training-inference-map.html", import.meta.url), "utf8");
2422
- return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
2423
- } catch {
2424
- return new Response("DAG map artifact not found at docs/dag/training-inference-map.html", { status: 404 });
2425
- }
2426
- }
2427
- if (url.pathname === "/curve-terrain" && request.method === "GET") {
2428
- try {
2429
- const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
2430
- return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
2431
- } catch {
2432
- return new Response("curve terrain artifact not found; run scripts/experiments/curve-terrain.ts first", { status: 404 });
2433
- }
2434
- }
2435
- if (request.method === "GET" &&
2436
- ["/status", "/chat", "/quantize", "/finetune", "/dataset"].includes(url.pathname)) {
2437
- return Response.redirect(`/#${url.pathname}`, 302);
2438
- }
2487
+ const auxiliaryResponse = await handleAuxiliaryRoute(url, request);
2488
+ if (auxiliaryResponse) return auxiliaryResponse;
2439
2489
 
2440
- if (url.pathname === "/library" && request.method === "GET") {
2441
- // Everything on disk, each with a fit assessment for THIS machine
2442
- // (30 s cache — registry scan + config reads, no tensor bytes).
2443
- if (!libraryCache || Date.now() - libraryCache.at > 30_000) {
2444
- const { Registry } = await import("./registry");
2445
- const { loadModelConfig } = await import("./config");
2446
- const reg = new Registry();
2447
- // Always rescan when (re)building the 30s cache so models that appeared
2448
- // after boot — fresh downloads AND quants written into the HF cache —
2449
- // surface on their own. (scan() is INSERT-OR-REPLACE + prunes deleted,
2450
- // so it's idempotent and cheap for a local cache.)
2451
- await reg.scan();
2452
- const { visionCapable, audioCapable } = await import("./registry");
2453
- const { supportTier } = await import("./model/support");
2454
- const rows = [];
2455
- // listCanonical: one row per repo (refs/main) — duplicate snapshots
2456
- // from upstream re-pushes stay visible only in `ls --all-revisions`.
2457
- for (const m of reg.listCanonical()) {
2458
- const tier = supportTier(m.modelType, m.repoId);
2459
- const supported = tier !== null;
2460
- let assessment = null;
2461
- try {
2462
- const config = await loadModelConfig(m.path);
2463
- const r = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
2464
- assessment = {
2465
- fits: r.fits,
2466
- max_safe_context: r.maxSafeContext,
2467
- predicted_decode_tps: r.predictedDecodeTps,
2468
- };
2469
- } catch {}
2470
- rows.push({
2471
- repo_id: m.repoId, model_type: m.modelType,
2472
- size_bytes: m.sizeBytes, quant_bits: m.quantBits,
2473
- vision: visionCapable(m), audio: audioCapable(m),
2474
- supported, support_tier: tier,
2475
- serving: m.repoId === ctx.modelId,
2476
- assessment,
2477
- });
2478
- }
2479
- libraryCache = { at: Date.now(), rows };
2480
- }
2481
- return Response.json({ models: libraryCache.rows });
2482
- }
2490
+ const staticResponse = handleStaticRoute(url, request, STATIC_ROUTE_ASSETS);
2491
+ if (staticResponse) return staticResponse;
2483
2492
 
2484
- if (url.pathname === "/downloads" && request.method === "GET") {
2485
- const { downloadsSnapshot } = await import("./download");
2486
- return Response.json({ downloads: downloadsSnapshot() });
2487
- }
2493
+ const discoveryResponse = await discoveryRoutes.handle(url, request);
2494
+ if (discoveryResponse) return discoveryResponse;
2488
2495
 
2489
- // Memory synthesis progress (P8-T5). The same DAG the nightly launchd job
2490
- // runs (`mlx-bun memory synthesize`), streamed as Server-Sent Events so the
2491
- // status page can show live stage/log/done progress. `?dry=1` plans the DAG
2492
- // without any model call or vault write — the safe wiring-verification path
2493
- // (the FULL-corpus run is USER-ACTION, P6-T5). GET so EventSource can drive it.
2494
- if (url.pathname === "/v1/memory/synthesize" && request.method === "GET") {
2495
- const dryRun = url.searchParams.get("dry") === "1";
2496
- const { runSynthesis } = await import("./memory/pipeline");
2497
- const enc = new TextEncoder();
2498
- const stream = new ReadableStream({
2499
- async start(controller) {
2500
- const send = (e: unknown) =>
2501
- controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
2502
- try {
2503
- const summary = await runSynthesis({ dryRun }, (ev) => send(ev));
2504
- send({ type: "summary", ...summary });
2505
- controller.enqueue(enc.encode("data: [DONE]\n\n"));
2506
- } catch (e) {
2507
- send({ type: "error", message: (e as Error).message });
2508
- } finally {
2509
- controller.close();
2510
- }
2511
- },
2512
- });
2513
- return new Response(stream, {
2514
- headers: {
2515
- "content-type": "text/event-stream",
2516
- "cache-control": "no-cache",
2517
- connection: "keep-alive",
2518
- },
2519
- });
2520
- }
2496
+ const modelAdminResponse = await handleModelAdminRoute(url, request, ctx, gateway);
2497
+ if (modelAdminResponse) return modelAdminResponse;
2521
2498
 
2522
2499
  if (url.pathname === "/fit" && request.method === "GET") {
2523
2500
  // Fit assessment for the status page: this-machine report at the
@@ -2593,6 +2570,7 @@ export function createServer(
2593
2570
  const report = fit(
2594
2571
  ctx.model.config, ctx.model.weightsBytes, admission.maxSafeContext,
2595
2572
  machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
2573
+ resolvedKvScheme.fitOptions,
2596
2574
  );
2597
2575
  return Response.json({
2598
2576
  machine: { chip: chip.name, ram_bytes: machine.ramBytes, bandwidth_gbs: machine.bandwidthGBs },
@@ -2605,6 +2583,7 @@ export function createServer(
2605
2583
  ctx.model.config, ctx.model.weightsBytes,
2606
2584
  Math.min(8192, admission.maxSafeContext),
2607
2585
  machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
2586
+ resolvedKvScheme.fitOptions,
2608
2587
  ).predictedDecodeTps,
2609
2588
  measured_decode_tps: measured?.decodeTps ?? null,
2610
2589
  measured_at: measured?.ts ?? null,
@@ -2626,18 +2605,6 @@ export function createServer(
2626
2605
  });
2627
2606
  }
2628
2607
 
2629
- if (url.pathname === "/v1" && request.method === "GET") {
2630
- return Response.json({
2631
- name: "mlx-bun", version: pkgVersion, model: ctx.modelId,
2632
- endpoints: [
2633
- "POST /v1/chat/completions", "POST /v1/completions", "POST /v1/messages",
2634
- "POST /v1/responses", "POST /v1/embeddings", "GET /v1/models",
2635
- "GET/POST/DELETE /v1/adapters", "GET /health",
2636
- "GET /stats", "GET /fit", "GET /library", "GET /downloads",
2637
- ],
2638
- });
2639
- }
2640
-
2641
2608
  if (url.pathname === "/stats" && request.method === "GET") {
2642
2609
  // Active KV scheme across ALL layers. Since Phase 9 rotating
2643
2610
  // (sliding-window) caches quantize too, so every layer the
@@ -2686,12 +2653,12 @@ export function createServer(
2686
2653
  spills: ssdStore.stats.spills,
2687
2654
  restore_ms_last: Math.round(ssdStore.stats.restoreMsLast),
2688
2655
  demotions: promptCache.demotions,
2689
- // Bounded write-behind queue (2026-07-07): clones waiting for
2690
- // the idle-gated flush, and spills the byte cap dropped
2691
- // (each = one future cache miss, never a wrong result).
2656
+ pending_snapshots: durability?.stats.pendingSnapshots ?? 0,
2692
2657
  pending_spills: spillQueue?.pendingCount ?? 0,
2693
2658
  pending_spill_bytes: spillQueue?.pendingBytes ?? 0,
2694
2659
  dropped_spills: spillQueue?.droppedCount ?? 0,
2660
+ failed_spills: spillQueue?.failedCount ?? 0,
2661
+ longest_durable_prefix_tokens: ssdStore.longestDurablePrefixTokens,
2695
2662
  },
2696
2663
  } : {}),
2697
2664
  response_store: {
@@ -2758,8 +2725,6 @@ export function createServer(
2758
2725
  });
2759
2726
  }
2760
2727
 
2761
- // mlx_lm.server parity: GET /health → the exact body it writes
2762
- // ('{"status": "ok"}', note the space) so byte-for-byte health checks pass.
2763
2728
  // Engine-mode admin (unix-socket children only — never exposed on
2764
2729
  // TCP): drain = quiesce the gateway + demote the whole prompt cache
2765
2730
  // to the SSD tier. The pool calls this before evicting a model
@@ -2775,207 +2740,6 @@ export function createServer(
2775
2740
  return Response.json({ drained: true, demotions: promptCache.demotions });
2776
2741
  }
2777
2742
 
2778
- if (url.pathname === "/health" && request.method === "GET") {
2779
- return new Response('{"status": "ok"}', {
2780
- headers: { "content-type": "application/json" },
2781
- });
2782
- }
2783
-
2784
- // GET /v1/models — the served model FIRST (with our capability extras),
2785
- // then every other servable model the registry knows (mlx-lm scans the
2786
- // HF cache here; our registry is that scan, filtered to supported
2787
- // architectures). GET /v1/models/<id> filters to that id — same list
2788
- // shape, matching mlx_lm.server's handle_models_request.
2789
- if (
2790
- (url.pathname === "/v1/models" || url.pathname.startsWith("/v1/models/")) &&
2791
- request.method === "GET"
2792
- ) {
2793
- const filterId = url.pathname.length > "/v1/models/".length - 1
2794
- ? decodeURIComponent(url.pathname.slice("/v1/models/".length))
2795
- : null;
2796
- const created = Math.floor(startedAt / 1000);
2797
- // Model-author sampling defaults (generation_config.json), resolved once
2798
- // at load (web-ui-pass-plan.md #14 groundwork): lets a client (the web
2799
- // chat's sampling popover, external pi) show the SERVED model's actual
2800
- // recommended values instead of one hardcoded shape.
2801
- const genDefaultsOut = {
2802
- temperature: ctx.genDefaults.temperature ?? null,
2803
- top_p: ctx.genDefaults.topP ?? null,
2804
- top_k: ctx.genDefaults.topK ?? null,
2805
- };
2806
- const data: Array<Record<string, unknown>> = [{
2807
- id: ctx.modelId, object: "model", created, owned_by: "mlx-bun",
2808
- context_window:
2809
- ctx.glmMemoryPlan?.contextTokens ??
2810
- ctx.model.config.text.maxPositionEmbeddings,
2811
- // Capability flags for clients (CLI/external pi) that build a
2812
- // provider from discovery — `reasoning` gates the thinking toggle,
2813
- // `vision` the image input declaration, `audio` whether
2814
- // `input_audio` content parts are accepted (tower loaded or lazily
2815
- // loadable; same signals the web embed uses).
2816
- reasoning: ctx.template.supportsThinking,
2817
- vision: !!(ctx.vision || ctx.loadVision),
2818
- audio: !!(ctx.audio || ctx.loadAudio),
2819
- batch_mode: gateway.batchMode,
2820
- tools: true,
2821
- structured_output: true,
2822
- embeddings: isEmbeddingModel(ctx.model),
2823
- adapters: !(ctx.model instanceof Glm52Model),
2824
- training: !(ctx.model instanceof Glm52Model),
2825
- dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
2826
- mtp: ctx.draft?.provider.id === "glm52-native-mtp",
2827
- capabilities: {
2828
- chat_completions: true,
2829
- text_completions: true,
2830
- anthropic_messages: true,
2831
- responses: true,
2832
- streaming: true,
2833
- tools: true,
2834
- structured_output: true,
2835
- logprobs: true,
2836
- embeddings: isEmbeddingModel(ctx.model),
2837
- vision: !!(ctx.vision || ctx.loadVision),
2838
- audio: !!(ctx.audio || ctx.loadAudio),
2839
- adapters: !(ctx.model instanceof Glm52Model),
2840
- training: !(ctx.model instanceof Glm52Model),
2841
- },
2842
- gen_defaults: genDefaultsOut,
2843
- }];
2844
- try {
2845
- const { Registry, visionCapable } = await import("./registry");
2846
- const { supportTier } = await import("./model/support");
2847
- const reg = new Registry();
2848
- try {
2849
- if (reg.list().length === 0) await reg.scan();
2850
- // Canonical rows only — a repo with N snapshots is ONE model id.
2851
- for (const m of reg.listCanonical()) {
2852
- if (m.repoId === ctx.modelId) continue;
2853
- const tier = supportTier(m.modelType, m.repoId);
2854
- if (tier === null) continue;
2855
- data.push({
2856
- id: m.repoId, object: "model", created,
2857
- vision: visionCapable(m), tier,
2858
- });
2859
- }
2860
- } finally {
2861
- reg.close();
2862
- }
2863
- } catch { /* registry unavailable → served model only */ }
2864
- return Response.json({
2865
- object: "list",
2866
- data: filterId ? data.filter((m) => m.id === filterId) : data,
2867
- });
2868
- }
2869
-
2870
- // OpenAI embeddings API. Works when the SERVED model is an embedding model
2871
- // (plain Qwen3 / Qwen3-Embedding) — consistent with the single-model server
2872
- // design: `mlx-bun serve <embedding-model>` to use this. Optional non-standard
2873
- // `instruction` applies Qwen3-Embedding's query format. Embedding is a pure
2874
- // forward (no decode loop / gateway), so it runs inline.
2875
- if (url.pathname === "/v1/embeddings" && request.method === "POST") {
2876
- if (!isEmbeddingModel(ctx.model))
2877
- return Response.json({
2878
- error: {
2879
- message: `served model "${ctx.modelId}" is not an embedding model; ` +
2880
- `serve an embedding model (e.g. Qwen3-Embedding) to use /v1/embeddings`,
2881
- type: "invalid_request_error",
2882
- },
2883
- }, { status: 400 });
2884
- let body: { input?: string | string[]; instruction?: string };
2885
- try {
2886
- body = (await request.json()) as typeof body;
2887
- } catch {
2888
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
2889
- }
2890
- const inputs = Array.isArray(body.input) ? body.input : body.input != null ? [body.input] : [];
2891
- if (inputs.length === 0 || !inputs.every((s) => typeof s === "string"))
2892
- return Response.json({
2893
- error: { message: "`input` must be a string or array of strings", type: "invalid_request_error" },
2894
- }, { status: 400 });
2895
- const instruction = typeof body.instruction === "string" ? body.instruction : undefined;
2896
- const results = embedMany(ctx.model, ctx.tokenizer, inputs, instruction);
2897
- let totalTokens = 0;
2898
- const data = results.map((r, index) => {
2899
- totalTokens += r.tokens;
2900
- return { object: "embedding", index, embedding: Array.from(r.vector) };
2901
- });
2902
- return Response.json({
2903
- object: "list",
2904
- data,
2905
- model: ctx.modelId,
2906
- usage: { prompt_tokens: totalTokens, total_tokens: totalTokens },
2907
- });
2908
- }
2909
-
2910
- // Adapter admin (port of optiq registry semantics): list / mount /
2911
- // unmount. Mount and unmount go through the generation queue so
2912
- // they never race an in-flight forward pass.
2913
- if (url.pathname === "/v1/adapters/available" && request.method === "GET") {
2914
- // On-disk adapters that can be mounted (the chat selector's source).
2915
- // Every on-disk adapter is returned — the web chat routing chip (§5.2)
2916
- // needs incompatible entries visible-but-grayed, not silently dropped,
2917
- // so a user understands "10 adapters exist, 2 apply here" rather than
2918
- // seeing a shorter list with no explanation. `compatible` = the
2919
- // adapter's recorded base repo id matches the served model (compared
2920
- // on the bare name, lenient about the org); an adapter with no
2921
- // recorded base is treated as compatible (mount validates it for
2922
- // real). `mounted` flags ones already loaded so the UI auto-loads on
2923
- // select only if needed.
2924
- const { homedir } = await import("node:os");
2925
- const stores = [
2926
- `${homedir()}/.cache/mlx-bun-finetunes`,
2927
- `${homedir()}/.cache/mlx-bun/adapters`,
2928
- ];
2929
- const mounted = new Set(ctx.adapters.list().map((a) => a.id));
2930
- const bareName = (s: string) => s.split("/").pop()!.toLowerCase();
2931
- const servedName = bareName(ctx.modelId);
2932
- const adapters = (await listAvailableAdapters(stores))
2933
- .map((a) => ({
2934
- id: a.id, path: a.path, rank: a.rank, scale: a.scale,
2935
- base_model: a.baseModel, mounted: mounted.has(a.id),
2936
- compatible: a.baseModel == null || bareName(a.baseModel) === servedName,
2937
- }));
2938
- return Response.json({ adapters });
2939
- }
2940
- if (url.pathname === "/v1/adapters" && request.method === "GET") {
2941
- return Response.json({
2942
- adapters: ctx.adapters.list().map((a) => ({
2943
- id: a.id, path: a.path, rank: a.rank, scale: a.scale,
2944
- size_bytes: a.sizeBytes, mounted_layers: a.mountedLayers,
2945
- ram_bytes: a.ramBytes,
2946
- })),
2947
- });
2948
- }
2949
- if (url.pathname === "/v1/adapters" && request.method === "POST") {
2950
- let body: { id?: string; path?: string };
2951
- try {
2952
- body = (await request.json()) as typeof body;
2953
- } catch {
2954
- return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
2955
- }
2956
- if (!body.id || !body.path)
2957
- return Response.json({ error: { message: "id and path required" } }, { status: 400 });
2958
- try {
2959
- // Under the gateway lock: mount/unmount mutate the shared adapter
2960
- // registry (unmount disposes arrays a running generation could still
2961
- // hold) — one mutual-exclusion domain with generation (D3).
2962
- const info = await gateway.runExclusive(() => ctx.adapters.mount(body.id!, body.path!));
2963
- return Response.json({
2964
- id: info.id, mounted_layers: info.mountedLayers,
2965
- rank: info.rank, scale: info.scale, ram_bytes: info.ramBytes,
2966
- });
2967
- } catch (e) {
2968
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
2969
- }
2970
- }
2971
- if (url.pathname.startsWith("/v1/adapters/") && request.method === "DELETE") {
2972
- const id = decodeURIComponent(url.pathname.slice("/v1/adapters/".length));
2973
- const removed = await gateway.runExclusive(async () => ctx.adapters.unmount(id));
2974
- return removed > 0
2975
- ? Response.json({ id, removed_layers: removed })
2976
- : Response.json({ error: { message: `adapter ${id} not mounted` } }, { status: 404 });
2977
- }
2978
-
2979
2743
  // ---- Curve Designer: POST /signal {prompt} → next-token histogram over the curve's x-axis ----
2980
2744
  // One prefill forward; bins the real log-prob distribution so the editor can draw the
2981
2745
  // signal under the curve (you shape where the tokens actually are).
@@ -3064,15 +2828,27 @@ export function createServer(
3064
2828
  // translates its body into this shape and the Response back —
3065
2829
  // generation, tools, vision, stop sequences, prompt cache, and
3066
2830
  // admission control all live here exactly once.
3067
- const handleChat = async (body: ChatRequest, signal: AbortSignal): Promise<Response> => {
2831
+ const handleChat = async (
2832
+ body: ChatRequest,
2833
+ signal: AbortSignal,
2834
+ streamProtocol?: CompletionStreamProtocol,
2835
+ requestId?: string,
2836
+ trace?: PromptResponseTrace,
2837
+ ): Promise<Response> => {
3068
2838
  if (!Array.isArray(body.messages) || body.messages.length === 0)
3069
2839
  return Response.json({ error: { message: "messages required" } }, { status: 400 });
3070
2840
  // mlx-lm validates logprobs params up front (ValueError → 400)
3071
2841
  const lpParamError = validateLogprobsParams(body);
3072
2842
  if (lpParamError)
3073
2843
  return Response.json({ error: { message: lpParamError } }, { status: 400 });
3074
-
3075
- const id = `chatcmpl-${crypto.randomUUID()}`;
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");
3076
2852
  const created = Math.floor(Date.now() / 1000);
3077
2853
  const tools =
3078
2854
  body.tool_choice === "none" ? null : (body.tools?.length ? body.tools : null);
@@ -3088,6 +2864,13 @@ export function createServer(
3088
2864
  (p: any) => p.type === "input_audio" || p.type === "audio" || p.type === "audio_url",
3089
2865
  ),
3090
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
+ );
3091
2874
  let promptIds: number[];
3092
2875
  let stableLen: number | null = null;
3093
2876
  // Whether the prompt primed an open <think> (Qwen3.5/MiniCPM5 thinking
@@ -3096,6 +2879,7 @@ export function createServer(
3096
2879
  let startInThinking = false;
3097
2880
  let vision: Parameters<typeof runGeneration>[3];
3098
2881
  let diffusionPixels: import("./mlx/array").MlxArray | null = null;
2882
+ const ownership = new RequestOwnership();
3099
2883
  // Grammar-constrained decoding (src/grammar.ts). Compile BEFORE prompt
3100
2884
  // rendering: on the degrade path (compile failed but a constraint was
3101
2885
  // requested) inject a system message instructing valid JSON so the
@@ -3109,27 +2893,30 @@ export function createServer(
3109
2893
  body.structured_outputs != null;
3110
2894
  if (grammarReq) {
3111
2895
  const g = await compileGrammarForRequest(body);
3112
- grammarCtrl = g.controller;
2896
+ grammarCtrl = ownership.own(g.controller);
3113
2897
  if (!g.controller && g.degradeHint) {
3114
2898
  const degraded = applyGrammarDegrade(body, g.degradeHint);
3115
2899
  grammarWarning = degraded.warning;
3116
2900
  body = degraded.body;
3117
2901
  }
3118
2902
  }
3119
- // Free what a rejected request has already allocated (grammar WASM
3120
- // matcher/compiled-grammar, vision embeddings, diffusion pixels).
3121
- // Every early return between here and gateway.run() must call this —
3122
- // generate()/the gateway only take disposal ownership once running
3123
- // (leaked one matcher per SSRF-blocked-media + response_format
3124
- // request before the 2026-07-07 sweep).
3125
- const disposeRejected = () => {
3126
- grammarCtrl?.dispose();
3127
- vision?.embeddings.dispose();
3128
- vision?.imageMask?.dispose();
3129
- vision?.multimodalMask?.dispose();
3130
- diffusionPixels?.dispose();
2903
+ const rejectBeforeRun = (response: Response): Response => {
2904
+ ownership.dispose();
2905
+ return response;
3131
2906
  };
3132
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
+ }
3133
2920
  if (hasAudio) {
3134
2921
  // Audio (and MIXED image+audio) input — A4 of
3135
2922
  // docs/design/audio-input-plan.md. One buildMultimodalPrompt call
@@ -3139,8 +2926,7 @@ export function createServer(
3139
2926
  // requests without the media, getVisionTower's contract).
3140
2927
  const audioTower = getAudioTower(ctx);
3141
2928
  if (!audioTower || !ctx.audioTokenIds) {
3142
- disposeRejected();
3143
- return Response.json(
2929
+ return rejectBeforeRun(Response.json(
3144
2930
  {
3145
2931
  error: {
3146
2932
  message:
@@ -3150,7 +2936,7 @@ export function createServer(
3150
2936
  },
3151
2937
  },
3152
2938
  { status: 400 },
3153
- );
2939
+ ));
3154
2940
  }
3155
2941
  let visionSide:
3156
2942
  | { tower: VisionEncoder; tokenIds: VisionTokenIds }
@@ -3158,10 +2944,9 @@ export function createServer(
3158
2944
  if (hasImages) {
3159
2945
  const tower = getVisionTower(ctx);
3160
2946
  if (!tower) {
3161
- disposeRejected();
3162
- return Response.json(
2947
+ return rejectBeforeRun(Response.json(
3163
2948
  { error: { message: "model has no vision sidecar" } }, { status: 400 },
3164
- );
2949
+ ));
3165
2950
  }
3166
2951
  visionSide = { tower, tokenIds: ctx.visionTokenIds };
3167
2952
  }
@@ -3196,37 +2981,62 @@ export function createServer(
3196
2981
  // forwardEmbeddings path). v1 supports a single image.
3197
2982
  const dm = ctx.model;
3198
2983
  if (!dm.visionTower) {
3199
- disposeRejected();
3200
- return Response.json(
2984
+ return rejectBeforeRun(Response.json(
3201
2985
  { error: { message: "this checkpoint has no vision tower" } }, { status: 400 },
3202
- );
2986
+ ));
3203
2987
  }
3204
2988
  const { messages, images } = await extractImages(normalizeMessages(body.messages));
3205
2989
  if (images.length !== 1) {
3206
- disposeRejected();
3207
- return Response.json(
2990
+ return rejectBeforeRun(Response.json(
3208
2991
  { error: { message: "DiffusionGemma image input supports exactly one image" } },
3209
2992
  { status: 400 },
3210
- );
2993
+ ));
3211
2994
  }
3212
2995
  const rendered = ctx.template.render(messages, { tools, addGenerationPrompt: true });
3213
2996
  const rawIds = ctx.tokenizer.encode(rendered, /* addSpecialTokens */ false);
3214
2997
  const { pixels, softTokens } = await dm.visionTower.preprocess(images[0]!);
2998
+ diffusionPixels = ownership.own(pixels);
3215
2999
  promptIds = spliceImageTokens(rawIds, [softTokens], {
3216
3000
  image: ctx.visionTokenIds.imageTokenId,
3217
3001
  boi: ctx.visionTokenIds.boiTokenId,
3218
3002
  eoi: ctx.visionTokenIds.eoiTokenId,
3219
3003
  });
3220
- diffusionPixels = pixels;
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 };
3221
3032
  } else if (hasImages) {
3222
3033
  // Loads (and caches) the tower on first image request — text-only
3223
3034
  // sessions never pay for it.
3224
3035
  const tower = getVisionTower(ctx);
3225
3036
  if (!tower) {
3226
- disposeRejected();
3227
- return Response.json(
3037
+ return rejectBeforeRun(Response.json(
3228
3038
  { error: { message: "model has no vision sidecar" } }, { status: 400 },
3229
- );
3039
+ ));
3230
3040
  }
3231
3041
  const { messages, images } = await extractImages(normalizeMessages(body.messages));
3232
3042
  // The tower is only ever non-null for Gemma4 (sidecar gate in
@@ -3244,12 +3054,15 @@ export function createServer(
3244
3054
  stableLen = -1; // marker: chat path, probe lazily below
3245
3055
  }
3246
3056
  } catch (e) {
3247
- disposeRejected();
3248
- return Response.json(
3057
+ return rejectBeforeRun(Response.json(
3249
3058
  { error: { message: `prompt build failed: ${(e as Error).message}` } },
3250
3059
  { status: 400 },
3251
- );
3060
+ ));
3252
3061
  }
3062
+ ownership.own(vision?.embeddings);
3063
+ ownership.own(vision?.imageMask);
3064
+ ownership.own(vision?.multimodalMask);
3065
+ ownership.own(diffusionPixels);
3253
3066
  let options: ReturnType<typeof toOptions>;
3254
3067
  try {
3255
3068
  options = toOptions(body);
@@ -3267,97 +3080,81 @@ export function createServer(
3267
3080
  }
3268
3081
  } catch (e) {
3269
3082
  // bad logit_bias (non-numeric keys/values) — mlx-lm's coercion error
3270
- disposeRejected();
3271
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3083
+ return rejectBeforeRun(
3084
+ Response.json({ error: { message: (e as Error).message } }, { status: 400 }),
3085
+ );
3272
3086
  }
3273
3087
  if (diffusionPixels) options.visionPixels = diffusionPixels;
3274
3088
  // Attach the compiled grammar controller (null when no constraint /
3275
3089
  // degrade — generate() runs the unmasked fast pipelined loop).
3276
3090
  if (grammarCtrl) options.grammar = grammarCtrl;
3277
- // Admission: reject what cannot finish within the memory budget
3278
- // (the GPU OOM it would otherwise hit is uncatchable and kills
3279
- // the process — Phase 6 finding).
3280
- const requiredCtx = promptIds.length + (options.maxTokens ?? 1024);
3281
- if (requiredCtx > admission.maxSafeContext) {
3282
- disposeRejected();
3283
- return Response.json(
3284
- {
3285
- error: {
3286
- message:
3287
- `request needs ${requiredCtx} tokens of context ` +
3288
- `(prompt ${promptIds.length} + max_tokens ${options.maxTokens}) but the ` +
3289
- `memory budget caps safe context at ${admission.maxSafeContext} — ` +
3290
- `shorten the prompt or lower max_tokens`,
3291
- type: "memory_admission",
3292
- code: "context_over_budget",
3293
- },
3294
- },
3295
- { status: 400 },
3296
- );
3297
- }
3091
+ const requestedMaxTokens = options.maxTokens ?? 1024;
3092
+ let adapterIds: string[];
3298
3093
  try {
3299
3094
  // A request's explicit `adapter` (incl. "none") wins over the
3300
3095
  // startup default from `serve --adapter <dir>`.
3301
- const adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3302
- if (adapterIds.length) options.adapters = adapterIds;
3096
+ adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3303
3097
  } catch (e) {
3304
- disposeRejected();
3305
- return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3098
+ return rejectBeforeRun(
3099
+ Response.json({ error: { message: (e as Error).message } }, { status: 400 }),
3100
+ );
3306
3101
  }
3307
3102
 
3308
- // logprobs capture: non-stream only — stream chunks never carry
3309
- // logprobs (mirroring mlx_lm.server, whose streaming generate_response
3310
- // calls pass no token_logprobs/top_tokens), so streaming requests skip
3311
- // the capture cost entirely and stay batchable.
3312
3103
  const wantLogprobs = body.logprobs === true;
3313
3104
  const topLogprobs =
3314
3105
  typeof body.top_logprobs === "number" && body.top_logprobs > 0
3315
3106
  ? body.top_logprobs : 0;
3316
- const captureLogprobs = !body.stream && (wantLogprobs || topLogprobs > 0);
3317
- if (captureLogprobs) {
3318
- options.logprobs = wantLogprobs;
3319
- options.topLogprobs = topLogprobs;
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 });
3320
3156
  }
3321
-
3322
- // What lane this request takes (vision/audio media / adapters /
3323
- // logprobs / seed / explicit kv-quant / a mounted draft → serial;
3324
- // sampler extras, repetition penalty, and grammar all BATCH — see
3325
- // willBatch). `vision` is the embeddings-prefill payload for BOTH
3326
- // media kinds, so hasVision routes audio serial too — batching is a
3327
- // mode, not a fallback, and batched audio has no oracle (§3.2).
3328
- const shape = {
3329
- hasVision: !!vision,
3330
- hasAdapters: !!options.adapters?.length,
3331
- hasRepetitionPenalty: !!options.repetitionPenalty,
3332
- // Informational since 2026-07-02: per-row logits processors batch;
3333
- // willBatch no longer gates on these fields.
3334
- hasLogitsExtras: !!(
3335
- options.minP || options.xtcProbability || options.logitBias ||
3336
- options.presencePenalty || options.frequencyPenalty
3337
- ),
3338
- wantsLogprobs: captureLogprobs,
3339
- userSeed: body.seed !== undefined,
3340
- kvQuant: !!(options.kvConfig?.length || options.kvBits),
3341
- turboQuant: !!options.turboQuant,
3342
- // grammarCtrl is null on the degrade path (prompt injection) —
3343
- // those stay batchable. A real controller batches via per-row
3344
- // matchers (MLX_BUN_GRAMMAR_BATCH=0 forces it serial).
3345
- hasGrammar: !!grammarCtrl,
3346
- hasDraft: !!ctx.draft,
3347
- };
3348
- const batched = gateway.willBatch(shape);
3349
- if (process.env.MLX_BUN_LANE_DEBUG === "1")
3350
- console.error(`[lane] batched=${batched} shape=${JSON.stringify(shape)} t=${Date.now() % 100000}`);
3351
- // Per-turn lane (docs/design/web-chat-redesign.md §2.3 caveat / risk #5):
3352
- // reported on usage AND recorded in the in-process lane registry (keyed
3353
- // by this response's `id`) so pi-web's WS bridge — which the pi SDK's
3354
- // usage parsing can't carry a custom field through — can correlate it
3355
- // via AssistantMessage.responseId. batched wins over hasDraft (mutually
3356
- // exclusive by construction: willBatch already excludes hasDraft), so
3357
- // the only ambiguity is serial vs serial+spec, resolved once s.spec is
3358
- // known post-generation.
3359
- const lane: Lane = batched ? "batched" : shape.hasDraft ? "serial+spec" : "serial";
3360
- recordLane(id, lane);
3157
+ closePromptPrepare?.();
3361
3158
 
3362
3159
  if (body.stream) {
3363
3160
  const streamAbort = new AbortController();
@@ -3370,6 +3167,13 @@ export function createServer(
3370
3167
  if (!generationSignal.aborted)
3371
3168
  controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3372
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";
3373
3177
  const chunk = (delta: Record<string, unknown>, finish: string | null) => ({
3374
3178
  id, object: "chat.completion.chunk", created, model: ctx.modelId,
3375
3179
  choices: [{ index: 0, delta, finish_reason: finish }],
@@ -3379,92 +3183,65 @@ export function createServer(
3379
3183
  // The gateway owns lane selection + GPU exclusivity; this body
3380
3184
  // runs per-request (concurrently in batched mode, each writing
3381
3185
  // its own SSE stream — the per-row fan-out).
3382
- send(chunk({ role: "assistant", content: "" }, null));
3383
- const router = toolRouter(tools);
3384
- const stopper = new StopMatcher(options.stopSequences);
3385
- // <think>-text splitting is only for text-marker models; gemma's
3386
- // reasoning is already split at the token level by the router.
3387
- const thinking = new ThinkingTagSplitter(ctx.template.thinkingFormat === "think-tag", startInThinking);
3388
- // Serial decode is an unbroken microtask chain (FFI + generator
3389
- // resumes) — without a macrotask hop, Bun never services the
3390
- // socket and the whole SSE response flushes in one burst at the
3391
- // end (Phase 15: "687k tok/s decode"). Hopping EVERY token cost
3392
- // ~23% decode; rate-limited to ≥25 ms keeps the flush smooth and
3393
- // hides behind the next GPU step. Batched mode doesn't need it —
3394
- // the scheduler yields to the event loop between steps.
3395
- let lastFlush = performance.now();
3396
- const s = await gateway.run(promptIds, options, (token) => {
3397
- const rawContent = router.push(token);
3398
- // gemma-channel reasoning, split at the token level by the router
3399
- const rReason = router.takeReasoning();
3400
- if (rReason) send(chunk({ reasoning: rReason }, null));
3401
- const text = stopper.push(rawContent);
3402
- const parts = thinking.push(text);
3403
- if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
3404
- if (parts.content) send(chunk({ content: parts.content }, null));
3405
- if (stopper.stopped) return false; // halt generation
3406
- if (!batched && (parts.content || parts.reasoning || rReason)) {
3407
- const now = performance.now();
3408
- if (now - lastFlush >= 25) {
3409
- lastFlush = now;
3410
- return new Promise<void>((r) => setImmediate(r));
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));
3411
3206
  }
3412
3207
  }
3413
- }, vision, shape, generationSignal);
3414
- if (generationSignal.aborted) return;
3415
- // a stop match discards everything from the match on,
3416
- // including text still held by the decoders
3417
- let tail = "";
3418
- if (!stopper.stopped) {
3419
- const flushed = router.flush();
3420
- const rReason = router.takeReasoning();
3421
- if (rReason) send(chunk({ reasoning: rReason }, null));
3422
- tail = stopper.push(flushed);
3423
- if (!stopper.stopped) tail += stopper.flush();
3424
- }
3425
- if (tail) {
3426
- const parts = thinking.push(tail);
3427
- if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
3428
- if (parts.content) send(chunk({ content: parts.content }, null));
3429
- }
3430
- {
3431
- const parts = thinking.flush();
3432
- if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
3433
- if (parts.content) send(chunk({ content: parts.content }, null));
3434
- }
3435
- const toolCalls = router.toolCalls();
3436
- if (toolCalls.length) {
3437
- send(chunk({
3438
- tool_calls: toolCalls.map((tc, i) => ({ index: i, ...tc })),
3439
- }, null));
3440
- }
3441
- const finish = toolCalls.length
3442
- ? "tool_calls"
3443
- : stopper.stopped ? "stop"
3444
- : s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
3445
- // Refine serial vs serial+spec now that s.spec is known (a mounted
3446
- // draft can still decode zero speculative tokens on a very short
3447
- // reply) and re-record for pi-web's WS correlation.
3448
- const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
3449
- if (finalLane !== lane) recordLane(id, finalLane);
3450
- send({
3451
- ...chunk({}, finish),
3452
- usage: {
3453
- prompt_tokens: s.promptTokens,
3454
- completion_tokens: s.generatedTokens,
3455
- total_tokens: s.promptTokens + s.generatedTokens,
3456
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3457
- lane: finalLane,
3458
- ...(s.spec ? { speculation: s.spec } : {}),
3459
- },
3208
+ };
3209
+ const summary = await completionExecutor.execute(prepared, {
3210
+ signal: generationSignal,
3211
+ trace,
3212
+ onEvents: sendEvents,
3213
+ ...(streamProtocol
3214
+ ? { onUsageProgress: (usage) => { latestUsage = usage; } }
3215
+ : {}),
3460
3216
  });
3461
- // bare sentinel per the OpenAI spec — JSON.stringify would
3462
- // quote it and strict SDK clients never see the terminator
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.
3463
3224
  controller.enqueue(enc.encode("data: [DONE]\n\n"));
3225
+ }
3226
+ trace?.mark("response.final_write");
3464
3227
  } catch (e) {
3465
- if (!generationSignal.aborted)
3466
- send({ error: { message: (e as Error).message } });
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
+ }
3467
3243
  } finally {
3244
+ trace?.finish(traceOutcome);
3468
3245
  if (!cancelled) {
3469
3246
  if (generationSignal.aborted) controller.error(generationSignal.reason);
3470
3247
  else controller.close();
@@ -3489,85 +3266,53 @@ export function createServer(
3489
3266
 
3490
3267
  try {
3491
3268
  {
3492
- const router = toolRouter(tools);
3493
- const stopper = new StopMatcher(options.stopSequences);
3494
- const thinking = new ThinkingTagSplitter(ctx.template.thinkingFormat === "think-tag", startInThinking);
3495
- // mlx-lm collects logprobs across EVERY generated token (reasoning
3496
- // and tool tokens included), not just visible content — same here.
3497
- const lpc = captureLogprobs
3498
- ? new LogprobsCollector(wantLogprobs, topLogprobs, (tid) => ctx.tokenizer.idToToken(tid))
3499
- : null;
3500
- let content = "";
3501
- let reasoning = "";
3502
- const s = await gateway.run(promptIds, options, (token, lpInfo) => {
3503
- lpc?.push(token, lpInfo);
3504
- const rawContent = router.push(token);
3505
- reasoning += router.takeReasoning(); // gemma-channel thinking
3506
- const parts = thinking.push(stopper.push(rawContent));
3507
- content += parts.content;
3508
- reasoning += parts.reasoning;
3509
- if (stopper.stopped) return false; // halt generation
3510
- }, vision, shape, signal);
3511
- if (!stopper.stopped) {
3512
- const flushed = router.flush();
3513
- reasoning += router.takeReasoning();
3514
- let tail = stopper.push(flushed);
3515
- if (!stopper.stopped) tail += stopper.flush();
3516
- const parts = thinking.push(tail);
3517
- content += parts.content;
3518
- reasoning += parts.reasoning;
3519
- }
3520
- {
3521
- const parts = thinking.flush();
3522
- content += parts.content;
3523
- reasoning += parts.reasoning;
3524
- }
3525
- const toolCalls = router.toolCalls();
3526
- const finish = toolCalls.length
3527
- ? "tool_calls"
3528
- : stopper.stopped ? "stop"
3529
- : s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
3530
- const logprobsBlock = lpc?.payload() ?? null;
3531
- // Refine serial vs serial+spec now that s.spec is known; re-record
3532
- // for pi-web's WS correlation (see the streaming branch above).
3533
- const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
3534
- if (finalLane !== lane) recordLane(id, finalLane);
3535
- return Response.json({
3269
+ const summary = await completionExecutor.execute(prepared, { signal, trace });
3270
+ trace?.mark("response.final_write");
3271
+ const response = Response.json({
3536
3272
  id, object: "chat.completion", created, model: ctx.modelId,
3537
3273
  choices: [{
3538
3274
  index: 0,
3539
3275
  message: {
3540
3276
  role: "assistant",
3541
- content: content || (toolCalls.length ? null : ""),
3542
- ...(reasoning ? { reasoning } : {}),
3543
- ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
3277
+ content: summary.content || (summary.toolCalls.length ? null : ""),
3278
+ ...(summary.reasoning ? { reasoning: summary.reasoning } : {}),
3279
+ ...(summary.toolCalls.length ? { tool_calls: summary.toolCalls } : {}),
3544
3280
  },
3545
- ...(logprobsBlock ? { logprobs: logprobsBlock } : {}),
3546
- finish_reason: finish,
3281
+ ...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
3282
+ finish_reason: summary.finishReason,
3547
3283
  }],
3548
- usage: {
3549
- prompt_tokens: s.promptTokens,
3550
- completion_tokens: s.generatedTokens,
3551
- total_tokens: s.promptTokens + s.generatedTokens,
3552
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3553
- lane: finalLane,
3554
- ...(s.spec ? { speculation: s.spec } : {}),
3555
- },
3284
+ usage: completionUsage(summary),
3556
3285
  }, grammarWarning ? { headers: { Warning: grammarWarning } } : undefined);
3286
+ trace?.finish("success");
3287
+ return response;
3557
3288
  }
3558
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}`);
3559
3294
  return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
3560
3295
  }
3561
3296
  };
3562
3297
 
3563
3298
  if (url.pathname === "/v1/chat/completions" && request.method === "POST") {
3299
+ const id = `chatcmpl-${crypto.randomUUID()}`;
3300
+ const trace = createPromptResponseTrace({
3301
+ traceId: request.headers.get("x-mlx-bun-trace-id") ?? id,
3302
+ requestId: id,
3303
+ route: url.pathname,
3304
+ });
3305
+ const closeBodyParse = trace?.begin("request.body_parse");
3564
3306
  let body: ChatRequest;
3565
3307
  try {
3566
3308
  body = (await request.json()) as ChatRequest;
3567
3309
  } catch {
3310
+ closeBodyParse?.();
3311
+ trace?.finish("error", { stage: "body_parse" });
3568
3312
  return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3569
3313
  }
3570
- return handleChat(body, request.signal);
3314
+ closeBodyParse?.();
3315
+ return handleChat(body, request.signal, undefined, id, trace);
3571
3316
  }
3572
3317
 
3573
3318
  // Raw text completion (mlx_lm.server's /v1/completions, request_type
@@ -3577,12 +3322,23 @@ export function createServer(
3577
3322
  // GenerationGateway + admission + adapter path as chat; no tool router
3578
3323
  // or thinking splitter (raw text in, raw text out).
3579
3324
  if (url.pathname === "/v1/completions" && request.method === "POST") {
3325
+ const id = `cmpl-${crypto.randomUUID()}`;
3326
+ const trace = createPromptResponseTrace({
3327
+ traceId: request.headers.get("x-mlx-bun-trace-id") ?? id,
3328
+ requestId: id,
3329
+ route: url.pathname,
3330
+ });
3331
+ const closeBodyParse = trace?.begin("request.body_parse");
3580
3332
  let body: TextCompletionRequest;
3581
3333
  try {
3582
3334
  body = (await request.json()) as TextCompletionRequest;
3583
3335
  } catch {
3336
+ closeBodyParse?.();
3337
+ trace?.finish("error", { stage: "body_parse" });
3584
3338
  return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3585
3339
  }
3340
+ closeBodyParse?.();
3341
+ const closePromptPrepare = trace?.begin("request.prompt_prepare");
3586
3342
  if (typeof body.prompt !== "string" || body.prompt.length === 0)
3587
3343
  return Response.json(
3588
3344
  {
@@ -3597,9 +3353,9 @@ export function createServer(
3597
3353
  const lpParamError = validateLogprobsParams(body);
3598
3354
  if (lpParamError)
3599
3355
  return Response.json({ error: { message: lpParamError } }, { status: 400 });
3600
- const id = `cmpl-${crypto.randomUUID()}`;
3601
3356
  const created = Math.floor(Date.now() / 1000);
3602
3357
  const promptIds = ctx.tokenizer.encode(body.prompt);
3358
+ const ownership = new RequestOwnership();
3603
3359
  let options: ReturnType<typeof toOptions>;
3604
3360
  try {
3605
3361
  options = toOptions(body as unknown as ChatRequest);
@@ -3619,77 +3375,64 @@ export function createServer(
3619
3375
  body.structured_outputs != null;
3620
3376
  if (textGrammarReq) {
3621
3377
  const g = await compileGrammarForRequest(body as unknown as ChatRequest);
3622
- if (g.controller) options.grammar = g.controller;
3378
+ if (g.controller) options.grammar = ownership.own(g.controller);
3623
3379
  else if (g.degradeHint)
3624
3380
  textGrammarWarning =
3625
3381
  `grammar not enforced: ${g.degradeHint} - no prompt injection on /v1/completions`;
3626
3382
  }
3627
- // Mirrors the chat lane: a real controller (not the degrade path)
3628
- // shapes the request for per-row grammar batching.
3629
- const textGrammarCtrl = options.grammar ?? null;
3630
3383
  // mlx_lm.server's default max_tokens is 512 (its --max-tokens CLI
3631
3384
  // default). The chat lane's very generous default is wrong for raw
3632
3385
  // completion: with no template an EOS may never come.
3633
- options.maxTokens = body.max_completion_tokens ?? body.max_tokens ??
3386
+ const requestedMaxTokens = body.max_completion_tokens ?? body.max_tokens ??
3634
3387
  defaultGeneratedTokens ?? 512;
3635
- const requiredCtx = promptIds.length + options.maxTokens;
3636
- if (requiredCtx > admission.maxSafeContext) {
3637
- // Pre-run reject: the gateway only takes grammar disposal ownership
3638
- // once run — free the WASM matcher here (chat lane: disposeRejected).
3639
- textGrammarCtrl?.dispose();
3640
- return Response.json(
3641
- {
3642
- error: {
3643
- message:
3644
- `request needs ${requiredCtx} tokens of context ` +
3645
- `(prompt ${promptIds.length} + max_tokens ${options.maxTokens}) but the ` +
3646
- `memory budget caps safe context at ${admission.maxSafeContext} — ` +
3647
- `shorten the prompt or lower max_tokens`,
3648
- type: "memory_admission",
3649
- code: "context_over_budget",
3650
- },
3651
- },
3652
- { status: 400 },
3653
- );
3654
- }
3388
+ let adapterIds: string[];
3655
3389
  try {
3656
- const adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3657
- if (adapterIds.length) options.adapters = adapterIds;
3390
+ adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3658
3391
  } catch (e) {
3659
- textGrammarCtrl?.dispose();
3392
+ ownership.dispose();
3660
3393
  return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
3661
3394
  }
3662
- // logprobs capture — same mlx-lm block as chat (generate_response is
3663
- // shared in the reference too); non-stream only, stream chunks never
3664
- // carry logprobs.
3665
3395
  const wantLogprobs = body.logprobs === true;
3666
3396
  const topLogprobs =
3667
3397
  typeof body.top_logprobs === "number" && body.top_logprobs > 0
3668
3398
  ? body.top_logprobs : 0;
3669
- const captureLogprobs = !body.stream && (wantLogprobs || topLogprobs > 0);
3670
- if (captureLogprobs) {
3671
- options.logprobs = wantLogprobs;
3672
- options.topLogprobs = topLogprobs;
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 });
3673
3434
  }
3674
- const shape = {
3675
- hasVision: false,
3676
- hasAdapters: !!options.adapters?.length,
3677
- hasRepetitionPenalty: !!options.repetitionPenalty,
3678
- hasLogitsExtras: !!(
3679
- options.minP || options.xtcProbability || options.logitBias ||
3680
- options.presencePenalty || options.frequencyPenalty
3681
- ),
3682
- wantsLogprobs: captureLogprobs,
3683
- userSeed: body.seed !== undefined,
3684
- kvQuant: !!(options.kvConfig?.length || options.kvBits),
3685
- turboQuant: !!options.turboQuant,
3686
- // /v1/completions grammar (textGrammarCtrl). Same null-on-degrade
3687
- // contract as the chat lane.
3688
- hasGrammar: !!textGrammarCtrl,
3689
- hasDraft: !!ctx.draft,
3690
- };
3691
- const finishReason = (stopped: boolean, generated: number): "stop" | "length" =>
3692
- stopped ? "stop" : generated >= (options.maxTokens ?? 512) ? "length" : "stop";
3435
+ closePromptPrepare?.();
3693
3436
 
3694
3437
  if (body.stream) {
3695
3438
  const streamAbort = new AbortController();
@@ -3706,49 +3449,39 @@ export function createServer(
3706
3449
  id, object: "text_completion", created, model: ctx.modelId,
3707
3450
  choices: [{ index: 0, text, finish_reason: finish }],
3708
3451
  });
3452
+ let wroteFirstEvent = false;
3453
+ let traceOutcome: "success" | "error" | "abort" = "success";
3709
3454
  void (async () => {
3710
3455
  try {
3711
- const decoder = new StreamDecoder(ctx.tokenizer);
3712
- const stopper = new StopMatcher(options.stopSequences);
3713
- // ≥25 ms macrotask hop, same reason as the chat lane: keep the
3714
- // serial decode loop from starving the socket (SSE bursts).
3715
- let lastFlush = performance.now();
3716
- const s = await gateway.run(promptIds, options, (token) => {
3717
- const text = stopper.push(decoder.push(token));
3718
- if (text) send(chunk(text, null));
3719
- if (stopper.stopped) return false; // halt generation
3720
- if (text) {
3721
- const now = performance.now();
3722
- if (now - lastFlush >= 25) {
3723
- lastFlush = now;
3724
- return new Promise<void>((r) => setImmediate(r));
3725
- }
3456
+ const sendEvents = (events: readonly CompletionEvent[]) => {
3457
+ if (events.length && !wroteFirstEvent) {
3458
+ wroteFirstEvent = true;
3459
+ trace?.mark("response.first_write");
3726
3460
  }
3727
- }, undefined, shape, generationSignal);
3728
- if (generationSignal.aborted) return;
3729
- if (!stopper.stopped) {
3730
- let tail = stopper.push(decoder.flush());
3731
- if (!stopper.stopped) tail += stopper.flush();
3732
- if (tail) send(chunk(tail, null));
3733
- }
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
+ });
3734
3470
  // final chunk: finish_reason + usage (mlx-lm gates usage behind
3735
3471
  // stream_options.include_usage; we always attach it, matching
3736
3472
  // our chat lane — an additive superset OpenAI clients ignore)
3737
3473
  send({
3738
- ...chunk("", finishReason(stopper.stopped, s.generatedTokens)),
3739
- usage: {
3740
- prompt_tokens: s.promptTokens,
3741
- completion_tokens: s.generatedTokens,
3742
- total_tokens: s.promptTokens + s.generatedTokens,
3743
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3744
- ...(s.spec ? { speculation: s.spec } : {}),
3745
- },
3474
+ ...chunk("", summary.finishReason),
3475
+ usage: completionUsage(summary),
3746
3476
  });
3747
3477
  controller.enqueue(enc.encode("data: [DONE]\n\n"));
3478
+ trace?.mark("response.final_write");
3748
3479
  } catch (e) {
3480
+ traceOutcome = generationSignal.aborted ? "abort" : "error";
3749
3481
  if (!generationSignal.aborted)
3750
3482
  send({ error: { message: (e as Error).message } });
3751
3483
  } finally {
3484
+ trace?.finish(traceOutcome);
3752
3485
  if (!cancelled) {
3753
3486
  if (generationSignal.aborted) controller.error(generationSignal.reason);
3754
3487
  else controller.close();
@@ -3772,39 +3505,24 @@ export function createServer(
3772
3505
  }
3773
3506
 
3774
3507
  try {
3775
- const decoder = new StreamDecoder(ctx.tokenizer);
3776
- const stopper = new StopMatcher(options.stopSequences);
3777
- const lpc = captureLogprobs
3778
- ? new LogprobsCollector(wantLogprobs, topLogprobs, (tid) => ctx.tokenizer.idToToken(tid))
3779
- : null;
3780
- let text = "";
3781
- const s = await gateway.run(promptIds, options, (token, lpInfo) => {
3782
- lpc?.push(token, lpInfo);
3783
- text += stopper.push(decoder.push(token));
3784
- if (stopper.stopped) return false; // halt generation
3785
- }, undefined, shape, request.signal);
3786
- if (!stopper.stopped) {
3787
- let tail = stopper.push(decoder.flush());
3788
- if (!stopper.stopped) tail += stopper.flush();
3789
- text += tail;
3790
- }
3791
- const logprobsBlock = lpc?.payload() ?? null;
3792
- return Response.json({
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({
3793
3514
  id, object: "text_completion", created, model: ctx.modelId,
3794
3515
  choices: [{
3795
- index: 0, text,
3796
- ...(logprobsBlock ? { logprobs: logprobsBlock } : {}),
3797
- finish_reason: finishReason(stopper.stopped, s.generatedTokens),
3516
+ index: 0, text: summary.content,
3517
+ ...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
3518
+ finish_reason: summary.finishReason,
3798
3519
  }],
3799
- usage: {
3800
- prompt_tokens: s.promptTokens,
3801
- completion_tokens: s.generatedTokens,
3802
- total_tokens: s.promptTokens + s.generatedTokens,
3803
- prompt_tokens_details: { cached_tokens: s.cachedTokens },
3804
- ...(s.spec ? { speculation: s.spec } : {}),
3805
- },
3520
+ usage: completionUsage(summary),
3806
3521
  }, textGrammarWarning ? { headers: { Warning: textGrammarWarning } } : undefined);
3522
+ trace?.finish("success");
3523
+ return response;
3807
3524
  } catch (e) {
3525
+ trace?.finish(request.signal.aborted ? "abort" : "error");
3808
3526
  return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
3809
3527
  }
3810
3528
  }
@@ -3829,7 +3547,13 @@ export function createServer(
3829
3547
  } catch (e) {
3830
3548
  return anthropicError(400, "invalid_request_error", (e as Error).message);
3831
3549
  }
3832
- const resp = await handleChat(chatBody, request.signal);
3550
+ const resp = await handleChat(
3551
+ chatBody,
3552
+ request.signal,
3553
+ anthropicBody.stream
3554
+ ? createAnthropicStreamProtocol(ctx.modelId)
3555
+ : undefined,
3556
+ );
3833
3557
  if (!resp.ok) {
3834
3558
  const err = (await resp.json().catch(() => null)) as
3835
3559
  | { error?: { message?: string } }
@@ -3841,13 +3565,7 @@ export function createServer(
3841
3565
  );
3842
3566
  }
3843
3567
  if (anthropicBody.stream) {
3844
- return new Response(translateOpenAiSse(resp.body!, ctx.modelId), {
3845
- headers: {
3846
- "content-type": "text/event-stream",
3847
- "cache-control": "no-cache",
3848
- connection: "keep-alive",
3849
- },
3850
- });
3568
+ return resp;
3851
3569
  }
3852
3570
  return Response.json(chatJsonToAnthropic(await resp.json(), ctx.modelId));
3853
3571
  }
@@ -3910,7 +3628,22 @@ export function createServer(
3910
3628
  } catch (e) {
3911
3629
  return responsesError(400, (e as Error).message);
3912
3630
  }
3913
- const resp = await handleChat(chatBody, request.signal);
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
+ );
3914
3647
  if (!resp.ok) {
3915
3648
  const err = (await resp.json().catch(() => null)) as
3916
3649
  | { error?: { message?: string } }
@@ -3918,22 +3651,7 @@ export function createServer(
3918
3651
  return responsesError(resp.status, err?.error?.message ?? "request failed");
3919
3652
  }
3920
3653
  if (responsesBody.stream) {
3921
- const body = translateOpenAiSseToResponses(
3922
- resp.body!, ctx.modelId, prevId,
3923
- (final) =>
3924
- responseStore.put(final.id as string, {
3925
- input: capturedInput,
3926
- output: final.output as unknown[],
3927
- instructions: capturedInstructions,
3928
- }),
3929
- );
3930
- return new Response(body, {
3931
- headers: {
3932
- "content-type": "text/event-stream",
3933
- "cache-control": "no-cache",
3934
- connection: "keep-alive",
3935
- },
3936
- });
3654
+ return resp;
3937
3655
  }
3938
3656
  const responses = chatJsonToResponses(await resp.json(), ctx.modelId, prevId);
3939
3657
  responseStore.put(responses.id as string, {
@@ -3944,346 +3662,29 @@ export function createServer(
3944
3662
  return Response.json(responses);
3945
3663
  }
3946
3664
 
3947
- // --- Lab API: dataset builder + quantize + finetune + jobs -------
3948
- if (url.pathname === "/api/dataset/templates" && request.method === "GET") {
3949
- const { TEMPLATES } = await import("./dataset");
3950
- return Response.json({ templates: TEMPLATES });
3951
- }
3952
- if (url.pathname === "/api/dataset/submit" && request.method === "POST") {
3953
- const body = (await request.json().catch(() => ({}))) as {
3954
- template_id?: string; inputs?: Record<string, unknown>; model_name?: string;
3955
- };
3956
- const { getTemplate } = await import("./dataset");
3957
- if (!body.template_id || !getTemplate(body.template_id))
3958
- return Response.json({ ok: false, error: `unknown template ${body.template_id}` }, { status: 400 });
3959
- const store = await ensureJobs();
3960
- const { submitInProcess } = await import("./jobs");
3961
- const { homedir } = await import("node:os");
3962
- const safe = body.template_id.replace(/[^a-z0-9_-]/gi, "");
3963
- const outDir = `${homedir()}/.cache/mlx-bun/datasets/dataset-${safe}-${Date.now()}`;
3964
- const { jobId } = submitInProcess(store, "dataset", {
3965
- template_id: body.template_id, inputs: body.inputs ?? {},
3966
- output_dir: outDir, api_url: `http://127.0.0.1:${server.port}`,
3967
- model_name: body.model_name ?? "local",
3968
- }, outDir);
3969
- return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
3970
- }
3971
-
3972
- if (url.pathname === "/api/quantize/inspect" && request.method === "POST") {
3973
- const body = (await request.json().catch(() => ({}))) as { model_id?: string };
3974
- const { inspectModel } = await import("./quantize");
3975
- return Response.json(await inspectModel(body.model_id ?? ""));
3976
- }
3977
- // Turn an OS-picked folder into the absolute snapshot path on disk. The
3978
- // browser can't reveal a filesystem path (security) and the HF cache
3979
- // stores real bytes in blobs/ with symlinked snapshots — so we resolve by
3980
- // the folder's NAME (which encodes the repo id), never by reading files.
3981
- // No upload, no dependence on the cache's symlink layout, and a
3982
- // just-downloaded model resolves before it's ever been indexed.
3983
- if ((url.pathname === "/api/quantize/resolve-folder" || url.pathname === "/api/model/resolve-folder") && request.method === "POST") {
3984
- const body = (await request.json().catch(() => ({}))) as { folder_name?: string; rel_path?: string };
3985
- const { statSync, readdirSync, readFileSync } = await import("node:fs");
3986
- const { join } = await import("node:path");
3987
- const { homedir } = await import("node:os");
3988
- const hubRoot = process.env.HF_HUB_CACHE
3989
- ?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
3990
- const roots = [hubRoot, join(homedir(), ".cache/mlx-bun")];
3991
- const hasConfig = (d: string) => { try { return statSync(join(d, "config.json")).isFile(); } catch { return false; } };
3992
- // basename of the picked folder; the rel path's last-but-one segment is
3993
- // the <hash> dir if they drilled into a snapshot.
3994
- const folder = (body.folder_name ?? "").replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
3995
- const relSegs = (body.rel_path ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
3996
- const configDir = relSegs.length >= 2 ? relSegs[relSegs.length - 2] : "";
3997
- const repoIdOf = (modelsDir: string) => modelsDir.slice("models--".length).replaceAll("--", "/");
3998
- // Pick a repo dir's canonical snapshot: the one refs/main points at, else
3999
- // the first snapshot that actually has a config.json.
4000
- const pickSnapshot = (repoDir: string): string | null => {
4001
- const snaps = join(repoDir, "snapshots");
4002
- let head = "";
4003
- try { head = readFileSync(join(repoDir, "refs", "main"), "utf8").trim(); } catch {}
4004
- if (head && hasConfig(join(snaps, head))) return join(snaps, head);
4005
- try { for (const h of readdirSync(snaps)) if (hasConfig(join(snaps, h))) return join(snaps, h); } catch {}
4006
- return null;
4007
- };
4008
-
4009
- // (1) HF-cache folder picked — its name IS the repo id.
4010
- if (folder.startsWith("models--")) {
4011
- const p = pickSnapshot(join(hubRoot, folder));
4012
- if (p) return Response.json({ ok: true, path: p, repo_id: repoIdOf(folder) });
4013
- }
4014
- // (2) A bare <hash> snapshot folder picked directly (or carried in rel).
4015
- const hashDir = configDir || folder;
4016
- if (hashDir) {
4017
- try {
4018
- for (const repo of readdirSync(hubRoot)) {
4019
- if (!repo.startsWith("models--")) continue;
4020
- const cand = join(hubRoot, repo, "snapshots", hashDir);
4021
- if (hasConfig(cand)) return Response.json({ ok: true, path: cand, repo_id: repoIdOf(repo) });
4022
- }
4023
- } catch {}
4024
- }
4025
- // (3) Flat local snapshot under a known root (folder name == model dir).
4026
- for (const root of roots) {
4027
- if (folder && hasConfig(join(root, folder))) return Response.json({ ok: true, path: join(root, folder) });
4028
- }
4029
- // (4) Registry rescan — last resort (also covers customized cache layouts).
4030
- const { Registry } = await import("./registry");
4031
- const reg = new Registry();
4032
- try {
4033
- await reg.scan();
4034
- const all = reg.list();
4035
- const rec = (folder.startsWith("models--")
4036
- ? all.find((m) => m.repoId === repoIdOf(folder))
4037
- : undefined)
4038
- ?? all.find((m) => m.path.split("/").pop() === hashDir)
4039
- ?? all.find((m) => m.repoId.split("/").pop() === folder);
4040
- if (rec) return Response.json({ ok: true, path: rec.path, repo_id: rec.repoId, model_type: rec.modelType });
4041
- } finally {
4042
- reg.close();
4043
- }
4044
- return Response.json({
4045
- ok: false,
4046
- error: "Couldn't locate this folder on disk — paste the path instead.",
4047
- });
4048
- }
4049
- if (url.pathname === "/api/quantize/submit" && request.method === "POST") {
4050
- const body = (await request.json().catch(() => ({}))) as {
4051
- model_id?: string; bits?: number; group_size?: number;
4052
- target_bpw?: number; candidate_bits?: number[]; reference?: string;
4053
- calibration_mix?: string; n_calibration?: number;
4054
- };
4055
- if (!body.model_id)
4056
- return Response.json({ ok: false, error: "model_id required" }, { status: 400 });
4057
- const store = await ensureJobs();
4058
- const { submitSubprocess } = await import("./jobs");
4059
- const { homedir } = await import("node:os");
4060
- const { join } = await import("node:path");
4061
- const { mkdirSync, writeFileSync } = await import("node:fs");
4062
- const { createHash } = await import("node:crypto");
4063
- const bits = body.bits ?? 4, gs = body.group_size ?? 64;
4064
- // Derive org/name from the source so the quant is named readably (the
4065
- // source is usually an HF-cache snapshot path whose basename is a hash).
4066
- const snapMatch = body.model_id.match(/(models--[^/]+)\/snapshots\//);
4067
- let org = "local", name: string;
4068
- if (snapMatch) {
4069
- const parts = snapMatch[1]!.split("--"); // ["models", org, ...name]
4070
- org = parts[1] ?? "local";
4071
- name = parts.slice(2).join("--");
4072
- } else if (body.model_id.includes("/") && !body.model_id.startsWith("/") && !body.model_id.startsWith("~")) {
4073
- const seg = body.model_id.split("/"); // a repo id "org/name"
4074
- org = seg[0]!; name = seg.slice(1).join("-");
4075
- } else {
4076
- name = body.model_id.split("/").filter(Boolean).at(-1) ?? "model"; // a bare path
4077
- }
4078
- name = (name || "model").replace(/[^a-z0-9_.-]/gi, "");
4079
- org = (org || "local").replace(/[^a-z0-9_.-]/gi, "");
4080
- const suffix = body.target_bpw ? `mixed-${body.target_bpw}bpw` : `${bits}bit`;
4081
- // Write the quant INTO the HF hub cache as a normal models--org--name/
4082
- // snapshots/<hash> entry, so the standard registry scan + every other
4083
- // tool discovers it alongside downloaded models. refs/main makes it a
4084
- // well-formed cache entry.
4085
- const quantRepo = `${name}-OptiQ-${suffix}`;
4086
- const hubRoot = process.env.HF_HUB_CACHE
4087
- ?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
4088
- const repoDir = join(hubRoot, `models--${org}--${quantRepo}`);
4089
- const snapHash = createHash("sha1").update(`${org}/${quantRepo}`).digest("hex");
4090
- const outDir = join(repoDir, "snapshots", snapHash);
4091
- try {
4092
- mkdirSync(join(repoDir, "refs"), { recursive: true });
4093
- writeFileSync(join(repoDir, "refs", "main"), snapHash);
4094
- } catch {}
4095
- const { jobId } = submitSubprocess(store, "quantize", {
4096
- model_id: body.model_id, out_dir: outDir, bits, group_size: gs,
4097
- // forwarded to the mixed-precision path when target_bpw is set
4098
- target_bpw: body.target_bpw, candidate_bits: body.candidate_bits,
4099
- reference: body.reference, calibration_mix: body.calibration_mix,
4100
- n_calibration: body.n_calibration,
4101
- }, outDir, {
4102
- // The model is in the HF cache now; drop the Library cache so the next
4103
- // poll rescans and shows it — no `mlx-bun scan`, no restart.
4104
- onComplete: () => { libraryCache = null; },
4105
- });
4106
- return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
4107
- }
4108
-
4109
- if (url.pathname === "/api/finetune/inspect-dataset" && request.method === "POST") {
4110
- const body = (await request.json().catch(() => ({}))) as { path?: string };
4111
- const { inspectDataset } = await import("./train");
4112
- return Response.json(await inspectDataset(body.path ?? ""));
4113
- }
4114
- if (url.pathname === "/api/finetune/submit" && request.method === "POST") {
4115
- const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
4116
- if (!body.model_dir || !body.data_dir)
4117
- return Response.json({ ok: false, error: "model_dir and data_dir required" }, { status: 400 });
4118
- const store = await ensureJobs();
4119
- const { submitSubprocess } = await import("./jobs");
4120
- const { homedir } = await import("node:os");
4121
- const adapterPath = (body.adapter_path as string) ||
4122
- `${homedir()}/.cache/mlx-bun/adapters/adapter-${Date.now()}`;
4123
- const { jobId } = submitSubprocess(store, "finetune",
4124
- { ...body, adapter_path: adapterPath }, adapterPath);
4125
- return Response.json({ ok: true, job_id: jobId, adapter_path: adapterPath });
4126
- }
4127
- if (url.pathname === "/api/finetune/merge" && request.method === "POST") {
4128
- const body = (await request.json().catch(() => ({}))) as {
4129
- adapter_a?: string; adapter_b?: string; scales?: number[];
4130
- };
4131
- if (!body.adapter_a || !body.adapter_b)
4132
- return Response.json({ ok: false, error: "adapter_a and adapter_b required" }, { status: 400 });
4133
- try {
4134
- const { mergeAdapters } = await import("./train");
4135
- const { homedir } = await import("node:os");
4136
- const mergedPath = `${homedir()}/.cache/mlx-bun/adapters/merged-${Date.now()}`;
4137
- const stats = await mergeAdapters([body.adapter_a, body.adapter_b], mergedPath, body.scales);
4138
- return Response.json({ ok: true, merged_path: mergedPath, stats });
4139
- } catch (e) {
4140
- return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
4141
- }
4142
- }
4143
- if (url.pathname === "/api/finetune/export" && request.method === "POST") {
4144
- const body = (await request.json().catch(() => ({}))) as {
4145
- base_model?: string; adapter_path?: string; method?: string;
4146
- };
4147
- if (!body.base_model || !body.adapter_path)
4148
- return Response.json({ ok: false, error: "base_model and adapter_path required" }, { status: 400 });
4149
- try {
4150
- const { exportAdapter } = await import("./train");
4151
- const { homedir } = await import("node:os");
4152
- const exportPath = `${homedir()}/.cache/mlx-bun/exports/export-${Date.now()}`;
4153
- const manifest = await exportAdapter(exportPath, body.base_model, body.adapter_path, body.method);
4154
- return Response.json({ ok: true, export_path: exportPath, manifest });
4155
- } catch (e) {
4156
- return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
4157
- }
4158
- }
4159
-
4160
- // --- HF token settings + push-to-hub (model & dataset repos) ------
4161
- if (url.pathname === "/api/settings/hf-token" && request.method === "GET") {
4162
- const { hasHfToken } = await import("./hf-push");
4163
- return Response.json({ ok: true, hasToken: hasHfToken() });
4164
- }
4165
- if (url.pathname === "/api/settings/hf-token" && request.method === "POST") {
4166
- const body = (await request.json().catch(() => ({}))) as { token?: string };
4167
- if (!body.token) return Response.json({ ok: false, error: "token required" }, { status: 400 });
4168
- const { saveHfToken } = await import("./hf-push");
4169
- saveHfToken(body.token);
4170
- return Response.json({ ok: true });
4171
- }
4172
-
4173
- // --- Durable tool-approvals settings (plan §5.4/§6.5/§9 Phase 2) ---
4174
- // The web chat's own "always allow this tool" list
4175
- // (~/.mlx-bun/tool-approvals.json, src/tool-approvals.ts). The
4176
- // approval card itself grants an always-allow via the `approval` WS
4177
- // frame (alwaysAllow:true) — these REST routes are read/forget only,
4178
- // for the settings panel's list view (matches the hf-token GET/POST
4179
- // pattern's separation of "the card that grants" vs "settings that
4180
- // manage" — there is no POST here on purpose, granting always goes
4181
- // through the approval card, never a bare settings form).
4182
- if (url.pathname === "/api/settings/tool-approvals" && request.method === "GET") {
4183
- const { listAlwaysAllowedTools } = await import("./tool-approvals");
4184
- return Response.json({ ok: true, alwaysAllow: listAlwaysAllowedTools() });
4185
- }
4186
- if (url.pathname === "/api/settings/tool-approvals" && request.method === "DELETE") {
4187
- const body = (await request.json().catch(() => ({}))) as { tool?: string };
4188
- if (!body.tool) return Response.json({ ok: false, error: "tool required" }, { status: 400 });
4189
- const { revokeToolAlwaysAllowed } = await import("./tool-approvals");
4190
- const file = revokeToolAlwaysAllowed(body.tool);
4191
- return Response.json({ ok: true, alwaysAllow: Object.keys(file.allows).sort() });
4192
- }
4193
- {
4194
- const m = url.pathname.match(/^\/api\/(quantize|finetune|dataset)\/push$/);
4195
- if (m && request.method === "POST") {
4196
- const kind = m[1]!;
4197
- const body = (await request.json().catch(() => ({}))) as {
4198
- job_id?: string; repo_id?: string; private?: boolean; source_path?: string;
4199
- };
4200
- if (!body.repo_id) return Response.json({ ok: false, error: "repo_id required" }, { status: 400 });
4201
- const { getHfToken, uploadFolder } = await import("./hf-push");
4202
- const token = getHfToken();
4203
- if (!token)
4204
- return Response.json({ ok: false, error: "no HF token saved — add one in Settings → Hugging Face" }, { status: 400 });
4205
- const store = await ensureJobs();
4206
- let dir = body.source_path;
4207
- if (!dir && body.job_id) dir = store.get(body.job_id)?.output_path ?? undefined;
4208
- if (!dir) return Response.json({ ok: false, error: "no source dir (pass job_id or source_path)" }, { status: 400 });
4209
- try {
4210
- const r = await uploadFolder(dir, body.repo_id, {
4211
- repoType: kind === "dataset" ? "dataset" : "model",
4212
- private: !!body.private, token,
4213
- });
4214
- return Response.json({ ok: true, url: r.url });
4215
- } catch (e) {
4216
- return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
4217
- }
4218
- }
4219
- }
4220
-
4221
- // gc plan/execute (web-ui-pass-plan.md #9): thin wrappers over the CLI's
4222
- // own `mlx-bun gc` planner/executor (src/registry.ts planGc/executeGc).
4223
- // Loopback-served admin route — no auth beyond the server's own bind
4224
- // (matches every other /api/* route here). GET plans (read-only, cheap:
4225
- // config.json + safetensors index reads, no tensor bytes); POST executes
4226
- // and requires an explicit {yes:true} body (mirrors the CLI's --yes gate)
4227
- // so a stray call can't delete anything by accident.
4228
- if (url.pathname === "/api/gc/plan" && request.method === "GET") {
4229
- const { planGc } = await import("./registry");
4230
- const plans = planGc().filter(
4231
- (p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
4232
- );
4233
- const superseded = plans.map((p) => ({
4234
- repo_id: p.repoId,
4235
- prune_snapshots: p.pruneSnapshots.length,
4236
- skipped_snapshots: p.skippedSnapshots.length,
4237
- dead_blobs: p.deadBlobs.length,
4238
- reclaim_bytes: p.reclaimBytes,
4239
- }));
4240
- const reclaim_bytes = plans.reduce((a, p) => a + p.reclaimBytes, 0);
4241
- return Response.json({ ok: true, superseded, reclaim_bytes });
4242
- }
4243
- if (url.pathname === "/api/gc/execute" && request.method === "POST") {
4244
- const body = (await request.json().catch(() => ({}))) as { yes?: boolean };
4245
- if (body.yes !== true)
4246
- return Response.json({ ok: false, error: "pass {\"yes\": true} to confirm deletion" }, { status: 400 });
4247
- const { planGc, executeGc, Registry } = await import("./registry");
4248
- const plans = planGc().filter(
4249
- (p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
4250
- );
4251
- const res = executeGc(plans);
4252
- const reg = new Registry();
4253
- try {
4254
- await reg.scan(); // reap deleted snapshots from the registry
4255
- } finally {
4256
- reg.close();
4257
- }
4258
- libraryCache = null; // force /library to reflect the deletion
4259
- return Response.json({
4260
- ok: true,
4261
- snapshots: res.snapshots, blobs: res.blobs, reclaimed_bytes: res.reclaimedBytes,
4262
- });
4263
- }
3665
+ const labResponse = await handleLabRoute(url, request, {
3666
+ ensureJobs,
3667
+ serverPort: () => server.port,
3668
+ invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
3669
+ });
3670
+ if (labResponse) return labResponse;
4264
3671
 
4265
- if (url.pathname === "/api/jobs" && request.method === "GET") {
4266
- const store = await ensureJobs();
4267
- const limit = Number(url.searchParams.get("limit") ?? "50");
4268
- const kind = url.searchParams.get("kind") ?? undefined;
4269
- return Response.json({ ok: true, jobs: store.recent(limit, kind) });
4270
- }
4271
- {
4272
- const m = url.pathname.match(/^\/api\/jobs\/([^/]+?)(\/stream)?$/);
4273
- if (m && request.method === "GET") {
4274
- const store = await ensureJobs();
4275
- if (m[2]) {
4276
- const { streamJobResponse } = await import("./jobs");
4277
- return streamJobResponse(store, m[1]!);
4278
- }
4279
- const job = store.get(m[1]!);
4280
- if (!job) return Response.json({ ok: false, error: "job not found" }, { status: 404 });
4281
- return Response.json({ ok: true, job });
4282
- }
4283
- }
3672
+ const adminResponse = await handleAdminRoute(url, request, {
3673
+ ensureJobs,
3674
+ invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
3675
+ });
3676
+ if (adminResponse) return adminResponse;
4284
3677
 
4285
3678
  return Response.json({ error: { message: "not found" } }, { status: 404 });
4286
3679
  },
4287
3680
  });
3681
+ serverLifecycles.set(serverRef, {
3682
+ flush: flushDurability,
3683
+ stats: durabilityStats,
3684
+ stopTimers: () => {
3685
+ if (demoteTimer) clearInterval(demoteTimer);
3686
+ demoteTimer = null;
3687
+ },
3688
+ });
4288
3689
  return serverRef;
4289
3690
  }